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 f46571c6b..342fee04a 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 @@ -48,35 +48,23 @@ export function defaultPaymentReason( : "Freight invoice"; } -/** - * 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); +/** Short descriptions per CBE integration request — CBE's channel renders them as-is. */ +export function reasonToDescription(reason: string | null | undefined): string { switch (reason) { case "ALREADY_PAID": - return `This ${subject} has already been paid.`; + return "Already paid"; case "CANCELLED": - return `This ${subject} has been cancelled.`; + return "Cancelled"; case "REFUNDED": - return `This ${subject} has been refunded.`; + return "Refunded"; case "EXPIRED": - return `This ${subject} has expired and can no longer be paid.`; + return "Expired"; // 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."; + return "Bill not found"; default: - return `This ${subject} is no longer payable.`; + return "Not payable"; } } @@ -135,7 +123,7 @@ export class BillResolverService { }`, ); // TRANSIENT so CBE may retry the same End_To_End_Txn_Id once we recover (plan R5). - throw new CbeBillError("Service temporarily unavailable.", "TRANSIENT"); + throw new CbeBillError("Service unavailable", "TRANSIENT"); } } } 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 c56622da0..8a915ece7 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 @@ -130,7 +130,7 @@ export class CbeBillService { const billQuery = await this.billResolver.billQuery(intent); if (!billQuery.stillPayable) { throw new CbeBillError( - reasonToDescription(billQuery.reason, intent.referenceType), + reasonToDescription(billQuery.reason), "BUSINESS", ); } @@ -197,14 +197,14 @@ export class CbeBillService { ) { return mapPaymentFailure( dto, - `End_To_End_Txn_Id ${dto.End_To_End_Txn_Id} was already used by a different payment.`, + "Duplicate End_To_End_Txn_Id", ); } // Replay the stored body verbatim. Never re-settle. return prior.responsePayload as unknown as CbePaymentResponseDto; } if (prior.tradeStatus === "PENDING") { - return mapPaymentFailure(dto, "Payment is being processed."); + return mapPaymentFailure(dto, "Payment in progress"); } if (prior.failureClass === "BUSINESS") { // Final — retrying cannot change the answer. Same End_To_End_Txn_Id was already @@ -212,7 +212,7 @@ export class CbeBillService { // echoing the original reason, which no longer describes this request. return mapPaymentFailure( dto, - `End_To_End_Txn_Id ${dto.End_To_End_Txn_Id} was already processed and failed: ${prior.responseDescription ?? "unknown reason"}.`, + `Already processed: ${prior.responseDescription ?? "failed"}`, ); } // FAILED + TRANSIENT: allowed retry — fall through and re-run the settlement. @@ -225,7 +225,7 @@ export class CbeBillService { if (settled) { return mapPaymentFailure( dto, - `Invalid transaction reference number ${dto.Cbe_Txn_Ref}.`, + "Duplicate transaction ref", ); } @@ -253,7 +253,7 @@ export class CbeBillService { } catch (err) { if ((err as { code?: string }).code === PG_UNIQUE_VIOLATION) { // Concurrent duplicate of the same attempt lost the insert race. - return mapPaymentFailure(dto, "Payment is being processed."); + return mapPaymentFailure(dto, "Payment in progress"); } throw err; } @@ -264,7 +264,7 @@ export class CbeBillService { intent = await this.resolveIntent(dto.Bill_Id); if (dto.Currency && dto.Currency !== intent.currency) { - throw new CbeBillError("Payment currency does not match.", "BUSINESS"); + throw new CbeBillError("Currency mismatch", "BUSINESS"); } // Re-run bill-query — fresh, never cached. Last legitimate point for a synchronous @@ -272,7 +272,7 @@ export class CbeBillService { const billQuery = await this.billResolver.billQuery(intent); if (!billQuery.stillPayable) { throw new CbeBillError( - reasonToDescription(billQuery.reason, intent.referenceType), + reasonToDescription(billQuery.reason), "BUSINESS", ); } @@ -283,7 +283,7 @@ export class CbeBillService { Math.abs(amount - intent.amountMinor) > intent.amountMinor * AMOUNT_TOLERANCE ) { - throw new CbeBillError("Payment amount does not match.", "BUSINESS"); + throw new CbeBillError("Amount mismatch", "BUSINESS"); } const paidAt = new Date(dto.Timestamp); @@ -341,10 +341,10 @@ export class CbeBillService { 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("Payment in progress", "BUSINESS"); } throw new CbeBillError( - reasonToDescription(localReason(intent), intent.referenceType), + reasonToDescription(localReason(intent)), "BUSINESS", ); } @@ -352,11 +352,11 @@ export class CbeBillService { /** Check digit first (cheap reject), then the unique bill_reference lookup. */ private async resolveIntent(billId: string): Promise { if (!this.billReferenceService.isValid(billId)) { - throw new CbeBillError("Bill not found.", "BUSINESS"); + throw new CbeBillError("Bill not found", "BUSINESS"); } const intent = await this.intentsRepository.findByBillReference(billId); if (!intent || intent.provider !== ProviderMethod.CBE_BILL) { - throw new CbeBillError("Bill not found.", "BUSINESS"); + throw new CbeBillError("Bill not found", "BUSINESS"); } return intent; } diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts index a5a7055b5..5024c8cf3 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-exception.filter.ts @@ -40,7 +40,7 @@ export class CbeExceptionFilter implements ExceptionFilter { response.status(HttpStatus.SERVICE_UNAVAILABLE).json({ Status: "FAILED", Response_Code: "9", - Response_Description: "Service temporarily unavailable.", + Response_Description: "Service unavailable", }); return; } @@ -66,7 +66,7 @@ export class CbeExceptionFilter implements ExceptionFilter { response.status(HttpStatus.OK).json({ Status: "FAILED", Response_Code: "2", - Response_Description: "Internal server error.", + Response_Description: "Internal error", }); } } diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts index 98b5e8825..e2ec58114 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-error.mapper.ts @@ -26,5 +26,5 @@ export function toCbeFailure(err: unknown): { if (err instanceof CbeBillError) { return { description: err.message, failureClass: err.failureClass }; } - return { description: "Internal server error.", failureClass: "TRANSIENT" }; + return { description: "Internal error", failureClass: "TRANSIENT" }; } diff --git a/integration/src/cbe-bill.it.ts b/integration/src/cbe-bill.it.ts index a75e897a4..f630e9a88 100644 --- a/integration/src/cbe-bill.it.ts +++ b/integration/src/cbe-bill.it.ts @@ -174,7 +174,7 @@ describe("CBE Unified Bill (payment service as biller)", () => { }); expect(res.status).toBe(200); expect(res.body.Response_Code).toBe("2"); - expect(res.body.Response_Description).toContain("already used by a different payment"); + expect(res.body.Response_Description).toBe("Duplicate End_To_End_Txn_Id"); }); it("rejects a second debit on the same bill", async () => {