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>
) : (