Files
edr-platform/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts
2026-07-31 07:50:10 +00:00

358 lines
13 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 {
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<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);
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<CbePaymentResponseDto> {
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<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 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<boolean>("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<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>,
});
}
}