diff --git a/src/app.ts b/src/app.ts index 66e3364..5fa78b0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -13,11 +13,13 @@ import { createAuthRouter } from "./routes/auth.routes"; import { createNotificationRouter } from "./routes/notification.routes"; import { createInvoiceRouter } from "./routes/invoice.routes"; import { createInvestmentRouter } from "./routes/investment.routes"; +import { createSettlementRouter } from "./routes/settlement.routes"; import type { AuthService } from "./services/auth.service"; import type { NotificationService } from "./services/notification.service"; import type { InvoiceService } from "./services/invoice.service"; import type { InvestmentService } from "./services/investment.service"; +import type { SettlementService } from "./services/settlement.service"; import dataSource from "./config/database"; @@ -52,6 +54,7 @@ export interface AppDependencies { notificationService?: NotificationService; invoiceService?: InvoiceService; investmentService?: InvestmentService; + settlementService?: SettlementService; logger?: AppLogger; metricsEnabled?: boolean; metricsRegistry?: MetricsRegistry; @@ -75,6 +78,7 @@ export function createApp({ notificationService, invoiceService, investmentService, + settlementService, logger: appLogger = logger, metricsEnabled = true, metricsRegistry = new MetricsRegistry(), @@ -173,6 +177,10 @@ export function createApp({ app.use("/api/v1/investments", createInvestmentRouter({ investmentService, authService })); } + if (settlementService) { + app.use("/api/v1/settlements", createSettlementRouter({ settlementService })); + } + app.use(notFoundMiddleware); app.use(createErrorMiddleware(appLogger)); diff --git a/src/controllers/settlement.controller.ts b/src/controllers/settlement.controller.ts new file mode 100644 index 0000000..36a22a0 --- /dev/null +++ b/src/controllers/settlement.controller.ts @@ -0,0 +1,43 @@ +import { Request, Response } from "express"; +import { SettlementService } from "../services/settlement.service"; + +export class SettlementController { + constructor(private readonly settlementService: SettlementService) {} + + settleInvoice = async (req: Request, res: Response) => { + try { + const invoiceId = req.params.invoiceId as string; + const { proceeds } = req.body; + + if (!proceeds) { + return res.status(400).json({ + error: { + code: "MISSING_FIELDS", + message: "proceeds is required", + }, + }); + } + + const result = await this.settlementService.settleInvoice({ + invoiceId, + proceeds, + }); + + return res.status(200).json({ + success: true, + data: result, + }); + } catch (err: unknown) { + const statusCode = + (err as { statusCode?: number }).statusCode || + (err as { status?: number }).status || + 400; + 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/index.ts b/src/index.ts index 43d8919..570b666 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { createNotificationService } from "./services/notification.service"; import { createInvoiceService } from "./services/invoice.service"; import { createIPFSService } from "./services/ipfs.service"; import { createInvestmentService } from "./services/investment.service"; +import { createSettlementService } from "./services/settlement.service"; export async function bootstrap(): Promise<{ server: Server }> { const config = getConfig(); @@ -24,12 +25,14 @@ export async function bootstrap(): Promise<{ server: Server }> { const ipfsService = createIPFSService(config.ipfs, logger); const invoiceService = createInvoiceService(dataSource, ipfsService); const investmentService = createInvestmentService(dataSource); + const settlementService = createSettlementService(dataSource); const app = createApp({ authService, notificationService, invoiceService, investmentService, + settlementService, config, logger, metricsEnabled: config.observability.metricsEnabled, diff --git a/src/lib/validate-invoice-for-publish.ts b/src/lib/validate-invoice-for-publish.ts new file mode 100644 index 0000000..f7331be --- /dev/null +++ b/src/lib/validate-invoice-for-publish.ts @@ -0,0 +1,45 @@ +import type { Invoice } from "@/models/Invoice.model"; + +export interface ValidationError { + field: string; + code: string; + message: string; +} + +const MIN_LEAD_TIME_MS = 24 * 60 * 60 * 1000; + +/** + * Validates that an invoice meets the minimum field requirements + * required for the draft -> published lifecycle transition. + */ +export function validateInvoiceForPublish(invoice: Invoice): ValidationError[] { + const errors: ValidationError[] = []; + + const faceValue = parseFloat(invoice.amount); + if (!(faceValue > 0)) { + errors.push({ + field: "amount", + code: "FACE_VALUE_NOT_POSITIVE", + message: "Invoice face value must be greater than zero.", + }); + } + + const dueDate = new Date(invoice.dueDate); + if (Number.isNaN(dueDate.getTime()) || dueDate.getTime() - Date.now() < MIN_LEAD_TIME_MS) { + errors.push({ + field: "dueDate", + code: "DUE_DATE_TOO_SOON", + message: "Invoice due date must be at least 24 hours in the future.", + }); + } + + if (!invoice.ipfsHash) { + errors.push({ + field: "ipfsHash", + code: "MISSING_DOCUMENT", + message: "Invoice must have at least one document attached before it can be published.", + }); + } + + return errors; +} diff --git a/src/observability/logger.ts b/src/observability/logger.ts index b0b68c6..b35f9ba 100644 --- a/src/observability/logger.ts +++ b/src/observability/logger.ts @@ -3,6 +3,7 @@ import winston from "winston"; export type LogMetadata = Record; export interface AppLogger { + debug(message: string, metadata?: LogMetadata): void; info(message: string, metadata?: LogMetadata): void; warn(message: string, metadata?: LogMetadata): void; error(message: string, metadata?: LogMetadata): void; @@ -12,6 +13,10 @@ export interface AppLogger { class WinstonAppLogger implements AppLogger { constructor(private readonly baseLogger: winston.Logger) {} + debug(message: string, metadata: LogMetadata = {}): void { + this.baseLogger.debug(message, metadata); + } + info(message: string, metadata: LogMetadata = {}): void { this.baseLogger.info(message, metadata); } diff --git a/src/routes/settlement.routes.ts b/src/routes/settlement.routes.ts new file mode 100644 index 0000000..32c1700 --- /dev/null +++ b/src/routes/settlement.routes.ts @@ -0,0 +1,21 @@ +import { Router } from "express"; +import { SettlementController } from "../controllers/settlement.controller"; +import type { SettlementService } from "../services/settlement.service"; +import { authenticateJWT } from "../middleware/auth.middleware"; + +export interface SettlementRouterDependencies { + settlementService: SettlementService; +} + +export function createSettlementRouter({ + settlementService, +}: SettlementRouterDependencies): Router { + const router = Router(); + const controller = new SettlementController(settlementService); + + // POST /api/v1/settlements/:invoiceId - Settle a funded invoice and + // distribute pro-rata returns to its confirmed investors + router.post("/:invoiceId", authenticateJWT, controller.settleInvoice); + + return router; +} diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index 5f88afb..7c6180b 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -3,7 +3,7 @@ import { Invoice } from "../models/Invoice.model"; import { User } from "../models/User.model"; import { InvoiceStatus, KYCStatus } from "../types/enums"; import { ServiceError } from "../utils/service-error"; -import { validateInvoiceForPublish } from "../lib/invoice-validation"; +import { validateInvoiceForPublish } from "../lib/validate-invoice-for-publish"; import type { IPFSService, IPFSUploadResult } from "./ipfs.service"; export interface InvoiceRepositoryContract { @@ -347,7 +347,14 @@ export class InvoiceService { ); } - validateInvoiceForPublish(invoice); + const validationErrors = validateInvoiceForPublish(invoice); + if (validationErrors.length > 0) { + throw new ServiceError( + "invoice_not_publishable", + `Invoice failed pre-publish validation: ${validationErrors.map((e) => e.message).join(" ")}`, + 400, + ); + } invoice.status = InvoiceStatus.PUBLISHED; const updated = await this.invoiceRepository.save(invoice); diff --git a/src/services/settlement.service.ts b/src/services/settlement.service.ts new file mode 100644 index 0000000..5b3160c --- /dev/null +++ b/src/services/settlement.service.ts @@ -0,0 +1,116 @@ +import { DataSource, EntityManager } from "typeorm"; +import { Decimal } from "decimal.js"; +import { Invoice } from "../models/Invoice.model"; +import { Investment } from "../models/Investment.model"; +import { InvoiceStatus, InvestmentStatus } from "../types/enums"; +import { ServiceError } from "../utils/service-error"; + +export interface SettleInvoiceInput { + invoiceId: string; + proceeds: string; +} + +export interface InvestorSettlement { + investmentId: string; + investorId: string; + investmentAmount: string; + actualReturn: string; +} + +export interface SettleInvoiceResult { + invoiceId: string; + status: InvoiceStatus.SETTLED; + proceeds: string; + settlements: InvestorSettlement[]; +} + +export class SettlementService { + constructor(private readonly dataSource: DataSource) {} + + /** + * Settles a funded invoice by distributing proceeds to each investor + * pro-rata to their share of the invoice's face value. + */ + async settleInvoice(input: SettleInvoiceInput): Promise { + const { invoiceId, proceeds: proceedsInput } = input; + + const proceeds = new Decimal(proceedsInput); + if (proceeds.isNegative() || proceeds.isZero()) { + throw new ServiceError( + "INVALID_PROCEEDS", + "Settlement proceeds must be greater than zero", + ); + } + + return await this.dataSource.transaction(async (transactionalEntityManager: EntityManager) => { + // 1. Lock the invoice row for update + const invoice = await transactionalEntityManager + .createQueryBuilder(Invoice, "invoice") + .setLock("pessimistic_write") + .where("invoice.id = :id", { id: invoiceId }) + .getOne(); + + if (!invoice) { + throw new ServiceError("INVOICE_NOT_FOUND", "Invoice not found", 404); + } + + // 2. Validate invoice status + if (invoice.status !== InvoiceStatus.FUNDED) { + throw new ServiceError( + "INVALID_INVOICE_STATUS", + `Cannot settle an invoice with status ${invoice.status}`, + ); + } + + // 3. Find confirmed investments backing this invoice + const investments = await transactionalEntityManager.find(Investment, { + where: { invoiceId: invoice.id, status: InvestmentStatus.CONFIRMED }, + }); + + if (investments.length === 0) { + throw new ServiceError( + "NO_CONFIRMED_INVESTMENTS", + "Invoice has no confirmed investments to settle", + ); + } + + // 4. Distribute proceeds pro-rata to each investor's share of the face value + const faceValue = new Decimal(invoice.amount); + const settlements: InvestorSettlement[] = []; + + for (const investment of investments) { + const investmentAmount = new Decimal(investment.investmentAmount); + const actualReturn = investmentAmount + .times(proceeds) + .dividedBy(faceValue) + .toDecimalPlaces(4); + + investment.actualReturn = actualReturn.toFixed(4); + investment.status = InvestmentStatus.SETTLED; + await transactionalEntityManager.save(Investment, investment); + + settlements.push({ + investmentId: investment.id, + investorId: investment.investorId, + investmentAmount: investment.investmentAmount, + actualReturn: investment.actualReturn, + }); + } + + // 5. Transition invoice to SETTLED + invoice.status = InvoiceStatus.SETTLED; + await transactionalEntityManager.save(Invoice, invoice); + + return { + invoiceId: invoice.id, + status: InvoiceStatus.SETTLED as const, + proceeds: proceeds.toFixed(4), + settlements, + }; + }); + } +} + +export function createSettlementService(dataSource: DataSource): SettlementService { + return new SettlementService(dataSource); +} diff --git a/src/services/stellar/orchestrate-investment-funding.service.ts b/src/services/stellar/orchestrate-investment-funding.service.ts index e192b7c..8a91c8d 100644 --- a/src/services/stellar/orchestrate-investment-funding.service.ts +++ b/src/services/stellar/orchestrate-investment-funding.service.ts @@ -3,6 +3,9 @@ import { Investment } from "../../models/Investment.model"; import { Transaction } from "../../models/Transaction.model"; import { InvestmentStatus, TransactionStatus, TransactionType } from "../../types/enums"; import { ServiceError } from "../../utils/service-error"; +import type { AppLogger } from "../../observability/logger"; + +const ESCROW_FUNDING_FUNCTION_NAME = "prepare_investment_funding"; export interface SorobanEscrowFundingInput { investmentId: string; @@ -15,6 +18,8 @@ export interface SorobanEscrowFundingDraft { contractId: string; xdr: string; expiresAt: string; + txHash: string; + ledger: number; } export interface SorobanEscrowClient { @@ -58,12 +63,27 @@ interface OrchestrateInvestmentFundingServiceDependencies { contractId: string | null; fundingMode: "wallet_xdr"; }; + logger?: AppLogger; } +const NOOP_LOGGER: AppLogger = { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + child: () => NOOP_LOGGER, +}; + export class OrchestrateInvestmentFundingService { + private readonly logger: AppLogger; + constructor( private readonly dependencies: OrchestrateInvestmentFundingServiceDependencies, - ) {} + ) { + this.logger = (dependencies.logger ?? NOOP_LOGGER).child({ + component: "soroban-escrow-funding", + }); + } async orchestrateFunding( investmentId: string, @@ -109,11 +129,41 @@ export class OrchestrateInvestmentFundingService { ); } - const draft = await this.dependencies.sorobanEscrowClient.prepareInvestmentFunding({ - investmentId: investment.id, - invoiceId: investment.invoiceId, - investorId: investment.investorId, - amount: investment.investmentAmount, + const submittedAt = new Date().toISOString(); + + this.logger.debug("Submitting Soroban escrow transaction.", { + contract_id: contractId, + function_name: ESCROW_FUNDING_FUNCTION_NAME, + invoice_id: investment.invoiceId, + submitted_at: submittedAt, + }); + + let draft: SorobanEscrowFundingDraft; + try { + draft = await this.dependencies.sorobanEscrowClient.prepareInvestmentFunding({ + investmentId: investment.id, + invoiceId: investment.invoiceId, + investorId: investment.investorId, + amount: investment.investmentAmount, + }); + } catch (error) { + this.logger.warn("Soroban escrow transaction submission failed.", { + contract_id: contractId, + function_name: ESCROW_FUNDING_FUNCTION_NAME, + invoice_id: investment.invoiceId, + submitted_at: submittedAt, + error_reason: error instanceof Error ? error.message : "Unknown error", + }); + throw error; + } + + this.logger.info("Soroban escrow transaction confirmed.", { + contract_id: contractId, + function_name: ESCROW_FUNDING_FUNCTION_NAME, + invoice_id: investment.invoiceId, + tx_hash: draft.txHash, + ledger: draft.ledger, + confirmed_at: new Date().toISOString(), }); return this.dependencies.transactionRunner.runInTransaction(async (unitOfWork) => { @@ -204,6 +254,7 @@ export function createOrchestrateInvestmentFundingService( dataSource: DataSource, sorobanEscrowClient: SorobanEscrowClient, config: OrchestrateInvestmentFundingServiceDependencies["config"], + logger?: AppLogger, ): OrchestrateInvestmentFundingService { return new OrchestrateInvestmentFundingService({ investmentReader: new TypeOrmInvestmentFundingReader( @@ -212,5 +263,6 @@ export function createOrchestrateInvestmentFundingService( transactionRunner: new TypeOrmFundingTransactionRunner(dataSource), sorobanEscrowClient, config, + logger, }); } diff --git a/tests/integration/settlement.integration.test.ts b/tests/integration/settlement.integration.test.ts new file mode 100644 index 0000000..d71410d --- /dev/null +++ b/tests/integration/settlement.integration.test.ts @@ -0,0 +1,164 @@ +import crypto from "crypto"; +import { DataSource } from "typeorm"; +import { InvestmentService } from "../../src/services/investment.service"; +import { SettlementService } from "../../src/services/settlement.service"; +import { Invoice } from "../../src/models/Invoice.model"; +import { Investment } from "../../src/models/Investment.model"; +import { InvoiceStatus, InvestmentStatus } from "../../src/types/enums"; + +/** + * Minimal in-memory TypeORM stand-in shared by InvestmentService and + * SettlementService so this test exercises the real funding -> settlement + * flow end to end without a live database. + */ +function createFakeDataSource(invoice: Invoice) { + const invoices = new Map([[invoice.id, invoice]]); + const investments = new Map(); + + type FakeManager = { + createQueryBuilder: (entity: unknown, alias: string) => { + setLock: () => unknown; + where: (clause: string, params: { id: string }) => unknown; + getOne: () => Promise; + }; + find: ( + entity: unknown, + options: { where: Record | Record[] }, + ) => Promise; + create: (entity: unknown, data: Partial) => Investment | Partial; + save: (entity: unknown, data: Investment | Invoice) => Promise; + }; + + const manager: FakeManager = { + createQueryBuilder: (_entity: unknown, _alias: string) => { + let targetId: string | undefined; + const builder = { + setLock: () => builder, + where: (_clause: string, params: { id: string }) => { + targetId = params.id; + return builder; + }, + getOne: async () => (targetId ? invoices.get(targetId) ?? null : null), + }; + return builder; + }, + find: async ( + entity: unknown, + options: { where: Record | Record[] }, + ) => { + if (entity === Investment) { + const whereClauses = Array.isArray(options.where) ? options.where : [options.where]; + return [...investments.values()].filter((investment) => + whereClauses.some((clause) => + Object.entries(clause).every( + ([key, value]) => (investment as unknown as Record)[key] === value, + ), + ), + ); + } + return []; + }, + create: (entity: unknown, data: Partial) => { + if (entity === Investment) { + return { id: crypto.randomUUID(), ...data } as Investment; + } + return data; + }, + save: async (entity: unknown, data: Investment | Invoice) => { + if (entity === Investment) { + investments.set((data as Investment).id, data as Investment); + } else if (entity === Invoice) { + invoices.set((data as Invoice).id, data as Invoice); + } + return data; + }, + }; + + const dataSource = { + transaction: async (callback: (manager: FakeManager) => Promise) => + callback(manager), + } as unknown as DataSource; + + return { dataSource, invoices, investments }; +} + +function createInvoice(overrides: Partial = {}): Invoice { + return { + id: crypto.randomUUID(), + sellerId: crypto.randomUUID(), + invoiceNumber: "INV-SETTLE-001", + customerName: "Customer A", + amount: "6000.0000", + discountRate: "0.00", + netAmount: "6000.0000", + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + ipfsHash: "QmTestHash", + riskScore: null, + status: InvoiceStatus.PUBLISHED, + smartContractId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + seller: undefined as unknown as Invoice["seller"], + investments: [], + transactions: [], + ...overrides, + } as Invoice; +} + +describe("Settlement integration: funding multiple investors then settling", () => { + it("distributes proceeds pro-rata to each investor and marks the invoice settled", async () => { + const invoice = createInvoice(); + const { dataSource, invoices, investments } = createFakeDataSource(invoice); + + const investmentService = new InvestmentService(dataSource); + const settlementService = new SettlementService(dataSource); + + const investorAId = crypto.randomUUID(); + const investorBId = crypto.randomUUID(); + + const investmentA = await investmentService.createInvestment({ + invoiceId: invoice.id, + investorId: investorAId, + investmentAmount: "4000.0000", + }); + + const investmentB = await investmentService.createInvestment({ + invoiceId: invoice.id, + investorId: investorBId, + investmentAmount: "2000.0000", + }); + + // Simulate confirmed on-chain payment for both investments before settlement. + for (const investment of [investmentA, investmentB]) { + const stored = investments.get(investment.id)!; + stored.status = InvestmentStatus.CONFIRMED; + investments.set(investment.id, stored); + } + + // Invoice reaches FUNDED once fully subscribed (asserted by InvestmentService already); + // settlement requires FUNDED status. + expect(invoices.get(invoice.id)?.status).toBe(InvoiceStatus.FUNDED); + + const result = await settlementService.settleInvoice({ + invoiceId: invoice.id, + proceeds: "6600.0000", + }); + + const returnByInvestor = new Map( + result.settlements.map((settlement) => [settlement.investorId, settlement.actualReturn]), + ); + + expect(returnByInvestor.get(investorAId)).toBe("4400.0000"); + expect(returnByInvestor.get(investorBId)).toBe("2200.0000"); + + expect(result.status).toBe(InvoiceStatus.SETTLED); + expect(invoices.get(invoice.id)?.status).toBe(InvoiceStatus.SETTLED); + + const sumOfReturns = result.settlements.reduce( + (sum, settlement) => sum + Number(settlement.actualReturn), + 0, + ); + expect(sumOfReturns).toBeCloseTo(6600, 4); + }); +}); diff --git a/tests/invoice.service.test.ts b/tests/invoice.service.test.ts index 9c157a8..af74b60 100644 --- a/tests/invoice.service.test.ts +++ b/tests/invoice.service.test.ts @@ -349,14 +349,16 @@ describe("InvoiceService", () => { // ============ PUBLISH INVOICE TESTS ============ describe("publishInvoice", () => { + const publishableInvoice = { + ...mockInvoice, + dueDate: new Date(Date.now() + 48 * 60 * 60 * 1000), + ipfsHash: "QmTestHash", + seller: { kycStatus: "approved" }, + } as Invoice; + it("should transition draft invoice to published", async () => { - const invoiceWithSeller = { - ...mockInvoice, - dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days in future - seller: { kycStatus: "approved" }, - }; - mockInvoiceRepository.findOne.mockResolvedValue(invoiceWithSeller); - const publishedInvoice = { ...invoiceWithSeller, status: InvoiceStatus.PUBLISHED }; + mockInvoiceRepository.findOne.mockResolvedValue({ ...publishableInvoice }); + const publishedInvoice = { ...publishableInvoice, status: InvoiceStatus.PUBLISHED }; mockInvoiceRepository.save.mockResolvedValue(publishedInvoice); const result = await invoiceService.publishInvoice({ @@ -381,7 +383,7 @@ describe("InvoiceService", () => { sellerId: "seller-456", }) ).rejects.toMatchObject({ - code: "invalid_due_date", + code: "invoice_not_publishable", statusCode: 400, }); }); @@ -407,12 +409,7 @@ describe("InvoiceService", () => { }); it("should throw error for unauthorized publish", async () => { - const invoiceWithSeller = { - ...mockInvoice, - dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - seller: { kycStatus: "approved" }, - }; - mockInvoiceRepository.findOne.mockResolvedValue(invoiceWithSeller); + mockInvoiceRepository.findOne.mockResolvedValue(publishableInvoice); await expect( invoiceService.publishInvoice({ @@ -426,12 +423,7 @@ describe("InvoiceService", () => { }); it("should allow transition from pending to published", async () => { - const pendingInvoice = { - ...mockInvoice, - status: InvoiceStatus.PENDING, - dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - seller: { kycStatus: "approved" }, - }; + const pendingInvoice = { ...publishableInvoice, status: InvoiceStatus.PENDING }; mockInvoiceRepository.findOne.mockResolvedValue(pendingInvoice); const publishedInvoice = { ...pendingInvoice, status: InvoiceStatus.PUBLISHED }; mockInvoiceRepository.save.mockResolvedValue(publishedInvoice); @@ -446,8 +438,8 @@ describe("InvoiceService", () => { it("should reject publish for seller without KYC approval", async () => { const invoiceWithPendingKYC = { - ...mockInvoice, - dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + ...publishableInvoice, + status: InvoiceStatus.DRAFT, seller: { kycStatus: "pending" }, }; mockInvoiceRepository.findOne.mockResolvedValue(invoiceWithPendingKYC); @@ -462,6 +454,22 @@ describe("InvoiceService", () => { statusCode: 403, }); }); + + it("should reject publishing an invoice that fails pre-publish validation", async () => { + const invalidInvoice = { ...publishableInvoice, status: InvoiceStatus.DRAFT, ipfsHash: null }; + mockInvoiceRepository.findOne.mockResolvedValue(invalidInvoice); + + await expect( + invoiceService.publishInvoice({ + invoiceId: "invoice-123", + sellerId: "seller-456", + }), + ).rejects.toMatchObject({ + code: "invoice_not_publishable", + statusCode: 400, + }); + expect(mockInvoiceRepository.save).not.toHaveBeenCalled(); + }); }); // ============ UPLOAD DOCUMENT TESTS ============ diff --git a/tests/observability.test.ts b/tests/observability.test.ts index 37b9944..9cd79e5 100644 --- a/tests/observability.test.ts +++ b/tests/observability.test.ts @@ -5,7 +5,7 @@ import { MetricsRegistry } from "../src/observability/metrics"; import type { AuthService } from "../src/services/auth.service"; interface LogEntry { - level: "info" | "warn" | "error"; + level: "debug" | "info" | "warn" | "error"; message: string; metadata: LogMetadata; } @@ -16,6 +16,17 @@ class CaptureLogger implements AppLogger { private readonly defaultMetadata: LogMetadata = {}, ) {} + debug(message: string, metadata: LogMetadata = {}): void { + this.entries.push({ + level: "debug", + message, + metadata: { + ...this.defaultMetadata, + ...metadata, + }, + }); + } + info(message: string, metadata: LogMetadata = {}): void { this.entries.push({ level: "info", diff --git a/tests/orchestrate-investment-funding.logging.test.ts b/tests/orchestrate-investment-funding.logging.test.ts new file mode 100644 index 0000000..075a5d1 --- /dev/null +++ b/tests/orchestrate-investment-funding.logging.test.ts @@ -0,0 +1,155 @@ +import crypto from "crypto"; +import { Investment } from "../src/models/Investment.model"; +import { Transaction } from "../src/models/Transaction.model"; +import { + OrchestrateInvestmentFundingService, +} from "../src/services/stellar/orchestrate-investment-funding.service"; +import { InvestmentStatus, TransactionStatus, TransactionType } from "../src/types/enums"; +import type { AppLogger } from "../src/observability/logger"; + +function createInvestment(overrides: Partial = {}): Investment { + return { + id: overrides.id ?? crypto.randomUUID(), + invoiceId: overrides.invoiceId ?? crypto.randomUUID(), + investorId: overrides.investorId ?? crypto.randomUUID(), + investmentAmount: overrides.investmentAmount ?? "100.0000", + expectedReturn: overrides.expectedReturn ?? "105.0000", + actualReturn: overrides.actualReturn ?? null, + status: overrides.status ?? InvestmentStatus.PENDING, + transactionHash: overrides.transactionHash ?? null, + stellarOperationIndex: overrides.stellarOperationIndex ?? null, + createdAt: overrides.createdAt ?? new Date(), + updatedAt: overrides.updatedAt ?? new Date(), + deletedAt: overrides.deletedAt ?? null, + invoice: overrides.invoice as Investment["invoice"], + investor: overrides.investor as Investment["investor"], + transactions: overrides.transactions ?? [], + }; +} + +function createTransaction(overrides: Partial = {}): Transaction { + return { + id: overrides.id ?? crypto.randomUUID(), + userId: overrides.userId ?? crypto.randomUUID(), + invoiceId: overrides.invoiceId ?? null, + investmentId: overrides.investmentId ?? null, + type: overrides.type ?? TransactionType.INVESTMENT, + amount: overrides.amount ?? "100.0000", + stellarTxHash: overrides.stellarTxHash ?? null, + stellarOperationIndex: overrides.stellarOperationIndex ?? null, + status: overrides.status ?? TransactionStatus.PENDING, + timestamp: overrides.timestamp ?? new Date(), + user: overrides.user as Transaction["user"], + invoice: overrides.invoice as Transaction["invoice"], + investment: overrides.investment as Transaction["investment"], + }; +} + +function createMockLogger(): jest.Mocked { + const logger: jest.Mocked = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + child: jest.fn(), + }; + logger.child.mockReturnValue(logger); + return logger; +} + +describe("OrchestrateInvestmentFundingService structured escrow logging", () => { + it("logs a debug entry before submission and an info entry after confirmation", async () => { + const investment = createInvestment(); + const logger = createMockLogger(); + const transactions = new Map(); + + const service = new OrchestrateInvestmentFundingService({ + investmentReader: { findById: async () => investment }, + transactionRunner: { + runInTransaction: async (callback) => + callback({ + findInvestmentByIdForUpdate: async () => investment, + findTransactionByInvestmentIdForUpdate: async () => + transactions.get(investment.id) ?? null, + saveTransaction: async (transaction) => { + transactions.set(investment.id, transaction); + return transaction; + }, + createTransaction: (input) => createTransaction(input), + }), + }, + sorobanEscrowClient: { + prepareInvestmentFunding: jest.fn().mockResolvedValue({ + contractId: "CESCROW123", + xdr: "AAAA-wallet-xdr", + expiresAt: "2026-03-27T22:00:00.000Z", + txHash: "escrow-tx-hash", + ledger: 555111, + }), + }, + config: { enabled: true, contractId: "CESCROW123", fundingMode: "wallet_xdr" }, + logger, + }); + + await service.orchestrateFunding(investment.id); + + expect(logger.debug).toHaveBeenCalledWith( + "Submitting Soroban escrow transaction.", + expect.objectContaining({ + contract_id: "CESCROW123", + function_name: "prepare_investment_funding", + invoice_id: investment.invoiceId, + submitted_at: expect.any(String), + }), + ); + + expect(logger.info).toHaveBeenCalledWith( + "Soroban escrow transaction confirmed.", + expect.objectContaining({ + contract_id: "CESCROW123", + function_name: "prepare_investment_funding", + invoice_id: investment.invoiceId, + tx_hash: "escrow-tx-hash", + ledger: 555111, + confirmed_at: expect.any(String), + }), + ); + + const [, debugMetadata] = logger.debug.mock.calls[0]; + const [, infoMetadata] = logger.info.mock.calls[0]; + for (const metadata of [debugMetadata, infoMetadata]) { + const serialized = JSON.stringify(metadata); + expect(serialized).not.toMatch(/investmentAmount|amount/i); + expect(serialized).not.toContain(investment.investorId); + } + }); + + it("logs a warn entry with the failure reason when escrow submission fails, and never logs info", async () => { + const investment = createInvestment(); + const logger = createMockLogger(); + + const service = new OrchestrateInvestmentFundingService({ + investmentReader: { findById: async () => investment }, + transactionRunner: { runInTransaction: jest.fn() }, + sorobanEscrowClient: { + prepareInvestmentFunding: jest.fn().mockRejectedValue(new Error("RPC unavailable")), + }, + config: { enabled: true, contractId: "CESCROW123", fundingMode: "wallet_xdr" }, + logger, + }); + + await expect(service.orchestrateFunding(investment.id)).rejects.toThrow("RPC unavailable"); + + expect(logger.warn).toHaveBeenCalledWith( + "Soroban escrow transaction submission failed.", + expect.objectContaining({ + contract_id: "CESCROW123", + function_name: "prepare_investment_funding", + invoice_id: investment.invoiceId, + submitted_at: expect.any(String), + error_reason: "RPC unavailable", + }), + ); + expect(logger.info).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/reconcile-pending-stellar-state.worker.test.ts b/tests/reconcile-pending-stellar-state.worker.test.ts index 0199a1d..510d5bf 100644 --- a/tests/reconcile-pending-stellar-state.worker.test.ts +++ b/tests/reconcile-pending-stellar-state.worker.test.ts @@ -8,7 +8,7 @@ import { ServiceError } from "../src/utils/service-error"; import type { PaymentVerificationResult } from "../src/services/stellar/verify-payment.service"; interface LogEntry { - level: "info" | "warn" | "error"; + level: "debug" | "info" | "warn" | "error"; message: string; metadata: LogMetadata; } @@ -19,6 +19,17 @@ class CaptureLogger implements AppLogger { private readonly defaultMetadata: LogMetadata = {}, ) {} + debug(message: string, metadata: LogMetadata = {}): void { + this.entries.push({ + level: "debug", + message, + metadata: { + ...this.defaultMetadata, + ...metadata, + }, + }); + } + info(message: string, metadata: LogMetadata = {}): void { this.entries.push({ level: "info", diff --git a/tests/validate-invoice-for-publish.test.ts b/tests/validate-invoice-for-publish.test.ts new file mode 100644 index 0000000..f8cb08d --- /dev/null +++ b/tests/validate-invoice-for-publish.test.ts @@ -0,0 +1,86 @@ +import { validateInvoiceForPublish } from "../src/lib/validate-invoice-for-publish"; +import { Invoice } from "../src/models/Invoice.model"; +import { InvoiceStatus } from "../src/types/enums"; + +function futureDate(hoursFromNow: number): Date { + return new Date(Date.now() + hoursFromNow * 60 * 60 * 1000); +} + +function createInvoice(overrides: Partial = {}): Invoice { + return { + id: "invoice-1", + sellerId: "seller-1", + invoiceNumber: "INV-001", + customerName: "Customer A", + amount: "1000.0000", + discountRate: "5.00", + netAmount: "950.0000", + dueDate: futureDate(48), + ipfsHash: "QmTestHash", + riskScore: null, + status: InvoiceStatus.DRAFT, + smartContractId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + seller: overrides.seller as Invoice["seller"], + investments: overrides.investments ?? [], + transactions: overrides.transactions ?? [], + ...overrides, + } as Invoice; +} + +describe("validateInvoiceForPublish", () => { + it("returns an empty array when all checks pass", () => { + const invoice = createInvoice(); + expect(validateInvoiceForPublish(invoice)).toEqual([]); + }); + + it("returns a validation error for zero face value", () => { + const invoice = createInvoice({ amount: "0.0000" }); + const errors = validateInvoiceForPublish(invoice); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ field: "amount", code: "FACE_VALUE_NOT_POSITIVE" }); + }); + + it("returns a validation error for a negative face value", () => { + const invoice = createInvoice({ amount: "-100.0000" }); + const errors = validateInvoiceForPublish(invoice); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ field: "amount", code: "FACE_VALUE_NOT_POSITIVE" }); + }); + + it("returns a validation error for a due date in the past", () => { + const invoice = createInvoice({ dueDate: futureDate(-1) }); + const errors = validateInvoiceForPublish(invoice); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ field: "dueDate", code: "DUE_DATE_TOO_SOON" }); + }); + + it("returns a validation error for a due date less than 24 hours away", () => { + const invoice = createInvoice({ dueDate: futureDate(1) }); + const errors = validateInvoiceForPublish(invoice); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ field: "dueDate", code: "DUE_DATE_TOO_SOON" }); + }); + + it("returns a validation error when no document is attached", () => { + const invoice = createInvoice({ ipfsHash: null }); + const errors = validateInvoiceForPublish(invoice); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ field: "ipfsHash", code: "MISSING_DOCUMENT" }); + }); + + it("returns all errors when multiple fields are invalid, not just the first", () => { + const invoice = createInvoice({ + amount: "0.0000", + dueDate: futureDate(-5), + ipfsHash: null, + }); + const errors = validateInvoiceForPublish(invoice); + expect(errors).toHaveLength(3); + expect(errors.map((e) => e.code)).toEqual( + expect.arrayContaining(["FACE_VALUE_NOT_POSITIVE", "DUE_DATE_TOO_SOON", "MISSING_DOCUMENT"]), + ); + }); +}); diff --git a/tests/verify-payment.idempotency.test.ts b/tests/verify-payment.idempotency.test.ts new file mode 100644 index 0000000..2468ce2 --- /dev/null +++ b/tests/verify-payment.idempotency.test.ts @@ -0,0 +1,242 @@ +import crypto from "crypto"; +import { Investment } from "../src/models/Investment.model"; +import { Transaction } from "../src/models/Transaction.model"; +import { VerifyPaymentService } from "../src/services/stellar/verify-payment.service"; +import { InvestmentStatus, TransactionStatus, TransactionType } from "../src/types/enums"; + +interface MockResponseInit { + ok: boolean; + status: number; + body: unknown; +} + +function createMockResponse({ ok, status, body }: MockResponseInit): Response { + return { + ok, + status, + json: async () => body, + } as Response; +} + +function createInvestment(overrides: Partial = {}): Investment { + return { + id: overrides.id ?? crypto.randomUUID(), + invoiceId: overrides.invoiceId ?? crypto.randomUUID(), + investorId: overrides.investorId ?? crypto.randomUUID(), + investmentAmount: overrides.investmentAmount ?? "100.0000", + expectedReturn: overrides.expectedReturn ?? "105.0000", + actualReturn: overrides.actualReturn ?? null, + status: overrides.status ?? InvestmentStatus.PENDING, + transactionHash: overrides.transactionHash ?? null, + stellarOperationIndex: overrides.stellarOperationIndex ?? null, + createdAt: overrides.createdAt ?? new Date(), + updatedAt: overrides.updatedAt ?? new Date(), + deletedAt: overrides.deletedAt ?? null, + invoice: overrides.invoice as Investment["invoice"], + investor: overrides.investor as Investment["investor"], + transactions: overrides.transactions ?? [], + }; +} + +function createTransaction(overrides: Partial = {}): Transaction { + return { + id: overrides.id ?? crypto.randomUUID(), + userId: overrides.userId ?? crypto.randomUUID(), + invoiceId: overrides.invoiceId ?? null, + investmentId: overrides.investmentId ?? null, + type: overrides.type ?? TransactionType.INVESTMENT, + amount: overrides.amount ?? "100.0000", + stellarTxHash: overrides.stellarTxHash ?? null, + stellarOperationIndex: overrides.stellarOperationIndex ?? null, + status: overrides.status ?? TransactionStatus.PENDING, + timestamp: overrides.timestamp ?? new Date(), + user: overrides.user as Transaction["user"], + invoice: overrides.invoice as Transaction["invoice"], + investment: overrides.investment as Transaction["investment"], + }; +} + +function successfulHorizonResponses(amount: string) { + return [ + createMockResponse({ ok: true, status: 200, body: { successful: true } }), + createMockResponse({ + ok: true, + status: 200, + body: { + _embedded: { + records: [ + { + type: "payment", + asset_code: "USDC", + asset_issuer: "GDUKMGUGDZQK6YHZZ7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXN5R4OT", + amount, + to: "GCFXROWPUBKEYEXAMPLE7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXNOPE", + }, + ], + }, + }, + }), + ]; +} + +function createServiceContext(invoiceId: string) { + const investmentStore = new Map(); + const transactions = new Map(); + const fetchImplementation = jest.fn, Parameters>(); + + const saveInvestment = jest.fn(async (investment: Investment) => { + investmentStore.set(investment.id, investment); + return investment; + }); + + const saveTransaction = jest.fn(async (transaction: Transaction) => { + const current = transactions.get(transaction.investmentId ?? "") ?? []; + if (!current.find((item) => item.id === transaction.id)) { + current.push(transaction); + } + transactions.set(transaction.investmentId ?? "", current); + return transaction; + }); + + function addInvestment(overrides: Partial = {}): Investment { + const investment = createInvestment({ invoiceId, ...overrides }); + investmentStore.set(investment.id, investment); + return investment; + } + + const service = new VerifyPaymentService({ + investmentReader: { + findById: async (investmentId) => investmentStore.get(investmentId) ?? null, + }, + transactionRunner: { + runInTransaction: async (callback) => + callback({ + findInvestmentByIdForUpdate: async (investmentId) => + investmentStore.get(investmentId) ?? null, + findTransactionsByInvestmentIdForUpdate: async (investmentId) => + transactions.get(investmentId) ?? [], + saveInvestment, + saveTransaction, + createTransaction: (input) => createTransaction(input), + }), + }, + config: { + horizonUrl: "https://horizon-testnet.stellar.org", + usdcAssetCode: "USDC", + usdcAssetIssuer: "GDUKMGUGDZQK6YHZZ7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXN5R4OT", + escrowPublicKey: "GCFXROWPUBKEYEXAMPLE7KQJX2BQPJYVY5W7C2D4GMXQ3MNK4V2ZXNOPE", + allowedAmountDelta: "0.0001", + retryAttempts: 3, + retryBaseDelayMs: 10, + }, + fetchImplementation, + sleep: jest.fn(async () => undefined), + }); + + return { + service, + addInvestment, + investmentStore, + transactions, + fetchImplementation, + saveInvestment, + saveTransaction, + }; +} + +describe("VerifyPaymentService idempotency (transaction hash as dedup key)", () => { + it("creates the investment record on the first call with a new transaction hash", async () => { + const invoiceId = crypto.randomUUID(); + const context = createServiceContext(invoiceId); + const investment = context.addInvestment(); + + context.fetchImplementation + .mockResolvedValueOnce(successfulHorizonResponses("100.0000")[0]) + .mockResolvedValueOnce(successfulHorizonResponses("100.0000")[1]); + + const result = await context.service.verifyPayment({ + investmentId: investment.id, + stellarTxHash: "hash-a", + }); + + expect(result.outcome).toBe("verified"); + expect(result.status).toBe(InvestmentStatus.CONFIRMED); + expect(context.investmentStore.get(investment.id)?.transactionHash).toBe("hash-a"); + expect(context.transactions.get(investment.id)).toHaveLength(1); + expect(context.saveInvestment).toHaveBeenCalledTimes(1); + expect(context.saveTransaction).toHaveBeenCalledTimes(1); + }); + + it("returns success without creating a duplicate record on a repeated call with the same hash", async () => { + const invoiceId = crypto.randomUUID(); + const context = createServiceContext(invoiceId); + const investment = context.addInvestment(); + const stellarTxHash = "duplicate-hash"; + + context.fetchImplementation + .mockResolvedValueOnce(successfulHorizonResponses("100.0000")[0]) + .mockResolvedValueOnce(successfulHorizonResponses("100.0000")[1]); + + const firstResult = await context.service.verifyPayment({ + investmentId: investment.id, + stellarTxHash, + }); + + expect(firstResult.outcome).toBe("verified"); + expect(context.transactions.get(investment.id)).toHaveLength(1); + expect(context.saveTransaction).toHaveBeenCalledTimes(1); + expect(context.saveInvestment).toHaveBeenCalledTimes(1); + + context.saveTransaction.mockClear(); + context.saveInvestment.mockClear(); + + const secondResult = await context.service.verifyPayment({ + investmentId: investment.id, + stellarTxHash, + operationIndex: firstResult.operationIndex, + }); + + expect(secondResult.outcome).toBe("already_verified"); + expect(secondResult.status).toBe(InvestmentStatus.CONFIRMED); + + // Deduplication happens before any write: no additional record created. + expect(context.transactions.get(investment.id)).toHaveLength(1); + expect(context.saveTransaction).not.toHaveBeenCalled(); + expect(context.saveInvestment).not.toHaveBeenCalled(); + + // The dedup path also never re-hits Horizon for a fully confirmed investment. + expect(context.fetchImplementation).toHaveBeenCalledTimes(2); + }); + + it("creates two independent records for two different transaction hashes on the same invoice", async () => { + const invoiceId = crypto.randomUUID(); + const context = createServiceContext(invoiceId); + const investmentA = context.addInvestment(); + const investmentB = context.addInvestment(); + + context.fetchImplementation + .mockResolvedValueOnce(successfulHorizonResponses("100.0000")[0]) + .mockResolvedValueOnce(successfulHorizonResponses("100.0000")[1]) + .mockResolvedValueOnce(successfulHorizonResponses("100.0000")[0]) + .mockResolvedValueOnce(successfulHorizonResponses("100.0000")[1]); + + const resultA = await context.service.verifyPayment({ + investmentId: investmentA.id, + stellarTxHash: "hash-a", + }); + const resultB = await context.service.verifyPayment({ + investmentId: investmentB.id, + stellarTxHash: "hash-b", + }); + + expect(resultA.outcome).toBe("verified"); + expect(resultB.outcome).toBe("verified"); + expect(context.investmentStore.get(investmentA.id)?.transactionHash).toBe("hash-a"); + expect(context.investmentStore.get(investmentB.id)?.transactionHash).toBe("hash-b"); + expect(context.transactions.get(investmentA.id)).toHaveLength(1); + expect(context.transactions.get(investmentB.id)).toHaveLength(1); + expect(context.transactions.get(investmentA.id)?.[0].id).not.toBe( + context.transactions.get(investmentB.id)?.[0].id, + ); + }); +});