diff --git a/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.spec.ts b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.spec.ts new file mode 100644 index 000000000..76edd2238 --- /dev/null +++ b/apps/edr-freight-api/src/modules/reports/definitions/receivables-payables.report.spec.ts @@ -0,0 +1,71 @@ +import { WAGON_CANCELLATION_STATUSES } from '../../bookings/entities/booking-wagon-cancellation.entity'; +import { ShippingLineCreditStatus } from '../../shipping-lines/entities/shipping-line-credit.entity'; +import { + CREDIT_LIABILITY_STATUS, + INVOICE_SIDE_EXPR, + LEDGER_SIDES, + UNINVOICED_CREDIT_STATUS, + receivablesPayablesReport, +} from './receivables-payables.report'; + +/** + * The report's whole point is the sign of the money: a cancellation FEE is + * owed TO EDR, and the cancelled freight is owed BACK to the customer as + * bookable credit. These tests pin the two down at the string level — the SQL + * itself is validated against the database, not here. + */ +describe('receivables-payables report', () => { + it('treats exactly one wagon-cancellation status as a liability', () => { + expect(WAGON_CANCELLATION_STATUSES).toContain(CREDIT_LIABILITY_STATUS); + // Every other status owes nothing: nothing cut yet (FEE_PENDING), redeemed + // (REBOOKED), or voided (WITHDRAWN / EXPIRED). If a new status appears, + // this fails until someone decides which side of the ledger it lands on. + expect(WAGON_CANCELLATION_STATUSES.filter((s) => s !== CREDIT_LIABILITY_STATUS).sort()).toEqual( + ['EXPIRED', 'FEE_PENDING', 'REBOOKED', 'WITHDRAWN'], + ); + }); + + it('counts only the shipping-line credit status that has no invoice behind it', () => { + expect(UNINVOICED_CREDIT_STATUS).toBe(ShippingLineCreditStatus.Unbilled); + // BILLED is debt too, but it is counted through its invoice on the invoice + // branch — taking it here as well would double it. + expect(UNINVOICED_CREDIT_STATUS).not.toBe(ShippingLineCreditStatus.Billed); + }); + + it('never classifies the cancellation fee as a payable', () => { + // The fee invoice rides the booking's invoice list; while it is open it is + // an ordinary receivable balance, and it must not reach a PAYABLE arm. + expect(INVOICE_SIDE_EXPR).not.toContain('WAGON_CANCEL_FEE'); + expect(INVOICE_SIDE_EXPR).not.toContain('CANCELLATION_FEE'); + }); + + it('does not double-count a booking already carried by the cancellation ledger', () => { + expect(INVOICE_SIDE_EXPR).toContain('NOT EXISTS'); + expect(INVOICE_SIDE_EXPR).toContain('booking_wagon_cancellations'); + }); + + it('emits exactly the side keys the filter offers', () => { + const declared = LEDGER_SIDES.map((s) => s.value).sort(); + expect(declared).toEqual([ + 'PAYABLE_PREPAID', + 'PAYABLE_WAGON_CREDIT', + 'RECEIVABLE_OPEN', + 'RECEIVABLE_SL_INVOICED', + 'RECEIVABLE_SL_UNBILLED', + ]); + // The summary KPIs split on these prefixes; a key matching neither would + // silently vanish from both totals. + for (const key of declared) { + expect(key.startsWith('RECEIVABLE') || key.startsWith('PAYABLE')).toBe(true); + } + }); + + it('sorts on the union wrapper, never on a branch-local alias', () => { + // The runner appends ORDER BY outside the union subquery, where `i.*`, + // `b.*` and `bwc.*` do not exist. + for (const col of receivablesPayablesReport.columns) { + if (!col.sortExpr) continue; + expect(col.sortExpr).toMatch(/^r\./); + } + }); +});