mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
feat: ( payment ) specific CBE query descriptions + Payment_Reason
This commit is contained in:
@@ -2,30 +2,82 @@ import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { PaymentService } from "@edr/types";
|
||||
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: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
|
||||
reason?: 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;
|
||||
}
|
||||
|
||||
const REASON_DESCRIPTIONS: Record<string, string> = {
|
||||
CANCELLED: "Bill has been cancelled.",
|
||||
ALREADY_PAID: "Bill already paid.",
|
||||
EXPIRED: "Bill has expired.",
|
||||
};
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
|
||||
export function reasonToDescription(reason?: string | null): string {
|
||||
return (
|
||||
(reason && REASON_DESCRIPTIONS[reason]) || "Bill is not payable."
|
||||
);
|
||||
/**
|
||||
* 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.`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,9 @@ import { IntentsRepository } from "../intents/intents.repository";
|
||||
import { IntentsService } from "../intents/intents.service";
|
||||
import { PaymentIntent } from "../intents/entities/payment-intent.entity";
|
||||
import {
|
||||
BillNotPayableReason,
|
||||
BillResolverService,
|
||||
defaultPaymentReason,
|
||||
reasonToDescription,
|
||||
} from "./bill-resolver.service";
|
||||
import { CbeBillRepository } from "./cbe-bill.repository";
|
||||
@@ -39,6 +41,22 @@ const PG_UNIQUE_VIOLATION = "23505";
|
||||
/** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */
|
||||
const AMOUNT_TOLERANCE = 0.01;
|
||||
|
||||
/**
|
||||
* Translate an intent's own terminal state into the same reason vocabulary the domain apps
|
||||
* speak, so both paths flow through one description mapper.
|
||||
*/
|
||||
function localReason(intent: PaymentIntent): BillNotPayableReason {
|
||||
switch (intent.status) {
|
||||
case ProviderPaymentStatus.SUCCEEDED:
|
||||
return "ALREADY_PAID";
|
||||
case ProviderPaymentStatus.CANCELLED:
|
||||
// expireIntent() cancels with failureCode EXPIRED — an abandoned bill, not a cancellation.
|
||||
return intent.failureCode === "EXPIRED" ? "EXPIRED" : "CANCELLED";
|
||||
default:
|
||||
return "NOT_PAYABLE";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -105,20 +123,13 @@ export class CbeBillService {
|
||||
|
||||
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",
|
||||
);
|
||||
}
|
||||
this.assertIntentPayable(intent);
|
||||
|
||||
// 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),
|
||||
reasonToDescription(billQuery.reason, intent.referenceType),
|
||||
"BUSINESS",
|
||||
);
|
||||
}
|
||||
@@ -126,6 +137,8 @@ export class CbeBillService {
|
||||
const response = mapQuerySuccess(dto, {
|
||||
amountMajor: billQuery.currentAmountMinor ?? intent.amountMinor,
|
||||
fullName: billQuery.payerName || intent.payerName || "",
|
||||
paymentReason:
|
||||
billQuery.paymentReason || defaultPaymentReason(intent.referenceType),
|
||||
});
|
||||
await this.finishAudit(audit, {
|
||||
intentId: intent.id,
|
||||
@@ -235,7 +248,7 @@ export class CbeBillService {
|
||||
const billQuery = await this.billResolver.billQuery(intent);
|
||||
if (!billQuery.stillPayable) {
|
||||
throw new CbeBillError(
|
||||
reasonToDescription(billQuery.reason),
|
||||
reasonToDescription(billQuery.reason, intent.referenceType),
|
||||
"BUSINESS",
|
||||
);
|
||||
}
|
||||
@@ -295,6 +308,23 @@ export class CbeBillService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The cheap local gate before the domain hop: what OUR record of this attempt says. Only
|
||||
* REQUIRES_ACTION is payable; every other status gets a description naming the actual reason,
|
||||
* because CBE reads it back to the payer standing at the counter. All BUSINESS — none of these
|
||||
* states can change back, so a same-End_To_End_Txn_Id retry cannot produce a different answer.
|
||||
*/
|
||||
private assertIntentPayable(intent: PaymentIntent): void {
|
||||
if (intent.status === ProviderPaymentStatus.REQUIRES_ACTION) return;
|
||||
if (intent.status === ProviderPaymentStatus.PROCESSING) {
|
||||
throw new CbeBillError("Payment is being processed.", "BUSINESS");
|
||||
}
|
||||
throw new CbeBillError(
|
||||
reasonToDescription(localReason(intent), intent.referenceType),
|
||||
"BUSINESS",
|
||||
);
|
||||
}
|
||||
|
||||
/** Check digit first (cheap reject), then the unique bill_reference lookup. */
|
||||
private async resolveIntent(billId: string): Promise<PaymentIntent> {
|
||||
if (!this.billReferenceService.isValid(billId)) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CbeQueryResponseDto } from "../dto/cbe-query-response.dto";
|
||||
|
||||
export function mapQuerySuccess(
|
||||
request: CbeQueryRequestDto,
|
||||
input: { amountMajor: number; fullName: string },
|
||||
input: { amountMajor: number; fullName: string; paymentReason: string },
|
||||
): CbeQueryResponseDto {
|
||||
const amount = input.amountMajor.toFixed(2);
|
||||
return {
|
||||
@@ -16,7 +16,7 @@ export function mapQuerySuccess(
|
||||
First_Name: "",
|
||||
Last_Name: "",
|
||||
Full_Name: input.fullName,
|
||||
Payment_Reason: "",
|
||||
Payment_Reason: input.paymentReason,
|
||||
Tin_Number: "",
|
||||
Credit_Acct_Number: "",
|
||||
Transaction_Type: "",
|
||||
|
||||
Reference in New Issue
Block a user