diff --git a/src/controllers/investment.controller.ts b/src/controllers/investment.controller.ts index 2379bf1..b466659 100644 --- a/src/controllers/investment.controller.ts +++ b/src/controllers/investment.controller.ts @@ -47,4 +47,28 @@ export class InvestmentController { }); } }; + + getDashboard = async (req: AuthenticatedRequest, res: Response) => { + try { + const user = req.user; + if (!user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const dashboard = await this.investmentService.getInvestorDashboard(user.id); + + return res.status(200).json({ + success: true, + data: dashboard, + }); + } catch (err: unknown) { + const statusCode = (err as { status?: number }).status || (err as { statusCode?: number }).statusCode || 500; + return res.status(statusCode).json({ + error: { + code: (err as { code?: string }).code || "INTERNAL_ERROR", + message: (err as { message?: string }).message || "Internal server error", + }, + }); + } + }; } diff --git a/src/lib/invoice-validation.ts b/src/lib/invoice-validation.ts new file mode 100644 index 0000000..2c0bb2a --- /dev/null +++ b/src/lib/invoice-validation.ts @@ -0,0 +1,27 @@ +import { ServiceError } from "../utils/service-error"; + +const MIN_LEAD_TIME_MS = 24 * 60 * 60 * 1000; + +export interface PublishableInvoice { + dueDate: Date; +} + +/** + * Validates that an invoice is eligible to be published. + * + * The due date must be at least 24 hours in the future at the moment of + * publishing, so investors always have a full day of runway before the + * invoice is due. + */ +export function validateInvoiceForPublish(invoice: PublishableInvoice, now: Date = new Date()): void { + const leadTimeMs = invoice.dueDate.getTime() - now.getTime(); + + if (leadTimeMs < MIN_LEAD_TIME_MS) { + throw new ServiceError( + "invalid_due_date", + "dueDate must be at least 24 hours in the future", + 400, + { field: "dueDate" }, + ); + } +} diff --git a/src/lib/kyc.ts b/src/lib/kyc.ts index 037b37b..9057bf6 100644 --- a/src/lib/kyc.ts +++ b/src/lib/kyc.ts @@ -16,4 +16,10 @@ export function requireApprovedKYC(user: { kycStatus: KYCStatus }) { if (user.kycStatus !== KYCStatus.APPROVED) { throw new KYCError("KYC not approved"); } +} + +/** Truncates a wallet address to its first 4 and last 4 characters for safe logging. */ +export function truncateWalletAddress(address: string): string { + if (address.length <= 8) return address; + return `${address.slice(0, 4)}...${address.slice(-4)}`; } \ No newline at end of file diff --git a/src/lib/stellar-format.ts b/src/lib/stellar-format.ts new file mode 100644 index 0000000..6cbe7fc --- /dev/null +++ b/src/lib/stellar-format.ts @@ -0,0 +1,28 @@ +const STROOP_DECIMALS = 7; + +/** + * Converts an amount in stroops (1 XLM = 10,000,000 stroops) to a decimal + * XLM string, using integer arithmetic throughout to avoid floating point + * error on large balances. + * + * @param stroops Amount in stroops + * @param decimals Number of decimal places to display (default: 7, full stroop precision) + */ +export function stroopsToXlm(stroops: bigint, decimals: number = STROOP_DECIMALS): string { + const negative = stroops < 0n; + const abs = negative ? -stroops : stroops; + + const divisor = 10n ** BigInt(STROOP_DECIMALS); + const whole = abs / divisor; + const remainderStroops = abs % divisor; + + // Pad the stroop remainder out to full precision, then round/truncate to + // the requested number of decimal places. + const fullFraction = remainderStroops.toString().padStart(STROOP_DECIMALS, "0"); + const fraction = decimals <= STROOP_DECIMALS + ? fullFraction.slice(0, decimals) + : fullFraction.padEnd(decimals, "0"); + + const sign = negative ? "-" : ""; + return decimals > 0 ? `${sign}${whole}.${fraction}` : `${sign}${whole}`; +} diff --git a/src/routes/admin/approve-kyc.ts b/src/routes/admin/approve-kyc.ts index acd637e..c179d63 100644 --- a/src/routes/admin/approve-kyc.ts +++ b/src/routes/admin/approve-kyc.ts @@ -1,10 +1,13 @@ import { Request, Response } from "express"; import { DataSource } from "typeorm"; -import { User } from "@/entities/User"; +import { User } from "@/models/User.model"; import { KYCStatus } from "@/types/enums"; +import { truncateWalletAddress } from "@/lib/kyc"; +import { logger } from "@/observability/logger"; interface ApproveKYCBody { userId: string; + reviewerId: string; } export async function approveKYC(req: Request, res: Response, dataSource: DataSource) { @@ -14,11 +17,26 @@ export async function approveKYC(req: Request, return res.status(401).json({ error: "Unauthorized" }); } - const { userId } = req.body; + const { userId, reviewerId } = req.body; const userRepo = dataSource.getRepository(User); + const user = await userRepo.findOneBy({ id: userId }); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + await userRepo.update(userId, { kycStatus: KYCStatus.APPROVED }); + // Logged only after the DB update succeeds, so the audit trail never + // records a decision that didn't actually persist. + const decidedAt = new Date().toISOString(); + logger.info("KYC approval decision", { + wallet_address: truncateWalletAddress(user.stellarAddress), + decision: "approved", + reviewer_id: reviewerId, + decided_at: decidedAt, + }); + return res.json({ success: true }); } catch (err: unknown) { const appErr = err as { status?: number; code?: string; message?: string }; @@ -29,4 +47,4 @@ export async function approveKYC(req: Request, }, }); } -} \ No newline at end of file +} diff --git a/src/routes/admin/reject-kyc.ts b/src/routes/admin/reject-kyc.ts new file mode 100644 index 0000000..60904a6 --- /dev/null +++ b/src/routes/admin/reject-kyc.ts @@ -0,0 +1,52 @@ +import { Request, Response } from "express"; +import { DataSource } from "typeorm"; +import { User } from "@/models/User.model"; +import { KYCStatus } from "@/types/enums"; +import { truncateWalletAddress } from "@/lib/kyc"; +import { logger } from "@/observability/logger"; + +interface RejectKYCBody { + userId: string; + reviewerId: string; + rejectionReason: string; +} + +export async function rejectKYC(req: Request, res: Response, dataSource: DataSource) { + try { + const adminKey = req.headers["x-admin-key"]; + if (adminKey !== process.env.ADMIN_API_KEY) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const { userId, reviewerId, rejectionReason } = req.body; + + const userRepo = dataSource.getRepository(User); + const user = await userRepo.findOneBy({ id: userId }); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + await userRepo.update(userId, { kycStatus: KYCStatus.REJECTED }); + + // Logged only after the DB update succeeds, so the audit trail never + // records a decision that didn't actually persist. + const decidedAt = new Date().toISOString(); + logger.info("KYC rejection decision", { + wallet_address: truncateWalletAddress(user.stellarAddress), + decision: "rejected", + reviewer_id: reviewerId, + decided_at: decidedAt, + rejection_reason: rejectionReason, + }); + + return res.json({ success: true }); + } catch (err: unknown) { + const appErr = err as { status?: number; code?: string; message?: string }; + return res.status(appErr.status ?? 500).json({ + error: { + code: appErr.code ?? "INTERNAL_ERROR", + message: appErr.message ?? "Internal server error", + }, + }); + } +} diff --git a/src/routes/investment.routes.ts b/src/routes/investment.routes.ts index 17f51e1..54035c8 100644 --- a/src/routes/investment.routes.ts +++ b/src/routes/investment.routes.ts @@ -20,5 +20,8 @@ export function createInvestmentRouter({ // POST /api/v1/investments - Create a new investment commitment router.post("/", authMiddleware, controller.createInvestment); + // GET /api/v1/investments/dashboard - Investor portfolio aggregate + router.get("/dashboard", authMiddleware, controller.getDashboard); + return router; } diff --git a/src/services/investment.service.ts b/src/services/investment.service.ts index d98b9d2..e725fb7 100644 --- a/src/services/investment.service.ts +++ b/src/services/investment.service.ts @@ -16,9 +16,54 @@ export interface CreateInvestmentInput { investmentAmount: string; } +export interface InvestorDashboard { + totalInvested: string; + totalReturns: string; + activeInvestments: number; +} + +const ACTIVE_INVESTMENT_STATUSES = [InvestmentStatus.PENDING, InvestmentStatus.CONFIRMED]; + export class InvestmentService { constructor(private readonly dataSource: DataSource) {} + /** + * Aggregates an investor's portfolio across all their investments. + * + * - totalInvested sums investmentAmount across every status (a commitment + * counts once made, regardless of how it later resolves). + * - totalReturns sums actualReturn for SETTLED investments only — pending + * or confirmed investments have no realised return yet. + * - activeInvestments counts investments still in flight (PENDING/CONFIRMED). + */ + async getInvestorDashboard(investorId: string): Promise { + const investments = await this.dataSource.getRepository(Investment).find({ + where: { investorId }, + }); + + let totalInvested = new Decimal(0); + let totalReturns = new Decimal(0); + let activeInvestments = 0; + + for (const investment of investments) { + totalInvested = totalInvested.plus(new Decimal(investment.investmentAmount)); + + if (investment.status === InvestmentStatus.SETTLED && investment.actualReturn !== null) { + totalReturns = totalReturns.plus(new Decimal(investment.actualReturn)); + } + + if (ACTIVE_INVESTMENT_STATUSES.includes(investment.status)) { + activeInvestments += 1; + } + } + + return { + totalInvested: totalInvested.toFixed(4), + totalReturns: totalReturns.toFixed(4), + activeInvestments, + }; + } + /** * Creates a new investment commitment for an invoice. * Uses a database transaction with a row-level lock on the invoice to prevent over-subscription. diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index c94ff5c..a3a2778 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -2,6 +2,7 @@ import { DataSource } from "typeorm"; import { Invoice } from "../models/Invoice.model"; import { InvoiceStatus } from "../types/enums"; import { ServiceError } from "../utils/service-error"; +import { validateInvoiceForPublish } from "../lib/invoice-validation"; import type { IPFSService, IPFSUploadResult } from "./ipfs.service"; export interface InvoiceRepositoryContract { @@ -352,6 +353,8 @@ export class InvoiceService { ); } + validateInvoiceForPublish(invoice); + invoice.status = InvoiceStatus.PUBLISHED; const updated = await this.invoiceRepository.save(invoice); return this.toDTO(updated); diff --git a/tests/integration/investor-dashboard.test.ts b/tests/integration/investor-dashboard.test.ts new file mode 100644 index 0000000..d54d5ea --- /dev/null +++ b/tests/integration/investor-dashboard.test.ts @@ -0,0 +1,101 @@ +import { DataSource, Repository } from "typeorm"; +import { InvestmentService } from "../../src/services/investment.service"; +import { Investment } from "../../src/models/Investment.model"; +import { InvestmentStatus } from "../../src/types/enums"; + +describe("Investor dashboard aggregate", () => { + let mockRepository: jest.Mocked>; + let mockDataSource: jest.Mocked; + let investmentService: InvestmentService; + + const walletAId = "wallet-a"; + + function seedInvestment(overrides: Partial): Investment { + return { + id: "investment-id", + invoiceId: "invoice-id", + investorId: walletAId, + investmentAmount: "0.0000", + expectedReturn: "0.0000", + actualReturn: null, + status: InvestmentStatus.PENDING, + transactionHash: null, + stellarOperationIndex: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + } as Investment; + } + + beforeEach(() => { + mockRepository = { + find: jest.fn(), + } as unknown as jest.Mocked>; + + mockDataSource = { + getRepository: jest.fn().mockReturnValue(mockRepository), + } as unknown as jest.Mocked; + + investmentService = new InvestmentService(mockDataSource); + }); + + it("aggregates total invested, total returns, and active count across investment states", async () => { + // Wallet A: two active investments (2000, 3000) and one settled + // investment (1000, realised return 1100). + mockRepository.find.mockResolvedValue([ + seedInvestment({ id: "inv-1", investmentAmount: "2000.0000", status: InvestmentStatus.PENDING }), + seedInvestment({ id: "inv-2", investmentAmount: "3000.0000", status: InvestmentStatus.CONFIRMED }), + seedInvestment({ + id: "inv-3", + investmentAmount: "1000.0000", + status: InvestmentStatus.SETTLED, + actualReturn: "1100.0000", + }), + ]); + + const dashboard = await investmentService.getInvestorDashboard(walletAId); + + expect(mockDataSource.getRepository).toHaveBeenCalledWith(Investment); + expect(mockRepository.find).toHaveBeenCalledWith({ where: { investorId: walletAId } }); + + expect(dashboard.totalInvested).toBe("6000.0000"); + expect(dashboard.totalReturns).toBe("1100.0000"); + expect(dashboard.activeInvestments).toBe(2); + }); + + it("does not count pending returns towards totalReturns", async () => { + mockRepository.find.mockResolvedValue([ + seedInvestment({ id: "inv-1", investmentAmount: "500.0000", status: InvestmentStatus.PENDING, expectedReturn: "550.0000" }), + ]); + + const dashboard = await investmentService.getInvestorDashboard(walletAId); + + expect(dashboard.totalInvested).toBe("500.0000"); + expect(dashboard.totalReturns).toBe("0.0000"); + expect(dashboard.activeInvestments).toBe(1); + }); + + it("excludes cancelled investments from the active count", async () => { + mockRepository.find.mockResolvedValue([ + seedInvestment({ id: "inv-1", investmentAmount: "800.0000", status: InvestmentStatus.CANCELLED }), + ]); + + const dashboard = await investmentService.getInvestorDashboard(walletAId); + + expect(dashboard.totalInvested).toBe("800.0000"); + expect(dashboard.activeInvestments).toBe(0); + }); + + it("returns zero for all fields when the wallet has no investments", async () => { + mockRepository.find.mockResolvedValue([]); + + const dashboard = await investmentService.getInvestorDashboard("wallet-with-nothing"); + + expect(dashboard).toEqual({ + totalInvested: "0.0000", + totalReturns: "0.0000", + activeInvestments: 0, + }); + }); +}); diff --git a/tests/invoice.service.test.ts b/tests/invoice.service.test.ts index 6a25cd1..4b4bc67 100644 --- a/tests/invoice.service.test.ts +++ b/tests/invoice.service.test.ts @@ -353,8 +353,12 @@ describe("InvoiceService", () => { // ============ PUBLISH INVOICE TESTS ============ describe("publishInvoice", () => { it("should transition draft invoice to published", async () => { - mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); - const publishedInvoice = { ...mockInvoice, status: InvoiceStatus.PUBLISHED }; + const publishableInvoice = { + ...mockInvoice, + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }; + mockInvoiceRepository.findOne.mockResolvedValue(publishableInvoice); + const publishedInvoice = { ...publishableInvoice, status: InvoiceStatus.PUBLISHED }; mockInvoiceRepository.save.mockResolvedValue(publishedInvoice); const result = await invoiceService.publishInvoice({ @@ -365,6 +369,24 @@ describe("InvoiceService", () => { expect(result.status).toBe(InvoiceStatus.PUBLISHED); }); + it("should reject a due date within 24 hours", async () => { + const soonDueInvoice = { + ...mockInvoice, + dueDate: new Date(Date.now() + 60 * 60 * 1000), + }; + mockInvoiceRepository.findOne.mockResolvedValue(soonDueInvoice); + + await expect( + invoiceService.publishInvoice({ + invoiceId: "invoice-123", + sellerId: "seller-456", + }), + ).rejects.toMatchObject({ + code: "invalid_due_date", + statusCode: 400, + }); + }); + it("should reject invalid status transitions", async () => { const settledInvoice = { ...mockInvoice, status: InvoiceStatus.SETTLED }; mockInvoiceRepository.findOne.mockResolvedValue(settledInvoice); @@ -395,7 +417,11 @@ describe("InvoiceService", () => { }); it("should allow transition from pending to published", async () => { - const pendingInvoice = { ...mockInvoice, status: InvoiceStatus.PENDING }; + const pendingInvoice = { + ...mockInvoice, + status: InvoiceStatus.PENDING, + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }; mockInvoiceRepository.findOne.mockResolvedValue(pendingInvoice); const publishedInvoice = { ...pendingInvoice, status: InvoiceStatus.PUBLISHED }; mockInvoiceRepository.save.mockResolvedValue(publishedInvoice); diff --git a/tests/kyc.test.ts b/tests/kyc.test.ts index 0de065b..b872b03 100644 --- a/tests/kyc.test.ts +++ b/tests/kyc.test.ts @@ -1,4 +1,4 @@ -import { requireApprovedKYC } from "@/lib/kyc"; +import { requireApprovedKYC, truncateWalletAddress } from "@/lib/kyc"; import { KYCStatus } from "@/types/enums"; describe("KYC check", () => { @@ -13,4 +13,14 @@ describe("KYC check", () => { requireApprovedKYC({ kycStatus: KYCStatus.APPROVED }) ).not.toThrow(); }); +}); + +describe("truncateWalletAddress", () => { + it("keeps the first 4 and last 4 characters", () => { + expect(truncateWalletAddress("GABCDEFGHIJKLMNOPQRSTUVWXYZ")).toBe("GABC...WXYZ"); + }); + + it("returns short addresses unchanged", () => { + expect(truncateWalletAddress("GABC")).toBe("GABC"); + }); }); \ No newline at end of file diff --git a/tests/unit/invoice-validation.test.ts b/tests/unit/invoice-validation.test.ts new file mode 100644 index 0000000..5be1390 --- /dev/null +++ b/tests/unit/invoice-validation.test.ts @@ -0,0 +1,36 @@ +import { validateInvoiceForPublish } from "@/lib/invoice-validation"; + +describe("validateInvoiceForPublish", () => { + const now = new Date("2025-01-01T00:00:00.000Z"); + + it("passes when due date is exactly 24 hours in the future", () => { + const dueDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); + expect(() => validateInvoiceForPublish({ dueDate }, now)).not.toThrow(); + }); + + it("fails when due date is 23 hours 59 minutes in the future", () => { + const dueDate = new Date(now.getTime() + (23 * 60 + 59) * 60 * 1000); + expect(() => validateInvoiceForPublish({ dueDate }, now)).toThrow(); + }); + + it("fails when due date is in the past", () => { + const dueDate = new Date(now.getTime() - 60 * 60 * 1000); + expect(() => validateInvoiceForPublish({ dueDate }, now)).toThrow(); + }); + + it("fails when due date equals the current timestamp", () => { + expect(() => validateInvoiceForPublish({ dueDate: new Date(now) }, now)).toThrow(); + }); + + it("reports the dueDate field on failure", () => { + const dueDate = new Date(now.getTime() - 1000); + try { + validateInvoiceForPublish({ dueDate }, now); + throw new Error("expected validateInvoiceForPublish to throw"); + } catch (err: unknown) { + const serviceErr = err as { code: string; details?: { field?: string } }; + expect(serviceErr.code).toBe("invalid_due_date"); + expect(serviceErr.details?.field).toBe("dueDate"); + } + }); +}); diff --git a/tests/unit/kyc-admin-routes.test.ts b/tests/unit/kyc-admin-routes.test.ts new file mode 100644 index 0000000..ba2d0ef --- /dev/null +++ b/tests/unit/kyc-admin-routes.test.ts @@ -0,0 +1,114 @@ +import { approveKYC } from "@/routes/admin/approve-kyc"; +import { rejectKYC } from "@/routes/admin/reject-kyc"; +import { KYCStatus } from "@/types/enums"; +import { logger } from "@/observability/logger"; + +describe("KYC admin routes — structured logging", () => { + const ADMIN_KEY = "test-admin-key"; + let mockUserRepo: any; + let mockDataSource: any; + let req: any; + let res: any; + let logSpy: jest.SpyInstance; + + beforeEach(() => { + process.env.ADMIN_API_KEY = ADMIN_KEY; + + mockUserRepo = { + findOneBy: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + }; + mockDataSource = { + getRepository: jest.fn().mockReturnValue(mockUserRepo), + }; + + req = { + headers: { "x-admin-key": ADMIN_KEY }, + body: {}, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + logSpy = jest.spyOn(logger, "info").mockImplementation(() => undefined); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + describe("approveKYC", () => { + it("emits an approval log with wallet, decision, reviewer, and timestamp after the DB update", async () => { + const callOrder: string[] = []; + mockUserRepo.findOneBy.mockResolvedValue({ id: "user-1", stellarAddress: "GABCDEFGHIJKLMNOP" }); + mockUserRepo.update.mockImplementation(async () => { + callOrder.push("db_update"); + }); + logSpy.mockImplementation(() => { + callOrder.push("log"); + }); + + req.body = { userId: "user-1", reviewerId: "reviewer-1" }; + + await approveKYC(req, res, mockDataSource); + + expect(mockUserRepo.update).toHaveBeenCalledWith("user-1", { kycStatus: KYCStatus.APPROVED }); + expect(logSpy).toHaveBeenCalledTimes(1); + + const [message, metadata] = logSpy.mock.calls[0]; + expect(message).toBe("KYC approval decision"); + expect(metadata).toMatchObject({ + wallet_address: "GABC...MNOP", + decision: "approved", + reviewer_id: "reviewer-1", + }); + expect(typeof metadata.decided_at).toBe("string"); + expect(Object.keys(metadata).sort()).toEqual( + ["decided_at", "decision", "reviewer_id", "wallet_address"].sort(), + ); + + expect(callOrder).toEqual(["db_update", "log"]); + }); + + it("does not log when the admin key is invalid", async () => { + req.headers["x-admin-key"] = "wrong-key"; + req.body = { userId: "user-1", reviewerId: "reviewer-1" }; + + await approveKYC(req, res, mockDataSource); + + expect(res.status).toHaveBeenCalledWith(401); + expect(logSpy).not.toHaveBeenCalled(); + }); + }); + + describe("rejectKYC", () => { + it("emits a rejection log including the rejection reason after the DB update", async () => { + mockUserRepo.findOneBy.mockResolvedValue({ id: "user-2", stellarAddress: "GZYXWVUTSRQPONML" }); + + req.body = { + userId: "user-2", + reviewerId: "reviewer-2", + rejectionReason: "Document expired", + }; + + await rejectKYC(req, res, mockDataSource); + + expect(mockUserRepo.update).toHaveBeenCalledWith("user-2", { kycStatus: KYCStatus.REJECTED }); + expect(logSpy).toHaveBeenCalledTimes(1); + + const [message, metadata] = logSpy.mock.calls[0]; + expect(message).toBe("KYC rejection decision"); + expect(metadata).toMatchObject({ + wallet_address: "GZYX...ONML", + decision: "rejected", + reviewer_id: "reviewer-2", + rejection_reason: "Document expired", + }); + expect(typeof metadata.decided_at).toBe("string"); + expect(Object.keys(metadata).sort()).toEqual( + ["decided_at", "decision", "reviewer_id", "rejection_reason", "wallet_address"].sort(), + ); + }); + }); +}); diff --git a/tests/unit/stellar-format.test.ts b/tests/unit/stellar-format.test.ts new file mode 100644 index 0000000..907c456 --- /dev/null +++ b/tests/unit/stellar-format.test.ts @@ -0,0 +1,37 @@ +import { stroopsToXlm } from "@/lib/stellar-format"; + +describe("stroopsToXlm", () => { + it("converts 10000000 stroops to 1.0000000", () => { + expect(stroopsToXlm(10_000_000n)).toBe("1.0000000"); + }); + + it("converts 1 stroop to 0.0000001", () => { + expect(stroopsToXlm(1n)).toBe("0.0000001"); + }); + + it("converts 0 stroops to 0.0000000", () => { + expect(stroopsToXlm(0n)).toBe("0.0000000"); + }); + + it("converts a large value above MAX_SAFE_INTEGER without floating point error", () => { + // Number.MAX_SAFE_INTEGER (9007199254740991) whole XLM, in stroops. + const stroops = 9_007_199_254_740_991n * 10_000_000n; + expect(stroopsToXlm(stroops)).toBe("9007199254740991.0000000"); + }); + + it("supports a decimal override", () => { + expect(stroopsToXlm(12_345_678n, 2)).toBe("1.23"); + }); + + it("truncates rather than rounds when overriding to fewer decimals", () => { + expect(stroopsToXlm(19_999_999n, 0)).toBe("1"); + }); + + it("pads with zeros when overriding to more decimals than stroop precision", () => { + expect(stroopsToXlm(10_000_000n, 9)).toBe("1.000000000"); + }); + + it("handles negative amounts", () => { + expect(stroopsToXlm(-10_000_000n)).toBe("-1.0000000"); + }); +});