feat(billing): issue credit/debit memos against registered invoices

POST billing/invoices/:id/memo files a MoR DEB/CRE memo by reusing
createInvoice unchanged. sourceId is the original invoice's own id, not its
source's — this structurally keeps memos out of findPayable/expirePayable/
billQuery's sourceId-keyed lookups regardless of status. Credit notes are
created settled; debit notes are created open/unpaid as a genuine new
receivable, not force-settled. memoIssue is granted to the chief position,
not the general finance role.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-08-15 06:44:41 +00:00
parent 87c51d7676
commit d66cfe2328
5 changed files with 396 additions and 17 deletions

View File

@@ -119,6 +119,159 @@ describe("BillingService.generateInvoice", () => {
});
});
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,
);
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 = {