mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 05:30:55 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user