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(); }); }); /** * A gateway success can land after the pay window AND its drain tail (relay * backlog, payment-api restart, a CBE bill paid at a counter). The money is * captured either way, so the settle lookup must accept an EXPIRED invoice — * matching only OPEN_STATUSES used to drop it silently, leaving a debited * customer with an EXPIRED invoice, an EXPIRED booking and no alert. */ describe("BillingService.settleByPaymentId", () => { function serviceFor(invoice: Record | null) { const mg = { findOne: jest.fn().mockResolvedValue(invoice), update: jest.fn().mockResolvedValue(undefined), }; const events = makeEvents(); // The lookup is by paymentId ALONE — status is judged on the resolved row, // so a stale capture can never skip past a newer invoice to an older one. const findOne = jest.fn(({ where }: { where: Record }) => { expect(where).toEqual({ paymentId: "pay-1" }); return Promise.resolve(invoice); }); const dataSource = { getRepository: () => ({ findOne }), transaction: (cb: (mg: unknown) => unknown) => cb(mg), manager: 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 }; } it("settles an EXPIRED invoice — the money was already captured", async () => { const { service, mg, events } = serviceFor({ id: "inv-1", status: Freight.InvoiceStatus.Expired, source: "booking", sourceId: "booking-1", totalAmount: 1500, paidAt: null, }); const settled = await service.settleByPaymentId("pay-1", "txn-1"); expect(settled?.status).toBe(Freight.InvoiceStatus.Paid); expect(mg.update).toHaveBeenCalledWith( expect.anything(), { id: "inv-1" }, expect.objectContaining({ status: Freight.InvoiceStatus.Paid, paidAmount: 1500, balanceAmount: 0, }), ); // The domain reacts to this — it is what revives the expired booking. expect(events.emitAsync).toHaveBeenCalledWith( "booking.invoice.paid", expect.objectContaining({ invoiceId: "inv-1" }), ); }); it("still settles an open (PENDING) invoice", async () => { const { service, mg } = serviceFor({ id: "inv-1", status: Freight.InvoiceStatus.Pending, source: "booking", sourceId: "booking-1", totalAmount: 1500, paidAt: null, }); await service.settleByPaymentId("pay-1"); expect(mg.update).toHaveBeenCalled(); }); /** * `upsertIntent` keeps ONE local payments row per booking reference, so every * invoice the booking was ever charged on carries the same `paymentId`. A * capture from a lapsed first attempt must not reach back past the invoice the * customer actually paid and settle the older EXPIRED one — that would mark two * invoices paid off a single payment. */ it("no-ops when the booking's newest invoice is already PAID", async () => { const { service, mg, events } = serviceFor({ id: "inv-2", status: Freight.InvoiceStatus.Paid, source: "booking", sourceId: "booking-1", totalAmount: 1500, }); expect(await service.settleByPaymentId("pay-1")).toBeNull(); expect(mg.update).not.toHaveBeenCalled(); expect(events.emitAsync).not.toHaveBeenCalled(); }); it.each([ ["CANCELLED", Freight.InvoiceStatus.Cancelled], ["REFUNDED", Freight.InvoiceStatus.Refunded], ])("does not settle a %s invoice — that is a refund case", async ( _label, status, ) => { const { service, mg, events } = serviceFor({ id: "inv-1", status, source: "booking", sourceId: "booking-1", totalAmount: 1500, }); expect(await service.settleByPaymentId("pay-1")).toBeNull(); expect(mg.update).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(); }); it("also retires a DRAFT invoice — a superseded/cancelled source must not leave one behind", async () => { const { service, defaultManager } = build({ ...openInvoice, status: Freight.InvoiceStatus.Draft, }); await service.expirePayable( Freight.InvoiceSource.Booking, "booking-1", "prepaid", ); const { where } = defaultManager.findOne.mock.calls[0][1]; expect(where.status.value).toContain(Freight.InvoiceStatus.Draft); }); }); describe("BillingService.issuePayable", () => { const dueAt = new Date("2026-01-02T00:00:00.000Z"); const build = (found: Record | null) => { const manager = { findOne: jest.fn().mockResolvedValue(found), update: jest.fn().mockResolvedValue(undefined), }; const service = new BillingService( { manager, transaction: jest.fn() } as never, {} as never, {} as never, makeEvents() as never, {} as never, {} as never, {} as never, ); return { service, manager }; }; const issue = (service: BillingService) => service.issuePayable( Freight.InvoiceSource.Booking, "booking-1", dueAt, "PREPAID", ); it("issues a DRAFT invoice to PENDING, stamping issuedAt and the pay-window dueAt", async () => { const { service, manager } = build({ id: "inv-1", invoiceNumber: "INV-20260101-00001", status: Freight.InvoiceStatus.Draft, issuedAt: null, }); const result = await issue(service); const patch = manager.update.mock.calls[0][2]; expect(patch.status).toBe(Freight.InvoiceStatus.Pending); expect(patch.dueAt).toBe(dueAt); expect(patch.issuedAt).toBeInstanceOf(Date); expect(result?.status).toBe(Freight.InvoiceStatus.Pending); }); it("looks up DRAFT invoices — a booking's invoice is minted DRAFT and this is what makes it payable", async () => { const { service, manager } = build(null); await issue(service); const { where } = manager.findOne.mock.calls[0][1]; expect(where.status.value).toContain(Freight.InvoiceStatus.Draft); }); it("only refreshes dueAt on an already-issued invoice, so a re-reserve never re-issues", async () => { const issuedAt = new Date("2026-01-01T00:00:00.000Z"); const { service, manager } = build({ id: "inv-1", invoiceNumber: "INV-20260101-00001", status: Freight.InvoiceStatus.Pending, issuedAt, }); const result = await issue(service); expect(manager.update.mock.calls[0][2]).toEqual({ dueAt }); expect(result?.issuedAt).toBe(issuedAt); }); it("is a no-op (returns null, writes nothing) when the source has no draft-or-open invoice", async () => { const { service, manager } = build(null); await expect(issue(service)).resolves.toBeNull(); expect(manager.update).not.toHaveBeenCalled(); }); }); describe("BillingService — CAC Bank (OTP debit)", () => { const openInvoice = { id: "inv-1", status: Freight.InvoiceStatus.Pending, source: Freight.InvoiceSource.Booking, sourceId: "booking-1", type: "PREPAID", invoiceNumber: "INV-20260101-00001", currency: "USD", balanceAmount: 500, totalAmount: 500, paymentId: "intent-1", dueAt: null, }; const build = (payment: Record) => { const repo = { findOne: jest.fn().mockResolvedValue(openInvoice), update: jest.fn().mockResolvedValue(undefined), }; const service = new BillingService( { getRepository: () => repo } as never, {} as never, {} as never, makeEvents() as never, payment as never, {} as never, {} as never, ); return { service, repo }; }; it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => { const initiate = jest.fn(); const { service } = build({ initiate }); await expect( service.payInvoice("inv-1", { method: "CAC_BANK" }), ).rejects.toThrow(/payerAccount/); expect(initiate).not.toHaveBeenCalled(); }); it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => { const handlePaymentEvent = jest.fn(); const { service } = build({ initiate: jest.fn().mockResolvedValue({ intentId: "intent-1", immediateSuccess: false, response: { intentId: "intent-1", status: "REQUIRES_ACTION", clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" }, }, }), handlePaymentEvent, }); await service.payInvoice("inv-1", { method: "CAC_BANK", payerAccount: "77123456", }); expect(handlePaymentEvent).not.toHaveBeenCalled(); }); it("confirms the OTP against the intent stamped on the invoice", async () => { const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" }); const { service } = build({ confirmOtp }); await service.confirmInvoiceOtp("inv-1", "123456"); expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456"); }); });