mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 14:20:58 +00:00
Merge remote-tracking branch 'origin/staging' into freight/fix/pay
# Conflicts: # apps/edr-freight-api/src/modules/billing/billing.service.ts # apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx # apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx # pnpm-lock.yaml
This commit is contained in:
@@ -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;
|
||||
@@ -1034,6 +1055,7 @@ export class BillingService {
|
||||
): Promise<InitiateResponseDto> {
|
||||
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||
where: { id: invoiceId, status: In(OPEN_STATUSES) },
|
||||
relations: { company: true },
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new NotFoundException(
|
||||
@@ -1090,6 +1112,10 @@ export class BillingService {
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
platform: opts.platform,
|
||||
payerAccount: opts.payerAccount,
|
||||
// CBE_BILL: payer identity + the invoice's own due date as the bill expiry
|
||||
// (docs/cbe/CBE_IMPLEMENTATION_PLAN.md §6.4).
|
||||
payerName: invoice.company?.name,
|
||||
expiresAt: invoice.dueAt?.toISOString(),
|
||||
returnUrl: opts.returnUrl,
|
||||
failureUrl: opts.failureUrl,
|
||||
});
|
||||
@@ -1100,12 +1126,14 @@ export class BillingService {
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
|
||||
// billing must not simulate it. Kept commented for local demos only.
|
||||
// billing must not simulate it. Kept for local demos only.
|
||||
// An OTP intent (CAC Bank) is NOT paid yet — the payer still has to enter the
|
||||
// code — so the demo shortcut must never fire for it.
|
||||
// code — so the demo shortcut must never fire for it. Same for CBE_BILL: its
|
||||
// bill must stay open until CBE actually settles it via /cbe/payment.
|
||||
if (
|
||||
!result.immediateSuccess &&
|
||||
result.response.clientAction?.type !== "COLLECT_OTP"
|
||||
result.response.clientAction?.type !== "COLLECT_OTP" &&
|
||||
opts.method !== "CBE_BILL"
|
||||
) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
@@ -1151,4 +1179,67 @@ export class BillingService {
|
||||
paidAt,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CBE bill-query (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 4): live still-payable check for
|
||||
* the invoice behind a payment reference. `referenceId` is the gateway intent's referenceId,
|
||||
* i.e. the invoice `sourceId`. Read-only; called while a CBE teller/app is waiting.
|
||||
*/
|
||||
async billQuery(referenceId: string): Promise<{
|
||||
stillPayable: boolean;
|
||||
payerName?: string | null;
|
||||
currentAmountMinor?: number | null;
|
||||
currency?: string | null;
|
||||
reason?: string | null;
|
||||
paymentReason?: string | null;
|
||||
}> {
|
||||
const repo = this.dataSource.getRepository(Invoice);
|
||||
const open = await repo.findOne({
|
||||
where: { sourceId: referenceId, status: In(OPEN_STATUSES) },
|
||||
relations: { company: true },
|
||||
order: { issuedAt: "DESC" },
|
||||
});
|
||||
|
||||
if (open) {
|
||||
const balance = Math.round(Number(open.balanceAmount ?? open.totalAmount));
|
||||
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
||||
return {
|
||||
stillPayable: balance > 0 && !expired,
|
||||
payerName: open.company?.name ?? null,
|
||||
currentAmountMinor: balance,
|
||||
currency: open.currency,
|
||||
// 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",
|
||||
};
|
||||
}
|
||||
|
||||
const latest = await repo.findOne({
|
||||
where: { sourceId: referenceId },
|
||||
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: Math.round(Number(latest.totalAmount)),
|
||||
currency: latest.currency,
|
||||
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
||||
reason: closedInvoiceReason(latest.status),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user