Files
Nexus/packages/application/src/use-cases/entitlement/read-entitlement-balance.ts
T
Hartmut b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00

567 lines
18 KiB
TypeScript

import { VacationType, VacationStatus } from "@nexus/db";
import type { Prisma, PrismaClient } from "@nexus/db";
import { toIsoDate } from "@nexus/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,
}));
}