mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
fix(cbe): update response code from 3 to 2 for business failures in CBE integration
This commit is contained in:
@@ -916,17 +916,57 @@ export default function TrainScheduleV2DetailPage() {
|
||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||
{schedule.route?.name ?? "Train schedule"}
|
||||
</Title>
|
||||
{schedule.trainNumber ? (
|
||||
<Badge variant="light" color="#F2A516" radius="sm" style={{ fontWeight: 600 }}>
|
||||
{schedule.trainNumber}
|
||||
</Badge>
|
||||
) : null}
|
||||
{schedule.train ? (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
Train {schedule.train.code}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{/* 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. */}
|
||||
<Group gap="lg" align="center" wrap="wrap">
|
||||
{schedule.trainNumber ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
Voyage No.
|
||||
</Text>
|
||||
<Text
|
||||
ff="monospace"
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
style={{ fontSize: 32, color: "#0f172a" }}
|
||||
>
|
||||
{schedule.trainNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{schedule.direction ? (
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lh={1.2}>
|
||||
Direction
|
||||
</Text>
|
||||
<Text
|
||||
fw={800}
|
||||
lh={1.1}
|
||||
tt="uppercase"
|
||||
style={{
|
||||
fontSize: 32,
|
||||
letterSpacing: 0.5,
|
||||
color:
|
||||
schedule.direction === "IMPORT"
|
||||
? "#2E5B96"
|
||||
: schedule.direction === "EXPORT"
|
||||
? "#0A6F4D"
|
||||
: "#0f172a",
|
||||
}}
|
||||
>
|
||||
{schedule.direction}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Group>
|
||||
{(schedule.stops?.length ?? 0) >= 3 ||
|
||||
(schedule.bookings ?? []).some(
|
||||
(b) => b.tradeDirection === "DOMESTIC",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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<string, string>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user