feat(billing): search invoices by PNR and transaction ref

Both are numbers a customer or a provider support desk quotes back, so they
belong in the free-text box rather than behind a filter pill.

The transaction id and merchant order id are plain ORs — the payment alias is
already joined by every caller of applyInvoiceFilters. The PNR folds into the
existing booking EXISTS block instead of adding a second subquery, so it
inherits that block's correlation and also matches warehouse-, first-mile-
and last-mile-sourced invoices, not just booking-sourced ones.

bk is promoted to alwaysJoin now that the export's scope() references it.
This commit is contained in:
Nathnael
2026-08-24 07:03:08 +00:00
parent 3da00e1f06
commit c3894462e7
3 changed files with 23 additions and 8 deletions

View File

@@ -351,20 +351,24 @@ export class BillingService {
qb.andWhere("invoice.balanceAmount > 0 AND invoice.dueAt < now()");
}
if (filter.search) {
// Searches what the row actually shows: its number, who it bills, and
// the source record behind it (booking reference, GRN, shipping line).
// Searches what the row actually shows: its number, who it bills, the
// source record behind it (booking reference, PNR, GRN, shipping line)
// and the payment references a customer or a provider support desk would
// quote back — the gateway transaction id and our merchant order id.
// The raw `sourceId` stays matchable so a pasted UUID still resolves.
// Requires the `company` alias — every caller of this joins it.
// Requires the `company` and `payment` aliases — every caller joins both.
qb.andWhere(
`(invoice.invoiceNumber ILIKE :search
OR invoice.sourceId ILIKE :search
OR company.name ILIKE :search
OR payment.transactionId ILIKE :search
OR payment.merchantOrderId ILIKE :search
OR EXISTS (
SELECT 1 FROM freight.bookings b
LEFT JOIN freight.warehouse_inventory wi ON wi.booking_id = b.id
LEFT JOIN freight.first_mile fm ON fm.booking_id = b.id
LEFT JOIN freight.last_mile lm ON lm.booking_id = b.id
WHERE b.reference ILIKE :search
WHERE (b.reference ILIKE :search OR b.pnr_code ILIKE :search)
AND (b.id::text = invoice.source_id
OR wi.id::text = invoice.source_id
OR fm.id::text = invoice.source_id

View File

@@ -41,7 +41,7 @@ export const invoicesDataset: ExportDataset = {
// other way throws on those rows.
{ alias: 'bk', entity: Booking, on: "bk.id::text = i.source_id AND i.source = 'booking'" },
],
alwaysJoin: ['c', 'p'],
alwaysJoin: ['c', 'p', 'bk'],
groups: [
{ id: 'invoice', label: 'Invoice' },
@@ -134,7 +134,7 @@ export const invoicesDataset: ExportDataset = {
{ key: 'hasBalance', label: 'Outstanding only', type: 'text' },
{ key: 'overdue', label: 'Overdue only', type: 'text' },
{ key: 'companyId', label: 'Customer', type: 'text' },
{ key: 'search', label: 'Search invoice no. or customer', type: 'text' },
{ key: 'search', label: 'Search invoice no., customer, PNR or transaction ref', type: 'text' },
],
defaultSort: { key: 'issuedAt', dir: 'DESC' },
@@ -171,7 +171,18 @@ export const invoicesDataset: ExportDataset = {
if (params.overdue === 'true') qb.andWhere('i.balance_amount > 0 AND i.due_at < now()');
if (params.companyId) qb.andWhere('i.company_id = :companyId', { companyId: params.companyId });
if (params.search) {
qb.andWhere('(i.invoice_number ILIKE :search OR c.name ILIKE :search)', { search: `%${params.search as string}%` });
// Same reach as the list page's search box, minus the source-record
// lookups it does with correlated subqueries: number, customer, the PNR
// the customer pays against, and the payment references support desks
// quote back.
qb.andWhere(
`(i.invoice_number ILIKE :search
OR c.name ILIKE :search
OR bk.pnr_code ILIKE :search
OR p.transaction_id ILIKE :search
OR p.merchant_order_id ILIKE :search)`,
{ search: `%${params.search as string}%` },
);
}
// ACL: invoices.source_id is a varchar pointer at the originating booking.
applyBookingRefDirectionScope(qb, 'i.source_id', directions);