From aa4ce7fd23a240da77966b5f193a17d472d562b8 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 31 Jul 2026 16:03:28 +0300 Subject: [PATCH] feat: ( payment ) specific CBE query descriptions + Payment_Reason --- .../src/modules/billing/billing.service.ts | 52 +++++++++++-- .../modules/payment/internal-payment.dto.ts | 10 ++- .../modules/payments/internal-payments.dto.ts | 10 ++- .../src/modules/payments/payments.service.ts | 18 ++++- .../src/app/payment-methods/page.tsx | 1 + .../modules/cbe-bill/bill-resolver.service.ts | 76 ++++++++++++++++--- .../src/modules/cbe-bill/cbe-bill.service.ts | 50 +++++++++--- .../cbe-bill/mappers/cbe-query.mapper.ts | 4 +- 8 files changed, 183 insertions(+), 38 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index e40e966be..2a08be24b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -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), }; } } diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts index fd022ebc1..e69a0e15a 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.dto.ts @@ -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; } diff --git a/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts index 67a8c67d4..d2b88372e 100644 --- a/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts +++ b/apps/edr-passenger-api/src/modules/payments/internal-payments.dto.ts @@ -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; } diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 79b656ebd..14e48dce5 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -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" }; diff --git a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx index 4e6a71950..c2965ea5c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/payment-methods/page.tsx @@ -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' }, diff --git a/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts index 99aebe1cd..f46571c6b 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/bill-resolver.service.ts @@ -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 = { - 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.`; + } } /** diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts index b1dd6f785..5b1a6cb28 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.service.ts @@ -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 { if (!this.billReferenceService.isValid(billId)) { diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts index 16ce4db70..95caadc75 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-query.mapper.ts @@ -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: "",