feat(api): explain holiday-aware vacation deductions

This commit is contained in:
2026-03-31 22:42:00 +02:00
parent 8acfbf8c3e
commit cb363ca5b3
7 changed files with 857 additions and 99 deletions
@@ -141,6 +141,22 @@ describe("entitlement router authorization", () => {
pending: 0,
remaining: 25,
sickDays: 0,
deductionSummary: {
formula: "remaining = entitlement - taken - pending",
approvedVacationCount: 0,
pendingVacationCount: 0,
approvedRequestedDays: 0,
pendingRequestedDays: 0,
approvedDeductedDays: 0,
pendingDeductedDays: 0,
excludedHolidayDates: [],
holidayBasisVariants: [],
sources: {
hasCalendarHolidays: false,
hasLegacyPublicHolidayEntries: false,
},
},
vacations: [],
});
expect(resourceFindUnique).toHaveBeenCalledWith({
where: { id: "res_2" },
@@ -617,8 +617,119 @@ describe("entitlement.getBalanceDetail", () => {
pending: 0.5,
remaining: 28.5,
sickDays: 1,
deductionSummary: {
formula: "remaining = entitlement - taken - pending",
approvedVacationCount: 0,
pendingVacationCount: 0,
approvedRequestedDays: 0,
pendingRequestedDays: 0,
approvedDeductedDays: 0,
pendingDeductedDays: 0,
excludedHolidayDates: [],
holidayBasisVariants: [],
sources: {
hasCalendarHolidays: false,
hasLegacyPublicHolidayEntries: false,
},
},
vacations: [],
});
});
it("includes holiday-adjusted vacation breakdown for explainability", async () => {
const entitlement = sampleEntitlement({ carryoverDays: 1, usedDays: 1, pendingDays: 0 });
const db = {
systemSettings: {
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
},
resource: {
findUnique: vi.fn().mockImplementation(async ({ select }: { select?: Record<string, unknown> } = {}) => ({
...(select?.userId ? { userId: "user_1" } : {}),
...(select?.displayName ? { displayName: "Alice Example" } : {}),
...(select?.eid ? { eid: "EMP-001" } : {}),
})),
},
vacationEntitlement: {
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
update: vi.fn().mockResolvedValue(entitlement),
},
vacation: {
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { type?: string | { in?: string[] } } } = {}) => {
if (where?.type === "SICK") {
return [];
}
if (typeof where?.type === "object" && Array.isArray(where.type.in)) {
return [
{
type: "ANNUAL",
startDate: new Date("2026-05-01T00:00:00.000Z"),
endDate: new Date("2026-05-02T00:00:00.000Z"),
status: "APPROVED",
isHalfDay: false,
deductedDays: 1,
holidayCountryCode: "DE",
holidayCountryName: "Germany",
holidayFederalState: "BY",
holidayMetroCityName: null,
holidayCalendarDates: ["2026-05-01"],
holidayLegacyPublicHolidayDates: [],
},
];
}
return [];
}),
},
};
const caller = createProtectedCaller(db);
const result = await caller.getBalanceDetail({ resourceId: "res_1", year: 2026 });
expect(result.deductionSummary).toEqual({
formula: "remaining = entitlement - taken - pending",
approvedVacationCount: 1,
pendingVacationCount: 0,
approvedRequestedDays: 2,
pendingRequestedDays: 0,
approvedDeductedDays: 1,
pendingDeductedDays: 0,
excludedHolidayDates: ["2026-05-01"],
holidayBasisVariants: ["Germany / BY"],
sources: {
hasCalendarHolidays: true,
hasLegacyPublicHolidayEntries: false,
},
});
expect(result.vacations).toEqual([
{
type: "ANNUAL",
status: "APPROVED",
startDate: "2026-05-01",
endDate: "2026-05-02",
isHalfDay: false,
requestedDays: 2,
deductedDays: 1,
holidayCountryCode: "DE",
holidayCountryName: "Germany",
holidayFederalState: "BY",
holidayMetroCityName: null,
holidayCalendarDates: ["2026-05-01"],
holidayLegacyPublicHolidayDates: [],
holidayDetails: [
{ date: "2026-05-01", source: "CALENDAR" },
],
holidayContext: {
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: null,
sources: {
hasCalendarHolidays: true,
hasLegacyPublicHolidayEntries: false,
},
},
},
]);
});
});
// ─── get ─────────────────────────────────────────────────────────────────────
@@ -813,8 +924,24 @@ describe("entitlement.bulkSet", () => {
describe("entitlement.getYearSummary", () => {
it("returns summary for all active resources (manager role)", async () => {
const resources = [
{ id: "res_1", displayName: "Alice", eid: "alice", chapter: "VFX" },
{ id: "res_2", displayName: "Bob", eid: "bob", chapter: "Animation" },
{
id: "res_1",
displayName: "Alice",
eid: "alice",
chapter: "VFX",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Muenchen" },
},
{
id: "res_2",
displayName: "Bob",
eid: "bob",
chapter: "Animation",
federalState: "HH",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Hamburg" },
},
];
const entitlement = sampleEntitlement({ usedDays: 5, pendingDays: 2 });
const db = {
@@ -845,6 +972,12 @@ describe("entitlement.getYearSummary", () => {
expect(result[0]).toHaveProperty("resourceId");
expect(result[0]).toHaveProperty("displayName");
expect(result[0]).toHaveProperty("remainingDays");
expect(result[0]).toMatchObject({
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Muenchen",
});
});
it("filters by chapter when provided", async () => {
@@ -881,8 +1014,24 @@ describe("entitlement.getYearSummary", () => {
describe("entitlement.getYearSummaryDetail", () => {
it("returns assistant-friendly year summary detail from the canonical summary workflow", async () => {
const resources = [
{ id: "res_1", displayName: "Alice Example", eid: "EMP-001", chapter: "Delivery" },
{ id: "res_2", displayName: "Bob Example", eid: "EMP-002", chapter: "CGI" },
{
id: "res_1",
displayName: "Alice Example",
eid: "EMP-001",
chapter: "Delivery",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Muenchen" },
},
{
id: "res_2",
displayName: "Bob Example",
eid: "EMP-002",
chapter: "CGI",
federalState: "HH",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Hamburg" },
},
];
const db = {
systemSettings: {
@@ -931,6 +1080,10 @@ describe("entitlement.getYearSummaryDetail", () => {
resource: "Alice Example",
eid: "EMP-001",
chapter: "Delivery",
countryCode: "DE",
countryName: "Germany",
federalState: "BY",
metroCityName: "Muenchen",
year: 2026,
entitled: 28,
carryover: 0,
@@ -110,6 +110,13 @@ const sampleVacation = {
note: "Summer vacation",
isHalfDay: false,
halfDayPart: null,
deductedDays: null,
holidayCountryCode: null,
holidayCountryName: null,
holidayFederalState: null,
holidayMetroCityName: null,
holidayCalendarDates: null,
holidayLegacyPublicHolidayDates: null,
requestedById: "user_1",
approvedById: null,
approvedAt: null,
@@ -636,6 +643,70 @@ describe("vacation router", () => {
expect(db.vacation.create).not.toHaveBeenCalled();
});
it("keeps mixed vacation ranges chargeable when only some days are holidays", async () => {
const createdVacation = {
...sampleVacation,
startDate: new Date("2026-11-14T00:00:00.000Z"),
endDate: new Date("2026-11-16T00:00:00.000Z"),
};
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
userId: "user_1",
countryId: "country_de",
metroCityId: "city_muc",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Muenchen" },
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_muc",
name: "Muenchen lokal",
scopeType: "CITY",
priority: 10,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
date: new Date("2020-11-15T00:00:00.000Z"),
name: "Lokaler Stadtfeiertag",
isRecurringAnnual: true,
},
],
},
]),
},
vacation: {
findFirst: vi.fn().mockResolvedValue(null),
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn().mockResolvedValue(createdVacation),
},
});
const caller = createProtectedCaller(db);
const result = await caller.create({
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-11-14T00:00:00.000Z"),
endDate: new Date("2026-11-16T00:00:00.000Z"),
});
expect(result.effectiveDays).toBe(2);
expect(db.vacation.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
deductedDays: 2,
holidayCountryCode: "DE",
holidayCountryName: "Germany",
holidayFederalState: "BY",
holidayMetroCityName: "Muenchen",
holidayCalendarDates: ["2026-11-15"],
holidayLegacyPublicHolidayDates: [],
}),
}));
});
});
describe("previewRequest", () => {
@@ -833,6 +904,9 @@ describe("vacation router", () => {
data: expect.objectContaining({
status: VacationStatus.APPROVED,
rejectionReason: null,
deductedDays: 5,
holidayCalendarDates: [],
holidayLegacyPublicHolidayDates: [],
}),
}),
);
@@ -865,6 +939,53 @@ describe("vacation router", () => {
);
});
it("rejects approval when the current holiday context reduces the request to zero days", async () => {
const db = createVacationDb({
resource: {
findUnique: vi.fn().mockResolvedValue({
countryId: "country_de",
metroCityId: "city_muc",
federalState: "BY",
country: { code: "DE", name: "Germany" },
metroCity: { name: "Muenchen" },
}),
},
holidayCalendar: {
findMany: vi.fn().mockResolvedValue([
{
id: "cal_muc",
name: "Muenchen lokal",
scopeType: "CITY",
priority: 10,
createdAt: new Date("2026-01-01T00:00:00.000Z"),
entries: [
{
date: new Date("2020-11-15T00:00:00.000Z"),
name: "Lokaler Stadtfeiertag",
isRecurringAnnual: true,
},
],
},
]),
},
vacation: {
findUnique: vi.fn().mockResolvedValue({
...sampleVacation,
startDate: new Date("2026-11-15T00:00:00.000Z"),
endDate: new Date("2026-11-15T00:00:00.000Z"),
}),
update: vi.fn(),
findMany: vi.fn().mockResolvedValue([]),
},
});
const caller = createManagerCaller(db);
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
"Vacation no longer deducts any vacation days for the current holiday calendar and cannot be approved",
);
expect(db.vacation.update).not.toHaveBeenCalled();
});
it("forbids regular users from approving", async () => {
const db = {};
const caller = createProtectedCaller(db);
@@ -974,11 +1095,33 @@ describe("vacation router", () => {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
{ id: "vac_2", resourceId: "res_2" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { status?: string; type?: string } } = {}) => {
if (where?.type === VacationType.PUBLIC_HOLIDAY) {
return [];
}
if (where?.status === VacationStatus.PENDING) {
return [
{
id: "vac_1",
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01T00:00:00.000Z"),
endDate: new Date("2026-06-05T00:00:00.000Z"),
isHalfDay: false,
},
{
id: "vac_2",
resourceId: "res_2",
type: VacationType.OTHER,
startDate: new Date("2026-06-08T00:00:00.000Z"),
endDate: new Date("2026-06-08T00:00:00.000Z"),
isHalfDay: false,
},
];
}
return [];
}),
update: vi.fn().mockResolvedValue(sampleVacation),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),
@@ -989,11 +1132,12 @@ describe("vacation router", () => {
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
expect(result.approved).toBe(2);
expect(db.vacation.updateMany).toHaveBeenCalledWith(
expect(db.vacation.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: ["vac_1", "vac_2"] } },
where: { id: "vac_1" },
data: expect.objectContaining({
status: VacationStatus.APPROVED,
deductedDays: 5,
}),
}),
);
@@ -1005,10 +1149,25 @@ describe("vacation router", () => {
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
},
vacation: {
findMany: vi.fn().mockResolvedValue([
{ id: "vac_1", resourceId: "res_1" },
]),
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { status?: string; type?: string } } = {}) => {
if (where?.type === VacationType.PUBLIC_HOLIDAY) {
return [];
}
if (where?.status === VacationStatus.PENDING) {
return [
{
id: "vac_1",
resourceId: "res_1",
type: VacationType.ANNUAL,
startDate: new Date("2026-06-01T00:00:00.000Z"),
endDate: new Date("2026-06-01T00:00:00.000Z"),
isHalfDay: false,
},
];
}
return [];
}),
update: vi.fn().mockResolvedValue(sampleVacation),
},
resource: {
findUnique: vi.fn().mockResolvedValue(null),