/** * 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 { 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 "2" — CBE treats a non-200 // as a channel fault and retries. expect(res.status).toBe(200); expect(res.body.Response_Code).toBe("2"); }); let settleBody: Record; it("settles the freight invoice when CBE reports the debit", async () => { const invoice = await currentInvoice(invoiceId); settleBody = { 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", }; const res = await cbe(token, "/cbe/payment", settleBody); 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("answers 'Already paid' when the settled attempt is sent again verbatim", async () => { const res = await cbe(token, "/cbe/payment", settleBody); expect(res.status).toBe(200); expect(res.body.Status).toBe("FAILED"); expect(res.body.Response_Code).toBe("2"); expect(res.body.Response_Description).toBe("Already paid"); }); it("answers 'Already paid' when the settled End_To_End_Txn_Id is 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).toBe("Already 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("2"); }); 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("2"); }); });