Files
edr-platform/apps/finance-api/src/modules/payables/payables.service.spec.ts
2026-08-26 23:57:26 +03:00

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,
);
});
});