feat: add drain tail to the payments

This commit is contained in:
Nathnael
2026-08-04 08:59:33 +00:00
parent a20498fd77
commit acd2cfbe8c
9 changed files with 379 additions and 22 deletions

View File

@@ -203,6 +203,129 @@ describe("BillingService.markInvoiceAsPaid", () => {
});
});
/**
* A gateway success can land after the pay window AND its drain tail (relay
* backlog, payment-api restart, a CBE bill paid at a counter). The money is
* captured either way, so the settle lookup must accept an EXPIRED invoice —
* matching only OPEN_STATUSES used to drop it silently, leaving a debited
* customer with an EXPIRED invoice, an EXPIRED booking and no alert.
*/
describe("BillingService.settleByPaymentId", () => {
function serviceFor(invoice: Record<string, unknown> | null) {
const mg = {
findOne: jest.fn().mockResolvedValue(invoice),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
// The lookup is by paymentId ALONE — status is judged on the resolved row,
// so a stale capture can never skip past a newer invoice to an older one.
const findOne = jest.fn(({ where }: { where: Record<string, unknown> }) => {
expect(where).toEqual({ paymentId: "pay-1" });
return Promise.resolve(invoice);
});
const dataSource = {
getRepository: () => ({ findOne }),
transaction: (cb: (mg: unknown) => unknown) => cb(mg),
manager: mg,
};
const service = new BillingService(
dataSource as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
return { service, mg, events };
}
it("settles an EXPIRED invoice — the money was already captured", async () => {
const { service, mg, events } = serviceFor({
id: "inv-1",
status: Freight.InvoiceStatus.Expired,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
});
const settled = await service.settleByPaymentId("pay-1", "txn-1");
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
expect.objectContaining({
status: Freight.InvoiceStatus.Paid,
paidAmount: 1500,
balanceAmount: 0,
}),
);
// The domain reacts to this — it is what revives the expired booking.
expect(events.emitAsync).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.objectContaining({ invoiceId: "inv-1" }),
);
});
it("still settles an open (PENDING) invoice", async () => {
const { service, mg } = serviceFor({
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
});
await service.settleByPaymentId("pay-1");
expect(mg.update).toHaveBeenCalled();
});
/**
* `upsertIntent` keeps ONE local payments row per booking reference, so every
* invoice the booking was ever charged on carries the same `paymentId`. A
* capture from a lapsed first attempt must not reach back past the invoice the
* customer actually paid and settle the older EXPIRED one — that would mark two
* invoices paid off a single payment.
*/
it("no-ops when the booking's newest invoice is already PAID", async () => {
const { service, mg, events } = serviceFor({
id: "inv-2",
status: Freight.InvoiceStatus.Paid,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
});
expect(await service.settleByPaymentId("pay-1")).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emitAsync).not.toHaveBeenCalled();
});
it.each([
["CANCELLED", Freight.InvoiceStatus.Cancelled],
["REFUNDED", Freight.InvoiceStatus.Refunded],
])("does not settle a %s invoice — that is a refund case", async (
_label,
status,
) => {
const { service, mg, events } = serviceFor({
id: "inv-1",
status,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
});
expect(await service.settleByPaymentId("pay-1")).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emitAsync).not.toHaveBeenCalled();
});
});
describe("BillingService.recordPayment", () => {
function serviceFor(invoice: Record<string, unknown> | null) {
const mg = {