Files
edr-platform/integration/src/cbe-bill.it.ts
2026-08-03 10:51:43 +00:00

181 lines
6.1 KiB
TypeScript

/**
* CBE Unified Bill — the INBOUND direction, and the only flow where the
* payment service calls freight rather than the other way round:
*
* customer picks CBE_BILL → freight → payment API mints a bill reference
* CBE POST /cbe/oauth/token → bearer token we issued
* CBE POST /cbe/query → payment API → freight /internal/payments/bill-query
* → payer name + live balance
* CBE POST /cbe/payment → intent settles → freight invoice PAID
*
* Both hops run real code on both sides; nothing is stubbed here at all.
*/
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { PAYMENT_API, closeDb, db, gateway, poll } from "./client";
import {
createImportSchedule,
currentInvoice,
departureAt,
ensureCorridorRoute,
releaseUnpaidHolds,
forceWindowOpen,
gatewayIntent,
invoiceForBooking,
payInvoice,
prepareBooking,
resetCorridorDay,
runBatch,
type ReadyBooking,
} from "./flows";
const DEPARTURE = departureAt(10);
const STAMP = String(Date.now());
const API_NAME = "EDR_FREIGHT";
const txnId = (tag: string) => `IT-${tag}-${Date.now()}`;
async function cbeToken(): Promise<string> {
const res = await request(PAYMENT_API).post("/cbe/oauth/token").send({
grant_type: "client_credentials",
client_id: "it-cbe-bill",
client_secret: "it-cbe-bill-secret",
scope: "Unified_Outgoing",
});
const token = res.body?.access_token ?? res.body?.data?.access_token;
if (!token) throw new Error(`cbe token failed: ${res.status} ${JSON.stringify(res.body)}`);
return token;
}
const cbe = (token: string, path: string, body: object) =>
request(PAYMENT_API).post(path).set("Authorization", `Bearer ${token}`).send(body);
describe("CBE Unified Bill (payment service as biller)", () => {
let booking: ReadyBooking;
let invoiceId: string;
let billId: string;
let token: string;
beforeAll(async () => {
await gateway.reset();
await releaseUnpaidHolds();
await ensureCorridorRoute();
await resetCorridorDay(DEPARTURE);
const schedule = await createImportSchedule({ departure: DEPARTURE });
await forceWindowOpen(schedule.id, 45);
booking = await prepareBooking({
suffix: "BILL1",
departure: DEPARTURE,
runStamp: STAMP,
isoSeed: 300,
twenty: 2,
currency: "ETB",
});
await runBatch(schedule.id);
invoiceId = (await invoiceForBooking(booking.bookingId)).id;
const res = await payInvoice(invoiceId, { method: "CBE_BILL" });
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
const intent = await gatewayIntent(booking.bookingId);
const [row] = await db<{ bill_reference: string }>(
`SELECT bill_reference FROM edr_payment.payment_intent WHERE id = $1`,
[intent.id],
);
billId = row.bill_reference;
token = await cbeToken();
}, 600_000);
afterAll(closeDb);
it("mints a 12-digit bill reference the customer can quote at any CBE channel", () => {
expect(billId).toMatch(/^\d{12}$/);
});
it("refuses the query without a token we issued", async () => {
const res = await request(PAYMENT_API)
.post("/cbe/query")
.send({ Destination_Api_Name: API_NAME, End_To_End_Txn_Id: txnId("noauth"), Bill_Id: billId });
expect(res.status).toBe(401);
});
it("answers the bill lookup from live freight data", async () => {
const invoice = await currentInvoice(invoiceId);
const res = await cbe(token, "/cbe/query", {
Destination_Api_Name: API_NAME,
End_To_End_Txn_Id: txnId("q1"),
Bill_Id: billId,
});
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("0");
expect(res.body.Bill_Id).toBe(billId);
// The amount and payer come from freight's billQuery, not from a cached
// copy in the payment service.
expect(Number(res.body.Total_Amount)).toBeCloseTo(Number(invoice.balance_amount), 2);
expect(res.body.Full_Name).toBe("E2E Logistics PLC");
expect(res.body.Payment_Reason).toMatch(/invoice/i);
});
it("reports an unknown bill as a business failure, not an error", async () => {
const res = await cbe(token, "/cbe/query", {
Destination_Api_Name: API_NAME,
End_To_End_Txn_Id: txnId("q404"),
Bill_Id: "000000000000",
});
// Business failures are HTTP 200 + Response_Code "3" — CBE treats a non-200
// as a channel fault and retries.
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("3");
});
it("settles the freight invoice when CBE reports the debit", async () => {
const invoice = await currentInvoice(invoiceId);
const res = await cbe(token, "/cbe/payment", {
Destination_Api_Name: API_NAME,
End_To_End_Txn_Id: txnId("p1"),
Cbe_Txn_Ref: `CBE${Date.now()}`,
Timestamp: new Date().toISOString(),
Bill_Id: billId,
Amount: String(invoice.balance_amount),
Currency: "ETB",
Full_Name: "IT Payer",
Phone_No: "+251911000001",
});
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("0");
const paid = await poll<{ status: string }>(
"invoice PAID via CBE bill",
`SELECT status FROM freight.invoices WHERE id = $1`,
[invoiceId],
(row) => row?.status === "PAID",
{ attempts: 30, intervalMs: 2000 },
);
expect(paid.status).toBe("PAID");
});
it("rejects a second debit on the same bill", async () => {
const res = await cbe(token, "/cbe/payment", {
Destination_Api_Name: API_NAME,
End_To_End_Txn_Id: txnId("p2"),
Cbe_Txn_Ref: `CBE${Date.now()}`,
Timestamp: new Date().toISOString(),
Bill_Id: billId,
Amount: "1.00",
Currency: "ETB",
});
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("3");
});
it("reports an already-paid bill on a later query", async () => {
const res = await cbe(token, "/cbe/query", {
Destination_Api_Name: API_NAME,
End_To_End_Txn_Id: txnId("q2"),
Bill_Id: billId,
});
expect(res.status).toBe(200);
expect(res.body.Response_Code).toBe("3");
});
});