mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
181 lines
6.1 KiB
TypeScript
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");
|
|
});
|
|
});
|