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) => data, save: jest .fn() .mockImplementation(async (data: Record) => ({ 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 = {}) { return { id: code, code, accountType: "LIABILITY", isActive: true, isGroup: false, ...over }; } describe("PayablesService.approveBill", () => { const draftBill = (over: Record = {}) => ({ 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 = {}) => ({ 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 = {}) => ({ id: "bill-1", organizationId: "org-1", billNumber: "BILL-2026-000001", status: "APPROVED", totalAmount: 1000, paidAmount: 0, supplierId: "sup-1", ...over, }); const supplierRow = (over: Record = {}) => ({ id: "sup-1", name: "Acme Supplies", payableAccountId: null, ...over, }); const paymentDto = (over: Record = {}) => ({ 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 ':'", 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, ); }); });