diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index a3ed3e417..f07e6d50f 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -939,8 +939,12 @@ describe("BillingService.document", () => { const build = (invoice: Record) => { const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") }); const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") }); + // `toDocumentModel` reads the booking (route/wagons, PNR) straight off the + // data source for a booking-sourced invoice — a stub that answers "no such + // booking" keeps these summary assertions about the invoice itself. + const dataSource = { getRepository: () => ({ findOne: jest.fn().mockResolvedValue(null) }) }; const service = new BillingService( - {} as never, + dataSource as never, { findById: jest.fn().mockResolvedValue(invoice) } as never, { findAll: jest.fn().mockResolvedValue([]) } as never, {} as never, @@ -1014,6 +1018,34 @@ describe("BillingService.document", () => { expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload"); }); + it("prints the provider transaction reference of a settled invoice", async () => { + const { service, render } = build( + invoiceRow({ + status: Freight.InvoiceStatus.Paid, + paidAmount: 100, + balanceAmount: 0, + payments: [{ amount: 100, method: "GATEWAY", reference: "FT26082700123", paidAt: "2026-08-27T09:00:00.000Z" }], + payment: { transactionId: "FT26082700123" }, + }), + ); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect(model.summary).toContainEqual({ label: "Transaction ref", value: "FT26082700123" }); + }); + + it("adds no transaction reference row to an unpaid invoice", async () => { + const { service, render } = build(invoiceRow()); + + await service.document("inv-1"); + + const model = render.mock.calls[0][0]; + expect( + model.summary.find((r: { label: string }) => r.label === "Transaction ref"), + ).toBeUndefined(); + }); + it("calls render (not renderThermal) for the default format", async () => { const { service, render, renderThermal } = build(invoiceRow()); jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 9dc6e15e9..cf41998ba 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -42,6 +42,7 @@ import { applySettlement, invoicePaymentMethodExpr, round2, + settlementReferences, } from "./invoice-settlement.util"; import { InvoiceRepository } from "./invoice.repository"; @@ -268,7 +269,7 @@ export class BillingService { private readonly files: FilesService, private readonly config: ConfigService, private readonly manualPaymentSettings: ManualPaymentSettingsService, - ) { } + ) {} // ── Reads ────────────────────────────────────────────────────────────────── @@ -301,7 +302,9 @@ export class BillingService { }); } if (filter.sources?.length) { - qb.andWhere("invoice.source IN (:...sources)", { sources: filter.sources }); + qb.andWhere("invoice.source IN (:...sources)", { + sources: filter.sources, + }); } if (filter.eimsStatuses?.length) { qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", { @@ -327,7 +330,9 @@ export class BillingService { }); } if (filter.issuedTo) { - qb.andWhere("invoice.issuedAt <= :issuedTo", { issuedTo: filter.issuedTo }); + qb.andWhere("invoice.issuedAt <= :issuedTo", { + issuedTo: filter.issuedTo, + }); } if (filter.dueFrom) { qb.andWhere("invoice.dueAt >= :dueFrom", { dueFrom: filter.dueFrom }); @@ -590,21 +595,28 @@ export class BillingService { /** * Finance's manual-settlement worklist: USD invoices (paid by bank transfer, * never through the gateway) and ETB invoices Finance settles by hand (bank - * transfer / counter) instead of the customer paying online. Open ones by - * default or a single status when filtered; both currencies unless - * `currency` narrows it. Booking-sourced rows carry the booking's reference, - * trade direction and pay-window deadline so the UI can show the countdown - * and link to the booking. + * transfer / counter) instead of the customer paying online. Both currencies + * unless `currency` narrows it, and only ones whose manual-payment channel is + * switched on. Open ones by default — pin `status` or `statuses` to widen + * that. Every other dimension is the invoice list's own (`applyInvoiceFilters` + * + `INVOICE_SORT_COLUMNS`), so the two screens filter and sort alike. + * Booking-sourced rows carry the booking's reference, trade direction and + * pay-window deadline so the UI can show the countdown and link to the + * booking. */ async findOfflineUsdPaginated( - filter: { - status?: Freight.InvoiceStatus; - search?: string; - currency?: "USD" | "ETB"; + filter: InvoiceListFilters & { page?: number; pageSize?: number; + sortBy?: string; + sortOrder?: "ASC" | "DESC"; } = {}, - ): Promise<{ items: OfflineUsdInvoiceRow[]; total: number }> { + ): Promise<{ + items: OfflineUsdInvoiceRow[]; + total: number; + /** Sum of `balanceAmount` over the WHOLE filtered set, by currency. */ + outstanding: Record; + }> { const page = filter.page && filter.page > 0 ? filter.page : 1; const pageSize = filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; @@ -613,33 +625,75 @@ export class BillingService { // a row Finance cannot act on is noise, and the confirm endpoint would // refuse it anyway. All off → nothing to work. const enabled = await this.manualPaymentSettings.enabledCurrencies(); - if (!enabled.length) return { items: [], total: 0 }; - const currencies = filter.currency - ? enabled.filter((c) => c === filter.currency) - : enabled; - if (!currencies.length) return { items: [], total: 0 }; + const empty = { items: [], total: 0, outstanding: {} }; + if (!enabled.length) return empty; + const wanted = filter.currency?.toUpperCase(); + const currencies = wanted ? enabled.filter((c) => c === wanted) : enabled; + if (!currencies.length) return empty; - const qb = this.dataSource - .getRepository(Invoice) - .createQueryBuilder("invoice") - .leftJoinAndSelect("invoice.company", "company") - .where("UPPER(invoice.currency) IN (:...currencies)", { currencies }) - .orderBy("invoice.issuedAt", "DESC") + /** + * The worklist narrows by the same vocabulary as the main invoice list, so + * both share `applyInvoiceFilters` — which references the `company` and + * `payment` aliases, hence the unconditional joins. `select` is false for + * the aggregate pass, where joined columns would break the GROUP BY. + */ + const buildQb = (select: boolean) => { + const qb = this.dataSource + .getRepository(Invoice) + .createQueryBuilder("invoice"); + if (select) { + qb.leftJoinAndSelect("invoice.company", "company").leftJoinAndSelect( + "invoice.payment", + "payment", + ); + } else { + qb.leftJoin("invoice.company", "company").leftJoin( + "invoice.payment", + "payment", + ); + } + qb.where("UPPER(invoice.currency) IN (:...currencies)", { currencies }); + // "What still needs settling" is the default cut, but only until the + // caller pins a status — either the single-status param or the filter + // bar's multi-select. + if (!filter.status && !filter.statuses?.length) { + qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES }); + } + // `currency` is already enforced by the enabled-currency IN above, and + // re-applying it would only repeat the same predicate. + this.applyInvoiceFilters(qb, { ...filter, currency: undefined }); + return qb; + }; + + const qb = buildQb(true) + // sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated + // raw; the id tiebreaker keeps paging stable when the column ties. + .orderBy( + INVOICE_SORT_COLUMNS[filter.sortBy ?? ""] ?? "invoice.issuedAt", + filter.sortOrder ?? "DESC", + ) + .addOrderBy("invoice.id", "ASC") .skip((page - 1) * pageSize) .take(pageSize); - if (filter.status) { - qb.andWhere("invoice.status = :status", { status: filter.status }); - } else { - qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES }); - } - if (filter.search) { - qb.andWhere( - "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", - { search: `%${filter.search}%` }, - ); - } const [rawItems, total] = await qb.getManyAndCount(); + + // Outstanding across the whole filtered set, not the visible page — the + // KPI must not change as Finance pages through the worklist. + const outstandingRows: { currency: string; outstanding: string }[] = + await buildQb(false) + .select("invoice.currency", "currency") + .addSelect("SUM(invoice.balanceAmount)", "outstanding") + .groupBy("invoice.currency") + .getRawMany(); + // Folded case-insensitively on the way out: stored casing has drifted + // ("usd" rows exist), so two groups can address the same currency. + const outstanding: Record = {}; + for (const row of outstandingRows) { + const key = (row.currency ?? "").toUpperCase(); + outstanding[key] = + (outstanding[key] ?? 0) + (Number(row.outstanding) || 0); + } const items = await this.attachShippingLineCompanies(rawItems); const bookingIds = items @@ -703,6 +757,7 @@ export class BillingService { } as OfflineUsdInvoiceRow; }), total, + outstanding, }; } @@ -849,7 +904,9 @@ export class BillingService { { label: "Wagons", value: - booking.wagonsRequired != null ? String(booking.wagonsRequired) : null, + booking.wagonsRequired != null + ? String(booking.wagonsRequired) + : null, }, ]; } @@ -919,11 +976,24 @@ export class BillingService { const eimsCfg = this.config.get("eims"); if (eimsCfg?.tin) summary.push({ label: "Seller TIN", value: eimsCfg.tin }); if (eimsCfg?.invoice?.sellerVatNumber) { - summary.push({ label: "Seller VAT No.", value: eimsCfg.invoice.sellerVatNumber }); + summary.push({ + label: "Seller VAT No.", + value: eimsCfg.invoice.sellerVatNumber, + }); } // MoR EIMS reference — only once actually registered, never a placeholder row. - if (invoice.eimsIrn) summary.push({ label: "EIMS IRN", value: invoice.eimsIrn }); + if (invoice.eimsIrn) + summary.push({ label: "EIMS IRN", value: invoice.eimsIrn }); + + // The provider's transaction number for the money actually received — CBE's `FT…`, + // telebirr's receipt number, or the bank-slip reference a teller recorded manually. + // It is what a payer holding a receipt can match this invoice against, and what + // finance reconciles a bank statement with; without it a PAID invoice proves only + // that EDR says it was paid. `findById` already loads the `payment` relation, so both + // sources are in hand here — see settlementReferences for why both are read. + const txnRefs = settlementReferences(invoice); + if (txnRefs) summary.push({ label: "Transaction ref", value: txnRefs }); // PNR — the CBE_BILL reference the customer pays against, written onto the booking at // payment-initiation time (see initiatePayment()). Not a column on Invoice/Payment, so @@ -933,7 +1003,8 @@ export class BillingService { where: { id: invoice.sourceId }, select: ["id", "pnrCode"], }); - if (booking?.pnrCode) summary.push({ label: "PNR", value: booking.pnrCode }); + if (booking?.pnrCode) + summary.push({ label: "PNR", value: booking.pnrCode }); } return { @@ -954,7 +1025,9 @@ export class BillingService { currency: l.currency, })), totals, - qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null, + qrImageUrl: invoice.eimsSignedQr + ? pngDataUrl(invoice.eimsSignedQr) + : null, }; } @@ -1213,7 +1286,9 @@ export class BillingService { metadata: l.metadata ?? null, })); - const total = round2(lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0)); + const total = round2( + lines.reduce((sum, l) => sum + Number(l.amount ?? 0), 0), + ); if (!(total > 0)) { throw new BadRequestException("A memo must have a positive total."); } @@ -1243,7 +1318,9 @@ export class BillingService { subtotalAmount: total, taxAmount: 0, totalAmount: total, - ...(settled ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } : {}), + ...(settled + ? { status: Freight.InvoiceStatus.Paid, dueAt: new Date() } + : {}), }, mg, code, @@ -1254,7 +1331,11 @@ export class BillingService { eimsReason: reason, relatedInvoiceId: original.id, ...(settled - ? { paidAmount: memo.totalAmount, balanceAmount: 0, paidAt: new Date() } + ? { + paidAmount: memo.totalAmount, + balanceAmount: 0, + paidAt: new Date(), + } : {}), }; await mg.update(Invoice, memo.id, patch); @@ -1314,7 +1395,7 @@ export class BillingService { input.dueAt ?? new Date( Date.now() + - (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, ); const invoiceNumber = await this.nextInvoiceNumber(mg, code); @@ -1835,9 +1916,9 @@ export class BillingService { dueAt, ...(issuing ? { - status: Freight.InvoiceStatus.Pending, - issuedAt: invoice.issuedAt ?? new Date(), - } + status: Freight.InvoiceStatus.Pending, + issuedAt: invoice.issuedAt ?? new Date(), + } : {}), }; await mg.update(Invoice, { id: invoice.id }, patch); @@ -1901,10 +1982,7 @@ export class BillingService { const repo = this.dataSource.getRepository(Invoice); const invoices = await repo.findBy({ paymentId, - status: In([ - Freight.InvoiceStatus.Issued, - Freight.InvoiceStatus.Pending, - ]), + status: In([Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Pending]), }); for (const invoice of invoices) { await repo.update( @@ -2081,7 +2159,10 @@ export class BillingService { // Same reference, for an ad-hoc additional charge — its own column, since // an AdditionalCharge doesn't own a Booking-scoped `pnrCode` and a booking // can carry many of these at once. - if (billReference && invoice.source === Freight.InvoiceSource.AdditionalCharge) { + if ( + billReference && + invoice.source === Freight.InvoiceSource.AdditionalCharge + ) { await this.dataSource .getRepository(AdditionalCharge) .update({ id: invoice.sourceId }, { paymentReference: billReference }); diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index 78cea01b6..d85facc0f 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -337,7 +337,11 @@ export class InvoiceDocumentService { let y = 700; const colX = [36, 300]; const colW = 250; - model.summary.slice(0, 16).forEach((row, i) => { + // 20, not 16: a booking invoice already fills 16 rows with every optional one present + // (buyer trade name, buyer VAT, seller TIN/VAT, IRN, PNR) and the transaction ref is the + // 17th — the old cap silently dropped whichever row landed last. Still fits: 20 rows end + // at y=423, leaving the line-item table its full run down to the y<190 cut-off. + model.summary.slice(0, 20).forEach((row, i) => { const x = colX[i % 2]; if (i % 2 === 0 && i > 0) y -= 27; ops.push(textOp((row.label ?? "").toUpperCase(), x, y, 7, "F1", PdfColor.gray)); diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts new file mode 100644 index 000000000..2d10d6f4d --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.spec.ts @@ -0,0 +1,50 @@ +import { settlementReferences } from "./invoice-settlement.util"; + +describe("settlementReferences", () => { + it("returns the provider reference recorded on the invoice ledger", () => { + expect( + settlementReferences({ + payments: [{ reference: "FT26082700123" }], + }), + ).toBe("FT26082700123"); + }); + + it("reads the linked gateway payment row when the ledger has no reference", () => { + expect( + settlementReferences({ + payments: [{ reference: null }], + payment: { transactionId: "TB998877" }, + }), + ).toBe("TB998877"); + }); + + it("does not repeat a reference that both sources carry", () => { + expect( + settlementReferences({ + payments: [{ reference: "FT26082700123" }], + payment: { transactionId: "FT26082700123" }, + }), + ).toBe("FT26082700123"); + }); + + it("lists every leg of a partially-then-fully paid invoice, oldest first", () => { + expect( + settlementReferences({ + payments: [{ reference: "SLIP-001" }, { reference: "FT26082700123" }], + }), + ).toBe("SLIP-001, FT26082700123"); + }); + + it("drops the internal intent id the gateway path falls back to", () => { + expect( + settlementReferences({ + payments: [{ reference: "3f8a1c2e-9b4d-4a71-8c6e-2d5f7a9b1c30" }], + }), + ).toBeNull(); + }); + + it("is null for an unpaid invoice", () => { + expect(settlementReferences({ payments: [] })).toBeNull(); + expect(settlementReferences({})).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts index 172e74c17..994208229 100644 --- a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -74,3 +74,44 @@ export const INVOICE_PAYMENT_METHODS = [ /** Settled at a gateway whose provider row is no longer linked. */ "GATEWAY", ] as const; + +/** Anything shaped enough to read settlement references off. */ +interface SettlementReferenceSource { + payments?: Array<{ reference?: string | null }> | null; + payment?: { transactionId?: string | null } | null; +} + +/** + * A settlement reference is the PROVIDER's own transaction number, never ours. + * The gateway path falls back to the intent id when a provider returns no txn + * ref (`markInvoiceAsPaid`: `providerTxnId ?? paymentId`), and that id is a + * uuid — an internal correlation key that means nothing to a payer holding a + * bank slip, so it is dropped rather than printed. No provider's reference is + * uuid-shaped: CBE sends `FT…`, telebirr/ebirr/waafi send digit strings. + */ +const INTERNAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Every provider transaction reference recorded against an invoice, oldest + * first, joined for display — CBE's `FT…`, telebirr's receipt number, or the + * bank-slip number a teller typed into a manual settlement. Null when nothing + * identifiable was recorded. + * + * Reads BOTH sources because neither alone is complete: the invoice's own + * ledger is the only record of manual settlements and of each leg of a + * partially-paid invoice, while the linked `freight.payments` row is the only + * place a provider txn id lands when it arrives after settlement (a webhook + * that stamps `transactionId` on an already-settled intent). Deduped, since + * the ordinary gateway path writes the same value to both. + */ +export function settlementReferences( + invoice: SettlementReferenceSource, +): string | null { + const refs = [ + ...(invoice.payments ?? []).map((p) => p.reference), + invoice.payment?.transactionId, + ].filter( + (ref): ref is string => Boolean(ref) && !INTERNAL_ID.test(ref as string), + ); + return [...new Set(refs)].join(", ") || null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index e29eba2c2..ba34c61bf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -20,6 +20,7 @@ import { Invoice } from "../billing/entities/invoice.entity"; import { InvoiceLine } from "../billing/entities/invoice-line.entity"; import { PayInvoiceDto as GatewayPayInvoiceDto } from "../billing/dto/pay-invoice.dto"; +import { settlementReferences } from "../billing/invoice-settlement.util"; import { InvoiceDocumentModel, sameCompanyName, @@ -785,6 +786,16 @@ export class WarehouseInvoiceService { ? `${lastPayment.method ?? "MANUAL"} / ${date(lastPayment.paidAt) ?? "-"}` : null, }, + // The provider's own transaction number (CBE `FT…`, telebirr receipt no., a + // teller's bank-slip ref) — the row above says only HOW and WHEN it was paid, + // which nobody can reconcile a bank statement against. The warehouse view + // projects the invoice ledger but not the linked gateway `payments` row, so the + // ledger is the only source here; it carries the provider ref on every path + // that has one. + { + label: "Transaction ref", + value: settlementReferences({ payments: invoice.payments }), + }, ], categoryHeader: "Fee type", lines: invoice.items.map((item) => ({ diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx index b2957a7b4..8bb142862 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/UsdPaymentsPage.tsx @@ -1,4 +1,4 @@ -import type { Freight } from "@edr/types"; +import { Freight } from "@edr/types"; import { ActionIcon, Badge, @@ -7,15 +7,19 @@ import { Card, Group, Modal, - SegmentedControl, Stack, Text, TextInput, Tooltip, } from "@mantine/core"; -import { useDebouncedValue } from "@mantine/hooks"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { CheckCircle2, ExternalLink, RefreshCw, Search, X } from "lucide-react"; +import { + CheckCircle2, + CircleDollarSign, + ExternalLink, + Receipt, + RefreshCw, +} from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import toast from "react-hot-toast"; @@ -25,18 +29,125 @@ import { formatMoney, humanize, } from "@/components/customers"; +import { + FilterBar, + dateRangeParams, + isoToLocalDateStr, + useFilters, + type FilterDef, +} from "@/components/filters"; +import { KpiStrip } from "@/components/page"; +import { ExportButton } from "@/components/export/ExportButton"; import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; import { useAuth } from "@/auth/useAuth"; import { useManualPaymentSettingsQuery } from "@/hooks/useManualPaymentSettings"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; -import type { OfflineUsdInvoice } from "@/types/invoice"; import { - DataTable, - DataTableFooter, - usePagination, - type ColumnDef, -} from "@edr/ui-common"; + PAYMENT_METHOD_OPTIONS, + type InvoiceListFilter, + type OfflineUsdInvoice, +} from "@/types/invoice"; +import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common"; + +const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({ + value, + label: humanize(value), +})); + +const SOURCE_OPTIONS = Object.values(Freight.InvoiceSource).map((value) => ({ + value, + label: humanize(value), +})); + +/** + * Mirrors `OPEN_STATUSES` in the API's billing service — the implicit "still + * needs settling" cut this worklist applies when no status pill is set. Only + * the export needs it spelled out (see `exportParams`); the list gets it from + * the server. + */ +const OPEN_STATUSES = [ + Freight.InvoiceStatus.Issued, + Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.PaymentProcessing, + Freight.InvoiceStatus.PartiallyPaid, + Freight.InvoiceStatus.Overdue, +]; + +/** + * The same filter vocabulary the invoices list uses, minus `currency` — this + * panel is mounted once per currency and pins it from the prop, so offering it + * as a pill could only contradict the tab you are on. + */ +const MANUAL_PAYMENT_FILTER_DEFS: FilterDef[] = [ + { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, + { + // Computed from the balance and due date rather than read off `status` — + // nothing sweeps PENDING rows into OVERDUE, so the status under-reports. + key: "settlement", + label: "Settlement", + type: "enum", + multiple: false, + options: [ + { value: "outstanding", label: "Outstanding" }, + { value: "overdue", label: "Overdue" }, + ], + toParams: (v) => + v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" }, + }, + { + key: "issued", + label: "Issued", + type: "date", + operators: ["between", "before", "after"], + toParams: dateRangeParams("issuedFrom", "issuedTo"), + }, + { + key: "sources", + label: "Source", + type: "enum", + secondary: true, + options: SOURCE_OPTIONS, + }, + { + key: "paymentMethods", + label: "Payment method", + type: "enum", + secondary: true, + options: PAYMENT_METHOD_OPTIONS, + }, + { + key: "due", + label: "Due", + type: "date", + secondary: true, + operators: ["between", "before", "after"], + toParams: dateRangeParams("dueFrom", "dueTo"), + }, + { + key: "amount", + label: "Amount", + type: "number", + secondary: true, + operators: ["between", "is"], + toParams: (v) => + v.op === "between" + ? { minAmount: v.v[0], maxAmount: v.v[1] } + : { minAmount: v.v[0], maxAmount: v.v[0] }, + }, +]; + +const SORT_OPTIONS = [ + { value: "issuedAt:DESC", label: "Newest issued" }, + { value: "issuedAt:ASC", label: "Oldest issued" }, + { value: "dueAt:ASC", label: "Due soonest" }, + { value: "totalAmount:DESC", label: "Largest amount" }, + { value: "balanceAmount:DESC", label: "Largest balance" }, + { value: "invoiceNumber:ASC", label: "Invoice no. (A–Z)" }, +]; + +/** Date params the export's `daterange` coercion expects as calendar days. */ +const EXPORT_DAY_KEYS = ["issuedFrom", "issuedTo", "dueFrom", "dueTo"]; /** * The customer's pay window, counted down live. Finance must confirm the bank @@ -157,21 +268,19 @@ export default function UsdPaymentsPanel({ currency: "USD" | "ETB"; }) { const navigate = useNavigate(); - const { pagination, setPagination } = usePagination({ pageSize: 10 }); - const [query, setQuery] = useState(""); - const [debouncedQuery] = useDebouncedValue(query, 300); - const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>( - "", - ); + // Namespaced: the ETB and USD tabs share this panel and live on the same URL + // as the Invoices tab, whose filter bar owns the bare `statuses`/`sort` keys. + const controls = useFilters(MANUAL_PAYMENT_FILTER_DEFS, { + defaultSort: "issuedAt:DESC", + pageSize: 10, + ns: "mp", + }); const [confirming, setConfirming] = useState(null); const [slip, setSlip] = useState(null); const [reference, setReference] = useState(""); const { user } = useAuth(); - const canConfirm = hasPermission( - user, - FREIGHT_PERMS.invoices.confirmOffline, - ); + const canConfirm = hasPermission(user, FREIGHT_PERMS.invoices.confirmOffline); // Manual settlement is switched on per currency in Configuration → Manual // payments. FinanceHubPage hides the tab for a disabled currency; this is @@ -184,20 +293,8 @@ export default function UsdPaymentsPanel({ : true; const filter = useMemo( - () => ({ - page: pagination.pageIndex + 1, - pageSize: pagination.pageSize, - search: debouncedQuery, - status: statusFilter || undefined, - currency, - }), - [ - pagination.pageIndex, - pagination.pageSize, - debouncedQuery, - statusFilter, - currency, - ], + () => ({ ...controls.params, currency }) as unknown as InvoiceListFilter, + [controls.params, currency], ); const { data, isLoading, isError, refetch, isFetching } = useQuery({ @@ -209,7 +306,24 @@ export default function UsdPaymentsPanel({ const rows = data?.items ?? []; const total = data?.total ?? 0; - const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + const outstanding = data?.outstanding?.[currency] ?? 0; + + /** + * The export's `daterange` filters are coerced from calendar days while the + * list takes ISO instants, so each bound is handed over as the local day it + * falls on. The worklist's implicit "still open" cut is not a URL param + * either — spelled out here so an exported file covers the rows the screen + * shows rather than every invoice ever raised in this currency. + */ + const exportParams = useMemo(() => { + const out: Record = { ...controls.params, currency }; + for (const key of EXPORT_DAY_KEYS) { + if (typeof out[key] === "string") + out[key] = isoToLocalDateStr(out[key] as string); + } + if (!out.statuses) out.statuses = OPEN_STATUSES.join(","); + return out; + }, [controls.params, currency]); const closeConfirm = () => { setConfirming(null); @@ -340,7 +454,9 @@ export default function UsdPaymentsPanel({ header: "Pay window", meta: { headerClassName: "text-right", cellClassName: "text-right" }, cell: ({ row }) => ( - + ), }, { @@ -358,47 +474,40 @@ export default function UsdPaymentsPanel({ ); return ( - <> + + + - - } - value={query} - onChange={(e) => setQuery(e.target.value)} - rightSection={ - query ? ( - setQuery("")} - > - - - ) : null - } - style={{ flex: 1, minWidth: "240px" }} - radius="lg" - /> - + { - setStatusFilter( - v === "open" ? "" : (v as Freight.InvoiceStatus), - ); - setPagination((prev) => ({ ...prev, pageIndex: 0 })); - }} - data={[ - { label: "Awaiting payment", value: "open" }, - { label: "Paid", value: "PAID" }, - { label: "Overdue", value: "OVERDUE" }, - ]} /> - + @@ -423,8 +532,8 @@ export default function UsdPaymentsPanel({ emptyMessage={ !currencyEnabled ? `Manual payment is switched off for ${currency} invoices. Enable it in Configuration → Manual payments.` - : debouncedQuery - ? "No invoices match your search." + : controls.activeCount > 0 + ? "No invoices match these filters." : `No ${currency} invoices awaiting manual payment confirmation.` } error={ @@ -435,18 +544,7 @@ export default function UsdPaymentsPanel({ } : undefined } - pagination={{ - pageIndex: pagination.pageIndex, - pageSize: pagination.pageSize, - pageCount, - totalCount: total, - }} - tableOptions={{ - state: { pagination }, - onPaginationChange: setPagination, - manualPagination: true, - pageCount, - }} + {...controls.tableProps(total)} containerClassName="border-0 shadow-none bg-transparent" footer={DataTableFooter} /> @@ -458,9 +556,7 @@ export default function UsdPaymentsPanel({ Confirm manual payment - } + title={Confirm manual payment} radius="md" size="md" > @@ -510,6 +606,6 @@ export default function UsdPaymentsPanel({ )} - + ); } diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts index 49de8b663..f33160a55 100644 --- a/apps/edr-freight-web/backoffice/src/types/invoice.ts +++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts @@ -118,6 +118,11 @@ export interface OfflineUsdInvoice extends Invoice { export interface PaginatedOfflineUsdInvoices { items: OfflineUsdInvoice[]; total: number; + /** + * Outstanding `balanceAmount` across the whole filtered set (not the visible + * page), keyed by normalised currency — feeds the worklist's KPI strip. + */ + outstanding: Record; } /** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */