Files
edr-platform/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
2026-08-04 08:59:33 +00:00

671 lines
21 KiB
TypeScript

import { Freight } from "@edr/types";
import { BillingService } from "./billing.service";
/**
* Minimal in-memory EntityManager stand-in covering the methods
* `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager.
*/
function makeManager(savedLines: unknown[]) {
return {
create: (_entity: unknown, data: Record<string, unknown>) => data,
save: (data: Record<string, unknown>) => {
const row = { id: data.id ?? "gen-1", ...data };
if (data.invoiceId) savedLines.push(row);
return Promise.resolve(row);
},
query: () => Promise.resolve([{ seq: 0 }]),
update: jest.fn().mockResolvedValue(undefined),
findOne: jest.fn().mockResolvedValue(null),
};
}
function makeEvents() {
// BillingService emits via both emit() and emitAsync() (the post-commit async
// listener path) — the mock must provide both.
return { emit: jest.fn(), emitAsync: jest.fn().mockResolvedValue([]) };
}
function generateInput(overrides: Record<string, unknown> = {}) {
return {
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: "prepaid",
companyId: "company-1",
companyProfileId: "profile-1",
currency: "ETB",
lines: [
{
chargeType: "RAIL_FREIGHT",
description: "Rail freight",
quantity: 2,
unitRate: 500,
amount: 1000,
},
{
chargeType: "HAZARD_SURCHARGE",
description: "Hazard surcharge",
quantity: 2,
unitRate: 250,
amount: 500,
},
],
...overrides,
};
}
describe("BillingService.generateInvoice", () => {
let savedLines: unknown[];
let manager: ReturnType<typeof makeManager>;
let events: ReturnType<typeof makeEvents>;
let dataSource: { transaction: jest.Mock; manager: unknown };
let service: BillingService;
beforeEach(() => {
savedLines = [];
manager = makeManager(savedLines);
events = makeEvents();
dataSource = {
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
manager,
};
service = new BillingService(
dataSource as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
});
it("creates a PENDING invoice with one line per input line", async () => {
const invoice = await service.generateInvoice(generateInput());
expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
expect(invoice.companyId).toBe("company-1");
expect(invoice.source).toBe("booking");
expect(invoice.sourceId).toBe("booking-1");
expect(invoice.totalAmount).toBe(1500);
expect(invoice.issuedAt).toBeInstanceOf(Date);
expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/);
expect(savedLines).toHaveLength(2);
});
it("sums line amounts when no explicit totalAmount is given", async () => {
const invoice = await service.generateInvoice(
generateInput({ totalAmount: undefined }),
);
expect(invoice.totalAmount).toBe(1500);
});
it("leaves issuedAt null for a DRAFT invoice", async () => {
const invoice = await service.generateInvoice(
generateInput({ status: Freight.InvoiceStatus.Draft }),
);
expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
expect(invoice.issuedAt).toBeNull();
});
it("enlists in a caller's transaction when a manager is passed", async () => {
await service.generateInvoice(generateInput(), manager as never);
expect(dataSource.transaction).not.toHaveBeenCalled();
expect(savedLines).toHaveLength(2);
});
});
describe("BillingService.markInvoiceAsPaid", () => {
it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{
status: Freight.InvoiceStatus.Paid,
paymentId: "pay-1",
paidAt: expect.any(Date),
paidAmount: 1500,
balanceAmount: 0,
payments: [
{
amount: 1500,
method: "GATEWAY",
reference: "pay-1",
paidAt: expect.any(String),
metadata: null,
},
],
},
);
expect(events.emitAsync).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.objectContaining({
invoiceId: "inv-1",
status: Freight.InvoiceStatus.Paid,
paymentId: "pay-1",
}),
);
});
it("is a no-op (no event) when the invoice is already paid", async () => {
const paid = {
id: "inv-1",
status: Freight.InvoiceStatus.Paid,
source: "booking",
};
const mg = {
findOne: jest.fn().mockResolvedValue(paid),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
expect(events.emitAsync).not.toHaveBeenCalled();
});
});
/**
* 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 = {
findOne: jest.fn().mockResolvedValue(invoice),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const dataSource = {
manager: mg,
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(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 };
}
const openInvoice = (overrides: Record<string, unknown> = {}) => ({
id: "inv-1",
status: Freight.InvoiceStatus.Issued,
source: "warehouse",
sourceId: "inv-item-1",
totalAmount: 1000,
paidAmount: 0,
balanceAmount: 1000,
payments: [],
paidAt: null,
...overrides,
});
it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => {
const { service, mg, events } = serviceFor(openInvoice());
const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" });
expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid);
expect(updated.paidAmount).toBe(400);
expect(updated.balanceAmount).toBe(600);
expect(updated.payments).toHaveLength(1);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
expect.objectContaining({
status: Freight.InvoiceStatus.PartiallyPaid,
paidAmount: 400,
balanceAmount: 600,
}),
);
expect(events.emit).not.toHaveBeenCalled();
expect(events.emitAsync).not.toHaveBeenCalled();
});
it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => {
const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 }));
const updated = await service.recordPayment("inv-1", { amount: 600 });
expect(updated.status).toBe(Freight.InvoiceStatus.Paid);
expect(updated.balanceAmount).toBe(0);
expect(updated.paidAt).toBeInstanceOf(Date);
expect(mg.update).toHaveBeenCalled();
expect(events.emitAsync).toHaveBeenCalledWith(
"warehouse.invoice.paid",
expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }),
);
});
it("rejects a non-positive amount", async () => {
const { service, mg } = serviceFor(openInvoice());
await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
it("rejects a payment that exceeds the outstanding balance", async () => {
const { service, mg } = serviceFor(openInvoice());
await expect(
service.recordPayment("inv-1", { amount: 1500 }),
).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
it("rejects payment against a cancelled invoice", async () => {
const { service, mg } = serviceFor(
openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
);
await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
});
/**
* Regression: `expirePayable` (batch settle path, called when a payment window
* lapses) transitions the invoice to EXPIRED, which locks the row FOR UPDATE.
* The bug passed `dataSource.manager` (the non-transactional default) into the
* transition, so runTransition skipped opening a transaction and the lock threw
* `An open transaction is required for pessimistic lock` — aborting the whole
* settle pass (the "settle/reserve one booking at a time" symptom). The locked
* write MUST run inside dataSource.transaction.
*/
describe("BillingService.expirePayable — locked write runs in a transaction", () => {
const openInvoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: "booking",
sourceId: "booking-1",
};
const build = (lookupResult: Record<string, unknown> | null) => {
const defaultManager = {
findOne: jest.fn().mockResolvedValue(lookupResult),
update: jest.fn().mockResolvedValue(undefined),
};
const txManager = {
findOne: jest.fn().mockResolvedValue(openInvoice),
update: jest.fn().mockResolvedValue(undefined),
};
const transaction = jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(txManager));
const events = makeEvents();
const service = new BillingService(
{ manager: defaultManager, transaction } as never,
{} as never,
{} as never,
events as never,
{} as never,
{} as never,
{} as never,
);
return { service, defaultManager, txManager, transaction };
};
it("opens a transaction and runs the pessimistic-lock read on the tx manager", async () => {
const { service, transaction, txManager, defaultManager } = build(openInvoice);
await service.expirePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"prepaid",
);
expect(transaction).toHaveBeenCalledTimes(1);
expect(txManager.findOne).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ lock: { mode: "pessimistic_write" } }),
);
expect(txManager.update).toHaveBeenCalled();
// The default manager only does the initial lock-free lookup, never a locked read.
for (const call of defaultManager.findOne.mock.calls) {
expect(call[1]).not.toHaveProperty("lock");
}
});
it("is a no-op (no transaction) when there is no open invoice", async () => {
const { service, transaction } = build(null);
const result = await service.expirePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"prepaid",
);
expect(result).toBeNull();
expect(transaction).not.toHaveBeenCalled();
});
it("also retires a DRAFT invoice — a superseded/cancelled source must not leave one behind", async () => {
const { service, defaultManager } = build({
...openInvoice,
status: Freight.InvoiceStatus.Draft,
});
await service.expirePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"prepaid",
);
const { where } = defaultManager.findOne.mock.calls[0][1];
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
});
});
describe("BillingService.issuePayable", () => {
const dueAt = new Date("2026-01-02T00:00:00.000Z");
const build = (found: Record<string, unknown> | null) => {
const manager = {
findOne: jest.fn().mockResolvedValue(found),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new BillingService(
{ manager, transaction: jest.fn() } as never,
{} as never,
{} as never,
makeEvents() as never,
{} as never,
{} as never,
{} as never,
);
return { service, manager };
};
const issue = (service: BillingService) =>
service.issuePayable(
Freight.InvoiceSource.Booking,
"booking-1",
dueAt,
"PREPAID",
);
it("issues a DRAFT invoice to PENDING, stamping issuedAt and the pay-window dueAt", async () => {
const { service, manager } = build({
id: "inv-1",
invoiceNumber: "INV-20260101-00001",
status: Freight.InvoiceStatus.Draft,
issuedAt: null,
});
const result = await issue(service);
const patch = manager.update.mock.calls[0][2];
expect(patch.status).toBe(Freight.InvoiceStatus.Pending);
expect(patch.dueAt).toBe(dueAt);
expect(patch.issuedAt).toBeInstanceOf(Date);
expect(result?.status).toBe(Freight.InvoiceStatus.Pending);
});
it("looks up DRAFT invoices — a booking's invoice is minted DRAFT and this is what makes it payable", async () => {
const { service, manager } = build(null);
await issue(service);
const { where } = manager.findOne.mock.calls[0][1];
expect(where.status.value).toContain(Freight.InvoiceStatus.Draft);
});
it("only refreshes dueAt on an already-issued invoice, so a re-reserve never re-issues", async () => {
const issuedAt = new Date("2026-01-01T00:00:00.000Z");
const { service, manager } = build({
id: "inv-1",
invoiceNumber: "INV-20260101-00001",
status: Freight.InvoiceStatus.Pending,
issuedAt,
});
const result = await issue(service);
expect(manager.update.mock.calls[0][2]).toEqual({ dueAt });
expect(result?.issuedAt).toBe(issuedAt);
});
it("is a no-op (returns null, writes nothing) when the source has no draft-or-open invoice", async () => {
const { service, manager } = build(null);
await expect(issue(service)).resolves.toBeNull();
expect(manager.update).not.toHaveBeenCalled();
});
});
describe("BillingService — CAC Bank (OTP debit)", () => {
const openInvoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
type: "PREPAID",
invoiceNumber: "INV-20260101-00001",
currency: "USD",
balanceAmount: 500,
totalAmount: 500,
paymentId: "intent-1",
dueAt: null,
};
const build = (payment: Record<string, unknown>) => {
const repo = {
findOne: jest.fn().mockResolvedValue(openInvoice),
update: jest.fn().mockResolvedValue(undefined),
};
const service = new BillingService(
{ getRepository: () => repo } as never,
{} as never,
{} as never,
makeEvents() as never,
payment as never,
{} as never,
{} as never,
);
return { service, repo };
};
it("rejects a CAC Bank charge with no payer mobile before calling the gateway", async () => {
const initiate = jest.fn();
const { service } = build({ initiate });
await expect(
service.payInvoice("inv-1", { method: "CAC_BANK" }),
).rejects.toThrow(/payerAccount/);
expect(initiate).not.toHaveBeenCalled();
});
it("does not settle an OTP intent at initiate — the payer still has to confirm", async () => {
const handlePaymentEvent = jest.fn();
const { service } = build({
initiate: jest.fn().mockResolvedValue({
intentId: "intent-1",
immediateSuccess: false,
response: {
intentId: "intent-1",
status: "REQUIRES_ACTION",
clientAction: { type: "COLLECT_OTP", providerOrderId: "cac-1" },
},
}),
handlePaymentEvent,
});
await service.payInvoice("inv-1", {
method: "CAC_BANK",
payerAccount: "77123456",
});
expect(handlePaymentEvent).not.toHaveBeenCalled();
});
it("confirms the OTP against the intent stamped on the invoice", async () => {
const confirmOtp = jest.fn().mockResolvedValue({ status: "SUCCEEDED" });
const { service } = build({ confirmOtp });
await service.confirmInvoiceOtp("inv-1", "123456");
expect(confirmOtp).toHaveBeenCalledWith("intent-1", "123456");
});
});