feat(billing): filter invoices and manual payments by invoice type

Adds a free-form `types` CSV filter to the invoice list DTO and query
(same treatment as `paymentMethods` — each billing source mints its own
type string, so an IsIn would drop real values), carries it into the
invoices export dataset, and surfaces a Type column plus filter pill on
both the Invoices and Manual Payments tables.
This commit is contained in:
Nathnael
2026-08-28 09:17:41 +00:00
parent 78c6f8be0a
commit a6b2519136
7 changed files with 81 additions and 2 deletions

View File

@@ -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,

View File

@@ -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);

View File

@@ -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()

View File

@@ -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;