feat: ( payment ) specific CBE query descriptions + Payment_Reason

This commit is contained in:
Abubeker Yasin
2026-07-31 16:03:28 +03:00
parent 37855b0a83
commit aa4ce7fd23
8 changed files with 183 additions and 38 deletions

View File

@@ -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.`;
}
}
/**

View File

@@ -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)) {

View File

@@ -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: "",