feat: ( payment ) specific CBE query descriptions + Payment_Reason

This commit is contained in:
Abubeker Yasin
2026-07-31 16:03:28 +03:00
parent 37855b0a83
commit aa4ce7fd23
8 changed files with 183 additions and 38 deletions

View File

@@ -55,6 +55,27 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Overdue,
];
/**
* Why a non-open invoice can no longer be paid, in the vocabulary the payment service's CBE
* bill-query mapper understands. Kept specific: CBE reads this back to the payer at the counter,
* so "cancelled" must not stand in for "already paid" or "refunded".
*/
function closedInvoiceReason(status: Freight.InvoiceStatus): string {
switch (status) {
case Freight.InvoiceStatus.Paid:
return "ALREADY_PAID";
case Freight.InvoiceStatus.Refunded:
return "REFUNDED";
case Freight.InvoiceStatus.Cancelled:
return "CANCELLED";
case Freight.InvoiceStatus.Expired:
return "EXPIRED";
// Draft — issued to nobody yet, so there is nothing honest to say beyond "not payable".
default:
return "NOT_PAYABLE";
}
}
/** A single line to bill on a generated invoice. */
export interface InvoiceLineInput {
chargeType: string;
@@ -1097,6 +1118,7 @@ export class BillingService {
currentAmountMinor?: number | null;
currency?: string | null;
reason?: string | null;
paymentReason?: string | null;
}> {
const repo = this.dataSource.getRepository(Invoice);
const open = await repo.findOne({
@@ -1113,7 +1135,12 @@ export class BillingService {
payerName: open.company?.name ?? null,
currentAmountMinor: balance,
currency: open.currency,
reason: expired ? "EXPIRED" : balance > 0 ? null : "ALREADY_PAID",
// CBE shows this beside the amount on the confirmation screen — the invoice number
// the payer is holding, not our internal reference.
paymentReason: `Freight invoice ${open.invoiceNumber}`,
// Settled-in-full wins over past-due: an invoice with nothing left to pay is paid, not
// expired, and that is what the payer at the CBE counter must be told.
reason: balance > 0 ? (expired ? "EXPIRED" : null) : "ALREADY_PAID",
};
}
@@ -1122,15 +1149,24 @@ export class BillingService {
relations: { company: true },
order: { createdAt: "DESC" },
});
// A bill reference whose invoice no longer exists at all — a data problem, not a
// cancellation the payer did anything to cause.
if (!latest) {
return {
stillPayable: false,
payerName: null,
currentAmountMinor: null,
currency: null,
reason: "NOT_FOUND",
};
}
return {
stillPayable: false,
payerName: latest?.company?.name ?? null,
currentAmountMinor: latest ? Math.round(Number(latest.totalAmount)) : null,
currency: latest?.currency ?? null,
reason:
latest?.status === Freight.InvoiceStatus.Paid
? "ALREADY_PAID"
: "CANCELLED",
payerName: latest.company?.name ?? null,
currentAmountMinor: Math.round(Number(latest.totalAmount)),
currency: latest.currency,
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
reason: closedInvoiceReason(latest.status),
};
}
}

View File

@@ -57,9 +57,13 @@ export class MarkPaidResponseDto {
* "is this invoice still payable, by whom, for how much" while a CBE channel is on the line.
*/
export class BillQueryRequestDto {
// Typed `string`, not the enum: the @nestjs/swagger CLI plugin resolves an enum-typed
// property to a relative require() into packages/types, which does not exist inside the
// Docker image (only /app is copied) and crashes at boot with MODULE_NOT_FOUND. The
// decorators below still give us enum docs + runtime validation.
@ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType)
referenceType!: PaymentReferenceType;
referenceType!: string;
@ApiProperty() @IsString() referenceId!: string;
}
@@ -69,6 +73,8 @@ export class BillQueryResponseDto {
@ApiPropertyOptional() payerName?: string | null;
@ApiPropertyOptional() currentAmountMinor?: number | null;
@ApiPropertyOptional() currency?: string | null;
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
/** When stillPayable=false: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */
@ApiPropertyOptional() reason?: string | null;
/** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */
@ApiPropertyOptional() paymentReason?: string | null;
}