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,
paidAt: new Date(),
});
} else {
await this.applyProviderResult(intent.id, {
status: ProviderPaymentStatus.FAILED,
failureCode: confirmResult.failureCode,
failureMessage: confirmResult.failureMessage,
});
return this.snapshotOf(intent.id);
}
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");
return this.toSnapshot(updated);
}
@@ -306,12 +335,12 @@ export class IntentsService {
}
if (intent.provider === ProviderMethod.CAC_BANK) {
const reference = (intent.rawInitiation as { reference?: string })
?.reference;
return this.cacBankProvider.queryStatus(
intent.merchantOrderId,
reference,
);
if (!intent.providerOrderId) {
throw new Error(
`CAC intent ${intent.id} has no providerOrderId to verify`,
);
}
return this.cacBankProvider.queryStatus(intent.providerOrderId);
}
return provider.queryStatus(intent.merchantOrderId);

View File

@@ -87,8 +87,7 @@ export class ReconciliationService implements OnModuleInit, OnModuleDestroy {
const status =
intent.provider === ProviderMethod.CAC_BANK
? await this.cacBankProvider.queryStatus(
intent.merchantOrderId,
(intent.rawInitiation as { reference?: string })?.reference,
intent.providerOrderId ?? intent.merchantOrderId,
)
: await provider.queryStatus(intent.merchantOrderId);
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 { firstValueFrom } from "rxjs";
import { CacBankAuth } from "./cac-bank.auth";
import {
normalizeCacMobile,
parseCacResponse,
serializeCacRequest,
} from "./cac-bank.json";
import type {
CacConfirmResult,
CacGetPaymentByReferenceRequest,
@@ -22,6 +27,10 @@ import type {
CacPaymentInitiateResponse,
} 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()
export class CacBankProvider implements PaymentProvider, OnModuleInit {
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)");
}
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 = {
app_key: this.appKey,
api_key: this.apiKey,
customer_mobile: input.payerAccount,
customer_mobile: customerMobile,
currency: input.currency || this.defaultCurrency,
desc: `${input.orderRef}`.slice(0, 500),
vender_ref: input.merchantOrderId,
amount: this.toMajorAmount(input.amountMinor, input.currency),
amount,
company_services_id: this.companyServicesId,
};
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(
`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);
return {
@@ -124,7 +143,9 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
const requestBody: CacPaymentConfirmRequest = {
app_key: this.appKey,
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,
};
@@ -169,15 +190,17 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
}
}
async queryStatus(
merchantOrderId: string,
reference?: string,
): Promise<ProviderStatus> {
const lookupRef = reference ?? merchantOrderId;
/**
* Verify a payment via GetPaymentByReferenceRequest, keyed on the paymentRequestId. This
* is CAC's callback replacement: the bank sends no webhook, but the id is known from
* initiate and the lookup accepts it, so a lost/failed confirm can still be reconciled.
* A settled payment carries a transactionNo; anything else is still pending.
*/
async queryStatus(paymentRequestId: string): Promise<ProviderStatus> {
const requestBody: CacGetPaymentByReferenceRequest = {
app_key: this.appKey,
api_key: this.apiKey,
reference: lookupRef,
reference: paymentRequestId,
};
try {
@@ -202,7 +225,7 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
if (err instanceof AxiosError && err.response?.status === 404) {
return {
status: ProviderPaymentStatus.PROCESSING,
rawResponse: { notFound: true, reference: lookupRef },
rawResponse: { notFound: true, reference: paymentRequestId },
};
}
throw err;
@@ -212,21 +235,28 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
private async postJson<T>(path: string, body: unknown): Promise<T> {
const token = await this.getAuth().getAccessToken();
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 = {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
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();
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(
`CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`,
);
return res.data;
return parseCacResponse<T>(res.data);
} catch (err) {
if (err instanceof AxiosError && err.response?.status === 401) {
this.getAuth().invalidate();
@@ -239,9 +269,9 @@ export class CacBankProvider implements PaymentProvider, OnModuleInit {
},
};
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) {

View File

@@ -24,19 +24,25 @@ export interface CacPaymentInitiateRequest {
export interface CacPaymentInitiateResponse {
description: string;
paymentRequestId: number;
/** Numeric id (<=18 digits) kept as a string — it exceeds JS's safe integer range. */
paymentRequestId: string;
}
export interface CacPaymentConfirmRequest {
app_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;
}
export interface CacPaymentConfirmResponse {
description: string;
confirmReference: number;
/** Numeric id kept as a string — see CacPaymentInitiateResponse.paymentRequestId. */
confirmReference: string;
reference: string;
}
@@ -52,7 +58,8 @@ export interface CacPaymentByReferenceResponse {
reference: string;
amount: number;
transactionDate: string;
transactionNo: number;
/** Numeric id kept as a string — see CacPaymentInitiateResponse.paymentRequestId. */
transactionNo: string;
}
export interface CacConfirmResult {