import { Freight } from "@edr/types"; import { BillingService } from "./billing.service"; /** * Minimal in-memory EntityManager stand-in covering the methods * `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager. */ function makeManager(savedLines: unknown[]) { return { create: (_entity: unknown, data: Record) => data, save: (data: Record) => { const row = { id: data.id ?? "gen-1", ...data }; if (data.invoiceId) savedLines.push(row); return Promise.resolve(row); }, query: () => Promise.resolve([{ seq: 0 }]), update: jest.fn().mockResolvedValue(undefined), findOne: jest.fn().mockResolvedValue(null), }; } function makeEvents() { // BillingService emits via both emit() and emitAsync() (the post-commit async // listener path) — the mock must provide both. return { emit: jest.fn(), emitAsync: jest.fn().mockResolvedValue([]) }; } function generateInput(overrides: Record = {}) { return { source: Freight.InvoiceSource.Booking, sourceId: "booking-1", type: "prepaid", companyId: "company-1", companyProfileId: "profile-1", currency: "ETB", lines: [ { chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000, }, { chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500, }, ], ...overrides, }; } describe("BillingService.generateInvoice", () => { let savedLines: unknown[]; let manager: ReturnType; let events: ReturnType; let dataSource: { transaction: jest.Mock; manager: unknown }; let service: BillingService; beforeEach(() => { savedLines = []; manager = makeManager(savedLines); events = makeEvents(); dataSource = { transaction: jest .fn() .mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), manager, }; service = new BillingService( dataSource as never, {} as never, {} as never, events as never, {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments ); }); it("creates a PENDING invoice with one line per input line", async () => { const invoice = await service.generateInvoice(generateInput()); expect(invoice.status).toBe(Freight.InvoiceStatus.Pending); expect(invoice.companyId).toBe("company-1"); expect(invoice.source).toBe("booking"); expect(invoice.sourceId).toBe("booking-1"); expect(invoice.totalAmount).toBe(1500); expect(invoice.issuedAt).toBeInstanceOf(Date); expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/); expect(savedLines).toHaveLength(2); }); it("sums line amounts when no explicit totalAmount is given", async () => { const invoice = await service.generateInvoice( generateInput({ totalAmount: undefined }), ); expect(invoice.totalAmount).toBe(1500); }); it("leaves issuedAt null for a DRAFT invoice", async () => { const invoice = await service.generateInvoice( generateInput({ status: Freight.InvoiceStatus.Draft }), ); expect(invoice.status).toBe(Freight.InvoiceStatus.Draft); expect(invoice.issuedAt).toBeNull(); }); it("enlists in a caller's transaction when a manager is passed", async () => { await service.generateInvoice(generateInput(), manager as never); expect(dataSource.transaction).not.toHaveBeenCalled(); expect(savedLines).toHaveLength(2); }); }); describe("BillingService.markInvoiceAsPaid", () => { it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => { const open = { id: "inv-1", status: Freight.InvoiceStatus.Pending, source: "booking", sourceId: "booking-1", totalAmount: 1500, paidAt: null, }; const mg = { findOne: jest.fn().mockResolvedValue(open), update: jest.fn().mockResolvedValue(undefined), }; const events = makeEvents(); const service = new BillingService( { manager: mg } as never, {} as never, {} as never, events as never, {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); expect(mg.update).toHaveBeenCalledWith( expect.anything(), { id: "inv-1" }, { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1", paidAt: expect.any(Date), paidAmount: 1500, balanceAmount: 0, payments: [ { amount: 1500, method: "GATEWAY", reference: "pay-1", paidAt: expect.any(String), metadata: null, }, ], }, ); expect(events.emitAsync).toHaveBeenCalledWith( "booking.invoice.paid", expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid, paymentId: "pay-1", }), ); }); it("is a no-op (no event) when the invoice is already paid", async () => { const paid = { id: "inv-1", status: Freight.InvoiceStatus.Paid, source: "booking", }; const mg = { findOne: jest.fn().mockResolvedValue(paid), update: jest.fn().mockResolvedValue(undefined), }; const events = makeEvents(); const service = new BillingService( { manager: mg } as never, {} as never, {} as never, events as never, {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); expect(mg.update).not.toHaveBeenCalled(); expect(events.emit).not.toHaveBeenCalled(); expect(events.emitAsync).not.toHaveBeenCalled(); }); }); describe("BillingService.recordPayment", () => { function serviceFor(invoice: Record | null) { const mg = { findOne: jest.fn().mockResolvedValue(invoice), update: jest.fn().mockResolvedValue(undefined), }; const events = makeEvents(); const dataSource = { manager: mg, transaction: jest .fn() .mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)), }; const service = new BillingService( dataSource as never, {} as never, {} as never, events as never, {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments ); return { service, mg, events }; } const openInvoice = (overrides: Record = {}) => ({ id: "inv-1", status: Freight.InvoiceStatus.Issued, source: "warehouse", sourceId: "inv-item-1", totalAmount: 1000, paidAmount: 0, balanceAmount: 1000, payments: [], paidAt: null, ...overrides, }); it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => { const { service, mg, events } = serviceFor(openInvoice()); const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" }); expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid); expect(updated.paidAmount).toBe(400); expect(updated.balanceAmount).toBe(600); expect(updated.payments).toHaveLength(1); expect(mg.update).toHaveBeenCalledWith( expect.anything(), { id: "inv-1" }, expect.objectContaining({ status: Freight.InvoiceStatus.PartiallyPaid, paidAmount: 400, balanceAmount: 600, }), ); expect(events.emit).not.toHaveBeenCalled(); expect(events.emitAsync).not.toHaveBeenCalled(); }); it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => { const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 })); const updated = await service.recordPayment("inv-1", { amount: 600 }); expect(updated.status).toBe(Freight.InvoiceStatus.Paid); expect(updated.balanceAmount).toBe(0); expect(updated.paidAt).toBeInstanceOf(Date); expect(mg.update).toHaveBeenCalled(); expect(events.emitAsync).toHaveBeenCalledWith( "warehouse.invoice.paid", expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }), ); }); it("rejects a non-positive amount", async () => { const { service, mg } = serviceFor(openInvoice()); await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow(); expect(mg.update).not.toHaveBeenCalled(); }); it("rejects a payment that exceeds the outstanding balance", async () => { const { service, mg } = serviceFor(openInvoice()); await expect( service.recordPayment("inv-1", { amount: 1500 }), ).rejects.toThrow(); expect(mg.update).not.toHaveBeenCalled(); }); it("rejects payment against a cancelled invoice", async () => { const { service, mg } = serviceFor( openInvoice({ status: Freight.InvoiceStatus.Cancelled }), ); await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow(); expect(mg.update).not.toHaveBeenCalled(); }); }); /** * Regression: `expirePayable` (batch settle path, called when a payment window * lapses) transitions the invoice to EXPIRED, which locks the row FOR UPDATE. * The bug passed `dataSource.manager` (the non-transactional default) into the * transition, so runTransition skipped opening a transaction and the lock threw * `An open transaction is required for pessimistic lock` — aborting the whole * settle pass (the "settle/reserve one booking at a time" symptom). The locked * write MUST run inside dataSource.transaction. */ describe("BillingService.expirePayable — locked write runs in a transaction", () => { const openInvoice = { id: "inv-1", status: Freight.InvoiceStatus.Pending, source: "booking", sourceId: "booking-1", }; const build = (lookupResult: Record | null) => { const defaultManager = { findOne: jest.fn().mockResolvedValue(lookupResult), update: jest.fn().mockResolvedValue(undefined), }; const txManager = { findOne: jest.fn().mockResolvedValue(openInvoice), update: jest.fn().mockResolvedValue(undefined), }; const transaction = jest .fn() .mockImplementation((cb: (mg: unknown) => unknown) => cb(txManager)); const events = makeEvents(); const service = new BillingService( { manager: defaultManager, transaction } as never, {} as never, {} as never, events as never, {} as never, {} as never, {} as never, ); return { service, defaultManager, txManager, transaction }; }; it("opens a transaction and runs the pessimistic-lock read on the tx manager", async () => { const { service, transaction, txManager, defaultManager } = build(openInvoice); await service.expirePayable( Freight.InvoiceSource.Booking, "booking-1", "prepaid", ); expect(transaction).toHaveBeenCalledTimes(1); expect(txManager.findOne).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ lock: { mode: "pessimistic_write" } }), ); expect(txManager.update).toHaveBeenCalled(); // The default manager only does the initial lock-free lookup, never a locked read. for (const call of defaultManager.findOne.mock.calls) { expect(call[1]).not.toHaveProperty("lock"); } }); it("is a no-op (no transaction) when there is no open invoice", async () => { const { service, transaction } = build(null); const result = await service.expirePayable( Freight.InvoiceSource.Booking, "booking-1", "prepaid", ); expect(result).toBeNull(); expect(transaction).not.toHaveBeenCalled(); }); });