From 533de3cc93591a757cad846a0d6b94af2e925b1d Mon Sep 17 00:00:00 2001 From: Marshal Date: Thu, 6 Aug 2026 17:50:27 +0000 Subject: [PATCH] fix(cbe): update response code from 3 to 2 for business failures in CBE integration --- .../TrainScheduleV2DetailPage.tsx | 50 +++++++++++++++++-- .../modules/cbe-bill/cbe-bill.controller.ts | 2 +- .../src/modules/cbe-bill/cbe-bill.service.ts | 18 ++++++- .../modules/cbe-bill/cbe-exception.filter.ts | 4 +- .../cbe-bill/mappers/cbe-error.mapper.ts | 4 +- .../cbe-bill/mappers/cbe-payment.mapper.ts | 2 +- .../cbe-bill/mappers/cbe-query.mapper.ts | 2 +- integration/src/cbe-bill.it.ts | 35 ++++++++++--- 8 files changed, 98 insertions(+), 19 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index ce6fe5353..d20ed83ef 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -916,17 +916,57 @@ export default function TrainScheduleV2DetailPage() { {schedule.route?.name ?? "Train schedule"} - {schedule.trainNumber ? ( - - {schedule.trainNumber} - - ) : null} {schedule.train ? ( Train {schedule.train.code} ) : null} + + {/* Voyage (train) number and trade direction — the two things + operations identify a run by, so they read at a glance + rather than as small badges among the rest. */} + + {schedule.trainNumber ? ( + + + Voyage No. + + + {schedule.trainNumber} + + + ) : null} + {schedule.direction ? ( + + + Direction + + + {schedule.direction} + + + ) : null} + {(schedule.stops?.length ?? 0) >= 3 || (schedule.bookings ?? []).some( (b) => b.tradeDirection === "DOMESTIC", diff --git a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts index 46b8f9009..86f8f1475 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/cbe-bill.controller.ts @@ -22,7 +22,7 @@ import { CbePaymentResponseDto } from "./dto/cbe-payment-response.dto"; * CBE Unified Bill Payment — the INBOUND surface CBE core banking calls (docs/cbe/). We are * the biller: CBE authenticates against /cbe/oauth/token with credentials we issued, then * presents the bearer token on /cbe/query and /cbe/payment. Business failures answer HTTP 200 - * with Response_Code "3"; only authentication answers 401 (plan D6/D7). + * with Response_Code "2"; only authentication answers 401 (plan D6/D7). */ @ApiTags("CBE Unified Bill (inbound)") @Controller("cbe") 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 478f60d0c..c56622da0 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 @@ -59,7 +59,7 @@ function localReason(intent: PaymentIntent): BillNotPayableReason { /** * 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 "2" envelopes (never throw past the * controller); the exception filter only catches auth, validation, and the unexpected. */ @Injectable() @@ -184,6 +184,22 @@ export class CbeBillService { ); if (prior) { if (prior.tradeStatus === "SUCCESS") { + // A replay must be the SAME attempt. A reused id with different money details is + // not a retry — echoing the stored success would fake a settlement that never ran. + const orig = prior.requestPayload as unknown as + | CbePaymentRequestDto + | undefined; + if ( + orig && + (orig.Bill_Id !== dto.Bill_Id || + orig.Cbe_Txn_Ref !== dto.Cbe_Txn_Ref || + Number(orig.Amount) !== Number(dto.Amount)) + ) { + return mapPaymentFailure( + dto, + `End_To_End_Txn_Id ${dto.End_To_End_Txn_Id} was already used by a different payment.`, + ); + } // Replay the stored body verbatim. Never re-settle. return prior.responsePayload as unknown as CbePaymentResponseDto; } 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 dcbb5d82a..a5a7055b5 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 @@ -54,7 +54,7 @@ export class CbeExceptionFilter implements ExceptionFilter { : exception.message; response.status(HttpStatus.OK).json({ Status: "FAILED", - Response_Code: "3", + Response_Code: "2", Response_Description: message || "Invalid request", }); return; @@ -65,7 +65,7 @@ export class CbeExceptionFilter implements ExceptionFilter { ); response.status(HttpStatus.OK).json({ Status: "FAILED", - Response_Code: "3", + Response_Code: "2", Response_Description: "Internal server 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 7cda33117..98b5e8825 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 @@ -16,8 +16,8 @@ export class CbeBillError extends Error { } /** - * Every failure maps to Response_Code "3" — the AAFDA spec (§2.10, §3.10) defines only - * 0 (success), 1 (auth), 3 (business); only the description is specific (plan §6.6). + * Every failure maps to Response_Code "2" (per current CBE integration requirement; the + * original AAFDA plan used 3); only the description is specific (plan §6.6). */ export function toCbeFailure(err: unknown): { description: string; diff --git a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts index d90d94c91..8e6259930 100644 --- a/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts +++ b/apps/edr-payment-api/src/modules/cbe-bill/mappers/cbe-payment.mapper.ts @@ -27,7 +27,7 @@ export function mapPaymentFailure( Cbe_Txn_Ref: request.Cbe_Txn_Ref, Destination_Txn_Ref: "", Status: "FAILED", - Response_Code: "3", + Response_Code: "2", Response_Description: description, Additional_Fields: [], }; 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 95caadc75..d2153b6a5 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 @@ -48,7 +48,7 @@ export function mapQueryFailure( Transaction_Type: "", Timestamp: new Date().toISOString(), Status: "FAILED", - Response_Code: "3", + Response_Code: "2", Response_Description: description, Additional_Fields: [], }; diff --git a/integration/src/cbe-bill.it.ts b/integration/src/cbe-bill.it.ts index 974d00b07..a75e897a4 100644 --- a/integration/src/cbe-bill.it.ts +++ b/integration/src/cbe-bill.it.ts @@ -122,15 +122,18 @@ describe("CBE Unified Bill (payment service as biller)", () => { End_To_End_Txn_Id: txnId("q404"), Bill_Id: "000000000000", }); - // Business failures are HTTP 200 + Response_Code "3" — CBE treats a non-200 + // Business failures are HTTP 200 + Response_Code "2" — CBE treats a non-200 // as a channel fault and retries. expect(res.status).toBe(200); - expect(res.body.Response_Code).toBe("3"); + expect(res.body.Response_Code).toBe("2"); }); + let settleBody: Record; + let settledTxnRef: string; + it("settles the freight invoice when CBE reports the debit", async () => { const invoice = await currentInvoice(invoiceId); - const res = await cbe(token, "/cbe/payment", { + settleBody = { Destination_Api_Name: API_NAME, End_To_End_Txn_Id: txnId("p1"), Cbe_Txn_Ref: `CBE${Date.now()}`, @@ -140,9 +143,11 @@ describe("CBE Unified Bill (payment service as biller)", () => { Currency: "ETB", Full_Name: "IT Payer", Phone_No: "+251911000001", - }); + }; + const res = await cbe(token, "/cbe/payment", settleBody); expect(res.status).toBe(200); expect(res.body.Response_Code).toBe("0"); + settledTxnRef = res.body.Destination_Txn_Ref; const paid = await poll<{ status: string }>( "invoice PAID via CBE bill", @@ -154,6 +159,24 @@ describe("CBE Unified Bill (payment service as biller)", () => { expect(paid.status).toBe("PAID"); }); + it("replays the stored success when CBE retries the same attempt verbatim", async () => { + const res = await cbe(token, "/cbe/payment", settleBody); + expect(res.status).toBe(200); + expect(res.body.Response_Code).toBe("0"); + // The stored body, not a re-settlement — same order id as the first answer. + expect(res.body.Destination_Txn_Ref).toBe(settledTxnRef); + }); + + it("rejects a settled End_To_End_Txn_Id reused with a different amount", async () => { + const res = await cbe(token, "/cbe/payment", { + ...settleBody, + Amount: "1.00", + }); + expect(res.status).toBe(200); + expect(res.body.Response_Code).toBe("2"); + expect(res.body.Response_Description).toContain("already used by a different payment"); + }); + it("rejects a second debit on the same bill", async () => { const res = await cbe(token, "/cbe/payment", { Destination_Api_Name: API_NAME, @@ -165,7 +188,7 @@ describe("CBE Unified Bill (payment service as biller)", () => { Currency: "ETB", }); expect(res.status).toBe(200); - expect(res.body.Response_Code).toBe("3"); + expect(res.body.Response_Code).toBe("2"); }); it("reports an already-paid bill on a later query", async () => { @@ -175,6 +198,6 @@ describe("CBE Unified Bill (payment service as biller)", () => { Bill_Id: billId, }); expect(res.status).toBe(200); - expect(res.body.Response_Code).toBe("3"); + expect(res.body.Response_Code).toBe("2"); }); });