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

391 lines
12 KiB
TypeScript

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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<typeof build>["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();
});
});