import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { DuplicateActivePlanError, EmploymentTypeNotActiveError, EmploymentTypeNotFoundError, InvalidAccrualMethodError, InvalidBaseGrantDaysError, InvalidEligibilityDelayMonthsError, InvalidExpirationMonthsError, InvalidGrantConditionError, InvalidGrantTypeError, InvalidTenureTiersError, LeaveTypeNotBalanceBackedError, LeaveTypeNotFoundError, } from "../lib/errors.generated"; // The erp-kit Result union, narrowed to what the employment-type guard reads. type Result = { ok: true; value: T } | { ok: false; error: { code: string } }; /** * Injected workforce seam: resolves the EmploymentType a plan restricts itself to. Declared as a * minimal structural signature (the `_approvalDeps.ts` / requestLeave precedent) so leave-management * stays free of the workforce kysely types; the app composition adapts the real query onto it. */ export type GetEmploymentTypeFn = ( db: Transaction, input: { id: string }, ctx: CommandContext, ) => Promise>; export interface AccrualPlanEmploymentTypeDeps { getEmploymentType: GetEmploymentTypeFn; } /** * Validates a plan's `appliesToEmploymentTypeId` cross-module reference: the workforce EmploymentType * must exist and be ACTIVE. A null/omitted id (applies to all) needs no lookup. Shared by * createAccrualPlan and updateAccrualPlan so both enforce the same invariant. */ export async function validateAppliesToEmploymentType( db: Transaction, deps: AccrualPlanEmploymentTypeDeps, appliesToEmploymentTypeId: string | null | undefined, ctx: CommandContext, ): Promise<{ ok: true } | { ok: false; error: Error }> { if (appliesToEmploymentTypeId == null) return { ok: true }; const result = await deps.getEmploymentType(db, { id: appliesToEmploymentTypeId }, ctx); if (!result.ok) { return { ok: false, error: new EmploymentTypeNotFoundError(appliesToEmploymentTypeId) }; } if (result.value.employmentType.status !== "ACTIVE") { return { ok: false, error: new EmploymentTypeNotActiveError(appliesToEmploymentTypeId) }; } return { ok: true }; } export interface TenureTierInput { yearsOfService: number; /** Total STATUTORY entitlement at this tenure (e.g. 11 at 1y), not a bonus. */ grantDays: number; } export interface GrantConditionInput { /** Gate kind; NONE = unconditional grant (v1 behaviour). Evaluated by the anniversary grant batch. */ type: "NONE" | "MIN_WORKED_DAYS"; /** Required when type = MIN_WORKED_DAYS; positive integer, e.g. 240. */ minWorkedDays?: number | null; /** Positive look-back period in months the gate is evaluated over (default 12 at evaluation time). */ referenceMonths?: number | null; } export const ACCRUAL_METHODS = ["FRONT_LOAD_TENURE"] as const; export const ACCRUAL_GRANT_TYPES = ["STATUTORY", "MANUAL"] as const; export interface CreateAccrualPlanInput { /** Stable key of the balance-backed LeaveType this plan governs. */ leaveTypeKey: string; /** Accrual method the grant batch dispatches on; defaults to FRONT_LOAD_TENURE. */ accrualMethod?: string; /** Grant provenance this plan produces (STATUTORY | MANUAL); defaults to STATUTORY. */ grantType?: string; /** Non-negative integer months of service before the first grant applies (0 = day one). */ eligibilityDelayMonths: number; /** Non-negative decimal amount as a string, e.g. "10", "10.5" — front-loaded amount at the eligibility date. */ baseGrantDays: string; /** Ordered tenure-to-total-entitlement mapping; each grantDays is the full grant at that tenure. */ tenureTiers: TenureTierInput[]; /** Optional decimal cap as a string (e.g. "20"); null/omitted = uncapped. */ annualCapDays?: string | null; /** Positive integer determining a STATUTORY grant's expirationDate = grant date + expirationMonths. */ expirationMonths: number; /** Workforce EmploymentType catalog id this plan applies to; omitted = applies to all. */ appliesToEmploymentTypeId?: string | null; /** Optional statutory grant gate (ADR-026 C); null/omitted = NONE (unconditional, v1 behaviour). */ grantCondition?: GrantConditionInput | null; /** Date this plan generation becomes effective; may be future-dated. */ effectiveStart: Date; } function isValidEligibilityDelayMonths(months: number): boolean { return Number.isInteger(months) && months >= 0; } function isValidBaseGrantDays(baseGrantDays: string): boolean { const value = Number(baseGrantDays); if (!Number.isFinite(value) || value < 0) return false; // half-day increments only return Number.isInteger(value * 2); } function isValidAnnualCapDays(annualCapDays: string | null | undefined): boolean { if (annualCapDays == null) return true; // uncapped const value = Number(annualCapDays); return Number.isFinite(value) && value >= 0; } function isValidTenureTiers( tiers: TenureTierInput[], annualCapDays: string | null | undefined, ): boolean { const cap = annualCapDays == null ? Number.POSITIVE_INFINITY : Number(annualCapDays); let previousYearsOfService = 0; let previousGrantDays = -1; for (const tier of tiers) { if (!Number.isInteger(tier.yearsOfService) || tier.yearsOfService <= previousYearsOfService) { return false; } if (!Number.isInteger(tier.grantDays) || tier.grantDays < 0 || tier.grantDays > cap) { return false; } if (tier.grantDays < previousGrantDays) { return false; } previousYearsOfService = tier.yearsOfService; previousGrantDays = tier.grantDays; } return true; } function isValidExpirationMonths(expirationMonths: number): boolean { return Number.isInteger(expirationMonths) && expirationMonths > 0; } export function isValidGrantCondition( grantCondition: GrantConditionInput | null | undefined, ): boolean { if (grantCondition == null) return true; // absent = NONE (unconditional, v1 behaviour) const { type, minWorkedDays, referenceMonths } = grantCondition; if (referenceMonths != null && (!Number.isInteger(referenceMonths) || referenceMonths <= 0)) { return false; } if (type === "MIN_WORKED_DAYS") { return minWorkedDays != null && Number.isInteger(minWorkedDays) && minWorkedDays > 0; } return type === "NONE"; // NONE requires no parameters; unknown runtime values are rejected } /** * Function: createAccrualPlan * Description: Defines a new effective-dated grant rule (AccrualPlan) for a * balance-backed LeaveType, establishing the initial generation used to * compute HIRE and ANNIVERSARY grants written into the LeaveGrant ledger. */ export async function run( db: Transaction, input: CreateAccrualPlanInput & CF, ctx: CommandContext, deps: AccrualPlanEmploymentTypeDeps, ) { // Every built-in key is destructured out (including the defaulted accrualMethod/grantType) so the // rest holds ONLY consumer custom fields — a built-in leaking into the rest would be spread into // the insert alongside its own explicit column. const { leaveTypeKey, accrualMethod: inputAccrualMethod, grantType: inputGrantType, eligibilityDelayMonths, baseGrantDays, tenureTiers, annualCapDays, expirationMonths, appliesToEmploymentTypeId, grantCondition, effectiveStart, ...customFields } = input; const accrualMethod = inputAccrualMethod ?? "FRONT_LOAD_TENURE"; const grantType = inputGrantType ?? "STATUTORY"; const leaveType = await db .selectFrom("LeaveType") .selectAll() .where("key", "=", leaveTypeKey) .executeTakeFirst(); if (!leaveType) { return err(new LeaveTypeNotFoundError(leaveTypeKey)); } if (!leaveType.requiresBalance) { return err(new LeaveTypeNotBalanceBackedError(leaveTypeKey)); } // Non-overlap is a model invariant across ALL generations, not just currently-open ones. The new // plan is open-ended [effectiveStart, ∞): any existing generation for the same (leaveTypeKey, // employmentType) that is still open OR ends on/after the new effectiveStart overlaps it — a // closed past generation ending after the new start would otherwise slip through (M11). let duplicateQuery = db .selectFrom("AccrualPlan") .selectAll() .where("leaveTypeKey", "=", leaveTypeKey) .where((eb) => eb.or([eb("effectiveEnd", "is", null), eb("effectiveEnd", ">=", effectiveStart)]), ); duplicateQuery = appliesToEmploymentTypeId == null ? duplicateQuery.where("appliesToEmploymentTypeId", "is", null) : duplicateQuery.where("appliesToEmploymentTypeId", "=", appliesToEmploymentTypeId); const existing = await duplicateQuery.forUpdate().executeTakeFirst(); if (existing) { return err( new DuplicateActivePlanError(`${leaveTypeKey}:${appliesToEmploymentTypeId ?? ""}`), ); } const employmentTypeCheck = await validateAppliesToEmploymentType( db, deps, appliesToEmploymentTypeId, ctx, ); if (!employmentTypeCheck.ok) { return err(employmentTypeCheck.error); } if (!isValidEligibilityDelayMonths(eligibilityDelayMonths)) { return err(new InvalidEligibilityDelayMonthsError(String(eligibilityDelayMonths))); } if (!isValidBaseGrantDays(baseGrantDays)) { return err(new InvalidBaseGrantDaysError(baseGrantDays)); } if (!isValidAnnualCapDays(annualCapDays) || !isValidTenureTiers(tenureTiers, annualCapDays)) { return err(new InvalidTenureTiersError(JSON.stringify(tenureTiers))); } if (!isValidExpirationMonths(expirationMonths)) { return err(new InvalidExpirationMonthsError(String(expirationMonths))); } if (!isValidGrantCondition(grantCondition)) { return err(new InvalidGrantConditionError(JSON.stringify(grantCondition))); } if (!(ACCRUAL_METHODS as readonly string[]).includes(accrualMethod)) { return err(new InvalidAccrualMethodError(accrualMethod)); } if (!(ACCRUAL_GRANT_TYPES as readonly string[]).includes(grantType)) { return err(new InvalidGrantTypeError(grantType)); } const id = crypto.randomUUID(); const accrualPlan = await db .insertInto("AccrualPlan") .values({ ...(customFields as Record), id, leaveTypeKey, accrualMethod: accrualMethod as (typeof ACCRUAL_METHODS)[number], grantType: grantType as (typeof ACCRUAL_GRANT_TYPES)[number], eligibilityDelayMonths, baseGrantDays, tenureTiers, annualCapDays: annualCapDays ?? null, expirationMonths, appliesToEmploymentTypeId: appliesToEmploymentTypeId ?? null, grantCondition: grantCondition ?? null, effectiveStart, effectiveEnd: null, versionOf: id, }) .returningAll() .executeTakeFirstOrThrow(); return ok({ accrualPlan }); }