import { err, ok, type ReadonlyDB } from "@tailor-platform/erp-kit/core"; import type { DB } from "../generated/kysely-tailordb"; import { LeaveTypeNotFoundError } from "../lib/errors.generated"; export interface GetLeaveBalanceInput { workerId: string; leaveTypeKey: string; /** Date the balance is computed as of. Defaults to now. */ asOf?: Date; } // LeaveRequest statuses that hold a reservation against the ledger: an APPROVED taking, a still-open // PENDING request, and a CANCEL_PENDING request (the reservation persists until the cancel is // approved). REJECTED / CANCELLED requests no longer reserve, so their consumption lines are ignored. const RESERVING_STATUSES = ["APPROVED", "PENDING", "CANCEL_PENDING"] as const; /** * Function: getLeaveBalance * Computes a worker's leave balance for a leaveTypeKey as of a date — a derived * aggregate reconstructed from the ledger, not a stored entity (ADR-005; there is * deliberately no LeaveBalance model). For each LeaveGrant whose validity window * [grantedDate, expirationDate] contains asOf and that had not lapsed by asOf, the * remaining amount is recomputed from the ledger — grantedDays minus the reserving * consumption whose LeaveRequest had started on or before asOf — rather than read from * the stored remainingDays, so a past asOf yields the balance as it stood then. Also * returns the soonest upcoming expirationDate among the contributing grants that still * had a positive remaining as of that date. */ export async function run(db: ReadonlyDB, input: GetLeaveBalanceInput) { const leaveType = await db .selectFrom("LeaveType") .select("id") .where("key", "=", input.leaveTypeKey) .executeTakeFirst(); if (!leaveType) { return err(new LeaveTypeNotFoundError(input.leaveTypeKey)); } // NOTE: workerId references workforce's Worker (cross-module), and there is no query // injection wired into module.ts yet, so we cannot proactively verify the Worker exists // here (see WORKER_NOT_FOUND in the doc). Following the precedent in // modules/workforce/command/createWorker.ts, we skip that check; referential integrity // for workerId is left to the DB-level FK constraint. const asOf = input.asOf ?? new Date(); // Grants that existed by asOf. Whether one contributes at asOf (window + not-yet-lapsed) is // decided in code so a since-expired grant still counts for a past asOf. const grants = await db .selectFrom("LeaveGrant") .selectAll() .where("workerId", "=", input.workerId) .where("leaveTypeKey", "=", input.leaveTypeKey) .where("grantedDate", "<=", asOf) .execute(); const contributing = grants.filter( (grant) => grant.grantedDate.getTime() <= asOf.getTime() && asOf.getTime() <= grant.expirationDate.getTime() && (grant.expiredAt === null || grant.expiredAt.getTime() > asOf.getTime()), ); // Reserving consumption that had already started by asOf, summed per grant, so the remaining is // reconstructed as-of rather than read from the current stored remainingDays. const grantIds = contributing.map((grant) => grant.id); const consumptions = grantIds.length ? await db .selectFrom("LeaveConsumption") .innerJoin("LeaveRequest", "LeaveRequest.id", "LeaveConsumption.leaveRequestId") .select(["LeaveConsumption.leaveGrantId", "LeaveConsumption.daysConsumed"]) .where("LeaveConsumption.leaveGrantId", "in", grantIds) .where("LeaveRequest.startDate", "<=", asOf) .where("LeaveRequest.status", "in", [...RESERVING_STATUSES]) .execute() : []; const consumedByGrant = new Map(); for (const consumption of consumptions) { consumedByGrant.set( consumption.leaveGrantId, (consumedByGrant.get(consumption.leaveGrantId) ?? 0) + Number(consumption.daysConsumed), ); } let balance = 0; const contributingWithRemaining: { expirationDate: Date }[] = []; for (const grant of contributing) { const remaining = Number(grant.grantedDays) - (consumedByGrant.get(grant.id) ?? 0); if (remaining > 0) { balance += remaining; contributingWithRemaining.push(grant); } } let nextExpirationDate: Date | null = null; if (contributingWithRemaining.length > 0) { nextExpirationDate = contributingWithRemaining.reduce( (soonest, grant) => (grant.expirationDate < soonest ? grant.expirationDate : soonest), contributingWithRemaining[0].expirationDate, ); } return ok({ balance, nextExpirationDate }); }