feat(billing): show the booking PNR on an invoice

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.
This commit is contained in:
Nathnael
2026-08-24 07:02:46 +00:00
parent 018505d6fa
commit 3da00e1f06
2 changed files with 18 additions and 0 deletions

View File

@@ -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',

View File

@@ -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",