mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
142 lines
5.3 KiB
TypeScript
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");
|
|
}
|
|
}
|
|
}
|