feat(payment): integrate CAC Bank OTP payments into freight flows

CAC Bank is an OTP debit with no redirect and no webhook: initiate SMSes a
code to the payer's mobile, and the charge only settles when that code is
confirmed. The payment service already spoke it (passenger uses it); the
freight side had the enum values but none of the flow.

API:
- PaymentClientService.confirmOtp forwards the code to
  POST /payments/intents/:id/confirm, mapping 400/404 to BadRequest so a
  mistyped code stays retryable instead of surfacing as a gateway failure.
- PaymentService.confirmOtp is keyed by the LOCAL intent id (the invoice's
  paymentId) rather than the domain reference, so the right invoice settles
  when several share a booking. On success billing settles the invoice.
- payInvoice rejects CAC_BANK without payerAccount before calling the
  gateway, and no longer runs the demo auto-settle for a COLLECT_OTP intent
  (it is not paid until the payer confirms).
- POST /billing/my-invoices/:id/confirm — ownership-checked, and since
  warehouse fee invoices are central invoices it covers those too.

Portal:
- useInvoicePayment owns the whole flow (initiate, redirect-or-OTP, confirm)
  and replaces the five near-identical pay mutations at the call sites.
- PaymentMethodModal gains the CAC Bank option, the payer mobile field, and
  the OTP step. Click-outside is disabled there so a stray click cannot drop
  the payer out of a live OTP window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathnael
2026-07-31 08:11:23 +00:00
parent 5a02da91d2
commit 97bfe95ec3
16 changed files with 605 additions and 224 deletions

View File

@@ -12,7 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
import { CompaniesService } from "../companies/companies.service";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto } from "../payment/payments.dto";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
import {
InvoiceDocumentModel,
InvoiceDocumentService,
@@ -352,6 +352,34 @@ export class BillingService {
return this.payInvoice(id, opts);
}
/**
* Submit the CAC Bank OTP for one of the customer's own invoices
* (ownership-checked). Settlement of the invoice happens inside the payment
* service when the OTP succeeds.
*/
async confirmInvoiceOtpForUser(
id: string,
userId: string,
otp: string,
): Promise<IntentStatusDto> {
await this.findByIdForUser(id, userId);
return this.confirmInvoiceOtp(id, otp);
}
/** OTP confirmation by invoice id — the intent is the one stamped at initiate. */
async confirmInvoiceOtp(
invoiceId: string,
otp: string,
): Promise<IntentStatusDto> {
const invoice = await this.dataSource
.getRepository(Invoice)
.findOne({ where: { id: invoiceId } });
if (!invoice?.paymentId) {
throw new NotFoundException("No payment to confirm for this invoice");
}
return this.payment.confirmOtp(invoice.paymentId, otp);
}
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
async documentForUser(
id: string,
@@ -1035,6 +1063,17 @@ export class BillingService {
throw new BadRequestException("Invoice has no outstanding balance.");
}
// CAC Bank is an OTP debit — the bank SMSes the code to this number, so it is
// required up front (the payment service rejects it otherwise, as a 502 here).
if (
(opts.method ?? "").toUpperCase() === "CAC_BANK" &&
!opts.payerAccount?.trim()
) {
throw new BadRequestException(
"payerAccount (mobile number) is required for CAC Bank",
);
}
const result = await this.payment.initiate({
referenceId: invoice.sourceId,
source: invoice.source,
@@ -1062,7 +1101,12 @@ export class BillingService {
// Settlement is driven by the payment API (webhook/outbox → payment.succeeded);
// billing must not simulate it. Kept commented for local demos only.
if (!result.immediateSuccess) {
// 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.
if (
!result.immediateSuccess &&
result.response.clientAction?.type !== "COLLECT_OTP"
) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,