mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(billing): filter, show and export invoice payment method
The settled method is split across two stores: a gateway settlement records the real provider on the linked freight.payments row (cbe-bill, telebirr) while the invoice's own payments ledger only writes a flat "GATEWAY"; a manual settlement has no payments row at all and the ledger is the only source (BANK_TRANSFER, OFFLINE, or whatever PayInvoiceDto.method carried). invoicePaymentMethodExpr folds both into one UPPER_SNAKE vocabulary — provider first, newest ledger entry as the fallback — and the list filter, the export field and the export filter all use that same expression, so the screen and the file can never disagree. The paymentMethods param is deliberately not validated against a fixed list: the manual pay endpoint takes a free-form method, so an IsIn would silently drop real values.
This commit is contained in:
@@ -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<Invoice & { lines: InvoiceLine[] }> {
|
||||
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]);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<LinkedEntityCard
|
||||
icon={CreditCard}
|
||||
title="Payment"
|
||||
name={method ? paymentMethodLabel(method) : "Settled"}
|
||||
rows={[
|
||||
{ label: "Transaction ref", value: transactionRef },
|
||||
{ label: "Provider status", value: invoice.payment?.status },
|
||||
{
|
||||
label: "Paid",
|
||||
value: invoice.paidAt ? formatDate(invoice.paidAt) : undefined,
|
||||
},
|
||||
]}
|
||||
emptyMessage="No provider reference recorded for this settlement."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="lg">
|
||||
<RecipientCard invoice={invoice} />
|
||||
<PaymentCard invoice={invoice} />
|
||||
<SourceCard invoice={invoice} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
@@ -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() {
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "paymentMethod",
|
||||
header: "Method",
|
||||
cell: ({ row }) => {
|
||||
const method = invoicePaymentMethod(row.original);
|
||||
return method ? (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{paymentMethodLabel(method)}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dueAt",
|
||||
header: "Due",
|
||||
@@ -361,7 +389,7 @@ export default function InvoicesPanel() {
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: "auto" }} w="100%">
|
||||
<Box miw={920}>
|
||||
<Box miw={1040}>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
|
||||
@@ -16,10 +16,40 @@ export interface InvoiceSourceRef {
|
||||
shippingLineName: string | null;
|
||||
}
|
||||
|
||||
/** One recorded settlement from the invoice's own `payments` jsonb ledger. */
|
||||
export interface InvoicePaymentEntry {
|
||||
amount: number;
|
||||
/** "GATEWAY" for an online settlement, else the manual method ("BANK_TRANSFER", …). */
|
||||
method?: string | null;
|
||||
reference?: string | null;
|
||||
paidAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway payment intent that settled the invoice, joined on `paymentId`.
|
||||
* Absent on manually-settled invoices — those carry their method and reference
|
||||
* in the `payments` ledger instead.
|
||||
*/
|
||||
export interface InvoiceGatewayPayment {
|
||||
id: string;
|
||||
/** Provider slug as the gateway stores it, lowercase-hyphen: "cbe-bill", "telebirr". */
|
||||
method: string;
|
||||
status: string;
|
||||
/** The provider's own transaction reference. Null until the provider returns one. */
|
||||
transactionId?: string | null;
|
||||
merchantOrderId?: string | null;
|
||||
paidAt?: string | null;
|
||||
}
|
||||
|
||||
/** Mirrors backend `Invoice` (the shared `Freight.IInvoice` omits a couple of raw entity columns). */
|
||||
export interface Invoice extends Freight.IInvoice {
|
||||
subtotalAmount: number;
|
||||
taxAmount: number;
|
||||
/** Set once fully settled. */
|
||||
paidAt?: string | null;
|
||||
/** Settlement ledger, newest last. */
|
||||
payments?: InvoicePaymentEntry[];
|
||||
payment?: InvoiceGatewayPayment | null;
|
||||
/** Present on list reads (`findAllPaginated`), absent on a single-invoice fetch. */
|
||||
sourceRef?: InvoiceSourceRef | null;
|
||||
}
|
||||
@@ -42,6 +72,8 @@ export interface InvoiceListFilter {
|
||||
sources?: string;
|
||||
/** CSV of EIMS filing states. */
|
||||
eimsStatuses?: string;
|
||||
/** CSV of normalised UPPER_SNAKE payment methods (see `PAYMENT_METHOD_OPTIONS`). */
|
||||
paymentMethods?: string;
|
||||
search?: string;
|
||||
currency?: "USD" | "ETB";
|
||||
/** ISO instants — inclusive bounds on `issuedAt` / `dueAt`. */
|
||||
@@ -90,3 +122,47 @@ export interface PaginatedOfflineUsdInvoices {
|
||||
|
||||
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */
|
||||
export type InvoiceCollectedSummary = Record<string, number>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
Reference in New Issue
Block a user