fix:( payment ): make CAC Bank confirm work end-to-end and callback-safe

This commit is contained in:
Abubeker Yasin
2026-07-13 12:11:47 +03:00
parent 594dd76ab7
commit bc3973cf0d
5 changed files with 166 additions and 35 deletions

View File

@@ -211,15 +211,44 @@ export class IntentsService {
providerTxnId: confirmResult.providerTxnId, providerTxnId: confirmResult.providerTxnId,
paidAt: new Date(), paidAt: new Date(),
}); });
} else { return this.snapshotOf(intent.id);
await this.applyProviderResult(intent.id, {
status: ProviderPaymentStatus.FAILED,
failureCode: confirmResult.failureCode,
failureMessage: confirmResult.failureMessage,
});
} }
const updated = await this.intentsRepository.findById(intent.id); // Confirm did not clearly succeed. CAC has no callback and the confirm response can be
// lost after the customer was charged, so before failing anything verify the source of
// truth by paymentRequestId (GetPaymentByReferenceRequest keys on it).
const verified = await this.cacBankProvider
.queryStatus(intent.providerOrderId)
.catch((err: unknown) => {
this.logger.warn(
`CAC verify after failed confirm errored for intent ${intent.id}: ${
err instanceof Error ? err.message : String(err)
}`,
);
return null;
});
if (verified?.status === ProviderPaymentStatus.SUCCEEDED) {
await this.applyProviderResult(intent.id, {
status: ProviderPaymentStatus.SUCCEEDED,
providerTxnId: verified.providerTxnId,
paidAt: new Date(),
});
return this.snapshotOf(intent.id);
}
// Genuinely not paid — almost always a wrong or expired OTP. Leave the intent in
// REQUIRES_ACTION so the payer can re-enter the code, and do NOT emit payment.failed:
// a mistyped OTP must not cancel the booking. The reconciliation sweep CANCELs the
// intent once its OTP window (expiresAt) passes.
throw new BadRequestException(
confirmResult.failureMessage ??
"OTP confirmation failed — please re-enter the code sent to your phone",
);
}
private async snapshotOf(intentId: string): Promise<PaymentIntentSnapshot> {
const updated = await this.intentsRepository.findById(intentId);
if (!updated) throw new NotFoundException("PaymentIntent not found"); if (!updated) throw new NotFoundException("PaymentIntent not found");
return this.toSnapshot(updated); return this.toSnapshot(updated);
} }
@@ -306,12 +335,12 @@ export class IntentsService {
} }
if (intent.provider === ProviderMethod.CAC_BANK) { if (intent.provider === ProviderMethod.CAC_BANK) {
const reference = (intent.rawInitiation as { reference?: string }) if (!intent.providerOrderId) {
?.reference; throw new Error(
return this.cacBankProvider.queryStatus( `CAC intent ${intent.id} has no providerOrderId to verify`,
intent.merchantOrderId, );
reference, }
); return this.cacBankProvider.queryStatus(intent.providerOrderId);
} }
return provider.queryStatus(intent.merchantOrderId); return provider.queryStatus(intent.merchantOrderId);

View File

@@ -87,8 +87,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
const status = const status =
intent.provider === ProviderMethod.CAC_BANK intent.provider === ProviderMethod.CAC_BANK
? await this.cacBankProvider.queryStatus( ? await this.cacBankProvider.queryStatus(
intent.merchantOrderId, intent.providerOrderId ?? intent.merchantOrderId,
(intent.rawInitiation as { reference?: string })?.reference,
) )
: await provider.queryStatus(intent.merchantOrderId); : await provider.queryStatus(intent.merchantOrderId);
const result = this.intentsService.fromProviderStatus(status); const result = this.intentsService.fromProviderStatus(status);

View File

@@ -0,0 +1,66 @@
/**
* Lossless JSON handling for CAC Bank.
*
* CAC returns 17-digit identifiers — `paymentRequestId`, `confirmReference`,
* `transactionNo` (spec: numeric, <=18) — that exceed `Number.MAX_SAFE_INTEGER`
* (9,007,199,254,740,991). A plain `JSON.parse` silently rounds them
* (…240611 → …240610), corrupting the id we send back to CONFIRM the payment and use
* to VERIFY it via GetPaymentByReferenceRequest. So we quote those fields to strings
* before parsing and carry them as strings end-to-end, then re-emit id fields as raw
* JSON numbers when building requests. This avoids a bigint-JSON dependency for the
* three fields that need it.
*/
/** Response id fields that must survive as exact strings, not JS numbers. */
const RESPONSE_ID_FIELDS = [
"paymentRequestId",
"confirmReference",
"transactionNo",
] as const;
/**
* Request id fields we carry as strings but the bank types as `numeric`, so they must
* go on the wire unquoted. Only PaymentConfirmationRequest.payment_request_id qualifies;
* GetPaymentByReferenceRequest.reference is a string field and stays quoted.
*/
const REQUEST_NUMERIC_ID_FIELDS = ["payment_request_id"] as const;
/**
* Parse a CAC JSON response body, keeping oversized integer ids as exact strings.
* `raw` is the untouched response text (axios response transform is disabled for CAC).
*/
export function parseCacResponse<T>(raw: string): T {
const pattern = new RegExp(
`"(${RESPONSE_ID_FIELDS.join("|")})"\\s*:\\s*(-?\\d+)`,
"g",
);
const quoted = raw.replace(pattern, '"$1":"$2"');
return JSON.parse(quoted) as T;
}
/**
* Serialize a CAC request body. Numeric id fields we hold as strings are emitted as raw
* JSON numbers (unquoted) so their full precision reaches the bank, matching the spec's
* `numeric` type. All other fields serialize normally.
*/
export function serializeCacRequest(body: unknown): string {
let json = JSON.stringify(body);
for (const field of REQUEST_NUMERIC_ID_FIELDS) {
json = json.replace(
new RegExp(`("${field}"\\s*:\\s*)"(-?\\d+)"`, "g"),
"$1$2",
);
}
return json;
}
/**
* Normalize a Djibouti mobile number to the bare 8-digit national form the bank expects
* (spec example `77112233`). Strips a leading `+253` / `00253` / `253` country code and any
* spaces or dashes. Returns the input trimmed if it doesn't match the expected shape.
*/
export function normalizeCacMobile(mobile: string): string {
const digits = mobile.replace(/[\s-]/g, "").replace(/^\+/, "");
const national = digits.replace(/^(?:00)?253/, "");
return national || digits;
}

View File

@@ -12,6 +12,11 @@ import {
import { AxiosError, AxiosRequestConfig } from "axios"; import { AxiosError, AxiosRequestConfig } from "axios";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { CacBankAuth } from "./cac-bank.auth"; import { CacBankAuth } from "./cac-bank.auth";
import {
normalizeCacMobile,
parseCacResponse,
serializeCacRequest,
} from "./cac-bank.json";
import type { import type {
CacConfirmResult, CacConfirmResult,
CacGetPaymentByReferenceRequest, CacGetPaymentByReferenceRequest,
@@ -22,6 +27,10 @@ import type {
CacPaymentInitiateResponse, CacPaymentInitiateResponse,
} from "./cac-bank.types"; } from "./cac-bank.types";
/** Bank-enforced amount bounds (PaymentInitiateRequest spec: between 10 and 100,000 DJF). */
const CAC_MIN_AMOUNT = 10;
const CAC_MAX_AMOUNT = 100_000;
@Injectable() @Injectable()
export class CacBankProvider implements PaymentProvider, OnModuleInit { export class CacBankProvider implements PaymentProvider, OnModuleInit {
readonly method = ProviderMethod.CAC_BANK; readonly method = ProviderMethod.CAC_BANK;
@@ -63,19 +72,28 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
throw new Error("CAC Bank requires payerAccount (customer mobile number)"); throw new Error("CAC Bank requires payerAccount (customer mobile number)");
} }
const customerMobile = normalizeCacMobile(input.payerAccount);
const amount = this.toMajorAmount(input.amountMinor, input.currency);
if (amount < CAC_MIN_AMOUNT || amount > CAC_MAX_AMOUNT) {
throw new Error(
`CAC Bank amount ${amount} ${input.currency} is outside the accepted range ` +
`(${CAC_MIN_AMOUNT}${CAC_MAX_AMOUNT} DJF)`,
);
}
const requestBody: CacPaymentInitiateRequest = { const requestBody: CacPaymentInitiateRequest = {
app_key: this.appKey, app_key: this.appKey,
api_key: this.apiKey, api_key: this.apiKey,
customer_mobile: input.payerAccount, customer_mobile: customerMobile,
currency: input.currency || this.defaultCurrency, currency: input.currency || this.defaultCurrency,
desc: `${input.orderRef}`.slice(0, 500), desc: `${input.orderRef}`.slice(0, 500),
vender_ref: input.merchantOrderId, vender_ref: input.merchantOrderId,
amount: this.toMajorAmount(input.amountMinor, input.currency), amount,
company_services_id: this.companyServicesId, company_services_id: this.companyServicesId,
}; };
this.logger.log( this.logger.log(
`CAC Bank initiate → ${this.baseUrl}/paymentapi/PaymentInitiateRequest | currency=${requestBody.currency} amount=${requestBody.amount} (amountMinorIn=${input.amountMinor}) mobile=${input.payerAccount} ref=${input.merchantOrderId}`, `CAC Bank initiate → ${this.baseUrl}/paymentapi/PaymentInitiateRequest | currency=${requestBody.currency} amount=${requestBody.amount} (amountMinorIn=${input.amountMinor}) mobile=${customerMobile} ref=${input.merchantOrderId}`,
); );
this.logger.debug( this.logger.debug(
`CAC Bank initiate request body: ${JSON.stringify(this.sanitizeKeys(requestBody))}`, `CAC Bank initiate request body: ${JSON.stringify(this.sanitizeKeys(requestBody))}`,
@@ -98,7 +116,8 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
); );
} }
const providerOrderId = String(response.paymentRequestId); // Already an exact string (parseCacResponse keeps the 17-digit id lossless).
const providerOrderId = response.paymentRequestId;
const expiresAt = new Date(Date.now() + this.otpExpiryMs); const expiresAt = new Date(Date.now() + this.otpExpiryMs);
return { return {
@@ -124,7 +143,9 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
const requestBody: CacPaymentConfirmRequest = { const requestBody: CacPaymentConfirmRequest = {
app_key: this.appKey, app_key: this.appKey,
api_key: this.apiKey, api_key: this.apiKey,
payment_request_id: Number(paymentRequestId), // Kept as a string here; serializeCacRequest emits it as a raw JSON number so the
// full 17-digit precision reaches the bank.
payment_request_id: paymentRequestId,
otp, otp,
}; };
@@ -169,15 +190,17 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
} }
} }
async queryStatus( /**
merchantOrderId: string, * Verify a payment via GetPaymentByReferenceRequest, keyed on the paymentRequestId. This
reference?: string, * is CAC's callback replacement: the bank sends no webhook, but the id is known from
): Promise<ProviderStatus> { * initiate and the lookup accepts it, so a lost/failed confirm can still be reconciled.
const lookupRef = reference ?? merchantOrderId; * A settled payment carries a transactionNo; anything else is still pending.
*/
async queryStatus(paymentRequestId: string): Promise<ProviderStatus> {
const requestBody: CacGetPaymentByReferenceRequest = { const requestBody: CacGetPaymentByReferenceRequest = {
app_key: this.appKey, app_key: this.appKey,
api_key: this.apiKey, api_key: this.apiKey,
reference: lookupRef, reference: paymentRequestId,
}; };
try { try {
@@ -202,7 +225,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
if (err instanceof AxiosError && err.response?.status === 404) { if (err instanceof AxiosError && err.response?.status === 404) {
return { return {
status: ProviderPaymentStatus.PROCESSING, status: ProviderPaymentStatus.PROCESSING,
rawResponse: { notFound: true, reference: lookupRef }, rawResponse: { notFound: true, reference: paymentRequestId },
}; };
} }
throw err; throw err;
@@ -212,21 +235,28 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
private async postJson<T>(path: string, body: unknown): Promise<T> { private async postJson<T>(path: string, body: unknown): Promise<T> {
const token = await this.getAuth().getAccessToken(); const token = await this.getAuth().getAccessToken();
const url = `${this.baseUrl}${path}`; const url = `${this.baseUrl}${path}`;
// Serialize ourselves so numeric ids we carry as strings go on the wire unquoted.
const payload = serializeCacRequest(body);
const config: AxiosRequestConfig = { const config: AxiosRequestConfig = {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
timeout: 10_000, timeout: 10_000,
// Keep the raw response text — 17-digit ids would lose precision under axios's
// default JSON.parse. We parse losslessly with parseCacResponse.
transformResponse: [(data) => data],
}; };
const started = Date.now(); const started = Date.now();
try { try {
const res = await firstValueFrom(this.http.post<T>(url, body, config)); const res = await firstValueFrom(
this.http.post<string>(url, payload, config),
);
this.logger.debug( this.logger.debug(
`CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`, `CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`,
); );
return res.data; return parseCacResponse<T>(res.data);
} catch (err) { } catch (err) {
if (err instanceof AxiosError && err.response?.status === 401) { if (err instanceof AxiosError && err.response?.status === 401) {
this.getAuth().invalidate(); this.getAuth().invalidate();
@@ -239,9 +269,9 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
}, },
}; };
const res = await firstValueFrom( const res = await firstValueFrom(
this.http.post<T>(url, body, retryConfig), this.http.post<string>(url, payload, retryConfig),
); );
return res.data; return parseCacResponse<T>(res.data);
} }
if (err instanceof AxiosError) { if (err instanceof AxiosError) {

View File

@@ -24,19 +24,25 @@ export interface CacPaymentInitiateRequest {
export interface CacPaymentInitiateResponse { export interface CacPaymentInitiateResponse {
description: string; description: string;
paymentRequestId: number; /** Numeric id (<=18 digits) kept as a string — it exceeds JS's safe integer range. */
paymentRequestId: string;
} }
export interface CacPaymentConfirmRequest { export interface CacPaymentConfirmRequest {
app_key: string; app_key: string;
api_key: string; api_key: string;
payment_request_id: number; /**
* Carried as a string for precision; emitted as a raw JSON number on the wire by
* `serializeCacRequest` (the bank types this field as `numeric`).
*/
payment_request_id: string;
otp: string; otp: string;
} }
export interface CacPaymentConfirmResponse { export interface CacPaymentConfirmResponse {
description: string; description: string;
confirmReference: number; /** Numeric id kept as a string — see CacPaymentInitiateResponse.paymentRequestId. */
confirmReference: string;
reference: string; reference: string;
} }
@@ -52,7 +58,8 @@ export interface CacPaymentByReferenceResponse {
reference: string; reference: string;
amount: number; amount: number;
transactionDate: string; transactionDate: string;
transactionNo: number; /** Numeric id kept as a string — see CacPaymentInitiateResponse.paymentRequestId. */
transactionNo: string;
} }
export interface CacConfirmResult { export interface CacConfirmResult {