import { Injectable, Logger, ServiceUnavailableException, UnauthorizedException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { JwtService } from "@nestjs/jwt"; import { ProviderMethod, ProviderPaymentStatus } from "@edr/types"; import { BillReferenceService } from "../intents/bill-reference.service"; import { IntentsRepository } from "../intents/intents.repository"; import { IntentsService } from "../intents/intents.service"; import { PaymentIntent } from "../intents/entities/payment-intent.entity"; import { BillNotPayableReason, BillResolverService, defaultPaymentReason, reasonToDescription, } from "./bill-resolver.service"; import { CbeBillRepository } from "./cbe-bill.repository"; import { CbeBillOperation } from "./entities/cbe-bill-operation.entity"; import { TokenRequestDto } from "./dto/token-request.dto"; import { TokenResponseDto } from "./dto/token-response.dto"; import { CbeQueryRequestDto } from "./dto/cbe-query-request.dto"; import { CbeQueryResponseDto } from "./dto/cbe-query-response.dto"; import { CbePaymentRequestDto } from "./dto/cbe-payment-request.dto"; import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto"; import { CbeBillError, toCbeFailure } from "./mappers/cbe-error.mapper"; import { mapQueryFailure, mapQuerySuccess, } from "./mappers/cbe-query.mapper"; import { mapPaymentFailure, mapPaymentSuccess, } from "./mappers/cbe-payment.mapper"; /** Postgres unique_violation — the DB-level idempotency backstop firing on a concurrent duplicate. */ const PG_UNIQUE_VIOLATION = "23505"; /** * CBE must pay the bill to the exact cent — compare in integer cents so * double-precision storage noise (1234.34 stored as 1234.33999…) can neither * mask nor fabricate a difference. A relative tolerance is wrong here: 1% of a * 1234.34 bill would wave through anything up to ±12.34. */ export function amountsMatchToTheCent(a: number, b: number): boolean { return Math.round(a * 100) === Math.round(b * 100); } /** * Translate an intent's own terminal state into the same reason vocabulary the domain apps * speak, so both paths flow through one description mapper. */ function localReason(intent: PaymentIntent): BillNotPayableReason { switch (intent.status) { case ProviderPaymentStatus.SUCCEEDED: return "ALREADY_PAID"; case ProviderPaymentStatus.CANCELLED: // expireIntent() cancels with failureCode EXPIRED — an abandoned bill, not a cancellation. return intent.failureCode === "EXPIRED" ? "EXPIRED" : "CANCELLED"; default: return "NOT_PAYABLE"; } } /** * Orchestration for CBE's three inbound calls (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 3). * Business failures return HTTP 200 + Response_Code "2" envelopes (never throw past the * controller); the exception filter only catches auth, validation, and the unexpected. */ @Injectable() export class CbeBillService { private readonly logger = new Logger(CbeBillService.name); constructor( private readonly config: ConfigService, private readonly jwtService: JwtService, private readonly cbeBillRepository: CbeBillRepository, private readonly intentsRepository: IntentsRepository, private readonly intentsService: IntentsService, private readonly billReferenceService: BillReferenceService, private readonly billResolver: BillResolverService, ) {} /* ------------------------------------------------------------------ token */ async generateToken(dto: TokenRequestDto): Promise { this.assertEnabled(); const clientId = this.config.get("cbeBill.clientId"); const clientSecret = this.config.get("cbeBill.clientSecret"); // Unset credentials must fail closed — never let "" === "" mint a token. const valid = !!clientId && !!clientSecret && dto.client_id === clientId && dto.client_secret === clientSecret && dto.grant_type === "client_credentials" && dto.scope === this.config.get("cbeBill.scope"); if (!valid) throw new UnauthorizedException("Invalid credentials"); const expiresIn = this.config.get("cbeBill.tokenExpiresIn") ?? 3600; const accessToken = await this.jwtService.signAsync( { iss: "edr-payment-api", clientId: dto.client_id, scope: dto.scope }, { secret: this.config.get("cbeBill.jwtSecret"), expiresIn }, ); return { token_type: "Bearer", access_token: accessToken, expires_in: expiresIn, scope: dto.scope, consented_on: Math.floor(Date.now() / 1000), }; } /* ------------------------------------------------------------------ query */ async query(dto: CbeQueryRequestDto): Promise { this.assertEnabled(); // Audit first — unlike the reference (which left this commented out), every query attempt // is persisted; counter disputes are exactly what this row is for (plan Phase 3.2). const audit = await this.upsertAudit("QUERY", dto.End_To_End_Txn_Id, { billId: dto.Bill_Id, destinationApiName: dto.Destination_Api_Name, requestPayload: dto as unknown as Record, }); try { const intent = await this.resolveIntent(dto.Bill_Id); this.assertIntentPayable(intent); // The live domain hop — the double-payment guard (§6.3). Not optional. const billQuery = await this.billResolver.billQuery(intent); if (!billQuery.stillPayable) { throw new CbeBillError( reasonToDescription(billQuery.reason), "BUSINESS", ); } const response = mapQuerySuccess(dto, { amountMajor: billQuery.currentAmountMinor ?? intent.amountMinor, fullName: billQuery.payerName || intent.payerName || "", paymentReason: billQuery.paymentReason || defaultPaymentReason(intent.referenceType), }); await this.finishAudit(audit, { intentId: intent.id, tradeStatus: "SUCCESS", response, }); return response; } catch (err) { const failure = toCbeFailure(err); if (!(err instanceof CbeBillError)) { this.logger.error( `/cbe/query ${dto.Bill_Id} failed unexpectedly: ${err instanceof Error ? err.stack : String(err)}`, ); } const response = mapQueryFailure(dto, failure.description); await this.finishAudit(audit, { tradeStatus: "FAILED", failureClass: failure.failureClass, response, }); return response; } } /* ------------------------------------------------------------------ payment */ async pay(dto: CbePaymentRequestDto): Promise { this.assertEnabled(); // this.logger.log({ // msg: "cbe.payment.request", // billId: dto.Bill_Id, // endToEndTxnId: dto.End_To_End_Txn_Id, // cbeTxnRef: dto.Cbe_Txn_Ref, // request: dto, // }); // §6.5 idempotency on CBE's per-attempt id, in order. const prior = await this.cbeBillRepository.findByEndToEndTxnId( dto.End_To_End_Txn_Id, "PAYMENT", ); if (prior) { if (prior.tradeStatus === "SUCCESS") { // Per CBE integration request: a settled End_To_End_Txn_Id never replays the stored // success — every repeat answers "Already paid". Money moved exactly once (the first // call); this only changes what a duplicate hears back. NOTE this diverges from the // original §6.5 replay design: if CBE retries because our SUCCESS response was lost // in transit, it now sees FAILED for a debit we kept — reconcile such cases manually. return mapPaymentFailure(dto, "Already paid"); } if (prior.tradeStatus === "PENDING") { return mapPaymentFailure(dto, "Payment in progress"); } if (prior.failureClass === "BUSINESS") { // Final — retrying cannot change the answer. Same End_To_End_Txn_Id was already // resolved (possibly against a different Bill_Id/amount); say so instead of // echoing the original reason, which no longer describes this request. return mapPaymentFailure( dto, `Already processed: ${prior.responseDescription ?? "failed"}`, ); } // FAILED + TRANSIENT: allowed retry — fall through and re-run the settlement. } // Cbe_Txn_Ref replay across different bills/attempts (partial-unique backstop in the DB). const settled = await this.cbeBillRepository.findSettledByCbeTxnRef( dto.Cbe_Txn_Ref, ); if (settled) { return mapPaymentFailure( dto, "Duplicate transaction ref", ); } let audit: CbeBillOperation; if (prior) { // Transient retry reuses the row — UNIQUE (end_to_end_txn_id, operation) forbids a second. audit = (await this.cbeBillRepository.update(prior.id, { tradeStatus: "PENDING", failureClass: null, cbeTxnRef: dto.Cbe_Txn_Ref, requestPayload: dto as unknown as Record, })) ?? prior; } else { try { audit = await this.cbeBillRepository.create({ operation: "PAYMENT", billId: dto.Bill_Id, endToEndTxnId: dto.End_To_End_Txn_Id, cbeTxnRef: dto.Cbe_Txn_Ref, destinationApiName: dto.Destination_Api_Name, tradeStatus: "PENDING", requestPayload: dto as unknown as Record, }); } catch (err) { if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) { // Concurrent duplicate of the same attempt lost the insert race. return mapPaymentFailure(dto, "Payment in progress"); } throw err; } } let intent: PaymentIntent | undefined; try { intent = await this.resolveIntent(dto.Bill_Id); if (dto.Currency && dto.Currency !== intent.currency) { throw new CbeBillError("Currency mismatch", "BUSINESS"); } // Re-run bill-query — fresh, never cached. Last legitimate point for a synchronous // failure (§6.1): after this we settle and reconcile downstream. const billQuery = await this.billResolver.billQuery(intent); if (!billQuery.stillPayable) { throw new CbeBillError( reasonToDescription(billQuery.reason), "BUSINESS", ); } const amount = Number(dto.Amount); if ( !Number.isFinite(amount) || !amountsMatchToTheCent(amount, intent.amountMinor) ) { throw new CbeBillError("Amount mismatch", "BUSINESS"); } const paidAt = new Date(dto.Timestamp); // Existing state machine, unmodified — intent + outbox commit in one transaction. await this.intentsService.applyProviderResult(intent.id, { status: ProviderPaymentStatus.SUCCEEDED, providerTxnId: dto.Cbe_Txn_Ref, paidAt: Number.isNaN(paidAt.getTime()) ? new Date() : paidAt, confirmedAmountMinor: amount, }); const response = mapPaymentSuccess(dto, intent.merchantOrderId); await this.finishAudit(audit, { intentId: intent.id, tradeStatus: "SUCCESS", response, }); this.logger.log( `bill ${dto.Bill_Id} settled by CBE txn ${dto.Cbe_Txn_Ref} (intent ${intent.id})`, ); return response; } catch (err) { const failure = toCbeFailure(err); if (!(err instanceof CbeBillError)) { this.logger.error( `/cbe/payment ${dto.Bill_Id} failed unexpectedly: ${err instanceof Error ? err.stack : String(err)}`, ); } const response = mapPaymentFailure(dto, failure.description); await this.finishAudit(audit, { intentId: intent?.id, tradeStatus: "FAILED", failureClass: failure.failureClass, response, }); return response; } } /* ------------------------------------------------------------------ helpers */ /** Kill switch (CBE_BILL_ENABLED) — 503 so CBE classes it as transport error and retries. */ assertEnabled(): void { if (!this.config.get("cbeBill.enabled")) { throw new ServiceUnavailableException("CBE bill payment is disabled"); } } /** * The cheap local gate before the domain hop: what OUR record of this attempt says. Only * REQUIRES_ACTION is payable; every other status gets a description naming the actual reason, * because CBE reads it back to the payer standing at the counter. All BUSINESS — none of these * states can change back, so a same-End_To_End_Txn_Id retry cannot produce a different answer. */ private assertIntentPayable(intent: PaymentIntent): void { if (intent.status === ProviderPaymentStatus.REQUIRES_ACTION) return; if (intent.status === ProviderPaymentStatus.PROCESSING) { throw new CbeBillError("Payment in progress", "BUSINESS"); } throw new CbeBillError( reasonToDescription(localReason(intent)), "BUSINESS", ); } /** Check digit first (cheap reject), then the unique bill_reference lookup. */ private async resolveIntent(billId: string): Promise { if (!this.billReferenceService.isValid(billId)) { throw new CbeBillError("Bill not found", "BUSINESS"); } const intent = await this.intentsRepository.findByBillReference(billId); if (!intent || intent.provider !== ProviderMethod.CBE_BILL) { throw new CbeBillError("Bill not found", "BUSINESS"); } return intent; } /** Insert the audit row; a CBE retry of the same QUERY attempt reuses (and refreshes) its row. */ private async upsertAudit( operation: "QUERY", endToEndTxnId: string, data: { billId: string; destinationApiName: string; requestPayload: Record; }, ): Promise { try { return await this.cbeBillRepository.create({ operation, endToEndTxnId, tradeStatus: "PENDING", ...data, }); } catch (err) { if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) { const existing = await this.cbeBillRepository.findByEndToEndTxnId( endToEndTxnId, operation, ); if (existing) return existing; } throw err; } } private async finishAudit( audit: CbeBillOperation, outcome: { intentId?: string; tradeStatus: "SUCCESS" | "FAILED"; failureClass?: "BUSINESS" | "TRANSIENT"; response: { Response_Code: string; Response_Description: string }; }, ): Promise { await this.cbeBillRepository.update(audit.id, { intentId: outcome.intentId ?? audit.intentId, tradeStatus: outcome.tradeStatus, failureClass: outcome.failureClass ?? null, responseCode: outcome.response.Response_Code, responseDescription: outcome.response.Response_Description, responsePayload: outcome.response as unknown as Record, }); } }