From 7d9d1fb51829bcbaac9d427cafe1ffd51028dea9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 27 Aug 2026 12:40:03 +0000 Subject: [PATCH] fix: invoice filter --- .../src/modules/billing/billing.service.ts | 175 +++++++---- .../src/pages/invoices/UsdPaymentsPage.tsx | 280 ++++++++++++------ .../backoffice/src/types/invoice.ts | 5 + 3 files changed, 316 insertions(+), 144 deletions(-) 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 42334644d..cf41998ba 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -269,7 +269,7 @@ export class BillingService { private readonly files: FilesService, private readonly config: ConfigService, private readonly manualPaymentSettings: ManualPaymentSettingsService, - ) { } + ) {} // ── Reads ────────────────────────────────────────────────────────────────── @@ -302,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)", { @@ -328,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 }); @@ -591,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; @@ -614,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 @@ -704,6 +757,7 @@ export class BillingService { } as OfflineUsdInvoiceRow; }), total, + outstanding, }; } @@ -850,7 +904,9 @@ export class BillingService { { label: "Wagons", value: - booking.wagonsRequired != null ? String(booking.wagonsRequired) : null, + booking.wagonsRequired != null + ? String(booking.wagonsRequired) + : null, }, ]; } @@ -920,11 +976,15 @@ 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. @@ -943,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 { @@ -964,7 +1025,9 @@ export class BillingService { currency: l.currency, })), totals, - qrImageUrl: invoice.eimsSignedQr ? pngDataUrl(invoice.eimsSignedQr) : null, + qrImageUrl: invoice.eimsSignedQr + ? pngDataUrl(invoice.eimsSignedQr) + : null, }; } @@ -1223,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."); } @@ -1253,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, @@ -1264,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); @@ -1324,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); @@ -1845,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); @@ -1911,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( @@ -2091,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-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. */