mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
Adding all functionalities
This commit is contained in:
@@ -0,0 +1,494 @@
|
|||||||
|
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { AppraisalService } from "./appraisal.service";
|
||||||
|
import { ActorContext } from "../../employees/employees.service";
|
||||||
|
import {
|
||||||
|
Appraisal,
|
||||||
|
AppraisalCriterion,
|
||||||
|
AppraisalGoal,
|
||||||
|
AppraisalRating,
|
||||||
|
AppraisalTemplate,
|
||||||
|
EAppraisalStatus,
|
||||||
|
ECycleStatus,
|
||||||
|
RatingBand,
|
||||||
|
} from "../entities/appraisal.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A minimal stand-in for the transaction `EntityManager`. Tracks every
|
||||||
|
* `.save()`/`.update()` call per-entity so tests can assert exactly what was
|
||||||
|
* persisted, mirroring the freight billing/wagon spec convention.
|
||||||
|
*/
|
||||||
|
function makeManager() {
|
||||||
|
const calls: {
|
||||||
|
appraisalSaves: Record<string, unknown>[];
|
||||||
|
ratingSaves: Record<string, unknown>[];
|
||||||
|
appraisalUpdates: { id: string; data: Record<string, unknown> }[];
|
||||||
|
ratingUpdates: { id: string; data: Record<string, unknown> }[];
|
||||||
|
goalUpdates: { id: string; data: Record<string, unknown> }[];
|
||||||
|
} = {
|
||||||
|
appraisalSaves: [],
|
||||||
|
ratingSaves: [],
|
||||||
|
appraisalUpdates: [],
|
||||||
|
ratingUpdates: [],
|
||||||
|
goalUpdates: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
getRepository: (entity: { name: string }) => {
|
||||||
|
const name = entity.name;
|
||||||
|
return {
|
||||||
|
create: (data: Record<string, unknown>) => data,
|
||||||
|
save: jest.fn(async (data: Record<string, unknown>) => {
|
||||||
|
const row = { id: (data.id as string) ?? `${name}-${seq++}`, ...data };
|
||||||
|
if (name === "Appraisal") calls.appraisalSaves.push(row);
|
||||||
|
if (name === "AppraisalRating") calls.ratingSaves.push(row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
update: jest.fn(async (id: string, data: Record<string, unknown>) => {
|
||||||
|
if (name === "Appraisal") calls.appraisalUpdates.push({ id, data });
|
||||||
|
if (name === "AppraisalRating") calls.ratingUpdates.push({ id, data });
|
||||||
|
if (name === "AppraisalGoal") calls.goalUpdates.push({ id, data });
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { manager, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorFixture = (over: Partial<ActorContext> = {}): ActorContext => ({
|
||||||
|
employeeId: "mgr-1",
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
isSuperAdmin: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const criterionFixture = (
|
||||||
|
over: Partial<AppraisalCriterion> = {},
|
||||||
|
): AppraisalCriterion =>
|
||||||
|
({
|
||||||
|
id: "c1",
|
||||||
|
templateId: "tpl-1",
|
||||||
|
code: "QUALITY",
|
||||||
|
name: { am: "ጥራት", en: "Quality" },
|
||||||
|
weight: "50.00",
|
||||||
|
sortOrder: 10,
|
||||||
|
...over,
|
||||||
|
}) as AppraisalCriterion;
|
||||||
|
|
||||||
|
const bandFixtures: RatingBand[] = [
|
||||||
|
{ min: 90, label: { am: "", en: "Excellent" } },
|
||||||
|
{ min: 75, label: { am: "", en: "Good" } },
|
||||||
|
{ min: 50, label: { am: "", en: "Fair" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const templateFixture = (
|
||||||
|
over: Partial<AppraisalTemplate> = {},
|
||||||
|
): AppraisalTemplate =>
|
||||||
|
({
|
||||||
|
id: "tpl-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
code: "ANNUAL",
|
||||||
|
name: { am: "", en: "Annual" },
|
||||||
|
maxScore: "10.00",
|
||||||
|
ratingBands: bandFixtures,
|
||||||
|
requiresSelfAssessment: true,
|
||||||
|
requiresAcknowledgement: true,
|
||||||
|
criteria: [
|
||||||
|
criterionFixture({ id: "c1", code: "QUALITY", weight: "50.00", sortOrder: 10 }),
|
||||||
|
criterionFixture({ id: "c2", code: "SPEED", weight: "50.00", sortOrder: 20 }),
|
||||||
|
],
|
||||||
|
...over,
|
||||||
|
}) as AppraisalTemplate;
|
||||||
|
|
||||||
|
const cycleFixture = (over: Partial<import("../entities/appraisal.entity").AppraisalCycle> = {}) =>
|
||||||
|
({
|
||||||
|
id: "cycle-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
code: "FY2026",
|
||||||
|
name: { am: "", en: "FY2026" },
|
||||||
|
templateId: "tpl-1",
|
||||||
|
periodStart: "2026-01-01",
|
||||||
|
periodEnd: "2026-12-31",
|
||||||
|
status: ECycleStatus.DRAFT,
|
||||||
|
...over,
|
||||||
|
}) as import("../entities/appraisal.entity").AppraisalCycle;
|
||||||
|
|
||||||
|
const appraisalFixture = (over: Partial<Appraisal> = {}): Appraisal =>
|
||||||
|
({
|
||||||
|
id: "appraisal-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
cycleId: "cycle-1",
|
||||||
|
employeeId: "emp-1",
|
||||||
|
appraiserEmployeeId: "mgr-1",
|
||||||
|
status: EAppraisalStatus.PENDING_MANAGER,
|
||||||
|
ratings: [
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
appraisalId: "appraisal-1",
|
||||||
|
code: "QUALITY",
|
||||||
|
name: { am: "", en: "Quality" },
|
||||||
|
weight: "50.00",
|
||||||
|
maxScore: "10.00",
|
||||||
|
selfScore: "4.00",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r2",
|
||||||
|
appraisalId: "appraisal-1",
|
||||||
|
code: "SPEED",
|
||||||
|
name: { am: "", en: "Speed" },
|
||||||
|
weight: "50.00",
|
||||||
|
maxScore: "10.00",
|
||||||
|
selfScore: "3.00",
|
||||||
|
sortOrder: 20,
|
||||||
|
},
|
||||||
|
] as AppraisalRating[],
|
||||||
|
goals: [] as AppraisalGoal[],
|
||||||
|
...over,
|
||||||
|
}) as Appraisal;
|
||||||
|
|
||||||
|
describe("AppraisalService", () => {
|
||||||
|
let dataSource: { transaction: jest.Mock; query: jest.Mock };
|
||||||
|
let templates: { findOne: jest.Mock; find: jest.Mock; save: jest.Mock; create: jest.Mock };
|
||||||
|
let criteria: { save: jest.Mock; create: jest.Mock };
|
||||||
|
let cycles: { findOne: jest.Mock; update: jest.Mock; save: jest.Mock; create: jest.Mock };
|
||||||
|
let appraisals: {
|
||||||
|
findOne: jest.Mock;
|
||||||
|
find: jest.Mock;
|
||||||
|
createQueryBuilder: jest.Mock;
|
||||||
|
};
|
||||||
|
let goals: { count: jest.Mock; save: jest.Mock; create: jest.Mock };
|
||||||
|
let employees: { findByEmployeeId: jest.Mock };
|
||||||
|
let iamDirectory: { findLineManagerEmployeeId: jest.Mock };
|
||||||
|
let service: AppraisalService;
|
||||||
|
let managerCalls: ReturnType<typeof makeManager>["calls"];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const built = makeManager();
|
||||||
|
managerCalls = built.calls;
|
||||||
|
|
||||||
|
dataSource = {
|
||||||
|
transaction: jest.fn(async (cb: (m: unknown) => unknown) => cb(built.manager)),
|
||||||
|
query: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
templates = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(templateFixture()),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
save: jest.fn(),
|
||||||
|
create: jest.fn((d) => d),
|
||||||
|
};
|
||||||
|
criteria = { save: jest.fn(), create: jest.fn((d) => d) };
|
||||||
|
cycles = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(cycleFixture()),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
save: jest.fn(),
|
||||||
|
create: jest.fn((d) => d),
|
||||||
|
};
|
||||||
|
appraisals = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
};
|
||||||
|
goals = { count: jest.fn().mockResolvedValue(0), save: jest.fn(), create: jest.fn((d) => d) };
|
||||||
|
employees = { findByEmployeeId: jest.fn().mockResolvedValue({ managerEmployeeId: null }) };
|
||||||
|
iamDirectory = { findLineManagerEmployeeId: jest.fn().mockResolvedValue(null) };
|
||||||
|
|
||||||
|
service = new AppraisalService(
|
||||||
|
dataSource as never,
|
||||||
|
templates as never,
|
||||||
|
criteria as never,
|
||||||
|
cycles as never,
|
||||||
|
appraisals as never,
|
||||||
|
goals as never,
|
||||||
|
employees as never,
|
||||||
|
iamDirectory as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── openCycle ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("openCycle", () => {
|
||||||
|
it("only opens a DRAFT or OPEN cycle", async () => {
|
||||||
|
cycles.findOne.mockResolvedValue(cycleFixture({ status: ECycleStatus.CLOSED }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.openCycle("cycle-1", ["emp-1"], actorFixture()),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a DRAFT cycle and also allows re-opening an already-OPEN one", async () => {
|
||||||
|
cycles.findOne.mockResolvedValue(cycleFixture({ status: ECycleStatus.OPEN }));
|
||||||
|
appraisals.findOne.mockResolvedValue(null);
|
||||||
|
employees.findByEmployeeId.mockResolvedValue({ managerEmployeeId: null });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.openCycle("cycle-1", ["emp-1"], actorFixture()),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies the template's criteria onto AppraisalRating rows AT OPEN TIME — a later template edit does not retroactively change an already-opened appraisal", async () => {
|
||||||
|
const template = templateFixture();
|
||||||
|
templates.findOne.mockResolvedValue(template);
|
||||||
|
appraisals.findOne.mockResolvedValue(null);
|
||||||
|
employees.findByEmployeeId.mockResolvedValue({ managerEmployeeId: null });
|
||||||
|
|
||||||
|
const result = await service.openCycle("cycle-1", ["emp-1"], actorFixture());
|
||||||
|
expect(result.created).toBe(1);
|
||||||
|
expect(result.skipped).toBe(0);
|
||||||
|
expect(managerCalls.ratingSaves).toHaveLength(2);
|
||||||
|
|
||||||
|
const qualityRating = managerCalls.ratingSaves.find((r) => r.code === "QUALITY")!;
|
||||||
|
expect(qualityRating.weight).toBe("50.00");
|
||||||
|
expect(qualityRating.maxScore).toBe("10.00");
|
||||||
|
|
||||||
|
// Mutate the template's criteria AFTER the appraisal was opened.
|
||||||
|
template.criteria![0].weight = "999.00";
|
||||||
|
template.criteria![0].name = { am: "changed", en: "changed" };
|
||||||
|
|
||||||
|
// The rows already saved for the opened appraisal are untouched — they
|
||||||
|
// were copied, not a live reference into the mutable template.
|
||||||
|
expect(qualityRating.weight).toBe("50.00");
|
||||||
|
|
||||||
|
// And re-running openCycle for the SAME employee/cycle is a no-op skip —
|
||||||
|
// the mutated template criteria never reach the existing appraisal.
|
||||||
|
appraisals.findOne.mockResolvedValue(appraisalFixture());
|
||||||
|
const second = await service.openCycle("cycle-1", ["emp-1"], actorFixture());
|
||||||
|
expect(second.skipped).toBe(1);
|
||||||
|
expect(second.created).toBe(0);
|
||||||
|
// Still only the original 2 rating rows were ever saved.
|
||||||
|
expect(managerCalls.ratingSaves).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to everyone employed with an HR profile, excluding TERMINATED/RETIRED", async () => {
|
||||||
|
dataSource.query.mockResolvedValue([{ employeeId: "emp-1" }, { employeeId: "emp-2" }]);
|
||||||
|
appraisals.findOne.mockResolvedValue(null);
|
||||||
|
employees.findByEmployeeId.mockResolvedValue({ managerEmployeeId: null });
|
||||||
|
|
||||||
|
const result = await service.openCycle("cycle-1", undefined, actorFixture());
|
||||||
|
|
||||||
|
expect(dataSource.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("NOT IN ('TERMINATED','RETIRED')"),
|
||||||
|
["org-1"],
|
||||||
|
);
|
||||||
|
expect(result.created).toBe(2);
|
||||||
|
expect(employees.findByEmployeeId).toHaveBeenCalledWith("emp-1");
|
||||||
|
expect(employees.findByEmployeeId).toHaveBeenCalledWith("emp-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips (without erroring) an employee who has no HR profile", async () => {
|
||||||
|
appraisals.findOne.mockResolvedValue(null);
|
||||||
|
employees.findByEmployeeId.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.openCycle("cycle-1", ["emp-no-profile"], actorFixture());
|
||||||
|
|
||||||
|
expect(result.created).toBe(0);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips (without erroring) an employee who already has an appraisal for the cycle", async () => {
|
||||||
|
appraisals.findOne.mockResolvedValue(appraisalFixture());
|
||||||
|
|
||||||
|
const result = await service.openCycle("cycle-1", ["emp-1"], actorFixture());
|
||||||
|
|
||||||
|
expect(result.created).toBe(0);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps each employee's work in its own transaction", async () => {
|
||||||
|
appraisals.findOne.mockResolvedValue(null);
|
||||||
|
employees.findByEmployeeId.mockResolvedValue({ managerEmployeeId: null });
|
||||||
|
|
||||||
|
await service.openCycle("cycle-1", ["emp-1", "emp-2", "emp-3"], actorFixture());
|
||||||
|
|
||||||
|
expect(dataSource.transaction).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── submitManager ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("submitManager", () => {
|
||||||
|
const dto = {
|
||||||
|
ratings: [
|
||||||
|
{ code: "QUALITY", score: 9 },
|
||||||
|
{ code: "SPEED", score: 8 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
appraisals.findOne.mockResolvedValue(appraisalFixture());
|
||||||
|
cycles.findOne.mockResolvedValue(cycleFixture());
|
||||||
|
templates.findOne.mockResolvedValue(templateFixture());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only accepts submission for a PENDING_MANAGER appraisal", async () => {
|
||||||
|
appraisals.findOne.mockResolvedValue(
|
||||||
|
appraisalFixture({ status: EAppraisalStatus.PENDING_SELF }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.submitManager("appraisal-1", dto, actorFixture()),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses self-appraisal", async () => {
|
||||||
|
appraisals.findOne.mockResolvedValue(
|
||||||
|
appraisalFixture({ employeeId: "mgr-1", appraiserEmployeeId: "mgr-1" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.submitManager("appraisal-1", dto, actorFixture({ employeeId: "mgr-1" })),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects submission missing a score for any criterion", async () => {
|
||||||
|
await expect(
|
||||||
|
service.submitManager(
|
||||||
|
"appraisal-1",
|
||||||
|
{ ratings: [{ code: "QUALITY", score: 9 }] },
|
||||||
|
actorFixture(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/SPEED/);
|
||||||
|
await expect(
|
||||||
|
service.submitManager(
|
||||||
|
"appraisal-1",
|
||||||
|
{ ratings: [{ code: "QUALITY", score: 9 }] },
|
||||||
|
actorFixture(),
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the manager's score as the FINAL score — never averaged with the self-score", async () => {
|
||||||
|
// Self scores (4 and 3 out of 10, weights 50/50) would weight to 35.
|
||||||
|
// Manager scores (9 and 8 out of 10) weight to 85. An average would be 60.
|
||||||
|
await service.submitManager("appraisal-1", dto, actorFixture());
|
||||||
|
|
||||||
|
expect(managerCalls.appraisalUpdates).toHaveLength(1);
|
||||||
|
const update = managerCalls.appraisalUpdates[0].data;
|
||||||
|
expect(update.managerScore).toBe("85.00");
|
||||||
|
expect(update.finalScore).toBe("85.00");
|
||||||
|
expect(update.finalScore).not.toBe("60.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves to PENDING_ACKNOWLEDGEMENT when the template requires acknowledgement", async () => {
|
||||||
|
templates.findOne.mockResolvedValue(
|
||||||
|
templateFixture({ requiresAcknowledgement: true }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.submitManager("appraisal-1", dto, actorFixture());
|
||||||
|
|
||||||
|
const update = managerCalls.appraisalUpdates[0].data;
|
||||||
|
expect(update.status).toBe(EAppraisalStatus.PENDING_ACKNOWLEDGEMENT);
|
||||||
|
expect(update.acknowledgedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves straight to COMPLETED when the template does not require acknowledgement", async () => {
|
||||||
|
templates.findOne.mockResolvedValue(
|
||||||
|
templateFixture({ requiresAcknowledgement: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.submitManager("appraisal-1", dto, actorFixture());
|
||||||
|
|
||||||
|
const update = managerCalls.appraisalUpdates[0].data;
|
||||||
|
expect(update.status).toBe(EAppraisalStatus.COMPLETED);
|
||||||
|
expect(update.acknowledgedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── pure static functions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("weightedScore (pure)", () => {
|
||||||
|
it("sums score/maxScore*weight across entries", () => {
|
||||||
|
const result = AppraisalService.weightedScore([
|
||||||
|
{ weight: 50, maxScore: 10, score: 5 },
|
||||||
|
{ weight: 50, maxScore: 10, score: 10 },
|
||||||
|
]);
|
||||||
|
expect(result).toBe(75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps above 100 down to 100", () => {
|
||||||
|
const result = AppraisalService.weightedScore([
|
||||||
|
{ weight: 200, maxScore: 10, score: 10 },
|
||||||
|
]);
|
||||||
|
expect(result).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps below 0 up to 0", () => {
|
||||||
|
const result = AppraisalService.weightedScore([
|
||||||
|
{ weight: 100, maxScore: 10, score: -5 },
|
||||||
|
]);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips an entry whose maxScore is zero or negative rather than dividing by it", () => {
|
||||||
|
const result = AppraisalService.weightedScore([
|
||||||
|
{ weight: 50, maxScore: 0, score: 5 },
|
||||||
|
{ weight: 50, maxScore: 10, score: 10 },
|
||||||
|
]);
|
||||||
|
expect(result).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds to 2 decimal places", () => {
|
||||||
|
const result = AppraisalService.weightedScore([
|
||||||
|
{ weight: 33.33, maxScore: 3, score: 1 },
|
||||||
|
]);
|
||||||
|
expect(result).toBe(Math.round((33.33 / 3) * 100) / 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for an empty entry list", () => {
|
||||||
|
expect(AppraisalService.weightedScore([])).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bandFor (pure)", () => {
|
||||||
|
it("returns the highest matching band, regardless of input order", () => {
|
||||||
|
const shuffled: RatingBand[] = [
|
||||||
|
{ min: 50, label: { am: "", en: "Fair" } },
|
||||||
|
{ min: 90, label: { am: "", en: "Excellent" } },
|
||||||
|
{ min: 75, label: { am: "", en: "Good" } },
|
||||||
|
];
|
||||||
|
expect(AppraisalService.bandFor(95, shuffled)).toBe("Excellent");
|
||||||
|
expect(AppraisalService.bandFor(90, shuffled)).toBe("Excellent");
|
||||||
|
expect(AppraisalService.bandFor(80, shuffled)).toBe("Good");
|
||||||
|
expect(AppraisalService.bandFor(60, shuffled)).toBe("Fair");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the score is below every band's minimum", () => {
|
||||||
|
expect(AppraisalService.bandFor(10, bandFixtures)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for an empty band list", () => {
|
||||||
|
expect(AppraisalService.bandFor(100, [])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assertWeights (pure, private)", () => {
|
||||||
|
const assertWeights = (weights: number[]): void =>
|
||||||
|
(
|
||||||
|
AppraisalService as unknown as {
|
||||||
|
assertWeights(w: number[]): void;
|
||||||
|
}
|
||||||
|
).assertWeights(weights);
|
||||||
|
|
||||||
|
it("accepts weights that sum to exactly 100", () => {
|
||||||
|
expect(() => assertWeights([50, 50])).not.toThrow();
|
||||||
|
expect(() => assertWeights([33.33, 33.33, 33.34])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects weights that sum to anything other than exactly 100", () => {
|
||||||
|
expect(() => assertWeights([50, 49.99])).toThrow(BadRequestException);
|
||||||
|
expect(() => assertWeights([50, 50.01])).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty criteria list", () => {
|
||||||
|
expect(() => assertWeights([])).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { AttendanceDayService, atTime, type DayContext } from "./attendance-day.service";
|
||||||
|
import { EAttendanceStatus } from "../entities/attendance-record.entity";
|
||||||
|
import type { WorkSchedule } from "../entities/work-schedule.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both methods under test are `static` and side-effect free — no service
|
||||||
|
* instantiation, no mocks. Every Date fixture goes through the same `atTime`
|
||||||
|
* helper the source uses, so local-timezone parsing is consistent on both
|
||||||
|
* sides of every subtraction.
|
||||||
|
*/
|
||||||
|
describe("AttendanceDayService — pure day math", () => {
|
||||||
|
const schedule = (over: Partial<WorkSchedule> = {}): WorkSchedule =>
|
||||||
|
({
|
||||||
|
id: "sched-1",
|
||||||
|
startTime: "08:30",
|
||||||
|
endTime: "17:30",
|
||||||
|
breakMinutes: 60,
|
||||||
|
gracePeriodMinutes: 10,
|
||||||
|
minMinutesFullDay: 420,
|
||||||
|
minMinutesHalfDay: 210,
|
||||||
|
crossesMidnight: false,
|
||||||
|
workingDays: null,
|
||||||
|
...over,
|
||||||
|
}) as WorkSchedule;
|
||||||
|
|
||||||
|
describe("measure", () => {
|
||||||
|
it("returns all zeros when there is no check-out yet", () => {
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
schedule(),
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "08:30"),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ workedMinutes: 0, lateMinutes: 0, earlyLeaveMinutes: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with no schedule, worked minutes are the raw gross — no break, no lateness", () => {
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
null,
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "09:00"),
|
||||||
|
atTime("2026-03-10", "17:00"),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ workedMinutes: 480, lateMinutes: 0, earlyLeaveMinutes: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an ordinary on-time day: break deducted, nothing late or early", () => {
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
schedule(),
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "08:30"),
|
||||||
|
atTime("2026-03-10", "17:30"),
|
||||||
|
);
|
||||||
|
// gross 540 - break 60 = 480; checkIn == scheduledStart; checkOut == scheduledEnd
|
||||||
|
expect(result).toEqual({ workedMinutes: 480, lateMinutes: 0, earlyLeaveMinutes: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deducts the break only when the shift is long enough to have taken one", () => {
|
||||||
|
// gross = 45 minutes, which does not exceed the 60-minute break — so
|
||||||
|
// nothing is deducted, and workedMinutes is NOT clamped to zero.
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
schedule(),
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "08:30"),
|
||||||
|
atTime("2026-03-10", "09:15"),
|
||||||
|
);
|
||||||
|
expect(result.workedMinutes).toBe(45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charges lateness only past the grace period, and reports the full overage", () => {
|
||||||
|
// 15 minutes late against a 10-minute grace period.
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
schedule(),
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "08:45"),
|
||||||
|
atTime("2026-03-10", "17:30"),
|
||||||
|
);
|
||||||
|
expect(result.lateMinutes).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grace is all-or-nothing: inside it, nothing is recorded at all", () => {
|
||||||
|
// 8 minutes late, inside the 10-minute grace period.
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
schedule(),
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "08:38"),
|
||||||
|
atTime("2026-03-10", "17:30"),
|
||||||
|
);
|
||||||
|
expect(result.lateMinutes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grace is all-or-nothing: exactly AT the grace boundary is still not late (strictly greater-than)", () => {
|
||||||
|
// 10 minutes late == a 10-minute grace period exactly.
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
schedule({ gracePeriodMinutes: 10 }),
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "08:40"),
|
||||||
|
atTime("2026-03-10", "17:30"),
|
||||||
|
);
|
||||||
|
expect(result.lateMinutes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grace is all-or-nothing: one minute past the boundary charges the FULL lateness, not just the excess", () => {
|
||||||
|
// 11 minutes late — one minute past a 10-minute grace period. If grace were
|
||||||
|
// partial, this would report 1 minute; it must report the full 11.
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
schedule({ gracePeriodMinutes: 10 }),
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "08:41"),
|
||||||
|
atTime("2026-03-10", "17:30"),
|
||||||
|
);
|
||||||
|
expect(result.lateMinutes).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checking in early is never lateness, however early", () => {
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
schedule(),
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "07:00"),
|
||||||
|
atTime("2026-03-10", "17:30"),
|
||||||
|
);
|
||||||
|
expect(result.lateMinutes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charges an early leave in minutes short of the scheduled end", () => {
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
schedule(),
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "08:30"),
|
||||||
|
atTime("2026-03-10", "17:00"),
|
||||||
|
);
|
||||||
|
expect(result.earlyLeaveMinutes).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("staying past the scheduled end is never an early leave", () => {
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
schedule(),
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "08:30"),
|
||||||
|
atTime("2026-03-10", "19:00"),
|
||||||
|
);
|
||||||
|
expect(result.earlyLeaveMinutes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a night shift pushes the scheduled end 24h forward before comparing", () => {
|
||||||
|
const nightSchedule = schedule({
|
||||||
|
startTime: "22:00",
|
||||||
|
endTime: "06:00",
|
||||||
|
breakMinutes: 30,
|
||||||
|
crossesMidnight: true,
|
||||||
|
});
|
||||||
|
// On time both ends — checked against the +24h scheduled end, not 06:00
|
||||||
|
// on the shift's own start date.
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
nightSchedule,
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "22:00"),
|
||||||
|
atTime("2026-03-11", "06:00"),
|
||||||
|
);
|
||||||
|
// gross = 8h = 480; break 30 (480 > 30) deducted -> 450
|
||||||
|
expect(result).toEqual({ workedMinutes: 450, lateMinutes: 0, earlyLeaveMinutes: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a night shift leaving an hour early is charged against the pushed-forward end", () => {
|
||||||
|
const nightSchedule = schedule({
|
||||||
|
startTime: "22:00",
|
||||||
|
endTime: "06:00",
|
||||||
|
breakMinutes: 30,
|
||||||
|
crossesMidnight: true,
|
||||||
|
});
|
||||||
|
const result = AttendanceDayService.measure(
|
||||||
|
nightSchedule,
|
||||||
|
"2026-03-10",
|
||||||
|
atTime("2026-03-10", "22:00"),
|
||||||
|
atTime("2026-03-11", "05:00"),
|
||||||
|
);
|
||||||
|
expect(result.earlyLeaveMinutes).toBe(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("statusForUnworkedDay", () => {
|
||||||
|
const context = (over: Partial<DayContext> = {}): DayContext => ({
|
||||||
|
workDate: "2026-03-10",
|
||||||
|
isWorkingDay: true,
|
||||||
|
isHoliday: false,
|
||||||
|
holidayName: null,
|
||||||
|
leave: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leave wins over everything else, even a holiday on the same day", () => {
|
||||||
|
const result = AttendanceDayService.statusForUnworkedDay(
|
||||||
|
context({
|
||||||
|
isHoliday: true,
|
||||||
|
holidayName: "Meskel",
|
||||||
|
leave: { requestId: "req-1", leaveTypeCode: "ANNUAL", isHalfDay: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: EAttendanceStatus.ON_LEAVE,
|
||||||
|
leaveRequestId: "req-1",
|
||||||
|
notes: "ANNUAL leave",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a holiday wins over a rest day when there is no leave", () => {
|
||||||
|
const result = AttendanceDayService.statusForUnworkedDay(
|
||||||
|
context({ isWorkingDay: false, isHoliday: true, holidayName: "Meskel" }),
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: EAttendanceStatus.HOLIDAY,
|
||||||
|
leaveRequestId: null,
|
||||||
|
notes: "Meskel",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a holiday wins even on an otherwise-working day (not just over a rest day)", () => {
|
||||||
|
const result = AttendanceDayService.statusForUnworkedDay(
|
||||||
|
context({ isWorkingDay: true, isHoliday: true, holidayName: "Adwa Day" }),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(EAttendanceStatus.HOLIDAY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leave wins over a rest day too, not just over a holiday", () => {
|
||||||
|
const result = AttendanceDayService.statusForUnworkedDay(
|
||||||
|
context({
|
||||||
|
isWorkingDay: false,
|
||||||
|
isHoliday: false,
|
||||||
|
leave: { requestId: "req-2", leaveTypeCode: "SICK", isHalfDay: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(EAttendanceStatus.ON_LEAVE);
|
||||||
|
expect(result.leaveRequestId).toBe("req-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("half-day leave still wins this precedence — the half-day distinction belongs to checkIn, not this method", () => {
|
||||||
|
const result = AttendanceDayService.statusForUnworkedDay(
|
||||||
|
context({
|
||||||
|
isHoliday: true,
|
||||||
|
leave: { requestId: "req-3", leaveTypeCode: "ANNUAL", isHalfDay: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(EAttendanceStatus.ON_LEAVE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a non-working day with no leave or holiday is a rest day", () => {
|
||||||
|
const result = AttendanceDayService.statusForUnworkedDay(context({ isWorkingDay: false }));
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: EAttendanceStatus.REST_DAY,
|
||||||
|
leaveRequestId: null,
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a working day with nothing excusing it is absent", () => {
|
||||||
|
const result = AttendanceDayService.statusForUnworkedDay(context());
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: EAttendanceStatus.ABSENT,
|
||||||
|
leaveRequestId: null,
|
||||||
|
notes: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,497 @@
|
|||||||
|
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { AttendanceService } from "./attendance.service";
|
||||||
|
import { EAttendanceSource, EAttendanceStatus } from "../entities/attendance-record.entity";
|
||||||
|
import type { ActorContext } from "../../employees/employees.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the freight suite's convention: plain `new AttendanceService(...)`
|
||||||
|
* with hand-built fakes passed positionally, no `Test.createTestingModule`.
|
||||||
|
* `AttendanceDayService.measure`/`.statusForWorkedDay` are called as static
|
||||||
|
* methods directly on the real imported class inside the service, so they run
|
||||||
|
* for real here — only `this.days.contextFor` (an instance method) is mocked.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ORG_ID = "org-1";
|
||||||
|
const EMPLOYEE_ID = "emp-1";
|
||||||
|
|
||||||
|
const actor: ActorContext = {
|
||||||
|
employeeId: "emp-manager",
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: ORG_ID,
|
||||||
|
isSuperAdmin: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function employee(over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: EMPLOYEE_ID,
|
||||||
|
name: { en: "Test Employee", am: "ሙከራ" },
|
||||||
|
status: "ACTIVE",
|
||||||
|
isCurrent: true,
|
||||||
|
unitId: "unit-1",
|
||||||
|
organizationId: ORG_ID,
|
||||||
|
userId: "iam-user-1",
|
||||||
|
username: "test.employee",
|
||||||
|
email: "test@example.com",
|
||||||
|
phoneNumber: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultContext(over: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
workDate: "2026-08-26",
|
||||||
|
isWorkingDay: true,
|
||||||
|
isHoliday: false,
|
||||||
|
holidayName: null,
|
||||||
|
leave: null,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(over: {
|
||||||
|
attendance?: Record<string, jest.Mock>;
|
||||||
|
schedules?: Record<string, jest.Mock>;
|
||||||
|
days?: Record<string, jest.Mock>;
|
||||||
|
employees?: Record<string, jest.Mock>;
|
||||||
|
iamDirectory?: Record<string, jest.Mock>;
|
||||||
|
} = {}) {
|
||||||
|
const attendance = {
|
||||||
|
findForDate: jest.fn().mockResolvedValue(null),
|
||||||
|
create: jest.fn().mockImplementation((data: Record<string, unknown>) =>
|
||||||
|
Promise.resolve({ id: "record-1", ...data }),
|
||||||
|
),
|
||||||
|
update: jest.fn().mockImplementation((_id: string, data: Record<string, unknown>) =>
|
||||||
|
Promise.resolve({ id: "record-1", ...data }),
|
||||||
|
),
|
||||||
|
findRange: jest.fn().mockResolvedValue([]),
|
||||||
|
findPage: jest.fn().mockResolvedValue([[], 0]),
|
||||||
|
summarize: jest.fn().mockResolvedValue({}),
|
||||||
|
...over.attendance,
|
||||||
|
};
|
||||||
|
const schedules = {
|
||||||
|
resolveFor: jest.fn().mockResolvedValue(null),
|
||||||
|
...over.schedules,
|
||||||
|
};
|
||||||
|
const days = {
|
||||||
|
contextFor: jest.fn().mockResolvedValue(defaultContext()),
|
||||||
|
...over.days,
|
||||||
|
};
|
||||||
|
const employees = {
|
||||||
|
findByEmployeeId: jest.fn().mockResolvedValue(null),
|
||||||
|
...over.employees,
|
||||||
|
};
|
||||||
|
const iamDirectory = {
|
||||||
|
requireEmployee: jest.fn().mockResolvedValue(employee()),
|
||||||
|
...over.iamDirectory,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new AttendanceService(
|
||||||
|
attendance as never,
|
||||||
|
schedules as never,
|
||||||
|
days as never,
|
||||||
|
employees as never,
|
||||||
|
iamDirectory as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, attendance, schedules, days, employees, iamDirectory };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AttendanceService.checkIn", () => {
|
||||||
|
it("computes workDate as the calendar date of the check-in instant (UTC slice)", async () => {
|
||||||
|
const { service, attendance, days } = build();
|
||||||
|
// 21:00 UTC on Aug 25 — still Aug 25 in UTC terms, which is what
|
||||||
|
// `at.toISOString().slice(0, 10)` yields regardless of server-local zone.
|
||||||
|
const at = new Date("2026-08-25T21:00:00.000Z");
|
||||||
|
|
||||||
|
await service.checkIn(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor);
|
||||||
|
|
||||||
|
expect(attendance.findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-25");
|
||||||
|
expect(days.contextFor).toHaveBeenCalledWith(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
ORG_ID,
|
||||||
|
"2026-08-25",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes workDate correctly for an instant just past midnight UTC (rolls to the next date)", async () => {
|
||||||
|
const { service, attendance } = build();
|
||||||
|
const at = new Date("2026-08-26T00:30:00.000Z");
|
||||||
|
|
||||||
|
await service.checkIn(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor);
|
||||||
|
|
||||||
|
expect(attendance.findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-26");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second check-in on the same day", async () => {
|
||||||
|
const { service } = build({
|
||||||
|
attendance: {
|
||||||
|
findForDate: jest.fn().mockResolvedValue({
|
||||||
|
id: "record-1",
|
||||||
|
checkIn: new Date("2026-08-26T05:30:00.000Z"),
|
||||||
|
checkOut: null,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.checkIn(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
new Date("2026-08-26T06:00:00.000Z"),
|
||||||
|
EAttendanceSource.WEB,
|
||||||
|
actor,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses check-in on a day covered by approved, non-half-day leave", async () => {
|
||||||
|
const { service, attendance } = build({
|
||||||
|
days: {
|
||||||
|
contextFor: jest.fn().mockResolvedValue(
|
||||||
|
defaultContext({
|
||||||
|
leave: { requestId: "leave-1", leaveTypeCode: "ANNUAL", isHalfDay: false },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.checkIn(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
new Date("2026-08-26T05:00:00.000Z"),
|
||||||
|
EAttendanceSource.WEB,
|
||||||
|
actor,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(attendance.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows check-in on a day covered by approved HALF-DAY leave", async () => {
|
||||||
|
const { service, attendance } = build({
|
||||||
|
days: {
|
||||||
|
contextFor: jest.fn().mockResolvedValue(
|
||||||
|
defaultContext({
|
||||||
|
leave: { requestId: "leave-1", leaveTypeCode: "ANNUAL", isHalfDay: true },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.checkIn(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
new Date("2026-08-26T05:00:00.000Z"),
|
||||||
|
EAttendanceSource.WEB,
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(attendance.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new PRESENT record with lateMinutes 0 at check-in time — lateness is only finalized on check-out", async () => {
|
||||||
|
const { service, attendance } = build({
|
||||||
|
schedules: {
|
||||||
|
resolveFor: jest.fn().mockResolvedValue({
|
||||||
|
id: "sched-1",
|
||||||
|
startTime: "08:30",
|
||||||
|
endTime: "17:30",
|
||||||
|
breakMinutes: 60,
|
||||||
|
gracePeriodMinutes: 10,
|
||||||
|
minMinutesFullDay: 420,
|
||||||
|
minMinutesHalfDay: 210,
|
||||||
|
crossesMidnight: false,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 10:00 local — well past a 08:30 start, would normally be "late".
|
||||||
|
const at = new Date("2026-08-26T10:00:00.000Z");
|
||||||
|
const result = await service.checkIn(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor);
|
||||||
|
|
||||||
|
expect(attendance.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
organizationId: ORG_ID,
|
||||||
|
employeeId: EMPLOYEE_ID,
|
||||||
|
workDate: "2026-08-26",
|
||||||
|
checkIn: at,
|
||||||
|
status: EAttendanceStatus.PRESENT,
|
||||||
|
lateMinutes: 0,
|
||||||
|
source: EAttendanceSource.WEB,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.lateMinutes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-records an existing check-in-less row rather than creating a duplicate", async () => {
|
||||||
|
const { service, attendance } = build({
|
||||||
|
attendance: {
|
||||||
|
findForDate: jest.fn().mockResolvedValue({
|
||||||
|
id: "record-1",
|
||||||
|
checkIn: null,
|
||||||
|
checkOut: null,
|
||||||
|
notes: "pre-existing note",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const at = new Date("2026-08-26T05:00:00.000Z");
|
||||||
|
|
||||||
|
await service.checkIn(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor, "arrived late");
|
||||||
|
|
||||||
|
expect(attendance.update).toHaveBeenCalledWith(
|
||||||
|
"record-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
checkIn: at,
|
||||||
|
status: EAttendanceStatus.PRESENT,
|
||||||
|
lateMinutes: 0,
|
||||||
|
notes: "arrived late",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(attendance.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the notes through as null, not undefined, when none is given for a new record", async () => {
|
||||||
|
const { service, attendance } = build();
|
||||||
|
|
||||||
|
await service.checkIn(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
new Date("2026-08-26T05:00:00.000Z"),
|
||||||
|
EAttendanceSource.WEB,
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(attendance.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ notes: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AttendanceService.checkOut", () => {
|
||||||
|
const schedule = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: "sched-1",
|
||||||
|
startTime: "08:30",
|
||||||
|
endTime: "17:30",
|
||||||
|
breakMinutes: 60,
|
||||||
|
gracePeriodMinutes: 10,
|
||||||
|
minMinutesFullDay: 420,
|
||||||
|
minMinutesHalfDay: 210,
|
||||||
|
crossesMidnight: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes today's own open record when one exists — no previous-day lookup needed", async () => {
|
||||||
|
const todayRecord = {
|
||||||
|
id: "record-today",
|
||||||
|
workDate: "2026-08-26",
|
||||||
|
checkIn: new Date("2026-08-26T05:30:00.000Z"),
|
||||||
|
checkOut: null,
|
||||||
|
workSchedule: schedule(),
|
||||||
|
};
|
||||||
|
const findForDate = jest.fn().mockResolvedValue(todayRecord);
|
||||||
|
const { service, attendance } = build({ attendance: { findForDate } });
|
||||||
|
|
||||||
|
await service.checkOut(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
new Date("2026-08-26T13:00:00.000Z"),
|
||||||
|
EAttendanceSource.WEB,
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only the today lookup ran — the previous-day fallback never fired.
|
||||||
|
expect(findForDate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-26");
|
||||||
|
expect(attendance.update).toHaveBeenCalledWith(
|
||||||
|
"record-today",
|
||||||
|
expect.objectContaining({ checkOut: new Date("2026-08-26T13:00:00.000Z") }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the immediately preceding day's open record on a night shift (crossesMidnight)", async () => {
|
||||||
|
const previousDayRecord = {
|
||||||
|
id: "record-prev",
|
||||||
|
workDate: "2026-08-25",
|
||||||
|
checkIn: new Date("2026-08-25T20:00:00.000Z"),
|
||||||
|
checkOut: null,
|
||||||
|
workSchedule: schedule({ crossesMidnight: true }),
|
||||||
|
};
|
||||||
|
const findForDate = jest.fn().mockImplementation((_empId: string, workDate: string) =>
|
||||||
|
Promise.resolve(workDate === "2026-08-25" ? previousDayRecord : null),
|
||||||
|
);
|
||||||
|
const { service, attendance } = build({ attendance: { findForDate } });
|
||||||
|
|
||||||
|
const at = new Date("2026-08-26T02:00:00.000Z");
|
||||||
|
await service.checkOut(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor);
|
||||||
|
|
||||||
|
expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-26");
|
||||||
|
expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-25");
|
||||||
|
expect(attendance.update).toHaveBeenCalledWith(
|
||||||
|
"record-prev",
|
||||||
|
expect.objectContaining({ checkOut: at }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT fall back to the previous day when that schedule does not cross midnight", async () => {
|
||||||
|
const previousDayRecord = {
|
||||||
|
id: "record-prev",
|
||||||
|
workDate: "2026-08-25",
|
||||||
|
checkIn: new Date("2026-08-25T20:00:00.000Z"),
|
||||||
|
checkOut: null,
|
||||||
|
// Day schedule, not a night shift — the open record from yesterday is
|
||||||
|
// just a missed check-out, not evidence of a shift still running.
|
||||||
|
workSchedule: schedule({ crossesMidnight: false }),
|
||||||
|
};
|
||||||
|
const findForDate = jest.fn().mockImplementation((_empId: string, workDate: string) =>
|
||||||
|
Promise.resolve(workDate === "2026-08-25" ? previousDayRecord : null),
|
||||||
|
);
|
||||||
|
const { service } = build({ attendance: { findForDate } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.checkOut(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
new Date("2026-08-26T02:00:00.000Z"),
|
||||||
|
EAttendanceSource.WEB,
|
||||||
|
actor,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reach past the immediately preceding day even if an older open record exists", async () => {
|
||||||
|
// Only "two days ago" has an open, crosses-midnight record; yesterday and
|
||||||
|
// today are both empty. findOpenPreviousDay only ever looks at yesterday.
|
||||||
|
const twoDaysAgoRecord = {
|
||||||
|
id: "record-old",
|
||||||
|
workDate: "2026-08-24",
|
||||||
|
checkIn: new Date("2026-08-24T20:00:00.000Z"),
|
||||||
|
checkOut: null,
|
||||||
|
workSchedule: schedule({ crossesMidnight: true }),
|
||||||
|
};
|
||||||
|
const findForDate = jest.fn().mockImplementation((_empId: string, workDate: string) =>
|
||||||
|
Promise.resolve(workDate === "2026-08-24" ? twoDaysAgoRecord : null),
|
||||||
|
);
|
||||||
|
const { service } = build({ attendance: { findForDate } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.checkOut(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
new Date("2026-08-26T02:00:00.000Z"),
|
||||||
|
EAttendanceSource.WEB,
|
||||||
|
actor,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
// Confirms it only ever asked for today (08-26) and yesterday (08-25).
|
||||||
|
expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-26");
|
||||||
|
expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-25");
|
||||||
|
expect(findForDate).not.toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-24");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not fall back to a previous-day record that has already been checked out", async () => {
|
||||||
|
const previousDayRecord = {
|
||||||
|
id: "record-prev",
|
||||||
|
workDate: "2026-08-25",
|
||||||
|
checkIn: new Date("2026-08-25T20:00:00.000Z"),
|
||||||
|
checkOut: new Date("2026-08-26T04:00:00.000Z"),
|
||||||
|
workSchedule: schedule({ crossesMidnight: true }),
|
||||||
|
};
|
||||||
|
const findForDate = jest.fn().mockImplementation((_empId: string, workDate: string) =>
|
||||||
|
Promise.resolve(workDate === "2026-08-25" ? previousDayRecord : null),
|
||||||
|
);
|
||||||
|
const { service } = build({ attendance: { findForDate } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.checkOut(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
new Date("2026-08-26T05:00:00.000Z"),
|
||||||
|
EAttendanceSource.WEB,
|
||||||
|
actor,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when there is no check-in to close at all", async () => {
|
||||||
|
const { service } = build({ attendance: { findForDate: jest.fn().mockResolvedValue(null) } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.checkOut(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
new Date("2026-08-26T13:00:00.000Z"),
|
||||||
|
EAttendanceSource.WEB,
|
||||||
|
actor,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when already checked out", async () => {
|
||||||
|
const record = {
|
||||||
|
id: "record-today",
|
||||||
|
workDate: "2026-08-26",
|
||||||
|
checkIn: new Date("2026-08-26T05:30:00.000Z"),
|
||||||
|
checkOut: new Date("2026-08-26T13:00:00.000Z"),
|
||||||
|
workSchedule: schedule(),
|
||||||
|
};
|
||||||
|
const { service } = build({
|
||||||
|
attendance: { findForDate: jest.fn().mockResolvedValue(record) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.checkOut(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
new Date("2026-08-26T14:00:00.000Z"),
|
||||||
|
EAttendanceSource.WEB,
|
||||||
|
actor,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a check-out timestamped before the check-in", async () => {
|
||||||
|
const record = {
|
||||||
|
id: "record-today",
|
||||||
|
workDate: "2026-08-26",
|
||||||
|
checkIn: new Date("2026-08-26T05:30:00.000Z"),
|
||||||
|
checkOut: null,
|
||||||
|
workSchedule: schedule(),
|
||||||
|
};
|
||||||
|
const { service } = build({
|
||||||
|
attendance: { findForDate: jest.fn().mockResolvedValue(record) },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.checkOut(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
new Date("2026-08-26T05:00:00.000Z"),
|
||||||
|
EAttendanceSource.WEB,
|
||||||
|
actor,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settles worked/late/early minutes and status from the real check-in through AttendanceDayService.measure", async () => {
|
||||||
|
const record = {
|
||||||
|
id: "record-today",
|
||||||
|
workDate: "2026-08-26",
|
||||||
|
checkIn: new Date("2026-08-26T05:30:00.000Z"), // 08:30 local (EAT), on time
|
||||||
|
checkOut: null,
|
||||||
|
workSchedule: schedule(),
|
||||||
|
};
|
||||||
|
const { service, attendance } = build({
|
||||||
|
attendance: { findForDate: jest.fn().mockResolvedValue(record) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exactly on-schedule end -> full day, present, no lateness/earliness.
|
||||||
|
await service.checkOut(
|
||||||
|
EMPLOYEE_ID,
|
||||||
|
new Date("2026-08-26T14:30:00.000Z"), // 17:30 local (EAT)
|
||||||
|
EAttendanceSource.WEB,
|
||||||
|
actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(attendance.update).toHaveBeenCalledWith(
|
||||||
|
"record-today",
|
||||||
|
expect.objectContaining({
|
||||||
|
workedMinutes: 480, // 9h gross - 60min break
|
||||||
|
lateMinutes: 0,
|
||||||
|
earlyLeaveMinutes: 0,
|
||||||
|
status: EAttendanceStatus.PRESENT,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { RegularizationsService } from "./regularizations.service";
|
||||||
|
import {
|
||||||
|
AttendanceRecord,
|
||||||
|
EAttendanceSource,
|
||||||
|
EAttendanceStatus,
|
||||||
|
} from "../entities/attendance-record.entity";
|
||||||
|
import {
|
||||||
|
AttendanceRegularization,
|
||||||
|
ERegularizationStatus,
|
||||||
|
} from "../entities/attendance-regularization.entity";
|
||||||
|
import type { ActorContext } from "../../employees/employees.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the freight suite's convention: plain `new RegularizationsService(...)`
|
||||||
|
* with hand-built fakes, no `Test.createTestingModule`. `dataSource.transaction`
|
||||||
|
* runs its callback against a fake manager whose `getRepository` dispatches on
|
||||||
|
* the entity class, just like the real EntityManager does.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ORG_ID = "org-1";
|
||||||
|
const APPROVER_EMPLOYEE_ID = "emp-manager";
|
||||||
|
const REQUESTER_EMPLOYEE_ID = "emp-1";
|
||||||
|
|
||||||
|
const approverActor: ActorContext = {
|
||||||
|
employeeId: APPROVER_EMPLOYEE_ID,
|
||||||
|
userId: "user-manager",
|
||||||
|
organizationId: ORG_ID,
|
||||||
|
isSuperAdmin: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function pendingRequest(over: Record<string, unknown> = {}): AttendanceRegularization {
|
||||||
|
return {
|
||||||
|
id: "reg-1",
|
||||||
|
organizationId: ORG_ID,
|
||||||
|
employeeId: REQUESTER_EMPLOYEE_ID,
|
||||||
|
attendanceRecordId: "record-1",
|
||||||
|
workDate: "2026-08-26",
|
||||||
|
requestedCheckIn: new Date("2026-08-26T05:30:00.000Z"),
|
||||||
|
requestedCheckOut: new Date("2026-08-26T14:30:00.000Z"),
|
||||||
|
originalCheckIn: null,
|
||||||
|
originalCheckOut: null,
|
||||||
|
originalStatus: null,
|
||||||
|
reason: "Forgot to punch in",
|
||||||
|
status: ERegularizationStatus.SUBMITTED,
|
||||||
|
approverEmployeeId: APPROVER_EMPLOYEE_ID,
|
||||||
|
decidedByEmployeeId: null,
|
||||||
|
decidedAt: null,
|
||||||
|
decisionNote: null,
|
||||||
|
createdBy: "user-1",
|
||||||
|
updatedBy: null,
|
||||||
|
...over,
|
||||||
|
} as AttendanceRegularization;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(over: {
|
||||||
|
request?: AttendanceRegularization;
|
||||||
|
existingRecord?: Record<string, unknown> | null;
|
||||||
|
schedule?: Record<string, unknown> | null;
|
||||||
|
} = {}) {
|
||||||
|
const request = over.request ?? pendingRequest();
|
||||||
|
const existingRecord = over.existingRecord ?? null;
|
||||||
|
|
||||||
|
const regularizationsRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(request),
|
||||||
|
save: jest.fn(),
|
||||||
|
create: jest.fn((data: Record<string, unknown>) => data),
|
||||||
|
};
|
||||||
|
|
||||||
|
const attendanceRecordsManagerRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(existingRecord),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
save: jest.fn().mockImplementation((data: Record<string, unknown>) =>
|
||||||
|
Promise.resolve({ id: "record-new", ...data }),
|
||||||
|
),
|
||||||
|
create: jest.fn((data: Record<string, unknown>) => data),
|
||||||
|
};
|
||||||
|
|
||||||
|
const attendanceRegularizationsManagerRepo = {
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn((entity: unknown) => {
|
||||||
|
if (entity === AttendanceRecord) return attendanceRecordsManagerRepo;
|
||||||
|
if (entity === AttendanceRegularization) return attendanceRegularizationsManagerRepo;
|
||||||
|
throw new Error(`Unexpected getRepository(${String(entity)})`);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedules = {
|
||||||
|
resolveFor: jest.fn().mockResolvedValue(over.schedule ?? null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new RegularizationsService(
|
||||||
|
dataSource as never,
|
||||||
|
regularizationsRepo as never,
|
||||||
|
{} as never, // attendance (AttendanceRepository) — approve() never touches it directly
|
||||||
|
schedules as never,
|
||||||
|
{} as never, // employees
|
||||||
|
{} as never, // iamDirectory
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
request,
|
||||||
|
regularizationsRepo,
|
||||||
|
attendanceRecordsManagerRepo,
|
||||||
|
attendanceRegularizationsManagerRepo,
|
||||||
|
dataSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RegularizationsService.approve", () => {
|
||||||
|
it("refuses to approve one's own attendance correction", async () => {
|
||||||
|
const { service } = build({
|
||||||
|
request: pendingRequest({ employeeId: APPROVER_EMPLOYEE_ID }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.approve("reg-1", undefined, approverActor),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to decide a request that is not SUBMITTED", async () => {
|
||||||
|
const { service, dataSource } = build({
|
||||||
|
request: pendingRequest({ status: ERegularizationStatus.APPROVED }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.approve("reg-1", undefined, approverActor),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("snapshots the ORIGINAL check-in/out/status onto the request BEFORE the attendance record is overwritten", async () => {
|
||||||
|
const existingRecord = {
|
||||||
|
id: "record-1",
|
||||||
|
employeeId: REQUESTER_EMPLOYEE_ID,
|
||||||
|
workDate: "2026-08-26",
|
||||||
|
checkIn: new Date("2026-08-26T06:00:00.000Z"), // the pre-correction punch
|
||||||
|
checkOut: new Date("2026-08-26T15:00:00.000Z"),
|
||||||
|
status: EAttendanceStatus.LATE, // the pre-correction status
|
||||||
|
};
|
||||||
|
const request = pendingRequest({
|
||||||
|
requestedCheckIn: new Date("2026-08-26T05:30:00.000Z"), // the corrected punch
|
||||||
|
requestedCheckOut: new Date("2026-08-26T14:30:00.000Z"),
|
||||||
|
});
|
||||||
|
const { service, attendanceRegularizationsManagerRepo, attendanceRecordsManagerRepo } =
|
||||||
|
build({ request, existingRecord });
|
||||||
|
|
||||||
|
await service.approve("reg-1", "Confirmed with security log", approverActor);
|
||||||
|
|
||||||
|
// The snapshot on the regularization row is the value BEFORE this approval
|
||||||
|
// — evidence that survives the overwrite about to happen below.
|
||||||
|
expect(attendanceRegularizationsManagerRepo.update).toHaveBeenCalledWith(
|
||||||
|
"reg-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
status: ERegularizationStatus.APPROVED,
|
||||||
|
originalCheckIn: existingRecord.checkIn,
|
||||||
|
originalCheckOut: existingRecord.checkOut,
|
||||||
|
originalStatus: EAttendanceStatus.LATE,
|
||||||
|
decisionNote: "Confirmed with security log",
|
||||||
|
decidedByEmployeeId: APPROVER_EMPLOYEE_ID,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Meanwhile the attendance record itself is overwritten with the REQUESTED
|
||||||
|
// (corrected) values, not the original ones.
|
||||||
|
expect(attendanceRecordsManagerRepo.update).toHaveBeenCalledWith(
|
||||||
|
"record-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
checkIn: request.requestedCheckIn,
|
||||||
|
checkOut: request.requestedCheckOut,
|
||||||
|
isRegularized: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-derives status and measured minutes via AttendanceDayService.measure against the requested punches", async () => {
|
||||||
|
const existingRecord = {
|
||||||
|
id: "record-1",
|
||||||
|
employeeId: REQUESTER_EMPLOYEE_ID,
|
||||||
|
workDate: "2026-08-26",
|
||||||
|
checkIn: null,
|
||||||
|
checkOut: null,
|
||||||
|
status: EAttendanceStatus.ABSENT,
|
||||||
|
};
|
||||||
|
const schedule = {
|
||||||
|
id: "sched-1",
|
||||||
|
startTime: "08:30",
|
||||||
|
endTime: "17:30",
|
||||||
|
breakMinutes: 60,
|
||||||
|
gracePeriodMinutes: 10,
|
||||||
|
minMinutesFullDay: 420,
|
||||||
|
minMinutesHalfDay: 210,
|
||||||
|
crossesMidnight: false,
|
||||||
|
};
|
||||||
|
const request = pendingRequest({
|
||||||
|
// Schedule times are parsed as local (EAT, UTC+3) via atTime() — these
|
||||||
|
// UTC instants are 08:30/17:30 local, matching the 08:30-17:30 schedule.
|
||||||
|
requestedCheckIn: new Date("2026-08-26T05:30:00.000Z"), // on time
|
||||||
|
requestedCheckOut: new Date("2026-08-26T14:30:00.000Z"), // full day
|
||||||
|
});
|
||||||
|
const { service, attendanceRecordsManagerRepo } = build({
|
||||||
|
request,
|
||||||
|
existingRecord,
|
||||||
|
schedule,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.approve("reg-1", undefined, approverActor);
|
||||||
|
|
||||||
|
expect(attendanceRecordsManagerRepo.update).toHaveBeenCalledWith(
|
||||||
|
"record-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
workedMinutes: 480, // 9h gross - 60min break
|
||||||
|
lateMinutes: 0,
|
||||||
|
earlyLeaveMinutes: 0,
|
||||||
|
status: EAttendanceStatus.PRESENT,
|
||||||
|
workScheduleId: "sched-1",
|
||||||
|
source: EAttendanceSource.MANUAL,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires at least a resulting check-in — a check-out-only request with nothing on record is refused", async () => {
|
||||||
|
const request = pendingRequest({
|
||||||
|
requestedCheckIn: null,
|
||||||
|
requestedCheckOut: new Date("2026-08-26T14:30:00.000Z"),
|
||||||
|
});
|
||||||
|
const { service, attendanceRecordsManagerRepo } = build({
|
||||||
|
request,
|
||||||
|
existingRecord: null, // nothing on the record to close
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.approve("reg-1", undefined, approverActor),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(attendanceRecordsManagerRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the EXISTING check-in when the request proposes only a check-out", async () => {
|
||||||
|
const existingRecord = {
|
||||||
|
id: "record-1",
|
||||||
|
employeeId: REQUESTER_EMPLOYEE_ID,
|
||||||
|
workDate: "2026-08-26",
|
||||||
|
checkIn: new Date("2026-08-26T08:30:00.000Z"),
|
||||||
|
checkOut: null,
|
||||||
|
status: EAttendanceStatus.PRESENT,
|
||||||
|
};
|
||||||
|
const request = pendingRequest({
|
||||||
|
requestedCheckIn: null,
|
||||||
|
requestedCheckOut: new Date("2026-08-26T17:30:00.000Z"),
|
||||||
|
});
|
||||||
|
const { service, attendanceRecordsManagerRepo } = build({ request, existingRecord });
|
||||||
|
|
||||||
|
await service.approve("reg-1", undefined, approverActor);
|
||||||
|
|
||||||
|
expect(attendanceRecordsManagerRepo.update).toHaveBeenCalledWith(
|
||||||
|
"record-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
checkIn: existingRecord.checkIn,
|
||||||
|
checkOut: request.requestedCheckOut,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a brand-new attendance record when none existed for the day", async () => {
|
||||||
|
const request = pendingRequest({
|
||||||
|
attendanceRecordId: null,
|
||||||
|
requestedCheckIn: new Date("2026-08-26T08:30:00.000Z"),
|
||||||
|
requestedCheckOut: new Date("2026-08-26T17:30:00.000Z"),
|
||||||
|
});
|
||||||
|
const { service, attendanceRecordsManagerRepo } = build({
|
||||||
|
request,
|
||||||
|
existingRecord: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.approve("reg-1", undefined, approverActor);
|
||||||
|
|
||||||
|
expect(attendanceRecordsManagerRepo.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
organizationId: ORG_ID,
|
||||||
|
employeeId: REQUESTER_EMPLOYEE_ID,
|
||||||
|
workDate: "2026-08-26",
|
||||||
|
checkIn: request.requestedCheckIn,
|
||||||
|
checkOut: request.requestedCheckOut,
|
||||||
|
isRegularized: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(attendanceRecordsManagerRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does everything inside one transaction", async () => {
|
||||||
|
const { service, dataSource } = build();
|
||||||
|
|
||||||
|
await service.approve("reg-1", undefined, approverActor);
|
||||||
|
|
||||||
|
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
138
apps/edr-hr-api/src/modules/employees/employees.service.spec.ts
Normal file
138
apps/edr-hr-api/src/modules/employees/employees.service.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { ConflictException, ForbiddenException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { ActorContext, EmployeesService } from "./employees.service";
|
||||||
|
import { EEmploymentState, EEmploymentType, EmployeeProfile } from "./entities/employee-profile.entity";
|
||||||
|
import { CreateEmployeeProfileDto } from "./dto/create-employee-profile.dto";
|
||||||
|
|
||||||
|
const actorFixture = (over: Partial<ActorContext> = {}): ActorContext => ({
|
||||||
|
employeeId: null,
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
isSuperAdmin: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const iamEmployeeFixture = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: "iam-emp-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
name: { am: "", en: "Jane Doe" },
|
||||||
|
status: "ACTIVE",
|
||||||
|
isCurrent: true,
|
||||||
|
unitId: null,
|
||||||
|
username: "jane.doe",
|
||||||
|
email: "jane@example.com",
|
||||||
|
phoneNumber: "0911000000",
|
||||||
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createDto = (over: Partial<CreateEmployeeProfileDto> = {}): CreateEmployeeProfileDto =>
|
||||||
|
({
|
||||||
|
employeeId: "iam-emp-1",
|
||||||
|
employmentType: EEmploymentType.PERMANENT,
|
||||||
|
hireDate: "2026-01-15",
|
||||||
|
...over,
|
||||||
|
}) as CreateEmployeeProfileDto;
|
||||||
|
|
||||||
|
describe("EmployeesService.create", () => {
|
||||||
|
let employeesRepository: {
|
||||||
|
findByEmployeeId: jest.Mock;
|
||||||
|
findByEmployeeNumber: jest.Mock;
|
||||||
|
findHighestEmployeeNumberSuffix: jest.Mock;
|
||||||
|
create: jest.Mock;
|
||||||
|
};
|
||||||
|
let jobTitlesRepository: { findById: jest.Mock };
|
||||||
|
let iamDirectory: {
|
||||||
|
requireEmployee: jest.Mock;
|
||||||
|
findEmployee: jest.Mock;
|
||||||
|
findLineManagerEmployeeId: jest.Mock;
|
||||||
|
};
|
||||||
|
let iamOperations: { activateEmployee: jest.Mock; deactivateEmployee: jest.Mock };
|
||||||
|
let service: EmployeesService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
employeesRepository = {
|
||||||
|
findByEmployeeId: jest.fn().mockResolvedValue(null),
|
||||||
|
findByEmployeeNumber: jest.fn().mockResolvedValue(null),
|
||||||
|
findHighestEmployeeNumberSuffix: jest.fn().mockResolvedValue(5),
|
||||||
|
create: jest.fn().mockImplementation(async (data: Partial<EmployeeProfile>) => ({
|
||||||
|
id: "profile-new",
|
||||||
|
managerEmployeeId: null,
|
||||||
|
...data,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
jobTitlesRepository = { findById: jest.fn() };
|
||||||
|
iamDirectory = {
|
||||||
|
requireEmployee: jest.fn().mockResolvedValue(iamEmployeeFixture()),
|
||||||
|
findEmployee: jest.fn().mockResolvedValue(iamEmployeeFixture()),
|
||||||
|
findLineManagerEmployeeId: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
iamOperations = {
|
||||||
|
activateEmployee: jest.fn().mockResolvedValue(undefined),
|
||||||
|
deactivateEmployee: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
service = new EmployeesService(
|
||||||
|
employeesRepository as never,
|
||||||
|
jobTitlesRepository as never,
|
||||||
|
iamDirectory as never,
|
||||||
|
iamOperations as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a profile for an IAM employee who does not have one yet", async () => {
|
||||||
|
const result = await service.create(createDto(), actorFixture());
|
||||||
|
|
||||||
|
expect(employeesRepository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
employeeId: "iam-emp-1",
|
||||||
|
employeeNumber: "EMP-00006",
|
||||||
|
employmentState: EEmploymentState.ACTIVE,
|
||||||
|
isPensionEligible: true,
|
||||||
|
createdBy: "user-1",
|
||||||
|
updatedBy: "user-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.employeeId).toBe("iam-emp-1");
|
||||||
|
expect(result.iam?.username).toBe("jane.doe");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws ConflictException when a profile already exists for that IAM employee id — the re-hire guard", async () => {
|
||||||
|
employeesRepository.findByEmployeeId.mockResolvedValue({
|
||||||
|
id: "existing-profile-1",
|
||||||
|
employeeId: "iam-emp-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.create(createDto(), actorFixture())).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
await expect(service.create(createDto(), actorFixture())).rejects.toThrow(
|
||||||
|
/already has HR profile existing-profile-1/,
|
||||||
|
);
|
||||||
|
expect(employeesRepository.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a SEPARATE guard from recruitment's hireFromOffer re-hire check — it fires purely off employeesRepository.findByEmployeeId, independent of any offer/application state", async () => {
|
||||||
|
employeesRepository.findByEmployeeId.mockResolvedValue({
|
||||||
|
id: "existing-profile-2",
|
||||||
|
employeeId: "iam-emp-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.create(createDto(), actorFixture())).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expect(employeesRepository.findByEmployeeId).toHaveBeenCalledWith("iam-emp-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to create a profile for an IAM employee in another organization", async () => {
|
||||||
|
iamDirectory.requireEmployee.mockResolvedValue(
|
||||||
|
iamEmployeeFixture({ organizationId: "other-org" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.create(createDto(), actorFixture())).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
expect(employeesRepository.findByEmployeeId).not.toHaveBeenCalled();
|
||||||
|
expect(employeesRepository.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { JobPositionsRepository } from "./job-positions.repository";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `hr.job_positions` has no `organization_id` column of its own — tenancy is
|
||||||
|
* resolved via an `EXISTS` against `iam.positions`, never a direct column
|
||||||
|
* filter. A prior shipped bug referenced `position.organization_id` directly
|
||||||
|
* and 500'd for every non-super-admin actor; these tests pin the fix.
|
||||||
|
*/
|
||||||
|
function makeQueryBuilder(overrides: {
|
||||||
|
getManyAndCount?: [unknown[], number];
|
||||||
|
getRawOne?: Record<string, unknown> | undefined;
|
||||||
|
} = {}) {
|
||||||
|
const qb: Record<string, jest.Mock> = {
|
||||||
|
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
skip: jest.fn().mockReturnThis(),
|
||||||
|
take: jest.fn().mockReturnThis(),
|
||||||
|
getManyAndCount: jest.fn().mockResolvedValue(overrides.getManyAndCount ?? [[], 0]),
|
||||||
|
getRawOne: jest.fn().mockResolvedValue(overrides.getRawOne),
|
||||||
|
};
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("JobPositionsRepository", () => {
|
||||||
|
let repository: { createQueryBuilder: jest.Mock };
|
||||||
|
let repo: JobPositionsRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repository = { createQueryBuilder: jest.fn() };
|
||||||
|
repo = new JobPositionsRepository(repository as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findPage", () => {
|
||||||
|
it("scopes tenancy via EXISTS against iam.positions, not a direct column filter", async () => {
|
||||||
|
const qb = makeQueryBuilder();
|
||||||
|
repository.createQueryBuilder.mockReturnValue(qb);
|
||||||
|
|
||||||
|
await repo.findPage("org-1", {});
|
||||||
|
|
||||||
|
const existsCall = qb.andWhere.mock.calls.find(([sql]) =>
|
||||||
|
String(sql).includes("EXISTS (SELECT 1 FROM iam.positions p"),
|
||||||
|
);
|
||||||
|
expect(existsCall).toBeDefined();
|
||||||
|
const [sql, params] = existsCall!;
|
||||||
|
expect(sql).toContain("p.id = position.position_id");
|
||||||
|
expect(sql).toContain("p.organization_id = :organizationId");
|
||||||
|
expect(sql).not.toContain("position.organization_id");
|
||||||
|
expect(params).toEqual({ organizationId: "org-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the tenancy filter entirely when organizationId is null (super admin)", async () => {
|
||||||
|
const qb = makeQueryBuilder();
|
||||||
|
repository.createQueryBuilder.mockReturnValue(qb);
|
||||||
|
|
||||||
|
await repo.findPage(null, {});
|
||||||
|
|
||||||
|
expect(qb.andWhere).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still applies the other filters alongside the EXISTS scope", async () => {
|
||||||
|
const qb = makeQueryBuilder();
|
||||||
|
repository.createQueryBuilder.mockReturnValue(qb);
|
||||||
|
|
||||||
|
await repo.findPage("org-1", { unitId: "unit-1", jobTitleId: "jt-1", isOpen: true, vacantOnly: true });
|
||||||
|
|
||||||
|
const clauses = qb.andWhere.mock.calls.map(([sql]) => String(sql));
|
||||||
|
expect(clauses.some((c) => c.includes("iam.positions p"))).toBe(true);
|
||||||
|
expect(clauses).toContain("position.unit_id = :unitId");
|
||||||
|
expect(clauses).toContain("position.job_title_id = :jobTitleId");
|
||||||
|
expect(clauses).toContain("position.is_open = :isOpen");
|
||||||
|
expect(clauses).toContain("position.current_count < position.budgeted_count");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("orders by the entity property path (createdAt), not a raw column", async () => {
|
||||||
|
const qb = makeQueryBuilder();
|
||||||
|
repository.createQueryBuilder.mockReturnValue(qb);
|
||||||
|
|
||||||
|
await repo.findPage("org-1", {});
|
||||||
|
|
||||||
|
expect(qb.orderBy).toHaveBeenCalledWith("position.createdAt", "DESC");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("headcountTotals", () => {
|
||||||
|
it("scopes tenancy via EXISTS against iam.positions, not a direct column filter", async () => {
|
||||||
|
const qb = makeQueryBuilder({ getRawOne: { budgeted: "10", current: "6" } });
|
||||||
|
repository.createQueryBuilder.mockReturnValue(qb);
|
||||||
|
|
||||||
|
const result = await repo.headcountTotals("org-1");
|
||||||
|
|
||||||
|
expect(qb.where).toHaveBeenCalledTimes(1);
|
||||||
|
const [sql, params] = qb.where.mock.calls[0];
|
||||||
|
expect(sql).toContain("EXISTS (SELECT 1 FROM iam.positions p");
|
||||||
|
expect(sql).toContain("p.organization_id = :organizationId");
|
||||||
|
expect(sql).not.toContain("position.organization_id");
|
||||||
|
expect(params).toEqual({ organizationId: "org-1" });
|
||||||
|
expect(result).toEqual({ budgeted: 10, current: 6, vacant: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the tenancy filter entirely when organizationId is null (super admin)", async () => {
|
||||||
|
const qb = makeQueryBuilder({ getRawOne: { budgeted: "10", current: "6" } });
|
||||||
|
repository.createQueryBuilder.mockReturnValue(qb);
|
||||||
|
|
||||||
|
await repo.headcountTotals(null);
|
||||||
|
|
||||||
|
expect(qb.where).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a negative vacant count when overstaffed", async () => {
|
||||||
|
const qb = makeQueryBuilder({ getRawOne: { budgeted: "5", current: "8" } });
|
||||||
|
repository.createQueryBuilder.mockReturnValue(qb);
|
||||||
|
|
||||||
|
const result = await repo.headcountTotals("org-1");
|
||||||
|
|
||||||
|
expect(result).toEqual({ budgeted: 5, current: 8, vacant: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to zero when the query returns no row", async () => {
|
||||||
|
const qb = makeQueryBuilder({ getRawOne: undefined });
|
||||||
|
repository.createQueryBuilder.mockReturnValue(qb);
|
||||||
|
|
||||||
|
const result = await repo.headcountTotals("org-1");
|
||||||
|
|
||||||
|
expect(result).toEqual({ budgeted: 0, current: 0, vacant: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { ActorContext } from "../../employees/employees.service";
|
||||||
|
import { ELeaveYearBasis } from "../entities/leave-settings.entity";
|
||||||
|
import { ELedgerEntryType } from "../entities/leave-ledger-entry.entity";
|
||||||
|
import { EAccrualMethod, EGenderRestriction, LeaveType } from "../entities/leave-type.entity";
|
||||||
|
import { LeaveBalancesService } from "./leave-balances.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the freight suite's convention: plain `new ServiceClass(...)` with
|
||||||
|
* hand-built fakes passed positionally — no `Test.createTestingModule`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
||||||
|
return {
|
||||||
|
employeeId: "emp-1",
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
isSuperAdmin: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeType(overrides: Partial<LeaveType> = {}): LeaveType {
|
||||||
|
return {
|
||||||
|
id: "type-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
code: "ANNUAL",
|
||||||
|
name: { am: "ዓመታዊ", en: "Annual" },
|
||||||
|
isPaid: true,
|
||||||
|
accrualMethod: EAccrualMethod.ANNUAL_ENTITLEMENT,
|
||||||
|
baseDaysPerYear: "16",
|
||||||
|
extraDaysPerPeriod: "1",
|
||||||
|
servicePeriodYears: 2,
|
||||||
|
maxDaysPerYear: null,
|
||||||
|
maxCarryOverDays: "0",
|
||||||
|
maxConsecutiveDays: null,
|
||||||
|
minServiceMonths: 0,
|
||||||
|
genderRestriction: EGenderRestriction.ANY,
|
||||||
|
countsWorkingDaysOnly: true,
|
||||||
|
allowsHalfDay: true,
|
||||||
|
requiresAttachmentAfter: null,
|
||||||
|
requiresApproval: true,
|
||||||
|
statuteReference: null,
|
||||||
|
sortOrder: 100,
|
||||||
|
isActive: true,
|
||||||
|
createdBy: null,
|
||||||
|
updatedBy: null,
|
||||||
|
...overrides,
|
||||||
|
} as LeaveType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProfile(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
employeeId: "emp-1",
|
||||||
|
gender: "MALE",
|
||||||
|
hireDate: "2020-01-01",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEntitlement(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "ent-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
employeeId: "emp-1",
|
||||||
|
leaveTypeId: "type-1",
|
||||||
|
leaveYearStart: "2026-01-01",
|
||||||
|
leaveYearEnd: "2026-12-31",
|
||||||
|
entitledDays: "16.00",
|
||||||
|
carriedOverDays: "0.00",
|
||||||
|
carryOverExpiresOn: null,
|
||||||
|
serviceYearsAtGrant: "5.00",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHarness() {
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn(),
|
||||||
|
};
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn().mockImplementation((cb: (m: unknown) => unknown) => cb(manager)),
|
||||||
|
query: jest.fn().mockResolvedValue([]),
|
||||||
|
manager: { getRepository: jest.fn() },
|
||||||
|
getRepository: jest.fn(),
|
||||||
|
};
|
||||||
|
const entitlements = {
|
||||||
|
findForYear: jest.fn(),
|
||||||
|
findById: jest.fn(),
|
||||||
|
};
|
||||||
|
const leaveTypes = {
|
||||||
|
findActive: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const settingsService = {
|
||||||
|
resolve: jest.fn().mockResolvedValue({
|
||||||
|
leaveYearBasis: ELeaveYearBasis.CALENDAR_YEAR,
|
||||||
|
fiscalYearStartMonth: 7,
|
||||||
|
fiscalYearStartDay: 8,
|
||||||
|
weekendDays: [0],
|
||||||
|
carryOverDeadlineMonths: 6,
|
||||||
|
allowNegativeBalance: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const employees = {
|
||||||
|
findByEmployeeId: jest.fn().mockResolvedValue(makeProfile()),
|
||||||
|
};
|
||||||
|
const iamDirectory = {
|
||||||
|
findEmployee: jest.fn().mockResolvedValue({ organizationId: "org-1" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new LeaveBalancesService(
|
||||||
|
dataSource as never,
|
||||||
|
entitlements as never,
|
||||||
|
leaveTypes as never,
|
||||||
|
settingsService as never,
|
||||||
|
employees as never,
|
||||||
|
iamDirectory as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
dataSource,
|
||||||
|
manager,
|
||||||
|
entitlements,
|
||||||
|
leaveTypes,
|
||||||
|
settingsService,
|
||||||
|
employees,
|
||||||
|
iamDirectory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("LeaveBalancesService.balancesFor / balanceFor", () => {
|
||||||
|
it("404s when the employee has no HR profile", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.employees.findByEmployeeId.mockResolvedValue(null);
|
||||||
|
await expect(h.service.balancesFor("emp-1", makeActor())).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s when the employee is outside the actor's organization scope", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.iamDirectory.findEmployee.mockResolvedValue({ organizationId: "org-9" });
|
||||||
|
await expect(
|
||||||
|
h.service.balancesFor("emp-1", makeActor({ organizationId: "org-1", isSuperAdmin: false })),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("400s when no organization can be resolved for the employee", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.iamDirectory.findEmployee.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
h.service.balancesFor("emp-1", makeActor({ organizationId: "" as unknown as string, isSuperAdmin: true })),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips NONE-accrual types", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findActive.mockResolvedValue([makeType({ accrualMethod: EAccrualMethod.NONE })]);
|
||||||
|
|
||||||
|
const result = await h.service.balancesFor("emp-1", makeActor());
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(h.entitlements.findForYear).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a type gated by minServiceMonths the employee has not yet reached", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ hireDate: "2026-06-01" }));
|
||||||
|
h.leaveTypes.findActive.mockResolvedValue([makeType({ minServiceMonths: 60 })]);
|
||||||
|
|
||||||
|
const result = await h.service.balancesFor("emp-1", makeActor(), "2026-06-15");
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a gender-restricted type that does not match a recorded gender", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ gender: "MALE" }));
|
||||||
|
h.leaveTypes.findActive.mockResolvedValue([
|
||||||
|
makeType({ genderRestriction: EGenderRestriction.FEMALE }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await h.service.balancesFor("emp-1", makeActor());
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT skip a gender-restricted type when the profile has no gender recorded", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ gender: null }));
|
||||||
|
h.leaveTypes.findActive.mockResolvedValue([
|
||||||
|
makeType({ genderRestriction: EGenderRestriction.FEMALE }),
|
||||||
|
]);
|
||||||
|
h.entitlements.findForYear.mockResolvedValue(makeEntitlement());
|
||||||
|
h.dataSource.query.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await h.service.balancesFor("emp-1", makeActor());
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives the balance from a grouped raw-SQL SUM over the ledger, never a stored column", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findActive.mockResolvedValue([makeType()]);
|
||||||
|
h.entitlements.findForYear.mockResolvedValue(makeEntitlement({ id: "ent-77" }));
|
||||||
|
h.dataSource.query.mockResolvedValue([
|
||||||
|
{ entryType: "GRANT", total: "16.00" },
|
||||||
|
{ entryType: "DEDUCTION", total: "-3.50" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [balance] = await h.service.balancesFor("emp-1", makeActor());
|
||||||
|
|
||||||
|
// The derivation actually ran the raw SQL against the ledger table for
|
||||||
|
// this entitlement — not a canned return value that bypasses it.
|
||||||
|
expect(h.dataSource.query).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("hr.leave_ledger_entries"),
|
||||||
|
["ent-77"],
|
||||||
|
);
|
||||||
|
expect(balance.grantedDays).toBe(16);
|
||||||
|
expect(balance.takenDays).toBe(3.5);
|
||||||
|
expect(balance.adjustmentDays).toBe(0);
|
||||||
|
expect(balance.availableDays).toBe(12.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds REVERSAL entries into adjustments alongside ADJUSTMENT", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findActive.mockResolvedValue([makeType()]);
|
||||||
|
h.entitlements.findForYear.mockResolvedValue(makeEntitlement());
|
||||||
|
h.dataSource.query.mockResolvedValue([
|
||||||
|
{ entryType: "GRANT", total: "16.00" },
|
||||||
|
{ entryType: "DEDUCTION", total: "-4.00" },
|
||||||
|
{ entryType: "REVERSAL", total: "4.00" },
|
||||||
|
{ entryType: "ADJUSTMENT", total: "-1.00" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [balance] = await h.service.balancesFor("emp-1", makeActor());
|
||||||
|
|
||||||
|
expect(balance.takenDays).toBe(4);
|
||||||
|
expect(balance.adjustmentDays).toBe(3); // 4 (reversal) + -1 (adjustment)
|
||||||
|
expect(balance.availableDays).toBe(15); // 16 + 3 - 4
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the leave year's entitlement on first read and posts its GRANT", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
// Hire date pinned to the leave year's start so completed service is
|
||||||
|
// exactly 0 — keeps the expected entitlement at the type's flat base
|
||||||
|
// rather than also exercising the service-growth arithmetic here.
|
||||||
|
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ hireDate: "2026-01-01" }));
|
||||||
|
h.leaveTypes.findActive.mockResolvedValue([makeType({ baseDaysPerYear: "16", extraDaysPerPeriod: "1", servicePeriodYears: 2 })]);
|
||||||
|
h.entitlements.findForYear.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const entitlementRepo = {
|
||||||
|
create: (d: Record<string, unknown>) => d,
|
||||||
|
save: jest.fn().mockResolvedValue(makeEntitlement({ id: "ent-created" })),
|
||||||
|
};
|
||||||
|
const ledgerRepo = {
|
||||||
|
create: (d: Record<string, unknown>) => d,
|
||||||
|
save: jest.fn().mockImplementation(async (d: Record<string, unknown>) => ({ id: "entry-1", ...d })),
|
||||||
|
};
|
||||||
|
h.manager.getRepository.mockImplementation((entity: { name?: string }) => {
|
||||||
|
// Distinguish by call order: first call is LeaveEntitlement, rest are LeaveLedgerEntry.
|
||||||
|
return entity && (entity as { name?: string }).name === "LeaveEntitlement"
|
||||||
|
? entitlementRepo
|
||||||
|
: ledgerRepo;
|
||||||
|
});
|
||||||
|
h.dataSource.query.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await h.service.balancesFor("emp-1", makeActor(), "2026-06-15");
|
||||||
|
|
||||||
|
expect(h.dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(entitlementRepo.save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ledgerRepo.save).toHaveBeenCalledTimes(1);
|
||||||
|
const [grantEntry] = ledgerRepo.save.mock.calls[0];
|
||||||
|
// post() stores days as entry.days.toFixed(2) — a string, not a number.
|
||||||
|
expect(grantEntry).toMatchObject({ entryType: ELedgerEntryType.GRANT, days: "16.00" });
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not post a CARRY_OVER entry when the type caps carry-over at 0", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findActive.mockResolvedValue([makeType({ maxCarryOverDays: "0" })]);
|
||||||
|
h.entitlements.findForYear.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const entitlementRepo = {
|
||||||
|
create: (d: Record<string, unknown>) => d,
|
||||||
|
save: jest.fn().mockResolvedValue(makeEntitlement({ id: "ent-created" })),
|
||||||
|
};
|
||||||
|
const ledgerRepo = {
|
||||||
|
create: (d: Record<string, unknown>) => d,
|
||||||
|
save: jest.fn().mockImplementation(async (d: Record<string, unknown>) => ({ id: "entry-1", ...d })),
|
||||||
|
};
|
||||||
|
h.manager.getRepository.mockImplementation((entity: { name?: string }) =>
|
||||||
|
(entity as { name?: string }).name === "LeaveEntitlement" ? entitlementRepo : ledgerRepo,
|
||||||
|
);
|
||||||
|
h.dataSource.query.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await h.service.balancesFor("emp-1", makeActor());
|
||||||
|
|
||||||
|
// Only the GRANT entry, never CARRY_OVER.
|
||||||
|
expect(ledgerRepo.save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ledgerRepo.save.mock.calls[0][0]).toMatchObject({ entryType: ELedgerEntryType.GRANT });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps a carried-over amount at the type's maxCarryOverDays", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findActive.mockResolvedValue([
|
||||||
|
makeType({ maxCarryOverDays: "4", baseDaysPerYear: "16" }),
|
||||||
|
]);
|
||||||
|
// First entitlement lookup (current year) -> not found, triggers ensureEntitlement.
|
||||||
|
// Inside carryOverInto, a second findForYear call looks up the *previous* year.
|
||||||
|
h.entitlements.findForYear.mockImplementation(
|
||||||
|
async (_employeeId: string, _typeId: string, yearStart: string) => {
|
||||||
|
if (yearStart === "2025-01-01") return makeEntitlement({ id: "ent-prev", leaveYearStart: "2025-01-01" });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// sumLedger for the previous entitlement reports 10 remaining days — above the cap of 4.
|
||||||
|
h.dataSource.query.mockResolvedValue([{ total: "10.00" }]);
|
||||||
|
|
||||||
|
const entitlementRepo = {
|
||||||
|
create: (d: Record<string, unknown>) => d,
|
||||||
|
save: jest.fn().mockImplementation(async (d: Record<string, unknown>) => ({ id: "ent-created", ...d })),
|
||||||
|
};
|
||||||
|
const ledgerRepo = {
|
||||||
|
create: (d: Record<string, unknown>) => d,
|
||||||
|
save: jest.fn().mockImplementation(async (d: Record<string, unknown>) => ({ id: "entry-1", ...d })),
|
||||||
|
};
|
||||||
|
h.manager.getRepository.mockImplementation((entity: { name?: string }) =>
|
||||||
|
(entity as { name?: string }).name === "LeaveEntitlement" ? entitlementRepo : ledgerRepo,
|
||||||
|
);
|
||||||
|
|
||||||
|
await h.service.balancesFor("emp-1", makeActor(), "2026-06-15");
|
||||||
|
|
||||||
|
const carryOverCall = ledgerRepo.save.mock.calls.find(
|
||||||
|
([entry]: [Record<string, unknown>]) => entry.entryType === ELedgerEntryType.CARRY_OVER,
|
||||||
|
);
|
||||||
|
expect(carryOverCall).toBeDefined();
|
||||||
|
expect(carryOverCall![0]).toMatchObject({ days: "4.00" }); // capped, not the full 10
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LeaveBalancesService.post", () => {
|
||||||
|
it("is the only way days move — saves a signed entry against the entitlement", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
const repo = {
|
||||||
|
create: (d: Record<string, unknown>) => d,
|
||||||
|
save: jest.fn().mockImplementation(async (d: Record<string, unknown>) => ({ id: "entry-1", ...d })),
|
||||||
|
};
|
||||||
|
h.dataSource.manager.getRepository.mockReturnValue(repo);
|
||||||
|
|
||||||
|
const result = await h.service.post(
|
||||||
|
"ent-1",
|
||||||
|
{ entryType: ELedgerEntryType.DEDUCTION, days: -4, effectiveOn: "2026-09-14", reason: "annual" },
|
||||||
|
makeActor({ userId: "user-9" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
entitlementId: "ent-1",
|
||||||
|
entryType: ELedgerEntryType.DEDUCTION,
|
||||||
|
days: "-4.00",
|
||||||
|
effectiveOn: "2026-09-14",
|
||||||
|
createdBy: "user-9",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ id: "entry-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("joins the caller's transaction when a manager is supplied", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
const txRepo = {
|
||||||
|
create: (d: Record<string, unknown>) => d,
|
||||||
|
save: jest.fn().mockResolvedValue({ id: "entry-2" }),
|
||||||
|
};
|
||||||
|
h.manager.getRepository.mockReturnValue(txRepo);
|
||||||
|
|
||||||
|
await h.service.post(
|
||||||
|
"ent-1",
|
||||||
|
{ entryType: ELedgerEntryType.DEDUCTION, days: -1, effectiveOn: "2026-09-14" },
|
||||||
|
makeActor(),
|
||||||
|
h.manager as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(txRepo.save).toHaveBeenCalled();
|
||||||
|
expect(h.dataSource.manager.getRepository).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LeaveBalancesService.reverse", () => {
|
||||||
|
it("404s when the original entry does not exist", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
const repo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||||
|
h.dataSource.manager.getRepository.mockReturnValue(repo);
|
||||||
|
|
||||||
|
await expect(h.service.reverse("entry-1", "cancelled", makeActor())).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to reverse an entry that already has a reversal (pre-check)", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
const original = { id: "entry-1", entitlementId: "ent-1", days: "-4.00" };
|
||||||
|
const repo = {
|
||||||
|
findOne: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(original) // lookup of the original
|
||||||
|
.mockResolvedValueOnce({ id: "entry-2", reversesId: "entry-1" }), // an existing reversal
|
||||||
|
save: jest.fn(),
|
||||||
|
create: (d: Record<string, unknown>) => d,
|
||||||
|
};
|
||||||
|
h.dataSource.manager.getRepository.mockReturnValue(repo);
|
||||||
|
|
||||||
|
await expect(h.service.reverse("entry-1", "twice", makeActor())).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expect(repo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts the mirrored opposite entry when nothing has reversed it yet", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
const original = { id: "entry-1", entitlementId: "ent-1", days: "-4.00" };
|
||||||
|
const repo = {
|
||||||
|
findOne: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(original)
|
||||||
|
.mockResolvedValueOnce(null), // no existing reversal
|
||||||
|
save: jest.fn().mockImplementation(async (d: Record<string, unknown>) => ({ id: "entry-2", ...d })),
|
||||||
|
create: (d: Record<string, unknown>) => d,
|
||||||
|
};
|
||||||
|
h.dataSource.manager.getRepository.mockReturnValue(repo);
|
||||||
|
|
||||||
|
const result = await h.service.reverse("entry-1", "leave cancelled", makeActor());
|
||||||
|
|
||||||
|
expect(repo.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
entitlementId: "ent-1",
|
||||||
|
entryType: ELedgerEntryType.REVERSAL,
|
||||||
|
days: "4.00", // opposite sign of the original's -4.00
|
||||||
|
reversesId: "entry-1",
|
||||||
|
reason: "leave cancelled",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ id: "entry-2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns a caught 23505 unique-violation into ConflictException (the race case)", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
const original = { id: "entry-1", entitlementId: "ent-1", days: "-4.00" };
|
||||||
|
const repo = {
|
||||||
|
findOne: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(original)
|
||||||
|
.mockResolvedValueOnce(null), // pre-check sees no reversal yet — the race is in the write
|
||||||
|
save: jest.fn().mockRejectedValue({ code: "23505" }),
|
||||||
|
create: (d: Record<string, unknown>) => d,
|
||||||
|
};
|
||||||
|
h.dataSource.manager.getRepository.mockReturnValue(repo);
|
||||||
|
|
||||||
|
await expect(h.service.reverse("entry-1", "double click", makeActor())).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-throws an unrelated error from the write unchanged", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
const original = { id: "entry-1", entitlementId: "ent-1", days: "-4.00" };
|
||||||
|
const boom = new Error("connection reset");
|
||||||
|
const repo = {
|
||||||
|
findOne: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(original)
|
||||||
|
.mockResolvedValueOnce(null),
|
||||||
|
save: jest.fn().mockRejectedValue(boom),
|
||||||
|
create: (d: Record<string, unknown>) => d,
|
||||||
|
};
|
||||||
|
h.dataSource.manager.getRepository.mockReturnValue(repo);
|
||||||
|
|
||||||
|
await expect(h.service.reverse("entry-1", "x", makeActor())).rejects.toBe(boom);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
import { BadRequestException, ForbiddenException, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { ActorContext } from "../../employees/employees.service";
|
||||||
|
import { ELeaveRequestStatus, LeaveRequest } from "../entities/leave-request.entity";
|
||||||
|
import { ELedgerEntryType } from "../entities/leave-ledger-entry.entity";
|
||||||
|
import { EAccrualMethod, EGenderRestriction, LeaveType } from "../entities/leave-type.entity";
|
||||||
|
import { CreateLeaveRequestDto } from "../dto/leave-request.dto";
|
||||||
|
import { LeaveRequestsService } from "./leave-requests.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the freight suite's convention: plain `new ServiceClass(...)` with
|
||||||
|
* hand-built fakes passed positionally — no `Test.createTestingModule`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
||||||
|
return {
|
||||||
|
employeeId: "emp-1",
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
isSuperAdmin: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeType(overrides: Partial<LeaveType> = {}): LeaveType {
|
||||||
|
return {
|
||||||
|
id: "type-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
code: "ANNUAL",
|
||||||
|
name: { am: "ዓመታዊ", en: "Annual" },
|
||||||
|
isPaid: true,
|
||||||
|
accrualMethod: EAccrualMethod.ANNUAL_ENTITLEMENT,
|
||||||
|
baseDaysPerYear: "16",
|
||||||
|
extraDaysPerPeriod: "1",
|
||||||
|
servicePeriodYears: 2,
|
||||||
|
maxDaysPerYear: null,
|
||||||
|
maxCarryOverDays: "6",
|
||||||
|
maxConsecutiveDays: null,
|
||||||
|
minServiceMonths: 0,
|
||||||
|
genderRestriction: EGenderRestriction.ANY,
|
||||||
|
countsWorkingDaysOnly: true,
|
||||||
|
allowsHalfDay: true,
|
||||||
|
requiresAttachmentAfter: null,
|
||||||
|
requiresApproval: true,
|
||||||
|
statuteReference: null,
|
||||||
|
sortOrder: 100,
|
||||||
|
isActive: true,
|
||||||
|
createdBy: null,
|
||||||
|
updatedBy: null,
|
||||||
|
...overrides,
|
||||||
|
} as LeaveType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeProfile(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
employeeId: "emp-1",
|
||||||
|
gender: "MALE",
|
||||||
|
hireDate: "2020-01-01",
|
||||||
|
managerEmployeeId: "mgr-1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRequest(overrides: Partial<LeaveRequest> = {}): LeaveRequest {
|
||||||
|
return {
|
||||||
|
id: "req-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
employeeId: "emp-2",
|
||||||
|
leaveTypeId: "type-1",
|
||||||
|
status: ELeaveRequestStatus.SUBMITTED,
|
||||||
|
startDate: "2026-09-14",
|
||||||
|
endDate: "2026-09-18",
|
||||||
|
isHalfDay: false,
|
||||||
|
workingDays: "4.00",
|
||||||
|
calendarDays: "5.00",
|
||||||
|
chargedDays: "4.00",
|
||||||
|
reason: null,
|
||||||
|
contactDuringLeave: null,
|
||||||
|
attachmentDocumentId: null,
|
||||||
|
approverEmployeeId: "mgr-1",
|
||||||
|
decidedByEmployeeId: null,
|
||||||
|
decidedAt: null,
|
||||||
|
decisionNote: null,
|
||||||
|
cancelledReason: null,
|
||||||
|
createdBy: "user-2",
|
||||||
|
updatedBy: null,
|
||||||
|
...overrides,
|
||||||
|
} as LeaveRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBalance(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
leaveTypeId: "type-1",
|
||||||
|
code: "ANNUAL",
|
||||||
|
name: { am: "ዓመታዊ", en: "Annual" },
|
||||||
|
leaveYearStart: "2026-01-01",
|
||||||
|
leaveYearEnd: "2026-12-31",
|
||||||
|
entitledDays: 16,
|
||||||
|
carriedOverDays: 0,
|
||||||
|
grantedDays: 16,
|
||||||
|
takenDays: 0,
|
||||||
|
adjustmentDays: 0,
|
||||||
|
availableDays: 10,
|
||||||
|
carryOverExpiresOn: null,
|
||||||
|
entitlementId: "ent-1",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSettings(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
weekendDays: [0],
|
||||||
|
allowNegativeBalance: false,
|
||||||
|
carryOverDeadlineMonths: 6,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHarness() {
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn(),
|
||||||
|
};
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn().mockImplementation((cb: (m: unknown) => unknown) => cb(manager)),
|
||||||
|
manager,
|
||||||
|
};
|
||||||
|
const requests = {
|
||||||
|
findOverlapping: jest.fn().mockResolvedValue([]),
|
||||||
|
create: jest.fn(),
|
||||||
|
findPage: jest.fn().mockResolvedValue([[], 0]),
|
||||||
|
findById: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
const leaveTypes = {
|
||||||
|
findById: jest.fn(),
|
||||||
|
};
|
||||||
|
const balances = {
|
||||||
|
balanceFor: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
entriesForSource: jest.fn().mockResolvedValue([]),
|
||||||
|
reverse: jest.fn(),
|
||||||
|
};
|
||||||
|
const settingsService = {
|
||||||
|
resolve: jest.fn().mockResolvedValue(makeSettings()),
|
||||||
|
};
|
||||||
|
const workingDays = {
|
||||||
|
countWorkingDays: jest.fn().mockResolvedValue(4),
|
||||||
|
holidayDates: jest.fn().mockResolvedValue(new Set<string>()),
|
||||||
|
};
|
||||||
|
const employees = {
|
||||||
|
findByEmployeeId: jest.fn().mockResolvedValue(makeProfile()),
|
||||||
|
};
|
||||||
|
const iamDirectory = {
|
||||||
|
findLineManagerEmployeeId: jest.fn().mockResolvedValue("line-mgr-1"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new LeaveRequestsService(
|
||||||
|
dataSource as never,
|
||||||
|
requests as never,
|
||||||
|
leaveTypes as never,
|
||||||
|
balances as never,
|
||||||
|
settingsService as never,
|
||||||
|
workingDays as never,
|
||||||
|
employees as never,
|
||||||
|
iamDirectory as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
leaveTypes.findById.mockResolvedValue(makeType());
|
||||||
|
balances.balanceFor.mockResolvedValue(makeBalance());
|
||||||
|
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
dataSource,
|
||||||
|
manager,
|
||||||
|
requests,
|
||||||
|
leaveTypes,
|
||||||
|
balances,
|
||||||
|
settingsService,
|
||||||
|
workingDays,
|
||||||
|
employees,
|
||||||
|
iamDirectory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("LeaveRequestsService.quote", () => {
|
||||||
|
it("prices working-days-only types off countWorkingDays, not calendar span", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.workingDays.countWorkingDays.mockResolvedValue(4); // deliberately != calendar span (5)
|
||||||
|
h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 10 }));
|
||||||
|
|
||||||
|
const result = await h.service.quote(
|
||||||
|
"emp-1",
|
||||||
|
"type-1",
|
||||||
|
"2026-09-14",
|
||||||
|
"2026-09-18",
|
||||||
|
false,
|
||||||
|
makeActor(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.workingDays).toBe(4);
|
||||||
|
expect(result.calendarDays).toBe(5);
|
||||||
|
expect(result.chargedDays).toBe(4);
|
||||||
|
expect(result.countsWorkingDaysOnly).toBe(true);
|
||||||
|
expect(result.availableDays).toBe(10);
|
||||||
|
expect(result.remainingAfter).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices calendar-day types off the full span, ignoring weekends/holidays", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findById.mockResolvedValue(
|
||||||
|
makeType({ countsWorkingDaysOnly: false, accrualMethod: EAccrualMethod.NONE }),
|
||||||
|
);
|
||||||
|
h.workingDays.countWorkingDays.mockResolvedValue(2); // must be ignored
|
||||||
|
|
||||||
|
const result = await h.service.quote(
|
||||||
|
"emp-1",
|
||||||
|
"type-1",
|
||||||
|
"2026-09-14",
|
||||||
|
"2026-09-18",
|
||||||
|
false,
|
||||||
|
makeActor(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.chargedDays).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("halves the charge for a half day", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.workingDays.countWorkingDays.mockResolvedValue(5);
|
||||||
|
|
||||||
|
const result = await h.service.quote(
|
||||||
|
"emp-1",
|
||||||
|
"type-1",
|
||||||
|
"2026-09-14",
|
||||||
|
"2026-09-14",
|
||||||
|
true,
|
||||||
|
makeActor(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.chargedDays).toBe(2.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags the attachment requirement once charged days pass the threshold", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findById.mockResolvedValue(makeType({ requiresAttachmentAfter: "3" }));
|
||||||
|
h.workingDays.countWorkingDays.mockResolvedValue(3);
|
||||||
|
|
||||||
|
const atThreshold = await h.service.quote(
|
||||||
|
"emp-1",
|
||||||
|
"type-1",
|
||||||
|
"2026-09-14",
|
||||||
|
"2026-09-18",
|
||||||
|
false,
|
||||||
|
makeActor(),
|
||||||
|
);
|
||||||
|
expect(atThreshold.requiresAttachment).toBe(false); // 3 is not > 3
|
||||||
|
|
||||||
|
h.workingDays.countWorkingDays.mockResolvedValue(4);
|
||||||
|
const overThreshold = await h.service.quote(
|
||||||
|
"emp-1",
|
||||||
|
"type-1",
|
||||||
|
"2026-09-14",
|
||||||
|
"2026-09-18",
|
||||||
|
false,
|
||||||
|
makeActor(),
|
||||||
|
);
|
||||||
|
expect(overThreshold.requiresAttachment).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows a balance-lookup failure into null rather than throwing", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.balances.balanceFor.mockRejectedValue(new BadRequestException("no balance"));
|
||||||
|
|
||||||
|
const result = await h.service.quote(
|
||||||
|
"emp-1",
|
||||||
|
"type-1",
|
||||||
|
"2026-09-14",
|
||||||
|
"2026-09-18",
|
||||||
|
false,
|
||||||
|
makeActor(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.availableDays).toBeNull();
|
||||||
|
expect(result.remainingAfter).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the holidays in range, sorted", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.workingDays.holidayDates.mockResolvedValue(new Set(["2026-09-16", "2026-09-15"]));
|
||||||
|
|
||||||
|
const result = await h.service.quote(
|
||||||
|
"emp-1",
|
||||||
|
"type-1",
|
||||||
|
"2026-09-14",
|
||||||
|
"2026-09-18",
|
||||||
|
false,
|
||||||
|
makeActor(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.holidaysInRange).toEqual(["2026-09-15", "2026-09-16"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LeaveRequestsService.create", () => {
|
||||||
|
function dto(overrides: Partial<CreateLeaveRequestDto> = {}): CreateLeaveRequestDto {
|
||||||
|
return {
|
||||||
|
leaveTypeId: "type-1",
|
||||||
|
startDate: "2026-09-14",
|
||||||
|
endDate: "2026-09-18",
|
||||||
|
isHalfDay: false,
|
||||||
|
...overrides,
|
||||||
|
} as CreateLeaveRequestDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("uses actor.employeeId when dto.employeeId is omitted", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||||
|
id: "req-new",
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await h.service.create(dto(), makeActor({ employeeId: "emp-1" }));
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ employeeId: "emp-1" });
|
||||||
|
expect(h.employees.findByEmployeeId).toHaveBeenCalledWith("emp-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when neither dto.employeeId nor actor.employeeId is set", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
await expect(
|
||||||
|
h.service.create(dto(), makeActor({ employeeId: null })),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a staff account to file for someone else", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
await expect(
|
||||||
|
h.service.create(
|
||||||
|
dto({ employeeId: "emp-2" }),
|
||||||
|
makeActor({ employeeId: "emp-1", organizationId: "" as unknown as string }),
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a staff account (has organizationId) to file for someone else", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||||
|
id: "req-new",
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await h.service.create(
|
||||||
|
dto({ employeeId: "emp-2" }),
|
||||||
|
makeActor({ employeeId: "emp-1", organizationId: "org-1" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ employeeId: "emp-2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects endDate before startDate", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
await expect(
|
||||||
|
h.service.create(dto({ startDate: "2026-09-18", endDate: "2026-09-14" }), makeActor()),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s when the HR profile does not exist", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.employees.findByEmployeeId.mockResolvedValue(null);
|
||||||
|
await expect(h.service.create(dto(), makeActor())).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the gender restriction against the profile", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findById.mockResolvedValue(
|
||||||
|
makeType({ genderRestriction: EGenderRestriction.FEMALE }),
|
||||||
|
);
|
||||||
|
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ gender: "MALE" }));
|
||||||
|
|
||||||
|
await expect(h.service.create(dto(), makeActor())).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces the gender restriction when no gender is recorded", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findById.mockResolvedValue(
|
||||||
|
makeType({ genderRestriction: EGenderRestriction.FEMALE }),
|
||||||
|
);
|
||||||
|
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ gender: null }));
|
||||||
|
|
||||||
|
await expect(h.service.create(dto(), makeActor())).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses leave starting before the hire date", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ hireDate: "2027-01-01" }));
|
||||||
|
|
||||||
|
await expect(h.service.create(dto(), makeActor())).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses dates overlapping an existing live request", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.findOverlapping.mockResolvedValue([
|
||||||
|
makeRequest({ status: ELeaveRequestStatus.APPROVED, startDate: "2026-09-15", endDate: "2026-09-16" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/overlap/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a half day the type does not allow", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findById.mockResolvedValue(makeType({ allowsHalfDay: false }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
h.service.create(dto({ isHalfDay: true }), makeActor()),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a range that is entirely weekends/holidays (charged <= 0)", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.workingDays.countWorkingDays.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/no working days/i);
|
||||||
|
expect(h.requests.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps at maxConsecutiveDays", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findById.mockResolvedValue(makeType({ maxConsecutiveDays: "3" }));
|
||||||
|
h.workingDays.countWorkingDays.mockResolvedValue(4);
|
||||||
|
|
||||||
|
await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/limited to 3 days/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an attachment past the threshold", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findById.mockResolvedValue(makeType({ requiresAttachmentAfter: "2" }));
|
||||||
|
h.workingDays.countWorkingDays.mockResolvedValue(4);
|
||||||
|
|
||||||
|
await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/supporting evidence/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an attachment past the threshold when supplied", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findById.mockResolvedValue(makeType({ requiresAttachmentAfter: "2" }));
|
||||||
|
h.workingDays.countWorkingDays.mockResolvedValue(4);
|
||||||
|
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||||
|
id: "req-new",
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
h.service.create(dto({ attachmentDocumentId: "doc-1" }), makeActor()),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the balance check entirely for NONE-accrual types", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findById.mockResolvedValue(makeType({ accrualMethod: EAccrualMethod.NONE }));
|
||||||
|
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||||
|
id: "req-new",
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await h.service.create(dto(), makeActor());
|
||||||
|
|
||||||
|
expect(h.balances.balanceFor).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the balance check when the org allows a negative balance", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.settingsService.resolve.mockResolvedValue(makeSettings({ allowNegativeBalance: true }));
|
||||||
|
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||||
|
id: "req-new",
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await h.service.create(dto(), makeActor());
|
||||||
|
|
||||||
|
expect(h.balances.balanceFor).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts other SUBMITTED requests as held against the balance", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.workingDays.countWorkingDays.mockResolvedValue(4);
|
||||||
|
h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 5 }));
|
||||||
|
h.requests.findPage.mockResolvedValue([
|
||||||
|
[makeRequest({ id: "other-req", chargedDays: "2.00" })],
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// available 5 - held 2 = free 3, charged 4 > free 3 -> refused
|
||||||
|
await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/already held by pending/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows the request when enough balance remains after held requests", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.workingDays.countWorkingDays.mockResolvedValue(4);
|
||||||
|
h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 10 }));
|
||||||
|
h.requests.findPage.mockResolvedValue([
|
||||||
|
[makeRequest({ id: "other-req", chargedDays: "2.00" })],
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||||
|
id: "req-new",
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(h.service.create(dto(), makeActor())).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the approver from the profile's managerEmployeeId without an IAM lookup", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ managerEmployeeId: "mgr-9" }));
|
||||||
|
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||||
|
id: "req-new",
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await h.service.create(dto(), makeActor());
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ approverEmployeeId: "mgr-9" });
|
||||||
|
expect(h.iamDirectory.findLineManagerEmployeeId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the IAM position tree when the profile has no manager", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ managerEmployeeId: null }));
|
||||||
|
h.iamDirectory.findLineManagerEmployeeId.mockResolvedValue("line-mgr-7");
|
||||||
|
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||||
|
id: "req-new",
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await h.service.create(dto(), makeActor());
|
||||||
|
|
||||||
|
expect(h.iamDirectory.findLineManagerEmployeeId).toHaveBeenCalledWith("emp-1");
|
||||||
|
expect(result).toMatchObject({ approverEmployeeId: "line-mgr-7" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits requiresApproval types as SUBMITTED with no ledger posting", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||||
|
id: "req-new",
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await h.service.create(dto(), makeActor());
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: ELeaveRequestStatus.SUBMITTED,
|
||||||
|
decidedByEmployeeId: null,
|
||||||
|
decidedAt: null,
|
||||||
|
decisionNote: null,
|
||||||
|
});
|
||||||
|
expect(h.balances.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-approves requiresApproval:false types and posts a DEDUCTION outside a transaction", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.leaveTypes.findById.mockResolvedValue(makeType({ requiresApproval: false, code: "BEREAVEMENT" }));
|
||||||
|
h.workingDays.countWorkingDays.mockResolvedValue(3);
|
||||||
|
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||||
|
id: "req-new",
|
||||||
|
...data,
|
||||||
|
}));
|
||||||
|
h.balances.balanceFor.mockResolvedValue(makeBalance({ entitlementId: "ent-42" }));
|
||||||
|
|
||||||
|
const result = await h.service.create(dto(), makeActor({ employeeId: "emp-1" }));
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
status: ELeaveRequestStatus.APPROVED,
|
||||||
|
decidedByEmployeeId: "emp-1",
|
||||||
|
approverEmployeeId: null,
|
||||||
|
});
|
||||||
|
expect(result.decidedAt).toBeInstanceOf(Date);
|
||||||
|
expect(result.decisionNote).toMatch(/does not require prior approval/i);
|
||||||
|
|
||||||
|
expect(h.dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
expect(h.balances.post).toHaveBeenCalledTimes(1);
|
||||||
|
const [entitlementId, entry, , manager] = h.balances.post.mock.calls[0];
|
||||||
|
expect(entitlementId).toBe("ent-42");
|
||||||
|
expect(entry).toMatchObject({ entryType: ELedgerEntryType.DEDUCTION, days: -3 });
|
||||||
|
expect(manager).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LeaveRequestsService.approve", () => {
|
||||||
|
it("404s when the request does not exist", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.findById.mockResolvedValue(null);
|
||||||
|
await expect(h.service.approve("missing", undefined, makeActor())).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only allows SUBMITTED to become APPROVED", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.findById.mockResolvedValue(
|
||||||
|
makeRequest({ status: ELeaveRequestStatus.APPROVED }),
|
||||||
|
);
|
||||||
|
await expect(h.service.approve("req-1", undefined, makeActor())).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses self-approval", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.findById.mockResolvedValue(makeRequest({ employeeId: "emp-1" }));
|
||||||
|
await expect(
|
||||||
|
h.service.approve("req-1", undefined, makeActor({ employeeId: "emp-1" })),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("404s when the leave type behind the request no longer exists", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.findById.mockResolvedValue(makeRequest());
|
||||||
|
h.leaveTypes.findById.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
h.service.approve("req-1", undefined, makeActor({ employeeId: "mgr-1" })),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes the request's own pending entry from the held total when re-checking balance", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
// The request being approved is itself SUBMITTED and would otherwise show
|
||||||
|
// up in the pending-requests query — assertBalance must exclude it via
|
||||||
|
// excludeRequestId, or a solo request would always look self-blocking
|
||||||
|
// (held == charged, leaving zero free even though nothing else is pending).
|
||||||
|
const request = makeRequest({ chargedDays: "4.00" });
|
||||||
|
h.requests.findById.mockResolvedValue(request);
|
||||||
|
h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 4, entitlementId: "ent-1" }));
|
||||||
|
h.requests.findPage.mockResolvedValue([[request], 1]);
|
||||||
|
const requestRepoInTx = { update: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
h.manager.getRepository.mockReturnValue(requestRepoInTx);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
h.service.approve("req-1", undefined, makeActor({ employeeId: "mgr-1" })),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when another request has consumed the balance since submission", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
const request = makeRequest({ chargedDays: "4.00" });
|
||||||
|
h.requests.findById.mockResolvedValue(request);
|
||||||
|
h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 3 }));
|
||||||
|
h.requests.findPage.mockResolvedValue([
|
||||||
|
[request, makeRequest({ id: "other-req", chargedDays: "1.00" })],
|
||||||
|
2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
h.service.approve("req-1", undefined, makeActor({ employeeId: "mgr-1" })),
|
||||||
|
).rejects.toThrow(/not enough/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts the DEDUCTION and flips status to APPROVED atomically in one transaction", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
const request = makeRequest({ chargedDays: "4.00" });
|
||||||
|
h.requests.findById.mockResolvedValue(request);
|
||||||
|
h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 10, entitlementId: "ent-1" }));
|
||||||
|
h.requests.findPage.mockResolvedValue([[], 0]);
|
||||||
|
|
||||||
|
const requestRepoInTx = { update: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
h.manager.getRepository.mockReturnValue(requestRepoInTx);
|
||||||
|
|
||||||
|
const result = await h.service.approve("req-1", "looks good", makeActor({ employeeId: "mgr-1" }));
|
||||||
|
|
||||||
|
expect(h.dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Both writes happened against the transaction's manager.
|
||||||
|
expect(h.balances.post).toHaveBeenCalledTimes(1);
|
||||||
|
const [entitlementId, entry, , manager] = h.balances.post.mock.calls[0];
|
||||||
|
expect(entitlementId).toBe("ent-1");
|
||||||
|
expect(entry).toMatchObject({ entryType: ELedgerEntryType.DEDUCTION, days: -4 });
|
||||||
|
expect(manager).toBe(h.manager);
|
||||||
|
|
||||||
|
expect(requestRepoInTx.update).toHaveBeenCalledWith(
|
||||||
|
"req-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
status: ELeaveRequestStatus.APPROVED,
|
||||||
|
decidedByEmployeeId: "mgr-1",
|
||||||
|
decisionNote: "looks good",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.status).toBe(ELeaveRequestStatus.APPROVED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LeaveRequestsService.reject", () => {
|
||||||
|
it("only allows SUBMITTED to become REJECTED", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.findById.mockResolvedValue(
|
||||||
|
makeRequest({ status: ELeaveRequestStatus.CANCELLED }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
h.service.reject("req-1", "no", makeActor({ employeeId: "mgr-1" })),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses self-rejection", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.findById.mockResolvedValue(makeRequest({ employeeId: "emp-1" }));
|
||||||
|
await expect(
|
||||||
|
h.service.reject("req-1", "no", makeActor({ employeeId: "emp-1" })),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates status without touching the ledger — nothing was deducted at submission", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
const request = makeRequest();
|
||||||
|
h.requests.findById.mockResolvedValue(request);
|
||||||
|
h.requests.update.mockResolvedValue({ ...request, status: ELeaveRequestStatus.REJECTED });
|
||||||
|
|
||||||
|
const result = await h.service.reject("req-1", "not eligible", makeActor({ employeeId: "mgr-1" }));
|
||||||
|
|
||||||
|
expect(h.requests.update).toHaveBeenCalledWith(
|
||||||
|
"req-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
status: ELeaveRequestStatus.REJECTED,
|
||||||
|
decidedByEmployeeId: "mgr-1",
|
||||||
|
decisionNote: "not eligible",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(h.balances.post).not.toHaveBeenCalled();
|
||||||
|
expect(h.balances.reverse).not.toHaveBeenCalled();
|
||||||
|
expect(h.dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
expect(result.status).toBe(ELeaveRequestStatus.REJECTED);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LeaveRequestsService.cancel", () => {
|
||||||
|
it("only allows APPROVED to become CANCELLED", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.findById.mockResolvedValue(
|
||||||
|
makeRequest({ status: ELeaveRequestStatus.SUBMITTED }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
h.service.cancel("req-1", "change of plans", makeActor({ employeeId: "emp-2" })),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a staff account to cancel someone else's leave", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.findById.mockResolvedValue(
|
||||||
|
makeRequest({ status: ELeaveRequestStatus.APPROVED, employeeId: "emp-2" }),
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
h.service.cancel(
|
||||||
|
"req-1",
|
||||||
|
"change of plans",
|
||||||
|
makeActor({ employeeId: "emp-1", organizationId: "" as unknown as string }),
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverses every un-reversed DEDUCTION entry for the request, atomically", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.findById.mockResolvedValue(
|
||||||
|
makeRequest({ status: ELeaveRequestStatus.APPROVED, employeeId: "emp-2" }),
|
||||||
|
);
|
||||||
|
h.balances.entriesForSource.mockResolvedValue([
|
||||||
|
{ id: "entry-1", reversesId: null },
|
||||||
|
]);
|
||||||
|
const requestRepoInTx = { update: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
h.manager.getRepository.mockReturnValue(requestRepoInTx);
|
||||||
|
|
||||||
|
const result = await h.service.cancel(
|
||||||
|
"req-1",
|
||||||
|
"change of plans",
|
||||||
|
makeActor({ employeeId: "emp-1", organizationId: "org-1" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(h.dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.balances.reverse).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.balances.reverse).toHaveBeenCalledWith(
|
||||||
|
"entry-1",
|
||||||
|
expect.stringContaining("change of plans"),
|
||||||
|
expect.anything(),
|
||||||
|
h.manager,
|
||||||
|
);
|
||||||
|
expect(requestRepoInTx.update).toHaveBeenCalledWith(
|
||||||
|
"req-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
status: ELeaveRequestStatus.CANCELLED,
|
||||||
|
cancelledReason: "change of plans",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(ELeaveRequestStatus.CANCELLED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idempotently skips an entry that has already been reversed", async () => {
|
||||||
|
const h = makeHarness();
|
||||||
|
h.requests.findById.mockResolvedValue(
|
||||||
|
makeRequest({ status: ELeaveRequestStatus.APPROVED, employeeId: "emp-2" }),
|
||||||
|
);
|
||||||
|
// entry-1 is the original DEDUCTION; entry-2 already reverses it.
|
||||||
|
h.balances.entriesForSource.mockResolvedValue([
|
||||||
|
{ id: "entry-1", reversesId: null },
|
||||||
|
{ id: "entry-2", reversesId: "entry-1" },
|
||||||
|
]);
|
||||||
|
const requestRepoInTx = { update: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
h.manager.getRepository.mockReturnValue(requestRepoInTx);
|
||||||
|
|
||||||
|
await h.service.cancel(
|
||||||
|
"req-1",
|
||||||
|
"already reversed",
|
||||||
|
makeActor({ employeeId: "emp-1", organizationId: "org-1" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(h.balances.reverse).not.toHaveBeenCalled();
|
||||||
|
expect(requestRepoInTx.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
import {
|
||||||
|
CalculationInput,
|
||||||
|
PayrollCalculatorService,
|
||||||
|
} from "./payroll-calculator.service";
|
||||||
|
import {
|
||||||
|
EComponentCalculation,
|
||||||
|
EComponentType,
|
||||||
|
SalaryComponent,
|
||||||
|
} from "../entities/salary-component.entity";
|
||||||
|
import { IncomeTaxBracket } from "../entities/statutory.entity";
|
||||||
|
import { INCOME_TAX_BRACKETS_979_2016, RESERVED_CODES } from "../statutory-payroll";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The real national Schedule B bands (Proc. 979/2016 Art. 11), shaped as the
|
||||||
|
* entity rows `TaxService.computeTax` expects. Using the actual bands — not a
|
||||||
|
* simplified stand-in — is what makes the worked examples below real, checkable
|
||||||
|
* arithmetic rather than a tautology against a fake schedule.
|
||||||
|
*/
|
||||||
|
const BRACKETS = INCOME_TAX_BRACKETS_979_2016.bands.map((band) => ({
|
||||||
|
lowerBound: band.lowerBound,
|
||||||
|
upperBound: band.upperBound,
|
||||||
|
rate: band.rate,
|
||||||
|
deduction: band.deduction,
|
||||||
|
})) as unknown as IncomeTaxBracket[];
|
||||||
|
|
||||||
|
function component(overrides: Partial<SalaryComponent> = {}): SalaryComponent {
|
||||||
|
return {
|
||||||
|
id: "comp-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
code: "CODE",
|
||||||
|
name: { am: "test", en: "Test" },
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
calculation: EComponentCalculation.FIXED,
|
||||||
|
defaultAmount: null,
|
||||||
|
defaultRate: null,
|
||||||
|
isTaxable: true,
|
||||||
|
isPensionable: false,
|
||||||
|
taxExemptAmount: null,
|
||||||
|
taxExemptRateOfBasic: null,
|
||||||
|
affectsNetPay: true,
|
||||||
|
statuteReference: null,
|
||||||
|
sortOrder: 100,
|
||||||
|
isActive: true,
|
||||||
|
createdBy: null,
|
||||||
|
updatedBy: null,
|
||||||
|
...overrides,
|
||||||
|
} as SalaryComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseInput(overrides: Partial<CalculationInput> = {}): CalculationInput {
|
||||||
|
return {
|
||||||
|
basicSalary: 10000,
|
||||||
|
components: [],
|
||||||
|
brackets: BRACKETS,
|
||||||
|
pensionEmployeeRate: 0.07,
|
||||||
|
pensionEmployerRate: 0.11,
|
||||||
|
overtimePay: 0,
|
||||||
|
overtimeHours: 0,
|
||||||
|
absenceDeductionRate: 0,
|
||||||
|
isPensionEligible: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PayrollCalculatorService.calculate — worked examples", () => {
|
||||||
|
let service: PayrollCalculatorService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new PayrollCalculatorService();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single regression test this whole file exists for: income tax must be
|
||||||
|
* computed on taxable income NET of the employee's own pension deduction, not
|
||||||
|
* on gross taxable income. Basic-only, full attendance, pension-eligible:
|
||||||
|
*
|
||||||
|
* basic = 10,000.00
|
||||||
|
* pension employee = 10,000.00 × 7% = 700.00
|
||||||
|
* pension employer = 10,000.00 × 11% = 1,100.00
|
||||||
|
* taxable income = 10,000.00 − 700.00 = 9,300.00
|
||||||
|
* band = 7,800.01–10,900.00 (30%, ded. 955.00)
|
||||||
|
* income tax = 9,300.00 × 0.30 − 955.00 = 1,835.00
|
||||||
|
* net pay = 10,000.00 − 700.00 − 1,835.00 = 7,465.00
|
||||||
|
*
|
||||||
|
* Taxing GROSS taxable income (10,000.00, same band) instead would give
|
||||||
|
* 10,000 × 0.30 − 955 = 2,045.00 — a different, wrong, number. This test
|
||||||
|
* would fail if that ordering bug were reintroduced.
|
||||||
|
*/
|
||||||
|
it("computes income tax on taxable income net of the employee pension deduction", () => {
|
||||||
|
const result = service.calculate(baseInput());
|
||||||
|
|
||||||
|
expect(result.basicSalary).toBe(10000);
|
||||||
|
expect(result.grossPay).toBe(10000);
|
||||||
|
expect(result.pensionableIncome).toBe(10000);
|
||||||
|
expect(result.pensionEmployee).toBe(700);
|
||||||
|
expect(result.pensionEmployer).toBe(1100);
|
||||||
|
expect(result.taxableIncome).toBe(9300);
|
||||||
|
expect(result.incomeTax).toBe(1835);
|
||||||
|
expect(result.incomeTax).not.toBe(2045); // the gross-taxed wrong answer
|
||||||
|
expect(result.totalDeductions).toBe(700 + 1835);
|
||||||
|
expect(result.netPay).toBe(7465);
|
||||||
|
|
||||||
|
// The employer's contribution is reported but plays no part in the deduction total.
|
||||||
|
const employerLine = result.lines.find((l) => l.code === RESERVED_CODES.pensionEmployer);
|
||||||
|
expect(employerLine?.affectsNetPay).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A full slip: unpaid-absence pro-ration, a capped allowance, an uncapped
|
||||||
|
* allowance, a percent-of-basic allowance, overtime, a plain other deduction,
|
||||||
|
* and both sides of pension — worked by hand end to end.
|
||||||
|
*
|
||||||
|
* basic (90% paid, 10% unpaid absence) = 10,000.00 × 0.90 = 9,000.00
|
||||||
|
* transport allowance = 3,000.00 (exempt: min(2,200, 25% of
|
||||||
|
* unprorated basic 10,000=2,500) = 2,200
|
||||||
|
* → taxable 800.00)
|
||||||
|
* housing allowance (no cap) = 1,500.00 (fully taxable)
|
||||||
|
* position allowance (5% of unprorated
|
||||||
|
* basic 10,000) = 500.00 (fully taxable)
|
||||||
|
* overtime (always fully taxable, never
|
||||||
|
* pensionable) = 150.00
|
||||||
|
* ────────────────────────────────────────────────
|
||||||
|
* gross pay = 9,000+3,000+1,500+500+150 = 14,150.00
|
||||||
|
* pensionable = basic only (all allowances non-pensionable) = 9,000.00
|
||||||
|
* taxable earnings = 9,000+800+1,500+500+150 = 11,950.00
|
||||||
|
* pension employee = 9,000 × 7% = 630.00
|
||||||
|
* pension employer = 9,000 × 11% = 990.00
|
||||||
|
* taxable income = 11,950 − 630 = 11,320.00
|
||||||
|
* band = 10,900.01+ (35%, ded. 1,500.00)
|
||||||
|
* income tax = 11,320 × 0.35 − 1,500 = 2,462.00
|
||||||
|
* other deduction (loan) = 200.00
|
||||||
|
* total deductions = 630 + 2,462 + 200 = 3,292.00
|
||||||
|
* net pay = 14,150 − 3,292 = 10,858.00
|
||||||
|
*/
|
||||||
|
it("computes a full payslip: absence pro-ration, capped/uncapped allowances, overtime, other deductions, both pension sides", () => {
|
||||||
|
const transport = component({
|
||||||
|
id: "transport-1",
|
||||||
|
code: "TRANSPORT",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
calculation: EComponentCalculation.FIXED,
|
||||||
|
isTaxable: true,
|
||||||
|
isPensionable: false,
|
||||||
|
taxExemptAmount: "2200.00",
|
||||||
|
taxExemptRateOfBasic: "0.2500",
|
||||||
|
sortOrder: 20,
|
||||||
|
});
|
||||||
|
const housing = component({
|
||||||
|
id: "housing-1",
|
||||||
|
code: "HOUSING",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
calculation: EComponentCalculation.FIXED,
|
||||||
|
isTaxable: true,
|
||||||
|
isPensionable: false,
|
||||||
|
sortOrder: 30,
|
||||||
|
});
|
||||||
|
const position = component({
|
||||||
|
id: "position-1",
|
||||||
|
code: "POSITION",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
calculation: EComponentCalculation.PERCENT_OF_BASIC,
|
||||||
|
isTaxable: true,
|
||||||
|
isPensionable: false,
|
||||||
|
sortOrder: 40,
|
||||||
|
});
|
||||||
|
const loan = component({
|
||||||
|
id: "loan-1",
|
||||||
|
code: "LOAN",
|
||||||
|
componentType: EComponentType.DEDUCTION,
|
||||||
|
calculation: EComponentCalculation.FIXED,
|
||||||
|
sortOrder: 90,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = service.calculate(
|
||||||
|
baseInput({
|
||||||
|
basicSalary: 10000,
|
||||||
|
absenceDeductionRate: 0.1,
|
||||||
|
overtimePay: 150,
|
||||||
|
overtimeHours: 10,
|
||||||
|
components: [
|
||||||
|
{ component: transport, amount: "3000" },
|
||||||
|
{ component: housing, amount: "1500" },
|
||||||
|
{ component: position, rate: "0.05" },
|
||||||
|
{ component: loan, amount: "200" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.basicSalary).toBe(9000);
|
||||||
|
expect(result.grossPay).toBe(14150);
|
||||||
|
expect(result.pensionableIncome).toBe(9000);
|
||||||
|
expect(result.pensionEmployee).toBe(630);
|
||||||
|
expect(result.pensionEmployer).toBe(990);
|
||||||
|
expect(result.taxableIncome).toBe(11320);
|
||||||
|
expect(result.incomeTax).toBe(2462);
|
||||||
|
expect(result.totalDeductions).toBe(630 + 2462 + 200);
|
||||||
|
expect(result.netPay).toBe(10858);
|
||||||
|
|
||||||
|
const transportLine = result.lines.find((l) => l.code === "TRANSPORT");
|
||||||
|
expect(transportLine?.amount).toBe(3000);
|
||||||
|
expect(transportLine?.taxableAmount).toBe(800);
|
||||||
|
expect(transportLine?.basis).toBe("2200.00 exempt, 800.00 taxable");
|
||||||
|
|
||||||
|
const housingLine = result.lines.find((l) => l.code === "HOUSING");
|
||||||
|
expect(housingLine?.amount).toBe(1500);
|
||||||
|
expect(housingLine?.taxableAmount).toBe(1500);
|
||||||
|
expect(housingLine?.basis).toBeNull();
|
||||||
|
|
||||||
|
const positionLine = result.lines.find((l) => l.code === "POSITION");
|
||||||
|
expect(positionLine?.amount).toBe(500);
|
||||||
|
|
||||||
|
const overtimeLine = result.lines.find((l) => l.code === RESERVED_CODES.overtime);
|
||||||
|
expect(overtimeLine?.amount).toBe(150);
|
||||||
|
expect(overtimeLine?.taxableAmount).toBe(150);
|
||||||
|
expect(overtimeLine?.isTaxable).toBe(true);
|
||||||
|
expect(overtimeLine?.isPensionable).toBe(false);
|
||||||
|
|
||||||
|
const loanLine = result.lines.find((l) => l.code === "LOAN");
|
||||||
|
expect(loanLine?.amount).toBe(200);
|
||||||
|
expect(loanLine?.taxableAmount).toBe(0);
|
||||||
|
expect(loanLine?.affectsNetPay).toBe(true);
|
||||||
|
|
||||||
|
const basicLine = result.lines.find((l) => l.componentType === EComponentType.BASIC);
|
||||||
|
expect(basicLine?.basis).toBe("90% of 10000.00 — unpaid absence");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayrollCalculatorService.calculate — allowance taxable-portion cap", () => {
|
||||||
|
let service: PayrollCalculatorService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new PayrollCalculatorService();
|
||||||
|
});
|
||||||
|
|
||||||
|
const allowanceLine = (input: CalculationInput) =>
|
||||||
|
service.calculate(input).lines.find((l) => l.code === "ALLOW");
|
||||||
|
|
||||||
|
it("caps the exempt portion at a fixed amount when only taxExemptAmount is set", () => {
|
||||||
|
const allowance = component({
|
||||||
|
code: "ALLOW",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
taxExemptAmount: "1000.00",
|
||||||
|
taxExemptRateOfBasic: null,
|
||||||
|
});
|
||||||
|
const line = allowanceLine(
|
||||||
|
baseInput({ components: [{ component: allowance, amount: "1500" }] }),
|
||||||
|
);
|
||||||
|
expect(line?.taxableAmount).toBe(500);
|
||||||
|
expect(line?.basis).toBe("1000.00 exempt, 500.00 taxable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the exempt portion at a percentage of basic when only taxExemptRateOfBasic is set", () => {
|
||||||
|
const allowance = component({
|
||||||
|
code: "ALLOW",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
taxExemptAmount: null,
|
||||||
|
taxExemptRateOfBasic: "0.1000",
|
||||||
|
});
|
||||||
|
const line = allowanceLine(
|
||||||
|
baseInput({
|
||||||
|
basicSalary: 10000,
|
||||||
|
components: [{ component: allowance, amount: "1500" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// exempt = 10% of 10,000 = 1,000 → taxable 500
|
||||||
|
expect(line?.taxableAmount).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the LESSER of the two caps — the transport-allowance rule — not the greater", () => {
|
||||||
|
const allowance = component({
|
||||||
|
code: "ALLOW",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
taxExemptAmount: "2200.00",
|
||||||
|
taxExemptRateOfBasic: "0.2500",
|
||||||
|
});
|
||||||
|
// 25% of 10,000 = 2,500 > 2,200 fixed cap. The LOWER (2,200) must win.
|
||||||
|
const line = allowanceLine(
|
||||||
|
baseInput({
|
||||||
|
basicSalary: 10000,
|
||||||
|
components: [{ component: allowance, amount: "3000" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(line?.taxableAmount).toBe(800); // NOT 500 (which the higher 2,500 cap would give)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is fully exempt when the allowance amount is below the cap", () => {
|
||||||
|
const allowance = component({
|
||||||
|
code: "ALLOW",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
taxExemptAmount: "2200.00",
|
||||||
|
});
|
||||||
|
const line = allowanceLine(
|
||||||
|
baseInput({ components: [{ component: allowance, amount: "2000" }] }),
|
||||||
|
);
|
||||||
|
expect(line?.taxableAmount).toBe(0);
|
||||||
|
expect(line?.basis).toBe("fully exempt (2000.00)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no exempt cap at all when neither field is set — fully taxable", () => {
|
||||||
|
const allowance = component({
|
||||||
|
code: "ALLOW",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
taxExemptAmount: null,
|
||||||
|
taxExemptRateOfBasic: null,
|
||||||
|
});
|
||||||
|
const line = allowanceLine(
|
||||||
|
baseInput({ components: [{ component: allowance, amount: "1500" }] }),
|
||||||
|
);
|
||||||
|
expect(line?.taxableAmount).toBe(1500);
|
||||||
|
expect(line?.basis).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is fully non-taxable, ignoring any cap, when the component itself is not taxable", () => {
|
||||||
|
const allowance = component({
|
||||||
|
code: "ALLOW",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
isTaxable: false,
|
||||||
|
taxExemptAmount: "100.00",
|
||||||
|
});
|
||||||
|
const line = allowanceLine(
|
||||||
|
baseInput({ components: [{ component: allowance, amount: "1500" }] }),
|
||||||
|
);
|
||||||
|
expect(line?.taxableAmount).toBe(0);
|
||||||
|
expect(line?.basis).toBe("not taxable");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayrollCalculatorService.calculate — pension", () => {
|
||||||
|
let service: PayrollCalculatorService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new PayrollCalculatorService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charges neither pension side and does not reduce taxable income when the employee is ineligible", () => {
|
||||||
|
const result = service.calculate(baseInput({ isPensionEligible: false }));
|
||||||
|
|
||||||
|
expect(result.pensionEmployee).toBe(0);
|
||||||
|
expect(result.pensionEmployer).toBe(0);
|
||||||
|
expect(result.lines.find((l) => l.code === RESERVED_CODES.pensionEmployee)).toBeUndefined();
|
||||||
|
expect(result.lines.find((l) => l.code === RESERVED_CODES.pensionEmployer)).toBeUndefined();
|
||||||
|
// Taxable income equals taxable earnings verbatim — nothing was deducted first.
|
||||||
|
expect(result.taxableIncome).toBe(result.grossPay);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records the employer contribution with affectsNetPay:false and excludes it from totalDeductions", () => {
|
||||||
|
const result = service.calculate(baseInput());
|
||||||
|
|
||||||
|
const employerLine = result.lines.find((l) => l.code === RESERVED_CODES.pensionEmployer);
|
||||||
|
expect(employerLine).toBeDefined();
|
||||||
|
expect(employerLine?.componentType).toBe(EComponentType.EMPLOYER_CONTRIBUTION);
|
||||||
|
expect(employerLine?.affectsNetPay).toBe(false);
|
||||||
|
expect(result.totalDeductions).toBe(result.pensionEmployee + result.incomeTax);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayrollCalculatorService.calculate — other deductions", () => {
|
||||||
|
let service: PayrollCalculatorService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new PayrollCalculatorService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a DEDUCTION component whose calculation is STATUTORY — those are computed by the engine, not the structure", () => {
|
||||||
|
const fakeStatutory = component({
|
||||||
|
code: "CUSTOM_STAT",
|
||||||
|
componentType: EComponentType.DEDUCTION,
|
||||||
|
calculation: EComponentCalculation.STATUTORY,
|
||||||
|
defaultAmount: "500.00",
|
||||||
|
});
|
||||||
|
const result = service.calculate(
|
||||||
|
baseInput({ components: [{ component: fakeStatutory, amount: "500" }] }),
|
||||||
|
);
|
||||||
|
expect(result.lines.find((l) => l.code === "CUSTOM_STAT")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prices a PERCENT_OF_GROSS deduction off the actual computed gross pay", () => {
|
||||||
|
const grossDeduction = component({
|
||||||
|
code: "UNION_DUES",
|
||||||
|
componentType: EComponentType.DEDUCTION,
|
||||||
|
calculation: EComponentCalculation.PERCENT_OF_GROSS,
|
||||||
|
});
|
||||||
|
const result = service.calculate(
|
||||||
|
baseInput({
|
||||||
|
basicSalary: 10000,
|
||||||
|
components: [{ component: grossDeduction, rate: "0.02" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// gross pay is basic-only here (10,000) since no allowances were added.
|
||||||
|
const line = result.lines.find((l) => l.code === "UNION_DUES");
|
||||||
|
expect(line?.amount).toBe(200); // 2% of 10,000
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops an allowance or deduction whose computed amount is zero or negative — no line is emitted", () => {
|
||||||
|
const zeroAllowance = component({
|
||||||
|
code: "ZERO",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
calculation: EComponentCalculation.FIXED,
|
||||||
|
defaultAmount: null,
|
||||||
|
});
|
||||||
|
const result = service.calculate(
|
||||||
|
baseInput({ components: [{ component: zeroAllowance }] }),
|
||||||
|
);
|
||||||
|
expect(result.lines.find((l) => l.code === "ZERO")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the component's own defaultAmount/defaultRate when the structure line has no override", () => {
|
||||||
|
const fixedByDefault = component({
|
||||||
|
code: "FIXED_DEFAULT",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
calculation: EComponentCalculation.FIXED,
|
||||||
|
defaultAmount: "750.00",
|
||||||
|
});
|
||||||
|
const percentByDefault = component({
|
||||||
|
code: "PERCENT_DEFAULT",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
calculation: EComponentCalculation.PERCENT_OF_BASIC,
|
||||||
|
defaultRate: "0.0500",
|
||||||
|
});
|
||||||
|
const result = service.calculate(
|
||||||
|
baseInput({
|
||||||
|
basicSalary: 10000,
|
||||||
|
components: [{ component: fixedByDefault }, { component: percentByDefault }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.lines.find((l) => l.code === "FIXED_DEFAULT")?.amount).toBe(750);
|
||||||
|
expect(result.lines.find((l) => l.code === "PERCENT_DEFAULT")?.amount).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayrollCalculatorService.calculate — line identity and ordering", () => {
|
||||||
|
let service: PayrollCalculatorService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new PayrollCalculatorService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the reserved default code/name for BASIC when no BASIC component is supplied", () => {
|
||||||
|
const result = service.calculate(baseInput());
|
||||||
|
const basicLine = result.lines.find((l) => l.componentType === EComponentType.BASIC);
|
||||||
|
expect(basicLine?.code).toBe(RESERVED_CODES.basic);
|
||||||
|
expect(basicLine?.name.en).toBe("Basic salary");
|
||||||
|
expect(basicLine?.salaryComponentId).toBeNull();
|
||||||
|
expect(basicLine?.isPensionable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the supplied BASIC component's own code/name/id/sortOrder/isPensionable when one is given", () => {
|
||||||
|
const basicComponent = component({
|
||||||
|
id: "basic-custom",
|
||||||
|
code: "BASE",
|
||||||
|
name: { am: "custom", en: "Custom basic" },
|
||||||
|
componentType: EComponentType.BASIC,
|
||||||
|
isPensionable: false,
|
||||||
|
sortOrder: 5,
|
||||||
|
});
|
||||||
|
const result = service.calculate(
|
||||||
|
baseInput({ components: [{ component: basicComponent }] }),
|
||||||
|
);
|
||||||
|
const basicLine = result.lines.find((l) => l.componentType === EComponentType.BASIC);
|
||||||
|
expect(basicLine?.code).toBe("BASE");
|
||||||
|
expect(basicLine?.name.en).toBe("Custom basic");
|
||||||
|
expect(basicLine?.salaryComponentId).toBe("basic-custom");
|
||||||
|
expect(basicLine?.isPensionable).toBe(false);
|
||||||
|
expect(basicLine?.sortOrder).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the reserved default name for OVERTIME when no OVERTIME component is supplied", () => {
|
||||||
|
const result = service.calculate(baseInput({ overtimePay: 100, overtimeHours: 5 }));
|
||||||
|
const line = result.lines.find((l) => l.code === RESERVED_CODES.overtime);
|
||||||
|
expect(line?.name.en).toBe("Overtime");
|
||||||
|
expect(line?.salaryComponentId).toBeNull();
|
||||||
|
expect(line?.basis).toBe("5 hour(s) of approved overtime");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the overtime line entirely when overtimePay is zero", () => {
|
||||||
|
const result = service.calculate(baseInput({ overtimePay: 0, overtimeHours: 0 }));
|
||||||
|
expect(result.lines.find((l) => l.code === RESERVED_CODES.overtime)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns lines sorted by sortOrder ascending, regardless of push order", () => {
|
||||||
|
const lowSort = component({
|
||||||
|
code: "LOW_SORT",
|
||||||
|
componentType: EComponentType.ALLOWANCE,
|
||||||
|
defaultAmount: "1",
|
||||||
|
sortOrder: 1,
|
||||||
|
});
|
||||||
|
const result = service.calculate(
|
||||||
|
baseInput({ components: [{ component: lowSort }] }),
|
||||||
|
);
|
||||||
|
const sortOrders = result.lines.map((l) => l.sortOrder);
|
||||||
|
const sorted = [...sortOrders].sort((a, b) => a - b);
|
||||||
|
expect(sortOrders).toEqual(sorted);
|
||||||
|
expect(result.lines[0].code).toBe("LOW_SORT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the income tax line when both incomeTax and taxableIncome are zero", () => {
|
||||||
|
const result = service.calculate(baseInput({ basicSalary: 0 }));
|
||||||
|
expect(result.incomeTax).toBe(0);
|
||||||
|
expect(result.taxableIncome).toBe(0);
|
||||||
|
expect(result.lines.find((l) => l.code === RESERVED_CODES.incomeTax)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayrollCalculatorService.calculate — absence pro-ration clamping", () => {
|
||||||
|
let service: PayrollCalculatorService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new PayrollCalculatorService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps an absenceDeductionRate above 1 to a fully unpaid (zero) basic", () => {
|
||||||
|
const result = service.calculate(baseInput({ absenceDeductionRate: 1.5 }));
|
||||||
|
expect(result.basicSalary).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps a negative absenceDeductionRate to zero — full pay, no proration basis text", () => {
|
||||||
|
const result = service.calculate(baseInput({ absenceDeductionRate: -0.2 }));
|
||||||
|
expect(result.basicSalary).toBe(10000);
|
||||||
|
const basicLine = result.lines.find((l) => l.componentType === EComponentType.BASIC);
|
||||||
|
expect(basicLine?.basis).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import type { ActorContext } from "../../employees/employees.service";
|
||||||
|
import { EComponentType } from "../entities/salary-component.entity";
|
||||||
|
import {
|
||||||
|
EPayrollRunStatus,
|
||||||
|
PayrollRun,
|
||||||
|
Payslip,
|
||||||
|
PayslipLine,
|
||||||
|
} from "../entities/payroll-run.entity";
|
||||||
|
import { RESERVED_CODES } from "../statutory-payroll";
|
||||||
|
import type { CalculationResult } from "./payroll-calculator.service";
|
||||||
|
import { PayrollRunsService } from "./payroll-runs.service";
|
||||||
|
|
||||||
|
function actor(overrides: Partial<ActorContext> = {}): ActorContext {
|
||||||
|
return {
|
||||||
|
employeeId: "actor-emp-1",
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
isSuperAdmin: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(overrides: Partial<PayrollRun> = {}): PayrollRun {
|
||||||
|
return {
|
||||||
|
id: "run-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
periodStart: "2026-07-01",
|
||||||
|
periodEnd: "2026-07-30",
|
||||||
|
paymentDate: null,
|
||||||
|
status: EPayrollRunStatus.DRAFT,
|
||||||
|
note: null,
|
||||||
|
employeeCount: 0,
|
||||||
|
totalGross: "0.00",
|
||||||
|
totalDeductions: "0.00",
|
||||||
|
totalNet: "0.00",
|
||||||
|
totalIncomeTax: "0.00",
|
||||||
|
totalPensionEmployee: "0.00",
|
||||||
|
totalPensionEmployer: "0.00",
|
||||||
|
calculatedAt: null,
|
||||||
|
approvedByEmployeeId: null,
|
||||||
|
approvedAt: null,
|
||||||
|
createdBy: null,
|
||||||
|
updatedBy: null,
|
||||||
|
...overrides,
|
||||||
|
} as PayrollRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcResult(overrides: Partial<CalculationResult> = {}): CalculationResult {
|
||||||
|
return {
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
code: RESERVED_CODES.basic,
|
||||||
|
name: { am: "መሠረታዊ ደመወዝ", en: "Basic salary" },
|
||||||
|
componentType: EComponentType.BASIC,
|
||||||
|
salaryComponentId: null,
|
||||||
|
amount: 10000,
|
||||||
|
taxableAmount: 10000,
|
||||||
|
isTaxable: true,
|
||||||
|
isPensionable: true,
|
||||||
|
affectsNetPay: true,
|
||||||
|
basis: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
basicSalary: 10000,
|
||||||
|
grossPay: 10000,
|
||||||
|
taxableIncome: 9300,
|
||||||
|
pensionableIncome: 10000,
|
||||||
|
incomeTax: 1835,
|
||||||
|
pensionEmployee: 700,
|
||||||
|
pensionEmployer: 1100,
|
||||||
|
totalDeductions: 2535,
|
||||||
|
netPay: 7465,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-memory stand-in for the manager's `getRepository(Entity)` calls inside the transaction. */
|
||||||
|
function makeManager(onRunUpdate: (patch: Record<string, unknown>) => void) {
|
||||||
|
let payslipSeq = 0;
|
||||||
|
let lineSeq = 0;
|
||||||
|
const payslipRepo = {
|
||||||
|
delete: jest.fn().mockResolvedValue(undefined),
|
||||||
|
create: jest.fn((data: Record<string, unknown>) => data),
|
||||||
|
save: jest.fn(async (data: Record<string, unknown>) => ({
|
||||||
|
id: `payslip-${++payslipSeq}`,
|
||||||
|
...data,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const lineRepo = {
|
||||||
|
create: jest.fn((data: Record<string, unknown>) => data),
|
||||||
|
save: jest.fn(async (data: Record<string, unknown>) => ({
|
||||||
|
id: `line-${++lineSeq}`,
|
||||||
|
...data,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const runRepo = {
|
||||||
|
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||||
|
onRunUpdate(patch);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const getRepository = jest.fn((entity: unknown) => {
|
||||||
|
if (entity === Payslip) return payslipRepo;
|
||||||
|
if (entity === PayslipLine) return lineRepo;
|
||||||
|
if (entity === PayrollRun) return runRepo;
|
||||||
|
throw new Error("unexpected entity passed to manager.getRepository in test mock");
|
||||||
|
});
|
||||||
|
return { getRepository, payslipRepo, lineRepo, runRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
let stored: PayrollRun | undefined;
|
||||||
|
const applyUpdate = (patch: Record<string, unknown>) => {
|
||||||
|
stored = { ...(stored as PayrollRun), ...patch } as PayrollRun;
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = makeManager(applyUpdate);
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn((cb: (m: unknown) => unknown) => cb(manager)),
|
||||||
|
};
|
||||||
|
const runs = {
|
||||||
|
findOne: jest.fn(async () => stored ?? null),
|
||||||
|
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||||
|
applyUpdate(patch);
|
||||||
|
}),
|
||||||
|
save: jest.fn(),
|
||||||
|
create: jest.fn((d: Record<string, unknown>) => d),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
};
|
||||||
|
const payslips = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
createQueryBuilder: jest.fn(),
|
||||||
|
};
|
||||||
|
const salaries = { findPayableOn: jest.fn().mockResolvedValue([]) };
|
||||||
|
const components = { findActive: jest.fn().mockResolvedValue([]) };
|
||||||
|
const structures = { findWithLines: jest.fn() };
|
||||||
|
const calculator = { calculate: jest.fn() };
|
||||||
|
const tax = {
|
||||||
|
bracketsOn: jest.fn().mockResolvedValue([]),
|
||||||
|
rateOn: jest.fn().mockResolvedValue(0.07),
|
||||||
|
};
|
||||||
|
const employees = { findByEmployeeId: jest.fn().mockResolvedValue(null) };
|
||||||
|
const overtime = { approvedTotals: jest.fn().mockResolvedValue([]) };
|
||||||
|
const attendance = { findRange: jest.fn().mockResolvedValue([]) };
|
||||||
|
const schedules = { resolveFor: jest.fn().mockResolvedValue(null) };
|
||||||
|
const leaveSettings = { resolve: jest.fn().mockResolvedValue({ weekendDays: [0, 6] }) };
|
||||||
|
const workingDays = { countWorkingDays: jest.fn().mockResolvedValue(22) };
|
||||||
|
|
||||||
|
const service = new PayrollRunsService(
|
||||||
|
dataSource as never,
|
||||||
|
runs as never,
|
||||||
|
payslips as never,
|
||||||
|
salaries as never,
|
||||||
|
components as never,
|
||||||
|
structures as never,
|
||||||
|
calculator as never,
|
||||||
|
tax as never,
|
||||||
|
employees as never,
|
||||||
|
overtime as never,
|
||||||
|
attendance as never,
|
||||||
|
schedules as never,
|
||||||
|
leaveSettings as never,
|
||||||
|
workingDays as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
service,
|
||||||
|
manager,
|
||||||
|
dataSource,
|
||||||
|
runs,
|
||||||
|
payslips,
|
||||||
|
salaries,
|
||||||
|
components,
|
||||||
|
structures,
|
||||||
|
calculator,
|
||||||
|
tax,
|
||||||
|
employees,
|
||||||
|
overtime,
|
||||||
|
attendance,
|
||||||
|
schedules,
|
||||||
|
leaveSettings,
|
||||||
|
workingDays,
|
||||||
|
use: (row: PayrollRun) => {
|
||||||
|
stored = row;
|
||||||
|
},
|
||||||
|
getStored: () => stored,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeComponent = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: "comp-basic",
|
||||||
|
code: RESERVED_CODES.basic,
|
||||||
|
name: { am: "x", en: "Basic" },
|
||||||
|
componentType: EComponentType.BASIC,
|
||||||
|
isActive: true,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayrollRunsService.calculate", () => {
|
||||||
|
it.each([
|
||||||
|
EPayrollRunStatus.APPROVED,
|
||||||
|
EPayrollRunStatus.PAID,
|
||||||
|
EPayrollRunStatus.CANCELLED,
|
||||||
|
])("refuses to (re)calculate a %s run", async (status) => {
|
||||||
|
const { service, use } = build();
|
||||||
|
use(run({ status }));
|
||||||
|
|
||||||
|
await expect(service.calculate("run-1", actor())).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds from scratch: deletes existing payslips before writing any new one, one payslip+lines per employee, aggregated totals, status CALCULATED", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.DRAFT }));
|
||||||
|
|
||||||
|
ctx.salaries.findPayableOn.mockResolvedValue([
|
||||||
|
{ employeeId: "emp-1", basicSalary: "10000", salaryStructureId: "struct-1" },
|
||||||
|
{ employeeId: "emp-2", basicSalary: "8000", salaryStructureId: null },
|
||||||
|
]);
|
||||||
|
ctx.structures.findWithLines.mockResolvedValue({
|
||||||
|
id: "struct-1",
|
||||||
|
lines: [{ salaryComponent: activeComponent(), amount: null, rate: null }],
|
||||||
|
});
|
||||||
|
ctx.components.findActive.mockResolvedValue([activeComponent()]);
|
||||||
|
ctx.employees.findByEmployeeId.mockImplementation(async (employeeId: string) => ({
|
||||||
|
employeeId,
|
||||||
|
employeeNumber: `E-${employeeId}`,
|
||||||
|
isPensionEligible: true,
|
||||||
|
salaryMode: "BANK",
|
||||||
|
bankAccountNumber: "123456",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const resultsByBasic: Record<number, CalculationResult> = {
|
||||||
|
10000: calcResult({
|
||||||
|
grossPay: 10000,
|
||||||
|
totalDeductions: 2535,
|
||||||
|
netPay: 7465,
|
||||||
|
incomeTax: 1835,
|
||||||
|
pensionEmployee: 700,
|
||||||
|
pensionEmployer: 1100,
|
||||||
|
}),
|
||||||
|
8000: calcResult({
|
||||||
|
basicSalary: 8000,
|
||||||
|
grossPay: 8000,
|
||||||
|
totalDeductions: 2000,
|
||||||
|
netPay: 6000,
|
||||||
|
incomeTax: 1300,
|
||||||
|
pensionEmployee: 560,
|
||||||
|
pensionEmployer: 880,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
ctx.calculator.calculate.mockImplementation(
|
||||||
|
(input: { basicSalary: number }) => resultsByBasic[input.basicSalary],
|
||||||
|
);
|
||||||
|
|
||||||
|
await ctx.service.calculate("run-1", actor({ userId: "actor-user" }));
|
||||||
|
|
||||||
|
// Delete-then-rebuild ordering.
|
||||||
|
expect(ctx.manager.payslipRepo.delete).toHaveBeenCalledWith({ payrollRunId: "run-1" });
|
||||||
|
expect(ctx.manager.payslipRepo.delete.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
ctx.manager.payslipRepo.save.mock.invocationCallOrder[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ctx.manager.payslipRepo.save).toHaveBeenCalledTimes(2);
|
||||||
|
expect(ctx.manager.lineRepo.save).toHaveBeenCalledTimes(2); // one line per employee's fixture result
|
||||||
|
|
||||||
|
const savedLine = ctx.manager.lineRepo.save.mock.calls[0][0];
|
||||||
|
expect(savedLine.code).toBe(RESERVED_CODES.basic);
|
||||||
|
expect(savedLine.amount).toBe("10000.00");
|
||||||
|
|
||||||
|
expect(ctx.manager.runRepo.update).toHaveBeenCalledWith(
|
||||||
|
"run-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
status: EPayrollRunStatus.CALCULATED,
|
||||||
|
employeeCount: 2,
|
||||||
|
totalGross: "18000.00",
|
||||||
|
totalDeductions: "4535.00",
|
||||||
|
totalNet: "13465.00",
|
||||||
|
totalIncomeTax: "3135.00",
|
||||||
|
totalPensionEmployee: "1260.00",
|
||||||
|
totalPensionEmployer: "1980.00",
|
||||||
|
calculatedAt: expect.any(Date),
|
||||||
|
updatedBy: "actor-user",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The applied update is visible on the next read of the run.
|
||||||
|
expect(ctx.getStored()?.status).toBe(EPayrollRunStatus.CALCULATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("degrades to basic-plus-statutory only when an employee has no salary structure", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.CALCULATED })); // recalculation is allowed while CALCULATED
|
||||||
|
|
||||||
|
ctx.salaries.findPayableOn.mockResolvedValue([
|
||||||
|
{ employeeId: "emp-2", basicSalary: "8000", salaryStructureId: null },
|
||||||
|
]);
|
||||||
|
ctx.components.findActive.mockResolvedValue([
|
||||||
|
activeComponent({ id: "c-basic", code: RESERVED_CODES.basic, componentType: EComponentType.BASIC }),
|
||||||
|
activeComponent({
|
||||||
|
id: "c-tax",
|
||||||
|
code: RESERVED_CODES.incomeTax,
|
||||||
|
componentType: EComponentType.DEDUCTION,
|
||||||
|
}),
|
||||||
|
activeComponent({
|
||||||
|
id: "c-pe",
|
||||||
|
code: RESERVED_CODES.pensionEmployee,
|
||||||
|
componentType: EComponentType.DEDUCTION,
|
||||||
|
}),
|
||||||
|
activeComponent({
|
||||||
|
id: "c-per",
|
||||||
|
code: RESERVED_CODES.pensionEmployer,
|
||||||
|
componentType: EComponentType.EMPLOYER_CONTRIBUTION,
|
||||||
|
}),
|
||||||
|
// Must be excluded: an ordinary allowance is neither BASIC nor one of the
|
||||||
|
// three reserved statutory codes.
|
||||||
|
activeComponent({ id: "c-transport", code: "TRANSPORT", componentType: EComponentType.ALLOWANCE }),
|
||||||
|
]);
|
||||||
|
ctx.employees.findByEmployeeId.mockResolvedValue({
|
||||||
|
employeeId: "emp-2",
|
||||||
|
employeeNumber: "E-emp-2",
|
||||||
|
isPensionEligible: true,
|
||||||
|
salaryMode: "BANK",
|
||||||
|
bankAccountNumber: null,
|
||||||
|
});
|
||||||
|
ctx.calculator.calculate.mockReturnValue(calcResult({ basicSalary: 8000, grossPay: 8000 }));
|
||||||
|
|
||||||
|
await ctx.service.calculate("run-1", actor());
|
||||||
|
|
||||||
|
const passedInput = ctx.calculator.calculate.mock.calls[0][0];
|
||||||
|
const codes = passedInput.components.map((c: { component: { code: string } }) => c.component.code);
|
||||||
|
expect(codes).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
RESERVED_CODES.basic,
|
||||||
|
RESERVED_CODES.incomeTax,
|
||||||
|
RESERVED_CODES.pensionEmployee,
|
||||||
|
RESERVED_CODES.pensionEmployer,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(codes).not.toContain("TRANSPORT");
|
||||||
|
expect(passedInput.components.every((c: { amount: unknown; rate: unknown }) => c.amount === null && c.rate === null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips an employee with no HR profile — no payslip is written and employeeCount reflects only the priced employees", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.DRAFT }));
|
||||||
|
ctx.salaries.findPayableOn.mockResolvedValue([
|
||||||
|
{ employeeId: "emp-1", basicSalary: "10000", salaryStructureId: null },
|
||||||
|
{ employeeId: "emp-ghost", basicSalary: "5000", salaryStructureId: null },
|
||||||
|
]);
|
||||||
|
ctx.components.findActive.mockResolvedValue([activeComponent()]);
|
||||||
|
ctx.employees.findByEmployeeId.mockImplementation(async (employeeId: string) =>
|
||||||
|
employeeId === "emp-1"
|
||||||
|
? { employeeId, employeeNumber: "E-1", isPensionEligible: true, salaryMode: "BANK" }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
ctx.calculator.calculate.mockReturnValue(calcResult());
|
||||||
|
|
||||||
|
await ctx.service.calculate("run-1", actor());
|
||||||
|
|
||||||
|
expect(ctx.calculator.calculate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.manager.payslipRepo.save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.manager.runRepo.update).toHaveBeenCalledWith(
|
||||||
|
"run-1",
|
||||||
|
expect.objectContaining({ employeeCount: 1 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the first row per employee when findPayableOn returns overlapping history", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.DRAFT }));
|
||||||
|
ctx.salaries.findPayableOn.mockResolvedValue([
|
||||||
|
{ employeeId: "emp-1", basicSalary: "12000", salaryStructureId: null }, // newest — first in array
|
||||||
|
{ employeeId: "emp-1", basicSalary: "9000", salaryStructureId: null }, // stale, must be ignored
|
||||||
|
]);
|
||||||
|
ctx.components.findActive.mockResolvedValue([activeComponent()]);
|
||||||
|
ctx.employees.findByEmployeeId.mockResolvedValue({
|
||||||
|
employeeId: "emp-1",
|
||||||
|
employeeNumber: "E-1",
|
||||||
|
isPensionEligible: true,
|
||||||
|
salaryMode: "BANK",
|
||||||
|
});
|
||||||
|
ctx.calculator.calculate.mockReturnValue(calcResult());
|
||||||
|
|
||||||
|
await ctx.service.calculate("run-1", actor());
|
||||||
|
|
||||||
|
expect(ctx.calculator.calculate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(ctx.calculator.calculate.mock.calls[0][0].basicSalary).toBe(12000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes absenceDeductionRate = absentDays / periodWorkingDays (HALF_DAY counts as 0.5 absent)", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.DRAFT }));
|
||||||
|
ctx.workingDays.countWorkingDays.mockResolvedValue(20);
|
||||||
|
ctx.salaries.findPayableOn.mockResolvedValue([
|
||||||
|
{ employeeId: "emp-1", basicSalary: "10000", salaryStructureId: null },
|
||||||
|
]);
|
||||||
|
ctx.components.findActive.mockResolvedValue([activeComponent()]);
|
||||||
|
ctx.employees.findByEmployeeId.mockResolvedValue({
|
||||||
|
employeeId: "emp-1",
|
||||||
|
employeeNumber: "E-1",
|
||||||
|
isPensionEligible: true,
|
||||||
|
salaryMode: "BANK",
|
||||||
|
});
|
||||||
|
ctx.attendance.findRange.mockResolvedValue([
|
||||||
|
{ status: "ABSENT" },
|
||||||
|
{ status: "ABSENT" },
|
||||||
|
{ status: "HALF_DAY" },
|
||||||
|
{ status: "PRESENT" },
|
||||||
|
{ status: "LATE" },
|
||||||
|
]);
|
||||||
|
ctx.calculator.calculate.mockReturnValue(calcResult());
|
||||||
|
|
||||||
|
await ctx.service.calculate("run-1", actor());
|
||||||
|
|
||||||
|
// absentDays = 2 + 0.5 = 2.5; rate = 2.5 / 20 = 0.125
|
||||||
|
expect(ctx.calculator.calculate.mock.calls[0][0].absenceDeductionRate).toBeCloseTo(0.125);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards against division by zero — zero working days in the period degrades to no absence deduction", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.DRAFT }));
|
||||||
|
ctx.workingDays.countWorkingDays.mockResolvedValue(0);
|
||||||
|
ctx.salaries.findPayableOn.mockResolvedValue([
|
||||||
|
{ employeeId: "emp-1", basicSalary: "10000", salaryStructureId: null },
|
||||||
|
]);
|
||||||
|
ctx.components.findActive.mockResolvedValue([activeComponent()]);
|
||||||
|
ctx.employees.findByEmployeeId.mockResolvedValue({
|
||||||
|
employeeId: "emp-1",
|
||||||
|
employeeNumber: "E-1",
|
||||||
|
isPensionEligible: true,
|
||||||
|
salaryMode: "BANK",
|
||||||
|
});
|
||||||
|
ctx.attendance.findRange.mockResolvedValue([{ status: "ABSENT" }, { status: "ABSENT" }]);
|
||||||
|
ctx.calculator.calculate.mockReturnValue(calcResult());
|
||||||
|
|
||||||
|
await ctx.service.calculate("run-1", actor());
|
||||||
|
|
||||||
|
expect(ctx.calculator.calculate.mock.calls[0][0].absenceDeductionRate).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayrollRunsService.approve", () => {
|
||||||
|
it("signs off a CALCULATED, non-empty run, attributing it to the actor's employee id", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.CALCULATED, employeeCount: 3 }));
|
||||||
|
|
||||||
|
await ctx.service.approve("run-1", actor({ employeeId: "approver-1", userId: "u-1" }));
|
||||||
|
|
||||||
|
expect(ctx.runs.update).toHaveBeenCalledWith(
|
||||||
|
"run-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
status: EPayrollRunStatus.APPROVED,
|
||||||
|
approvedByEmployeeId: "approver-1",
|
||||||
|
approvedAt: expect.any(Date),
|
||||||
|
updatedBy: "u-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(ctx.getStored()?.status).toBe(EPayrollRunStatus.APPROVED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a run that is not CALCULATED", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.DRAFT, employeeCount: 3 }));
|
||||||
|
|
||||||
|
await expect(ctx.service.approve("run-1", actor())).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(ctx.runs.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses an empty run — approving it would record that nobody was paid", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.CALCULATED, employeeCount: 0 }));
|
||||||
|
|
||||||
|
await expect(ctx.service.approve("run-1", actor())).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(ctx.runs.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when the actor has no employeeId — approval must be attributable", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.CALCULATED, employeeCount: 2 }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ctx.service.approve("run-1", actor({ employeeId: null })),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(ctx.runs.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to recalculate a run once it has been approved", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.CALCULATED, employeeCount: 1 }));
|
||||||
|
|
||||||
|
await ctx.service.approve("run-1", actor());
|
||||||
|
expect(ctx.getStored()?.status).toBe(EPayrollRunStatus.APPROVED);
|
||||||
|
|
||||||
|
await expect(ctx.service.calculate("run-1", actor())).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
// No transaction should even have been opened for the refused recalculation attempt.
|
||||||
|
expect(ctx.dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayrollRunsService.markPaid", () => {
|
||||||
|
it("moves an APPROVED run to PAID with an explicit payment date", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.APPROVED }));
|
||||||
|
|
||||||
|
await ctx.service.markPaid("run-1", "2026-08-05", actor({ userId: "u-2" }));
|
||||||
|
|
||||||
|
expect(ctx.runs.update).toHaveBeenCalledWith("run-1", {
|
||||||
|
status: EPayrollRunStatus.PAID,
|
||||||
|
paymentDate: "2026-08-05",
|
||||||
|
updatedBy: "u-2",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults the payment date to today when none is given", async () => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status: EPayrollRunStatus.APPROVED }));
|
||||||
|
|
||||||
|
await ctx.service.markPaid("run-1", undefined, actor());
|
||||||
|
|
||||||
|
const patch = ctx.runs.update.mock.calls[0][1];
|
||||||
|
expect(patch.paymentDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([EPayrollRunStatus.DRAFT, EPayrollRunStatus.CALCULATED, EPayrollRunStatus.PAID])(
|
||||||
|
"refuses when the run is %s, not APPROVED",
|
||||||
|
async (status) => {
|
||||||
|
const ctx = build();
|
||||||
|
ctx.use(run({ status }));
|
||||||
|
|
||||||
|
await expect(ctx.service.markPaid("run-1", undefined, actor())).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(ctx.runs.update).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PayrollRunsService.payslipsForEmployee", () => {
|
||||||
|
/**
|
||||||
|
* A query-builder stand-in that actually filters a fixture set the way the
|
||||||
|
* real SQL would — this is what proves a DRAFT/CALCULATED run's payslip row
|
||||||
|
* stays invisible even though the row exists, not merely that the right
|
||||||
|
* arguments were passed to `.andWhere`.
|
||||||
|
*/
|
||||||
|
function makeFilteringPayslipsRepo(
|
||||||
|
rows: { employeeId: string; payrollRun: { status: EPayrollRunStatus; periodStart: string } }[],
|
||||||
|
) {
|
||||||
|
interface FilteringQueryBuilder {
|
||||||
|
innerJoinAndSelect: jest.Mock;
|
||||||
|
where: jest.Mock;
|
||||||
|
andWhere: jest.Mock;
|
||||||
|
orderBy: jest.Mock;
|
||||||
|
getMany: jest.Mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createQueryBuilder = jest.fn(() => {
|
||||||
|
let employeeId: string | undefined;
|
||||||
|
let statuses: EPayrollRunStatus[] = [];
|
||||||
|
const qb: FilteringQueryBuilder = {
|
||||||
|
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn((_sql: string, params: Record<string, unknown>) => {
|
||||||
|
employeeId = params.employeeId as string;
|
||||||
|
return qb;
|
||||||
|
}),
|
||||||
|
andWhere: jest.fn((_sql: string, params: Record<string, unknown>) => {
|
||||||
|
if (params.statuses) statuses = params.statuses as EPayrollRunStatus[];
|
||||||
|
return qb;
|
||||||
|
}),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn(async () =>
|
||||||
|
rows.filter(
|
||||||
|
(r) => r.employeeId === employeeId && statuses.includes(r.payrollRun.status),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return qb;
|
||||||
|
});
|
||||||
|
return { createQueryBuilder };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns only payslips whose parent run is APPROVED or PAID — a DRAFT/CALCULATED row stays invisible", async () => {
|
||||||
|
const rows = [
|
||||||
|
{ employeeId: "emp-1", payrollRun: { status: EPayrollRunStatus.DRAFT, periodStart: "2026-05-01" } },
|
||||||
|
{ employeeId: "emp-1", payrollRun: { status: EPayrollRunStatus.CALCULATED, periodStart: "2026-06-01" } },
|
||||||
|
{ employeeId: "emp-1", payrollRun: { status: EPayrollRunStatus.APPROVED, periodStart: "2026-07-01" } },
|
||||||
|
{ employeeId: "emp-1", payrollRun: { status: EPayrollRunStatus.PAID, periodStart: "2026-04-01" } },
|
||||||
|
// A different employee's APPROVED payslip must never leak into emp-1's list.
|
||||||
|
{ employeeId: "emp-2", payrollRun: { status: EPayrollRunStatus.APPROVED, periodStart: "2026-07-01" } },
|
||||||
|
];
|
||||||
|
const ctx = build();
|
||||||
|
ctx.payslips.createQueryBuilder = makeFilteringPayslipsRepo(rows as never).createQueryBuilder;
|
||||||
|
|
||||||
|
const result = await ctx.service.payslipsForEmployee("emp-1");
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
result.every((r) =>
|
||||||
|
[EPayrollRunStatus.APPROVED, EPayrollRunStatus.PAID].includes(
|
||||||
|
(r as unknown as { payrollRun: { status: EPayrollRunStatus } }).payrollRun.status,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(result.every((r) => (r as unknown as { employeeId: string }).employeeId === "emp-1")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing for an employee whose only runs are still DRAFT or CALCULATED", async () => {
|
||||||
|
const rows = [
|
||||||
|
{ employeeId: "emp-3", payrollRun: { status: EPayrollRunStatus.DRAFT, periodStart: "2026-05-01" } },
|
||||||
|
{ employeeId: "emp-3", payrollRun: { status: EPayrollRunStatus.CALCULATED, periodStart: "2026-06-01" } },
|
||||||
|
];
|
||||||
|
const ctx = build();
|
||||||
|
ctx.payslips.createQueryBuilder = makeFilteringPayslipsRepo(rows as never).createQueryBuilder;
|
||||||
|
|
||||||
|
const result = await ctx.service.payslipsForEmployee("emp-3");
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { RecruitmentService } from "./recruitment.service";
|
||||||
|
import { ActorContext } from "../../employees/employees.service";
|
||||||
|
import {
|
||||||
|
Applicant,
|
||||||
|
Application,
|
||||||
|
EApplicationStage,
|
||||||
|
EOfferStatus,
|
||||||
|
EOpeningStatus,
|
||||||
|
JobOffer,
|
||||||
|
JobOpening,
|
||||||
|
} from "../entities/recruitment.entity";
|
||||||
|
|
||||||
|
/** Tracks what a transaction wrote per-entity, keyed by the entity class name. */
|
||||||
|
function makeManager() {
|
||||||
|
const calls: {
|
||||||
|
offerUpdates: { id: string; data: Record<string, unknown> }[];
|
||||||
|
applicationUpdates: { id: string; data: Record<string, unknown> }[];
|
||||||
|
openingUpdates: { id: string; data: Record<string, unknown> }[];
|
||||||
|
} = { offerUpdates: [], applicationUpdates: [], openingUpdates: [] };
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
getRepository: (entity: { name: string }) => {
|
||||||
|
const name = entity.name;
|
||||||
|
return {
|
||||||
|
update: jest.fn(async (id: string, data: Record<string, unknown>) => {
|
||||||
|
if (name === "JobOffer") calls.offerUpdates.push({ id, data });
|
||||||
|
if (name === "Application") calls.applicationUpdates.push({ id, data });
|
||||||
|
if (name === "JobOpening") calls.openingUpdates.push({ id, data });
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { manager, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorFixture = (over: Partial<ActorContext> = {}): ActorContext => ({
|
||||||
|
employeeId: "staff-1",
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
isSuperAdmin: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const applicationFixture = (over: Partial<Application> = {}): Application =>
|
||||||
|
({
|
||||||
|
id: "app-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
jobOpeningId: "opening-1",
|
||||||
|
applicantId: "applicant-1",
|
||||||
|
stage: EApplicationStage.APPLIED,
|
||||||
|
appliedOn: "2026-01-01",
|
||||||
|
...over,
|
||||||
|
}) as Application;
|
||||||
|
|
||||||
|
const openingFixture = (over: Partial<JobOpening> = {}): JobOpening =>
|
||||||
|
({
|
||||||
|
id: "opening-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
reference: "VAC-1",
|
||||||
|
title: { am: "", en: "Engineer" },
|
||||||
|
positionId: "position-1",
|
||||||
|
jobTitleId: "job-title-1",
|
||||||
|
employmentType: "PERMANENT",
|
||||||
|
openings: 1,
|
||||||
|
filled: 0,
|
||||||
|
status: EOpeningStatus.OPEN,
|
||||||
|
...over,
|
||||||
|
}) as JobOpening;
|
||||||
|
|
||||||
|
const applicantFixture = (over: Partial<Applicant> = {}): Applicant =>
|
||||||
|
({
|
||||||
|
id: "applicant-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
fullName: { am: "", en: "Jane Doe" },
|
||||||
|
email: "jane@example.com",
|
||||||
|
phoneNumber: "0911000000",
|
||||||
|
source: "DIRECT",
|
||||||
|
...over,
|
||||||
|
}) as Applicant;
|
||||||
|
|
||||||
|
const offerFixture = (over: Partial<JobOffer> = {}): JobOffer =>
|
||||||
|
({
|
||||||
|
id: "offer-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
applicationId: "app-1",
|
||||||
|
offeredBasicSalary: "12000.00",
|
||||||
|
employmentType: "PERMANENT",
|
||||||
|
proposedStartDate: "2026-03-01",
|
||||||
|
status: EOfferStatus.ACCEPTED,
|
||||||
|
hiredEmployeeId: null,
|
||||||
|
...over,
|
||||||
|
}) as JobOffer;
|
||||||
|
|
||||||
|
describe("RecruitmentService", () => {
|
||||||
|
let dataSource: { transaction: jest.Mock };
|
||||||
|
let openings: { findOne: jest.Mock };
|
||||||
|
let applicants: { findOne: jest.Mock };
|
||||||
|
let applications: { findOne: jest.Mock; update: jest.Mock };
|
||||||
|
let interviews: { findOne: jest.Mock; count: jest.Mock };
|
||||||
|
let offers: { findOne: jest.Mock };
|
||||||
|
let iamDirectory: Record<string, jest.Mock>;
|
||||||
|
let orgExplorer: { hireIntoPosition: jest.Mock };
|
||||||
|
let payrollConfig: { assignSalary: jest.Mock };
|
||||||
|
let service: RecruitmentService;
|
||||||
|
let managerCalls: ReturnType<typeof makeManager>["calls"];
|
||||||
|
let storedApplication: Application;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const built = makeManager();
|
||||||
|
managerCalls = built.calls;
|
||||||
|
|
||||||
|
dataSource = {
|
||||||
|
transaction: jest.fn(async (cb: (m: unknown) => unknown) => cb(built.manager)),
|
||||||
|
};
|
||||||
|
openings = { findOne: jest.fn().mockResolvedValue(openingFixture()) };
|
||||||
|
applicants = { findOne: jest.fn().mockResolvedValue(applicantFixture()) };
|
||||||
|
|
||||||
|
storedApplication = applicationFixture();
|
||||||
|
applications = {
|
||||||
|
findOne: jest.fn().mockImplementation(async () => storedApplication),
|
||||||
|
update: jest.fn().mockImplementation(async (_id: string, patch: Record<string, unknown>) => {
|
||||||
|
storedApplication = { ...storedApplication, ...patch } as Application;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
interviews = { findOne: jest.fn(), count: jest.fn().mockResolvedValue(0) };
|
||||||
|
offers = { findOne: jest.fn().mockResolvedValue(offerFixture()) };
|
||||||
|
iamDirectory = { findPosition: jest.fn() };
|
||||||
|
orgExplorer = {
|
||||||
|
hireIntoPosition: jest.fn().mockResolvedValue({
|
||||||
|
employeeId: "emp-99",
|
||||||
|
positionId: "position-1",
|
||||||
|
profile: { id: "profile-99" },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
payrollConfig = { assignSalary: jest.fn().mockResolvedValue({ id: "salary-1" }) };
|
||||||
|
|
||||||
|
service = new RecruitmentService(
|
||||||
|
dataSource as never,
|
||||||
|
openings as never,
|
||||||
|
applicants as never,
|
||||||
|
applications as never,
|
||||||
|
interviews as never,
|
||||||
|
offers as never,
|
||||||
|
iamDirectory as never,
|
||||||
|
orgExplorer as never,
|
||||||
|
payrollConfig as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── moveStage — the STAGE_TRANSITIONS state machine ───────────────────────
|
||||||
|
|
||||||
|
describe("moveStage", () => {
|
||||||
|
const validTransitions: [EApplicationStage, EApplicationStage][] = [
|
||||||
|
[EApplicationStage.APPLIED, EApplicationStage.SCREENING],
|
||||||
|
[EApplicationStage.SCREENING, EApplicationStage.SHORTLISTED],
|
||||||
|
[EApplicationStage.SHORTLISTED, EApplicationStage.INTERVIEW],
|
||||||
|
[EApplicationStage.SHORTLISTED, EApplicationStage.OFFER],
|
||||||
|
[EApplicationStage.INTERVIEW, EApplicationStage.OFFER],
|
||||||
|
[EApplicationStage.OFFER, EApplicationStage.HIRED],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(validTransitions)("allows %s → %s", async (from, to) => {
|
||||||
|
storedApplication = applicationFixture({ stage: from });
|
||||||
|
await expect(
|
||||||
|
service.moveStage("app-1", to, {}, actorFixture()),
|
||||||
|
).resolves.toMatchObject({ stage: to });
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectableFrom: EApplicationStage[] = [
|
||||||
|
EApplicationStage.APPLIED,
|
||||||
|
EApplicationStage.SCREENING,
|
||||||
|
EApplicationStage.SHORTLISTED,
|
||||||
|
EApplicationStage.INTERVIEW,
|
||||||
|
EApplicationStage.OFFER,
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(rejectableFrom)("allows REJECTED from %s (with a reason)", async (from) => {
|
||||||
|
storedApplication = applicationFixture({ stage: from });
|
||||||
|
await expect(
|
||||||
|
service.moveStage(
|
||||||
|
"app-1",
|
||||||
|
EApplicationStage.REJECTED,
|
||||||
|
{ reason: "Not a fit" },
|
||||||
|
actorFixture(),
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ stage: EApplicationStage.REJECTED });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(rejectableFrom)("allows WITHDRAWN from %s", async (from) => {
|
||||||
|
storedApplication = applicationFixture({ stage: from });
|
||||||
|
await expect(
|
||||||
|
service.moveStage("app-1", EApplicationStage.WITHDRAWN, {}, actorFixture()),
|
||||||
|
).resolves.toMatchObject({ stage: EApplicationStage.WITHDRAWN });
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidTransitions: [EApplicationStage, EApplicationStage][] = [
|
||||||
|
[EApplicationStage.APPLIED, EApplicationStage.SHORTLISTED],
|
||||||
|
[EApplicationStage.APPLIED, EApplicationStage.INTERVIEW],
|
||||||
|
[EApplicationStage.APPLIED, EApplicationStage.OFFER],
|
||||||
|
[EApplicationStage.APPLIED, EApplicationStage.HIRED],
|
||||||
|
[EApplicationStage.SCREENING, EApplicationStage.INTERVIEW],
|
||||||
|
[EApplicationStage.SCREENING, EApplicationStage.HIRED],
|
||||||
|
[EApplicationStage.SHORTLISTED, EApplicationStage.SCREENING],
|
||||||
|
[EApplicationStage.INTERVIEW, EApplicationStage.SCREENING],
|
||||||
|
[EApplicationStage.OFFER, EApplicationStage.INTERVIEW],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(invalidTransitions)("refuses %s → %s", async (from, to) => {
|
||||||
|
storedApplication = applicationFixture({ stage: from });
|
||||||
|
await expect(
|
||||||
|
service.moveStage("app-1", to, {}, actorFixture()),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([EApplicationStage.HIRED, EApplicationStage.REJECTED, EApplicationStage.WITHDRAWN])(
|
||||||
|
"refuses any move out of the terminal stage %s",
|
||||||
|
async (from) => {
|
||||||
|
storedApplication = applicationFixture({ stage: from });
|
||||||
|
await expect(
|
||||||
|
service.moveStage(
|
||||||
|
"app-1",
|
||||||
|
EApplicationStage.SCREENING,
|
||||||
|
{},
|
||||||
|
actorFixture(),
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("requires a reason to move to REJECTED", async () => {
|
||||||
|
storedApplication = applicationFixture({ stage: EApplicationStage.SCREENING });
|
||||||
|
await expect(
|
||||||
|
service.moveStage("app-1", EApplicationStage.REJECTED, {}, actorFixture()),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records the rejection reason when one is given", async () => {
|
||||||
|
storedApplication = applicationFixture({ stage: EApplicationStage.SCREENING });
|
||||||
|
await service.moveStage(
|
||||||
|
"app-1",
|
||||||
|
EApplicationStage.REJECTED,
|
||||||
|
{ reason: "Salary expectations too high" },
|
||||||
|
actorFixture(),
|
||||||
|
);
|
||||||
|
expect(applications.update).toHaveBeenCalledWith(
|
||||||
|
"app-1",
|
||||||
|
expect.objectContaining({ rejectionReason: "Salary expectations too high" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── hireFromOffer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("hireFromOffer", () => {
|
||||||
|
const hireDto = { username: "jane.doe", email: "jane@example.com" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storedApplication = applicationFixture({ stage: EApplicationStage.OFFER });
|
||||||
|
applications.findOne.mockImplementation(async () => ({
|
||||||
|
...storedApplication,
|
||||||
|
applicant: applicantFixture(),
|
||||||
|
jobOpening: openingFixture(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only turns an ACCEPTED offer into a hire", async () => {
|
||||||
|
offers.findOne.mockResolvedValue(offerFixture({ status: EOfferStatus.SENT }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(orgExplorer.hireIntoPosition).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a SECOND hire off the same offer with a 409 — the re-hire guard", async () => {
|
||||||
|
offers.findOne.mockResolvedValue(
|
||||||
|
offerFixture({ status: EOfferStatus.ACCEPTED, hiredEmployeeId: "emp-already-hired" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(orgExplorer.hireIntoPosition).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates to OrgExplorerService.hireIntoPosition with the offer/opening/applicant details", async () => {
|
||||||
|
await service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never);
|
||||||
|
|
||||||
|
expect(orgExplorer.hireIntoPosition).toHaveBeenCalledWith(
|
||||||
|
"position-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
name: { am: "", en: "Jane Doe" },
|
||||||
|
username: "jane.doe",
|
||||||
|
email: "jane@example.com",
|
||||||
|
employmentType: "PERMANENT",
|
||||||
|
hireDate: "2026-03-01",
|
||||||
|
jobTitleId: "job-title-1",
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns salary AFTER and OUTSIDE the hire transaction, swallowing a failure without failing the hire", async () => {
|
||||||
|
payrollConfig.assignSalary.mockRejectedValue(new Error("payroll unreachable"));
|
||||||
|
|
||||||
|
const result = await service.hireFromOffer(
|
||||||
|
"offer-1",
|
||||||
|
hireDto,
|
||||||
|
actorFixture(),
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The hire itself succeeded despite the salary assignment failing.
|
||||||
|
expect(result.employeeId).toBe("emp-99");
|
||||||
|
expect(payrollConfig.assignSalary).toHaveBeenCalledTimes(1);
|
||||||
|
// The final bookkeeping transaction still ran to completion.
|
||||||
|
expect(managerCalls.offerUpdates).toHaveLength(1);
|
||||||
|
expect(managerCalls.offerUpdates[0].data.hiredEmployeeId).toBe("emp-99");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates offer, application and opening's filled count together in the final transaction", async () => {
|
||||||
|
await service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never);
|
||||||
|
|
||||||
|
expect(managerCalls.offerUpdates).toEqual([
|
||||||
|
{ id: "offer-1", data: expect.objectContaining({ hiredEmployeeId: "emp-99" }) },
|
||||||
|
]);
|
||||||
|
expect(managerCalls.applicationUpdates).toEqual([
|
||||||
|
{ id: "app-1", data: expect.objectContaining({ stage: EApplicationStage.HIRED }) },
|
||||||
|
]);
|
||||||
|
expect(managerCalls.openingUpdates).toEqual([
|
||||||
|
{
|
||||||
|
id: "opening-1",
|
||||||
|
data: expect.objectContaining({ filled: 1, status: EOpeningStatus.FILLED }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the opening OPEN when there are still unfilled seats", async () => {
|
||||||
|
applications.findOne.mockImplementation(async () => ({
|
||||||
|
...storedApplication,
|
||||||
|
applicant: applicantFixture(),
|
||||||
|
jobOpening: openingFixture({ openings: 3, filled: 0, status: EOpeningStatus.OPEN }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never);
|
||||||
|
|
||||||
|
expect(managerCalls.openingUpdates[0].data).toMatchObject({
|
||||||
|
filled: 1,
|
||||||
|
status: EOpeningStatus.OPEN,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { ReportsService } from "./reports.service";
|
||||||
|
import { ActorContext } from "../../employees/employees.service";
|
||||||
|
|
||||||
|
const actorFixture = (over: Partial<ActorContext> = {}): ActorContext => ({
|
||||||
|
employeeId: null,
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
isSuperAdmin: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ReportsService.leaveLiability", () => {
|
||||||
|
let dataSource: { query: jest.Mock };
|
||||||
|
let service: ReportsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
dataSource = { query: jest.fn().mockResolvedValue([]) };
|
||||||
|
service = new ReportsService(dataSource as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("values only leave types where is_paid=true AND max_carry_over_days>0 — excluding sick/bereavement/marriage/paternity-style entitlements", async () => {
|
||||||
|
await service.leaveLiability(actorFixture(), "2026-06-30");
|
||||||
|
|
||||||
|
const [sql] = dataSource.query.mock.calls[0];
|
||||||
|
expect(sql).toContain('t."is_paid" = true');
|
||||||
|
expect(sql).toContain('t."max_carry_over_days" > 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes the daily rate from the current effective basic salary via LEFT JOIN LATERAL, as of the given date", async () => {
|
||||||
|
await service.leaveLiability(actorFixture(), "2026-06-30");
|
||||||
|
|
||||||
|
const [sql] = dataSource.query.mock.calls[0];
|
||||||
|
expect(sql).toContain("LEFT JOIN LATERAL");
|
||||||
|
expect(sql).toContain('es."effective_from" <= $2::date');
|
||||||
|
expect(sql).toContain(
|
||||||
|
'(es."effective_to" IS NULL OR es."effective_to" >= $2::date)',
|
||||||
|
);
|
||||||
|
expect(sql).toContain('ORDER BY es."effective_from" DESC');
|
||||||
|
expect(sql).toContain("/ 30.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes TERMINATED/RETIRED employees", async () => {
|
||||||
|
await service.leaveLiability(actorFixture(), "2026-06-30");
|
||||||
|
|
||||||
|
const [sql] = dataSource.query.mock.calls[0];
|
||||||
|
expect(sql).toContain("NOT IN ('TERMINATED','RETIRED')");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops zero balances via HAVING", async () => {
|
||||||
|
await service.leaveLiability(actorFixture(), "2026-06-30");
|
||||||
|
|
||||||
|
const [sql] = dataSource.query.mock.calls[0];
|
||||||
|
expect(sql).toContain('HAVING COALESCE(SUM(l."days"), 0) > 0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes [organizationId, asOf] as params when asOf is given", async () => {
|
||||||
|
await service.leaveLiability(actorFixture({ organizationId: "org-1" }), "2026-06-30");
|
||||||
|
|
||||||
|
const [, params] = dataSource.query.mock.calls[0];
|
||||||
|
expect(params).toEqual(["org-1", "2026-06-30"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults asOf to today when omitted", async () => {
|
||||||
|
await service.leaveLiability(actorFixture());
|
||||||
|
|
||||||
|
const [, params] = dataSource.query.mock.calls[0];
|
||||||
|
expect(params[0]).toBe("org-1");
|
||||||
|
expect(params[1]).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scopes by organization for a normal actor", async () => {
|
||||||
|
await service.leaveLiability(actorFixture({ organizationId: "org-1", isSuperAdmin: false }));
|
||||||
|
|
||||||
|
const [, params] = dataSource.query.mock.calls[0];
|
||||||
|
expect(params[0]).toBe("org-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes a null org scope for a super admin — no organization filter", async () => {
|
||||||
|
await service.leaveLiability(actorFixture({ isSuperAdmin: true }));
|
||||||
|
|
||||||
|
const [, params] = dataSource.query.mock.calls[0];
|
||||||
|
expect(params[0]).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns whatever the query resolves", async () => {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
employeeId: "emp-1",
|
||||||
|
employeeNumber: "EMP-00001",
|
||||||
|
leaveTypeCode: "ANNUAL",
|
||||||
|
balanceDays: "12",
|
||||||
|
dailyRate: "500.00",
|
||||||
|
liability: "6000.00",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
dataSource.query.mockResolvedValue(rows);
|
||||||
|
|
||||||
|
const result = await service.leaveLiability(actorFixture(), "2026-06-30");
|
||||||
|
expect(result).toEqual(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -58,7 +58,7 @@ export class ReportsService {
|
|||||||
* two.
|
* two.
|
||||||
*/
|
*/
|
||||||
async headcount(actor: ActorContext): Promise<HeadcountRow[]> {
|
async headcount(actor: ActorContext): Promise<HeadcountRow[]> {
|
||||||
const scope = orgScope(actor) ?? actor.organizationId ?? null;
|
const scope = orgScope(actor);
|
||||||
return this.dataSource.query<HeadcountRow[]>(
|
return this.dataSource.query<HeadcountRow[]>(
|
||||||
`SELECT e."unit_id" AS "unitId",
|
`SELECT e."unit_id" AS "unitId",
|
||||||
u."name" AS "unitName",
|
u."name" AS "unitName",
|
||||||
@@ -104,7 +104,7 @@ export class ReportsService {
|
|||||||
turnoverRate: number;
|
turnoverRate: number;
|
||||||
}> {
|
}> {
|
||||||
ReportsService.assertRange(from, to);
|
ReportsService.assertRange(from, to);
|
||||||
const scope = orgScope(actor) ?? actor.organizationId ?? null;
|
const scope = orgScope(actor);
|
||||||
|
|
||||||
const [row] = await this.dataSource.query<
|
const [row] = await this.dataSource.query<
|
||||||
{
|
{
|
||||||
@@ -155,7 +155,7 @@ export class ReportsService {
|
|||||||
payrollRunId: string,
|
payrollRunId: string,
|
||||||
actor: ActorContext,
|
actor: ActorContext,
|
||||||
): Promise<PayrollRegisterRow[]> {
|
): Promise<PayrollRegisterRow[]> {
|
||||||
const scope = orgScope(actor) ?? actor.organizationId ?? null;
|
const scope = orgScope(actor);
|
||||||
return this.dataSource.query<PayrollRegisterRow[]>(
|
return this.dataSource.query<PayrollRegisterRow[]>(
|
||||||
`SELECT s."employee_id" AS "employeeId",
|
`SELECT s."employee_id" AS "employeeId",
|
||||||
s."employee_number" AS "employeeNumber",
|
s."employee_number" AS "employeeNumber",
|
||||||
@@ -198,7 +198,7 @@ export class ReportsService {
|
|||||||
}[]
|
}[]
|
||||||
> {
|
> {
|
||||||
ReportsService.assertRange(from, to);
|
ReportsService.assertRange(from, to);
|
||||||
const scope = orgScope(actor) ?? actor.organizationId ?? null;
|
const scope = orgScope(actor);
|
||||||
return this.dataSource.query(
|
return this.dataSource.query(
|
||||||
`SELECT s."employee_id" AS "employeeId",
|
`SELECT s."employee_id" AS "employeeId",
|
||||||
MAX(s."employee_number") AS "employeeNumber",
|
MAX(s."employee_number") AS "employeeNumber",
|
||||||
@@ -234,7 +234,7 @@ export class ReportsService {
|
|||||||
}[]
|
}[]
|
||||||
> {
|
> {
|
||||||
ReportsService.assertRange(from, to);
|
ReportsService.assertRange(from, to);
|
||||||
const scope = orgScope(actor) ?? actor.organizationId ?? null;
|
const scope = orgScope(actor);
|
||||||
return this.dataSource.query(
|
return this.dataSource.query(
|
||||||
`SELECT s."employee_id" AS "employeeId",
|
`SELECT s."employee_id" AS "employeeId",
|
||||||
MAX(s."employee_number") AS "employeeNumber",
|
MAX(s."employee_number") AS "employeeNumber",
|
||||||
@@ -289,7 +289,7 @@ export class ReportsService {
|
|||||||
liability: string;
|
liability: string;
|
||||||
}[]
|
}[]
|
||||||
> {
|
> {
|
||||||
const scope = orgScope(actor) ?? actor.organizationId ?? null;
|
const scope = orgScope(actor);
|
||||||
const on = asOf ?? new Date().toISOString().slice(0, 10);
|
const on = asOf ?? new Date().toISOString().slice(0, 10);
|
||||||
return this.dataSource.query(
|
return this.dataSource.query(
|
||||||
`SELECT ent."employee_id" AS "employeeId",
|
`SELECT ent."employee_id" AS "employeeId",
|
||||||
@@ -345,7 +345,7 @@ export class ReportsService {
|
|||||||
}[]
|
}[]
|
||||||
> {
|
> {
|
||||||
ReportsService.assertRange(from, to);
|
ReportsService.assertRange(from, to);
|
||||||
const scope = orgScope(actor) ?? actor.organizationId ?? null;
|
const scope = orgScope(actor);
|
||||||
return this.dataSource.query(
|
return this.dataSource.query(
|
||||||
`SELECT e."unit_id" AS "unitId",
|
`SELECT e."unit_id" AS "unitId",
|
||||||
u."name" AS "unitName",
|
u."name" AS "unitName",
|
||||||
@@ -376,7 +376,7 @@ export class ReportsService {
|
|||||||
{ leaveTypeCode: string; requests: number; days: string; employees: number }[]
|
{ leaveTypeCode: string; requests: number; days: string; employees: number }[]
|
||||||
> {
|
> {
|
||||||
ReportsService.assertRange(from, to);
|
ReportsService.assertRange(from, to);
|
||||||
const scope = orgScope(actor) ?? actor.organizationId ?? null;
|
const scope = orgScope(actor);
|
||||||
return this.dataSource.query(
|
return this.dataSource.query(
|
||||||
`SELECT t."code" AS "leaveTypeCode",
|
`SELECT t."code" AS "leaveTypeCode",
|
||||||
COUNT(*)::int AS "requests",
|
COUNT(*)::int AS "requests",
|
||||||
@@ -416,7 +416,7 @@ export class ReportsService {
|
|||||||
}[]
|
}[]
|
||||||
> {
|
> {
|
||||||
ReportsService.assertRange(from, to);
|
ReportsService.assertRange(from, to);
|
||||||
const scope = orgScope(actor) ?? actor.organizationId ?? null;
|
const scope = orgScope(actor);
|
||||||
return this.dataSource.query(
|
return this.dataSource.query(
|
||||||
`SELECT pl."code" AS "code",
|
`SELECT pl."code" AS "code",
|
||||||
pl."component_type" AS "componentType",
|
pl."component_type" AS "componentType",
|
||||||
@@ -447,7 +447,7 @@ export class ReportsService {
|
|||||||
if (withinDays < 1 || withinDays > 365) {
|
if (withinDays < 1 || withinDays > 365) {
|
||||||
throw new BadRequestException("withinDays must be between 1 and 365.");
|
throw new BadRequestException("withinDays must be between 1 and 365.");
|
||||||
}
|
}
|
||||||
const scope = orgScope(actor) ?? actor.organizationId ?? null;
|
const scope = orgScope(actor);
|
||||||
|
|
||||||
const contracts = await this.dataSource.query(
|
const contracts = await this.dataSource.query(
|
||||||
`SELECT p."employee_id" AS "employeeId",
|
`SELECT p."employee_id" AS "employeeId",
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ export function AppraisalReviewsPage() {
|
|||||||
showSelf
|
showSelf
|
||||||
isLoading={submit.isPending}
|
isLoading={submit.isPending}
|
||||||
submitLabel="Submit assessment"
|
submitLabel="Submit assessment"
|
||||||
|
submitTestId="appraisal-submit-manager"
|
||||||
onSubmit={(scores, comment) => submit.mutate({ scores, comment })}
|
onSubmit={(scores, comment) => submit.mutate({ scores, comment })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export function MyAppraisalsPage() {
|
|||||||
ratings={detail.data.ratings ?? []}
|
ratings={detail.data.ratings ?? []}
|
||||||
isLoading={self.isPending}
|
isLoading={self.isPending}
|
||||||
submitLabel="Submit my assessment"
|
submitLabel="Submit my assessment"
|
||||||
|
submitTestId="appraisal-submit-self"
|
||||||
onSubmit={(scores, comment) => self.mutate({ scores, comment })}
|
onSubmit={(scores, comment) => self.mutate({ scores, comment })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function ScoringForm({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
isLoading,
|
isLoading,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
|
submitTestId,
|
||||||
error,
|
error,
|
||||||
}: {
|
}: {
|
||||||
ratings: AppraisalRating[];
|
ratings: AppraisalRating[];
|
||||||
@@ -46,6 +47,7 @@ export function ScoringForm({
|
|||||||
onSubmit: (scores: ScoreInput[], comment: string) => void;
|
onSubmit: (scores: ScoreInput[], comment: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
|
submitTestId?: string;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
@@ -80,7 +82,13 @@ export function ScoringForm({
|
|||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{ratings.map((rating) => (
|
{ratings.map((rating) => (
|
||||||
<Card key={rating.id} withBorder radius="md" padding="md">
|
<Card
|
||||||
|
key={rating.id}
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
padding="md"
|
||||||
|
data-testid={`appraisal-criterion-${rating.code}`}
|
||||||
|
>
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
<Stack gap={2} style={{ flex: 1 }}>
|
<Stack gap={2} style={{ flex: 1 }}>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
@@ -133,7 +141,7 @@ export function ScoringForm({
|
|||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
Running total
|
Running total
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xl" fw={700}>
|
<Text size="xl" fw={700} data-testid="appraisal-weight-total">
|
||||||
{total}%
|
{total}%
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -155,6 +163,7 @@ export function ScoringForm({
|
|||||||
<Button
|
<Button
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
disabled={!complete}
|
disabled={!complete}
|
||||||
|
data-testid={submitTestId}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onSubmit(
|
onSubmit(
|
||||||
ratings.map((rating) => ({
|
ratings.map((rating) => ({
|
||||||
|
|||||||
@@ -100,7 +100,13 @@ export function AttendanceApprovalsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Stack>
|
<Stack>
|
||||||
{correctionItems.map((item) => (
|
{correctionItems.map((item) => (
|
||||||
<Card key={item.id} withBorder radius="md" padding="md">
|
<Card
|
||||||
|
key={item.id}
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
padding="md"
|
||||||
|
data-testid={`attendance-regularization-row-${item.id}`}
|
||||||
|
>
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
@@ -123,6 +129,7 @@ export function AttendanceApprovalsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Actions
|
<Actions
|
||||||
|
id={item.id}
|
||||||
onApprove={() =>
|
onApprove={() =>
|
||||||
decide.mutate({ kind: "correction", item, approve: true })
|
decide.mutate({ kind: "correction", item, approve: true })
|
||||||
}
|
}
|
||||||
@@ -169,6 +176,7 @@ export function AttendanceApprovalsPage() {
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Actions
|
<Actions
|
||||||
|
id={item.id}
|
||||||
onApprove={() =>
|
onApprove={() =>
|
||||||
decide.mutate({ kind: "overtime", item, approve: true })
|
decide.mutate({ kind: "overtime", item, approve: true })
|
||||||
}
|
}
|
||||||
@@ -211,10 +219,12 @@ export function AttendanceApprovalsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Actions({
|
function Actions({
|
||||||
|
id,
|
||||||
onApprove,
|
onApprove,
|
||||||
onReject,
|
onReject,
|
||||||
loading,
|
loading,
|
||||||
}: {
|
}: {
|
||||||
|
id: string;
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -227,6 +237,7 @@ function Actions({
|
|||||||
color="red"
|
color="red"
|
||||||
leftSection={<IconX size={14} />}
|
leftSection={<IconX size={14} />}
|
||||||
onClick={onReject}
|
onClick={onReject}
|
||||||
|
data-testid={`attendance-regularization-reject-btn-${id}`}
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
@@ -235,6 +246,7 @@ function Actions({
|
|||||||
leftSection={<IconCheck size={14} />}
|
leftSection={<IconCheck size={14} />}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onClick={onApprove}
|
onClick={onApprove}
|
||||||
|
data-testid={`attendance-regularization-approve-btn-${id}`}
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -193,7 +193,10 @@ export function MyAttendancePage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{(records.data ?? []).map((record) => (
|
{(records.data ?? []).map((record) => (
|
||||||
<Table.Tr key={record.id}>
|
<Table.Tr
|
||||||
|
key={record.id}
|
||||||
|
data-testid={`attendance-record-row-${record.id}`}
|
||||||
|
>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm">{record.workDate}</Text>
|
<Text size="sm">{record.workDate}</Text>
|
||||||
{record.notes && (
|
{record.notes && (
|
||||||
@@ -281,7 +284,10 @@ export function MyAttendancePage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{(regularizations.data?.items ?? []).map((request) => (
|
{(regularizations.data?.items ?? []).map((request) => (
|
||||||
<Table.Tr key={request.id}>
|
<Table.Tr
|
||||||
|
key={request.id}
|
||||||
|
data-testid={`attendance-my-regularization-row-${request.id}`}
|
||||||
|
>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm">{request.workDate}</Text>
|
<Text size="sm">{request.workDate}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function ClockWidget() {
|
|||||||
}
|
}
|
||||||
loading={punchIn.isPending || punchOut.isPending}
|
loading={punchIn.isPending || punchOut.isPending}
|
||||||
onClick={() => (clockedIn ? punchOut.mutate() : punchIn.mutate())}
|
onClick={() => (clockedIn ? punchOut.mutate() : punchIn.mutate())}
|
||||||
|
data-testid={clockedIn ? "attendance-clock-out" : "attendance-clock-in"}
|
||||||
>
|
>
|
||||||
{clockedIn ? "Clock out" : "Clock in"}
|
{clockedIn ? "Clock out" : "Clock in"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ function ApprovalCard({
|
|||||||
const after = balance ? Number((balance.availableDays - charged).toFixed(2)) : null;
|
const after = balance ? Number((balance.availableDays - charged).toFixed(2)) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder radius="md" padding="md">
|
<Card withBorder radius="md" padding="md" data-testid={`leave-approval-row-${request.id}`}>
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
@@ -203,6 +203,7 @@ function ApprovalCard({
|
|||||||
color="red"
|
color="red"
|
||||||
leftSection={<IconX size={14} />}
|
leftSection={<IconX size={14} />}
|
||||||
onClick={onReject}
|
onClick={onReject}
|
||||||
|
data-testid={`leave-reject-btn-${request.id}`}
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
@@ -211,6 +212,7 @@ function ApprovalCard({
|
|||||||
leftSection={<IconCheck size={14} />}
|
leftSection={<IconCheck size={14} />}
|
||||||
loading={isApproving}
|
loading={isApproving}
|
||||||
onClick={onApprove}
|
onClick={onApprove}
|
||||||
|
data-testid={`leave-approve-btn-${request.id}`}
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ export function RequestLeaveModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
|
data-testid="leave-request-type"
|
||||||
label="Leave type"
|
label="Leave type"
|
||||||
required
|
required
|
||||||
searchable
|
searchable
|
||||||
@@ -163,12 +164,13 @@ export function RequestLeaveModal({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group grow align="flex-start">
|
<Group grow align="flex-start" data-testid="leave-request-dates">
|
||||||
<EthiopianDateInput
|
<EthiopianDateInput
|
||||||
label="First day"
|
label="First day"
|
||||||
required
|
required
|
||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={setStart}
|
onChange={setStart}
|
||||||
|
data-testid="leave-request-start-date"
|
||||||
/>
|
/>
|
||||||
<EthiopianDateInput
|
<EthiopianDateInput
|
||||||
label="Last day"
|
label="Last day"
|
||||||
@@ -176,6 +178,7 @@ export function RequestLeaveModal({
|
|||||||
disabled={isHalfDay}
|
disabled={isHalfDay}
|
||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={setEndDate}
|
onChange={setEndDate}
|
||||||
|
data-testid="leave-request-end-date"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -288,6 +291,7 @@ export function RequestLeaveModal({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="leave-request-submit"
|
||||||
loading={submit.isPending}
|
loading={submit.isPending}
|
||||||
disabled={!ready}
|
disabled={!ready}
|
||||||
onClick={() => submit.mutate()}
|
onClick={() => submit.mutate()}
|
||||||
|
|||||||
@@ -118,7 +118,13 @@ export function PayrollRunsPage() {
|
|||||||
|
|
||||||
<Stack>
|
<Stack>
|
||||||
{items.map((run) => (
|
{items.map((run) => (
|
||||||
<Card key={run.id} withBorder radius="md" padding="md">
|
<Card
|
||||||
|
key={run.id}
|
||||||
|
withBorder
|
||||||
|
radius="md"
|
||||||
|
padding="md"
|
||||||
|
data-testid={`payroll-run-row-${run.id}`}
|
||||||
|
>
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
<Stack gap={4} style={{ flex: 1 }}>
|
<Stack gap={4} style={{ flex: 1 }}>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
@@ -159,6 +165,7 @@ export function PayrollRunsPage() {
|
|||||||
leftSection={<IconCalculator size={14} />}
|
leftSection={<IconCalculator size={14} />}
|
||||||
loading={act.isPending}
|
loading={act.isPending}
|
||||||
onClick={() => act.mutate({ id: run.id, action: "calculate" })}
|
onClick={() => act.mutate({ id: run.id, action: "calculate" })}
|
||||||
|
data-testid={`payroll-calculate-btn-${run.id}`}
|
||||||
>
|
>
|
||||||
{run.status === "DRAFT" ? "Calculate" : "Recalculate"}
|
{run.status === "DRAFT" ? "Calculate" : "Recalculate"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -170,6 +177,7 @@ export function PayrollRunsPage() {
|
|||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
leftSection={<IconCheck size={14} />}
|
leftSection={<IconCheck size={14} />}
|
||||||
onClick={() => setApproving(run)}
|
onClick={() => setApproving(run)}
|
||||||
|
data-testid={`payroll-approve-btn-${run.id}`}
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
@@ -183,6 +191,7 @@ export function PayrollRunsPage() {
|
|||||||
leftSection={<IconCash size={14} />}
|
leftSection={<IconCash size={14} />}
|
||||||
loading={act.isPending}
|
loading={act.isPending}
|
||||||
onClick={() => act.mutate({ id: run.id, action: "pay" })}
|
onClick={() => act.mutate({ id: run.id, action: "pay" })}
|
||||||
|
data-testid={`payroll-mark-paid-btn-${run.id}`}
|
||||||
>
|
>
|
||||||
Mark paid
|
Mark paid
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ export function OpeningDetailPage() {
|
|||||||
? setRejecting(application.id)
|
? setRejecting(application.id)
|
||||||
: advance.mutate({ id: application.id, stage })
|
: advance.mutate({ id: application.id, stage })
|
||||||
}
|
}
|
||||||
|
data-testid={`recruitment-stage-advance-btn-${stage}`}
|
||||||
>
|
>
|
||||||
{stage.toLowerCase()}
|
{stage.toLowerCase()}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ export function ApplicationDrawer({
|
|||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
leftSection={<IconUserCheck size={14} />}
|
leftSection={<IconUserCheck size={14} />}
|
||||||
onClick={() => setHiring(true)}
|
onClick={() => setHiring(true)}
|
||||||
|
data-testid="recruitment-hire-btn"
|
||||||
>
|
>
|
||||||
Hire
|
Hire
|
||||||
</Button>
|
</Button>
|
||||||
@@ -700,6 +701,7 @@ function HireModal({
|
|||||||
loading={submit.isPending}
|
loading={submit.isPending}
|
||||||
disabled={!username || !email || !positionId}
|
disabled={!username || !email || !positionId}
|
||||||
onClick={() => submit.mutate()}
|
onClick={() => submit.mutate()}
|
||||||
|
data-testid="recruitment-hire-confirm-btn"
|
||||||
>
|
>
|
||||||
Hire
|
Hire
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export function ReportsPage() {
|
|||||||
leftSection={<IconDownload size={14} />}
|
leftSection={<IconDownload size={14} />}
|
||||||
disabled={!rows?.length}
|
disabled={!rows?.length}
|
||||||
onClick={() => downloadCsv(`${name}-${from}-to-${to}.csv`, rows ?? [])}
|
onClick={() => downloadCsv(`${name}-${from}-to-${to}.csv`, rows ?? [])}
|
||||||
|
data-testid={`report-export-btn-${name}`}
|
||||||
>
|
>
|
||||||
CSV
|
CSV
|
||||||
</Button>
|
</Button>
|
||||||
@@ -215,7 +216,10 @@ export function ReportsPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{(headcount.data ?? []).map((row) => (
|
{(headcount.data ?? []).map((row) => (
|
||||||
<Table.Tr key={row.unitId ?? "none"}>
|
<Table.Tr
|
||||||
|
key={row.unitId ?? "none"}
|
||||||
|
data-testid={`report-headcount-row-${row.unitId ?? "none"}`}
|
||||||
|
>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{row.unitName
|
{row.unitName
|
||||||
? localized(row.unitName, i18n.language)
|
? localized(row.unitName, i18n.language)
|
||||||
@@ -259,7 +263,7 @@ export function ReportsPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{(cost.data ?? []).map((row) => (
|
{(cost.data ?? []).map((row) => (
|
||||||
<Table.Tr key={row.code}>
|
<Table.Tr key={row.code} data-testid={`report-payroll-cost-row-${row.code}`}>
|
||||||
<Table.Td>{row.code}</Table.Td>
|
<Table.Td>{row.code}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -313,7 +317,10 @@ export function ReportsPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{(tax.data ?? []).map((row) => (
|
{(tax.data ?? []).map((row) => (
|
||||||
<Table.Tr key={row.employeeId}>
|
<Table.Tr
|
||||||
|
key={row.employeeId}
|
||||||
|
data-testid={`report-income-tax-row-${row.employeeId}`}
|
||||||
|
>
|
||||||
<Table.Td>{row.employeeNumber ?? "—"}</Table.Td>
|
<Table.Td>{row.employeeNumber ?? "—"}</Table.Td>
|
||||||
<Table.Td ta="right">{row.periods}</Table.Td>
|
<Table.Td ta="right">{row.periods}</Table.Td>
|
||||||
<Table.Td ta="right">{money(row.grossPay)}</Table.Td>
|
<Table.Td ta="right">{money(row.grossPay)}</Table.Td>
|
||||||
@@ -346,7 +353,10 @@ export function ReportsPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{(pension.data ?? []).map((row) => (
|
{(pension.data ?? []).map((row) => (
|
||||||
<Table.Tr key={row.employeeId}>
|
<Table.Tr
|
||||||
|
key={row.employeeId}
|
||||||
|
data-testid={`report-pension-row-${row.employeeId}`}
|
||||||
|
>
|
||||||
<Table.Td>{row.employeeNumber ?? "—"}</Table.Td>
|
<Table.Td>{row.employeeNumber ?? "—"}</Table.Td>
|
||||||
<Table.Td ta="right">{money(row.pensionableIncome)}</Table.Td>
|
<Table.Td ta="right">{money(row.pensionableIncome)}</Table.Td>
|
||||||
<Table.Td ta="right">{money(row.employeeContribution)}</Table.Td>
|
<Table.Td ta="right">{money(row.employeeContribution)}</Table.Td>
|
||||||
@@ -386,7 +396,10 @@ export function ReportsPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{(liability.data ?? []).map((row) => (
|
{(liability.data ?? []).map((row) => (
|
||||||
<Table.Tr key={`${row.employeeId}-${row.leaveTypeCode}`}>
|
<Table.Tr
|
||||||
|
key={`${row.employeeId}-${row.leaveTypeCode}`}
|
||||||
|
data-testid={`report-leave-liability-row-${row.employeeId}-${row.leaveTypeCode}`}
|
||||||
|
>
|
||||||
<Table.Td>{row.employeeNumber ?? "—"}</Table.Td>
|
<Table.Td>{row.employeeNumber ?? "—"}</Table.Td>
|
||||||
<Table.Td>{row.leaveTypeCode}</Table.Td>
|
<Table.Td>{row.leaveTypeCode}</Table.Td>
|
||||||
<Table.Td ta="right">{Number(row.balanceDays)}</Table.Td>
|
<Table.Td ta="right">{Number(row.balanceDays)}</Table.Td>
|
||||||
@@ -418,7 +431,10 @@ export function ReportsPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{(leaveTaken.data ?? []).map((row) => (
|
{(leaveTaken.data ?? []).map((row) => (
|
||||||
<Table.Tr key={row.leaveTypeCode}>
|
<Table.Tr
|
||||||
|
key={row.leaveTypeCode}
|
||||||
|
data-testid={`report-leave-taken-row-${row.leaveTypeCode}`}
|
||||||
|
>
|
||||||
<Table.Td>{row.leaveTypeCode}</Table.Td>
|
<Table.Td>{row.leaveTypeCode}</Table.Td>
|
||||||
<Table.Td ta="right">{row.requests}</Table.Td>
|
<Table.Td ta="right">{row.requests}</Table.Td>
|
||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function EthiopianDateInput({
|
|||||||
error,
|
error,
|
||||||
description,
|
description,
|
||||||
disabled,
|
disabled,
|
||||||
|
"data-testid": testId,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | null | undefined;
|
value: string | null | undefined;
|
||||||
@@ -41,6 +42,7 @@ export function EthiopianDateInput({
|
|||||||
error?: string;
|
error?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
"data-testid"?: string;
|
||||||
}) {
|
}) {
|
||||||
const gregorian = useMemo(() => fromIsoDate(value), [value]);
|
const gregorian = useMemo(() => fromIsoDate(value), [value]);
|
||||||
const ethiopian = useMemo(
|
const ethiopian = useMemo(
|
||||||
@@ -66,6 +68,7 @@ export function EthiopianDateInput({
|
|||||||
required={required}
|
required={required}
|
||||||
error={error}
|
error={error}
|
||||||
description={description}
|
description={description}
|
||||||
|
data-testid={testId}
|
||||||
>
|
>
|
||||||
<Stack gap={6} mt={4}>
|
<Stack gap={6} mt={4}>
|
||||||
<DatePickerInput
|
<DatePickerInput
|
||||||
|
|||||||
416
apps/finance-api/src/modules/assets/assets.service.spec.ts
Normal file
416
apps/finance-api/src/modules/assets/assets.service.spec.ts
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { AssetsService } from "./assets.service";
|
||||||
|
import { DepreciationEntry, DepreciationRun, FixedAsset } from "./entities/fixed-asset.entity";
|
||||||
|
import type { ActorContext } from "../../common/current-actor.util";
|
||||||
|
|
||||||
|
const actor: ActorContext = {
|
||||||
|
employeeId: "emp-1",
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
isSuperAdmin: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A manager whose getRepository() dispatches to per-entity save/create/update spies. */
|
||||||
|
function makeManager() {
|
||||||
|
const savedRuns: unknown[] = [];
|
||||||
|
const savedEntries: unknown[] = [];
|
||||||
|
const assetUpdates: { id: string; patch: Record<string, unknown> }[] = [];
|
||||||
|
|
||||||
|
const runRepo = {
|
||||||
|
create: (data: Record<string, unknown>) => ({ id: "run-1", ...data }),
|
||||||
|
save: (data: Record<string, unknown>) => {
|
||||||
|
savedRuns.push(data);
|
||||||
|
return Promise.resolve(data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const entryRepo = {
|
||||||
|
create: (data: Record<string, unknown>) => data,
|
||||||
|
save: (rows: Record<string, unknown>[]) => {
|
||||||
|
savedEntries.push(...rows);
|
||||||
|
return Promise.resolve(rows);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const assetRepo = {
|
||||||
|
update: (id: string, patch: Record<string, unknown>) => {
|
||||||
|
assetUpdates.push({ id, patch });
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
getRepository: (entity: unknown) => {
|
||||||
|
if (entity === DepreciationRun) return runRepo;
|
||||||
|
if (entity === DepreciationEntry) return entryRepo;
|
||||||
|
if (entity === FixedAsset) return assetRepo;
|
||||||
|
throw new Error("unexpected repository requested");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { manager, savedRuns, savedEntries, assetUpdates };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AssetsService.runDepreciation", () => {
|
||||||
|
function build(opts: {
|
||||||
|
period?: Record<string, unknown>;
|
||||||
|
existingRun?: Record<string, unknown> | null;
|
||||||
|
ledgerRows?: Record<string, unknown>[];
|
||||||
|
depreciable?: Record<string, unknown>[];
|
||||||
|
}) {
|
||||||
|
const period = opts.period ?? {
|
||||||
|
id: "period-1",
|
||||||
|
status: "OPEN",
|
||||||
|
endDate: "2026-01-31",
|
||||||
|
name: { en: "Jan 2026" },
|
||||||
|
fiscalYearId: "fy-2026",
|
||||||
|
};
|
||||||
|
const runs = {
|
||||||
|
findForPeriod: jest.fn().mockResolvedValue(opts.existingRun ?? null),
|
||||||
|
};
|
||||||
|
const managerBundle = makeManager();
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((cb: (mg: unknown) => unknown) => cb(managerBundle.manager)),
|
||||||
|
};
|
||||||
|
const journals = {
|
||||||
|
createPosted: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: "je-1", entryNumber: "JE-2026-0001" }),
|
||||||
|
};
|
||||||
|
const assets = {
|
||||||
|
ledgerAccumulated: jest.fn().mockResolvedValue(
|
||||||
|
opts.ledgerRows ?? [
|
||||||
|
{ accountId: "acc-accum", ledgerAccumulated: 0, registerAccumulated: 0 },
|
||||||
|
],
|
||||||
|
),
|
||||||
|
findDepreciable: jest.fn().mockResolvedValue(
|
||||||
|
opts.depreciable ?? [
|
||||||
|
{
|
||||||
|
id: "asset-1",
|
||||||
|
assetCode: "AST-001",
|
||||||
|
acquisitionCost: 1000,
|
||||||
|
salvageValue: 0,
|
||||||
|
usefulLifeMonths: 10,
|
||||||
|
accumulatedDepreciation: 0,
|
||||||
|
inServiceDate: "2026-01-01",
|
||||||
|
depreciationMethod: "STRAIGHT_LINE",
|
||||||
|
status: "ACTIVE",
|
||||||
|
costCenterId: null,
|
||||||
|
expenseAccountId: "acc-expense",
|
||||||
|
accumulatedAccountId: "acc-accum",
|
||||||
|
periodsCharged: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const periods = { findPeriod: jest.fn().mockResolvedValue(period) };
|
||||||
|
|
||||||
|
const service = new AssetsService(
|
||||||
|
{} as never, // categories
|
||||||
|
assets as never,
|
||||||
|
runs as never,
|
||||||
|
{} as never, // disposals
|
||||||
|
{} as never, // accounts
|
||||||
|
periods as never,
|
||||||
|
journals as never,
|
||||||
|
dataSource as never,
|
||||||
|
);
|
||||||
|
return { service, runs, assets, periods, journals, dataSource, ...managerBundle };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("charges depreciation, posts ONE journal entry, and advances the asset's running total", async () => {
|
||||||
|
const { service, journals, assetUpdates } = build({});
|
||||||
|
|
||||||
|
const result = await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
|
||||||
|
|
||||||
|
expect(journals.createPosted).toHaveBeenCalledTimes(1);
|
||||||
|
const [, dto] = journals.createPosted.mock.calls[0];
|
||||||
|
expect(dto.lines).toHaveLength(2); // one debit (expense), one credit (accumulated)
|
||||||
|
expect(result.assetCount).toBe(1);
|
||||||
|
expect(result.total).toBe(100); // 1000 base / 10 months
|
||||||
|
expect(assetUpdates).toEqual([
|
||||||
|
{ id: "asset-1", patch: { accumulatedDepreciation: 100 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes multiple assets sharing the same (expense account, cost center) into ONE debit line, not one per asset", async () => {
|
||||||
|
const { service, journals } = build({
|
||||||
|
depreciable: [
|
||||||
|
{
|
||||||
|
id: "asset-1", assetCode: "AST-001", acquisitionCost: 1000, salvageValue: 0,
|
||||||
|
usefulLifeMonths: 10, accumulatedDepreciation: 0, inServiceDate: "2026-01-01",
|
||||||
|
depreciationMethod: "STRAIGHT_LINE", status: "ACTIVE", costCenterId: null,
|
||||||
|
expenseAccountId: "acc-expense", accumulatedAccountId: "acc-accum", periodsCharged: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "asset-2", assetCode: "AST-002", acquisitionCost: 2000, salvageValue: 0,
|
||||||
|
usefulLifeMonths: 10, accumulatedDepreciation: 0, inServiceDate: "2026-01-01",
|
||||||
|
depreciationMethod: "STRAIGHT_LINE", status: "ACTIVE", costCenterId: null,
|
||||||
|
expenseAccountId: "acc-expense", accumulatedAccountId: "acc-accum", periodsCharged: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
|
||||||
|
|
||||||
|
const [, dto] = journals.createPosted.mock.calls[0];
|
||||||
|
expect(dto.lines).toHaveLength(2); // still just debit + credit, summed
|
||||||
|
const debitLine = dto.lines.find((l: { debit?: number }) => l.debit);
|
||||||
|
const creditLine = dto.lines.find((l: { credit?: number }) => l.credit);
|
||||||
|
expect(debitLine.debit).toBe(300); // 100 + 200
|
||||||
|
expect(creditLine.credit).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flips a fully-depreciated asset's status to FULLY_DEPRECIATED", async () => {
|
||||||
|
const { service, assetUpdates } = build({
|
||||||
|
depreciable: [
|
||||||
|
{
|
||||||
|
id: "asset-1", assetCode: "AST-001", acquisitionCost: 1000, salvageValue: 0,
|
||||||
|
usefulLifeMonths: 10, accumulatedDepreciation: 900, inServiceDate: "2026-01-01",
|
||||||
|
depreciationMethod: "STRAIGHT_LINE", status: "ACTIVE", costCenterId: null,
|
||||||
|
expenseAccountId: "acc-expense", accumulatedAccountId: "acc-accum", periodsCharged: 9,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
|
||||||
|
|
||||||
|
expect(assetUpdates[0].patch).toMatchObject({
|
||||||
|
accumulatedDepreciation: 1000,
|
||||||
|
status: "FULLY_DEPRECIATED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second run for the same period (idempotency)", async () => {
|
||||||
|
const { service, journals } = build({
|
||||||
|
existingRun: { id: "run-existing" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.runDepreciation(actor, { fiscalPeriodId: "period-1" }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to charge a non-OPEN period", async () => {
|
||||||
|
const { service, journals } = build({
|
||||||
|
period: { id: "period-1", status: "CLOSED", endDate: "2026-01-31", name: { en: "Jan" }, fiscalYearId: "fy" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.runDepreciation(actor, { fiscalPeriodId: "period-1" }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when the asset register disagrees with the ledger by 0.005 or more", async () => {
|
||||||
|
const { service, journals } = build({
|
||||||
|
ledgerRows: [
|
||||||
|
{ accountId: "acc-accum", ledgerAccumulated: 100, registerAccumulated: 105 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.runDepreciation(actor, { fiscalPeriodId: "period-1" }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates sub-cent drift between register and ledger (< 0.005)", async () => {
|
||||||
|
const { service, journals } = build({
|
||||||
|
ledgerRows: [
|
||||||
|
{ accountId: "acc-accum", ledgerAccumulated: 100, registerAccumulated: 100.003 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
|
||||||
|
expect(journals.createPosted).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when nothing is due this period (every asset skipped)", async () => {
|
||||||
|
const { service, journals } = build({
|
||||||
|
depreciable: [
|
||||||
|
{
|
||||||
|
id: "asset-1", assetCode: "AST-001", acquisitionCost: 1000, salvageValue: 0,
|
||||||
|
usefulLifeMonths: 10, accumulatedDepreciation: 1000, inServiceDate: "2026-01-01",
|
||||||
|
depreciationMethod: "STRAIGHT_LINE", status: "ACTIVE", costCenterId: null,
|
||||||
|
expenseAccountId: "acc-expense", accumulatedAccountId: "acc-accum", periodsCharged: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.runDepreciation(actor, { fiscalPeriodId: "period-1" }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the transaction manager through to journals.createPosted, so the entry, run, entries and asset updates all commit together", async () => {
|
||||||
|
const { service, journals, manager } = build({});
|
||||||
|
|
||||||
|
await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
|
||||||
|
|
||||||
|
const [, , passedManager] = journals.createPosted.mock.calls[0];
|
||||||
|
expect(passedManager).toBe(manager);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AssetsService.dispose", () => {
|
||||||
|
function fixedAsset(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "asset-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
assetCode: "AST-001",
|
||||||
|
name: "Forklift",
|
||||||
|
assetCategoryId: "cat-1",
|
||||||
|
acquisitionCost: 1000,
|
||||||
|
salvageValue: 0,
|
||||||
|
accumulatedDepreciation: 700,
|
||||||
|
inServiceDate: "2026-01-01",
|
||||||
|
depreciationMethod: "STRAIGHT_LINE",
|
||||||
|
status: "ACTIVE",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(opts: {
|
||||||
|
asset?: Record<string, unknown>;
|
||||||
|
category?: Record<string, unknown> | null;
|
||||||
|
}) {
|
||||||
|
const assets = {
|
||||||
|
findById: jest.fn().mockResolvedValue(opts.asset ?? fixedAsset()),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const categories = {
|
||||||
|
findById: jest.fn().mockResolvedValue(
|
||||||
|
opts.category ?? {
|
||||||
|
id: "cat-1",
|
||||||
|
assetAccountId: "acc-asset",
|
||||||
|
accumulatedAccountId: "acc-accum",
|
||||||
|
expenseAccountId: "acc-expense",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const accounts = {
|
||||||
|
assertPostable: jest.fn().mockResolvedValue({ id: "acc-cash", code: "1110" }),
|
||||||
|
findByCode: jest.fn().mockImplementation((_actor, code: string) =>
|
||||||
|
Promise.resolve({ id: `acc-${code}`, code }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const journals = {
|
||||||
|
createPosted: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: "je-1", entryNumber: "JE-2026-0002" }),
|
||||||
|
};
|
||||||
|
const disposals = {
|
||||||
|
create: jest.fn().mockImplementation((data) => Promise.resolve({ id: "disposal-1", ...data })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new AssetsService(
|
||||||
|
categories as never,
|
||||||
|
assets as never,
|
||||||
|
{} as never, // runs
|
||||||
|
disposals as never,
|
||||||
|
accounts as never,
|
||||||
|
{} as never, // periods
|
||||||
|
journals as never,
|
||||||
|
{} as never, // dataSource
|
||||||
|
);
|
||||||
|
return { service, assets, categories, accounts, journals, disposals };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("computes gain/loss as a plug and posts the disposal (gain case: proceeds > NBV)", async () => {
|
||||||
|
const { service, journals, assets } = build({});
|
||||||
|
// cost 1000, accumulated 700 -> NBV 300; proceeds 450 -> gain 150
|
||||||
|
|
||||||
|
const result = await service.dispose(actor, "asset-1", {
|
||||||
|
disposalDate: "2026-06-01",
|
||||||
|
disposalType: "SALE",
|
||||||
|
proceeds: 450,
|
||||||
|
proceedsAccountId: "acc-cash",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.netBookValue).toBe(300);
|
||||||
|
expect(result.gainLoss).toBe(150);
|
||||||
|
const [, dto] = journals.createPosted.mock.calls[0];
|
||||||
|
const creditGain = dto.lines.find((l: { accountId: string }) => l.accountId === "acc-4910");
|
||||||
|
expect(creditGain).toBeDefined();
|
||||||
|
expect(creditGain.credit).toBe(150);
|
||||||
|
expect(assets.update).toHaveBeenCalledWith("asset-1", { status: "DISPOSED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes a loss and books it to the loss account (proceeds < NBV)", async () => {
|
||||||
|
const { service, journals } = build({});
|
||||||
|
// NBV 300; proceeds 100 -> loss 200
|
||||||
|
|
||||||
|
const result = await service.dispose(actor, "asset-1", {
|
||||||
|
disposalDate: "2026-06-01",
|
||||||
|
disposalType: "SALE",
|
||||||
|
proceeds: 100,
|
||||||
|
proceedsAccountId: "acc-cash",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.gainLoss).toBe(-200);
|
||||||
|
const [, dto] = journals.createPosted.mock.calls[0];
|
||||||
|
const debitLoss = dto.lines.find((l: { accountId: string }) => l.accountId === "acc-5900");
|
||||||
|
expect(debitLoss).toBeDefined();
|
||||||
|
expect(debitLoss.debit).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets status to WRITTEN_OFF (not DISPOSED) for a write-off", async () => {
|
||||||
|
const { service, assets } = build({});
|
||||||
|
|
||||||
|
await service.dispose(actor, "asset-1", {
|
||||||
|
disposalDate: "2026-06-01",
|
||||||
|
disposalType: "WRITE_OFF",
|
||||||
|
proceeds: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(assets.update).toHaveBeenCalledWith("asset-1", { status: "WRITTEN_OFF" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to dispose an asset that is already DISPOSED", async () => {
|
||||||
|
const { service } = build({ asset: fixedAsset({ status: "DISPOSED" }) });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.dispose(actor, "asset-1", { disposalDate: "2026-06-01", disposalType: "SALE", proceeds: 0 }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to dispose an asset that is already WRITTEN_OFF", async () => {
|
||||||
|
const { service } = build({ asset: fixedAsset({ status: "WRITTEN_OFF" }) });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.dispose(actor, "asset-1", { disposalDate: "2026-06-01", disposalType: "SALE", proceeds: 0 }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a disposal date before the asset entered service", async () => {
|
||||||
|
const { service } = build({ asset: fixedAsset({ inServiceDate: "2026-06-01" }) });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.dispose(actor, "asset-1", { disposalDate: "2026-01-01", disposalType: "SALE", proceeds: 0 }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires a proceedsAccountId whenever proceeds > 0", async () => {
|
||||||
|
const { service } = build({});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.dispose(actor, "asset-1", { disposalDate: "2026-06-01", disposalType: "SALE", proceeds: 450 }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not require a proceedsAccountId when proceeds is zero", async () => {
|
||||||
|
const { service, journals } = build({});
|
||||||
|
|
||||||
|
await service.dispose(actor, "asset-1", {
|
||||||
|
disposalDate: "2026-06-01",
|
||||||
|
disposalType: "WRITE_OFF",
|
||||||
|
proceeds: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(journals.createPosted).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
depreciableBase,
|
||||||
|
depreciationFor,
|
||||||
|
depreciationSchedule,
|
||||||
|
disposalResult,
|
||||||
|
netBookValue,
|
||||||
|
type DepreciableAsset,
|
||||||
|
} from "./depreciation.calculator";
|
||||||
|
|
||||||
|
function asset(overrides: Partial<DepreciableAsset> = {}): DepreciableAsset {
|
||||||
|
return {
|
||||||
|
acquisitionCost: 1000,
|
||||||
|
salvageValue: 0,
|
||||||
|
usefulLifeMonths: 3,
|
||||||
|
accumulatedDepreciation: 0,
|
||||||
|
inServiceDate: "2026-01-01",
|
||||||
|
depreciationMethod: "STRAIGHT_LINE",
|
||||||
|
status: "ACTIVE",
|
||||||
|
periodsCharged: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("depreciationFor — the cumulative-target method", () => {
|
||||||
|
it("guards against the naive fixed-monthly bug: 1000 over 3 months gives 333.33/333.34/333.33, not 333.33 x3 (which would lose a cent)", () => {
|
||||||
|
let a = asset();
|
||||||
|
let periodEnd = "2026-01-31";
|
||||||
|
|
||||||
|
const c1 = depreciationFor(a, periodEnd);
|
||||||
|
expect(c1.amount).toBe(333.33);
|
||||||
|
a = { ...a, accumulatedDepreciation: c1.accumulatedAfter, periodsCharged: 1 };
|
||||||
|
|
||||||
|
periodEnd = "2026-02-28";
|
||||||
|
const c2 = depreciationFor(a, periodEnd);
|
||||||
|
expect(c2.amount).toBe(333.34); // the rounding remainder lands here, not lost
|
||||||
|
a = { ...a, accumulatedDepreciation: c2.accumulatedAfter, periodsCharged: 2 };
|
||||||
|
|
||||||
|
periodEnd = "2026-03-31";
|
||||||
|
const c3 = depreciationFor(a, periodEnd);
|
||||||
|
expect(c3.amount).toBe(333.33);
|
||||||
|
a = { ...a, accumulatedDepreciation: c3.accumulatedAfter, periodsCharged: 3 };
|
||||||
|
|
||||||
|
expect(roundSum([c1.amount, c2.amount, c3.amount])).toBe(1000);
|
||||||
|
expect(a.accumulatedDepreciation).toBe(1000);
|
||||||
|
expect(c3.fullyDepreciated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a full life's schedule sums EXACTLY to the depreciable base, for an odd (non-dividing) life/cost combination", () => {
|
||||||
|
const a = asset({ acquisitionCost: 10000, salvageValue: 500, usefulLifeMonths: 7 });
|
||||||
|
const base = depreciableBase(a);
|
||||||
|
expect(base).toBe(9500);
|
||||||
|
|
||||||
|
const schedule = depreciationSchedule(a);
|
||||||
|
expect(schedule).toHaveLength(7);
|
||||||
|
const total = roundSum(schedule.map((s) => s.amount));
|
||||||
|
expect(total).toBe(base);
|
||||||
|
// Monotonically increasing accumulated total, ending exactly at the base.
|
||||||
|
expect(schedule[schedule.length - 1].accumulated).toBe(base);
|
||||||
|
expect(schedule[schedule.length - 1].netBookValue).toBe(
|
||||||
|
Math.round((a.acquisitionCost - base) * 100) / 100,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charges nothing and refuses (skipReason) for a non-STRAIGHT_LINE method", () => {
|
||||||
|
const a = asset({ depreciationMethod: "DECLINING_BALANCE" });
|
||||||
|
const charge = depreciationFor(a, "2026-01-31");
|
||||||
|
expect(charge.amount).toBe(0);
|
||||||
|
expect(charge.skipReason).toMatch(/not implemented/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charges nothing for a DISPOSED asset", () => {
|
||||||
|
const a = asset({ status: "DISPOSED" });
|
||||||
|
const charge = depreciationFor(a, "2026-01-31");
|
||||||
|
expect(charge.amount).toBe(0);
|
||||||
|
expect(charge.skipReason).toBe("asset is DISPOSED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charges nothing for a WRITTEN_OFF asset", () => {
|
||||||
|
const a = asset({ status: "WRITTEN_OFF" });
|
||||||
|
const charge = depreciationFor(a, "2026-01-31");
|
||||||
|
expect(charge.amount).toBe(0);
|
||||||
|
expect(charge.skipReason).toBe("asset is WRITTEN_OFF");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charges nothing for an asset not yet in service by the period end", () => {
|
||||||
|
const a = asset({ inServiceDate: "2026-05-01" });
|
||||||
|
const charge = depreciationFor(a, "2026-01-31");
|
||||||
|
expect(charge.amount).toBe(0);
|
||||||
|
expect(charge.skipReason).toMatch(/not in service until/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("charges nothing once the asset is already fully depreciated", () => {
|
||||||
|
const a = asset({ accumulatedDepreciation: 1000, periodsCharged: 3 });
|
||||||
|
const charge = depreciationFor(a, "2026-04-30");
|
||||||
|
expect(charge.amount).toBe(0);
|
||||||
|
expect(charge.fullyDepreciated).toBe(true);
|
||||||
|
expect(charge.skipReason).toBe("fully depreciated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps periodsAfter at usefulLifeMonths even if periodsCharged somehow exceeds it, so it never charges past the base", () => {
|
||||||
|
const a = asset({
|
||||||
|
accumulatedDepreciation: 999.99,
|
||||||
|
periodsCharged: 3, // already at life
|
||||||
|
});
|
||||||
|
const charge = depreciationFor(a, "2026-04-30");
|
||||||
|
// remaining = 1000 - 999.99 = 0.01, target capped at base (1000)
|
||||||
|
expect(charge.amount).toBe(0.01);
|
||||||
|
expect(charge.accumulatedAfter).toBe(1000);
|
||||||
|
expect(charge.fullyDepreciated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("first-month charge for an asset entering service exactly on the period end (full-month convention)", () => {
|
||||||
|
const a = asset({ inServiceDate: "2026-01-31" });
|
||||||
|
const charge = depreciationFor(a, "2026-01-31");
|
||||||
|
expect(charge.amount).toBe(333.33);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("depreciationSchedule", () => {
|
||||||
|
it("stops emitting rows once nothing further is due (no trailing zero-amount rows)", () => {
|
||||||
|
const a = asset({ usefulLifeMonths: 3 });
|
||||||
|
const schedule = depreciationSchedule(a);
|
||||||
|
expect(schedule.every((s) => s.amount > 0)).toBe(true);
|
||||||
|
expect(schedule).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("netBookValue / depreciableBase", () => {
|
||||||
|
it("netBookValue is cost minus accumulated depreciation", () => {
|
||||||
|
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 400 });
|
||||||
|
expect(netBookValue(a)).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("depreciableBase is cost minus salvage value, independent of what has accumulated", () => {
|
||||||
|
const a = asset({ acquisitionCost: 1000, salvageValue: 200, accumulatedDepreciation: 400 });
|
||||||
|
expect(depreciableBase(a)).toBe(800);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disposalResult — gain/loss is a computed plug", () => {
|
||||||
|
it("is a gain when proceeds exceed net book value", () => {
|
||||||
|
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 700 }); // nbv 300
|
||||||
|
const result = disposalResult(a, 450);
|
||||||
|
expect(result.netBookValue).toBe(300);
|
||||||
|
expect(result.gainLoss).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a loss when proceeds are below net book value", () => {
|
||||||
|
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 700 }); // nbv 300
|
||||||
|
const result = disposalResult(a, 100);
|
||||||
|
expect(result.gainLoss).toBe(-200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a scrap with zero proceeds is a loss equal to the remaining net book value", () => {
|
||||||
|
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 700 }); // nbv 300
|
||||||
|
const result = disposalResult(a, 0);
|
||||||
|
expect(result.gainLoss).toBe(-300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("proceeds exactly equal to net book value produce neither gain nor loss", () => {
|
||||||
|
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 700 }); // nbv 300
|
||||||
|
const result = disposalResult(a, 300);
|
||||||
|
expect(result.gainLoss).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sums an array of already-rounded money amounts and rounds once, at the end. */
|
||||||
|
function roundSum(amounts: number[]): number {
|
||||||
|
return Math.round(amounts.reduce((s, a) => s + a, 0) * 100) / 100;
|
||||||
|
}
|
||||||
140
apps/finance-api/src/modules/budgeting/budgeting.service.spec.ts
Normal file
140
apps/finance-api/src/modules/budgeting/budgeting.service.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { BudgetingService } from "./budgeting.service";
|
||||||
|
import type { ActorContext } from "../../common/current-actor.util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `BudgetingService.approveBudget` — only DRAFT→APPROVED, refuses an empty
|
||||||
|
* budget, and enforces at most one APPROVED budget per fiscal year (also
|
||||||
|
* backed by a DB unique index, but the service-level guard is what's under
|
||||||
|
* test here).
|
||||||
|
*/
|
||||||
|
describe("BudgetingService.approveBudget", () => {
|
||||||
|
const actor: ActorContext = {
|
||||||
|
employeeId: "emp-1",
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
isSuperAdmin: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function draftBudget(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "budget-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
fiscalYearId: "fy-2026",
|
||||||
|
name: "FY2026 Budget",
|
||||||
|
status: "DRAFT",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(opts: {
|
||||||
|
budget?: Record<string, unknown> | null;
|
||||||
|
lines?: unknown[];
|
||||||
|
approvedForYear?: Record<string, unknown> | null;
|
||||||
|
}) {
|
||||||
|
// `"budget" in opts` distinguishes "not provided" (use the default draft)
|
||||||
|
// from an explicit `null` (simulate a missing/foreign-org row) — `??`
|
||||||
|
// would treat both the same and mask the not-found case.
|
||||||
|
let current: Record<string, unknown> | null =
|
||||||
|
"budget" in opts ? (opts.budget as Record<string, unknown> | null) : draftBudget();
|
||||||
|
const budgets = {
|
||||||
|
findById: jest.fn().mockImplementation(() => Promise.resolve(current)),
|
||||||
|
findApprovedForYear: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(opts.approvedForYear ?? null),
|
||||||
|
update: jest.fn().mockImplementation((_id, patch) => {
|
||||||
|
current = { ...(current as Record<string, unknown>), ...patch };
|
||||||
|
return Promise.resolve(current);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const budgetLines = {
|
||||||
|
findByBudget: jest.fn().mockResolvedValue(opts.lines ?? [{ id: "line-1" }]),
|
||||||
|
};
|
||||||
|
const service = new BudgetingService(
|
||||||
|
{} as never, // costCenters
|
||||||
|
budgets as never,
|
||||||
|
budgetLines as never,
|
||||||
|
{} as never, // accounts
|
||||||
|
{} as never, // periods
|
||||||
|
{} as never, // dataSource
|
||||||
|
);
|
||||||
|
return { service, budgets, budgetLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("approves a DRAFT budget with lines and no prior approval for the year", async () => {
|
||||||
|
const { service, budgets } = build({});
|
||||||
|
|
||||||
|
const result = await service.approveBudget(actor, "budget-1");
|
||||||
|
|
||||||
|
expect(budgets.update).toHaveBeenCalledWith(
|
||||||
|
"budget-1",
|
||||||
|
expect.objectContaining({
|
||||||
|
status: "APPROVED",
|
||||||
|
approvedBy: "emp-1",
|
||||||
|
approvedAt: expect.any(Date),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.status).toBe("APPROVED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to approve a budget that has no lines", async () => {
|
||||||
|
const { service, budgets } = build({ lines: [] });
|
||||||
|
|
||||||
|
await expect(service.approveBudget(actor, "budget-1")).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(budgets.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second APPROVED budget for the same fiscal year", async () => {
|
||||||
|
const { service, budgets } = build({
|
||||||
|
approvedForYear: draftBudget({ id: "budget-existing", status: "APPROVED", name: "Already approved" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.approveBudget(actor, "budget-1")).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expect(budgets.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to approve a budget that is not DRAFT (e.g. already APPROVED)", async () => {
|
||||||
|
const { service, budgets } = build({
|
||||||
|
budget: draftBudget({ status: "APPROVED" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.approveBudget(actor, "budget-1")).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
expect(budgets.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a budget that does not exist", async () => {
|
||||||
|
const { service } = build({ budget: null });
|
||||||
|
|
||||||
|
await expect(service.approveBudget(actor, "missing")).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides a budget belonging to another organization from a non-super-admin (404, not 403)", async () => {
|
||||||
|
const { service } = build({
|
||||||
|
budget: draftBudget({ organizationId: "org-OTHER" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.approveBudget(actor, "budget-1")).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a super admin approve a budget outside their own organization", async () => {
|
||||||
|
const superAdmin: ActorContext = { ...actor, isSuperAdmin: true, organizationId: "" };
|
||||||
|
const { service, budgets } = build({
|
||||||
|
budget: draftBudget({ organizationId: "org-OTHER" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.approveBudget(superAdmin, "budget-1");
|
||||||
|
|
||||||
|
expect(budgets.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
536
apps/finance-api/src/modules/cutover/cutover.service.spec.ts
Normal file
536
apps/finance-api/src/modules/cutover/cutover.service.spec.ts
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
import { BadRequestException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { CutoverService } from "./cutover.service";
|
||||||
|
import type { ActorContext } from "../../common/current-actor.util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All fixture values below are synthetic — invented account codes
|
||||||
|
* (`TEST-...`), round test amounts, and a made-up organization id. None of
|
||||||
|
* this resembles any real organization's chart of accounts or balances; the
|
||||||
|
* plan this suite implements requires cutover testing to use synthetic data
|
||||||
|
* only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ORG_ID = "org-synthetic-1";
|
||||||
|
|
||||||
|
function actor(overrides: Partial<ActorContext> = {}): ActorContext {
|
||||||
|
return {
|
||||||
|
employeeId: "emp-1",
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: ORG_ID,
|
||||||
|
isSuperAdmin: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSettingsRepo(row: Record<string, unknown> | null) {
|
||||||
|
return {
|
||||||
|
findOne: jest.fn().mockResolvedValue(row),
|
||||||
|
save: jest.fn(async (r: Record<string, unknown>) => ({ id: "settings-1", ...r })),
|
||||||
|
create: jest.fn((r: Record<string, unknown>) => r),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAccounts() {
|
||||||
|
return { findByCode: jest.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeJournals() {
|
||||||
|
return { create: jest.fn(), post: jest.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDataSource(queryImpl: (...args: unknown[]) => unknown) {
|
||||||
|
return { query: jest.fn(queryImpl) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CutoverService.readiness", () => {
|
||||||
|
let settings: ReturnType<typeof makeSettingsRepo>;
|
||||||
|
let accounts: ReturnType<typeof makeAccounts>;
|
||||||
|
let journals: ReturnType<typeof makeJournals>;
|
||||||
|
let dataSource: { query: jest.Mock };
|
||||||
|
let service: CutoverService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The service issues, in order: suspense query, [pre-cutover query if a date
|
||||||
|
* is set], asset-register query, [migrated-assets query if assets exist].
|
||||||
|
* Each test configures a queue of responses consumed in that call order.
|
||||||
|
*/
|
||||||
|
function queueQueries(responses: unknown[]) {
|
||||||
|
let i = 0;
|
||||||
|
dataSource = makeDataSource(() => Promise.resolve(responses[i++] ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
accounts = makeAccounts();
|
||||||
|
journals = makeJournals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function build(settingsRow: Record<string, unknown> | null) {
|
||||||
|
settings = makeSettingsRepo(settingsRow);
|
||||||
|
service = new CutoverService(
|
||||||
|
settings as never,
|
||||||
|
accounts as never,
|
||||||
|
journals as never,
|
||||||
|
dataSource as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("PENDING on the date check when no cutover date is set, and skips the pre-cutover check entirely", async () => {
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "0", lines: "0" }], // suspense: nothing posted yet
|
||||||
|
[{ register: "0", assets: "0" }], // asset register: empty
|
||||||
|
]);
|
||||||
|
build(null);
|
||||||
|
|
||||||
|
const result = await service.readiness(actor());
|
||||||
|
|
||||||
|
const dateCheck = result.checks.find((c) => c.key === "cutover_date")!;
|
||||||
|
expect(dateCheck.status).toBe("PENDING");
|
||||||
|
expect(result.checks.find((c) => c.key === "no_pre_cutover")).toBeUndefined();
|
||||||
|
expect(result.ready).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PASS on the date check once a cutover date is set", async () => {
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "0", lines: "0" }],
|
||||||
|
[{ n: "0", first: null }],
|
||||||
|
[{ register: "0", assets: "0" }],
|
||||||
|
]);
|
||||||
|
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||||
|
|
||||||
|
const result = await service.readiness(actor());
|
||||||
|
|
||||||
|
const dateCheck = result.checks.find((c) => c.key === "cutover_date")!;
|
||||||
|
expect(dateCheck.status).toBe("PASS");
|
||||||
|
expect(dateCheck.detail).toContain("2026-07-08");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Suspense: DRAFT-only vs posted-nonzero ──────────────────────────────
|
||||||
|
|
||||||
|
it("suspense reads PENDING when only DRAFT (unposted) lines exist — not FAIL", async () => {
|
||||||
|
// lines === 0 because the posted-only EXISTS filter excludes the DRAFT
|
||||||
|
// batch's lines entirely (the LEFT JOIN keeps the account row, the filter
|
||||||
|
// nulls only the entry side).
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "0", lines: "0" }],
|
||||||
|
[{ n: "0", first: null }],
|
||||||
|
[{ register: "0", assets: "0" }],
|
||||||
|
]);
|
||||||
|
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||||
|
|
||||||
|
const result = await service.readiness(actor());
|
||||||
|
|
||||||
|
const suspense = result.checks.find((c) => c.key === "suspense")!;
|
||||||
|
expect(suspense.status).toBe("PENDING");
|
||||||
|
expect(suspense.detail).toMatch(/nothing has been posted/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suspense FAILs with the exact drift amount when POSTED lines leave a nonzero balance", async () => {
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "125.50", lines: "3" }],
|
||||||
|
[{ n: "0", first: null }],
|
||||||
|
[{ register: "0", assets: "0" }],
|
||||||
|
]);
|
||||||
|
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||||
|
|
||||||
|
const result = await service.readiness(actor());
|
||||||
|
|
||||||
|
const suspense = result.checks.find((c) => c.key === "suspense")!;
|
||||||
|
expect(suspense.status).toBe("FAIL");
|
||||||
|
expect(suspense.detail).toContain("125.50");
|
||||||
|
expect(result.ready).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suspense PASSes when POSTED lines net to exactly zero", async () => {
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "0", lines: "4" }],
|
||||||
|
[{ n: "0", first: null }],
|
||||||
|
[{ register: "0", assets: "0" }],
|
||||||
|
]);
|
||||||
|
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||||
|
|
||||||
|
const result = await service.readiness(actor());
|
||||||
|
|
||||||
|
const suspense = result.checks.find((c) => c.key === "suspense")!;
|
||||||
|
expect(suspense.status).toBe("PASS");
|
||||||
|
expect(suspense.detail).toContain("4 line(s)");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── No non-OPENING entries before cutover ───────────────────────────────
|
||||||
|
|
||||||
|
it("PASS when nothing predates the cutover date", async () => {
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "0", lines: "2" }],
|
||||||
|
[{ n: "0", first: null }],
|
||||||
|
[{ register: "0", assets: "0" }],
|
||||||
|
]);
|
||||||
|
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||||
|
|
||||||
|
const result = await service.readiness(actor());
|
||||||
|
|
||||||
|
const early = result.checks.find((c) => c.key === "no_pre_cutover")!;
|
||||||
|
expect(early.status).toBe("PASS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FAILs and names the count and earliest date when non-OPENING entries predate cutover", async () => {
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "0", lines: "2" }],
|
||||||
|
[{ n: "3", first: "2026-06-01" }],
|
||||||
|
[{ register: "0", assets: "0" }],
|
||||||
|
]);
|
||||||
|
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||||
|
|
||||||
|
const result = await service.readiness(actor());
|
||||||
|
|
||||||
|
const early = result.checks.find((c) => c.key === "no_pre_cutover")!;
|
||||||
|
expect(early.status).toBe("FAIL");
|
||||||
|
expect(early.detail).toContain("3");
|
||||||
|
expect(early.detail).toContain("2026-06-01");
|
||||||
|
expect(result.ready).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Asset register agrees with the ledger ───────────────────────────────
|
||||||
|
|
||||||
|
it("asset register check is PENDING when the register is empty", async () => {
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "0", lines: "2" }],
|
||||||
|
[{ n: "0", first: null }],
|
||||||
|
[{ register: "0", assets: "0" }],
|
||||||
|
]);
|
||||||
|
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||||
|
|
||||||
|
const result = await service.readiness(actor());
|
||||||
|
|
||||||
|
const assetCheck = result.checks.find((c) => c.key === "asset_register")!;
|
||||||
|
expect(assetCheck.status).toBe("PENDING");
|
||||||
|
expect(result.checks.find((c) => c.key === "migrated_assets")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PASSes when the register and the ledger's contra-ASSET balance agree within 0.005", async () => {
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "0", lines: "2" }],
|
||||||
|
[{ n: "0", first: null }],
|
||||||
|
[{ register: "1000.00", assets: "2" }],
|
||||||
|
[{ balance: "1000.00" }], // ledger contra-asset balance
|
||||||
|
[{ n: "0", codes: null }], // migrated-assets sub-check: clean
|
||||||
|
]);
|
||||||
|
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||||
|
|
||||||
|
const result = await service.readiness(actor());
|
||||||
|
|
||||||
|
const assetCheck = result.checks.find((c) => c.key === "asset_register")!;
|
||||||
|
expect(assetCheck.status).toBe("PASS");
|
||||||
|
expect(assetCheck.detail).toContain("1000.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FAILs with the register, ledger and drift figures when they disagree", async () => {
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "0", lines: "2" }],
|
||||||
|
[{ n: "0", first: null }],
|
||||||
|
[{ register: "1000.00", assets: "2" }],
|
||||||
|
[{ balance: "960.00" }],
|
||||||
|
[{ n: "0", codes: null }],
|
||||||
|
]);
|
||||||
|
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||||
|
|
||||||
|
const result = await service.readiness(actor());
|
||||||
|
|
||||||
|
const assetCheck = result.checks.find((c) => c.key === "asset_register")!;
|
||||||
|
expect(assetCheck.status).toBe("FAIL");
|
||||||
|
expect(assetCheck.detail).toContain("1000.00");
|
||||||
|
expect(assetCheck.detail).toContain("960.00");
|
||||||
|
expect(assetCheck.detail).toContain("40.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrated-assets sub-check FAILs distinctly for assets carrying accumulated depreciation with zero opening periods and no depreciation history", async () => {
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "0", lines: "2" }],
|
||||||
|
[{ n: "0", first: null }],
|
||||||
|
[{ register: "1000.00", assets: "2" }],
|
||||||
|
[{ balance: "1000.00" }], // register/ledger agree — this check PASSes
|
||||||
|
[{ n: "1", codes: "TEST-ASSET-01" }], // but a migrated asset is stalled
|
||||||
|
]);
|
||||||
|
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||||
|
|
||||||
|
const result = await service.readiness(actor());
|
||||||
|
|
||||||
|
const assetCheck = result.checks.find((c) => c.key === "asset_register")!;
|
||||||
|
const migrated = result.checks.find((c) => c.key === "migrated_assets")!;
|
||||||
|
expect(assetCheck.status).toBe("PASS");
|
||||||
|
expect(migrated.status).toBe("FAIL");
|
||||||
|
expect(migrated.detail).toContain("TEST-ASSET-01");
|
||||||
|
expect(result.ready).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ready is true only when every check is PASS", async () => {
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "0", lines: "2" }],
|
||||||
|
[{ n: "0", first: null }],
|
||||||
|
[{ register: "500.00", assets: "1" }],
|
||||||
|
[{ balance: "500.00" }],
|
||||||
|
[{ n: "0", codes: null }],
|
||||||
|
]);
|
||||||
|
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||||
|
|
||||||
|
const result = await service.readiness(actor());
|
||||||
|
|
||||||
|
expect(result.checks.every((c) => c.status === "PASS")).toBe(true);
|
||||||
|
expect(result.ready).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the organization from the token, not a client-supplied param, for a non-super-admin", async () => {
|
||||||
|
queueQueries([
|
||||||
|
[{ balance: "0", lines: "0" }],
|
||||||
|
[{ register: "0", assets: "0" }],
|
||||||
|
]);
|
||||||
|
build(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.readiness(actor({ isSuperAdmin: false }), "some-other-org"),
|
||||||
|
).rejects.toThrow(/only read or set the cutover for your own organization/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("CutoverService.importOpeningBalances", () => {
|
||||||
|
let settings: ReturnType<typeof makeSettingsRepo>;
|
||||||
|
let accounts: ReturnType<typeof makeAccounts>;
|
||||||
|
let journals: ReturnType<typeof makeJournals>;
|
||||||
|
let dataSource: { query: jest.Mock };
|
||||||
|
let service: CutoverService;
|
||||||
|
|
||||||
|
function testAccount(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "acct-" + Math.random().toString(36).slice(2),
|
||||||
|
code: "TEST-1000",
|
||||||
|
isGroup: false,
|
||||||
|
isActive: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
settings = makeSettingsRepo(null);
|
||||||
|
accounts = makeAccounts();
|
||||||
|
journals = makeJournals();
|
||||||
|
dataSource = { query: jest.fn() };
|
||||||
|
service = new CutoverService(
|
||||||
|
settings as never,
|
||||||
|
accounts as never,
|
||||||
|
journals as never,
|
||||||
|
dataSource as never,
|
||||||
|
);
|
||||||
|
journals.create.mockResolvedValue({
|
||||||
|
id: "entry-1",
|
||||||
|
status: "DRAFT",
|
||||||
|
entryNumber: "OPEN-2026-0001",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const dto = (lines: Record<string, unknown>[], overrides: Record<string, unknown> = {}) => ({
|
||||||
|
entryDate: "2026-07-08",
|
||||||
|
lines,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a caller-supplied 3900 suspense line outright", async () => {
|
||||||
|
accounts.findByCode.mockResolvedValue(testAccount({ code: "TEST-1000" }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.importOpeningBalances(
|
||||||
|
actor(),
|
||||||
|
dto([{ accountCode: "3900", debit: 100 }]) as never,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/is calculated, not entered/i);
|
||||||
|
expect(journals.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a group account", async () => {
|
||||||
|
accounts.findByCode.mockResolvedValue(testAccount({ isGroup: true }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.importOpeningBalances(
|
||||||
|
actor(),
|
||||||
|
dto([{ accountCode: "TEST-1000", debit: 100 }]) as never,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/group account/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an inactive account", async () => {
|
||||||
|
accounts.findByCode.mockResolvedValue(testAccount({ isActive: false }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.importOpeningBalances(
|
||||||
|
actor(),
|
||||||
|
dto([{ accountCode: "TEST-1000", debit: 100 }]) as never,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/inactive/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown account code", async () => {
|
||||||
|
accounts.findByCode.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.importOpeningBalances(
|
||||||
|
actor(),
|
||||||
|
dto([{ accountCode: "TEST-NOPE", debit: 100 }]) as never,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/no such account/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a line carrying both debit and credit", async () => {
|
||||||
|
accounts.findByCode.mockResolvedValue(testAccount());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.importOpeningBalances(
|
||||||
|
actor(),
|
||||||
|
dto([{ accountCode: "TEST-1000", debit: 100, credit: 50 }]) as never,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/never both/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a line carrying neither debit nor credit", async () => {
|
||||||
|
accounts.findByCode.mockResolvedValue(testAccount());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.importOpeningBalances(
|
||||||
|
actor(),
|
||||||
|
dto([{ accountCode: "TEST-1000" }]) as never,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/no amount/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes the suspense plug as totalDebit - totalCredit and credits suspense when debits exceed credits", async () => {
|
||||||
|
// Synthetic unbalanced batch: 700 debit vs 300 credit → plug of 400,
|
||||||
|
// which must land as a 400 CREDIT to suspense (debits were the larger side).
|
||||||
|
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
|
||||||
|
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
|
||||||
|
const suspense = testAccount({ id: "acct-3900", code: "3900" });
|
||||||
|
|
||||||
|
accounts.findByCode.mockImplementation((_org: string, code: string) => {
|
||||||
|
if (code === "TEST-1000") return Promise.resolve(cash);
|
||||||
|
if (code === "TEST-3000") return Promise.resolve(equity);
|
||||||
|
if (code === "3900") return Promise.resolve(suspense);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.importOpeningBalances(
|
||||||
|
actor(),
|
||||||
|
dto([
|
||||||
|
{ accountCode: "TEST-1000", debit: 700 },
|
||||||
|
{ accountCode: "TEST-3000", credit: 300 },
|
||||||
|
]) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.suspensePlug).toBe(400);
|
||||||
|
expect(result.totalDebit).toBe(700);
|
||||||
|
expect(result.totalCredit).toBe(300);
|
||||||
|
|
||||||
|
const [, createDto] = journals.create.mock.calls[0];
|
||||||
|
const plugLine = createDto.lines.find(
|
||||||
|
(l: { accountId: string }) => l.accountId === "acct-3900",
|
||||||
|
);
|
||||||
|
expect(plugLine).toBeDefined();
|
||||||
|
expect(plugLine.debit).toBe(0);
|
||||||
|
expect(plugLine.credit).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes the suspense plug as a DEBIT to suspense when credits exceed debits", async () => {
|
||||||
|
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
|
||||||
|
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
|
||||||
|
const suspense = testAccount({ id: "acct-3900", code: "3900" });
|
||||||
|
|
||||||
|
accounts.findByCode.mockImplementation((_org: string, code: string) => {
|
||||||
|
if (code === "TEST-1000") return Promise.resolve(cash);
|
||||||
|
if (code === "TEST-3000") return Promise.resolve(equity);
|
||||||
|
if (code === "3900") return Promise.resolve(suspense);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.importOpeningBalances(
|
||||||
|
actor(),
|
||||||
|
dto([
|
||||||
|
{ accountCode: "TEST-1000", debit: 300 },
|
||||||
|
{ accountCode: "TEST-3000", credit: 700 },
|
||||||
|
]) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.suspensePlug).toBe(-400);
|
||||||
|
|
||||||
|
const [, createDto] = journals.create.mock.calls[0];
|
||||||
|
const plugLine = createDto.lines.find(
|
||||||
|
(l: { accountId: string }) => l.accountId === "acct-3900",
|
||||||
|
);
|
||||||
|
expect(plugLine.debit).toBe(400);
|
||||||
|
expect(plugLine.credit).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the suspense line entirely when the batch already balances", async () => {
|
||||||
|
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
|
||||||
|
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
|
||||||
|
|
||||||
|
accounts.findByCode.mockImplementation((_org: string, code: string) => {
|
||||||
|
if (code === "TEST-1000") return Promise.resolve(cash);
|
||||||
|
if (code === "TEST-3000") return Promise.resolve(equity);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.importOpeningBalances(
|
||||||
|
actor(),
|
||||||
|
dto([
|
||||||
|
{ accountCode: "TEST-1000", debit: 500 },
|
||||||
|
{ accountCode: "TEST-3000", credit: 500 },
|
||||||
|
]) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.suspensePlug).toBe(0);
|
||||||
|
const [, createDto] = journals.create.mock.calls[0];
|
||||||
|
expect(createDto.lines).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates via journals.create producing a DRAFT and never calls journals.post directly", async () => {
|
||||||
|
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
|
||||||
|
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
|
||||||
|
|
||||||
|
accounts.findByCode.mockImplementation((_org: string, code: string) => {
|
||||||
|
if (code === "TEST-1000") return Promise.resolve(cash);
|
||||||
|
if (code === "TEST-3000") return Promise.resolve(equity);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.importOpeningBalances(
|
||||||
|
actor(),
|
||||||
|
dto([
|
||||||
|
{ accountCode: "TEST-1000", debit: 500 },
|
||||||
|
{ accountCode: "TEST-3000", credit: 500 },
|
||||||
|
]) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(journals.create).toHaveBeenCalledTimes(1);
|
||||||
|
const [, createDto] = journals.create.mock.calls[0];
|
||||||
|
expect(createDto.journalType).toBe("OPENING");
|
||||||
|
expect(result.entry.status).toBe("DRAFT");
|
||||||
|
expect(journals.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("raises a BadRequestException if the suspense account is missing from the chart when a plug is needed", async () => {
|
||||||
|
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
|
||||||
|
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
|
||||||
|
|
||||||
|
accounts.findByCode.mockImplementation((_org: string, code: string) => {
|
||||||
|
if (code === "TEST-1000") return Promise.resolve(cash);
|
||||||
|
if (code === "TEST-3000") return Promise.resolve(equity);
|
||||||
|
if (code === "3900") return Promise.resolve(null);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.importOpeningBalances(
|
||||||
|
actor(),
|
||||||
|
dto([
|
||||||
|
{ accountCode: "TEST-1000", debit: 700 },
|
||||||
|
{ accountCode: "TEST-3000", credit: 300 },
|
||||||
|
]) as never,
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
582
apps/finance-api/src/modules/journals/journals.service.spec.ts
Normal file
582
apps/finance-api/src/modules/journals/journals.service.spec.ts
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||||
|
|
||||||
|
import type { ActorContext } from "../../common/current-actor.util";
|
||||||
|
import { JournalsService } from "./journals.service";
|
||||||
|
import { JournalEntry } from "./entities/journal-entry.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors edr-freight-api's convention: plain `new ServiceClass(...)` with
|
||||||
|
* hand-built fakes passed positionally. No TestingModule, no real DB.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const actor: ActorContext = {
|
||||||
|
employeeId: "emp-1",
|
||||||
|
userId: "user-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
isSuperAdmin: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeManager() {
|
||||||
|
const entryRepo = {
|
||||||
|
create: (data: Record<string, unknown>) => data,
|
||||||
|
save: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||||
|
id: (data.id as string) ?? "entry-1",
|
||||||
|
...data,
|
||||||
|
})),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const lineRepo = {
|
||||||
|
create: (data: Record<string, unknown>) => data,
|
||||||
|
save: jest.fn().mockImplementation(async (data: unknown) => data),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
getRepository: (entity: unknown) =>
|
||||||
|
entity === JournalEntry ? entryRepo : lineRepo,
|
||||||
|
query: jest.fn().mockResolvedValue([]),
|
||||||
|
entryRepo,
|
||||||
|
lineRepo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function build() {
|
||||||
|
const entries = {
|
||||||
|
findById: jest.fn(),
|
||||||
|
findPage: jest.fn(),
|
||||||
|
nextEntryNumber: jest.fn().mockResolvedValue("JV-2026-000001"),
|
||||||
|
findBySource: jest.fn(),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
hardDelete: jest.fn(),
|
||||||
|
};
|
||||||
|
const lines = {
|
||||||
|
findByEntry: jest.fn(),
|
||||||
|
findByEntryWithAccounts: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const accounts = { assertPostable: jest.fn() };
|
||||||
|
const periods = { resolveOpenPeriodForDate: jest.fn() };
|
||||||
|
const manager = makeManager();
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||||
|
manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new JournalsService(
|
||||||
|
entries as never,
|
||||||
|
lines as never,
|
||||||
|
accounts as never,
|
||||||
|
periods as never,
|
||||||
|
dataSource as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, entries, lines, accounts, periods, dataSource, manager };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("JournalsService.post", () => {
|
||||||
|
const draftEntry = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: "entry-1",
|
||||||
|
entryNumber: "JV-2026-000001",
|
||||||
|
organizationId: "org-1",
|
||||||
|
entryDate: "2026-08-15",
|
||||||
|
status: "DRAFT",
|
||||||
|
totalDebit: 100,
|
||||||
|
totalCredit: 100,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const twoLines = () => [
|
||||||
|
{ id: "line-1", accountId: "acc-1", debit: 100, credit: 0 },
|
||||||
|
{ id: "line-2", accountId: "acc-2", debit: 0, credit: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("refuses posting an entry that is not DRAFT", async () => {
|
||||||
|
const { service, entries } = build();
|
||||||
|
entries.findById.mockResolvedValue(draftEntry({ status: "POSTED" }));
|
||||||
|
|
||||||
|
await expect(service.post(actor, "entry-1")).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires at least two lines", async () => {
|
||||||
|
const { service, entries, lines, accounts, periods } = build();
|
||||||
|
entries.findById.mockResolvedValue(draftEntry());
|
||||||
|
lines.findByEntry.mockResolvedValue([twoLines()[0]]);
|
||||||
|
|
||||||
|
await expect(service.post(actor, "entry-1")).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(accounts.assertPostable).not.toHaveBeenCalled();
|
||||||
|
expect(periods.resolveOpenPeriodForDate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-validates every line's account via accounts.assertPostable AT POSTING TIME — an account deactivated after the draft was written fails posting", async () => {
|
||||||
|
const { service, entries, lines, accounts, periods } = build();
|
||||||
|
entries.findById.mockResolvedValue(draftEntry());
|
||||||
|
lines.findByEntry.mockResolvedValue(twoLines());
|
||||||
|
// acc-1 was postable when the draft was written; acc-2 has since been
|
||||||
|
// deactivated. Nothing about the draft itself changed.
|
||||||
|
accounts.assertPostable.mockImplementation(
|
||||||
|
async (_a: unknown, accountId: string) => {
|
||||||
|
if (accountId === "acc-2") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"ACC-2 is inactive and cannot accept new postings",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { id: accountId, code: accountId, isActive: true, isGroup: false };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.post(actor, "entry-1")).rejects.toThrow(/inactive/);
|
||||||
|
// Never got as far as re-resolving the period or writing the update.
|
||||||
|
expect(periods.resolveOpenPeriodForDate).not.toHaveBeenCalled();
|
||||||
|
expect(entries.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-derives totals from the lines as they stand now, not the stored (possibly stale) header totals", async () => {
|
||||||
|
const { service, entries, lines, accounts, periods } = build();
|
||||||
|
// The header claims 100/100 from create time; the lines underneath it no
|
||||||
|
// longer agree.
|
||||||
|
entries.findById.mockResolvedValue(
|
||||||
|
draftEntry({ totalDebit: 100, totalCredit: 100 }),
|
||||||
|
);
|
||||||
|
lines.findByEntry.mockResolvedValue([
|
||||||
|
{ id: "line-1", accountId: "acc-1", debit: 150, credit: 0 },
|
||||||
|
{ id: "line-2", accountId: "acc-2", debit: 0, credit: 100 },
|
||||||
|
]);
|
||||||
|
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({
|
||||||
|
id,
|
||||||
|
isActive: true,
|
||||||
|
isGroup: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await expect(service.post(actor, "entry-1")).rejects.toThrow(
|
||||||
|
"Entry does not balance: debits 150.00, credits 100.00 — a difference of 50.00",
|
||||||
|
);
|
||||||
|
expect(periods.resolveOpenPeriodForDate).not.toHaveBeenCalled();
|
||||||
|
expect(entries.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-resolves the fiscal period from the entry date — a draft written before month-end close cannot slip into the now-closed period", async () => {
|
||||||
|
const { service, entries, lines, accounts, periods } = build();
|
||||||
|
entries.findById.mockResolvedValue(draftEntry({ entryDate: "2026-07-30" }));
|
||||||
|
lines.findByEntry.mockResolvedValue(twoLines());
|
||||||
|
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({
|
||||||
|
id,
|
||||||
|
isActive: true,
|
||||||
|
isGroup: false,
|
||||||
|
}));
|
||||||
|
periods.resolveOpenPeriodForDate.mockRejectedValue(
|
||||||
|
new BadRequestException(
|
||||||
|
"July 2026 is CLOSED — nothing further can be posted into it.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.post(actor, "entry-1")).rejects.toThrow(/CLOSED/);
|
||||||
|
expect(periods.resolveOpenPeriodForDate).toHaveBeenCalledWith(
|
||||||
|
"org-1",
|
||||||
|
"2026-07-30",
|
||||||
|
);
|
||||||
|
expect(entries.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts successfully: status POSTED, re-derived totals, the freshly-resolved period, and postedBy/postedAt stamped", async () => {
|
||||||
|
const { service, entries, lines, accounts, periods } = build();
|
||||||
|
entries.findById.mockResolvedValue(draftEntry());
|
||||||
|
lines.findByEntry.mockResolvedValue(twoLines());
|
||||||
|
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({
|
||||||
|
id,
|
||||||
|
isActive: true,
|
||||||
|
isGroup: false,
|
||||||
|
}));
|
||||||
|
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-2" });
|
||||||
|
|
||||||
|
await service.post(actor, "entry-1");
|
||||||
|
|
||||||
|
expect(entries.update).toHaveBeenCalledWith("entry-1", {
|
||||||
|
status: "POSTED",
|
||||||
|
fiscalPeriodId: "period-2",
|
||||||
|
totalDebit: 100,
|
||||||
|
totalCredit: 100,
|
||||||
|
postedBy: "emp-1",
|
||||||
|
postedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JournalsService.reverse", () => {
|
||||||
|
const originalEntry = (over: Record<string, unknown> = {}) => ({
|
||||||
|
id: "orig-1",
|
||||||
|
entryNumber: "JV-2026-000001",
|
||||||
|
organizationId: "org-1",
|
||||||
|
entryDate: "2026-01-10",
|
||||||
|
status: "POSTED",
|
||||||
|
reversedByEntryId: null,
|
||||||
|
totalDebit: 100,
|
||||||
|
totalCredit: 100,
|
||||||
|
reference: "REF-1",
|
||||||
|
sourceModule: "supplier-bill",
|
||||||
|
sourceId: "bill-42",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalLines = [
|
||||||
|
{ accountId: "acc-1", debit: 100, credit: 0, description: "Expense", costCenterId: null },
|
||||||
|
{ accountId: "acc-2", debit: 0, credit: 100, description: "Payable", costCenterId: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** entries.findById is called once for the original, then once more inside
|
||||||
|
* the closing findOne() for the freshly-created reversal. */
|
||||||
|
function wireOriginalThenReversal(
|
||||||
|
entries: ReturnType<typeof build>["entries"],
|
||||||
|
original: ReturnType<typeof originalEntry>,
|
||||||
|
) {
|
||||||
|
entries.findById
|
||||||
|
.mockResolvedValueOnce(original)
|
||||||
|
.mockResolvedValueOnce({ id: "entry-1", organizationId: "org-1" });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("refuses reversing a DRAFT — nothing has affected any balance yet", async () => {
|
||||||
|
const { service, entries, dataSource } = build();
|
||||||
|
entries.findById.mockResolvedValue(originalEntry({ status: "DRAFT" }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.reverse(actor, "orig-1", { reason: "typo" }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses reversing an entry that has already been reversed (reversedByEntryId already set)", async () => {
|
||||||
|
const { service, entries, dataSource } = build();
|
||||||
|
entries.findById.mockResolvedValue(
|
||||||
|
originalEntry({ reversedByEntryId: "some-other-reversal" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.reverse(actor, "orig-1", { reason: "typo" }),
|
||||||
|
).rejects.toThrow(/already been reversed/);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("also refuses when status is already REVERSED", async () => {
|
||||||
|
const { service, entries, dataSource } = build();
|
||||||
|
entries.findById.mockResolvedValue(originalEntry({ status: "REVERSED" }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.reverse(actor, "orig-1", { reason: "typo" }),
|
||||||
|
).rejects.toThrow(/already been reversed/);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dates the reversal TODAY by default — never backdated onto the original's date", async () => {
|
||||||
|
const { service, entries, lines, periods, manager } = build();
|
||||||
|
const original = originalEntry({ entryDate: "2026-01-10" });
|
||||||
|
wireOriginalThenReversal(entries, original);
|
||||||
|
lines.findByEntry.mockResolvedValue(originalLines);
|
||||||
|
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
|
||||||
|
|
||||||
|
await service.reverse(actor, "orig-1", { reason: "correction" });
|
||||||
|
|
||||||
|
const expectedToday = new Date().toISOString().slice(0, 10);
|
||||||
|
expect(periods.resolveOpenPeriodForDate).toHaveBeenCalledWith(
|
||||||
|
"org-1",
|
||||||
|
expectedToday,
|
||||||
|
);
|
||||||
|
const savedArg = manager.entryRepo.save.mock.calls[0][0];
|
||||||
|
expect(savedArg.entryDate).toBe(expectedToday);
|
||||||
|
expect(savedArg.entryDate).not.toBe(original.entryDate);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses an explicit reversalDate when supplied, instead of today", async () => {
|
||||||
|
const { service, entries, lines, periods, manager } = build();
|
||||||
|
wireOriginalThenReversal(entries, originalEntry());
|
||||||
|
lines.findByEntry.mockResolvedValue(originalLines);
|
||||||
|
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
|
||||||
|
|
||||||
|
await service.reverse(actor, "orig-1", {
|
||||||
|
reason: "correction",
|
||||||
|
reversalDate: "2026-02-01",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(periods.resolveOpenPeriodForDate).toHaveBeenCalledWith(
|
||||||
|
"org-1",
|
||||||
|
"2026-02-01",
|
||||||
|
);
|
||||||
|
const savedArg = manager.entryRepo.save.mock.calls[0][0];
|
||||||
|
expect(savedArg.entryDate).toBe("2026-02-01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mirrors every line — debit and credit swapped", async () => {
|
||||||
|
const { service, entries, lines, periods, manager } = build();
|
||||||
|
wireOriginalThenReversal(entries, originalEntry());
|
||||||
|
lines.findByEntry.mockResolvedValue(originalLines);
|
||||||
|
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
|
||||||
|
|
||||||
|
await service.reverse(actor, "orig-1", { reason: "correction" });
|
||||||
|
|
||||||
|
const savedLines = manager.lineRepo.save.mock.calls[0][0];
|
||||||
|
expect(savedLines).toEqual([
|
||||||
|
expect.objectContaining({ accountId: "acc-1", debit: 0, credit: 100 }),
|
||||||
|
expect.objectContaining({ accountId: "acc-2", debit: 100, credit: 0 }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT copy sourceModule/sourceId — that would collide with the idempotency key on the original", async () => {
|
||||||
|
const { service, entries, lines, periods, manager } = build();
|
||||||
|
wireOriginalThenReversal(
|
||||||
|
entries,
|
||||||
|
originalEntry({ sourceModule: "supplier-bill", sourceId: "bill-42" }),
|
||||||
|
);
|
||||||
|
lines.findByEntry.mockResolvedValue(originalLines);
|
||||||
|
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
|
||||||
|
|
||||||
|
await service.reverse(actor, "orig-1", { reason: "correction" });
|
||||||
|
|
||||||
|
const savedArg = manager.entryRepo.save.mock.calls[0][0];
|
||||||
|
expect(savedArg.sourceModule).toBeUndefined();
|
||||||
|
expect(savedArg.sourceId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the original REVERSED with reversedByEntryId pointing at the new entry, all inside one transaction", async () => {
|
||||||
|
const { service, entries, lines, periods, manager, dataSource } = build();
|
||||||
|
wireOriginalThenReversal(entries, originalEntry());
|
||||||
|
lines.findByEntry.mockResolvedValue(originalLines);
|
||||||
|
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
|
||||||
|
|
||||||
|
await service.reverse(actor, "orig-1", { reason: "correction" });
|
||||||
|
|
||||||
|
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(manager.entryRepo.update).toHaveBeenCalledWith("orig-1", {
|
||||||
|
status: "REVERSED",
|
||||||
|
reversedByEntryId: "entry-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mirrors totalDebit/totalCredit too (credit becomes debit and vice versa) and journalType REVERSAL", async () => {
|
||||||
|
const { service, entries, lines, periods, manager } = build();
|
||||||
|
wireOriginalThenReversal(
|
||||||
|
entries,
|
||||||
|
originalEntry({ totalDebit: 400, totalCredit: 400 }),
|
||||||
|
);
|
||||||
|
lines.findByEntry.mockResolvedValue(originalLines);
|
||||||
|
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
|
||||||
|
|
||||||
|
await service.reverse(actor, "orig-1", { reason: "correction" });
|
||||||
|
|
||||||
|
const savedArg = manager.entryRepo.save.mock.calls[0][0];
|
||||||
|
expect(savedArg.totalDebit).toBe(400);
|
||||||
|
expect(savedArg.totalCredit).toBe(400);
|
||||||
|
expect(savedArg.journalType).toBe("REVERSAL");
|
||||||
|
expect(savedArg.reversesEntryId).toBe("orig-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JournalsService.createPosted", () => {
|
||||||
|
const dto = () => ({
|
||||||
|
entryDate: "2026-03-05",
|
||||||
|
memo: "Test posting",
|
||||||
|
journalType: "GENERAL" as const,
|
||||||
|
lines: [
|
||||||
|
{ accountId: "acc-1", debit: 500 },
|
||||||
|
{ accountId: "acc-2", credit: 500 },
|
||||||
|
],
|
||||||
|
sourceModule: "supplier-bill",
|
||||||
|
sourceId: "bill-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
function wireHappyPath(mocks: ReturnType<typeof build>) {
|
||||||
|
mocks.accounts.assertPostable.mockImplementation(
|
||||||
|
async (_a: unknown, id: string) => ({ id, isActive: true, isGroup: false }),
|
||||||
|
);
|
||||||
|
mocks.periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-1" });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("with no manager: opens its own transaction and returns the full detail via findOne()", async () => {
|
||||||
|
const mocks = build();
|
||||||
|
wireHappyPath(mocks);
|
||||||
|
mocks.entries.findById.mockResolvedValue({
|
||||||
|
id: "entry-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
});
|
||||||
|
mocks.lines.findByEntryWithAccounts.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await mocks.service.createPosted(actor, dto());
|
||||||
|
|
||||||
|
expect(mocks.dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.id).toBe("entry-1");
|
||||||
|
expect(mocks.manager.entryRepo.save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caller-supplied manager: writes on THAT manager and reads lines back via manager.query — never opens its own transaction", async () => {
|
||||||
|
const mocks = build();
|
||||||
|
wireHappyPath(mocks);
|
||||||
|
|
||||||
|
const callerManager = makeManager();
|
||||||
|
callerManager.query.mockResolvedValue([
|
||||||
|
{ id: "line-1", accountId: "acc-1", debit: 500, credit: 0 },
|
||||||
|
{ id: "line-2", accountId: "acc-2", debit: 0, credit: 500 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await mocks.service.createPosted(
|
||||||
|
actor,
|
||||||
|
dto(),
|
||||||
|
callerManager as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The bug this guards against: a service opening its OWN nested
|
||||||
|
// transaction inside the caller's would commit independently and orphan
|
||||||
|
// rows on a later failure. It must write on the given manager instead.
|
||||||
|
expect(mocks.dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
expect(callerManager.entryRepo.save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callerManager.query).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.lines).toEqual([
|
||||||
|
{ id: "line-1", accountId: "acc-1", debit: 500, credit: 0 },
|
||||||
|
{ id: "line-2", accountId: "acc-2", debit: 0, credit: 500 },
|
||||||
|
]);
|
||||||
|
// Nothing was written on the service's own default manager.
|
||||||
|
expect(mocks.manager.entryRepo.save).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.entries.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caller-supplied manager: nextEntryNumber is generated on the SAME manager, not a fresh connection", async () => {
|
||||||
|
const mocks = build();
|
||||||
|
wireHappyPath(mocks);
|
||||||
|
const callerManager = makeManager();
|
||||||
|
|
||||||
|
await mocks.service.createPosted(actor, dto(), callerManager as never);
|
||||||
|
|
||||||
|
expect(mocks.entries.nextEntryNumber).toHaveBeenCalledWith(
|
||||||
|
"org-1",
|
||||||
|
2026,
|
||||||
|
callerManager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sourceModule/sourceId form the idempotency key on the saved entry", async () => {
|
||||||
|
const mocks = build();
|
||||||
|
wireHappyPath(mocks);
|
||||||
|
const callerManager = makeManager();
|
||||||
|
|
||||||
|
await mocks.service.createPosted(actor, dto(), callerManager as never);
|
||||||
|
|
||||||
|
const savedArg = callerManager.entryRepo.save.mock.calls[0][0];
|
||||||
|
expect(savedArg.sourceModule).toBe("supplier-bill");
|
||||||
|
expect(savedArg.sourceId).toBe("bill-1");
|
||||||
|
expect(savedArg.status).toBe("POSTED");
|
||||||
|
expect(savedArg.postedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-resolves the open period from the entry date, same as the human-posting path", async () => {
|
||||||
|
const mocks = build();
|
||||||
|
wireHappyPath(mocks);
|
||||||
|
const callerManager = makeManager();
|
||||||
|
|
||||||
|
await mocks.service.createPosted(actor, dto(), callerManager as never);
|
||||||
|
|
||||||
|
expect(mocks.periods.resolveOpenPeriodForDate).toHaveBeenCalledWith(
|
||||||
|
"org-1",
|
||||||
|
"2026-03-05",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JournalsService — assertBalanced (private)", () => {
|
||||||
|
it("rejects when total debit is not positive", () => {
|
||||||
|
const { service } = build();
|
||||||
|
expect(() => (service as never as { assertBalanced: (d: number, c: number) => void }).assertBalanced(0, 0)).toThrow(
|
||||||
|
"An entry must move a non-zero amount",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses balances()'s tolerant (sub-cent) comparison rather than strict equality", () => {
|
||||||
|
const { service } = build();
|
||||||
|
const assertBalanced = (
|
||||||
|
service as never as { assertBalanced: (d: number, c: number) => void }
|
||||||
|
).assertBalanced.bind(service);
|
||||||
|
// 0.004 difference is inside the < 0.005 tolerance from money.ts.
|
||||||
|
expect(() => assertBalanced(100, 100.004)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still rejects a genuine cent-or-more mismatch, naming both totals and the exact difference", () => {
|
||||||
|
const { service } = build();
|
||||||
|
const assertBalanced = (
|
||||||
|
service as never as { assertBalanced: (d: number, c: number) => void }
|
||||||
|
).assertBalanced.bind(service);
|
||||||
|
expect(() => assertBalanced(100, 105)).toThrow(
|
||||||
|
"Entry does not balance: debits 100.00, credits 105.00 — a difference of 5.00",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("JournalsService — normalizeLines (private)", () => {
|
||||||
|
type NormalizeLines = (
|
||||||
|
actor: ActorContext,
|
||||||
|
lines: unknown[],
|
||||||
|
) => Promise<{ accountId: string; debit: number; credit: number }[]>;
|
||||||
|
|
||||||
|
function normalizer(service: JournalsService) {
|
||||||
|
return (
|
||||||
|
service as never as { normalizeLines: NormalizeLines }
|
||||||
|
).normalizeLines.bind(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("requires at least two lines", async () => {
|
||||||
|
const { service } = build();
|
||||||
|
await expect(
|
||||||
|
normalizer(service)(actor, [{ accountId: "a1", debit: 100 }]),
|
||||||
|
).rejects.toThrow("An entry needs at least two lines");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a line carrying both a debit and a credit — a line must be one side or the other", async () => {
|
||||||
|
const { service, accounts } = build();
|
||||||
|
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({ id }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
normalizer(service)(actor, [
|
||||||
|
{ accountId: "a1", debit: 100, credit: 50 },
|
||||||
|
{ accountId: "a2", credit: 50 },
|
||||||
|
]),
|
||||||
|
).rejects.toThrow(/has both a debit and a credit/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a line carrying neither a debit nor a credit", async () => {
|
||||||
|
const { service, accounts } = build();
|
||||||
|
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({ id }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
normalizer(service)(actor, [
|
||||||
|
{ accountId: "a1" },
|
||||||
|
{ accountId: "a2", credit: 100 },
|
||||||
|
]),
|
||||||
|
).rejects.toThrow(/has no amount/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when total debit is not positive — e.g. every line is credit-only", async () => {
|
||||||
|
const { service, accounts } = build();
|
||||||
|
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({ id }));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
normalizer(service)(actor, [
|
||||||
|
{ accountId: "a1", credit: 50 },
|
||||||
|
{ accountId: "a2", credit: 50 },
|
||||||
|
]),
|
||||||
|
).rejects.toThrow("An entry must move a non-zero amount");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves each account via accounts.assertPostable and rounds every amount to the cent", async () => {
|
||||||
|
const { service, accounts } = build();
|
||||||
|
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({ id }));
|
||||||
|
|
||||||
|
const normalized = await normalizer(service)(actor, [
|
||||||
|
{ accountId: "a1", debit: 33.333333 },
|
||||||
|
{ accountId: "a2", credit: 33.333333 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(normalized[0]).toMatchObject({ accountId: "a1", debit: 33.33, credit: 0 });
|
||||||
|
expect(normalized[1]).toMatchObject({ accountId: "a2", debit: 0, credit: 33.33 });
|
||||||
|
expect(accounts.assertPostable).toHaveBeenCalledWith(actor, "a1");
|
||||||
|
expect(accounts.assertPostable).toHaveBeenCalledWith(actor, "a2");
|
||||||
|
});
|
||||||
|
});
|
||||||
444
apps/finance-api/src/modules/payables/payables.service.spec.ts
Normal file
444
apps/finance-api/src/modules/payables/payables.service.spec.ts
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -346,23 +346,33 @@ export class PayablesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = await this.journals.createPosted(actor, {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
entryDate: bill.billDate,
|
// The manager is passed through so the journal entry is written on THIS
|
||||||
journalType: "PURCHASE",
|
// transaction. Without it the entry committed on its own connection, and
|
||||||
memo: `${supplier?.name ?? "Supplier"} bill ${bill.billNumber}${
|
// a failure in the bill update below left a posted purchase entry with
|
||||||
bill.supplierInvoiceNumber ? ` (inv ${bill.supplierInvoiceNumber})` : ""
|
// no bill ever marked APPROVED to explain it.
|
||||||
}`,
|
const entry = await this.journals.createPosted(
|
||||||
reference: bill.supplierInvoiceNumber ?? bill.billNumber,
|
actor,
|
||||||
sourceModule: "supplier-bill",
|
{
|
||||||
sourceId: bill.id,
|
entryDate: bill.billDate,
|
||||||
lines: journalLines,
|
journalType: "PURCHASE",
|
||||||
});
|
memo: `${supplier?.name ?? "Supplier"} bill ${bill.billNumber}${
|
||||||
|
bill.supplierInvoiceNumber ? ` (inv ${bill.supplierInvoiceNumber})` : ""
|
||||||
|
}`,
|
||||||
|
reference: bill.supplierInvoiceNumber ?? bill.billNumber,
|
||||||
|
sourceModule: "supplier-bill",
|
||||||
|
sourceId: bill.id,
|
||||||
|
lines: journalLines,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
|
||||||
await this.bills.update(id, {
|
await manager.getRepository(SupplierBill).update(id, {
|
||||||
status: "APPROVED",
|
status: "APPROVED",
|
||||||
journalEntryId: entry.id,
|
journalEntryId: entry.id,
|
||||||
approvedBy: actor.employeeId,
|
approvedBy: actor.employeeId,
|
||||||
approvedAt: new Date(),
|
approvedAt: new Date(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.findBill(actor, id);
|
return this.findBill(actor, id);
|
||||||
@@ -420,26 +430,34 @@ export class PayablesService {
|
|||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
|
|
||||||
const entry = await this.journals.createPosted(actor, {
|
// The manager is passed through so the journal entry is written on THIS
|
||||||
entryDate: dto.paymentDate,
|
// transaction. Without it the entry committed on its own connection, and
|
||||||
journalType: "CASH_PAYMENT",
|
// a failure in the payment/bill writes below left a posted cash-payment
|
||||||
memo: `Payment to ${supplier?.name ?? "supplier"} for ${bill.billNumber}`,
|
// entry with no payment record and no paidAmount update to explain it.
|
||||||
reference: dto.reference ?? paymentNumber,
|
const entry = await this.journals.createPosted(
|
||||||
sourceModule: "supplier-payment",
|
actor,
|
||||||
sourceId: `${bill.id}:${paymentNumber}`,
|
{
|
||||||
lines: [
|
entryDate: dto.paymentDate,
|
||||||
{
|
journalType: "CASH_PAYMENT",
|
||||||
accountId: payableAccount.id,
|
memo: `Payment to ${supplier?.name ?? "supplier"} for ${bill.billNumber}`,
|
||||||
debit: amount,
|
reference: dto.reference ?? paymentNumber,
|
||||||
description: `Settle ${bill.billNumber}`,
|
sourceModule: "supplier-payment",
|
||||||
},
|
sourceId: `${bill.id}:${paymentNumber}`,
|
||||||
{
|
lines: [
|
||||||
accountId: cashAccount.id,
|
{
|
||||||
credit: amount,
|
accountId: payableAccount.id,
|
||||||
description: `${dto.method} ${dto.reference ?? ""}`.trim(),
|
debit: amount,
|
||||||
},
|
description: `Settle ${bill.billNumber}`,
|
||||||
],
|
},
|
||||||
});
|
{
|
||||||
|
accountId: cashAccount.id,
|
||||||
|
credit: amount,
|
||||||
|
description: `${dto.method} ${dto.reference ?? ""}`.trim(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
|
||||||
const repo = manager.getRepository(SupplierPayment);
|
const repo = manager.getRepository(SupplierPayment);
|
||||||
const payment = await repo.save(
|
const payment = await repo.save(
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { Nack } from "@golevelup/nestjs-rabbitmq";
|
||||||
|
import { PaymentReferenceType, PaymentService, ProviderMethod } from "@edr/types";
|
||||||
|
import type { PaymentSucceededEvent } from "@edr/types";
|
||||||
|
|
||||||
|
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||||
|
import type { PaymentPostingResult } from "./revenue-posting.service";
|
||||||
|
|
||||||
|
/** Synthetic payment event — no resemblance to any real transaction. */
|
||||||
|
function syntheticEvent(
|
||||||
|
overrides: Partial<PaymentSucceededEvent> = {},
|
||||||
|
): PaymentSucceededEvent {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
eventId: "evt-synthetic-1",
|
||||||
|
eventType: "payment.succeeded",
|
||||||
|
occurredAt: "2026-07-10T00:00:00.000Z",
|
||||||
|
service: PaymentService.FREIGHT,
|
||||||
|
intentId: "intent-synthetic-1",
|
||||||
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
|
referenceId: "shipment-synthetic-1",
|
||||||
|
merchantOrderId: "order-synthetic-1",
|
||||||
|
provider: ProviderMethod.CARD,
|
||||||
|
amountMinor: 100,
|
||||||
|
currency: "ETB",
|
||||||
|
paidAt: "2026-07-10T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeManager() {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeInbound() {
|
||||||
|
return {
|
||||||
|
claim: jest.fn(),
|
||||||
|
settle: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePosting() {
|
||||||
|
return { postPaymentReceipt: jest.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDataSource() {
|
||||||
|
const manager = makeManager();
|
||||||
|
return {
|
||||||
|
transaction: jest.fn((cb: (m: unknown) => unknown) => cb(manager)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("PaymentEventsConsumer.handle", () => {
|
||||||
|
let inbound: ReturnType<typeof makeInbound>;
|
||||||
|
let posting: ReturnType<typeof makePosting>;
|
||||||
|
let dataSource: ReturnType<typeof makeDataSource>;
|
||||||
|
let consumer: PaymentEventsConsumer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
inbound = makeInbound();
|
||||||
|
posting = makePosting();
|
||||||
|
dataSource = makeDataSource();
|
||||||
|
consumer = new PaymentEventsConsumer(
|
||||||
|
inbound as never,
|
||||||
|
posting as never,
|
||||||
|
dataSource as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dead-letters an event with no eventId — cannot dedupe without a key", async () => {
|
||||||
|
const event = syntheticEvent({ eventId: undefined as unknown as string });
|
||||||
|
|
||||||
|
const result = await consumer.handle(event);
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(Nack);
|
||||||
|
expect((result as Nack).requeue).toBe(false);
|
||||||
|
expect(inbound.claim).not.toHaveBeenCalled();
|
||||||
|
expect(posting.postPaymentReceipt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acks and skips without posting when claim() returns null — a redelivery already seen", async () => {
|
||||||
|
inbound.claim.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await consumer.handle(syntheticEvent());
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(posting.postPaymentReceipt).not.toHaveBeenCalled();
|
||||||
|
expect(inbound.settle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dead-letters when the claim insert itself fails — the broker keeps the only copy", async () => {
|
||||||
|
dataSource.transaction.mockImplementation(() => {
|
||||||
|
throw new Error("connection terminated");
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await consumer.handle(syntheticEvent());
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(Nack);
|
||||||
|
expect((result as Nack).requeue).toBe(false);
|
||||||
|
expect(posting.postPaymentReceipt).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acks a successful claim followed by a POSTED outcome, and records it", async () => {
|
||||||
|
inbound.claim.mockResolvedValue("inbound-1");
|
||||||
|
posting.postPaymentReceipt.mockResolvedValue({
|
||||||
|
outcome: "POSTED",
|
||||||
|
journalEntryId: "entry-1",
|
||||||
|
entryNumber: "CR-2026-0001",
|
||||||
|
} satisfies PaymentPostingResult);
|
||||||
|
|
||||||
|
const result = await consumer.handle(syntheticEvent());
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(inbound.settle).toHaveBeenCalledWith("inbound-1", "POSTED", {
|
||||||
|
journalEntryId: "entry-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acks a successful claim followed by a SKIPPED outcome, and records it", async () => {
|
||||||
|
inbound.claim.mockResolvedValue("inbound-1");
|
||||||
|
posting.postPaymentReceipt.mockResolvedValue({
|
||||||
|
outcome: "SKIPPED",
|
||||||
|
reason: "already captured by opening balances",
|
||||||
|
} satisfies PaymentPostingResult);
|
||||||
|
|
||||||
|
const result = await consumer.handle(syntheticEvent());
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(inbound.settle).toHaveBeenCalledWith("inbound-1", "SKIPPED", {
|
||||||
|
error: "already captured by opening balances",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acks — never nacks — a successful claim followed by a FAILED posting outcome", async () => {
|
||||||
|
inbound.claim.mockResolvedValue("inbound-1");
|
||||||
|
posting.postPaymentReceipt.mockResolvedValue({
|
||||||
|
outcome: "FAILED",
|
||||||
|
reason: "period is closed",
|
||||||
|
} satisfies PaymentPostingResult);
|
||||||
|
|
||||||
|
const result = await consumer.handle(syntheticEvent());
|
||||||
|
|
||||||
|
// The deliberate part: a FAILED posting outcome is still acked (recorded
|
||||||
|
// for manual replay), NOT nacked/dead-lettered.
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(result).not.toBeInstanceOf(Nack);
|
||||||
|
expect(inbound.settle).toHaveBeenCalledWith("inbound-1", "FAILED", {
|
||||||
|
error: "period is closed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("acks even when posting itself throws, and records the row as FAILED best-effort", async () => {
|
||||||
|
inbound.claim.mockResolvedValue("inbound-1");
|
||||||
|
posting.postPaymentReceipt.mockRejectedValue(new Error("unexpected crash"));
|
||||||
|
|
||||||
|
const result = await consumer.handle(syntheticEvent());
|
||||||
|
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(result).not.toBeInstanceOf(Nack);
|
||||||
|
expect(inbound.settle).toHaveBeenCalledWith("inbound-1", "FAILED", {
|
||||||
|
error: "unexpected crash",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
import { Logger } from "@nestjs/common";
|
||||||
|
import { PaymentReferenceType, PaymentService, ProviderMethod } from "@edr/types";
|
||||||
|
import type { PaymentFailedEvent, PaymentSucceededEvent } from "@edr/types";
|
||||||
|
|
||||||
|
import { RevenuePostingService } from "./revenue-posting.service";
|
||||||
|
import type { ActorContext } from "../../common/current-actor.util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All test fixtures below are synthetic — invented account codes, round
|
||||||
|
* amounts and made-up organization/revenue-key names. Nothing here resembles
|
||||||
|
* a real organization's revenue figures.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ORG_ID = "org-synthetic-1";
|
||||||
|
|
||||||
|
function actor(overrides: Partial<ActorContext> = {}): ActorContext {
|
||||||
|
return {
|
||||||
|
employeeId: null,
|
||||||
|
userId: "",
|
||||||
|
organizationId: ORG_ID,
|
||||||
|
isSuperAdmin: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syntheticSucceeded(
|
||||||
|
overrides: Partial<PaymentSucceededEvent> = {},
|
||||||
|
): PaymentSucceededEvent {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
eventId: "evt-synthetic-1",
|
||||||
|
eventType: "payment.succeeded",
|
||||||
|
occurredAt: "2026-07-10T00:00:00.000Z",
|
||||||
|
service: PaymentService.FREIGHT,
|
||||||
|
intentId: "intent-synthetic-1",
|
||||||
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
|
referenceId: "shipment-synthetic-1",
|
||||||
|
merchantOrderId: "order-synthetic-1",
|
||||||
|
provider: ProviderMethod.CARD,
|
||||||
|
amountMinor: 500,
|
||||||
|
currency: "ETB",
|
||||||
|
paidAt: "2026-07-10T00:00:00.000Z",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syntheticFailed(
|
||||||
|
overrides: Partial<PaymentFailedEvent> = {},
|
||||||
|
): PaymentFailedEvent {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
eventId: "evt-synthetic-2",
|
||||||
|
eventType: "payment.failed",
|
||||||
|
occurredAt: "2026-07-10T00:00:00.000Z",
|
||||||
|
service: PaymentService.FREIGHT,
|
||||||
|
intentId: "intent-synthetic-2",
|
||||||
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
|
referenceId: "shipment-synthetic-2",
|
||||||
|
merchantOrderId: "order-synthetic-2",
|
||||||
|
provider: ProviderMethod.CARD,
|
||||||
|
amountMinor: 500,
|
||||||
|
currency: "ETB",
|
||||||
|
failureCode: "DECLINED",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function account(id: string, code: string) {
|
||||||
|
return { id, code, name: { am: "", en: "" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDeps() {
|
||||||
|
const mappings = { findMatch: jest.fn() };
|
||||||
|
const accounts = { findByCode: jest.fn(), findOne: jest.fn() };
|
||||||
|
const journals = { createPosted: jest.fn() };
|
||||||
|
const projection = { revenueByKey: jest.fn() };
|
||||||
|
const cutover = { cutoverDateFor: jest.fn().mockResolvedValue(null) };
|
||||||
|
return { mappings, accounts, journals, projection, cutover };
|
||||||
|
}
|
||||||
|
|
||||||
|
function build(deps: ReturnType<typeof makeDeps>) {
|
||||||
|
return new RevenuePostingService(
|
||||||
|
deps.mappings as never,
|
||||||
|
deps.accounts as never,
|
||||||
|
deps.journals as never,
|
||||||
|
deps.projection as never,
|
||||||
|
deps.cutover as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RevenuePostingService.postPaymentReceipt", () => {
|
||||||
|
let deps: ReturnType<typeof makeDeps>;
|
||||||
|
let service: RevenuePostingService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
deps = makeDeps();
|
||||||
|
service = build(deps);
|
||||||
|
deps.accounts.findByCode.mockImplementation(
|
||||||
|
(_org: string, code: string) => {
|
||||||
|
if (code === "1114") return Promise.resolve(account("acct-clearing", "1114"));
|
||||||
|
if (code === "1121") return Promise.resolve(account("acct-recv-freight", "1121"));
|
||||||
|
if (code === "1122")
|
||||||
|
return Promise.resolve(account("acct-recv-passenger", "1122"));
|
||||||
|
return Promise.resolve(null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SKIPs non-'payment.succeeded' events and writes no journal", async () => {
|
||||||
|
const result = await service.postPaymentReceipt(actor(), syntheticFailed());
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("SKIPPED");
|
||||||
|
expect(deps.journals.createPosted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts normally when cutoverDate is null — no boundary set", async () => {
|
||||||
|
deps.cutover.cutoverDateFor.mockResolvedValue(null);
|
||||||
|
deps.journals.createPosted.mockResolvedValue({
|
||||||
|
id: "entry-1",
|
||||||
|
entryNumber: "CR-2026-0001",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.postPaymentReceipt(actor(), syntheticSucceeded());
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("POSTED");
|
||||||
|
expect(deps.journals.createPosted).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SKIPs a payment settled before the cutover date — already captured by opening balances", async () => {
|
||||||
|
deps.cutover.cutoverDateFor.mockResolvedValue("2026-08-01");
|
||||||
|
|
||||||
|
const result = await service.postPaymentReceipt(
|
||||||
|
actor(),
|
||||||
|
syntheticSucceeded({ paidAt: "2026-07-01T00:00:00.000Z" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("SKIPPED");
|
||||||
|
if (result.outcome === "SKIPPED") {
|
||||||
|
expect(result.reason).toMatch(/before the cutover/i);
|
||||||
|
}
|
||||||
|
expect(deps.journals.createPosted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts a payment settled ON or after the cutover date", async () => {
|
||||||
|
deps.cutover.cutoverDateFor.mockResolvedValue("2026-07-01");
|
||||||
|
deps.journals.createPosted.mockResolvedValue({
|
||||||
|
id: "entry-1",
|
||||||
|
entryNumber: "CR-2026-0001",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.postPaymentReceipt(
|
||||||
|
actor(),
|
||||||
|
syntheticSucceeded({ paidAt: "2026-07-01T00:00:00.000Z" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("POSTED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FAILs a non-ETB event rather than silently converting at a guessed rate", async () => {
|
||||||
|
const result = await service.postPaymentReceipt(
|
||||||
|
actor(),
|
||||||
|
syntheticSucceeded({ currency: "USD" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("FAILED");
|
||||||
|
if (result.outcome === "FAILED") {
|
||||||
|
expect(result.reason).toMatch(/USD/);
|
||||||
|
expect(result.reason).toMatch(/ETB/);
|
||||||
|
}
|
||||||
|
expect(deps.journals.createPosted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FAILs a non-positive amountMinor (which carries MAJOR units on this path, despite the name)", async () => {
|
||||||
|
const result = await service.postPaymentReceipt(
|
||||||
|
actor(),
|
||||||
|
syntheticSucceeded({ amountMinor: 0 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("FAILED");
|
||||||
|
if (result.outcome === "FAILED") {
|
||||||
|
expect(result.reason).toMatch(/non-positive/i);
|
||||||
|
}
|
||||||
|
expect(deps.journals.createPosted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FAILs a negative amountMinor too", async () => {
|
||||||
|
const result = await service.postPaymentReceipt(
|
||||||
|
actor(),
|
||||||
|
syntheticSucceeded({ amountMinor: -50 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("FAILED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts Dr 1114 gateway-clearing / Cr 1121 receivable for a FREIGHT service payment, with the idempotency key set", async () => {
|
||||||
|
deps.journals.createPosted.mockResolvedValue({
|
||||||
|
id: "entry-1",
|
||||||
|
entryNumber: "CR-2026-0001",
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = syntheticSucceeded({ service: PaymentService.FREIGHT, amountMinor: 750 });
|
||||||
|
const result = await service.postPaymentReceipt(actor(), event);
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("POSTED");
|
||||||
|
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
|
||||||
|
expect(postedDto.sourceModule).toBe("payment");
|
||||||
|
expect(postedDto.sourceId).toBe(event.eventId);
|
||||||
|
const debitLine = postedDto.lines.find((l: { debit?: number }) => l.debit);
|
||||||
|
const creditLine = postedDto.lines.find((l: { credit?: number }) => l.credit);
|
||||||
|
expect(debitLine.accountId).toBe("acct-clearing");
|
||||||
|
expect(debitLine.debit).toBe(750);
|
||||||
|
expect(creditLine.accountId).toBe("acct-recv-freight");
|
||||||
|
expect(creditLine.credit).toBe(750);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts Cr 1122 receivable for a PASSENGER service payment", async () => {
|
||||||
|
deps.journals.createPosted.mockResolvedValue({
|
||||||
|
id: "entry-1",
|
||||||
|
entryNumber: "CR-2026-0001",
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = syntheticSucceeded({ service: PaymentService.PASSENGER });
|
||||||
|
await service.postPaymentReceipt(actor(), event);
|
||||||
|
|
||||||
|
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
|
||||||
|
const creditLine = postedDto.lines.find((l: { credit?: number }) => l.credit);
|
||||||
|
expect(creditLine.accountId).toBe("acct-recv-passenger");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches any exception during posting and returns a FAILED result instead of throwing", async () => {
|
||||||
|
deps.journals.createPosted.mockRejectedValue(
|
||||||
|
new Error("period 2026-07 is closed"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.postPaymentReceipt(actor(), syntheticSucceeded());
|
||||||
|
|
||||||
|
expect(result.outcome).toBe("FAILED");
|
||||||
|
if (result.outcome === "FAILED") {
|
||||||
|
expect(result.reason).toBe("period 2026-07 is closed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw/reject even though createPosted rejected", async () => {
|
||||||
|
deps.journals.createPosted.mockRejectedValue(new Error("boom"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.postPaymentReceipt(actor(), syntheticSucceeded()),
|
||||||
|
).resolves.toMatchObject({ outcome: "FAILED" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("RevenuePostingService.recognizeRevenue", () => {
|
||||||
|
let deps: ReturnType<typeof makeDeps>;
|
||||||
|
let service: RevenuePostingService;
|
||||||
|
|
||||||
|
const receivableFreight = account("acct-recv-freight", "1121");
|
||||||
|
const unclassified = account("acct-4900", "4900");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
deps = makeDeps();
|
||||||
|
service = build(deps);
|
||||||
|
deps.accounts.findByCode.mockImplementation(
|
||||||
|
(_org: string, code: string) => {
|
||||||
|
if (code === "1121") return Promise.resolve(receivableFreight);
|
||||||
|
if (code === "1122") return Promise.resolve(account("acct-recv-passenger", "1122"));
|
||||||
|
if (code === "4900") return Promise.resolve(unclassified);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters buckets to the requested sourceModule only", async () => {
|
||||||
|
deps.projection.revenueByKey.mockResolvedValue([
|
||||||
|
{
|
||||||
|
sourceModule: "freight",
|
||||||
|
revenueKey: "TEST-FREIGHT-CHARGE",
|
||||||
|
currency: "ETB",
|
||||||
|
amount: 1000,
|
||||||
|
documentCount: 1,
|
||||||
|
postable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceModule: "passenger",
|
||||||
|
revenueKey: "TEST-PASSENGER-FARE",
|
||||||
|
currency: "ETB",
|
||||||
|
amount: 2000,
|
||||||
|
documentCount: 1,
|
||||||
|
postable: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
deps.mappings.findMatch.mockResolvedValue(null);
|
||||||
|
deps.journals.createPosted.mockResolvedValue({
|
||||||
|
id: "entry-1",
|
||||||
|
entryNumber: "SALES-2026-0001",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.recognizeRevenue(actor(), {
|
||||||
|
sourceModule: "freight",
|
||||||
|
period: "2026-07",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.total).toBe(1000);
|
||||||
|
expect(deps.mappings.findMatch).toHaveBeenCalledWith(
|
||||||
|
ORG_ID,
|
||||||
|
"freight",
|
||||||
|
"TEST-FREIGHT-CHARGE",
|
||||||
|
);
|
||||||
|
expect(deps.mappings.findMatch).not.toHaveBeenCalledWith(
|
||||||
|
ORG_ID,
|
||||||
|
"freight",
|
||||||
|
"TEST-PASSENGER-FARE",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes non-ETB and non-positive buckets into excluded[] instead of failing the whole run", async () => {
|
||||||
|
deps.projection.revenueByKey.mockResolvedValue([
|
||||||
|
{
|
||||||
|
sourceModule: "freight",
|
||||||
|
revenueKey: "TEST-USD-CHARGE",
|
||||||
|
currency: "USD",
|
||||||
|
amount: 500,
|
||||||
|
documentCount: 1,
|
||||||
|
postable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceModule: "freight",
|
||||||
|
revenueKey: "TEST-ZERO-CHARGE",
|
||||||
|
currency: "ETB",
|
||||||
|
amount: 0,
|
||||||
|
documentCount: 1,
|
||||||
|
postable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceModule: "freight",
|
||||||
|
revenueKey: "TEST-GOOD-CHARGE",
|
||||||
|
currency: "ETB",
|
||||||
|
amount: 300,
|
||||||
|
documentCount: 1,
|
||||||
|
postable: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
deps.mappings.findMatch.mockResolvedValue(null);
|
||||||
|
deps.journals.createPosted.mockResolvedValue({
|
||||||
|
id: "entry-1",
|
||||||
|
entryNumber: "SALES-2026-0001",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.recognizeRevenue(actor(), {
|
||||||
|
sourceModule: "freight",
|
||||||
|
period: "2026-07",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.posted).toBe(true);
|
||||||
|
expect(result.total).toBe(300);
|
||||||
|
expect(result.excluded).toHaveLength(2);
|
||||||
|
expect(result.excluded.map((e) => e.revenueKey).sort()).toEqual(
|
||||||
|
["TEST-USD-CHARGE", "TEST-ZERO-CHARGE"].sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns posted:false with no journal call when every bucket is excluded", async () => {
|
||||||
|
deps.projection.revenueByKey.mockResolvedValue([
|
||||||
|
{
|
||||||
|
sourceModule: "freight",
|
||||||
|
revenueKey: "TEST-USD-CHARGE",
|
||||||
|
currency: "USD",
|
||||||
|
amount: 500,
|
||||||
|
documentCount: 1,
|
||||||
|
postable: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.recognizeRevenue(actor(), {
|
||||||
|
sourceModule: "freight",
|
||||||
|
period: "2026-07",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.posted).toBe(false);
|
||||||
|
expect(deps.journals.createPosted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back an unmapped revenue key to 4900 Unclassified Revenue and logs a warning, without failing", async () => {
|
||||||
|
const warnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined);
|
||||||
|
deps.projection.revenueByKey.mockResolvedValue([
|
||||||
|
{
|
||||||
|
sourceModule: "freight",
|
||||||
|
revenueKey: "TEST-UNMAPPED-CHARGE",
|
||||||
|
currency: "ETB",
|
||||||
|
amount: 400,
|
||||||
|
documentCount: 1,
|
||||||
|
postable: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
deps.mappings.findMatch.mockResolvedValue(null);
|
||||||
|
deps.journals.createPosted.mockResolvedValue({
|
||||||
|
id: "entry-1",
|
||||||
|
entryNumber: "SALES-2026-0001",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.recognizeRevenue(actor(), {
|
||||||
|
sourceModule: "freight",
|
||||||
|
period: "2026-07",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.posted).toBe(true);
|
||||||
|
expect(result.lines).toHaveLength(1);
|
||||||
|
expect(result.lines[0].accountCode).toBe("4900");
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes multiple revenue keys mapped to the same account into ONE journal line", async () => {
|
||||||
|
deps.projection.revenueByKey.mockResolvedValue([
|
||||||
|
{
|
||||||
|
sourceModule: "freight",
|
||||||
|
revenueKey: "TEST-CHARGE-A",
|
||||||
|
currency: "ETB",
|
||||||
|
amount: 100,
|
||||||
|
documentCount: 1,
|
||||||
|
postable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sourceModule: "freight",
|
||||||
|
revenueKey: "TEST-CHARGE-B",
|
||||||
|
currency: "ETB",
|
||||||
|
amount: 250,
|
||||||
|
documentCount: 1,
|
||||||
|
postable: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const revenueAccount = account("acct-revenue-x", "4100");
|
||||||
|
deps.mappings.findMatch.mockImplementation(
|
||||||
|
(_org: string, _src: string, key: string) =>
|
||||||
|
Promise.resolve({ accountId: revenueAccount.id, matchValue: key }),
|
||||||
|
);
|
||||||
|
deps.accounts.findOne.mockResolvedValue(revenueAccount);
|
||||||
|
deps.journals.createPosted.mockResolvedValue({
|
||||||
|
id: "entry-1",
|
||||||
|
entryNumber: "SALES-2026-0001",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.recognizeRevenue(actor(), {
|
||||||
|
sourceModule: "freight",
|
||||||
|
period: "2026-07",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.lines).toHaveLength(1);
|
||||||
|
expect(result.lines[0].accountCode).toBe("4100");
|
||||||
|
expect(result.lines[0].amount).toBe(350);
|
||||||
|
expect(result.lines[0].revenueKey).toContain("TEST-CHARGE-A");
|
||||||
|
expect(result.lines[0].revenueKey).toContain("TEST-CHARGE-B");
|
||||||
|
|
||||||
|
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
|
||||||
|
const revenueCreditLines = postedDto.lines.filter(
|
||||||
|
(l: { accountId: string }) => l.accountId === revenueAccount.id,
|
||||||
|
);
|
||||||
|
expect(revenueCreditLines).toHaveLength(1);
|
||||||
|
expect(revenueCreditLines[0].credit).toBe(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dates the entry the LAST DAY OF THE PERIOD, not today", async () => {
|
||||||
|
// "Today" is deliberately a different month than the period being
|
||||||
|
// recognized, so a bug that used `new Date()` for entryDate would fail
|
||||||
|
// this assertion.
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date("2026-01-15T00:00:00.000Z"));
|
||||||
|
|
||||||
|
deps.projection.revenueByKey.mockResolvedValue([
|
||||||
|
{
|
||||||
|
sourceModule: "freight",
|
||||||
|
revenueKey: "TEST-CHARGE-A",
|
||||||
|
currency: "ETB",
|
||||||
|
amount: 100,
|
||||||
|
documentCount: 1,
|
||||||
|
postable: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
deps.mappings.findMatch.mockResolvedValue(null);
|
||||||
|
deps.journals.createPosted.mockResolvedValue({
|
||||||
|
id: "entry-1",
|
||||||
|
entryNumber: "SALES-2026-0001",
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.recognizeRevenue(actor(), {
|
||||||
|
sourceModule: "freight",
|
||||||
|
period: "2026-07",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
|
||||||
|
expect(postedDto.entryDate).toBe("2026-07-31");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Dr's the receivable control account for the total and Cr's each revenue line", async () => {
|
||||||
|
deps.projection.revenueByKey.mockResolvedValue([
|
||||||
|
{
|
||||||
|
sourceModule: "freight",
|
||||||
|
revenueKey: "TEST-CHARGE-A",
|
||||||
|
currency: "ETB",
|
||||||
|
amount: 100,
|
||||||
|
documentCount: 1,
|
||||||
|
postable: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
deps.mappings.findMatch.mockResolvedValue(null);
|
||||||
|
deps.journals.createPosted.mockResolvedValue({
|
||||||
|
id: "entry-1",
|
||||||
|
entryNumber: "SALES-2026-0001",
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.recognizeRevenue(actor(), {
|
||||||
|
sourceModule: "freight",
|
||||||
|
period: "2026-07",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
|
||||||
|
const receivableLine = postedDto.lines.find(
|
||||||
|
(l: { accountId: string }) => l.accountId === receivableFreight.id,
|
||||||
|
);
|
||||||
|
expect(receivableLine.debit).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,7 +85,11 @@ export function AssetsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{can(FINANCE_PERMS.asset.depreciate) && (
|
{can(FINANCE_PERMS.asset.depreciate) && (
|
||||||
<Button variant="light" onClick={() => setRunOpen(true)}>
|
<Button
|
||||||
|
data-testid="run-depreciation-btn"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => setRunOpen(true)}
|
||||||
|
>
|
||||||
Run depreciation
|
Run depreciation
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -135,7 +139,7 @@ export function AssetsPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{(assets.data ?? []).map((a) => (
|
{(assets.data ?? []).map((a) => (
|
||||||
<Table.Tr key={a.id}>
|
<Table.Tr key={a.id} data-testid={`asset-row-${a.id}`}>
|
||||||
<Table.Td><Text ff="monospace" size="sm">{a.assetCode}</Text></Table.Td>
|
<Table.Td><Text ff="monospace" size="sm">{a.assetCode}</Text></Table.Td>
|
||||||
<Table.Td>{a.name}</Table.Td>
|
<Table.Td>{a.name}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -160,6 +164,7 @@ export function AssetsPage() {
|
|||||||
{a.status !== "DISPOSED" && a.status !== "WRITTEN_OFF" &&
|
{a.status !== "DISPOSED" && a.status !== "WRITTEN_OFF" &&
|
||||||
can(FINANCE_PERMS.asset.dispose) && (
|
can(FINANCE_PERMS.asset.dispose) && (
|
||||||
<Button
|
<Button
|
||||||
|
data-testid={`dispose-btn-${a.id}`}
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="light"
|
variant="light"
|
||||||
color="orange"
|
color="orange"
|
||||||
@@ -394,7 +399,12 @@ function RunModal({
|
|||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||||
<Button loading={run.isPending} disabled={!periodId} onClick={() => run.mutate()}>
|
<Button
|
||||||
|
data-testid="run-depreciation-confirm-btn"
|
||||||
|
loading={run.isPending}
|
||||||
|
disabled={!periodId}
|
||||||
|
onClick={() => run.mutate()}
|
||||||
|
>
|
||||||
Run
|
Run
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -106,12 +106,18 @@ export function BudgetsPage() {
|
|||||||
)}
|
)}
|
||||||
{isDraft &&
|
{isDraft &&
|
||||||
(can(FINANCE_PERMS.budget.approve) ? (
|
(can(FINANCE_PERMS.budget.approve) ? (
|
||||||
<Button loading={approve.isPending} onClick={() => approve.mutate()}>
|
<Button
|
||||||
|
data-testid="budget-approve-btn"
|
||||||
|
loading={approve.isPending}
|
||||||
|
onClick={() => approve.mutate()}
|
||||||
|
>
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip label="Approving a budget is a separate authorisation from preparing it.">
|
<Tooltip label="Approving a budget is a separate authorisation from preparing it.">
|
||||||
<Button disabled>Approve</Button>
|
<Button data-testid="budget-approve-btn" disabled>
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{can(FINANCE_PERMS.budget.manage) && (
|
{can(FINANCE_PERMS.budget.manage) && (
|
||||||
@@ -138,6 +144,7 @@ export function BudgetsPage() {
|
|||||||
<Group mb="md" gap="sm" align="flex-end">
|
<Group mb="md" gap="sm" align="flex-end">
|
||||||
<Select
|
<Select
|
||||||
label="Budget"
|
label="Budget"
|
||||||
|
data-testid="budget-select"
|
||||||
data={(budgets.data ?? []).map((b) => ({
|
data={(budgets.data ?? []).map((b) => ({
|
||||||
value: b.id,
|
value: b.id,
|
||||||
label: `${b.name} (${b.status})`,
|
label: `${b.name} (${b.status})`,
|
||||||
@@ -228,7 +235,10 @@ export function BudgetsPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<Table.Tr key={`${r.accountId}-${r.costCenterId ?? "none"}`}>
|
<Table.Tr
|
||||||
|
key={`${r.accountId}-${r.costCenterId ?? "none"}`}
|
||||||
|
data-testid={`budget-variance-row-${r.accountId}-${r.costCenterId ?? "none"}`}
|
||||||
|
>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text ff="monospace" size="sm">{r.accountCode}</Text>
|
<Text ff="monospace" size="sm">{r.accountCode}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -303,7 +313,7 @@ export function BudgetsPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{(budget.data?.lines ?? []).map((l) => (
|
{(budget.data?.lines ?? []).map((l) => (
|
||||||
<Table.Tr key={l.id}>
|
<Table.Tr key={l.id} data-testid={`budget-line-row-${l.id}`}>
|
||||||
<Table.Td><Text ff="monospace" size="sm">{l.accountCode}</Text></Table.Td>
|
<Table.Td><Text ff="monospace" size="sm">{l.accountCode}</Text></Table.Td>
|
||||||
<Table.Td>{l.accountName?.en}</Table.Td>
|
<Table.Td>{l.accountName?.en}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function CutoverPage() {
|
|||||||
description="Going live: the opening balances, and whether the books are ready to carry them."
|
description="Going live: the opening balances, and whether the books are ready to carry them."
|
||||||
actions={
|
actions={
|
||||||
canManage ? (
|
canManage ? (
|
||||||
<Button onClick={() => setDateOpen(true)}>
|
<Button data-testid="cutover-set-date-btn" onClick={() => setDateOpen(true)}>
|
||||||
{cutoverDate ? "Change cutover date" : "Set cutover date"}
|
{cutoverDate ? "Change cutover date" : "Set cutover date"}
|
||||||
</Button>
|
</Button>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -159,7 +159,12 @@ export function CutoverPage() {
|
|||||||
{(readiness.data?.checks ?? []).map((check) => {
|
{(readiness.data?.checks ?? []).map((check) => {
|
||||||
const Icon = CHECK_ICON[check.status];
|
const Icon = CHECK_ICON[check.status];
|
||||||
return (
|
return (
|
||||||
<Group key={check.key} align="flex-start" wrap="nowrap">
|
<Group
|
||||||
|
key={check.key}
|
||||||
|
data-testid={`cutover-readiness-check-${check.key}`}
|
||||||
|
align="flex-start"
|
||||||
|
wrap="nowrap"
|
||||||
|
>
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
size="sm"
|
size="sm"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
@@ -255,6 +260,7 @@ export function CutoverPage() {
|
|||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Rows"
|
label="Rows"
|
||||||
|
data-testid="cutover-import-paste"
|
||||||
placeholder={SAMPLE}
|
placeholder={SAMPLE}
|
||||||
autosize
|
autosize
|
||||||
minRows={6}
|
minRows={6}
|
||||||
@@ -264,7 +270,7 @@ export function CutoverPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{parsed.length > 0 ? (
|
{parsed.length > 0 ? (
|
||||||
<>
|
<div data-testid="cutover-import-preview">
|
||||||
{bad.length > 0 ? (
|
{bad.length > 0 ? (
|
||||||
<Alert color="red" mt="md" title={`${bad.length} row(s) could not be read`}>
|
<Alert color="red" mt="md" title={`${bad.length} row(s) could not be read`}>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
@@ -380,7 +386,7 @@ export function CutoverPage() {
|
|||||||
put a wrong plug in the ledger.
|
put a wrong plug in the ledger.
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -130,12 +130,18 @@ export function JournalDetailPage() {
|
|||||||
rather than left as an absence. */}
|
rather than left as an absence. */}
|
||||||
{isDraft &&
|
{isDraft &&
|
||||||
(can(FINANCE_PERMS.journal.post) ? (
|
(can(FINANCE_PERMS.journal.post) ? (
|
||||||
<Button loading={post.isPending} onClick={() => post.mutate()}>
|
<Button
|
||||||
|
data-testid="journal-post-btn"
|
||||||
|
loading={post.isPending}
|
||||||
|
onClick={() => post.mutate()}
|
||||||
|
>
|
||||||
Post to ledger
|
Post to ledger
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip label="Posting is a separate approval from preparing. Ask someone who holds it.">
|
<Tooltip label="Posting is a separate approval from preparing. Ask someone who holds it.">
|
||||||
<Button disabled>Post to ledger</Button>
|
<Button data-testid="journal-post-btn" disabled>
|
||||||
|
Post to ledger
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export function NewJournalPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{lines.map((line) => (
|
{lines.map((line) => (
|
||||||
<Table.Tr key={line.key}>
|
<Table.Tr key={line.key} data-testid={`journal-line-row-${line.key}`}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Select
|
<Select
|
||||||
placeholder="Select an account"
|
placeholder="Select an account"
|
||||||
@@ -293,11 +293,21 @@ export function NewJournalPage() {
|
|||||||
Enter the debits and credits.
|
Enter the debits and credits.
|
||||||
</Text>
|
</Text>
|
||||||
) : balanced ? (
|
) : balanced ? (
|
||||||
<Alert color="green" variant="light" py={6}>
|
<Alert
|
||||||
|
color="green"
|
||||||
|
variant="light"
|
||||||
|
py={6}
|
||||||
|
data-testid="journal-balance-indicator"
|
||||||
|
>
|
||||||
Balanced — {formatMoney(totals.debit)} on each side
|
Balanced — {formatMoney(totals.debit)} on each side
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Alert color="red" variant="light" py={6}>
|
<Alert
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
py={6}
|
||||||
|
data-testid="journal-balance-indicator"
|
||||||
|
>
|
||||||
Out of balance by {formatMoney(Math.abs(totals.difference))} —{" "}
|
Out of balance by {formatMoney(Math.abs(totals.difference))} —{" "}
|
||||||
{totals.difference > 0 ? "credits" : "debits"} are short
|
{totals.difference > 0 ? "credits" : "debits"} are short
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export function PayablesPage() {
|
|||||||
return (
|
return (
|
||||||
<Table.Tr
|
<Table.Tr
|
||||||
key={b.id}
|
key={b.id}
|
||||||
|
data-testid={`bill-row-${b.id}`}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
onClick={() => setOpenBillId(b.id)}
|
onClick={() => setOpenBillId(b.id)}
|
||||||
>
|
>
|
||||||
@@ -390,18 +391,26 @@ function BillDrawer({
|
|||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
{data.status === "DRAFT" &&
|
{data.status === "DRAFT" &&
|
||||||
(can(FINANCE_PERMS.payable.approveBill) ? (
|
(can(FINANCE_PERMS.payable.approveBill) ? (
|
||||||
<Button loading={approve.isPending} onClick={() => approve.mutate()}>
|
<Button
|
||||||
|
data-testid="bill-approve-btn"
|
||||||
|
loading={approve.isPending}
|
||||||
|
onClick={() => approve.mutate()}
|
||||||
|
>
|
||||||
Approve & post
|
Approve & post
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Tooltip label="Approving is a separate authorisation from entering the bill. Ask someone who holds it.">
|
<Tooltip label="Approving is a separate authorisation from entering the bill. Ask someone who holds it.">
|
||||||
<Button disabled>Approve & post</Button>
|
<Button data-testid="bill-approve-btn" disabled>
|
||||||
|
Approve & post
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
{outstanding > 0 &&
|
{outstanding > 0 &&
|
||||||
data.status !== "DRAFT" &&
|
data.status !== "DRAFT" &&
|
||||||
can(FINANCE_PERMS.payable.recordPayment) && (
|
can(FINANCE_PERMS.payable.recordPayment) && (
|
||||||
<Button onClick={() => setPayOpen(true)}>Record payment</Button>
|
<Button data-testid="bill-record-payment-btn" onClick={() => setPayOpen(true)}>
|
||||||
|
Record payment
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -508,6 +517,7 @@ function PaymentModal({
|
|||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="bill-record-payment-confirm-btn"
|
||||||
loading={pay.isPending}
|
loading={pay.isPending}
|
||||||
disabled={!accountId || !amount}
|
disabled={!accountId || !amount}
|
||||||
onClick={() => pay.mutate()}
|
onClick={() => pay.mutate()}
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export function ReportsPage() {
|
|||||||
// unused side of a one-sided row stays blank.
|
// unused side of a one-sided row stays blank.
|
||||||
const netZero = !Number(r.debit) && !Number(r.credit);
|
const netZero = !Number(r.debit) && !Number(r.credit);
|
||||||
return (
|
return (
|
||||||
<Table.Tr key={r.accountCode}>
|
<Table.Tr key={r.accountCode} data-testid={`report-trial-balance-row-${r.accountCode}`}>
|
||||||
<Table.Td><Text ff="monospace" size="sm">{r.accountCode}</Text></Table.Td>
|
<Table.Td><Text ff="monospace" size="sm">{r.accountCode}</Text></Table.Td>
|
||||||
<Table.Td>{r.accountName?.en}</Table.Td>
|
<Table.Td>{r.accountName?.en}</Table.Td>
|
||||||
<Table.Td><Text size="sm" c="dimmed">{r.accountType}</Text></Table.Td>
|
<Table.Td><Text size="sm" c="dimmed">{r.accountType}</Text></Table.Td>
|
||||||
@@ -240,9 +240,19 @@ export function ReportsPage() {
|
|||||||
{bs.data?.balanced ? (
|
{bs.data?.balanced ? (
|
||||||
<Alert color="green" variant="light" icon={<IconCheck size={18} />}>
|
<Alert color="green" variant="light" icon={<IconCheck size={18} />}>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Balanced as at {bs.data.asOf} — assets {formatMoney(bs.data.totalAssets)} =
|
Balanced as at {bs.data.asOf} — assets{" "}
|
||||||
liabilities {formatMoney(bs.data.totalLiabilities)} + equity{" "}
|
<span data-testid="report-bs-total-assets">
|
||||||
{formatMoney(bs.data.equityWithResult)}.
|
{formatMoney(bs.data.totalAssets)}
|
||||||
|
</span>{" "}
|
||||||
|
= liabilities{" "}
|
||||||
|
<span data-testid="report-bs-total-liabilities">
|
||||||
|
{formatMoney(bs.data.totalLiabilities)}
|
||||||
|
</span>{" "}
|
||||||
|
+ equity{" "}
|
||||||
|
<span data-testid="report-bs-total-equity">
|
||||||
|
{formatMoney(bs.data.equityWithResult)}
|
||||||
|
</span>
|
||||||
|
.
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -249,30 +249,64 @@ confirmed but the guard should be re-read at test-writing time.
|
|||||||
|
|
||||||
## 4. `data-testid` prerequisite checklist
|
## 4. `data-testid` prerequisite checklist
|
||||||
|
|
||||||
**Current state:** only nav-level testids exist (`nav-item-*`, `nav-group-*` from the redesign).
|
**Landed** (this pass). The inventory below was corrected against the real components while wiring
|
||||||
**Zero on any feature page.** This is the blocker that stalled the passenger effort; treat it as a
|
it in — several entries in the original draft named the wrong file or an element that doesn't exist
|
||||||
blocker, not a footnote. Note `SpotlightSearchProps` rejects `data-testid` — address the palette
|
in the shape assumed; those are noted inline. `SpotlightSearchProps` still rejects `data-testid` —
|
||||||
input by placeholder.
|
the palette input is addressed by placeholder, not a testid.
|
||||||
|
|
||||||
**HR (`apps/edr-hr-web/src/features/**`)**
|
**HR (`apps/edr-hr-web/src/features/**`)**
|
||||||
- `leave/RequestModal.tsx`: `leave-request-type`, `leave-request-dates`, `leave-request-submit`
|
- `leave/components/RequestLeaveModal.tsx` (not `leave/RequestModal.tsx` — that file doesn't exist):
|
||||||
- `leave/LeaveApprovalsPage.tsx`: `leave-approval-row-<id>`, `leave-approve-btn-<id>`, `leave-reject-btn-<id>`
|
`leave-request-type`, `leave-request-start-date`, `leave-request-end-date` (the pair sits inside a
|
||||||
- `attendance/*`: `attendance-clock-in`, `attendance-clock-out`, `attendance-regularization-row-<id>`
|
`leave-request-dates` group), `leave-request-submit`. Required a `data-testid` pass-through prop
|
||||||
- `payroll/PayrollRunsPage.tsx`: `payroll-run-row-<id>`, `payroll-calculate-btn`, `payroll-approve-btn`, `payroll-mark-paid-btn`
|
added to the shared `EthiopianDateInput` component, which didn't forward one.
|
||||||
- `appraisal/*`: `appraisal-criterion-<key>`, `appraisal-weight-total`, `appraisal-submit-self`, `appraisal-submit-manager`
|
- `leave/LeaveApprovalsPage.tsx`: `leave-approval-row-<id>`, `leave-approve-btn-<id>`,
|
||||||
- `recruitment/ApplicationDrawer.tsx`: `recruitment-stage-select`, `recruitment-hire-btn`
|
`leave-reject-btn-<id>`
|
||||||
- `reports/ReportsPage.tsx`: `report-row-<key>`, `report-export-btn`
|
- `attendance/components/ClockWidget.tsx`: `attendance-clock-in` / `attendance-clock-out` — one
|
||||||
|
button, testid set conditionally on state, not two coexisting elements.
|
||||||
|
- `attendance/AttendanceApprovalsPage.tsx`: `attendance-regularization-row-<id>`,
|
||||||
|
`attendance-regularization-approve-btn-<id>`, `attendance-regularization-reject-btn-<id>` (the
|
||||||
|
shared `Actions` component also renders the overtime tab's rows, so these testids appear there too
|
||||||
|
— harmless, just note it if a spec ever needs to disambiguate).
|
||||||
|
- `attendance/MyAttendancePage.tsx` (a **second**, distinct "regularization" UI — the employee's own
|
||||||
|
history, not the approval queue above): `attendance-record-row-<id>`,
|
||||||
|
`attendance-my-regularization-row-<id>`.
|
||||||
|
- `payroll/PayrollRunsPage.tsx`: `payroll-run-row-<id>`, and — corrected from the original bare
|
||||||
|
names, since these render once per row — `payroll-calculate-btn-<id>`, `payroll-approve-btn-<id>`,
|
||||||
|
`payroll-mark-paid-btn-<id>`.
|
||||||
|
- `appraisal/components/ScoringForm.tsx` (shared by both self- and manager-review — required adding
|
||||||
|
a new `submitTestId?` prop, threaded from each caller): `appraisal-criterion-<code>` (keyed by the
|
||||||
|
criterion's business `code`, not its DB id), `appraisal-weight-total`,
|
||||||
|
`appraisal-submit-self`/`appraisal-submit-manager` (set via the new prop in
|
||||||
|
`MyAppraisalsPage.tsx`/`AppraisalReviewsPage.tsx` respectively).
|
||||||
|
- `recruitment/components/ApplicationDrawer.tsx`: `recruitment-hire-btn`,
|
||||||
|
`recruitment-hire-confirm-btn`. **`recruitment-stage-select` does not exist** — there is no
|
||||||
|
dropdown; stage advance is a row of per-stage buttons in `recruitment/OpeningDetailPage.tsx`,
|
||||||
|
testid `recruitment-stage-advance-btn-<stage>`.
|
||||||
|
- `reports/ReportsPage.tsx`: `report-export-btn-<name>` (the shared `exportButton(name, rows)`
|
||||||
|
helper takes the testid once and every instantiation gets it automatically — 7 reports, not 6),
|
||||||
|
plus a per-report row testid keyed by each table's natural key, e.g.
|
||||||
|
`report-headcount-row-<unitId>`, `report-leave-liability-row-<employeeId>-<leaveTypeCode>`.
|
||||||
|
|
||||||
**Finance (`apps/finance-web/src/features/**`)**
|
**Finance (`apps/finance-web/src/features/**`)**
|
||||||
- `journals/NewJournalPage.tsx`: `journal-line-row-<n>`, `journal-balance-indicator`, `journal-post-btn`
|
- `journals/NewJournalPage.tsx`: `journal-line-row-<key>` (a client-generated draft key, not a DB
|
||||||
- `payables/PayablesPage.tsx`: `bill-row-<id>`, `bill-approve-btn-<id>`, `bill-record-payment-btn-<id>`
|
id — these rows have no id yet), `journal-balance-indicator` (on both the balanced and
|
||||||
- `budgeting/BudgetsPage.tsx`: `budget-row-<id>`, `budget-approve-btn`
|
out-of-balance branches). **`journal-post-btn` is NOT on this page** — this page only ever creates
|
||||||
- `assets/AssetsPage.tsx`: `asset-row-<id>`, `run-depreciation-btn`, `dispose-btn-<id>`
|
a DRAFT. It's on `journals/JournalDetailPage.tsx` instead (not in the original checklist at all).
|
||||||
- `cutover/CutoverPage.tsx`: `cutover-readiness-check-<key>`, `cutover-set-date-btn`, `cutover-import-paste`, `cutover-import-preview`
|
- `payables/PayablesPage.tsx`: `bill-row-<id>`, `bill-approve-btn` (singular — only one bill is open
|
||||||
- `reports/ReportsPage.tsx`: `report-trial-balance-row-<code>`, `report-balance-sheet-total`
|
at a time via the drawer, no `-<id>` needed), `bill-record-payment-btn`,
|
||||||
|
`bill-record-payment-confirm-btn` (on the payment modal's actual submit button).
|
||||||
A starting inventory from reading route components, not exhaustive — whoever writes each spec adds
|
- `budgeting/BudgetsPage.tsx`: **`budget-row-<id>` does not apply** — budgets are chosen via a
|
||||||
what their scenario needs in the same pass.
|
`<Select>`, not table rows. Testid is `budget-select` on that dropdown, plus `budget-approve-btn`,
|
||||||
|
`budget-variance-row-<accountId>-<costCenterId>`, `budget-line-row-<id>`.
|
||||||
|
- `assets/AssetsPage.tsx`: `asset-row-<id>`, `run-depreciation-btn` (+
|
||||||
|
`run-depreciation-confirm-btn` on the modal's Run button), `dispose-btn-<id>`.
|
||||||
|
- `cutover/CutoverPage.tsx`: `cutover-readiness-check-<key>`, `cutover-set-date-btn`,
|
||||||
|
`cutover-import-paste`, `cutover-import-preview` (required converting a conditional `<>...</>`
|
||||||
|
fragment to a `<div>` so the testid has a real node to land on).
|
||||||
|
- `reports/ReportsPage.tsx`: `report-trial-balance-row-<code>`. **`report-balance-sheet-total` was
|
||||||
|
ambiguous** — the summary sentence holds three numbers inline, not one. Split into
|
||||||
|
`report-bs-total-assets`, `report-bs-total-liabilities`, `report-bs-total-equity`, each wrapping
|
||||||
|
just its number within the original sentence.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export DB_HOST="${DB_HOST:-localhost}"
|
|||||||
export DB_PORT="${DB_PORT:-5432}"
|
export DB_PORT="${DB_PORT:-5432}"
|
||||||
export DB_USER="${DB_USER:-postgres}"
|
export DB_USER="${DB_USER:-postgres}"
|
||||||
|
|
||||||
|
# Local convenience default — CHANGE THIS to your actual local Postgres
|
||||||
|
# password, or just export DB_PASSWORD in your shell instead and leave this
|
||||||
|
# alone. Either way: NEVER commit this file with a real password in it. If
|
||||||
|
# you edit the line below, `git diff` it before any commit and revert it, or
|
||||||
|
# keep the password out of git entirely by exporting DB_PASSWORD instead.
|
||||||
|
export DB_PASSWORD="${DB_PASSWORD:-TradingTria@2090}"
|
||||||
|
|
||||||
# A checked-in password reaching the production replica would be a real
|
# A checked-in password reaching the production replica would be a real
|
||||||
# incident, not an inconvenience. Refuse early and unmistakably.
|
# incident, not an inconvenience. Refuse early and unmistakably.
|
||||||
if [[ "$DB_NAME" == "smart_office_prod" ]]; then
|
if [[ "$DB_NAME" == "smart_office_prod" ]]; then
|
||||||
@@ -26,8 +33,8 @@ if [[ "$DB_NAME" == "smart_office_prod" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${DB_PASSWORD:-}" ]]; then
|
if [[ -z "${DB_PASSWORD:-}" || "$DB_PASSWORD" == "CHANGE_ME_DB_PASSWORD" ]]; then
|
||||||
echo "DB_PASSWORD is required (the seeders connect to $DB_NAME)" >&2
|
echo "DB_PASSWORD is required (the seeders connect to $DB_NAME) — edit the placeholder near the top of this script, or export DB_PASSWORD instead" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
304
e2e-hr-finance/specs/finance/asset-migrated-depreciates.spec.ts
Normal file
304
e2e-hr-finance/specs/finance/asset-migrated-depreciates.spec.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||||
|
import { query } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIN-04 — a migrated asset (accumulated depreciation carried in from
|
||||||
|
* cutover, no `depreciation_entries` rows of its own) still depreciates.
|
||||||
|
*
|
||||||
|
* The mechanism under test lives in `FixedAssetsRepository.findDepreciable`
|
||||||
|
* and `depreciationFor` (depreciation.calculator.ts):
|
||||||
|
* `periodsCharged = opening_periods_charged + COUNT(depreciation_entries)`.
|
||||||
|
* Without `openingPeriodsCharged` a migrated asset's count would read zero,
|
||||||
|
* the cumulative target would land BELOW what is already accumulated, the
|
||||||
|
* computed charge would be <= 0 and skipped — and a skip writes no row, so
|
||||||
|
* the count could never grow. The asset would silently never depreciate
|
||||||
|
* again. See the doc comments on `FixedAsset.openingPeriodsCharged` and on
|
||||||
|
* `findDepreciable` itself.
|
||||||
|
*
|
||||||
|
* A migrated asset is created via `POST /assets` with
|
||||||
|
* `openingAccumulatedDepreciation` + `openingPeriodsCharged` and NO
|
||||||
|
* `fundingAccountId` — the DTO's own doc comment says the ledger side comes
|
||||||
|
* from the opening balance entry, not from this call. This scenario supplies
|
||||||
|
* that missing ledger side itself (a balancing journal against Retained
|
||||||
|
* Earnings, never against 3900 — this file has nothing to do with
|
||||||
|
* cutover-suspense-draft-only.spec.ts and stays independent of it), because
|
||||||
|
* `runDepreciation` refuses to charge anything until the register and the
|
||||||
|
* ledger reconcile (`AssetsService.assertRegisterReconciles`).
|
||||||
|
*/
|
||||||
|
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
||||||
|
|
||||||
|
const tokenFor = async (request: any, key: string): Promise<string> => {
|
||||||
|
const persona = personaByKey(key);
|
||||||
|
const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
|
||||||
|
data: { email: persona.email, password: persona.password },
|
||||||
|
});
|
||||||
|
return (await res.json()).token;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deliberately round and obviously synthetic — see money.ts / the cutover
|
||||||
|
// spec's own convention. base = 24000 - 2400 = 21600; base / life(60) = 360
|
||||||
|
// exactly, so the expected charge has no rounding ambiguity to reason about.
|
||||||
|
const ASSET_CODE = "E2E-FIN04-MIGRATED-01";
|
||||||
|
const CATEGORY_CODE = "E2E-CAT-FIN04";
|
||||||
|
const ACQUISITION_COST = 24000;
|
||||||
|
const SALVAGE_VALUE = 2400;
|
||||||
|
const USEFUL_LIFE_MONTHS = 60;
|
||||||
|
const OPENING_PERIODS_CHARGED = 30;
|
||||||
|
const OPENING_ACCUMULATED = 10_800; // half of the 21,600 depreciable base
|
||||||
|
const EXPECTED_CHARGE = 360; // (21600 * 31/60) - 10800, exact — see above
|
||||||
|
|
||||||
|
test.describe("FIN-04 · a migrated asset still depreciates", () => {
|
||||||
|
test.use({ storageState: storageFor("finance-manager") });
|
||||||
|
|
||||||
|
test("opening_periods_charged carries a migrated asset past its cutover count", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const token = await tokenFor(request, "finance-manager");
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
|
// ── Guard: the register must already reconcile with the ledger org-wide,
|
||||||
|
// exactly what AssetsService.assertRegisterReconciles checks, replicated
|
||||||
|
// read-only here. If it does not, runDepreciation refuses for reasons that
|
||||||
|
// have nothing to do with this scenario, and this test should say so
|
||||||
|
// rather than fail confusingly.
|
||||||
|
const reconciliation = await query<{
|
||||||
|
accountId: string;
|
||||||
|
ledgerAccumulated: string;
|
||||||
|
registerAccumulated: string;
|
||||||
|
}>(
|
||||||
|
`SELECT c.accumulated_account_id AS "accountId",
|
||||||
|
ROUND(COALESCE(SUM(l.credit - l.debit), 0), 2)::text AS "ledgerAccumulated",
|
||||||
|
ROUND(COALESCE((
|
||||||
|
SELECT SUM(a2.accumulated_depreciation)
|
||||||
|
FROM finance.fixed_assets a2
|
||||||
|
WHERE a2.asset_category_id IN (
|
||||||
|
SELECT c2.id FROM finance.asset_categories c2
|
||||||
|
WHERE c2.accumulated_account_id = c.accumulated_account_id
|
||||||
|
AND c2.organization_id = $1)
|
||||||
|
AND a2.deleted_at IS NULL
|
||||||
|
AND a2.status NOT IN ('DISPOSED','WRITTEN_OFF')
|
||||||
|
), 0), 2)::text AS "registerAccumulated"
|
||||||
|
FROM finance.asset_categories c
|
||||||
|
LEFT JOIN finance.journal_lines l ON l.account_id = c.accumulated_account_id
|
||||||
|
LEFT JOIN finance.journal_entries e
|
||||||
|
ON e.id = l.journal_entry_id
|
||||||
|
AND e.status <> 'DRAFT'
|
||||||
|
AND e.deleted_at IS NULL
|
||||||
|
AND e.organization_id = $1
|
||||||
|
WHERE c.organization_id = $1 AND c.deleted_at IS NULL
|
||||||
|
GROUP BY c.accumulated_account_id`,
|
||||||
|
[ORG_ID],
|
||||||
|
);
|
||||||
|
const alreadyDrifted = reconciliation.some(
|
||||||
|
(row) =>
|
||||||
|
Math.abs(Number(row.ledgerAccumulated) - Number(row.registerAccumulated)) >= 0.005,
|
||||||
|
);
|
||||||
|
test.skip(
|
||||||
|
alreadyDrifted,
|
||||||
|
"the asset register and ledger already disagree in this environment (pre-existing, unrelated to this scenario) — runDepreciation would refuse regardless",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Resolve the three chart-of-accounts rows the category needs. These
|
||||||
|
// are real, existing seeded accounts (chart-of-accounts.seed.ts), not
|
||||||
|
// invented ones.
|
||||||
|
const [assetAccount, accumulatedAccount, expenseAccount, retainedEarnings] =
|
||||||
|
await Promise.all([
|
||||||
|
query<{ id: string }>(
|
||||||
|
`SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '1213' AND deleted_at IS NULL`,
|
||||||
|
[ORG_ID],
|
||||||
|
),
|
||||||
|
query<{ id: string }>(
|
||||||
|
`SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '1290' AND deleted_at IS NULL`,
|
||||||
|
[ORG_ID],
|
||||||
|
),
|
||||||
|
query<{ id: string }>(
|
||||||
|
`SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '5400' AND deleted_at IS NULL`,
|
||||||
|
[ORG_ID],
|
||||||
|
),
|
||||||
|
query<{ id: string }>(
|
||||||
|
`SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '3200' AND deleted_at IS NULL`,
|
||||||
|
[ORG_ID],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
test.skip(
|
||||||
|
!assetAccount[0] || !accumulatedAccount[0] || !expenseAccount[0] || !retainedEarnings[0],
|
||||||
|
"the chart of accounts is missing one of 1213/1290/5400/3200 in this organization",
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Find-or-create the category (idempotent across reruns).
|
||||||
|
let categoryId: string;
|
||||||
|
const existingCategory = await query<{ id: string }>(
|
||||||
|
`SELECT id FROM finance.asset_categories WHERE organization_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||||
|
[ORG_ID, CATEGORY_CODE],
|
||||||
|
);
|
||||||
|
if (existingCategory[0]) {
|
||||||
|
categoryId = existingCategory[0].id;
|
||||||
|
} else {
|
||||||
|
const categoryRes = await request.post(`${FINANCE_API}/api/v1/assets/categories`, {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
code: CATEGORY_CODE,
|
||||||
|
name: { en: "E2E FIN-04 category", am: "E2E FIN-04 ምድብ" },
|
||||||
|
assetAccountId: assetAccount[0].id,
|
||||||
|
accumulatedAccountId: accumulatedAccount[0].id,
|
||||||
|
expenseAccountId: expenseAccount[0].id,
|
||||||
|
defaultLifeMonths: USEFUL_LIFE_MONTHS,
|
||||||
|
defaultSalvageRate: 0.1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(categoryRes.status(), "creating the E2E-FIN04 asset category").toBeLessThan(300);
|
||||||
|
categoryId = (await categoryRes.json()).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find-or-create the migrated asset itself (idempotent across reruns).
|
||||||
|
let assetId: string;
|
||||||
|
let assetIsFresh = false;
|
||||||
|
const existingAsset = await query<{ id: string }>(
|
||||||
|
`SELECT id FROM finance.fixed_assets WHERE organization_id = $1 AND asset_code = $2 AND deleted_at IS NULL`,
|
||||||
|
[ORG_ID, ASSET_CODE],
|
||||||
|
);
|
||||||
|
if (existingAsset[0]) {
|
||||||
|
assetId = existingAsset[0].id;
|
||||||
|
} else {
|
||||||
|
assetIsFresh = true;
|
||||||
|
const assetRes = await request.post(`${FINANCE_API}/api/v1/assets`, {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
assetCategoryId: categoryId,
|
||||||
|
assetCode: ASSET_CODE,
|
||||||
|
name: "E2E-FIN-04 migrated asset (opening depreciation)",
|
||||||
|
// Deliberately in the past and NOT tied to any fiscal period —
|
||||||
|
// createAsset performs no period check at all when fundingAccountId
|
||||||
|
// is omitted (see AssetsService.createAsset).
|
||||||
|
acquisitionDate: "2015-01-01",
|
||||||
|
inServiceDate: "2015-01-01",
|
||||||
|
acquisitionCost: ACQUISITION_COST,
|
||||||
|
salvageValue: SALVAGE_VALUE,
|
||||||
|
usefulLifeMonths: USEFUL_LIFE_MONTHS,
|
||||||
|
openingAccumulatedDepreciation: OPENING_ACCUMULATED,
|
||||||
|
openingPeriodsCharged: OPENING_PERIODS_CHARGED,
|
||||||
|
// No fundingAccountId — see the DTO's own doc comment.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(assetRes.status(), "creating the migrated asset").toBeLessThan(300);
|
||||||
|
assetId = (await assetRes.json()).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (DB) The asset arrived exactly as a migrated asset should: accumulated
|
||||||
|
// depreciation > 0, opening_periods_charged > 0, and zero rows of its own
|
||||||
|
// depreciation history — the precise shape the FIN-04 regression guards.
|
||||||
|
const registered = await query<{
|
||||||
|
accumulatedDepreciation: string;
|
||||||
|
openingPeriodsCharged: string;
|
||||||
|
entryRows: string;
|
||||||
|
}>(
|
||||||
|
`SELECT a.accumulated_depreciation::text AS "accumulatedDepreciation",
|
||||||
|
a.opening_periods_charged::text AS "openingPeriodsCharged",
|
||||||
|
(SELECT count(*)::text FROM finance.depreciation_entries d
|
||||||
|
WHERE d.fixed_asset_id = a.id) AS "entryRows"
|
||||||
|
FROM finance.fixed_assets a
|
||||||
|
WHERE a.id = $1`,
|
||||||
|
[assetId],
|
||||||
|
);
|
||||||
|
expect(Number(registered[0].accumulatedDepreciation)).toBeGreaterThan(0);
|
||||||
|
expect(Number(registered[0].openingPeriodsCharged)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// ── Find an OPEN period with no depreciation run against it yet.
|
||||||
|
const candidatePeriod = await query<{ id: string; startDate: string; endDate: string }>(
|
||||||
|
`SELECT p.id, p.start_date::text AS "startDate", p.end_date::text AS "endDate"
|
||||||
|
FROM finance.fiscal_periods p
|
||||||
|
WHERE p.organization_id = $1
|
||||||
|
AND p.status = 'OPEN'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM finance.depreciation_runs r WHERE r.fiscal_period_id = p.id)
|
||||||
|
ORDER BY p.start_date ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[ORG_ID],
|
||||||
|
);
|
||||||
|
test.skip(
|
||||||
|
candidatePeriod.length === 0,
|
||||||
|
"no OPEN fiscal period without an existing depreciation run is available in this environment",
|
||||||
|
);
|
||||||
|
const period = candidatePeriod[0];
|
||||||
|
|
||||||
|
// ── Supply the ledger side of the migration, once, on first creation —
|
||||||
|
// the register/ledger reconciliation this run depends on. Both sides move
|
||||||
|
// by the SAME amount, so whatever the pre-existing drift was (checked
|
||||||
|
// above to be ~0), it is unchanged by this pair.
|
||||||
|
if (assetIsFresh) {
|
||||||
|
const catchUpMemo = `E2E-FIN-04 opening accumulated depreciation catch-up for ${ASSET_CODE}`;
|
||||||
|
const catchUp = await request.post(`${FINANCE_API}/api/v1/journals`, {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
entryDate: period.startDate,
|
||||||
|
journalType: "OPENING",
|
||||||
|
memo: catchUpMemo,
|
||||||
|
lines: [
|
||||||
|
{ accountId: retainedEarnings[0].id, debit: OPENING_ACCUMULATED, credit: 0 },
|
||||||
|
{ accountId: accumulatedAccount[0].id, debit: 0, credit: OPENING_ACCUMULATED },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(catchUp.status(), "creating the catch-up draft").toBeLessThan(300);
|
||||||
|
const catchUpId = (await catchUp.json()).id;
|
||||||
|
const posted = await request.post(
|
||||||
|
`${FINANCE_API}/api/v1/journals/${catchUpId}/post`,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
expect(posted.status(), "posting the catch-up entry").toBeLessThan(300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Act: run depreciation for the period.
|
||||||
|
const runRes = await request.post(`${FINANCE_API}/api/v1/assets/depreciation/run`, {
|
||||||
|
headers,
|
||||||
|
data: { fiscalPeriodId: period.id },
|
||||||
|
});
|
||||||
|
// (NET) Success — the whole register (every depreciable asset in the org,
|
||||||
|
// not only ours) was charged in one summarized entry.
|
||||||
|
expect(runRes.status(), "running depreciation for the period").toBeLessThan(300);
|
||||||
|
const runBody = await runRes.json();
|
||||||
|
expect(runBody.assetCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// (NET) The response, at the per-asset level, includes a non-zero charge
|
||||||
|
// for OUR migrated asset specifically — not just a nonzero org-wide total.
|
||||||
|
const entriesRes = await request.get(
|
||||||
|
`${FINANCE_API}/api/v1/assets/depreciation/runs/${runBody.runId}/entries`,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
expect(entriesRes.status()).toBe(200);
|
||||||
|
const entries: { amount: string | number; assetCode: string }[] = await entriesRes.json();
|
||||||
|
const ourEntry = entries.find((e) => e.assetCode === ASSET_CODE);
|
||||||
|
expect(ourEntry, "the run must include a line for the migrated asset").toBeDefined();
|
||||||
|
expect(Number(ourEntry!.amount)).toBe(EXPECTED_CHARGE);
|
||||||
|
|
||||||
|
// (DB) A new row landed in finance.depreciation_entries for this asset.
|
||||||
|
const depreciationRows = await query<{ amount: string }>(
|
||||||
|
`SELECT amount::text FROM finance.depreciation_entries
|
||||||
|
WHERE fixed_asset_id = $1 AND depreciation_run_id = $2`,
|
||||||
|
[assetId, runBody.runId],
|
||||||
|
);
|
||||||
|
expect(depreciationRows).toHaveLength(1);
|
||||||
|
expect(Number(depreciationRows[0].amount)).toBe(EXPECTED_CHARGE);
|
||||||
|
|
||||||
|
// (DB) periodsCharged (opening_periods_charged + COUNT(depreciation_entries))
|
||||||
|
// is now past the opening count — the asset moved, it did not stall.
|
||||||
|
const after = await query<{
|
||||||
|
periodsCharged: string;
|
||||||
|
accumulatedDepreciation: string;
|
||||||
|
}>(
|
||||||
|
`SELECT (a.opening_periods_charged
|
||||||
|
+ (SELECT COUNT(*)::int FROM finance.depreciation_entries d
|
||||||
|
WHERE d.fixed_asset_id = a.id))::text AS "periodsCharged",
|
||||||
|
a.accumulated_depreciation::text AS "accumulatedDepreciation"
|
||||||
|
FROM finance.fixed_assets a
|
||||||
|
WHERE a.id = $1`,
|
||||||
|
[assetId],
|
||||||
|
);
|
||||||
|
expect(Number(after[0].periodsCharged)).toBeGreaterThan(OPENING_PERIODS_CHARGED);
|
||||||
|
expect(Number(after[0].accumulatedDepreciation)).toBeGreaterThan(
|
||||||
|
Number(registered[0].accumulatedDepreciation),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
173
e2e-hr-finance/specs/finance/cutover-suspense-draft-only.spec.ts
Normal file
173
e2e-hr-finance/specs/finance/cutover-suspense-draft-only.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||||
|
import { query } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIN-05 — the cutover readiness "suspense" check counts POSTED lines only.
|
||||||
|
*
|
||||||
|
* `CutoverService.readiness` (apps/finance-api/src/modules/cutover/cutover.service.ts)
|
||||||
|
* sums 3900 Opening Balance Suspense with a LEFT JOIN whose posted-only filter
|
||||||
|
* sits on the JOIN's `ON` clause, not as a second `LEFT JOIN … WHERE` step —
|
||||||
|
* the comment right above that query names the exact regression this guards:
|
||||||
|
* written the other way, a DRAFT line still joins (the filter would only null
|
||||||
|
* the entry side, never exclude the line), and its amount would still be
|
||||||
|
* summed — so an unposted opening batch would read as a broken migration
|
||||||
|
* (FAIL) instead of "not started yet" (PENDING).
|
||||||
|
*
|
||||||
|
* `importOpeningBalances` (`POST /cutover/opening-balances`) creates a DRAFT
|
||||||
|
* `OPENING` entry ONLY — see its own doc comment: posting always goes through
|
||||||
|
* the normal `JournalsService.post` path, never here. So a batch that is
|
||||||
|
* never posted must leave the readiness suspense check UNCHANGED, whatever
|
||||||
|
* that check currently reads.
|
||||||
|
*
|
||||||
|
* The scenario does not assume a pristine environment (no prior cutover
|
||||||
|
* activity in 3900): it captures the check's exact status/detail BEFORE
|
||||||
|
* creating the DRAFT batch and asserts it is byte-identical AFTER — a
|
||||||
|
* regression that leaked a DRAFT line into the sum would change these numbers
|
||||||
|
* regardless of what they started at. Where the environment happens to be
|
||||||
|
* pristine (no posted line has ever touched 3900), the stronger, more literal
|
||||||
|
* assertion the plan asks for — PENDING, not FAIL — is also made directly.
|
||||||
|
*/
|
||||||
|
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
||||||
|
|
||||||
|
const tokenFor = async (request: any, key: string): Promise<string> => {
|
||||||
|
const persona = personaByKey(key);
|
||||||
|
const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
|
||||||
|
data: { email: persona.email, password: persona.password },
|
||||||
|
});
|
||||||
|
return (await res.json()).token;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReadinessCheck = { key: string; status: "PASS" | "FAIL" | "PENDING"; detail: string };
|
||||||
|
|
||||||
|
/** The exact SQL readiness.suspense runs, replicated read-only for the DB axis. */
|
||||||
|
const suspenseFromDb = () =>
|
||||||
|
query<{ balance: string; lines: string }>(
|
||||||
|
`SELECT ROUND(COALESCE(SUM(l.debit - l.credit), 0), 2)::text AS balance,
|
||||||
|
COUNT(l.id)::text AS lines
|
||||||
|
FROM finance.accounts a
|
||||||
|
LEFT JOIN finance.journal_lines l
|
||||||
|
ON l.account_id = a.id
|
||||||
|
AND EXISTS (SELECT 1
|
||||||
|
FROM finance.journal_entries e
|
||||||
|
WHERE e.id = l.journal_entry_id
|
||||||
|
AND e.status IN ('POSTED','REVERSED'))
|
||||||
|
WHERE a.organization_id = $1
|
||||||
|
AND a.code = '3900'
|
||||||
|
AND a.deleted_at IS NULL`,
|
||||||
|
[ORG_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
test.describe("FIN-05 · the suspense readiness check counts posted lines only", () => {
|
||||||
|
test.use({ storageState: storageFor("finance-manager") });
|
||||||
|
|
||||||
|
test("a DRAFT-only opening-balances batch never moves the suspense check", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const token = await tokenFor(request, "finance-manager");
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
|
const readinessBefore = await request.get(`${FINANCE_API}/api/v1/cutover/readiness`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(readinessBefore.status()).toBe(200);
|
||||||
|
const beforeChecks: ReadinessCheck[] = (await readinessBefore.json()).checks;
|
||||||
|
const suspenseBefore = beforeChecks.find((c) => c.key === "suspense");
|
||||||
|
expect(suspenseBefore, "the readiness payload must include a suspense check").toBeDefined();
|
||||||
|
|
||||||
|
test.skip(
|
||||||
|
suspenseBefore!.status === "FAIL",
|
||||||
|
"the org's 3900 suspense account already carries an unrelated posted imbalance in this environment — not this regression's concern",
|
||||||
|
);
|
||||||
|
|
||||||
|
const dbBefore = await suspenseFromDb();
|
||||||
|
|
||||||
|
const period = await query<{ startDate: string }>(
|
||||||
|
`SELECT start_date::text AS "startDate"
|
||||||
|
FROM finance.fiscal_periods
|
||||||
|
WHERE organization_id = $1 AND status = 'OPEN'
|
||||||
|
ORDER BY start_date ASC
|
||||||
|
LIMIT 1`,
|
||||||
|
[ORG_ID],
|
||||||
|
);
|
||||||
|
test.skip(period.length === 0, "no OPEN fiscal period is available in this environment");
|
||||||
|
|
||||||
|
const [cash, capital] = await Promise.all([
|
||||||
|
query<{ code: string }>(
|
||||||
|
`SELECT code FROM finance.accounts WHERE organization_id = $1 AND code = '1111' AND deleted_at IS NULL`,
|
||||||
|
[ORG_ID],
|
||||||
|
),
|
||||||
|
query<{ code: string }>(
|
||||||
|
`SELECT code FROM finance.accounts WHERE organization_id = $1 AND code = '3100' AND deleted_at IS NULL`,
|
||||||
|
[ORG_ID],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
test.skip(
|
||||||
|
cash.length === 0 || capital.length === 0,
|
||||||
|
"the chart of accounts is missing 1111 Cash on Hand or 3100 Capital in this organization",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Intentionally unbalanced-looking rows (debit 5000, credit 3000) so
|
||||||
|
// importOpeningBalances computes a nonzero plug — see the exact logic in
|
||||||
|
// CutoverService.importOpeningBalances: `plug = totalDebit - totalCredit`,
|
||||||
|
// credited to 3900 when positive. Round, obviously-synthetic amounts, the
|
||||||
|
// same convention cutover.service.spec.ts uses.
|
||||||
|
const memo = `E2E-FIN-05 draft-only opening balances (never posted, ${Date.now()})`;
|
||||||
|
const importRes = await request.post(`${FINANCE_API}/api/v1/cutover/opening-balances`, {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
entryDate: period[0].startDate,
|
||||||
|
memo,
|
||||||
|
lines: [
|
||||||
|
{ accountCode: "1111", debit: 5000, description: "E2E-FIN-05 cash" },
|
||||||
|
{ accountCode: "3100", credit: 3000, description: "E2E-FIN-05 capital" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(importRes.status(), "importing the draft opening-balances batch").toBeLessThan(300);
|
||||||
|
const importBody = await importRes.json();
|
||||||
|
expect(importBody.suspensePlug).toBe(2000);
|
||||||
|
expect(
|
||||||
|
importBody.entry.status,
|
||||||
|
"importOpeningBalances must only ever produce a DRAFT",
|
||||||
|
).toBe("DRAFT");
|
||||||
|
|
||||||
|
// Never post it — that omission IS the scenario.
|
||||||
|
|
||||||
|
const readinessAfter = await request.get(`${FINANCE_API}/api/v1/cutover/readiness`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(readinessAfter.status()).toBe(200);
|
||||||
|
const afterChecks: ReadinessCheck[] = (await readinessAfter.json()).checks;
|
||||||
|
const suspenseAfter = afterChecks.find((c) => c.key === "suspense");
|
||||||
|
expect(suspenseAfter).toBeDefined();
|
||||||
|
|
||||||
|
// (NET) The headline assertion: a DRAFT-only batch must never turn the
|
||||||
|
// check into FAIL, and must leave its status exactly where it was.
|
||||||
|
expect(
|
||||||
|
suspenseAfter!.status,
|
||||||
|
"a DRAFT-only batch must never fail the suspense check",
|
||||||
|
).not.toBe("FAIL");
|
||||||
|
expect(suspenseAfter!.status).toBe(suspenseBefore!.status);
|
||||||
|
expect(suspenseAfter!.detail).toBe(suspenseBefore!.detail);
|
||||||
|
|
||||||
|
// The most literal form of the plan's ask, made when the environment
|
||||||
|
// allows it: no prior posted line has ever touched 3900, so the check
|
||||||
|
// reads PENDING — and must still read PENDING, not FAIL, after our batch.
|
||||||
|
if (suspenseBefore!.status === "PENDING") {
|
||||||
|
expect(suspenseAfter!.status).toBe("PENDING");
|
||||||
|
}
|
||||||
|
|
||||||
|
// (DB) Independent of the API's own arithmetic: re-run the exact query
|
||||||
|
// readiness.suspense runs and confirm it is unchanged. Distinguishes
|
||||||
|
// "PENDING" (lines === '0') from "PASS"/"FAIL" (lines > '0') explicitly,
|
||||||
|
// rather than only checking truthiness of a balance.
|
||||||
|
const dbAfter = await suspenseFromDb();
|
||||||
|
expect(dbAfter[0].lines, "no DRAFT line may ever be counted as a posted suspense line").toBe(
|
||||||
|
dbBefore[0].lines,
|
||||||
|
);
|
||||||
|
expect(dbAfter[0].balance).toBe(dbBefore[0].balance);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import type { APIRequestContext } from "@playwright/test";
|
||||||
import { expect, test } from "@playwright/test";
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
import { personaByKey } from "../../fixtures/personas";
|
import { personaByKey } from "../../fixtures/personas";
|
||||||
import { query } from "../../fixtures/db";
|
import { query } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FIN-01, FIN-03, FIN-07, FIN-08 — the Finance gates and two ledger invariants
|
* FIN-01, FIN-03, FIN-07, FIN-08 — the Finance gates and two ledger invariants
|
||||||
@@ -14,7 +16,7 @@ import { query } from "../../fixtures/db";
|
|||||||
|
|
||||||
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
||||||
|
|
||||||
const tokenFor = async (request: any, key: string): Promise<string> => {
|
const tokenFor = async (request: APIRequestContext, key: string): Promise<string> => {
|
||||||
const persona = personaByKey(key);
|
const persona = personaByKey(key);
|
||||||
const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
|
const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
|
||||||
data: { email: persona.email, password: persona.password },
|
data: { email: persona.email, password: persona.password },
|
||||||
@@ -23,7 +25,7 @@ const tokenFor = async (request: any, key: string): Promise<string> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test.describe("FIN-08 · a cashier is refused what it must not do", () => {
|
test.describe("FIN-08 · a cashier is refused what it must not do", () => {
|
||||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/finance-cashier.json` });
|
test.use({ storageState: storageFor("finance-cashier") });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `cashier` is the narrowest Finance role: READ_ONLY_KEYS plus receivable
|
* `cashier` is the narrowest Finance role: READ_ONLY_KEYS plus receivable
|
||||||
@@ -81,7 +83,7 @@ test.describe("FIN-03 · separation of duties, approve vs pay", () => {
|
|||||||
*/
|
*/
|
||||||
const NO_SUCH_BILL = "00000000-0000-4000-8000-000000000000";
|
const NO_SUCH_BILL = "00000000-0000-4000-8000-000000000000";
|
||||||
|
|
||||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/finance-cashier.json` });
|
test.use({ storageState: storageFor("finance-cashier") });
|
||||||
|
|
||||||
test("a cashier may pay but may not approve", async ({ request }) => {
|
test("a cashier may pay but may not approve", async ({ request }) => {
|
||||||
const token = await tokenFor(request, "finance-cashier");
|
const token = await tokenFor(request, "finance-cashier");
|
||||||
@@ -125,7 +127,7 @@ test.describe("FIN-03 · separation of duties, approve vs pay", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe("FIN-01 / FIN-07 · ledger invariants", () => {
|
test.describe("FIN-01 / FIN-07 · ledger invariants", () => {
|
||||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/finance-accountant.json` });
|
test.use({ storageState: storageFor("finance-accountant") });
|
||||||
|
|
||||||
test("FIN-01 · an unbalanced journal entry is refused and writes nothing", async ({ request }) => {
|
test("FIN-01 · an unbalanced journal entry is refused and writes nothing", async ({ request }) => {
|
||||||
const token = await tokenFor(request, "finance-accountant");
|
const token = await tokenFor(request, "finance-accountant");
|
||||||
@@ -157,9 +159,12 @@ test.describe("FIN-01 / FIN-07 · ledger invariants", () => {
|
|||||||
|
|
||||||
test("FIN-07 · the trial balance balances, computed independently", async ({ request }) => {
|
test("FIN-07 · the trial balance balances, computed independently", async ({ request }) => {
|
||||||
const token = await tokenFor(request, "finance-accountant");
|
const token = await tokenFor(request, "finance-accountant");
|
||||||
const res = await request.get(`${FINANCE_API}/api/v1/reports/trial-balance`, {
|
// dateFrom/dateTo are required (RangeQueryDto) — omitting them 400s before
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
// the report ever runs. A wide range covers whatever is actually posted.
|
||||||
});
|
const res = await request.get(
|
||||||
|
`${FINANCE_API}/api/v1/reports/trial-balance?dateFrom=2000-01-01&dateTo=2100-01-01`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
);
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
|
|
||||||
// Assert against the ledger itself rather than trusting the report to check
|
// Assert against the ledger itself rather than trusting the report to check
|
||||||
|
|||||||
157
e2e-hr-finance/specs/finance/journal-post-outside-period.spec.ts
Normal file
157
e2e-hr-finance/specs/finance/journal-post-outside-period.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||||
|
import { query } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIN-02 — posting outside a fiscal period is refused, and never leaves a
|
||||||
|
* POSTED row behind.
|
||||||
|
*
|
||||||
|
* `JournalsService.create` and `JournalsService.post` both resolve the entry's
|
||||||
|
* period through the SAME gate — `PeriodsService.resolveOpenPeriodForDate`
|
||||||
|
* (apps/finance-api/src/modules/periods/periods.service.ts) — so a date that
|
||||||
|
* covers no fiscal period at all is refused at CREATE time, before a draft
|
||||||
|
* ever exists to post. That is a stronger guarantee than catching it only at
|
||||||
|
* post time, and this scenario is written to prove exactly that: the write
|
||||||
|
* path can never produce a DRAFT dated outside a period in the first place.
|
||||||
|
*
|
||||||
|
* The date is discovered, not invented: it is chosen to sit past the LATEST
|
||||||
|
* `finance.fiscal_periods` row this organization has, exactly as the plan
|
||||||
|
* instructs ("pick a date before the earliest open period or after the
|
||||||
|
* latest, whichever exists in fixture data").
|
||||||
|
*/
|
||||||
|
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
||||||
|
|
||||||
|
const tokenFor = async (request: any, key: string): Promise<string> => {
|
||||||
|
const persona = personaByKey(key);
|
||||||
|
const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
|
||||||
|
data: { email: persona.email, password: persona.password },
|
||||||
|
});
|
||||||
|
return (await res.json()).token;
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe("FIN-02 · posting outside a fiscal period is refused", () => {
|
||||||
|
// finance-manager holds BOTH can:create:journal_entry and
|
||||||
|
// can:post:journal_entry (accountant holds only the first — see
|
||||||
|
// finance-permissions.registry.ts). Using manager throughout means a
|
||||||
|
// non-2xx response can only be explained by the period gate, never by a
|
||||||
|
// permission gate the accountant would also trip.
|
||||||
|
test.use({ storageState: storageFor("finance-manager") });
|
||||||
|
|
||||||
|
test("a date covered by no fiscal period is refused at create — never becomes a postable draft, never becomes POSTED", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const token = await tokenFor(request, "finance-manager");
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
|
// Cast every date column to text in SQL — node-postgres parses `date` to
|
||||||
|
// LOCAL midnight, and this org's periods must not round-trip through a JS
|
||||||
|
// Date (see the platform's known DATE trap).
|
||||||
|
const bounds = await query<{
|
||||||
|
minStart: string | null;
|
||||||
|
maxEnd: string | null;
|
||||||
|
periodCount: string;
|
||||||
|
}>(
|
||||||
|
`SELECT MIN(start_date)::text AS "minStart",
|
||||||
|
MAX(end_date)::text AS "maxEnd",
|
||||||
|
count(*)::text AS "periodCount"
|
||||||
|
FROM finance.fiscal_periods
|
||||||
|
WHERE organization_id = $1`,
|
||||||
|
[ORG_ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skip(
|
||||||
|
bounds[0].periodCount === "0" || !bounds[0].maxEnd,
|
||||||
|
"no fiscal periods are seeded for this organization — nothing to sit outside of",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Comfortably past the latest period this org has ever defined — not a
|
||||||
|
// CLOSED/LOCKED period (which create() also refuses, for a different
|
||||||
|
// reason), a genuine gap `findByDate` returns null for.
|
||||||
|
const latestYear = Number(bounds[0].maxEnd!.slice(0, 4));
|
||||||
|
const gapDate = `${latestYear + 50}-01-15`;
|
||||||
|
|
||||||
|
const memo = `E2E-FIN-02 outside-period draft attempt (${gapDate})`;
|
||||||
|
|
||||||
|
// Two balanced, obviously-synthetic lines — the balance rule is not what
|
||||||
|
// this scenario is testing, so the amounts are trivial and round.
|
||||||
|
const [cash, capital] = await Promise.all([
|
||||||
|
query<{ id: string }>(
|
||||||
|
`SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '1111' AND deleted_at IS NULL`,
|
||||||
|
[ORG_ID],
|
||||||
|
),
|
||||||
|
query<{ id: string }>(
|
||||||
|
`SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '3100' AND deleted_at IS NULL`,
|
||||||
|
[ORG_ID],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
test.skip(
|
||||||
|
cash.length === 0 || capital.length === 0,
|
||||||
|
"the chart of accounts is missing 1111 Cash on Hand or 3100 Capital in this organization",
|
||||||
|
);
|
||||||
|
|
||||||
|
const before = await query<{ count: string }>(
|
||||||
|
`SELECT count(*)::text AS count FROM finance.journal_entries WHERE organization_id = $1 AND memo = $2`,
|
||||||
|
[ORG_ID, memo],
|
||||||
|
);
|
||||||
|
expect(before[0].count).toBe("0");
|
||||||
|
|
||||||
|
const createRes = await request.post(`${FINANCE_API}/api/v1/journals`, {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
entryDate: gapDate,
|
||||||
|
memo,
|
||||||
|
lines: [
|
||||||
|
{ accountId: cash[0].id, debit: 50, credit: 0 },
|
||||||
|
{ accountId: capital[0].id, debit: 0, credit: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// (NET) The write is refused before a draft ever exists.
|
||||||
|
expect(
|
||||||
|
createRes.status(),
|
||||||
|
"a date no fiscal period covers must be refused at create, not merely at post",
|
||||||
|
).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(createRes.status()).toBeLessThan(500);
|
||||||
|
|
||||||
|
const createBody = await createRes.json().catch(() => ({}));
|
||||||
|
expect(String(createBody?.message ?? "")).toMatch(/fiscal period/i);
|
||||||
|
|
||||||
|
// (DB) Nothing was written — not a DRAFT, and certainly not POSTED.
|
||||||
|
const after = await query<{ count: string }>(
|
||||||
|
`SELECT count(*)::text AS count FROM finance.journal_entries WHERE organization_id = $1 AND memo = $2`,
|
||||||
|
[ORG_ID, memo],
|
||||||
|
);
|
||||||
|
expect(after[0].count, "a refused create must leave no journal_entries row behind").toBe("0");
|
||||||
|
|
||||||
|
// Defense in depth: even with no id to post (the point above), the /post
|
||||||
|
// route itself refuses a fabricated id rather than answering 2xx.
|
||||||
|
const NO_SUCH_ENTRY = "00000000-0000-4000-8000-000000000000";
|
||||||
|
const postRes = await request.post(
|
||||||
|
`${FINANCE_API}/api/v1/journals/${NO_SUCH_ENTRY}/post`,
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
|
expect(postRes.status()).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(postRes.status()).toBeLessThan(500);
|
||||||
|
|
||||||
|
// (DB) The whole-ledger invariant this scenario protects, checked
|
||||||
|
// independently of our own attempt — same discipline as FIN-07 in
|
||||||
|
// gating.spec.ts: never trust the API to have checked its own arithmetic.
|
||||||
|
// No POSTED entry may EVER carry a date outside the period it names.
|
||||||
|
const outOfRange = await query<{ count: string }>(
|
||||||
|
`SELECT count(*)::text AS count
|
||||||
|
FROM finance.journal_entries e
|
||||||
|
JOIN finance.fiscal_periods p ON p.id = e.fiscal_period_id
|
||||||
|
WHERE e.organization_id = $1
|
||||||
|
AND e.status = 'POSTED'
|
||||||
|
AND (e.entry_date < p.start_date OR e.entry_date > p.end_date)`,
|
||||||
|
[ORG_ID],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
outOfRange[0].count,
|
||||||
|
"no POSTED journal entry may ever carry a date outside its own fiscal period's range",
|
||||||
|
).toBe("0");
|
||||||
|
});
|
||||||
|
});
|
||||||
179
e2e-hr-finance/specs/finance/payment-pre-cutover-skip.spec.ts
Normal file
179
e2e-hr-finance/specs/finance/payment-pre-cutover-skip.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { personaByKey } from "../../fixtures/personas";
|
||||||
|
import { query } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIN-06 — a payment settled BEFORE the organization's cutover date is
|
||||||
|
* recorded SKIPPED, with a reason, and no journal entry is ever created for
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* The mechanism: `RevenuePostingService.postPaymentReceipt`
|
||||||
|
* (apps/finance-api/src/modules/revenue/revenue-posting.service.ts) checks the
|
||||||
|
* cutover boundary before anything else about the payment is judged. A
|
||||||
|
* payment paid before `cutoverDateFor(organizationId)` is already inside the
|
||||||
|
* receivable/cash figures the opening balances brought over — posting it
|
||||||
|
* again would count the same money twice — so it returns
|
||||||
|
* `{ outcome: "SKIPPED", reason: "…already carried by the opening
|
||||||
|
* balances…" }`. `PaymentEventsConsumer.handle` writes that outcome onto the
|
||||||
|
* claimed `finance.inbound_events` row and never calls `journals.createPosted`
|
||||||
|
* for a SKIPPED outcome.
|
||||||
|
*
|
||||||
|
* ── Why this can only run with a live broker ────────────────────────────────
|
||||||
|
* `RevenueModule`'s `rabbitMQImport()` (revenue.module.ts) returns `[]` — no
|
||||||
|
* `RabbitMQModule`, no exchange, no queue — whenever `PAYMENT_RABBITMQ_URL` is
|
||||||
|
* unset. `PaymentEventsConsumer` is still registered as a provider either way,
|
||||||
|
* but with no `RabbitMQModule` its `@RabbitSubscribe` decorator is inert: it
|
||||||
|
* is never bound to anything and there is no exchange to publish onto. That is
|
||||||
|
* "very likely NOT configured" in this environment, exactly as the plan says,
|
||||||
|
* so the FIRST thing this test does is check for the same environment
|
||||||
|
* variable the app itself gates on and skip cleanly — never attempting a
|
||||||
|
* broker connection, and never even resolving the `amqplib` module — when it
|
||||||
|
* is absent.
|
||||||
|
*/
|
||||||
|
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
||||||
|
const PAYMENT_EVENTS_EXCHANGE = "payment.events"; // @edr/types PAYMENT_EVENTS_EXCHANGE
|
||||||
|
|
||||||
|
const tokenFor = async (request: any, key: string): Promise<string> => {
|
||||||
|
const persona = personaByKey(key);
|
||||||
|
const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
|
||||||
|
data: { email: persona.email, password: persona.password },
|
||||||
|
});
|
||||||
|
return (await res.json()).token;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `"2026-07-08"` minus `n` days, as a pure string/date computation — never a
|
||||||
|
* round-trip of a value read back out of Postgres (see the platform's DATE
|
||||||
|
* trap: that only bites when node-postgres itself parses a `date` column). */
|
||||||
|
const daysBefore = (iso: string, n: number): string => {
|
||||||
|
const d = new Date(`${iso}T00:00:00Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() - n);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe("FIN-06 · a pre-cutover payment is skipped, not double-posted", () => {
|
||||||
|
test.use({ storageState: storageFor("finance-manager") });
|
||||||
|
|
||||||
|
test("a payment.freight.succeeded message dated before cutover settles as SKIPPED with no journal entry", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// The runtime guard the plan requires — checked first, before anything
|
||||||
|
// else, exactly the way RevenueModule itself gates the broker.
|
||||||
|
if (!process.env.PAYMENT_RABBITMQ_URL) {
|
||||||
|
test.skip(true, "PAYMENT_RABBITMQ_URL not configured in this environment");
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await tokenFor(request, "finance-manager");
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
|
// Discover the organization's real cutover date rather than inventing one
|
||||||
|
// — set() is not exercised here; a boundary this scenario depends on
|
||||||
|
// should already exist as a genuine fact about the org, or there is
|
||||||
|
// nothing "pre-cutover" to test.
|
||||||
|
const cutoverRes = await request.get(`${FINANCE_API}/api/v1/cutover`, { headers });
|
||||||
|
expect(cutoverRes.status()).toBe(200);
|
||||||
|
const cutoverDate: string | null = (await cutoverRes.json())?.cutoverDate ?? null;
|
||||||
|
test.skip(
|
||||||
|
!cutoverDate,
|
||||||
|
"no cutover date is set for this organization — nothing is 'pre-cutover' to test",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Borrowed, not a dependency of this directory — the same pattern
|
||||||
|
// fixtures/db.ts uses for `pg`. `amqplib` is a direct dependency of
|
||||||
|
// edr-freight-api (and edr-passenger-api), so it resolves from there.
|
||||||
|
// This line only runs once PAYMENT_RABBITMQ_URL is confirmed present, so
|
||||||
|
// an environment without amqplib installed anywhere never reaches it.
|
||||||
|
const requireFrom = createRequire(
|
||||||
|
path.join(__dirname, "..", "..", "..", "apps", "edr-freight-api", "package.json"),
|
||||||
|
);
|
||||||
|
const amqp = requireFrom("amqplib") as {
|
||||||
|
connect: (url: string) => Promise<{
|
||||||
|
createChannel: () => Promise<{
|
||||||
|
assertExchange: (
|
||||||
|
name: string,
|
||||||
|
type: string,
|
||||||
|
options: Record<string, unknown>,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
publish: (
|
||||||
|
exchange: string,
|
||||||
|
routingKey: string,
|
||||||
|
content: Buffer,
|
||||||
|
options?: Record<string, unknown>,
|
||||||
|
) => boolean;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventId = `E2E-FIN-06-${Date.now()}`;
|
||||||
|
const paidBefore = daysBefore(cutoverDate as string, 10);
|
||||||
|
|
||||||
|
// Shaped like PaymentSucceededEvent (@edr/types common/payments.ts).
|
||||||
|
// `amountMinor` is MAJOR units despite the name on this path — see
|
||||||
|
// apps/finance-api/src/common/money.ts.
|
||||||
|
const event = {
|
||||||
|
version: 1,
|
||||||
|
eventId,
|
||||||
|
eventType: "payment.succeeded",
|
||||||
|
occurredAt: `${paidBefore}T00:00:00.000Z`,
|
||||||
|
service: "FREIGHT",
|
||||||
|
intentId: `E2E-FIN-06-INTENT-${eventId}`,
|
||||||
|
referenceType: "SHIPMENT",
|
||||||
|
referenceId: `E2E-FIN-06-REF-${eventId}`,
|
||||||
|
merchantOrderId: `E2E-FIN-06-ORDER-${eventId}`,
|
||||||
|
provider: "CARD",
|
||||||
|
amountMinor: 1000,
|
||||||
|
currency: "ETB",
|
||||||
|
paidAt: `${paidBefore}T00:00:00.000Z`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// `payment.<service>.<outcome>` is three words on the wire — a `payment.*`
|
||||||
|
// binding matches nothing (AMQP `*` matches exactly one word); Finance's
|
||||||
|
// queue is bound `payment.#`, so `payment.freight.succeeded` is what a
|
||||||
|
// real publisher sends. See the platform's own known-traps table.
|
||||||
|
const routingKey = "payment.freight.succeeded";
|
||||||
|
|
||||||
|
const conn = await amqp.connect(process.env.PAYMENT_RABBITMQ_URL as string);
|
||||||
|
try {
|
||||||
|
const channel = await conn.createChannel();
|
||||||
|
await channel.assertExchange(PAYMENT_EVENTS_EXCHANGE, "topic", { durable: true });
|
||||||
|
channel.publish(PAYMENT_EVENTS_EXCHANGE, routingKey, Buffer.from(JSON.stringify(event)), {
|
||||||
|
contentType: "application/json",
|
||||||
|
persistent: true,
|
||||||
|
});
|
||||||
|
await channel.close();
|
||||||
|
} finally {
|
||||||
|
await conn.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// (DB) The consumer claims, judges and settles asynchronously — poll
|
||||||
|
// rather than assume it has landed by the time publish() returns.
|
||||||
|
await expect(async () => {
|
||||||
|
const rows = await query<{ status: string; error: string | null }>(
|
||||||
|
`SELECT status, error FROM finance.inbound_events WHERE event_id = $1`,
|
||||||
|
[eventId],
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].status).toBe("SKIPPED");
|
||||||
|
expect(rows[0].error ?? "").toMatch(/cutover/i);
|
||||||
|
}).toPass({ timeout: 15_000 });
|
||||||
|
|
||||||
|
// (DB) — and, just as importantly, nothing was posted for it: SKIPPED
|
||||||
|
// means no call to journals.createPosted was ever made for this source.
|
||||||
|
const journalRows = await query<{ count: string }>(
|
||||||
|
`SELECT count(*)::text AS count
|
||||||
|
FROM finance.journal_entries
|
||||||
|
WHERE source_module = 'payment' AND source_id = $1`,
|
||||||
|
[eventId],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
journalRows[0].count,
|
||||||
|
"a SKIPPED payment must never have a journal entry posted for it",
|
||||||
|
).toBe("0");
|
||||||
|
});
|
||||||
|
});
|
||||||
176
e2e-hr-finance/specs/hr/appraisal-manager-score-final.spec.ts
Normal file
176
e2e-hr-finance/specs/hr/appraisal-manager-score-final.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||||
|
import { query, scalar } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HR-08 · the manager's score is final — it is never averaged with the
|
||||||
|
* employee's own.
|
||||||
|
*
|
||||||
|
* `AppraisalService.submitManager` writes `finalScore = managerScore.toFixed(2)`
|
||||||
|
* verbatim (apps/edr-hr-api/src/modules/appraisal/services/appraisal.service.ts).
|
||||||
|
* The self-assessment is recorded for comparison only and plays no part in the
|
||||||
|
* arithmetic — the code comment there says so explicitly: "Averaging it with
|
||||||
|
* the employee's own would let anyone raise their result by rating themselves
|
||||||
|
* highly." This scenario proves that end to end: submit a low self-score, then
|
||||||
|
* a materially different manager score, and check the number that lands in the
|
||||||
|
* response AND the database is the manager's weighted score, not any blend of
|
||||||
|
* the two.
|
||||||
|
*
|
||||||
|
* Fixtures: a template and cycle are created fresh each run — their codes
|
||||||
|
* carry a timestamp so reruns never collide — opened for the `hr-employee`
|
||||||
|
* persona, the one non-manager persona this suite already holds real
|
||||||
|
* self-service credentials for. `openCycle` silently skips any employeeId with
|
||||||
|
* no `hr.employee_profiles` row, and that persona is seeded at the IAM level
|
||||||
|
* only (fixtures/seed-personas.cjs), so
|
||||||
|
* `POST /employee-profiles/by-employee/:id/ensure` (idempotent, the product's
|
||||||
|
* own onboarding endpoint) guarantees one exists first.
|
||||||
|
*/
|
||||||
|
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||||
|
|
||||||
|
const login = async (request: any, key: string): Promise<string> => {
|
||||||
|
const persona = personaByKey(key);
|
||||||
|
const res = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||||
|
data: { email: persona.email, password: persona.password },
|
||||||
|
});
|
||||||
|
const { token } = await res.json();
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe("HR-08 · manager score is final, not averaged with self", () => {
|
||||||
|
test.use({ storageState: storageFor("hr-manager") });
|
||||||
|
|
||||||
|
test("finalScore equals the manager's weighted score, in the response AND in hr.appraisals", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const managerToken = await login(request, "hr-manager");
|
||||||
|
const managerAuth = { Authorization: `Bearer ${managerToken}` };
|
||||||
|
|
||||||
|
// hr-employee is a real iam.employees row (seeded by seed-personas.cjs),
|
||||||
|
// matched the same way that script matches one: by email, in the Active org.
|
||||||
|
const employeePersona = personaByKey("hr-employee");
|
||||||
|
const employeeId = await scalar<string>(
|
||||||
|
`SELECT e."id"
|
||||||
|
FROM iam.employees e
|
||||||
|
JOIN iam.users u ON u."id" = e."user_id"
|
||||||
|
WHERE u."email" = $1 AND e."organization_id" = $2 AND e."is_current" = true
|
||||||
|
LIMIT 1`,
|
||||||
|
[employeePersona.email, ORG_ID],
|
||||||
|
);
|
||||||
|
expect(employeeId, "hr-employee must exist as a real iam.employees row").toBeTruthy();
|
||||||
|
|
||||||
|
const ensure = await request.post(
|
||||||
|
`${HR_API}/api/v1/employee-profiles/by-employee/${employeeId}/ensure`,
|
||||||
|
{ headers: managerAuth },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ensure.ok(),
|
||||||
|
"an HR profile must exist before openCycle will create an appraisal for this employee",
|
||||||
|
).toBeTruthy();
|
||||||
|
|
||||||
|
const ts = Date.now();
|
||||||
|
const template = await request.post(`${HR_API}/api/v1/appraisal/templates`, {
|
||||||
|
headers: managerAuth,
|
||||||
|
data: {
|
||||||
|
code: `E2E-APR-${ts}`,
|
||||||
|
name: { am: "ኢ2ኢ ግምገማ ቅጽ", en: "E2E HR-08 Template" },
|
||||||
|
maxScore: "10.00",
|
||||||
|
requiresSelfAssessment: true,
|
||||||
|
requiresAcknowledgement: true,
|
||||||
|
// Weights sum to exactly 100 — createTemplate refuses anything else.
|
||||||
|
criteria: [
|
||||||
|
{ code: "QUALITY", name: { am: "ጥራት", en: "Quality" }, weight: "60.00" },
|
||||||
|
{ code: "SPEED", name: { am: "ፍጥነት", en: "Speed" }, weight: "40.00" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(template.status(), "weights sum to 100, so the template must be accepted").toBe(201);
|
||||||
|
const templateBody = await template.json();
|
||||||
|
|
||||||
|
const cycle = await request.post(`${HR_API}/api/v1/appraisal/cycles`, {
|
||||||
|
headers: managerAuth,
|
||||||
|
data: {
|
||||||
|
code: `E2E-CYC-${ts}`,
|
||||||
|
name: { am: "ኢ2ኢ ዑደት", en: "E2E HR-08 Cycle" },
|
||||||
|
templateId: templateBody.id,
|
||||||
|
periodStart: "2026-01-01",
|
||||||
|
periodEnd: "2026-12-31",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(cycle.status()).toBe(201);
|
||||||
|
const cycleBody = await cycle.json();
|
||||||
|
|
||||||
|
const open = await request.post(`${HR_API}/api/v1/appraisal/cycles/${cycleBody.id}/open`, {
|
||||||
|
headers: managerAuth,
|
||||||
|
data: { employeeIds: [employeeId] },
|
||||||
|
});
|
||||||
|
expect(open.status()).toBe(201);
|
||||||
|
const openBody = await open.json();
|
||||||
|
expect(openBody.created, "the target employee must not have been skipped").toBe(1);
|
||||||
|
|
||||||
|
const appraisalId = await scalar<string>(
|
||||||
|
`SELECT "id" FROM hr.appraisals WHERE cycle_id = $1 AND employee_id = $2`,
|
||||||
|
[cycleBody.id, employeeId],
|
||||||
|
);
|
||||||
|
expect(appraisalId).toBeTruthy();
|
||||||
|
|
||||||
|
// Self scores low: weightedScore = (2/10)*60 + (2/10)*40 = 20.00
|
||||||
|
const employeeToken = await login(request, "hr-employee");
|
||||||
|
const self = await request.post(`${HR_API}/api/v1/appraisal/${appraisalId}/self`, {
|
||||||
|
headers: { Authorization: `Bearer ${employeeToken}` },
|
||||||
|
data: {
|
||||||
|
ratings: [
|
||||||
|
{ code: "QUALITY", score: 2 },
|
||||||
|
{ code: "SPEED", score: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(self.status()).toBe(201);
|
||||||
|
const selfBody = await self.json();
|
||||||
|
expect(selfBody.selfScore).toBe("20.00");
|
||||||
|
expect(selfBody.status).toBe("PENDING_MANAGER");
|
||||||
|
|
||||||
|
// Manager scores much higher and DIFFERENT: (9/10)*60 + (8/10)*40 = 86.00.
|
||||||
|
// An average of 20.00 and 86.00 would be 53.00 — that is the number a
|
||||||
|
// regression to averaging would produce instead.
|
||||||
|
const manager = await request.post(`${HR_API}/api/v1/appraisal/${appraisalId}/manager`, {
|
||||||
|
headers: managerAuth,
|
||||||
|
data: {
|
||||||
|
ratings: [
|
||||||
|
{ code: "QUALITY", score: 9 },
|
||||||
|
{ code: "SPEED", score: 8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(manager.status()).toBe(201);
|
||||||
|
const managerBody = await manager.json();
|
||||||
|
|
||||||
|
expect(managerBody.managerScore).toBe("86.00");
|
||||||
|
expect(
|
||||||
|
managerBody.finalScore,
|
||||||
|
"finalScore must equal the manager's weighted score",
|
||||||
|
).toBe("86.00");
|
||||||
|
expect(managerBody.finalScore).not.toBe("53.00");
|
||||||
|
expect(managerBody.finalScore).not.toBe(managerBody.selfScore);
|
||||||
|
|
||||||
|
const row = await query<{
|
||||||
|
selfScore: string;
|
||||||
|
managerScore: string;
|
||||||
|
finalScore: string;
|
||||||
|
}>(
|
||||||
|
`SELECT "self_score" AS "selfScore",
|
||||||
|
"manager_score" AS "managerScore",
|
||||||
|
"final_score" AS "finalScore"
|
||||||
|
FROM hr.appraisals
|
||||||
|
WHERE id = $1`,
|
||||||
|
[appraisalId],
|
||||||
|
);
|
||||||
|
expect(row[0].selfScore).toBe("20.00");
|
||||||
|
expect(row[0].managerScore).toBe("86.00");
|
||||||
|
expect(
|
||||||
|
row[0].finalScore,
|
||||||
|
"hr.appraisals.final_score must match the manager's score exactly, not an average",
|
||||||
|
).toBe("86.00");
|
||||||
|
});
|
||||||
|
});
|
||||||
206
e2e-hr-finance/specs/hr/attendance-night-shift.spec.ts
Normal file
206
e2e-hr-finance/specs/hr/attendance-night-shift.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { personaByKey } from "../../fixtures/personas";
|
||||||
|
import { scalar } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HR-05 — a night shift stays on its start date.
|
||||||
|
*
|
||||||
|
* `AttendanceRecord.workDate` is a DATE, not a timestamp, and deliberately so
|
||||||
|
* (see the entity's own doc comment): a shift that clocks out at 03:00 belongs
|
||||||
|
* to the day it BEGAN. `AttendanceService.checkOut` proves this by looking for
|
||||||
|
* an open record on the check-out's own calendar day first, and — when the
|
||||||
|
* schedule crosses midnight — falling back to an open record from the
|
||||||
|
* immediately preceding day (`findOpenPreviousDay`). This scenario drives both
|
||||||
|
* punches through the real endpoints and checks the settled row lands on the
|
||||||
|
* check-in's date, not the check-out's.
|
||||||
|
*
|
||||||
|
* Naive `HH:MM` times in this codebase are wall-clock EAT (UTC+3) —
|
||||||
|
* `attendance-day.service.ts`'s `atTime()` helper parses a schedule's
|
||||||
|
* `startTime`/`endTime` the same way. Every instant sent here is written with
|
||||||
|
* an explicit `+03:00` offset for that reason, rather than left for whichever
|
||||||
|
* timezone happens to run this test to guess.
|
||||||
|
*/
|
||||||
|
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||||
|
|
||||||
|
async function login(
|
||||||
|
request: import("@playwright/test").APIRequestContext,
|
||||||
|
personaKey: string,
|
||||||
|
): Promise<{ token: string; employeeId: string }> {
|
||||||
|
const persona = personaByKey(personaKey);
|
||||||
|
const res = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||||
|
data: { email: persona.email, password: persona.password },
|
||||||
|
});
|
||||||
|
const { token } = await res.json();
|
||||||
|
|
||||||
|
const me = await request.get(`${HR_API}/api/v1/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const body = await me.json();
|
||||||
|
const employee = Array.isArray(body.employee) ? body.employee[0] : body.employee;
|
||||||
|
const employeeId: string | undefined = employee?.id;
|
||||||
|
if (!employeeId) {
|
||||||
|
throw new Error(`[HR-05] persona "${personaKey}" has no employee id on /me`);
|
||||||
|
}
|
||||||
|
return { token, employeeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calendar-day arithmetic done in UTC, so it never drifts with the local zone. */
|
||||||
|
function addDaysISO(dateStr: string, days: number): string {
|
||||||
|
const d = new Date(`${dateStr}T00:00:00Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("HR-05 · night shift stays on its start date", () => {
|
||||||
|
test.use({ storageState: storageFor("hr-employee") });
|
||||||
|
|
||||||
|
test("check-out after midnight settles on the check-in's calendar day", async ({
|
||||||
|
request,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Whole-org lookups elsewhere in this suite are heavy; this test only
|
||||||
|
// touches one employee, but give it room anyway.
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
const admin = await login(request, "hr-manager");
|
||||||
|
const employee = await login(request, "hr-employee");
|
||||||
|
const authAdmin = { Authorization: `Bearer ${admin.token}` };
|
||||||
|
const authEmployee = { Authorization: `Bearer ${employee.token}` };
|
||||||
|
|
||||||
|
// ── 1. A real crossesMidnight schedule, or one we add ──────────────────
|
||||||
|
const schedulesRes = await request.get(`${HR_API}/api/v1/work-schedules`, {
|
||||||
|
headers: authAdmin,
|
||||||
|
});
|
||||||
|
expect(schedulesRes.status(), "GET /work-schedules as hr-manager").toBe(200);
|
||||||
|
const schedules: Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
crossesMidnight: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
}> = await schedulesRes.json();
|
||||||
|
|
||||||
|
let schedule = schedules.find((s) => s.crossesMidnight && s.isActive);
|
||||||
|
|
||||||
|
if (!schedule) {
|
||||||
|
const create = await request.post(`${HR_API}/api/v1/work-schedules`, {
|
||||||
|
headers: authAdmin,
|
||||||
|
data: {
|
||||||
|
code: "E2E-NIGHT-SHIFT",
|
||||||
|
name: { am: "ኢ2ኢ የሌሊት ፈረቃ", en: "E2E Night Shift" },
|
||||||
|
startTime: "22:00",
|
||||||
|
endTime: "06:00",
|
||||||
|
breakMinutes: 30,
|
||||||
|
crossesMidnight: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (create.status() === 409) {
|
||||||
|
// Another pass of this suite created it a moment earlier — the config
|
||||||
|
// serializes workers, but not repeated invocations of the whole run.
|
||||||
|
const again = await request.get(`${HR_API}/api/v1/work-schedules`, {
|
||||||
|
headers: authAdmin,
|
||||||
|
});
|
||||||
|
schedule = (await again.json()).find(
|
||||||
|
(s: { code: string }) => s.code === "E2E-NIGHT-SHIFT",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
expect(create.status(), "creating the E2E night-shift schedule").toBe(201);
|
||||||
|
schedule = await create.json();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!schedule) {
|
||||||
|
throw new Error("[HR-05] could not find or create a crossesMidnight schedule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Put the employee on it, well before any date this test picks ────
|
||||||
|
const openOnThisSchedule = await scalar<string>(
|
||||||
|
`SELECT a.id FROM hr.work_schedule_assignments a
|
||||||
|
WHERE a.employee_id = $1 AND a.work_schedule_id = $2 AND a.effective_to IS NULL`,
|
||||||
|
[employee.employeeId, schedule.id],
|
||||||
|
);
|
||||||
|
if (!openOnThisSchedule) {
|
||||||
|
const assign = await request.post(`${HR_API}/api/v1/work-schedules/assign`, {
|
||||||
|
headers: authAdmin,
|
||||||
|
data: {
|
||||||
|
employeeId: employee.employeeId,
|
||||||
|
workScheduleId: schedule.id,
|
||||||
|
effectiveFrom: "2020-01-01",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(assign.status(), "assigning the E2E employee to the night shift").toBe(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. A shift date this employee has never punched — accumulate rather
|
||||||
|
// than delete, matching the suite's E2E- convention (doc §1.4) ─────────
|
||||||
|
const priorRuns = await scalar<string>(
|
||||||
|
`SELECT count(*)::text FROM hr.attendance_records
|
||||||
|
WHERE employee_id = $1 AND notes LIKE 'E2E-HR-05%'`,
|
||||||
|
[employee.employeeId],
|
||||||
|
);
|
||||||
|
// 3-day spacing keeps every run's [shiftDate, shiftDate+1] pair isolated
|
||||||
|
// from every other run's pair.
|
||||||
|
const offset = Number(priorRuns ?? "0") * 3;
|
||||||
|
const shiftDate = addDaysISO("2023-01-02", offset); // a Monday, safely in the past
|
||||||
|
const nextDate = addDaysISO(shiftDate, 1);
|
||||||
|
|
||||||
|
// ── 4. Clock in at 22:00 EAT on shiftDate, clock out at 03:00 EAT the
|
||||||
|
// following calendar day ────────────────────────────────────────────────
|
||||||
|
const checkIn = await request.post(`${HR_API}/api/v1/attendance/check-in`, {
|
||||||
|
headers: authEmployee,
|
||||||
|
data: { at: `${shiftDate}T22:00:00+03:00`, notes: "E2E-HR-05 night shift check-in" },
|
||||||
|
});
|
||||||
|
expect(checkIn.status(), "check-in").toBe(201);
|
||||||
|
const checkInBody = await checkIn.json();
|
||||||
|
expect(checkInBody.workDate, "check-in lands on its own calendar day").toBe(shiftDate);
|
||||||
|
|
||||||
|
const checkOut = await request.post(`${HR_API}/api/v1/attendance/check-out`, {
|
||||||
|
headers: authEmployee,
|
||||||
|
data: {
|
||||||
|
at: `${nextDate}T03:00:00+03:00`,
|
||||||
|
notes: "E2E-HR-05 night shift check-out",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(checkOut.status(), "check-out").toBe(201);
|
||||||
|
const checkOutBody = await checkOut.json();
|
||||||
|
|
||||||
|
// NET — the settled record still belongs to the day the shift BEGAN, not
|
||||||
|
// the day the check-out instant falls on.
|
||||||
|
expect(
|
||||||
|
checkOutBody.workDate,
|
||||||
|
"a night shift settles on its start date, not the check-out's calendar day",
|
||||||
|
).toBe(shiftDate);
|
||||||
|
expect(checkOutBody.id, "check-out closes the SAME row check-in opened").toBe(
|
||||||
|
checkInBody.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// DB — cast the DATE column to text. node-postgres parses a bare DATE via
|
||||||
|
// this pool's default (non-TypeORM) type parser as LOCAL midnight, which
|
||||||
|
// reads back as the previous day east of UTC (see CLAUDE.md's DATE trap).
|
||||||
|
const dbWorkDate = await scalar<string>(
|
||||||
|
`SELECT work_date::text FROM hr.attendance_records WHERE id = $1`,
|
||||||
|
[checkOutBody.id],
|
||||||
|
);
|
||||||
|
expect(dbWorkDate).toBe(shiftDate);
|
||||||
|
|
||||||
|
// No stray second row was created on the check-out's own calendar day.
|
||||||
|
const strayCount = await scalar<string>(
|
||||||
|
`SELECT count(*)::text FROM hr.attendance_records
|
||||||
|
WHERE employee_id = $1 AND work_date = $2::date`,
|
||||||
|
[employee.employeeId, nextDate],
|
||||||
|
);
|
||||||
|
expect(strayCount, "no separate row was filed under the check-out's date").toBe("0");
|
||||||
|
|
||||||
|
// DOM — the self-service employee's own attendance screen still renders.
|
||||||
|
// No feature-page testids exist yet (doc §4), so this proves the screen is
|
||||||
|
// reachable and error-free after the punch, not the exact cell contents.
|
||||||
|
const pageErrors: string[] = [];
|
||||||
|
page.on("pageerror", (e) => pageErrors.push(e.message));
|
||||||
|
page.on("response", (r) => {
|
||||||
|
if (r.status() >= 500) pageErrors.push(`${r.status()} ${r.url()}`);
|
||||||
|
});
|
||||||
|
await page.goto("/attendance", { waitUntil: "networkidle" });
|
||||||
|
expect(page.url()).not.toContain("/forbidden");
|
||||||
|
expect(pageErrors, "no page errors or 5xx responses").toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
|
|
||||||
import { personaByKey } from "../../fixtures/personas";
|
import { personaByKey } from "../../fixtures/personas";
|
||||||
import { query } from "../../fixtures/db";
|
import { query } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HR-03, HR-10, HR-12 — permission gating, and the two regressions found by the
|
* HR-03, HR-10, HR-12 — permission gating, and the two regressions found by the
|
||||||
@@ -17,7 +18,7 @@ import { query } from "../../fixtures/db";
|
|||||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||||
|
|
||||||
test.describe("HR-03 · leave approvals are reachable by an L2 holder", () => {
|
test.describe("HR-03 · leave approvals are reachable by an L2 holder", () => {
|
||||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/hr-manager.json` });
|
test.use({ storageState: storageFor("hr-manager") });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The regression: nav gate, route gate, badge hook and three backend routes
|
* The regression: nav gate, route gate, badge hook and three backend routes
|
||||||
@@ -77,7 +78,7 @@ test.describe("HR-03 · leave approvals are reachable by an L2 holder", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe("HR-10 · the org-filtered job positions list", () => {
|
test.describe("HR-10 · the org-filtered job positions list", () => {
|
||||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/hr-manager.json` });
|
test.use({ storageState: storageFor("hr-manager") });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The regression: `JobPositionsRepository.findPage` filtered on
|
* The regression: `JobPositionsRepository.findPage` filtered on
|
||||||
@@ -118,7 +119,7 @@ test.describe("HR-10 · the org-filtered job positions list", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe("HR-12 · a self-service employee is refused the manage screens", () => {
|
test.describe("HR-12 · a self-service employee is refused the manage screens", () => {
|
||||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/hr-employee.json` });
|
test.use({ storageState: storageFor("hr-employee") });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `employee_self_service` is the narrowest HR role — the ten SELF_SERVICE_KEYS
|
* `employee_self_service` is the narrowest HR role — the ten SELF_SERVICE_KEYS
|
||||||
|
|||||||
158
e2e-hr-finance/specs/hr/leave-approve.spec.ts
Normal file
158
e2e-hr-finance/specs/hr/leave-approve.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||||
|
|
||||||
|
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||||
|
import { query, scalar } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HR-02 — L1 approval deducts atomically.
|
||||||
|
*
|
||||||
|
* `LeaveRequestsService.approve()` posts the DEDUCTION and flips the status
|
||||||
|
* inside one `dataSource.transaction`, on purpose: "A request marked approved
|
||||||
|
* whose deduction failed gives away free leave; a deduction whose approval
|
||||||
|
* failed takes days for an absence nobody agreed to." This proves both halves
|
||||||
|
* landed together — exactly one DEDUCTION row, for this request, in the window
|
||||||
|
* the approval itself ran in.
|
||||||
|
*
|
||||||
|
* `hr-manager` (MANAGER_POSITION_ID, "Team Leader, Online Booking System") and
|
||||||
|
* `hr-employee` (REPORT_POSITION_ID, "Officer, Operation Data Management") are
|
||||||
|
* a real parent/child pair in the IAM position tree — see the doc comment on
|
||||||
|
* those constants in fixtures/personas.ts, written for exactly this scenario.
|
||||||
|
* Because of that, a request hr-employee submits resolves its approver (via
|
||||||
|
* `IamDirectoryService.findLineManagerEmployeeId`) to hr-manager for real, so
|
||||||
|
* the approval below is driven through the actual "awaiting-me" queue on
|
||||||
|
* `/leave/approvals` rather than an approve call aimed at an arbitrary id.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||||
|
|
||||||
|
// A different leave year (2032) from leave-submit.spec.ts's 2031 and
|
||||||
|
// leave-cancel.spec.ts's 2033 — each spec's MARRIAGE entitlement (3.00
|
||||||
|
// days/year, uncapped by prior specs) stays independent, so the three files
|
||||||
|
// never contend for the same balance.
|
||||||
|
const START_DATE = "2032-06-14";
|
||||||
|
const END_DATE = "2032-06-16";
|
||||||
|
|
||||||
|
async function loginAndMe(
|
||||||
|
request: APIRequestContext,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ token: string; employeeId: string }> {
|
||||||
|
const login = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(login.ok(), `login failed for ${email}: ${await login.text()}`).toBeTruthy();
|
||||||
|
const { token } = await login.json();
|
||||||
|
|
||||||
|
const meRes = await request.get(`${HR_API}/api/v1/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(meRes.ok(), `/me failed for ${email}`).toBeTruthy();
|
||||||
|
const me = await meRes.json();
|
||||||
|
return { token, employeeId: me.employee?.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("HR-02 · L1 approval deducts atomically", () => {
|
||||||
|
test.use({ storageState: storageFor("hr-manager") });
|
||||||
|
|
||||||
|
test("approving posts exactly one DEDUCTION in the same window as the status flip", async ({
|
||||||
|
request,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const manager = personaByKey("hr-manager");
|
||||||
|
const employee = personaByKey("hr-employee");
|
||||||
|
|
||||||
|
const mgr = await loginAndMe(request, manager.email, manager.password);
|
||||||
|
const emp = await loginAndMe(request, employee.email, employee.password);
|
||||||
|
const employeeAuth = { Authorization: `Bearer ${emp.token}` };
|
||||||
|
|
||||||
|
// Setup, via API for speed — see leave-submit.spec.ts for why the profile
|
||||||
|
// must be ensured first.
|
||||||
|
const ensure = await request.post(
|
||||||
|
`${HR_API}/api/v1/employee-profiles/by-employee/${emp.employeeId}/ensure`,
|
||||||
|
{ headers: { Authorization: `Bearer ${mgr.token}` } },
|
||||||
|
);
|
||||||
|
expect(ensure.ok(), `ensuring hr-employee's HR profile: ${await ensure.text()}`).toBeTruthy();
|
||||||
|
|
||||||
|
const leaveTypeId = await scalar<string>(
|
||||||
|
`SELECT id FROM hr.leave_types
|
||||||
|
WHERE organization_id = $1 AND code = 'MARRIAGE' AND is_active = true
|
||||||
|
LIMIT 1`,
|
||||||
|
[ORG_ID],
|
||||||
|
);
|
||||||
|
expect(leaveTypeId, "MARRIAGE leave type must exist for the Active org").not.toBeNull();
|
||||||
|
|
||||||
|
const createRes = await request.post(`${HR_API}/api/v1/leave-requests`, {
|
||||||
|
headers: employeeAuth,
|
||||||
|
data: {
|
||||||
|
leaveTypeId,
|
||||||
|
startDate: START_DATE,
|
||||||
|
endDate: END_DATE,
|
||||||
|
isHalfDay: false,
|
||||||
|
reason: "E2E-HR-02 leave-approve spec",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createRes.status(), await createRes.text()).toBe(201);
|
||||||
|
const created = await createRes.json();
|
||||||
|
expect(created.status).toBe("SUBMITTED");
|
||||||
|
|
||||||
|
// DOM: the request is really sitting in hr-manager's own approvals queue —
|
||||||
|
// not merely reachable via a direct approve call — which is the proof that
|
||||||
|
// the position pair above is genuinely related, not incidental.
|
||||||
|
await page.goto("/leave/approvals", { waitUntil: "networkidle" });
|
||||||
|
const row = page.getByTestId(`leave-approval-row-${created.id}`);
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
const before = new Date();
|
||||||
|
const [approveResponse] = await Promise.all([
|
||||||
|
page.waitForResponse(
|
||||||
|
(res) =>
|
||||||
|
res.url().includes(`/leave-requests/${created.id}/approve`) &&
|
||||||
|
res.request().method() === "PATCH",
|
||||||
|
),
|
||||||
|
page.getByTestId(`leave-approve-btn-${created.id}`).click(),
|
||||||
|
]);
|
||||||
|
const after = new Date();
|
||||||
|
|
||||||
|
// NET: the response Approve actually returned.
|
||||||
|
expect(approveResponse.status(), await approveResponse.text()).toBe(200);
|
||||||
|
const approved = await approveResponse.json();
|
||||||
|
expect(approved.status).toBe("APPROVED");
|
||||||
|
|
||||||
|
// DOM: decided requests leave the "awaiting-me" queue.
|
||||||
|
await expect(row).not.toBeVisible();
|
||||||
|
|
||||||
|
// DB: exactly one DEDUCTION for this request's source, sized to match the
|
||||||
|
// frozen chargedDays, posted inside the window this approval itself ran in.
|
||||||
|
const ledgerRows = await query<{ entry_type: string; days: string; created_at: string }>(
|
||||||
|
`SELECT entry_type, days, created_at FROM hr.leave_ledger_entries
|
||||||
|
WHERE source_type = 'LEAVE_REQUEST' AND source_id = $1`,
|
||||||
|
[created.id],
|
||||||
|
);
|
||||||
|
const deductions = ledgerRows.filter((row) => row.entry_type === "DEDUCTION");
|
||||||
|
expect(deductions.length, "exactly one deduction must exist for this request").toBe(1);
|
||||||
|
expect(Number(deductions[0].days)).toBe(-Number(created.chargedDays));
|
||||||
|
|
||||||
|
const postedAt = new Date(deductions[0].created_at).getTime();
|
||||||
|
// A small tolerance either side for clock skew between the test runner and
|
||||||
|
// the database server — the point is "inside this test's run", not to the
|
||||||
|
// millisecond.
|
||||||
|
expect(postedAt).toBeGreaterThanOrEqual(before.getTime() - 2000);
|
||||||
|
expect(postedAt).toBeLessThanOrEqual(after.getTime() + 2000);
|
||||||
|
|
||||||
|
const dbStatus = await scalar<string>(
|
||||||
|
`SELECT status FROM hr.leave_requests WHERE id = $1`,
|
||||||
|
[created.id],
|
||||||
|
);
|
||||||
|
expect(dbStatus).toBe("APPROVED");
|
||||||
|
|
||||||
|
// Teardown: cancel (reverses, does not delete — see leave-cancel.spec.ts)
|
||||||
|
// so these dates are free for the next run. An APPROVED row is "live" per
|
||||||
|
// LIVE_STATUSES and would otherwise trip a rerun's findOverlapping() check.
|
||||||
|
const cancelRes = await request.patch(`${HR_API}/api/v1/leave-requests/${created.id}/cancel`, {
|
||||||
|
headers: employeeAuth,
|
||||||
|
data: { reason: "E2E-HR-02 teardown" },
|
||||||
|
});
|
||||||
|
expect(cancelRes.status(), "teardown: cancel must succeed").toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
180
e2e-hr-finance/specs/hr/leave-cancel.spec.ts
Normal file
180
e2e-hr-finance/specs/hr/leave-cancel.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||||
|
|
||||||
|
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||||
|
import { query, scalar } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HR-04 — cancelling approved leave reverses the deduction, it does not delete
|
||||||
|
* it.
|
||||||
|
*
|
||||||
|
* `LeaveRequestsService.cancel()` posts a REVERSAL against the original
|
||||||
|
* DEDUCTION rather than touching it — `hr.leave_ledger_entries` has no
|
||||||
|
* `updated_at`/`deleted_at` at all (see the entity's own doc comment:
|
||||||
|
* "append-only: never updated, never deleted"). This proves the ledger stays
|
||||||
|
* append-only across a real cancel, and that the unique-index-on-`reverses_id`
|
||||||
|
* guard actually refuses a second cancel of the same request.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||||
|
|
||||||
|
// A third leave year (2033) — independent of leave-submit.spec.ts's 2031 and
|
||||||
|
// leave-approve.spec.ts's 2032, so this file's MARRIAGE entitlement (3.00
|
||||||
|
// days/year) is never contended by the other two.
|
||||||
|
const START_DATE = "2033-06-13";
|
||||||
|
const END_DATE = "2033-06-15";
|
||||||
|
|
||||||
|
async function loginAndMe(
|
||||||
|
request: APIRequestContext,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ token: string; employeeId: string }> {
|
||||||
|
const login = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(login.ok(), `login failed for ${email}: ${await login.text()}`).toBeTruthy();
|
||||||
|
const { token } = await login.json();
|
||||||
|
|
||||||
|
const meRes = await request.get(`${HR_API}/api/v1/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(meRes.ok(), `/me failed for ${email}`).toBeTruthy();
|
||||||
|
const me = await meRes.json();
|
||||||
|
return { token, employeeId: me.employee?.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("HR-04 · cancelling approved leave reverses, not deletes", () => {
|
||||||
|
test.use({ storageState: storageFor("hr-employee") });
|
||||||
|
|
||||||
|
test("cancel posts a REVERSAL, leaves the DEDUCTION untouched, and refuses a second cancel", async ({
|
||||||
|
request,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const manager = personaByKey("hr-manager");
|
||||||
|
const employee = personaByKey("hr-employee");
|
||||||
|
|
||||||
|
const mgr = await loginAndMe(request, manager.email, manager.password);
|
||||||
|
const emp = await loginAndMe(request, employee.email, employee.password);
|
||||||
|
const employeeAuth = { Authorization: `Bearer ${emp.token}` };
|
||||||
|
const managerAuth = { Authorization: `Bearer ${mgr.token}` };
|
||||||
|
|
||||||
|
// Setup: an APPROVED request, via API for speed — the approve path itself
|
||||||
|
// is leave-approve.spec.ts's job, not this file's.
|
||||||
|
const ensure = await request.post(
|
||||||
|
`${HR_API}/api/v1/employee-profiles/by-employee/${emp.employeeId}/ensure`,
|
||||||
|
{ headers: managerAuth },
|
||||||
|
);
|
||||||
|
expect(ensure.ok(), `ensuring hr-employee's HR profile: ${await ensure.text()}`).toBeTruthy();
|
||||||
|
|
||||||
|
const leaveTypeId = await scalar<string>(
|
||||||
|
`SELECT id FROM hr.leave_types
|
||||||
|
WHERE organization_id = $1 AND code = 'MARRIAGE' AND is_active = true
|
||||||
|
LIMIT 1`,
|
||||||
|
[ORG_ID],
|
||||||
|
);
|
||||||
|
expect(leaveTypeId, "MARRIAGE leave type must exist for the Active org").not.toBeNull();
|
||||||
|
|
||||||
|
const createRes = await request.post(`${HR_API}/api/v1/leave-requests`, {
|
||||||
|
headers: employeeAuth,
|
||||||
|
data: {
|
||||||
|
leaveTypeId,
|
||||||
|
startDate: START_DATE,
|
||||||
|
endDate: END_DATE,
|
||||||
|
isHalfDay: false,
|
||||||
|
reason: "E2E-HR-04 leave-cancel spec",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createRes.status(), await createRes.text()).toBe(201);
|
||||||
|
const created = await createRes.json();
|
||||||
|
|
||||||
|
const approveRes = await request.patch(`${HR_API}/api/v1/leave-requests/${created.id}/approve`, {
|
||||||
|
headers: managerAuth,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(approveRes.status(), await approveRes.text()).toBe(200);
|
||||||
|
|
||||||
|
// The DEDUCTION posted by approve — captured whole, so "untouched" below
|
||||||
|
// can be a real equality check rather than a re-derived guess.
|
||||||
|
const beforeCancel = await query<Record<string, unknown>>(
|
||||||
|
`SELECT * FROM hr.leave_ledger_entries
|
||||||
|
WHERE source_type = 'LEAVE_REQUEST' AND source_id = $1 AND entry_type = 'DEDUCTION'`,
|
||||||
|
[created.id],
|
||||||
|
);
|
||||||
|
expect(beforeCancel.length, "the approval must have posted one deduction").toBe(1);
|
||||||
|
const deduction = beforeCancel[0] as { id: string; days: string };
|
||||||
|
|
||||||
|
// The actual behaviour under test: the employee cancels their own
|
||||||
|
// approved leave. There is no data-testid for this action in MyLeavePage
|
||||||
|
// (only the leave-request-* and leave-approval-*/leave-approve-btn-*/
|
||||||
|
// leave-reject-btn-* ids exist), so this is asserted via NET + DB rather
|
||||||
|
// than a UI click.
|
||||||
|
const cancelRes = await request.patch(`${HR_API}/api/v1/leave-requests/${created.id}/cancel`, {
|
||||||
|
headers: employeeAuth,
|
||||||
|
data: { reason: "E2E-HR-04 leave-cancel spec — plans changed" },
|
||||||
|
});
|
||||||
|
expect(cancelRes.status(), await cancelRes.text()).toBe(200);
|
||||||
|
const cancelled = await cancelRes.json();
|
||||||
|
expect(cancelled.status).toBe("CANCELLED");
|
||||||
|
|
||||||
|
// DB: a REVERSAL row referencing the DEDUCTION by id, crediting back
|
||||||
|
// exactly what was deducted.
|
||||||
|
const reversalRows = await query<{ entry_type: string; days: string; reverses_id: string }>(
|
||||||
|
`SELECT entry_type, days, reverses_id FROM hr.leave_ledger_entries WHERE reverses_id = $1`,
|
||||||
|
[deduction.id],
|
||||||
|
);
|
||||||
|
expect(reversalRows.length, "exactly one reversal must exist").toBe(1);
|
||||||
|
expect(reversalRows[0].entry_type).toBe("REVERSAL");
|
||||||
|
expect(Number(reversalRows[0].days)).toBe(-Number(deduction.days));
|
||||||
|
|
||||||
|
// DB: the original DEDUCTION is byte-for-byte unchanged — not deleted, not
|
||||||
|
// updated. The ledger has no updated_at/deleted_at at all, so this is a
|
||||||
|
// straight equality against the row captured before cancel.
|
||||||
|
const afterDeduction = await query<Record<string, unknown>>(
|
||||||
|
`SELECT * FROM hr.leave_ledger_entries WHERE id = $1`,
|
||||||
|
[deduction.id],
|
||||||
|
);
|
||||||
|
expect(afterDeduction.length).toBe(1);
|
||||||
|
expect(afterDeduction[0]).toEqual(deduction);
|
||||||
|
|
||||||
|
const dbStatus = await scalar<string>(
|
||||||
|
`SELECT status FROM hr.leave_requests WHERE id = $1`,
|
||||||
|
[created.id],
|
||||||
|
);
|
||||||
|
expect(dbStatus).toBe("CANCELLED");
|
||||||
|
|
||||||
|
// The idempotent-double-cancel guard. cancel() re-runs assertTransition()
|
||||||
|
// first, and ALLOWED[CANCELLED] is empty ("that status is final") — so a
|
||||||
|
// second cancel is refused with 400 before it ever reaches the ledger's
|
||||||
|
// own unique-index-on-reverses_id guard in LeaveBalancesService.reverse()
|
||||||
|
// (a ConflictException/23505, reserved for two cancels racing inside the
|
||||||
|
// same still-APPROVED window; a sequential second call here never gets
|
||||||
|
// that far, because the first has already flipped the status).
|
||||||
|
const secondCancel = await request.patch(`${HR_API}/api/v1/leave-requests/${created.id}/cancel`, {
|
||||||
|
headers: employeeAuth,
|
||||||
|
data: { reason: "second cancel must be refused" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
secondCancel.status(),
|
||||||
|
`a cancelled request must refuse a second cancel: ${await secondCancel.text()}`,
|
||||||
|
).toBe(400);
|
||||||
|
|
||||||
|
// DB: the refused attempt wrote nothing — still exactly one reversal.
|
||||||
|
const reversalsAfter = await scalar<string>(
|
||||||
|
`SELECT count(*)::text AS count FROM hr.leave_ledger_entries WHERE reverses_id = $1`,
|
||||||
|
[deduction.id],
|
||||||
|
);
|
||||||
|
expect(reversalsAfter).toBe("1");
|
||||||
|
|
||||||
|
// DOM: hr-employee's own "My leave" table shows the cancelled status.
|
||||||
|
// A prior run's own (also CANCELLED) row can share this fixed START_DATE.
|
||||||
|
// The list sorts startDate DESC, createdAt DESC, so among same-date rows
|
||||||
|
// the one this test just created is always first.
|
||||||
|
await page.goto("/leave", { waitUntil: "networkidle" });
|
||||||
|
const row = page.locator("tr", { hasText: START_DATE }).first();
|
||||||
|
await expect(row).toContainText("Cancelled");
|
||||||
|
|
||||||
|
// No teardown needed: CANCELLED is not in LIVE_STATUSES, so
|
||||||
|
// findOverlapping() ignores this row and these dates are free for the
|
||||||
|
// next run without any extra cleanup.
|
||||||
|
});
|
||||||
|
});
|
||||||
152
e2e-hr-finance/specs/hr/leave-submit.spec.ts
Normal file
152
e2e-hr-finance/specs/hr/leave-submit.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||||
|
|
||||||
|
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||||
|
import { query, scalar } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HR-01 — submitting a leave request freezes the charged-day count.
|
||||||
|
*
|
||||||
|
* `LeaveRequest.chargedDays` is written once, at submission, and nothing in
|
||||||
|
* the codebase ever recomputes it (see the entity's own doc comment: "The day
|
||||||
|
* counts are frozen at submission rather than derived on read"). This proves
|
||||||
|
* the freeze the only way that is actually observable: the number the server
|
||||||
|
* priced via `GET /leave-requests/quote` — before anything is committed — must
|
||||||
|
* equal both what `POST /leave-requests` returns AND what lands in
|
||||||
|
* `hr.leave_requests.charged_days`.
|
||||||
|
*
|
||||||
|
* A genuine "edit the holiday calendar after submission and reprice" scenario
|
||||||
|
* is not simulated here — there is no product endpoint that recomputes an
|
||||||
|
* already-submitted request, which is exactly the invariant being frozen: there
|
||||||
|
* is nothing to trigger. What this proves is the freeze AT submission time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||||
|
|
||||||
|
// A Monday-to-Wednesday window five years out, in a leave year (2031) this
|
||||||
|
// employee has never touched. Far enough ahead to never collide with real
|
||||||
|
// production leave data; the teardown at the end of the test (withdraw) is
|
||||||
|
// what keeps a rerun from colliding with ITSELF.
|
||||||
|
const START_DATE = "2031-06-09";
|
||||||
|
const END_DATE = "2031-06-11";
|
||||||
|
|
||||||
|
async function loginAndMe(
|
||||||
|
request: APIRequestContext,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<{ token: string; employeeId: string }> {
|
||||||
|
const login = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(login.ok(), `login failed for ${email}: ${await login.text()}`).toBeTruthy();
|
||||||
|
const { token } = await login.json();
|
||||||
|
|
||||||
|
const meRes = await request.get(`${HR_API}/api/v1/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(meRes.ok(), `/me failed for ${email}`).toBeTruthy();
|
||||||
|
const me = await meRes.json();
|
||||||
|
return { token, employeeId: me.employee?.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("HR-01 · submitting leave freezes the charged-day count", () => {
|
||||||
|
test.use({ storageState: storageFor("hr-employee") });
|
||||||
|
|
||||||
|
test("chargedDays on the created request equals the pre-submit quote", async ({
|
||||||
|
request,
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const manager = personaByKey("hr-manager");
|
||||||
|
const employee = personaByKey("hr-employee");
|
||||||
|
|
||||||
|
const mgr = await loginAndMe(request, manager.email, manager.password);
|
||||||
|
const emp = await loginAndMe(request, employee.email, employee.password);
|
||||||
|
const employeeAuth = { Authorization: `Bearer ${emp.token}` };
|
||||||
|
|
||||||
|
// hr-employee is seeded straight into iam.* by seed-personas.cjs and
|
||||||
|
// carries no hr.employee_profile — LeaveRequestsService.create() 404s
|
||||||
|
// without one ("Onboard them before they can request leave"). Provision
|
||||||
|
// it through the product's own idempotent onboarding endpoint rather than
|
||||||
|
// writing hr.employee_profiles ourselves — the suite's read-only-SQL
|
||||||
|
// doctrine (fixtures/db.ts) is exactly this: fixtures come from real
|
||||||
|
// endpoints, never raw INSERTs.
|
||||||
|
const ensure = await request.post(
|
||||||
|
`${HR_API}/api/v1/employee-profiles/by-employee/${emp.employeeId}/ensure`,
|
||||||
|
{ headers: { Authorization: `Bearer ${mgr.token}` } },
|
||||||
|
);
|
||||||
|
expect(ensure.ok(), `ensuring hr-employee's HR profile: ${await ensure.text()}`).toBeTruthy();
|
||||||
|
|
||||||
|
// MARRIAGE is the one statutory type a same-day-provisioned profile is
|
||||||
|
// guaranteed eligible for: genderRestriction ANY, minServiceMonths 0 — and
|
||||||
|
// requiresApproval:true, unlike BEREAVEMENT, so this actually produces a
|
||||||
|
// SUBMITTED request rather than an auto-approved one.
|
||||||
|
const leaveTypeId = await scalar<string>(
|
||||||
|
`SELECT id FROM hr.leave_types
|
||||||
|
WHERE organization_id = $1 AND code = 'MARRIAGE' AND is_active = true
|
||||||
|
LIMIT 1`,
|
||||||
|
[ORG_ID],
|
||||||
|
);
|
||||||
|
expect(leaveTypeId, "MARRIAGE leave type must exist for the Active org").not.toBeNull();
|
||||||
|
|
||||||
|
const quoteRes = await request.get(`${HR_API}/api/v1/leave-requests/quote`, {
|
||||||
|
headers: employeeAuth,
|
||||||
|
params: { leaveTypeId: leaveTypeId!, startDate: START_DATE, endDate: END_DATE, isHalfDay: "false" },
|
||||||
|
});
|
||||||
|
expect(quoteRes.status(), await quoteRes.text()).toBe(200);
|
||||||
|
const quote = await quoteRes.json();
|
||||||
|
expect(quote.chargedDays, "the quote must price something before submitting").toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const createRes = await request.post(`${HR_API}/api/v1/leave-requests`, {
|
||||||
|
headers: employeeAuth,
|
||||||
|
data: {
|
||||||
|
leaveTypeId,
|
||||||
|
startDate: START_DATE,
|
||||||
|
endDate: END_DATE,
|
||||||
|
isHalfDay: false,
|
||||||
|
reason: "E2E-HR-01 leave-submit spec",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createRes.status(), await createRes.text()).toBe(201);
|
||||||
|
const created = await createRes.json();
|
||||||
|
|
||||||
|
// NET: the response is what the UI and everything downstream sees.
|
||||||
|
expect(created.status).toBe("SUBMITTED");
|
||||||
|
expect(Number(created.chargedDays)).toBe(quote.chargedDays);
|
||||||
|
|
||||||
|
// DB: the row actually written carries the same frozen number — not a
|
||||||
|
// value the client invented, and not one derived again on read.
|
||||||
|
const dbRows = await query<{ status: string; charged_days: string }>(
|
||||||
|
`SELECT status, charged_days FROM hr.leave_requests WHERE id = $1`,
|
||||||
|
[created.id],
|
||||||
|
);
|
||||||
|
expect(dbRows.length).toBe(1);
|
||||||
|
expect(dbRows[0].status).toBe("SUBMITTED");
|
||||||
|
expect(Number(dbRows[0].charged_days)).toBe(quote.chargedDays);
|
||||||
|
|
||||||
|
// DOM: hr-employee's own "My leave" table, in the real browser session
|
||||||
|
// this describe block's storageState signs in — the frozen number and the
|
||||||
|
// SUBMITTED badge are both visible without touching the request-leave
|
||||||
|
// modal's Ethiopian/Gregorian date picker (already exercised by creating
|
||||||
|
// the request; reading a rendered table is the stable part to assert on).
|
||||||
|
await page.goto("/leave", { waitUntil: "networkidle" });
|
||||||
|
// A prior run's already-terminal row can share this fixed START_DATE (the
|
||||||
|
// suite's convention is to leave a named row behind, not delete it). The
|
||||||
|
// list sorts startDate DESC, createdAt DESC, so among same-date rows the
|
||||||
|
// one this test just created is always first.
|
||||||
|
const row = page.locator("tr", { hasText: START_DATE }).first();
|
||||||
|
await expect(row).toContainText("Awaiting decision");
|
||||||
|
await expect(row).toContainText(String(quote.chargedDays));
|
||||||
|
|
||||||
|
// Teardown: withdraw rather than leave a SUBMITTED row behind. Nothing was
|
||||||
|
// deducted at submission (withdraw's own doc comment: "nothing was
|
||||||
|
// deducted, so nothing comes back"), so this is a clean no-op for the
|
||||||
|
// balance, and frees these dates so a rerun's findOverlapping() (which
|
||||||
|
// only looks at SUBMITTED/APPROVED — see LIVE_STATUSES) does not refuse
|
||||||
|
// the next run's create().
|
||||||
|
const withdrawRes = await request.patch(
|
||||||
|
`${HR_API}/api/v1/leave-requests/${created.id}/withdraw`,
|
||||||
|
{ headers: employeeAuth },
|
||||||
|
);
|
||||||
|
expect(withdrawRes.status(), "teardown: withdraw must succeed").toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
171
e2e-hr-finance/specs/hr/payroll-no-recalc.spec.ts
Normal file
171
e2e-hr-finance/specs/hr/payroll-no-recalc.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||||
|
import { scalar } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HR-06 — recalculation is refused after approval.
|
||||||
|
*
|
||||||
|
* `PayrollRunsService.calculate()` is repeatable while a run is DRAFT or
|
||||||
|
* CALCULATED — that is how "recalculate after fixing a salary" works — and
|
||||||
|
* refused with a `BadRequestException` (HTTP 400) for anything else. Once
|
||||||
|
* APPROVED the payslips ARE the record: recomputing on read would silently
|
||||||
|
* restate figures already reported to tax and pension.
|
||||||
|
*
|
||||||
|
* Two roles are load-bearing here, and the split is structural
|
||||||
|
* (`hr-permissions.registry.ts`'s own comment names it): `run:payroll` belongs
|
||||||
|
* to `payroll_admin` and `approve:payroll` belongs to `hr_manager`. Neither
|
||||||
|
* role holds both, so this scenario genuinely needs the two personas already
|
||||||
|
* in the fixture roster — it is not possible to drive the whole lifecycle as
|
||||||
|
* one persona.
|
||||||
|
*/
|
||||||
|
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||||
|
|
||||||
|
/** Tags the run this scenario owns, so a rerun can find and reuse it instead
|
||||||
|
* of piling up a fresh APPROVED run every pass — the suite's "named, findable
|
||||||
|
* row" convention (doc §1.4) rather than a per-run DELETE. */
|
||||||
|
const NOTE = "E2E-HR-06 recalculation-refused";
|
||||||
|
|
||||||
|
async function login(
|
||||||
|
request: import("@playwright/test").APIRequestContext,
|
||||||
|
personaKey: string,
|
||||||
|
): Promise<{ token: string }> {
|
||||||
|
const persona = personaByKey(personaKey);
|
||||||
|
const res = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||||
|
data: { email: persona.email, password: persona.password },
|
||||||
|
});
|
||||||
|
const { token } = await res.json();
|
||||||
|
return { token };
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstOfMonth = (year: number, month0: number): string =>
|
||||||
|
new Date(Date.UTC(year, month0, 1)).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const lastOfMonth = (year: number, month0: number): string =>
|
||||||
|
new Date(Date.UTC(year, month0 + 1, 0)).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The nearest calendar month, starting at `fromYear`/`fromMonth0`, for which
|
||||||
|
* this org has no live (non-cancelled) payroll run. Kept close to "now" rather
|
||||||
|
* than picking an arbitrary future date, since the statutory tax brackets and
|
||||||
|
* rates a run depends on are seeded for the present, not for an arbitrary
|
||||||
|
* decade ahead.
|
||||||
|
*/
|
||||||
|
async function findFreePeriod(
|
||||||
|
fromYear: number,
|
||||||
|
fromMonth0: number,
|
||||||
|
): Promise<{ periodStart: string; periodEnd: string }> {
|
||||||
|
for (let i = 0; i < 36; i += 1) {
|
||||||
|
const y = fromYear + Math.floor((fromMonth0 + i) / 12);
|
||||||
|
const m = (fromMonth0 + i) % 12;
|
||||||
|
const periodStart = firstOfMonth(y, m);
|
||||||
|
const periodEnd = lastOfMonth(y, m);
|
||||||
|
// Mirrors the partial unique index (`uq_payroll_runs_org_period`), which
|
||||||
|
// excludes CANCELLED runs — a cancelled period is free to reuse.
|
||||||
|
const clash = await scalar<string>(
|
||||||
|
`SELECT id FROM hr.payroll_runs
|
||||||
|
WHERE organization_id = $1 AND period_start = $2::date AND period_end = $3::date
|
||||||
|
AND status <> 'CANCELLED'`,
|
||||||
|
[ORG_ID, periodStart, periodEnd],
|
||||||
|
);
|
||||||
|
if (!clash) return { periodStart, periodEnd };
|
||||||
|
}
|
||||||
|
throw new Error("[HR-06] no free payroll period found in the next 36 months");
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("HR-06 · recalculation refused after approval", () => {
|
||||||
|
test.use({ storageState: storageFor("hr-payroll-admin") });
|
||||||
|
|
||||||
|
test("a second POST /:id/calculate is refused once APPROVED, and nothing changes", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// Calculation sweeps every payable salary in the Active org (2,400+
|
||||||
|
// employees) — give it real room rather than the config's 60s default.
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
const admin = await login(request, "hr-payroll-admin");
|
||||||
|
const manager = await login(request, "hr-manager");
|
||||||
|
const authAdmin = { Authorization: `Bearer ${admin.token}` };
|
||||||
|
const authManager = { Authorization: `Bearer ${manager.token}` };
|
||||||
|
|
||||||
|
let runId = await scalar<string>(
|
||||||
|
`SELECT id FROM hr.payroll_runs
|
||||||
|
WHERE organization_id = $1 AND note = $2 AND status = 'APPROVED'
|
||||||
|
LIMIT 1`,
|
||||||
|
[ORG_ID, NOTE],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!runId) {
|
||||||
|
const today = new Date();
|
||||||
|
const { periodStart, periodEnd } = await findFreePeriod(
|
||||||
|
today.getUTCFullYear(),
|
||||||
|
today.getUTCMonth(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const create = await request.post(`${HR_API}/api/v1/payroll-runs`, {
|
||||||
|
headers: authAdmin,
|
||||||
|
data: { periodStart, periodEnd, note: NOTE },
|
||||||
|
});
|
||||||
|
expect(create.status(), "open the run (payroll_admin holds run:payroll)").toBe(201);
|
||||||
|
const run = await create.json();
|
||||||
|
expect(run.status).toBe("DRAFT");
|
||||||
|
runId = run.id;
|
||||||
|
|
||||||
|
const calc = await request.post(`${HR_API}/api/v1/payroll-runs/${runId}/calculate`, {
|
||||||
|
headers: authAdmin,
|
||||||
|
});
|
||||||
|
expect(calc.status(), "first calculate — DRAFT is calculable").toBe(201);
|
||||||
|
const calculated = await calc.json();
|
||||||
|
expect(calculated.status).toBe("CALCULATED");
|
||||||
|
expect(
|
||||||
|
calculated.employeeCount,
|
||||||
|
"the Active org has payable salaries for this period",
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// payroll_admin does NOT hold approve:payroll — that separation of duty
|
||||||
|
// is deliberate, so approval goes through hr-manager instead.
|
||||||
|
const approve = await request.patch(
|
||||||
|
`${HR_API}/api/v1/payroll-runs/${runId}/approve`,
|
||||||
|
{ headers: authManager },
|
||||||
|
);
|
||||||
|
expect(approve.status(), "approve (hr_manager holds approve:payroll)").toBe(200);
|
||||||
|
expect((await approve.json()).status).toBe("APPROVED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payslipCountBefore = await scalar<string>(
|
||||||
|
`SELECT count(*)::text FROM hr.payslips WHERE payroll_run_id = $1`,
|
||||||
|
[runId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// NET — the run is APPROVED; PayrollRunsService.calculate()'s status guard
|
||||||
|
// throws a BadRequestException for anything but DRAFT/CALCULATED.
|
||||||
|
const second = await request.post(`${HR_API}/api/v1/payroll-runs/${runId}/calculate`, {
|
||||||
|
headers: authAdmin,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
second.status(),
|
||||||
|
"recalculating an APPROVED run must be refused with the exact 400 the service throws",
|
||||||
|
).toBe(400);
|
||||||
|
|
||||||
|
// DB — the status held, and the payslips were never touched. calculate()
|
||||||
|
// deletes-and-rebuilds them only inside the same call the status guard
|
||||||
|
// refused, so an unchanged count proves the guard ran before any write.
|
||||||
|
const statusAfter = await scalar<string>(
|
||||||
|
`SELECT status FROM hr.payroll_runs WHERE id = $1`,
|
||||||
|
[runId],
|
||||||
|
);
|
||||||
|
expect(statusAfter, "status must still be APPROVED after the refused call").toBe(
|
||||||
|
"APPROVED",
|
||||||
|
);
|
||||||
|
|
||||||
|
const payslipCountAfter = await scalar<string>(
|
||||||
|
`SELECT count(*)::text FROM hr.payslips WHERE payroll_run_id = $1`,
|
||||||
|
[runId],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
payslipCountAfter,
|
||||||
|
"payslips must be byte-for-byte untouched by the refused recalculation",
|
||||||
|
).toBe(payslipCountBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
271
e2e-hr-finance/specs/hr/payroll-self-service-scoping.spec.ts
Normal file
271
e2e-hr-finance/specs/hr/payroll-self-service-scoping.spec.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||||
|
import { query, scalar } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HR-07 — self-service payroll is scoped.
|
||||||
|
*
|
||||||
|
* `PayrollRunsService.payslipsForEmployee` is the whole contract:
|
||||||
|
*
|
||||||
|
* .where("payslip.employee_id = :employeeId", { employeeId })
|
||||||
|
* .andWhere("run.status IN (:...statuses)", { statuses: [APPROVED, PAID] })
|
||||||
|
*
|
||||||
|
* Two things have to hold for `GET /payroll-runs/my-payslips` to be trustworthy:
|
||||||
|
* it must never return anyone else's payslip, and it must not publish a run's
|
||||||
|
* figures before they are approved. This scenario needs two employees on the
|
||||||
|
* SAME run to prove the first, and drives one run through CALCULATED before
|
||||||
|
* approving it to prove the second.
|
||||||
|
*
|
||||||
|
* `hr-employee` (self-service) and `hr-payroll-admin` are both real, distinct
|
||||||
|
* `iam.employees` rows in the Active org, and every HR role carries
|
||||||
|
* `view_own:payslip` (`SELF_SERVICE_KEYS`) — so both fixture personas already
|
||||||
|
* on the roster can stand in as "two employees" without inventing a third.
|
||||||
|
*/
|
||||||
|
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||||
|
const NOTE = "E2E-HR-07 self-service-scoping";
|
||||||
|
|
||||||
|
async function login(
|
||||||
|
request: import("@playwright/test").APIRequestContext,
|
||||||
|
personaKey: string,
|
||||||
|
): Promise<{ token: string; employeeId: string }> {
|
||||||
|
const persona = personaByKey(personaKey);
|
||||||
|
const res = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||||
|
data: { email: persona.email, password: persona.password },
|
||||||
|
});
|
||||||
|
const { token } = await res.json();
|
||||||
|
|
||||||
|
const me = await request.get(`${HR_API}/api/v1/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const body = await me.json();
|
||||||
|
const employee = Array.isArray(body.employee) ? body.employee[0] : body.employee;
|
||||||
|
const employeeId: string | undefined = employee?.id;
|
||||||
|
if (!employeeId) {
|
||||||
|
throw new Error(`[HR-07] persona "${personaKey}" has no employee id on /me`);
|
||||||
|
}
|
||||||
|
return { token, employeeId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These fixture personas have no `hr.employee_profiles` row and no
|
||||||
|
* `hr.employee_salaries` row — `seed-personas.cjs` only ever writes `iam.*`.
|
||||||
|
* `PayrollRunsService.calculate()` silently skips anyone without an HR profile
|
||||||
|
* (see the service's own comment) and only prices a salary in force on the
|
||||||
|
* period end, so both are prerequisites for this employee to appear in a run
|
||||||
|
* at all. Created once, through the product's own endpoints, and reused on
|
||||||
|
* every rerun.
|
||||||
|
*/
|
||||||
|
async function ensurePayable(
|
||||||
|
request: import("@playwright/test").APIRequestContext,
|
||||||
|
authManager: Record<string, string>,
|
||||||
|
employeeId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const hasProfile = await scalar<string>(
|
||||||
|
`SELECT id FROM hr.employee_profiles WHERE employee_id = $1`,
|
||||||
|
[employeeId],
|
||||||
|
);
|
||||||
|
if (!hasProfile) {
|
||||||
|
const create = await request.post(`${HR_API}/api/v1/employee-profiles`, {
|
||||||
|
headers: authManager,
|
||||||
|
data: { employeeId, employmentType: "PERMANENT", hireDate: "2020-01-01" },
|
||||||
|
});
|
||||||
|
// A concurrent create from an earlier pass racing this one is the only
|
||||||
|
// expected non-201 — anything else is a real failure.
|
||||||
|
if (create.status() !== 409) {
|
||||||
|
expect(create.status(), `create HR profile for ${employeeId}`).toBe(201);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOpenSalary = await scalar<string>(
|
||||||
|
`SELECT id FROM hr.employee_salaries WHERE employee_id = $1 AND effective_to IS NULL`,
|
||||||
|
[employeeId],
|
||||||
|
);
|
||||||
|
if (!hasOpenSalary) {
|
||||||
|
const assign = await request.post(`${HR_API}/api/v1/payroll/salaries`, {
|
||||||
|
headers: authManager,
|
||||||
|
data: {
|
||||||
|
employeeId,
|
||||||
|
basicSalary: "8000.00",
|
||||||
|
effectiveFrom: "2020-01-01",
|
||||||
|
reason: "E2E-HR-07 fixture salary",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(assign.status(), `assign a salary to ${employeeId}`).toBe(201);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstOfMonth = (year: number, month0: number): string =>
|
||||||
|
new Date(Date.UTC(year, month0, 1)).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const lastOfMonth = (year: number, month0: number): string =>
|
||||||
|
new Date(Date.UTC(year, month0 + 1, 0)).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
async function findFreePeriod(
|
||||||
|
fromYear: number,
|
||||||
|
fromMonth0: number,
|
||||||
|
): Promise<{ periodStart: string; periodEnd: string }> {
|
||||||
|
for (let i = 0; i < 36; i += 1) {
|
||||||
|
const y = fromYear + Math.floor((fromMonth0 + i) / 12);
|
||||||
|
const m = (fromMonth0 + i) % 12;
|
||||||
|
const periodStart = firstOfMonth(y, m);
|
||||||
|
const periodEnd = lastOfMonth(y, m);
|
||||||
|
const clash = await scalar<string>(
|
||||||
|
`SELECT id FROM hr.payroll_runs
|
||||||
|
WHERE organization_id = $1 AND period_start = $2::date AND period_end = $3::date
|
||||||
|
AND status <> 'CANCELLED'`,
|
||||||
|
[ORG_ID, periodStart, periodEnd],
|
||||||
|
);
|
||||||
|
if (!clash) return { periodStart, periodEnd };
|
||||||
|
}
|
||||||
|
throw new Error("[HR-07] no free payroll period found in the next 36 months");
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("HR-07 · self-service is scoped", () => {
|
||||||
|
test.use({ storageState: storageFor("hr-employee") });
|
||||||
|
|
||||||
|
test("my-payslips returns only the caller's own, approved-or-paid payslips", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
const self = await login(request, "hr-employee"); // employee A — the caller under test
|
||||||
|
const decoy = await login(request, "hr-payroll-admin"); // employee B — must never leak
|
||||||
|
const manager = await login(request, "hr-manager");
|
||||||
|
const authManager = { Authorization: `Bearer ${manager.token}` };
|
||||||
|
const authSelf = { Authorization: `Bearer ${self.token}` };
|
||||||
|
|
||||||
|
await ensurePayable(request, authManager, self.employeeId);
|
||||||
|
await ensurePayable(request, authManager, decoy.employeeId);
|
||||||
|
|
||||||
|
let runId = await scalar<string>(
|
||||||
|
`SELECT id FROM hr.payroll_runs
|
||||||
|
WHERE organization_id = $1 AND note = $2 AND status = 'APPROVED'
|
||||||
|
LIMIT 1`,
|
||||||
|
[ORG_ID, NOTE],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!runId) {
|
||||||
|
const admin = await login(request, "hr-payroll-admin");
|
||||||
|
const authAdmin = { Authorization: `Bearer ${admin.token}` };
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
// A different starting month than HR-06's own run, so the two scenarios
|
||||||
|
// never contend for the same free period.
|
||||||
|
const { periodStart, periodEnd } = await findFreePeriod(
|
||||||
|
today.getUTCFullYear(),
|
||||||
|
today.getUTCMonth() + 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const create = await request.post(`${HR_API}/api/v1/payroll-runs`, {
|
||||||
|
headers: authAdmin,
|
||||||
|
data: { periodStart, periodEnd, note: NOTE },
|
||||||
|
});
|
||||||
|
expect(create.status(), "open the run").toBe(201);
|
||||||
|
runId = (await create.json()).id;
|
||||||
|
|
||||||
|
const calc = await request.post(`${HR_API}/api/v1/payroll-runs/${runId}/calculate`, {
|
||||||
|
headers: authAdmin,
|
||||||
|
});
|
||||||
|
expect(calc.status(), "calculate").toBe(201);
|
||||||
|
expect((await calc.json()).employeeCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Both fixture employees are genuinely priced into this run before it is
|
||||||
|
// approved — sanity-check the setup, not just the scoping under test.
|
||||||
|
const selfPayslip = await scalar<string>(
|
||||||
|
`SELECT id FROM hr.payslips WHERE payroll_run_id = $1 AND employee_id = $2`,
|
||||||
|
[runId, self.employeeId],
|
||||||
|
);
|
||||||
|
const decoyPayslip = await scalar<string>(
|
||||||
|
`SELECT id FROM hr.payslips WHERE payroll_run_id = $1 AND employee_id = $2`,
|
||||||
|
[runId, decoy.employeeId],
|
||||||
|
);
|
||||||
|
expect(selfPayslip, "employee A must be priced into this run").not.toBeNull();
|
||||||
|
expect(decoyPayslip, "employee B must be priced into this run").not.toBeNull();
|
||||||
|
|
||||||
|
// ── The state-transition half of the contract: CALCULATED is not
|
||||||
|
// published yet ──────────────────────────────────────────────────────
|
||||||
|
const beforeApproval = await request.get(
|
||||||
|
`${HR_API}/api/v1/payroll-runs/my-payslips`,
|
||||||
|
{ headers: authSelf },
|
||||||
|
);
|
||||||
|
expect(beforeApproval.status()).toBe(200);
|
||||||
|
const beforeItems: Array<{ id: string; payrollRun?: { id: string } }> =
|
||||||
|
await beforeApproval.json();
|
||||||
|
expect(
|
||||||
|
beforeItems.some((item) => item.id === selfPayslip),
|
||||||
|
"a CALCULATED run's payslip must not appear before approval",
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
const approve = await request.patch(
|
||||||
|
`${HR_API}/api/v1/payroll-runs/${runId}/approve`,
|
||||||
|
{ headers: authManager },
|
||||||
|
);
|
||||||
|
expect(approve.status(), "approve").toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The specific payslip id belonging to employee B (the decoy), fetched
|
||||||
|
// through the view-ALL route so we know exactly what must never leak into
|
||||||
|
// employee A's own-scoped response.
|
||||||
|
const decoyPayslipId = await scalar<string>(
|
||||||
|
`SELECT id FROM hr.payslips WHERE payroll_run_id = $1 AND employee_id = $2`,
|
||||||
|
[runId, decoy.employeeId],
|
||||||
|
);
|
||||||
|
expect(decoyPayslipId, "employee B's payslip must exist to test against").not.toBeNull();
|
||||||
|
|
||||||
|
// ── NET — employee A's own view, after approval ─────────────────────────
|
||||||
|
const mine = await request.get(`${HR_API}/api/v1/payroll-runs/my-payslips`, {
|
||||||
|
headers: authSelf,
|
||||||
|
});
|
||||||
|
expect(mine.status()).toBe(200);
|
||||||
|
const items: Array<{
|
||||||
|
id: string;
|
||||||
|
employeeId: string;
|
||||||
|
payrollRun?: { id: string; status: string };
|
||||||
|
}> = await mine.json();
|
||||||
|
|
||||||
|
const ours = items.find((item) => item.payrollRun?.id === runId);
|
||||||
|
expect(ours, "employee A's own payslip from this run must appear").toBeTruthy();
|
||||||
|
expect(ours?.employeeId).toBe(self.employeeId);
|
||||||
|
expect(
|
||||||
|
ours?.payrollRun?.status,
|
||||||
|
"only APPROVED/PAID runs are published — never DRAFT/CALCULATED",
|
||||||
|
).toMatch(/^(APPROVED|PAID)$/);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
items.every((item) => item.employeeId === self.employeeId),
|
||||||
|
"every item returned must belong to the caller — never another employee",
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
items.some((item) => item.id === decoyPayslipId),
|
||||||
|
"employee B's payslip must never appear in employee A's own-scoped response",
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
// ── DB — the response is exactly the set of A's approved-or-paid payslips,
|
||||||
|
// no more and no fewer ──────────────────────────────────────────────────
|
||||||
|
const dbOwnCount = await scalar<string>(
|
||||||
|
`SELECT count(*)::text
|
||||||
|
FROM hr.payslips p
|
||||||
|
JOIN hr.payroll_runs r ON r.id = p.payroll_run_id
|
||||||
|
WHERE p.employee_id = $1 AND r.status IN ('APPROVED', 'PAID')`,
|
||||||
|
[self.employeeId],
|
||||||
|
);
|
||||||
|
expect(items.length).toBe(Number(dbOwnCount));
|
||||||
|
|
||||||
|
const dbOthers = await query<{ id: string }>(
|
||||||
|
`SELECT p.id
|
||||||
|
FROM hr.payslips p
|
||||||
|
JOIN hr.payroll_runs r ON r.id = p.payroll_run_id
|
||||||
|
WHERE p.employee_id = $1 AND r.status IN ('APPROVED', 'PAID')`,
|
||||||
|
[decoy.employeeId],
|
||||||
|
);
|
||||||
|
const returnedIds = new Set(items.map((item) => item.id));
|
||||||
|
for (const row of dbOthers) {
|
||||||
|
expect(
|
||||||
|
returnedIds.has(row.id),
|
||||||
|
`decoy payslip ${row.id} must not be in employee A's response`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
158
e2e-hr-finance/specs/hr/recruitment-rehire-conflict.spec.ts
Normal file
158
e2e-hr-finance/specs/hr/recruitment-rehire-conflict.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { personaByKey, STAFF_POSITION_ID } from "../../fixtures/personas";
|
||||||
|
import { scalar } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HR-09 · hiring twice off the same offer is refused, and the vacancy's
|
||||||
|
* filled count does not move on the refused attempt.
|
||||||
|
*
|
||||||
|
* `RecruitmentService.hireFromOffer` guards on `offer.hiredEmployeeId`, not on
|
||||||
|
* a status transition: a hired offer stays ACCEPTED forever — only its
|
||||||
|
* `hiredEmployeeId` flips from null to the new employee's id. A second call
|
||||||
|
* throws `ConflictException` (409) BEFORE the transaction that increments
|
||||||
|
* `hr.job_openings.filled` ever runs
|
||||||
|
* (apps/edr-hr-api/src/modules/recruitment/services/recruitment.service.ts):
|
||||||
|
*
|
||||||
|
* if (offer.hiredEmployeeId) {
|
||||||
|
* throw new ConflictException(
|
||||||
|
* "This offer has already been hired — a second hire would create a " +
|
||||||
|
* "duplicate person.",
|
||||||
|
* );
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Fixture: a real hire creates a new IAM account, employee record and HR
|
||||||
|
* profile as a side effect — that is the behaviour under test, not incidental
|
||||||
|
* setup — so this scenario builds its own opening → applicant → application →
|
||||||
|
* offer chain through the product's own endpoints (STAGE_TRANSITIONS: APPLIED
|
||||||
|
* → SCREENING → SHORTLISTED → OFFER) rather than reusing a real employee's
|
||||||
|
* historical offer. Every identifying field carries an E2E- prefix or a
|
||||||
|
* fresh timestamp so reruns never collide. `STAFF_POSITION_ID` is reused
|
||||||
|
* deliberately — three other personas already hold it concurrently, which is
|
||||||
|
* the existing proof that this position accepts more than one holder.
|
||||||
|
*/
|
||||||
|
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||||
|
|
||||||
|
test.describe("HR-09 · re-hiring an accepted offer is refused", () => {
|
||||||
|
test.use({ storageState: storageFor("hr-recruitment-officer") });
|
||||||
|
|
||||||
|
test("a second hire on the same offer returns 409 and the opening's filled count is unchanged", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const persona = personaByKey("hr-recruitment-officer");
|
||||||
|
const loginRes = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||||
|
data: { email: persona.email, password: persona.password },
|
||||||
|
});
|
||||||
|
const { token } = await loginRes.json();
|
||||||
|
const headers = { Authorization: `Bearer ${token}` };
|
||||||
|
|
||||||
|
const ts = Date.now();
|
||||||
|
|
||||||
|
const opening = await request.post(`${HR_API}/api/v1/recruitment/openings`, {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
reference: `E2E-REHIRE-${ts}`,
|
||||||
|
title: { am: "ኢ2ኢ ምልመላ", en: "E2E HR-09 Opening" },
|
||||||
|
positionId: STAFF_POSITION_ID,
|
||||||
|
openings: 1,
|
||||||
|
closesOn: "2027-01-01",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(opening.status()).toBe(201);
|
||||||
|
const openingBody = await opening.json();
|
||||||
|
|
||||||
|
const publish = await request.patch(
|
||||||
|
`${HR_API}/api/v1/recruitment/openings/${openingBody.id}/publish`,
|
||||||
|
{ headers, data: {} },
|
||||||
|
);
|
||||||
|
expect(publish.status()).toBe(200);
|
||||||
|
|
||||||
|
const applicant = await request.post(`${HR_API}/api/v1/recruitment/applicants`, {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
fullName: { am: "ኢ2ኢ አመልካች", en: "E2E HR-09 Applicant" },
|
||||||
|
phoneNumber: `E2E-${ts}`,
|
||||||
|
email: `e2e.hr09.${ts}@edr.local`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(applicant.status()).toBe(201);
|
||||||
|
const applicantBody = await applicant.json();
|
||||||
|
|
||||||
|
const application = await request.post(`${HR_API}/api/v1/recruitment/applications`, {
|
||||||
|
headers,
|
||||||
|
data: { jobOpeningId: openingBody.id, applicantId: applicantBody.id },
|
||||||
|
});
|
||||||
|
expect(application.status()).toBe(201);
|
||||||
|
const applicationBody = await application.json();
|
||||||
|
|
||||||
|
for (const stage of ["SCREENING", "SHORTLISTED", "OFFER"]) {
|
||||||
|
const move = await request.patch(
|
||||||
|
`${HR_API}/api/v1/recruitment/applications/${applicationBody.id}/stage`,
|
||||||
|
{ headers, data: { stage } },
|
||||||
|
);
|
||||||
|
expect(move.status(), `move to ${stage} must be accepted`).toBe(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const offer = await request.post(`${HR_API}/api/v1/recruitment/offers`, {
|
||||||
|
headers,
|
||||||
|
data: {
|
||||||
|
applicationId: applicationBody.id,
|
||||||
|
offeredBasicSalary: "9000.00",
|
||||||
|
proposedStartDate: "2027-02-01",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(offer.status()).toBe(201);
|
||||||
|
const offerBody = await offer.json();
|
||||||
|
|
||||||
|
const send = await request.patch(`${HR_API}/api/v1/recruitment/offers/${offerBody.id}/send`, {
|
||||||
|
headers,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
expect(send.status()).toBe(200);
|
||||||
|
|
||||||
|
const respond = await request.patch(
|
||||||
|
`${HR_API}/api/v1/recruitment/offers/${offerBody.id}/respond`,
|
||||||
|
{ headers, data: { accepted: true } },
|
||||||
|
);
|
||||||
|
expect(respond.status()).toBe(200);
|
||||||
|
|
||||||
|
const hireDto = {
|
||||||
|
username: `e2e.hr09.${ts}`,
|
||||||
|
email: `e2e.hr09.${ts}@edr.local`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstHire = await request.post(
|
||||||
|
`${HR_API}/api/v1/recruitment/offers/${offerBody.id}/hire`,
|
||||||
|
{ headers, data: hireDto },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
firstHire.status(),
|
||||||
|
"the first hire off a freshly accepted offer must succeed",
|
||||||
|
).toBe(201);
|
||||||
|
|
||||||
|
const filledAfterFirst = await scalar<number>(
|
||||||
|
`SELECT "filled" FROM hr.job_openings WHERE id = $1`,
|
||||||
|
[openingBody.id],
|
||||||
|
);
|
||||||
|
expect(filledAfterFirst, "one seat filled after the real hire").toBe(1);
|
||||||
|
|
||||||
|
const secondHire = await request.post(
|
||||||
|
`${HR_API}/api/v1/recruitment/offers/${offerBody.id}/hire`,
|
||||||
|
{ headers, data: hireDto },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
secondHire.status(),
|
||||||
|
"a second hire off the same already-hired offer must be refused with 409",
|
||||||
|
).toBe(409);
|
||||||
|
|
||||||
|
const filledAfterSecond = await scalar<number>(
|
||||||
|
`SELECT "filled" FROM hr.job_openings WHERE id = $1`,
|
||||||
|
[openingBody.id],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
filledAfterSecond,
|
||||||
|
"the refused second attempt must not move the opening's filled count",
|
||||||
|
).toBe(filledAfterFirst);
|
||||||
|
});
|
||||||
|
});
|
||||||
141
e2e-hr-finance/specs/hr/reports-leave-liability-filter.spec.ts
Normal file
141
e2e-hr-finance/specs/hr/reports-leave-liability-filter.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||||
|
import { query, scalar } from "../../fixtures/db";
|
||||||
|
import { storageFor } from "../../playwright.config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HR-11 · the leave-liability provision values only PAID leave that CARRIES
|
||||||
|
* OVER, never leave that lapses at year end.
|
||||||
|
*
|
||||||
|
* `ReportsService.leaveLiability` filters on
|
||||||
|
* `t."is_paid" = true AND t."max_carry_over_days" > 0`
|
||||||
|
* (apps/edr-hr-api/src/modules/reports/services/reports.service.ts). The
|
||||||
|
* service's own comment names the reason: sick, bereavement, marriage and
|
||||||
|
* paternity leave are entitlements that lapse at year end, not days the
|
||||||
|
* employee has banked, and valuing them would inflate the provision several
|
||||||
|
* times over. This scenario reads that exact predicate independently from
|
||||||
|
* `hr.leave_types` — rather than hardcoding assumed leave-type codes like
|
||||||
|
* "SICK" — and asserts the report's returned codes are exactly the ones that
|
||||||
|
* predicate allows. It then re-derives the total liability straight from the
|
||||||
|
* ledger (`hr.leave_entitlements` / `hr.leave_ledger_entries` /
|
||||||
|
* `hr.employee_salaries`) and checks it against what the endpoint returned —
|
||||||
|
* the same "don't trust the report to check its own arithmetic" pattern
|
||||||
|
* FIN-07 runs against the trial balance.
|
||||||
|
*
|
||||||
|
* `asOf` is pinned to a fixed date so the API call and the independent DB
|
||||||
|
* check are guaranteed to price the same day — leaving it to default to
|
||||||
|
* "today" on both sides would work in practice, but would make a mismatch
|
||||||
|
* from clock drift indistinguishable from a real regression.
|
||||||
|
*/
|
||||||
|
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||||
|
const AS_OF = "2026-06-30";
|
||||||
|
|
||||||
|
test.describe("HR-11 · leave liability values only carry-over leave types", () => {
|
||||||
|
test.use({ storageState: storageFor("hr-manager") });
|
||||||
|
|
||||||
|
test("excludes non-carry-over/unpaid types, and the total agrees with an independent ledger sum", async ({
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
const persona = personaByKey("hr-manager");
|
||||||
|
const loginRes = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||||
|
data: { email: persona.email, password: persona.password },
|
||||||
|
});
|
||||||
|
const { token } = await loginRes.json();
|
||||||
|
|
||||||
|
const res = await request.get(`${HR_API}/api/v1/reports/leave-liability?asOf=${AS_OF}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const rows = (await res.json()) as {
|
||||||
|
employeeId: string;
|
||||||
|
employeeNumber: string | null;
|
||||||
|
leaveTypeCode: string;
|
||||||
|
balanceDays: string;
|
||||||
|
dailyRate: string;
|
||||||
|
liability: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
// A 2,416-employee org with a real leave history should have SOME
|
||||||
|
// untaken carry-over balance — otherwise the assertions below would pass
|
||||||
|
// vacuously on an empty response.
|
||||||
|
expect(
|
||||||
|
rows.length,
|
||||||
|
"expected at least one carry-over liability row for the Active org",
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const returnedCodes = new Set(rows.map((r) => r.leaveTypeCode));
|
||||||
|
|
||||||
|
// Types the SQL predicate must EXCLUDE — unpaid, or lapsing at year end.
|
||||||
|
const excluded = await query<{ code: string }>(
|
||||||
|
`SELECT DISTINCT "code" FROM hr.leave_types
|
||||||
|
WHERE organization_id = $1 AND deleted_at IS NULL
|
||||||
|
AND ("max_carry_over_days" <= 0 OR "is_paid" = false)`,
|
||||||
|
[ORG_ID],
|
||||||
|
);
|
||||||
|
for (const { code } of excluded) {
|
||||||
|
expect(
|
||||||
|
returnedCodes.has(code),
|
||||||
|
`${code} does not carry over (or is unpaid) and must not appear in the liability report`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every code the report DID return must be a paid, carry-over type — the
|
||||||
|
// other half of the same predicate, checked from the other direction.
|
||||||
|
const eligible = await query<{ code: string }>(
|
||||||
|
`SELECT DISTINCT "code" FROM hr.leave_types
|
||||||
|
WHERE organization_id = $1 AND deleted_at IS NULL
|
||||||
|
AND "max_carry_over_days" > 0 AND "is_paid" = true`,
|
||||||
|
[ORG_ID],
|
||||||
|
);
|
||||||
|
const eligibleCodes = new Set(eligible.map((r) => r.code));
|
||||||
|
for (const code of returnedCodes) {
|
||||||
|
expect(
|
||||||
|
eligibleCodes.has(code),
|
||||||
|
`${code} appeared in the report but is not a paid, carry-over leave type`,
|
||||||
|
).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Independently re-derive the total liability from the ledger, using the
|
||||||
|
// same carry-over/paid filter, the same effective-dated salary lookup and
|
||||||
|
// the same per-row rounding — then compare the SUM to the endpoint's.
|
||||||
|
const independentTotal = await scalar<string>(
|
||||||
|
`SELECT ROUND(SUM("rowLiability"), 2)::text AS "total"
|
||||||
|
FROM (
|
||||||
|
SELECT ROUND(
|
||||||
|
COALESCE(SUM(l."days"), 0) * COALESCE(sal."basic_salary", 0) / 30.0,
|
||||||
|
2
|
||||||
|
) AS "rowLiability"
|
||||||
|
FROM hr.leave_entitlements ent
|
||||||
|
JOIN hr.leave_types t ON t."id" = ent."leave_type_id"
|
||||||
|
JOIN hr.employee_profiles p ON p."employee_id" = ent."employee_id"
|
||||||
|
LEFT JOIN hr.leave_ledger_entries l ON l."entitlement_id" = ent."id"
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT es."basic_salary"
|
||||||
|
FROM hr.employee_salaries es
|
||||||
|
WHERE es."employee_id" = ent."employee_id"
|
||||||
|
AND es."deleted_at" IS NULL
|
||||||
|
AND es."effective_from" <= $2::date
|
||||||
|
AND (es."effective_to" IS NULL OR es."effective_to" >= $2::date)
|
||||||
|
ORDER BY es."effective_from" DESC
|
||||||
|
LIMIT 1
|
||||||
|
) sal ON true
|
||||||
|
WHERE ent."deleted_at" IS NULL
|
||||||
|
AND t."is_paid" = true
|
||||||
|
AND t."max_carry_over_days" > 0
|
||||||
|
AND p."employment_state" NOT IN ('TERMINATED','RETIRED')
|
||||||
|
AND ent."organization_id" = $1::uuid
|
||||||
|
GROUP BY ent."employee_id", t."code", sal."basic_salary"
|
||||||
|
HAVING COALESCE(SUM(l."days"), 0) > 0
|
||||||
|
) "rowsByEmployeeAndType"`,
|
||||||
|
[ORG_ID, AS_OF],
|
||||||
|
);
|
||||||
|
|
||||||
|
const apiTotal = rows.reduce((sum, r) => sum + Number(r.liability), 0).toFixed(2);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
independentTotal,
|
||||||
|
"the report's total liability must match an independent re-derivation from the ledger",
|
||||||
|
).toBe(apiTotal);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
"test:e2e:passenger": "bash e2e/run.sh",
|
"test:e2e:passenger": "bash e2e/run.sh",
|
||||||
"test:e2e:ui": "bash e2e-ui/run.sh",
|
"test:e2e:ui": "bash e2e-ui/run.sh",
|
||||||
"test:e2e:ui:only": "playwright test -c e2e-ui/playwright.config.ts",
|
"test:e2e:ui:only": "playwright test -c e2e-ui/playwright.config.ts",
|
||||||
|
"test:e2e:hr-finance": "bash e2e-hr-finance/run.sh",
|
||||||
|
"test:e2e:hr-finance:only": "playwright test -c e2e-hr-finance/playwright.config.ts",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"type-check": "turbo run type-check",
|
"type-check": "turbo run type-check",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
|
||||||
|
|||||||
3731
pnpm-lock.yaml
generated
3731
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user