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, 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. */ /** A single line to bill on a generated invoice. */
export interface InvoiceLineInput { export interface InvoiceLineInput {
chargeType: string; chargeType: string;
@@ -1097,6 +1118,7 @@ export class BillingService {
currentAmountMinor?: number | null; currentAmountMinor?: number | null;
currency?: string | null; currency?: string | null;
reason?: string | null; reason?: string | null;
paymentReason?: string | null;
}> { }> {
const repo = this.dataSource.getRepository(Invoice); const repo = this.dataSource.getRepository(Invoice);
const open = await repo.findOne({ const open = await repo.findOne({
@@ -1113,7 +1135,12 @@ export class BillingService {
payerName: open.company?.name ?? null, payerName: open.company?.name ?? null,
currentAmountMinor: balance, currentAmountMinor: balance,
currency: open.currency, 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 }, relations: { company: true },
order: { createdAt: "DESC" }, 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 { return {
stillPayable: false, stillPayable: false,
payerName: latest?.company?.name ?? null, payerName: latest.company?.name ?? null,
currentAmountMinor: latest ? Math.round(Number(latest.totalAmount)) : null, currentAmountMinor: Math.round(Number(latest.totalAmount)),
currency: latest?.currency ?? null, currency: latest.currency,
reason: paymentReason: `Freight invoice ${latest.invoiceNumber}`,
latest?.status === Freight.InvoiceStatus.Paid reason: closedInvoiceReason(latest.status),
? "ALREADY_PAID"
: "CANCELLED",
}; };
} }
} }

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. * "is this invoice still payable, by whom, for how much" while a CBE channel is on the line.
*/ */
export class BillQueryRequestDto { 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 }) @ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType) @IsEnum(PaymentReferenceType)
referenceType!: PaymentReferenceType; referenceType!: string;
@ApiProperty() @IsString() referenceId!: string; @ApiProperty() @IsString() referenceId!: string;
} }
@@ -69,6 +73,8 @@ export class BillQueryResponseDto {
@ApiPropertyOptional() payerName?: string | null; @ApiPropertyOptional() payerName?: string | null;
@ApiPropertyOptional() currentAmountMinor?: number | null; @ApiPropertyOptional() currentAmountMinor?: number | null;
@ApiPropertyOptional() currency?: string | 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; @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. * "is this order still payable, by whom, for how much" while a CBE teller/app is on the line.
*/ */
export class BillQueryRequestDto { 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 }) @ApiProperty({ enum: PaymentReferenceType })
@IsEnum(PaymentReferenceType) @IsEnum(PaymentReferenceType)
referenceType!: PaymentReferenceType; referenceType!: string;
@ApiProperty() @IsString() referenceId!: string; @ApiProperty() @IsString() referenceId!: string;
} }
@@ -73,6 +77,8 @@ export class BillQueryResponseDto {
@ApiPropertyOptional() payerName?: string | null; @ApiPropertyOptional() payerName?: string | null;
@ApiPropertyOptional() currentAmountMinor?: number | null; @ApiPropertyOptional() currentAmountMinor?: number | null;
@ApiPropertyOptional() currency?: string | 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; @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 }, where: { id: bookingId },
include: { seats: true, passenger: { include: { user: true } } }, 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 = { const base = {
// Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder. // Full_Name is mandatory in CBE's envelope: lead passenger first, then account holder.
@@ -414,14 +416,26 @@ export class PaymentsService {
"ETB", "ETB",
), ),
currency: "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) { if (booking.status === "CONFIRMED" || booking.paidAt) {
return { ...base, stillPayable: false, reason: "ALREADY_PAID" }; 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" }; 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); const deadline = await this.computeBookingPaymentDeadline(booking.id);
if (deadline && deadline.getTime() < Date.now()) { if (deadline && deadline.getTime() < Date.now()) {
return { ...base, stillPayable: false, reason: "EXPIRED" }; return { ...base, stillPayable: false, reason: "EXPIRED" };

View File

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

View File

@@ -2,30 +2,82 @@ import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { HttpService } from "@nestjs/axios"; import { HttpService } from "@nestjs/axios";
import { firstValueFrom } from "rxjs"; import { firstValueFrom } from "rxjs";
import { PaymentService } from "@edr/types"; import { PaymentReferenceType, PaymentService } from "@edr/types";
import { PaymentIntent } from "../intents/entities/payment-intent.entity"; import { PaymentIntent } from "../intents/entities/payment-intent.entity";
import { CbeBillError } from "./mappers/cbe-error.mapper"; 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). */ /** Contract of POST /internal/payments/bill-query on the domain apps (plan Phase 4). */
export interface BillQueryResult { export interface BillQueryResult {
stillPayable: boolean; stillPayable: boolean;
payerName?: string | null; payerName?: string | null;
currentAmountMinor?: number | null; currentAmountMinor?: number | null;
currency?: string | null; currency?: string | null;
/** When stillPayable=false: "CANCELLED" | "ALREADY_PAID" | "EXPIRED". */ /** When stillPayable=false — see {@link BillNotPayableReason}. */
reason?: string | null; 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.", * Payment_Reason when the domain app sends none (older build, or an order with no human
ALREADY_PAID: "Bill already paid.", * reference). Generic but never blank: CBE renders this field to the payer, and a bill with
EXPIRED: "Bill has expired.", * 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 ( * CBE reads Response_Description back to the payer at the counter or in the USSD prompt, so it
(reason && REASON_DESCRIPTIONS[reason]) || "Bill is not payable." * 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 { IntentsService } from "../intents/intents.service";
import { PaymentIntent } from "../intents/entities/payment-intent.entity"; import { PaymentIntent } from "../intents/entities/payment-intent.entity";
import { import {
BillNotPayableReason,
BillResolverService, BillResolverService,
defaultPaymentReason,
reasonToDescription, reasonToDescription,
} from "./bill-resolver.service"; } from "./bill-resolver.service";
import { CbeBillRepository } from "./cbe-bill.repository"; import { CbeBillRepository } from "./cbe-bill.repository";
@@ -39,6 +41,22 @@ const PG_UNIQUE_VIOLATION = "23505";
/** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */ /** Mirrors the short-pay tolerance already applied in handlePaymentEvent. */
const AMOUNT_TOLERANCE = 0.01; 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). * 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 * Business failures return HTTP 200 + Response_Code "3" envelopes (never throw past the
@@ -105,20 +123,13 @@ export class CbeBillService {
try { try {
const intent = await this.resolveIntent(dto.Bill_Id); const intent = await this.resolveIntent(dto.Bill_Id);
if (intent.status !== ProviderPaymentStatus.REQUIRES_ACTION) { this.assertIntentPayable(intent);
throw new CbeBillError(
intent.status === ProviderPaymentStatus.SUCCEEDED
? "Bill already paid."
: "Bill is not payable.",
"BUSINESS",
);
}
// The live domain hop — the double-payment guard (§6.3). Not optional. // The live domain hop — the double-payment guard (§6.3). Not optional.
const billQuery = await this.billResolver.billQuery(intent); const billQuery = await this.billResolver.billQuery(intent);
if (!billQuery.stillPayable) { if (!billQuery.stillPayable) {
throw new CbeBillError( throw new CbeBillError(
reasonToDescription(billQuery.reason), reasonToDescription(billQuery.reason, intent.referenceType),
"BUSINESS", "BUSINESS",
); );
} }
@@ -126,6 +137,8 @@ export class CbeBillService {
const response = mapQuerySuccess(dto, { const response = mapQuerySuccess(dto, {
amountMajor: billQuery.currentAmountMinor ?? intent.amountMinor, amountMajor: billQuery.currentAmountMinor ?? intent.amountMinor,
fullName: billQuery.payerName || intent.payerName || "", fullName: billQuery.payerName || intent.payerName || "",
paymentReason:
billQuery.paymentReason || defaultPaymentReason(intent.referenceType),
}); });
await this.finishAudit(audit, { await this.finishAudit(audit, {
intentId: intent.id, intentId: intent.id,
@@ -235,7 +248,7 @@ export class CbeBillService {
const billQuery = await this.billResolver.billQuery(intent); const billQuery = await this.billResolver.billQuery(intent);
if (!billQuery.stillPayable) { if (!billQuery.stillPayable) {
throw new CbeBillError( throw new CbeBillError(
reasonToDescription(billQuery.reason), reasonToDescription(billQuery.reason, intent.referenceType),
"BUSINESS", "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. */ /** Check digit first (cheap reject), then the unique bill_reference lookup. */
private async resolveIntent(billId: string): Promise<PaymentIntent> { private async resolveIntent(billId: string): Promise<PaymentIntent> {
if (!this.billReferenceService.isValid(billId)) { if (!this.billReferenceService.isValid(billId)) {

View File

@@ -3,7 +3,7 @@ import { CbeQueryResponseDto } from "../dto/cbe-query-response.dto";
export function mapQuerySuccess( export function mapQuerySuccess(
request: CbeQueryRequestDto, request: CbeQueryRequestDto,
input: { amountMajor: number; fullName: string }, input: { amountMajor: number; fullName: string; paymentReason: string },
): CbeQueryResponseDto { ): CbeQueryResponseDto {
const amount = input.amountMajor.toFixed(2); const amount = input.amountMajor.toFixed(2);
return { return {
@@ -16,7 +16,7 @@ export function mapQuerySuccess(
First_Name: "", First_Name: "",
Last_Name: "", Last_Name: "",
Full_Name: input.fullName, Full_Name: input.fullName,
Payment_Reason: "", Payment_Reason: input.paymentReason,
Tin_Number: "", Tin_Number: "",
Credit_Acct_Number: "", Credit_Acct_Number: "",
Transaction_Type: "", Transaction_Type: "",