mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +00:00
feat: add drain tail to the payments
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user