mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-01 18:13:27 +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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user