Merge pull request #1047 from Tria-plc/cbe-integration

feat: ( payment ) specific CBE query descriptions + Payment_Reason
This commit is contained in:
Abubeker Yasin
2026-07-31 16:04:44 +03:00
committed by GitHub
8 changed files with 183 additions and 38 deletions

View File

@@ -55,6 +55,27 @@ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Overdue,
];
/**
* Why a non-open invoice can no longer be paid, in the vocabulary the payment service's CBE
* bill-query mapper understands. Kept specific: CBE reads this back to the payer at the counter,
* so "cancelled" must not stand in for "already paid" or "refunded".
*/
function closedInvoiceReason(status: Freight.InvoiceStatus): string {
switch (status) {
case Freight.InvoiceStatus.Paid:
return "ALREADY_PAID";
case Freight.InvoiceStatus.Refunded:
return "REFUNDED";
case Freight.InvoiceStatus.Cancelled:
return "CANCELLED";
case Freight.InvoiceStatus.Expired:
return "EXPIRED";
// Draft — issued to nobody yet, so there is nothing honest to say beyond "not payable".
default:
return "NOT_PAYABLE";
}
}
/** A single line to bill on a generated invoice. */
export interface InvoiceLineInput {
chargeType: string;
@@ -1097,6 +1118,7 @@ export class BillingService {
currentAmountMinor?: number | null;
currency?: string | null;
reason?: string | null;
paymentReason?: string | null;
}> {
const repo = this.dataSource.getRepository(Invoice);
const open = await repo.findOne({
@@ -1113,7 +1135,12 @@ export class BillingService {
payerName: open.company?.name ?? null,
currentAmountMinor: balance,
currency: open.currency,
reason: expired ? "EXPIRED" : balance > 0 ? null : "ALREADY_PAID",
// CBE shows this beside the amount on the confirmation screen — the invoice number
// the payer is holding, not our internal reference.
paymentReason: `Freight invoice ${open.invoiceNumber}`,
// Settled-in-full wins over past-due: an invoice with nothing left to pay is paid, not
// expired, and that is what the payer at the CBE counter must be told.
reason: balance > 0 ? (expired ? "EXPIRED" : null) : "ALREADY_PAID",
};
}
@@ -1122,15 +1149,24 @@ export class BillingService {
relations: { company: true },
order: { createdAt: "DESC" },
});
// A bill reference whose invoice no longer exists at all — a data problem, not a
// cancellation the payer did anything to cause.
if (!latest) {
return {
stillPayable: false,
payerName: null,
currentAmountMinor: null,
currency: null,
reason: "NOT_FOUND",
};
}
return {
stillPayable: false,
payerName: latest?.company?.name ?? null,
currentAmountMinor: latest ? Math.round(Number(latest.totalAmount)) : null,
currency: latest?.currency ?? null,
reason:
latest?.status === Freight.InvoiceStatus.Paid
? "ALREADY_PAID"
: "CANCELLED",
payerName: latest.company?.name ?? null,
currentAmountMinor: Math.round(Number(latest.totalAmount)),
currency: latest.currency,
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
reason: closedInvoiceReason(latest.status),
};
}
}

View File

@@ -57,9 +57,13 @@ export class MarkPaidResponseDto {
* "is this invoice still payable, by whom, for how much" while a CBE channel is on the line.
*/
export class BillQueryRequestDto {
// Typed `string`, not the enum: the @nestjs/swagger CLI plugin resolves an enum-typed
// property to a relative require() into packages/types, which does not exist inside the
// Docker image (only /app is copied) and crashes at boot with MODULE_NOT_FOUND. The
// decorators below still give us enum docs + runtime validation.
@ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType)
referenceType!: PaymentReferenceType;
referenceType!: string;
@ApiProperty() @IsString() referenceId!: string;
}
@@ -69,6 +73,8 @@ export class BillQueryResponseDto {
@ApiPropertyOptional() payerName?: string | null;
@ApiPropertyOptional() currentAmountMinor?: number | null;
@ApiPropertyOptional() currency?: string | null;
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
/** When stillPayable=false: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */
@ApiPropertyOptional() reason?: string | null;
/** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */
@ApiPropertyOptional() paymentReason?: string | null;
}

View File

@@ -61,9 +61,13 @@ export class MarkPaidResponseDto {
* "is this order still payable, by whom, for how much" while a CBE teller/app is on the line.
*/
export class BillQueryRequestDto {
// Typed `string`, not the enum: the @nestjs/swagger CLI plugin resolves an enum-typed
// property to a relative require() into packages/types, which does not exist inside the
// Docker image (only /app is copied) and crashes at boot with MODULE_NOT_FOUND. The
// decorators below still give us enum docs + runtime validation.
@ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType)
referenceType!: PaymentReferenceType;
referenceType!: string;
@ApiProperty() @IsString() referenceId!: string;
}
@@ -73,6 +77,8 @@ export class BillQueryResponseDto {
@ApiPropertyOptional() payerName?: string | null;
@ApiPropertyOptional() currentAmountMinor?: number | null;
@ApiPropertyOptional() currency?: string | null;
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */
/** When stillPayable=false: "ALREADY_PAID" | "CANCELLED" | "REFUNDED" | "EXPIRED" | "NOT_FOUND" | "NOT_PAYABLE". */
@ApiPropertyOptional() reason?: string | null;
/** What the payer is paying for — CBE renders it beside the amount (Payment_Reason). */
@ApiPropertyOptional() paymentReason?: string | null;
}

View File

@@ -400,7 +400,9 @@ export class PaymentsService {
where: { id: bookingId },
include: { seats: true, passenger: { include: { user: true } } },
});
if (!booking) return { stillPayable: false, reason: "CANCELLED" };
// Distinct from CANCELLED: the payment service issued a bill reference for a booking that
// no longer exists at all, which is a data problem, not a customer-facing cancellation.
if (!booking) return { stillPayable: false, reason: "NOT_FOUND" };
const base = {
// Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder.
@@ -414,14 +416,26 @@ export class PaymentsService {
"ETB",
),
currency: "ETB",
// CBE shows this beside the amount on the confirmation screen. bookingRef is the same
// code on the customer's ticket, so they can match the two before confirming.
paymentReason: `Train ticket booking ${booking.bookingRef}`,
};
// Paid first: a booking that was paid and then boarded/refunded must never be reported as
// merely "not payable" — the payer needs to hear that their money already went through.
if (booking.status === "CONFIRMED" || booking.paidAt) {
return { ...base, stillPayable: false, reason: "ALREADY_PAID" };
}
if (booking.status !== "PENDING_PAYMENT") {
if (booking.status === "REFUNDED") {
return { ...base, stillPayable: false, reason: "REFUNDED" };
}
if (booking.status === "CANCELLED") {
return { ...base, stillPayable: false, reason: "CANCELLED" };
}
// DRAFT / BOARDED / NO_SHOW without a payment: no honest specific wording exists.
if (booking.status !== "PENDING_PAYMENT") {
return { ...base, stillPayable: false, reason: "NOT_PAYABLE" };
}
const deadline = await this.computeBookingPaymentDeadline(booking.id);
if (deadline && deadline.getTime() < Date.now()) {
return { ...base, stillPayable: false, reason: "EXPIRED" };

View File

@@ -200,6 +200,7 @@ export default function PaymentMethodsPage() {
const paymentTypes = [
{ value: 'TELEBIRR', label: 'Telebirr' },
{ value: 'CBE_BIRR', label: 'CBE Birr' },
{ value: 'CBE_BILL', label: 'CBE Bill Payment' },
{ value: 'EBIRR', label: 'eBirr' },
{ value: 'WAAFI', label: 'Waafi' },
{ value: 'DMONEY', label: 'dMoney' },

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