mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
358 lines
13 KiB
TypeScript
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>,
|
|
});
|
|
}
|
|
}
|