Files
edr-platform/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts

142 lines
5.3 KiB
TypeScript

import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from "rxjs";
import { PaymentReferenceType, PaymentService } from "@edr/types";
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
import { CbeBillError } from "./mappers/cbe-error.mapper";
/**
* Why a bill is no longer payable. Shared vocabulary between both domain apps and the local
* intent check, so one mapper produces every Response_Description CBE sees.
* `NOT_PAYABLE` is the catch-all for domain states with no better name (booking DRAFT/BOARDED,
* invoice DRAFT) — it must stay last-resort, never a substitute for a specific reason.
*/
export type BillNotPayableReason =
| "ALREADY_PAID"
| "CANCELLED"
| "REFUNDED"
| "EXPIRED"
| "NOT_FOUND"
| "NOT_PAYABLE";
/** Contract of POST /internal/payments/bill-query on the domain apps (plan Phase 4). */
export interface BillQueryResult {
stillPayable: boolean;
payerName?: string | null;
currentAmountMinor?: number | null;
currency?: string | null;
/** When stillPayable=false — see {@link BillNotPayableReason}. */
reason?: BillNotPayableReason | string | null;
/**
* What the payer is paying for, shown on CBE's confirmation screen next to the amount —
* the domain's own human reference (booking ref / invoice number), not our internal ids.
*/
paymentReason?: string | null;
}
/**
* Payment_Reason when the domain app sends none (older build, or an order with no human
* reference). Generic but never blank: CBE renders this field to the payer, and a bill with
* an amount and no stated purpose is what a customer refuses to confirm.
*/
export function defaultPaymentReason(
referenceType: PaymentReferenceType,
): string {
return referenceType === PaymentReferenceType.BOOKING
? "Train ticket booking"
: "Freight invoice";
}
/**
* CBE reads Response_Description back to the payer at the counter or in the USSD prompt, so it
* has to name the thing they are actually holding — a passenger booking or a freight invoice —
* rather than our internal "bill" abstraction (plan §6.6).
*/
function subjectOf(referenceType: PaymentReferenceType): string {
return referenceType === PaymentReferenceType.BOOKING ? "booking" : "invoice";
}
export function reasonToDescription(
reason: string | null | undefined,
referenceType: PaymentReferenceType,
): string {
const subject = subjectOf(referenceType);
switch (reason) {
case "ALREADY_PAID":
return `This ${subject} has already been paid.`;
case "CANCELLED":
return `This ${subject} has been cancelled.`;
case "REFUNDED":
return `This ${subject} has been refunded.`;
case "EXPIRED":
return `This ${subject} has expired and can no longer be paid.`;
// A bill reference we issued whose order has since vanished from the domain app. Same
// wording as an unknown Bill_Id — from the teller's side it is the same situation.
case "NOT_FOUND":
return "Bill not found.";
default:
return `This ${subject} is no longer payable.`;
}
}
/**
* The live "still payable?" hop to the owning domain app — routing comes from
* `intent.service` (plan D3). This hop is the double-payment guard (§6.3) and the source of
* the mandatory Full_Name: NOT optional, and on the /cbe/payment path never served from cache.
* Short timeout, no retries — CBE holds its own timeout over ours.
*/
@Injectable()
export class BillResolverService {
private readonly logger = new Logger(BillResolverService.name);
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";
constructor(
private readonly http: HttpService,
private readonly config: ConfigService,
) {}
async billQuery(intent: PaymentIntent): Promise<BillQueryResult> {
const base =
intent.service === PaymentService.PASSENGER
? this.config.get<string>("cbeBill.passengerApiBaseUrl")
: this.config.get<string>("cbeBill.freightApiBaseUrl");
const url = `${base}/internal/payments/bill-query`;
try {
const response = await firstValueFrom(
this.http.post<BillQueryResult>(
url,
{
referenceType: intent.referenceType,
referenceId: intent.referenceId,
},
{
timeout: this.config.get<number>("cbeBill.domainTimeoutMs") ?? 3000,
headers: this.serviceToken
? { "x-service-token": this.serviceToken }
: {},
},
),
);
// The passenger API wraps every response in a { success, data } envelope
// (global transform interceptor); freight returns the body bare. Accept both.
const body = response.data as unknown as {
success?: boolean;
data?: BillQueryResult;
};
return body && typeof body === "object" && "success" in body && body.data
? body.data
: (response.data as BillQueryResult);
} catch (err) {
this.logger.warn(
`bill-query ${intent.service}/${intent.referenceId} unreachable: ${
err instanceof Error ? err.message : String(err)
}`,
);
// TRANSIENT so CBE may retry the same End_To_End_Txn_Id once we recover (plan R5).
throw new CbeBillError("Service temporarily unavailable.", "TRANSIENT");
}
}
}