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; 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; 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, deps: SyncEntitlementDeps, ): Promise { 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 = new Set(), ): Promise { 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 | 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 }, }); }