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 fbadc77c0..eab221505 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -35,7 +35,11 @@ import { InvoiceLine } from "./entities/invoice-line.entity"; import { Invoice, InvoicePayment } from "./entities/invoice.entity"; import { InvoiceLineRepository } from "./invoice-line.repository"; import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; -import { applySettlement, round2 } from "./invoice-settlement.util"; +import { + applySettlement, + invoicePaymentMethodExpr, + round2, +} from "./invoice-settlement.util"; import { InvoiceRepository } from "./invoice.repository"; /** Options forwarded to the payment gateway when settling an invoice. */ @@ -109,6 +113,8 @@ export interface InvoiceListFilters { statuses?: Freight.InvoiceStatus[]; sources?: string[]; eimsStatuses?: string[]; + /** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */ + paymentMethods?: string[]; currency?: string; search?: string; issuedFrom?: string; @@ -123,6 +129,13 @@ export interface InvoiceListFilters { tradeDirections?: string[]; } +/** + * The list/summary query builders both alias the invoice as `invoice` and the + * joined gateway payment as `payment`; TypeORM rewrites those alias.property + * references into real quoted columns. + */ +const PAYMENT_METHOD_EXPR = invoicePaymentMethodExpr("invoice", "payment"); + const DEFAULT_DUE_DAYS = 14; /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ @@ -292,6 +305,13 @@ export class BillingService { eimsStatuses: filter.eimsStatuses, }); } + if (filter.paymentMethods?.length) { + // Requires the `payment` alias to be joined by the caller — both call + // sites do, unconditionally, so this can never reference a missing alias. + qb.andWhere(`${PAYMENT_METHOD_EXPR} IN (:...paymentMethods)`, { + paymentMethods: filter.paymentMethods, + }); + } if (filter.currency) { // Stored casing has drifted ("usd" rows exist) — compare normalised. qb.andWhere("UPPER(invoice.currency) = :currency", { @@ -386,6 +406,9 @@ export class BillingService { .getRepository(Invoice) .createQueryBuilder("invoice") .leftJoinAndSelect("invoice.company", "company") + // The gateway payment behind the invoice: the settled method and the + // provider's transaction reference both live on it, and nowhere else. + .leftJoinAndSelect("invoice.payment", "payment") // sortBy is whitelisted through INVOICE_SORT_COLUMNS, never interpolated // raw. The id tiebreaker keeps paging stable when the sort column ties // (issuedAt is null on every DRAFT row). @@ -542,6 +565,7 @@ export class BillingService { // Joined, not selected: `applyInvoiceFilters` searches the customer name, // so the alias has to exist even though the summary only sums money. .leftJoin("invoice.company", "company") + .leftJoin("invoice.payment", "payment") .select("invoice.currency", "currency") .addSelect("SUM(invoice.paidAmount)", "collected") .groupBy("invoice.currency"); @@ -748,7 +772,7 @@ export class BillingService { /** Invoice header plus its line items. */ async findById(id: string): Promise { const invoice = await this.invoices.findById(id, { - relations: { company: true, companyProfile: true }, + relations: { company: true, companyProfile: true, payment: true }, }); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); const [hydrated] = await this.attachShippingLineCompanies([invoice]); 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 aec6e4ac0..a98ad06c1 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 @@ -15,6 +15,7 @@ import { } from "class-validator"; import { EimsInvoiceStatus } from "../../eims/eims-registration.types"; +import { INVOICE_PAYMENT_METHODS } from "../invoice-settlement.util"; /** Columns the invoice list may be ordered by -> their query-builder expression. */ export const INVOICE_SORT_COLUMNS: Record = { @@ -96,6 +97,19 @@ export class FilterInvoiceDto { @IsIn(Object.values(EimsInvoiceStatus), { each: true }) eimsStatuses?: EimsInvoiceStatus[]; + /** + * Settled payment method (`?paymentMethods=CBE_BILL,BANK_TRANSFER`). Values are + * the normalised UPPER_SNAKE vocabulary of `invoicePaymentMethodExpr`. Not + * validated against a fixed list — the manual pay endpoint takes a free-form + * method, so an `IsIn` here would silently drop a real value. + */ + @ApiPropertyOptional({ isArray: true, enum: INVOICE_PAYMENT_METHODS }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsString({ each: true }) + paymentMethods?: string[]; + /** Manual-payments worklist and the invoice list: restrict to one currency. */ @ApiPropertyOptional({ enum: ["USD", "ETB"] }) @IsOptional() 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 ab1e27b1a..172e74c17 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 @@ -34,3 +34,43 @@ export function applySettlement( const balanceAmount = Math.max(0, round2(total - paidAmount)); return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total }; } + +/** + * SQL for an invoice's settled payment method, normalised to one vocabulary. + * + * Two sources have to be merged: gateway settlements carry the real provider on + * the linked `freight.payments` row (`cbe-bill`, `telebirr`, …) while the + * invoice's own `payments` ledger only records a flat `"GATEWAY"`; manual + * settlements have no payments row at all and the ledger is the ONLY source + * (`BANK_TRANSFER`, `OFFLINE`, or whatever `PayInvoiceDto.method` carried). + * So: provider first, newest ledger entry as the fallback. + * + * `-> -1` is the last ledger element — the ledger is appended newest-last. + * `::text` is not cosmetic: `payments.method` is a real Postgres enum, and + * COALESCE against a text fallback fails without the cast. + * + * Normalised UPPER_SNAKE so `cbe-bill` and a hand-typed `CBE_BILL` are one + * value on screen, in the filter and in the export. + */ +export const invoicePaymentMethodExpr = (invoice: string, payment: string): string => + `UPPER(REPLACE(COALESCE(${payment}.method::text, ${invoice}.payments -> -1 ->> 'method'), '-', '_'))`; + +/** + * The methods the filter offers. Not exhaustive by construction — the manual + * pay endpoint takes a free-form `method` string — so nothing validates against + * this list; it is the pick-list, not a constraint. + */ +export const INVOICE_PAYMENT_METHODS = [ + "TELEBIRR", + "CBE_BIRR", + "CBE_BILL", + "EBIRR", + "WAAFI", + "CARD", + "DMONEY", + "CAC_BANK", + "BANK_TRANSFER", + "OFFLINE", + /** Settled at a gateway whose provider row is no longer linked. */ + "GATEWAY", +] as const; 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 67f2207e9..3b897ef2d 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 @@ -1,11 +1,16 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; import { Invoice } from '../../billing/entities/invoice.entity'; +import { invoicePaymentMethodExpr } from '../../billing/invoice-settlement.util'; +import { PaymentEntity } from '../../payment/entities/payment.entity'; import { Company } from '../../companies/entities/company.entity'; import { CompanyProfile } from '../../companies/entities/company-profile.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ExportDataset } from '../export.types'; +/** Same expression the list endpoint filters by, in this dataset's aliases. */ +const PAYMENT_METHOD = invoicePaymentMethodExpr('i', 'p'); + /** * Sensitive EIMS internals are deliberately absent: `eims_signed_qr` (a * signature blob) and `eims_last_error` (a raw error dump). The @@ -26,8 +31,11 @@ export const invoicesDataset: ExportDataset = { // with a second query. In a dataset it is just a join by column. { alias: 'slc', entity: ShippingLineCompany, on: 'slc.id = i.shipping_line_company_id' }, { alias: 'rel', entity: Invoice, on: 'rel.id = i.related_invoice_id' }, + // The gateway payment behind the invoice — provider method and its + // transaction reference. Always joined: `scope()` filters on it. + { alias: 'p', entity: PaymentEntity, on: 'p.id = i.payment_id' }, ], - alwaysJoin: ['c'], + alwaysJoin: ['c', 'p'], groups: [ { id: 'invoice', label: 'Invoice' }, @@ -66,6 +74,9 @@ export const invoicesDataset: ExportDataset = { { key: 'currency', label: 'Currency', type: 'string', group: 'amounts', default: true, select: 'i.currency' }, { key: 'paidAt', label: 'Paid at', type: 'datetime', group: 'payment', select: `to_char(i.paid_at, 'YYYY-MM-DD HH24:MI')` }, + { key: 'paymentMethod', label: 'Payment method', type: 'string', group: 'payment', default: true, requires: ['p'], select: PAYMENT_METHOD, sortExpr: PAYMENT_METHOD }, + { key: 'transactionRef', label: 'Transaction ref', type: 'string', group: 'payment', requires: ['p'], select: 'p.transaction_id' }, + { key: 'paymentStatus', label: 'Payment status', type: 'string', group: 'payment', requires: ['p'], select: 'p.status::text' }, { key: 'daysOverdue', label: 'Days overdue', type: 'number', group: 'payment', select: `CASE WHEN i.balance_amount > 0 AND i.due_at < now() @@ -104,6 +115,7 @@ export const invoicesDataset: ExportDataset = { { key: 'status', label: 'Status (single)', type: 'text' }, { key: 'sources', label: 'Source', type: 'multiselect' }, { key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' }, + { key: 'paymentMethods', label: 'Payment method', type: 'multiselect' }, { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, @@ -132,6 +144,10 @@ export const invoicesDataset: ExportDataset = { if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources }); 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; + if (paymentMethods?.length) { + qb.andWhere(`${PAYMENT_METHOD} IN (:...paymentMethods)`, { paymentMethods }); + } // Casing has drifted in the data ("usd" rows exist) — normalise both sides, // same as the list endpoint does. if (params.currency) { diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx index 23a974856..0fa911485 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoiceDetailPage.tsx @@ -15,7 +15,14 @@ import { Text, } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; -import { ArrowLeft, Building2, Download, FileText, Printer } from "lucide-react"; +import { + ArrowLeft, + Building2, + CreditCard, + Download, + FileText, + Printer, +} from "lucide-react"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { EimsFilingCard } from "@/components/invoices/EimsFilingCard"; @@ -34,7 +41,11 @@ import { LinkedEntityCard, type FieldRowProps } from "@/components/detail"; import { useBookingDetail } from "@/hooks/bookings/useBookings"; import { api } from "@/services/api"; import { invoicesService } from "@/services/invoices.service"; -import type { Invoice } from "@/types/invoice"; +import { + invoicePaymentMethod, + paymentMethodLabel, + type Invoice, +} from "@/types/invoice"; function openPdfBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); @@ -124,6 +135,45 @@ function RecipientCard({ invoice }: { invoice: Invoice }) { ); } +/** + * How the invoice was settled. The method and the provider's transaction + * reference live on the linked gateway payment for an online settlement, and in + * the invoice's own `payments` ledger for a manual one — this reads whichever + * exists, same precedence the API filters by. Not rendered at all until + * something has been settled. + */ +function PaymentCard({ invoice }: { invoice: Invoice }) { + const method = invoicePaymentMethod(invoice); + const lastEntry = invoice.payments?.at(-1); + if (!method && !lastEntry) return null; + + // The gateway's own reference first; `merchantOrderId` is our order id, which + // is still the number a provider support desk can trace. The ledger reference + // is what a manual settlement carries (bank slip / transfer reference). + const transactionRef = + invoice.payment?.transactionId ?? + invoice.payment?.merchantOrderId ?? + lastEntry?.reference ?? + undefined; + + return ( + + ); +} + /** What the invoice was raised for — a booking's route/wagons when the * source is a booking; otherwise just the source type and its raw id * (warehouse/demurrage/first-mile/last-mile ids don't link anywhere). */ @@ -393,6 +443,7 @@ export default function InvoiceDetailPage() { + 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 8026f692b..5361558fb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -18,7 +18,13 @@ import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActio import { ExportButton } from "@/components/export/ExportButton"; import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings"; import { api } from "@/services/api"; -import type { Invoice, InvoiceListFilter } from "@/types/invoice"; +import { + PAYMENT_METHOD_OPTIONS, + invoicePaymentMethod, + paymentMethodLabel, + type Invoice, + type InvoiceListFilter, +} from "@/types/invoice"; import { DataTable, DataTableFooter, type ColumnDef } from "@edr/ui-common"; const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((value) => ({ @@ -74,6 +80,12 @@ const INVOICE_FILTER_DEFS: FilterDef[] = [ toParams: (v) => v.v[0] === "overdue" ? { overdue: "true" } : { hasBalance: "true" }, }, + { + key: "paymentMethods", + label: "Payment method", + type: "enum", + options: PAYMENT_METHOD_OPTIONS, + }, { key: "issued", label: "Issued", @@ -274,6 +286,22 @@ export default function InvoicesPanel() { ), }, + { + id: "paymentMethod", + header: "Method", + cell: ({ row }) => { + const method = invoicePaymentMethod(row.original); + return method ? ( + + {paymentMethodLabel(method)} + + ) : ( + + — + + ); + }, + }, { id: "dueAt", header: "Due", @@ -361,7 +389,7 @@ export default function InvoicesPanel() { - + ; + +/** + * The payment methods the invoice list filters by. Normalised UPPER_SNAKE, the + * one vocabulary the API's `invoicePaymentMethodExpr` folds both sources into: + * the gateway's own provider slugs (`cbe-bill` → CBE_BILL) and the flat labels + * a manual settlement writes to the ledger. + * + * Not exhaustive — the manual pay endpoint takes a free-form method — so treat + * an unknown value as displayable, never as invalid. + */ +export const PAYMENT_METHOD_OPTIONS: { value: string; label: string }[] = [ + { value: "TELEBIRR", label: "Telebirr" }, + { value: "CBE_BIRR", label: "CBE Birr" }, + { value: "CBE_BILL", label: "CBE Bill" }, + { value: "EBIRR", label: "E-Birr" }, + { value: "WAAFI", label: "Waafi" }, + { value: "CARD", label: "Card" }, + { value: "DMONEY", label: "D-Money" }, + { value: "CAC_BANK", label: "CAC Bank" }, + { value: "BANK_TRANSFER", label: "Bank transfer" }, + { value: "OFFLINE", label: "Offline" }, + { value: "GATEWAY", label: "Gateway" }, +]; + +const PAYMENT_METHOD_LABELS = new Map( + PAYMENT_METHOD_OPTIONS.map((o) => [o.value, o.label]), +); + +/** Normalise a raw method from either source to the vocabulary above. */ +export const normalizePaymentMethod = (raw?: string | null): string | null => + raw ? raw.toUpperCase().replace(/-/g, "_") : null; + +/** + * An invoice's settled method, resolved the same way the API does: the gateway + * provider first, the newest ledger entry as the fallback. Null while unpaid. + */ +export function invoicePaymentMethod(invoice: Invoice): string | null { + const ledger = invoice.payments?.at(-1)?.method; + return normalizePaymentMethod(invoice.payment?.method ?? ledger); +} + +/** Human label for a method value; unknown values are shown as-is. */ +export const paymentMethodLabel = (method: string): string => + PAYMENT_METHOD_LABELS.get(method) ?? method;