import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import { ACCRUAL_PLAN_BUILTIN_COLUMNS } from "../db/accrualPlan"; import type { Transaction } from "../generated/kysely-tailordb"; import { AccrualPlanNotFoundError, InvalidAccrualMethodError, InvalidBaseGrantDaysError, InvalidEligibilityDelayMonthsError, InvalidExpirationMonthsError, InvalidGrantConditionError, InvalidGrantTypeError, InvalidTenureTiersError, OverlappingGenerationError, PlanAlreadyRetiredError, } from "../lib/errors.generated"; import { ACCRUAL_GRANT_TYPES, ACCRUAL_METHODS, isValidGrantCondition, validateAppliesToEmploymentType, type AccrualPlanEmploymentTypeDeps, type GrantConditionInput, type TenureTierInput, } from "./createAccrualPlan"; export type UpdateAccrualPlanInput = { id: string; /** Date the change takes effect; becomes the new generation's effectiveStart. */ effectiveStart: Date; /** Accrual method; omitted = keep current. */ accrualMethod?: string; /** Grant provenance (STATUTORY | MANUAL); omitted = keep current. */ grantType?: string; eligibilityDelayMonths?: number; 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; null = uncapped. */ annualCapDays?: string | null; expirationMonths?: number; appliesToEmploymentTypeId?: string | null; /** Optional statutory grant gate (ADR-026 C); null = clear (NONE / v1 behaviour), omitted = keep current. */ grantCondition?: GrantConditionInput | null; }; 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; return Number.isInteger(value * 2); } function isValidExpirationMonths(expirationMonths: number): boolean { return Number.isInteger(expirationMonths) && expirationMonths > 0; } function isValidTenureTiers(tiers: TenureTierInput[], annualCapDays: string | null): boolean { const cap = annualCapDays == null ? Number.POSITIVE_INFINITY : Number(annualCapDays); if (annualCapDays != null && !Number.isFinite(cap)) return false; 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: updateAccrualPlan * Description: Records a rule change to an AccrualPlan (eligibility delay, base * grant days, tenure tiers, annual cap, expiration months, applicability, or * grant condition) as a new effective-dated generation, closing the prior * generation without overwriting it (ADR-013). */ export async function run( db: Transaction, input: UpdateAccrualPlanInput & Partial, ctx: CommandContext, deps: AccrualPlanEmploymentTypeDeps, ) { const { id, effectiveStart } = input; const current = await db .selectFrom("AccrualPlan") .selectAll() .where("id", "=", id) .forUpdate() .executeTakeFirst(); if (!current) { return err(new AccrualPlanNotFoundError(id)); } if (current.effectiveEnd !== null) { return err(new PlanAlreadyRetiredError(id)); } if (effectiveStart.getTime() <= current.effectiveStart.getTime()) { return err(new OverlappingGenerationError(id)); } const nextEligibilityDelayMonths = input.eligibilityDelayMonths ?? current.eligibilityDelayMonths; const nextBaseGrantDays = input.baseGrantDays ?? current.baseGrantDays; const nextTenureTiers = input.tenureTiers ?? current.tenureTiers; const nextAnnualCapDays = input.annualCapDays !== undefined ? input.annualCapDays : current.annualCapDays; const nextExpirationMonths = input.expirationMonths ?? current.expirationMonths; const nextAppliesToEmploymentType = input.appliesToEmploymentTypeId !== undefined ? input.appliesToEmploymentTypeId : current.appliesToEmploymentTypeId; const nextGrantCondition = input.grantCondition !== undefined ? input.grantCondition : current.grantCondition; const nextAccrualMethod = input.accrualMethod ?? current.accrualMethod; const nextGrantType = input.grantType ?? current.grantType; // An update creates a full new generation, so custom fields must be inherited from the current // generation unless the caller explicitly supplies a replacement value. The built-in column set // is derived from the model definition, so adding a built-in field never turns it into a // silently carried-over "custom" field here. const customFields: Record = {}; for (const [key, value] of Object.entries(current as Record)) { if (!ACCRUAL_PLAN_BUILTIN_COLUMNS.has(key)) customFields[key] = value; } for (const [key, value] of Object.entries(input as Record)) { if (!ACCRUAL_PLAN_BUILTIN_COLUMNS.has(key) && value !== undefined) customFields[key] = value; } if (!(ACCRUAL_METHODS as readonly string[]).includes(nextAccrualMethod)) { return err(new InvalidAccrualMethodError(nextAccrualMethod)); } if (!(ACCRUAL_GRANT_TYPES as readonly string[]).includes(nextGrantType)) { return err(new InvalidGrantTypeError(nextGrantType)); } if (!isValidEligibilityDelayMonths(nextEligibilityDelayMonths)) { return err(new InvalidEligibilityDelayMonthsError(String(nextEligibilityDelayMonths))); } if (!isValidBaseGrantDays(nextBaseGrantDays)) { return err(new InvalidBaseGrantDaysError(nextBaseGrantDays)); } if (!isValidTenureTiers(nextTenureTiers, nextAnnualCapDays)) { return err(new InvalidTenureTiersError(JSON.stringify(nextTenureTiers))); } if (!isValidExpirationMonths(nextExpirationMonths)) { return err(new InvalidExpirationMonthsError(String(nextExpirationMonths))); } if (!isValidGrantCondition(nextGrantCondition)) { return err(new InvalidGrantConditionError(JSON.stringify(nextGrantCondition))); } // When this update sets the plan to a concrete EmploymentType, that cross-module reference must // resolve to an ACTIVE workforce type. An untouched (inherited) or cleared (null = all) value is // not re-checked here — it was validated when it was first set. if (input.appliesToEmploymentTypeId !== undefined && input.appliesToEmploymentTypeId !== null) { const employmentTypeCheck = await validateAppliesToEmploymentType( db, deps, input.appliesToEmploymentTypeId, ctx, ); if (!employmentTypeCheck.ok) { return err(employmentTypeCheck.error); } } const previousGenerationEnd = new Date(effectiveStart); previousGenerationEnd.setUTCDate(previousGenerationEnd.getUTCDate() - 1); await db .updateTable("AccrualPlan") .set({ effectiveEnd: previousGenerationEnd }) .where("id", "=", current.id) .execute(); const nextId = crypto.randomUUID(); const accrualPlan = await db .insertInto("AccrualPlan") .values({ ...customFields, id: nextId, leaveTypeKey: current.leaveTypeKey, accrualMethod: nextAccrualMethod as (typeof ACCRUAL_METHODS)[number], grantType: nextGrantType as (typeof ACCRUAL_GRANT_TYPES)[number], eligibilityDelayMonths: nextEligibilityDelayMonths, baseGrantDays: nextBaseGrantDays, tenureTiers: nextTenureTiers, annualCapDays: nextAnnualCapDays, expirationMonths: nextExpirationMonths, appliesToEmploymentTypeId: nextAppliesToEmploymentType, grantCondition: nextGrantCondition ?? null, effectiveStart, effectiveEnd: null, versionOf: current.versionOf, }) .returningAll() .executeTakeFirstOrThrow(); return ok({ accrualPlan }); }