mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 20:10:56 +00:00
445 lines
16 KiB
TypeScript
445 lines
16 KiB
TypeScript
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
|
|
|
import type { ActorContext } from "../../common/current-actor.util";
|
|
import { PayablesService } from "./payables.service";
|
|
import { SupplierPayment } from "./entities/supplier-payment.entity";
|
|
import type { RecordPaymentDto } from "./dto/payables.dto";
|
|
|
|
const actor: ActorContext = {
|
|
employeeId: "emp-1",
|
|
userId: "user-1",
|
|
organizationId: "org-1",
|
|
isSuperAdmin: false,
|
|
};
|
|
|
|
function makeManager() {
|
|
const paymentRepo = {
|
|
create: (data: Record<string, unknown>) => data,
|
|
save: jest
|
|
.fn()
|
|
.mockImplementation(async (data: Record<string, unknown>) => ({
|
|
id: (data.id as string) ?? "payment-1",
|
|
...data,
|
|
})),
|
|
};
|
|
const billRepo = {
|
|
update: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
return {
|
|
getRepository: (entity: unknown) =>
|
|
entity === SupplierPayment ? paymentRepo : billRepo,
|
|
paymentRepo,
|
|
billRepo,
|
|
};
|
|
}
|
|
|
|
function build() {
|
|
const suppliers = {
|
|
findById: jest.fn(),
|
|
findByCode: jest.fn(),
|
|
findAllForOrg: jest.fn(),
|
|
countBills: jest.fn(),
|
|
create: jest.fn(),
|
|
update: jest.fn(),
|
|
softDelete: jest.fn(),
|
|
};
|
|
const bills = {
|
|
findById: jest.fn(),
|
|
update: jest.fn().mockResolvedValue(undefined),
|
|
findPage: jest.fn(),
|
|
nextBillNumber: jest.fn(),
|
|
findPageWithSupplier: jest.fn(),
|
|
agingAsOf: jest.fn(),
|
|
};
|
|
const billLines = {
|
|
findByBill: jest.fn(),
|
|
findByBillWithAccounts: jest.fn().mockResolvedValue([]),
|
|
replaceForBill: jest.fn(),
|
|
};
|
|
const payments = {
|
|
findByBill: jest.fn().mockResolvedValue([]),
|
|
nextPaymentNumber: jest.fn().mockResolvedValue("PAY-2026-000001"),
|
|
};
|
|
const remittances = {
|
|
outstandingByAccount: jest.fn(),
|
|
findAllForOrg: jest.fn(),
|
|
create: jest.fn(),
|
|
};
|
|
const accounts = { findOne: jest.fn(), findByCode: jest.fn(), assertPostable: jest.fn() };
|
|
const journals = { createPosted: jest.fn(), findBySource: jest.fn() };
|
|
const manager = makeManager();
|
|
const dataSource = {
|
|
transaction: jest
|
|
.fn()
|
|
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
|
manager,
|
|
query: jest.fn(),
|
|
};
|
|
|
|
const service = new PayablesService(
|
|
suppliers as never,
|
|
bills as never,
|
|
billLines as never,
|
|
payments as never,
|
|
remittances as never,
|
|
accounts as never,
|
|
journals as never,
|
|
dataSource as never,
|
|
);
|
|
|
|
return {
|
|
service,
|
|
suppliers,
|
|
bills,
|
|
billLines,
|
|
payments,
|
|
remittances,
|
|
accounts,
|
|
journals,
|
|
dataSource,
|
|
manager,
|
|
};
|
|
}
|
|
|
|
function stubAccount(code: string, over: Record<string, unknown> = {}) {
|
|
return { id: code, code, accountType: "LIABILITY", isActive: true, isGroup: false, ...over };
|
|
}
|
|
|
|
describe("PayablesService.approveBill", () => {
|
|
const draftBill = (over: Record<string, unknown> = {}) => ({
|
|
id: "bill-1",
|
|
organizationId: "org-1",
|
|
billNumber: "BILL-2026-000001",
|
|
supplierInvoiceNumber: "INV-100",
|
|
billDate: "2026-08-01",
|
|
status: "DRAFT",
|
|
totalAmount: 1150,
|
|
taxAmount: 150,
|
|
withholdingAmount: 100,
|
|
supplierId: "sup-1",
|
|
...over,
|
|
});
|
|
|
|
const billLinesRows = [
|
|
{ accountId: "exp-1", amount: 1000, description: "Consulting" },
|
|
];
|
|
|
|
const supplierRow = (over: Record<string, unknown> = {}) => ({
|
|
id: "sup-1",
|
|
name: "Acme Supplies",
|
|
payableAccountId: null,
|
|
...over,
|
|
});
|
|
|
|
it("refuses approving a bill that is not DRAFT", async () => {
|
|
const { service, bills } = build();
|
|
bills.findById.mockResolvedValue(draftBill({ status: "APPROVED" }));
|
|
|
|
await expect(service.approveBill(actor, "bill-1")).rejects.toBeInstanceOf(
|
|
ForbiddenException,
|
|
);
|
|
});
|
|
|
|
it("refuses when withholding exceeds the bill total", async () => {
|
|
const { service, bills, billLines, suppliers, journals, accounts } = build();
|
|
bills.findById.mockResolvedValue(
|
|
draftBill({ totalAmount: 500, withholdingAmount: 600 }),
|
|
);
|
|
billLines.findByBill.mockResolvedValue(billLinesRows);
|
|
suppliers.findById.mockResolvedValue(supplierRow());
|
|
// The trade-payables lookup runs before the withholding check — needs an
|
|
// account on file even though this test never reaches the journal build.
|
|
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
|
|
|
|
await expect(service.approveBill(actor, "bill-1")).rejects.toThrow(
|
|
/Withholding 600\.00 exceeds the bill total 500\.00/,
|
|
);
|
|
expect(journals.createPosted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses a bill with no lines", async () => {
|
|
const { service, bills, billLines } = build();
|
|
bills.findById.mockResolvedValue(draftBill());
|
|
billLines.findByBill.mockResolvedValue([]);
|
|
|
|
await expect(service.approveBill(actor, "bill-1")).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
});
|
|
|
|
it("builds Dr expense + Dr VAT payable, Cr trade-payables (net of withholding) + Cr withholding-payable, posted with sourceModule/sourceId", async () => {
|
|
const { service, bills, billLines, suppliers, accounts, journals } = build();
|
|
bills.findById.mockResolvedValue(draftBill());
|
|
billLines.findByBill.mockResolvedValue(billLinesRows);
|
|
suppliers.findById.mockResolvedValue(supplierRow());
|
|
accounts.findByCode.mockImplementation(async (_org: string, code: string) =>
|
|
stubAccount(code),
|
|
);
|
|
journals.createPosted.mockResolvedValue({
|
|
id: "je-1",
|
|
entryNumber: "JV-2026-000001",
|
|
});
|
|
|
|
await service.approveBill(actor, "bill-1");
|
|
|
|
expect(journals.createPosted).toHaveBeenCalledWith(
|
|
actor,
|
|
expect.objectContaining({
|
|
entryDate: "2026-08-01",
|
|
journalType: "PURCHASE",
|
|
sourceModule: "supplier-bill",
|
|
sourceId: "bill-1",
|
|
lines: [
|
|
{ accountId: "exp-1", debit: 1000, description: "Consulting" },
|
|
{ accountId: "2123", debit: 150, description: "Input VAT" },
|
|
{
|
|
accountId: "2111",
|
|
credit: 1050,
|
|
description: "Acme Supplies — BILL-2026-000001",
|
|
},
|
|
{
|
|
accountId: "2124",
|
|
credit: 100,
|
|
description: "Withheld on BILL-2026-000001",
|
|
},
|
|
],
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("omits the VAT and withholding lines when neither applies", async () => {
|
|
const { service, bills, billLines, suppliers, accounts, journals } = build();
|
|
bills.findById.mockResolvedValue(
|
|
draftBill({ totalAmount: 1000, taxAmount: 0, withholdingAmount: 0 }),
|
|
);
|
|
billLines.findByBill.mockResolvedValue(billLinesRows);
|
|
suppliers.findById.mockResolvedValue(supplierRow());
|
|
accounts.findByCode.mockImplementation(async (_org: string, code: string) =>
|
|
stubAccount(code),
|
|
);
|
|
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
|
|
|
await service.approveBill(actor, "bill-1");
|
|
|
|
const call = journals.createPosted.mock.calls[0][1] as { lines: unknown[] };
|
|
expect(call.lines).toEqual([
|
|
{ accountId: "exp-1", debit: 1000, description: "Consulting" },
|
|
{ accountId: "2111", credit: 1000, description: "Acme Supplies — BILL-2026-000001" },
|
|
]);
|
|
});
|
|
|
|
it("credits the supplier's OWN payable account when one is configured, instead of the default trade-payables control account", async () => {
|
|
const { service, bills, billLines, suppliers, accounts, journals } = build();
|
|
bills.findById.mockResolvedValue(
|
|
draftBill({ totalAmount: 1000, taxAmount: 0, withholdingAmount: 0 }),
|
|
);
|
|
billLines.findByBill.mockResolvedValue(billLinesRows);
|
|
suppliers.findById.mockResolvedValue(
|
|
supplierRow({ payableAccountId: "custom-payable" }),
|
|
);
|
|
accounts.findOne.mockResolvedValue(
|
|
stubAccount("custom-payable", { id: "custom-payable" }),
|
|
);
|
|
accounts.findByCode.mockImplementation(async (_org: string, code: string) =>
|
|
stubAccount(code),
|
|
);
|
|
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
|
|
|
await service.approveBill(actor, "bill-1");
|
|
|
|
expect(accounts.findOne).toHaveBeenCalledWith(actor, "custom-payable");
|
|
expect(accounts.findByCode).not.toHaveBeenCalledWith("org-1", "2111");
|
|
const call = journals.createPosted.mock.calls[0][1] as { lines: { accountId: string }[] };
|
|
expect(call.lines.some((l) => l.accountId === "custom-payable")).toBe(true);
|
|
});
|
|
|
|
it("writes the journal entry and the bill's APPROVED status/journalEntryId inside ONE transaction (not the nested-transaction trap)", async () => {
|
|
const { service, bills, billLines, suppliers, accounts, journals, dataSource, manager } =
|
|
build();
|
|
bills.findById.mockResolvedValue(draftBill());
|
|
billLines.findByBill.mockResolvedValue(billLinesRows);
|
|
suppliers.findById.mockResolvedValue(supplierRow());
|
|
accounts.findByCode.mockImplementation(async (_org: string, code: string) =>
|
|
stubAccount(code),
|
|
);
|
|
journals.createPosted.mockResolvedValue({
|
|
id: "je-1",
|
|
entryNumber: "JV-2026-000001",
|
|
});
|
|
|
|
await service.approveBill(actor, "bill-1");
|
|
|
|
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
|
// journals.createPosted must be given the SAME manager the transaction
|
|
// opened, not left to open its own nested one.
|
|
expect(journals.createPosted).toHaveBeenCalledWith(
|
|
actor,
|
|
expect.anything(),
|
|
manager,
|
|
);
|
|
expect(manager.billRepo.update).toHaveBeenCalledWith("bill-1", {
|
|
status: "APPROVED",
|
|
journalEntryId: "je-1",
|
|
approvedBy: "emp-1",
|
|
approvedAt: expect.any(Date),
|
|
});
|
|
// The old direct (unguarded, non-transactional) write path is gone.
|
|
expect(bills.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("PayablesService.recordPayment", () => {
|
|
const approvedBill = (over: Record<string, unknown> = {}) => ({
|
|
id: "bill-1",
|
|
organizationId: "org-1",
|
|
billNumber: "BILL-2026-000001",
|
|
status: "APPROVED",
|
|
totalAmount: 1000,
|
|
paidAmount: 0,
|
|
supplierId: "sup-1",
|
|
...over,
|
|
});
|
|
|
|
const supplierRow = (over: Record<string, unknown> = {}) => ({
|
|
id: "sup-1",
|
|
name: "Acme Supplies",
|
|
payableAccountId: null,
|
|
...over,
|
|
});
|
|
|
|
const paymentDto = (over: Record<string, unknown> = {}) =>
|
|
({
|
|
paymentDate: "2026-08-15",
|
|
amount: 400,
|
|
method: "BANK",
|
|
paidFromAccountId: "cash-1",
|
|
...over,
|
|
}) as RecordPaymentDto;
|
|
|
|
it.each(["DRAFT", "PAID", "CANCELLED"])(
|
|
"refuses paying a %s bill — only APPROVED/PARTIALLY_PAID bills are payable",
|
|
async (status) => {
|
|
const { service, bills } = build();
|
|
bills.findById.mockResolvedValue(approvedBill({ status }));
|
|
|
|
await expect(
|
|
service.recordPayment(actor, "bill-1", paymentDto()),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
},
|
|
);
|
|
|
|
it("allows paying a PARTIALLY_PAID bill", async () => {
|
|
const { service, bills, suppliers, accounts, journals } = build();
|
|
bills.findById.mockResolvedValue(
|
|
approvedBill({ status: "PARTIALLY_PAID", paidAmount: 400 }),
|
|
);
|
|
suppliers.findById.mockResolvedValue(supplierRow());
|
|
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
|
|
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
|
|
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
|
|
|
await expect(
|
|
service.recordPayment(actor, "bill-1", paymentDto({ amount: 200 })),
|
|
).resolves.toBeDefined();
|
|
});
|
|
|
|
it("refuses a payment that exceeds the outstanding balance (totalAmount - paidAmount)", async () => {
|
|
const { service, bills, suppliers, accounts } = build();
|
|
bills.findById.mockResolvedValue(approvedBill({ totalAmount: 1000, paidAmount: 800 }));
|
|
suppliers.findById.mockResolvedValue(supplierRow());
|
|
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
|
|
|
|
await expect(
|
|
service.recordPayment(actor, "bill-1", paymentDto({ amount: 300 })),
|
|
).rejects.toThrow(/exceeds the 200\.00 still outstanding/);
|
|
});
|
|
|
|
it("requires paidFromAccountId to resolve to an ASSET account", async () => {
|
|
const { service, bills, accounts } = build();
|
|
bills.findById.mockResolvedValue(approvedBill());
|
|
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "LIABILITY" }));
|
|
|
|
await expect(
|
|
service.recordPayment(actor, "bill-1", paymentDto()),
|
|
).rejects.toThrow(/must come from an ASSET/);
|
|
});
|
|
|
|
it("posts Dr payable / Cr cash with sourceModule 'supplier-payment' and sourceId '<billId>:<paymentNumber>'", async () => {
|
|
const { service, bills, suppliers, accounts, journals, payments } = build();
|
|
bills.findById.mockResolvedValue(approvedBill());
|
|
suppliers.findById.mockResolvedValue(supplierRow());
|
|
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
|
|
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
|
|
payments.nextPaymentNumber.mockResolvedValue("PAY-2026-000007");
|
|
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
|
|
|
await service.recordPayment(actor, "bill-1", paymentDto({ amount: 400 }));
|
|
|
|
expect(journals.createPosted).toHaveBeenCalledWith(
|
|
actor,
|
|
expect.objectContaining({
|
|
journalType: "CASH_PAYMENT",
|
|
sourceModule: "supplier-payment",
|
|
sourceId: "bill-1:PAY-2026-000007",
|
|
lines: [
|
|
{ accountId: "2111", debit: 400, description: "Settle BILL-2026-000001" },
|
|
{ accountId: "cash-1", credit: 400, description: "BANK" },
|
|
],
|
|
}),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("updates paidAmount and flips to PAID once paidAmount >= totalAmount, all inside one transaction", async () => {
|
|
const { service, bills, suppliers, accounts, journals, dataSource, manager } = build();
|
|
bills.findById.mockResolvedValue(approvedBill({ totalAmount: 400, paidAmount: 0 }));
|
|
suppliers.findById.mockResolvedValue(supplierRow());
|
|
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
|
|
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
|
|
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
|
|
|
await service.recordPayment(actor, "bill-1", paymentDto({ amount: 400 }));
|
|
|
|
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
|
expect(manager.billRepo.update).toHaveBeenCalledWith("bill-1", {
|
|
paidAmount: 400,
|
|
status: "PAID",
|
|
});
|
|
});
|
|
|
|
it("flips to PARTIALLY_PAID when a balance remains after the payment", async () => {
|
|
const { service, bills, suppliers, accounts, journals, manager } = build();
|
|
bills.findById.mockResolvedValue(approvedBill({ totalAmount: 1000, paidAmount: 0 }));
|
|
suppliers.findById.mockResolvedValue(supplierRow());
|
|
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
|
|
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
|
|
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
|
|
|
await service.recordPayment(actor, "bill-1", paymentDto({ amount: 400 }));
|
|
|
|
expect(manager.billRepo.update).toHaveBeenCalledWith("bill-1", {
|
|
paidAmount: 400,
|
|
status: "PARTIALLY_PAID",
|
|
});
|
|
});
|
|
|
|
it("passes the transaction's manager into journals.createPosted — the same nested-transaction trap the depreciation run guards against", async () => {
|
|
const { service, bills, suppliers, accounts, journals, dataSource, manager } = build();
|
|
bills.findById.mockResolvedValue(approvedBill());
|
|
suppliers.findById.mockResolvedValue(supplierRow());
|
|
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
|
|
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
|
|
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
|
|
|
await service.recordPayment(actor, "bill-1", paymentDto());
|
|
|
|
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
|
expect(journals.createPosted).toHaveBeenCalledWith(
|
|
actor,
|
|
expect.anything(),
|
|
manager,
|
|
);
|
|
});
|
|
});
|