Adding all functionalities

This commit is contained in:
Muluhabt
2026-08-26 23:57:26 +03:00
parent 70171fa9d8
commit 1587620e14
62 changed files with 14331 additions and 357 deletions

View File

@@ -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);
});
});
});

View File

@@ -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,
});
});
});
});

View File

@@ -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,
}),
);
});
});

View File

@@ -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);
});
});

View 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();
});
});

View File

@@ -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 });
});
});
});

View File

@@ -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);
});
});

View File

@@ -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();
});
});

View File

@@ -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.0110,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();
});
});

View File

@@ -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([]);
});
});

View File

@@ -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,
});
});
});
});

View File

@@ -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);
});
});

View File

@@ -58,7 +58,7 @@ export class ReportsService {
* two.
*/
async headcount(actor: ActorContext): Promise<HeadcountRow[]> {
const scope = orgScope(actor) ?? actor.organizationId ?? null;
const scope = orgScope(actor);
return this.dataSource.query<HeadcountRow[]>(
`SELECT e."unit_id" AS "unitId",
u."name" AS "unitName",
@@ -104,7 +104,7 @@ export class ReportsService {
turnoverRate: number;
}> {
ReportsService.assertRange(from, to);
const scope = orgScope(actor) ?? actor.organizationId ?? null;
const scope = orgScope(actor);
const [row] = await this.dataSource.query<
{
@@ -155,7 +155,7 @@ export class ReportsService {
payrollRunId: string,
actor: ActorContext,
): Promise<PayrollRegisterRow[]> {
const scope = orgScope(actor) ?? actor.organizationId ?? null;
const scope = orgScope(actor);
return this.dataSource.query<PayrollRegisterRow[]>(
`SELECT s."employee_id" AS "employeeId",
s."employee_number" AS "employeeNumber",
@@ -198,7 +198,7 @@ export class ReportsService {
}[]
> {
ReportsService.assertRange(from, to);
const scope = orgScope(actor) ?? actor.organizationId ?? null;
const scope = orgScope(actor);
return this.dataSource.query(
`SELECT s."employee_id" AS "employeeId",
MAX(s."employee_number") AS "employeeNumber",
@@ -234,7 +234,7 @@ export class ReportsService {
}[]
> {
ReportsService.assertRange(from, to);
const scope = orgScope(actor) ?? actor.organizationId ?? null;
const scope = orgScope(actor);
return this.dataSource.query(
`SELECT s."employee_id" AS "employeeId",
MAX(s."employee_number") AS "employeeNumber",
@@ -289,7 +289,7 @@ export class ReportsService {
liability: string;
}[]
> {
const scope = orgScope(actor) ?? actor.organizationId ?? null;
const scope = orgScope(actor);
const on = asOf ?? new Date().toISOString().slice(0, 10);
return this.dataSource.query(
`SELECT ent."employee_id" AS "employeeId",
@@ -345,7 +345,7 @@ export class ReportsService {
}[]
> {
ReportsService.assertRange(from, to);
const scope = orgScope(actor) ?? actor.organizationId ?? null;
const scope = orgScope(actor);
return this.dataSource.query(
`SELECT e."unit_id" AS "unitId",
u."name" AS "unitName",
@@ -376,7 +376,7 @@ export class ReportsService {
{ leaveTypeCode: string; requests: number; days: string; employees: number }[]
> {
ReportsService.assertRange(from, to);
const scope = orgScope(actor) ?? actor.organizationId ?? null;
const scope = orgScope(actor);
return this.dataSource.query(
`SELECT t."code" AS "leaveTypeCode",
COUNT(*)::int AS "requests",
@@ -416,7 +416,7 @@ export class ReportsService {
}[]
> {
ReportsService.assertRange(from, to);
const scope = orgScope(actor) ?? actor.organizationId ?? null;
const scope = orgScope(actor);
return this.dataSource.query(
`SELECT pl."code" AS "code",
pl."component_type" AS "componentType",
@@ -447,7 +447,7 @@ export class ReportsService {
if (withinDays < 1 || withinDays > 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(
`SELECT p."employee_id" AS "employeeId",

View File

@@ -147,6 +147,7 @@ export function AppraisalReviewsPage() {
showSelf
isLoading={submit.isPending}
submitLabel="Submit assessment"
submitTestId="appraisal-submit-manager"
onSubmit={(scores, comment) => submit.mutate({ scores, comment })}
/>
</>

View File

@@ -180,6 +180,7 @@ export function MyAppraisalsPage() {
ratings={detail.data.ratings ?? []}
isLoading={self.isPending}
submitLabel="Submit my assessment"
submitTestId="appraisal-submit-self"
onSubmit={(scores, comment) => self.mutate({ scores, comment })}
/>
</>

View File

@@ -39,6 +39,7 @@ export function ScoringForm({
onSubmit,
isLoading,
submitLabel,
submitTestId,
error,
}: {
ratings: AppraisalRating[];
@@ -46,6 +47,7 @@ export function ScoringForm({
onSubmit: (scores: ScoreInput[], comment: string) => void;
isLoading?: boolean;
submitLabel: string;
submitTestId?: string;
error?: string | null;
}) {
const { i18n } = useTranslation();
@@ -80,7 +82,13 @@ export function ScoringForm({
</Alert>
{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">
<Stack gap={2} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
@@ -133,7 +141,7 @@ export function ScoringForm({
<Text size="sm" fw={600}>
Running total
</Text>
<Text size="xl" fw={700}>
<Text size="xl" fw={700} data-testid="appraisal-weight-total">
{total}%
</Text>
</Group>
@@ -155,6 +163,7 @@ export function ScoringForm({
<Button
loading={isLoading}
disabled={!complete}
data-testid={submitTestId}
onClick={() =>
onSubmit(
ratings.map((rating) => ({

View File

@@ -100,7 +100,13 @@ export function AttendanceApprovalsPage() {
) : (
<Stack>
{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">
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs">
@@ -123,6 +129,7 @@ export function AttendanceApprovalsPage() {
</Text>
</Stack>
<Actions
id={item.id}
onApprove={() =>
decide.mutate({ kind: "correction", item, approve: true })
}
@@ -169,6 +176,7 @@ export function AttendanceApprovalsPage() {
)}
</Stack>
<Actions
id={item.id}
onApprove={() =>
decide.mutate({ kind: "overtime", item, approve: true })
}
@@ -211,10 +219,12 @@ export function AttendanceApprovalsPage() {
}
function Actions({
id,
onApprove,
onReject,
loading,
}: {
id: string;
onApprove: () => void;
onReject: () => void;
loading: boolean;
@@ -227,6 +237,7 @@ function Actions({
color="red"
leftSection={<IconX size={14} />}
onClick={onReject}
data-testid={`attendance-regularization-reject-btn-${id}`}
>
Reject
</Button>
@@ -235,6 +246,7 @@ function Actions({
leftSection={<IconCheck size={14} />}
loading={loading}
onClick={onApprove}
data-testid={`attendance-regularization-approve-btn-${id}`}
>
Approve
</Button>

View File

@@ -193,7 +193,10 @@ export function MyAttendancePage() {
</Table.Thead>
<Table.Tbody>
{(records.data ?? []).map((record) => (
<Table.Tr key={record.id}>
<Table.Tr
key={record.id}
data-testid={`attendance-record-row-${record.id}`}
>
<Table.Td>
<Text size="sm">{record.workDate}</Text>
{record.notes && (
@@ -281,7 +284,10 @@ export function MyAttendancePage() {
</Table.Thead>
<Table.Tbody>
{(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>
<Text size="sm">{request.workDate}</Text>
</Table.Td>

View File

@@ -91,6 +91,7 @@ export function ClockWidget() {
}
loading={punchIn.isPending || punchOut.isPending}
onClick={() => (clockedIn ? punchOut.mutate() : punchIn.mutate())}
data-testid={clockedIn ? "attendance-clock-out" : "attendance-clock-in"}
>
{clockedIn ? "Clock out" : "Clock in"}
</Button>

View File

@@ -153,7 +153,7 @@ function ApprovalCard({
const after = balance ? Number((balance.availableDays - charged).toFixed(2)) : null;
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">
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs">
@@ -203,6 +203,7 @@ function ApprovalCard({
color="red"
leftSection={<IconX size={14} />}
onClick={onReject}
data-testid={`leave-reject-btn-${request.id}`}
>
Reject
</Button>
@@ -211,6 +212,7 @@ function ApprovalCard({
leftSection={<IconCheck size={14} />}
loading={isApproving}
onClick={onApprove}
data-testid={`leave-approve-btn-${request.id}`}
>
Approve
</Button>

View File

@@ -142,6 +142,7 @@ export function RequestLeaveModal({
)}
<Select
data-testid="leave-request-type"
label="Leave type"
required
searchable
@@ -163,12 +164,13 @@ export function RequestLeaveModal({
}
/>
<Group grow align="flex-start">
<Group grow align="flex-start" data-testid="leave-request-dates">
<EthiopianDateInput
label="First day"
required
value={startDate}
onChange={setStart}
data-testid="leave-request-start-date"
/>
<EthiopianDateInput
label="Last day"
@@ -176,6 +178,7 @@ export function RequestLeaveModal({
disabled={isHalfDay}
value={endDate}
onChange={setEndDate}
data-testid="leave-request-end-date"
/>
</Group>
@@ -288,6 +291,7 @@ export function RequestLeaveModal({
Cancel
</Button>
<Button
data-testid="leave-request-submit"
loading={submit.isPending}
disabled={!ready}
onClick={() => submit.mutate()}

View File

@@ -118,7 +118,13 @@ export function PayrollRunsPage() {
<Stack>
{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">
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs">
@@ -159,6 +165,7 @@ export function PayrollRunsPage() {
leftSection={<IconCalculator size={14} />}
loading={act.isPending}
onClick={() => act.mutate({ id: run.id, action: "calculate" })}
data-testid={`payroll-calculate-btn-${run.id}`}
>
{run.status === "DRAFT" ? "Calculate" : "Recalculate"}
</Button>
@@ -170,6 +177,7 @@ export function PayrollRunsPage() {
size="compact-sm"
leftSection={<IconCheck size={14} />}
onClick={() => setApproving(run)}
data-testid={`payroll-approve-btn-${run.id}`}
>
Approve
</Button>
@@ -183,6 +191,7 @@ export function PayrollRunsPage() {
leftSection={<IconCash size={14} />}
loading={act.isPending}
onClick={() => act.mutate({ id: run.id, action: "pay" })}
data-testid={`payroll-mark-paid-btn-${run.id}`}
>
Mark paid
</Button>

View File

@@ -239,6 +239,7 @@ export function OpeningDetailPage() {
? setRejecting(application.id)
: advance.mutate({ id: application.id, stage })
}
data-testid={`recruitment-stage-advance-btn-${stage}`}
>
{stage.toLowerCase()}
</Button>

View File

@@ -311,6 +311,7 @@ export function ApplicationDrawer({
size="compact-sm"
leftSection={<IconUserCheck size={14} />}
onClick={() => setHiring(true)}
data-testid="recruitment-hire-btn"
>
Hire
</Button>
@@ -700,6 +701,7 @@ function HireModal({
loading={submit.isPending}
disabled={!username || !email || !positionId}
onClick={() => submit.mutate()}
data-testid="recruitment-hire-confirm-btn"
>
Hire
</Button>

View File

@@ -132,6 +132,7 @@ export function ReportsPage() {
leftSection={<IconDownload size={14} />}
disabled={!rows?.length}
onClick={() => downloadCsv(`${name}-${from}-to-${to}.csv`, rows ?? [])}
data-testid={`report-export-btn-${name}`}
>
CSV
</Button>
@@ -215,7 +216,10 @@ export function ReportsPage() {
</Table.Thead>
<Table.Tbody>
{(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>
{row.unitName
? localized(row.unitName, i18n.language)
@@ -259,7 +263,7 @@ export function ReportsPage() {
</Table.Thead>
<Table.Tbody>
{(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>
<Badge
@@ -313,7 +317,10 @@ export function ReportsPage() {
</Table.Thead>
<Table.Tbody>
{(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 ta="right">{row.periods}</Table.Td>
<Table.Td ta="right">{money(row.grossPay)}</Table.Td>
@@ -346,7 +353,10 @@ export function ReportsPage() {
</Table.Thead>
<Table.Tbody>
{(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 ta="right">{money(row.pensionableIncome)}</Table.Td>
<Table.Td ta="right">{money(row.employeeContribution)}</Table.Td>
@@ -386,7 +396,10 @@ export function ReportsPage() {
</Table.Thead>
<Table.Tbody>
{(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.leaveTypeCode}</Table.Td>
<Table.Td ta="right">{Number(row.balanceDays)}</Table.Td>
@@ -418,7 +431,10 @@ export function ReportsPage() {
</Table.Thead>
<Table.Tbody>
{(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 ta="right">{row.requests}</Table.Td>
<Table.Td ta="right">

View File

@@ -33,6 +33,7 @@ export function EthiopianDateInput({
error,
description,
disabled,
"data-testid": testId,
}: {
label: string;
value: string | null | undefined;
@@ -41,6 +42,7 @@ export function EthiopianDateInput({
error?: string;
description?: string;
disabled?: boolean;
"data-testid"?: string;
}) {
const gregorian = useMemo(() => fromIsoDate(value), [value]);
const ethiopian = useMemo(
@@ -66,6 +68,7 @@ export function EthiopianDateInput({
required={required}
error={error}
description={description}
data-testid={testId}
>
<Stack gap={6} mt={4}>
<DatePickerInput

View 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();
});
});

View File

@@ -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;
}

View 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();
});
});

View 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);
});
});

View 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");
});
});

View 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,
);
});
});

View File

@@ -346,23 +346,33 @@ export class PayablesService {
});
}
const entry = await this.journals.createPosted(actor, {
entryDate: bill.billDate,
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,
});
await this.dataSource.transaction(async (manager) => {
// The manager is passed through so the journal entry is written on THIS
// transaction. Without it the entry committed on its own connection, and
// a failure in the bill update below left a posted purchase entry with
// no bill ever marked APPROVED to explain it.
const entry = await this.journals.createPosted(
actor,
{
entryDate: bill.billDate,
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, {
status: "APPROVED",
journalEntryId: entry.id,
approvedBy: actor.employeeId,
approvedAt: new Date(),
await manager.getRepository(SupplierBill).update(id, {
status: "APPROVED",
journalEntryId: entry.id,
approvedBy: actor.employeeId,
approvedAt: new Date(),
});
});
return this.findBill(actor, id);
@@ -420,26 +430,34 @@ export class PayablesService {
manager,
);
const entry = await this.journals.createPosted(actor, {
entryDate: dto.paymentDate,
journalType: "CASH_PAYMENT",
memo: `Payment to ${supplier?.name ?? "supplier"} for ${bill.billNumber}`,
reference: dto.reference ?? paymentNumber,
sourceModule: "supplier-payment",
sourceId: `${bill.id}:${paymentNumber}`,
lines: [
{
accountId: payableAccount.id,
debit: amount,
description: `Settle ${bill.billNumber}`,
},
{
accountId: cashAccount.id,
credit: amount,
description: `${dto.method} ${dto.reference ?? ""}`.trim(),
},
],
});
// The manager is passed through so the journal entry is written on THIS
// transaction. Without it the entry committed on its own connection, and
// a failure in the payment/bill writes below left a posted cash-payment
// entry with no payment record and no paidAmount update to explain it.
const entry = await this.journals.createPosted(
actor,
{
entryDate: dto.paymentDate,
journalType: "CASH_PAYMENT",
memo: `Payment to ${supplier?.name ?? "supplier"} for ${bill.billNumber}`,
reference: dto.reference ?? paymentNumber,
sourceModule: "supplier-payment",
sourceId: `${bill.id}:${paymentNumber}`,
lines: [
{
accountId: payableAccount.id,
debit: amount,
description: `Settle ${bill.billNumber}`,
},
{
accountId: cashAccount.id,
credit: amount,
description: `${dto.method} ${dto.reference ?? ""}`.trim(),
},
],
},
manager,
);
const repo = manager.getRepository(SupplierPayment);
const payment = await repo.save(

View File

@@ -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();
});
});

View File

@@ -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",
});
});
});

View File

@@ -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);
});
});

View File

@@ -85,7 +85,11 @@ export function AssetsPage() {
</>
)}
{can(FINANCE_PERMS.asset.depreciate) && (
<Button variant="light" onClick={() => setRunOpen(true)}>
<Button
data-testid="run-depreciation-btn"
variant="light"
onClick={() => setRunOpen(true)}
>
Run depreciation
</Button>
)}
@@ -135,7 +139,7 @@ export function AssetsPage() {
</Table.Thead>
<Table.Tbody>
{(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>{a.name}</Table.Td>
<Table.Td>
@@ -160,6 +164,7 @@ export function AssetsPage() {
{a.status !== "DISPOSED" && a.status !== "WRITTEN_OFF" &&
can(FINANCE_PERMS.asset.dispose) && (
<Button
data-testid={`dispose-btn-${a.id}`}
size="xs"
variant="light"
color="orange"
@@ -394,7 +399,12 @@ function RunModal({
/>
<Group justify="flex-end">
<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
</Button>
</Group>

View File

@@ -106,12 +106,18 @@ export function BudgetsPage() {
)}
{isDraft &&
(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
</Button>
) : (
<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>
))}
{can(FINANCE_PERMS.budget.manage) && (
@@ -138,6 +144,7 @@ export function BudgetsPage() {
<Group mb="md" gap="sm" align="flex-end">
<Select
label="Budget"
data-testid="budget-select"
data={(budgets.data ?? []).map((b) => ({
value: b.id,
label: `${b.name} (${b.status})`,
@@ -228,7 +235,10 @@ export function BudgetsPage() {
</Table.Thead>
<Table.Tbody>
{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>
<Text ff="monospace" size="sm">{r.accountCode}</Text>
</Table.Td>
@@ -303,7 +313,7 @@ export function BudgetsPage() {
</Table.Thead>
<Table.Tbody>
{(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>{l.accountName?.en}</Table.Td>
<Table.Td>

View File

@@ -121,7 +121,7 @@ export function CutoverPage() {
description="Going live: the opening balances, and whether the books are ready to carry them."
actions={
canManage ? (
<Button onClick={() => setDateOpen(true)}>
<Button data-testid="cutover-set-date-btn" onClick={() => setDateOpen(true)}>
{cutoverDate ? "Change cutover date" : "Set cutover date"}
</Button>
) : undefined
@@ -159,7 +159,12 @@ export function CutoverPage() {
{(readiness.data?.checks ?? []).map((check) => {
const Icon = CHECK_ICON[check.status];
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
size="sm"
radius="xl"
@@ -255,6 +260,7 @@ export function CutoverPage() {
<Textarea
label="Rows"
data-testid="cutover-import-paste"
placeholder={SAMPLE}
autosize
minRows={6}
@@ -264,7 +270,7 @@ export function CutoverPage() {
/>
{parsed.length > 0 ? (
<>
<div data-testid="cutover-import-preview">
{bad.length > 0 ? (
<Alert color="red" mt="md" title={`${bad.length} row(s) could not be read`}>
<Stack gap={4}>
@@ -380,7 +386,7 @@ export function CutoverPage() {
put a wrong plug in the ledger.
</Text>
) : null}
</>
</div>
) : null}
</Card>
) : null}

View File

@@ -130,12 +130,18 @@ export function JournalDetailPage() {
rather than left as an absence. */}
{isDraft &&
(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
</Button>
) : (
<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>
))}

View File

@@ -186,7 +186,7 @@ export function NewJournalPage() {
</Table.Thead>
<Table.Tbody>
{lines.map((line) => (
<Table.Tr key={line.key}>
<Table.Tr key={line.key} data-testid={`journal-line-row-${line.key}`}>
<Table.Td>
<Select
placeholder="Select an account"
@@ -293,11 +293,21 @@ export function NewJournalPage() {
Enter the debits and credits.
</Text>
) : 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
</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))} {" "}
{totals.difference > 0 ? "credits" : "debits"} are short
</Alert>

View File

@@ -125,6 +125,7 @@ export function PayablesPage() {
return (
<Table.Tr
key={b.id}
data-testid={`bill-row-${b.id}`}
style={{ cursor: "pointer" }}
onClick={() => setOpenBillId(b.id)}
>
@@ -390,18 +391,26 @@ function BillDrawer({
<Group justify="flex-end">
{data.status === "DRAFT" &&
(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 &amp; post
</Button>
) : (
<Tooltip label="Approving is a separate authorisation from entering the bill. Ask someone who holds it.">
<Button disabled>Approve &amp; post</Button>
<Button data-testid="bill-approve-btn" disabled>
Approve &amp; post
</Button>
</Tooltip>
))}
{outstanding > 0 &&
data.status !== "DRAFT" &&
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>
@@ -508,6 +517,7 @@ function PaymentModal({
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button
data-testid="bill-record-payment-confirm-btn"
loading={pay.isPending}
disabled={!accountId || !amount}
onClick={() => pay.mutate()}

View File

@@ -166,7 +166,7 @@ export function ReportsPage() {
// unused side of a one-sided row stays blank.
const netZero = !Number(r.debit) && !Number(r.credit);
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>{r.accountName?.en}</Table.Td>
<Table.Td><Text size="sm" c="dimmed">{r.accountType}</Text></Table.Td>
@@ -240,9 +240,19 @@ export function ReportsPage() {
{bs.data?.balanced ? (
<Alert color="green" variant="light" icon={<IconCheck size={18} />}>
<Text size="sm">
Balanced as at {bs.data.asOf} assets {formatMoney(bs.data.totalAssets)} =
liabilities {formatMoney(bs.data.totalLiabilities)} + equity{" "}
{formatMoney(bs.data.equityWithResult)}.
Balanced as at {bs.data.asOf} assets{" "}
<span data-testid="report-bs-total-assets">
{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>
</Alert>
) : (

View File

@@ -249,30 +249,64 @@ confirmed but the guard should be re-read at test-writing time.
## 4. `data-testid` prerequisite checklist
**Current state:** only nav-level testids exist (`nav-item-*`, `nav-group-*` from the redesign).
**Zero on any feature page.** This is the blocker that stalled the passenger effort; treat it as a
blocker, not a footnote. Note `SpotlightSearchProps` rejects `data-testid` address the palette
input by placeholder.
**Landed** (this pass). The inventory below was corrected against the real components while wiring
it in — several entries in the original draft named the wrong file or an element that doesn't exist
in the shape assumed; those are noted inline. `SpotlightSearchProps` still rejects `data-testid`
the palette input is addressed by placeholder, not a testid.
**HR (`apps/edr-hr-web/src/features/**`)**
- `leave/RequestModal.tsx`: `leave-request-type`, `leave-request-dates`, `leave-request-submit`
- `leave/LeaveApprovalsPage.tsx`: `leave-approval-row-<id>`, `leave-approve-btn-<id>`, `leave-reject-btn-<id>`
- `attendance/*`: `attendance-clock-in`, `attendance-clock-out`, `attendance-regularization-row-<id>`
- `payroll/PayrollRunsPage.tsx`: `payroll-run-row-<id>`, `payroll-calculate-btn`, `payroll-approve-btn`, `payroll-mark-paid-btn`
- `appraisal/*`: `appraisal-criterion-<key>`, `appraisal-weight-total`, `appraisal-submit-self`, `appraisal-submit-manager`
- `recruitment/ApplicationDrawer.tsx`: `recruitment-stage-select`, `recruitment-hire-btn`
- `reports/ReportsPage.tsx`: `report-row-<key>`, `report-export-btn`
- `leave/components/RequestLeaveModal.tsx` (not `leave/RequestModal.tsx` — that file doesn't exist):
`leave-request-type`, `leave-request-start-date`, `leave-request-end-date` (the pair sits inside a
`leave-request-dates` group), `leave-request-submit`. Required a `data-testid` pass-through prop
added to the shared `EthiopianDateInput` component, which didn't forward one.
- `leave/LeaveApprovalsPage.tsx`: `leave-approval-row-<id>`, `leave-approve-btn-<id>`,
`leave-reject-btn-<id>`
- `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/**`)**
- `journals/NewJournalPage.tsx`: `journal-line-row-<n>`, `journal-balance-indicator`, `journal-post-btn`
- `payables/PayablesPage.tsx`: `bill-row-<id>`, `bill-approve-btn-<id>`, `bill-record-payment-btn-<id>`
- `budgeting/BudgetsPage.tsx`: `budget-row-<id>`, `budget-approve-btn`
- `assets/AssetsPage.tsx`: `asset-row-<id>`, `run-depreciation-btn`, `dispose-btn-<id>`
- `cutover/CutoverPage.tsx`: `cutover-readiness-check-<key>`, `cutover-set-date-btn`, `cutover-import-paste`, `cutover-import-preview`
- `reports/ReportsPage.tsx`: `report-trial-balance-row-<code>`, `report-balance-sheet-total`
A starting inventory from reading route components, not exhaustive — whoever writes each spec adds
what their scenario needs in the same pass.
- `journals/NewJournalPage.tsx`: `journal-line-row-<key>` (a client-generated draft key, not a DB
id — these rows have no id yet), `journal-balance-indicator` (on both the balanced and
out-of-balance branches). **`journal-post-btn` is NOT on this page** — this page only ever creates
a DRAFT. It's on `journals/JournalDetailPage.tsx` instead (not in the original checklist at all).
- `payables/PayablesPage.tsx`: `bill-row-<id>`, `bill-approve-btn` (singular — only one bill is open
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).
- `budgeting/BudgetsPage.tsx`: **`budget-row-<id>` does not apply** — budgets are chosen via a
`<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.
---

View File

@@ -19,6 +19,13 @@ export DB_HOST="${DB_HOST:-localhost}"
export DB_PORT="${DB_PORT:-5432}"
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
# incident, not an inconvenience. Refuse early and unmistakably.
if [[ "$DB_NAME" == "smart_office_prod" ]]; then
@@ -26,8 +33,8 @@ if [[ "$DB_NAME" == "smart_office_prod" ]]; then
exit 1
fi
if [[ -z "${DB_PASSWORD:-}" ]]; then
echo "DB_PASSWORD is required (the seeders connect to $DB_NAME)" >&2
if [[ -z "${DB_PASSWORD:-}" || "$DB_PASSWORD" == "CHANGE_ME_DB_PASSWORD" ]]; then
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
fi

View 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),
);
});
});

View 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);
});
});

View File

@@ -1,7 +1,9 @@
import type { APIRequestContext } from "@playwright/test";
import { expect, test } from "@playwright/test";
import { personaByKey } from "../../fixtures/personas";
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
@@ -14,7 +16,7 @@ import { query } from "../../fixtures/db";
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 res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
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.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
@@ -81,7 +83,7 @@ test.describe("FIN-03 · separation of duties, approve vs pay", () => {
*/
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 }) => {
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.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 }) => {
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 }) => {
const token = await tokenFor(request, "finance-accountant");
const res = await request.get(`${FINANCE_API}/api/v1/reports/trial-balance`, {
headers: { Authorization: `Bearer ${token}` },
});
// dateFrom/dateTo are required (RangeQueryDto) — omitting them 400s before
// 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);
// Assert against the ledger itself rather than trusting the report to check

View 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");
});
});

View 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");
});
});

View 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");
});
});

View 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([]);
});
});

View File

@@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";
import { personaByKey } from "../../fixtures/personas";
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
@@ -17,7 +18,7 @@ import { query } from "../../fixtures/db";
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
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
@@ -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.use({ storageState: `${__dirname}/../../fixtures/storage/hr-manager.json` });
test.use({ storageState: storageFor("hr-manager") });
/**
* 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.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

View 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);
});
});

View 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.
});
});

View 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);
});
});

View 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);
});
});

View 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);
}
});
});

View 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);
});
});

View 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);
});
});

View File

@@ -24,6 +24,8 @@
"test:e2e:passenger": "bash e2e/run.sh",
"test:e2e:ui": "bash e2e-ui/run.sh",
"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",
"type-check": "turbo run type-check",
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",

3731
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff