import { BadRequestException } from "@nestjs/common"; import type { ActorContext } from "../../common/current-actor.util"; import { PayrollPostingService } from "./payroll-posting.service"; const actor: ActorContext = { employeeId: "emp-1", userId: "user-1", organizationId: "org-1", isSuperAdmin: false, }; function stubAccount(code: string) { return { id: `acct-${code}`, code, isActive: true, isGroup: false }; } function build() { const dataSource = { query: jest.fn() }; const accounts = { findByCode: jest .fn() .mockImplementation(async (_org: string, code: string) => stubAccount(code)), }; const journals = { findBySource: jest.fn(), createPosted: jest.fn() }; const service = new PayrollPostingService( dataSource as never, accounts as never, journals as never, ); return { service, dataSource, accounts, journals }; } const AVAILABLE_ROW = [{ ok: true }]; function runRow(over: Record = {}) { return [ { periodStart: "2026-07-01", periodEnd: "2026-07-31", paymentDate: "2026-08-05", status: "APPROVED", ...over, }, ]; } /** * A payroll that reconciles: gross - totalDeductions = netPay, exactly. * allowances = gross - basic = 12000 - 9000 = 3000 * otherDeductions = totalDeductions - incomeTax - pensionEmployee * = 1430 - 800 - 630 = 0 */ function payslipTotalsRow(over: Record = {}) { return [ { payslipCount: 3, basic: 9000, gross: 12000, incomeTax: 800, pensionEmployee: 630, pensionEmployer: 840, totalDeductions: 1430, netPay: 10570, ...over, }, ]; } /** Wires the fixed dataSource.query call sequence postRun makes: available(), * loadRun(), then the payslip aggregation. */ function wireQueries( dataSource: ReturnType["dataSource"], totals: unknown[] = payslipTotalsRow(), run: unknown[] = runRow(), ) { dataSource.query .mockResolvedValueOnce(AVAILABLE_ROW) .mockResolvedValueOnce(run) .mockResolvedValueOnce(totals); } describe("PayrollPostingService.postRun", () => { it("refuses when HR's payroll tables are not present in this database", async () => { const { service, dataSource, journals } = build(); dataSource.query.mockResolvedValueOnce([{ ok: false }]); await expect(service.postRun(actor, "run-1")).rejects.toBeInstanceOf( BadRequestException, ); expect(journals.createPosted).not.toHaveBeenCalled(); }); it.each(["DRAFT", "CALCULATED", "CANCELLED"])( "refuses posting a %s run — only APPROVED/PAID runs are postable", async (status) => { const { service, dataSource, journals } = build(); dataSource.query .mockResolvedValueOnce(AVAILABLE_ROW) .mockResolvedValueOnce(runRow({ status })); await expect(service.postRun(actor, "run-1")).rejects.toBeInstanceOf( BadRequestException, ); expect(journals.findBySource).not.toHaveBeenCalled(); expect(journals.createPosted).not.toHaveBeenCalled(); }, ); it("allows posting a PAID run, not just APPROVED", async () => { const { service, dataSource, journals } = build(); dataSource.query .mockResolvedValueOnce(AVAILABLE_ROW) .mockResolvedValueOnce(runRow({ status: "PAID" })) .mockResolvedValueOnce(payslipTotalsRow()); journals.findBySource.mockResolvedValue(null); journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-2026-000001", }); await expect(service.postRun(actor, "run-1")).resolves.toBeDefined(); }); it("refuses a second post — the idempotency check via journals.findBySource('hr-payroll', runId)", async () => { const { service, dataSource, journals } = build(); dataSource.query .mockResolvedValueOnce(AVAILABLE_ROW) .mockResolvedValueOnce(runRow()); journals.findBySource.mockResolvedValue({ entryNumber: "JV-2026-000005", }); await expect(service.postRun(actor, "run-1")).rejects.toThrow( /already posted as JV-2026-000005/, ); expect(journals.findBySource).toHaveBeenCalledWith("org-1", "hr-payroll", "run-1"); // The payslip aggregation query is never reached once the idempotency // guard has already refused. expect(dataSource.query).toHaveBeenCalledTimes(2); expect(journals.createPosted).not.toHaveBeenCalled(); }); it("refuses a run with no payslips", async () => { const { service, dataSource, journals } = build(); wireQueries(dataSource, [{ payslipCount: 0 }]); journals.findBySource.mockResolvedValue(null); await expect(service.postRun(actor, "run-1")).rejects.toThrow( /has no payslips/, ); expect(journals.createPosted).not.toHaveBeenCalled(); }); it("aggregates from hr.payslips, not the run header — the query sums SUM(p.*) from hr.payslips, and that sum alone drives every posted figure", async () => { const { service, dataSource, journals } = build(); wireQueries(dataSource); journals.findBySource.mockResolvedValue(null); journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-2026-000001", }); const result = await service.postRun(actor, "run-1"); // The aggregation query itself reads from hr.payslips. const aggSql = dataSource.query.mock.calls[2][0] as string; expect(aggSql).toMatch(/FROM hr\.payslips/); // loadRun's row (the "run header" stand-in) never even exposes total // fields — everything posted is derivable ONLY from the payslip sum. expect(runRow()[0]).not.toHaveProperty("totalGross"); // total = gross + employer pension, straight from the payslip aggregation. expect(result.total).toBe(12000 + 840); }); it("derives allowances = gross - basic and otherDeductions = totalDeductions - incomeTax - pensionEmployee", async () => { const { service, dataSource, journals } = build(); wireQueries( dataSource, payslipTotalsRow({ basic: 9000, gross: 12500, // allowances = 3500 incomeTax: 800, pensionEmployee: 630, totalDeductions: 1530, // otherDeductions = 1530-800-630 = 100 netPay: 10970, // 12500-1530 }), ); journals.findBySource.mockResolvedValue(null); journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" }); await service.postRun(actor, "run-1"); const lines = journals.createPosted.mock.calls[0][1].lines as { accountId: string; debit?: number; credit?: number; description: string; }[]; expect(lines).toContainEqual( expect.objectContaining({ accountId: "acct-5120", debit: 3500, description: "Allowances and other earnings", }), ); expect(lines).toContainEqual( expect.objectContaining({ accountId: "acct-2160", credit: 100, description: "Other deductions withheld", }), ); }); it("employer pension is BOTH a debit (expense) AND part of the pension-payable credit — the classic sign error the identity check guards against", async () => { const { service, dataSource, journals } = build(); wireQueries(dataSource); // pensionEmployee 630, pensionEmployer 840 journals.findBySource.mockResolvedValue(null); journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" }); await service.postRun(actor, "run-1"); const lines = journals.createPosted.mock.calls[0][1].lines as { accountId: string; debit?: number; credit?: number; }[]; const pensionExpenseLine = lines.find((l) => l.accountId === "acct-5140"); expect(pensionExpenseLine).toEqual( expect.objectContaining({ debit: 840 }), ); const pensionPayableLine = lines.find((l) => l.accountId === "acct-2122"); // employee (630) + employer (840) = 1470, both rolled into the one // credit to the fund. expect(pensionPayableLine).toEqual( expect.objectContaining({ credit: 1470 }), ); // The entry still balances by CONSTRUCTION (dual role deliberately, not // by an accidental sign cancellation elsewhere). const totalDebit = lines.reduce((s, l) => s + (l.debit ?? 0), 0); const totalCredit = lines.reduce((s, l) => s + (l.credit ?? 0), 0); expect(totalDebit).toBe(totalCredit); }); it("omits zero-value lines from the journal entirely", async () => { const { service, dataSource, journals } = build(); wireQueries( dataSource, payslipTotalsRow({ basic: 12000, gross: 12000, // allowances = 0 -> omitted incomeTax: 800, pensionEmployee: 630, totalDeductions: 1430, // otherDeductions = 0 -> omitted netPay: 10570, }), ); journals.findBySource.mockResolvedValue(null); journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" }); await service.postRun(actor, "run-1"); const lines = journals.createPosted.mock.calls[0][1].lines as { accountId: string; }[]; expect(lines.some((l) => l.accountId === "acct-5120")).toBe(false); // allowances expect(lines.some((l) => l.accountId === "acct-2160")).toBe(false); // other deductions expect(lines.length).toBe(5); // basic, pension-employer, tax, pension-payable, salaries-payable }); it("dates the entry the period END, not the payment date", async () => { const { service, dataSource, journals } = build(); wireQueries(dataSource, payslipTotalsRow(), runRow({ periodEnd: "2026-07-31", paymentDate: "2026-08-05", })); journals.findBySource.mockResolvedValue(null); journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" }); await service.postRun(actor, "run-1"); expect(journals.createPosted.mock.calls[0][1]).toMatchObject({ entryDate: "2026-07-31", sourceModule: "hr-payroll", sourceId: "run-1", }); }); }); describe("PayrollPostingService — assertPayrollIdentity (private)", () => { type Totals = { gross: number; totalDeductions: number; netPay: number; allowances: number; otherDeductions: number; }; function identityCheck(service: PayrollPostingService) { return ( service as never as { assertPayrollIdentity: (t: Totals) => void } ).assertPayrollIdentity.bind(service); } it("passes when gross - totalDeductions == netPay exactly", () => { const { service } = build(); expect(() => identityCheck(service)({ gross: 12000, totalDeductions: 1430, netPay: 10570, allowances: 3000, otherDeductions: 0, }), ).not.toThrow(); }); it("tolerates a sub-half-cent discrepancy (< 0.005)", () => { const { service } = build(); expect(() => identityCheck(service)({ gross: 12000, totalDeductions: 1430, netPay: 10570.004, allowances: 3000, otherDeductions: 0, }), ).not.toThrow(); }); it("rejects a genuine mismatch between gross - totalDeductions and netPay", () => { const { service } = build(); expect(() => identityCheck(service)({ gross: 12000, totalDeductions: 1430, netPay: 10600, // should be 10570 allowances: 3000, otherDeductions: 0, }), ).toThrow(/does not reconcile/); }); it("rejects negative allowances — basic salary exceeding gross pay", () => { const { service } = build(); expect(() => identityCheck(service)({ gross: 9000, totalDeductions: 1000, netPay: 8000, allowances: -500, // basic > gross otherDeductions: 0, }), ).toThrow(/Basic salary exceeds gross pay/); }); it("rejects negative otherDeductions — income tax and pension exceeding total deductions", () => { const { service } = build(); expect(() => identityCheck(service)({ gross: 9000, totalDeductions: 500, netPay: 8500, allowances: 1000, otherDeductions: -200, }), ).toThrow(/Income tax and pension exceed total deductions/); }); it("checks the identity BEFORE any account is resolved or anything is posted", async () => { const { service, dataSource, journals, accounts } = build(); wireQueries( dataSource, payslipTotalsRow({ netPay: 999999 }), // deliberately broken identity ); journals.findBySource.mockResolvedValue(null); await expect(service.postRun(actor, "run-1")).rejects.toThrow( /does not reconcile/, ); expect(accounts.findByCode).not.toHaveBeenCalled(); expect(journals.createPosted).not.toHaveBeenCalled(); }); });