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 { const base = intent.service === PaymentService.PASSENGER ? this.config.get("cbeBill.passengerApiBaseUrl") : this.config.get("cbeBill.freightApiBaseUrl"); const url = `${base}/internal/payments/bill-query`; try { const response = await firstValueFrom( this.http.post( url, { referenceType: intent.referenceType, referenceId: intent.referenceId, }, { timeout: this.config.get("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"); } } }