Files
edr-platform/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts
2026-08-07 08:47:04 +00:00

406 lines
15 KiB
TypeScript

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<TokenResponseDto> {
this.assertEnabled();
const clientId = this.config.get<string>("cbeBill.clientId");
const clientSecret = this.config.get<string>("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<string>("cbeBill.scope");
if (!valid) throw new UnauthorizedException("Invalid credentials");
const expiresIn = this.config.get<number>("cbeBill.tokenExpiresIn") ?? 3600;
const accessToken = await this.jwtService.signAsync(
{ iss: "edr-payment-api", clientId: dto.client_id, scope: dto.scope },
{ secret: this.config.get<string>("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<CbeQueryResponseDto> {
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<string, unknown>,
});
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<CbePaymentResponseDto> {
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<string, unknown>,
})) ?? 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<string, unknown>,
});
} 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<boolean>("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<PaymentIntent> {
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<string, unknown>;
},
): Promise<CbeBillOperation> {
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<void> {
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<string, unknown>,
});
}
}