fix: invoice filter

This commit is contained in:
Nathnael
2026-08-27 12:40:03 +00:00
parent 63bd9197c5
commit 7d9d1fb518
3 changed files with 316 additions and 144 deletions

View File

@@ -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<string, number>;
}> {
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<string, number> = {};
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<EimsConfig>("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 });

View File

@@ -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. (AZ)" },
];
/** 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<OfflineUsdInvoice | null>(null);
const [slip, setSlip] = useState<File | null>(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<string, unknown> = { ...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 }) => (
<PayWindowCell deadline={row.original.booking?.paymentDeadline ?? null} />
<PayWindowCell
deadline={row.original.booking?.paymentDeadline ?? null}
/>
),
},
{
@@ -358,47 +474,40 @@ export default function UsdPaymentsPanel({
);
return (
<>
<Stack gap="md">
<KpiStrip
loading={isLoading}
items={[
{
label: `Outstanding in ${currency}`,
hint: "all matching",
value: formatMoney(outstanding, currency),
icon: CircleDollarSign,
color: "edr-green",
},
{
label: "Invoices listed",
value: total,
icon: Receipt,
color: "blue",
},
]}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search by invoice number…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ flex: 1, minWidth: "240px" }}
radius="lg"
/>
<SegmentedControl
<FilterBar
defs={MANUAL_PAYMENT_FILTER_DEFS}
controls={controls}
searchPlaceholder="Search invoice, customer, booking ref, PNR, transaction ref, GRN or shipping line…"
sortOptions={SORT_OPTIONS}
viewId={`manual-payments-${currency.toLowerCase()}`}
>
<ExportButton
datasetKey="invoices"
params={exportParams}
size="sm"
radius="md"
value={statusFilter || "open"}
onChange={(v) => {
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" },
]}
/>
<ActionIcon
variant="default"
@@ -410,7 +519,7 @@ export default function UsdPaymentsPanel({
>
<RefreshCw size={16} />
</ActionIcon>
</Group>
</FilterBar>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
@@ -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({
<Modal
opened={confirming !== null}
onClose={closeConfirm}
title={
<Text fw={700}>Confirm manual payment</Text>
}
title={<Text fw={700}>Confirm manual payment</Text>}
radius="md"
size="md"
>
@@ -510,6 +606,6 @@ export default function UsdPaymentsPanel({
</Stack>
)}
</Modal>
</>
</Stack>
);
}

View File

@@ -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<string, number>;
}
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */