mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
fix:( payment ): make CAC Bank confirm work end-to-end and callback-safe
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user