From 63bd9197c5ac5730e4397049779b17ad05644884 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 27 Aug 2026 11:56:10 +0000 Subject: [PATCH] fix: add platofmr transactions to the invoice pdf --- .../modules/billing/billing.service.spec.ts | 34 ++++++++++++- .../src/modules/billing/billing.service.ts | 10 ++++ .../documents/invoice-document.service.ts | 6 ++- .../billing/invoice-settlement.util.spec.ts | 50 +++++++++++++++++++ .../billing/invoice-settlement.util.ts | 41 +++++++++++++++ .../warehouses/warehouse-invoice.service.ts | 11 ++++ 6 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index a3ed3e417..f07e6d50f 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -939,8 +939,12 @@ describe("BillingService.document", () => { 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("") }); + // `toDocumentModel` reads the booking (route/wagons, PNR) straight off the + // data source for a booking-sourced invoice — a stub that answers "no such + // booking" keeps these summary assertions about the invoice itself. + const dataSource = { getRepository: () => ({ findOne: jest.fn().mockResolvedValue(null) }) }; const service = new BillingService( - {} as never, + dataSource as never, { findById: jest.fn().mockResolvedValue(invoice) } as never, { findAll: jest.fn().mockResolvedValue([]) } as never, {} as never, @@ -1014,6 +1018,34 @@ describe("BillingService.document", () => { expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); }); + it("prints the provider transaction reference of a settled invoice", async () => { + const { service, render } = build( + invoiceRow({ + status: Freight.InvoiceStatus.Paid, + paidAmount: 100, + balanceAmount: 0, + payments: [{ amount: 100, method: "GATEWAY", reference: "FT26082700123", paidAt: "2026-08-27T09:00:00.000Z" }], + payment: { transactionId: "FT26082700123" }, + }), + ); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Transaction ref", value: "FT26082700123" }); + }); + + it("adds no transaction reference row to an unpaid 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 === "Transaction ref"), + ).toBeUndefined(); + }); + 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); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 9dc6e15e9..42334644d 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -42,6 +42,7 @@ import { applySettlement, invoicePaymentMethodExpr, round2, + settlementReferences, } from "./invoice-settlement.util"; import { InvoiceRepository } from "./invoice.repository"; @@ -925,6 +926,15 @@ export class BillingService { // MoR EIMS reference — only once actually registered, never a placeholder row. if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn }); + // The provider's transaction number for the money actually received — CBE's `FT…`, + // telebirr's receipt number, or the bank-slip reference a teller recorded manually. + // It is what a payer holding a receipt can match this invoice against, and what + // finance reconciles a bank statement with; without it a PAID invoice proves only + // that EDR says it was paid. `findById` already loads the `payment` relation, so both + // sources are in hand here — see settlementReferences for why both are read. + const txnRefs = settlementReferences(invoice); + if (txnRefs) summary.push({ label: "Transaction ref", value: txnRefs }); + // PNR — the CBE_BILL reference the customer pays against, written onto the booking at // payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so // look it up by source id; only shown once a payment actually generated one. diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index 78cea01b6..d85facc0f 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -337,7 +337,11 @@ export class InvoiceDocumentService { let y = 700; const colX = [36, 300]; const colW = 250; - model.summary.slice(0, 16).forEach((row, i) => { + // 20, not 16: a booking invoice already fills 16 rows with every optional one present + // (buyer trade name, buyer VAT, seller TIN/VAT, IRN, PNR) and the transaction ref is the + // 17th — the old cap silently dropped whichever row landed last. Still fits: 20 rows end + // at y=423, leaving the line-item table its full run down to the y<190 cut-off. + model.summary.slice(0, 20).forEach((row, i) => { const x = colX[i % 2]; if (i % 2 === 0 && i > 0) y -= 27; ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray)); diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts new file mode 100644 index 000000000..2d10d6f4d --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts @@ -0,0 +1,50 @@ +import { settlementReferences } from "./invoice-settlement.util"; + +describe("settlementReferences", () => { + it("returns the provider reference recorded on the invoice ledger", () => { + expect( + settlementReferences({ + payments: [{ reference: "FT26082700123" }], + }), + ).toBe("FT26082700123"); + }); + + it("reads the linked gateway payment row when the ledger has no reference", () => { + expect( + settlementReferences({ + payments: [{ reference: null }], + payment: { transactionId: "TB998877" }, + }), + ).toBe("TB998877"); + }); + + it("does not repeat a reference that both sources carry", () => { + expect( + settlementReferences({ + payments: [{ reference: "FT26082700123" }], + payment: { transactionId: "FT26082700123" }, + }), + ).toBe("FT26082700123"); + }); + + it("lists every leg of a partially-then-fully paid invoice, oldest first", () => { + expect( + settlementReferences({ + payments: [{ reference: "SLIP-001" }, { reference: "FT26082700123" }], + }), + ).toBe("SLIP-001, FT26082700123"); + }); + + it("drops the internal intent id the gateway path falls back to", () => { + expect( + settlementReferences({ + payments: [{ reference: "3f8a1c2e-9b4d-4a71-8c6e-2d5f7a9b1c30" }], + }), + ).toBeNull(); + }); + + it("is null for an unpaid invoice", () => { + expect(settlementReferences({ payments: [] })).toBeNull(); + expect(settlementReferences({})).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts index 172e74c17..994208229 100644 --- a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -74,3 +74,44 @@ export const INVOICE_PAYMENT_METHODS = [ /** Settled at a gateway whose provider row is no longer linked. */ "GATEWAY", ] as const; + +/** Anything shaped enough to read settlement references off. */ +interface SettlementReferenceSource { + payments?: Array<{ reference?: string | null }> | null; + payment?: { transactionId?: string | null } | null; +} + +/** + * A settlement reference is the PROVIDER's own transaction number, never ours. + * The gateway path falls back to the intent id when a provider returns no txn + * ref (`markInvoiceAsPaid`: `providerTxnId ?? paymentId`), and that id is a + * uuid — an internal correlation key that means nothing to a payer holding a + * bank slip, so it is dropped rather than printed. No provider's reference is + * uuid-shaped: CBE sends `FT…`, telebirr/ebirr/waafi send digit strings. + */ +const INTERNAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Every provider transaction reference recorded against an invoice, oldest + * first, joined for display — CBE's `FT…`, telebirr's receipt number, or the + * bank-slip number a teller typed into a manual settlement. Null when nothing + * identifiable was recorded. + * + * Reads BOTH sources because neither alone is complete: the invoice's own + * ledger is the only record of manual settlements and of each leg of a + * partially-paid invoice, while the linked `freight.payments` row is the only + * place a provider txn id lands when it arrives after settlement (a webhook + * that stamps `transactionId` on an already-settled intent). Deduped, since + * the ordinary gateway path writes the same value to both. + */ +export function settlementReferences( + invoice: SettlementReferenceSource, +): string | null { + const refs = [ + ...(invoice.payments ?? []).map((p) => p.reference), + invoice.payment?.transactionId, + ].filter( + (ref): ref is string => Boolean(ref) && !INTERNAL_ID.test(ref as string), + ); + return [...new Set(refs)].join(", ") || null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index e29eba2c2..ba34c61bf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -20,6 +20,7 @@ import { Invoice } from "../billing/entities/invoice.entity"; import { InvoiceLine } from "../billing/entities/invoice-line.entity"; import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto"; +import { settlementReferences } from "../billing/invoice-settlement.util"; import { InvoiceDocumentModel, sameCompanyName, @@ -785,6 +786,16 @@ export class WarehouseInvoiceService { ? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}` : null, }, + // The provider's own transaction number (CBE `FT…`, telebirr receipt no., a + // teller's bank-slip ref) — the row above says only HOW and WHEN it was paid, + // which nobody can reconcile a bank statement against. The warehouse view + // projects the invoice ledger but not the linked gateway `payments` row, so the + // ledger is the only source here; it carries the provider ref on every path + // that has one. + { + label: "Transaction ref", + value: settlementReferences({ payments: invoice.payments }), + }, ], categoryHeader: "Fee type", lines: invoice.items.map((item) => ({