feat(billing): add PAYMENT_PROCESSING invoice status on payment success redirect (all except CBE bill)

This commit is contained in:
Marshal
2026-08-05 12:59:37 +00:00
parent 26837d5a7d
commit b7dcc1bf0a
20 changed files with 160 additions and 3 deletions

View File

@@ -52,6 +52,8 @@ const DEFAULT_DUE_DAYS = 14;
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
// Success-redirect ack; still unsettled, so it must stay payable/settleable.
Freight.InvoiceStatus.PaymentProcessing,
Freight.InvoiceStatus.PartiallyPaid,
Freight.InvoiceStatus.Overdue,
];
@@ -924,6 +926,26 @@ export class BillingService {
});
if (!invoice) return null;
// Reconcile-before-expire, caller-proof: an invoice with a payment intent may
// have settled at the gateway without the webhook landing yet. `paid` — leave
// it open, the (re-emitted) payment.succeeded settles it. `unverifiable` —
// never expire on unknown; the caller's next sweep retries. Invoices with no
// intent (`paymentId` null) were never payable at a gateway and expire directly.
if (invoice.paymentId) {
const { paid, unverifiable } = await this.reconcilePayable(
invoice.sourceId,
);
if (paid || unverifiable) {
this.logger.warn(
`expirePayable skipped for invoice ${invoice.invoiceNumber} (${invoice.id}) — ` +
(paid
? "gateway reconcile found a settled payment"
: "settlement unverifiable at the gateway"),
);
return null;
}
}
return this.transition(
invoice.id,
Freight.InvoiceStatus.Expired,
@@ -1028,6 +1050,40 @@ export class BillingService {
);
}
/**
* Success-redirect ack (see PaymentService.acknowledgeSuccessRedirect): move
* the invoice linked to a gateway intent to PAYMENT_PROCESSING. Only from
* ISSUED/PENDING — never overwrites a settlement (PAID/PARTIALLY_PAID) and
* is idempotent. Balance untouched: this is a display state, not a
* settlement; settleByPaymentId still performs the real transition.
*/
async markInvoicePaymentProcessing(paymentId: string): Promise<void> {
await this.dataSource.getRepository(Invoice).update(
{
paymentId,
status: In([
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
]),
},
{ status: Freight.InvoiceStatus.PaymentProcessing },
);
}
/**
* Counterpart of {@link markInvoicePaymentProcessing} for a failed intent:
* PAYMENT_PROCESSING → PENDING so the invoice reads payable again for a
* retry. No-op from any other status.
*/
async revertInvoicePaymentProcessing(paymentId: string): Promise<void> {
await this.dataSource
.getRepository(Invoice)
.update(
{ paymentId, status: Freight.InvoiceStatus.PaymentProcessing },
{ status: Freight.InvoiceStatus.Pending },
);
}
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
/**

View File

@@ -4,6 +4,7 @@ import {
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
Res,
} from "@nestjs/common";
@@ -84,6 +85,15 @@ export class PaymentController {
return this.paymentService.getIntentByBookingId(bookingId);
}
@Post("redirect-success/:bookingId")
@ApiOperation({
summary:
"Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)",
})
acknowledgeSuccessRedirect(@Param("bookingId") bookingId: string) {
return this.paymentService.acknowledgeSuccessRedirect(bookingId);
}
@Get("receipt/:orderId")
@Public()
@ApiOperation({ summary: "Generate a payment receipt HTML page" })

View File

@@ -253,8 +253,10 @@ export class PaymentService {
payerAccount: input.payerAccount,
payerName: input.payerName,
expiresAt: input.expiresAt,
// bookingId lets the success page ack the redirect (→ PAYMENT_PROCESSING).
returnUrl:
input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
input.returnUrl ??
`https://edrfreight.triaplc.com/payment/success?bookingId=${encodeURIComponent(input.referenceId)}`,
failureUrl:
input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
});
@@ -492,6 +494,37 @@ export class PaymentService {
return { alreadyFinalized: false };
}
/**
* Success-redirect ack from the portal: the customer finished provider
* checkout, settlement webhook not (necessarily) in yet. Optimistic
* intermediate only — the webhook stays the source of truth. Never
* downgrades: only action-required → processing, and the invoice moves to
* PAYMENT_PROCESSING only from an open unpaid status. CBE_BILL is excluded
* (bank-counter flow, it has no redirect).
*/
async acknowledgeSuccessRedirect(
referenceId: string,
): Promise<{ acknowledged: boolean }> {
const intent = await this.paymentRepo.findOneBy({ refId: referenceId });
if (!intent || intent.method === "cbe-bill") {
return { acknowledged: false };
}
if (intent.status === "action-required") {
await this.paymentRepo.update(
{ id: intent.id, status: "action-required" },
{ status: "processing" },
);
}
// Even if the intent already advanced (e.g. webhook raced the redirect to
// "processing"), the invoice ack is idempotent and status-guarded.
if (intent.status === "action-required" || intent.status === "processing") {
await this.billing.markInvoicePaymentProcessing(intent.id);
return { acknowledged: true };
}
return { acknowledged: false };
}
async markPaymentFailed(input: {
intentId: string;
failureCode?: string;
@@ -510,7 +543,9 @@ export class PaymentService {
},
);
// Invoice stays open for retry — nothing to settle. Logged only.
// Invoice stays open for retry — nothing to settle. A redirect-acked
// PAYMENT_PROCESSING invoice is put back to PENDING so it reads payable.
await this.billing.revertInvoicePaymentProcessing(intent.id);
this.logger.warn(
`Payment ${intent.id} failed for ${intent.refId}` +
(input.failureMessage ? `: ${input.failureMessage}` : ""),