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 { BillResolverService, 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"; /** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */ const AMOUNT_TOLERANCE = 0.01; /** * Orchestration for CBE's three inbound calls (docs/cbe/CBE_IMPLEMENTATION_PLAN.md Phase 3). * Business failures return HTTP 200 + Response_Code "3" 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); if (intent.status !== ProviderPaymentStatus.REQUIRES_ACTION) { throw new CbeBillError( intent.status === ProviderPaymentStatus.SUCCEEDED ? "Bill already paid." : "Bill is not payable.", "BUSINESS", ); } // 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 || "", }); 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(); // §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") { // Replay the stored body verbatim. Never re-settle. return prior.responsePayload as unknown as CbePaymentResponseDto; } if (prior.tradeStatus === "PENDING") { return mapPaymentFailure(dto, "Payment is being processed."); } if (prior.failureClass === "BUSINESS") { // Final — retrying cannot change the answer. Replay what we told CBE last time. return ( (prior.responsePayload as unknown as CbePaymentResponseDto) ?? mapPaymentFailure( dto, prior.responseDescription ?? "Payment already 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, `Invalid transaction reference number ${dto.Cbe_Txn_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 is being processed."); } throw err; } } let intent: PaymentIntent | undefined; try { intent = await this.resolveIntent(dto.Bill_Id); if (dto.Currency && dto.Currency !== intent.currency) { throw new CbeBillError("Payment currency does not match.", "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) || Math.abs(amount - intent.amountMinor) > intent.amountMinor * AMOUNT_TOLERANCE ) { throw new CbeBillError("Payment amount does not match.", "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"); } } /** 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, }); } }