feat(application): extract entitlement use-cases from API router layer

Move core entitlement business logic (syncEntitlement, balance reading,
year summary, set/bulk-set) into packages/application/src/use-cases/entitlement/
using the deps-injection pattern. Audit logging stays in the router support
file; authorization check for getBalance/getBalanceDetail stays in the router
layer. The router support file becomes a thin wiring adapter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 20:14:35 +02:00
parent d3bfa8ca98
commit 999626cf70
6 changed files with 966 additions and 677 deletions
+49
View File
@@ -6,6 +6,7 @@ export { updateDemandRequirement } from "./use-cases/allocation/update-demand-re
export {
createAssignment,
createAssignmentFragment,
type AssignmentWithRelations,
} from "./use-cases/allocation/create-assignment.js";
export { updateAssignment } from "./use-cases/allocation/update-assignment.js";
@@ -120,6 +121,54 @@ export {
type RecomputeResourceValueScoresInput,
} from "./use-cases/resource/index.js";
export {
approveVacation,
batchApproveVacations,
rejectVacation,
batchRejectVacations,
cancelVacation,
type ApproveVacationInput,
type ApproveVacationResult,
type ApproveVacationDeps,
type BatchApproveVacationInput,
type BatchApproveVacationResult,
type BatchApproveVacationDeps,
type VacationChargeableInput,
type RejectVacationInput,
type RejectVacationResult,
type RejectVacationDeps,
type BatchRejectVacationInput,
type BatchRejectVacationResult,
type BatchRejectVacationDeps,
type CancelVacationInput,
type CancelVacationResult,
type CancelVacationDeps,
} from "./use-cases/vacation/index.js";
export {
syncEntitlement,
getEntitlementBalance,
getEntitlementBalanceDetail,
getEntitlementSync,
getEntitlementYearSummary,
getEntitlementYearSummaryDetail,
setEntitlement,
bulkSetEntitlements,
type EntitlementSnapshot,
type ResourceHolidayContext,
type SyncEntitlementDeps,
type ReadEntitlementBalanceDeps,
type EntitlementBalanceInput,
type EntitlementBalanceResult,
type EntitlementYearSummaryInput,
type EntitlementYearSummaryDetailInput,
type EntitlementYearSummaryRow,
type SetEntitlementInput,
type SetEntitlementReturn,
type BulkSetEntitlementsInput,
type BulkSetEntitlementsResult,
} from "./use-cases/entitlement/index.js";
export {
calculateEffectiveAllocationCostCents,
calculateEffectiveAllocationHours,
@@ -0,0 +1,29 @@
export {
syncEntitlement,
type EntitlementSnapshot,
type ResourceHolidayContext,
type SyncEntitlementDeps,
} from "./sync-entitlement.js";
export {
getEntitlementBalance,
getEntitlementBalanceDetail,
getEntitlementSync,
getEntitlementYearSummary,
getEntitlementYearSummaryDetail,
type ReadEntitlementBalanceDeps,
type EntitlementBalanceInput,
type EntitlementBalanceResult,
type EntitlementYearSummaryInput,
type EntitlementYearSummaryDetailInput,
type EntitlementYearSummaryRow,
} from "./read-entitlement-balance.js";
export {
setEntitlement,
bulkSetEntitlements,
type SetEntitlementInput,
type SetEntitlementReturn,
type BulkSetEntitlementsInput,
type BulkSetEntitlementsResult,
} from "./set-entitlement.js";
@@ -0,0 +1,496 @@
import { VacationType, VacationStatus } from "@capakraken/db";
import type { Prisma, PrismaClient } from "@capakraken/db";
import { toIsoDate } from "@capakraken/shared";
import { TRPCError } from "@trpc/server";
import { type EntitlementSnapshot, type ResourceHolidayContext, type SyncEntitlementDeps, syncEntitlement } from "./sync-entitlement.js";
/** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
type DbClient = Pick<PrismaClient, "vacation" | "vacationEntitlement" | "systemSettings" | "resource">;
type EntitlementVacationStatus = "APPROVED" | "PENDING";
type EntitlementVacationExplainability = {
type: VacationType;
status: EntitlementVacationStatus;
startDate: string;
endDate: string;
isHalfDay: boolean;
requestedDays: number;
deductedDays: number;
holidayCountryCode: string | null;
holidayCountryName: string | null;
holidayFederalState: string | null;
holidayMetroCityName: string | null;
holidayCalendarDates: string[];
holidayLegacyPublicHolidayDates: string[];
holidayDetails: Array<{
date: string;
source: "CALENDAR" | "LEGACY_PUBLIC_HOLIDAY" | "CALENDAR_AND_LEGACY";
}>;
holidayContext: {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
sources: {
hasCalendarHolidays: boolean;
hasLegacyPublicHolidayEntries: boolean;
};
};
};
type VacationPreviewInput = {
type: VacationType;
startDate: Date;
endDate: Date;
isHalfDay: boolean;
holidayContext: ResourceHolidayContext;
};
type VacationPreviewResult = {
requestedDays: number;
deductedDays: number;
holidayDetails: Array<{
date: string;
source: "CALENDAR" | "LEGACY_PUBLIC_HOLIDAY" | "CALENDAR_AND_LEGACY";
}>;
holidayContext: {
countryCode: string | null;
countryName: string | null;
federalState: string | null;
metroCityName: string | null;
sources: {
hasCalendarHolidays: boolean;
hasLegacyPublicHolidayEntries: boolean;
};
};
};
export type ReadEntitlementBalanceDeps = SyncEntitlementDeps & {
buildVacationPreview: (input: VacationPreviewInput) => VacationPreviewResult;
parseVacationSnapshotDateList: (value: Prisma.JsonValue | null | undefined) => string[];
};
export type EntitlementBalanceInput = {
resourceId: string;
year: number;
};
export type EntitlementYearSummaryInput = {
year: number;
chapter?: string | undefined;
};
export type EntitlementYearSummaryDetailInput = EntitlementYearSummaryInput & {
resourceName?: string | undefined;
};
function clampVacationPeriodToYear(
vacation: { startDate: Date; endDate: Date },
yearStart: Date,
yearEnd: Date,
): { startDate: Date; endDate: Date } {
return {
startDate: vacation.startDate > yearStart ? vacation.startDate : yearStart,
endDate: vacation.endDate < yearEnd ? vacation.endDate : yearEnd,
};
}
function filterIsoDatesToRange(isoDates: string[], startDate: Date, endDate: Date): string[] {
const startIso = toIsoDate(startDate);
const endIso = toIsoDate(endDate);
return isoDates.filter((isoDate) => isoDate >= startIso && isoDate <= endIso);
}
function hasPersistedHolidaySnapshot(vacation: {
deductedDays: number | null;
holidayCountryCode: string | null;
holidayCountryName: string | null;
holidayFederalState: string | null;
holidayMetroCityName: string | null;
holidayCalendarDates: Prisma.JsonValue | null;
holidayLegacyPublicHolidayDates: Prisma.JsonValue | null;
}): boolean {
return vacation.deductedDays != null
|| vacation.holidayCountryCode != null
|| vacation.holidayCountryName != null
|| vacation.holidayFederalState != null
|| vacation.holidayMetroCityName != null
|| vacation.holidayCalendarDates != null
|| vacation.holidayLegacyPublicHolidayDates != null;
}
function mapEntitlementVacationStatus(status: VacationStatus): EntitlementVacationStatus {
if (status === VacationStatus.APPROVED || status === VacationStatus.PENDING) {
return status;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Unsupported entitlement vacation status: ${status}`,
});
}
function buildEntitlementHolidayDateUnion(vacations: EntitlementVacationExplainability[]): string[] {
return [...new Set(vacations.flatMap((vacation) => vacation.holidayDetails.map((detail) => detail.date)))].sort();
}
function formatEntitlementHolidayBasis(vacation: Pick<
EntitlementVacationExplainability,
"holidayCountryName" | "holidayCountryCode" | "holidayFederalState" | "holidayMetroCityName"
>): string {
return [
vacation.holidayCountryName ?? vacation.holidayCountryCode ?? null,
vacation.holidayFederalState ?? null,
vacation.holidayMetroCityName ?? null,
].filter((value): value is string => Boolean(value)).join(" / ");
}
function mapBalanceDetail(resource: {
displayName: string;
eid: string;
}, balance: {
year: number;
entitledDays: number;
carryoverDays: number;
usedDays: number;
pendingDays: number;
remainingDays: number;
sickDays: number;
deductionSummary?: {
formula: string;
approvedVacationCount: number;
pendingVacationCount: number;
approvedRequestedDays: number;
pendingRequestedDays: number;
approvedDeductedDays: number;
pendingDeductedDays: number;
excludedHolidayDates: string[];
holidayBasisVariants: string[];
sources: {
hasCalendarHolidays: boolean;
hasLegacyPublicHolidayEntries: boolean;
};
};
vacations?: EntitlementVacationExplainability[];
}) {
return {
resource: resource.displayName,
eid: resource.eid,
year: balance.year,
entitlement: balance.entitledDays,
carryOver: balance.carryoverDays,
taken: balance.usedDays,
pending: balance.pendingDays,
remaining: balance.remainingDays,
sickDays: balance.sickDays,
...(balance.deductionSummary ? { deductionSummary: balance.deductionSummary } : {}),
...(balance.vacations ? { vacations: balance.vacations } : {}),
};
}
async function readBalanceSnapshot(
db: DbClient,
input: EntitlementBalanceInput,
deps: ReadEntitlementBalanceDeps,
) {
const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
const entitlement = await syncEntitlement(db, input.resourceId, input.year, defaultDays, deps);
const sickVacationsResult = await db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: VacationType.SICK,
status: VacationStatus.APPROVED,
startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) },
endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) },
},
select: { startDate: true, endDate: true, isHalfDay: true },
});
const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : [];
const sickDays = sickVacations.reduce(
(sum, vacation) => sum + deps.countCalendarDaysInPeriod(
vacation,
new Date(`${input.year}-01-01T00:00:00.000Z`),
new Date(`${input.year}-12-31T00:00:00.000Z`),
),
0,
);
return {
year: input.year,
resourceId: input.resourceId,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
sickDays,
};
}
async function readEntitlementVacationExplainability(
db: DbClient,
input: EntitlementBalanceInput,
deps: ReadEntitlementBalanceDeps,
): Promise<EntitlementVacationExplainability[]> {
const yearStart = new Date(`${input.year}-01-01T00:00:00.000Z`);
const yearEnd = new Date(`${input.year}-12-31T00:00:00.000Z`);
const vacations = await db.vacation.findMany({
where: {
resourceId: input.resourceId,
type: { in: BALANCE_TYPES },
startDate: { lte: yearEnd },
endDate: { gte: yearStart },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
},
select: {
type: true,
startDate: true,
endDate: true,
status: true,
isHalfDay: true,
deductedDays: true,
holidayCountryCode: true,
holidayCountryName: true,
holidayFederalState: true,
holidayMetroCityName: true,
holidayCalendarDates: true,
holidayLegacyPublicHolidayDates: true,
},
orderBy: [{ startDate: "asc" }, { endDate: "asc" }],
});
return Promise.all(vacations.map(async (vacation) => {
const period = clampVacationPeriodToYear(vacation, yearStart, yearEnd);
let vacationHolidayContextPromise: Promise<ResourceHolidayContext> | null = null;
const getVacationHolidayContext = async () => {
if (!vacationHolidayContextPromise) {
vacationHolidayContextPromise = deps.loadResourceHolidayContext(
db,
input.resourceId,
period.startDate,
period.endDate,
);
}
return vacationHolidayContextPromise;
};
const fallbackHolidayContext = await getVacationHolidayContext();
const preview = deps.buildVacationPreview({
type: vacation.type,
startDate: period.startDate,
endDate: period.endDate,
isHalfDay: vacation.isHalfDay,
holidayContext: hasPersistedHolidaySnapshot(vacation)
? {
countryCode: vacation.holidayCountryCode ?? fallbackHolidayContext.countryCode ?? null,
countryName: vacation.holidayCountryName ?? fallbackHolidayContext.countryName ?? null,
federalState: vacation.holidayFederalState ?? fallbackHolidayContext.federalState ?? null,
metroCityName: vacation.holidayMetroCityName ?? fallbackHolidayContext.metroCityName ?? null,
calendarHolidayStrings: filterIsoDatesToRange(
deps.parseVacationSnapshotDateList(vacation.holidayCalendarDates),
period.startDate,
period.endDate,
),
publicHolidayStrings: filterIsoDatesToRange(
deps.parseVacationSnapshotDateList(vacation.holidayLegacyPublicHolidayDates),
period.startDate,
period.endDate,
),
}
: fallbackHolidayContext,
});
const persistedDeductedDays = deps.countVacationChargeableDaysFromSnapshot(vacation, yearStart, yearEnd);
return {
type: vacation.type,
status: mapEntitlementVacationStatus(vacation.status),
startDate: toIsoDate(vacation.startDate),
endDate: toIsoDate(vacation.endDate),
isHalfDay: vacation.isHalfDay,
requestedDays: preview.requestedDays,
deductedDays: persistedDeductedDays ?? preview.deductedDays,
holidayCountryCode: preview.holidayContext.countryCode,
holidayCountryName: preview.holidayContext.countryName,
holidayFederalState: preview.holidayContext.federalState,
holidayMetroCityName: preview.holidayContext.metroCityName,
holidayCalendarDates: preview.holidayDetails
.filter((detail) => detail.source === "CALENDAR" || detail.source === "CALENDAR_AND_LEGACY")
.map((detail) => detail.date),
holidayLegacyPublicHolidayDates: preview.holidayDetails
.filter((detail) => detail.source === "LEGACY_PUBLIC_HOLIDAY" || detail.source === "CALENDAR_AND_LEGACY")
.map((detail) => detail.date),
holidayDetails: preview.holidayDetails,
holidayContext: preview.holidayContext,
};
}));
}
async function readYearSummarySnapshot(
db: DbClient,
input: EntitlementYearSummaryInput,
deps: ReadEntitlementBalanceDeps,
) {
const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
const resources = await db.resource.findMany({
where: {
isActive: true,
...(input.chapter ? { chapter: input.chapter } : {}),
},
select: {
id: true,
displayName: true,
eid: true,
lcrCents: true,
chapter: true,
federalState: true,
country: { select: { code: true, name: true } },
metroCity: { select: { name: true } },
},
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
});
return Promise.all(
resources.map(async (resource) => {
const entitlement = await syncEntitlement(db, resource.id, input.year, defaultDays, deps);
return {
resourceId: resource.id,
displayName: resource.displayName,
eid: resource.eid,
chapter: resource.chapter,
countryCode: resource.country?.code ?? null,
countryName: resource.country?.name ?? null,
federalState: resource.federalState ?? null,
metroCityName: resource.metroCity?.name ?? null,
entitledDays: entitlement.entitledDays,
carryoverDays: entitlement.carryoverDays,
usedDays: entitlement.usedDays,
pendingDays: entitlement.pendingDays,
remainingDays: Math.max(
0,
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
),
};
}),
);
}
export type EntitlementBalanceResult = Awaited<ReturnType<typeof readBalanceSnapshot>>;
export type EntitlementYearSummaryRow = Awaited<ReturnType<typeof readYearSummarySnapshot>>[number];
export async function getEntitlementBalance(
db: DbClient,
input: EntitlementBalanceInput,
deps: ReadEntitlementBalanceDeps,
) {
return readBalanceSnapshot(db, input, deps);
}
export async function getEntitlementBalanceDetail(
db: DbClient,
input: EntitlementBalanceInput,
deps: ReadEntitlementBalanceDeps,
) {
const [balance, vacations] = await Promise.all([
readBalanceSnapshot(db, input, deps),
readEntitlementVacationExplainability(db, input, deps),
]);
const resource = await db.resource.findUnique({
where: { id: input.resourceId },
select: { displayName: true, eid: true },
});
if (!resource) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
});
}
const approvedVacations = vacations.filter((vacation) => vacation.status === VacationStatus.APPROVED);
const pendingVacations = vacations.filter((vacation) => vacation.status === VacationStatus.PENDING);
return mapBalanceDetail(resource, {
...balance,
deductionSummary: {
formula: "remaining = entitlement - taken - pending",
approvedVacationCount: approvedVacations.length,
pendingVacationCount: pendingVacations.length,
approvedRequestedDays: approvedVacations.reduce((sum, vacation) => sum + vacation.requestedDays, 0),
pendingRequestedDays: pendingVacations.reduce((sum, vacation) => sum + vacation.requestedDays, 0),
approvedDeductedDays: approvedVacations.reduce((sum, vacation) => sum + vacation.deductedDays, 0),
pendingDeductedDays: pendingVacations.reduce((sum, vacation) => sum + vacation.deductedDays, 0),
excludedHolidayDates: buildEntitlementHolidayDateUnion(vacations),
holidayBasisVariants: [...new Set(vacations.map(formatEntitlementHolidayBasis).filter((value) => value.length > 0))],
sources: {
hasCalendarHolidays: vacations.some((vacation) => vacation.holidayContext.sources.hasCalendarHolidays),
hasLegacyPublicHolidayEntries: vacations.some((vacation) => vacation.holidayContext.sources.hasLegacyPublicHolidayEntries),
},
},
vacations,
});
}
export async function getEntitlementSync(
db: DbClient,
input: EntitlementBalanceInput,
deps: ReadEntitlementBalanceDeps,
): Promise<EntitlementSnapshot> {
const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } });
const defaultDays = settings?.vacationDefaultDays ?? 28;
return syncEntitlement(db, input.resourceId, input.year, defaultDays, deps);
}
export async function getEntitlementYearSummary(
db: DbClient,
input: EntitlementYearSummaryInput,
deps: ReadEntitlementBalanceDeps,
) {
return readYearSummarySnapshot(db, input, deps);
}
export async function getEntitlementYearSummaryDetail(
db: DbClient,
input: EntitlementYearSummaryDetailInput,
deps: ReadEntitlementBalanceDeps,
) {
const summaries = await readYearSummarySnapshot(db, {
year: input.year,
...(input.chapter ? { chapter: input.chapter } : {}),
}, deps);
const needle = input.resourceName?.toLowerCase();
return summaries
.filter((summary) => {
if (!needle) {
return true;
}
return summary.displayName.toLowerCase().includes(needle)
|| summary.eid.toLowerCase().includes(needle);
})
.slice(0, 50)
.map((summary) => ({
resource: summary.displayName,
eid: summary.eid,
chapter: summary.chapter ?? null,
countryCode: summary.countryCode ?? null,
countryName: summary.countryName ?? null,
federalState: summary.federalState ?? null,
metroCityName: summary.metroCityName ?? null,
year: input.year,
entitled: summary.entitledDays,
carryover: summary.carryoverDays,
used: summary.usedDays,
pending: summary.pendingDays,
remaining: summary.remainingDays,
}));
}
@@ -0,0 +1,89 @@
import type { PrismaClient } from "@capakraken/db";
type DbClient = Pick<PrismaClient, "vacation" | "vacationEntitlement" | "systemSettings" | "resource">;
export type SetEntitlementInput = {
resourceId: string;
year: number;
entitledDays: number;
};
export type BulkSetEntitlementsInput = {
year: number;
entitledDays: number;
resourceIds?: string[] | undefined;
};
export type SetEntitlementResult = Awaited<ReturnType<PrismaClient["vacationEntitlement"]["update"]>>;
export type CreateEntitlementResult = Awaited<ReturnType<PrismaClient["vacationEntitlement"]["create"]>>;
export type SetEntitlementReturn = {
result: SetEntitlementResult | CreateEntitlementResult;
existing: SetEntitlementResult | null;
};
export async function setEntitlement(
db: DbClient,
input: SetEntitlementInput,
): Promise<SetEntitlementReturn> {
const existing = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId: input.resourceId, year: input.year } },
});
if (existing) {
const updated = await db.vacationEntitlement.update({
where: { id: existing.id },
data: { entitledDays: input.entitledDays },
});
return { result: updated, existing };
}
const created = await db.vacationEntitlement.create({
data: {
resourceId: input.resourceId,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
});
return { result: created, existing: null };
}
export type BulkSetEntitlementsResult = {
updated: number;
};
export async function bulkSetEntitlements(
db: DbClient,
input: BulkSetEntitlementsInput,
): Promise<BulkSetEntitlementsResult> {
const resources = await db.resource.findMany({
where: {
isActive: true,
...(input.resourceIds ? { id: { in: input.resourceIds } } : {}),
},
select: { id: true },
});
let updated = 0;
for (const resource of resources) {
await db.vacationEntitlement.upsert({
where: { resourceId_year: { resourceId: resource.id, year: input.year } },
create: {
resourceId: resource.id,
year: input.year,
entitledDays: input.entitledDays,
carryoverDays: 0,
usedDays: 0,
pendingDays: 0,
},
update: { entitledDays: input.entitledDays },
});
updated++;
}
return { updated };
}
@@ -0,0 +1,241 @@
import { VacationType, VacationStatus } from "@capakraken/db";
import type { Prisma, PrismaClient } from "@capakraken/db";
import { TRPCError } from "@trpc/server";
/** Types that consume from annual leave balance */
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
type DbClient = Pick<PrismaClient, "vacation" | "vacationEntitlement" | "systemSettings" | "resource">;
export type EntitlementSnapshot = {
id: string;
entitledDays: number;
carryoverDays: number;
usedDays: number;
pendingDays: number;
};
type VacationSnapshotCarrier = {
startDate: Date;
endDate: Date;
isHalfDay: boolean;
deductedDays: number | null;
holidayCountryCode: string | null;
holidayFederalState: string | null;
holidayMetroCityName: string | null;
holidayCalendarDates: Prisma.JsonValue | null;
holidayLegacyPublicHolidayDates: Prisma.JsonValue | null;
};
export type ResourceHolidayContext = {
countryCode?: string | null;
countryName?: string | null;
federalState?: string | null;
metroCityName?: string | null;
calendarHolidayStrings: string[];
publicHolidayStrings: string[];
};
export type SyncEntitlementDeps = {
loadResourceHolidayContext: (
db: DbClient,
resourceId: string,
periodStart: Date,
periodEnd: Date,
) => Promise<ResourceHolidayContext>;
countCalendarDaysInPeriod: (
vacation: { startDate: Date; endDate: Date; isHalfDay: boolean },
periodStart?: Date,
periodEnd?: Date,
) => number;
countVacationChargeableDays: (args: {
vacation: { startDate: Date; endDate: Date; isHalfDay: boolean };
periodStart?: Date;
periodEnd?: Date;
countryCode?: string | null;
federalState?: string | null;
metroCityName?: string | null;
calendarHolidayStrings: string[];
publicHolidayStrings: string[];
}) => number;
countVacationChargeableDaysFromSnapshot: (
vacation: VacationSnapshotCarrier,
periodStart?: Date,
periodEnd?: Date,
) => number | null;
};
function calculateCarryoverDays(entitlement: {
entitledDays: number;
usedDays: number;
pendingDays: number;
}): number {
return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays);
}
async function getOrCreateEntitlement(
db: DbClient,
resourceId: string,
year: number,
defaultDays: number,
) {
let entitlement = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year } },
});
if (!entitlement) {
const prevYear = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year: year - 1 } },
});
const carryover = prevYear
? Math.max(0, prevYear.entitledDays - prevYear.usedDays - prevYear.pendingDays)
: 0;
entitlement = await db.vacationEntitlement.create({
data: {
resourceId,
year,
entitledDays: defaultDays + carryover,
carryoverDays: carryover,
usedDays: 0,
pendingDays: 0,
},
});
}
return entitlement;
}
async function calculateEntitlementVacationDays(
yearStart: Date,
yearEnd: Date,
vacation: VacationSnapshotCarrier,
getLegacyHolidayContext: () => Promise<ResourceHolidayContext>,
deps: SyncEntitlementDeps,
): Promise<number> {
const persistedDays = deps.countVacationChargeableDaysFromSnapshot(vacation, yearStart, yearEnd);
if (persistedDays !== null) {
return persistedDays;
}
const holidayContext = await getLegacyHolidayContext();
return deps.countVacationChargeableDays({
vacation,
periodStart: yearStart,
periodEnd: yearEnd,
countryCode: holidayContext.countryCode ?? null,
federalState: holidayContext.federalState ?? null,
metroCityName: holidayContext.metroCityName ?? null,
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
publicHolidayStrings: holidayContext.publicHolidayStrings,
});
}
/**
* Recompute used/pending days from actual vacation records and update the cached values.
*/
export async function syncEntitlement(
db: DbClient,
resourceId: string,
year: number,
defaultDays: number,
deps: SyncEntitlementDeps,
visitedYears: Set<number> = new Set(),
): Promise<EntitlementSnapshot> {
if (visitedYears.has(year)) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Detected recursive entitlement sync for year ${year}`,
});
}
visitedYears.add(year);
let previousYearEntitlement: EntitlementSnapshot | null = await db.vacationEntitlement.findUnique({
where: { resourceId_year: { resourceId, year: year - 1 } },
});
if (previousYearEntitlement) {
previousYearEntitlement = await syncEntitlement(
db,
resourceId,
year - 1,
defaultDays,
deps,
visitedYears,
);
}
const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays);
const carryoverDays = previousYearEntitlement
? calculateCarryoverDays(previousYearEntitlement)
: 0;
const expectedEntitledDays = defaultDays + carryoverDays;
const entitlementWithCarryover = (
entitlement.carryoverDays !== carryoverDays
|| entitlement.entitledDays !== expectedEntitledDays
)
? await db.vacationEntitlement.update({
where: { id: entitlement.id },
data: {
carryoverDays,
entitledDays: expectedEntitledDays,
},
})
: entitlement;
const yearStart = new Date(`${year}-01-01T00:00:00.000Z`);
const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
const vacations = await db.vacation.findMany({
where: {
resourceId,
type: { in: BALANCE_TYPES },
startDate: { lte: yearEnd },
endDate: { gte: yearStart },
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
},
select: {
startDate: true,
endDate: true,
status: true,
isHalfDay: true,
deductedDays: true,
holidayCountryCode: true,
holidayFederalState: true,
holidayMetroCityName: true,
holidayCalendarDates: true,
holidayLegacyPublicHolidayDates: true,
},
});
let usedDays = 0;
let pendingDays = 0;
let legacyHolidayContextPromise: Promise<ResourceHolidayContext> | null = null;
const getLegacyHolidayContext = async () => {
if (!legacyHolidayContextPromise) {
legacyHolidayContextPromise = deps.loadResourceHolidayContext(db, resourceId, yearStart, yearEnd);
}
return legacyHolidayContextPromise;
};
for (const vacation of vacations) {
const days = await calculateEntitlementVacationDays(
yearStart,
yearEnd,
vacation,
getLegacyHolidayContext,
deps,
);
if (vacation.status === VacationStatus.APPROVED) {
usedDays += days;
} else {
pendingDays += days;
}
}
return db.vacationEntitlement.update({
where: { id: entitlementWithCarryover.id },
data: { usedDays, pendingDays },
});
}