/** * The freight ⇄ payment happy path, end to end through both services. * * portal pays invoice * → freight billing.payInvoice → payment API /payments/initiate * → CBE Birr provider → gateway mock (intent opened, invoice.payment_id set) * gateway calls back (correctly signed) * → payment API webhook pipeline → intent SUCCEEDED → outbox row * → RabbitMQ → freight consumer → settleByPaymentId * → invoice PAID → booking.invoice.paid → booking advances * * Nothing here is stubbed except the bank itself. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { closeDb, customerA, db, gateway, intentByMerchantOrderId, poll, } from "./client"; import { createImportSchedule, currentInvoice, departureAt, ensureCorridorRoute, releaseUnpaidHolds, forceWindowOpen, freightPayment, gatewayIntent, invoiceForBooking, payInvoice, prepareBooking, resetCorridorDay, runBatch, type ReadyBooking, } from "./flows"; const DEPARTURE = departureAt(4); const STAMP = String(Date.now()); describe("freight invoice settles through the real payment service", () => { let booking: ReadyBooking; let invoiceId: 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: "PAY1", departure: DEPARTURE, runStamp: STAMP, isoSeed: 0, twenty: 2, }); await runBatch(schedule.id); invoiceId = (await invoiceForBooking(booking.bookingId)).id; }); afterAll(closeDb); it("opens a gateway intent and links it to the invoice", async () => { const res = await payInvoice(invoiceId, { method: "CBE_BIRR" }); expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201); const intent = await gatewayIntent(booking.bookingId); expect(intent.provider).toBe("CBE_BIRR"); expect(intent.status).toBe("REQUIRES_ACTION"); expect(intent.merchant_order_id).toBeTruthy(); // The invoice must carry the intent id BEFORE any callback can arrive — // settlement correlates on it (billing.service.ts payInvoice). const invoice = await currentInvoice(invoiceId); expect(invoice.payment_id).toBeTruthy(); // Freight's local projection of the same intent. const projection = await freightPayment(invoice.payment_id!); expect(projection.status).toBe("action-required"); expect(projection.merchant_order_id).toBe(intent.merchant_order_id); // Freight deliberately sends a dev-shortcut amount for non-CBE_BILL // providers (payment.service.ts:238-247) — 1 minor unit, not the invoice // total. Asserted, not "fixed": changing it is a product decision. expect(Number(intent.amount_minor)).toBe(1); }); it("settles the invoice and advances the booking when the gateway calls back", async () => { const intent = await gatewayIntent(booking.bookingId); const res = await gateway.webhook({ merchantOrderId: intent.merchant_order_id }); expect(res.body.delivered).toBe(200); const settled = await poll<{ status: string }>( "payment intent SUCCEEDED", `SELECT status FROM edr_payment.payment_intent WHERE id = $1`, [intent.id], (row) => row?.status === "SUCCEEDED", { attempts: 20, intervalMs: 1000 }, ); expect(settled.status).toBe("SUCCEEDED"); // The outbox row is written in the SAME transaction as the intent update // and relayed to RabbitMQ by the relay loop. const outbox = await poll<{ status: string; attempts: number }>( "outbox row relayed", `SELECT status, attempts FROM edr_payment.notification_outbox WHERE intent_id = $1 AND event_type = 'payment.succeeded'`, [intent.id], (row) => row?.status === "SENT", { attempts: 20, intervalMs: 1000 }, ); expect(outbox.status).toBe("SENT"); // …and freight, on the other end of the broker, settles the invoice. const invoice = await poll<{ status: string; paid_amount: string; balance_amount: string }>( "invoice PAID", `SELECT status, paid_amount, balance_amount FROM freight.invoices WHERE id = $1`, [invoiceId], (row) => row?.status === "PAID", { attempts: 30, intervalMs: 2000 }, ); expect(Number(invoice.balance_amount)).toBe(0); expect(Number(invoice.paid_amount)).toBeGreaterThan(0); const booked = await poll<{ status: string }>( "booking advanced on payment", `SELECT status FROM freight.bookings WHERE id = $1`, [booking.bookingId], (row) => row?.status === "PAID", { attempts: 20, intervalMs: 2000 }, ); expect(booked.status).toBe("PAID"); }); it("records exactly one intent, one webhook event and one ledger entry", async () => { const intent = await gatewayIntent(booking.bookingId); const intents = await intentByMerchantOrderId(intent.merchant_order_id); expect(intents.length).toBe(1); const [{ n }] = await db<{ n: string }>( `SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event WHERE merchant_order_id = $1`, [intent.merchant_order_id], ); expect(Number(n)).toBe(1); const invoice = await currentInvoice(invoiceId); const ledger = (invoice.payments ?? []) as unknown[]; expect(Array.isArray(ledger) ? ledger.length : 0).toBe(1); }); it("is idempotent — a replayed callback changes nothing", async () => { const intent = await gatewayIntent(booking.bookingId); const before = await currentInvoice(invoiceId); // Same orderId ⇒ same externalEventId ⇒ deduped at the webhook table. await gateway.webhook({ merchantOrderId: intent.merchant_order_id, eventId: `CBEORD-${intent.merchant_order_id}`, }); await new Promise((r) => setTimeout(r, 3000)); const after = await currentInvoice(invoiceId); expect(after.status).toBe("PAID"); expect(after.paid_amount).toBe(before.paid_amount); expect((after.payments as unknown[]).length).toBe((before.payments as unknown[]).length); }); it("refuses a second payment on an already-paid invoice", async () => { const res = await payInvoice(invoiceId, { method: "CBE_BIRR", as: customerA }); expect(res.status).toBeGreaterThanOrEqual(400); }); });