mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
251 lines
7.0 KiB
TypeScript
251 lines
7.0 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() {
|
|
return { emit: jest.fn() };
|
|
}
|
|
|
|
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
|
|
);
|
|
});
|
|
|
|
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(/^FRT-\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, links the payment, and emits ${source}.invoice.paid", async () => {
|
|
const open = {
|
|
id: "inv-1",
|
|
status: Freight.InvoiceStatus.Pending,
|
|
source: "booking",
|
|
sourceId: "booking-1",
|
|
};
|
|
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
|
|
);
|
|
|
|
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" },
|
|
);
|
|
expect(events.emit).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
|
|
);
|
|
|
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
|
|
|
expect(mg.update).not.toHaveBeenCalled();
|
|
expect(events.emit).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("BillingService.settlePayable", () => {
|
|
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
|
|
const open = {
|
|
id: "inv-1",
|
|
status: Freight.InvoiceStatus.Pending,
|
|
source: Freight.InvoiceSource.Booking,
|
|
sourceId: "booking-1",
|
|
};
|
|
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
|
|
);
|
|
|
|
const settled = await service.settlePayable(
|
|
Freight.InvoiceSource.Booking,
|
|
"booking-1",
|
|
"pay-1",
|
|
mg as never,
|
|
);
|
|
|
|
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
|
|
expect(mg.update).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
{ id: "inv-1" },
|
|
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
|
);
|
|
expect(events.emit).toHaveBeenCalledWith(
|
|
"booking.invoice.paid",
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("is a no-op (returns null) when the source has no open invoice", async () => {
|
|
const mg = {
|
|
findOne: jest.fn().mockResolvedValue(null),
|
|
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
|
|
);
|
|
|
|
const settled = await service.settlePayable(
|
|
Freight.InvoiceSource.Booking,
|
|
"booking-1",
|
|
"pay-1",
|
|
mg as never,
|
|
);
|
|
|
|
expect(settled).toBeNull();
|
|
expect(mg.update).not.toHaveBeenCalled();
|
|
expect(events.emit).not.toHaveBeenCalled();
|
|
});
|
|
});
|