mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +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:
@@ -470,3 +470,78 @@ describe("BillingService.issuePayable", () => {
|
||||
expect(manager.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
const openInvoice = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: "booking-1",
|
||||
type: "PREPAID",
|
||||
invoiceNumber: "INV-20260101-00001",
|
||||
currency: "USD",
|
||||
balanceAmount: 500,
|
||||
totalAmount: 500,
|
||||
paymentId: "intent-1",
|
||||
dueAt: null,
|
||||
};
|
||||
|
||||
const build = (payment: Record<string, unknown>) => {
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue(openInvoice),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = new BillingService(
|
||||
{ getRepository: () => repo } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
makeEvents() as never,
|
||||
payment as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
|
||||
it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => {
|
||||
const initiate = jest.fn();
|
||||
const { service } = build({ initiate });
|
||||
|
||||
await expect(
|
||||
service.payInvoice("inv-1", { method: "CAC_BANK" }),
|
||||
).rejects.toThrow(/payerAccount/);
|
||||
expect(initiate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => {
|
||||
const handlePaymentEvent = jest.fn();
|
||||
const { service } = build({
|
||||
initiate: jest.fn().mockResolvedValue({
|
||||
intentId: "intent-1",
|
||||
immediateSuccess: false,
|
||||
response: {
|
||||
intentId: "intent-1",
|
||||
status: "REQUIRES_ACTION",
|
||||
clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" },
|
||||
},
|
||||
}),
|
||||
handlePaymentEvent,
|
||||
});
|
||||
|
||||
await service.payInvoice("inv-1", {
|
||||
method: "CAC_BANK",
|
||||
payerAccount: "77123456",
|
||||
});
|
||||
|
||||
expect(handlePaymentEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("confirms the OTP against the intent stamped on the invoice", async () => {
|
||||
const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" });
|
||||
const { service } = build({ confirmOtp });
|
||||
|
||||
await service.confirmInvoiceOtp("inv-1", "123456");
|
||||
|
||||
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user