mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
1107 lines
37 KiB
TypeScript
1107 lines
37 KiB
TypeScript
import { Freight } from "@edr/types";
|
|
|
|
import { WAGON_CANCEL_FEE_INVOICE_TYPE } from "../bookings/entities/booking-wagon-cancellation.entity";
|
|
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
|
|
{} as never, // files
|
|
{ get: () => undefined } as never, // config
|
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
|
);
|
|
});
|
|
|
|
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.issueMemo", () => {
|
|
const ORIGINAL_ID = "original-invoice-1";
|
|
|
|
function originalInvoice(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: ORIGINAL_ID,
|
|
invoiceNumber: "INV-20260807-00042",
|
|
eimsIrn: "irn-value",
|
|
eimsDocumentType: "INV",
|
|
eimsStatus: "REGISTERED",
|
|
source: Freight.InvoiceSource.Booking,
|
|
sourceId: "booking-1",
|
|
companyId: "company-1",
|
|
companyProfileId: "profile-1",
|
|
shippingLineCompanyId: null,
|
|
currency: "ETB",
|
|
totalAmount: 1500,
|
|
lines: [
|
|
{ chargeType: "RAIL_FREIGHT", description: "Rail freight", quantity: 2, unitRate: 500, amount: 1000, currency: "ETB", metadata: null },
|
|
{ chargeType: "HAZARD_SURCHARGE", description: "Hazard surcharge", quantity: 2, unitRate: 250, amount: 500, currency: "ETB", metadata: null },
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function build(original: ReturnType<typeof originalInvoice>) {
|
|
const savedLines: unknown[] = [];
|
|
const manager = makeManager(savedLines);
|
|
const dataSource = {
|
|
transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
|
manager,
|
|
};
|
|
const invoices = { findById: jest.fn().mockResolvedValue(original) };
|
|
const invoiceLines = { findAll: jest.fn().mockResolvedValue(original.lines) };
|
|
const service = new BillingService(
|
|
dataSource as never,
|
|
invoices as never,
|
|
invoiceLines as never,
|
|
makeEvents() as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{ get: () => undefined } as never,
|
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
|
);
|
|
return { service, manager, savedLines };
|
|
}
|
|
|
|
it("creates a settled credit memo copying the original's lines, linked via relatedInvoiceId", async () => {
|
|
const { service, savedLines } = build(originalInvoice());
|
|
|
|
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "Overbilled freight charge" });
|
|
|
|
expect(memo.invoiceNumber).toMatch(/^CRE-\d{8}-00001$/);
|
|
expect(memo.totalAmount).toBe(1500);
|
|
expect(memo.status).toBe(Freight.InvoiceStatus.Paid);
|
|
expect((memo as unknown as Record<string, unknown>).eimsDocumentType).toBe("CRE");
|
|
expect((memo as unknown as Record<string, unknown>).eimsReason).toBe("Overbilled freight charge");
|
|
expect((memo as unknown as Record<string, unknown>).relatedInvoiceId).toBe(ORIGINAL_ID);
|
|
expect((memo as unknown as Record<string, unknown>).paidAmount).toBe(1500);
|
|
expect((memo as unknown as Record<string, unknown>).balanceAmount).toBe(0);
|
|
expect(savedLines).toHaveLength(2);
|
|
});
|
|
|
|
it("creates an open, unpaid debit memo — a genuine new receivable, not force-settled", async () => {
|
|
const { service } = build(originalInvoice());
|
|
|
|
const memo = await service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "Additional handling fee" });
|
|
|
|
expect(memo.invoiceNumber).toMatch(/^DEB-\d{8}-00001$/);
|
|
expect(memo.status).toBe(Freight.InvoiceStatus.Pending);
|
|
expect(memo.balanceAmount).toBe(1500);
|
|
expect(memo.paidAmount).toBe(0);
|
|
});
|
|
|
|
it("keys the memo's sourceId to the original invoice's own id, not the original's sourceId", async () => {
|
|
const { service } = build(originalInvoice());
|
|
|
|
const memo = await service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "test" });
|
|
|
|
expect(memo.sourceId).toBe(ORIGINAL_ID);
|
|
expect(memo.sourceId).not.toBe("booking-1");
|
|
});
|
|
|
|
it("allows a partial memo with explicit lines instead of copying the original", async () => {
|
|
const { service } = build(originalInvoice());
|
|
|
|
const memo = await service.issueMemo(ORIGINAL_ID, {
|
|
type: "CRE",
|
|
reason: "Partial credit",
|
|
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 200, amount: 200 }],
|
|
});
|
|
|
|
expect(memo.totalAmount).toBe(200);
|
|
});
|
|
|
|
it("refuses a memo against an invoice never registered with EIMS", async () => {
|
|
const { service } = build(originalInvoice({ eimsIrn: null }));
|
|
|
|
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toMatchObject({
|
|
response: expect.objectContaining({ code: "EIMS_RELATED_INVOICE_NOT_REGISTERED" }),
|
|
});
|
|
});
|
|
|
|
it("refuses a memo against a memo", async () => {
|
|
const { service } = build(originalInvoice({ eimsDocumentType: "CRE" }));
|
|
|
|
await expect(service.issueMemo(ORIGINAL_ID, { type: "DEB", reason: "x" })).rejects.toThrow(
|
|
"cannot issue a memo against a memo",
|
|
);
|
|
});
|
|
|
|
it("refuses a memo against an EIMS-cancelled invoice", async () => {
|
|
const { service } = build(originalInvoice({ eimsStatus: "CANCELLED" }));
|
|
|
|
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: "x" })).rejects.toThrow(
|
|
"cancelled with EIMS",
|
|
);
|
|
});
|
|
|
|
it("refuses a credit memo whose total exceeds the original", async () => {
|
|
const { service } = build(originalInvoice({ totalAmount: 1500 }));
|
|
|
|
await expect(
|
|
service.issueMemo(ORIGINAL_ID, {
|
|
type: "CRE",
|
|
reason: "too much",
|
|
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 2000, amount: 2000 }],
|
|
}),
|
|
).rejects.toThrow(/exceeds/);
|
|
});
|
|
|
|
it("does NOT bound a debit memo by the original's total — it is a new charge, not a refund", async () => {
|
|
const { service } = build(originalInvoice({ totalAmount: 1500 }));
|
|
|
|
const memo = await service.issueMemo(ORIGINAL_ID, {
|
|
type: "DEB",
|
|
reason: "additional charge",
|
|
lines: [{ chargeType: "RAIL_FREIGHT", quantity: 1, unitRate: 5000, amount: 5000 }],
|
|
});
|
|
|
|
expect(memo.totalAmount).toBe(5000);
|
|
});
|
|
|
|
it("refuses a blank reason", async () => {
|
|
const { service } = build(originalInvoice());
|
|
|
|
await expect(service.issueMemo(ORIGINAL_ID, { type: "CRE", reason: " " })).rejects.toThrow(
|
|
"requires a reason",
|
|
);
|
|
});
|
|
});
|
|
|
|
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
|
|
{} as never, // files
|
|
{ get: () => undefined } as never, // config
|
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
|
);
|
|
|
|
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
|
|
{} as never, // files
|
|
{ get: () => undefined } as never, // config
|
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
|
);
|
|
|
|
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
|
|
{} as never, // files
|
|
{ get: () => undefined } as never, // config
|
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
|
);
|
|
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
|
|
{} as never, // files
|
|
{ get: () => undefined } as never, // config
|
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
|
);
|
|
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,
|
|
{} as never,
|
|
{} as never, // config
|
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
|
);
|
|
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,
|
|
{} as never,
|
|
{} as never, // config
|
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
|
);
|
|
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,
|
|
{} as never,
|
|
{} as never, // config
|
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
|
);
|
|
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");
|
|
});
|
|
});
|
|
|
|
describe("BillingService — CBE bill amounts carry cents, never rounded", () => {
|
|
// CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the
|
|
// bill must quote the exact balance. Rounding UP overcharged the payer by up to
|
|
// a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount
|
|
// = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches.
|
|
const invoice = {
|
|
id: "inv-1",
|
|
status: Freight.InvoiceStatus.Pending,
|
|
source: Freight.InvoiceSource.Booking,
|
|
sourceId: "booking-1",
|
|
type: "PREPAID",
|
|
invoiceNumber: "INV-20260101-00001",
|
|
currency: "ETB",
|
|
// .43 — cents that must survive all the way to the bill.
|
|
balanceAmount: 12345.43,
|
|
totalAmount: 12345.43,
|
|
company: { name: "Acme PLC" },
|
|
paymentId: null,
|
|
dueAt: null,
|
|
};
|
|
|
|
const build = (payment: Record<string, unknown> = {}) => {
|
|
const repo = {
|
|
findOne: jest.fn().mockResolvedValue(invoice),
|
|
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,
|
|
{} as never,
|
|
{} as never, // config
|
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
|
);
|
|
return { service, repo };
|
|
};
|
|
|
|
it("opens the intent for the exact balance, cents included", async () => {
|
|
const initiate = jest.fn().mockResolvedValue({
|
|
intentId: "intent-1",
|
|
immediateSuccess: false,
|
|
response: { intentId: "intent-1", status: "REQUIRES_ACTION" },
|
|
});
|
|
const { service } = build({ initiate });
|
|
|
|
await service.payInvoice("inv-1", { method: "CBE_BILL" });
|
|
|
|
expect(initiate).toHaveBeenCalledWith(
|
|
expect.objectContaining({ amountMinor: 12345.43 }),
|
|
);
|
|
});
|
|
|
|
it("quotes the same exact amount on bill-query as payInvoice opened", async () => {
|
|
const { service } = build();
|
|
|
|
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
|
|
stillPayable: true,
|
|
currentAmountMinor: 12345.43,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("BillingService.document", () => {
|
|
const invoiceRow = (over: Record<string, unknown> = {}) => ({
|
|
id: "inv-1",
|
|
invoiceNumber: "INV-20260812-00001",
|
|
source: "booking",
|
|
sourceId: "booking-1",
|
|
status: Freight.InvoiceStatus.Pending,
|
|
type: "freight",
|
|
currency: "ETB",
|
|
subtotalAmount: 100,
|
|
taxAmount: 0,
|
|
totalAmount: 100,
|
|
paidAmount: 0,
|
|
balanceAmount: 100,
|
|
issuedAt: new Date(2026, 7, 12),
|
|
dueAt: new Date(2026, 7, 19),
|
|
eimsIrn: null,
|
|
eimsSignedQr: null,
|
|
company: { name: "ABC Trading PLC", tin: "0999930000", vatNumber: "123475885858" },
|
|
...over,
|
|
});
|
|
|
|
const build = (invoice: Record<string, unknown>) => {
|
|
const render = jest.fn().mockResolvedValue({ filename: "x.pdf", buffer: Buffer.from("") });
|
|
const renderThermal = jest.fn().mockResolvedValue({ filename: "x-thermal.pdf", buffer: Buffer.from("") });
|
|
const service = new BillingService(
|
|
{} as never,
|
|
{ findById: jest.fn().mockResolvedValue(invoice) } as never,
|
|
{ findAll: jest.fn().mockResolvedValue([]) } as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{ render, renderThermal } as never,
|
|
{} as never,
|
|
{
|
|
get: (key: string) =>
|
|
key === "eims"
|
|
? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } }
|
|
: undefined,
|
|
} as never, // config
|
|
{ isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings
|
|
);
|
|
return { service, render, renderThermal };
|
|
};
|
|
|
|
it("adds no EIMS IRN row and no QR for an unregistered invoice", async () => {
|
|
const { service, render } = build(invoiceRow());
|
|
|
|
await service.document("inv-1");
|
|
|
|
const model = render.mock.calls[0][0];
|
|
expect(model.summary.find((r: { label: string }) => r.label === "EIMS IRN")).toBeUndefined();
|
|
expect(model.qrImageUrl).toBeNull();
|
|
});
|
|
|
|
it("shows the buyer's name, TIN and VAT number on every invoice", async () => {
|
|
const { service, render } = build(invoiceRow());
|
|
|
|
await service.document("inv-1");
|
|
|
|
const model = render.mock.calls[0][0];
|
|
expect(model.summary).toContainEqual({ label: "Buyer", value: "ABC Trading PLC" });
|
|
expect(model.summary).toContainEqual({ label: "Buyer TIN", value: "0999930000" });
|
|
expect(model.summary).toContainEqual({ label: "Buyer VAT No.", value: "123475885858" });
|
|
});
|
|
|
|
it("omits the VAT row when the buyer company has none", async () => {
|
|
const { service, render } = build(invoiceRow({ company: { name: "Acme", tin: "0011223344" } }));
|
|
|
|
await service.document("inv-1");
|
|
|
|
const model = render.mock.calls[0][0];
|
|
expect(model.summary.find((r: { label: string }) => r.label === "Buyer VAT No.")).toBeUndefined();
|
|
});
|
|
|
|
it("shows EDR's own seller TIN and VAT number from EIMS config", async () => {
|
|
const { service, render } = build(invoiceRow());
|
|
|
|
await service.document("inv-1");
|
|
|
|
const model = render.mock.calls[0][0];
|
|
expect(model.summary).toContainEqual({ label: "Seller TIN", value: "0053481357" });
|
|
expect(model.summary).toContainEqual({
|
|
label: "Seller VAT No.",
|
|
value: "43256663343256663322",
|
|
});
|
|
});
|
|
|
|
it("adds the EIMS IRN to the summary and renders the QR for a registered invoice", async () => {
|
|
const { service, render } = build(
|
|
invoiceRow({ eimsIrn: "IRN-123", eimsSignedQr: "signed-payload" }),
|
|
);
|
|
|
|
await service.document("inv-1");
|
|
|
|
const model = render.mock.calls[0][0];
|
|
expect(model.summary).toContainEqual({ label: "EIMS IRN", value: "IRN-123" });
|
|
expect(model.qrImageUrl).toBe("data:image/png;base64,signed-payload");
|
|
});
|
|
|
|
it("calls render (not renderThermal) for the default format", async () => {
|
|
const { service, render, renderThermal } = build(invoiceRow());
|
|
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
|
|
|
await service.document("inv-1");
|
|
|
|
expect(render).toHaveBeenCalledTimes(1);
|
|
expect(renderThermal).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls renderThermal (not render) for format 'thermal'", async () => {
|
|
const { service, render, renderThermal } = build(invoiceRow());
|
|
jest.spyOn(service as never, "toDocumentModel").mockResolvedValue({} as never);
|
|
|
|
await service.document("inv-1", "thermal");
|
|
|
|
expect(renderThermal).toHaveBeenCalledTimes(1);
|
|
expect(render).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The pay-window guard belongs to the freight invoice. A wagon-cancellation fee
|
|
* rides source=booking but is raised on an already-PAID booking, so it inherits
|
|
* a deadline that has long passed — guarding it would make the fee permanently
|
|
* unsettleable.
|
|
*/
|
|
describe("BillingService.confirmOfflinePayment pay-window guard", () => {
|
|
const PAST = new Date(Date.now() - 86_400_000);
|
|
|
|
function makeService(invoiceType: string) {
|
|
const invoice = {
|
|
id: "inv-1",
|
|
source: Freight.InvoiceSource.Booking,
|
|
sourceId: "booking-1",
|
|
type: invoiceType,
|
|
currency: "ETB",
|
|
status: Freight.InvoiceStatus.Issued,
|
|
balanceAmount: 500,
|
|
};
|
|
const recordPayment = jest.fn().mockResolvedValue(invoice);
|
|
const dataSource = {
|
|
getRepository: () => ({
|
|
findOne: async () => ({ id: "booking-1", paymentDeadline: PAST }),
|
|
}),
|
|
};
|
|
const service = new BillingService(
|
|
dataSource as never,
|
|
{ findById: async () => invoice } as never,
|
|
{} as never,
|
|
makeEvents() as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{ upload: async () => ({ id: "file-1", name: "slip.pdf" }) } as never,
|
|
{ get: () => undefined } as never,
|
|
{ isEnabled: async () => true } as never,
|
|
);
|
|
(service as unknown as { recordPayment: unknown }).recordPayment =
|
|
recordPayment;
|
|
return { service, recordPayment };
|
|
}
|
|
|
|
const slip = { originalname: "slip.pdf" } as never;
|
|
|
|
it("refuses a freight invoice once the pay window has closed", async () => {
|
|
const { service } = makeService("PREPAID");
|
|
await expect(
|
|
service.confirmOfflinePayment("inv-1", slip, {}),
|
|
).rejects.toThrow(/payment window has closed/i);
|
|
});
|
|
|
|
it("settles a wagon-cancellation fee despite the closed window", async () => {
|
|
const { service, recordPayment } = makeService(
|
|
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
|
);
|
|
await service.confirmOfflinePayment("inv-1", slip, {});
|
|
expect(recordPayment).toHaveBeenCalledWith(
|
|
"inv-1",
|
|
expect.objectContaining({ amount: 500, method: "BANK_TRANSFER" }),
|
|
);
|
|
});
|
|
|
|
it("still requires the bank slip for a cancellation fee", async () => {
|
|
const { service } = makeService(WAGON_CANCEL_FEE_INVOICE_TYPE);
|
|
await expect(
|
|
service.confirmOfflinePayment("inv-1", undefined, {}),
|
|
).rejects.toThrow(/slip file is required/i);
|
|
});
|
|
});
|