mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 00:50:56 +00:00
391 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|