fix(reports): classify receivables and payables by real money flow

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.
This commit is contained in:
ghost2023
2026-08-21 17:47:41 +03:00
parent 23b3f265e6
commit 6d113ca00c

View File

@@ -1,5 +1,12 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; 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 { ReportContext, ReportDefinition, ReportFilterOption } from '../report.types';
import { import {
PAYER_EXPR, PAYER_EXPR,
@@ -10,47 +17,287 @@ import {
} from '../revenue-classification'; } from '../revenue-classification';
export const LEDGER_SIDES: ReportFilterOption[] = [ export const LEDGER_SIDES: ReportFilterOption[] = [
{ value: 'RECEIVABLE_CREDIT', label: 'Receivable — credit service (shipping line)' }, {
{ value: 'RECEIVABLE_OPEN', label: 'Receivable — open balance' }, value: 'RECEIVABLE_SL_UNBILLED',
{ value: 'PAYABLE_CANCELLATION', label: 'Payable — cancellation fee' }, label: 'Receivable — shipping-line service, not yet invoiced',
{ value: 'PAYABLE_UNDELIVERED', label: 'Payable — paid but not delivered' }, },
{ value: 'SETTLED', label: 'Settled' }, {
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 * RECEIVABLE — money EDR is owed. The shipping-line arrangement is service
* arrangement, plus any invoice still carrying a balance. * first, pay later, and it produces debt in two shapes: a `shipping_line_credits`
* Payable = the customer paid for something EDR did not deliver, so the money * row with NO invoice while it is UNBILLED (a shipping-line booking raises no
* is a refund liability rather than revenue: cancellation fees, and prepaid * invoice at all), and an open batch invoice once finance bills it. Counting
* invoices whose booking died. * 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 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`; END`;
/** Money at stake on this row: what is owed, or what may have to be given back. */ /**
const EXPOSURE = `CASE * The union's column contract, in positional order.
WHEN ${SIDE_EXPR} LIKE 'PAYABLE%' THEN i.paid_amount *
ELSE i.balance_amount * UNION matches by POSITION, and TypeORM does not preserve `addSelect` order —
END`; * 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<ObjectLiteral>,
ctx: ReportContext,
dateExpr: string,
): SelectQueryBuilder<ObjectLiteral> {
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<ObjectLiteral> {
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<ObjectLiteral> {
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<ObjectLiteral> {
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<ObjectLiteral> { function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
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; 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; return qb;
} }
@@ -58,57 +305,113 @@ export const receivablesPayablesReport: ReportDefinition = {
key: 'receivables-payables', key: 'receivables-payables',
title: 'Receivables and Payables', title: 'Receivables and Payables',
description: description:
'Splits customer money two ways: receivable, where EDR delivered and is owed — ' + 'Splits open customer money two ways: receivable, where EDR delivered and is owed — ' +
'including shipping-line credit services — and payable, where the customer paid but ' + 'shipping-line credit services whether invoiced yet or not, plus any invoice still ' +
'the service was not delivered, such as cancellation fees and prepayments against ' + 'carrying a balance — and payable, where the customer paid and the service was not ' +
'dead bookings. Payable amounts are a refund liability, not revenue.', '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', group: 'Finance',
filters: [ filters: [
...REVENUE_FILTERS.filter((f) => f.key !== 'categories' && f.key !== 'methods'), ...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: [ 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: 'side',
{ key: 'invoiceNumber', label: 'Invoice No.', type: 'string', sortable: true, sortExpr: 'i.invoice_number' }, 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: 'bookingRef', label: 'Booking', type: 'string' },
{ key: 'bookingStatus', label: 'Booking status', 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: 'customer',
{ key: 'paid', label: 'Paid', type: 'money', sortable: true, sortExpr: 'i.paid_amount' }, label: 'Payer',
{ key: 'exposure', label: 'Owed / refundable', type: 'money', sortable: true, sortExpr: EXPOSURE }, 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' }, defaultSort: { key: 'exposure', dir: 'DESC' },
chart: { type: 'bar', x: 'side', y: ['exposure'] }, chart: { type: 'bar', x: 'side', y: ['exposure'] },
query(ctx) { query(ctx) {
return baseQuery(ctx) return baseQuery(ctx)
.select(SIDE_LABEL_EXPR, 'side') .select(SIDE_LABEL_OF('r.side_key'), 'side')
.addSelect(`to_char(${REVENUE_DATE}, 'YYYY-MM-DD')`, 'issuedAt') .addSelect("to_char(r.txn_date, 'YYYY-MM-DD')", 'issuedAt')
.addSelect('i.invoice_number', 'invoiceNumber') .addSelect('r.doc_ref', 'invoiceNumber')
.addSelect("COALESCE(b.reference, '—')", 'bookingRef') .addSelect('r.booking_ref', 'bookingRef')
.addSelect("COALESCE(b.status, '—')", 'bookingStatus') .addSelect('r.booking_status', 'bookingStatus')
.addSelect(PAYER_EXPR, 'customer') .addSelect('r.payer', 'customer')
.addSelect('ROUND(i.total_amount, 2)::float8', 'invoiced') .addSelect('ROUND(r.gross, 2)::float8', 'invoiced')
.addSelect('ROUND(i.paid_amount, 2)::float8', 'paid') .addSelect('ROUND(r.settled, 2)::float8', 'paid')
.addSelect(`ROUND(${EXPOSURE}, 2)::float8`, 'exposure'); .addSelect('ROUND(r.exposure, 2)::float8', 'exposure');
}, },
async summary(ctx) { async summary(ctx) {
const row = await baseQuery(ctx) const row = await baseQuery(ctx)
.select( .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', 'receivable',
) )
.addSelect( .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', 'payable',
) )
.addSelect('COUNT(*)::int', 'invoices') .addSelect('COUNT(*)::int', 'items')
.getRawOne<{ receivable: number; payable: number; invoices: number }>(); .getRawOne<{ receivable: number; payable: number; items: number }>();
const receivable = Number(row?.receivable ?? 0);
const payable = Number(row?.payable ?? 0);
const currency = currencyOf(ctx.params); const currency = currencyOf(ctx.params);
return [ return [
{ label: 'Receivable', value: Number(row?.receivable ?? 0), unit: currency }, { label: 'Receivable', value: receivable, unit: currency },
{ label: 'Payable', value: Number(row?.payable ?? 0), unit: currency }, { label: 'Payable', value: payable, unit: currency },
{ label: 'Invoices', value: Number(row?.invoices ?? 0) }, {
label: 'Net position',
value: Math.round(receivable - payable),
unit: currency,
},
{ label: 'Open items', value: Number(row?.items ?? 0) },
]; ];
}, },
}; };