From 6d113ca00c1e761ca66cc905a0e34afbee528a84 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Fri, 21 Aug 2026 17:47:41 +0300 Subject: [PATCH] fix(reports): classify receivables and payables by real money flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The receivable/payable split contradicted how money actually moves, in three ways that each changed a headline number: - The wagon-cancellation FEE was booked as a payable. It is money the customer owes EDR (raised ISSUED and unpaid at request time), so it belongs on the receivable side while open. The sign was inverted. - A whole-booking wagon cancellation was booked at the source invoice's full paid_amount, and never cleared: the booking stays CANCELLED and the invoice stays PAID even after the credit is rebooked. The real liability is the ledger row's credit_amount, and only while it sits in CREDIT_AVAILABLE — cancellation refunds no cash, it hands back bookable credit redeemed by creating another booking. - Shipping-line debt in UNBILLED has no invoice row at all, so an invoice-only fact table could not see it. That is the un-batched half of the debt, in the report whose stated purpose is shipping-line credit. The report is now a UNION of the three tables that hold the answer: invoices with a balance (plus prepayments against dead bookings), UNBILLED shipping_line_credits, and CREDIT_AVAILABLE booking_wagon_cancellations. A booking already carried by the cancellation ledger is excluded from the invoice branch so its money is counted once. Fully settled invoices are dropped — zero exposure is neither a receivable nor a payable. Branches are re-projected through an explicit column list before being unioned: UNION matches by position and TypeORM does not preserve addSelect order, which silently reordered one branch into "gross, exposure, side_key, ..." and failed with "UNION types text and numeric cannot be matched". Verified against Postgres with a rollback-only fixture covering every side, plus EXPLAIN over each filter combination and every sortable column. --- .../receivables-payables.report.ts | 419 +++++++++++++++--- 1 file changed, 361 insertions(+), 58 deletions(-) diff --git a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts index 018a68faf..0e07710bb 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.ts @@ -1,5 +1,12 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { BookingWagonCancellation } from '../../bookings/entities/booking-wagon-cancellation.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; +import { ShippingLineCredit } from '../../shipping-lines/entities/shipping-line-credit.entity'; +import { directionScopeSql } from '../../user-trade-access/trade-scope.util'; import { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types'; import { PAYER_EXPR, @@ -10,47 +17,287 @@ import { } from '../revenue-classification'; export const LEDGER_SIDES: ReportFilterOption[] = [ - { value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' }, - { value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' }, - { value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' }, - { value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' }, - { value: 'SETTLED', label: 'Settled' }, + { + value: 'RECEIVABLE_SL_UNBILLED', + label: 'Receivable — shipping-line service, not yet invoiced', + }, + { + value: 'RECEIVABLE_SL_INVOICED', + label: 'Receivable — shipping-line invoice open', + }, + { value: 'RECEIVABLE_OPEN', label: 'Receivable — open invoice balance' }, + { + value: 'PAYABLE_WAGON_CREDIT', + label: 'Payable — unapplied wagon-cancellation credit', + }, + { value: 'PAYABLE_PREPAID', label: 'Payable — paid but not delivered' }, ]; /** - * Which side of the ledger an invoice sits on. + * Which side of the ledger a row sits on, and why the report is a union of + * three fact tables rather than a CASE over `invoices`. * - * Receivable = EDR delivered and is owed money — the shipping-line credit - * arrangement, plus any invoice still carrying a balance. - * Payable = the customer paid for something EDR did not deliver, so the money - * is a refund liability rather than revenue: cancellation fees, and prepaid - * invoices whose booking died. + * RECEIVABLE — money EDR is owed. The shipping-line arrangement is service + * first, pay later, and it produces debt in two shapes: a `shipping_line_credits` + * row with NO invoice while it is UNBILLED (a shipping-line booking raises no + * invoice at all), and an open batch invoice once finance bills it. Counting + * only the second understates the debt by everything not yet batched. Ordinary + * open invoice balances are the third shape — including the wagon-cancellation + * FEE, which is money the customer owes EDR, never a liability. + * + * PAYABLE — the customer paid and did not get the service. Wagon cancellation + * never refunds cash: the cancelled freight becomes a rebooking credit that is + * redeemed by creating another booking (see BookingWagonCancellationService). + * So the liability is exactly the cancellations sitting in CREDIT_AVAILABLE — + * fee settled, wagons freed, credit not yet applied — valued at `credit_amount`, + * and it disappears the moment the row turns REBOOKED. The source invoice is + * useless for this: a whole-booking cut leaves it PAID at its full amount + * forever, which is neither the right number nor the right lifetime. + * + * Fully settled invoices are not rows here. A zero-exposure invoice is neither + * a receivable nor a payable; Invoicing Pipeline is the report that lists them. */ -const SIDE_EXPR = `CASE - WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT' - THEN 'RECEIVABLE_CREDIT' - WHEN i.type = 'WAGON_CANCEL_FEE' THEN 'PAYABLE_CANCELLATION' - WHEN i.paid_amount > 0 AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED') - THEN 'PAYABLE_UNDELIVERED' - WHEN i.balance_amount > 0 THEN 'RECEIVABLE_OPEN' - ELSE 'SETTLED' -END`; - const LABELS = new Map(LEDGER_SIDES.map((s) => [s.value, s.label])); -const SIDE_LABEL_EXPR = `CASE ${SIDE_EXPR} - ${[...LABELS].map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`).join('\n ')} + +/** Labels a side key that is already a column — the union is classified inside, labelled outside. */ +const SIDE_LABEL_OF = (keyExpr: string): string => + `CASE ${keyExpr}\n ${[...LABELS] + .map(([value, label]) => `WHEN '${value}' THEN '${label.replace(/'/g, "''")}'`) + .join('\n ')}\nEND`; + +/** + * Statuses that cannot become cash. EXPIRED closed its own pay window and + * REFUNDED already gave the money back, so neither is owed in either + * direction. Filtered here rather than in the shared DEAD_INVOICE_STATUSES — + * that constant feeds every revenue report and those invoices did earn revenue. + */ +const UNCOLLECTABLE_INVOICE_STATUSES = "('EXPIRED', 'REFUNDED')"; + +/** + * A booking whose money is accounted for by the cancellation ledger instead. + * Without this, a whole-booking wagon cancellation would be counted twice: once + * as its own CREDIT_AVAILABLE credit, and again as the source booking's paid + * invoice sitting against a CANCELLED booking — and the second copy would never + * clear, because rebooking updates the ledger row, not the old invoice. + */ +const HAS_CANCELLATION_LEDGER = `EXISTS ( + SELECT 1 FROM freight.booking_wagon_cancellations bwc0 + WHERE bwc0.booking_id = b.id + AND bwc0.deleted_at IS NULL + AND bwc0.status <> 'WITHDRAWN' +)`; + +/** Customer paid, booking died, and no cancellation credit represents it. */ +const PREPAID_DEAD = `i.paid_amount > 0 + AND b.status IN ('CANCELLED', 'REJECTED', 'EXPIRED') + AND NOT ${HAS_CANCELLATION_LEDGER}`; + +export const INVOICE_SIDE_EXPR = `CASE + WHEN i.source = 'shipping_line_credit' OR i.type = 'SHIPPING_LINE_CREDIT' + THEN 'RECEIVABLE_SL_INVOICED' + WHEN ${PREPAID_DEAD} THEN 'PAYABLE_PREPAID' + ELSE 'RECEIVABLE_OPEN' END`; -/** Money at stake on this row: what is owed, or what may have to be given back. */ -const EXPOSURE = `CASE - WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount - ELSE i.balance_amount -END`; +/** + * The union's column contract, in positional order. + * + * UNION matches by POSITION, and TypeORM does not preserve `addSelect` order — + * it hoists a branch's repeated expressions to the front, which silently + * rearranged one branch into `gross, exposure, side_key, …` and failed with + * "UNION types text and numeric cannot be matched". Every branch is therefore + * re-projected through this list by name before it is unioned. + */ +const UNION_COLUMNS = [ + 'side_key', + 'txn_date', + 'doc_ref', + 'booking_ref', + 'booking_status', + 'payer', + 'gross', + 'settled', + 'exposure', +] as const; +/** + * The one wagon-cancellation status that is a live liability: the fee is + * settled and the booking cut, but the credit has not been turned into a + * booking yet. FEE_PENDING has cut nothing, REBOOKED has been redeemed, and + * WITHDRAWN/EXPIRED owe nothing. + */ +export const CREDIT_LIABILITY_STATUS = 'CREDIT_AVAILABLE'; + +/** + * Shipping-line credit status that is debt with no invoice behind it. BILLED + * credits are counted through their invoice on branch A, which is what keeps + * the two shipping-line sides disjoint. + */ +export const UNINVOICED_CREDIT_STATUS = 'UNBILLED'; + +/** Applies the filters branches B and C share with {@link invoiceLedgerQb}. */ +function applySharedFilters( + qb: SelectQueryBuilder, + ctx: ReportContext, + dateExpr: string, +): SelectQueryBuilder { + const { params, directions } = ctx; + + if (params.dateFrom) qb.andWhere(`${dateExpr} >= :dateFrom`, { dateFrom: params.dateFrom }); + if (params.dateTo) qb.andWhere(`${dateExpr} < :dateTo`, { dateTo: params.dateTo }); + if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin }); + if (params.destination) { + qb.andWhere('dy.code = :destination', { destination: params.destination }); + } + if (params.customer) { + qb.andWhere( + '(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)', + { customer: `%${params.customer as string}%` }, + ); + } + + // An umbrella general contract is paid once and drawn down by many orders — + // same exclusion invoiceLedgerQb applies on branch A. + qb.andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"); + + // Both branches reach their booking directly, so the direction scope is the + // plain column form, not the source_id-pointer form invoices need. A row + // whose booking is gone carries no direction to scope by and stays visible — + // the same rule applyBookingRefDirectionScope applies on branch A. + const scope = directionScopeSql('b.trade_direction', directions); + qb.andWhere(`(b.id IS NULL OR ${scope.sql})`, scope.params); + + return qb; +} + +/** Branch A — invoices carrying a balance, plus prepayments against dead bookings. */ +function invoiceBranch(ctx: ReportContext): SelectQueryBuilder { + return invoiceLedgerQb(ctx) + .andWhere(`i.status NOT IN ${UNCOLLECTABLE_INVOICE_STATUSES}`) + .andWhere(`(i.balance_amount > 0 OR (${PREPAID_DEAD}))`) + .select(INVOICE_SIDE_EXPR, 'side_key') + .addSelect(REVENUE_DATE, 'txn_date') + .addSelect('i.invoice_number', 'doc_ref') + .addSelect("COALESCE(b.reference, '—')", 'booking_ref') + .addSelect("COALESCE(b.status, '—')", 'booking_status') + .addSelect(PAYER_EXPR, 'payer') + .addSelect('i.total_amount', 'gross') + .addSelect('i.paid_amount', 'settled') + .addSelect( + `CASE WHEN ${PREPAID_DEAD} THEN i.paid_amount ELSE i.balance_amount END`, + 'exposure', + ); +} + +/** + * Branch B — shipping-line services used but never invoiced. + * + * The credit row IS the debt while it is UNBILLED; BILLED rows are the ones + * behind an invoice and are already counted by branch A, so taking only + * UNBILLED here is what keeps the two shipping-line sides disjoint. + */ +function unbilledCreditBranch(ctx: ReportContext): SelectQueryBuilder { + const qb = ctx.ds + .createQueryBuilder() + .from(ShippingLineCredit, 'slc_c') + .leftJoin(Booking, 'b', 'b.id = slc_c.booking_id AND b.deleted_at IS NULL') + .leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id') + .leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id') + .leftJoin(Company, 'co', 'co.id = b.company_id') + .leftJoin(ShippingLineCompany, 'slc', 'slc.id = slc_c.shipping_line_company_id') + .where('slc_c.deleted_at IS NULL') + .andWhere('slc_c.status = :uninvoicedCreditStatus', { + uninvoicedCreditStatus: UNINVOICED_CREDIT_STATUS, + }) + .andWhere('slc_c.currency = :currency', { + currency: currencyOf(ctx.params), + }); + + // Priced when the service was used; that is the date the debt was incurred. + applySharedFilters(qb, ctx, 'slc_c.created_at'); + + return qb + .select("'RECEIVABLE_SL_UNBILLED'", 'side_key') + .addSelect('slc_c.created_at', 'txn_date') + .addSelect("'—'", 'doc_ref') + .addSelect("COALESCE(b.reference, '—')", 'booking_ref') + .addSelect("COALESCE(b.status, '—')", 'booking_status') + .addSelect("COALESCE(slc.name, 'Unknown')", 'payer') + .addSelect('slc_c.amount', 'gross') + .addSelect('0::numeric', 'settled') + .addSelect('slc_c.amount', 'exposure'); +} + +/** + * Branch C — cancelled wagons whose credit has not been rebooked. + * + * `credit_amount` is priced in the BOOKING's payment currency, not + * `fee_currency` — that one prices the cancellation fee, which is a separate + * (and opposite-signed) piece of money. + */ +function wagonCreditBranch(ctx: ReportContext): SelectQueryBuilder { + const qb = ctx.ds + .createQueryBuilder() + .from(BookingWagonCancellation, 'bwc') + .innerJoin(Booking, 'b', 'b.id = bwc.booking_id AND b.deleted_at IS NULL') + .leftJoin(Yard, 'oy', 'oy.id = b.origin_yard_id') + .leftJoin(Yard, 'dy', 'dy.id = b.destination_yard_id') + .leftJoin(Company, 'co', 'co.id = b.company_id') + .leftJoin(ShippingLineCompany, 'slc', 'slc.id = b.shipping_line_company_id') + .where('bwc.deleted_at IS NULL') + .andWhere('bwc.status = :creditLiabilityStatus', { + creditLiabilityStatus: CREDIT_LIABILITY_STATUS, + }) + .andWhere("COALESCE(b.payment_currency, 'ETB') = :currency", { + currency: currencyOf(ctx.params), + }); + + // The credit exists from the moment the fee settled and the booking was cut. + applySharedFilters(qb, ctx, 'COALESCE(bwc.fee_paid_at, bwc.created_at)'); + + return ( + qb + .select("'PAYABLE_WAGON_CREDIT'", 'side_key') + .addSelect('COALESCE(bwc.fee_paid_at, bwc.created_at)', 'txn_date') + // numeric(6,2) renders as "2.00"; a wagon count reads as "2" (and "2.5" + // survives, because a half wagon is a real bulk quantity here). + .addSelect( + `rtrim(rtrim(bwc.wagons_cancelled::text, '0'), '.') || ' wagon(s) cancelled'`, + 'doc_ref', + ) + .addSelect("COALESCE(b.reference, '—')", 'booking_ref') + .addSelect("COALESCE(b.status, '—')", 'booking_status') + .addSelect(PAYER_EXPR, 'payer') + // The freight was paid in full on the original booking, so the whole + // credit is money already in hand and owed back as bookable value. + .addSelect('bwc.credit_amount', 'gross') + .addSelect('bwc.credit_amount', 'settled') + .addSelect('bwc.credit_amount', 'exposure') + ); +} + +/** + * The three branches as one relation, wrapped so the runner can sort, page and + * COUNT(*) it like any other report query. + * + * Parameters are merged from every branch: `getQuery()` leaves `:name` + * placeholders in place, and only the outer builder's parameter bag is read + * when the SQL is finally bound. + */ function baseQuery(ctx: ReportContext): SelectQueryBuilder { - const qb = invoiceLedgerQb(ctx); + const branches = [invoiceBranch(ctx), unbilledCreditBranch(ctx), wagonCreditBranch(ctx)]; + const combined = branches + .map((b, idx) => `SELECT ${UNION_COLUMNS.join(', ')} FROM (${b.getQuery()}) branch_${idx}`) + .join('\n UNION ALL\n '); + + const qb = ctx.ds + .createQueryBuilder() + .from(`(${combined})`, 'r') + .setParameters(Object.assign({}, ...branches.map((b) => b.getParameters()))); + const sides = ctx.params.sides as string[] | null; - if (sides?.length) qb.andWhere(`${SIDE_EXPR} IN (:...sides)`, { sides }); + if (sides?.length) qb.andWhere('r.side_key IN (:...sides)', { sides }); + return qb; } @@ -58,57 +305,113 @@ export const receivablesPayablesReport: ReportDefinition = { key: 'receivables-payables', title: 'Receivables and Payables', description: - 'Splits customer money two ways: receivable, where EDR delivered and is owed — ' + - 'including shipping-line credit services — and payable, where the customer paid but ' + - 'the service was not delivered, such as cancellation fees and prepayments against ' + - 'dead bookings. Payable amounts are a refund liability, not revenue.', + 'Splits open customer money two ways: receivable, where EDR delivered and is owed — ' + + 'shipping-line credit services whether invoiced yet or not, plus any invoice still ' + + 'carrying a balance — and payable, where the customer paid and the service was not ' + + 'delivered. The payable is dominated by wagon cancellations whose credit has not been ' + + 'rebooked; that credit is redeemed by creating another booking, never refunded in cash.', group: 'Finance', filters: [ ...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'), - { key: 'sides', label: 'Ledger side', type: 'multiselect', options: LEDGER_SIDES }, + { + key: 'sides', + label: 'Ledger side', + type: 'multiselect', + options: LEDGER_SIDES, + }, ], columns: [ - { key: 'side', label: 'Ledger side', type: 'string', sortable: true, sortExpr: SIDE_EXPR }, - { key: 'issuedAt', label: 'Issued', type: 'date', sortable: true, sortExpr: REVENUE_DATE }, - { key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, + { + key: 'side', + label: 'Ledger side', + type: 'string', + sortable: true, + sortExpr: 'r.side_key', + }, + { + key: 'issuedAt', + label: 'Date', + type: 'date', + sortable: true, + sortExpr: 'r.txn_date', + }, + { + key: 'invoiceNumber', + label: 'Invoice / ref', + type: 'string', + sortable: true, + sortExpr: 'r.doc_ref', + }, { key: 'bookingRef', label: 'Booking', type: 'string' }, { key: 'bookingStatus', label: 'Booking status', type: 'string' }, - { key: 'customer', label: 'Payer', type: 'string', sortable: true, sortExpr: PAYER_EXPR }, - { key: 'invoiced', label: 'Invoiced', type: 'money', sortable: true, sortExpr: 'i.total_amount' }, - { key: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' }, - { key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE }, + { + key: 'customer', + label: 'Payer', + type: 'string', + sortable: true, + sortExpr: 'r.payer', + }, + { + key: 'invoiced', + label: 'Amount', + type: 'money', + sortable: true, + sortExpr: 'r.gross', + }, + { + key: 'paid', + label: 'Paid', + type: 'money', + sortable: true, + sortExpr: 'r.settled', + }, + { + key: 'exposure', + label: 'Owed / refundable', + type: 'money', + sortable: true, + sortExpr: 'r.exposure', + }, ], defaultSort: { key: 'exposure', dir: 'DESC' }, chart: { type: 'bar', x: 'side', y: ['exposure'] }, query(ctx) { return baseQuery(ctx) - .select(SIDE_LABEL_EXPR, 'side') - .addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') - .addSelect('i.invoice_number', 'invoiceNumber') - .addSelect("COALESCE(b.reference, '—')", 'bookingRef') - .addSelect("COALESCE(b.status, '—')", 'bookingStatus') - .addSelect(PAYER_EXPR, 'customer') - .addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced') - .addSelect('ROUND(i.paid_amount, 2)::float8', 'paid') - .addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure'); + .select(SIDE_LABEL_OF('r.side_key'), 'side') + .addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt') + .addSelect('r.doc_ref', 'invoiceNumber') + .addSelect('r.booking_ref', 'bookingRef') + .addSelect('r.booking_status', 'bookingStatus') + .addSelect('r.payer', 'customer') + .addSelect('ROUND(r.gross, 2)::float8', 'invoiced') + .addSelect('ROUND(r.settled, 2)::float8', 'paid') + .addSelect('ROUND(r.exposure, 2)::float8', 'exposure'); }, async summary(ctx) { const row = await baseQuery(ctx) .select( - `ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'RECEIVABLE%'), 0))::float8`, + "ROUND(COALESCE(SUM(r.exposure) FILTER (WHERE r.side_key LIKE 'RECEIVABLE%'), 0))::float8", 'receivable', ) .addSelect( - `ROUND(COALESCE(SUM(${EXPOSURE}) FILTER (WHERE ${SIDE_EXPR} LIKE 'PAYABLE%'), 0))::float8`, + "ROUND(COALESCE(SUM(r.exposure) FILTER (WHERE r.side_key LIKE 'PAYABLE%'), 0))::float8", 'payable', ) - .addSelect('COUNT(*)::int', 'invoices') - .getRawOne<{ receivable: number; payable: number; invoices: number }>(); + .addSelect('COUNT(*)::int', 'items') + .getRawOne<{ receivable: number; payable: number; items: number }>(); + + const receivable = Number(row?.receivable ?? 0); + const payable = Number(row?.payable ?? 0); const currency = currencyOf(ctx.params); return [ - { label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency }, - { label: 'Payable', value: Number(row?.payable ?? 0), unit: currency }, - { label: 'Invoices', value: Number(row?.invoices ?? 0) }, + { label: 'Receivable', value: receivable, unit: currency }, + { label: 'Payable', value: payable, unit: currency }, + { + label: 'Net position', + value: Math.round(receivable - payable), + unit: currency, + }, + { label: 'Open items', value: Number(row?.items ?? 0) }, ]; }, };