From 3da00e1f069e44f9d8086978a19daf48b27f4a63 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 24 Aug 2026 07:02:46 +0000 Subject: [PATCH] feat(billing): show the booking PNR on an invoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PNR is the CBE_BILL reference the customer actually pays against, but it is stamped onto the booking at payment-initiation time — it is a column on neither the invoice nor the payment. Both surfaces read it back by source id, the same lookup the sealed invoice PDF already did, so screen, export and document now agree. The export's join casts bk.id::text rather than i.source_id::uuid: source_id is a bare varchar pointer that is not always a UUID (EIMS self-test rows carry a slug), and casting that direction throws on those rows. --- .../src/modules/exports/datasets/invoices.dataset.ts | 9 +++++++++ .../backoffice/src/pages/invoices/InvoiceDetailPage.tsx | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 3b897ef2d..59d6db4ca 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -2,6 +2,7 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; import { Invoice } from '../../billing/entities/invoice.entity'; import { invoicePaymentMethodExpr } from '../../billing/invoice-settlement.util'; import { PaymentEntity } from '../../payment/entities/payment.entity'; +import { Booking } from '../../bookings/entities/booking.entity'; import { Company } from '../../companies/entities/company.entity'; import { CompanyProfile } from '../../companies/entities/company-profile.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; @@ -34,6 +35,11 @@ export const invoicesDataset: ExportDataset = { // The gateway payment behind the invoice — provider method and its // transaction reference. Always joined: `scope()` filters on it. { alias: 'p', entity: PaymentEntity, on: 'p.id = i.payment_id' }, + // Booking behind the invoice, for the PNR alone. `i.source_id` is a bare + // varchar pointer that is not always a UUID (EIMS self-test rows carry a + // slug), so the cast goes on `bk.id`, never on `source_id` — casting the + // other way throws on those rows. + { alias: 'bk', entity: Booking, on: "bk.id::text = i.source_id AND i.source = 'booking'" }, ], alwaysJoin: ['c', 'p'], @@ -76,6 +82,9 @@ export const invoicesDataset: ExportDataset = { { key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` }, { key: 'paymentMethod', label: 'Payment method', type: 'string', group: 'payment', default: true, requires: ['p'], select: PAYMENT_METHOD, sortExpr: PAYMENT_METHOD }, { key: 'transactionRef', label: 'Transaction ref', type: 'string', group: 'payment', requires: ['p'], select: 'p.transaction_id' }, + // The CBE_BILL reference the customer pays against — stamped onto the + // booking at payment-initiation time, not held on the invoice or payment. + { key: 'pnrCode', label: 'PNR', type: 'string', group: 'payment', requires: ['bk'], select: 'bk.pnr_code' }, { key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', requires: ['p'], select: 'p.status::text' }, { key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment', diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx index 0fa911485..def389687 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx @@ -145,6 +145,14 @@ function RecipientCard({ invoice }: { invoice: Invoice }) { function PaymentCard({ invoice }: { invoice: Invoice }) { const method = invoicePaymentMethod(invoice); const lastEntry = invoice.payments?.at(-1); + // The PNR is the CBE_BILL reference the customer pays against. It is stamped + // onto the BOOKING at payment-initiation time, not onto the invoice or the + // payment, so it has to be read back from there — same lookup the sealed PDF + // does. Shares react-query's cache with `SourceCard`, so this costs no + // second request. + const { data: booking } = useBookingDetail( + invoice.source === "booking" ? invoice.sourceId : undefined, + ); if (!method && !lastEntry) return null; // The gateway's own reference first; `merchantOrderId` is our order id, which @@ -163,6 +171,7 @@ function PaymentCard({ invoice }: { invoice: Invoice }) { name={method ? paymentMethodLabel(method) : "Settled"} rows={[ { label: "Transaction ref", value: transactionRef }, + { label: "PNR", value: booking?.pnrCode ?? undefined }, { label: "Provider status", value: invoice.payment?.status }, { label: "Paid",