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 cf41998ba..1dd4c5656 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -116,6 +116,8 @@ export interface InvoiceListFilters { status?: Freight.InvoiceStatus; statuses?: Freight.InvoiceStatus[]; sources?: string[]; + /** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */ + types?: string[]; eimsStatuses?: string[]; /** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */ paymentMethods?: string[]; @@ -306,6 +308,9 @@ export class BillingService { sources: filter.sources, }); } + if (filter.types?.length) { + qb.andWhere("invoice.type IN (:...types)", { types: filter.types }); + } if (filter.eimsStatuses?.length) { qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", { eimsStatuses: filter.eimsStatuses, diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts index 55e6b19d2..45c7acb51 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts @@ -22,6 +22,7 @@ describe("FilterInvoiceDto", () => { search: "INV-2026", statuses: "PENDING,OVERDUE", sources: "booking,warehouse", + types: "PREPAID,WAGON_CANCEL_FEE", eimsStatuses: "NOT_SUBMITTED", currency: "etb", issuedFrom: "2026-08-01T00:00:00.000Z", @@ -39,6 +40,7 @@ describe("FilterInvoiceDto", () => { expect(errors).toEqual([]); expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]); expect(dto.sources).toEqual(["booking", "warehouse"]); + expect(dto.types).toEqual(["PREPAID", "WAGON_CANCEL_FEE"]); expect(dto.currency).toBe("ETB"); expect(dto.minAmount).toBe(100); expect(dto.hasBalance).toBe(true); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index a98ad06c1..fa00fb521 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -89,6 +89,18 @@ export class FilterInvoiceDto { @IsIn(Object.values(Freight.InvoiceSource), { each: true }) sources?: Freight.InvoiceSource[]; + /** + * What the invoice bills for (`?types=PREPAID,WAGON_CANCEL_FEE`). Free-form + * like `paymentMethods`: every billing source mints its own `type` string, so + * an `IsIn` here would silently drop a real value. + */ + @ApiPropertyOptional({ isArray: true, example: ["PREPAID"] }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsString({ each: true }) + types?: string[]; + /** MoR filing state — Finance's "what still needs registering" cut. */ @ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 56ab3b74e..d4d635f6f 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -123,6 +123,7 @@ export const invoicesDataset: ExportDataset = { // on-screen filter actually carries into the export. { key: 'status', label: 'Status (single)', type: 'text' }, { key: 'sources', label: 'Source', type: 'multiselect' }, + { key: 'types', label: 'Type', type: 'multiselect' }, { key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' }, { key: 'paymentMethods', label: 'Payment method', type: 'multiselect' }, { key: 'currency', label: 'Currency', type: 'select', options: [ @@ -151,6 +152,8 @@ export const invoicesDataset: ExportDataset = { if (params.status) qb.andWhere('i.status = :status', { status: params.status }); const sources = params.sources as string[] | null; if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources }); + const types = params.types as string[] | null; + if (types?.length) qb.andWhere('i.type IN (:...types)', { types }); const eimsStatuses = params.eimsStatuses as string[] | null; if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses }); const paymentMethods = params.paymentMethods as string[] | null; diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 8b28fd71a..2b319de30 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -19,8 +19,10 @@ import { ExportButton } from "@/components/export/ExportButton"; import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings"; import { api } from "@/services/api"; import { + INVOICE_TYPE_OPTIONS, PAYMENT_METHOD_OPTIONS, invoicePaymentMethod, + invoiceTypeLabel, paymentMethodLabel, type Invoice, type InvoiceListFilter, @@ -55,6 +57,7 @@ const EIMS_STATUS_OPTIONS = [ const INVOICE_FILTER_DEFS: FilterDef[] = [ { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, { key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS }, + { key: "types", label: "Type", type: "enum", options: INVOICE_TYPE_OPTIONS }, { key: "currency", label: "Currency", @@ -261,6 +264,15 @@ export default function InvoicesPanel() { size: 220, cell: ({ row }) => , }, + { + id: "type", + header: "Type", + cell: ({ row }) => ( + + {invoiceTypeLabel(row.original.type)} + + ), + }, { id: "status", header: "Status", @@ -389,7 +401,7 @@ export default function InvoicesPanel() { - + ( + + {invoiceTypeLabel(row.original.type)} + + ), + }, { id: "status", header: "Status", @@ -523,7 +541,7 @@ export default function UsdPaymentsPanel({ - + PAYMENT_METHOD_LABELS.get(method) ?? method; + +/** + * What an invoice bills for. Every billing source mints its own `type` string, + * so this list is the known vocabulary, not a closed enum — render an unknown + * value rather than treating it as invalid. + */ +export const INVOICE_TYPE_OPTIONS: { value: string; label: string }[] = [ + { value: "PREPAID", label: "Prepaid freight" }, + { value: "WAGON_CANCEL_FEE", label: "Wagon cancellation fee" }, + { value: "GL_FINAL", label: "General contract final" }, + { value: "ADDITIONAL_CHARGE", label: "Additional charge" }, + { value: "PORT_CHARGES", label: "Port charges" }, + { value: "MISCELLANEOUS", label: "Miscellaneous" }, + { value: "DELIVERY_FEE", label: "Delivery fee" }, + { value: "LAST_MILE_ADVANCE", label: "Last-mile advance" }, + { value: "SHIPPING_LINE_CREDIT", label: "Shipping line credit" }, + { value: "STORAGE_FEE", label: "Storage fee" }, + { value: "DEMURRAGE", label: "Demurrage" }, + { value: "MIXED_WAREHOUSE_FEES", label: "Mixed warehouse fees" }, +]; + +/** Label for an invoice `type`, falling back to the humanised raw value. */ +export const invoiceTypeLabel = (type: string): string => + INVOICE_TYPE_OPTIONS.find((o) => o.value === type)?.label ?? + type.replace(/_/g, " ");