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");
});
});