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

@@ -1,4 +1,9 @@
import { BadGatewayException, Injectable, Logger } from "@nestjs/common";
import {
BadGatewayException,
BadRequestException,
Injectable,
Logger,
} from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { AxiosError } from "axios";
import { firstValueFrom } from "rxjs";
@@ -67,6 +72,33 @@ export class PaymentClientService {
}
}
/**
* POST /payments/intents/:id/confirm — submit an OTP for a COLLECT_OTP provider
* (CAC Bank). A wrong/expired OTP comes back as 400 from the payment service;
* surface that as a BadRequest (retryable) rather than a 502, so the payer can
* re-enter the code.
*/
async confirmOtp(intentId: string, otp: string): Promise<PaymentIntentSnapshot> {
try {
return await this.call<PaymentIntentSnapshot>(
"POST",
`/payments/intents/${intentId}/confirm`,
{ otp },
);
} catch (err) {
// `call` re-throws raw 404s and masks every other 4xx as BadGateway; an
// unknown intent or a bad OTP is client-fixable, so translate both to 400.
if (err instanceof AxiosError && err.response?.status === 404) {
throw new BadRequestException("PaymentIntent not found");
}
if (err instanceof BadGatewayException) {
const detail = err.message.replace(/^Payment service error: /, "");
throw new BadRequestException(detail);
}
throw err;
}
}
private async call<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
const url = `${this.baseUrl}${path}`;
try {

View File

@@ -374,6 +374,53 @@ export class PaymentService {
return this.formatIntentStatus(refreshed ?? local);
}
/**
* Submit an OTP for a COLLECT_OTP provider (CAC Bank). Keyed by the LOCAL intent
* id (the invoice's `paymentId`) so the right invoice settles even when several
* invoices share a domain reference. The active gateway intent is looked up by
* reference, the OTP is forwarded, and the projection is refreshed. On success
* billing settles the linked invoice (idempotent — the outbox path converges too).
* A wrong/expired OTP bubbles up as a 400 and leaves the intent open for retry.
*/
async confirmOtp(intentId: string, otp: string): Promise<IntentStatusDto> {
const local = await this.paymentRepo.findOneBy({ id: intentId });
if (!local) throw new NotFoundException("PaymentIntent not found");
const snapshot = await this.paymentClient.getIntentByReference(
(local.referenceType as PaymentReferenceType) ??
PaymentReferenceType.SHIPMENT,
local.refId,
);
if (!snapshot) {
throw new NotFoundException("No active payment to confirm");
}
const confirmed = await this.paymentClient.confirmOtp(
snapshot.intentId,
otp,
);
if (confirmed.status === ProviderPaymentStatus.SUCCEEDED) {
await this.markIntentSucceeded(local.id, {
providerTxnId: confirmed.providerTxnId,
paidAt: confirmed.paidAt ? new Date(confirmed.paidAt) : undefined,
notify: true,
});
} else {
await this.paymentRepo.update(
{ id: local.id },
{
status: this.toLocalStatus(confirmed.status),
failerCode: confirmed.failureCode ?? undefined,
failureMessage: confirmed.failureMessage ?? undefined,
},
);
}
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
return this.formatIntentStatus(refreshed ?? local);
}
/**
* Mark a gateway intent paid and (by default) notify billing to settle the
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`