import { Freight } from "@edr/types"; import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity"; 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 {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); }); 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.issueMemo", () => { const ORIGINAL_ID = "original-invoice-1"; function originalInvoice(overrides: Record = {}) { return { id: ORIGINAL_ID, invoiceNumber: "INV-20260807-00042", eimsIrn: "irn-value", eimsDocumentType: "INV", eimsStatus: "REGISTERED", source: Freight.InvoiceSource.Booking, sourceId: "booking-1", companyId: "company-1", companyProfileId: "profile-1", shippingLineCompanyId: null, currency: "ETB", totalAmount: 1500, lines: [ { chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000, currency: "ETB", metadata: null }, { chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500, currency: "ETB", metadata: null }, ], ...overrides, }; } function build(original: ReturnType) { const savedLines: unknown[] = []; const manager = makeManager(savedLines); const dataSource = { transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), manager, }; const invoices = { findById: jest.fn().mockResolvedValue(original) }; const invoiceLines = { findAll: jest.fn().mockResolvedValue(original.lines) }; const service = new BillingService( dataSource as never, invoices as never, invoiceLines as never, makeEvents() as never, {} as never, {} as never, {} as never, {} as never, { get: () => undefined } as never, { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, manager, savedLines }; } it("creates a settled credit memo copying the original's lines, linked via relatedInvoiceId", async () => { const { service, savedLines } = build(originalInvoice()); const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "Overbilled freight charge" }); expect(memo.invoiceNumber).toMatch(/^CRE-\d{8}-00001$/); expect(memo.totalAmount).toBe(1500); expect(memo.status).toBe(Freight.InvoiceStatus.Paid); expect((memo as unknown as Record).eimsDocumentType).toBe("CRE"); expect((memo as unknown as Record).eimsReason).toBe("Overbilled freight charge"); expect((memo as unknown as Record).relatedInvoiceId).toBe(ORIGINAL_ID); expect((memo as unknown as Record).paidAmount).toBe(1500); expect((memo as unknown as Record).balanceAmount).toBe(0); expect(savedLines).toHaveLength(2); }); it("creates an open, unpaid debit memo — a genuine new receivable, not force-settled", async () => { const { service } = build(originalInvoice()); const memo = await service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "Additional handling fee" }); expect(memo.invoiceNumber).toMatch(/^DEB-\d{8}-00001$/); expect(memo.status).toBe(Freight.InvoiceStatus.Pending); expect(memo.balanceAmount).toBe(1500); expect(memo.paidAmount).toBe(0); }); it("keys the memo's sourceId to the original invoice's own id, not the original's sourceId", async () => { const { service } = build(originalInvoice()); const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "test" }); expect(memo.sourceId).toBe(ORIGINAL_ID); expect(memo.sourceId).not.toBe("booking-1"); }); it("allows a partial memo with explicit lines instead of copying the original", async () => { const { service } = build(originalInvoice()); const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "Partial credit", lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 200, amount: 200 }], }); expect(memo.totalAmount).toBe(200); }); it("refuses a memo against an invoice never registered with EIMS", async () => { const { service } = build(originalInvoice({ eimsIrn: null })); await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toMatchObject({ response: expect.objectContaining({ code: "EIMS_RELATED_INVOICE_NOT_REGISTERED" }), }); }); it("refuses a memo against a memo", async () => { const { service } = build(originalInvoice({ eimsDocumentType: "CRE" })); await expect(service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "x" })).rejects.toThrow( "cannot issue a memo against a memo", ); }); it("refuses a memo against an EIMS-cancelled invoice", async () => { const { service } = build(originalInvoice({ eimsStatus: "CANCELLED" })); await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toThrow( "cancelled with EIMS", ); }); it("refuses a credit memo whose total exceeds the original", async () => { const { service } = build(originalInvoice({ totalAmount: 1500 })); await expect( service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "too much", lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 2000, amount: 2000 }], }), ).rejects.toThrow(/exceeds/); }); it("does NOT bound a debit memo by the original's total — it is a new charge, not a refund", async () => { const { service } = build(originalInvoice({ totalAmount: 1500 })); const memo = await service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "additional charge", lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 5000, amount: 5000 }], }); expect(memo.totalAmount).toBe(5000); }); it("refuses a blank reason", async () => { const { service } = build(originalInvoice()); await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: " " })).rejects.toThrow( "requires a reason", ); }); }); 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 {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); 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 {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); 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 {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); 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 {} as never, // files { get: () => undefined } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); 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, {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); 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, {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); 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, {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); 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"); }); }); describe("BillingService — CBE bill amounts carry cents, never rounded", () => { // CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the // bill must quote the exact balance. Rounding UP overcharged the payer by up to // a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount // = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches. const invoice = { id: "inv-1", status: Freight.InvoiceStatus.Pending, source: Freight.InvoiceSource.Booking, sourceId: "booking-1", type: "PREPAID", invoiceNumber: "INV-20260101-00001", currency: "ETB", // .43 — cents that must survive all the way to the bill. balanceAmount: 12345.43, totalAmount: 12345.43, company: { name: "Acme PLC" }, paymentId: null, dueAt: null, }; const build = (payment: Record = {}) => { const repo = { findOne: jest.fn().mockResolvedValue(invoice), 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, {} as never, {} as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, repo }; }; it("opens the intent for the exact balance, cents included", async () => { const initiate = jest.fn().mockResolvedValue({ intentId: "intent-1", immediateSuccess: false, response: { intentId: "intent-1", status: "REQUIRES_ACTION" }, }); const { service } = build({ initiate }); await service.payInvoice("inv-1", { method: "CBE_BILL" }); expect(initiate).toHaveBeenCalledWith( expect.objectContaining({ amountMinor: 12345.43 }), ); }); it("quotes the same exact amount on bill-query as payInvoice opened", async () => { const { service } = build(); await expect(service.billQuery("booking-1")).resolves.toMatchObject({ stillPayable: true, currentAmountMinor: 12345.43, }); }); }); describe("BillingService.document", () => { const invoiceRow = (over: Record = {}) => ({ id: "inv-1", invoiceNumber: "INV-20260812-00001", source: "booking", sourceId: "booking-1", status: Freight.InvoiceStatus.Pending, type: "freight", currency: "ETB", subtotalAmount: 100, taxAmount: 0, totalAmount: 100, paidAmount: 0, balanceAmount: 100, issuedAt: new Date(2026, 7, 12), dueAt: new Date(2026, 7, 19), eimsIrn: null, eimsSignedQr: null, company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" }, ...over, }); const build = (invoice: Record) => { const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }); const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") }); const service = new BillingService( {} as never, { findById: jest.fn().mockResolvedValue(invoice) } as never, { findAll: jest.fn().mockResolvedValue([]) } as never, {} as never, {} as never, {} as never, { render, renderThermal } as never, {} as never, { get: (key: string) => key === "eims" ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } : undefined, } as never, // config { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, render, renderThermal }; }; it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => { const { service, render } = build(invoiceRow()); await service.document("inv-1"); const model = render.mock.calls[0][0]; expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined(); expect(model.qrImageUrl).toBeNull(); }); it("shows the buyer's name, TIN and VAT number on every invoice", async () => { const { service, render } = build(invoiceRow()); await service.document("inv-1"); const model = render.mock.calls[0][0]; expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" }); expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" }); expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" }); }); it("omits the VAT row when the buyer company has none", async () => { const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } })); await service.document("inv-1"); const model = render.mock.calls[0][0]; expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined(); }); it("shows EDR's own seller TIN and VAT number from EIMS config", async () => { const { service, render } = build(invoiceRow()); await service.document("inv-1"); const model = render.mock.calls[0][0]; expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" }); expect(model.summary).toContainEqual({ label: "Seller VAT No.", value: "43256663343256663322", }); }); it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => { const { service, render } = build( invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }), ); await service.document("inv-1"); const model = render.mock.calls[0][0]; expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" }); expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); }); it("calls render (not renderThermal) for the default format", async () => { const { service, render, renderThermal } = build(invoiceRow()); jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never); await service.document("inv-1"); expect(render).toHaveBeenCalledTimes(1); expect(renderThermal).not.toHaveBeenCalled(); }); it("calls renderThermal (not render) for format 'thermal'", async () => { const { service, render, renderThermal } = build(invoiceRow()); jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never); await service.document("inv-1", "thermal"); expect(renderThermal).toHaveBeenCalledTimes(1); expect(render).not.toHaveBeenCalled(); }); }); /** * The pay-window guard belongs to the freight invoice. A wagon-cancellation fee * rides source=booking but is raised on an already-PAID booking, so it inherits * a deadline that has long passed — guarding it would make the fee permanently * unsettleable. */ describe("BillingService.confirmOfflinePayment pay-window guard", () => { const PAST = new Date(Date.now() - 86_400_000); function makeService(invoiceType: string) { const invoice = { id: "inv-1", source: Freight.InvoiceSource.Booking, sourceId: "booking-1", type: invoiceType, currency: "ETB", status: Freight.InvoiceStatus.Issued, balanceAmount: 500, }; const recordPayment = jest.fn().mockResolvedValue(invoice); const dataSource = { getRepository: () => ({ findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }), }), }; const service = new BillingService( dataSource as never, { findById: async () => invoice } as never, {} as never, makeEvents() as never, {} as never, {} as never, {} as never, { upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never, { get: () => undefined } as never, { isEnabled: async () => true } as never, ); (service as unknown as { recordPayment: unknown }).recordPayment = recordPayment; return { service, recordPayment }; } const slip = { originalname: "slip.pdf" } as never; it("refuses a freight invoice once the pay window has closed", async () => { const { service } = makeService("PREPAID"); await expect( service.confirmOfflinePayment("inv-1", slip, {}), ).rejects.toThrow(/payment window has closed/i); }); it("settles a wagon-cancellation fee despite the closed window", async () => { const { service, recordPayment } = makeService( WAGON_CANCEL_FEE_INVOICE_TYPE, ); await service.confirmOfflinePayment("inv-1", slip, {}); expect(recordPayment).toHaveBeenCalledWith( "inv-1", expect.objectContaining({ amount: 500, method: "BANK_TRANSFER" }), ); }); it("still requires the bank slip for a cancellation fee", async () => { const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE); await expect( service.confirmOfflinePayment("inv-1", undefined, {}), ).rejects.toThrow(/slip file is required/i); }); });