mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Adding all functionalities
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||
|
||||
import { AppraisalService } from "./appraisal.service";
|
||||
import { ActorContext } from "../../employees/employees.service";
|
||||
import {
|
||||
Appraisal,
|
||||
AppraisalCriterion,
|
||||
AppraisalGoal,
|
||||
AppraisalRating,
|
||||
AppraisalTemplate,
|
||||
EAppraisalStatus,
|
||||
ECycleStatus,
|
||||
RatingBand,
|
||||
} from "../entities/appraisal.entity";
|
||||
|
||||
/**
|
||||
* A minimal stand-in for the transaction `EntityManager`. Tracks every
|
||||
* `.save()`/`.update()` call per-entity so tests can assert exactly what was
|
||||
* persisted, mirroring the freight billing/wagon spec convention.
|
||||
*/
|
||||
function makeManager() {
|
||||
const calls: {
|
||||
appraisalSaves: Record<string, unknown>[];
|
||||
ratingSaves: Record<string, unknown>[];
|
||||
appraisalUpdates: { id: string; data: Record<string, unknown> }[];
|
||||
ratingUpdates: { id: string; data: Record<string, unknown> }[];
|
||||
goalUpdates: { id: string; data: Record<string, unknown> }[];
|
||||
} = {
|
||||
appraisalSaves: [],
|
||||
ratingSaves: [],
|
||||
appraisalUpdates: [],
|
||||
ratingUpdates: [],
|
||||
goalUpdates: [],
|
||||
};
|
||||
|
||||
let seq = 0;
|
||||
|
||||
const manager = {
|
||||
getRepository: (entity: { name: string }) => {
|
||||
const name = entity.name;
|
||||
return {
|
||||
create: (data: Record<string, unknown>) => data,
|
||||
save: jest.fn(async (data: Record<string, unknown>) => {
|
||||
const row = { id: (data.id as string) ?? `${name}-${seq++}`, ...data };
|
||||
if (name === "Appraisal") calls.appraisalSaves.push(row);
|
||||
if (name === "AppraisalRating") calls.ratingSaves.push(row);
|
||||
return row;
|
||||
}),
|
||||
update: jest.fn(async (id: string, data: Record<string, unknown>) => {
|
||||
if (name === "Appraisal") calls.appraisalUpdates.push({ id, data });
|
||||
if (name === "AppraisalRating") calls.ratingUpdates.push({ id, data });
|
||||
if (name === "AppraisalGoal") calls.goalUpdates.push({ id, data });
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return { manager, calls };
|
||||
}
|
||||
|
||||
const actorFixture = (over: Partial<ActorContext> = {}): ActorContext => ({
|
||||
employeeId: "mgr-1",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
isSuperAdmin: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const criterionFixture = (
|
||||
over: Partial<AppraisalCriterion> = {},
|
||||
): AppraisalCriterion =>
|
||||
({
|
||||
id: "c1",
|
||||
templateId: "tpl-1",
|
||||
code: "QUALITY",
|
||||
name: { am: "ጥራት", en: "Quality" },
|
||||
weight: "50.00",
|
||||
sortOrder: 10,
|
||||
...over,
|
||||
}) as AppraisalCriterion;
|
||||
|
||||
const bandFixtures: RatingBand[] = [
|
||||
{ min: 90, label: { am: "", en: "Excellent" } },
|
||||
{ min: 75, label: { am: "", en: "Good" } },
|
||||
{ min: 50, label: { am: "", en: "Fair" } },
|
||||
];
|
||||
|
||||
const templateFixture = (
|
||||
over: Partial<AppraisalTemplate> = {},
|
||||
): AppraisalTemplate =>
|
||||
({
|
||||
id: "tpl-1",
|
||||
organizationId: "org-1",
|
||||
code: "ANNUAL",
|
||||
name: { am: "", en: "Annual" },
|
||||
maxScore: "10.00",
|
||||
ratingBands: bandFixtures,
|
||||
requiresSelfAssessment: true,
|
||||
requiresAcknowledgement: true,
|
||||
criteria: [
|
||||
criterionFixture({ id: "c1", code: "QUALITY", weight: "50.00", sortOrder: 10 }),
|
||||
criterionFixture({ id: "c2", code: "SPEED", weight: "50.00", sortOrder: 20 }),
|
||||
],
|
||||
...over,
|
||||
}) as AppraisalTemplate;
|
||||
|
||||
const cycleFixture = (over: Partial<import("../entities/appraisal.entity").AppraisalCycle> = {}) =>
|
||||
({
|
||||
id: "cycle-1",
|
||||
organizationId: "org-1",
|
||||
code: "FY2026",
|
||||
name: { am: "", en: "FY2026" },
|
||||
templateId: "tpl-1",
|
||||
periodStart: "2026-01-01",
|
||||
periodEnd: "2026-12-31",
|
||||
status: ECycleStatus.DRAFT,
|
||||
...over,
|
||||
}) as import("../entities/appraisal.entity").AppraisalCycle;
|
||||
|
||||
const appraisalFixture = (over: Partial<Appraisal> = {}): Appraisal =>
|
||||
({
|
||||
id: "appraisal-1",
|
||||
organizationId: "org-1",
|
||||
cycleId: "cycle-1",
|
||||
employeeId: "emp-1",
|
||||
appraiserEmployeeId: "mgr-1",
|
||||
status: EAppraisalStatus.PENDING_MANAGER,
|
||||
ratings: [
|
||||
{
|
||||
id: "r1",
|
||||
appraisalId: "appraisal-1",
|
||||
code: "QUALITY",
|
||||
name: { am: "", en: "Quality" },
|
||||
weight: "50.00",
|
||||
maxScore: "10.00",
|
||||
selfScore: "4.00",
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
appraisalId: "appraisal-1",
|
||||
code: "SPEED",
|
||||
name: { am: "", en: "Speed" },
|
||||
weight: "50.00",
|
||||
maxScore: "10.00",
|
||||
selfScore: "3.00",
|
||||
sortOrder: 20,
|
||||
},
|
||||
] as AppraisalRating[],
|
||||
goals: [] as AppraisalGoal[],
|
||||
...over,
|
||||
}) as Appraisal;
|
||||
|
||||
describe("AppraisalService", () => {
|
||||
let dataSource: { transaction: jest.Mock; query: jest.Mock };
|
||||
let templates: { findOne: jest.Mock; find: jest.Mock; save: jest.Mock; create: jest.Mock };
|
||||
let criteria: { save: jest.Mock; create: jest.Mock };
|
||||
let cycles: { findOne: jest.Mock; update: jest.Mock; save: jest.Mock; create: jest.Mock };
|
||||
let appraisals: {
|
||||
findOne: jest.Mock;
|
||||
find: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let goals: { count: jest.Mock; save: jest.Mock; create: jest.Mock };
|
||||
let employees: { findByEmployeeId: jest.Mock };
|
||||
let iamDirectory: { findLineManagerEmployeeId: jest.Mock };
|
||||
let service: AppraisalService;
|
||||
let managerCalls: ReturnType<typeof makeManager>["calls"];
|
||||
|
||||
beforeEach(() => {
|
||||
const built = makeManager();
|
||||
managerCalls = built.calls;
|
||||
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (cb: (m: unknown) => unknown) => cb(built.manager)),
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
templates = {
|
||||
findOne: jest.fn().mockResolvedValue(templateFixture()),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
save: jest.fn(),
|
||||
create: jest.fn((d) => d),
|
||||
};
|
||||
criteria = { save: jest.fn(), create: jest.fn((d) => d) };
|
||||
cycles = {
|
||||
findOne: jest.fn().mockResolvedValue(cycleFixture()),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
save: jest.fn(),
|
||||
create: jest.fn((d) => d),
|
||||
};
|
||||
appraisals = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
goals = { count: jest.fn().mockResolvedValue(0), save: jest.fn(), create: jest.fn((d) => d) };
|
||||
employees = { findByEmployeeId: jest.fn().mockResolvedValue({ managerEmployeeId: null }) };
|
||||
iamDirectory = { findLineManagerEmployeeId: jest.fn().mockResolvedValue(null) };
|
||||
|
||||
service = new AppraisalService(
|
||||
dataSource as never,
|
||||
templates as never,
|
||||
criteria as never,
|
||||
cycles as never,
|
||||
appraisals as never,
|
||||
goals as never,
|
||||
employees as never,
|
||||
iamDirectory as never,
|
||||
);
|
||||
});
|
||||
|
||||
// ── openCycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("openCycle", () => {
|
||||
it("only opens a DRAFT or OPEN cycle", async () => {
|
||||
cycles.findOne.mockResolvedValue(cycleFixture({ status: ECycleStatus.CLOSED }));
|
||||
|
||||
await expect(
|
||||
service.openCycle("cycle-1", ["emp-1"], actorFixture()),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens a DRAFT cycle and also allows re-opening an already-OPEN one", async () => {
|
||||
cycles.findOne.mockResolvedValue(cycleFixture({ status: ECycleStatus.OPEN }));
|
||||
appraisals.findOne.mockResolvedValue(null);
|
||||
employees.findByEmployeeId.mockResolvedValue({ managerEmployeeId: null });
|
||||
|
||||
await expect(
|
||||
service.openCycle("cycle-1", ["emp-1"], actorFixture()),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("copies the template's criteria onto AppraisalRating rows AT OPEN TIME — a later template edit does not retroactively change an already-opened appraisal", async () => {
|
||||
const template = templateFixture();
|
||||
templates.findOne.mockResolvedValue(template);
|
||||
appraisals.findOne.mockResolvedValue(null);
|
||||
employees.findByEmployeeId.mockResolvedValue({ managerEmployeeId: null });
|
||||
|
||||
const result = await service.openCycle("cycle-1", ["emp-1"], actorFixture());
|
||||
expect(result.created).toBe(1);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(managerCalls.ratingSaves).toHaveLength(2);
|
||||
|
||||
const qualityRating = managerCalls.ratingSaves.find((r) => r.code === "QUALITY")!;
|
||||
expect(qualityRating.weight).toBe("50.00");
|
||||
expect(qualityRating.maxScore).toBe("10.00");
|
||||
|
||||
// Mutate the template's criteria AFTER the appraisal was opened.
|
||||
template.criteria![0].weight = "999.00";
|
||||
template.criteria![0].name = { am: "changed", en: "changed" };
|
||||
|
||||
// The rows already saved for the opened appraisal are untouched — they
|
||||
// were copied, not a live reference into the mutable template.
|
||||
expect(qualityRating.weight).toBe("50.00");
|
||||
|
||||
// And re-running openCycle for the SAME employee/cycle is a no-op skip —
|
||||
// the mutated template criteria never reach the existing appraisal.
|
||||
appraisals.findOne.mockResolvedValue(appraisalFixture());
|
||||
const second = await service.openCycle("cycle-1", ["emp-1"], actorFixture());
|
||||
expect(second.skipped).toBe(1);
|
||||
expect(second.created).toBe(0);
|
||||
// Still only the original 2 rating rows were ever saved.
|
||||
expect(managerCalls.ratingSaves).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("defaults to everyone employed with an HR profile, excluding TERMINATED/RETIRED", async () => {
|
||||
dataSource.query.mockResolvedValue([{ employeeId: "emp-1" }, { employeeId: "emp-2" }]);
|
||||
appraisals.findOne.mockResolvedValue(null);
|
||||
employees.findByEmployeeId.mockResolvedValue({ managerEmployeeId: null });
|
||||
|
||||
const result = await service.openCycle("cycle-1", undefined, actorFixture());
|
||||
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("NOT IN ('TERMINATED','RETIRED')"),
|
||||
["org-1"],
|
||||
);
|
||||
expect(result.created).toBe(2);
|
||||
expect(employees.findByEmployeeId).toHaveBeenCalledWith("emp-1");
|
||||
expect(employees.findByEmployeeId).toHaveBeenCalledWith("emp-2");
|
||||
});
|
||||
|
||||
it("skips (without erroring) an employee who has no HR profile", async () => {
|
||||
appraisals.findOne.mockResolvedValue(null);
|
||||
employees.findByEmployeeId.mockResolvedValue(null);
|
||||
|
||||
const result = await service.openCycle("cycle-1", ["emp-no-profile"], actorFixture());
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips (without erroring) an employee who already has an appraisal for the cycle", async () => {
|
||||
appraisals.findOne.mockResolvedValue(appraisalFixture());
|
||||
|
||||
const result = await service.openCycle("cycle-1", ["emp-1"], actorFixture());
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(result.skipped).toBe(1);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wraps each employee's work in its own transaction", async () => {
|
||||
appraisals.findOne.mockResolvedValue(null);
|
||||
employees.findByEmployeeId.mockResolvedValue({ managerEmployeeId: null });
|
||||
|
||||
await service.openCycle("cycle-1", ["emp-1", "emp-2", "emp-3"], actorFixture());
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── submitManager ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("submitManager", () => {
|
||||
const dto = {
|
||||
ratings: [
|
||||
{ code: "QUALITY", score: 9 },
|
||||
{ code: "SPEED", score: 8 },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
appraisals.findOne.mockResolvedValue(appraisalFixture());
|
||||
cycles.findOne.mockResolvedValue(cycleFixture());
|
||||
templates.findOne.mockResolvedValue(templateFixture());
|
||||
});
|
||||
|
||||
it("only accepts submission for a PENDING_MANAGER appraisal", async () => {
|
||||
appraisals.findOne.mockResolvedValue(
|
||||
appraisalFixture({ status: EAppraisalStatus.PENDING_SELF }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.submitManager("appraisal-1", dto, actorFixture()),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("refuses self-appraisal", async () => {
|
||||
appraisals.findOne.mockResolvedValue(
|
||||
appraisalFixture({ employeeId: "mgr-1", appraiserEmployeeId: "mgr-1" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.submitManager("appraisal-1", dto, actorFixture({ employeeId: "mgr-1" })),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it("rejects submission missing a score for any criterion", async () => {
|
||||
await expect(
|
||||
service.submitManager(
|
||||
"appraisal-1",
|
||||
{ ratings: [{ code: "QUALITY", score: 9 }] },
|
||||
actorFixture(),
|
||||
),
|
||||
).rejects.toThrow(/SPEED/);
|
||||
await expect(
|
||||
service.submitManager(
|
||||
"appraisal-1",
|
||||
{ ratings: [{ code: "QUALITY", score: 9 }] },
|
||||
actorFixture(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("uses the manager's score as the FINAL score — never averaged with the self-score", async () => {
|
||||
// Self scores (4 and 3 out of 10, weights 50/50) would weight to 35.
|
||||
// Manager scores (9 and 8 out of 10) weight to 85. An average would be 60.
|
||||
await service.submitManager("appraisal-1", dto, actorFixture());
|
||||
|
||||
expect(managerCalls.appraisalUpdates).toHaveLength(1);
|
||||
const update = managerCalls.appraisalUpdates[0].data;
|
||||
expect(update.managerScore).toBe("85.00");
|
||||
expect(update.finalScore).toBe("85.00");
|
||||
expect(update.finalScore).not.toBe("60.00");
|
||||
});
|
||||
|
||||
it("moves to PENDING_ACKNOWLEDGEMENT when the template requires acknowledgement", async () => {
|
||||
templates.findOne.mockResolvedValue(
|
||||
templateFixture({ requiresAcknowledgement: true }),
|
||||
);
|
||||
|
||||
await service.submitManager("appraisal-1", dto, actorFixture());
|
||||
|
||||
const update = managerCalls.appraisalUpdates[0].data;
|
||||
expect(update.status).toBe(EAppraisalStatus.PENDING_ACKNOWLEDGEMENT);
|
||||
expect(update.acknowledgedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("moves straight to COMPLETED when the template does not require acknowledgement", async () => {
|
||||
templates.findOne.mockResolvedValue(
|
||||
templateFixture({ requiresAcknowledgement: false }),
|
||||
);
|
||||
|
||||
await service.submitManager("appraisal-1", dto, actorFixture());
|
||||
|
||||
const update = managerCalls.appraisalUpdates[0].data;
|
||||
expect(update.status).toBe(EAppraisalStatus.COMPLETED);
|
||||
expect(update.acknowledgedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
// ── pure static functions ─────────────────────────────────────────────────
|
||||
|
||||
describe("weightedScore (pure)", () => {
|
||||
it("sums score/maxScore*weight across entries", () => {
|
||||
const result = AppraisalService.weightedScore([
|
||||
{ weight: 50, maxScore: 10, score: 5 },
|
||||
{ weight: 50, maxScore: 10, score: 10 },
|
||||
]);
|
||||
expect(result).toBe(75);
|
||||
});
|
||||
|
||||
it("clamps above 100 down to 100", () => {
|
||||
const result = AppraisalService.weightedScore([
|
||||
{ weight: 200, maxScore: 10, score: 10 },
|
||||
]);
|
||||
expect(result).toBe(100);
|
||||
});
|
||||
|
||||
it("clamps below 0 up to 0", () => {
|
||||
const result = AppraisalService.weightedScore([
|
||||
{ weight: 100, maxScore: 10, score: -5 },
|
||||
]);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("skips an entry whose maxScore is zero or negative rather than dividing by it", () => {
|
||||
const result = AppraisalService.weightedScore([
|
||||
{ weight: 50, maxScore: 0, score: 5 },
|
||||
{ weight: 50, maxScore: 10, score: 10 },
|
||||
]);
|
||||
expect(result).toBe(50);
|
||||
});
|
||||
|
||||
it("rounds to 2 decimal places", () => {
|
||||
const result = AppraisalService.weightedScore([
|
||||
{ weight: 33.33, maxScore: 3, score: 1 },
|
||||
]);
|
||||
expect(result).toBe(Math.round((33.33 / 3) * 100) / 100);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty entry list", () => {
|
||||
expect(AppraisalService.weightedScore([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bandFor (pure)", () => {
|
||||
it("returns the highest matching band, regardless of input order", () => {
|
||||
const shuffled: RatingBand[] = [
|
||||
{ min: 50, label: { am: "", en: "Fair" } },
|
||||
{ min: 90, label: { am: "", en: "Excellent" } },
|
||||
{ min: 75, label: { am: "", en: "Good" } },
|
||||
];
|
||||
expect(AppraisalService.bandFor(95, shuffled)).toBe("Excellent");
|
||||
expect(AppraisalService.bandFor(90, shuffled)).toBe("Excellent");
|
||||
expect(AppraisalService.bandFor(80, shuffled)).toBe("Good");
|
||||
expect(AppraisalService.bandFor(60, shuffled)).toBe("Fair");
|
||||
});
|
||||
|
||||
it("returns null when the score is below every band's minimum", () => {
|
||||
expect(AppraisalService.bandFor(10, bandFixtures)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an empty band list", () => {
|
||||
expect(AppraisalService.bandFor(100, [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertWeights (pure, private)", () => {
|
||||
const assertWeights = (weights: number[]): void =>
|
||||
(
|
||||
AppraisalService as unknown as {
|
||||
assertWeights(w: number[]): void;
|
||||
}
|
||||
).assertWeights(weights);
|
||||
|
||||
it("accepts weights that sum to exactly 100", () => {
|
||||
expect(() => assertWeights([50, 50])).not.toThrow();
|
||||
expect(() => assertWeights([33.33, 33.33, 33.34])).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects weights that sum to anything other than exactly 100", () => {
|
||||
expect(() => assertWeights([50, 49.99])).toThrow(BadRequestException);
|
||||
expect(() => assertWeights([50, 50.01])).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("rejects an empty criteria list", () => {
|
||||
expect(() => assertWeights([])).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { AttendanceDayService, atTime, type DayContext } from "./attendance-day.service";
|
||||
import { EAttendanceStatus } from "../entities/attendance-record.entity";
|
||||
import type { WorkSchedule } from "../entities/work-schedule.entity";
|
||||
|
||||
/**
|
||||
* Both methods under test are `static` and side-effect free — no service
|
||||
* instantiation, no mocks. Every Date fixture goes through the same `atTime`
|
||||
* helper the source uses, so local-timezone parsing is consistent on both
|
||||
* sides of every subtraction.
|
||||
*/
|
||||
describe("AttendanceDayService — pure day math", () => {
|
||||
const schedule = (over: Partial<WorkSchedule> = {}): WorkSchedule =>
|
||||
({
|
||||
id: "sched-1",
|
||||
startTime: "08:30",
|
||||
endTime: "17:30",
|
||||
breakMinutes: 60,
|
||||
gracePeriodMinutes: 10,
|
||||
minMinutesFullDay: 420,
|
||||
minMinutesHalfDay: 210,
|
||||
crossesMidnight: false,
|
||||
workingDays: null,
|
||||
...over,
|
||||
}) as WorkSchedule;
|
||||
|
||||
describe("measure", () => {
|
||||
it("returns all zeros when there is no check-out yet", () => {
|
||||
const result = AttendanceDayService.measure(
|
||||
schedule(),
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "08:30"),
|
||||
null,
|
||||
);
|
||||
expect(result).toEqual({ workedMinutes: 0, lateMinutes: 0, earlyLeaveMinutes: 0 });
|
||||
});
|
||||
|
||||
it("with no schedule, worked minutes are the raw gross — no break, no lateness", () => {
|
||||
const result = AttendanceDayService.measure(
|
||||
null,
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "09:00"),
|
||||
atTime("2026-03-10", "17:00"),
|
||||
);
|
||||
expect(result).toEqual({ workedMinutes: 480, lateMinutes: 0, earlyLeaveMinutes: 0 });
|
||||
});
|
||||
|
||||
it("an ordinary on-time day: break deducted, nothing late or early", () => {
|
||||
const result = AttendanceDayService.measure(
|
||||
schedule(),
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "08:30"),
|
||||
atTime("2026-03-10", "17:30"),
|
||||
);
|
||||
// gross 540 - break 60 = 480; checkIn == scheduledStart; checkOut == scheduledEnd
|
||||
expect(result).toEqual({ workedMinutes: 480, lateMinutes: 0, earlyLeaveMinutes: 0 });
|
||||
});
|
||||
|
||||
it("deducts the break only when the shift is long enough to have taken one", () => {
|
||||
// gross = 45 minutes, which does not exceed the 60-minute break — so
|
||||
// nothing is deducted, and workedMinutes is NOT clamped to zero.
|
||||
const result = AttendanceDayService.measure(
|
||||
schedule(),
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "08:30"),
|
||||
atTime("2026-03-10", "09:15"),
|
||||
);
|
||||
expect(result.workedMinutes).toBe(45);
|
||||
});
|
||||
|
||||
it("charges lateness only past the grace period, and reports the full overage", () => {
|
||||
// 15 minutes late against a 10-minute grace period.
|
||||
const result = AttendanceDayService.measure(
|
||||
schedule(),
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "08:45"),
|
||||
atTime("2026-03-10", "17:30"),
|
||||
);
|
||||
expect(result.lateMinutes).toBe(15);
|
||||
});
|
||||
|
||||
it("grace is all-or-nothing: inside it, nothing is recorded at all", () => {
|
||||
// 8 minutes late, inside the 10-minute grace period.
|
||||
const result = AttendanceDayService.measure(
|
||||
schedule(),
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "08:38"),
|
||||
atTime("2026-03-10", "17:30"),
|
||||
);
|
||||
expect(result.lateMinutes).toBe(0);
|
||||
});
|
||||
|
||||
it("grace is all-or-nothing: exactly AT the grace boundary is still not late (strictly greater-than)", () => {
|
||||
// 10 minutes late == a 10-minute grace period exactly.
|
||||
const result = AttendanceDayService.measure(
|
||||
schedule({ gracePeriodMinutes: 10 }),
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "08:40"),
|
||||
atTime("2026-03-10", "17:30"),
|
||||
);
|
||||
expect(result.lateMinutes).toBe(0);
|
||||
});
|
||||
|
||||
it("grace is all-or-nothing: one minute past the boundary charges the FULL lateness, not just the excess", () => {
|
||||
// 11 minutes late — one minute past a 10-minute grace period. If grace were
|
||||
// partial, this would report 1 minute; it must report the full 11.
|
||||
const result = AttendanceDayService.measure(
|
||||
schedule({ gracePeriodMinutes: 10 }),
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "08:41"),
|
||||
atTime("2026-03-10", "17:30"),
|
||||
);
|
||||
expect(result.lateMinutes).toBe(11);
|
||||
});
|
||||
|
||||
it("checking in early is never lateness, however early", () => {
|
||||
const result = AttendanceDayService.measure(
|
||||
schedule(),
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "07:00"),
|
||||
atTime("2026-03-10", "17:30"),
|
||||
);
|
||||
expect(result.lateMinutes).toBe(0);
|
||||
});
|
||||
|
||||
it("charges an early leave in minutes short of the scheduled end", () => {
|
||||
const result = AttendanceDayService.measure(
|
||||
schedule(),
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "08:30"),
|
||||
atTime("2026-03-10", "17:00"),
|
||||
);
|
||||
expect(result.earlyLeaveMinutes).toBe(30);
|
||||
});
|
||||
|
||||
it("staying past the scheduled end is never an early leave", () => {
|
||||
const result = AttendanceDayService.measure(
|
||||
schedule(),
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "08:30"),
|
||||
atTime("2026-03-10", "19:00"),
|
||||
);
|
||||
expect(result.earlyLeaveMinutes).toBe(0);
|
||||
});
|
||||
|
||||
it("a night shift pushes the scheduled end 24h forward before comparing", () => {
|
||||
const nightSchedule = schedule({
|
||||
startTime: "22:00",
|
||||
endTime: "06:00",
|
||||
breakMinutes: 30,
|
||||
crossesMidnight: true,
|
||||
});
|
||||
// On time both ends — checked against the +24h scheduled end, not 06:00
|
||||
// on the shift's own start date.
|
||||
const result = AttendanceDayService.measure(
|
||||
nightSchedule,
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "22:00"),
|
||||
atTime("2026-03-11", "06:00"),
|
||||
);
|
||||
// gross = 8h = 480; break 30 (480 > 30) deducted -> 450
|
||||
expect(result).toEqual({ workedMinutes: 450, lateMinutes: 0, earlyLeaveMinutes: 0 });
|
||||
});
|
||||
|
||||
it("a night shift leaving an hour early is charged against the pushed-forward end", () => {
|
||||
const nightSchedule = schedule({
|
||||
startTime: "22:00",
|
||||
endTime: "06:00",
|
||||
breakMinutes: 30,
|
||||
crossesMidnight: true,
|
||||
});
|
||||
const result = AttendanceDayService.measure(
|
||||
nightSchedule,
|
||||
"2026-03-10",
|
||||
atTime("2026-03-10", "22:00"),
|
||||
atTime("2026-03-11", "05:00"),
|
||||
);
|
||||
expect(result.earlyLeaveMinutes).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusForUnworkedDay", () => {
|
||||
const context = (over: Partial<DayContext> = {}): DayContext => ({
|
||||
workDate: "2026-03-10",
|
||||
isWorkingDay: true,
|
||||
isHoliday: false,
|
||||
holidayName: null,
|
||||
leave: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("leave wins over everything else, even a holiday on the same day", () => {
|
||||
const result = AttendanceDayService.statusForUnworkedDay(
|
||||
context({
|
||||
isHoliday: true,
|
||||
holidayName: "Meskel",
|
||||
leave: { requestId: "req-1", leaveTypeCode: "ANNUAL", isHalfDay: false },
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
status: EAttendanceStatus.ON_LEAVE,
|
||||
leaveRequestId: "req-1",
|
||||
notes: "ANNUAL leave",
|
||||
});
|
||||
});
|
||||
|
||||
it("a holiday wins over a rest day when there is no leave", () => {
|
||||
const result = AttendanceDayService.statusForUnworkedDay(
|
||||
context({ isWorkingDay: false, isHoliday: true, holidayName: "Meskel" }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
status: EAttendanceStatus.HOLIDAY,
|
||||
leaveRequestId: null,
|
||||
notes: "Meskel",
|
||||
});
|
||||
});
|
||||
|
||||
it("a holiday wins even on an otherwise-working day (not just over a rest day)", () => {
|
||||
const result = AttendanceDayService.statusForUnworkedDay(
|
||||
context({ isWorkingDay: true, isHoliday: true, holidayName: "Adwa Day" }),
|
||||
);
|
||||
expect(result.status).toBe(EAttendanceStatus.HOLIDAY);
|
||||
});
|
||||
|
||||
it("leave wins over a rest day too, not just over a holiday", () => {
|
||||
const result = AttendanceDayService.statusForUnworkedDay(
|
||||
context({
|
||||
isWorkingDay: false,
|
||||
isHoliday: false,
|
||||
leave: { requestId: "req-2", leaveTypeCode: "SICK", isHalfDay: false },
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe(EAttendanceStatus.ON_LEAVE);
|
||||
expect(result.leaveRequestId).toBe("req-2");
|
||||
});
|
||||
|
||||
it("half-day leave still wins this precedence — the half-day distinction belongs to checkIn, not this method", () => {
|
||||
const result = AttendanceDayService.statusForUnworkedDay(
|
||||
context({
|
||||
isHoliday: true,
|
||||
leave: { requestId: "req-3", leaveTypeCode: "ANNUAL", isHalfDay: true },
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe(EAttendanceStatus.ON_LEAVE);
|
||||
});
|
||||
|
||||
it("a non-working day with no leave or holiday is a rest day", () => {
|
||||
const result = AttendanceDayService.statusForUnworkedDay(context({ isWorkingDay: false }));
|
||||
expect(result).toEqual({
|
||||
status: EAttendanceStatus.REST_DAY,
|
||||
leaveRequestId: null,
|
||||
notes: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("a working day with nothing excusing it is absent", () => {
|
||||
const result = AttendanceDayService.statusForUnworkedDay(context());
|
||||
expect(result).toEqual({
|
||||
status: EAttendanceStatus.ABSENT,
|
||||
leaveRequestId: null,
|
||||
notes: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||
|
||||
import { AttendanceService } from "./attendance.service";
|
||||
import { EAttendanceSource, EAttendanceStatus } from "../entities/attendance-record.entity";
|
||||
import type { ActorContext } from "../../employees/employees.service";
|
||||
|
||||
/**
|
||||
* Mirrors the freight suite's convention: plain `new AttendanceService(...)`
|
||||
* with hand-built fakes passed positionally, no `Test.createTestingModule`.
|
||||
* `AttendanceDayService.measure`/`.statusForWorkedDay` are called as static
|
||||
* methods directly on the real imported class inside the service, so they run
|
||||
* for real here — only `this.days.contextFor` (an instance method) is mocked.
|
||||
*/
|
||||
|
||||
const ORG_ID = "org-1";
|
||||
const EMPLOYEE_ID = "emp-1";
|
||||
|
||||
const actor: ActorContext = {
|
||||
employeeId: "emp-manager",
|
||||
userId: "user-1",
|
||||
organizationId: ORG_ID,
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
|
||||
function employee(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: EMPLOYEE_ID,
|
||||
name: { en: "Test Employee", am: "ሙከራ" },
|
||||
status: "ACTIVE",
|
||||
isCurrent: true,
|
||||
unitId: "unit-1",
|
||||
organizationId: ORG_ID,
|
||||
userId: "iam-user-1",
|
||||
username: "test.employee",
|
||||
email: "test@example.com",
|
||||
phoneNumber: null,
|
||||
createdAt: new Date(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultContext(over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
workDate: "2026-08-26",
|
||||
isWorkingDay: true,
|
||||
isHoliday: false,
|
||||
holidayName: null,
|
||||
leave: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function build(over: {
|
||||
attendance?: Record<string, jest.Mock>;
|
||||
schedules?: Record<string, jest.Mock>;
|
||||
days?: Record<string, jest.Mock>;
|
||||
employees?: Record<string, jest.Mock>;
|
||||
iamDirectory?: Record<string, jest.Mock>;
|
||||
} = {}) {
|
||||
const attendance = {
|
||||
findForDate: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation((data: Record<string, unknown>) =>
|
||||
Promise.resolve({ id: "record-1", ...data }),
|
||||
),
|
||||
update: jest.fn().mockImplementation((_id: string, data: Record<string, unknown>) =>
|
||||
Promise.resolve({ id: "record-1", ...data }),
|
||||
),
|
||||
findRange: jest.fn().mockResolvedValue([]),
|
||||
findPage: jest.fn().mockResolvedValue([[], 0]),
|
||||
summarize: jest.fn().mockResolvedValue({}),
|
||||
...over.attendance,
|
||||
};
|
||||
const schedules = {
|
||||
resolveFor: jest.fn().mockResolvedValue(null),
|
||||
...over.schedules,
|
||||
};
|
||||
const days = {
|
||||
contextFor: jest.fn().mockResolvedValue(defaultContext()),
|
||||
...over.days,
|
||||
};
|
||||
const employees = {
|
||||
findByEmployeeId: jest.fn().mockResolvedValue(null),
|
||||
...over.employees,
|
||||
};
|
||||
const iamDirectory = {
|
||||
requireEmployee: jest.fn().mockResolvedValue(employee()),
|
||||
...over.iamDirectory,
|
||||
};
|
||||
|
||||
const service = new AttendanceService(
|
||||
attendance as never,
|
||||
schedules as never,
|
||||
days as never,
|
||||
employees as never,
|
||||
iamDirectory as never,
|
||||
);
|
||||
|
||||
return { service, attendance, schedules, days, employees, iamDirectory };
|
||||
}
|
||||
|
||||
describe("AttendanceService.checkIn", () => {
|
||||
it("computes workDate as the calendar date of the check-in instant (UTC slice)", async () => {
|
||||
const { service, attendance, days } = build();
|
||||
// 21:00 UTC on Aug 25 — still Aug 25 in UTC terms, which is what
|
||||
// `at.toISOString().slice(0, 10)` yields regardless of server-local zone.
|
||||
const at = new Date("2026-08-25T21:00:00.000Z");
|
||||
|
||||
await service.checkIn(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor);
|
||||
|
||||
expect(attendance.findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-25");
|
||||
expect(days.contextFor).toHaveBeenCalledWith(
|
||||
EMPLOYEE_ID,
|
||||
ORG_ID,
|
||||
"2026-08-25",
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("computes workDate correctly for an instant just past midnight UTC (rolls to the next date)", async () => {
|
||||
const { service, attendance } = build();
|
||||
const at = new Date("2026-08-26T00:30:00.000Z");
|
||||
|
||||
await service.checkIn(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor);
|
||||
|
||||
expect(attendance.findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-26");
|
||||
});
|
||||
|
||||
it("refuses a second check-in on the same day", async () => {
|
||||
const { service } = build({
|
||||
attendance: {
|
||||
findForDate: jest.fn().mockResolvedValue({
|
||||
id: "record-1",
|
||||
checkIn: new Date("2026-08-26T05:30:00.000Z"),
|
||||
checkOut: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.checkIn(
|
||||
EMPLOYEE_ID,
|
||||
new Date("2026-08-26T06:00:00.000Z"),
|
||||
EAttendanceSource.WEB,
|
||||
actor,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("refuses check-in on a day covered by approved, non-half-day leave", async () => {
|
||||
const { service, attendance } = build({
|
||||
days: {
|
||||
contextFor: jest.fn().mockResolvedValue(
|
||||
defaultContext({
|
||||
leave: { requestId: "leave-1", leaveTypeCode: "ANNUAL", isHalfDay: false },
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.checkIn(
|
||||
EMPLOYEE_ID,
|
||||
new Date("2026-08-26T05:00:00.000Z"),
|
||||
EAttendanceSource.WEB,
|
||||
actor,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(attendance.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows check-in on a day covered by approved HALF-DAY leave", async () => {
|
||||
const { service, attendance } = build({
|
||||
days: {
|
||||
contextFor: jest.fn().mockResolvedValue(
|
||||
defaultContext({
|
||||
leave: { requestId: "leave-1", leaveTypeCode: "ANNUAL", isHalfDay: true },
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
await service.checkIn(
|
||||
EMPLOYEE_ID,
|
||||
new Date("2026-08-26T05:00:00.000Z"),
|
||||
EAttendanceSource.WEB,
|
||||
actor,
|
||||
);
|
||||
|
||||
expect(attendance.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a new PRESENT record with lateMinutes 0 at check-in time — lateness is only finalized on check-out", async () => {
|
||||
const { service, attendance } = build({
|
||||
schedules: {
|
||||
resolveFor: jest.fn().mockResolvedValue({
|
||||
id: "sched-1",
|
||||
startTime: "08:30",
|
||||
endTime: "17:30",
|
||||
breakMinutes: 60,
|
||||
gracePeriodMinutes: 10,
|
||||
minMinutesFullDay: 420,
|
||||
minMinutesHalfDay: 210,
|
||||
crossesMidnight: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// 10:00 local — well past a 08:30 start, would normally be "late".
|
||||
const at = new Date("2026-08-26T10:00:00.000Z");
|
||||
const result = await service.checkIn(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor);
|
||||
|
||||
expect(attendance.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
organizationId: ORG_ID,
|
||||
employeeId: EMPLOYEE_ID,
|
||||
workDate: "2026-08-26",
|
||||
checkIn: at,
|
||||
status: EAttendanceStatus.PRESENT,
|
||||
lateMinutes: 0,
|
||||
source: EAttendanceSource.WEB,
|
||||
}),
|
||||
);
|
||||
expect(result.lateMinutes).toBe(0);
|
||||
});
|
||||
|
||||
it("re-records an existing check-in-less row rather than creating a duplicate", async () => {
|
||||
const { service, attendance } = build({
|
||||
attendance: {
|
||||
findForDate: jest.fn().mockResolvedValue({
|
||||
id: "record-1",
|
||||
checkIn: null,
|
||||
checkOut: null,
|
||||
notes: "pre-existing note",
|
||||
}),
|
||||
},
|
||||
});
|
||||
const at = new Date("2026-08-26T05:00:00.000Z");
|
||||
|
||||
await service.checkIn(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor, "arrived late");
|
||||
|
||||
expect(attendance.update).toHaveBeenCalledWith(
|
||||
"record-1",
|
||||
expect.objectContaining({
|
||||
checkIn: at,
|
||||
status: EAttendanceStatus.PRESENT,
|
||||
lateMinutes: 0,
|
||||
notes: "arrived late",
|
||||
}),
|
||||
);
|
||||
expect(attendance.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the notes through as null, not undefined, when none is given for a new record", async () => {
|
||||
const { service, attendance } = build();
|
||||
|
||||
await service.checkIn(
|
||||
EMPLOYEE_ID,
|
||||
new Date("2026-08-26T05:00:00.000Z"),
|
||||
EAttendanceSource.WEB,
|
||||
actor,
|
||||
);
|
||||
|
||||
expect(attendance.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ notes: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AttendanceService.checkOut", () => {
|
||||
const schedule = (over: Record<string, unknown> = {}) => ({
|
||||
id: "sched-1",
|
||||
startTime: "08:30",
|
||||
endTime: "17:30",
|
||||
breakMinutes: 60,
|
||||
gracePeriodMinutes: 10,
|
||||
minMinutesFullDay: 420,
|
||||
minMinutesHalfDay: 210,
|
||||
crossesMidnight: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("closes today's own open record when one exists — no previous-day lookup needed", async () => {
|
||||
const todayRecord = {
|
||||
id: "record-today",
|
||||
workDate: "2026-08-26",
|
||||
checkIn: new Date("2026-08-26T05:30:00.000Z"),
|
||||
checkOut: null,
|
||||
workSchedule: schedule(),
|
||||
};
|
||||
const findForDate = jest.fn().mockResolvedValue(todayRecord);
|
||||
const { service, attendance } = build({ attendance: { findForDate } });
|
||||
|
||||
await service.checkOut(
|
||||
EMPLOYEE_ID,
|
||||
new Date("2026-08-26T13:00:00.000Z"),
|
||||
EAttendanceSource.WEB,
|
||||
actor,
|
||||
);
|
||||
|
||||
// Only the today lookup ran — the previous-day fallback never fired.
|
||||
expect(findForDate).toHaveBeenCalledTimes(1);
|
||||
expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-26");
|
||||
expect(attendance.update).toHaveBeenCalledWith(
|
||||
"record-today",
|
||||
expect.objectContaining({ checkOut: new Date("2026-08-26T13:00:00.000Z") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the immediately preceding day's open record on a night shift (crossesMidnight)", async () => {
|
||||
const previousDayRecord = {
|
||||
id: "record-prev",
|
||||
workDate: "2026-08-25",
|
||||
checkIn: new Date("2026-08-25T20:00:00.000Z"),
|
||||
checkOut: null,
|
||||
workSchedule: schedule({ crossesMidnight: true }),
|
||||
};
|
||||
const findForDate = jest.fn().mockImplementation((_empId: string, workDate: string) =>
|
||||
Promise.resolve(workDate === "2026-08-25" ? previousDayRecord : null),
|
||||
);
|
||||
const { service, attendance } = build({ attendance: { findForDate } });
|
||||
|
||||
const at = new Date("2026-08-26T02:00:00.000Z");
|
||||
await service.checkOut(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor);
|
||||
|
||||
expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-26");
|
||||
expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-25");
|
||||
expect(attendance.update).toHaveBeenCalledWith(
|
||||
"record-prev",
|
||||
expect.objectContaining({ checkOut: at }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT fall back to the previous day when that schedule does not cross midnight", async () => {
|
||||
const previousDayRecord = {
|
||||
id: "record-prev",
|
||||
workDate: "2026-08-25",
|
||||
checkIn: new Date("2026-08-25T20:00:00.000Z"),
|
||||
checkOut: null,
|
||||
// Day schedule, not a night shift — the open record from yesterday is
|
||||
// just a missed check-out, not evidence of a shift still running.
|
||||
workSchedule: schedule({ crossesMidnight: false }),
|
||||
};
|
||||
const findForDate = jest.fn().mockImplementation((_empId: string, workDate: string) =>
|
||||
Promise.resolve(workDate === "2026-08-25" ? previousDayRecord : null),
|
||||
);
|
||||
const { service } = build({ attendance: { findForDate } });
|
||||
|
||||
await expect(
|
||||
service.checkOut(
|
||||
EMPLOYEE_ID,
|
||||
new Date("2026-08-26T02:00:00.000Z"),
|
||||
EAttendanceSource.WEB,
|
||||
actor,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("does NOT reach past the immediately preceding day even if an older open record exists", async () => {
|
||||
// Only "two days ago" has an open, crosses-midnight record; yesterday and
|
||||
// today are both empty. findOpenPreviousDay only ever looks at yesterday.
|
||||
const twoDaysAgoRecord = {
|
||||
id: "record-old",
|
||||
workDate: "2026-08-24",
|
||||
checkIn: new Date("2026-08-24T20:00:00.000Z"),
|
||||
checkOut: null,
|
||||
workSchedule: schedule({ crossesMidnight: true }),
|
||||
};
|
||||
const findForDate = jest.fn().mockImplementation((_empId: string, workDate: string) =>
|
||||
Promise.resolve(workDate === "2026-08-24" ? twoDaysAgoRecord : null),
|
||||
);
|
||||
const { service } = build({ attendance: { findForDate } });
|
||||
|
||||
await expect(
|
||||
service.checkOut(
|
||||
EMPLOYEE_ID,
|
||||
new Date("2026-08-26T02:00:00.000Z"),
|
||||
EAttendanceSource.WEB,
|
||||
actor,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
// Confirms it only ever asked for today (08-26) and yesterday (08-25).
|
||||
expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-26");
|
||||
expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-25");
|
||||
expect(findForDate).not.toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-24");
|
||||
});
|
||||
|
||||
it("does not fall back to a previous-day record that has already been checked out", async () => {
|
||||
const previousDayRecord = {
|
||||
id: "record-prev",
|
||||
workDate: "2026-08-25",
|
||||
checkIn: new Date("2026-08-25T20:00:00.000Z"),
|
||||
checkOut: new Date("2026-08-26T04:00:00.000Z"),
|
||||
workSchedule: schedule({ crossesMidnight: true }),
|
||||
};
|
||||
const findForDate = jest.fn().mockImplementation((_empId: string, workDate: string) =>
|
||||
Promise.resolve(workDate === "2026-08-25" ? previousDayRecord : null),
|
||||
);
|
||||
const { service } = build({ attendance: { findForDate } });
|
||||
|
||||
await expect(
|
||||
service.checkOut(
|
||||
EMPLOYEE_ID,
|
||||
new Date("2026-08-26T05:00:00.000Z"),
|
||||
EAttendanceSource.WEB,
|
||||
actor,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("refuses when there is no check-in to close at all", async () => {
|
||||
const { service } = build({ attendance: { findForDate: jest.fn().mockResolvedValue(null) } });
|
||||
|
||||
await expect(
|
||||
service.checkOut(
|
||||
EMPLOYEE_ID,
|
||||
new Date("2026-08-26T13:00:00.000Z"),
|
||||
EAttendanceSource.WEB,
|
||||
actor,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("refuses when already checked out", async () => {
|
||||
const record = {
|
||||
id: "record-today",
|
||||
workDate: "2026-08-26",
|
||||
checkIn: new Date("2026-08-26T05:30:00.000Z"),
|
||||
checkOut: new Date("2026-08-26T13:00:00.000Z"),
|
||||
workSchedule: schedule(),
|
||||
};
|
||||
const { service } = build({
|
||||
attendance: { findForDate: jest.fn().mockResolvedValue(record) },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.checkOut(
|
||||
EMPLOYEE_ID,
|
||||
new Date("2026-08-26T14:00:00.000Z"),
|
||||
EAttendanceSource.WEB,
|
||||
actor,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("refuses a check-out timestamped before the check-in", async () => {
|
||||
const record = {
|
||||
id: "record-today",
|
||||
workDate: "2026-08-26",
|
||||
checkIn: new Date("2026-08-26T05:30:00.000Z"),
|
||||
checkOut: null,
|
||||
workSchedule: schedule(),
|
||||
};
|
||||
const { service } = build({
|
||||
attendance: { findForDate: jest.fn().mockResolvedValue(record) },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.checkOut(
|
||||
EMPLOYEE_ID,
|
||||
new Date("2026-08-26T05:00:00.000Z"),
|
||||
EAttendanceSource.WEB,
|
||||
actor,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("settles worked/late/early minutes and status from the real check-in through AttendanceDayService.measure", async () => {
|
||||
const record = {
|
||||
id: "record-today",
|
||||
workDate: "2026-08-26",
|
||||
checkIn: new Date("2026-08-26T05:30:00.000Z"), // 08:30 local (EAT), on time
|
||||
checkOut: null,
|
||||
workSchedule: schedule(),
|
||||
};
|
||||
const { service, attendance } = build({
|
||||
attendance: { findForDate: jest.fn().mockResolvedValue(record) },
|
||||
});
|
||||
|
||||
// Exactly on-schedule end -> full day, present, no lateness/earliness.
|
||||
await service.checkOut(
|
||||
EMPLOYEE_ID,
|
||||
new Date("2026-08-26T14:30:00.000Z"), // 17:30 local (EAT)
|
||||
EAttendanceSource.WEB,
|
||||
actor,
|
||||
);
|
||||
|
||||
expect(attendance.update).toHaveBeenCalledWith(
|
||||
"record-today",
|
||||
expect.objectContaining({
|
||||
workedMinutes: 480, // 9h gross - 60min break
|
||||
lateMinutes: 0,
|
||||
earlyLeaveMinutes: 0,
|
||||
status: EAttendanceStatus.PRESENT,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||
|
||||
import { RegularizationsService } from "./regularizations.service";
|
||||
import {
|
||||
AttendanceRecord,
|
||||
EAttendanceSource,
|
||||
EAttendanceStatus,
|
||||
} from "../entities/attendance-record.entity";
|
||||
import {
|
||||
AttendanceRegularization,
|
||||
ERegularizationStatus,
|
||||
} from "../entities/attendance-regularization.entity";
|
||||
import type { ActorContext } from "../../employees/employees.service";
|
||||
|
||||
/**
|
||||
* Mirrors the freight suite's convention: plain `new RegularizationsService(...)`
|
||||
* with hand-built fakes, no `Test.createTestingModule`. `dataSource.transaction`
|
||||
* runs its callback against a fake manager whose `getRepository` dispatches on
|
||||
* the entity class, just like the real EntityManager does.
|
||||
*/
|
||||
|
||||
const ORG_ID = "org-1";
|
||||
const APPROVER_EMPLOYEE_ID = "emp-manager";
|
||||
const REQUESTER_EMPLOYEE_ID = "emp-1";
|
||||
|
||||
const approverActor: ActorContext = {
|
||||
employeeId: APPROVER_EMPLOYEE_ID,
|
||||
userId: "user-manager",
|
||||
organizationId: ORG_ID,
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
|
||||
function pendingRequest(over: Record<string, unknown> = {}): AttendanceRegularization {
|
||||
return {
|
||||
id: "reg-1",
|
||||
organizationId: ORG_ID,
|
||||
employeeId: REQUESTER_EMPLOYEE_ID,
|
||||
attendanceRecordId: "record-1",
|
||||
workDate: "2026-08-26",
|
||||
requestedCheckIn: new Date("2026-08-26T05:30:00.000Z"),
|
||||
requestedCheckOut: new Date("2026-08-26T14:30:00.000Z"),
|
||||
originalCheckIn: null,
|
||||
originalCheckOut: null,
|
||||
originalStatus: null,
|
||||
reason: "Forgot to punch in",
|
||||
status: ERegularizationStatus.SUBMITTED,
|
||||
approverEmployeeId: APPROVER_EMPLOYEE_ID,
|
||||
decidedByEmployeeId: null,
|
||||
decidedAt: null,
|
||||
decisionNote: null,
|
||||
createdBy: "user-1",
|
||||
updatedBy: null,
|
||||
...over,
|
||||
} as AttendanceRegularization;
|
||||
}
|
||||
|
||||
function build(over: {
|
||||
request?: AttendanceRegularization;
|
||||
existingRecord?: Record<string, unknown> | null;
|
||||
schedule?: Record<string, unknown> | null;
|
||||
} = {}) {
|
||||
const request = over.request ?? pendingRequest();
|
||||
const existingRecord = over.existingRecord ?? null;
|
||||
|
||||
const regularizationsRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(request),
|
||||
save: jest.fn(),
|
||||
create: jest.fn((data: Record<string, unknown>) => data),
|
||||
};
|
||||
|
||||
const attendanceRecordsManagerRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(existingRecord),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
save: jest.fn().mockImplementation((data: Record<string, unknown>) =>
|
||||
Promise.resolve({ id: "record-new", ...data }),
|
||||
),
|
||||
create: jest.fn((data: Record<string, unknown>) => data),
|
||||
};
|
||||
|
||||
const attendanceRegularizationsManagerRepo = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const manager = {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === AttendanceRecord) return attendanceRecordsManagerRepo;
|
||||
if (entity === AttendanceRegularization) return attendanceRegularizationsManagerRepo;
|
||||
throw new Error(`Unexpected getRepository(${String(entity)})`);
|
||||
}),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||
};
|
||||
|
||||
const schedules = {
|
||||
resolveFor: jest.fn().mockResolvedValue(over.schedule ?? null),
|
||||
};
|
||||
|
||||
const service = new RegularizationsService(
|
||||
dataSource as never,
|
||||
regularizationsRepo as never,
|
||||
{} as never, // attendance (AttendanceRepository) — approve() never touches it directly
|
||||
schedules as never,
|
||||
{} as never, // employees
|
||||
{} as never, // iamDirectory
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
request,
|
||||
regularizationsRepo,
|
||||
attendanceRecordsManagerRepo,
|
||||
attendanceRegularizationsManagerRepo,
|
||||
dataSource,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RegularizationsService.approve", () => {
|
||||
it("refuses to approve one's own attendance correction", async () => {
|
||||
const { service } = build({
|
||||
request: pendingRequest({ employeeId: APPROVER_EMPLOYEE_ID }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.approve("reg-1", undefined, approverActor),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it("refuses to decide a request that is not SUBMITTED", async () => {
|
||||
const { service, dataSource } = build({
|
||||
request: pendingRequest({ status: ERegularizationStatus.APPROVED }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.approve("reg-1", undefined, approverActor),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("snapshots the ORIGINAL check-in/out/status onto the request BEFORE the attendance record is overwritten", async () => {
|
||||
const existingRecord = {
|
||||
id: "record-1",
|
||||
employeeId: REQUESTER_EMPLOYEE_ID,
|
||||
workDate: "2026-08-26",
|
||||
checkIn: new Date("2026-08-26T06:00:00.000Z"), // the pre-correction punch
|
||||
checkOut: new Date("2026-08-26T15:00:00.000Z"),
|
||||
status: EAttendanceStatus.LATE, // the pre-correction status
|
||||
};
|
||||
const request = pendingRequest({
|
||||
requestedCheckIn: new Date("2026-08-26T05:30:00.000Z"), // the corrected punch
|
||||
requestedCheckOut: new Date("2026-08-26T14:30:00.000Z"),
|
||||
});
|
||||
const { service, attendanceRegularizationsManagerRepo, attendanceRecordsManagerRepo } =
|
||||
build({ request, existingRecord });
|
||||
|
||||
await service.approve("reg-1", "Confirmed with security log", approverActor);
|
||||
|
||||
// The snapshot on the regularization row is the value BEFORE this approval
|
||||
// — evidence that survives the overwrite about to happen below.
|
||||
expect(attendanceRegularizationsManagerRepo.update).toHaveBeenCalledWith(
|
||||
"reg-1",
|
||||
expect.objectContaining({
|
||||
status: ERegularizationStatus.APPROVED,
|
||||
originalCheckIn: existingRecord.checkIn,
|
||||
originalCheckOut: existingRecord.checkOut,
|
||||
originalStatus: EAttendanceStatus.LATE,
|
||||
decisionNote: "Confirmed with security log",
|
||||
decidedByEmployeeId: APPROVER_EMPLOYEE_ID,
|
||||
}),
|
||||
);
|
||||
|
||||
// Meanwhile the attendance record itself is overwritten with the REQUESTED
|
||||
// (corrected) values, not the original ones.
|
||||
expect(attendanceRecordsManagerRepo.update).toHaveBeenCalledWith(
|
||||
"record-1",
|
||||
expect.objectContaining({
|
||||
checkIn: request.requestedCheckIn,
|
||||
checkOut: request.requestedCheckOut,
|
||||
isRegularized: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-derives status and measured minutes via AttendanceDayService.measure against the requested punches", async () => {
|
||||
const existingRecord = {
|
||||
id: "record-1",
|
||||
employeeId: REQUESTER_EMPLOYEE_ID,
|
||||
workDate: "2026-08-26",
|
||||
checkIn: null,
|
||||
checkOut: null,
|
||||
status: EAttendanceStatus.ABSENT,
|
||||
};
|
||||
const schedule = {
|
||||
id: "sched-1",
|
||||
startTime: "08:30",
|
||||
endTime: "17:30",
|
||||
breakMinutes: 60,
|
||||
gracePeriodMinutes: 10,
|
||||
minMinutesFullDay: 420,
|
||||
minMinutesHalfDay: 210,
|
||||
crossesMidnight: false,
|
||||
};
|
||||
const request = pendingRequest({
|
||||
// Schedule times are parsed as local (EAT, UTC+3) via atTime() — these
|
||||
// UTC instants are 08:30/17:30 local, matching the 08:30-17:30 schedule.
|
||||
requestedCheckIn: new Date("2026-08-26T05:30:00.000Z"), // on time
|
||||
requestedCheckOut: new Date("2026-08-26T14:30:00.000Z"), // full day
|
||||
});
|
||||
const { service, attendanceRecordsManagerRepo } = build({
|
||||
request,
|
||||
existingRecord,
|
||||
schedule,
|
||||
});
|
||||
|
||||
await service.approve("reg-1", undefined, approverActor);
|
||||
|
||||
expect(attendanceRecordsManagerRepo.update).toHaveBeenCalledWith(
|
||||
"record-1",
|
||||
expect.objectContaining({
|
||||
workedMinutes: 480, // 9h gross - 60min break
|
||||
lateMinutes: 0,
|
||||
earlyLeaveMinutes: 0,
|
||||
status: EAttendanceStatus.PRESENT,
|
||||
workScheduleId: "sched-1",
|
||||
source: EAttendanceSource.MANUAL,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires at least a resulting check-in — a check-out-only request with nothing on record is refused", async () => {
|
||||
const request = pendingRequest({
|
||||
requestedCheckIn: null,
|
||||
requestedCheckOut: new Date("2026-08-26T14:30:00.000Z"),
|
||||
});
|
||||
const { service, attendanceRecordsManagerRepo } = build({
|
||||
request,
|
||||
existingRecord: null, // nothing on the record to close
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.approve("reg-1", undefined, approverActor),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(attendanceRecordsManagerRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the EXISTING check-in when the request proposes only a check-out", async () => {
|
||||
const existingRecord = {
|
||||
id: "record-1",
|
||||
employeeId: REQUESTER_EMPLOYEE_ID,
|
||||
workDate: "2026-08-26",
|
||||
checkIn: new Date("2026-08-26T08:30:00.000Z"),
|
||||
checkOut: null,
|
||||
status: EAttendanceStatus.PRESENT,
|
||||
};
|
||||
const request = pendingRequest({
|
||||
requestedCheckIn: null,
|
||||
requestedCheckOut: new Date("2026-08-26T17:30:00.000Z"),
|
||||
});
|
||||
const { service, attendanceRecordsManagerRepo } = build({ request, existingRecord });
|
||||
|
||||
await service.approve("reg-1", undefined, approverActor);
|
||||
|
||||
expect(attendanceRecordsManagerRepo.update).toHaveBeenCalledWith(
|
||||
"record-1",
|
||||
expect.objectContaining({
|
||||
checkIn: existingRecord.checkIn,
|
||||
checkOut: request.requestedCheckOut,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a brand-new attendance record when none existed for the day", async () => {
|
||||
const request = pendingRequest({
|
||||
attendanceRecordId: null,
|
||||
requestedCheckIn: new Date("2026-08-26T08:30:00.000Z"),
|
||||
requestedCheckOut: new Date("2026-08-26T17:30:00.000Z"),
|
||||
});
|
||||
const { service, attendanceRecordsManagerRepo } = build({
|
||||
request,
|
||||
existingRecord: null,
|
||||
});
|
||||
|
||||
await service.approve("reg-1", undefined, approverActor);
|
||||
|
||||
expect(attendanceRecordsManagerRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
organizationId: ORG_ID,
|
||||
employeeId: REQUESTER_EMPLOYEE_ID,
|
||||
workDate: "2026-08-26",
|
||||
checkIn: request.requestedCheckIn,
|
||||
checkOut: request.requestedCheckOut,
|
||||
isRegularized: true,
|
||||
}),
|
||||
);
|
||||
expect(attendanceRecordsManagerRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does everything inside one transaction", async () => {
|
||||
const { service, dataSource } = build();
|
||||
|
||||
await service.approve("reg-1", undefined, approverActor);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
138
apps/edr-hr-api/src/modules/employees/employees.service.spec.ts
Normal file
138
apps/edr-hr-api/src/modules/employees/employees.service.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { ConflictException, ForbiddenException } from "@nestjs/common";
|
||||
|
||||
import { ActorContext, EmployeesService } from "./employees.service";
|
||||
import { EEmploymentState, EEmploymentType, EmployeeProfile } from "./entities/employee-profile.entity";
|
||||
import { CreateEmployeeProfileDto } from "./dto/create-employee-profile.dto";
|
||||
|
||||
const actorFixture = (over: Partial<ActorContext> = {}): ActorContext => ({
|
||||
employeeId: null,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
isSuperAdmin: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const iamEmployeeFixture = (over: Record<string, unknown> = {}) => ({
|
||||
id: "iam-emp-1",
|
||||
organizationId: "org-1",
|
||||
name: { am: "", en: "Jane Doe" },
|
||||
status: "ACTIVE",
|
||||
isCurrent: true,
|
||||
unitId: null,
|
||||
username: "jane.doe",
|
||||
email: "jane@example.com",
|
||||
phoneNumber: "0911000000",
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
...over,
|
||||
});
|
||||
|
||||
const createDto = (over: Partial<CreateEmployeeProfileDto> = {}): CreateEmployeeProfileDto =>
|
||||
({
|
||||
employeeId: "iam-emp-1",
|
||||
employmentType: EEmploymentType.PERMANENT,
|
||||
hireDate: "2026-01-15",
|
||||
...over,
|
||||
}) as CreateEmployeeProfileDto;
|
||||
|
||||
describe("EmployeesService.create", () => {
|
||||
let employeesRepository: {
|
||||
findByEmployeeId: jest.Mock;
|
||||
findByEmployeeNumber: jest.Mock;
|
||||
findHighestEmployeeNumberSuffix: jest.Mock;
|
||||
create: jest.Mock;
|
||||
};
|
||||
let jobTitlesRepository: { findById: jest.Mock };
|
||||
let iamDirectory: {
|
||||
requireEmployee: jest.Mock;
|
||||
findEmployee: jest.Mock;
|
||||
findLineManagerEmployeeId: jest.Mock;
|
||||
};
|
||||
let iamOperations: { activateEmployee: jest.Mock; deactivateEmployee: jest.Mock };
|
||||
let service: EmployeesService;
|
||||
|
||||
beforeEach(() => {
|
||||
employeesRepository = {
|
||||
findByEmployeeId: jest.fn().mockResolvedValue(null),
|
||||
findByEmployeeNumber: jest.fn().mockResolvedValue(null),
|
||||
findHighestEmployeeNumberSuffix: jest.fn().mockResolvedValue(5),
|
||||
create: jest.fn().mockImplementation(async (data: Partial<EmployeeProfile>) => ({
|
||||
id: "profile-new",
|
||||
managerEmployeeId: null,
|
||||
...data,
|
||||
})),
|
||||
};
|
||||
jobTitlesRepository = { findById: jest.fn() };
|
||||
iamDirectory = {
|
||||
requireEmployee: jest.fn().mockResolvedValue(iamEmployeeFixture()),
|
||||
findEmployee: jest.fn().mockResolvedValue(iamEmployeeFixture()),
|
||||
findLineManagerEmployeeId: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
iamOperations = {
|
||||
activateEmployee: jest.fn().mockResolvedValue(undefined),
|
||||
deactivateEmployee: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
service = new EmployeesService(
|
||||
employeesRepository as never,
|
||||
jobTitlesRepository as never,
|
||||
iamDirectory as never,
|
||||
iamOperations as never,
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a profile for an IAM employee who does not have one yet", async () => {
|
||||
const result = await service.create(createDto(), actorFixture());
|
||||
|
||||
expect(employeesRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
employeeId: "iam-emp-1",
|
||||
employeeNumber: "EMP-00006",
|
||||
employmentState: EEmploymentState.ACTIVE,
|
||||
isPensionEligible: true,
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
}),
|
||||
);
|
||||
expect(result.employeeId).toBe("iam-emp-1");
|
||||
expect(result.iam?.username).toBe("jane.doe");
|
||||
});
|
||||
|
||||
it("throws ConflictException when a profile already exists for that IAM employee id — the re-hire guard", async () => {
|
||||
employeesRepository.findByEmployeeId.mockResolvedValue({
|
||||
id: "existing-profile-1",
|
||||
employeeId: "iam-emp-1",
|
||||
});
|
||||
|
||||
await expect(service.create(createDto(), actorFixture())).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
await expect(service.create(createDto(), actorFixture())).rejects.toThrow(
|
||||
/already has HR profile existing-profile-1/,
|
||||
);
|
||||
expect(employeesRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a SEPARATE guard from recruitment's hireFromOffer re-hire check — it fires purely off employeesRepository.findByEmployeeId, independent of any offer/application state", async () => {
|
||||
employeesRepository.findByEmployeeId.mockResolvedValue({
|
||||
id: "existing-profile-2",
|
||||
employeeId: "iam-emp-1",
|
||||
});
|
||||
|
||||
await expect(service.create(createDto(), actorFixture())).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(employeesRepository.findByEmployeeId).toHaveBeenCalledWith("iam-emp-1");
|
||||
});
|
||||
|
||||
it("refuses to create a profile for an IAM employee in another organization", async () => {
|
||||
iamDirectory.requireEmployee.mockResolvedValue(
|
||||
iamEmployeeFixture({ organizationId: "other-org" }),
|
||||
);
|
||||
|
||||
await expect(service.create(createDto(), actorFixture())).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(employeesRepository.findByEmployeeId).not.toHaveBeenCalled();
|
||||
expect(employeesRepository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { JobPositionsRepository } from "./job-positions.repository";
|
||||
|
||||
/**
|
||||
* `hr.job_positions` has no `organization_id` column of its own — tenancy is
|
||||
* resolved via an `EXISTS` against `iam.positions`, never a direct column
|
||||
* filter. A prior shipped bug referenced `position.organization_id` directly
|
||||
* and 500'd for every non-super-admin actor; these tests pin the fix.
|
||||
*/
|
||||
function makeQueryBuilder(overrides: {
|
||||
getManyAndCount?: [unknown[], number];
|
||||
getRawOne?: Record<string, unknown> | undefined;
|
||||
} = {}) {
|
||||
const qb: Record<string, jest.Mock> = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue(overrides.getManyAndCount ?? [[], 0]),
|
||||
getRawOne: jest.fn().mockResolvedValue(overrides.getRawOne),
|
||||
};
|
||||
return qb;
|
||||
}
|
||||
|
||||
describe("JobPositionsRepository", () => {
|
||||
let repository: { createQueryBuilder: jest.Mock };
|
||||
let repo: JobPositionsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = { createQueryBuilder: jest.fn() };
|
||||
repo = new JobPositionsRepository(repository as never);
|
||||
});
|
||||
|
||||
describe("findPage", () => {
|
||||
it("scopes tenancy via EXISTS against iam.positions, not a direct column filter", async () => {
|
||||
const qb = makeQueryBuilder();
|
||||
repository.createQueryBuilder.mockReturnValue(qb);
|
||||
|
||||
await repo.findPage("org-1", {});
|
||||
|
||||
const existsCall = qb.andWhere.mock.calls.find(([sql]) =>
|
||||
String(sql).includes("EXISTS (SELECT 1 FROM iam.positions p"),
|
||||
);
|
||||
expect(existsCall).toBeDefined();
|
||||
const [sql, params] = existsCall!;
|
||||
expect(sql).toContain("p.id = position.position_id");
|
||||
expect(sql).toContain("p.organization_id = :organizationId");
|
||||
expect(sql).not.toContain("position.organization_id");
|
||||
expect(params).toEqual({ organizationId: "org-1" });
|
||||
});
|
||||
|
||||
it("skips the tenancy filter entirely when organizationId is null (super admin)", async () => {
|
||||
const qb = makeQueryBuilder();
|
||||
repository.createQueryBuilder.mockReturnValue(qb);
|
||||
|
||||
await repo.findPage(null, {});
|
||||
|
||||
expect(qb.andWhere).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still applies the other filters alongside the EXISTS scope", async () => {
|
||||
const qb = makeQueryBuilder();
|
||||
repository.createQueryBuilder.mockReturnValue(qb);
|
||||
|
||||
await repo.findPage("org-1", { unitId: "unit-1", jobTitleId: "jt-1", isOpen: true, vacantOnly: true });
|
||||
|
||||
const clauses = qb.andWhere.mock.calls.map(([sql]) => String(sql));
|
||||
expect(clauses.some((c) => c.includes("iam.positions p"))).toBe(true);
|
||||
expect(clauses).toContain("position.unit_id = :unitId");
|
||||
expect(clauses).toContain("position.job_title_id = :jobTitleId");
|
||||
expect(clauses).toContain("position.is_open = :isOpen");
|
||||
expect(clauses).toContain("position.current_count < position.budgeted_count");
|
||||
});
|
||||
|
||||
it("orders by the entity property path (createdAt), not a raw column", async () => {
|
||||
const qb = makeQueryBuilder();
|
||||
repository.createQueryBuilder.mockReturnValue(qb);
|
||||
|
||||
await repo.findPage("org-1", {});
|
||||
|
||||
expect(qb.orderBy).toHaveBeenCalledWith("position.createdAt", "DESC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("headcountTotals", () => {
|
||||
it("scopes tenancy via EXISTS against iam.positions, not a direct column filter", async () => {
|
||||
const qb = makeQueryBuilder({ getRawOne: { budgeted: "10", current: "6" } });
|
||||
repository.createQueryBuilder.mockReturnValue(qb);
|
||||
|
||||
const result = await repo.headcountTotals("org-1");
|
||||
|
||||
expect(qb.where).toHaveBeenCalledTimes(1);
|
||||
const [sql, params] = qb.where.mock.calls[0];
|
||||
expect(sql).toContain("EXISTS (SELECT 1 FROM iam.positions p");
|
||||
expect(sql).toContain("p.organization_id = :organizationId");
|
||||
expect(sql).not.toContain("position.organization_id");
|
||||
expect(params).toEqual({ organizationId: "org-1" });
|
||||
expect(result).toEqual({ budgeted: 10, current: 6, vacant: 4 });
|
||||
});
|
||||
|
||||
it("skips the tenancy filter entirely when organizationId is null (super admin)", async () => {
|
||||
const qb = makeQueryBuilder({ getRawOne: { budgeted: "10", current: "6" } });
|
||||
repository.createQueryBuilder.mockReturnValue(qb);
|
||||
|
||||
await repo.headcountTotals(null);
|
||||
|
||||
expect(qb.where).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never returns a negative vacant count when overstaffed", async () => {
|
||||
const qb = makeQueryBuilder({ getRawOne: { budgeted: "5", current: "8" } });
|
||||
repository.createQueryBuilder.mockReturnValue(qb);
|
||||
|
||||
const result = await repo.headcountTotals("org-1");
|
||||
|
||||
expect(result).toEqual({ budgeted: 5, current: 8, vacant: 0 });
|
||||
});
|
||||
|
||||
it("defaults to zero when the query returns no row", async () => {
|
||||
const qb = makeQueryBuilder({ getRawOne: undefined });
|
||||
repository.createQueryBuilder.mockReturnValue(qb);
|
||||
|
||||
const result = await repo.headcountTotals("org-1");
|
||||
|
||||
expect(result).toEqual({ budgeted: 0, current: 0, vacant: 0 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,484 @@
|
||||
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { ActorContext } from "../../employees/employees.service";
|
||||
import { ELeaveYearBasis } from "../entities/leave-settings.entity";
|
||||
import { ELedgerEntryType } from "../entities/leave-ledger-entry.entity";
|
||||
import { EAccrualMethod, EGenderRestriction, LeaveType } from "../entities/leave-type.entity";
|
||||
import { LeaveBalancesService } from "./leave-balances.service";
|
||||
|
||||
/**
|
||||
* Mirrors the freight suite's convention: plain `new ServiceClass(...)` with
|
||||
* hand-built fakes passed positionally — no `Test.createTestingModule`.
|
||||
*/
|
||||
|
||||
function makeActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
||||
return {
|
||||
employeeId: "emp-1",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
isSuperAdmin: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeType(overrides: Partial<LeaveType> = {}): LeaveType {
|
||||
return {
|
||||
id: "type-1",
|
||||
organizationId: "org-1",
|
||||
code: "ANNUAL",
|
||||
name: { am: "ዓመታዊ", en: "Annual" },
|
||||
isPaid: true,
|
||||
accrualMethod: EAccrualMethod.ANNUAL_ENTITLEMENT,
|
||||
baseDaysPerYear: "16",
|
||||
extraDaysPerPeriod: "1",
|
||||
servicePeriodYears: 2,
|
||||
maxDaysPerYear: null,
|
||||
maxCarryOverDays: "0",
|
||||
maxConsecutiveDays: null,
|
||||
minServiceMonths: 0,
|
||||
genderRestriction: EGenderRestriction.ANY,
|
||||
countsWorkingDaysOnly: true,
|
||||
allowsHalfDay: true,
|
||||
requiresAttachmentAfter: null,
|
||||
requiresApproval: true,
|
||||
statuteReference: null,
|
||||
sortOrder: 100,
|
||||
isActive: true,
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
...overrides,
|
||||
} as LeaveType;
|
||||
}
|
||||
|
||||
function makeProfile(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
employeeId: "emp-1",
|
||||
gender: "MALE",
|
||||
hireDate: "2020-01-01",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntitlement(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "ent-1",
|
||||
organizationId: "org-1",
|
||||
employeeId: "emp-1",
|
||||
leaveTypeId: "type-1",
|
||||
leaveYearStart: "2026-01-01",
|
||||
leaveYearEnd: "2026-12-31",
|
||||
entitledDays: "16.00",
|
||||
carriedOverDays: "0.00",
|
||||
carryOverExpiresOn: null,
|
||||
serviceYearsAtGrant: "5.00",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHarness() {
|
||||
const manager = {
|
||||
getRepository: jest.fn(),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn().mockImplementation((cb: (m: unknown) => unknown) => cb(manager)),
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
manager: { getRepository: jest.fn() },
|
||||
getRepository: jest.fn(),
|
||||
};
|
||||
const entitlements = {
|
||||
findForYear: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
};
|
||||
const leaveTypes = {
|
||||
findActive: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const settingsService = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
leaveYearBasis: ELeaveYearBasis.CALENDAR_YEAR,
|
||||
fiscalYearStartMonth: 7,
|
||||
fiscalYearStartDay: 8,
|
||||
weekendDays: [0],
|
||||
carryOverDeadlineMonths: 6,
|
||||
allowNegativeBalance: false,
|
||||
}),
|
||||
};
|
||||
const employees = {
|
||||
findByEmployeeId: jest.fn().mockResolvedValue(makeProfile()),
|
||||
};
|
||||
const iamDirectory = {
|
||||
findEmployee: jest.fn().mockResolvedValue({ organizationId: "org-1" }),
|
||||
};
|
||||
|
||||
const service = new LeaveBalancesService(
|
||||
dataSource as never,
|
||||
entitlements as never,
|
||||
leaveTypes as never,
|
||||
settingsService as never,
|
||||
employees as never,
|
||||
iamDirectory as never,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
dataSource,
|
||||
manager,
|
||||
entitlements,
|
||||
leaveTypes,
|
||||
settingsService,
|
||||
employees,
|
||||
iamDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("LeaveBalancesService.balancesFor / balanceFor", () => {
|
||||
it("404s when the employee has no HR profile", async () => {
|
||||
const h = makeHarness();
|
||||
h.employees.findByEmployeeId.mockResolvedValue(null);
|
||||
await expect(h.service.balancesFor("emp-1", makeActor())).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it("404s when the employee is outside the actor's organization scope", async () => {
|
||||
const h = makeHarness();
|
||||
h.iamDirectory.findEmployee.mockResolvedValue({ organizationId: "org-9" });
|
||||
await expect(
|
||||
h.service.balancesFor("emp-1", makeActor({ organizationId: "org-1", isSuperAdmin: false })),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it("400s when no organization can be resolved for the employee", async () => {
|
||||
const h = makeHarness();
|
||||
h.iamDirectory.findEmployee.mockResolvedValue(null);
|
||||
await expect(
|
||||
h.service.balancesFor("emp-1", makeActor({ organizationId: "" as unknown as string, isSuperAdmin: true })),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("skips NONE-accrual types", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findActive.mockResolvedValue([makeType({ accrualMethod: EAccrualMethod.NONE })]);
|
||||
|
||||
const result = await h.service.balancesFor("emp-1", makeActor());
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(h.entitlements.findForYear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a type gated by minServiceMonths the employee has not yet reached", async () => {
|
||||
const h = makeHarness();
|
||||
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ hireDate: "2026-06-01" }));
|
||||
h.leaveTypes.findActive.mockResolvedValue([makeType({ minServiceMonths: 60 })]);
|
||||
|
||||
const result = await h.service.balancesFor("emp-1", makeActor(), "2026-06-15");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips a gender-restricted type that does not match a recorded gender", async () => {
|
||||
const h = makeHarness();
|
||||
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ gender: "MALE" }));
|
||||
h.leaveTypes.findActive.mockResolvedValue([
|
||||
makeType({ genderRestriction: EGenderRestriction.FEMALE }),
|
||||
]);
|
||||
|
||||
const result = await h.service.balancesFor("emp-1", makeActor());
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT skip a gender-restricted type when the profile has no gender recorded", async () => {
|
||||
const h = makeHarness();
|
||||
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ gender: null }));
|
||||
h.leaveTypes.findActive.mockResolvedValue([
|
||||
makeType({ genderRestriction: EGenderRestriction.FEMALE }),
|
||||
]);
|
||||
h.entitlements.findForYear.mockResolvedValue(makeEntitlement());
|
||||
h.dataSource.query.mockResolvedValue([]);
|
||||
|
||||
const result = await h.service.balancesFor("emp-1", makeActor());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("derives the balance from a grouped raw-SQL SUM over the ledger, never a stored column", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findActive.mockResolvedValue([makeType()]);
|
||||
h.entitlements.findForYear.mockResolvedValue(makeEntitlement({ id: "ent-77" }));
|
||||
h.dataSource.query.mockResolvedValue([
|
||||
{ entryType: "GRANT", total: "16.00" },
|
||||
{ entryType: "DEDUCTION", total: "-3.50" },
|
||||
]);
|
||||
|
||||
const [balance] = await h.service.balancesFor("emp-1", makeActor());
|
||||
|
||||
// The derivation actually ran the raw SQL against the ledger table for
|
||||
// this entitlement — not a canned return value that bypasses it.
|
||||
expect(h.dataSource.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("hr.leave_ledger_entries"),
|
||||
["ent-77"],
|
||||
);
|
||||
expect(balance.grantedDays).toBe(16);
|
||||
expect(balance.takenDays).toBe(3.5);
|
||||
expect(balance.adjustmentDays).toBe(0);
|
||||
expect(balance.availableDays).toBe(12.5);
|
||||
});
|
||||
|
||||
it("folds REVERSAL entries into adjustments alongside ADJUSTMENT", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findActive.mockResolvedValue([makeType()]);
|
||||
h.entitlements.findForYear.mockResolvedValue(makeEntitlement());
|
||||
h.dataSource.query.mockResolvedValue([
|
||||
{ entryType: "GRANT", total: "16.00" },
|
||||
{ entryType: "DEDUCTION", total: "-4.00" },
|
||||
{ entryType: "REVERSAL", total: "4.00" },
|
||||
{ entryType: "ADJUSTMENT", total: "-1.00" },
|
||||
]);
|
||||
|
||||
const [balance] = await h.service.balancesFor("emp-1", makeActor());
|
||||
|
||||
expect(balance.takenDays).toBe(4);
|
||||
expect(balance.adjustmentDays).toBe(3); // 4 (reversal) + -1 (adjustment)
|
||||
expect(balance.availableDays).toBe(15); // 16 + 3 - 4
|
||||
});
|
||||
|
||||
it("creates the leave year's entitlement on first read and posts its GRANT", async () => {
|
||||
const h = makeHarness();
|
||||
// Hire date pinned to the leave year's start so completed service is
|
||||
// exactly 0 — keeps the expected entitlement at the type's flat base
|
||||
// rather than also exercising the service-growth arithmetic here.
|
||||
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ hireDate: "2026-01-01" }));
|
||||
h.leaveTypes.findActive.mockResolvedValue([makeType({ baseDaysPerYear: "16", extraDaysPerPeriod: "1", servicePeriodYears: 2 })]);
|
||||
h.entitlements.findForYear.mockResolvedValue(null);
|
||||
|
||||
const entitlementRepo = {
|
||||
create: (d: Record<string, unknown>) => d,
|
||||
save: jest.fn().mockResolvedValue(makeEntitlement({ id: "ent-created" })),
|
||||
};
|
||||
const ledgerRepo = {
|
||||
create: (d: Record<string, unknown>) => d,
|
||||
save: jest.fn().mockImplementation(async (d: Record<string, unknown>) => ({ id: "entry-1", ...d })),
|
||||
};
|
||||
h.manager.getRepository.mockImplementation((entity: { name?: string }) => {
|
||||
// Distinguish by call order: first call is LeaveEntitlement, rest are LeaveLedgerEntry.
|
||||
return entity && (entity as { name?: string }).name === "LeaveEntitlement"
|
||||
? entitlementRepo
|
||||
: ledgerRepo;
|
||||
});
|
||||
h.dataSource.query.mockResolvedValue([]);
|
||||
|
||||
const result = await h.service.balancesFor("emp-1", makeActor(), "2026-06-15");
|
||||
|
||||
expect(h.dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(entitlementRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(ledgerRepo.save).toHaveBeenCalledTimes(1);
|
||||
const [grantEntry] = ledgerRepo.save.mock.calls[0];
|
||||
// post() stores days as entry.days.toFixed(2) — a string, not a number.
|
||||
expect(grantEntry).toMatchObject({ entryType: ELedgerEntryType.GRANT, days: "16.00" });
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not post a CARRY_OVER entry when the type caps carry-over at 0", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findActive.mockResolvedValue([makeType({ maxCarryOverDays: "0" })]);
|
||||
h.entitlements.findForYear.mockResolvedValue(null);
|
||||
|
||||
const entitlementRepo = {
|
||||
create: (d: Record<string, unknown>) => d,
|
||||
save: jest.fn().mockResolvedValue(makeEntitlement({ id: "ent-created" })),
|
||||
};
|
||||
const ledgerRepo = {
|
||||
create: (d: Record<string, unknown>) => d,
|
||||
save: jest.fn().mockImplementation(async (d: Record<string, unknown>) => ({ id: "entry-1", ...d })),
|
||||
};
|
||||
h.manager.getRepository.mockImplementation((entity: { name?: string }) =>
|
||||
(entity as { name?: string }).name === "LeaveEntitlement" ? entitlementRepo : ledgerRepo,
|
||||
);
|
||||
h.dataSource.query.mockResolvedValue([]);
|
||||
|
||||
await h.service.balancesFor("emp-1", makeActor());
|
||||
|
||||
// Only the GRANT entry, never CARRY_OVER.
|
||||
expect(ledgerRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(ledgerRepo.save.mock.calls[0][0]).toMatchObject({ entryType: ELedgerEntryType.GRANT });
|
||||
});
|
||||
|
||||
it("caps a carried-over amount at the type's maxCarryOverDays", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findActive.mockResolvedValue([
|
||||
makeType({ maxCarryOverDays: "4", baseDaysPerYear: "16" }),
|
||||
]);
|
||||
// First entitlement lookup (current year) -> not found, triggers ensureEntitlement.
|
||||
// Inside carryOverInto, a second findForYear call looks up the *previous* year.
|
||||
h.entitlements.findForYear.mockImplementation(
|
||||
async (_employeeId: string, _typeId: string, yearStart: string) => {
|
||||
if (yearStart === "2025-01-01") return makeEntitlement({ id: "ent-prev", leaveYearStart: "2025-01-01" });
|
||||
return null;
|
||||
},
|
||||
);
|
||||
// sumLedger for the previous entitlement reports 10 remaining days — above the cap of 4.
|
||||
h.dataSource.query.mockResolvedValue([{ total: "10.00" }]);
|
||||
|
||||
const entitlementRepo = {
|
||||
create: (d: Record<string, unknown>) => d,
|
||||
save: jest.fn().mockImplementation(async (d: Record<string, unknown>) => ({ id: "ent-created", ...d })),
|
||||
};
|
||||
const ledgerRepo = {
|
||||
create: (d: Record<string, unknown>) => d,
|
||||
save: jest.fn().mockImplementation(async (d: Record<string, unknown>) => ({ id: "entry-1", ...d })),
|
||||
};
|
||||
h.manager.getRepository.mockImplementation((entity: { name?: string }) =>
|
||||
(entity as { name?: string }).name === "LeaveEntitlement" ? entitlementRepo : ledgerRepo,
|
||||
);
|
||||
|
||||
await h.service.balancesFor("emp-1", makeActor(), "2026-06-15");
|
||||
|
||||
const carryOverCall = ledgerRepo.save.mock.calls.find(
|
||||
([entry]: [Record<string, unknown>]) => entry.entryType === ELedgerEntryType.CARRY_OVER,
|
||||
);
|
||||
expect(carryOverCall).toBeDefined();
|
||||
expect(carryOverCall![0]).toMatchObject({ days: "4.00" }); // capped, not the full 10
|
||||
});
|
||||
});
|
||||
|
||||
describe("LeaveBalancesService.post", () => {
|
||||
it("is the only way days move — saves a signed entry against the entitlement", async () => {
|
||||
const h = makeHarness();
|
||||
const repo = {
|
||||
create: (d: Record<string, unknown>) => d,
|
||||
save: jest.fn().mockImplementation(async (d: Record<string, unknown>) => ({ id: "entry-1", ...d })),
|
||||
};
|
||||
h.dataSource.manager.getRepository.mockReturnValue(repo);
|
||||
|
||||
const result = await h.service.post(
|
||||
"ent-1",
|
||||
{ entryType: ELedgerEntryType.DEDUCTION, days: -4, effectiveOn: "2026-09-14", reason: "annual" },
|
||||
makeActor({ userId: "user-9" }),
|
||||
);
|
||||
|
||||
expect(repo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entitlementId: "ent-1",
|
||||
entryType: ELedgerEntryType.DEDUCTION,
|
||||
days: "-4.00",
|
||||
effectiveOn: "2026-09-14",
|
||||
createdBy: "user-9",
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({ id: "entry-1" });
|
||||
});
|
||||
|
||||
it("joins the caller's transaction when a manager is supplied", async () => {
|
||||
const h = makeHarness();
|
||||
const txRepo = {
|
||||
create: (d: Record<string, unknown>) => d,
|
||||
save: jest.fn().mockResolvedValue({ id: "entry-2" }),
|
||||
};
|
||||
h.manager.getRepository.mockReturnValue(txRepo);
|
||||
|
||||
await h.service.post(
|
||||
"ent-1",
|
||||
{ entryType: ELedgerEntryType.DEDUCTION, days: -1, effectiveOn: "2026-09-14" },
|
||||
makeActor(),
|
||||
h.manager as never,
|
||||
);
|
||||
|
||||
expect(txRepo.save).toHaveBeenCalled();
|
||||
expect(h.dataSource.manager.getRepository).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LeaveBalancesService.reverse", () => {
|
||||
it("404s when the original entry does not exist", async () => {
|
||||
const h = makeHarness();
|
||||
const repo = { findOne: jest.fn().mockResolvedValue(null) };
|
||||
h.dataSource.manager.getRepository.mockReturnValue(repo);
|
||||
|
||||
await expect(h.service.reverse("entry-1", "cancelled", makeActor())).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to reverse an entry that already has a reversal (pre-check)", async () => {
|
||||
const h = makeHarness();
|
||||
const original = { id: "entry-1", entitlementId: "ent-1", days: "-4.00" };
|
||||
const repo = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(original) // lookup of the original
|
||||
.mockResolvedValueOnce({ id: "entry-2", reversesId: "entry-1" }), // an existing reversal
|
||||
save: jest.fn(),
|
||||
create: (d: Record<string, unknown>) => d,
|
||||
};
|
||||
h.dataSource.manager.getRepository.mockReturnValue(repo);
|
||||
|
||||
await expect(h.service.reverse("entry-1", "twice", makeActor())).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts the mirrored opposite entry when nothing has reversed it yet", async () => {
|
||||
const h = makeHarness();
|
||||
const original = { id: "entry-1", entitlementId: "ent-1", days: "-4.00" };
|
||||
const repo = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(original)
|
||||
.mockResolvedValueOnce(null), // no existing reversal
|
||||
save: jest.fn().mockImplementation(async (d: Record<string, unknown>) => ({ id: "entry-2", ...d })),
|
||||
create: (d: Record<string, unknown>) => d,
|
||||
};
|
||||
h.dataSource.manager.getRepository.mockReturnValue(repo);
|
||||
|
||||
const result = await h.service.reverse("entry-1", "leave cancelled", makeActor());
|
||||
|
||||
expect(repo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
entitlementId: "ent-1",
|
||||
entryType: ELedgerEntryType.REVERSAL,
|
||||
days: "4.00", // opposite sign of the original's -4.00
|
||||
reversesId: "entry-1",
|
||||
reason: "leave cancelled",
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({ id: "entry-2" });
|
||||
});
|
||||
|
||||
it("turns a caught 23505 unique-violation into ConflictException (the race case)", async () => {
|
||||
const h = makeHarness();
|
||||
const original = { id: "entry-1", entitlementId: "ent-1", days: "-4.00" };
|
||||
const repo = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(original)
|
||||
.mockResolvedValueOnce(null), // pre-check sees no reversal yet — the race is in the write
|
||||
save: jest.fn().mockRejectedValue({ code: "23505" }),
|
||||
create: (d: Record<string, unknown>) => d,
|
||||
};
|
||||
h.dataSource.manager.getRepository.mockReturnValue(repo);
|
||||
|
||||
await expect(h.service.reverse("entry-1", "double click", makeActor())).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it("re-throws an unrelated error from the write unchanged", async () => {
|
||||
const h = makeHarness();
|
||||
const original = { id: "entry-1", entitlementId: "ent-1", days: "-4.00" };
|
||||
const boom = new Error("connection reset");
|
||||
const repo = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(original)
|
||||
.mockResolvedValueOnce(null),
|
||||
save: jest.fn().mockRejectedValue(boom),
|
||||
create: (d: Record<string, unknown>) => d,
|
||||
};
|
||||
h.dataSource.manager.getRepository.mockReturnValue(repo);
|
||||
|
||||
await expect(h.service.reverse("entry-1", "x", makeActor())).rejects.toBe(boom);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,816 @@
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { ActorContext } from "../../employees/employees.service";
|
||||
import { ELeaveRequestStatus, LeaveRequest } from "../entities/leave-request.entity";
|
||||
import { ELedgerEntryType } from "../entities/leave-ledger-entry.entity";
|
||||
import { EAccrualMethod, EGenderRestriction, LeaveType } from "../entities/leave-type.entity";
|
||||
import { CreateLeaveRequestDto } from "../dto/leave-request.dto";
|
||||
import { LeaveRequestsService } from "./leave-requests.service";
|
||||
|
||||
/**
|
||||
* Mirrors the freight suite's convention: plain `new ServiceClass(...)` with
|
||||
* hand-built fakes passed positionally — no `Test.createTestingModule`.
|
||||
*/
|
||||
|
||||
function makeActor(overrides: Partial<ActorContext> = {}): ActorContext {
|
||||
return {
|
||||
employeeId: "emp-1",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
isSuperAdmin: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeType(overrides: Partial<LeaveType> = {}): LeaveType {
|
||||
return {
|
||||
id: "type-1",
|
||||
organizationId: "org-1",
|
||||
code: "ANNUAL",
|
||||
name: { am: "ዓመታዊ", en: "Annual" },
|
||||
isPaid: true,
|
||||
accrualMethod: EAccrualMethod.ANNUAL_ENTITLEMENT,
|
||||
baseDaysPerYear: "16",
|
||||
extraDaysPerPeriod: "1",
|
||||
servicePeriodYears: 2,
|
||||
maxDaysPerYear: null,
|
||||
maxCarryOverDays: "6",
|
||||
maxConsecutiveDays: null,
|
||||
minServiceMonths: 0,
|
||||
genderRestriction: EGenderRestriction.ANY,
|
||||
countsWorkingDaysOnly: true,
|
||||
allowsHalfDay: true,
|
||||
requiresAttachmentAfter: null,
|
||||
requiresApproval: true,
|
||||
statuteReference: null,
|
||||
sortOrder: 100,
|
||||
isActive: true,
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
...overrides,
|
||||
} as LeaveType;
|
||||
}
|
||||
|
||||
function makeProfile(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
employeeId: "emp-1",
|
||||
gender: "MALE",
|
||||
hireDate: "2020-01-01",
|
||||
managerEmployeeId: "mgr-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRequest(overrides: Partial<LeaveRequest> = {}): LeaveRequest {
|
||||
return {
|
||||
id: "req-1",
|
||||
organizationId: "org-1",
|
||||
employeeId: "emp-2",
|
||||
leaveTypeId: "type-1",
|
||||
status: ELeaveRequestStatus.SUBMITTED,
|
||||
startDate: "2026-09-14",
|
||||
endDate: "2026-09-18",
|
||||
isHalfDay: false,
|
||||
workingDays: "4.00",
|
||||
calendarDays: "5.00",
|
||||
chargedDays: "4.00",
|
||||
reason: null,
|
||||
contactDuringLeave: null,
|
||||
attachmentDocumentId: null,
|
||||
approverEmployeeId: "mgr-1",
|
||||
decidedByEmployeeId: null,
|
||||
decidedAt: null,
|
||||
decisionNote: null,
|
||||
cancelledReason: null,
|
||||
createdBy: "user-2",
|
||||
updatedBy: null,
|
||||
...overrides,
|
||||
} as LeaveRequest;
|
||||
}
|
||||
|
||||
function makeBalance(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
leaveTypeId: "type-1",
|
||||
code: "ANNUAL",
|
||||
name: { am: "ዓመታዊ", en: "Annual" },
|
||||
leaveYearStart: "2026-01-01",
|
||||
leaveYearEnd: "2026-12-31",
|
||||
entitledDays: 16,
|
||||
carriedOverDays: 0,
|
||||
grantedDays: 16,
|
||||
takenDays: 0,
|
||||
adjustmentDays: 0,
|
||||
availableDays: 10,
|
||||
carryOverExpiresOn: null,
|
||||
entitlementId: "ent-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSettings(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
weekendDays: [0],
|
||||
allowNegativeBalance: false,
|
||||
carryOverDeadlineMonths: 6,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHarness() {
|
||||
const manager = {
|
||||
getRepository: jest.fn(),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn().mockImplementation((cb: (m: unknown) => unknown) => cb(manager)),
|
||||
manager,
|
||||
};
|
||||
const requests = {
|
||||
findOverlapping: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn(),
|
||||
findPage: jest.fn().mockResolvedValue([[], 0]),
|
||||
findById: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const leaveTypes = {
|
||||
findById: jest.fn(),
|
||||
};
|
||||
const balances = {
|
||||
balanceFor: jest.fn(),
|
||||
post: jest.fn(),
|
||||
entriesForSource: jest.fn().mockResolvedValue([]),
|
||||
reverse: jest.fn(),
|
||||
};
|
||||
const settingsService = {
|
||||
resolve: jest.fn().mockResolvedValue(makeSettings()),
|
||||
};
|
||||
const workingDays = {
|
||||
countWorkingDays: jest.fn().mockResolvedValue(4),
|
||||
holidayDates: jest.fn().mockResolvedValue(new Set<string>()),
|
||||
};
|
||||
const employees = {
|
||||
findByEmployeeId: jest.fn().mockResolvedValue(makeProfile()),
|
||||
};
|
||||
const iamDirectory = {
|
||||
findLineManagerEmployeeId: jest.fn().mockResolvedValue("line-mgr-1"),
|
||||
};
|
||||
|
||||
const service = new LeaveRequestsService(
|
||||
dataSource as never,
|
||||
requests as never,
|
||||
leaveTypes as never,
|
||||
balances as never,
|
||||
settingsService as never,
|
||||
workingDays as never,
|
||||
employees as never,
|
||||
iamDirectory as never,
|
||||
);
|
||||
|
||||
leaveTypes.findById.mockResolvedValue(makeType());
|
||||
balances.balanceFor.mockResolvedValue(makeBalance());
|
||||
|
||||
return {
|
||||
service,
|
||||
dataSource,
|
||||
manager,
|
||||
requests,
|
||||
leaveTypes,
|
||||
balances,
|
||||
settingsService,
|
||||
workingDays,
|
||||
employees,
|
||||
iamDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("LeaveRequestsService.quote", () => {
|
||||
it("prices working-days-only types off countWorkingDays, not calendar span", async () => {
|
||||
const h = makeHarness();
|
||||
h.workingDays.countWorkingDays.mockResolvedValue(4); // deliberately != calendar span (5)
|
||||
h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 10 }));
|
||||
|
||||
const result = await h.service.quote(
|
||||
"emp-1",
|
||||
"type-1",
|
||||
"2026-09-14",
|
||||
"2026-09-18",
|
||||
false,
|
||||
makeActor(),
|
||||
);
|
||||
|
||||
expect(result.workingDays).toBe(4);
|
||||
expect(result.calendarDays).toBe(5);
|
||||
expect(result.chargedDays).toBe(4);
|
||||
expect(result.countsWorkingDaysOnly).toBe(true);
|
||||
expect(result.availableDays).toBe(10);
|
||||
expect(result.remainingAfter).toBe(6);
|
||||
});
|
||||
|
||||
it("prices calendar-day types off the full span, ignoring weekends/holidays", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findById.mockResolvedValue(
|
||||
makeType({ countsWorkingDaysOnly: false, accrualMethod: EAccrualMethod.NONE }),
|
||||
);
|
||||
h.workingDays.countWorkingDays.mockResolvedValue(2); // must be ignored
|
||||
|
||||
const result = await h.service.quote(
|
||||
"emp-1",
|
||||
"type-1",
|
||||
"2026-09-14",
|
||||
"2026-09-18",
|
||||
false,
|
||||
makeActor(),
|
||||
);
|
||||
|
||||
expect(result.chargedDays).toBe(5);
|
||||
});
|
||||
|
||||
it("halves the charge for a half day", async () => {
|
||||
const h = makeHarness();
|
||||
h.workingDays.countWorkingDays.mockResolvedValue(5);
|
||||
|
||||
const result = await h.service.quote(
|
||||
"emp-1",
|
||||
"type-1",
|
||||
"2026-09-14",
|
||||
"2026-09-14",
|
||||
true,
|
||||
makeActor(),
|
||||
);
|
||||
|
||||
expect(result.chargedDays).toBe(2.5);
|
||||
});
|
||||
|
||||
it("flags the attachment requirement once charged days pass the threshold", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findById.mockResolvedValue(makeType({ requiresAttachmentAfter: "3" }));
|
||||
h.workingDays.countWorkingDays.mockResolvedValue(3);
|
||||
|
||||
const atThreshold = await h.service.quote(
|
||||
"emp-1",
|
||||
"type-1",
|
||||
"2026-09-14",
|
||||
"2026-09-18",
|
||||
false,
|
||||
makeActor(),
|
||||
);
|
||||
expect(atThreshold.requiresAttachment).toBe(false); // 3 is not > 3
|
||||
|
||||
h.workingDays.countWorkingDays.mockResolvedValue(4);
|
||||
const overThreshold = await h.service.quote(
|
||||
"emp-1",
|
||||
"type-1",
|
||||
"2026-09-14",
|
||||
"2026-09-18",
|
||||
false,
|
||||
makeActor(),
|
||||
);
|
||||
expect(overThreshold.requiresAttachment).toBe(true);
|
||||
});
|
||||
|
||||
it("swallows a balance-lookup failure into null rather than throwing", async () => {
|
||||
const h = makeHarness();
|
||||
h.balances.balanceFor.mockRejectedValue(new BadRequestException("no balance"));
|
||||
|
||||
const result = await h.service.quote(
|
||||
"emp-1",
|
||||
"type-1",
|
||||
"2026-09-14",
|
||||
"2026-09-18",
|
||||
false,
|
||||
makeActor(),
|
||||
);
|
||||
|
||||
expect(result.availableDays).toBeNull();
|
||||
expect(result.remainingAfter).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the holidays in range, sorted", async () => {
|
||||
const h = makeHarness();
|
||||
h.workingDays.holidayDates.mockResolvedValue(new Set(["2026-09-16", "2026-09-15"]));
|
||||
|
||||
const result = await h.service.quote(
|
||||
"emp-1",
|
||||
"type-1",
|
||||
"2026-09-14",
|
||||
"2026-09-18",
|
||||
false,
|
||||
makeActor(),
|
||||
);
|
||||
|
||||
expect(result.holidaysInRange).toEqual(["2026-09-15", "2026-09-16"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LeaveRequestsService.create", () => {
|
||||
function dto(overrides: Partial<CreateLeaveRequestDto> = {}): CreateLeaveRequestDto {
|
||||
return {
|
||||
leaveTypeId: "type-1",
|
||||
startDate: "2026-09-14",
|
||||
endDate: "2026-09-18",
|
||||
isHalfDay: false,
|
||||
...overrides,
|
||||
} as CreateLeaveRequestDto;
|
||||
}
|
||||
|
||||
it("uses actor.employeeId when dto.employeeId is omitted", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||
id: "req-new",
|
||||
...data,
|
||||
}));
|
||||
|
||||
const result = await h.service.create(dto(), makeActor({ employeeId: "emp-1" }));
|
||||
|
||||
expect(result).toMatchObject({ employeeId: "emp-1" });
|
||||
expect(h.employees.findByEmployeeId).toHaveBeenCalledWith("emp-1");
|
||||
});
|
||||
|
||||
it("refuses when neither dto.employeeId nor actor.employeeId is set", async () => {
|
||||
const h = makeHarness();
|
||||
await expect(
|
||||
h.service.create(dto(), makeActor({ employeeId: null })),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("requires a staff account to file for someone else", async () => {
|
||||
const h = makeHarness();
|
||||
await expect(
|
||||
h.service.create(
|
||||
dto({ employeeId: "emp-2" }),
|
||||
makeActor({ employeeId: "emp-1", organizationId: "" as unknown as string }),
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it("allows a staff account (has organizationId) to file for someone else", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||
id: "req-new",
|
||||
...data,
|
||||
}));
|
||||
|
||||
const result = await h.service.create(
|
||||
dto({ employeeId: "emp-2" }),
|
||||
makeActor({ employeeId: "emp-1", organizationId: "org-1" }),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ employeeId: "emp-2" });
|
||||
});
|
||||
|
||||
it("rejects endDate before startDate", async () => {
|
||||
const h = makeHarness();
|
||||
await expect(
|
||||
h.service.create(dto({ startDate: "2026-09-18", endDate: "2026-09-14" }), makeActor()),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("404s when the HR profile does not exist", async () => {
|
||||
const h = makeHarness();
|
||||
h.employees.findByEmployeeId.mockResolvedValue(null);
|
||||
await expect(h.service.create(dto(), makeActor())).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it("enforces the gender restriction against the profile", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findById.mockResolvedValue(
|
||||
makeType({ genderRestriction: EGenderRestriction.FEMALE }),
|
||||
);
|
||||
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ gender: "MALE" }));
|
||||
|
||||
await expect(h.service.create(dto(), makeActor())).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("enforces the gender restriction when no gender is recorded", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findById.mockResolvedValue(
|
||||
makeType({ genderRestriction: EGenderRestriction.FEMALE }),
|
||||
);
|
||||
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ gender: null }));
|
||||
|
||||
await expect(h.service.create(dto(), makeActor())).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("refuses leave starting before the hire date", async () => {
|
||||
const h = makeHarness();
|
||||
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ hireDate: "2027-01-01" }));
|
||||
|
||||
await expect(h.service.create(dto(), makeActor())).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("refuses dates overlapping an existing live request", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.findOverlapping.mockResolvedValue([
|
||||
makeRequest({ status: ELeaveRequestStatus.APPROVED, startDate: "2026-09-15", endDate: "2026-09-16" }),
|
||||
]);
|
||||
|
||||
await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/overlap/i);
|
||||
});
|
||||
|
||||
it("refuses a half day the type does not allow", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findById.mockResolvedValue(makeType({ allowsHalfDay: false }));
|
||||
|
||||
await expect(
|
||||
h.service.create(dto({ isHalfDay: true }), makeActor()),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("refuses a range that is entirely weekends/holidays (charged <= 0)", async () => {
|
||||
const h = makeHarness();
|
||||
h.workingDays.countWorkingDays.mockResolvedValue(0);
|
||||
|
||||
await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/no working days/i);
|
||||
expect(h.requests.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caps at maxConsecutiveDays", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findById.mockResolvedValue(makeType({ maxConsecutiveDays: "3" }));
|
||||
h.workingDays.countWorkingDays.mockResolvedValue(4);
|
||||
|
||||
await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/limited to 3 days/i);
|
||||
});
|
||||
|
||||
it("requires an attachment past the threshold", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findById.mockResolvedValue(makeType({ requiresAttachmentAfter: "2" }));
|
||||
h.workingDays.countWorkingDays.mockResolvedValue(4);
|
||||
|
||||
await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/supporting evidence/i);
|
||||
});
|
||||
|
||||
it("accepts an attachment past the threshold when supplied", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findById.mockResolvedValue(makeType({ requiresAttachmentAfter: "2" }));
|
||||
h.workingDays.countWorkingDays.mockResolvedValue(4);
|
||||
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||
id: "req-new",
|
||||
...data,
|
||||
}));
|
||||
|
||||
await expect(
|
||||
h.service.create(dto({ attachmentDocumentId: "doc-1" }), makeActor()),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("skips the balance check entirely for NONE-accrual types", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findById.mockResolvedValue(makeType({ accrualMethod: EAccrualMethod.NONE }));
|
||||
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||
id: "req-new",
|
||||
...data,
|
||||
}));
|
||||
|
||||
await h.service.create(dto(), makeActor());
|
||||
|
||||
expect(h.balances.balanceFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the balance check when the org allows a negative balance", async () => {
|
||||
const h = makeHarness();
|
||||
h.settingsService.resolve.mockResolvedValue(makeSettings({ allowNegativeBalance: true }));
|
||||
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||
id: "req-new",
|
||||
...data,
|
||||
}));
|
||||
|
||||
await h.service.create(dto(), makeActor());
|
||||
|
||||
expect(h.balances.balanceFor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("counts other SUBMITTED requests as held against the balance", async () => {
|
||||
const h = makeHarness();
|
||||
h.workingDays.countWorkingDays.mockResolvedValue(4);
|
||||
h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 5 }));
|
||||
h.requests.findPage.mockResolvedValue([
|
||||
[makeRequest({ id: "other-req", chargedDays: "2.00" })],
|
||||
1,
|
||||
]);
|
||||
|
||||
// available 5 - held 2 = free 3, charged 4 > free 3 -> refused
|
||||
await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/already held by pending/i);
|
||||
});
|
||||
|
||||
it("allows the request when enough balance remains after held requests", async () => {
|
||||
const h = makeHarness();
|
||||
h.workingDays.countWorkingDays.mockResolvedValue(4);
|
||||
h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 10 }));
|
||||
h.requests.findPage.mockResolvedValue([
|
||||
[makeRequest({ id: "other-req", chargedDays: "2.00" })],
|
||||
1,
|
||||
]);
|
||||
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||
id: "req-new",
|
||||
...data,
|
||||
}));
|
||||
|
||||
await expect(h.service.create(dto(), makeActor())).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("resolves the approver from the profile's managerEmployeeId without an IAM lookup", async () => {
|
||||
const h = makeHarness();
|
||||
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ managerEmployeeId: "mgr-9" }));
|
||||
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||
id: "req-new",
|
||||
...data,
|
||||
}));
|
||||
|
||||
const result = await h.service.create(dto(), makeActor());
|
||||
|
||||
expect(result).toMatchObject({ approverEmployeeId: "mgr-9" });
|
||||
expect(h.iamDirectory.findLineManagerEmployeeId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the IAM position tree when the profile has no manager", async () => {
|
||||
const h = makeHarness();
|
||||
h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ managerEmployeeId: null }));
|
||||
h.iamDirectory.findLineManagerEmployeeId.mockResolvedValue("line-mgr-7");
|
||||
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||
id: "req-new",
|
||||
...data,
|
||||
}));
|
||||
|
||||
const result = await h.service.create(dto(), makeActor());
|
||||
|
||||
expect(h.iamDirectory.findLineManagerEmployeeId).toHaveBeenCalledWith("emp-1");
|
||||
expect(result).toMatchObject({ approverEmployeeId: "line-mgr-7" });
|
||||
});
|
||||
|
||||
it("submits requiresApproval types as SUBMITTED with no ledger posting", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||
id: "req-new",
|
||||
...data,
|
||||
}));
|
||||
|
||||
const result = await h.service.create(dto(), makeActor());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: ELeaveRequestStatus.SUBMITTED,
|
||||
decidedByEmployeeId: null,
|
||||
decidedAt: null,
|
||||
decisionNote: null,
|
||||
});
|
||||
expect(h.balances.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-approves requiresApproval:false types and posts a DEDUCTION outside a transaction", async () => {
|
||||
const h = makeHarness();
|
||||
h.leaveTypes.findById.mockResolvedValue(makeType({ requiresApproval: false, code: "BEREAVEMENT" }));
|
||||
h.workingDays.countWorkingDays.mockResolvedValue(3);
|
||||
h.requests.create.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||
id: "req-new",
|
||||
...data,
|
||||
}));
|
||||
h.balances.balanceFor.mockResolvedValue(makeBalance({ entitlementId: "ent-42" }));
|
||||
|
||||
const result = await h.service.create(dto(), makeActor({ employeeId: "emp-1" }));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: ELeaveRequestStatus.APPROVED,
|
||||
decidedByEmployeeId: "emp-1",
|
||||
approverEmployeeId: null,
|
||||
});
|
||||
expect(result.decidedAt).toBeInstanceOf(Date);
|
||||
expect(result.decisionNote).toMatch(/does not require prior approval/i);
|
||||
|
||||
expect(h.dataSource.transaction).not.toHaveBeenCalled();
|
||||
expect(h.balances.post).toHaveBeenCalledTimes(1);
|
||||
const [entitlementId, entry, , manager] = h.balances.post.mock.calls[0];
|
||||
expect(entitlementId).toBe("ent-42");
|
||||
expect(entry).toMatchObject({ entryType: ELedgerEntryType.DEDUCTION, days: -3 });
|
||||
expect(manager).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LeaveRequestsService.approve", () => {
|
||||
it("404s when the request does not exist", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.findById.mockResolvedValue(null);
|
||||
await expect(h.service.approve("missing", undefined, makeActor())).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it("only allows SUBMITTED to become APPROVED", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.findById.mockResolvedValue(
|
||||
makeRequest({ status: ELeaveRequestStatus.APPROVED }),
|
||||
);
|
||||
await expect(h.service.approve("req-1", undefined, makeActor())).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses self-approval", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.findById.mockResolvedValue(makeRequest({ employeeId: "emp-1" }));
|
||||
await expect(
|
||||
h.service.approve("req-1", undefined, makeActor({ employeeId: "emp-1" })),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it("404s when the leave type behind the request no longer exists", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.findById.mockResolvedValue(makeRequest());
|
||||
h.leaveTypes.findById.mockResolvedValue(null);
|
||||
await expect(
|
||||
h.service.approve("req-1", undefined, makeActor({ employeeId: "mgr-1" })),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it("excludes the request's own pending entry from the held total when re-checking balance", async () => {
|
||||
const h = makeHarness();
|
||||
// The request being approved is itself SUBMITTED and would otherwise show
|
||||
// up in the pending-requests query — assertBalance must exclude it via
|
||||
// excludeRequestId, or a solo request would always look self-blocking
|
||||
// (held == charged, leaving zero free even though nothing else is pending).
|
||||
const request = makeRequest({ chargedDays: "4.00" });
|
||||
h.requests.findById.mockResolvedValue(request);
|
||||
h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 4, entitlementId: "ent-1" }));
|
||||
h.requests.findPage.mockResolvedValue([[request], 1]);
|
||||
const requestRepoInTx = { update: jest.fn().mockResolvedValue(undefined) };
|
||||
h.manager.getRepository.mockReturnValue(requestRepoInTx);
|
||||
|
||||
await expect(
|
||||
h.service.approve("req-1", undefined, makeActor({ employeeId: "mgr-1" })),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("refuses when another request has consumed the balance since submission", async () => {
|
||||
const h = makeHarness();
|
||||
const request = makeRequest({ chargedDays: "4.00" });
|
||||
h.requests.findById.mockResolvedValue(request);
|
||||
h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 3 }));
|
||||
h.requests.findPage.mockResolvedValue([
|
||||
[request, makeRequest({ id: "other-req", chargedDays: "1.00" })],
|
||||
2,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
h.service.approve("req-1", undefined, makeActor({ employeeId: "mgr-1" })),
|
||||
).rejects.toThrow(/not enough/i);
|
||||
});
|
||||
|
||||
it("posts the DEDUCTION and flips status to APPROVED atomically in one transaction", async () => {
|
||||
const h = makeHarness();
|
||||
const request = makeRequest({ chargedDays: "4.00" });
|
||||
h.requests.findById.mockResolvedValue(request);
|
||||
h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 10, entitlementId: "ent-1" }));
|
||||
h.requests.findPage.mockResolvedValue([[], 0]);
|
||||
|
||||
const requestRepoInTx = { update: jest.fn().mockResolvedValue(undefined) };
|
||||
h.manager.getRepository.mockReturnValue(requestRepoInTx);
|
||||
|
||||
const result = await h.service.approve("req-1", "looks good", makeActor({ employeeId: "mgr-1" }));
|
||||
|
||||
expect(h.dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Both writes happened against the transaction's manager.
|
||||
expect(h.balances.post).toHaveBeenCalledTimes(1);
|
||||
const [entitlementId, entry, , manager] = h.balances.post.mock.calls[0];
|
||||
expect(entitlementId).toBe("ent-1");
|
||||
expect(entry).toMatchObject({ entryType: ELedgerEntryType.DEDUCTION, days: -4 });
|
||||
expect(manager).toBe(h.manager);
|
||||
|
||||
expect(requestRepoInTx.update).toHaveBeenCalledWith(
|
||||
"req-1",
|
||||
expect.objectContaining({
|
||||
status: ELeaveRequestStatus.APPROVED,
|
||||
decidedByEmployeeId: "mgr-1",
|
||||
decisionNote: "looks good",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(ELeaveRequestStatus.APPROVED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LeaveRequestsService.reject", () => {
|
||||
it("only allows SUBMITTED to become REJECTED", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.findById.mockResolvedValue(
|
||||
makeRequest({ status: ELeaveRequestStatus.CANCELLED }),
|
||||
);
|
||||
await expect(
|
||||
h.service.reject("req-1", "no", makeActor({ employeeId: "mgr-1" })),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("refuses self-rejection", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.findById.mockResolvedValue(makeRequest({ employeeId: "emp-1" }));
|
||||
await expect(
|
||||
h.service.reject("req-1", "no", makeActor({ employeeId: "emp-1" })),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it("updates status without touching the ledger — nothing was deducted at submission", async () => {
|
||||
const h = makeHarness();
|
||||
const request = makeRequest();
|
||||
h.requests.findById.mockResolvedValue(request);
|
||||
h.requests.update.mockResolvedValue({ ...request, status: ELeaveRequestStatus.REJECTED });
|
||||
|
||||
const result = await h.service.reject("req-1", "not eligible", makeActor({ employeeId: "mgr-1" }));
|
||||
|
||||
expect(h.requests.update).toHaveBeenCalledWith(
|
||||
"req-1",
|
||||
expect.objectContaining({
|
||||
status: ELeaveRequestStatus.REJECTED,
|
||||
decidedByEmployeeId: "mgr-1",
|
||||
decisionNote: "not eligible",
|
||||
}),
|
||||
);
|
||||
expect(h.balances.post).not.toHaveBeenCalled();
|
||||
expect(h.balances.reverse).not.toHaveBeenCalled();
|
||||
expect(h.dataSource.transaction).not.toHaveBeenCalled();
|
||||
expect(result.status).toBe(ELeaveRequestStatus.REJECTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LeaveRequestsService.cancel", () => {
|
||||
it("only allows APPROVED to become CANCELLED", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.findById.mockResolvedValue(
|
||||
makeRequest({ status: ELeaveRequestStatus.SUBMITTED }),
|
||||
);
|
||||
await expect(
|
||||
h.service.cancel("req-1", "change of plans", makeActor({ employeeId: "emp-2" })),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("requires a staff account to cancel someone else's leave", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.findById.mockResolvedValue(
|
||||
makeRequest({ status: ELeaveRequestStatus.APPROVED, employeeId: "emp-2" }),
|
||||
);
|
||||
await expect(
|
||||
h.service.cancel(
|
||||
"req-1",
|
||||
"change of plans",
|
||||
makeActor({ employeeId: "emp-1", organizationId: "" as unknown as string }),
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it("reverses every un-reversed DEDUCTION entry for the request, atomically", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.findById.mockResolvedValue(
|
||||
makeRequest({ status: ELeaveRequestStatus.APPROVED, employeeId: "emp-2" }),
|
||||
);
|
||||
h.balances.entriesForSource.mockResolvedValue([
|
||||
{ id: "entry-1", reversesId: null },
|
||||
]);
|
||||
const requestRepoInTx = { update: jest.fn().mockResolvedValue(undefined) };
|
||||
h.manager.getRepository.mockReturnValue(requestRepoInTx);
|
||||
|
||||
const result = await h.service.cancel(
|
||||
"req-1",
|
||||
"change of plans",
|
||||
makeActor({ employeeId: "emp-1", organizationId: "org-1" }),
|
||||
);
|
||||
|
||||
expect(h.dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(h.balances.reverse).toHaveBeenCalledTimes(1);
|
||||
expect(h.balances.reverse).toHaveBeenCalledWith(
|
||||
"entry-1",
|
||||
expect.stringContaining("change of plans"),
|
||||
expect.anything(),
|
||||
h.manager,
|
||||
);
|
||||
expect(requestRepoInTx.update).toHaveBeenCalledWith(
|
||||
"req-1",
|
||||
expect.objectContaining({
|
||||
status: ELeaveRequestStatus.CANCELLED,
|
||||
cancelledReason: "change of plans",
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe(ELeaveRequestStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it("idempotently skips an entry that has already been reversed", async () => {
|
||||
const h = makeHarness();
|
||||
h.requests.findById.mockResolvedValue(
|
||||
makeRequest({ status: ELeaveRequestStatus.APPROVED, employeeId: "emp-2" }),
|
||||
);
|
||||
// entry-1 is the original DEDUCTION; entry-2 already reverses it.
|
||||
h.balances.entriesForSource.mockResolvedValue([
|
||||
{ id: "entry-1", reversesId: null },
|
||||
{ id: "entry-2", reversesId: "entry-1" },
|
||||
]);
|
||||
const requestRepoInTx = { update: jest.fn().mockResolvedValue(undefined) };
|
||||
h.manager.getRepository.mockReturnValue(requestRepoInTx);
|
||||
|
||||
await h.service.cancel(
|
||||
"req-1",
|
||||
"already reversed",
|
||||
makeActor({ employeeId: "emp-1", organizationId: "org-1" }),
|
||||
);
|
||||
|
||||
expect(h.balances.reverse).not.toHaveBeenCalled();
|
||||
expect(requestRepoInTx.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
import {
|
||||
CalculationInput,
|
||||
PayrollCalculatorService,
|
||||
} from "./payroll-calculator.service";
|
||||
import {
|
||||
EComponentCalculation,
|
||||
EComponentType,
|
||||
SalaryComponent,
|
||||
} from "../entities/salary-component.entity";
|
||||
import { IncomeTaxBracket } from "../entities/statutory.entity";
|
||||
import { INCOME_TAX_BRACKETS_979_2016, RESERVED_CODES } from "../statutory-payroll";
|
||||
|
||||
/**
|
||||
* The real national Schedule B bands (Proc. 979/2016 Art. 11), shaped as the
|
||||
* entity rows `TaxService.computeTax` expects. Using the actual bands — not a
|
||||
* simplified stand-in — is what makes the worked examples below real, checkable
|
||||
* arithmetic rather than a tautology against a fake schedule.
|
||||
*/
|
||||
const BRACKETS = INCOME_TAX_BRACKETS_979_2016.bands.map((band) => ({
|
||||
lowerBound: band.lowerBound,
|
||||
upperBound: band.upperBound,
|
||||
rate: band.rate,
|
||||
deduction: band.deduction,
|
||||
})) as unknown as IncomeTaxBracket[];
|
||||
|
||||
function component(overrides: Partial<SalaryComponent> = {}): SalaryComponent {
|
||||
return {
|
||||
id: "comp-1",
|
||||
organizationId: "org-1",
|
||||
code: "CODE",
|
||||
name: { am: "test", en: "Test" },
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
calculation: EComponentCalculation.FIXED,
|
||||
defaultAmount: null,
|
||||
defaultRate: null,
|
||||
isTaxable: true,
|
||||
isPensionable: false,
|
||||
taxExemptAmount: null,
|
||||
taxExemptRateOfBasic: null,
|
||||
affectsNetPay: true,
|
||||
statuteReference: null,
|
||||
sortOrder: 100,
|
||||
isActive: true,
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
...overrides,
|
||||
} as SalaryComponent;
|
||||
}
|
||||
|
||||
function baseInput(overrides: Partial<CalculationInput> = {}): CalculationInput {
|
||||
return {
|
||||
basicSalary: 10000,
|
||||
components: [],
|
||||
brackets: BRACKETS,
|
||||
pensionEmployeeRate: 0.07,
|
||||
pensionEmployerRate: 0.11,
|
||||
overtimePay: 0,
|
||||
overtimeHours: 0,
|
||||
absenceDeductionRate: 0,
|
||||
isPensionEligible: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PayrollCalculatorService.calculate — worked examples", () => {
|
||||
let service: PayrollCalculatorService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PayrollCalculatorService();
|
||||
});
|
||||
|
||||
/**
|
||||
* The single regression test this whole file exists for: income tax must be
|
||||
* computed on taxable income NET of the employee's own pension deduction, not
|
||||
* on gross taxable income. Basic-only, full attendance, pension-eligible:
|
||||
*
|
||||
* basic = 10,000.00
|
||||
* pension employee = 10,000.00 × 7% = 700.00
|
||||
* pension employer = 10,000.00 × 11% = 1,100.00
|
||||
* taxable income = 10,000.00 − 700.00 = 9,300.00
|
||||
* band = 7,800.01–10,900.00 (30%, ded. 955.00)
|
||||
* income tax = 9,300.00 × 0.30 − 955.00 = 1,835.00
|
||||
* net pay = 10,000.00 − 700.00 − 1,835.00 = 7,465.00
|
||||
*
|
||||
* Taxing GROSS taxable income (10,000.00, same band) instead would give
|
||||
* 10,000 × 0.30 − 955 = 2,045.00 — a different, wrong, number. This test
|
||||
* would fail if that ordering bug were reintroduced.
|
||||
*/
|
||||
it("computes income tax on taxable income net of the employee pension deduction", () => {
|
||||
const result = service.calculate(baseInput());
|
||||
|
||||
expect(result.basicSalary).toBe(10000);
|
||||
expect(result.grossPay).toBe(10000);
|
||||
expect(result.pensionableIncome).toBe(10000);
|
||||
expect(result.pensionEmployee).toBe(700);
|
||||
expect(result.pensionEmployer).toBe(1100);
|
||||
expect(result.taxableIncome).toBe(9300);
|
||||
expect(result.incomeTax).toBe(1835);
|
||||
expect(result.incomeTax).not.toBe(2045); // the gross-taxed wrong answer
|
||||
expect(result.totalDeductions).toBe(700 + 1835);
|
||||
expect(result.netPay).toBe(7465);
|
||||
|
||||
// The employer's contribution is reported but plays no part in the deduction total.
|
||||
const employerLine = result.lines.find((l) => l.code === RESERVED_CODES.pensionEmployer);
|
||||
expect(employerLine?.affectsNetPay).toBe(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* A full slip: unpaid-absence pro-ration, a capped allowance, an uncapped
|
||||
* allowance, a percent-of-basic allowance, overtime, a plain other deduction,
|
||||
* and both sides of pension — worked by hand end to end.
|
||||
*
|
||||
* basic (90% paid, 10% unpaid absence) = 10,000.00 × 0.90 = 9,000.00
|
||||
* transport allowance = 3,000.00 (exempt: min(2,200, 25% of
|
||||
* unprorated basic 10,000=2,500) = 2,200
|
||||
* → taxable 800.00)
|
||||
* housing allowance (no cap) = 1,500.00 (fully taxable)
|
||||
* position allowance (5% of unprorated
|
||||
* basic 10,000) = 500.00 (fully taxable)
|
||||
* overtime (always fully taxable, never
|
||||
* pensionable) = 150.00
|
||||
* ────────────────────────────────────────────────
|
||||
* gross pay = 9,000+3,000+1,500+500+150 = 14,150.00
|
||||
* pensionable = basic only (all allowances non-pensionable) = 9,000.00
|
||||
* taxable earnings = 9,000+800+1,500+500+150 = 11,950.00
|
||||
* pension employee = 9,000 × 7% = 630.00
|
||||
* pension employer = 9,000 × 11% = 990.00
|
||||
* taxable income = 11,950 − 630 = 11,320.00
|
||||
* band = 10,900.01+ (35%, ded. 1,500.00)
|
||||
* income tax = 11,320 × 0.35 − 1,500 = 2,462.00
|
||||
* other deduction (loan) = 200.00
|
||||
* total deductions = 630 + 2,462 + 200 = 3,292.00
|
||||
* net pay = 14,150 − 3,292 = 10,858.00
|
||||
*/
|
||||
it("computes a full payslip: absence pro-ration, capped/uncapped allowances, overtime, other deductions, both pension sides", () => {
|
||||
const transport = component({
|
||||
id: "transport-1",
|
||||
code: "TRANSPORT",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
calculation: EComponentCalculation.FIXED,
|
||||
isTaxable: true,
|
||||
isPensionable: false,
|
||||
taxExemptAmount: "2200.00",
|
||||
taxExemptRateOfBasic: "0.2500",
|
||||
sortOrder: 20,
|
||||
});
|
||||
const housing = component({
|
||||
id: "housing-1",
|
||||
code: "HOUSING",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
calculation: EComponentCalculation.FIXED,
|
||||
isTaxable: true,
|
||||
isPensionable: false,
|
||||
sortOrder: 30,
|
||||
});
|
||||
const position = component({
|
||||
id: "position-1",
|
||||
code: "POSITION",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
calculation: EComponentCalculation.PERCENT_OF_BASIC,
|
||||
isTaxable: true,
|
||||
isPensionable: false,
|
||||
sortOrder: 40,
|
||||
});
|
||||
const loan = component({
|
||||
id: "loan-1",
|
||||
code: "LOAN",
|
||||
componentType: EComponentType.DEDUCTION,
|
||||
calculation: EComponentCalculation.FIXED,
|
||||
sortOrder: 90,
|
||||
});
|
||||
|
||||
const result = service.calculate(
|
||||
baseInput({
|
||||
basicSalary: 10000,
|
||||
absenceDeductionRate: 0.1,
|
||||
overtimePay: 150,
|
||||
overtimeHours: 10,
|
||||
components: [
|
||||
{ component: transport, amount: "3000" },
|
||||
{ component: housing, amount: "1500" },
|
||||
{ component: position, rate: "0.05" },
|
||||
{ component: loan, amount: "200" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.basicSalary).toBe(9000);
|
||||
expect(result.grossPay).toBe(14150);
|
||||
expect(result.pensionableIncome).toBe(9000);
|
||||
expect(result.pensionEmployee).toBe(630);
|
||||
expect(result.pensionEmployer).toBe(990);
|
||||
expect(result.taxableIncome).toBe(11320);
|
||||
expect(result.incomeTax).toBe(2462);
|
||||
expect(result.totalDeductions).toBe(630 + 2462 + 200);
|
||||
expect(result.netPay).toBe(10858);
|
||||
|
||||
const transportLine = result.lines.find((l) => l.code === "TRANSPORT");
|
||||
expect(transportLine?.amount).toBe(3000);
|
||||
expect(transportLine?.taxableAmount).toBe(800);
|
||||
expect(transportLine?.basis).toBe("2200.00 exempt, 800.00 taxable");
|
||||
|
||||
const housingLine = result.lines.find((l) => l.code === "HOUSING");
|
||||
expect(housingLine?.amount).toBe(1500);
|
||||
expect(housingLine?.taxableAmount).toBe(1500);
|
||||
expect(housingLine?.basis).toBeNull();
|
||||
|
||||
const positionLine = result.lines.find((l) => l.code === "POSITION");
|
||||
expect(positionLine?.amount).toBe(500);
|
||||
|
||||
const overtimeLine = result.lines.find((l) => l.code === RESERVED_CODES.overtime);
|
||||
expect(overtimeLine?.amount).toBe(150);
|
||||
expect(overtimeLine?.taxableAmount).toBe(150);
|
||||
expect(overtimeLine?.isTaxable).toBe(true);
|
||||
expect(overtimeLine?.isPensionable).toBe(false);
|
||||
|
||||
const loanLine = result.lines.find((l) => l.code === "LOAN");
|
||||
expect(loanLine?.amount).toBe(200);
|
||||
expect(loanLine?.taxableAmount).toBe(0);
|
||||
expect(loanLine?.affectsNetPay).toBe(true);
|
||||
|
||||
const basicLine = result.lines.find((l) => l.componentType === EComponentType.BASIC);
|
||||
expect(basicLine?.basis).toBe("90% of 10000.00 — unpaid absence");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayrollCalculatorService.calculate — allowance taxable-portion cap", () => {
|
||||
let service: PayrollCalculatorService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PayrollCalculatorService();
|
||||
});
|
||||
|
||||
const allowanceLine = (input: CalculationInput) =>
|
||||
service.calculate(input).lines.find((l) => l.code === "ALLOW");
|
||||
|
||||
it("caps the exempt portion at a fixed amount when only taxExemptAmount is set", () => {
|
||||
const allowance = component({
|
||||
code: "ALLOW",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
taxExemptAmount: "1000.00",
|
||||
taxExemptRateOfBasic: null,
|
||||
});
|
||||
const line = allowanceLine(
|
||||
baseInput({ components: [{ component: allowance, amount: "1500" }] }),
|
||||
);
|
||||
expect(line?.taxableAmount).toBe(500);
|
||||
expect(line?.basis).toBe("1000.00 exempt, 500.00 taxable");
|
||||
});
|
||||
|
||||
it("caps the exempt portion at a percentage of basic when only taxExemptRateOfBasic is set", () => {
|
||||
const allowance = component({
|
||||
code: "ALLOW",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
taxExemptAmount: null,
|
||||
taxExemptRateOfBasic: "0.1000",
|
||||
});
|
||||
const line = allowanceLine(
|
||||
baseInput({
|
||||
basicSalary: 10000,
|
||||
components: [{ component: allowance, amount: "1500" }],
|
||||
}),
|
||||
);
|
||||
// exempt = 10% of 10,000 = 1,000 → taxable 500
|
||||
expect(line?.taxableAmount).toBe(500);
|
||||
});
|
||||
|
||||
it("takes the LESSER of the two caps — the transport-allowance rule — not the greater", () => {
|
||||
const allowance = component({
|
||||
code: "ALLOW",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
taxExemptAmount: "2200.00",
|
||||
taxExemptRateOfBasic: "0.2500",
|
||||
});
|
||||
// 25% of 10,000 = 2,500 > 2,200 fixed cap. The LOWER (2,200) must win.
|
||||
const line = allowanceLine(
|
||||
baseInput({
|
||||
basicSalary: 10000,
|
||||
components: [{ component: allowance, amount: "3000" }],
|
||||
}),
|
||||
);
|
||||
expect(line?.taxableAmount).toBe(800); // NOT 500 (which the higher 2,500 cap would give)
|
||||
});
|
||||
|
||||
it("is fully exempt when the allowance amount is below the cap", () => {
|
||||
const allowance = component({
|
||||
code: "ALLOW",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
taxExemptAmount: "2200.00",
|
||||
});
|
||||
const line = allowanceLine(
|
||||
baseInput({ components: [{ component: allowance, amount: "2000" }] }),
|
||||
);
|
||||
expect(line?.taxableAmount).toBe(0);
|
||||
expect(line?.basis).toBe("fully exempt (2000.00)");
|
||||
});
|
||||
|
||||
it("has no exempt cap at all when neither field is set — fully taxable", () => {
|
||||
const allowance = component({
|
||||
code: "ALLOW",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
taxExemptAmount: null,
|
||||
taxExemptRateOfBasic: null,
|
||||
});
|
||||
const line = allowanceLine(
|
||||
baseInput({ components: [{ component: allowance, amount: "1500" }] }),
|
||||
);
|
||||
expect(line?.taxableAmount).toBe(1500);
|
||||
expect(line?.basis).toBeNull();
|
||||
});
|
||||
|
||||
it("is fully non-taxable, ignoring any cap, when the component itself is not taxable", () => {
|
||||
const allowance = component({
|
||||
code: "ALLOW",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
isTaxable: false,
|
||||
taxExemptAmount: "100.00",
|
||||
});
|
||||
const line = allowanceLine(
|
||||
baseInput({ components: [{ component: allowance, amount: "1500" }] }),
|
||||
);
|
||||
expect(line?.taxableAmount).toBe(0);
|
||||
expect(line?.basis).toBe("not taxable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayrollCalculatorService.calculate — pension", () => {
|
||||
let service: PayrollCalculatorService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PayrollCalculatorService();
|
||||
});
|
||||
|
||||
it("charges neither pension side and does not reduce taxable income when the employee is ineligible", () => {
|
||||
const result = service.calculate(baseInput({ isPensionEligible: false }));
|
||||
|
||||
expect(result.pensionEmployee).toBe(0);
|
||||
expect(result.pensionEmployer).toBe(0);
|
||||
expect(result.lines.find((l) => l.code === RESERVED_CODES.pensionEmployee)).toBeUndefined();
|
||||
expect(result.lines.find((l) => l.code === RESERVED_CODES.pensionEmployer)).toBeUndefined();
|
||||
// Taxable income equals taxable earnings verbatim — nothing was deducted first.
|
||||
expect(result.taxableIncome).toBe(result.grossPay);
|
||||
});
|
||||
|
||||
it("records the employer contribution with affectsNetPay:false and excludes it from totalDeductions", () => {
|
||||
const result = service.calculate(baseInput());
|
||||
|
||||
const employerLine = result.lines.find((l) => l.code === RESERVED_CODES.pensionEmployer);
|
||||
expect(employerLine).toBeDefined();
|
||||
expect(employerLine?.componentType).toBe(EComponentType.EMPLOYER_CONTRIBUTION);
|
||||
expect(employerLine?.affectsNetPay).toBe(false);
|
||||
expect(result.totalDeductions).toBe(result.pensionEmployee + result.incomeTax);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayrollCalculatorService.calculate — other deductions", () => {
|
||||
let service: PayrollCalculatorService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PayrollCalculatorService();
|
||||
});
|
||||
|
||||
it("skips a DEDUCTION component whose calculation is STATUTORY — those are computed by the engine, not the structure", () => {
|
||||
const fakeStatutory = component({
|
||||
code: "CUSTOM_STAT",
|
||||
componentType: EComponentType.DEDUCTION,
|
||||
calculation: EComponentCalculation.STATUTORY,
|
||||
defaultAmount: "500.00",
|
||||
});
|
||||
const result = service.calculate(
|
||||
baseInput({ components: [{ component: fakeStatutory, amount: "500" }] }),
|
||||
);
|
||||
expect(result.lines.find((l) => l.code === "CUSTOM_STAT")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prices a PERCENT_OF_GROSS deduction off the actual computed gross pay", () => {
|
||||
const grossDeduction = component({
|
||||
code: "UNION_DUES",
|
||||
componentType: EComponentType.DEDUCTION,
|
||||
calculation: EComponentCalculation.PERCENT_OF_GROSS,
|
||||
});
|
||||
const result = service.calculate(
|
||||
baseInput({
|
||||
basicSalary: 10000,
|
||||
components: [{ component: grossDeduction, rate: "0.02" }],
|
||||
}),
|
||||
);
|
||||
// gross pay is basic-only here (10,000) since no allowances were added.
|
||||
const line = result.lines.find((l) => l.code === "UNION_DUES");
|
||||
expect(line?.amount).toBe(200); // 2% of 10,000
|
||||
});
|
||||
|
||||
it("drops an allowance or deduction whose computed amount is zero or negative — no line is emitted", () => {
|
||||
const zeroAllowance = component({
|
||||
code: "ZERO",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
calculation: EComponentCalculation.FIXED,
|
||||
defaultAmount: null,
|
||||
});
|
||||
const result = service.calculate(
|
||||
baseInput({ components: [{ component: zeroAllowance }] }),
|
||||
);
|
||||
expect(result.lines.find((l) => l.code === "ZERO")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the component's own defaultAmount/defaultRate when the structure line has no override", () => {
|
||||
const fixedByDefault = component({
|
||||
code: "FIXED_DEFAULT",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
calculation: EComponentCalculation.FIXED,
|
||||
defaultAmount: "750.00",
|
||||
});
|
||||
const percentByDefault = component({
|
||||
code: "PERCENT_DEFAULT",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
calculation: EComponentCalculation.PERCENT_OF_BASIC,
|
||||
defaultRate: "0.0500",
|
||||
});
|
||||
const result = service.calculate(
|
||||
baseInput({
|
||||
basicSalary: 10000,
|
||||
components: [{ component: fixedByDefault }, { component: percentByDefault }],
|
||||
}),
|
||||
);
|
||||
expect(result.lines.find((l) => l.code === "FIXED_DEFAULT")?.amount).toBe(750);
|
||||
expect(result.lines.find((l) => l.code === "PERCENT_DEFAULT")?.amount).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayrollCalculatorService.calculate — line identity and ordering", () => {
|
||||
let service: PayrollCalculatorService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PayrollCalculatorService();
|
||||
});
|
||||
|
||||
it("uses the reserved default code/name for BASIC when no BASIC component is supplied", () => {
|
||||
const result = service.calculate(baseInput());
|
||||
const basicLine = result.lines.find((l) => l.componentType === EComponentType.BASIC);
|
||||
expect(basicLine?.code).toBe(RESERVED_CODES.basic);
|
||||
expect(basicLine?.name.en).toBe("Basic salary");
|
||||
expect(basicLine?.salaryComponentId).toBeNull();
|
||||
expect(basicLine?.isPensionable).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the supplied BASIC component's own code/name/id/sortOrder/isPensionable when one is given", () => {
|
||||
const basicComponent = component({
|
||||
id: "basic-custom",
|
||||
code: "BASE",
|
||||
name: { am: "custom", en: "Custom basic" },
|
||||
componentType: EComponentType.BASIC,
|
||||
isPensionable: false,
|
||||
sortOrder: 5,
|
||||
});
|
||||
const result = service.calculate(
|
||||
baseInput({ components: [{ component: basicComponent }] }),
|
||||
);
|
||||
const basicLine = result.lines.find((l) => l.componentType === EComponentType.BASIC);
|
||||
expect(basicLine?.code).toBe("BASE");
|
||||
expect(basicLine?.name.en).toBe("Custom basic");
|
||||
expect(basicLine?.salaryComponentId).toBe("basic-custom");
|
||||
expect(basicLine?.isPensionable).toBe(false);
|
||||
expect(basicLine?.sortOrder).toBe(5);
|
||||
});
|
||||
|
||||
it("uses the reserved default name for OVERTIME when no OVERTIME component is supplied", () => {
|
||||
const result = service.calculate(baseInput({ overtimePay: 100, overtimeHours: 5 }));
|
||||
const line = result.lines.find((l) => l.code === RESERVED_CODES.overtime);
|
||||
expect(line?.name.en).toBe("Overtime");
|
||||
expect(line?.salaryComponentId).toBeNull();
|
||||
expect(line?.basis).toBe("5 hour(s) of approved overtime");
|
||||
});
|
||||
|
||||
it("omits the overtime line entirely when overtimePay is zero", () => {
|
||||
const result = service.calculate(baseInput({ overtimePay: 0, overtimeHours: 0 }));
|
||||
expect(result.lines.find((l) => l.code === RESERVED_CODES.overtime)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns lines sorted by sortOrder ascending, regardless of push order", () => {
|
||||
const lowSort = component({
|
||||
code: "LOW_SORT",
|
||||
componentType: EComponentType.ALLOWANCE,
|
||||
defaultAmount: "1",
|
||||
sortOrder: 1,
|
||||
});
|
||||
const result = service.calculate(
|
||||
baseInput({ components: [{ component: lowSort }] }),
|
||||
);
|
||||
const sortOrders = result.lines.map((l) => l.sortOrder);
|
||||
const sorted = [...sortOrders].sort((a, b) => a - b);
|
||||
expect(sortOrders).toEqual(sorted);
|
||||
expect(result.lines[0].code).toBe("LOW_SORT");
|
||||
});
|
||||
|
||||
it("omits the income tax line when both incomeTax and taxableIncome are zero", () => {
|
||||
const result = service.calculate(baseInput({ basicSalary: 0 }));
|
||||
expect(result.incomeTax).toBe(0);
|
||||
expect(result.taxableIncome).toBe(0);
|
||||
expect(result.lines.find((l) => l.code === RESERVED_CODES.incomeTax)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayrollCalculatorService.calculate — absence pro-ration clamping", () => {
|
||||
let service: PayrollCalculatorService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PayrollCalculatorService();
|
||||
});
|
||||
|
||||
it("clamps an absenceDeductionRate above 1 to a fully unpaid (zero) basic", () => {
|
||||
const result = service.calculate(baseInput({ absenceDeductionRate: 1.5 }));
|
||||
expect(result.basicSalary).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps a negative absenceDeductionRate to zero — full pay, no proration basis text", () => {
|
||||
const result = service.calculate(baseInput({ absenceDeductionRate: -0.2 }));
|
||||
expect(result.basicSalary).toBe(10000);
|
||||
const basicLine = result.lines.find((l) => l.componentType === EComponentType.BASIC);
|
||||
expect(basicLine?.basis).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,630 @@
|
||||
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||
|
||||
import type { ActorContext } from "../../employees/employees.service";
|
||||
import { EComponentType } from "../entities/salary-component.entity";
|
||||
import {
|
||||
EPayrollRunStatus,
|
||||
PayrollRun,
|
||||
Payslip,
|
||||
PayslipLine,
|
||||
} from "../entities/payroll-run.entity";
|
||||
import { RESERVED_CODES } from "../statutory-payroll";
|
||||
import type { CalculationResult } from "./payroll-calculator.service";
|
||||
import { PayrollRunsService } from "./payroll-runs.service";
|
||||
|
||||
function actor(overrides: Partial<ActorContext> = {}): ActorContext {
|
||||
return {
|
||||
employeeId: "actor-emp-1",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
isSuperAdmin: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function run(overrides: Partial<PayrollRun> = {}): PayrollRun {
|
||||
return {
|
||||
id: "run-1",
|
||||
organizationId: "org-1",
|
||||
periodStart: "2026-07-01",
|
||||
periodEnd: "2026-07-30",
|
||||
paymentDate: null,
|
||||
status: EPayrollRunStatus.DRAFT,
|
||||
note: null,
|
||||
employeeCount: 0,
|
||||
totalGross: "0.00",
|
||||
totalDeductions: "0.00",
|
||||
totalNet: "0.00",
|
||||
totalIncomeTax: "0.00",
|
||||
totalPensionEmployee: "0.00",
|
||||
totalPensionEmployer: "0.00",
|
||||
calculatedAt: null,
|
||||
approvedByEmployeeId: null,
|
||||
approvedAt: null,
|
||||
createdBy: null,
|
||||
updatedBy: null,
|
||||
...overrides,
|
||||
} as PayrollRun;
|
||||
}
|
||||
|
||||
function calcResult(overrides: Partial<CalculationResult> = {}): CalculationResult {
|
||||
return {
|
||||
lines: [
|
||||
{
|
||||
code: RESERVED_CODES.basic,
|
||||
name: { am: "መሠረታዊ ደመወዝ", en: "Basic salary" },
|
||||
componentType: EComponentType.BASIC,
|
||||
salaryComponentId: null,
|
||||
amount: 10000,
|
||||
taxableAmount: 10000,
|
||||
isTaxable: true,
|
||||
isPensionable: true,
|
||||
affectsNetPay: true,
|
||||
basis: null,
|
||||
sortOrder: 10,
|
||||
},
|
||||
],
|
||||
basicSalary: 10000,
|
||||
grossPay: 10000,
|
||||
taxableIncome: 9300,
|
||||
pensionableIncome: 10000,
|
||||
incomeTax: 1835,
|
||||
pensionEmployee: 700,
|
||||
pensionEmployer: 1100,
|
||||
totalDeductions: 2535,
|
||||
netPay: 7465,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** In-memory stand-in for the manager's `getRepository(Entity)` calls inside the transaction. */
|
||||
function makeManager(onRunUpdate: (patch: Record<string, unknown>) => void) {
|
||||
let payslipSeq = 0;
|
||||
let lineSeq = 0;
|
||||
const payslipRepo = {
|
||||
delete: jest.fn().mockResolvedValue(undefined),
|
||||
create: jest.fn((data: Record<string, unknown>) => data),
|
||||
save: jest.fn(async (data: Record<string, unknown>) => ({
|
||||
id: `payslip-${++payslipSeq}`,
|
||||
...data,
|
||||
})),
|
||||
};
|
||||
const lineRepo = {
|
||||
create: jest.fn((data: Record<string, unknown>) => data),
|
||||
save: jest.fn(async (data: Record<string, unknown>) => ({
|
||||
id: `line-${++lineSeq}`,
|
||||
...data,
|
||||
})),
|
||||
};
|
||||
const runRepo = {
|
||||
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||
onRunUpdate(patch);
|
||||
}),
|
||||
};
|
||||
const getRepository = jest.fn((entity: unknown) => {
|
||||
if (entity === Payslip) return payslipRepo;
|
||||
if (entity === PayslipLine) return lineRepo;
|
||||
if (entity === PayrollRun) return runRepo;
|
||||
throw new Error("unexpected entity passed to manager.getRepository in test mock");
|
||||
});
|
||||
return { getRepository, payslipRepo, lineRepo, runRepo };
|
||||
}
|
||||
|
||||
function build() {
|
||||
let stored: PayrollRun | undefined;
|
||||
const applyUpdate = (patch: Record<string, unknown>) => {
|
||||
stored = { ...(stored as PayrollRun), ...patch } as PayrollRun;
|
||||
};
|
||||
|
||||
const manager = makeManager(applyUpdate);
|
||||
const dataSource = {
|
||||
transaction: jest.fn((cb: (m: unknown) => unknown) => cb(manager)),
|
||||
};
|
||||
const runs = {
|
||||
findOne: jest.fn(async () => stored ?? null),
|
||||
update: jest.fn(async (_id: string, patch: Record<string, unknown>) => {
|
||||
applyUpdate(patch);
|
||||
}),
|
||||
save: jest.fn(),
|
||||
create: jest.fn((d: Record<string, unknown>) => d),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
const payslips = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
const salaries = { findPayableOn: jest.fn().mockResolvedValue([]) };
|
||||
const components = { findActive: jest.fn().mockResolvedValue([]) };
|
||||
const structures = { findWithLines: jest.fn() };
|
||||
const calculator = { calculate: jest.fn() };
|
||||
const tax = {
|
||||
bracketsOn: jest.fn().mockResolvedValue([]),
|
||||
rateOn: jest.fn().mockResolvedValue(0.07),
|
||||
};
|
||||
const employees = { findByEmployeeId: jest.fn().mockResolvedValue(null) };
|
||||
const overtime = { approvedTotals: jest.fn().mockResolvedValue([]) };
|
||||
const attendance = { findRange: jest.fn().mockResolvedValue([]) };
|
||||
const schedules = { resolveFor: jest.fn().mockResolvedValue(null) };
|
||||
const leaveSettings = { resolve: jest.fn().mockResolvedValue({ weekendDays: [0, 6] }) };
|
||||
const workingDays = { countWorkingDays: jest.fn().mockResolvedValue(22) };
|
||||
|
||||
const service = new PayrollRunsService(
|
||||
dataSource as never,
|
||||
runs as never,
|
||||
payslips as never,
|
||||
salaries as never,
|
||||
components as never,
|
||||
structures as never,
|
||||
calculator as never,
|
||||
tax as never,
|
||||
employees as never,
|
||||
overtime as never,
|
||||
attendance as never,
|
||||
schedules as never,
|
||||
leaveSettings as never,
|
||||
workingDays as never,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
manager,
|
||||
dataSource,
|
||||
runs,
|
||||
payslips,
|
||||
salaries,
|
||||
components,
|
||||
structures,
|
||||
calculator,
|
||||
tax,
|
||||
employees,
|
||||
overtime,
|
||||
attendance,
|
||||
schedules,
|
||||
leaveSettings,
|
||||
workingDays,
|
||||
use: (row: PayrollRun) => {
|
||||
stored = row;
|
||||
},
|
||||
getStored: () => stored,
|
||||
};
|
||||
}
|
||||
|
||||
const activeComponent = (over: Record<string, unknown> = {}) => ({
|
||||
id: "comp-basic",
|
||||
code: RESERVED_CODES.basic,
|
||||
name: { am: "x", en: "Basic" },
|
||||
componentType: EComponentType.BASIC,
|
||||
isActive: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("PayrollRunsService.calculate", () => {
|
||||
it.each([
|
||||
EPayrollRunStatus.APPROVED,
|
||||
EPayrollRunStatus.PAID,
|
||||
EPayrollRunStatus.CANCELLED,
|
||||
])("refuses to (re)calculate a %s run", async (status) => {
|
||||
const { service, use } = build();
|
||||
use(run({ status }));
|
||||
|
||||
await expect(service.calculate("run-1", actor())).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it("rebuilds from scratch: deletes existing payslips before writing any new one, one payslip+lines per employee, aggregated totals, status CALCULATED", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.DRAFT }));
|
||||
|
||||
ctx.salaries.findPayableOn.mockResolvedValue([
|
||||
{ employeeId: "emp-1", basicSalary: "10000", salaryStructureId: "struct-1" },
|
||||
{ employeeId: "emp-2", basicSalary: "8000", salaryStructureId: null },
|
||||
]);
|
||||
ctx.structures.findWithLines.mockResolvedValue({
|
||||
id: "struct-1",
|
||||
lines: [{ salaryComponent: activeComponent(), amount: null, rate: null }],
|
||||
});
|
||||
ctx.components.findActive.mockResolvedValue([activeComponent()]);
|
||||
ctx.employees.findByEmployeeId.mockImplementation(async (employeeId: string) => ({
|
||||
employeeId,
|
||||
employeeNumber: `E-${employeeId}`,
|
||||
isPensionEligible: true,
|
||||
salaryMode: "BANK",
|
||||
bankAccountNumber: "123456",
|
||||
}));
|
||||
|
||||
const resultsByBasic: Record<number, CalculationResult> = {
|
||||
10000: calcResult({
|
||||
grossPay: 10000,
|
||||
totalDeductions: 2535,
|
||||
netPay: 7465,
|
||||
incomeTax: 1835,
|
||||
pensionEmployee: 700,
|
||||
pensionEmployer: 1100,
|
||||
}),
|
||||
8000: calcResult({
|
||||
basicSalary: 8000,
|
||||
grossPay: 8000,
|
||||
totalDeductions: 2000,
|
||||
netPay: 6000,
|
||||
incomeTax: 1300,
|
||||
pensionEmployee: 560,
|
||||
pensionEmployer: 880,
|
||||
}),
|
||||
};
|
||||
ctx.calculator.calculate.mockImplementation(
|
||||
(input: { basicSalary: number }) => resultsByBasic[input.basicSalary],
|
||||
);
|
||||
|
||||
await ctx.service.calculate("run-1", actor({ userId: "actor-user" }));
|
||||
|
||||
// Delete-then-rebuild ordering.
|
||||
expect(ctx.manager.payslipRepo.delete).toHaveBeenCalledWith({ payrollRunId: "run-1" });
|
||||
expect(ctx.manager.payslipRepo.delete.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
ctx.manager.payslipRepo.save.mock.invocationCallOrder[0],
|
||||
);
|
||||
|
||||
expect(ctx.manager.payslipRepo.save).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.manager.lineRepo.save).toHaveBeenCalledTimes(2); // one line per employee's fixture result
|
||||
|
||||
const savedLine = ctx.manager.lineRepo.save.mock.calls[0][0];
|
||||
expect(savedLine.code).toBe(RESERVED_CODES.basic);
|
||||
expect(savedLine.amount).toBe("10000.00");
|
||||
|
||||
expect(ctx.manager.runRepo.update).toHaveBeenCalledWith(
|
||||
"run-1",
|
||||
expect.objectContaining({
|
||||
status: EPayrollRunStatus.CALCULATED,
|
||||
employeeCount: 2,
|
||||
totalGross: "18000.00",
|
||||
totalDeductions: "4535.00",
|
||||
totalNet: "13465.00",
|
||||
totalIncomeTax: "3135.00",
|
||||
totalPensionEmployee: "1260.00",
|
||||
totalPensionEmployer: "1980.00",
|
||||
calculatedAt: expect.any(Date),
|
||||
updatedBy: "actor-user",
|
||||
}),
|
||||
);
|
||||
|
||||
// The applied update is visible on the next read of the run.
|
||||
expect(ctx.getStored()?.status).toBe(EPayrollRunStatus.CALCULATED);
|
||||
});
|
||||
|
||||
it("degrades to basic-plus-statutory only when an employee has no salary structure", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.CALCULATED })); // recalculation is allowed while CALCULATED
|
||||
|
||||
ctx.salaries.findPayableOn.mockResolvedValue([
|
||||
{ employeeId: "emp-2", basicSalary: "8000", salaryStructureId: null },
|
||||
]);
|
||||
ctx.components.findActive.mockResolvedValue([
|
||||
activeComponent({ id: "c-basic", code: RESERVED_CODES.basic, componentType: EComponentType.BASIC }),
|
||||
activeComponent({
|
||||
id: "c-tax",
|
||||
code: RESERVED_CODES.incomeTax,
|
||||
componentType: EComponentType.DEDUCTION,
|
||||
}),
|
||||
activeComponent({
|
||||
id: "c-pe",
|
||||
code: RESERVED_CODES.pensionEmployee,
|
||||
componentType: EComponentType.DEDUCTION,
|
||||
}),
|
||||
activeComponent({
|
||||
id: "c-per",
|
||||
code: RESERVED_CODES.pensionEmployer,
|
||||
componentType: EComponentType.EMPLOYER_CONTRIBUTION,
|
||||
}),
|
||||
// Must be excluded: an ordinary allowance is neither BASIC nor one of the
|
||||
// three reserved statutory codes.
|
||||
activeComponent({ id: "c-transport", code: "TRANSPORT", componentType: EComponentType.ALLOWANCE }),
|
||||
]);
|
||||
ctx.employees.findByEmployeeId.mockResolvedValue({
|
||||
employeeId: "emp-2",
|
||||
employeeNumber: "E-emp-2",
|
||||
isPensionEligible: true,
|
||||
salaryMode: "BANK",
|
||||
bankAccountNumber: null,
|
||||
});
|
||||
ctx.calculator.calculate.mockReturnValue(calcResult({ basicSalary: 8000, grossPay: 8000 }));
|
||||
|
||||
await ctx.service.calculate("run-1", actor());
|
||||
|
||||
const passedInput = ctx.calculator.calculate.mock.calls[0][0];
|
||||
const codes = passedInput.components.map((c: { component: { code: string } }) => c.component.code);
|
||||
expect(codes).toEqual(
|
||||
expect.arrayContaining([
|
||||
RESERVED_CODES.basic,
|
||||
RESERVED_CODES.incomeTax,
|
||||
RESERVED_CODES.pensionEmployee,
|
||||
RESERVED_CODES.pensionEmployer,
|
||||
]),
|
||||
);
|
||||
expect(codes).not.toContain("TRANSPORT");
|
||||
expect(passedInput.components.every((c: { amount: unknown; rate: unknown }) => c.amount === null && c.rate === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("skips an employee with no HR profile — no payslip is written and employeeCount reflects only the priced employees", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.DRAFT }));
|
||||
ctx.salaries.findPayableOn.mockResolvedValue([
|
||||
{ employeeId: "emp-1", basicSalary: "10000", salaryStructureId: null },
|
||||
{ employeeId: "emp-ghost", basicSalary: "5000", salaryStructureId: null },
|
||||
]);
|
||||
ctx.components.findActive.mockResolvedValue([activeComponent()]);
|
||||
ctx.employees.findByEmployeeId.mockImplementation(async (employeeId: string) =>
|
||||
employeeId === "emp-1"
|
||||
? { employeeId, employeeNumber: "E-1", isPensionEligible: true, salaryMode: "BANK" }
|
||||
: null,
|
||||
);
|
||||
ctx.calculator.calculate.mockReturnValue(calcResult());
|
||||
|
||||
await ctx.service.calculate("run-1", actor());
|
||||
|
||||
expect(ctx.calculator.calculate).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.manager.payslipRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.manager.runRepo.update).toHaveBeenCalledWith(
|
||||
"run-1",
|
||||
expect.objectContaining({ employeeCount: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps only the first row per employee when findPayableOn returns overlapping history", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.DRAFT }));
|
||||
ctx.salaries.findPayableOn.mockResolvedValue([
|
||||
{ employeeId: "emp-1", basicSalary: "12000", salaryStructureId: null }, // newest — first in array
|
||||
{ employeeId: "emp-1", basicSalary: "9000", salaryStructureId: null }, // stale, must be ignored
|
||||
]);
|
||||
ctx.components.findActive.mockResolvedValue([activeComponent()]);
|
||||
ctx.employees.findByEmployeeId.mockResolvedValue({
|
||||
employeeId: "emp-1",
|
||||
employeeNumber: "E-1",
|
||||
isPensionEligible: true,
|
||||
salaryMode: "BANK",
|
||||
});
|
||||
ctx.calculator.calculate.mockReturnValue(calcResult());
|
||||
|
||||
await ctx.service.calculate("run-1", actor());
|
||||
|
||||
expect(ctx.calculator.calculate).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.calculator.calculate.mock.calls[0][0].basicSalary).toBe(12000);
|
||||
});
|
||||
|
||||
it("computes absenceDeductionRate = absentDays / periodWorkingDays (HALF_DAY counts as 0.5 absent)", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.DRAFT }));
|
||||
ctx.workingDays.countWorkingDays.mockResolvedValue(20);
|
||||
ctx.salaries.findPayableOn.mockResolvedValue([
|
||||
{ employeeId: "emp-1", basicSalary: "10000", salaryStructureId: null },
|
||||
]);
|
||||
ctx.components.findActive.mockResolvedValue([activeComponent()]);
|
||||
ctx.employees.findByEmployeeId.mockResolvedValue({
|
||||
employeeId: "emp-1",
|
||||
employeeNumber: "E-1",
|
||||
isPensionEligible: true,
|
||||
salaryMode: "BANK",
|
||||
});
|
||||
ctx.attendance.findRange.mockResolvedValue([
|
||||
{ status: "ABSENT" },
|
||||
{ status: "ABSENT" },
|
||||
{ status: "HALF_DAY" },
|
||||
{ status: "PRESENT" },
|
||||
{ status: "LATE" },
|
||||
]);
|
||||
ctx.calculator.calculate.mockReturnValue(calcResult());
|
||||
|
||||
await ctx.service.calculate("run-1", actor());
|
||||
|
||||
// absentDays = 2 + 0.5 = 2.5; rate = 2.5 / 20 = 0.125
|
||||
expect(ctx.calculator.calculate.mock.calls[0][0].absenceDeductionRate).toBeCloseTo(0.125);
|
||||
});
|
||||
|
||||
it("guards against division by zero — zero working days in the period degrades to no absence deduction", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.DRAFT }));
|
||||
ctx.workingDays.countWorkingDays.mockResolvedValue(0);
|
||||
ctx.salaries.findPayableOn.mockResolvedValue([
|
||||
{ employeeId: "emp-1", basicSalary: "10000", salaryStructureId: null },
|
||||
]);
|
||||
ctx.components.findActive.mockResolvedValue([activeComponent()]);
|
||||
ctx.employees.findByEmployeeId.mockResolvedValue({
|
||||
employeeId: "emp-1",
|
||||
employeeNumber: "E-1",
|
||||
isPensionEligible: true,
|
||||
salaryMode: "BANK",
|
||||
});
|
||||
ctx.attendance.findRange.mockResolvedValue([{ status: "ABSENT" }, { status: "ABSENT" }]);
|
||||
ctx.calculator.calculate.mockReturnValue(calcResult());
|
||||
|
||||
await ctx.service.calculate("run-1", actor());
|
||||
|
||||
expect(ctx.calculator.calculate.mock.calls[0][0].absenceDeductionRate).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayrollRunsService.approve", () => {
|
||||
it("signs off a CALCULATED, non-empty run, attributing it to the actor's employee id", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.CALCULATED, employeeCount: 3 }));
|
||||
|
||||
await ctx.service.approve("run-1", actor({ employeeId: "approver-1", userId: "u-1" }));
|
||||
|
||||
expect(ctx.runs.update).toHaveBeenCalledWith(
|
||||
"run-1",
|
||||
expect.objectContaining({
|
||||
status: EPayrollRunStatus.APPROVED,
|
||||
approvedByEmployeeId: "approver-1",
|
||||
approvedAt: expect.any(Date),
|
||||
updatedBy: "u-1",
|
||||
}),
|
||||
);
|
||||
expect(ctx.getStored()?.status).toBe(EPayrollRunStatus.APPROVED);
|
||||
});
|
||||
|
||||
it("refuses a run that is not CALCULATED", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.DRAFT, employeeCount: 3 }));
|
||||
|
||||
await expect(ctx.service.approve("run-1", actor())).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(ctx.runs.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an empty run — approving it would record that nobody was paid", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.CALCULATED, employeeCount: 0 }));
|
||||
|
||||
await expect(ctx.service.approve("run-1", actor())).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(ctx.runs.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the actor has no employeeId — approval must be attributable", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.CALCULATED, employeeCount: 2 }));
|
||||
|
||||
await expect(
|
||||
ctx.service.approve("run-1", actor({ employeeId: null })),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(ctx.runs.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to recalculate a run once it has been approved", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.CALCULATED, employeeCount: 1 }));
|
||||
|
||||
await ctx.service.approve("run-1", actor());
|
||||
expect(ctx.getStored()?.status).toBe(EPayrollRunStatus.APPROVED);
|
||||
|
||||
await expect(ctx.service.calculate("run-1", actor())).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
// No transaction should even have been opened for the refused recalculation attempt.
|
||||
expect(ctx.dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayrollRunsService.markPaid", () => {
|
||||
it("moves an APPROVED run to PAID with an explicit payment date", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.APPROVED }));
|
||||
|
||||
await ctx.service.markPaid("run-1", "2026-08-05", actor({ userId: "u-2" }));
|
||||
|
||||
expect(ctx.runs.update).toHaveBeenCalledWith("run-1", {
|
||||
status: EPayrollRunStatus.PAID,
|
||||
paymentDate: "2026-08-05",
|
||||
updatedBy: "u-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults the payment date to today when none is given", async () => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status: EPayrollRunStatus.APPROVED }));
|
||||
|
||||
await ctx.service.markPaid("run-1", undefined, actor());
|
||||
|
||||
const patch = ctx.runs.update.mock.calls[0][1];
|
||||
expect(patch.paymentDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it.each([EPayrollRunStatus.DRAFT, EPayrollRunStatus.CALCULATED, EPayrollRunStatus.PAID])(
|
||||
"refuses when the run is %s, not APPROVED",
|
||||
async (status) => {
|
||||
const ctx = build();
|
||||
ctx.use(run({ status }));
|
||||
|
||||
await expect(ctx.service.markPaid("run-1", undefined, actor())).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(ctx.runs.update).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("PayrollRunsService.payslipsForEmployee", () => {
|
||||
/**
|
||||
* A query-builder stand-in that actually filters a fixture set the way the
|
||||
* real SQL would — this is what proves a DRAFT/CALCULATED run's payslip row
|
||||
* stays invisible even though the row exists, not merely that the right
|
||||
* arguments were passed to `.andWhere`.
|
||||
*/
|
||||
function makeFilteringPayslipsRepo(
|
||||
rows: { employeeId: string; payrollRun: { status: EPayrollRunStatus; periodStart: string } }[],
|
||||
) {
|
||||
interface FilteringQueryBuilder {
|
||||
innerJoinAndSelect: jest.Mock;
|
||||
where: jest.Mock;
|
||||
andWhere: jest.Mock;
|
||||
orderBy: jest.Mock;
|
||||
getMany: jest.Mock;
|
||||
}
|
||||
|
||||
const createQueryBuilder = jest.fn(() => {
|
||||
let employeeId: string | undefined;
|
||||
let statuses: EPayrollRunStatus[] = [];
|
||||
const qb: FilteringQueryBuilder = {
|
||||
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn((_sql: string, params: Record<string, unknown>) => {
|
||||
employeeId = params.employeeId as string;
|
||||
return qb;
|
||||
}),
|
||||
andWhere: jest.fn((_sql: string, params: Record<string, unknown>) => {
|
||||
if (params.statuses) statuses = params.statuses as EPayrollRunStatus[];
|
||||
return qb;
|
||||
}),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(async () =>
|
||||
rows.filter(
|
||||
(r) => r.employeeId === employeeId && statuses.includes(r.payrollRun.status),
|
||||
),
|
||||
),
|
||||
};
|
||||
return qb;
|
||||
});
|
||||
return { createQueryBuilder };
|
||||
}
|
||||
|
||||
it("returns only payslips whose parent run is APPROVED or PAID — a DRAFT/CALCULATED row stays invisible", async () => {
|
||||
const rows = [
|
||||
{ employeeId: "emp-1", payrollRun: { status: EPayrollRunStatus.DRAFT, periodStart: "2026-05-01" } },
|
||||
{ employeeId: "emp-1", payrollRun: { status: EPayrollRunStatus.CALCULATED, periodStart: "2026-06-01" } },
|
||||
{ employeeId: "emp-1", payrollRun: { status: EPayrollRunStatus.APPROVED, periodStart: "2026-07-01" } },
|
||||
{ employeeId: "emp-1", payrollRun: { status: EPayrollRunStatus.PAID, periodStart: "2026-04-01" } },
|
||||
// A different employee's APPROVED payslip must never leak into emp-1's list.
|
||||
{ employeeId: "emp-2", payrollRun: { status: EPayrollRunStatus.APPROVED, periodStart: "2026-07-01" } },
|
||||
];
|
||||
const ctx = build();
|
||||
ctx.payslips.createQueryBuilder = makeFilteringPayslipsRepo(rows as never).createQueryBuilder;
|
||||
|
||||
const result = await ctx.service.payslipsForEmployee("emp-1");
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(
|
||||
result.every((r) =>
|
||||
[EPayrollRunStatus.APPROVED, EPayrollRunStatus.PAID].includes(
|
||||
(r as unknown as { payrollRun: { status: EPayrollRunStatus } }).payrollRun.status,
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.every((r) => (r as unknown as { employeeId: string }).employeeId === "emp-1")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns nothing for an employee whose only runs are still DRAFT or CALCULATED", async () => {
|
||||
const rows = [
|
||||
{ employeeId: "emp-3", payrollRun: { status: EPayrollRunStatus.DRAFT, periodStart: "2026-05-01" } },
|
||||
{ employeeId: "emp-3", payrollRun: { status: EPayrollRunStatus.CALCULATED, periodStart: "2026-06-01" } },
|
||||
];
|
||||
const ctx = build();
|
||||
ctx.payslips.createQueryBuilder = makeFilteringPayslipsRepo(rows as never).createQueryBuilder;
|
||||
|
||||
const result = await ctx.service.payslipsForEmployee("emp-3");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||
|
||||
import { RecruitmentService } from "./recruitment.service";
|
||||
import { ActorContext } from "../../employees/employees.service";
|
||||
import {
|
||||
Applicant,
|
||||
Application,
|
||||
EApplicationStage,
|
||||
EOfferStatus,
|
||||
EOpeningStatus,
|
||||
JobOffer,
|
||||
JobOpening,
|
||||
} from "../entities/recruitment.entity";
|
||||
|
||||
/** Tracks what a transaction wrote per-entity, keyed by the entity class name. */
|
||||
function makeManager() {
|
||||
const calls: {
|
||||
offerUpdates: { id: string; data: Record<string, unknown> }[];
|
||||
applicationUpdates: { id: string; data: Record<string, unknown> }[];
|
||||
openingUpdates: { id: string; data: Record<string, unknown> }[];
|
||||
} = { offerUpdates: [], applicationUpdates: [], openingUpdates: [] };
|
||||
|
||||
const manager = {
|
||||
getRepository: (entity: { name: string }) => {
|
||||
const name = entity.name;
|
||||
return {
|
||||
update: jest.fn(async (id: string, data: Record<string, unknown>) => {
|
||||
if (name === "JobOffer") calls.offerUpdates.push({ id, data });
|
||||
if (name === "Application") calls.applicationUpdates.push({ id, data });
|
||||
if (name === "JobOpening") calls.openingUpdates.push({ id, data });
|
||||
return undefined;
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return { manager, calls };
|
||||
}
|
||||
|
||||
const actorFixture = (over: Partial<ActorContext> = {}): ActorContext => ({
|
||||
employeeId: "staff-1",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
isSuperAdmin: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const applicationFixture = (over: Partial<Application> = {}): Application =>
|
||||
({
|
||||
id: "app-1",
|
||||
organizationId: "org-1",
|
||||
jobOpeningId: "opening-1",
|
||||
applicantId: "applicant-1",
|
||||
stage: EApplicationStage.APPLIED,
|
||||
appliedOn: "2026-01-01",
|
||||
...over,
|
||||
}) as Application;
|
||||
|
||||
const openingFixture = (over: Partial<JobOpening> = {}): JobOpening =>
|
||||
({
|
||||
id: "opening-1",
|
||||
organizationId: "org-1",
|
||||
reference: "VAC-1",
|
||||
title: { am: "", en: "Engineer" },
|
||||
positionId: "position-1",
|
||||
jobTitleId: "job-title-1",
|
||||
employmentType: "PERMANENT",
|
||||
openings: 1,
|
||||
filled: 0,
|
||||
status: EOpeningStatus.OPEN,
|
||||
...over,
|
||||
}) as JobOpening;
|
||||
|
||||
const applicantFixture = (over: Partial<Applicant> = {}): Applicant =>
|
||||
({
|
||||
id: "applicant-1",
|
||||
organizationId: "org-1",
|
||||
fullName: { am: "", en: "Jane Doe" },
|
||||
email: "jane@example.com",
|
||||
phoneNumber: "0911000000",
|
||||
source: "DIRECT",
|
||||
...over,
|
||||
}) as Applicant;
|
||||
|
||||
const offerFixture = (over: Partial<JobOffer> = {}): JobOffer =>
|
||||
({
|
||||
id: "offer-1",
|
||||
organizationId: "org-1",
|
||||
applicationId: "app-1",
|
||||
offeredBasicSalary: "12000.00",
|
||||
employmentType: "PERMANENT",
|
||||
proposedStartDate: "2026-03-01",
|
||||
status: EOfferStatus.ACCEPTED,
|
||||
hiredEmployeeId: null,
|
||||
...over,
|
||||
}) as JobOffer;
|
||||
|
||||
describe("RecruitmentService", () => {
|
||||
let dataSource: { transaction: jest.Mock };
|
||||
let openings: { findOne: jest.Mock };
|
||||
let applicants: { findOne: jest.Mock };
|
||||
let applications: { findOne: jest.Mock; update: jest.Mock };
|
||||
let interviews: { findOne: jest.Mock; count: jest.Mock };
|
||||
let offers: { findOne: jest.Mock };
|
||||
let iamDirectory: Record<string, jest.Mock>;
|
||||
let orgExplorer: { hireIntoPosition: jest.Mock };
|
||||
let payrollConfig: { assignSalary: jest.Mock };
|
||||
let service: RecruitmentService;
|
||||
let managerCalls: ReturnType<typeof makeManager>["calls"];
|
||||
let storedApplication: Application;
|
||||
|
||||
beforeEach(() => {
|
||||
const built = makeManager();
|
||||
managerCalls = built.calls;
|
||||
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (cb: (m: unknown) => unknown) => cb(built.manager)),
|
||||
};
|
||||
openings = { findOne: jest.fn().mockResolvedValue(openingFixture()) };
|
||||
applicants = { findOne: jest.fn().mockResolvedValue(applicantFixture()) };
|
||||
|
||||
storedApplication = applicationFixture();
|
||||
applications = {
|
||||
findOne: jest.fn().mockImplementation(async () => storedApplication),
|
||||
update: jest.fn().mockImplementation(async (_id: string, patch: Record<string, unknown>) => {
|
||||
storedApplication = { ...storedApplication, ...patch } as Application;
|
||||
}),
|
||||
};
|
||||
interviews = { findOne: jest.fn(), count: jest.fn().mockResolvedValue(0) };
|
||||
offers = { findOne: jest.fn().mockResolvedValue(offerFixture()) };
|
||||
iamDirectory = { findPosition: jest.fn() };
|
||||
orgExplorer = {
|
||||
hireIntoPosition: jest.fn().mockResolvedValue({
|
||||
employeeId: "emp-99",
|
||||
positionId: "position-1",
|
||||
profile: { id: "profile-99" },
|
||||
}),
|
||||
};
|
||||
payrollConfig = { assignSalary: jest.fn().mockResolvedValue({ id: "salary-1" }) };
|
||||
|
||||
service = new RecruitmentService(
|
||||
dataSource as never,
|
||||
openings as never,
|
||||
applicants as never,
|
||||
applications as never,
|
||||
interviews as never,
|
||||
offers as never,
|
||||
iamDirectory as never,
|
||||
orgExplorer as never,
|
||||
payrollConfig as never,
|
||||
);
|
||||
});
|
||||
|
||||
// ── moveStage — the STAGE_TRANSITIONS state machine ───────────────────────
|
||||
|
||||
describe("moveStage", () => {
|
||||
const validTransitions: [EApplicationStage, EApplicationStage][] = [
|
||||
[EApplicationStage.APPLIED, EApplicationStage.SCREENING],
|
||||
[EApplicationStage.SCREENING, EApplicationStage.SHORTLISTED],
|
||||
[EApplicationStage.SHORTLISTED, EApplicationStage.INTERVIEW],
|
||||
[EApplicationStage.SHORTLISTED, EApplicationStage.OFFER],
|
||||
[EApplicationStage.INTERVIEW, EApplicationStage.OFFER],
|
||||
[EApplicationStage.OFFER, EApplicationStage.HIRED],
|
||||
];
|
||||
|
||||
it.each(validTransitions)("allows %s → %s", async (from, to) => {
|
||||
storedApplication = applicationFixture({ stage: from });
|
||||
await expect(
|
||||
service.moveStage("app-1", to, {}, actorFixture()),
|
||||
).resolves.toMatchObject({ stage: to });
|
||||
});
|
||||
|
||||
const rejectableFrom: EApplicationStage[] = [
|
||||
EApplicationStage.APPLIED,
|
||||
EApplicationStage.SCREENING,
|
||||
EApplicationStage.SHORTLISTED,
|
||||
EApplicationStage.INTERVIEW,
|
||||
EApplicationStage.OFFER,
|
||||
];
|
||||
|
||||
it.each(rejectableFrom)("allows REJECTED from %s (with a reason)", async (from) => {
|
||||
storedApplication = applicationFixture({ stage: from });
|
||||
await expect(
|
||||
service.moveStage(
|
||||
"app-1",
|
||||
EApplicationStage.REJECTED,
|
||||
{ reason: "Not a fit" },
|
||||
actorFixture(),
|
||||
),
|
||||
).resolves.toMatchObject({ stage: EApplicationStage.REJECTED });
|
||||
});
|
||||
|
||||
it.each(rejectableFrom)("allows WITHDRAWN from %s", async (from) => {
|
||||
storedApplication = applicationFixture({ stage: from });
|
||||
await expect(
|
||||
service.moveStage("app-1", EApplicationStage.WITHDRAWN, {}, actorFixture()),
|
||||
).resolves.toMatchObject({ stage: EApplicationStage.WITHDRAWN });
|
||||
});
|
||||
|
||||
const invalidTransitions: [EApplicationStage, EApplicationStage][] = [
|
||||
[EApplicationStage.APPLIED, EApplicationStage.SHORTLISTED],
|
||||
[EApplicationStage.APPLIED, EApplicationStage.INTERVIEW],
|
||||
[EApplicationStage.APPLIED, EApplicationStage.OFFER],
|
||||
[EApplicationStage.APPLIED, EApplicationStage.HIRED],
|
||||
[EApplicationStage.SCREENING, EApplicationStage.INTERVIEW],
|
||||
[EApplicationStage.SCREENING, EApplicationStage.HIRED],
|
||||
[EApplicationStage.SHORTLISTED, EApplicationStage.SCREENING],
|
||||
[EApplicationStage.INTERVIEW, EApplicationStage.SCREENING],
|
||||
[EApplicationStage.OFFER, EApplicationStage.INTERVIEW],
|
||||
];
|
||||
|
||||
it.each(invalidTransitions)("refuses %s → %s", async (from, to) => {
|
||||
storedApplication = applicationFixture({ stage: from });
|
||||
await expect(
|
||||
service.moveStage("app-1", to, {}, actorFixture()),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it.each([EApplicationStage.HIRED, EApplicationStage.REJECTED, EApplicationStage.WITHDRAWN])(
|
||||
"refuses any move out of the terminal stage %s",
|
||||
async (from) => {
|
||||
storedApplication = applicationFixture({ stage: from });
|
||||
await expect(
|
||||
service.moveStage(
|
||||
"app-1",
|
||||
EApplicationStage.SCREENING,
|
||||
{},
|
||||
actorFixture(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
},
|
||||
);
|
||||
|
||||
it("requires a reason to move to REJECTED", async () => {
|
||||
storedApplication = applicationFixture({ stage: EApplicationStage.SCREENING });
|
||||
await expect(
|
||||
service.moveStage("app-1", EApplicationStage.REJECTED, {}, actorFixture()),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("records the rejection reason when one is given", async () => {
|
||||
storedApplication = applicationFixture({ stage: EApplicationStage.SCREENING });
|
||||
await service.moveStage(
|
||||
"app-1",
|
||||
EApplicationStage.REJECTED,
|
||||
{ reason: "Salary expectations too high" },
|
||||
actorFixture(),
|
||||
);
|
||||
expect(applications.update).toHaveBeenCalledWith(
|
||||
"app-1",
|
||||
expect.objectContaining({ rejectionReason: "Salary expectations too high" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── hireFromOffer ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("hireFromOffer", () => {
|
||||
const hireDto = { username: "jane.doe", email: "jane@example.com" };
|
||||
|
||||
beforeEach(() => {
|
||||
storedApplication = applicationFixture({ stage: EApplicationStage.OFFER });
|
||||
applications.findOne.mockImplementation(async () => ({
|
||||
...storedApplication,
|
||||
applicant: applicantFixture(),
|
||||
jobOpening: openingFixture(),
|
||||
}));
|
||||
});
|
||||
|
||||
it("only turns an ACCEPTED offer into a hire", async () => {
|
||||
offers.findOne.mockResolvedValue(offerFixture({ status: EOfferStatus.SENT }));
|
||||
|
||||
await expect(
|
||||
service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(orgExplorer.hireIntoPosition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a SECOND hire off the same offer with a 409 — the re-hire guard", async () => {
|
||||
offers.findOne.mockResolvedValue(
|
||||
offerFixture({ status: EOfferStatus.ACCEPTED, hiredEmployeeId: "emp-already-hired" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(orgExplorer.hireIntoPosition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates to OrgExplorerService.hireIntoPosition with the offer/opening/applicant details", async () => {
|
||||
await service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never);
|
||||
|
||||
expect(orgExplorer.hireIntoPosition).toHaveBeenCalledWith(
|
||||
"position-1",
|
||||
expect.objectContaining({
|
||||
name: { am: "", en: "Jane Doe" },
|
||||
username: "jane.doe",
|
||||
email: "jane@example.com",
|
||||
employmentType: "PERMANENT",
|
||||
hireDate: "2026-03-01",
|
||||
jobTitleId: "job-title-1",
|
||||
}),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("assigns salary AFTER and OUTSIDE the hire transaction, swallowing a failure without failing the hire", async () => {
|
||||
payrollConfig.assignSalary.mockRejectedValue(new Error("payroll unreachable"));
|
||||
|
||||
const result = await service.hireFromOffer(
|
||||
"offer-1",
|
||||
hireDto,
|
||||
actorFixture(),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
// The hire itself succeeded despite the salary assignment failing.
|
||||
expect(result.employeeId).toBe("emp-99");
|
||||
expect(payrollConfig.assignSalary).toHaveBeenCalledTimes(1);
|
||||
// The final bookkeeping transaction still ran to completion.
|
||||
expect(managerCalls.offerUpdates).toHaveLength(1);
|
||||
expect(managerCalls.offerUpdates[0].data.hiredEmployeeId).toBe("emp-99");
|
||||
});
|
||||
|
||||
it("updates offer, application and opening's filled count together in the final transaction", async () => {
|
||||
await service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never);
|
||||
|
||||
expect(managerCalls.offerUpdates).toEqual([
|
||||
{ id: "offer-1", data: expect.objectContaining({ hiredEmployeeId: "emp-99" }) },
|
||||
]);
|
||||
expect(managerCalls.applicationUpdates).toEqual([
|
||||
{ id: "app-1", data: expect.objectContaining({ stage: EApplicationStage.HIRED }) },
|
||||
]);
|
||||
expect(managerCalls.openingUpdates).toEqual([
|
||||
{
|
||||
id: "opening-1",
|
||||
data: expect.objectContaining({ filled: 1, status: EOpeningStatus.FILLED }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the opening OPEN when there are still unfilled seats", async () => {
|
||||
applications.findOne.mockImplementation(async () => ({
|
||||
...storedApplication,
|
||||
applicant: applicantFixture(),
|
||||
jobOpening: openingFixture({ openings: 3, filled: 0, status: EOpeningStatus.OPEN }),
|
||||
}));
|
||||
|
||||
await service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never);
|
||||
|
||||
expect(managerCalls.openingUpdates[0].data).toMatchObject({
|
||||
filled: 1,
|
||||
status: EOpeningStatus.OPEN,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ReportsService } from "./reports.service";
|
||||
import { ActorContext } from "../../employees/employees.service";
|
||||
|
||||
const actorFixture = (over: Partial<ActorContext> = {}): ActorContext => ({
|
||||
employeeId: null,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
isSuperAdmin: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("ReportsService.leaveLiability", () => {
|
||||
let dataSource: { query: jest.Mock };
|
||||
let service: ReportsService;
|
||||
|
||||
beforeEach(() => {
|
||||
dataSource = { query: jest.fn().mockResolvedValue([]) };
|
||||
service = new ReportsService(dataSource as never);
|
||||
});
|
||||
|
||||
it("values only leave types where is_paid=true AND max_carry_over_days>0 — excluding sick/bereavement/marriage/paternity-style entitlements", async () => {
|
||||
await service.leaveLiability(actorFixture(), "2026-06-30");
|
||||
|
||||
const [sql] = dataSource.query.mock.calls[0];
|
||||
expect(sql).toContain('t."is_paid" = true');
|
||||
expect(sql).toContain('t."max_carry_over_days" > 0');
|
||||
});
|
||||
|
||||
it("computes the daily rate from the current effective basic salary via LEFT JOIN LATERAL, as of the given date", async () => {
|
||||
await service.leaveLiability(actorFixture(), "2026-06-30");
|
||||
|
||||
const [sql] = dataSource.query.mock.calls[0];
|
||||
expect(sql).toContain("LEFT JOIN LATERAL");
|
||||
expect(sql).toContain('es."effective_from" <= $2::date');
|
||||
expect(sql).toContain(
|
||||
'(es."effective_to" IS NULL OR es."effective_to" >= $2::date)',
|
||||
);
|
||||
expect(sql).toContain('ORDER BY es."effective_from" DESC');
|
||||
expect(sql).toContain("/ 30.0");
|
||||
});
|
||||
|
||||
it("excludes TERMINATED/RETIRED employees", async () => {
|
||||
await service.leaveLiability(actorFixture(), "2026-06-30");
|
||||
|
||||
const [sql] = dataSource.query.mock.calls[0];
|
||||
expect(sql).toContain("NOT IN ('TERMINATED','RETIRED')");
|
||||
});
|
||||
|
||||
it("drops zero balances via HAVING", async () => {
|
||||
await service.leaveLiability(actorFixture(), "2026-06-30");
|
||||
|
||||
const [sql] = dataSource.query.mock.calls[0];
|
||||
expect(sql).toContain('HAVING COALESCE(SUM(l."days"), 0) > 0');
|
||||
});
|
||||
|
||||
it("passes [organizationId, asOf] as params when asOf is given", async () => {
|
||||
await service.leaveLiability(actorFixture({ organizationId: "org-1" }), "2026-06-30");
|
||||
|
||||
const [, params] = dataSource.query.mock.calls[0];
|
||||
expect(params).toEqual(["org-1", "2026-06-30"]);
|
||||
});
|
||||
|
||||
it("defaults asOf to today when omitted", async () => {
|
||||
await service.leaveLiability(actorFixture());
|
||||
|
||||
const [, params] = dataSource.query.mock.calls[0];
|
||||
expect(params[0]).toBe("org-1");
|
||||
expect(params[1]).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it("scopes by organization for a normal actor", async () => {
|
||||
await service.leaveLiability(actorFixture({ organizationId: "org-1", isSuperAdmin: false }));
|
||||
|
||||
const [, params] = dataSource.query.mock.calls[0];
|
||||
expect(params[0]).toBe("org-1");
|
||||
});
|
||||
|
||||
it("passes a null org scope for a super admin — no organization filter", async () => {
|
||||
await service.leaveLiability(actorFixture({ isSuperAdmin: true }));
|
||||
|
||||
const [, params] = dataSource.query.mock.calls[0];
|
||||
expect(params[0]).toBeNull();
|
||||
});
|
||||
|
||||
it("returns whatever the query resolves", async () => {
|
||||
const rows = [
|
||||
{
|
||||
employeeId: "emp-1",
|
||||
employeeNumber: "EMP-00001",
|
||||
leaveTypeCode: "ANNUAL",
|
||||
balanceDays: "12",
|
||||
dailyRate: "500.00",
|
||||
liability: "6000.00",
|
||||
},
|
||||
];
|
||||
dataSource.query.mockResolvedValue(rows);
|
||||
|
||||
const result = await service.leaveLiability(actorFixture(), "2026-06-30");
|
||||
expect(result).toEqual(rows);
|
||||
});
|
||||
});
|
||||
@@ -58,7 +58,7 @@ export class ReportsService {
|
||||
* two.
|
||||
*/
|
||||
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",
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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 })}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
416
apps/finance-api/src/modules/assets/assets.service.spec.ts
Normal file
416
apps/finance-api/src/modules/assets/assets.service.spec.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
import { BadRequestException, ConflictException } from "@nestjs/common";
|
||||
|
||||
import { AssetsService } from "./assets.service";
|
||||
import { DepreciationEntry, DepreciationRun, FixedAsset } from "./entities/fixed-asset.entity";
|
||||
import type { ActorContext } from "../../common/current-actor.util";
|
||||
|
||||
const actor: ActorContext = {
|
||||
employeeId: "emp-1",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
|
||||
/** A manager whose getRepository() dispatches to per-entity save/create/update spies. */
|
||||
function makeManager() {
|
||||
const savedRuns: unknown[] = [];
|
||||
const savedEntries: unknown[] = [];
|
||||
const assetUpdates: { id: string; patch: Record<string, unknown> }[] = [];
|
||||
|
||||
const runRepo = {
|
||||
create: (data: Record<string, unknown>) => ({ id: "run-1", ...data }),
|
||||
save: (data: Record<string, unknown>) => {
|
||||
savedRuns.push(data);
|
||||
return Promise.resolve(data);
|
||||
},
|
||||
};
|
||||
const entryRepo = {
|
||||
create: (data: Record<string, unknown>) => data,
|
||||
save: (rows: Record<string, unknown>[]) => {
|
||||
savedEntries.push(...rows);
|
||||
return Promise.resolve(rows);
|
||||
},
|
||||
};
|
||||
const assetRepo = {
|
||||
update: (id: string, patch: Record<string, unknown>) => {
|
||||
assetUpdates.push({ id, patch });
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
};
|
||||
|
||||
const manager = {
|
||||
getRepository: (entity: unknown) => {
|
||||
if (entity === DepreciationRun) return runRepo;
|
||||
if (entity === DepreciationEntry) return entryRepo;
|
||||
if (entity === FixedAsset) return assetRepo;
|
||||
throw new Error("unexpected repository requested");
|
||||
},
|
||||
};
|
||||
|
||||
return { manager, savedRuns, savedEntries, assetUpdates };
|
||||
}
|
||||
|
||||
describe("AssetsService.runDepreciation", () => {
|
||||
function build(opts: {
|
||||
period?: Record<string, unknown>;
|
||||
existingRun?: Record<string, unknown> | null;
|
||||
ledgerRows?: Record<string, unknown>[];
|
||||
depreciable?: Record<string, unknown>[];
|
||||
}) {
|
||||
const period = opts.period ?? {
|
||||
id: "period-1",
|
||||
status: "OPEN",
|
||||
endDate: "2026-01-31",
|
||||
name: { en: "Jan 2026" },
|
||||
fiscalYearId: "fy-2026",
|
||||
};
|
||||
const runs = {
|
||||
findForPeriod: jest.fn().mockResolvedValue(opts.existingRun ?? null),
|
||||
};
|
||||
const managerBundle = makeManager();
|
||||
const dataSource = {
|
||||
transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(managerBundle.manager)),
|
||||
};
|
||||
const journals = {
|
||||
createPosted: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: "je-1", entryNumber: "JE-2026-0001" }),
|
||||
};
|
||||
const assets = {
|
||||
ledgerAccumulated: jest.fn().mockResolvedValue(
|
||||
opts.ledgerRows ?? [
|
||||
{ accountId: "acc-accum", ledgerAccumulated: 0, registerAccumulated: 0 },
|
||||
],
|
||||
),
|
||||
findDepreciable: jest.fn().mockResolvedValue(
|
||||
opts.depreciable ?? [
|
||||
{
|
||||
id: "asset-1",
|
||||
assetCode: "AST-001",
|
||||
acquisitionCost: 1000,
|
||||
salvageValue: 0,
|
||||
usefulLifeMonths: 10,
|
||||
accumulatedDepreciation: 0,
|
||||
inServiceDate: "2026-01-01",
|
||||
depreciationMethod: "STRAIGHT_LINE",
|
||||
status: "ACTIVE",
|
||||
costCenterId: null,
|
||||
expenseAccountId: "acc-expense",
|
||||
accumulatedAccountId: "acc-accum",
|
||||
periodsCharged: 0,
|
||||
},
|
||||
],
|
||||
),
|
||||
};
|
||||
const periods = { findPeriod: jest.fn().mockResolvedValue(period) };
|
||||
|
||||
const service = new AssetsService(
|
||||
{} as never, // categories
|
||||
assets as never,
|
||||
runs as never,
|
||||
{} as never, // disposals
|
||||
{} as never, // accounts
|
||||
periods as never,
|
||||
journals as never,
|
||||
dataSource as never,
|
||||
);
|
||||
return { service, runs, assets, periods, journals, dataSource, ...managerBundle };
|
||||
}
|
||||
|
||||
it("charges depreciation, posts ONE journal entry, and advances the asset's running total", async () => {
|
||||
const { service, journals, assetUpdates } = build({});
|
||||
|
||||
const result = await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
|
||||
|
||||
expect(journals.createPosted).toHaveBeenCalledTimes(1);
|
||||
const [, dto] = journals.createPosted.mock.calls[0];
|
||||
expect(dto.lines).toHaveLength(2); // one debit (expense), one credit (accumulated)
|
||||
expect(result.assetCount).toBe(1);
|
||||
expect(result.total).toBe(100); // 1000 base / 10 months
|
||||
expect(assetUpdates).toEqual([
|
||||
{ id: "asset-1", patch: { accumulatedDepreciation: 100 } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("summarizes multiple assets sharing the same (expense account, cost center) into ONE debit line, not one per asset", async () => {
|
||||
const { service, journals } = build({
|
||||
depreciable: [
|
||||
{
|
||||
id: "asset-1", assetCode: "AST-001", acquisitionCost: 1000, salvageValue: 0,
|
||||
usefulLifeMonths: 10, accumulatedDepreciation: 0, inServiceDate: "2026-01-01",
|
||||
depreciationMethod: "STRAIGHT_LINE", status: "ACTIVE", costCenterId: null,
|
||||
expenseAccountId: "acc-expense", accumulatedAccountId: "acc-accum", periodsCharged: 0,
|
||||
},
|
||||
{
|
||||
id: "asset-2", assetCode: "AST-002", acquisitionCost: 2000, salvageValue: 0,
|
||||
usefulLifeMonths: 10, accumulatedDepreciation: 0, inServiceDate: "2026-01-01",
|
||||
depreciationMethod: "STRAIGHT_LINE", status: "ACTIVE", costCenterId: null,
|
||||
expenseAccountId: "acc-expense", accumulatedAccountId: "acc-accum", periodsCharged: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
|
||||
|
||||
const [, dto] = journals.createPosted.mock.calls[0];
|
||||
expect(dto.lines).toHaveLength(2); // still just debit + credit, summed
|
||||
const debitLine = dto.lines.find((l: { debit?: number }) => l.debit);
|
||||
const creditLine = dto.lines.find((l: { credit?: number }) => l.credit);
|
||||
expect(debitLine.debit).toBe(300); // 100 + 200
|
||||
expect(creditLine.credit).toBe(300);
|
||||
});
|
||||
|
||||
it("flips a fully-depreciated asset's status to FULLY_DEPRECIATED", async () => {
|
||||
const { service, assetUpdates } = build({
|
||||
depreciable: [
|
||||
{
|
||||
id: "asset-1", assetCode: "AST-001", acquisitionCost: 1000, salvageValue: 0,
|
||||
usefulLifeMonths: 10, accumulatedDepreciation: 900, inServiceDate: "2026-01-01",
|
||||
depreciationMethod: "STRAIGHT_LINE", status: "ACTIVE", costCenterId: null,
|
||||
expenseAccountId: "acc-expense", accumulatedAccountId: "acc-accum", periodsCharged: 9,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
|
||||
|
||||
expect(assetUpdates[0].patch).toMatchObject({
|
||||
accumulatedDepreciation: 1000,
|
||||
status: "FULLY_DEPRECIATED",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a second run for the same period (idempotency)", async () => {
|
||||
const { service, journals } = build({
|
||||
existingRun: { id: "run-existing" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.runDepreciation(actor, { fiscalPeriodId: "period-1" }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to charge a non-OPEN period", async () => {
|
||||
const { service, journals } = build({
|
||||
period: { id: "period-1", status: "CLOSED", endDate: "2026-01-31", name: { en: "Jan" }, fiscalYearId: "fy" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.runDepreciation(actor, { fiscalPeriodId: "period-1" }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the asset register disagrees with the ledger by 0.005 or more", async () => {
|
||||
const { service, journals } = build({
|
||||
ledgerRows: [
|
||||
{ accountId: "acc-accum", ledgerAccumulated: 100, registerAccumulated: 105 },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.runDepreciation(actor, { fiscalPeriodId: "period-1" }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tolerates sub-cent drift between register and ledger (< 0.005)", async () => {
|
||||
const { service, journals } = build({
|
||||
ledgerRows: [
|
||||
{ accountId: "acc-accum", ledgerAccumulated: 100, registerAccumulated: 100.003 },
|
||||
],
|
||||
});
|
||||
|
||||
await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
|
||||
expect(journals.createPosted).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when nothing is due this period (every asset skipped)", async () => {
|
||||
const { service, journals } = build({
|
||||
depreciable: [
|
||||
{
|
||||
id: "asset-1", assetCode: "AST-001", acquisitionCost: 1000, salvageValue: 0,
|
||||
usefulLifeMonths: 10, accumulatedDepreciation: 1000, inServiceDate: "2026-01-01",
|
||||
depreciationMethod: "STRAIGHT_LINE", status: "ACTIVE", costCenterId: null,
|
||||
expenseAccountId: "acc-expense", accumulatedAccountId: "acc-accum", periodsCharged: 10,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.runDepreciation(actor, { fiscalPeriodId: "period-1" }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the transaction manager through to journals.createPosted, so the entry, run, entries and asset updates all commit together", async () => {
|
||||
const { service, journals, manager } = build({});
|
||||
|
||||
await service.runDepreciation(actor, { fiscalPeriodId: "period-1" });
|
||||
|
||||
const [, , passedManager] = journals.createPosted.mock.calls[0];
|
||||
expect(passedManager).toBe(manager);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AssetsService.dispose", () => {
|
||||
function fixedAsset(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "asset-1",
|
||||
organizationId: "org-1",
|
||||
assetCode: "AST-001",
|
||||
name: "Forklift",
|
||||
assetCategoryId: "cat-1",
|
||||
acquisitionCost: 1000,
|
||||
salvageValue: 0,
|
||||
accumulatedDepreciation: 700,
|
||||
inServiceDate: "2026-01-01",
|
||||
depreciationMethod: "STRAIGHT_LINE",
|
||||
status: "ACTIVE",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function build(opts: {
|
||||
asset?: Record<string, unknown>;
|
||||
category?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const assets = {
|
||||
findById: jest.fn().mockResolvedValue(opts.asset ?? fixedAsset()),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const categories = {
|
||||
findById: jest.fn().mockResolvedValue(
|
||||
opts.category ?? {
|
||||
id: "cat-1",
|
||||
assetAccountId: "acc-asset",
|
||||
accumulatedAccountId: "acc-accum",
|
||||
expenseAccountId: "acc-expense",
|
||||
},
|
||||
),
|
||||
};
|
||||
const accounts = {
|
||||
assertPostable: jest.fn().mockResolvedValue({ id: "acc-cash", code: "1110" }),
|
||||
findByCode: jest.fn().mockImplementation((_actor, code: string) =>
|
||||
Promise.resolve({ id: `acc-${code}`, code }),
|
||||
),
|
||||
};
|
||||
const journals = {
|
||||
createPosted: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: "je-1", entryNumber: "JE-2026-0002" }),
|
||||
};
|
||||
const disposals = {
|
||||
create: jest.fn().mockImplementation((data) => Promise.resolve({ id: "disposal-1", ...data })),
|
||||
};
|
||||
|
||||
const service = new AssetsService(
|
||||
categories as never,
|
||||
assets as never,
|
||||
{} as never, // runs
|
||||
disposals as never,
|
||||
accounts as never,
|
||||
{} as never, // periods
|
||||
journals as never,
|
||||
{} as never, // dataSource
|
||||
);
|
||||
return { service, assets, categories, accounts, journals, disposals };
|
||||
}
|
||||
|
||||
it("computes gain/loss as a plug and posts the disposal (gain case: proceeds > NBV)", async () => {
|
||||
const { service, journals, assets } = build({});
|
||||
// cost 1000, accumulated 700 -> NBV 300; proceeds 450 -> gain 150
|
||||
|
||||
const result = await service.dispose(actor, "asset-1", {
|
||||
disposalDate: "2026-06-01",
|
||||
disposalType: "SALE",
|
||||
proceeds: 450,
|
||||
proceedsAccountId: "acc-cash",
|
||||
});
|
||||
|
||||
expect(result.netBookValue).toBe(300);
|
||||
expect(result.gainLoss).toBe(150);
|
||||
const [, dto] = journals.createPosted.mock.calls[0];
|
||||
const creditGain = dto.lines.find((l: { accountId: string }) => l.accountId === "acc-4910");
|
||||
expect(creditGain).toBeDefined();
|
||||
expect(creditGain.credit).toBe(150);
|
||||
expect(assets.update).toHaveBeenCalledWith("asset-1", { status: "DISPOSED" });
|
||||
});
|
||||
|
||||
it("computes a loss and books it to the loss account (proceeds < NBV)", async () => {
|
||||
const { service, journals } = build({});
|
||||
// NBV 300; proceeds 100 -> loss 200
|
||||
|
||||
const result = await service.dispose(actor, "asset-1", {
|
||||
disposalDate: "2026-06-01",
|
||||
disposalType: "SALE",
|
||||
proceeds: 100,
|
||||
proceedsAccountId: "acc-cash",
|
||||
});
|
||||
|
||||
expect(result.gainLoss).toBe(-200);
|
||||
const [, dto] = journals.createPosted.mock.calls[0];
|
||||
const debitLoss = dto.lines.find((l: { accountId: string }) => l.accountId === "acc-5900");
|
||||
expect(debitLoss).toBeDefined();
|
||||
expect(debitLoss.debit).toBe(200);
|
||||
});
|
||||
|
||||
it("sets status to WRITTEN_OFF (not DISPOSED) for a write-off", async () => {
|
||||
const { service, assets } = build({});
|
||||
|
||||
await service.dispose(actor, "asset-1", {
|
||||
disposalDate: "2026-06-01",
|
||||
disposalType: "WRITE_OFF",
|
||||
proceeds: 0,
|
||||
});
|
||||
|
||||
expect(assets.update).toHaveBeenCalledWith("asset-1", { status: "WRITTEN_OFF" });
|
||||
});
|
||||
|
||||
it("refuses to dispose an asset that is already DISPOSED", async () => {
|
||||
const { service } = build({ asset: fixedAsset({ status: "DISPOSED" }) });
|
||||
|
||||
await expect(
|
||||
service.dispose(actor, "asset-1", { disposalDate: "2026-06-01", disposalType: "SALE", proceeds: 0 }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("refuses to dispose an asset that is already WRITTEN_OFF", async () => {
|
||||
const { service } = build({ asset: fixedAsset({ status: "WRITTEN_OFF" }) });
|
||||
|
||||
await expect(
|
||||
service.dispose(actor, "asset-1", { disposalDate: "2026-06-01", disposalType: "SALE", proceeds: 0 }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it("refuses a disposal date before the asset entered service", async () => {
|
||||
const { service } = build({ asset: fixedAsset({ inServiceDate: "2026-06-01" }) });
|
||||
|
||||
await expect(
|
||||
service.dispose(actor, "asset-1", { disposalDate: "2026-01-01", disposalType: "SALE", proceeds: 0 }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("requires a proceedsAccountId whenever proceeds > 0", async () => {
|
||||
const { service } = build({});
|
||||
|
||||
await expect(
|
||||
service.dispose(actor, "asset-1", { disposalDate: "2026-06-01", disposalType: "SALE", proceeds: 450 }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it("does not require a proceedsAccountId when proceeds is zero", async () => {
|
||||
const { service, journals } = build({});
|
||||
|
||||
await service.dispose(actor, "asset-1", {
|
||||
disposalDate: "2026-06-01",
|
||||
disposalType: "WRITE_OFF",
|
||||
proceeds: 0,
|
||||
});
|
||||
|
||||
expect(journals.createPosted).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
depreciableBase,
|
||||
depreciationFor,
|
||||
depreciationSchedule,
|
||||
disposalResult,
|
||||
netBookValue,
|
||||
type DepreciableAsset,
|
||||
} from "./depreciation.calculator";
|
||||
|
||||
function asset(overrides: Partial<DepreciableAsset> = {}): DepreciableAsset {
|
||||
return {
|
||||
acquisitionCost: 1000,
|
||||
salvageValue: 0,
|
||||
usefulLifeMonths: 3,
|
||||
accumulatedDepreciation: 0,
|
||||
inServiceDate: "2026-01-01",
|
||||
depreciationMethod: "STRAIGHT_LINE",
|
||||
status: "ACTIVE",
|
||||
periodsCharged: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("depreciationFor — the cumulative-target method", () => {
|
||||
it("guards against the naive fixed-monthly bug: 1000 over 3 months gives 333.33/333.34/333.33, not 333.33 x3 (which would lose a cent)", () => {
|
||||
let a = asset();
|
||||
let periodEnd = "2026-01-31";
|
||||
|
||||
const c1 = depreciationFor(a, periodEnd);
|
||||
expect(c1.amount).toBe(333.33);
|
||||
a = { ...a, accumulatedDepreciation: c1.accumulatedAfter, periodsCharged: 1 };
|
||||
|
||||
periodEnd = "2026-02-28";
|
||||
const c2 = depreciationFor(a, periodEnd);
|
||||
expect(c2.amount).toBe(333.34); // the rounding remainder lands here, not lost
|
||||
a = { ...a, accumulatedDepreciation: c2.accumulatedAfter, periodsCharged: 2 };
|
||||
|
||||
periodEnd = "2026-03-31";
|
||||
const c3 = depreciationFor(a, periodEnd);
|
||||
expect(c3.amount).toBe(333.33);
|
||||
a = { ...a, accumulatedDepreciation: c3.accumulatedAfter, periodsCharged: 3 };
|
||||
|
||||
expect(roundSum([c1.amount, c2.amount, c3.amount])).toBe(1000);
|
||||
expect(a.accumulatedDepreciation).toBe(1000);
|
||||
expect(c3.fullyDepreciated).toBe(true);
|
||||
});
|
||||
|
||||
it("a full life's schedule sums EXACTLY to the depreciable base, for an odd (non-dividing) life/cost combination", () => {
|
||||
const a = asset({ acquisitionCost: 10000, salvageValue: 500, usefulLifeMonths: 7 });
|
||||
const base = depreciableBase(a);
|
||||
expect(base).toBe(9500);
|
||||
|
||||
const schedule = depreciationSchedule(a);
|
||||
expect(schedule).toHaveLength(7);
|
||||
const total = roundSum(schedule.map((s) => s.amount));
|
||||
expect(total).toBe(base);
|
||||
// Monotonically increasing accumulated total, ending exactly at the base.
|
||||
expect(schedule[schedule.length - 1].accumulated).toBe(base);
|
||||
expect(schedule[schedule.length - 1].netBookValue).toBe(
|
||||
Math.round((a.acquisitionCost - base) * 100) / 100,
|
||||
);
|
||||
});
|
||||
|
||||
it("charges nothing and refuses (skipReason) for a non-STRAIGHT_LINE method", () => {
|
||||
const a = asset({ depreciationMethod: "DECLINING_BALANCE" });
|
||||
const charge = depreciationFor(a, "2026-01-31");
|
||||
expect(charge.amount).toBe(0);
|
||||
expect(charge.skipReason).toMatch(/not implemented/);
|
||||
});
|
||||
|
||||
it("charges nothing for a DISPOSED asset", () => {
|
||||
const a = asset({ status: "DISPOSED" });
|
||||
const charge = depreciationFor(a, "2026-01-31");
|
||||
expect(charge.amount).toBe(0);
|
||||
expect(charge.skipReason).toBe("asset is DISPOSED");
|
||||
});
|
||||
|
||||
it("charges nothing for a WRITTEN_OFF asset", () => {
|
||||
const a = asset({ status: "WRITTEN_OFF" });
|
||||
const charge = depreciationFor(a, "2026-01-31");
|
||||
expect(charge.amount).toBe(0);
|
||||
expect(charge.skipReason).toBe("asset is WRITTEN_OFF");
|
||||
});
|
||||
|
||||
it("charges nothing for an asset not yet in service by the period end", () => {
|
||||
const a = asset({ inServiceDate: "2026-05-01" });
|
||||
const charge = depreciationFor(a, "2026-01-31");
|
||||
expect(charge.amount).toBe(0);
|
||||
expect(charge.skipReason).toMatch(/not in service until/);
|
||||
});
|
||||
|
||||
it("charges nothing once the asset is already fully depreciated", () => {
|
||||
const a = asset({ accumulatedDepreciation: 1000, periodsCharged: 3 });
|
||||
const charge = depreciationFor(a, "2026-04-30");
|
||||
expect(charge.amount).toBe(0);
|
||||
expect(charge.fullyDepreciated).toBe(true);
|
||||
expect(charge.skipReason).toBe("fully depreciated");
|
||||
});
|
||||
|
||||
it("caps periodsAfter at usefulLifeMonths even if periodsCharged somehow exceeds it, so it never charges past the base", () => {
|
||||
const a = asset({
|
||||
accumulatedDepreciation: 999.99,
|
||||
periodsCharged: 3, // already at life
|
||||
});
|
||||
const charge = depreciationFor(a, "2026-04-30");
|
||||
// remaining = 1000 - 999.99 = 0.01, target capped at base (1000)
|
||||
expect(charge.amount).toBe(0.01);
|
||||
expect(charge.accumulatedAfter).toBe(1000);
|
||||
expect(charge.fullyDepreciated).toBe(true);
|
||||
});
|
||||
|
||||
it("first-month charge for an asset entering service exactly on the period end (full-month convention)", () => {
|
||||
const a = asset({ inServiceDate: "2026-01-31" });
|
||||
const charge = depreciationFor(a, "2026-01-31");
|
||||
expect(charge.amount).toBe(333.33);
|
||||
});
|
||||
});
|
||||
|
||||
describe("depreciationSchedule", () => {
|
||||
it("stops emitting rows once nothing further is due (no trailing zero-amount rows)", () => {
|
||||
const a = asset({ usefulLifeMonths: 3 });
|
||||
const schedule = depreciationSchedule(a);
|
||||
expect(schedule.every((s) => s.amount > 0)).toBe(true);
|
||||
expect(schedule).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("netBookValue / depreciableBase", () => {
|
||||
it("netBookValue is cost minus accumulated depreciation", () => {
|
||||
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 400 });
|
||||
expect(netBookValue(a)).toBe(600);
|
||||
});
|
||||
|
||||
it("depreciableBase is cost minus salvage value, independent of what has accumulated", () => {
|
||||
const a = asset({ acquisitionCost: 1000, salvageValue: 200, accumulatedDepreciation: 400 });
|
||||
expect(depreciableBase(a)).toBe(800);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposalResult — gain/loss is a computed plug", () => {
|
||||
it("is a gain when proceeds exceed net book value", () => {
|
||||
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 700 }); // nbv 300
|
||||
const result = disposalResult(a, 450);
|
||||
expect(result.netBookValue).toBe(300);
|
||||
expect(result.gainLoss).toBe(150);
|
||||
});
|
||||
|
||||
it("is a loss when proceeds are below net book value", () => {
|
||||
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 700 }); // nbv 300
|
||||
const result = disposalResult(a, 100);
|
||||
expect(result.gainLoss).toBe(-200);
|
||||
});
|
||||
|
||||
it("a scrap with zero proceeds is a loss equal to the remaining net book value", () => {
|
||||
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 700 }); // nbv 300
|
||||
const result = disposalResult(a, 0);
|
||||
expect(result.gainLoss).toBe(-300);
|
||||
});
|
||||
|
||||
it("proceeds exactly equal to net book value produce neither gain nor loss", () => {
|
||||
const a = asset({ acquisitionCost: 1000, accumulatedDepreciation: 700 }); // nbv 300
|
||||
const result = disposalResult(a, 300);
|
||||
expect(result.gainLoss).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/** Sums an array of already-rounded money amounts and rounds once, at the end. */
|
||||
function roundSum(amounts: number[]): number {
|
||||
return Math.round(amounts.reduce((s, a) => s + a, 0) * 100) / 100;
|
||||
}
|
||||
140
apps/finance-api/src/modules/budgeting/budgeting.service.spec.ts
Normal file
140
apps/finance-api/src/modules/budgeting/budgeting.service.spec.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { BudgetingService } from "./budgeting.service";
|
||||
import type { ActorContext } from "../../common/current-actor.util";
|
||||
|
||||
/**
|
||||
* `BudgetingService.approveBudget` — only DRAFT→APPROVED, refuses an empty
|
||||
* budget, and enforces at most one APPROVED budget per fiscal year (also
|
||||
* backed by a DB unique index, but the service-level guard is what's under
|
||||
* test here).
|
||||
*/
|
||||
describe("BudgetingService.approveBudget", () => {
|
||||
const actor: ActorContext = {
|
||||
employeeId: "emp-1",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
|
||||
function draftBudget(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "budget-1",
|
||||
organizationId: "org-1",
|
||||
fiscalYearId: "fy-2026",
|
||||
name: "FY2026 Budget",
|
||||
status: "DRAFT",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function build(opts: {
|
||||
budget?: Record<string, unknown> | null;
|
||||
lines?: unknown[];
|
||||
approvedForYear?: Record<string, unknown> | null;
|
||||
}) {
|
||||
// `"budget" in opts` distinguishes "not provided" (use the default draft)
|
||||
// from an explicit `null` (simulate a missing/foreign-org row) — `??`
|
||||
// would treat both the same and mask the not-found case.
|
||||
let current: Record<string, unknown> | null =
|
||||
"budget" in opts ? (opts.budget as Record<string, unknown> | null) : draftBudget();
|
||||
const budgets = {
|
||||
findById: jest.fn().mockImplementation(() => Promise.resolve(current)),
|
||||
findApprovedForYear: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.approvedForYear ?? null),
|
||||
update: jest.fn().mockImplementation((_id, patch) => {
|
||||
current = { ...(current as Record<string, unknown>), ...patch };
|
||||
return Promise.resolve(current);
|
||||
}),
|
||||
};
|
||||
const budgetLines = {
|
||||
findByBudget: jest.fn().mockResolvedValue(opts.lines ?? [{ id: "line-1" }]),
|
||||
};
|
||||
const service = new BudgetingService(
|
||||
{} as never, // costCenters
|
||||
budgets as never,
|
||||
budgetLines as never,
|
||||
{} as never, // accounts
|
||||
{} as never, // periods
|
||||
{} as never, // dataSource
|
||||
);
|
||||
return { service, budgets, budgetLines };
|
||||
}
|
||||
|
||||
it("approves a DRAFT budget with lines and no prior approval for the year", async () => {
|
||||
const { service, budgets } = build({});
|
||||
|
||||
const result = await service.approveBudget(actor, "budget-1");
|
||||
|
||||
expect(budgets.update).toHaveBeenCalledWith(
|
||||
"budget-1",
|
||||
expect.objectContaining({
|
||||
status: "APPROVED",
|
||||
approvedBy: "emp-1",
|
||||
approvedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe("APPROVED");
|
||||
});
|
||||
|
||||
it("refuses to approve a budget that has no lines", async () => {
|
||||
const { service, budgets } = build({ lines: [] });
|
||||
|
||||
await expect(service.approveBudget(actor, "budget-1")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(budgets.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a second APPROVED budget for the same fiscal year", async () => {
|
||||
const { service, budgets } = build({
|
||||
approvedForYear: draftBudget({ id: "budget-existing", status: "APPROVED", name: "Already approved" }),
|
||||
});
|
||||
|
||||
await expect(service.approveBudget(actor, "budget-1")).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
expect(budgets.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to approve a budget that is not DRAFT (e.g. already APPROVED)", async () => {
|
||||
const { service, budgets } = build({
|
||||
budget: draftBudget({ status: "APPROVED" }),
|
||||
});
|
||||
|
||||
await expect(service.approveBudget(actor, "budget-1")).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(budgets.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a budget that does not exist", async () => {
|
||||
const { service } = build({ budget: null });
|
||||
|
||||
await expect(service.approveBudget(actor, "missing")).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it("hides a budget belonging to another organization from a non-super-admin (404, not 403)", async () => {
|
||||
const { service } = build({
|
||||
budget: draftBudget({ organizationId: "org-OTHER" }),
|
||||
});
|
||||
|
||||
await expect(service.approveBudget(actor, "budget-1")).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a super admin approve a budget outside their own organization", async () => {
|
||||
const superAdmin: ActorContext = { ...actor, isSuperAdmin: true, organizationId: "" };
|
||||
const { service, budgets } = build({
|
||||
budget: draftBudget({ organizationId: "org-OTHER" }),
|
||||
});
|
||||
|
||||
await service.approveBudget(superAdmin, "budget-1");
|
||||
|
||||
expect(budgets.update).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
536
apps/finance-api/src/modules/cutover/cutover.service.spec.ts
Normal file
536
apps/finance-api/src/modules/cutover/cutover.service.spec.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import { CutoverService } from "./cutover.service";
|
||||
import type { ActorContext } from "../../common/current-actor.util";
|
||||
|
||||
/**
|
||||
* All fixture values below are synthetic — invented account codes
|
||||
* (`TEST-...`), round test amounts, and a made-up organization id. None of
|
||||
* this resembles any real organization's chart of accounts or balances; the
|
||||
* plan this suite implements requires cutover testing to use synthetic data
|
||||
* only.
|
||||
*/
|
||||
|
||||
const ORG_ID = "org-synthetic-1";
|
||||
|
||||
function actor(overrides: Partial<ActorContext> = {}): ActorContext {
|
||||
return {
|
||||
employeeId: "emp-1",
|
||||
userId: "user-1",
|
||||
organizationId: ORG_ID,
|
||||
isSuperAdmin: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSettingsRepo(row: Record<string, unknown> | null) {
|
||||
return {
|
||||
findOne: jest.fn().mockResolvedValue(row),
|
||||
save: jest.fn(async (r: Record<string, unknown>) => ({ id: "settings-1", ...r })),
|
||||
create: jest.fn((r: Record<string, unknown>) => r),
|
||||
};
|
||||
}
|
||||
|
||||
function makeAccounts() {
|
||||
return { findByCode: jest.fn() };
|
||||
}
|
||||
|
||||
function makeJournals() {
|
||||
return { create: jest.fn(), post: jest.fn() };
|
||||
}
|
||||
|
||||
function makeDataSource(queryImpl: (...args: unknown[]) => unknown) {
|
||||
return { query: jest.fn(queryImpl) };
|
||||
}
|
||||
|
||||
describe("CutoverService.readiness", () => {
|
||||
let settings: ReturnType<typeof makeSettingsRepo>;
|
||||
let accounts: ReturnType<typeof makeAccounts>;
|
||||
let journals: ReturnType<typeof makeJournals>;
|
||||
let dataSource: { query: jest.Mock };
|
||||
let service: CutoverService;
|
||||
|
||||
/**
|
||||
* The service issues, in order: suspense query, [pre-cutover query if a date
|
||||
* is set], asset-register query, [migrated-assets query if assets exist].
|
||||
* Each test configures a queue of responses consumed in that call order.
|
||||
*/
|
||||
function queueQueries(responses: unknown[]) {
|
||||
let i = 0;
|
||||
dataSource = makeDataSource(() => Promise.resolve(responses[i++] ?? []));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
accounts = makeAccounts();
|
||||
journals = makeJournals();
|
||||
});
|
||||
|
||||
function build(settingsRow: Record<string, unknown> | null) {
|
||||
settings = makeSettingsRepo(settingsRow);
|
||||
service = new CutoverService(
|
||||
settings as never,
|
||||
accounts as never,
|
||||
journals as never,
|
||||
dataSource as never,
|
||||
);
|
||||
}
|
||||
|
||||
it("PENDING on the date check when no cutover date is set, and skips the pre-cutover check entirely", async () => {
|
||||
queueQueries([
|
||||
[{ balance: "0", lines: "0" }], // suspense: nothing posted yet
|
||||
[{ register: "0", assets: "0" }], // asset register: empty
|
||||
]);
|
||||
build(null);
|
||||
|
||||
const result = await service.readiness(actor());
|
||||
|
||||
const dateCheck = result.checks.find((c) => c.key === "cutover_date")!;
|
||||
expect(dateCheck.status).toBe("PENDING");
|
||||
expect(result.checks.find((c) => c.key === "no_pre_cutover")).toBeUndefined();
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
|
||||
it("PASS on the date check once a cutover date is set", async () => {
|
||||
queueQueries([
|
||||
[{ balance: "0", lines: "0" }],
|
||||
[{ n: "0", first: null }],
|
||||
[{ register: "0", assets: "0" }],
|
||||
]);
|
||||
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||
|
||||
const result = await service.readiness(actor());
|
||||
|
||||
const dateCheck = result.checks.find((c) => c.key === "cutover_date")!;
|
||||
expect(dateCheck.status).toBe("PASS");
|
||||
expect(dateCheck.detail).toContain("2026-07-08");
|
||||
});
|
||||
|
||||
// ── Suspense: DRAFT-only vs posted-nonzero ──────────────────────────────
|
||||
|
||||
it("suspense reads PENDING when only DRAFT (unposted) lines exist — not FAIL", async () => {
|
||||
// lines === 0 because the posted-only EXISTS filter excludes the DRAFT
|
||||
// batch's lines entirely (the LEFT JOIN keeps the account row, the filter
|
||||
// nulls only the entry side).
|
||||
queueQueries([
|
||||
[{ balance: "0", lines: "0" }],
|
||||
[{ n: "0", first: null }],
|
||||
[{ register: "0", assets: "0" }],
|
||||
]);
|
||||
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||
|
||||
const result = await service.readiness(actor());
|
||||
|
||||
const suspense = result.checks.find((c) => c.key === "suspense")!;
|
||||
expect(suspense.status).toBe("PENDING");
|
||||
expect(suspense.detail).toMatch(/nothing has been posted/i);
|
||||
});
|
||||
|
||||
it("suspense FAILs with the exact drift amount when POSTED lines leave a nonzero balance", async () => {
|
||||
queueQueries([
|
||||
[{ balance: "125.50", lines: "3" }],
|
||||
[{ n: "0", first: null }],
|
||||
[{ register: "0", assets: "0" }],
|
||||
]);
|
||||
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||
|
||||
const result = await service.readiness(actor());
|
||||
|
||||
const suspense = result.checks.find((c) => c.key === "suspense")!;
|
||||
expect(suspense.status).toBe("FAIL");
|
||||
expect(suspense.detail).toContain("125.50");
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
|
||||
it("suspense PASSes when POSTED lines net to exactly zero", async () => {
|
||||
queueQueries([
|
||||
[{ balance: "0", lines: "4" }],
|
||||
[{ n: "0", first: null }],
|
||||
[{ register: "0", assets: "0" }],
|
||||
]);
|
||||
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||
|
||||
const result = await service.readiness(actor());
|
||||
|
||||
const suspense = result.checks.find((c) => c.key === "suspense")!;
|
||||
expect(suspense.status).toBe("PASS");
|
||||
expect(suspense.detail).toContain("4 line(s)");
|
||||
});
|
||||
|
||||
// ── No non-OPENING entries before cutover ───────────────────────────────
|
||||
|
||||
it("PASS when nothing predates the cutover date", async () => {
|
||||
queueQueries([
|
||||
[{ balance: "0", lines: "2" }],
|
||||
[{ n: "0", first: null }],
|
||||
[{ register: "0", assets: "0" }],
|
||||
]);
|
||||
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||
|
||||
const result = await service.readiness(actor());
|
||||
|
||||
const early = result.checks.find((c) => c.key === "no_pre_cutover")!;
|
||||
expect(early.status).toBe("PASS");
|
||||
});
|
||||
|
||||
it("FAILs and names the count and earliest date when non-OPENING entries predate cutover", async () => {
|
||||
queueQueries([
|
||||
[{ balance: "0", lines: "2" }],
|
||||
[{ n: "3", first: "2026-06-01" }],
|
||||
[{ register: "0", assets: "0" }],
|
||||
]);
|
||||
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||
|
||||
const result = await service.readiness(actor());
|
||||
|
||||
const early = result.checks.find((c) => c.key === "no_pre_cutover")!;
|
||||
expect(early.status).toBe("FAIL");
|
||||
expect(early.detail).toContain("3");
|
||||
expect(early.detail).toContain("2026-06-01");
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
|
||||
// ── Asset register agrees with the ledger ───────────────────────────────
|
||||
|
||||
it("asset register check is PENDING when the register is empty", async () => {
|
||||
queueQueries([
|
||||
[{ balance: "0", lines: "2" }],
|
||||
[{ n: "0", first: null }],
|
||||
[{ register: "0", assets: "0" }],
|
||||
]);
|
||||
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||
|
||||
const result = await service.readiness(actor());
|
||||
|
||||
const assetCheck = result.checks.find((c) => c.key === "asset_register")!;
|
||||
expect(assetCheck.status).toBe("PENDING");
|
||||
expect(result.checks.find((c) => c.key === "migrated_assets")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("PASSes when the register and the ledger's contra-ASSET balance agree within 0.005", async () => {
|
||||
queueQueries([
|
||||
[{ balance: "0", lines: "2" }],
|
||||
[{ n: "0", first: null }],
|
||||
[{ register: "1000.00", assets: "2" }],
|
||||
[{ balance: "1000.00" }], // ledger contra-asset balance
|
||||
[{ n: "0", codes: null }], // migrated-assets sub-check: clean
|
||||
]);
|
||||
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||
|
||||
const result = await service.readiness(actor());
|
||||
|
||||
const assetCheck = result.checks.find((c) => c.key === "asset_register")!;
|
||||
expect(assetCheck.status).toBe("PASS");
|
||||
expect(assetCheck.detail).toContain("1000.00");
|
||||
});
|
||||
|
||||
it("FAILs with the register, ledger and drift figures when they disagree", async () => {
|
||||
queueQueries([
|
||||
[{ balance: "0", lines: "2" }],
|
||||
[{ n: "0", first: null }],
|
||||
[{ register: "1000.00", assets: "2" }],
|
||||
[{ balance: "960.00" }],
|
||||
[{ n: "0", codes: null }],
|
||||
]);
|
||||
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||
|
||||
const result = await service.readiness(actor());
|
||||
|
||||
const assetCheck = result.checks.find((c) => c.key === "asset_register")!;
|
||||
expect(assetCheck.status).toBe("FAIL");
|
||||
expect(assetCheck.detail).toContain("1000.00");
|
||||
expect(assetCheck.detail).toContain("960.00");
|
||||
expect(assetCheck.detail).toContain("40.00");
|
||||
});
|
||||
|
||||
it("migrated-assets sub-check FAILs distinctly for assets carrying accumulated depreciation with zero opening periods and no depreciation history", async () => {
|
||||
queueQueries([
|
||||
[{ balance: "0", lines: "2" }],
|
||||
[{ n: "0", first: null }],
|
||||
[{ register: "1000.00", assets: "2" }],
|
||||
[{ balance: "1000.00" }], // register/ledger agree — this check PASSes
|
||||
[{ n: "1", codes: "TEST-ASSET-01" }], // but a migrated asset is stalled
|
||||
]);
|
||||
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||
|
||||
const result = await service.readiness(actor());
|
||||
|
||||
const assetCheck = result.checks.find((c) => c.key === "asset_register")!;
|
||||
const migrated = result.checks.find((c) => c.key === "migrated_assets")!;
|
||||
expect(assetCheck.status).toBe("PASS");
|
||||
expect(migrated.status).toBe("FAIL");
|
||||
expect(migrated.detail).toContain("TEST-ASSET-01");
|
||||
expect(result.ready).toBe(false);
|
||||
});
|
||||
|
||||
it("ready is true only when every check is PASS", async () => {
|
||||
queueQueries([
|
||||
[{ balance: "0", lines: "2" }],
|
||||
[{ n: "0", first: null }],
|
||||
[{ register: "500.00", assets: "1" }],
|
||||
[{ balance: "500.00" }],
|
||||
[{ n: "0", codes: null }],
|
||||
]);
|
||||
build({ cutoverDate: "2026-07-08", cutoverNote: null });
|
||||
|
||||
const result = await service.readiness(actor());
|
||||
|
||||
expect(result.checks.every((c) => c.status === "PASS")).toBe(true);
|
||||
expect(result.ready).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves the organization from the token, not a client-supplied param, for a non-super-admin", async () => {
|
||||
queueQueries([
|
||||
[{ balance: "0", lines: "0" }],
|
||||
[{ register: "0", assets: "0" }],
|
||||
]);
|
||||
build(null);
|
||||
|
||||
await expect(
|
||||
service.readiness(actor({ isSuperAdmin: false }), "some-other-org"),
|
||||
).rejects.toThrow(/only read or set the cutover for your own organization/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CutoverService.importOpeningBalances", () => {
|
||||
let settings: ReturnType<typeof makeSettingsRepo>;
|
||||
let accounts: ReturnType<typeof makeAccounts>;
|
||||
let journals: ReturnType<typeof makeJournals>;
|
||||
let dataSource: { query: jest.Mock };
|
||||
let service: CutoverService;
|
||||
|
||||
function testAccount(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "acct-" + Math.random().toString(36).slice(2),
|
||||
code: "TEST-1000",
|
||||
isGroup: false,
|
||||
isActive: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
settings = makeSettingsRepo(null);
|
||||
accounts = makeAccounts();
|
||||
journals = makeJournals();
|
||||
dataSource = { query: jest.fn() };
|
||||
service = new CutoverService(
|
||||
settings as never,
|
||||
accounts as never,
|
||||
journals as never,
|
||||
dataSource as never,
|
||||
);
|
||||
journals.create.mockResolvedValue({
|
||||
id: "entry-1",
|
||||
status: "DRAFT",
|
||||
entryNumber: "OPEN-2026-0001",
|
||||
});
|
||||
});
|
||||
|
||||
const dto = (lines: Record<string, unknown>[], overrides: Record<string, unknown> = {}) => ({
|
||||
entryDate: "2026-07-08",
|
||||
lines,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("rejects a caller-supplied 3900 suspense line outright", async () => {
|
||||
accounts.findByCode.mockResolvedValue(testAccount({ code: "TEST-1000" }));
|
||||
|
||||
await expect(
|
||||
service.importOpeningBalances(
|
||||
actor(),
|
||||
dto([{ accountCode: "3900", debit: 100 }]) as never,
|
||||
),
|
||||
).rejects.toThrow(/is calculated, not entered/i);
|
||||
expect(journals.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a group account", async () => {
|
||||
accounts.findByCode.mockResolvedValue(testAccount({ isGroup: true }));
|
||||
|
||||
await expect(
|
||||
service.importOpeningBalances(
|
||||
actor(),
|
||||
dto([{ accountCode: "TEST-1000", debit: 100 }]) as never,
|
||||
),
|
||||
).rejects.toThrow(/group account/i);
|
||||
});
|
||||
|
||||
it("rejects an inactive account", async () => {
|
||||
accounts.findByCode.mockResolvedValue(testAccount({ isActive: false }));
|
||||
|
||||
await expect(
|
||||
service.importOpeningBalances(
|
||||
actor(),
|
||||
dto([{ accountCode: "TEST-1000", debit: 100 }]) as never,
|
||||
),
|
||||
).rejects.toThrow(/inactive/i);
|
||||
});
|
||||
|
||||
it("rejects an unknown account code", async () => {
|
||||
accounts.findByCode.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.importOpeningBalances(
|
||||
actor(),
|
||||
dto([{ accountCode: "TEST-NOPE", debit: 100 }]) as never,
|
||||
),
|
||||
).rejects.toThrow(/no such account/i);
|
||||
});
|
||||
|
||||
it("rejects a line carrying both debit and credit", async () => {
|
||||
accounts.findByCode.mockResolvedValue(testAccount());
|
||||
|
||||
await expect(
|
||||
service.importOpeningBalances(
|
||||
actor(),
|
||||
dto([{ accountCode: "TEST-1000", debit: 100, credit: 50 }]) as never,
|
||||
),
|
||||
).rejects.toThrow(/never both/i);
|
||||
});
|
||||
|
||||
it("rejects a line carrying neither debit nor credit", async () => {
|
||||
accounts.findByCode.mockResolvedValue(testAccount());
|
||||
|
||||
await expect(
|
||||
service.importOpeningBalances(
|
||||
actor(),
|
||||
dto([{ accountCode: "TEST-1000" }]) as never,
|
||||
),
|
||||
).rejects.toThrow(/no amount/i);
|
||||
});
|
||||
|
||||
it("computes the suspense plug as totalDebit - totalCredit and credits suspense when debits exceed credits", async () => {
|
||||
// Synthetic unbalanced batch: 700 debit vs 300 credit → plug of 400,
|
||||
// which must land as a 400 CREDIT to suspense (debits were the larger side).
|
||||
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
|
||||
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
|
||||
const suspense = testAccount({ id: "acct-3900", code: "3900" });
|
||||
|
||||
accounts.findByCode.mockImplementation((_org: string, code: string) => {
|
||||
if (code === "TEST-1000") return Promise.resolve(cash);
|
||||
if (code === "TEST-3000") return Promise.resolve(equity);
|
||||
if (code === "3900") return Promise.resolve(suspense);
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const result = await service.importOpeningBalances(
|
||||
actor(),
|
||||
dto([
|
||||
{ accountCode: "TEST-1000", debit: 700 },
|
||||
{ accountCode: "TEST-3000", credit: 300 },
|
||||
]) as never,
|
||||
);
|
||||
|
||||
expect(result.suspensePlug).toBe(400);
|
||||
expect(result.totalDebit).toBe(700);
|
||||
expect(result.totalCredit).toBe(300);
|
||||
|
||||
const [, createDto] = journals.create.mock.calls[0];
|
||||
const plugLine = createDto.lines.find(
|
||||
(l: { accountId: string }) => l.accountId === "acct-3900",
|
||||
);
|
||||
expect(plugLine).toBeDefined();
|
||||
expect(plugLine.debit).toBe(0);
|
||||
expect(plugLine.credit).toBe(400);
|
||||
});
|
||||
|
||||
it("computes the suspense plug as a DEBIT to suspense when credits exceed debits", async () => {
|
||||
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
|
||||
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
|
||||
const suspense = testAccount({ id: "acct-3900", code: "3900" });
|
||||
|
||||
accounts.findByCode.mockImplementation((_org: string, code: string) => {
|
||||
if (code === "TEST-1000") return Promise.resolve(cash);
|
||||
if (code === "TEST-3000") return Promise.resolve(equity);
|
||||
if (code === "3900") return Promise.resolve(suspense);
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const result = await service.importOpeningBalances(
|
||||
actor(),
|
||||
dto([
|
||||
{ accountCode: "TEST-1000", debit: 300 },
|
||||
{ accountCode: "TEST-3000", credit: 700 },
|
||||
]) as never,
|
||||
);
|
||||
|
||||
expect(result.suspensePlug).toBe(-400);
|
||||
|
||||
const [, createDto] = journals.create.mock.calls[0];
|
||||
const plugLine = createDto.lines.find(
|
||||
(l: { accountId: string }) => l.accountId === "acct-3900",
|
||||
);
|
||||
expect(plugLine.debit).toBe(400);
|
||||
expect(plugLine.credit).toBe(0);
|
||||
});
|
||||
|
||||
it("omits the suspense line entirely when the batch already balances", async () => {
|
||||
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
|
||||
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
|
||||
|
||||
accounts.findByCode.mockImplementation((_org: string, code: string) => {
|
||||
if (code === "TEST-1000") return Promise.resolve(cash);
|
||||
if (code === "TEST-3000") return Promise.resolve(equity);
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const result = await service.importOpeningBalances(
|
||||
actor(),
|
||||
dto([
|
||||
{ accountCode: "TEST-1000", debit: 500 },
|
||||
{ accountCode: "TEST-3000", credit: 500 },
|
||||
]) as never,
|
||||
);
|
||||
|
||||
expect(result.suspensePlug).toBe(0);
|
||||
const [, createDto] = journals.create.mock.calls[0];
|
||||
expect(createDto.lines).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("creates via journals.create producing a DRAFT and never calls journals.post directly", async () => {
|
||||
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
|
||||
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
|
||||
|
||||
accounts.findByCode.mockImplementation((_org: string, code: string) => {
|
||||
if (code === "TEST-1000") return Promise.resolve(cash);
|
||||
if (code === "TEST-3000") return Promise.resolve(equity);
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
const result = await service.importOpeningBalances(
|
||||
actor(),
|
||||
dto([
|
||||
{ accountCode: "TEST-1000", debit: 500 },
|
||||
{ accountCode: "TEST-3000", credit: 500 },
|
||||
]) as never,
|
||||
);
|
||||
|
||||
expect(journals.create).toHaveBeenCalledTimes(1);
|
||||
const [, createDto] = journals.create.mock.calls[0];
|
||||
expect(createDto.journalType).toBe("OPENING");
|
||||
expect(result.entry.status).toBe("DRAFT");
|
||||
expect(journals.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("raises a BadRequestException if the suspense account is missing from the chart when a plug is needed", async () => {
|
||||
const cash = testAccount({ id: "acct-cash", code: "TEST-1000" });
|
||||
const equity = testAccount({ id: "acct-equity", code: "TEST-3000" });
|
||||
|
||||
accounts.findByCode.mockImplementation((_org: string, code: string) => {
|
||||
if (code === "TEST-1000") return Promise.resolve(cash);
|
||||
if (code === "TEST-3000") return Promise.resolve(equity);
|
||||
if (code === "3900") return Promise.resolve(null);
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.importOpeningBalances(
|
||||
actor(),
|
||||
dto([
|
||||
{ accountCode: "TEST-1000", debit: 700 },
|
||||
{ accountCode: "TEST-3000", credit: 300 },
|
||||
]) as never,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
582
apps/finance-api/src/modules/journals/journals.service.spec.ts
Normal file
582
apps/finance-api/src/modules/journals/journals.service.spec.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||
|
||||
import type { ActorContext } from "../../common/current-actor.util";
|
||||
import { JournalsService } from "./journals.service";
|
||||
import { JournalEntry } from "./entities/journal-entry.entity";
|
||||
|
||||
/**
|
||||
* Mirrors edr-freight-api's convention: plain `new ServiceClass(...)` with
|
||||
* hand-built fakes passed positionally. No TestingModule, no real DB.
|
||||
*/
|
||||
|
||||
const actor: ActorContext = {
|
||||
employeeId: "emp-1",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
|
||||
function makeManager() {
|
||||
const entryRepo = {
|
||||
create: (data: Record<string, unknown>) => data,
|
||||
save: jest
|
||||
.fn()
|
||||
.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||
id: (data.id as string) ?? "entry-1",
|
||||
...data,
|
||||
})),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const lineRepo = {
|
||||
create: (data: Record<string, unknown>) => data,
|
||||
save: jest.fn().mockImplementation(async (data: unknown) => data),
|
||||
};
|
||||
return {
|
||||
getRepository: (entity: unknown) =>
|
||||
entity === JournalEntry ? entryRepo : lineRepo,
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
entryRepo,
|
||||
lineRepo,
|
||||
};
|
||||
}
|
||||
|
||||
function build() {
|
||||
const entries = {
|
||||
findById: jest.fn(),
|
||||
findPage: jest.fn(),
|
||||
nextEntryNumber: jest.fn().mockResolvedValue("JV-2026-000001"),
|
||||
findBySource: jest.fn(),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
hardDelete: jest.fn(),
|
||||
};
|
||||
const lines = {
|
||||
findByEntry: jest.fn(),
|
||||
findByEntryWithAccounts: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const accounts = { assertPostable: jest.fn() };
|
||||
const periods = { resolveOpenPeriodForDate: jest.fn() };
|
||||
const manager = makeManager();
|
||||
const dataSource = {
|
||||
transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||
manager,
|
||||
};
|
||||
|
||||
const service = new JournalsService(
|
||||
entries as never,
|
||||
lines as never,
|
||||
accounts as never,
|
||||
periods as never,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
return { service, entries, lines, accounts, periods, dataSource, manager };
|
||||
}
|
||||
|
||||
describe("JournalsService.post", () => {
|
||||
const draftEntry = (over: Record<string, unknown> = {}) => ({
|
||||
id: "entry-1",
|
||||
entryNumber: "JV-2026-000001",
|
||||
organizationId: "org-1",
|
||||
entryDate: "2026-08-15",
|
||||
status: "DRAFT",
|
||||
totalDebit: 100,
|
||||
totalCredit: 100,
|
||||
...over,
|
||||
});
|
||||
|
||||
const twoLines = () => [
|
||||
{ id: "line-1", accountId: "acc-1", debit: 100, credit: 0 },
|
||||
{ id: "line-2", accountId: "acc-2", debit: 0, credit: 100 },
|
||||
];
|
||||
|
||||
it("refuses posting an entry that is not DRAFT", async () => {
|
||||
const { service, entries } = build();
|
||||
entries.findById.mockResolvedValue(draftEntry({ status: "POSTED" }));
|
||||
|
||||
await expect(service.post(actor, "entry-1")).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it("requires at least two lines", async () => {
|
||||
const { service, entries, lines, accounts, periods } = build();
|
||||
entries.findById.mockResolvedValue(draftEntry());
|
||||
lines.findByEntry.mockResolvedValue([twoLines()[0]]);
|
||||
|
||||
await expect(service.post(actor, "entry-1")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(accounts.assertPostable).not.toHaveBeenCalled();
|
||||
expect(periods.resolveOpenPeriodForDate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-validates every line's account via accounts.assertPostable AT POSTING TIME — an account deactivated after the draft was written fails posting", async () => {
|
||||
const { service, entries, lines, accounts, periods } = build();
|
||||
entries.findById.mockResolvedValue(draftEntry());
|
||||
lines.findByEntry.mockResolvedValue(twoLines());
|
||||
// acc-1 was postable when the draft was written; acc-2 has since been
|
||||
// deactivated. Nothing about the draft itself changed.
|
||||
accounts.assertPostable.mockImplementation(
|
||||
async (_a: unknown, accountId: string) => {
|
||||
if (accountId === "acc-2") {
|
||||
throw new BadRequestException(
|
||||
"ACC-2 is inactive and cannot accept new postings",
|
||||
);
|
||||
}
|
||||
return { id: accountId, code: accountId, isActive: true, isGroup: false };
|
||||
},
|
||||
);
|
||||
|
||||
await expect(service.post(actor, "entry-1")).rejects.toThrow(/inactive/);
|
||||
// Never got as far as re-resolving the period or writing the update.
|
||||
expect(periods.resolveOpenPeriodForDate).not.toHaveBeenCalled();
|
||||
expect(entries.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-derives totals from the lines as they stand now, not the stored (possibly stale) header totals", async () => {
|
||||
const { service, entries, lines, accounts, periods } = build();
|
||||
// The header claims 100/100 from create time; the lines underneath it no
|
||||
// longer agree.
|
||||
entries.findById.mockResolvedValue(
|
||||
draftEntry({ totalDebit: 100, totalCredit: 100 }),
|
||||
);
|
||||
lines.findByEntry.mockResolvedValue([
|
||||
{ id: "line-1", accountId: "acc-1", debit: 150, credit: 0 },
|
||||
{ id: "line-2", accountId: "acc-2", debit: 0, credit: 100 },
|
||||
]);
|
||||
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({
|
||||
id,
|
||||
isActive: true,
|
||||
isGroup: false,
|
||||
}));
|
||||
|
||||
await expect(service.post(actor, "entry-1")).rejects.toThrow(
|
||||
"Entry does not balance: debits 150.00, credits 100.00 — a difference of 50.00",
|
||||
);
|
||||
expect(periods.resolveOpenPeriodForDate).not.toHaveBeenCalled();
|
||||
expect(entries.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-resolves the fiscal period from the entry date — a draft written before month-end close cannot slip into the now-closed period", async () => {
|
||||
const { service, entries, lines, accounts, periods } = build();
|
||||
entries.findById.mockResolvedValue(draftEntry({ entryDate: "2026-07-30" }));
|
||||
lines.findByEntry.mockResolvedValue(twoLines());
|
||||
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({
|
||||
id,
|
||||
isActive: true,
|
||||
isGroup: false,
|
||||
}));
|
||||
periods.resolveOpenPeriodForDate.mockRejectedValue(
|
||||
new BadRequestException(
|
||||
"July 2026 is CLOSED — nothing further can be posted into it.",
|
||||
),
|
||||
);
|
||||
|
||||
await expect(service.post(actor, "entry-1")).rejects.toThrow(/CLOSED/);
|
||||
expect(periods.resolveOpenPeriodForDate).toHaveBeenCalledWith(
|
||||
"org-1",
|
||||
"2026-07-30",
|
||||
);
|
||||
expect(entries.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts successfully: status POSTED, re-derived totals, the freshly-resolved period, and postedBy/postedAt stamped", async () => {
|
||||
const { service, entries, lines, accounts, periods } = build();
|
||||
entries.findById.mockResolvedValue(draftEntry());
|
||||
lines.findByEntry.mockResolvedValue(twoLines());
|
||||
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({
|
||||
id,
|
||||
isActive: true,
|
||||
isGroup: false,
|
||||
}));
|
||||
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-2" });
|
||||
|
||||
await service.post(actor, "entry-1");
|
||||
|
||||
expect(entries.update).toHaveBeenCalledWith("entry-1", {
|
||||
status: "POSTED",
|
||||
fiscalPeriodId: "period-2",
|
||||
totalDebit: 100,
|
||||
totalCredit: 100,
|
||||
postedBy: "emp-1",
|
||||
postedAt: expect.any(Date),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("JournalsService.reverse", () => {
|
||||
const originalEntry = (over: Record<string, unknown> = {}) => ({
|
||||
id: "orig-1",
|
||||
entryNumber: "JV-2026-000001",
|
||||
organizationId: "org-1",
|
||||
entryDate: "2026-01-10",
|
||||
status: "POSTED",
|
||||
reversedByEntryId: null,
|
||||
totalDebit: 100,
|
||||
totalCredit: 100,
|
||||
reference: "REF-1",
|
||||
sourceModule: "supplier-bill",
|
||||
sourceId: "bill-42",
|
||||
...over,
|
||||
});
|
||||
|
||||
const originalLines = [
|
||||
{ accountId: "acc-1", debit: 100, credit: 0, description: "Expense", costCenterId: null },
|
||||
{ accountId: "acc-2", debit: 0, credit: 100, description: "Payable", costCenterId: null },
|
||||
];
|
||||
|
||||
/** entries.findById is called once for the original, then once more inside
|
||||
* the closing findOne() for the freshly-created reversal. */
|
||||
function wireOriginalThenReversal(
|
||||
entries: ReturnType<typeof build>["entries"],
|
||||
original: ReturnType<typeof originalEntry>,
|
||||
) {
|
||||
entries.findById
|
||||
.mockResolvedValueOnce(original)
|
||||
.mockResolvedValueOnce({ id: "entry-1", organizationId: "org-1" });
|
||||
}
|
||||
|
||||
it("refuses reversing a DRAFT — nothing has affected any balance yet", async () => {
|
||||
const { service, entries, dataSource } = build();
|
||||
entries.findById.mockResolvedValue(originalEntry({ status: "DRAFT" }));
|
||||
|
||||
await expect(
|
||||
service.reverse(actor, "orig-1", { reason: "typo" }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses reversing an entry that has already been reversed (reversedByEntryId already set)", async () => {
|
||||
const { service, entries, dataSource } = build();
|
||||
entries.findById.mockResolvedValue(
|
||||
originalEntry({ reversedByEntryId: "some-other-reversal" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.reverse(actor, "orig-1", { reason: "typo" }),
|
||||
).rejects.toThrow(/already been reversed/);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("also refuses when status is already REVERSED", async () => {
|
||||
const { service, entries, dataSource } = build();
|
||||
entries.findById.mockResolvedValue(originalEntry({ status: "REVERSED" }));
|
||||
|
||||
await expect(
|
||||
service.reverse(actor, "orig-1", { reason: "typo" }),
|
||||
).rejects.toThrow(/already been reversed/);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dates the reversal TODAY by default — never backdated onto the original's date", async () => {
|
||||
const { service, entries, lines, periods, manager } = build();
|
||||
const original = originalEntry({ entryDate: "2026-01-10" });
|
||||
wireOriginalThenReversal(entries, original);
|
||||
lines.findByEntry.mockResolvedValue(originalLines);
|
||||
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
|
||||
|
||||
await service.reverse(actor, "orig-1", { reason: "correction" });
|
||||
|
||||
const expectedToday = new Date().toISOString().slice(0, 10);
|
||||
expect(periods.resolveOpenPeriodForDate).toHaveBeenCalledWith(
|
||||
"org-1",
|
||||
expectedToday,
|
||||
);
|
||||
const savedArg = manager.entryRepo.save.mock.calls[0][0];
|
||||
expect(savedArg.entryDate).toBe(expectedToday);
|
||||
expect(savedArg.entryDate).not.toBe(original.entryDate);
|
||||
});
|
||||
|
||||
it("uses an explicit reversalDate when supplied, instead of today", async () => {
|
||||
const { service, entries, lines, periods, manager } = build();
|
||||
wireOriginalThenReversal(entries, originalEntry());
|
||||
lines.findByEntry.mockResolvedValue(originalLines);
|
||||
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
|
||||
|
||||
await service.reverse(actor, "orig-1", {
|
||||
reason: "correction",
|
||||
reversalDate: "2026-02-01",
|
||||
});
|
||||
|
||||
expect(periods.resolveOpenPeriodForDate).toHaveBeenCalledWith(
|
||||
"org-1",
|
||||
"2026-02-01",
|
||||
);
|
||||
const savedArg = manager.entryRepo.save.mock.calls[0][0];
|
||||
expect(savedArg.entryDate).toBe("2026-02-01");
|
||||
});
|
||||
|
||||
it("mirrors every line — debit and credit swapped", async () => {
|
||||
const { service, entries, lines, periods, manager } = build();
|
||||
wireOriginalThenReversal(entries, originalEntry());
|
||||
lines.findByEntry.mockResolvedValue(originalLines);
|
||||
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
|
||||
|
||||
await service.reverse(actor, "orig-1", { reason: "correction" });
|
||||
|
||||
const savedLines = manager.lineRepo.save.mock.calls[0][0];
|
||||
expect(savedLines).toEqual([
|
||||
expect.objectContaining({ accountId: "acc-1", debit: 0, credit: 100 }),
|
||||
expect.objectContaining({ accountId: "acc-2", debit: 100, credit: 0 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does NOT copy sourceModule/sourceId — that would collide with the idempotency key on the original", async () => {
|
||||
const { service, entries, lines, periods, manager } = build();
|
||||
wireOriginalThenReversal(
|
||||
entries,
|
||||
originalEntry({ sourceModule: "supplier-bill", sourceId: "bill-42" }),
|
||||
);
|
||||
lines.findByEntry.mockResolvedValue(originalLines);
|
||||
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
|
||||
|
||||
await service.reverse(actor, "orig-1", { reason: "correction" });
|
||||
|
||||
const savedArg = manager.entryRepo.save.mock.calls[0][0];
|
||||
expect(savedArg.sourceModule).toBeUndefined();
|
||||
expect(savedArg.sourceId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("marks the original REVERSED with reversedByEntryId pointing at the new entry, all inside one transaction", async () => {
|
||||
const { service, entries, lines, periods, manager, dataSource } = build();
|
||||
wireOriginalThenReversal(entries, originalEntry());
|
||||
lines.findByEntry.mockResolvedValue(originalLines);
|
||||
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
|
||||
|
||||
await service.reverse(actor, "orig-1", { reason: "correction" });
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(manager.entryRepo.update).toHaveBeenCalledWith("orig-1", {
|
||||
status: "REVERSED",
|
||||
reversedByEntryId: "entry-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("mirrors totalDebit/totalCredit too (credit becomes debit and vice versa) and journalType REVERSAL", async () => {
|
||||
const { service, entries, lines, periods, manager } = build();
|
||||
wireOriginalThenReversal(
|
||||
entries,
|
||||
originalEntry({ totalDebit: 400, totalCredit: 400 }),
|
||||
);
|
||||
lines.findByEntry.mockResolvedValue(originalLines);
|
||||
periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-x" });
|
||||
|
||||
await service.reverse(actor, "orig-1", { reason: "correction" });
|
||||
|
||||
const savedArg = manager.entryRepo.save.mock.calls[0][0];
|
||||
expect(savedArg.totalDebit).toBe(400);
|
||||
expect(savedArg.totalCredit).toBe(400);
|
||||
expect(savedArg.journalType).toBe("REVERSAL");
|
||||
expect(savedArg.reversesEntryId).toBe("orig-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("JournalsService.createPosted", () => {
|
||||
const dto = () => ({
|
||||
entryDate: "2026-03-05",
|
||||
memo: "Test posting",
|
||||
journalType: "GENERAL" as const,
|
||||
lines: [
|
||||
{ accountId: "acc-1", debit: 500 },
|
||||
{ accountId: "acc-2", credit: 500 },
|
||||
],
|
||||
sourceModule: "supplier-bill",
|
||||
sourceId: "bill-1",
|
||||
});
|
||||
|
||||
function wireHappyPath(mocks: ReturnType<typeof build>) {
|
||||
mocks.accounts.assertPostable.mockImplementation(
|
||||
async (_a: unknown, id: string) => ({ id, isActive: true, isGroup: false }),
|
||||
);
|
||||
mocks.periods.resolveOpenPeriodForDate.mockResolvedValue({ id: "period-1" });
|
||||
}
|
||||
|
||||
it("with no manager: opens its own transaction and returns the full detail via findOne()", async () => {
|
||||
const mocks = build();
|
||||
wireHappyPath(mocks);
|
||||
mocks.entries.findById.mockResolvedValue({
|
||||
id: "entry-1",
|
||||
organizationId: "org-1",
|
||||
});
|
||||
mocks.lines.findByEntryWithAccounts.mockResolvedValue([]);
|
||||
|
||||
const result = await mocks.service.createPosted(actor, dto());
|
||||
|
||||
expect(mocks.dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(result.id).toBe("entry-1");
|
||||
expect(mocks.manager.entryRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("caller-supplied manager: writes on THAT manager and reads lines back via manager.query — never opens its own transaction", async () => {
|
||||
const mocks = build();
|
||||
wireHappyPath(mocks);
|
||||
|
||||
const callerManager = makeManager();
|
||||
callerManager.query.mockResolvedValue([
|
||||
{ id: "line-1", accountId: "acc-1", debit: 500, credit: 0 },
|
||||
{ id: "line-2", accountId: "acc-2", debit: 0, credit: 500 },
|
||||
]);
|
||||
|
||||
const result = await mocks.service.createPosted(
|
||||
actor,
|
||||
dto(),
|
||||
callerManager as never,
|
||||
);
|
||||
|
||||
// The bug this guards against: a service opening its OWN nested
|
||||
// transaction inside the caller's would commit independently and orphan
|
||||
// rows on a later failure. It must write on the given manager instead.
|
||||
expect(mocks.dataSource.transaction).not.toHaveBeenCalled();
|
||||
expect(callerManager.entryRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(callerManager.query).toHaveBeenCalledTimes(1);
|
||||
expect(result.lines).toEqual([
|
||||
{ id: "line-1", accountId: "acc-1", debit: 500, credit: 0 },
|
||||
{ id: "line-2", accountId: "acc-2", debit: 0, credit: 500 },
|
||||
]);
|
||||
// Nothing was written on the service's own default manager.
|
||||
expect(mocks.manager.entryRepo.save).not.toHaveBeenCalled();
|
||||
expect(mocks.entries.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caller-supplied manager: nextEntryNumber is generated on the SAME manager, not a fresh connection", async () => {
|
||||
const mocks = build();
|
||||
wireHappyPath(mocks);
|
||||
const callerManager = makeManager();
|
||||
|
||||
await mocks.service.createPosted(actor, dto(), callerManager as never);
|
||||
|
||||
expect(mocks.entries.nextEntryNumber).toHaveBeenCalledWith(
|
||||
"org-1",
|
||||
2026,
|
||||
callerManager,
|
||||
);
|
||||
});
|
||||
|
||||
it("sourceModule/sourceId form the idempotency key on the saved entry", async () => {
|
||||
const mocks = build();
|
||||
wireHappyPath(mocks);
|
||||
const callerManager = makeManager();
|
||||
|
||||
await mocks.service.createPosted(actor, dto(), callerManager as never);
|
||||
|
||||
const savedArg = callerManager.entryRepo.save.mock.calls[0][0];
|
||||
expect(savedArg.sourceModule).toBe("supplier-bill");
|
||||
expect(savedArg.sourceId).toBe("bill-1");
|
||||
expect(savedArg.status).toBe("POSTED");
|
||||
expect(savedArg.postedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("re-resolves the open period from the entry date, same as the human-posting path", async () => {
|
||||
const mocks = build();
|
||||
wireHappyPath(mocks);
|
||||
const callerManager = makeManager();
|
||||
|
||||
await mocks.service.createPosted(actor, dto(), callerManager as never);
|
||||
|
||||
expect(mocks.periods.resolveOpenPeriodForDate).toHaveBeenCalledWith(
|
||||
"org-1",
|
||||
"2026-03-05",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JournalsService — assertBalanced (private)", () => {
|
||||
it("rejects when total debit is not positive", () => {
|
||||
const { service } = build();
|
||||
expect(() => (service as never as { assertBalanced: (d: number, c: number) => void }).assertBalanced(0, 0)).toThrow(
|
||||
"An entry must move a non-zero amount",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses balances()'s tolerant (sub-cent) comparison rather than strict equality", () => {
|
||||
const { service } = build();
|
||||
const assertBalanced = (
|
||||
service as never as { assertBalanced: (d: number, c: number) => void }
|
||||
).assertBalanced.bind(service);
|
||||
// 0.004 difference is inside the < 0.005 tolerance from money.ts.
|
||||
expect(() => assertBalanced(100, 100.004)).not.toThrow();
|
||||
});
|
||||
|
||||
it("still rejects a genuine cent-or-more mismatch, naming both totals and the exact difference", () => {
|
||||
const { service } = build();
|
||||
const assertBalanced = (
|
||||
service as never as { assertBalanced: (d: number, c: number) => void }
|
||||
).assertBalanced.bind(service);
|
||||
expect(() => assertBalanced(100, 105)).toThrow(
|
||||
"Entry does not balance: debits 100.00, credits 105.00 — a difference of 5.00",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JournalsService — normalizeLines (private)", () => {
|
||||
type NormalizeLines = (
|
||||
actor: ActorContext,
|
||||
lines: unknown[],
|
||||
) => Promise<{ accountId: string; debit: number; credit: number }[]>;
|
||||
|
||||
function normalizer(service: JournalsService) {
|
||||
return (
|
||||
service as never as { normalizeLines: NormalizeLines }
|
||||
).normalizeLines.bind(service);
|
||||
}
|
||||
|
||||
it("requires at least two lines", async () => {
|
||||
const { service } = build();
|
||||
await expect(
|
||||
normalizer(service)(actor, [{ accountId: "a1", debit: 100 }]),
|
||||
).rejects.toThrow("An entry needs at least two lines");
|
||||
});
|
||||
|
||||
it("rejects a line carrying both a debit and a credit — a line must be one side or the other", async () => {
|
||||
const { service, accounts } = build();
|
||||
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({ id }));
|
||||
|
||||
await expect(
|
||||
normalizer(service)(actor, [
|
||||
{ accountId: "a1", debit: 100, credit: 50 },
|
||||
{ accountId: "a2", credit: 50 },
|
||||
]),
|
||||
).rejects.toThrow(/has both a debit and a credit/);
|
||||
});
|
||||
|
||||
it("rejects a line carrying neither a debit nor a credit", async () => {
|
||||
const { service, accounts } = build();
|
||||
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({ id }));
|
||||
|
||||
await expect(
|
||||
normalizer(service)(actor, [
|
||||
{ accountId: "a1" },
|
||||
{ accountId: "a2", credit: 100 },
|
||||
]),
|
||||
).rejects.toThrow(/has no amount/);
|
||||
});
|
||||
|
||||
it("rejects when total debit is not positive — e.g. every line is credit-only", async () => {
|
||||
const { service, accounts } = build();
|
||||
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({ id }));
|
||||
|
||||
await expect(
|
||||
normalizer(service)(actor, [
|
||||
{ accountId: "a1", credit: 50 },
|
||||
{ accountId: "a2", credit: 50 },
|
||||
]),
|
||||
).rejects.toThrow("An entry must move a non-zero amount");
|
||||
});
|
||||
|
||||
it("resolves each account via accounts.assertPostable and rounds every amount to the cent", async () => {
|
||||
const { service, accounts } = build();
|
||||
accounts.assertPostable.mockImplementation(async (_a: unknown, id: string) => ({ id }));
|
||||
|
||||
const normalized = await normalizer(service)(actor, [
|
||||
{ accountId: "a1", debit: 33.333333 },
|
||||
{ accountId: "a2", credit: 33.333333 },
|
||||
]);
|
||||
|
||||
expect(normalized[0]).toMatchObject({ accountId: "a1", debit: 33.33, credit: 0 });
|
||||
expect(normalized[1]).toMatchObject({ accountId: "a2", debit: 0, credit: 33.33 });
|
||||
expect(accounts.assertPostable).toHaveBeenCalledWith(actor, "a1");
|
||||
expect(accounts.assertPostable).toHaveBeenCalledWith(actor, "a2");
|
||||
});
|
||||
});
|
||||
444
apps/finance-api/src/modules/payables/payables.service.spec.ts
Normal file
444
apps/finance-api/src/modules/payables/payables.service.spec.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
import { BadRequestException, ForbiddenException } from "@nestjs/common";
|
||||
|
||||
import type { ActorContext } from "../../common/current-actor.util";
|
||||
import { PayablesService } from "./payables.service";
|
||||
import { SupplierPayment } from "./entities/supplier-payment.entity";
|
||||
import type { RecordPaymentDto } from "./dto/payables.dto";
|
||||
|
||||
const actor: ActorContext = {
|
||||
employeeId: "emp-1",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
|
||||
function makeManager() {
|
||||
const paymentRepo = {
|
||||
create: (data: Record<string, unknown>) => data,
|
||||
save: jest
|
||||
.fn()
|
||||
.mockImplementation(async (data: Record<string, unknown>) => ({
|
||||
id: (data.id as string) ?? "payment-1",
|
||||
...data,
|
||||
})),
|
||||
};
|
||||
const billRepo = {
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return {
|
||||
getRepository: (entity: unknown) =>
|
||||
entity === SupplierPayment ? paymentRepo : billRepo,
|
||||
paymentRepo,
|
||||
billRepo,
|
||||
};
|
||||
}
|
||||
|
||||
function build() {
|
||||
const suppliers = {
|
||||
findById: jest.fn(),
|
||||
findByCode: jest.fn(),
|
||||
findAllForOrg: jest.fn(),
|
||||
countBills: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
};
|
||||
const bills = {
|
||||
findById: jest.fn(),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
findPage: jest.fn(),
|
||||
nextBillNumber: jest.fn(),
|
||||
findPageWithSupplier: jest.fn(),
|
||||
agingAsOf: jest.fn(),
|
||||
};
|
||||
const billLines = {
|
||||
findByBill: jest.fn(),
|
||||
findByBillWithAccounts: jest.fn().mockResolvedValue([]),
|
||||
replaceForBill: jest.fn(),
|
||||
};
|
||||
const payments = {
|
||||
findByBill: jest.fn().mockResolvedValue([]),
|
||||
nextPaymentNumber: jest.fn().mockResolvedValue("PAY-2026-000001"),
|
||||
};
|
||||
const remittances = {
|
||||
outstandingByAccount: jest.fn(),
|
||||
findAllForOrg: jest.fn(),
|
||||
create: jest.fn(),
|
||||
};
|
||||
const accounts = { findOne: jest.fn(), findByCode: jest.fn(), assertPostable: jest.fn() };
|
||||
const journals = { createPosted: jest.fn(), findBySource: jest.fn() };
|
||||
const manager = makeManager();
|
||||
const dataSource = {
|
||||
transaction: jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||
manager,
|
||||
query: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new PayablesService(
|
||||
suppliers as never,
|
||||
bills as never,
|
||||
billLines as never,
|
||||
payments as never,
|
||||
remittances as never,
|
||||
accounts as never,
|
||||
journals as never,
|
||||
dataSource as never,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
suppliers,
|
||||
bills,
|
||||
billLines,
|
||||
payments,
|
||||
remittances,
|
||||
accounts,
|
||||
journals,
|
||||
dataSource,
|
||||
manager,
|
||||
};
|
||||
}
|
||||
|
||||
function stubAccount(code: string, over: Record<string, unknown> = {}) {
|
||||
return { id: code, code, accountType: "LIABILITY", isActive: true, isGroup: false, ...over };
|
||||
}
|
||||
|
||||
describe("PayablesService.approveBill", () => {
|
||||
const draftBill = (over: Record<string, unknown> = {}) => ({
|
||||
id: "bill-1",
|
||||
organizationId: "org-1",
|
||||
billNumber: "BILL-2026-000001",
|
||||
supplierInvoiceNumber: "INV-100",
|
||||
billDate: "2026-08-01",
|
||||
status: "DRAFT",
|
||||
totalAmount: 1150,
|
||||
taxAmount: 150,
|
||||
withholdingAmount: 100,
|
||||
supplierId: "sup-1",
|
||||
...over,
|
||||
});
|
||||
|
||||
const billLinesRows = [
|
||||
{ accountId: "exp-1", amount: 1000, description: "Consulting" },
|
||||
];
|
||||
|
||||
const supplierRow = (over: Record<string, unknown> = {}) => ({
|
||||
id: "sup-1",
|
||||
name: "Acme Supplies",
|
||||
payableAccountId: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("refuses approving a bill that is not DRAFT", async () => {
|
||||
const { service, bills } = build();
|
||||
bills.findById.mockResolvedValue(draftBill({ status: "APPROVED" }));
|
||||
|
||||
await expect(service.approveBill(actor, "bill-1")).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses when withholding exceeds the bill total", async () => {
|
||||
const { service, bills, billLines, suppliers, journals, accounts } = build();
|
||||
bills.findById.mockResolvedValue(
|
||||
draftBill({ totalAmount: 500, withholdingAmount: 600 }),
|
||||
);
|
||||
billLines.findByBill.mockResolvedValue(billLinesRows);
|
||||
suppliers.findById.mockResolvedValue(supplierRow());
|
||||
// The trade-payables lookup runs before the withholding check — needs an
|
||||
// account on file even though this test never reaches the journal build.
|
||||
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
|
||||
|
||||
await expect(service.approveBill(actor, "bill-1")).rejects.toThrow(
|
||||
/Withholding 600\.00 exceeds the bill total 500\.00/,
|
||||
);
|
||||
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a bill with no lines", async () => {
|
||||
const { service, bills, billLines } = build();
|
||||
bills.findById.mockResolvedValue(draftBill());
|
||||
billLines.findByBill.mockResolvedValue([]);
|
||||
|
||||
await expect(service.approveBill(actor, "bill-1")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it("builds Dr expense + Dr VAT payable, Cr trade-payables (net of withholding) + Cr withholding-payable, posted with sourceModule/sourceId", async () => {
|
||||
const { service, bills, billLines, suppliers, accounts, journals } = build();
|
||||
bills.findById.mockResolvedValue(draftBill());
|
||||
billLines.findByBill.mockResolvedValue(billLinesRows);
|
||||
suppliers.findById.mockResolvedValue(supplierRow());
|
||||
accounts.findByCode.mockImplementation(async (_org: string, code: string) =>
|
||||
stubAccount(code),
|
||||
);
|
||||
journals.createPosted.mockResolvedValue({
|
||||
id: "je-1",
|
||||
entryNumber: "JV-2026-000001",
|
||||
});
|
||||
|
||||
await service.approveBill(actor, "bill-1");
|
||||
|
||||
expect(journals.createPosted).toHaveBeenCalledWith(
|
||||
actor,
|
||||
expect.objectContaining({
|
||||
entryDate: "2026-08-01",
|
||||
journalType: "PURCHASE",
|
||||
sourceModule: "supplier-bill",
|
||||
sourceId: "bill-1",
|
||||
lines: [
|
||||
{ accountId: "exp-1", debit: 1000, description: "Consulting" },
|
||||
{ accountId: "2123", debit: 150, description: "Input VAT" },
|
||||
{
|
||||
accountId: "2111",
|
||||
credit: 1050,
|
||||
description: "Acme Supplies — BILL-2026-000001",
|
||||
},
|
||||
{
|
||||
accountId: "2124",
|
||||
credit: 100,
|
||||
description: "Withheld on BILL-2026-000001",
|
||||
},
|
||||
],
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the VAT and withholding lines when neither applies", async () => {
|
||||
const { service, bills, billLines, suppliers, accounts, journals } = build();
|
||||
bills.findById.mockResolvedValue(
|
||||
draftBill({ totalAmount: 1000, taxAmount: 0, withholdingAmount: 0 }),
|
||||
);
|
||||
billLines.findByBill.mockResolvedValue(billLinesRows);
|
||||
suppliers.findById.mockResolvedValue(supplierRow());
|
||||
accounts.findByCode.mockImplementation(async (_org: string, code: string) =>
|
||||
stubAccount(code),
|
||||
);
|
||||
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
||||
|
||||
await service.approveBill(actor, "bill-1");
|
||||
|
||||
const call = journals.createPosted.mock.calls[0][1] as { lines: unknown[] };
|
||||
expect(call.lines).toEqual([
|
||||
{ accountId: "exp-1", debit: 1000, description: "Consulting" },
|
||||
{ accountId: "2111", credit: 1000, description: "Acme Supplies — BILL-2026-000001" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("credits the supplier's OWN payable account when one is configured, instead of the default trade-payables control account", async () => {
|
||||
const { service, bills, billLines, suppliers, accounts, journals } = build();
|
||||
bills.findById.mockResolvedValue(
|
||||
draftBill({ totalAmount: 1000, taxAmount: 0, withholdingAmount: 0 }),
|
||||
);
|
||||
billLines.findByBill.mockResolvedValue(billLinesRows);
|
||||
suppliers.findById.mockResolvedValue(
|
||||
supplierRow({ payableAccountId: "custom-payable" }),
|
||||
);
|
||||
accounts.findOne.mockResolvedValue(
|
||||
stubAccount("custom-payable", { id: "custom-payable" }),
|
||||
);
|
||||
accounts.findByCode.mockImplementation(async (_org: string, code: string) =>
|
||||
stubAccount(code),
|
||||
);
|
||||
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
||||
|
||||
await service.approveBill(actor, "bill-1");
|
||||
|
||||
expect(accounts.findOne).toHaveBeenCalledWith(actor, "custom-payable");
|
||||
expect(accounts.findByCode).not.toHaveBeenCalledWith("org-1", "2111");
|
||||
const call = journals.createPosted.mock.calls[0][1] as { lines: { accountId: string }[] };
|
||||
expect(call.lines.some((l) => l.accountId === "custom-payable")).toBe(true);
|
||||
});
|
||||
|
||||
it("writes the journal entry and the bill's APPROVED status/journalEntryId inside ONE transaction (not the nested-transaction trap)", async () => {
|
||||
const { service, bills, billLines, suppliers, accounts, journals, dataSource, manager } =
|
||||
build();
|
||||
bills.findById.mockResolvedValue(draftBill());
|
||||
billLines.findByBill.mockResolvedValue(billLinesRows);
|
||||
suppliers.findById.mockResolvedValue(supplierRow());
|
||||
accounts.findByCode.mockImplementation(async (_org: string, code: string) =>
|
||||
stubAccount(code),
|
||||
);
|
||||
journals.createPosted.mockResolvedValue({
|
||||
id: "je-1",
|
||||
entryNumber: "JV-2026-000001",
|
||||
});
|
||||
|
||||
await service.approveBill(actor, "bill-1");
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
// journals.createPosted must be given the SAME manager the transaction
|
||||
// opened, not left to open its own nested one.
|
||||
expect(journals.createPosted).toHaveBeenCalledWith(
|
||||
actor,
|
||||
expect.anything(),
|
||||
manager,
|
||||
);
|
||||
expect(manager.billRepo.update).toHaveBeenCalledWith("bill-1", {
|
||||
status: "APPROVED",
|
||||
journalEntryId: "je-1",
|
||||
approvedBy: "emp-1",
|
||||
approvedAt: expect.any(Date),
|
||||
});
|
||||
// The old direct (unguarded, non-transactional) write path is gone.
|
||||
expect(bills.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayablesService.recordPayment", () => {
|
||||
const approvedBill = (over: Record<string, unknown> = {}) => ({
|
||||
id: "bill-1",
|
||||
organizationId: "org-1",
|
||||
billNumber: "BILL-2026-000001",
|
||||
status: "APPROVED",
|
||||
totalAmount: 1000,
|
||||
paidAmount: 0,
|
||||
supplierId: "sup-1",
|
||||
...over,
|
||||
});
|
||||
|
||||
const supplierRow = (over: Record<string, unknown> = {}) => ({
|
||||
id: "sup-1",
|
||||
name: "Acme Supplies",
|
||||
payableAccountId: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const paymentDto = (over: Record<string, unknown> = {}) =>
|
||||
({
|
||||
paymentDate: "2026-08-15",
|
||||
amount: 400,
|
||||
method: "BANK",
|
||||
paidFromAccountId: "cash-1",
|
||||
...over,
|
||||
}) as RecordPaymentDto;
|
||||
|
||||
it.each(["DRAFT", "PAID", "CANCELLED"])(
|
||||
"refuses paying a %s bill — only APPROVED/PARTIALLY_PAID bills are payable",
|
||||
async (status) => {
|
||||
const { service, bills } = build();
|
||||
bills.findById.mockResolvedValue(approvedBill({ status }));
|
||||
|
||||
await expect(
|
||||
service.recordPayment(actor, "bill-1", paymentDto()),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
},
|
||||
);
|
||||
|
||||
it("allows paying a PARTIALLY_PAID bill", async () => {
|
||||
const { service, bills, suppliers, accounts, journals } = build();
|
||||
bills.findById.mockResolvedValue(
|
||||
approvedBill({ status: "PARTIALLY_PAID", paidAmount: 400 }),
|
||||
);
|
||||
suppliers.findById.mockResolvedValue(supplierRow());
|
||||
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
|
||||
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
|
||||
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
||||
|
||||
await expect(
|
||||
service.recordPayment(actor, "bill-1", paymentDto({ amount: 200 })),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("refuses a payment that exceeds the outstanding balance (totalAmount - paidAmount)", async () => {
|
||||
const { service, bills, suppliers, accounts } = build();
|
||||
bills.findById.mockResolvedValue(approvedBill({ totalAmount: 1000, paidAmount: 800 }));
|
||||
suppliers.findById.mockResolvedValue(supplierRow());
|
||||
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
|
||||
|
||||
await expect(
|
||||
service.recordPayment(actor, "bill-1", paymentDto({ amount: 300 })),
|
||||
).rejects.toThrow(/exceeds the 200\.00 still outstanding/);
|
||||
});
|
||||
|
||||
it("requires paidFromAccountId to resolve to an ASSET account", async () => {
|
||||
const { service, bills, accounts } = build();
|
||||
bills.findById.mockResolvedValue(approvedBill());
|
||||
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "LIABILITY" }));
|
||||
|
||||
await expect(
|
||||
service.recordPayment(actor, "bill-1", paymentDto()),
|
||||
).rejects.toThrow(/must come from an ASSET/);
|
||||
});
|
||||
|
||||
it("posts Dr payable / Cr cash with sourceModule 'supplier-payment' and sourceId '<billId>:<paymentNumber>'", async () => {
|
||||
const { service, bills, suppliers, accounts, journals, payments } = build();
|
||||
bills.findById.mockResolvedValue(approvedBill());
|
||||
suppliers.findById.mockResolvedValue(supplierRow());
|
||||
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
|
||||
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
|
||||
payments.nextPaymentNumber.mockResolvedValue("PAY-2026-000007");
|
||||
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
||||
|
||||
await service.recordPayment(actor, "bill-1", paymentDto({ amount: 400 }));
|
||||
|
||||
expect(journals.createPosted).toHaveBeenCalledWith(
|
||||
actor,
|
||||
expect.objectContaining({
|
||||
journalType: "CASH_PAYMENT",
|
||||
sourceModule: "supplier-payment",
|
||||
sourceId: "bill-1:PAY-2026-000007",
|
||||
lines: [
|
||||
{ accountId: "2111", debit: 400, description: "Settle BILL-2026-000001" },
|
||||
{ accountId: "cash-1", credit: 400, description: "BANK" },
|
||||
],
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates paidAmount and flips to PAID once paidAmount >= totalAmount, all inside one transaction", async () => {
|
||||
const { service, bills, suppliers, accounts, journals, dataSource, manager } = build();
|
||||
bills.findById.mockResolvedValue(approvedBill({ totalAmount: 400, paidAmount: 0 }));
|
||||
suppliers.findById.mockResolvedValue(supplierRow());
|
||||
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
|
||||
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
|
||||
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
||||
|
||||
await service.recordPayment(actor, "bill-1", paymentDto({ amount: 400 }));
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(manager.billRepo.update).toHaveBeenCalledWith("bill-1", {
|
||||
paidAmount: 400,
|
||||
status: "PAID",
|
||||
});
|
||||
});
|
||||
|
||||
it("flips to PARTIALLY_PAID when a balance remains after the payment", async () => {
|
||||
const { service, bills, suppliers, accounts, journals, manager } = build();
|
||||
bills.findById.mockResolvedValue(approvedBill({ totalAmount: 1000, paidAmount: 0 }));
|
||||
suppliers.findById.mockResolvedValue(supplierRow());
|
||||
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
|
||||
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
|
||||
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
||||
|
||||
await service.recordPayment(actor, "bill-1", paymentDto({ amount: 400 }));
|
||||
|
||||
expect(manager.billRepo.update).toHaveBeenCalledWith("bill-1", {
|
||||
paidAmount: 400,
|
||||
status: "PARTIALLY_PAID",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the transaction's manager into journals.createPosted — the same nested-transaction trap the depreciation run guards against", async () => {
|
||||
const { service, bills, suppliers, accounts, journals, dataSource, manager } = build();
|
||||
bills.findById.mockResolvedValue(approvedBill());
|
||||
suppliers.findById.mockResolvedValue(supplierRow());
|
||||
accounts.findOne.mockResolvedValue(stubAccount("cash-1", { accountType: "ASSET" }));
|
||||
accounts.findByCode.mockResolvedValue(stubAccount("2111"));
|
||||
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
||||
|
||||
await service.recordPayment(actor, "bill-1", paymentDto());
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(journals.createPosted).toHaveBeenCalledWith(
|
||||
actor,
|
||||
expect.anything(),
|
||||
manager,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -346,23 +346,33 @@ export class PayablesService {
|
||||
});
|
||||
}
|
||||
|
||||
const entry = await this.journals.createPosted(actor, {
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { BadRequestException } from "@nestjs/common";
|
||||
|
||||
import type { ActorContext } from "../../common/current-actor.util";
|
||||
import { PayrollPostingService } from "./payroll-posting.service";
|
||||
|
||||
const actor: ActorContext = {
|
||||
employeeId: "emp-1",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
isSuperAdmin: false,
|
||||
};
|
||||
|
||||
function stubAccount(code: string) {
|
||||
return { id: `acct-${code}`, code, isActive: true, isGroup: false };
|
||||
}
|
||||
|
||||
function build() {
|
||||
const dataSource = { query: jest.fn() };
|
||||
const accounts = {
|
||||
findByCode: jest
|
||||
.fn()
|
||||
.mockImplementation(async (_org: string, code: string) => stubAccount(code)),
|
||||
};
|
||||
const journals = { findBySource: jest.fn(), createPosted: jest.fn() };
|
||||
|
||||
const service = new PayrollPostingService(
|
||||
dataSource as never,
|
||||
accounts as never,
|
||||
journals as never,
|
||||
);
|
||||
|
||||
return { service, dataSource, accounts, journals };
|
||||
}
|
||||
|
||||
const AVAILABLE_ROW = [{ ok: true }];
|
||||
|
||||
function runRow(over: Record<string, unknown> = {}) {
|
||||
return [
|
||||
{
|
||||
periodStart: "2026-07-01",
|
||||
periodEnd: "2026-07-31",
|
||||
paymentDate: "2026-08-05",
|
||||
status: "APPROVED",
|
||||
...over,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A payroll that reconciles: gross - totalDeductions = netPay, exactly.
|
||||
* allowances = gross - basic = 12000 - 9000 = 3000
|
||||
* otherDeductions = totalDeductions - incomeTax - pensionEmployee
|
||||
* = 1430 - 800 - 630 = 0
|
||||
*/
|
||||
function payslipTotalsRow(over: Record<string, unknown> = {}) {
|
||||
return [
|
||||
{
|
||||
payslipCount: 3,
|
||||
basic: 9000,
|
||||
gross: 12000,
|
||||
incomeTax: 800,
|
||||
pensionEmployee: 630,
|
||||
pensionEmployer: 840,
|
||||
totalDeductions: 1430,
|
||||
netPay: 10570,
|
||||
...over,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Wires the fixed dataSource.query call sequence postRun makes: available(),
|
||||
* loadRun(), then the payslip aggregation. */
|
||||
function wireQueries(
|
||||
dataSource: ReturnType<typeof build>["dataSource"],
|
||||
totals: unknown[] = payslipTotalsRow(),
|
||||
run: unknown[] = runRow(),
|
||||
) {
|
||||
dataSource.query
|
||||
.mockResolvedValueOnce(AVAILABLE_ROW)
|
||||
.mockResolvedValueOnce(run)
|
||||
.mockResolvedValueOnce(totals);
|
||||
}
|
||||
|
||||
describe("PayrollPostingService.postRun", () => {
|
||||
it("refuses when HR's payroll tables are not present in this database", async () => {
|
||||
const { service, dataSource, journals } = build();
|
||||
dataSource.query.mockResolvedValueOnce([{ ok: false }]);
|
||||
|
||||
await expect(service.postRun(actor, "run-1")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["DRAFT", "CALCULATED", "CANCELLED"])(
|
||||
"refuses posting a %s run — only APPROVED/PAID runs are postable",
|
||||
async (status) => {
|
||||
const { service, dataSource, journals } = build();
|
||||
dataSource.query
|
||||
.mockResolvedValueOnce(AVAILABLE_ROW)
|
||||
.mockResolvedValueOnce(runRow({ status }));
|
||||
|
||||
await expect(service.postRun(actor, "run-1")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(journals.findBySource).not.toHaveBeenCalled();
|
||||
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("allows posting a PAID run, not just APPROVED", async () => {
|
||||
const { service, dataSource, journals } = build();
|
||||
dataSource.query
|
||||
.mockResolvedValueOnce(AVAILABLE_ROW)
|
||||
.mockResolvedValueOnce(runRow({ status: "PAID" }))
|
||||
.mockResolvedValueOnce(payslipTotalsRow());
|
||||
journals.findBySource.mockResolvedValue(null);
|
||||
journals.createPosted.mockResolvedValue({
|
||||
id: "je-1",
|
||||
entryNumber: "JV-2026-000001",
|
||||
});
|
||||
|
||||
await expect(service.postRun(actor, "run-1")).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("refuses a second post — the idempotency check via journals.findBySource('hr-payroll', runId)", async () => {
|
||||
const { service, dataSource, journals } = build();
|
||||
dataSource.query
|
||||
.mockResolvedValueOnce(AVAILABLE_ROW)
|
||||
.mockResolvedValueOnce(runRow());
|
||||
journals.findBySource.mockResolvedValue({
|
||||
entryNumber: "JV-2026-000005",
|
||||
});
|
||||
|
||||
await expect(service.postRun(actor, "run-1")).rejects.toThrow(
|
||||
/already posted as JV-2026-000005/,
|
||||
);
|
||||
expect(journals.findBySource).toHaveBeenCalledWith("org-1", "hr-payroll", "run-1");
|
||||
// The payslip aggregation query is never reached once the idempotency
|
||||
// guard has already refused.
|
||||
expect(dataSource.query).toHaveBeenCalledTimes(2);
|
||||
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a run with no payslips", async () => {
|
||||
const { service, dataSource, journals } = build();
|
||||
wireQueries(dataSource, [{ payslipCount: 0 }]);
|
||||
journals.findBySource.mockResolvedValue(null);
|
||||
|
||||
await expect(service.postRun(actor, "run-1")).rejects.toThrow(
|
||||
/has no payslips/,
|
||||
);
|
||||
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aggregates from hr.payslips, not the run header — the query sums SUM(p.*) from hr.payslips, and that sum alone drives every posted figure", async () => {
|
||||
const { service, dataSource, journals } = build();
|
||||
wireQueries(dataSource);
|
||||
journals.findBySource.mockResolvedValue(null);
|
||||
journals.createPosted.mockResolvedValue({
|
||||
id: "je-1",
|
||||
entryNumber: "JV-2026-000001",
|
||||
});
|
||||
|
||||
const result = await service.postRun(actor, "run-1");
|
||||
|
||||
// The aggregation query itself reads from hr.payslips.
|
||||
const aggSql = dataSource.query.mock.calls[2][0] as string;
|
||||
expect(aggSql).toMatch(/FROM hr\.payslips/);
|
||||
// loadRun's row (the "run header" stand-in) never even exposes total
|
||||
// fields — everything posted is derivable ONLY from the payslip sum.
|
||||
expect(runRow()[0]).not.toHaveProperty("totalGross");
|
||||
|
||||
// total = gross + employer pension, straight from the payslip aggregation.
|
||||
expect(result.total).toBe(12000 + 840);
|
||||
});
|
||||
|
||||
it("derives allowances = gross - basic and otherDeductions = totalDeductions - incomeTax - pensionEmployee", async () => {
|
||||
const { service, dataSource, journals } = build();
|
||||
wireQueries(
|
||||
dataSource,
|
||||
payslipTotalsRow({
|
||||
basic: 9000,
|
||||
gross: 12500, // allowances = 3500
|
||||
incomeTax: 800,
|
||||
pensionEmployee: 630,
|
||||
totalDeductions: 1530, // otherDeductions = 1530-800-630 = 100
|
||||
netPay: 10970, // 12500-1530
|
||||
}),
|
||||
);
|
||||
journals.findBySource.mockResolvedValue(null);
|
||||
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
||||
|
||||
await service.postRun(actor, "run-1");
|
||||
|
||||
const lines = journals.createPosted.mock.calls[0][1].lines as {
|
||||
accountId: string;
|
||||
debit?: number;
|
||||
credit?: number;
|
||||
description: string;
|
||||
}[];
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({
|
||||
accountId: "acct-5120",
|
||||
debit: 3500,
|
||||
description: "Allowances and other earnings",
|
||||
}),
|
||||
);
|
||||
expect(lines).toContainEqual(
|
||||
expect.objectContaining({
|
||||
accountId: "acct-2160",
|
||||
credit: 100,
|
||||
description: "Other deductions withheld",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("employer pension is BOTH a debit (expense) AND part of the pension-payable credit — the classic sign error the identity check guards against", async () => {
|
||||
const { service, dataSource, journals } = build();
|
||||
wireQueries(dataSource); // pensionEmployee 630, pensionEmployer 840
|
||||
journals.findBySource.mockResolvedValue(null);
|
||||
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
||||
|
||||
await service.postRun(actor, "run-1");
|
||||
|
||||
const lines = journals.createPosted.mock.calls[0][1].lines as {
|
||||
accountId: string;
|
||||
debit?: number;
|
||||
credit?: number;
|
||||
}[];
|
||||
|
||||
const pensionExpenseLine = lines.find((l) => l.accountId === "acct-5140");
|
||||
expect(pensionExpenseLine).toEqual(
|
||||
expect.objectContaining({ debit: 840 }),
|
||||
);
|
||||
|
||||
const pensionPayableLine = lines.find((l) => l.accountId === "acct-2122");
|
||||
// employee (630) + employer (840) = 1470, both rolled into the one
|
||||
// credit to the fund.
|
||||
expect(pensionPayableLine).toEqual(
|
||||
expect.objectContaining({ credit: 1470 }),
|
||||
);
|
||||
|
||||
// The entry still balances by CONSTRUCTION (dual role deliberately, not
|
||||
// by an accidental sign cancellation elsewhere).
|
||||
const totalDebit = lines.reduce((s, l) => s + (l.debit ?? 0), 0);
|
||||
const totalCredit = lines.reduce((s, l) => s + (l.credit ?? 0), 0);
|
||||
expect(totalDebit).toBe(totalCredit);
|
||||
});
|
||||
|
||||
it("omits zero-value lines from the journal entirely", async () => {
|
||||
const { service, dataSource, journals } = build();
|
||||
wireQueries(
|
||||
dataSource,
|
||||
payslipTotalsRow({
|
||||
basic: 12000,
|
||||
gross: 12000, // allowances = 0 -> omitted
|
||||
incomeTax: 800,
|
||||
pensionEmployee: 630,
|
||||
totalDeductions: 1430, // otherDeductions = 0 -> omitted
|
||||
netPay: 10570,
|
||||
}),
|
||||
);
|
||||
journals.findBySource.mockResolvedValue(null);
|
||||
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
||||
|
||||
await service.postRun(actor, "run-1");
|
||||
|
||||
const lines = journals.createPosted.mock.calls[0][1].lines as {
|
||||
accountId: string;
|
||||
}[];
|
||||
expect(lines.some((l) => l.accountId === "acct-5120")).toBe(false); // allowances
|
||||
expect(lines.some((l) => l.accountId === "acct-2160")).toBe(false); // other deductions
|
||||
expect(lines.length).toBe(5); // basic, pension-employer, tax, pension-payable, salaries-payable
|
||||
});
|
||||
|
||||
it("dates the entry the period END, not the payment date", async () => {
|
||||
const { service, dataSource, journals } = build();
|
||||
wireQueries(dataSource, payslipTotalsRow(), runRow({
|
||||
periodEnd: "2026-07-31",
|
||||
paymentDate: "2026-08-05",
|
||||
}));
|
||||
journals.findBySource.mockResolvedValue(null);
|
||||
journals.createPosted.mockResolvedValue({ id: "je-1", entryNumber: "JV-1" });
|
||||
|
||||
await service.postRun(actor, "run-1");
|
||||
|
||||
expect(journals.createPosted.mock.calls[0][1]).toMatchObject({
|
||||
entryDate: "2026-07-31",
|
||||
sourceModule: "hr-payroll",
|
||||
sourceId: "run-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PayrollPostingService — assertPayrollIdentity (private)", () => {
|
||||
type Totals = {
|
||||
gross: number;
|
||||
totalDeductions: number;
|
||||
netPay: number;
|
||||
allowances: number;
|
||||
otherDeductions: number;
|
||||
};
|
||||
|
||||
function identityCheck(service: PayrollPostingService) {
|
||||
return (
|
||||
service as never as { assertPayrollIdentity: (t: Totals) => void }
|
||||
).assertPayrollIdentity.bind(service);
|
||||
}
|
||||
|
||||
it("passes when gross - totalDeductions == netPay exactly", () => {
|
||||
const { service } = build();
|
||||
expect(() =>
|
||||
identityCheck(service)({
|
||||
gross: 12000,
|
||||
totalDeductions: 1430,
|
||||
netPay: 10570,
|
||||
allowances: 3000,
|
||||
otherDeductions: 0,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("tolerates a sub-half-cent discrepancy (< 0.005)", () => {
|
||||
const { service } = build();
|
||||
expect(() =>
|
||||
identityCheck(service)({
|
||||
gross: 12000,
|
||||
totalDeductions: 1430,
|
||||
netPay: 10570.004,
|
||||
allowances: 3000,
|
||||
otherDeductions: 0,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a genuine mismatch between gross - totalDeductions and netPay", () => {
|
||||
const { service } = build();
|
||||
expect(() =>
|
||||
identityCheck(service)({
|
||||
gross: 12000,
|
||||
totalDeductions: 1430,
|
||||
netPay: 10600, // should be 10570
|
||||
allowances: 3000,
|
||||
otherDeductions: 0,
|
||||
}),
|
||||
).toThrow(/does not reconcile/);
|
||||
});
|
||||
|
||||
it("rejects negative allowances — basic salary exceeding gross pay", () => {
|
||||
const { service } = build();
|
||||
expect(() =>
|
||||
identityCheck(service)({
|
||||
gross: 9000,
|
||||
totalDeductions: 1000,
|
||||
netPay: 8000,
|
||||
allowances: -500, // basic > gross
|
||||
otherDeductions: 0,
|
||||
}),
|
||||
).toThrow(/Basic salary exceeds gross pay/);
|
||||
});
|
||||
|
||||
it("rejects negative otherDeductions — income tax and pension exceeding total deductions", () => {
|
||||
const { service } = build();
|
||||
expect(() =>
|
||||
identityCheck(service)({
|
||||
gross: 9000,
|
||||
totalDeductions: 500,
|
||||
netPay: 8500,
|
||||
allowances: 1000,
|
||||
otherDeductions: -200,
|
||||
}),
|
||||
).toThrow(/Income tax and pension exceed total deductions/);
|
||||
});
|
||||
|
||||
it("checks the identity BEFORE any account is resolved or anything is posted", async () => {
|
||||
const { service, dataSource, journals, accounts } = build();
|
||||
wireQueries(
|
||||
dataSource,
|
||||
payslipTotalsRow({ netPay: 999999 }), // deliberately broken identity
|
||||
);
|
||||
journals.findBySource.mockResolvedValue(null);
|
||||
|
||||
await expect(service.postRun(actor, "run-1")).rejects.toThrow(
|
||||
/does not reconcile/,
|
||||
);
|
||||
expect(accounts.findByCode).not.toHaveBeenCalled();
|
||||
expect(journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { Nack } from "@golevelup/nestjs-rabbitmq";
|
||||
import { PaymentReferenceType, PaymentService, ProviderMethod } from "@edr/types";
|
||||
import type { PaymentSucceededEvent } from "@edr/types";
|
||||
|
||||
import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||
import type { PaymentPostingResult } from "./revenue-posting.service";
|
||||
|
||||
/** Synthetic payment event — no resemblance to any real transaction. */
|
||||
function syntheticEvent(
|
||||
overrides: Partial<PaymentSucceededEvent> = {},
|
||||
): PaymentSucceededEvent {
|
||||
return {
|
||||
version: 1,
|
||||
eventId: "evt-synthetic-1",
|
||||
eventType: "payment.succeeded",
|
||||
occurredAt: "2026-07-10T00:00:00.000Z",
|
||||
service: PaymentService.FREIGHT,
|
||||
intentId: "intent-synthetic-1",
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: "shipment-synthetic-1",
|
||||
merchantOrderId: "order-synthetic-1",
|
||||
provider: ProviderMethod.CARD,
|
||||
amountMinor: 100,
|
||||
currency: "ETB",
|
||||
paidAt: "2026-07-10T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeManager() {
|
||||
return {};
|
||||
}
|
||||
|
||||
function makeInbound() {
|
||||
return {
|
||||
claim: jest.fn(),
|
||||
settle: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function makePosting() {
|
||||
return { postPaymentReceipt: jest.fn() };
|
||||
}
|
||||
|
||||
function makeDataSource() {
|
||||
const manager = makeManager();
|
||||
return {
|
||||
transaction: jest.fn((cb: (m: unknown) => unknown) => cb(manager)),
|
||||
};
|
||||
}
|
||||
|
||||
describe("PaymentEventsConsumer.handle", () => {
|
||||
let inbound: ReturnType<typeof makeInbound>;
|
||||
let posting: ReturnType<typeof makePosting>;
|
||||
let dataSource: ReturnType<typeof makeDataSource>;
|
||||
let consumer: PaymentEventsConsumer;
|
||||
|
||||
beforeEach(() => {
|
||||
inbound = makeInbound();
|
||||
posting = makePosting();
|
||||
dataSource = makeDataSource();
|
||||
consumer = new PaymentEventsConsumer(
|
||||
inbound as never,
|
||||
posting as never,
|
||||
dataSource as never,
|
||||
);
|
||||
});
|
||||
|
||||
it("dead-letters an event with no eventId — cannot dedupe without a key", async () => {
|
||||
const event = syntheticEvent({ eventId: undefined as unknown as string });
|
||||
|
||||
const result = await consumer.handle(event);
|
||||
|
||||
expect(result).toBeInstanceOf(Nack);
|
||||
expect((result as Nack).requeue).toBe(false);
|
||||
expect(inbound.claim).not.toHaveBeenCalled();
|
||||
expect(posting.postPaymentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("acks and skips without posting when claim() returns null — a redelivery already seen", async () => {
|
||||
inbound.claim.mockResolvedValue(null);
|
||||
|
||||
const result = await consumer.handle(syntheticEvent());
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(posting.postPaymentReceipt).not.toHaveBeenCalled();
|
||||
expect(inbound.settle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dead-letters when the claim insert itself fails — the broker keeps the only copy", async () => {
|
||||
dataSource.transaction.mockImplementation(() => {
|
||||
throw new Error("connection terminated");
|
||||
});
|
||||
|
||||
const result = await consumer.handle(syntheticEvent());
|
||||
|
||||
expect(result).toBeInstanceOf(Nack);
|
||||
expect((result as Nack).requeue).toBe(false);
|
||||
expect(posting.postPaymentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("acks a successful claim followed by a POSTED outcome, and records it", async () => {
|
||||
inbound.claim.mockResolvedValue("inbound-1");
|
||||
posting.postPaymentReceipt.mockResolvedValue({
|
||||
outcome: "POSTED",
|
||||
journalEntryId: "entry-1",
|
||||
entryNumber: "CR-2026-0001",
|
||||
} satisfies PaymentPostingResult);
|
||||
|
||||
const result = await consumer.handle(syntheticEvent());
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(inbound.settle).toHaveBeenCalledWith("inbound-1", "POSTED", {
|
||||
journalEntryId: "entry-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("acks a successful claim followed by a SKIPPED outcome, and records it", async () => {
|
||||
inbound.claim.mockResolvedValue("inbound-1");
|
||||
posting.postPaymentReceipt.mockResolvedValue({
|
||||
outcome: "SKIPPED",
|
||||
reason: "already captured by opening balances",
|
||||
} satisfies PaymentPostingResult);
|
||||
|
||||
const result = await consumer.handle(syntheticEvent());
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(inbound.settle).toHaveBeenCalledWith("inbound-1", "SKIPPED", {
|
||||
error: "already captured by opening balances",
|
||||
});
|
||||
});
|
||||
|
||||
it("acks — never nacks — a successful claim followed by a FAILED posting outcome", async () => {
|
||||
inbound.claim.mockResolvedValue("inbound-1");
|
||||
posting.postPaymentReceipt.mockResolvedValue({
|
||||
outcome: "FAILED",
|
||||
reason: "period is closed",
|
||||
} satisfies PaymentPostingResult);
|
||||
|
||||
const result = await consumer.handle(syntheticEvent());
|
||||
|
||||
// The deliberate part: a FAILED posting outcome is still acked (recorded
|
||||
// for manual replay), NOT nacked/dead-lettered.
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).not.toBeInstanceOf(Nack);
|
||||
expect(inbound.settle).toHaveBeenCalledWith("inbound-1", "FAILED", {
|
||||
error: "period is closed",
|
||||
});
|
||||
});
|
||||
|
||||
it("acks even when posting itself throws, and records the row as FAILED best-effort", async () => {
|
||||
inbound.claim.mockResolvedValue("inbound-1");
|
||||
posting.postPaymentReceipt.mockRejectedValue(new Error("unexpected crash"));
|
||||
|
||||
const result = await consumer.handle(syntheticEvent());
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).not.toBeInstanceOf(Nack);
|
||||
expect(inbound.settle).toHaveBeenCalledWith("inbound-1", "FAILED", {
|
||||
error: "unexpected crash",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { PaymentReferenceType, PaymentService, ProviderMethod } from "@edr/types";
|
||||
import type { PaymentFailedEvent, PaymentSucceededEvent } from "@edr/types";
|
||||
|
||||
import { RevenuePostingService } from "./revenue-posting.service";
|
||||
import type { ActorContext } from "../../common/current-actor.util";
|
||||
|
||||
/**
|
||||
* All test fixtures below are synthetic — invented account codes, round
|
||||
* amounts and made-up organization/revenue-key names. Nothing here resembles
|
||||
* a real organization's revenue figures.
|
||||
*/
|
||||
|
||||
const ORG_ID = "org-synthetic-1";
|
||||
|
||||
function actor(overrides: Partial<ActorContext> = {}): ActorContext {
|
||||
return {
|
||||
employeeId: null,
|
||||
userId: "",
|
||||
organizationId: ORG_ID,
|
||||
isSuperAdmin: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function syntheticSucceeded(
|
||||
overrides: Partial<PaymentSucceededEvent> = {},
|
||||
): PaymentSucceededEvent {
|
||||
return {
|
||||
version: 1,
|
||||
eventId: "evt-synthetic-1",
|
||||
eventType: "payment.succeeded",
|
||||
occurredAt: "2026-07-10T00:00:00.000Z",
|
||||
service: PaymentService.FREIGHT,
|
||||
intentId: "intent-synthetic-1",
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: "shipment-synthetic-1",
|
||||
merchantOrderId: "order-synthetic-1",
|
||||
provider: ProviderMethod.CARD,
|
||||
amountMinor: 500,
|
||||
currency: "ETB",
|
||||
paidAt: "2026-07-10T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function syntheticFailed(
|
||||
overrides: Partial<PaymentFailedEvent> = {},
|
||||
): PaymentFailedEvent {
|
||||
return {
|
||||
version: 1,
|
||||
eventId: "evt-synthetic-2",
|
||||
eventType: "payment.failed",
|
||||
occurredAt: "2026-07-10T00:00:00.000Z",
|
||||
service: PaymentService.FREIGHT,
|
||||
intentId: "intent-synthetic-2",
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: "shipment-synthetic-2",
|
||||
merchantOrderId: "order-synthetic-2",
|
||||
provider: ProviderMethod.CARD,
|
||||
amountMinor: 500,
|
||||
currency: "ETB",
|
||||
failureCode: "DECLINED",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function account(id: string, code: string) {
|
||||
return { id, code, name: { am: "", en: "" } };
|
||||
}
|
||||
|
||||
function makeDeps() {
|
||||
const mappings = { findMatch: jest.fn() };
|
||||
const accounts = { findByCode: jest.fn(), findOne: jest.fn() };
|
||||
const journals = { createPosted: jest.fn() };
|
||||
const projection = { revenueByKey: jest.fn() };
|
||||
const cutover = { cutoverDateFor: jest.fn().mockResolvedValue(null) };
|
||||
return { mappings, accounts, journals, projection, cutover };
|
||||
}
|
||||
|
||||
function build(deps: ReturnType<typeof makeDeps>) {
|
||||
return new RevenuePostingService(
|
||||
deps.mappings as never,
|
||||
deps.accounts as never,
|
||||
deps.journals as never,
|
||||
deps.projection as never,
|
||||
deps.cutover as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RevenuePostingService.postPaymentReceipt", () => {
|
||||
let deps: ReturnType<typeof makeDeps>;
|
||||
let service: RevenuePostingService;
|
||||
|
||||
beforeEach(() => {
|
||||
deps = makeDeps();
|
||||
service = build(deps);
|
||||
deps.accounts.findByCode.mockImplementation(
|
||||
(_org: string, code: string) => {
|
||||
if (code === "1114") return Promise.resolve(account("acct-clearing", "1114"));
|
||||
if (code === "1121") return Promise.resolve(account("acct-recv-freight", "1121"));
|
||||
if (code === "1122")
|
||||
return Promise.resolve(account("acct-recv-passenger", "1122"));
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("SKIPs non-'payment.succeeded' events and writes no journal", async () => {
|
||||
const result = await service.postPaymentReceipt(actor(), syntheticFailed());
|
||||
|
||||
expect(result.outcome).toBe("SKIPPED");
|
||||
expect(deps.journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts normally when cutoverDate is null — no boundary set", async () => {
|
||||
deps.cutover.cutoverDateFor.mockResolvedValue(null);
|
||||
deps.journals.createPosted.mockResolvedValue({
|
||||
id: "entry-1",
|
||||
entryNumber: "CR-2026-0001",
|
||||
});
|
||||
|
||||
const result = await service.postPaymentReceipt(actor(), syntheticSucceeded());
|
||||
|
||||
expect(result.outcome).toBe("POSTED");
|
||||
expect(deps.journals.createPosted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("SKIPs a payment settled before the cutover date — already captured by opening balances", async () => {
|
||||
deps.cutover.cutoverDateFor.mockResolvedValue("2026-08-01");
|
||||
|
||||
const result = await service.postPaymentReceipt(
|
||||
actor(),
|
||||
syntheticSucceeded({ paidAt: "2026-07-01T00:00:00.000Z" }),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe("SKIPPED");
|
||||
if (result.outcome === "SKIPPED") {
|
||||
expect(result.reason).toMatch(/before the cutover/i);
|
||||
}
|
||||
expect(deps.journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts a payment settled ON or after the cutover date", async () => {
|
||||
deps.cutover.cutoverDateFor.mockResolvedValue("2026-07-01");
|
||||
deps.journals.createPosted.mockResolvedValue({
|
||||
id: "entry-1",
|
||||
entryNumber: "CR-2026-0001",
|
||||
});
|
||||
|
||||
const result = await service.postPaymentReceipt(
|
||||
actor(),
|
||||
syntheticSucceeded({ paidAt: "2026-07-01T00:00:00.000Z" }),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe("POSTED");
|
||||
});
|
||||
|
||||
it("FAILs a non-ETB event rather than silently converting at a guessed rate", async () => {
|
||||
const result = await service.postPaymentReceipt(
|
||||
actor(),
|
||||
syntheticSucceeded({ currency: "USD" }),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe("FAILED");
|
||||
if (result.outcome === "FAILED") {
|
||||
expect(result.reason).toMatch(/USD/);
|
||||
expect(result.reason).toMatch(/ETB/);
|
||||
}
|
||||
expect(deps.journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FAILs a non-positive amountMinor (which carries MAJOR units on this path, despite the name)", async () => {
|
||||
const result = await service.postPaymentReceipt(
|
||||
actor(),
|
||||
syntheticSucceeded({ amountMinor: 0 }),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe("FAILED");
|
||||
if (result.outcome === "FAILED") {
|
||||
expect(result.reason).toMatch(/non-positive/i);
|
||||
}
|
||||
expect(deps.journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("FAILs a negative amountMinor too", async () => {
|
||||
const result = await service.postPaymentReceipt(
|
||||
actor(),
|
||||
syntheticSucceeded({ amountMinor: -50 }),
|
||||
);
|
||||
|
||||
expect(result.outcome).toBe("FAILED");
|
||||
});
|
||||
|
||||
it("posts Dr 1114 gateway-clearing / Cr 1121 receivable for a FREIGHT service payment, with the idempotency key set", async () => {
|
||||
deps.journals.createPosted.mockResolvedValue({
|
||||
id: "entry-1",
|
||||
entryNumber: "CR-2026-0001",
|
||||
});
|
||||
|
||||
const event = syntheticSucceeded({ service: PaymentService.FREIGHT, amountMinor: 750 });
|
||||
const result = await service.postPaymentReceipt(actor(), event);
|
||||
|
||||
expect(result.outcome).toBe("POSTED");
|
||||
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
|
||||
expect(postedDto.sourceModule).toBe("payment");
|
||||
expect(postedDto.sourceId).toBe(event.eventId);
|
||||
const debitLine = postedDto.lines.find((l: { debit?: number }) => l.debit);
|
||||
const creditLine = postedDto.lines.find((l: { credit?: number }) => l.credit);
|
||||
expect(debitLine.accountId).toBe("acct-clearing");
|
||||
expect(debitLine.debit).toBe(750);
|
||||
expect(creditLine.accountId).toBe("acct-recv-freight");
|
||||
expect(creditLine.credit).toBe(750);
|
||||
});
|
||||
|
||||
it("posts Cr 1122 receivable for a PASSENGER service payment", async () => {
|
||||
deps.journals.createPosted.mockResolvedValue({
|
||||
id: "entry-1",
|
||||
entryNumber: "CR-2026-0001",
|
||||
});
|
||||
|
||||
const event = syntheticSucceeded({ service: PaymentService.PASSENGER });
|
||||
await service.postPaymentReceipt(actor(), event);
|
||||
|
||||
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
|
||||
const creditLine = postedDto.lines.find((l: { credit?: number }) => l.credit);
|
||||
expect(creditLine.accountId).toBe("acct-recv-passenger");
|
||||
});
|
||||
|
||||
it("catches any exception during posting and returns a FAILED result instead of throwing", async () => {
|
||||
deps.journals.createPosted.mockRejectedValue(
|
||||
new Error("period 2026-07 is closed"),
|
||||
);
|
||||
|
||||
const result = await service.postPaymentReceipt(actor(), syntheticSucceeded());
|
||||
|
||||
expect(result.outcome).toBe("FAILED");
|
||||
if (result.outcome === "FAILED") {
|
||||
expect(result.reason).toBe("period 2026-07 is closed");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not throw/reject even though createPosted rejected", async () => {
|
||||
deps.journals.createPosted.mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(
|
||||
service.postPaymentReceipt(actor(), syntheticSucceeded()),
|
||||
).resolves.toMatchObject({ outcome: "FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("RevenuePostingService.recognizeRevenue", () => {
|
||||
let deps: ReturnType<typeof makeDeps>;
|
||||
let service: RevenuePostingService;
|
||||
|
||||
const receivableFreight = account("acct-recv-freight", "1121");
|
||||
const unclassified = account("acct-4900", "4900");
|
||||
|
||||
beforeEach(() => {
|
||||
deps = makeDeps();
|
||||
service = build(deps);
|
||||
deps.accounts.findByCode.mockImplementation(
|
||||
(_org: string, code: string) => {
|
||||
if (code === "1121") return Promise.resolve(receivableFreight);
|
||||
if (code === "1122") return Promise.resolve(account("acct-recv-passenger", "1122"));
|
||||
if (code === "4900") return Promise.resolve(unclassified);
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("filters buckets to the requested sourceModule only", async () => {
|
||||
deps.projection.revenueByKey.mockResolvedValue([
|
||||
{
|
||||
sourceModule: "freight",
|
||||
revenueKey: "TEST-FREIGHT-CHARGE",
|
||||
currency: "ETB",
|
||||
amount: 1000,
|
||||
documentCount: 1,
|
||||
postable: true,
|
||||
},
|
||||
{
|
||||
sourceModule: "passenger",
|
||||
revenueKey: "TEST-PASSENGER-FARE",
|
||||
currency: "ETB",
|
||||
amount: 2000,
|
||||
documentCount: 1,
|
||||
postable: true,
|
||||
},
|
||||
]);
|
||||
deps.mappings.findMatch.mockResolvedValue(null);
|
||||
deps.journals.createPosted.mockResolvedValue({
|
||||
id: "entry-1",
|
||||
entryNumber: "SALES-2026-0001",
|
||||
});
|
||||
|
||||
const result = await service.recognizeRevenue(actor(), {
|
||||
sourceModule: "freight",
|
||||
period: "2026-07",
|
||||
});
|
||||
|
||||
expect(result.total).toBe(1000);
|
||||
expect(deps.mappings.findMatch).toHaveBeenCalledWith(
|
||||
ORG_ID,
|
||||
"freight",
|
||||
"TEST-FREIGHT-CHARGE",
|
||||
);
|
||||
expect(deps.mappings.findMatch).not.toHaveBeenCalledWith(
|
||||
ORG_ID,
|
||||
"freight",
|
||||
"TEST-PASSENGER-FARE",
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes non-ETB and non-positive buckets into excluded[] instead of failing the whole run", async () => {
|
||||
deps.projection.revenueByKey.mockResolvedValue([
|
||||
{
|
||||
sourceModule: "freight",
|
||||
revenueKey: "TEST-USD-CHARGE",
|
||||
currency: "USD",
|
||||
amount: 500,
|
||||
documentCount: 1,
|
||||
postable: false,
|
||||
},
|
||||
{
|
||||
sourceModule: "freight",
|
||||
revenueKey: "TEST-ZERO-CHARGE",
|
||||
currency: "ETB",
|
||||
amount: 0,
|
||||
documentCount: 1,
|
||||
postable: true,
|
||||
},
|
||||
{
|
||||
sourceModule: "freight",
|
||||
revenueKey: "TEST-GOOD-CHARGE",
|
||||
currency: "ETB",
|
||||
amount: 300,
|
||||
documentCount: 1,
|
||||
postable: true,
|
||||
},
|
||||
]);
|
||||
deps.mappings.findMatch.mockResolvedValue(null);
|
||||
deps.journals.createPosted.mockResolvedValue({
|
||||
id: "entry-1",
|
||||
entryNumber: "SALES-2026-0001",
|
||||
});
|
||||
|
||||
const result = await service.recognizeRevenue(actor(), {
|
||||
sourceModule: "freight",
|
||||
period: "2026-07",
|
||||
});
|
||||
|
||||
expect(result.posted).toBe(true);
|
||||
expect(result.total).toBe(300);
|
||||
expect(result.excluded).toHaveLength(2);
|
||||
expect(result.excluded.map((e) => e.revenueKey).sort()).toEqual(
|
||||
["TEST-USD-CHARGE", "TEST-ZERO-CHARGE"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns posted:false with no journal call when every bucket is excluded", async () => {
|
||||
deps.projection.revenueByKey.mockResolvedValue([
|
||||
{
|
||||
sourceModule: "freight",
|
||||
revenueKey: "TEST-USD-CHARGE",
|
||||
currency: "USD",
|
||||
amount: 500,
|
||||
documentCount: 1,
|
||||
postable: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.recognizeRevenue(actor(), {
|
||||
sourceModule: "freight",
|
||||
period: "2026-07",
|
||||
});
|
||||
|
||||
expect(result.posted).toBe(false);
|
||||
expect(deps.journals.createPosted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back an unmapped revenue key to 4900 Unclassified Revenue and logs a warning, without failing", async () => {
|
||||
const warnSpy = jest.spyOn(Logger.prototype, "warn").mockImplementation(() => undefined);
|
||||
deps.projection.revenueByKey.mockResolvedValue([
|
||||
{
|
||||
sourceModule: "freight",
|
||||
revenueKey: "TEST-UNMAPPED-CHARGE",
|
||||
currency: "ETB",
|
||||
amount: 400,
|
||||
documentCount: 1,
|
||||
postable: true,
|
||||
},
|
||||
]);
|
||||
deps.mappings.findMatch.mockResolvedValue(null);
|
||||
deps.journals.createPosted.mockResolvedValue({
|
||||
id: "entry-1",
|
||||
entryNumber: "SALES-2026-0001",
|
||||
});
|
||||
|
||||
const result = await service.recognizeRevenue(actor(), {
|
||||
sourceModule: "freight",
|
||||
period: "2026-07",
|
||||
});
|
||||
|
||||
expect(result.posted).toBe(true);
|
||||
expect(result.lines).toHaveLength(1);
|
||||
expect(result.lines[0].accountCode).toBe("4900");
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("summarizes multiple revenue keys mapped to the same account into ONE journal line", async () => {
|
||||
deps.projection.revenueByKey.mockResolvedValue([
|
||||
{
|
||||
sourceModule: "freight",
|
||||
revenueKey: "TEST-CHARGE-A",
|
||||
currency: "ETB",
|
||||
amount: 100,
|
||||
documentCount: 1,
|
||||
postable: true,
|
||||
},
|
||||
{
|
||||
sourceModule: "freight",
|
||||
revenueKey: "TEST-CHARGE-B",
|
||||
currency: "ETB",
|
||||
amount: 250,
|
||||
documentCount: 1,
|
||||
postable: true,
|
||||
},
|
||||
]);
|
||||
const revenueAccount = account("acct-revenue-x", "4100");
|
||||
deps.mappings.findMatch.mockImplementation(
|
||||
(_org: string, _src: string, key: string) =>
|
||||
Promise.resolve({ accountId: revenueAccount.id, matchValue: key }),
|
||||
);
|
||||
deps.accounts.findOne.mockResolvedValue(revenueAccount);
|
||||
deps.journals.createPosted.mockResolvedValue({
|
||||
id: "entry-1",
|
||||
entryNumber: "SALES-2026-0001",
|
||||
});
|
||||
|
||||
const result = await service.recognizeRevenue(actor(), {
|
||||
sourceModule: "freight",
|
||||
period: "2026-07",
|
||||
});
|
||||
|
||||
expect(result.lines).toHaveLength(1);
|
||||
expect(result.lines[0].accountCode).toBe("4100");
|
||||
expect(result.lines[0].amount).toBe(350);
|
||||
expect(result.lines[0].revenueKey).toContain("TEST-CHARGE-A");
|
||||
expect(result.lines[0].revenueKey).toContain("TEST-CHARGE-B");
|
||||
|
||||
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
|
||||
const revenueCreditLines = postedDto.lines.filter(
|
||||
(l: { accountId: string }) => l.accountId === revenueAccount.id,
|
||||
);
|
||||
expect(revenueCreditLines).toHaveLength(1);
|
||||
expect(revenueCreditLines[0].credit).toBe(350);
|
||||
});
|
||||
|
||||
it("dates the entry the LAST DAY OF THE PERIOD, not today", async () => {
|
||||
// "Today" is deliberately a different month than the period being
|
||||
// recognized, so a bug that used `new Date()` for entryDate would fail
|
||||
// this assertion.
|
||||
jest.useFakeTimers().setSystemTime(new Date("2026-01-15T00:00:00.000Z"));
|
||||
|
||||
deps.projection.revenueByKey.mockResolvedValue([
|
||||
{
|
||||
sourceModule: "freight",
|
||||
revenueKey: "TEST-CHARGE-A",
|
||||
currency: "ETB",
|
||||
amount: 100,
|
||||
documentCount: 1,
|
||||
postable: true,
|
||||
},
|
||||
]);
|
||||
deps.mappings.findMatch.mockResolvedValue(null);
|
||||
deps.journals.createPosted.mockResolvedValue({
|
||||
id: "entry-1",
|
||||
entryNumber: "SALES-2026-0001",
|
||||
});
|
||||
|
||||
await service.recognizeRevenue(actor(), {
|
||||
sourceModule: "freight",
|
||||
period: "2026-07",
|
||||
});
|
||||
|
||||
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
|
||||
expect(postedDto.entryDate).toBe("2026-07-31");
|
||||
});
|
||||
|
||||
it("Dr's the receivable control account for the total and Cr's each revenue line", async () => {
|
||||
deps.projection.revenueByKey.mockResolvedValue([
|
||||
{
|
||||
sourceModule: "freight",
|
||||
revenueKey: "TEST-CHARGE-A",
|
||||
currency: "ETB",
|
||||
amount: 100,
|
||||
documentCount: 1,
|
||||
postable: true,
|
||||
},
|
||||
]);
|
||||
deps.mappings.findMatch.mockResolvedValue(null);
|
||||
deps.journals.createPosted.mockResolvedValue({
|
||||
id: "entry-1",
|
||||
entryNumber: "SALES-2026-0001",
|
||||
});
|
||||
|
||||
await service.recognizeRevenue(actor(), {
|
||||
sourceModule: "freight",
|
||||
period: "2026-07",
|
||||
});
|
||||
|
||||
const [, postedDto] = deps.journals.createPosted.mock.calls[0];
|
||||
const receivableLine = postedDto.lines.find(
|
||||
(l: { accountId: string }) => l.accountId === receivableFreight.id,
|
||||
);
|
||||
expect(receivableLine.debit).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -85,7 +85,11 @@ export function AssetsPage() {
|
||||
</>
|
||||
)}
|
||||
{can(FINANCE_PERMS.asset.depreciate) && (
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & post
|
||||
</Button>
|
||||
) : (
|
||||
<Tooltip label="Approving is a separate authorisation from entering the bill. Ask someone who holds it.">
|
||||
<Button disabled>Approve & post</Button>
|
||||
<Button data-testid="bill-approve-btn" disabled>
|
||||
Approve & post
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
{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()}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -249,30 +249,64 @@ confirmed but the guard should be re-read at test-writing time.
|
||||
|
||||
## 4. `data-testid` prerequisite checklist
|
||||
|
||||
**Current state:** only nav-level testids exist (`nav-item-*`, `nav-group-*` from the redesign).
|
||||
**Zero on any feature page.** This is the blocker that stalled the passenger effort; treat it as a
|
||||
blocker, not a footnote. Note `SpotlightSearchProps` rejects `data-testid` — address the palette
|
||||
input by placeholder.
|
||||
**Landed** (this pass). The inventory below was corrected against the real components while wiring
|
||||
it in — several entries in the original draft named the wrong file or an element that doesn't exist
|
||||
in the shape assumed; those are noted inline. `SpotlightSearchProps` still rejects `data-testid` —
|
||||
the palette input is addressed by placeholder, not a testid.
|
||||
|
||||
**HR (`apps/edr-hr-web/src/features/**`)**
|
||||
- `leave/RequestModal.tsx`: `leave-request-type`, `leave-request-dates`, `leave-request-submit`
|
||||
- `leave/LeaveApprovalsPage.tsx`: `leave-approval-row-<id>`, `leave-approve-btn-<id>`, `leave-reject-btn-<id>`
|
||||
- `attendance/*`: `attendance-clock-in`, `attendance-clock-out`, `attendance-regularization-row-<id>`
|
||||
- `payroll/PayrollRunsPage.tsx`: `payroll-run-row-<id>`, `payroll-calculate-btn`, `payroll-approve-btn`, `payroll-mark-paid-btn`
|
||||
- `appraisal/*`: `appraisal-criterion-<key>`, `appraisal-weight-total`, `appraisal-submit-self`, `appraisal-submit-manager`
|
||||
- `recruitment/ApplicationDrawer.tsx`: `recruitment-stage-select`, `recruitment-hire-btn`
|
||||
- `reports/ReportsPage.tsx`: `report-row-<key>`, `report-export-btn`
|
||||
- `leave/components/RequestLeaveModal.tsx` (not `leave/RequestModal.tsx` — that file doesn't exist):
|
||||
`leave-request-type`, `leave-request-start-date`, `leave-request-end-date` (the pair sits inside a
|
||||
`leave-request-dates` group), `leave-request-submit`. Required a `data-testid` pass-through prop
|
||||
added to the shared `EthiopianDateInput` component, which didn't forward one.
|
||||
- `leave/LeaveApprovalsPage.tsx`: `leave-approval-row-<id>`, `leave-approve-btn-<id>`,
|
||||
`leave-reject-btn-<id>`
|
||||
- `attendance/components/ClockWidget.tsx`: `attendance-clock-in` / `attendance-clock-out` — one
|
||||
button, testid set conditionally on state, not two coexisting elements.
|
||||
- `attendance/AttendanceApprovalsPage.tsx`: `attendance-regularization-row-<id>`,
|
||||
`attendance-regularization-approve-btn-<id>`, `attendance-regularization-reject-btn-<id>` (the
|
||||
shared `Actions` component also renders the overtime tab's rows, so these testids appear there too
|
||||
— harmless, just note it if a spec ever needs to disambiguate).
|
||||
- `attendance/MyAttendancePage.tsx` (a **second**, distinct "regularization" UI — the employee's own
|
||||
history, not the approval queue above): `attendance-record-row-<id>`,
|
||||
`attendance-my-regularization-row-<id>`.
|
||||
- `payroll/PayrollRunsPage.tsx`: `payroll-run-row-<id>`, and — corrected from the original bare
|
||||
names, since these render once per row — `payroll-calculate-btn-<id>`, `payroll-approve-btn-<id>`,
|
||||
`payroll-mark-paid-btn-<id>`.
|
||||
- `appraisal/components/ScoringForm.tsx` (shared by both self- and manager-review — required adding
|
||||
a new `submitTestId?` prop, threaded from each caller): `appraisal-criterion-<code>` (keyed by the
|
||||
criterion's business `code`, not its DB id), `appraisal-weight-total`,
|
||||
`appraisal-submit-self`/`appraisal-submit-manager` (set via the new prop in
|
||||
`MyAppraisalsPage.tsx`/`AppraisalReviewsPage.tsx` respectively).
|
||||
- `recruitment/components/ApplicationDrawer.tsx`: `recruitment-hire-btn`,
|
||||
`recruitment-hire-confirm-btn`. **`recruitment-stage-select` does not exist** — there is no
|
||||
dropdown; stage advance is a row of per-stage buttons in `recruitment/OpeningDetailPage.tsx`,
|
||||
testid `recruitment-stage-advance-btn-<stage>`.
|
||||
- `reports/ReportsPage.tsx`: `report-export-btn-<name>` (the shared `exportButton(name, rows)`
|
||||
helper takes the testid once and every instantiation gets it automatically — 7 reports, not 6),
|
||||
plus a per-report row testid keyed by each table's natural key, e.g.
|
||||
`report-headcount-row-<unitId>`, `report-leave-liability-row-<employeeId>-<leaveTypeCode>`.
|
||||
|
||||
**Finance (`apps/finance-web/src/features/**`)**
|
||||
- `journals/NewJournalPage.tsx`: `journal-line-row-<n>`, `journal-balance-indicator`, `journal-post-btn`
|
||||
- `payables/PayablesPage.tsx`: `bill-row-<id>`, `bill-approve-btn-<id>`, `bill-record-payment-btn-<id>`
|
||||
- `budgeting/BudgetsPage.tsx`: `budget-row-<id>`, `budget-approve-btn`
|
||||
- `assets/AssetsPage.tsx`: `asset-row-<id>`, `run-depreciation-btn`, `dispose-btn-<id>`
|
||||
- `cutover/CutoverPage.tsx`: `cutover-readiness-check-<key>`, `cutover-set-date-btn`, `cutover-import-paste`, `cutover-import-preview`
|
||||
- `reports/ReportsPage.tsx`: `report-trial-balance-row-<code>`, `report-balance-sheet-total`
|
||||
|
||||
A starting inventory from reading route components, not exhaustive — whoever writes each spec adds
|
||||
what their scenario needs in the same pass.
|
||||
- `journals/NewJournalPage.tsx`: `journal-line-row-<key>` (a client-generated draft key, not a DB
|
||||
id — these rows have no id yet), `journal-balance-indicator` (on both the balanced and
|
||||
out-of-balance branches). **`journal-post-btn` is NOT on this page** — this page only ever creates
|
||||
a DRAFT. It's on `journals/JournalDetailPage.tsx` instead (not in the original checklist at all).
|
||||
- `payables/PayablesPage.tsx`: `bill-row-<id>`, `bill-approve-btn` (singular — only one bill is open
|
||||
at a time via the drawer, no `-<id>` needed), `bill-record-payment-btn`,
|
||||
`bill-record-payment-confirm-btn` (on the payment modal's actual submit button).
|
||||
- `budgeting/BudgetsPage.tsx`: **`budget-row-<id>` does not apply** — budgets are chosen via a
|
||||
`<Select>`, not table rows. Testid is `budget-select` on that dropdown, plus `budget-approve-btn`,
|
||||
`budget-variance-row-<accountId>-<costCenterId>`, `budget-line-row-<id>`.
|
||||
- `assets/AssetsPage.tsx`: `asset-row-<id>`, `run-depreciation-btn` (+
|
||||
`run-depreciation-confirm-btn` on the modal's Run button), `dispose-btn-<id>`.
|
||||
- `cutover/CutoverPage.tsx`: `cutover-readiness-check-<key>`, `cutover-set-date-btn`,
|
||||
`cutover-import-paste`, `cutover-import-preview` (required converting a conditional `<>...</>`
|
||||
fragment to a `<div>` so the testid has a real node to land on).
|
||||
- `reports/ReportsPage.tsx`: `report-trial-balance-row-<code>`. **`report-balance-sheet-total` was
|
||||
ambiguous** — the summary sentence holds three numbers inline, not one. Split into
|
||||
`report-bs-total-assets`, `report-bs-total-liabilities`, `report-bs-total-equity`, each wrapping
|
||||
just its number within the original sentence.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ export DB_HOST="${DB_HOST:-localhost}"
|
||||
export DB_PORT="${DB_PORT:-5432}"
|
||||
export DB_USER="${DB_USER:-postgres}"
|
||||
|
||||
# Local convenience default — CHANGE THIS to your actual local Postgres
|
||||
# password, or just export DB_PASSWORD in your shell instead and leave this
|
||||
# alone. Either way: NEVER commit this file with a real password in it. If
|
||||
# you edit the line below, `git diff` it before any commit and revert it, or
|
||||
# keep the password out of git entirely by exporting DB_PASSWORD instead.
|
||||
export DB_PASSWORD="${DB_PASSWORD:-TradingTria@2090}"
|
||||
|
||||
# A checked-in password reaching the production replica would be a real
|
||||
# incident, not an inconvenience. Refuse early and unmistakably.
|
||||
if [[ "$DB_NAME" == "smart_office_prod" ]]; then
|
||||
@@ -26,8 +33,8 @@ if [[ "$DB_NAME" == "smart_office_prod" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${DB_PASSWORD:-}" ]]; then
|
||||
echo "DB_PASSWORD is required (the seeders connect to $DB_NAME)" >&2
|
||||
if [[ -z "${DB_PASSWORD:-}" || "$DB_PASSWORD" == "CHANGE_ME_DB_PASSWORD" ]]; then
|
||||
echo "DB_PASSWORD is required (the seeders connect to $DB_NAME) — edit the placeholder near the top of this script, or export DB_PASSWORD instead" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
304
e2e-hr-finance/specs/finance/asset-migrated-depreciates.spec.ts
Normal file
304
e2e-hr-finance/specs/finance/asset-migrated-depreciates.spec.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||
import { query } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* FIN-04 — a migrated asset (accumulated depreciation carried in from
|
||||
* cutover, no `depreciation_entries` rows of its own) still depreciates.
|
||||
*
|
||||
* The mechanism under test lives in `FixedAssetsRepository.findDepreciable`
|
||||
* and `depreciationFor` (depreciation.calculator.ts):
|
||||
* `periodsCharged = opening_periods_charged + COUNT(depreciation_entries)`.
|
||||
* Without `openingPeriodsCharged` a migrated asset's count would read zero,
|
||||
* the cumulative target would land BELOW what is already accumulated, the
|
||||
* computed charge would be <= 0 and skipped — and a skip writes no row, so
|
||||
* the count could never grow. The asset would silently never depreciate
|
||||
* again. See the doc comments on `FixedAsset.openingPeriodsCharged` and on
|
||||
* `findDepreciable` itself.
|
||||
*
|
||||
* A migrated asset is created via `POST /assets` with
|
||||
* `openingAccumulatedDepreciation` + `openingPeriodsCharged` and NO
|
||||
* `fundingAccountId` — the DTO's own doc comment says the ledger side comes
|
||||
* from the opening balance entry, not from this call. This scenario supplies
|
||||
* that missing ledger side itself (a balancing journal against Retained
|
||||
* Earnings, never against 3900 — this file has nothing to do with
|
||||
* cutover-suspense-draft-only.spec.ts and stays independent of it), because
|
||||
* `runDepreciation` refuses to charge anything until the register and the
|
||||
* ledger reconcile (`AssetsService.assertRegisterReconciles`).
|
||||
*/
|
||||
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
||||
|
||||
const tokenFor = async (request: any, key: string): Promise<string> => {
|
||||
const persona = personaByKey(key);
|
||||
const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
return (await res.json()).token;
|
||||
};
|
||||
|
||||
// Deliberately round and obviously synthetic — see money.ts / the cutover
|
||||
// spec's own convention. base = 24000 - 2400 = 21600; base / life(60) = 360
|
||||
// exactly, so the expected charge has no rounding ambiguity to reason about.
|
||||
const ASSET_CODE = "E2E-FIN04-MIGRATED-01";
|
||||
const CATEGORY_CODE = "E2E-CAT-FIN04";
|
||||
const ACQUISITION_COST = 24000;
|
||||
const SALVAGE_VALUE = 2400;
|
||||
const USEFUL_LIFE_MONTHS = 60;
|
||||
const OPENING_PERIODS_CHARGED = 30;
|
||||
const OPENING_ACCUMULATED = 10_800; // half of the 21,600 depreciable base
|
||||
const EXPECTED_CHARGE = 360; // (21600 * 31/60) - 10800, exact — see above
|
||||
|
||||
test.describe("FIN-04 · a migrated asset still depreciates", () => {
|
||||
test.use({ storageState: storageFor("finance-manager") });
|
||||
|
||||
test("opening_periods_charged carries a migrated asset past its cutover count", async ({
|
||||
request,
|
||||
}) => {
|
||||
const token = await tokenFor(request, "finance-manager");
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
|
||||
// ── Guard: the register must already reconcile with the ledger org-wide,
|
||||
// exactly what AssetsService.assertRegisterReconciles checks, replicated
|
||||
// read-only here. If it does not, runDepreciation refuses for reasons that
|
||||
// have nothing to do with this scenario, and this test should say so
|
||||
// rather than fail confusingly.
|
||||
const reconciliation = await query<{
|
||||
accountId: string;
|
||||
ledgerAccumulated: string;
|
||||
registerAccumulated: string;
|
||||
}>(
|
||||
`SELECT c.accumulated_account_id AS "accountId",
|
||||
ROUND(COALESCE(SUM(l.credit - l.debit), 0), 2)::text AS "ledgerAccumulated",
|
||||
ROUND(COALESCE((
|
||||
SELECT SUM(a2.accumulated_depreciation)
|
||||
FROM finance.fixed_assets a2
|
||||
WHERE a2.asset_category_id IN (
|
||||
SELECT c2.id FROM finance.asset_categories c2
|
||||
WHERE c2.accumulated_account_id = c.accumulated_account_id
|
||||
AND c2.organization_id = $1)
|
||||
AND a2.deleted_at IS NULL
|
||||
AND a2.status NOT IN ('DISPOSED','WRITTEN_OFF')
|
||||
), 0), 2)::text AS "registerAccumulated"
|
||||
FROM finance.asset_categories c
|
||||
LEFT JOIN finance.journal_lines l ON l.account_id = c.accumulated_account_id
|
||||
LEFT JOIN finance.journal_entries e
|
||||
ON e.id = l.journal_entry_id
|
||||
AND e.status <> 'DRAFT'
|
||||
AND e.deleted_at IS NULL
|
||||
AND e.organization_id = $1
|
||||
WHERE c.organization_id = $1 AND c.deleted_at IS NULL
|
||||
GROUP BY c.accumulated_account_id`,
|
||||
[ORG_ID],
|
||||
);
|
||||
const alreadyDrifted = reconciliation.some(
|
||||
(row) =>
|
||||
Math.abs(Number(row.ledgerAccumulated) - Number(row.registerAccumulated)) >= 0.005,
|
||||
);
|
||||
test.skip(
|
||||
alreadyDrifted,
|
||||
"the asset register and ledger already disagree in this environment (pre-existing, unrelated to this scenario) — runDepreciation would refuse regardless",
|
||||
);
|
||||
|
||||
// ── Resolve the three chart-of-accounts rows the category needs. These
|
||||
// are real, existing seeded accounts (chart-of-accounts.seed.ts), not
|
||||
// invented ones.
|
||||
const [assetAccount, accumulatedAccount, expenseAccount, retainedEarnings] =
|
||||
await Promise.all([
|
||||
query<{ id: string }>(
|
||||
`SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '1213' AND deleted_at IS NULL`,
|
||||
[ORG_ID],
|
||||
),
|
||||
query<{ id: string }>(
|
||||
`SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '1290' AND deleted_at IS NULL`,
|
||||
[ORG_ID],
|
||||
),
|
||||
query<{ id: string }>(
|
||||
`SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '5400' AND deleted_at IS NULL`,
|
||||
[ORG_ID],
|
||||
),
|
||||
query<{ id: string }>(
|
||||
`SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '3200' AND deleted_at IS NULL`,
|
||||
[ORG_ID],
|
||||
),
|
||||
]);
|
||||
test.skip(
|
||||
!assetAccount[0] || !accumulatedAccount[0] || !expenseAccount[0] || !retainedEarnings[0],
|
||||
"the chart of accounts is missing one of 1213/1290/5400/3200 in this organization",
|
||||
);
|
||||
|
||||
// ── Find-or-create the category (idempotent across reruns).
|
||||
let categoryId: string;
|
||||
const existingCategory = await query<{ id: string }>(
|
||||
`SELECT id FROM finance.asset_categories WHERE organization_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||
[ORG_ID, CATEGORY_CODE],
|
||||
);
|
||||
if (existingCategory[0]) {
|
||||
categoryId = existingCategory[0].id;
|
||||
} else {
|
||||
const categoryRes = await request.post(`${FINANCE_API}/api/v1/assets/categories`, {
|
||||
headers,
|
||||
data: {
|
||||
code: CATEGORY_CODE,
|
||||
name: { en: "E2E FIN-04 category", am: "E2E FIN-04 ምድብ" },
|
||||
assetAccountId: assetAccount[0].id,
|
||||
accumulatedAccountId: accumulatedAccount[0].id,
|
||||
expenseAccountId: expenseAccount[0].id,
|
||||
defaultLifeMonths: USEFUL_LIFE_MONTHS,
|
||||
defaultSalvageRate: 0.1,
|
||||
},
|
||||
});
|
||||
expect(categoryRes.status(), "creating the E2E-FIN04 asset category").toBeLessThan(300);
|
||||
categoryId = (await categoryRes.json()).id;
|
||||
}
|
||||
|
||||
// ── Find-or-create the migrated asset itself (idempotent across reruns).
|
||||
let assetId: string;
|
||||
let assetIsFresh = false;
|
||||
const existingAsset = await query<{ id: string }>(
|
||||
`SELECT id FROM finance.fixed_assets WHERE organization_id = $1 AND asset_code = $2 AND deleted_at IS NULL`,
|
||||
[ORG_ID, ASSET_CODE],
|
||||
);
|
||||
if (existingAsset[0]) {
|
||||
assetId = existingAsset[0].id;
|
||||
} else {
|
||||
assetIsFresh = true;
|
||||
const assetRes = await request.post(`${FINANCE_API}/api/v1/assets`, {
|
||||
headers,
|
||||
data: {
|
||||
assetCategoryId: categoryId,
|
||||
assetCode: ASSET_CODE,
|
||||
name: "E2E-FIN-04 migrated asset (opening depreciation)",
|
||||
// Deliberately in the past and NOT tied to any fiscal period —
|
||||
// createAsset performs no period check at all when fundingAccountId
|
||||
// is omitted (see AssetsService.createAsset).
|
||||
acquisitionDate: "2015-01-01",
|
||||
inServiceDate: "2015-01-01",
|
||||
acquisitionCost: ACQUISITION_COST,
|
||||
salvageValue: SALVAGE_VALUE,
|
||||
usefulLifeMonths: USEFUL_LIFE_MONTHS,
|
||||
openingAccumulatedDepreciation: OPENING_ACCUMULATED,
|
||||
openingPeriodsCharged: OPENING_PERIODS_CHARGED,
|
||||
// No fundingAccountId — see the DTO's own doc comment.
|
||||
},
|
||||
});
|
||||
expect(assetRes.status(), "creating the migrated asset").toBeLessThan(300);
|
||||
assetId = (await assetRes.json()).id;
|
||||
}
|
||||
|
||||
// (DB) The asset arrived exactly as a migrated asset should: accumulated
|
||||
// depreciation > 0, opening_periods_charged > 0, and zero rows of its own
|
||||
// depreciation history — the precise shape the FIN-04 regression guards.
|
||||
const registered = await query<{
|
||||
accumulatedDepreciation: string;
|
||||
openingPeriodsCharged: string;
|
||||
entryRows: string;
|
||||
}>(
|
||||
`SELECT a.accumulated_depreciation::text AS "accumulatedDepreciation",
|
||||
a.opening_periods_charged::text AS "openingPeriodsCharged",
|
||||
(SELECT count(*)::text FROM finance.depreciation_entries d
|
||||
WHERE d.fixed_asset_id = a.id) AS "entryRows"
|
||||
FROM finance.fixed_assets a
|
||||
WHERE a.id = $1`,
|
||||
[assetId],
|
||||
);
|
||||
expect(Number(registered[0].accumulatedDepreciation)).toBeGreaterThan(0);
|
||||
expect(Number(registered[0].openingPeriodsCharged)).toBeGreaterThan(0);
|
||||
|
||||
// ── Find an OPEN period with no depreciation run against it yet.
|
||||
const candidatePeriod = await query<{ id: string; startDate: string; endDate: string }>(
|
||||
`SELECT p.id, p.start_date::text AS "startDate", p.end_date::text AS "endDate"
|
||||
FROM finance.fiscal_periods p
|
||||
WHERE p.organization_id = $1
|
||||
AND p.status = 'OPEN'
|
||||
AND NOT EXISTS (SELECT 1 FROM finance.depreciation_runs r WHERE r.fiscal_period_id = p.id)
|
||||
ORDER BY p.start_date ASC
|
||||
LIMIT 1`,
|
||||
[ORG_ID],
|
||||
);
|
||||
test.skip(
|
||||
candidatePeriod.length === 0,
|
||||
"no OPEN fiscal period without an existing depreciation run is available in this environment",
|
||||
);
|
||||
const period = candidatePeriod[0];
|
||||
|
||||
// ── Supply the ledger side of the migration, once, on first creation —
|
||||
// the register/ledger reconciliation this run depends on. Both sides move
|
||||
// by the SAME amount, so whatever the pre-existing drift was (checked
|
||||
// above to be ~0), it is unchanged by this pair.
|
||||
if (assetIsFresh) {
|
||||
const catchUpMemo = `E2E-FIN-04 opening accumulated depreciation catch-up for ${ASSET_CODE}`;
|
||||
const catchUp = await request.post(`${FINANCE_API}/api/v1/journals`, {
|
||||
headers,
|
||||
data: {
|
||||
entryDate: period.startDate,
|
||||
journalType: "OPENING",
|
||||
memo: catchUpMemo,
|
||||
lines: [
|
||||
{ accountId: retainedEarnings[0].id, debit: OPENING_ACCUMULATED, credit: 0 },
|
||||
{ accountId: accumulatedAccount[0].id, debit: 0, credit: OPENING_ACCUMULATED },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(catchUp.status(), "creating the catch-up draft").toBeLessThan(300);
|
||||
const catchUpId = (await catchUp.json()).id;
|
||||
const posted = await request.post(
|
||||
`${FINANCE_API}/api/v1/journals/${catchUpId}/post`,
|
||||
{ headers },
|
||||
);
|
||||
expect(posted.status(), "posting the catch-up entry").toBeLessThan(300);
|
||||
}
|
||||
|
||||
// ── Act: run depreciation for the period.
|
||||
const runRes = await request.post(`${FINANCE_API}/api/v1/assets/depreciation/run`, {
|
||||
headers,
|
||||
data: { fiscalPeriodId: period.id },
|
||||
});
|
||||
// (NET) Success — the whole register (every depreciable asset in the org,
|
||||
// not only ours) was charged in one summarized entry.
|
||||
expect(runRes.status(), "running depreciation for the period").toBeLessThan(300);
|
||||
const runBody = await runRes.json();
|
||||
expect(runBody.assetCount).toBeGreaterThan(0);
|
||||
|
||||
// (NET) The response, at the per-asset level, includes a non-zero charge
|
||||
// for OUR migrated asset specifically — not just a nonzero org-wide total.
|
||||
const entriesRes = await request.get(
|
||||
`${FINANCE_API}/api/v1/assets/depreciation/runs/${runBody.runId}/entries`,
|
||||
{ headers },
|
||||
);
|
||||
expect(entriesRes.status()).toBe(200);
|
||||
const entries: { amount: string | number; assetCode: string }[] = await entriesRes.json();
|
||||
const ourEntry = entries.find((e) => e.assetCode === ASSET_CODE);
|
||||
expect(ourEntry, "the run must include a line for the migrated asset").toBeDefined();
|
||||
expect(Number(ourEntry!.amount)).toBe(EXPECTED_CHARGE);
|
||||
|
||||
// (DB) A new row landed in finance.depreciation_entries for this asset.
|
||||
const depreciationRows = await query<{ amount: string }>(
|
||||
`SELECT amount::text FROM finance.depreciation_entries
|
||||
WHERE fixed_asset_id = $1 AND depreciation_run_id = $2`,
|
||||
[assetId, runBody.runId],
|
||||
);
|
||||
expect(depreciationRows).toHaveLength(1);
|
||||
expect(Number(depreciationRows[0].amount)).toBe(EXPECTED_CHARGE);
|
||||
|
||||
// (DB) periodsCharged (opening_periods_charged + COUNT(depreciation_entries))
|
||||
// is now past the opening count — the asset moved, it did not stall.
|
||||
const after = await query<{
|
||||
periodsCharged: string;
|
||||
accumulatedDepreciation: string;
|
||||
}>(
|
||||
`SELECT (a.opening_periods_charged
|
||||
+ (SELECT COUNT(*)::int FROM finance.depreciation_entries d
|
||||
WHERE d.fixed_asset_id = a.id))::text AS "periodsCharged",
|
||||
a.accumulated_depreciation::text AS "accumulatedDepreciation"
|
||||
FROM finance.fixed_assets a
|
||||
WHERE a.id = $1`,
|
||||
[assetId],
|
||||
);
|
||||
expect(Number(after[0].periodsCharged)).toBeGreaterThan(OPENING_PERIODS_CHARGED);
|
||||
expect(Number(after[0].accumulatedDepreciation)).toBeGreaterThan(
|
||||
Number(registered[0].accumulatedDepreciation),
|
||||
);
|
||||
});
|
||||
});
|
||||
173
e2e-hr-finance/specs/finance/cutover-suspense-draft-only.spec.ts
Normal file
173
e2e-hr-finance/specs/finance/cutover-suspense-draft-only.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||
import { query } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* FIN-05 — the cutover readiness "suspense" check counts POSTED lines only.
|
||||
*
|
||||
* `CutoverService.readiness` (apps/finance-api/src/modules/cutover/cutover.service.ts)
|
||||
* sums 3900 Opening Balance Suspense with a LEFT JOIN whose posted-only filter
|
||||
* sits on the JOIN's `ON` clause, not as a second `LEFT JOIN … WHERE` step —
|
||||
* the comment right above that query names the exact regression this guards:
|
||||
* written the other way, a DRAFT line still joins (the filter would only null
|
||||
* the entry side, never exclude the line), and its amount would still be
|
||||
* summed — so an unposted opening batch would read as a broken migration
|
||||
* (FAIL) instead of "not started yet" (PENDING).
|
||||
*
|
||||
* `importOpeningBalances` (`POST /cutover/opening-balances`) creates a DRAFT
|
||||
* `OPENING` entry ONLY — see its own doc comment: posting always goes through
|
||||
* the normal `JournalsService.post` path, never here. So a batch that is
|
||||
* never posted must leave the readiness suspense check UNCHANGED, whatever
|
||||
* that check currently reads.
|
||||
*
|
||||
* The scenario does not assume a pristine environment (no prior cutover
|
||||
* activity in 3900): it captures the check's exact status/detail BEFORE
|
||||
* creating the DRAFT batch and asserts it is byte-identical AFTER — a
|
||||
* regression that leaked a DRAFT line into the sum would change these numbers
|
||||
* regardless of what they started at. Where the environment happens to be
|
||||
* pristine (no posted line has ever touched 3900), the stronger, more literal
|
||||
* assertion the plan asks for — PENDING, not FAIL — is also made directly.
|
||||
*/
|
||||
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
||||
|
||||
const tokenFor = async (request: any, key: string): Promise<string> => {
|
||||
const persona = personaByKey(key);
|
||||
const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
return (await res.json()).token;
|
||||
};
|
||||
|
||||
type ReadinessCheck = { key: string; status: "PASS" | "FAIL" | "PENDING"; detail: string };
|
||||
|
||||
/** The exact SQL readiness.suspense runs, replicated read-only for the DB axis. */
|
||||
const suspenseFromDb = () =>
|
||||
query<{ balance: string; lines: string }>(
|
||||
`SELECT ROUND(COALESCE(SUM(l.debit - l.credit), 0), 2)::text AS balance,
|
||||
COUNT(l.id)::text AS lines
|
||||
FROM finance.accounts a
|
||||
LEFT JOIN finance.journal_lines l
|
||||
ON l.account_id = a.id
|
||||
AND EXISTS (SELECT 1
|
||||
FROM finance.journal_entries e
|
||||
WHERE e.id = l.journal_entry_id
|
||||
AND e.status IN ('POSTED','REVERSED'))
|
||||
WHERE a.organization_id = $1
|
||||
AND a.code = '3900'
|
||||
AND a.deleted_at IS NULL`,
|
||||
[ORG_ID],
|
||||
);
|
||||
|
||||
test.describe("FIN-05 · the suspense readiness check counts posted lines only", () => {
|
||||
test.use({ storageState: storageFor("finance-manager") });
|
||||
|
||||
test("a DRAFT-only opening-balances batch never moves the suspense check", async ({
|
||||
request,
|
||||
}) => {
|
||||
const token = await tokenFor(request, "finance-manager");
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
|
||||
const readinessBefore = await request.get(`${FINANCE_API}/api/v1/cutover/readiness`, {
|
||||
headers,
|
||||
});
|
||||
expect(readinessBefore.status()).toBe(200);
|
||||
const beforeChecks: ReadinessCheck[] = (await readinessBefore.json()).checks;
|
||||
const suspenseBefore = beforeChecks.find((c) => c.key === "suspense");
|
||||
expect(suspenseBefore, "the readiness payload must include a suspense check").toBeDefined();
|
||||
|
||||
test.skip(
|
||||
suspenseBefore!.status === "FAIL",
|
||||
"the org's 3900 suspense account already carries an unrelated posted imbalance in this environment — not this regression's concern",
|
||||
);
|
||||
|
||||
const dbBefore = await suspenseFromDb();
|
||||
|
||||
const period = await query<{ startDate: string }>(
|
||||
`SELECT start_date::text AS "startDate"
|
||||
FROM finance.fiscal_periods
|
||||
WHERE organization_id = $1 AND status = 'OPEN'
|
||||
ORDER BY start_date ASC
|
||||
LIMIT 1`,
|
||||
[ORG_ID],
|
||||
);
|
||||
test.skip(period.length === 0, "no OPEN fiscal period is available in this environment");
|
||||
|
||||
const [cash, capital] = await Promise.all([
|
||||
query<{ code: string }>(
|
||||
`SELECT code FROM finance.accounts WHERE organization_id = $1 AND code = '1111' AND deleted_at IS NULL`,
|
||||
[ORG_ID],
|
||||
),
|
||||
query<{ code: string }>(
|
||||
`SELECT code FROM finance.accounts WHERE organization_id = $1 AND code = '3100' AND deleted_at IS NULL`,
|
||||
[ORG_ID],
|
||||
),
|
||||
]);
|
||||
test.skip(
|
||||
cash.length === 0 || capital.length === 0,
|
||||
"the chart of accounts is missing 1111 Cash on Hand or 3100 Capital in this organization",
|
||||
);
|
||||
|
||||
// Intentionally unbalanced-looking rows (debit 5000, credit 3000) so
|
||||
// importOpeningBalances computes a nonzero plug — see the exact logic in
|
||||
// CutoverService.importOpeningBalances: `plug = totalDebit - totalCredit`,
|
||||
// credited to 3900 when positive. Round, obviously-synthetic amounts, the
|
||||
// same convention cutover.service.spec.ts uses.
|
||||
const memo = `E2E-FIN-05 draft-only opening balances (never posted, ${Date.now()})`;
|
||||
const importRes = await request.post(`${FINANCE_API}/api/v1/cutover/opening-balances`, {
|
||||
headers,
|
||||
data: {
|
||||
entryDate: period[0].startDate,
|
||||
memo,
|
||||
lines: [
|
||||
{ accountCode: "1111", debit: 5000, description: "E2E-FIN-05 cash" },
|
||||
{ accountCode: "3100", credit: 3000, description: "E2E-FIN-05 capital" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(importRes.status(), "importing the draft opening-balances batch").toBeLessThan(300);
|
||||
const importBody = await importRes.json();
|
||||
expect(importBody.suspensePlug).toBe(2000);
|
||||
expect(
|
||||
importBody.entry.status,
|
||||
"importOpeningBalances must only ever produce a DRAFT",
|
||||
).toBe("DRAFT");
|
||||
|
||||
// Never post it — that omission IS the scenario.
|
||||
|
||||
const readinessAfter = await request.get(`${FINANCE_API}/api/v1/cutover/readiness`, {
|
||||
headers,
|
||||
});
|
||||
expect(readinessAfter.status()).toBe(200);
|
||||
const afterChecks: ReadinessCheck[] = (await readinessAfter.json()).checks;
|
||||
const suspenseAfter = afterChecks.find((c) => c.key === "suspense");
|
||||
expect(suspenseAfter).toBeDefined();
|
||||
|
||||
// (NET) The headline assertion: a DRAFT-only batch must never turn the
|
||||
// check into FAIL, and must leave its status exactly where it was.
|
||||
expect(
|
||||
suspenseAfter!.status,
|
||||
"a DRAFT-only batch must never fail the suspense check",
|
||||
).not.toBe("FAIL");
|
||||
expect(suspenseAfter!.status).toBe(suspenseBefore!.status);
|
||||
expect(suspenseAfter!.detail).toBe(suspenseBefore!.detail);
|
||||
|
||||
// The most literal form of the plan's ask, made when the environment
|
||||
// allows it: no prior posted line has ever touched 3900, so the check
|
||||
// reads PENDING — and must still read PENDING, not FAIL, after our batch.
|
||||
if (suspenseBefore!.status === "PENDING") {
|
||||
expect(suspenseAfter!.status).toBe("PENDING");
|
||||
}
|
||||
|
||||
// (DB) Independent of the API's own arithmetic: re-run the exact query
|
||||
// readiness.suspense runs and confirm it is unchanged. Distinguishes
|
||||
// "PENDING" (lines === '0') from "PASS"/"FAIL" (lines > '0') explicitly,
|
||||
// rather than only checking truthiness of a balance.
|
||||
const dbAfter = await suspenseFromDb();
|
||||
expect(dbAfter[0].lines, "no DRAFT line may ever be counted as a posted suspense line").toBe(
|
||||
dbBefore[0].lines,
|
||||
);
|
||||
expect(dbAfter[0].balance).toBe(dbBefore[0].balance);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { APIRequestContext } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { personaByKey } from "../../fixtures/personas";
|
||||
import { query } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* FIN-01, FIN-03, FIN-07, FIN-08 — the Finance gates and two ledger invariants
|
||||
@@ -14,7 +16,7 @@ import { query } from "../../fixtures/db";
|
||||
|
||||
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
||||
|
||||
const tokenFor = async (request: any, key: string): Promise<string> => {
|
||||
const tokenFor = async (request: APIRequestContext, key: string): Promise<string> => {
|
||||
const persona = personaByKey(key);
|
||||
const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
@@ -23,7 +25,7 @@ const tokenFor = async (request: any, key: string): Promise<string> => {
|
||||
};
|
||||
|
||||
test.describe("FIN-08 · a cashier is refused what it must not do", () => {
|
||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/finance-cashier.json` });
|
||||
test.use({ storageState: storageFor("finance-cashier") });
|
||||
|
||||
/**
|
||||
* `cashier` is the narrowest Finance role: READ_ONLY_KEYS plus receivable
|
||||
@@ -81,7 +83,7 @@ test.describe("FIN-03 · separation of duties, approve vs pay", () => {
|
||||
*/
|
||||
const NO_SUCH_BILL = "00000000-0000-4000-8000-000000000000";
|
||||
|
||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/finance-cashier.json` });
|
||||
test.use({ storageState: storageFor("finance-cashier") });
|
||||
|
||||
test("a cashier may pay but may not approve", async ({ request }) => {
|
||||
const token = await tokenFor(request, "finance-cashier");
|
||||
@@ -125,7 +127,7 @@ test.describe("FIN-03 · separation of duties, approve vs pay", () => {
|
||||
});
|
||||
|
||||
test.describe("FIN-01 / FIN-07 · ledger invariants", () => {
|
||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/finance-accountant.json` });
|
||||
test.use({ storageState: storageFor("finance-accountant") });
|
||||
|
||||
test("FIN-01 · an unbalanced journal entry is refused and writes nothing", async ({ request }) => {
|
||||
const token = await tokenFor(request, "finance-accountant");
|
||||
@@ -157,9 +159,12 @@ test.describe("FIN-01 / FIN-07 · ledger invariants", () => {
|
||||
|
||||
test("FIN-07 · the trial balance balances, computed independently", async ({ request }) => {
|
||||
const token = await tokenFor(request, "finance-accountant");
|
||||
const res = await request.get(`${FINANCE_API}/api/v1/reports/trial-balance`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
// dateFrom/dateTo are required (RangeQueryDto) — omitting them 400s before
|
||||
// the report ever runs. A wide range covers whatever is actually posted.
|
||||
const res = await request.get(
|
||||
`${FINANCE_API}/api/v1/reports/trial-balance?dateFrom=2000-01-01&dateTo=2100-01-01`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
// Assert against the ledger itself rather than trusting the report to check
|
||||
|
||||
157
e2e-hr-finance/specs/finance/journal-post-outside-period.spec.ts
Normal file
157
e2e-hr-finance/specs/finance/journal-post-outside-period.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||
import { query } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* FIN-02 — posting outside a fiscal period is refused, and never leaves a
|
||||
* POSTED row behind.
|
||||
*
|
||||
* `JournalsService.create` and `JournalsService.post` both resolve the entry's
|
||||
* period through the SAME gate — `PeriodsService.resolveOpenPeriodForDate`
|
||||
* (apps/finance-api/src/modules/periods/periods.service.ts) — so a date that
|
||||
* covers no fiscal period at all is refused at CREATE time, before a draft
|
||||
* ever exists to post. That is a stronger guarantee than catching it only at
|
||||
* post time, and this scenario is written to prove exactly that: the write
|
||||
* path can never produce a DRAFT dated outside a period in the first place.
|
||||
*
|
||||
* The date is discovered, not invented: it is chosen to sit past the LATEST
|
||||
* `finance.fiscal_periods` row this organization has, exactly as the plan
|
||||
* instructs ("pick a date before the earliest open period or after the
|
||||
* latest, whichever exists in fixture data").
|
||||
*/
|
||||
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
||||
|
||||
const tokenFor = async (request: any, key: string): Promise<string> => {
|
||||
const persona = personaByKey(key);
|
||||
const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
return (await res.json()).token;
|
||||
};
|
||||
|
||||
test.describe("FIN-02 · posting outside a fiscal period is refused", () => {
|
||||
// finance-manager holds BOTH can:create:journal_entry and
|
||||
// can:post:journal_entry (accountant holds only the first — see
|
||||
// finance-permissions.registry.ts). Using manager throughout means a
|
||||
// non-2xx response can only be explained by the period gate, never by a
|
||||
// permission gate the accountant would also trip.
|
||||
test.use({ storageState: storageFor("finance-manager") });
|
||||
|
||||
test("a date covered by no fiscal period is refused at create — never becomes a postable draft, never becomes POSTED", async ({
|
||||
request,
|
||||
}) => {
|
||||
const token = await tokenFor(request, "finance-manager");
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
|
||||
// Cast every date column to text in SQL — node-postgres parses `date` to
|
||||
// LOCAL midnight, and this org's periods must not round-trip through a JS
|
||||
// Date (see the platform's known DATE trap).
|
||||
const bounds = await query<{
|
||||
minStart: string | null;
|
||||
maxEnd: string | null;
|
||||
periodCount: string;
|
||||
}>(
|
||||
`SELECT MIN(start_date)::text AS "minStart",
|
||||
MAX(end_date)::text AS "maxEnd",
|
||||
count(*)::text AS "periodCount"
|
||||
FROM finance.fiscal_periods
|
||||
WHERE organization_id = $1`,
|
||||
[ORG_ID],
|
||||
);
|
||||
|
||||
test.skip(
|
||||
bounds[0].periodCount === "0" || !bounds[0].maxEnd,
|
||||
"no fiscal periods are seeded for this organization — nothing to sit outside of",
|
||||
);
|
||||
|
||||
// Comfortably past the latest period this org has ever defined — not a
|
||||
// CLOSED/LOCKED period (which create() also refuses, for a different
|
||||
// reason), a genuine gap `findByDate` returns null for.
|
||||
const latestYear = Number(bounds[0].maxEnd!.slice(0, 4));
|
||||
const gapDate = `${latestYear + 50}-01-15`;
|
||||
|
||||
const memo = `E2E-FIN-02 outside-period draft attempt (${gapDate})`;
|
||||
|
||||
// Two balanced, obviously-synthetic lines — the balance rule is not what
|
||||
// this scenario is testing, so the amounts are trivial and round.
|
||||
const [cash, capital] = await Promise.all([
|
||||
query<{ id: string }>(
|
||||
`SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '1111' AND deleted_at IS NULL`,
|
||||
[ORG_ID],
|
||||
),
|
||||
query<{ id: string }>(
|
||||
`SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '3100' AND deleted_at IS NULL`,
|
||||
[ORG_ID],
|
||||
),
|
||||
]);
|
||||
test.skip(
|
||||
cash.length === 0 || capital.length === 0,
|
||||
"the chart of accounts is missing 1111 Cash on Hand or 3100 Capital in this organization",
|
||||
);
|
||||
|
||||
const before = await query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM finance.journal_entries WHERE organization_id = $1 AND memo = $2`,
|
||||
[ORG_ID, memo],
|
||||
);
|
||||
expect(before[0].count).toBe("0");
|
||||
|
||||
const createRes = await request.post(`${FINANCE_API}/api/v1/journals`, {
|
||||
headers,
|
||||
data: {
|
||||
entryDate: gapDate,
|
||||
memo,
|
||||
lines: [
|
||||
{ accountId: cash[0].id, debit: 50, credit: 0 },
|
||||
{ accountId: capital[0].id, debit: 0, credit: 50 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// (NET) The write is refused before a draft ever exists.
|
||||
expect(
|
||||
createRes.status(),
|
||||
"a date no fiscal period covers must be refused at create, not merely at post",
|
||||
).toBeGreaterThanOrEqual(400);
|
||||
expect(createRes.status()).toBeLessThan(500);
|
||||
|
||||
const createBody = await createRes.json().catch(() => ({}));
|
||||
expect(String(createBody?.message ?? "")).toMatch(/fiscal period/i);
|
||||
|
||||
// (DB) Nothing was written — not a DRAFT, and certainly not POSTED.
|
||||
const after = await query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count FROM finance.journal_entries WHERE organization_id = $1 AND memo = $2`,
|
||||
[ORG_ID, memo],
|
||||
);
|
||||
expect(after[0].count, "a refused create must leave no journal_entries row behind").toBe("0");
|
||||
|
||||
// Defense in depth: even with no id to post (the point above), the /post
|
||||
// route itself refuses a fabricated id rather than answering 2xx.
|
||||
const NO_SUCH_ENTRY = "00000000-0000-4000-8000-000000000000";
|
||||
const postRes = await request.post(
|
||||
`${FINANCE_API}/api/v1/journals/${NO_SUCH_ENTRY}/post`,
|
||||
{ headers },
|
||||
);
|
||||
expect(postRes.status()).toBeGreaterThanOrEqual(400);
|
||||
expect(postRes.status()).toBeLessThan(500);
|
||||
|
||||
// (DB) The whole-ledger invariant this scenario protects, checked
|
||||
// independently of our own attempt — same discipline as FIN-07 in
|
||||
// gating.spec.ts: never trust the API to have checked its own arithmetic.
|
||||
// No POSTED entry may EVER carry a date outside the period it names.
|
||||
const outOfRange = await query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count
|
||||
FROM finance.journal_entries e
|
||||
JOIN finance.fiscal_periods p ON p.id = e.fiscal_period_id
|
||||
WHERE e.organization_id = $1
|
||||
AND e.status = 'POSTED'
|
||||
AND (e.entry_date < p.start_date OR e.entry_date > p.end_date)`,
|
||||
[ORG_ID],
|
||||
);
|
||||
expect(
|
||||
outOfRange[0].count,
|
||||
"no POSTED journal entry may ever carry a date outside its own fiscal period's range",
|
||||
).toBe("0");
|
||||
});
|
||||
});
|
||||
179
e2e-hr-finance/specs/finance/payment-pre-cutover-skip.spec.ts
Normal file
179
e2e-hr-finance/specs/finance/payment-pre-cutover-skip.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { personaByKey } from "../../fixtures/personas";
|
||||
import { query } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* FIN-06 — a payment settled BEFORE the organization's cutover date is
|
||||
* recorded SKIPPED, with a reason, and no journal entry is ever created for
|
||||
* it.
|
||||
*
|
||||
* The mechanism: `RevenuePostingService.postPaymentReceipt`
|
||||
* (apps/finance-api/src/modules/revenue/revenue-posting.service.ts) checks the
|
||||
* cutover boundary before anything else about the payment is judged. A
|
||||
* payment paid before `cutoverDateFor(organizationId)` is already inside the
|
||||
* receivable/cash figures the opening balances brought over — posting it
|
||||
* again would count the same money twice — so it returns
|
||||
* `{ outcome: "SKIPPED", reason: "…already carried by the opening
|
||||
* balances…" }`. `PaymentEventsConsumer.handle` writes that outcome onto the
|
||||
* claimed `finance.inbound_events` row and never calls `journals.createPosted`
|
||||
* for a SKIPPED outcome.
|
||||
*
|
||||
* ── Why this can only run with a live broker ────────────────────────────────
|
||||
* `RevenueModule`'s `rabbitMQImport()` (revenue.module.ts) returns `[]` — no
|
||||
* `RabbitMQModule`, no exchange, no queue — whenever `PAYMENT_RABBITMQ_URL` is
|
||||
* unset. `PaymentEventsConsumer` is still registered as a provider either way,
|
||||
* but with no `RabbitMQModule` its `@RabbitSubscribe` decorator is inert: it
|
||||
* is never bound to anything and there is no exchange to publish onto. That is
|
||||
* "very likely NOT configured" in this environment, exactly as the plan says,
|
||||
* so the FIRST thing this test does is check for the same environment
|
||||
* variable the app itself gates on and skip cleanly — never attempting a
|
||||
* broker connection, and never even resolving the `amqplib` module — when it
|
||||
* is absent.
|
||||
*/
|
||||
const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104";
|
||||
const PAYMENT_EVENTS_EXCHANGE = "payment.events"; // @edr/types PAYMENT_EVENTS_EXCHANGE
|
||||
|
||||
const tokenFor = async (request: any, key: string): Promise<string> => {
|
||||
const persona = personaByKey(key);
|
||||
const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
return (await res.json()).token;
|
||||
};
|
||||
|
||||
/** `"2026-07-08"` minus `n` days, as a pure string/date computation — never a
|
||||
* round-trip of a value read back out of Postgres (see the platform's DATE
|
||||
* trap: that only bites when node-postgres itself parses a `date` column). */
|
||||
const daysBefore = (iso: string, n: number): string => {
|
||||
const d = new Date(`${iso}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() - n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
test.describe("FIN-06 · a pre-cutover payment is skipped, not double-posted", () => {
|
||||
test.use({ storageState: storageFor("finance-manager") });
|
||||
|
||||
test("a payment.freight.succeeded message dated before cutover settles as SKIPPED with no journal entry", async ({
|
||||
request,
|
||||
}) => {
|
||||
// The runtime guard the plan requires — checked first, before anything
|
||||
// else, exactly the way RevenueModule itself gates the broker.
|
||||
if (!process.env.PAYMENT_RABBITMQ_URL) {
|
||||
test.skip(true, "PAYMENT_RABBITMQ_URL not configured in this environment");
|
||||
}
|
||||
|
||||
const token = await tokenFor(request, "finance-manager");
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
|
||||
// Discover the organization's real cutover date rather than inventing one
|
||||
// — set() is not exercised here; a boundary this scenario depends on
|
||||
// should already exist as a genuine fact about the org, or there is
|
||||
// nothing "pre-cutover" to test.
|
||||
const cutoverRes = await request.get(`${FINANCE_API}/api/v1/cutover`, { headers });
|
||||
expect(cutoverRes.status()).toBe(200);
|
||||
const cutoverDate: string | null = (await cutoverRes.json())?.cutoverDate ?? null;
|
||||
test.skip(
|
||||
!cutoverDate,
|
||||
"no cutover date is set for this organization — nothing is 'pre-cutover' to test",
|
||||
);
|
||||
|
||||
// Borrowed, not a dependency of this directory — the same pattern
|
||||
// fixtures/db.ts uses for `pg`. `amqplib` is a direct dependency of
|
||||
// edr-freight-api (and edr-passenger-api), so it resolves from there.
|
||||
// This line only runs once PAYMENT_RABBITMQ_URL is confirmed present, so
|
||||
// an environment without amqplib installed anywhere never reaches it.
|
||||
const requireFrom = createRequire(
|
||||
path.join(__dirname, "..", "..", "..", "apps", "edr-freight-api", "package.json"),
|
||||
);
|
||||
const amqp = requireFrom("amqplib") as {
|
||||
connect: (url: string) => Promise<{
|
||||
createChannel: () => Promise<{
|
||||
assertExchange: (
|
||||
name: string,
|
||||
type: string,
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
publish: (
|
||||
exchange: string,
|
||||
routingKey: string,
|
||||
content: Buffer,
|
||||
options?: Record<string, unknown>,
|
||||
) => boolean;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
close: () => Promise<void>;
|
||||
}>;
|
||||
};
|
||||
|
||||
const eventId = `E2E-FIN-06-${Date.now()}`;
|
||||
const paidBefore = daysBefore(cutoverDate as string, 10);
|
||||
|
||||
// Shaped like PaymentSucceededEvent (@edr/types common/payments.ts).
|
||||
// `amountMinor` is MAJOR units despite the name on this path — see
|
||||
// apps/finance-api/src/common/money.ts.
|
||||
const event = {
|
||||
version: 1,
|
||||
eventId,
|
||||
eventType: "payment.succeeded",
|
||||
occurredAt: `${paidBefore}T00:00:00.000Z`,
|
||||
service: "FREIGHT",
|
||||
intentId: `E2E-FIN-06-INTENT-${eventId}`,
|
||||
referenceType: "SHIPMENT",
|
||||
referenceId: `E2E-FIN-06-REF-${eventId}`,
|
||||
merchantOrderId: `E2E-FIN-06-ORDER-${eventId}`,
|
||||
provider: "CARD",
|
||||
amountMinor: 1000,
|
||||
currency: "ETB",
|
||||
paidAt: `${paidBefore}T00:00:00.000Z`,
|
||||
};
|
||||
|
||||
// `payment.<service>.<outcome>` is three words on the wire — a `payment.*`
|
||||
// binding matches nothing (AMQP `*` matches exactly one word); Finance's
|
||||
// queue is bound `payment.#`, so `payment.freight.succeeded` is what a
|
||||
// real publisher sends. See the platform's own known-traps table.
|
||||
const routingKey = "payment.freight.succeeded";
|
||||
|
||||
const conn = await amqp.connect(process.env.PAYMENT_RABBITMQ_URL as string);
|
||||
try {
|
||||
const channel = await conn.createChannel();
|
||||
await channel.assertExchange(PAYMENT_EVENTS_EXCHANGE, "topic", { durable: true });
|
||||
channel.publish(PAYMENT_EVENTS_EXCHANGE, routingKey, Buffer.from(JSON.stringify(event)), {
|
||||
contentType: "application/json",
|
||||
persistent: true,
|
||||
});
|
||||
await channel.close();
|
||||
} finally {
|
||||
await conn.close();
|
||||
}
|
||||
|
||||
// (DB) The consumer claims, judges and settles asynchronously — poll
|
||||
// rather than assume it has landed by the time publish() returns.
|
||||
await expect(async () => {
|
||||
const rows = await query<{ status: string; error: string | null }>(
|
||||
`SELECT status, error FROM finance.inbound_events WHERE event_id = $1`,
|
||||
[eventId],
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].status).toBe("SKIPPED");
|
||||
expect(rows[0].error ?? "").toMatch(/cutover/i);
|
||||
}).toPass({ timeout: 15_000 });
|
||||
|
||||
// (DB) — and, just as importantly, nothing was posted for it: SKIPPED
|
||||
// means no call to journals.createPosted was ever made for this source.
|
||||
const journalRows = await query<{ count: string }>(
|
||||
`SELECT count(*)::text AS count
|
||||
FROM finance.journal_entries
|
||||
WHERE source_module = 'payment' AND source_id = $1`,
|
||||
[eventId],
|
||||
);
|
||||
expect(
|
||||
journalRows[0].count,
|
||||
"a SKIPPED payment must never have a journal entry posted for it",
|
||||
).toBe("0");
|
||||
});
|
||||
});
|
||||
176
e2e-hr-finance/specs/hr/appraisal-manager-score-final.spec.ts
Normal file
176
e2e-hr-finance/specs/hr/appraisal-manager-score-final.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||
import { query, scalar } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* HR-08 · the manager's score is final — it is never averaged with the
|
||||
* employee's own.
|
||||
*
|
||||
* `AppraisalService.submitManager` writes `finalScore = managerScore.toFixed(2)`
|
||||
* verbatim (apps/edr-hr-api/src/modules/appraisal/services/appraisal.service.ts).
|
||||
* The self-assessment is recorded for comparison only and plays no part in the
|
||||
* arithmetic — the code comment there says so explicitly: "Averaging it with
|
||||
* the employee's own would let anyone raise their result by rating themselves
|
||||
* highly." This scenario proves that end to end: submit a low self-score, then
|
||||
* a materially different manager score, and check the number that lands in the
|
||||
* response AND the database is the manager's weighted score, not any blend of
|
||||
* the two.
|
||||
*
|
||||
* Fixtures: a template and cycle are created fresh each run — their codes
|
||||
* carry a timestamp so reruns never collide — opened for the `hr-employee`
|
||||
* persona, the one non-manager persona this suite already holds real
|
||||
* self-service credentials for. `openCycle` silently skips any employeeId with
|
||||
* no `hr.employee_profiles` row, and that persona is seeded at the IAM level
|
||||
* only (fixtures/seed-personas.cjs), so
|
||||
* `POST /employee-profiles/by-employee/:id/ensure` (idempotent, the product's
|
||||
* own onboarding endpoint) guarantees one exists first.
|
||||
*/
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
|
||||
const login = async (request: any, key: string): Promise<string> => {
|
||||
const persona = personaByKey(key);
|
||||
const res = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
const { token } = await res.json();
|
||||
return token;
|
||||
};
|
||||
|
||||
test.describe("HR-08 · manager score is final, not averaged with self", () => {
|
||||
test.use({ storageState: storageFor("hr-manager") });
|
||||
|
||||
test("finalScore equals the manager's weighted score, in the response AND in hr.appraisals", async ({
|
||||
request,
|
||||
}) => {
|
||||
const managerToken = await login(request, "hr-manager");
|
||||
const managerAuth = { Authorization: `Bearer ${managerToken}` };
|
||||
|
||||
// hr-employee is a real iam.employees row (seeded by seed-personas.cjs),
|
||||
// matched the same way that script matches one: by email, in the Active org.
|
||||
const employeePersona = personaByKey("hr-employee");
|
||||
const employeeId = await scalar<string>(
|
||||
`SELECT e."id"
|
||||
FROM iam.employees e
|
||||
JOIN iam.users u ON u."id" = e."user_id"
|
||||
WHERE u."email" = $1 AND e."organization_id" = $2 AND e."is_current" = true
|
||||
LIMIT 1`,
|
||||
[employeePersona.email, ORG_ID],
|
||||
);
|
||||
expect(employeeId, "hr-employee must exist as a real iam.employees row").toBeTruthy();
|
||||
|
||||
const ensure = await request.post(
|
||||
`${HR_API}/api/v1/employee-profiles/by-employee/${employeeId}/ensure`,
|
||||
{ headers: managerAuth },
|
||||
);
|
||||
expect(
|
||||
ensure.ok(),
|
||||
"an HR profile must exist before openCycle will create an appraisal for this employee",
|
||||
).toBeTruthy();
|
||||
|
||||
const ts = Date.now();
|
||||
const template = await request.post(`${HR_API}/api/v1/appraisal/templates`, {
|
||||
headers: managerAuth,
|
||||
data: {
|
||||
code: `E2E-APR-${ts}`,
|
||||
name: { am: "ኢ2ኢ ግምገማ ቅጽ", en: "E2E HR-08 Template" },
|
||||
maxScore: "10.00",
|
||||
requiresSelfAssessment: true,
|
||||
requiresAcknowledgement: true,
|
||||
// Weights sum to exactly 100 — createTemplate refuses anything else.
|
||||
criteria: [
|
||||
{ code: "QUALITY", name: { am: "ጥራት", en: "Quality" }, weight: "60.00" },
|
||||
{ code: "SPEED", name: { am: "ፍጥነት", en: "Speed" }, weight: "40.00" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(template.status(), "weights sum to 100, so the template must be accepted").toBe(201);
|
||||
const templateBody = await template.json();
|
||||
|
||||
const cycle = await request.post(`${HR_API}/api/v1/appraisal/cycles`, {
|
||||
headers: managerAuth,
|
||||
data: {
|
||||
code: `E2E-CYC-${ts}`,
|
||||
name: { am: "ኢ2ኢ ዑደት", en: "E2E HR-08 Cycle" },
|
||||
templateId: templateBody.id,
|
||||
periodStart: "2026-01-01",
|
||||
periodEnd: "2026-12-31",
|
||||
},
|
||||
});
|
||||
expect(cycle.status()).toBe(201);
|
||||
const cycleBody = await cycle.json();
|
||||
|
||||
const open = await request.post(`${HR_API}/api/v1/appraisal/cycles/${cycleBody.id}/open`, {
|
||||
headers: managerAuth,
|
||||
data: { employeeIds: [employeeId] },
|
||||
});
|
||||
expect(open.status()).toBe(201);
|
||||
const openBody = await open.json();
|
||||
expect(openBody.created, "the target employee must not have been skipped").toBe(1);
|
||||
|
||||
const appraisalId = await scalar<string>(
|
||||
`SELECT "id" FROM hr.appraisals WHERE cycle_id = $1 AND employee_id = $2`,
|
||||
[cycleBody.id, employeeId],
|
||||
);
|
||||
expect(appraisalId).toBeTruthy();
|
||||
|
||||
// Self scores low: weightedScore = (2/10)*60 + (2/10)*40 = 20.00
|
||||
const employeeToken = await login(request, "hr-employee");
|
||||
const self = await request.post(`${HR_API}/api/v1/appraisal/${appraisalId}/self`, {
|
||||
headers: { Authorization: `Bearer ${employeeToken}` },
|
||||
data: {
|
||||
ratings: [
|
||||
{ code: "QUALITY", score: 2 },
|
||||
{ code: "SPEED", score: 2 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(self.status()).toBe(201);
|
||||
const selfBody = await self.json();
|
||||
expect(selfBody.selfScore).toBe("20.00");
|
||||
expect(selfBody.status).toBe("PENDING_MANAGER");
|
||||
|
||||
// Manager scores much higher and DIFFERENT: (9/10)*60 + (8/10)*40 = 86.00.
|
||||
// An average of 20.00 and 86.00 would be 53.00 — that is the number a
|
||||
// regression to averaging would produce instead.
|
||||
const manager = await request.post(`${HR_API}/api/v1/appraisal/${appraisalId}/manager`, {
|
||||
headers: managerAuth,
|
||||
data: {
|
||||
ratings: [
|
||||
{ code: "QUALITY", score: 9 },
|
||||
{ code: "SPEED", score: 8 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(manager.status()).toBe(201);
|
||||
const managerBody = await manager.json();
|
||||
|
||||
expect(managerBody.managerScore).toBe("86.00");
|
||||
expect(
|
||||
managerBody.finalScore,
|
||||
"finalScore must equal the manager's weighted score",
|
||||
).toBe("86.00");
|
||||
expect(managerBody.finalScore).not.toBe("53.00");
|
||||
expect(managerBody.finalScore).not.toBe(managerBody.selfScore);
|
||||
|
||||
const row = await query<{
|
||||
selfScore: string;
|
||||
managerScore: string;
|
||||
finalScore: string;
|
||||
}>(
|
||||
`SELECT "self_score" AS "selfScore",
|
||||
"manager_score" AS "managerScore",
|
||||
"final_score" AS "finalScore"
|
||||
FROM hr.appraisals
|
||||
WHERE id = $1`,
|
||||
[appraisalId],
|
||||
);
|
||||
expect(row[0].selfScore).toBe("20.00");
|
||||
expect(row[0].managerScore).toBe("86.00");
|
||||
expect(
|
||||
row[0].finalScore,
|
||||
"hr.appraisals.final_score must match the manager's score exactly, not an average",
|
||||
).toBe("86.00");
|
||||
});
|
||||
});
|
||||
206
e2e-hr-finance/specs/hr/attendance-night-shift.spec.ts
Normal file
206
e2e-hr-finance/specs/hr/attendance-night-shift.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { personaByKey } from "../../fixtures/personas";
|
||||
import { scalar } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* HR-05 — a night shift stays on its start date.
|
||||
*
|
||||
* `AttendanceRecord.workDate` is a DATE, not a timestamp, and deliberately so
|
||||
* (see the entity's own doc comment): a shift that clocks out at 03:00 belongs
|
||||
* to the day it BEGAN. `AttendanceService.checkOut` proves this by looking for
|
||||
* an open record on the check-out's own calendar day first, and — when the
|
||||
* schedule crosses midnight — falling back to an open record from the
|
||||
* immediately preceding day (`findOpenPreviousDay`). This scenario drives both
|
||||
* punches through the real endpoints and checks the settled row lands on the
|
||||
* check-in's date, not the check-out's.
|
||||
*
|
||||
* Naive `HH:MM` times in this codebase are wall-clock EAT (UTC+3) —
|
||||
* `attendance-day.service.ts`'s `atTime()` helper parses a schedule's
|
||||
* `startTime`/`endTime` the same way. Every instant sent here is written with
|
||||
* an explicit `+03:00` offset for that reason, rather than left for whichever
|
||||
* timezone happens to run this test to guess.
|
||||
*/
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
|
||||
async function login(
|
||||
request: import("@playwright/test").APIRequestContext,
|
||||
personaKey: string,
|
||||
): Promise<{ token: string; employeeId: string }> {
|
||||
const persona = personaByKey(personaKey);
|
||||
const res = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
const { token } = await res.json();
|
||||
|
||||
const me = await request.get(`${HR_API}/api/v1/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const body = await me.json();
|
||||
const employee = Array.isArray(body.employee) ? body.employee[0] : body.employee;
|
||||
const employeeId: string | undefined = employee?.id;
|
||||
if (!employeeId) {
|
||||
throw new Error(`[HR-05] persona "${personaKey}" has no employee id on /me`);
|
||||
}
|
||||
return { token, employeeId };
|
||||
}
|
||||
|
||||
/** Calendar-day arithmetic done in UTC, so it never drifts with the local zone. */
|
||||
function addDaysISO(dateStr: string, days: number): string {
|
||||
const d = new Date(`${dateStr}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
test.describe("HR-05 · night shift stays on its start date", () => {
|
||||
test.use({ storageState: storageFor("hr-employee") });
|
||||
|
||||
test("check-out after midnight settles on the check-in's calendar day", async ({
|
||||
request,
|
||||
page,
|
||||
}) => {
|
||||
// Whole-org lookups elsewhere in this suite are heavy; this test only
|
||||
// touches one employee, but give it room anyway.
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const admin = await login(request, "hr-manager");
|
||||
const employee = await login(request, "hr-employee");
|
||||
const authAdmin = { Authorization: `Bearer ${admin.token}` };
|
||||
const authEmployee = { Authorization: `Bearer ${employee.token}` };
|
||||
|
||||
// ── 1. A real crossesMidnight schedule, or one we add ──────────────────
|
||||
const schedulesRes = await request.get(`${HR_API}/api/v1/work-schedules`, {
|
||||
headers: authAdmin,
|
||||
});
|
||||
expect(schedulesRes.status(), "GET /work-schedules as hr-manager").toBe(200);
|
||||
const schedules: Array<{
|
||||
id: string;
|
||||
code: string;
|
||||
crossesMidnight: boolean;
|
||||
isActive: boolean;
|
||||
}> = await schedulesRes.json();
|
||||
|
||||
let schedule = schedules.find((s) => s.crossesMidnight && s.isActive);
|
||||
|
||||
if (!schedule) {
|
||||
const create = await request.post(`${HR_API}/api/v1/work-schedules`, {
|
||||
headers: authAdmin,
|
||||
data: {
|
||||
code: "E2E-NIGHT-SHIFT",
|
||||
name: { am: "ኢ2ኢ የሌሊት ፈረቃ", en: "E2E Night Shift" },
|
||||
startTime: "22:00",
|
||||
endTime: "06:00",
|
||||
breakMinutes: 30,
|
||||
crossesMidnight: true,
|
||||
},
|
||||
});
|
||||
if (create.status() === 409) {
|
||||
// Another pass of this suite created it a moment earlier — the config
|
||||
// serializes workers, but not repeated invocations of the whole run.
|
||||
const again = await request.get(`${HR_API}/api/v1/work-schedules`, {
|
||||
headers: authAdmin,
|
||||
});
|
||||
schedule = (await again.json()).find(
|
||||
(s: { code: string }) => s.code === "E2E-NIGHT-SHIFT",
|
||||
);
|
||||
} else {
|
||||
expect(create.status(), "creating the E2E night-shift schedule").toBe(201);
|
||||
schedule = await create.json();
|
||||
}
|
||||
}
|
||||
if (!schedule) {
|
||||
throw new Error("[HR-05] could not find or create a crossesMidnight schedule");
|
||||
}
|
||||
|
||||
// ── 2. Put the employee on it, well before any date this test picks ────
|
||||
const openOnThisSchedule = await scalar<string>(
|
||||
`SELECT a.id FROM hr.work_schedule_assignments a
|
||||
WHERE a.employee_id = $1 AND a.work_schedule_id = $2 AND a.effective_to IS NULL`,
|
||||
[employee.employeeId, schedule.id],
|
||||
);
|
||||
if (!openOnThisSchedule) {
|
||||
const assign = await request.post(`${HR_API}/api/v1/work-schedules/assign`, {
|
||||
headers: authAdmin,
|
||||
data: {
|
||||
employeeId: employee.employeeId,
|
||||
workScheduleId: schedule.id,
|
||||
effectiveFrom: "2020-01-01",
|
||||
},
|
||||
});
|
||||
expect(assign.status(), "assigning the E2E employee to the night shift").toBe(201);
|
||||
}
|
||||
|
||||
// ── 3. A shift date this employee has never punched — accumulate rather
|
||||
// than delete, matching the suite's E2E- convention (doc §1.4) ─────────
|
||||
const priorRuns = await scalar<string>(
|
||||
`SELECT count(*)::text FROM hr.attendance_records
|
||||
WHERE employee_id = $1 AND notes LIKE 'E2E-HR-05%'`,
|
||||
[employee.employeeId],
|
||||
);
|
||||
// 3-day spacing keeps every run's [shiftDate, shiftDate+1] pair isolated
|
||||
// from every other run's pair.
|
||||
const offset = Number(priorRuns ?? "0") * 3;
|
||||
const shiftDate = addDaysISO("2023-01-02", offset); // a Monday, safely in the past
|
||||
const nextDate = addDaysISO(shiftDate, 1);
|
||||
|
||||
// ── 4. Clock in at 22:00 EAT on shiftDate, clock out at 03:00 EAT the
|
||||
// following calendar day ────────────────────────────────────────────────
|
||||
const checkIn = await request.post(`${HR_API}/api/v1/attendance/check-in`, {
|
||||
headers: authEmployee,
|
||||
data: { at: `${shiftDate}T22:00:00+03:00`, notes: "E2E-HR-05 night shift check-in" },
|
||||
});
|
||||
expect(checkIn.status(), "check-in").toBe(201);
|
||||
const checkInBody = await checkIn.json();
|
||||
expect(checkInBody.workDate, "check-in lands on its own calendar day").toBe(shiftDate);
|
||||
|
||||
const checkOut = await request.post(`${HR_API}/api/v1/attendance/check-out`, {
|
||||
headers: authEmployee,
|
||||
data: {
|
||||
at: `${nextDate}T03:00:00+03:00`,
|
||||
notes: "E2E-HR-05 night shift check-out",
|
||||
},
|
||||
});
|
||||
expect(checkOut.status(), "check-out").toBe(201);
|
||||
const checkOutBody = await checkOut.json();
|
||||
|
||||
// NET — the settled record still belongs to the day the shift BEGAN, not
|
||||
// the day the check-out instant falls on.
|
||||
expect(
|
||||
checkOutBody.workDate,
|
||||
"a night shift settles on its start date, not the check-out's calendar day",
|
||||
).toBe(shiftDate);
|
||||
expect(checkOutBody.id, "check-out closes the SAME row check-in opened").toBe(
|
||||
checkInBody.id,
|
||||
);
|
||||
|
||||
// DB — cast the DATE column to text. node-postgres parses a bare DATE via
|
||||
// this pool's default (non-TypeORM) type parser as LOCAL midnight, which
|
||||
// reads back as the previous day east of UTC (see CLAUDE.md's DATE trap).
|
||||
const dbWorkDate = await scalar<string>(
|
||||
`SELECT work_date::text FROM hr.attendance_records WHERE id = $1`,
|
||||
[checkOutBody.id],
|
||||
);
|
||||
expect(dbWorkDate).toBe(shiftDate);
|
||||
|
||||
// No stray second row was created on the check-out's own calendar day.
|
||||
const strayCount = await scalar<string>(
|
||||
`SELECT count(*)::text FROM hr.attendance_records
|
||||
WHERE employee_id = $1 AND work_date = $2::date`,
|
||||
[employee.employeeId, nextDate],
|
||||
);
|
||||
expect(strayCount, "no separate row was filed under the check-out's date").toBe("0");
|
||||
|
||||
// DOM — the self-service employee's own attendance screen still renders.
|
||||
// No feature-page testids exist yet (doc §4), so this proves the screen is
|
||||
// reachable and error-free after the punch, not the exact cell contents.
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (e) => pageErrors.push(e.message));
|
||||
page.on("response", (r) => {
|
||||
if (r.status() >= 500) pageErrors.push(`${r.status()} ${r.url()}`);
|
||||
});
|
||||
await page.goto("/attendance", { waitUntil: "networkidle" });
|
||||
expect(page.url()).not.toContain("/forbidden");
|
||||
expect(pageErrors, "no page errors or 5xx responses").toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
import { personaByKey } from "../../fixtures/personas";
|
||||
import { query } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* HR-03, HR-10, HR-12 — permission gating, and the two regressions found by the
|
||||
@@ -17,7 +18,7 @@ import { query } from "../../fixtures/db";
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
|
||||
test.describe("HR-03 · leave approvals are reachable by an L2 holder", () => {
|
||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/hr-manager.json` });
|
||||
test.use({ storageState: storageFor("hr-manager") });
|
||||
|
||||
/**
|
||||
* The regression: nav gate, route gate, badge hook and three backend routes
|
||||
@@ -77,7 +78,7 @@ test.describe("HR-03 · leave approvals are reachable by an L2 holder", () => {
|
||||
});
|
||||
|
||||
test.describe("HR-10 · the org-filtered job positions list", () => {
|
||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/hr-manager.json` });
|
||||
test.use({ storageState: storageFor("hr-manager") });
|
||||
|
||||
/**
|
||||
* The regression: `JobPositionsRepository.findPage` filtered on
|
||||
@@ -118,7 +119,7 @@ test.describe("HR-10 · the org-filtered job positions list", () => {
|
||||
});
|
||||
|
||||
test.describe("HR-12 · a self-service employee is refused the manage screens", () => {
|
||||
test.use({ storageState: `${__dirname}/../../fixtures/storage/hr-employee.json` });
|
||||
test.use({ storageState: storageFor("hr-employee") });
|
||||
|
||||
/**
|
||||
* `employee_self_service` is the narrowest HR role — the ten SELF_SERVICE_KEYS
|
||||
|
||||
158
e2e-hr-finance/specs/hr/leave-approve.spec.ts
Normal file
158
e2e-hr-finance/specs/hr/leave-approve.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||
import { query, scalar } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* HR-02 — L1 approval deducts atomically.
|
||||
*
|
||||
* `LeaveRequestsService.approve()` posts the DEDUCTION and flips the status
|
||||
* inside one `dataSource.transaction`, on purpose: "A request marked approved
|
||||
* whose deduction failed gives away free leave; a deduction whose approval
|
||||
* failed takes days for an absence nobody agreed to." This proves both halves
|
||||
* landed together — exactly one DEDUCTION row, for this request, in the window
|
||||
* the approval itself ran in.
|
||||
*
|
||||
* `hr-manager` (MANAGER_POSITION_ID, "Team Leader, Online Booking System") and
|
||||
* `hr-employee` (REPORT_POSITION_ID, "Officer, Operation Data Management") are
|
||||
* a real parent/child pair in the IAM position tree — see the doc comment on
|
||||
* those constants in fixtures/personas.ts, written for exactly this scenario.
|
||||
* Because of that, a request hr-employee submits resolves its approver (via
|
||||
* `IamDirectoryService.findLineManagerEmployeeId`) to hr-manager for real, so
|
||||
* the approval below is driven through the actual "awaiting-me" queue on
|
||||
* `/leave/approvals` rather than an approve call aimed at an arbitrary id.
|
||||
*/
|
||||
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
|
||||
// A different leave year (2032) from leave-submit.spec.ts's 2031 and
|
||||
// leave-cancel.spec.ts's 2033 — each spec's MARRIAGE entitlement (3.00
|
||||
// days/year, uncapped by prior specs) stays independent, so the three files
|
||||
// never contend for the same balance.
|
||||
const START_DATE = "2032-06-14";
|
||||
const END_DATE = "2032-06-16";
|
||||
|
||||
async function loginAndMe(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ token: string; employeeId: string }> {
|
||||
const login = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(login.ok(), `login failed for ${email}: ${await login.text()}`).toBeTruthy();
|
||||
const { token } = await login.json();
|
||||
|
||||
const meRes = await request.get(`${HR_API}/api/v1/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(meRes.ok(), `/me failed for ${email}`).toBeTruthy();
|
||||
const me = await meRes.json();
|
||||
return { token, employeeId: me.employee?.id as string };
|
||||
}
|
||||
|
||||
test.describe("HR-02 · L1 approval deducts atomically", () => {
|
||||
test.use({ storageState: storageFor("hr-manager") });
|
||||
|
||||
test("approving posts exactly one DEDUCTION in the same window as the status flip", async ({
|
||||
request,
|
||||
page,
|
||||
}) => {
|
||||
const manager = personaByKey("hr-manager");
|
||||
const employee = personaByKey("hr-employee");
|
||||
|
||||
const mgr = await loginAndMe(request, manager.email, manager.password);
|
||||
const emp = await loginAndMe(request, employee.email, employee.password);
|
||||
const employeeAuth = { Authorization: `Bearer ${emp.token}` };
|
||||
|
||||
// Setup, via API for speed — see leave-submit.spec.ts for why the profile
|
||||
// must be ensured first.
|
||||
const ensure = await request.post(
|
||||
`${HR_API}/api/v1/employee-profiles/by-employee/${emp.employeeId}/ensure`,
|
||||
{ headers: { Authorization: `Bearer ${mgr.token}` } },
|
||||
);
|
||||
expect(ensure.ok(), `ensuring hr-employee's HR profile: ${await ensure.text()}`).toBeTruthy();
|
||||
|
||||
const leaveTypeId = await scalar<string>(
|
||||
`SELECT id FROM hr.leave_types
|
||||
WHERE organization_id = $1 AND code = 'MARRIAGE' AND is_active = true
|
||||
LIMIT 1`,
|
||||
[ORG_ID],
|
||||
);
|
||||
expect(leaveTypeId, "MARRIAGE leave type must exist for the Active org").not.toBeNull();
|
||||
|
||||
const createRes = await request.post(`${HR_API}/api/v1/leave-requests`, {
|
||||
headers: employeeAuth,
|
||||
data: {
|
||||
leaveTypeId,
|
||||
startDate: START_DATE,
|
||||
endDate: END_DATE,
|
||||
isHalfDay: false,
|
||||
reason: "E2E-HR-02 leave-approve spec",
|
||||
},
|
||||
});
|
||||
expect(createRes.status(), await createRes.text()).toBe(201);
|
||||
const created = await createRes.json();
|
||||
expect(created.status).toBe("SUBMITTED");
|
||||
|
||||
// DOM: the request is really sitting in hr-manager's own approvals queue —
|
||||
// not merely reachable via a direct approve call — which is the proof that
|
||||
// the position pair above is genuinely related, not incidental.
|
||||
await page.goto("/leave/approvals", { waitUntil: "networkidle" });
|
||||
const row = page.getByTestId(`leave-approval-row-${created.id}`);
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
const before = new Date();
|
||||
const [approveResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes(`/leave-requests/${created.id}/approve`) &&
|
||||
res.request().method() === "PATCH",
|
||||
),
|
||||
page.getByTestId(`leave-approve-btn-${created.id}`).click(),
|
||||
]);
|
||||
const after = new Date();
|
||||
|
||||
// NET: the response Approve actually returned.
|
||||
expect(approveResponse.status(), await approveResponse.text()).toBe(200);
|
||||
const approved = await approveResponse.json();
|
||||
expect(approved.status).toBe("APPROVED");
|
||||
|
||||
// DOM: decided requests leave the "awaiting-me" queue.
|
||||
await expect(row).not.toBeVisible();
|
||||
|
||||
// DB: exactly one DEDUCTION for this request's source, sized to match the
|
||||
// frozen chargedDays, posted inside the window this approval itself ran in.
|
||||
const ledgerRows = await query<{ entry_type: string; days: string; created_at: string }>(
|
||||
`SELECT entry_type, days, created_at FROM hr.leave_ledger_entries
|
||||
WHERE source_type = 'LEAVE_REQUEST' AND source_id = $1`,
|
||||
[created.id],
|
||||
);
|
||||
const deductions = ledgerRows.filter((row) => row.entry_type === "DEDUCTION");
|
||||
expect(deductions.length, "exactly one deduction must exist for this request").toBe(1);
|
||||
expect(Number(deductions[0].days)).toBe(-Number(created.chargedDays));
|
||||
|
||||
const postedAt = new Date(deductions[0].created_at).getTime();
|
||||
// A small tolerance either side for clock skew between the test runner and
|
||||
// the database server — the point is "inside this test's run", not to the
|
||||
// millisecond.
|
||||
expect(postedAt).toBeGreaterThanOrEqual(before.getTime() - 2000);
|
||||
expect(postedAt).toBeLessThanOrEqual(after.getTime() + 2000);
|
||||
|
||||
const dbStatus = await scalar<string>(
|
||||
`SELECT status FROM hr.leave_requests WHERE id = $1`,
|
||||
[created.id],
|
||||
);
|
||||
expect(dbStatus).toBe("APPROVED");
|
||||
|
||||
// Teardown: cancel (reverses, does not delete — see leave-cancel.spec.ts)
|
||||
// so these dates are free for the next run. An APPROVED row is "live" per
|
||||
// LIVE_STATUSES and would otherwise trip a rerun's findOverlapping() check.
|
||||
const cancelRes = await request.patch(`${HR_API}/api/v1/leave-requests/${created.id}/cancel`, {
|
||||
headers: employeeAuth,
|
||||
data: { reason: "E2E-HR-02 teardown" },
|
||||
});
|
||||
expect(cancelRes.status(), "teardown: cancel must succeed").toBe(200);
|
||||
});
|
||||
});
|
||||
180
e2e-hr-finance/specs/hr/leave-cancel.spec.ts
Normal file
180
e2e-hr-finance/specs/hr/leave-cancel.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||
import { query, scalar } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* HR-04 — cancelling approved leave reverses the deduction, it does not delete
|
||||
* it.
|
||||
*
|
||||
* `LeaveRequestsService.cancel()` posts a REVERSAL against the original
|
||||
* DEDUCTION rather than touching it — `hr.leave_ledger_entries` has no
|
||||
* `updated_at`/`deleted_at` at all (see the entity's own doc comment:
|
||||
* "append-only: never updated, never deleted"). This proves the ledger stays
|
||||
* append-only across a real cancel, and that the unique-index-on-`reverses_id`
|
||||
* guard actually refuses a second cancel of the same request.
|
||||
*/
|
||||
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
|
||||
// A third leave year (2033) — independent of leave-submit.spec.ts's 2031 and
|
||||
// leave-approve.spec.ts's 2032, so this file's MARRIAGE entitlement (3.00
|
||||
// days/year) is never contended by the other two.
|
||||
const START_DATE = "2033-06-13";
|
||||
const END_DATE = "2033-06-15";
|
||||
|
||||
async function loginAndMe(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ token: string; employeeId: string }> {
|
||||
const login = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(login.ok(), `login failed for ${email}: ${await login.text()}`).toBeTruthy();
|
||||
const { token } = await login.json();
|
||||
|
||||
const meRes = await request.get(`${HR_API}/api/v1/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(meRes.ok(), `/me failed for ${email}`).toBeTruthy();
|
||||
const me = await meRes.json();
|
||||
return { token, employeeId: me.employee?.id as string };
|
||||
}
|
||||
|
||||
test.describe("HR-04 · cancelling approved leave reverses, not deletes", () => {
|
||||
test.use({ storageState: storageFor("hr-employee") });
|
||||
|
||||
test("cancel posts a REVERSAL, leaves the DEDUCTION untouched, and refuses a second cancel", async ({
|
||||
request,
|
||||
page,
|
||||
}) => {
|
||||
const manager = personaByKey("hr-manager");
|
||||
const employee = personaByKey("hr-employee");
|
||||
|
||||
const mgr = await loginAndMe(request, manager.email, manager.password);
|
||||
const emp = await loginAndMe(request, employee.email, employee.password);
|
||||
const employeeAuth = { Authorization: `Bearer ${emp.token}` };
|
||||
const managerAuth = { Authorization: `Bearer ${mgr.token}` };
|
||||
|
||||
// Setup: an APPROVED request, via API for speed — the approve path itself
|
||||
// is leave-approve.spec.ts's job, not this file's.
|
||||
const ensure = await request.post(
|
||||
`${HR_API}/api/v1/employee-profiles/by-employee/${emp.employeeId}/ensure`,
|
||||
{ headers: managerAuth },
|
||||
);
|
||||
expect(ensure.ok(), `ensuring hr-employee's HR profile: ${await ensure.text()}`).toBeTruthy();
|
||||
|
||||
const leaveTypeId = await scalar<string>(
|
||||
`SELECT id FROM hr.leave_types
|
||||
WHERE organization_id = $1 AND code = 'MARRIAGE' AND is_active = true
|
||||
LIMIT 1`,
|
||||
[ORG_ID],
|
||||
);
|
||||
expect(leaveTypeId, "MARRIAGE leave type must exist for the Active org").not.toBeNull();
|
||||
|
||||
const createRes = await request.post(`${HR_API}/api/v1/leave-requests`, {
|
||||
headers: employeeAuth,
|
||||
data: {
|
||||
leaveTypeId,
|
||||
startDate: START_DATE,
|
||||
endDate: END_DATE,
|
||||
isHalfDay: false,
|
||||
reason: "E2E-HR-04 leave-cancel spec",
|
||||
},
|
||||
});
|
||||
expect(createRes.status(), await createRes.text()).toBe(201);
|
||||
const created = await createRes.json();
|
||||
|
||||
const approveRes = await request.patch(`${HR_API}/api/v1/leave-requests/${created.id}/approve`, {
|
||||
headers: managerAuth,
|
||||
data: {},
|
||||
});
|
||||
expect(approveRes.status(), await approveRes.text()).toBe(200);
|
||||
|
||||
// The DEDUCTION posted by approve — captured whole, so "untouched" below
|
||||
// can be a real equality check rather than a re-derived guess.
|
||||
const beforeCancel = await query<Record<string, unknown>>(
|
||||
`SELECT * FROM hr.leave_ledger_entries
|
||||
WHERE source_type = 'LEAVE_REQUEST' AND source_id = $1 AND entry_type = 'DEDUCTION'`,
|
||||
[created.id],
|
||||
);
|
||||
expect(beforeCancel.length, "the approval must have posted one deduction").toBe(1);
|
||||
const deduction = beforeCancel[0] as { id: string; days: string };
|
||||
|
||||
// The actual behaviour under test: the employee cancels their own
|
||||
// approved leave. There is no data-testid for this action in MyLeavePage
|
||||
// (only the leave-request-* and leave-approval-*/leave-approve-btn-*/
|
||||
// leave-reject-btn-* ids exist), so this is asserted via NET + DB rather
|
||||
// than a UI click.
|
||||
const cancelRes = await request.patch(`${HR_API}/api/v1/leave-requests/${created.id}/cancel`, {
|
||||
headers: employeeAuth,
|
||||
data: { reason: "E2E-HR-04 leave-cancel spec — plans changed" },
|
||||
});
|
||||
expect(cancelRes.status(), await cancelRes.text()).toBe(200);
|
||||
const cancelled = await cancelRes.json();
|
||||
expect(cancelled.status).toBe("CANCELLED");
|
||||
|
||||
// DB: a REVERSAL row referencing the DEDUCTION by id, crediting back
|
||||
// exactly what was deducted.
|
||||
const reversalRows = await query<{ entry_type: string; days: string; reverses_id: string }>(
|
||||
`SELECT entry_type, days, reverses_id FROM hr.leave_ledger_entries WHERE reverses_id = $1`,
|
||||
[deduction.id],
|
||||
);
|
||||
expect(reversalRows.length, "exactly one reversal must exist").toBe(1);
|
||||
expect(reversalRows[0].entry_type).toBe("REVERSAL");
|
||||
expect(Number(reversalRows[0].days)).toBe(-Number(deduction.days));
|
||||
|
||||
// DB: the original DEDUCTION is byte-for-byte unchanged — not deleted, not
|
||||
// updated. The ledger has no updated_at/deleted_at at all, so this is a
|
||||
// straight equality against the row captured before cancel.
|
||||
const afterDeduction = await query<Record<string, unknown>>(
|
||||
`SELECT * FROM hr.leave_ledger_entries WHERE id = $1`,
|
||||
[deduction.id],
|
||||
);
|
||||
expect(afterDeduction.length).toBe(1);
|
||||
expect(afterDeduction[0]).toEqual(deduction);
|
||||
|
||||
const dbStatus = await scalar<string>(
|
||||
`SELECT status FROM hr.leave_requests WHERE id = $1`,
|
||||
[created.id],
|
||||
);
|
||||
expect(dbStatus).toBe("CANCELLED");
|
||||
|
||||
// The idempotent-double-cancel guard. cancel() re-runs assertTransition()
|
||||
// first, and ALLOWED[CANCELLED] is empty ("that status is final") — so a
|
||||
// second cancel is refused with 400 before it ever reaches the ledger's
|
||||
// own unique-index-on-reverses_id guard in LeaveBalancesService.reverse()
|
||||
// (a ConflictException/23505, reserved for two cancels racing inside the
|
||||
// same still-APPROVED window; a sequential second call here never gets
|
||||
// that far, because the first has already flipped the status).
|
||||
const secondCancel = await request.patch(`${HR_API}/api/v1/leave-requests/${created.id}/cancel`, {
|
||||
headers: employeeAuth,
|
||||
data: { reason: "second cancel must be refused" },
|
||||
});
|
||||
expect(
|
||||
secondCancel.status(),
|
||||
`a cancelled request must refuse a second cancel: ${await secondCancel.text()}`,
|
||||
).toBe(400);
|
||||
|
||||
// DB: the refused attempt wrote nothing — still exactly one reversal.
|
||||
const reversalsAfter = await scalar<string>(
|
||||
`SELECT count(*)::text AS count FROM hr.leave_ledger_entries WHERE reverses_id = $1`,
|
||||
[deduction.id],
|
||||
);
|
||||
expect(reversalsAfter).toBe("1");
|
||||
|
||||
// DOM: hr-employee's own "My leave" table shows the cancelled status.
|
||||
// A prior run's own (also CANCELLED) row can share this fixed START_DATE.
|
||||
// The list sorts startDate DESC, createdAt DESC, so among same-date rows
|
||||
// the one this test just created is always first.
|
||||
await page.goto("/leave", { waitUntil: "networkidle" });
|
||||
const row = page.locator("tr", { hasText: START_DATE }).first();
|
||||
await expect(row).toContainText("Cancelled");
|
||||
|
||||
// No teardown needed: CANCELLED is not in LIVE_STATUSES, so
|
||||
// findOverlapping() ignores this row and these dates are free for the
|
||||
// next run without any extra cleanup.
|
||||
});
|
||||
});
|
||||
152
e2e-hr-finance/specs/hr/leave-submit.spec.ts
Normal file
152
e2e-hr-finance/specs/hr/leave-submit.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||
import { query, scalar } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* HR-01 — submitting a leave request freezes the charged-day count.
|
||||
*
|
||||
* `LeaveRequest.chargedDays` is written once, at submission, and nothing in
|
||||
* the codebase ever recomputes it (see the entity's own doc comment: "The day
|
||||
* counts are frozen at submission rather than derived on read"). This proves
|
||||
* the freeze the only way that is actually observable: the number the server
|
||||
* priced via `GET /leave-requests/quote` — before anything is committed — must
|
||||
* equal both what `POST /leave-requests` returns AND what lands in
|
||||
* `hr.leave_requests.charged_days`.
|
||||
*
|
||||
* A genuine "edit the holiday calendar after submission and reprice" scenario
|
||||
* is not simulated here — there is no product endpoint that recomputes an
|
||||
* already-submitted request, which is exactly the invariant being frozen: there
|
||||
* is nothing to trigger. What this proves is the freeze AT submission time.
|
||||
*/
|
||||
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
|
||||
// A Monday-to-Wednesday window five years out, in a leave year (2031) this
|
||||
// employee has never touched. Far enough ahead to never collide with real
|
||||
// production leave data; the teardown at the end of the test (withdraw) is
|
||||
// what keeps a rerun from colliding with ITSELF.
|
||||
const START_DATE = "2031-06-09";
|
||||
const END_DATE = "2031-06-11";
|
||||
|
||||
async function loginAndMe(
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ token: string; employeeId: string }> {
|
||||
const login = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
expect(login.ok(), `login failed for ${email}: ${await login.text()}`).toBeTruthy();
|
||||
const { token } = await login.json();
|
||||
|
||||
const meRes = await request.get(`${HR_API}/api/v1/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(meRes.ok(), `/me failed for ${email}`).toBeTruthy();
|
||||
const me = await meRes.json();
|
||||
return { token, employeeId: me.employee?.id as string };
|
||||
}
|
||||
|
||||
test.describe("HR-01 · submitting leave freezes the charged-day count", () => {
|
||||
test.use({ storageState: storageFor("hr-employee") });
|
||||
|
||||
test("chargedDays on the created request equals the pre-submit quote", async ({
|
||||
request,
|
||||
page,
|
||||
}) => {
|
||||
const manager = personaByKey("hr-manager");
|
||||
const employee = personaByKey("hr-employee");
|
||||
|
||||
const mgr = await loginAndMe(request, manager.email, manager.password);
|
||||
const emp = await loginAndMe(request, employee.email, employee.password);
|
||||
const employeeAuth = { Authorization: `Bearer ${emp.token}` };
|
||||
|
||||
// hr-employee is seeded straight into iam.* by seed-personas.cjs and
|
||||
// carries no hr.employee_profile — LeaveRequestsService.create() 404s
|
||||
// without one ("Onboard them before they can request leave"). Provision
|
||||
// it through the product's own idempotent onboarding endpoint rather than
|
||||
// writing hr.employee_profiles ourselves — the suite's read-only-SQL
|
||||
// doctrine (fixtures/db.ts) is exactly this: fixtures come from real
|
||||
// endpoints, never raw INSERTs.
|
||||
const ensure = await request.post(
|
||||
`${HR_API}/api/v1/employee-profiles/by-employee/${emp.employeeId}/ensure`,
|
||||
{ headers: { Authorization: `Bearer ${mgr.token}` } },
|
||||
);
|
||||
expect(ensure.ok(), `ensuring hr-employee's HR profile: ${await ensure.text()}`).toBeTruthy();
|
||||
|
||||
// MARRIAGE is the one statutory type a same-day-provisioned profile is
|
||||
// guaranteed eligible for: genderRestriction ANY, minServiceMonths 0 — and
|
||||
// requiresApproval:true, unlike BEREAVEMENT, so this actually produces a
|
||||
// SUBMITTED request rather than an auto-approved one.
|
||||
const leaveTypeId = await scalar<string>(
|
||||
`SELECT id FROM hr.leave_types
|
||||
WHERE organization_id = $1 AND code = 'MARRIAGE' AND is_active = true
|
||||
LIMIT 1`,
|
||||
[ORG_ID],
|
||||
);
|
||||
expect(leaveTypeId, "MARRIAGE leave type must exist for the Active org").not.toBeNull();
|
||||
|
||||
const quoteRes = await request.get(`${HR_API}/api/v1/leave-requests/quote`, {
|
||||
headers: employeeAuth,
|
||||
params: { leaveTypeId: leaveTypeId!, startDate: START_DATE, endDate: END_DATE, isHalfDay: "false" },
|
||||
});
|
||||
expect(quoteRes.status(), await quoteRes.text()).toBe(200);
|
||||
const quote = await quoteRes.json();
|
||||
expect(quote.chargedDays, "the quote must price something before submitting").toBeGreaterThan(0);
|
||||
|
||||
const createRes = await request.post(`${HR_API}/api/v1/leave-requests`, {
|
||||
headers: employeeAuth,
|
||||
data: {
|
||||
leaveTypeId,
|
||||
startDate: START_DATE,
|
||||
endDate: END_DATE,
|
||||
isHalfDay: false,
|
||||
reason: "E2E-HR-01 leave-submit spec",
|
||||
},
|
||||
});
|
||||
expect(createRes.status(), await createRes.text()).toBe(201);
|
||||
const created = await createRes.json();
|
||||
|
||||
// NET: the response is what the UI and everything downstream sees.
|
||||
expect(created.status).toBe("SUBMITTED");
|
||||
expect(Number(created.chargedDays)).toBe(quote.chargedDays);
|
||||
|
||||
// DB: the row actually written carries the same frozen number — not a
|
||||
// value the client invented, and not one derived again on read.
|
||||
const dbRows = await query<{ status: string; charged_days: string }>(
|
||||
`SELECT status, charged_days FROM hr.leave_requests WHERE id = $1`,
|
||||
[created.id],
|
||||
);
|
||||
expect(dbRows.length).toBe(1);
|
||||
expect(dbRows[0].status).toBe("SUBMITTED");
|
||||
expect(Number(dbRows[0].charged_days)).toBe(quote.chargedDays);
|
||||
|
||||
// DOM: hr-employee's own "My leave" table, in the real browser session
|
||||
// this describe block's storageState signs in — the frozen number and the
|
||||
// SUBMITTED badge are both visible without touching the request-leave
|
||||
// modal's Ethiopian/Gregorian date picker (already exercised by creating
|
||||
// the request; reading a rendered table is the stable part to assert on).
|
||||
await page.goto("/leave", { waitUntil: "networkidle" });
|
||||
// A prior run's already-terminal row can share this fixed START_DATE (the
|
||||
// suite's convention is to leave a named row behind, not delete it). The
|
||||
// list sorts startDate DESC, createdAt DESC, so among same-date rows the
|
||||
// one this test just created is always first.
|
||||
const row = page.locator("tr", { hasText: START_DATE }).first();
|
||||
await expect(row).toContainText("Awaiting decision");
|
||||
await expect(row).toContainText(String(quote.chargedDays));
|
||||
|
||||
// Teardown: withdraw rather than leave a SUBMITTED row behind. Nothing was
|
||||
// deducted at submission (withdraw's own doc comment: "nothing was
|
||||
// deducted, so nothing comes back"), so this is a clean no-op for the
|
||||
// balance, and frees these dates so a rerun's findOverlapping() (which
|
||||
// only looks at SUBMITTED/APPROVED — see LIVE_STATUSES) does not refuse
|
||||
// the next run's create().
|
||||
const withdrawRes = await request.patch(
|
||||
`${HR_API}/api/v1/leave-requests/${created.id}/withdraw`,
|
||||
{ headers: employeeAuth },
|
||||
);
|
||||
expect(withdrawRes.status(), "teardown: withdraw must succeed").toBe(200);
|
||||
});
|
||||
});
|
||||
171
e2e-hr-finance/specs/hr/payroll-no-recalc.spec.ts
Normal file
171
e2e-hr-finance/specs/hr/payroll-no-recalc.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||
import { scalar } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* HR-06 — recalculation is refused after approval.
|
||||
*
|
||||
* `PayrollRunsService.calculate()` is repeatable while a run is DRAFT or
|
||||
* CALCULATED — that is how "recalculate after fixing a salary" works — and
|
||||
* refused with a `BadRequestException` (HTTP 400) for anything else. Once
|
||||
* APPROVED the payslips ARE the record: recomputing on read would silently
|
||||
* restate figures already reported to tax and pension.
|
||||
*
|
||||
* Two roles are load-bearing here, and the split is structural
|
||||
* (`hr-permissions.registry.ts`'s own comment names it): `run:payroll` belongs
|
||||
* to `payroll_admin` and `approve:payroll` belongs to `hr_manager`. Neither
|
||||
* role holds both, so this scenario genuinely needs the two personas already
|
||||
* in the fixture roster — it is not possible to drive the whole lifecycle as
|
||||
* one persona.
|
||||
*/
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
|
||||
/** Tags the run this scenario owns, so a rerun can find and reuse it instead
|
||||
* of piling up a fresh APPROVED run every pass — the suite's "named, findable
|
||||
* row" convention (doc §1.4) rather than a per-run DELETE. */
|
||||
const NOTE = "E2E-HR-06 recalculation-refused";
|
||||
|
||||
async function login(
|
||||
request: import("@playwright/test").APIRequestContext,
|
||||
personaKey: string,
|
||||
): Promise<{ token: string }> {
|
||||
const persona = personaByKey(personaKey);
|
||||
const res = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
const { token } = await res.json();
|
||||
return { token };
|
||||
}
|
||||
|
||||
const firstOfMonth = (year: number, month0: number): string =>
|
||||
new Date(Date.UTC(year, month0, 1)).toISOString().slice(0, 10);
|
||||
|
||||
const lastOfMonth = (year: number, month0: number): string =>
|
||||
new Date(Date.UTC(year, month0 + 1, 0)).toISOString().slice(0, 10);
|
||||
|
||||
/**
|
||||
* The nearest calendar month, starting at `fromYear`/`fromMonth0`, for which
|
||||
* this org has no live (non-cancelled) payroll run. Kept close to "now" rather
|
||||
* than picking an arbitrary future date, since the statutory tax brackets and
|
||||
* rates a run depends on are seeded for the present, not for an arbitrary
|
||||
* decade ahead.
|
||||
*/
|
||||
async function findFreePeriod(
|
||||
fromYear: number,
|
||||
fromMonth0: number,
|
||||
): Promise<{ periodStart: string; periodEnd: string }> {
|
||||
for (let i = 0; i < 36; i += 1) {
|
||||
const y = fromYear + Math.floor((fromMonth0 + i) / 12);
|
||||
const m = (fromMonth0 + i) % 12;
|
||||
const periodStart = firstOfMonth(y, m);
|
||||
const periodEnd = lastOfMonth(y, m);
|
||||
// Mirrors the partial unique index (`uq_payroll_runs_org_period`), which
|
||||
// excludes CANCELLED runs — a cancelled period is free to reuse.
|
||||
const clash = await scalar<string>(
|
||||
`SELECT id FROM hr.payroll_runs
|
||||
WHERE organization_id = $1 AND period_start = $2::date AND period_end = $3::date
|
||||
AND status <> 'CANCELLED'`,
|
||||
[ORG_ID, periodStart, periodEnd],
|
||||
);
|
||||
if (!clash) return { periodStart, periodEnd };
|
||||
}
|
||||
throw new Error("[HR-06] no free payroll period found in the next 36 months");
|
||||
}
|
||||
|
||||
test.describe("HR-06 · recalculation refused after approval", () => {
|
||||
test.use({ storageState: storageFor("hr-payroll-admin") });
|
||||
|
||||
test("a second POST /:id/calculate is refused once APPROVED, and nothing changes", async ({
|
||||
request,
|
||||
}) => {
|
||||
// Calculation sweeps every payable salary in the Active org (2,400+
|
||||
// employees) — give it real room rather than the config's 60s default.
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const admin = await login(request, "hr-payroll-admin");
|
||||
const manager = await login(request, "hr-manager");
|
||||
const authAdmin = { Authorization: `Bearer ${admin.token}` };
|
||||
const authManager = { Authorization: `Bearer ${manager.token}` };
|
||||
|
||||
let runId = await scalar<string>(
|
||||
`SELECT id FROM hr.payroll_runs
|
||||
WHERE organization_id = $1 AND note = $2 AND status = 'APPROVED'
|
||||
LIMIT 1`,
|
||||
[ORG_ID, NOTE],
|
||||
);
|
||||
|
||||
if (!runId) {
|
||||
const today = new Date();
|
||||
const { periodStart, periodEnd } = await findFreePeriod(
|
||||
today.getUTCFullYear(),
|
||||
today.getUTCMonth(),
|
||||
);
|
||||
|
||||
const create = await request.post(`${HR_API}/api/v1/payroll-runs`, {
|
||||
headers: authAdmin,
|
||||
data: { periodStart, periodEnd, note: NOTE },
|
||||
});
|
||||
expect(create.status(), "open the run (payroll_admin holds run:payroll)").toBe(201);
|
||||
const run = await create.json();
|
||||
expect(run.status).toBe("DRAFT");
|
||||
runId = run.id;
|
||||
|
||||
const calc = await request.post(`${HR_API}/api/v1/payroll-runs/${runId}/calculate`, {
|
||||
headers: authAdmin,
|
||||
});
|
||||
expect(calc.status(), "first calculate — DRAFT is calculable").toBe(201);
|
||||
const calculated = await calc.json();
|
||||
expect(calculated.status).toBe("CALCULATED");
|
||||
expect(
|
||||
calculated.employeeCount,
|
||||
"the Active org has payable salaries for this period",
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// payroll_admin does NOT hold approve:payroll — that separation of duty
|
||||
// is deliberate, so approval goes through hr-manager instead.
|
||||
const approve = await request.patch(
|
||||
`${HR_API}/api/v1/payroll-runs/${runId}/approve`,
|
||||
{ headers: authManager },
|
||||
);
|
||||
expect(approve.status(), "approve (hr_manager holds approve:payroll)").toBe(200);
|
||||
expect((await approve.json()).status).toBe("APPROVED");
|
||||
}
|
||||
|
||||
const payslipCountBefore = await scalar<string>(
|
||||
`SELECT count(*)::text FROM hr.payslips WHERE payroll_run_id = $1`,
|
||||
[runId],
|
||||
);
|
||||
|
||||
// NET — the run is APPROVED; PayrollRunsService.calculate()'s status guard
|
||||
// throws a BadRequestException for anything but DRAFT/CALCULATED.
|
||||
const second = await request.post(`${HR_API}/api/v1/payroll-runs/${runId}/calculate`, {
|
||||
headers: authAdmin,
|
||||
});
|
||||
expect(
|
||||
second.status(),
|
||||
"recalculating an APPROVED run must be refused with the exact 400 the service throws",
|
||||
).toBe(400);
|
||||
|
||||
// DB — the status held, and the payslips were never touched. calculate()
|
||||
// deletes-and-rebuilds them only inside the same call the status guard
|
||||
// refused, so an unchanged count proves the guard ran before any write.
|
||||
const statusAfter = await scalar<string>(
|
||||
`SELECT status FROM hr.payroll_runs WHERE id = $1`,
|
||||
[runId],
|
||||
);
|
||||
expect(statusAfter, "status must still be APPROVED after the refused call").toBe(
|
||||
"APPROVED",
|
||||
);
|
||||
|
||||
const payslipCountAfter = await scalar<string>(
|
||||
`SELECT count(*)::text FROM hr.payslips WHERE payroll_run_id = $1`,
|
||||
[runId],
|
||||
);
|
||||
expect(
|
||||
payslipCountAfter,
|
||||
"payslips must be byte-for-byte untouched by the refused recalculation",
|
||||
).toBe(payslipCountBefore);
|
||||
});
|
||||
});
|
||||
271
e2e-hr-finance/specs/hr/payroll-self-service-scoping.spec.ts
Normal file
271
e2e-hr-finance/specs/hr/payroll-self-service-scoping.spec.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||
import { query, scalar } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* HR-07 — self-service payroll is scoped.
|
||||
*
|
||||
* `PayrollRunsService.payslipsForEmployee` is the whole contract:
|
||||
*
|
||||
* .where("payslip.employee_id = :employeeId", { employeeId })
|
||||
* .andWhere("run.status IN (:...statuses)", { statuses: [APPROVED, PAID] })
|
||||
*
|
||||
* Two things have to hold for `GET /payroll-runs/my-payslips` to be trustworthy:
|
||||
* it must never return anyone else's payslip, and it must not publish a run's
|
||||
* figures before they are approved. This scenario needs two employees on the
|
||||
* SAME run to prove the first, and drives one run through CALCULATED before
|
||||
* approving it to prove the second.
|
||||
*
|
||||
* `hr-employee` (self-service) and `hr-payroll-admin` are both real, distinct
|
||||
* `iam.employees` rows in the Active org, and every HR role carries
|
||||
* `view_own:payslip` (`SELF_SERVICE_KEYS`) — so both fixture personas already
|
||||
* on the roster can stand in as "two employees" without inventing a third.
|
||||
*/
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
const NOTE = "E2E-HR-07 self-service-scoping";
|
||||
|
||||
async function login(
|
||||
request: import("@playwright/test").APIRequestContext,
|
||||
personaKey: string,
|
||||
): Promise<{ token: string; employeeId: string }> {
|
||||
const persona = personaByKey(personaKey);
|
||||
const res = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
const { token } = await res.json();
|
||||
|
||||
const me = await request.get(`${HR_API}/api/v1/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const body = await me.json();
|
||||
const employee = Array.isArray(body.employee) ? body.employee[0] : body.employee;
|
||||
const employeeId: string | undefined = employee?.id;
|
||||
if (!employeeId) {
|
||||
throw new Error(`[HR-07] persona "${personaKey}" has no employee id on /me`);
|
||||
}
|
||||
return { token, employeeId };
|
||||
}
|
||||
|
||||
/**
|
||||
* These fixture personas have no `hr.employee_profiles` row and no
|
||||
* `hr.employee_salaries` row — `seed-personas.cjs` only ever writes `iam.*`.
|
||||
* `PayrollRunsService.calculate()` silently skips anyone without an HR profile
|
||||
* (see the service's own comment) and only prices a salary in force on the
|
||||
* period end, so both are prerequisites for this employee to appear in a run
|
||||
* at all. Created once, through the product's own endpoints, and reused on
|
||||
* every rerun.
|
||||
*/
|
||||
async function ensurePayable(
|
||||
request: import("@playwright/test").APIRequestContext,
|
||||
authManager: Record<string, string>,
|
||||
employeeId: string,
|
||||
): Promise<void> {
|
||||
const hasProfile = await scalar<string>(
|
||||
`SELECT id FROM hr.employee_profiles WHERE employee_id = $1`,
|
||||
[employeeId],
|
||||
);
|
||||
if (!hasProfile) {
|
||||
const create = await request.post(`${HR_API}/api/v1/employee-profiles`, {
|
||||
headers: authManager,
|
||||
data: { employeeId, employmentType: "PERMANENT", hireDate: "2020-01-01" },
|
||||
});
|
||||
// A concurrent create from an earlier pass racing this one is the only
|
||||
// expected non-201 — anything else is a real failure.
|
||||
if (create.status() !== 409) {
|
||||
expect(create.status(), `create HR profile for ${employeeId}`).toBe(201);
|
||||
}
|
||||
}
|
||||
|
||||
const hasOpenSalary = await scalar<string>(
|
||||
`SELECT id FROM hr.employee_salaries WHERE employee_id = $1 AND effective_to IS NULL`,
|
||||
[employeeId],
|
||||
);
|
||||
if (!hasOpenSalary) {
|
||||
const assign = await request.post(`${HR_API}/api/v1/payroll/salaries`, {
|
||||
headers: authManager,
|
||||
data: {
|
||||
employeeId,
|
||||
basicSalary: "8000.00",
|
||||
effectiveFrom: "2020-01-01",
|
||||
reason: "E2E-HR-07 fixture salary",
|
||||
},
|
||||
});
|
||||
expect(assign.status(), `assign a salary to ${employeeId}`).toBe(201);
|
||||
}
|
||||
}
|
||||
|
||||
const firstOfMonth = (year: number, month0: number): string =>
|
||||
new Date(Date.UTC(year, month0, 1)).toISOString().slice(0, 10);
|
||||
|
||||
const lastOfMonth = (year: number, month0: number): string =>
|
||||
new Date(Date.UTC(year, month0 + 1, 0)).toISOString().slice(0, 10);
|
||||
|
||||
async function findFreePeriod(
|
||||
fromYear: number,
|
||||
fromMonth0: number,
|
||||
): Promise<{ periodStart: string; periodEnd: string }> {
|
||||
for (let i = 0; i < 36; i += 1) {
|
||||
const y = fromYear + Math.floor((fromMonth0 + i) / 12);
|
||||
const m = (fromMonth0 + i) % 12;
|
||||
const periodStart = firstOfMonth(y, m);
|
||||
const periodEnd = lastOfMonth(y, m);
|
||||
const clash = await scalar<string>(
|
||||
`SELECT id FROM hr.payroll_runs
|
||||
WHERE organization_id = $1 AND period_start = $2::date AND period_end = $3::date
|
||||
AND status <> 'CANCELLED'`,
|
||||
[ORG_ID, periodStart, periodEnd],
|
||||
);
|
||||
if (!clash) return { periodStart, periodEnd };
|
||||
}
|
||||
throw new Error("[HR-07] no free payroll period found in the next 36 months");
|
||||
}
|
||||
|
||||
test.describe("HR-07 · self-service is scoped", () => {
|
||||
test.use({ storageState: storageFor("hr-employee") });
|
||||
|
||||
test("my-payslips returns only the caller's own, approved-or-paid payslips", async ({
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const self = await login(request, "hr-employee"); // employee A — the caller under test
|
||||
const decoy = await login(request, "hr-payroll-admin"); // employee B — must never leak
|
||||
const manager = await login(request, "hr-manager");
|
||||
const authManager = { Authorization: `Bearer ${manager.token}` };
|
||||
const authSelf = { Authorization: `Bearer ${self.token}` };
|
||||
|
||||
await ensurePayable(request, authManager, self.employeeId);
|
||||
await ensurePayable(request, authManager, decoy.employeeId);
|
||||
|
||||
let runId = await scalar<string>(
|
||||
`SELECT id FROM hr.payroll_runs
|
||||
WHERE organization_id = $1 AND note = $2 AND status = 'APPROVED'
|
||||
LIMIT 1`,
|
||||
[ORG_ID, NOTE],
|
||||
);
|
||||
|
||||
if (!runId) {
|
||||
const admin = await login(request, "hr-payroll-admin");
|
||||
const authAdmin = { Authorization: `Bearer ${admin.token}` };
|
||||
|
||||
const today = new Date();
|
||||
// A different starting month than HR-06's own run, so the two scenarios
|
||||
// never contend for the same free period.
|
||||
const { periodStart, periodEnd } = await findFreePeriod(
|
||||
today.getUTCFullYear(),
|
||||
today.getUTCMonth() + 1,
|
||||
);
|
||||
|
||||
const create = await request.post(`${HR_API}/api/v1/payroll-runs`, {
|
||||
headers: authAdmin,
|
||||
data: { periodStart, periodEnd, note: NOTE },
|
||||
});
|
||||
expect(create.status(), "open the run").toBe(201);
|
||||
runId = (await create.json()).id;
|
||||
|
||||
const calc = await request.post(`${HR_API}/api/v1/payroll-runs/${runId}/calculate`, {
|
||||
headers: authAdmin,
|
||||
});
|
||||
expect(calc.status(), "calculate").toBe(201);
|
||||
expect((await calc.json()).employeeCount).toBeGreaterThan(0);
|
||||
|
||||
// Both fixture employees are genuinely priced into this run before it is
|
||||
// approved — sanity-check the setup, not just the scoping under test.
|
||||
const selfPayslip = await scalar<string>(
|
||||
`SELECT id FROM hr.payslips WHERE payroll_run_id = $1 AND employee_id = $2`,
|
||||
[runId, self.employeeId],
|
||||
);
|
||||
const decoyPayslip = await scalar<string>(
|
||||
`SELECT id FROM hr.payslips WHERE payroll_run_id = $1 AND employee_id = $2`,
|
||||
[runId, decoy.employeeId],
|
||||
);
|
||||
expect(selfPayslip, "employee A must be priced into this run").not.toBeNull();
|
||||
expect(decoyPayslip, "employee B must be priced into this run").not.toBeNull();
|
||||
|
||||
// ── The state-transition half of the contract: CALCULATED is not
|
||||
// published yet ──────────────────────────────────────────────────────
|
||||
const beforeApproval = await request.get(
|
||||
`${HR_API}/api/v1/payroll-runs/my-payslips`,
|
||||
{ headers: authSelf },
|
||||
);
|
||||
expect(beforeApproval.status()).toBe(200);
|
||||
const beforeItems: Array<{ id: string; payrollRun?: { id: string } }> =
|
||||
await beforeApproval.json();
|
||||
expect(
|
||||
beforeItems.some((item) => item.id === selfPayslip),
|
||||
"a CALCULATED run's payslip must not appear before approval",
|
||||
).toBe(false);
|
||||
|
||||
const approve = await request.patch(
|
||||
`${HR_API}/api/v1/payroll-runs/${runId}/approve`,
|
||||
{ headers: authManager },
|
||||
);
|
||||
expect(approve.status(), "approve").toBe(200);
|
||||
}
|
||||
|
||||
// The specific payslip id belonging to employee B (the decoy), fetched
|
||||
// through the view-ALL route so we know exactly what must never leak into
|
||||
// employee A's own-scoped response.
|
||||
const decoyPayslipId = await scalar<string>(
|
||||
`SELECT id FROM hr.payslips WHERE payroll_run_id = $1 AND employee_id = $2`,
|
||||
[runId, decoy.employeeId],
|
||||
);
|
||||
expect(decoyPayslipId, "employee B's payslip must exist to test against").not.toBeNull();
|
||||
|
||||
// ── NET — employee A's own view, after approval ─────────────────────────
|
||||
const mine = await request.get(`${HR_API}/api/v1/payroll-runs/my-payslips`, {
|
||||
headers: authSelf,
|
||||
});
|
||||
expect(mine.status()).toBe(200);
|
||||
const items: Array<{
|
||||
id: string;
|
||||
employeeId: string;
|
||||
payrollRun?: { id: string; status: string };
|
||||
}> = await mine.json();
|
||||
|
||||
const ours = items.find((item) => item.payrollRun?.id === runId);
|
||||
expect(ours, "employee A's own payslip from this run must appear").toBeTruthy();
|
||||
expect(ours?.employeeId).toBe(self.employeeId);
|
||||
expect(
|
||||
ours?.payrollRun?.status,
|
||||
"only APPROVED/PAID runs are published — never DRAFT/CALCULATED",
|
||||
).toMatch(/^(APPROVED|PAID)$/);
|
||||
|
||||
expect(
|
||||
items.every((item) => item.employeeId === self.employeeId),
|
||||
"every item returned must belong to the caller — never another employee",
|
||||
).toBe(true);
|
||||
expect(
|
||||
items.some((item) => item.id === decoyPayslipId),
|
||||
"employee B's payslip must never appear in employee A's own-scoped response",
|
||||
).toBe(false);
|
||||
|
||||
// ── DB — the response is exactly the set of A's approved-or-paid payslips,
|
||||
// no more and no fewer ──────────────────────────────────────────────────
|
||||
const dbOwnCount = await scalar<string>(
|
||||
`SELECT count(*)::text
|
||||
FROM hr.payslips p
|
||||
JOIN hr.payroll_runs r ON r.id = p.payroll_run_id
|
||||
WHERE p.employee_id = $1 AND r.status IN ('APPROVED', 'PAID')`,
|
||||
[self.employeeId],
|
||||
);
|
||||
expect(items.length).toBe(Number(dbOwnCount));
|
||||
|
||||
const dbOthers = await query<{ id: string }>(
|
||||
`SELECT p.id
|
||||
FROM hr.payslips p
|
||||
JOIN hr.payroll_runs r ON r.id = p.payroll_run_id
|
||||
WHERE p.employee_id = $1 AND r.status IN ('APPROVED', 'PAID')`,
|
||||
[decoy.employeeId],
|
||||
);
|
||||
const returnedIds = new Set(items.map((item) => item.id));
|
||||
for (const row of dbOthers) {
|
||||
expect(
|
||||
returnedIds.has(row.id),
|
||||
`decoy payslip ${row.id} must not be in employee A's response`,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
158
e2e-hr-finance/specs/hr/recruitment-rehire-conflict.spec.ts
Normal file
158
e2e-hr-finance/specs/hr/recruitment-rehire-conflict.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { personaByKey, STAFF_POSITION_ID } from "../../fixtures/personas";
|
||||
import { scalar } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* HR-09 · hiring twice off the same offer is refused, and the vacancy's
|
||||
* filled count does not move on the refused attempt.
|
||||
*
|
||||
* `RecruitmentService.hireFromOffer` guards on `offer.hiredEmployeeId`, not on
|
||||
* a status transition: a hired offer stays ACCEPTED forever — only its
|
||||
* `hiredEmployeeId` flips from null to the new employee's id. A second call
|
||||
* throws `ConflictException` (409) BEFORE the transaction that increments
|
||||
* `hr.job_openings.filled` ever runs
|
||||
* (apps/edr-hr-api/src/modules/recruitment/services/recruitment.service.ts):
|
||||
*
|
||||
* if (offer.hiredEmployeeId) {
|
||||
* throw new ConflictException(
|
||||
* "This offer has already been hired — a second hire would create a " +
|
||||
* "duplicate person.",
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* Fixture: a real hire creates a new IAM account, employee record and HR
|
||||
* profile as a side effect — that is the behaviour under test, not incidental
|
||||
* setup — so this scenario builds its own opening → applicant → application →
|
||||
* offer chain through the product's own endpoints (STAGE_TRANSITIONS: APPLIED
|
||||
* → SCREENING → SHORTLISTED → OFFER) rather than reusing a real employee's
|
||||
* historical offer. Every identifying field carries an E2E- prefix or a
|
||||
* fresh timestamp so reruns never collide. `STAFF_POSITION_ID` is reused
|
||||
* deliberately — three other personas already hold it concurrently, which is
|
||||
* the existing proof that this position accepts more than one holder.
|
||||
*/
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
|
||||
test.describe("HR-09 · re-hiring an accepted offer is refused", () => {
|
||||
test.use({ storageState: storageFor("hr-recruitment-officer") });
|
||||
|
||||
test("a second hire on the same offer returns 409 and the opening's filled count is unchanged", async ({
|
||||
request,
|
||||
}) => {
|
||||
const persona = personaByKey("hr-recruitment-officer");
|
||||
const loginRes = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
const { token } = await loginRes.json();
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
|
||||
const ts = Date.now();
|
||||
|
||||
const opening = await request.post(`${HR_API}/api/v1/recruitment/openings`, {
|
||||
headers,
|
||||
data: {
|
||||
reference: `E2E-REHIRE-${ts}`,
|
||||
title: { am: "ኢ2ኢ ምልመላ", en: "E2E HR-09 Opening" },
|
||||
positionId: STAFF_POSITION_ID,
|
||||
openings: 1,
|
||||
closesOn: "2027-01-01",
|
||||
},
|
||||
});
|
||||
expect(opening.status()).toBe(201);
|
||||
const openingBody = await opening.json();
|
||||
|
||||
const publish = await request.patch(
|
||||
`${HR_API}/api/v1/recruitment/openings/${openingBody.id}/publish`,
|
||||
{ headers, data: {} },
|
||||
);
|
||||
expect(publish.status()).toBe(200);
|
||||
|
||||
const applicant = await request.post(`${HR_API}/api/v1/recruitment/applicants`, {
|
||||
headers,
|
||||
data: {
|
||||
fullName: { am: "ኢ2ኢ አመልካች", en: "E2E HR-09 Applicant" },
|
||||
phoneNumber: `E2E-${ts}`,
|
||||
email: `e2e.hr09.${ts}@edr.local`,
|
||||
},
|
||||
});
|
||||
expect(applicant.status()).toBe(201);
|
||||
const applicantBody = await applicant.json();
|
||||
|
||||
const application = await request.post(`${HR_API}/api/v1/recruitment/applications`, {
|
||||
headers,
|
||||
data: { jobOpeningId: openingBody.id, applicantId: applicantBody.id },
|
||||
});
|
||||
expect(application.status()).toBe(201);
|
||||
const applicationBody = await application.json();
|
||||
|
||||
for (const stage of ["SCREENING", "SHORTLISTED", "OFFER"]) {
|
||||
const move = await request.patch(
|
||||
`${HR_API}/api/v1/recruitment/applications/${applicationBody.id}/stage`,
|
||||
{ headers, data: { stage } },
|
||||
);
|
||||
expect(move.status(), `move to ${stage} must be accepted`).toBe(200);
|
||||
}
|
||||
|
||||
const offer = await request.post(`${HR_API}/api/v1/recruitment/offers`, {
|
||||
headers,
|
||||
data: {
|
||||
applicationId: applicationBody.id,
|
||||
offeredBasicSalary: "9000.00",
|
||||
proposedStartDate: "2027-02-01",
|
||||
},
|
||||
});
|
||||
expect(offer.status()).toBe(201);
|
||||
const offerBody = await offer.json();
|
||||
|
||||
const send = await request.patch(`${HR_API}/api/v1/recruitment/offers/${offerBody.id}/send`, {
|
||||
headers,
|
||||
data: {},
|
||||
});
|
||||
expect(send.status()).toBe(200);
|
||||
|
||||
const respond = await request.patch(
|
||||
`${HR_API}/api/v1/recruitment/offers/${offerBody.id}/respond`,
|
||||
{ headers, data: { accepted: true } },
|
||||
);
|
||||
expect(respond.status()).toBe(200);
|
||||
|
||||
const hireDto = {
|
||||
username: `e2e.hr09.${ts}`,
|
||||
email: `e2e.hr09.${ts}@edr.local`,
|
||||
};
|
||||
|
||||
const firstHire = await request.post(
|
||||
`${HR_API}/api/v1/recruitment/offers/${offerBody.id}/hire`,
|
||||
{ headers, data: hireDto },
|
||||
);
|
||||
expect(
|
||||
firstHire.status(),
|
||||
"the first hire off a freshly accepted offer must succeed",
|
||||
).toBe(201);
|
||||
|
||||
const filledAfterFirst = await scalar<number>(
|
||||
`SELECT "filled" FROM hr.job_openings WHERE id = $1`,
|
||||
[openingBody.id],
|
||||
);
|
||||
expect(filledAfterFirst, "one seat filled after the real hire").toBe(1);
|
||||
|
||||
const secondHire = await request.post(
|
||||
`${HR_API}/api/v1/recruitment/offers/${offerBody.id}/hire`,
|
||||
{ headers, data: hireDto },
|
||||
);
|
||||
expect(
|
||||
secondHire.status(),
|
||||
"a second hire off the same already-hired offer must be refused with 409",
|
||||
).toBe(409);
|
||||
|
||||
const filledAfterSecond = await scalar<number>(
|
||||
`SELECT "filled" FROM hr.job_openings WHERE id = $1`,
|
||||
[openingBody.id],
|
||||
);
|
||||
expect(
|
||||
filledAfterSecond,
|
||||
"the refused second attempt must not move the opening's filled count",
|
||||
).toBe(filledAfterFirst);
|
||||
});
|
||||
});
|
||||
141
e2e-hr-finance/specs/hr/reports-leave-liability-filter.spec.ts
Normal file
141
e2e-hr-finance/specs/hr/reports-leave-liability-filter.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { ORG_ID, personaByKey } from "../../fixtures/personas";
|
||||
import { query, scalar } from "../../fixtures/db";
|
||||
import { storageFor } from "../../playwright.config";
|
||||
|
||||
/**
|
||||
* HR-11 · the leave-liability provision values only PAID leave that CARRIES
|
||||
* OVER, never leave that lapses at year end.
|
||||
*
|
||||
* `ReportsService.leaveLiability` filters on
|
||||
* `t."is_paid" = true AND t."max_carry_over_days" > 0`
|
||||
* (apps/edr-hr-api/src/modules/reports/services/reports.service.ts). The
|
||||
* service's own comment names the reason: sick, bereavement, marriage and
|
||||
* paternity leave are entitlements that lapse at year end, not days the
|
||||
* employee has banked, and valuing them would inflate the provision several
|
||||
* times over. This scenario reads that exact predicate independently from
|
||||
* `hr.leave_types` — rather than hardcoding assumed leave-type codes like
|
||||
* "SICK" — and asserts the report's returned codes are exactly the ones that
|
||||
* predicate allows. It then re-derives the total liability straight from the
|
||||
* ledger (`hr.leave_entitlements` / `hr.leave_ledger_entries` /
|
||||
* `hr.employee_salaries`) and checks it against what the endpoint returned —
|
||||
* the same "don't trust the report to check its own arithmetic" pattern
|
||||
* FIN-07 runs against the trial balance.
|
||||
*
|
||||
* `asOf` is pinned to a fixed date so the API call and the independent DB
|
||||
* check are guaranteed to price the same day — leaving it to default to
|
||||
* "today" on both sides would work in practice, but would make a mismatch
|
||||
* from clock drift indistinguishable from a real regression.
|
||||
*/
|
||||
const HR_API = process.env.HR_API_URL ?? "http://localhost:3105";
|
||||
const AS_OF = "2026-06-30";
|
||||
|
||||
test.describe("HR-11 · leave liability values only carry-over leave types", () => {
|
||||
test.use({ storageState: storageFor("hr-manager") });
|
||||
|
||||
test("excludes non-carry-over/unpaid types, and the total agrees with an independent ledger sum", async ({
|
||||
request,
|
||||
}) => {
|
||||
const persona = personaByKey("hr-manager");
|
||||
const loginRes = await request.post(`${HR_API}/api/v1/auth/login`, {
|
||||
data: { email: persona.email, password: persona.password },
|
||||
});
|
||||
const { token } = await loginRes.json();
|
||||
|
||||
const res = await request.get(`${HR_API}/api/v1/reports/leave-liability?asOf=${AS_OF}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const rows = (await res.json()) as {
|
||||
employeeId: string;
|
||||
employeeNumber: string | null;
|
||||
leaveTypeCode: string;
|
||||
balanceDays: string;
|
||||
dailyRate: string;
|
||||
liability: string;
|
||||
}[];
|
||||
|
||||
// A 2,416-employee org with a real leave history should have SOME
|
||||
// untaken carry-over balance — otherwise the assertions below would pass
|
||||
// vacuously on an empty response.
|
||||
expect(
|
||||
rows.length,
|
||||
"expected at least one carry-over liability row for the Active org",
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
const returnedCodes = new Set(rows.map((r) => r.leaveTypeCode));
|
||||
|
||||
// Types the SQL predicate must EXCLUDE — unpaid, or lapsing at year end.
|
||||
const excluded = await query<{ code: string }>(
|
||||
`SELECT DISTINCT "code" FROM hr.leave_types
|
||||
WHERE organization_id = $1 AND deleted_at IS NULL
|
||||
AND ("max_carry_over_days" <= 0 OR "is_paid" = false)`,
|
||||
[ORG_ID],
|
||||
);
|
||||
for (const { code } of excluded) {
|
||||
expect(
|
||||
returnedCodes.has(code),
|
||||
`${code} does not carry over (or is unpaid) and must not appear in the liability report`,
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// Every code the report DID return must be a paid, carry-over type — the
|
||||
// other half of the same predicate, checked from the other direction.
|
||||
const eligible = await query<{ code: string }>(
|
||||
`SELECT DISTINCT "code" FROM hr.leave_types
|
||||
WHERE organization_id = $1 AND deleted_at IS NULL
|
||||
AND "max_carry_over_days" > 0 AND "is_paid" = true`,
|
||||
[ORG_ID],
|
||||
);
|
||||
const eligibleCodes = new Set(eligible.map((r) => r.code));
|
||||
for (const code of returnedCodes) {
|
||||
expect(
|
||||
eligibleCodes.has(code),
|
||||
`${code} appeared in the report but is not a paid, carry-over leave type`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
// Independently re-derive the total liability from the ledger, using the
|
||||
// same carry-over/paid filter, the same effective-dated salary lookup and
|
||||
// the same per-row rounding — then compare the SUM to the endpoint's.
|
||||
const independentTotal = await scalar<string>(
|
||||
`SELECT ROUND(SUM("rowLiability"), 2)::text AS "total"
|
||||
FROM (
|
||||
SELECT ROUND(
|
||||
COALESCE(SUM(l."days"), 0) * COALESCE(sal."basic_salary", 0) / 30.0,
|
||||
2
|
||||
) AS "rowLiability"
|
||||
FROM hr.leave_entitlements ent
|
||||
JOIN hr.leave_types t ON t."id" = ent."leave_type_id"
|
||||
JOIN hr.employee_profiles p ON p."employee_id" = ent."employee_id"
|
||||
LEFT JOIN hr.leave_ledger_entries l ON l."entitlement_id" = ent."id"
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT es."basic_salary"
|
||||
FROM hr.employee_salaries es
|
||||
WHERE es."employee_id" = ent."employee_id"
|
||||
AND es."deleted_at" IS NULL
|
||||
AND es."effective_from" <= $2::date
|
||||
AND (es."effective_to" IS NULL OR es."effective_to" >= $2::date)
|
||||
ORDER BY es."effective_from" DESC
|
||||
LIMIT 1
|
||||
) sal ON true
|
||||
WHERE ent."deleted_at" IS NULL
|
||||
AND t."is_paid" = true
|
||||
AND t."max_carry_over_days" > 0
|
||||
AND p."employment_state" NOT IN ('TERMINATED','RETIRED')
|
||||
AND ent."organization_id" = $1::uuid
|
||||
GROUP BY ent."employee_id", t."code", sal."basic_salary"
|
||||
HAVING COALESCE(SUM(l."days"), 0) > 0
|
||||
) "rowsByEmployeeAndType"`,
|
||||
[ORG_ID, AS_OF],
|
||||
);
|
||||
|
||||
const apiTotal = rows.reduce((sum, r) => sum + Number(r.liability), 0).toFixed(2);
|
||||
|
||||
expect(
|
||||
independentTotal,
|
||||
"the report's total liability must match an independent re-derivation from the ledger",
|
||||
).toBe(apiTotal);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@
|
||||
"test:e2e:passenger": "bash e2e/run.sh",
|
||||
"test:e2e:ui": "bash e2e-ui/run.sh",
|
||||
"test:e2e:ui:only": "playwright test -c e2e-ui/playwright.config.ts",
|
||||
"test:e2e:hr-finance": "bash e2e-hr-finance/run.sh",
|
||||
"test:e2e:hr-finance:only": "playwright test -c e2e-hr-finance/playwright.config.ts",
|
||||
"lint": "turbo run lint",
|
||||
"type-check": "turbo run type-check",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
|
||||
|
||||
3731
pnpm-lock.yaml
generated
3731
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user