diff --git a/apps/edr-payment-api/src/modules/intents/intents.service.ts b/apps/edr-payment-api/src/modules/intents/intents.service.ts index ccd10f508..a7904e4cc 100644 --- a/apps/edr-payment-api/src/modules/intents/intents.service.ts +++ b/apps/edr-payment-api/src/modules/intents/intents.service.ts @@ -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 { + 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); diff --git a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts index 98595f8f2..7c4805ddb 100644 --- a/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts +++ b/apps/edr-payment-api/src/modules/reconciliation/reconciliation.service.ts @@ -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); diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.json.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.json.ts new file mode 100644 index 000000000..f58aa9e64 --- /dev/null +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.json.ts @@ -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(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; +} diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts index 8e5a94bbb..07de0ae0d 100644 --- a/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.provider.ts @@ -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 { - 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 { 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(path: string, body: unknown): Promise { 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(url, body, config)); + const res = await firstValueFrom( + this.http.post(url, payload, config), + ); this.logger.debug( `CAC Bank POST ${path} status=${res.status} latency=${Date.now() - started}ms`, ); - return res.data; + return parseCacResponse(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(url, body, retryConfig), + this.http.post(url, payload, retryConfig), ); - return res.data; + return parseCacResponse(res.data); } if (err instanceof AxiosError) { diff --git a/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts b/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts index 6e2b7ba5b..522994c4f 100644 --- a/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts +++ b/packages/payment-providers/src/providers/cac-bank/cac-bank.types.ts @@ -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 {