# TimeOff Module — Agent Context ~337 files, ~28 500 LoC. Hexagonal. DB tables named `Vacation*` (legacy), code is modern. **After finishing any implementation task, evaluate:** - New service added → §7 - New/modified critical flow (request, approval, balance, sync) → §2 - New/modified flow that creates, approves, cancels, or rewrites a request → §2 "Time Tracking integration" (does it need to emit `TIME_OFF_REQUEST_APPROVED` / `TIME_OFF_REQUEST_CANCELLED` so timeTracking day summaries reconcile?) - New dangerous pattern, race condition, or silent corruption risk → §3 - New law/compliance rule or policy-level flag → §2 + §3 - New queue message type or FF behavior → §4 - New endpoint or auth context → §5 - Change in balance / cycle / allowance / carryover computation → also invoke skill `timeoff-balance-cycles` maintenance (SKILL.md trigger table) and notify the user to sync Notion ([Balances y Ciclos](https://www.notion.so/humand-co/Balances-y-Ciclos-3686757f313080cbaaa5d08c27ea7bb2)) If any apply → **ask the user** if they want to update AGENTS.md (never update automatically). If none apply, skip silently. Skip for routine bug fixes. --- ## 1. Domain Model ``` PolicyType (name, unit DAYS|HOURS, visibility, order) └── Policy (allowance, counting method, cycle config, limits) ├── PolicyTemplate (instance + policyType + userGathering + policyId) ├── PolicyBlockedDate (date ranges → requests forbidden) ├── PolicyApprovalStep (position-ordered; responsibles: users / relations / segItems) └── PolicyUser (user ↔ policy) └── PolicyUserCycle (startDate/endDate, expired flag) └── Event (balance movement: ADDITION / SUBTRACTION / SET) └── EventCharge (links addition ↔ subtraction event) Request (policyId, policyTypeId, creatorId, issuerId, substituteApproverId) └── RequestApprovalStep (materialized from PolicyApprovalStep) └── RequestApprovalStepPotentialApprover (incl. substitutes) TimeOffUser (module-local: id, instanceId, hiringDate) PolicyUserConflict (user matches multiple policies for same type) JobTrack (daily job execution log) ``` **Balance = NOT persisted.** Computed from Events via `TimeOffService.getBalance()`. ### Key enums | Concept | Values | |---------|--------| | Request states | `IN_PROGRESS`, `APPROVED`, `REJECTED`, `CANCELLED` | | Approval step states | `PENDING`, `WAITING_OTHER_STEP`, `APPROVED`, `REJECTED` | | Event operations | `ADDITION`, `SUBTRACTION`, `SET` | | Event types | `BALANCE_USAGE`, `BALANCE_USAGE_CANCELLATION`, `BALANCE_USAGE_CORRECTION`, `CYCLE_ADDITION`, `CYCLE_CONCLUSION`, `CYCLE_EXPIRATION`, `BALANCE_REMNANT_CARRYOVER`, `BALANCE_MANUAL_CORRECTION`, `BALANCE_BULK_CORRECTION`, `BALANCE_MANUAL_OVERWRITE`, `POLICY_ALLOWANCE_TYPE_CHANGE`, `POLICY_CYCLE_ALLOWANCE_AMOUNT_CHANGE`, `USER_HIRING_DATE_CHANGE`, `USER_POLICY_CHANGE` | | Allowance types | `BASIC_ANNUAL`, `UNLIMITED` | | Counting methods | `BUSINESS_DAYS`, `CALENDAR_DAYS` | | Unit | `DAYS`, `HOURS` | | Visibility | `PRIVATE`, `PUBLIC` | | Allowance start | `JANUARY`…`DECEMBER`, `HIRING_DATE` | | Proration freq | `EVERY_ONE_MONTH`, `EVERY_TWELVE_MONTHS` | | Accumulation moment | `START_OF_CYCLE`, `END_OF_CYCLE` | | `maximumRemnantType` | `FIXED`, `PERCENTAGE` (null = no cap) | | Expiry reminder unit | `DAYS`, `WEEKS`, `MONTHS` | --- ## 2. Critical Business Flows ### Request lifecycle (`createRequest`) 1. Resolve issuer (on-behalf vs loggedUser), validate permissions 2. Load policyType + user's policy; validate substitute (non-work policies only) 3. Guards: overlapping (exception: two HALF_DAY requests on the same date are allowed if complementary halves — `isValidHalfDayOverlap()`), blocked dates, new-hires ban, future/retroactive limits, attachment, consumption type, min hour fraction 4. `calculateAmountRequested` (days/hours by calendar/work schedule/half-days) 5. Balance sale rules + min/max amount + `minimumAdvanceDays` 6. `validateSufficientBalance` (BASIC_ANNUAL): balance − pending vs minimumBalance; special branch next-cycle 7. Persist request + attachments 8. `skipApproval`: fake approved step → `buildRequestApprovedEvent` → `createEvents` → `checkPendingChargesForUsers` 9. Normal: `createApprovalStepsForNewRequest` (policy or instance fallback; substitute logic if approver offline) ### On-behalf (`adminOnBehalf`) `issuerId !== loggedUser.id` → `requestingOnBehalf`. Creator admin → `adminOnBehalf = true`; manager → `false`. | Actor | `adminOnBehalf` | Effect | |-------|----------------|--------| | Admin | `true` | Several validations **skipped** | | Manager | `false` | All validations apply | | Self | `false` | Normal | **Bypassed when `adminOnBehalf = true`:** - Overlapping requests - Blocked dates - `noFutureRequests` - `noRetroactiveRequests` (`createRequest`: skip; `previewAmountRequested`: → warnings) - Balance sale rules (**exception**: still enforced when `policy.allowBalanceSaleBrazil = true`) - `validateSufficientBalance` - Argentinian law (`createRequest`: skip; `previewAmountRequested`: → warnings) - Brazilian law (`createRequest`: skip; `previewAmountRequested`: → warnings) - `minimumAdvanceDays` / `TIME_OFF_REQUEST_TOO_ADVANCED` (`createRequest`: skip; `previewAmountRequested`: → warnings) — SQJG-4824 - Consumption type / half-day rules / `TIME_OFF_REQUEST_HALF_DAY_NOT_ALLOWED` (create: skip; preview: → warnings) — SQJG-4824 - Min hour fraction / `TIME_OFF_REQUEST_INVALID_TIME_FRACTION` (create: skip; preview: → warnings) — SQJG-4824 - Min/max amount per request / `VACATION_REQUEST_AMOUNT_TOO_LOW` · `VACATION_REQUEST_AMOUNT_TOO_HIGH` (create: skip; preview: → warnings; Brazilian-law branch unchanged) — SQJG-4824 - Description required / `VACATION_REQUEST_DESCRIPTION_REQUIRED` (`createRequest`: skip only; **not surfaced in preview** — preview payload has no `description`) — SQJG-4824 - Attachment required / `VACATION_REQUEST_ATTACHMENT_REQUIRED` (`createRequest`: skip only; **not surfaced in preview** — preview payload has no `attachments`) — SQJG-4824 **Never bypassed (enforced even for admin on-behalf):** new-hires ban (`TIME_OFF_NEW_HIRES_REQUESTS_BAN`), must-use-first / required policy balance (`TIME_OFF_POLICY_MUST_BE_USE_FIRST`), and the substitute-approver guard (`TIME_OFF_SUBSTITUTE_APPROVER_NOT_ALLOWED`, "#8" — intentionally left out of SQJG-4824 scope, still throws for every actor). **Preview:** for admin on-behalf, every surfaceable policy validation routes to `warnings` instead of `errors` (laws, balance, balance-sale, advance-days, half-day, time-fraction, min/max amount). For self / manager-on-behalf those same checks remain `errors` (SQJG-4824 added the 5 amount/advance/half-day/fraction checks to preview — previously they were silent in preview and only threw at create). Description/attachment are never evaluated in preview for any actor. Non-admin multi-violation caveat: preview early-returns on advance-days/half-day/time-fraction (the early-validation block) before computing the amount, so a request violating both an early check and min/max can surface a different *first* error in preview than `createRequest` throws — pre-existing early-return behavior (retroactive already behaves this way), strictly better than the old "preview shows nothing" state. **Bulk create inherits this bypass.** `createBulkRequest` (`:6506`) calls `createRequest` as the admin (`issuerId` = target user, logged user = admin ⟹ `adminOnBehalf = true`), so admin XLSX bulk uploads bypass all of the above too — intended (the uploader is an admin; this is why bulk can create past-cycle requests). `createBulkRequest` itself was not modified by SQJG-4824; the behavior change is inherited from the shared `createRequest` guards. > **⚠️ New policy-level validation?** Ask: bypass for admin on-behalf? Pattern: `if (!adminOnBehalf) { validateXxx(...) }`. Remember `createBulkRequest` shares this path. --- ### Approval — step-by-step (`reviewRequestStep`) - Serializable tx, `tries: 1` - Admin sees any step; non-admin only if potential approver - Reject → REJECTED immediately - Approve last step → APPROVED → `buildRequestApprovedEvent` + `checkPendingChargesForUsers` - Approve non-last → next step PENDING ### Approval — admin force (`editRequestState`) - Advisory lock on issuer userId - Cancel: if was APPROVED → `cancelBalanceUsageForRequest` + reconcile - Reject: admin only, must be IN_PROGRESS - Approve: all pending/waiting steps → approval event + reconcile ### Balance (`getBalance`) - Filter events by `effectiveAt` cycle range, ignore expired - BASIC_ANNUAL: sum `availableAmount` (additions) − `notChargedAmount` (subtractions) - UNLIMITED: `amountUsed` accumulates; `currentBalance` = null always - Uncancelled `BALANCE_USAGE` → counts toward `amountUsed` - Optional: `applyBalanceViewPrecision` rounding ### Event placement — `immediateFutureDeduction` flag (→ DZ #16) `buildRequestApprovedEvent` chooses the cycle for the `BALANCE_USAGE`: | Condition | `cycleId` | `effectiveAt` | |-----------|-----------|---------------| | Default (flag OFF, or current-cycle request) | cycle covering `request.from.date` | `request.from.date` | | `policy.immediateFutureDeduction === true` AND `isRequestForFutureCycle === true` | cycle covering **today** | `new Date()` (approval moment) | The actual leave date is always preserved in `metadata.from.date`. Reports that need "when the leave happens" must read that field, not `effectiveAt`. `validateSufficientBalance` dispatch: flag ON + future-cycle case falls through to the current-cycle branch (the raw `getUserBalance` is naturally correct since the event is in the current cycle). `validateSufficientBalanceForFutureCycle` runs only when flag OFF + future-cycle. `getProjectedBalance.totalAvailable` skips the `futureRequestsAmount` subtraction when flag ON — otherwise it double-counts (the deduction is already implicit in `currentBalance`). ### Preview (`previewAmountRequested`) `POST /requests/preview`. Same validations as `createRequest`, non-blocking — returns `{ errors, warnings }`. Errors: Argentinian law, Brazilian law, balance sale, insufficient balance. (Admin on-behalf: law errors → warnings.) Warnings: future cycle flag, pending requests. Nothing persisted. --- ### Edit flows — TWO DIFFERENT PATHS. Never confuse. --- #### Flow A: `editRequestDates` — Public API - `PUT /time-off/requests/:id/dates` — API key auth, `TimeOffService` - Precondition: **IN_PROGRESS**. Balance: none. No advisory lock. No preview. No notification. | Validation | Behavior | |-----------|----------| | Future cycle | Throws if `noFutureRequests=true` + future cycle | | Consumption type | Throws on bad half-day config | | Overlapping | Throws (excludes current) | | Argentinian law | `validateArgentinianLawRulesOrThrow` — blocks | | Min/max per request | `validatePolicyAmountRules` — skipped for `brazilianLawCompliance` | | Required policy balance | Throws if must-use-first not consumed | | Sufficient balance | `validateSufficientBalance` — throws | Brazilian law: **bypassed entirely**. --- #### Flow B: `editApprovedRequest` — App - Preview: `POST /requests/:id/edit-preview` / Actual: `PUT /requests/:id` — JWT auth, `TimeOffRequestService` - Precondition: **APPROVED**. Balance: live — cancel original + recreate. Advisory lock per `requestId`. Notification post-tx. **Preview — non-blocking `{ errors, warnings }`:** | Validation | Category | Notes | |-----------|----------|-------| | Date order | Error (early return) | `amount=0` if from > to | | Overlapping | Warning | Catches `RequestConflictError` | | Blocked dates | Warning | | | Future cycle | Warning | Sets `TIME_OFF_NEXT_CYCLE_REQUEST` | | Balance sale | Warning | | | Argentinian law | Error (collected) | `validateArgentinianLawRules` | | Brazilian law | Warning (collected) | `excludeRequestId` — avoids double-counting | | Min balance sim | Warning | `currentBalance + oldAmount − newAmount < minimumBalance` | **Response includes `amountDiff`** (edit-preview only, not in `previewAmountRequested`): - `amount`, `amountInTime`, `amountInMoney` → new totals (same shape as `previewAmountRequested`). - `amountDiff = amount − request.amount` → signed diff vs the original approved request (positive = adding; negative = removing). FE uses it to render "sumando/restando X días" and `available − amountDiff` for the projected balance. **Actual edit — only critical:** | Validation | Behavior | |-----------|----------| | Date order | Throws | | Argentinian law | `validateArgentinianLawRulesOrThrow` — blocks | **Balance sequence (→ DZ #3):** 1. `cancelBalanceUsageForRequest` 2. `updateRequest` (dates + amount + `edited=true`) 3. `buildRequestApprovedEvent` + `createEvents` --- #### Edit flows comparison | | Flow A | Flow B preview | Flow B actual | |-|--------|----------------|--------------| | Precondition | IN_PROGRESS | APPROVED | APPROVED | | Balance | None | Simulated | Cancel + recreate | | Advisory lock | No | No | Yes | | Overlapping | Blocking | Warning | — | | Argentinian law | Blocking | Error | Blocking | | Brazilian law | Bypassed | Warning | — | | Min balance | Blocking | Warning | — | | Return | Request | `{ errors, warnings, amount }` | Request | | Notification | No | No | Yes | | TimeTracking event sync | N/A (IN_PROGRESS — no APPROVED event ever fired) | N/A | **Missing** — does not emit `TIME_OFF_REQUEST_CANCELLED`/`APPROVED`; day summaries drift (→ DZ #17) | ### Daily job Monthly jobs, hiring-date jobs, cycle expirations, unlimited policies, AR law (October). Parallelism via `TimeOffBatchProcessingService`. --- ### Time Tracking integration timeOff is coupled to `module-timeTracking` in two directions. Both must be considered when writing or modifying any request/policy flow. **Direction 1 — reads (sync, via consumer-defined ports listed in §4).** Any flow that computes a day/hour amount eventually goes through `TimeOffService.calculateAmountRequested`, which reaches into timeTracking: | Caller (timeOff) | Port → method | What it provides | |------------------|--------------|-----------------| | `TimeOffService.getWorkDaysMap` (used by `adjustForBusinessDays` for day policies) | `TimeTrackingDaySummaryPort.getOrCreateDaySummariesByUser` | `isWorkday` + `isHoliday` per date | | `TimeOffService.getWorkDaysWithHoursAndWorkdayInfo` (used by `calculateHoursRequested` for hour policies) | `TimeTrackingDaySummaryPort.getOrCreateDaySummariesByUser` | `scheduledHours` + workday flags per date | | `TimeOffWorkSchedulesAdapter.getCurrentWorkdaysByUserIds` (used by `TimeOffUsersService.getUsers` when `includeCurrentWorkdays=true`) | `TimeTrackingWorkScheduleDataPort.getWorkScheduleByUserIds` | Current weekly workdays per user | | `TimeOffWorkSchedulesAdapter.getHolidaysAndWorkdaysByUser` (deprecated fallback + `TimeOffPolicyService.validateRequireMondayStart`) | `TimeTrackingWorkScheduleDataPort.getAssignedWorkSchedulesByUser` + `RegionsService.getHolidays` | Workdays + holidays over a date range | Notes: - `getOrCreateDaySummariesByUser` is **not pure** — it may insert empty `TimeTrackingDaySummary` rows for dates with no entries. - Hour priority for hour policies (`getHoursForDay` / `getHoursForDayWithoutSummary`): `policy.fixedHoursPerDay (if set) > SHIFT > WORK_SCHEDULE > 8h default`. **`fixedHoursPerDay` wins over the user's shift/schedule hours when set** (SQJG-4802) — a fixed-per-day leave (e.g. lactancia = 1h/day) deducts that amount on every counted day, regardless of the employee's actual scheduled hours. When `fixedHoursPerDay` is null or 0, the schedule/shift hours are used. Which days count is still gated upstream by `isWorkday`, not by these functions. - The legacy `timeOffWorkSchedulesPort.getHolidaysAndWorkdaysByUser` fallback **only** runs when all day summaries are `NO_SCHEDULE` and there is no shift. `TimeOffWorkSchedulesAdapter.getHolidaysAndWorkdaysByUser` is `@deprecated` — new callers must go through `TimeTrackingDaySummaryPort`. - Flows that transitively read from timeTracking via `calculateAmountRequested`: `createRequest`, `previewAmountRequested`, `editRequestDates` (Flow A), `editApprovedRequest` (Flow B actual), `previewEditApprovedRequest` (Flow B preview). **Direction 2 — writes (async, via `EventEmitter` events).** When a request transitions in/out of APPROVED, timeTracking must reconcile its day summaries: | timeOff emits | timeTracking listener | Side effects in timeTracking | |---------------|----------------------|------------------------------| | `Events.TIME_OFF_REQUEST_APPROVED` (`timeOffRequestApproved`) | `TimeTrackingService.handleTimeOffApproval` | For each affected day: pushes to `daySummary.timeOffRequests`, persists `TimeTrackingDaySummaryTimeOff`, recomputes `estimatedHours` / `timeOffHours` / schedule-dependent `incidences`, enqueues `SCHEDULE_CHANGE` (categorized-hour recompute), sends schedule-updated push notification | | `Events.TIME_OFF_REQUEST_CANCELLED` (`timeOffRequestCancelled`) | `TimeTrackingService.handleTimeOffCancellation` | Removes the request from affected day summaries and inverse-recomputes | Current emission sites: - `TimeOffController.createRequest` skip-approval branches (`presentation/controllers/timeOffController.ts:578/711/750`) — emit `TIME_OFF_REQUEST_APPROVED` when a new request is created already approved. - `TimeOffService.editRequestState` (`business/services/timeOffService.ts:5123/5126`) — admin force: emits `TIME_OFF_REQUEST_CANCELLED` or `TIME_OFF_REQUEST_APPROVED`. - `TimeOffService` final step approval path (`business/services/timeOffService.ts:4928`) — emits `TIME_OFF_REQUEST_APPROVED` when the last `reviewRequestStep` lands APPROVED. **Rule for any new/modified request flow:** > Any flow that transitions a request **into** APPROVED must emit `TIME_OFF_REQUEST_APPROVED`. Any flow that transitions a request **out of** APPROVED (cancel, reject, or rewrite of dates/amount) must emit `TIME_OFF_REQUEST_CANCELLED` for the original payload. A flow that does both atomically (edit-in-place of an APPROVED request) must emit the CANCELLED + APPROVED pair so timeTracking can reconcile via `TimeTrackingDaySummaryTimeOff`. The dangerous miss to watch for is "cancel-and-recreate internally without re-emitting" — the balance side stays consistent (events table inside timeOff), but timeTracking's day summaries silently drift. See DZ #17 for the current concrete gap. --- ## 3. Dangerous Zones 1. **Dual balance logic** — `TimeOffService.getBalance()` AND `TimeOffBalanceService` both compute balance. Change one → change both or drift. 2. **Two approval paths** — `reviewRequestStep` (serializable tx) vs `editRequestState` (advisory lock). Different locking + event emission. Don't mix. 3. **`cancelBalanceUsageForRequest`** — expects **exactly 1** active `BALANCE_USAGE` per request. 0 or >1 → `UnexpectedError`. Corruption silent until called. 4. **`reviewRequestStep` retries** — `transactionWithRetries` `tries: 1`. Minimal concurrency margin. 5. **Audiences vs gatherings split** — FF `TIME_OFF_POLICIES_AUDIENCES_ENABLED` gates two **mutually exclusive** policy assignment paths: - **Legacy (false)**: `USER_GATHERING_CRITERIA_CHANGE` → `TimeOffService.handleUserGatheringCriteriaChange` (marked TODO: remove after migration) - **Audiences (true)**: `TIME_OFF_AUDIENCES` queue → `TimeOffQueueService` → `TimeOffPoliciesSyncService` `handleUserGatheringCriteriaChange` is **no-op when FF=true**. Sending legacy messages while audiences is active → desync. **Sync decision per `policyTypeId`** (`calculatePolicyChanges`): | Eligible count | Action | |---------------|--------| | 0 | Remove current assignment + delete conflict | | 1, same as current | No-op | | 1, different | Remove current → add new → delete conflict | | >1 | Upsert `PolicyUserConflict`; leave assignment unchanged | **Execution ordering**: `executePolicyChangesOptimized` runs removes **before** adds (sequential) — prevents race conditions when reassigning the same `policyTypeId`. **`saveTimeOffPolicyAudience` trap**: requires `policy.policyType` loaded — **throws** `Error` if null. Always include `policyType` when calling this method. **Soft-delete guard**: `validateAndGetUser` returns `null` when `timeOffUser.deletedAt != null` → `USER_CREATED`/`USER_SEGMENTATION_UPDATED` silently no-op for soft-deleted users. 6. **`timeOffService.ts` ~7793 lines** — God service. Search exact method before touching. Many similar names (`addUsersToPolicy` vs `addUsersToPoliciesBulkForNewUser` vs `addUsersToPoliciesBulkForUpdate`). 7. **Argentinian law special cases** — AR-only. Easy to break without knowing legal rules. - `calculateArgentinianLawAllowance`, `processArgentinianOctoberPolicy`, `isValidArgentinianLawPolicy` — October annual bonus logic. - `requireMultiplesOfSeven`: request duration must be a calendar-day multiple of 7, OR amount = user's total balance (handles fractional leftovers). When true: `minimumAmountPerRequest` check is skipped; only max is enforced. Validated via `TimeOffPolicyService.validateRequireMultiplesOfSeven`. - `requireMondayStart`: first day of request must be the Monday of that week (or first non-holiday if Monday is a holiday). Validated via `TimeOffPolicyService.validateRequireMondayStart` (queries work schedule). Both flags are grouped under `validateArgentinianLawRules / validateArgentinianLawRulesOrThrow`. - These flags + `seniorityCalculationDate='12-31'` form the AR signal set for the country-signal mutex (DZ #18). Adding a new AR-specific flag → also extend that mutex. 8. **Legacy table names** — code: `TimeOffPolicy`, DB: `VacationPolicies`. Affects migrations + raw queries. 9. **Mapper pitfalls** — missing Sequelize `include` → nested mapper returns `undefined` silently. Model looks complete, has null objects. 10. **`deletePolicies` cascade** — manual (not DB-level). Missing a step → orphans. 11. **Query performance** — high-volume data. Avoid N+1, bulk ops, selective `attributes`/`include`, indexed `where` on hot paths (balance calc, request listing, daily jobs). 12. **`addUsersToPolicy` variants** — wrong one → silent corruption: - `addUsersToPolicy(policy, userIds, instanceId)` — single policy - `addUsersToPoliciesBulkForNewUser(policies, userId, instanceId)` — new users only - `addUsersToPoliciesBulkForUpdate(policies, userId, instanceId)` — segmentation updates (may have existing policy users) 13. **Brazilian law** — `brazilianLawCompliance = true` → 1 extra `listRequests` query. Rules: max 3 periods/cycle, min 5 days/period, ≥1 period ≥14 days. `min/maxAmountPerRequest` bypassed. Per-flow: see §2 tables + on-behalf section. `previewEditApprovedRequest` passes `excludeRequestId` to avoid double-counting. For country-signal mutex see DZ #18. - **`allowBalanceSaleBrazil`** — optional add-on to Brazilian law. Mutually exclusive with `allowBalanceSale` (via `validateAllowBalanceSaleBrazilConfig`, runs before the broader country-signal mutex so its specific error message wins). Requires `BASIC_ANNUAL` allowance type (throws if UNLIMITED). When enabled: balance sale rules are **enforced even for admin on-behalf** (exception to the general bypass via `shouldEnforceBalanceSaleRules`). 14. **Don't add to `TimeOffService`.** Check §7 first. Use existing service or create new one. Only add if logic genuinely crosses multiple sub-domains. 15. **CYCLE_ADDITION dedup** — `createCycleAdditionEventsForPolicy` checks existing `CYCLE_ADDITION` events per cycle before creating. Re-running without this guard (or bypassing it) silently creates duplicate balance additions that corrupt balances. 16. **`immediateFutureDeduction` event placement (Option B)** — when the flag is ON, future-cycle `BALANCE_USAGE` events are placed in the **current** cycle at approval time, not in the cycle covering `request.from.date`. See §2 "Event placement". Implications: - **Rollover**: `expireCycle` marks the `BALANCE_USAGE` as `expired: true` alongside the source cycle. The request stays `APPROVED`. `getBalance` ignores expired events → next cycle starts fresh (`newAllowance`) with no event consuming from it. The pre-approved days were paid from the old cycle's allowance. - **Cancellation re-routing**: when `additionEvent.cycle.expired === true`, `cancelBalanceUsageForRequest` does NOT route the `BALANCE_USAGE_CANCELLATION` to the expired source. It loads `currentCycle = getCycle({ coveredDate: new Date(), policyUserId })` and routes the refund there (with `effectiveAt = new Date()`, `expired = false`). Applies to all three cancellation branches (per-charge BASIC_ANNUAL, BASIC_ANNUAL remainder, UNLIMITED/fallback). If `currentCycle` is undefined or itself expired, falls back to the source (refund effectively lost — acceptable corner case). - **No retroactive migration**: this is a forward-only change. Pre-existing approved future requests (from before Option B shipped) keep their `BALANCE_USAGE` in the future cycle. Their current cycle balance does NOT reflect the deduction until the future request is cancelled or its cycle expires. Accepted product trade-off documented in `docs/superpowers/plans/2026-05-19-immediate-future-deduction-option-b.md`. - **`effectiveAt` semantics**: the event's `effectiveAt` is the approval date (in current cycle's range), NOT the leave date. Reports and audits that need "actual leave date" must read `metadata.from.date`. `listUserEvents` filtered by `expired: false` will hide the event post-rollover — the request is still visible in calendars / request listings. - **`previewEditApprovedRequest` min-balance simulation** assumes the original event is in the current cycle for its `currentBalance + oldAmount − newAmount` formula. This is accurate when the request stayed under the same flag setting; toggle mid-flight or pre-Option-B legacy requests produce stale warnings (non-blocking, warning only). 17. **TimeTracking day-summary drift on edit flows** — see §2 "Time Tracking integration" for the full rule. Current concrete gap: `TimeOffRequestService.editApprovedRequest` and its controller (`TimeOffController.editApprovedRequest`) cancel + recreate the internal `BALANCE_USAGE` event but emit **no** `Events.TIME_OFF_REQUEST_CANCELLED` (for the original from/to) or `TIME_OFF_REQUEST_APPROVED` (for the new from/to). `module-timeTracking`'s `handleTimeOffApproval` / `handleTimeOffCancellation` listeners therefore never fire — `TimeTrackingDaySummaryTimeOff`, `estimatedHours`, `incidences`, and the `SCHEDULE_CHANGE` categorized-hour recompute all stay pegged to the original dates until another lifecycle transition forces reconciliation. `Flow A` (`editRequestDates`, IN_PROGRESS only) does not have this issue because no APPROVED event was ever emitted for the original dates. Any new or modified edit/rewrite flow on APPROVED requests must emit the CANCELLED + APPROVED pair (or explicitly justify why not in code). 18. **VacationPolicy country invariant** (`createPolicy`, `editPolicy`) — **`country` is the authority** (Spec A Stage 1). Enforced via `assertSignalsBelongToCountry` + `deriveCountry` (renamed from `assertPolicyCountrySignalsConsistency` + `deriveCountryFromParams`). A compliance signal that does **not** belong to the declared `country` is rejected with `BadRequestError`. The reverse (`country=X ⟹ signal=true`) is still NOT enforced — an admin can tag a policy with any country without enabling its strict feature flags. **Single source of truth** — `business/types/policies/compliance/complianceFlagsByCountry.ts`: `COMPLIANCE_FLAGS_BY_COUNTRY` (country → allowed flags) + `ALL_COMPLIANCE_FLAGS` (the 7-flag union). Consumed by both `deriveCountry` and `assertSignalsBelongToCountry`. A unit drift-guard asserts every flag appears in exactly one country. **Flag sets** (per country, from the map): - **AR**: `argentinianLawCompliance`, `requireMultiplesOfSeven`, `requireMondayStart` (+ `seniorityCalculationDate='12-31'`, an AR signal handled out-of-band — it is a generic column, not a compliance flag) - **BR**: `brazilianLawCompliance`, `allowBalanceSaleBrazil` - **CO**: `allowBalanceSale`, `requireTimeGreaterThanMoney` - Flag-less countries (MX, ES, EC, GT, PE, PY, US): **no** compliance flags allowed — any flag set on such a policy is rejected. **Behavior change (Spec A Stage 1, was `[feature]`)**: previously the per-country signals won and `country` was only a fallback, so `country='AR' + allowBalanceSale=true` silently produced a **CO** policy. Now `country` wins: that same payload is **rejected**. The FE only sends flags of the selected country, so this should not fire in practice (a data audit confirmed 0 inconsistent live rows before ship). **Cross-country mutex is subsumed**: when `country` is absent (legacy / non-FE callers), `deriveCountry` infers it from the signals (priority AR > BR > CO), and the foreign-flag check then rejects any other country's signal — reproducing the old AR↔BR / AR↔CO / BR↔CO mutex. Order in createPolicy/editPolicy: `validateAllowBalanceSaleBrazilConfig` runs **first** (preserves its dedicated `allowBalanceSaleBrazil ↔ allowBalanceSale` error message), then `assertSignalsBelongToCountry`. **CO sub-invariant**: `requireTimeGreaterThanMoney=true ⟹ allowBalanceSale=true` (independent of country). Violation → `BadRequestError`. The reverse is allowed (a CO policy can sell balance without enforcing time > money). **Country derivation**: `country` (param on create, merged `resultingState` on edit) wins when present. When `null`, it is inferred from the signals (`'12-31'` ⟹ AR). When neither is present → `null`. **Adding a new country-specific flag**: add it to the `PolicyCompliance` interface (`compliance/policyCompliance.ts`) + `buildCompliance` + `ALL_COMPLIANCE_FLAGS` (`ComplianceFlag` = `keyof PolicyCompliance`) + the country's entry in `COMPLIANCE_FLAGS_BY_COUNTRY` (the drift-guard test fails otherwise) + the flat column in the DAO/mapper dual-write + the migration backfill `CASE`. If it should drive inference when `country` is absent, extend `deriveCountry`'s fallback. If the new flag implies an existing one (like `requireTimeGreaterThanMoney ⟹ allowBalanceSale`), add the runtime check in `assertSignalsBelongToCountry` AND a one-shot data fix in a migration scoped to `WHERE =true AND =false` (do not flip the implied flag silently in code on existing rows). **Domain shape & persistence (Spec A Stage 2, Phases 1–4 — landed in this PR)**: the 7 flat booleans are consolidated into a nested `policy.compliance: PolicyCompliance` object (`compliance/policyCompliance.ts`), always built via `buildCompliance` (every field a concrete boolean, never `undefined` — DZ #9). All read sites use `policy.compliance.`; VCs stay flat (FE contract unchanged), SCs derive the flat keys from the object. Persistence (`timeOffPolicyMapper.ts`): a `compliance` JSONB column is **dual-written** alongside the 7 flat columns (kept for revert safety). The JSONB stores **only the declared country's flags** (`complianceForPersistence`, pick-by-country); the read coalesces **per-field** `dao.compliance?. ?? dao. ?? false`. The flat fallback covers a null/legacy JSONB and rows an old pod inserts flat-only during the deploy window. It does **not** close the JSONB-first staleness window: an old pod *editing* a backfilled row writes flat-only, leaving a stale JSONB that the read returns until the next new-pod write — same transition window as `requestRules`, rare, self-healing. Pick-by-country is safe because of the `country=NULL ⟹ no signal` invariant above (verified clean in prod for live rows before ship). Backfill: `20260529120000-add-compliance-to-vacation-policies` (add column + single-statement pick-by-country backfill scoped to `country IS NOT NULL` — ~12k country-tagged rows; country-less rows are skipped on purpose and keep `compliance=NULL`, which reads as all-false, identical to `'{}'`). > **Phase 5 (follow-up PR, NOT here)**: drop the 7 flat columns + stop dual-writing, ≥1 week after this ships stably. The `compliance` column stays **nullable** (country-less rows are NULL by design and read all-false); do NOT `SET NOT NULL` unless those rows are first backfilled with `'{}'`. Until then both representations coexist and the flat columns remain the source of truth on revert. **Data history**: migrations `20260526113727-cleanup-vacation-policies-data` (data correction) + `20260526113728-set-default-for-require-time-greater-than-money` (column default flip) reconciled `VacationPolicies` to this invariant. The 296k rows with `requireTimeGreaterThanMoney=true AND allowBalanceSale=false` were a backfill bug from `20251210114844-add-balance-sale-fields.js`'s `defaultValue:true`. Pre-existing admin-tagged policies without signals (35 AR / 2 BR / 11 CO) are intentionally preserved (admin choice ≠ inconsistency). --- ## 4. Architecture & DI ### Port → Adapter | Port | Adapter | Backing | |------|---------|---------| | `TimeOffRepositoryPort` | `TimeOffRepositoryAdapter` | 15+ DAOs + raw SQL | | `TimeOffUsersRepositoryPort` | `TimeOffUsersRepositoryAdapter` | `TimeOffUserDAO`, `TimeOffPolicyUserDAO`, `UserDAO` | | `TimeOffUsersPort` | `TimeOffUsersAdapter` | `UsersService`, `PermissionsService`, `OrganizationChartsService` | | `TimeOffOrganizationChartPort` | `TimeOffOrganizationChartAdapter` | `OrganizationChartsService` | | `TimeOffAttachmentsPort` | `TimeOffAttachmentsAdapter` | `AttachmentsService` | | `TimeOffNotificationsPort` | `TimeOffNotificationsAdapter` | Push, email, notification center | | `TimeOffWorkSchedulesPort` | `TimeOffWorkSchedulesAdapter` | `TimeTrackingWorkScheduleDataPort`, `RegionsService`, `InstancesService` | | `TimeOffXlsxPort` | `TimeOffXlsxAdapter` | ExcelJS | | `TimeOffAudiencesPort` | `TimeOffAudiencesAdapter` | `AudiencesService`, `SegmentationsService` | | `TimeOffJobTrackRepositoryPort` | `TimeOffJobTrackRepositoryAdapter` | `TimeOffJobTrackDAO` | | `TimeOffAudiencesSqsConsumerPort` | `TimeOffQueueService` | SQS handlers (not in adapters/) | | `TimeOffPolicyOperationsSqsConsumerPort` | `TimeOffPolicyOperationsService` | SQS fan-out handlers for async policy-edit (not in adapters/) | | `TimeOffDateUtilsPort` | `DateUtilsAdapter` | via `serverHandlers.ts` | | `TimeOffUtilsPort` | lodash utils | via `serverHandlers.ts` | | `TimeTrackingDaySummaryPort` *(consumer-defined)* | `TimeTrackingDaySummaryAdapter` (in `module-timeTracking`) | consumed by `TimeOffService` to get day summaries without importing from timeTracking | | `TimeTrackingWorkScheduleDataPort` *(consumer-defined)* | `TimeTrackingWorkScheduleDataAdapter` (in `module-timeTracking`) | consumed by `TimeOffWorkSchedulesAdapter` to get work-schedule data without importing from timeTracking | ### DB tables `TimeOffPolicyDAO` → `VacationPolicies` | `TimeOffPolicyTypeDAO` → `VacationPolicyTypes` | `TimeOffPolicyUserDAO` → `UserVacationPolicies` | `TimeOffRequestDAO` → `VacationRequests` | `TimeOffEventDAO` → `VacationEvents` | `TimeOffEventChargeDAO` → `TimeOffEventCharges` | `TimeOffPolicyUserCycleDAO` → `TimeOffPolicyUserCycles` | `TimeOffRequestApprovalStepDAO` → `VacationRequestApprovalSteps` | `TimeOffJobTrackDAO` → `TimeOffJobTrack` ### Queue `TIME_OFF_AUDIENCES`: `USER_CREATED`, `USER_SEGMENTATION_UPDATED` (skip when `TIME_OFF_POLICIES_AUDIENCES_ENABLED=false`), `USER_DELETED` (always runs — no FF guard). Unknown types → deleted, not retried. `TIME_OFF_POLICY_OPERATIONS` (FIFO): `TIME_OFF_POLICY_EDIT_DISPATCH` (fan-out) + `TIME_OFF_POLICY_EDIT_APPLY_CHUNK` (per-chunk balance adjustments). Dispatched from `editPolicy` **after** the HTTP transaction commits, to keep bulk policy edits (30k+ users) under the API gateway timeout. Two-stage: 1 DISPATCH message paginates policy users and fans out N APPLY_CHUNK messages (100 users/chunk), each on its own `messageGroupId` (`${jobId}:${chunkIndex}`) for parallel FIFO processing. Skip-init (no-op) when `AWS_SQS_TIME_OFF_POLICY_OPS_QUEUE_URL` is unset (expected before humand-time-off infra is applied). --- ## 5. HTTP Surface | Router | Path | Auth | Controller | |--------|------|------|-----------| | App | `/vacations` | JWT + app | `TimeOffController` | | Admin | `/vacations` | JWT + backoffice | `TimeOffController` + `TimeOffPolicyTypeController` | | Public (OTC) | `/time-off` | one-time code | `publicReviewRequestStep` only | | Public API | `/time-off` | API key + rate limit | `TimeOffController` | | DevOps | `/vacations` | JWT (no scope) | `TimeOffDevOpsController` | App/Admin/DevOps = `/vacations` (legacy). Public/PublicAPI = `/time-off`. Permissions in controllers, not routers. | Router | Method | Path | Notes | |--------|--------|------|-------| | App | POST | `/vacations/requests/:id/remind-approval-users` | Reminders to pending approvers | | Admin | PUT | `/vacations/policies/:id/user-gathering-criteria` | Upsert gathering criteria | | Public API | GET | `/time-off/balances/by-cycle` | Paginated balances by user + cycle | | Public API | PUT | `/time-off/requests/:id/dates` | Edit from/to (admin-only) | | Public API | POST | `/time-off/policies/:id/requests/bulk` | Async bulk creation of requests; reuses admin XLSX pipeline (in-memory XLSX from JSON items → S3 → SQS → `createBulkRequest`). 202 with `{ jobId, status, totalRows }` | | Public API | GET | `/time-off/requests/bulk/:jobId` | Retrieve a bulk-create job's status + per-row JobErrors. **Complement of the POST**, not a generic Jobs API: 404 if `job.type !== BULK_TIME_OFF_REQUESTS_CREATION` | | DevOps | POST | `/devops/vacations/send-expiry-reminders` | Trigger balance expiry reminder notifications | --- ## 6. Testing **Unit** (`test/modules/timeOff/`) — 47 files: 11 services, 5 adapters, 18 VCs, 13 SCs. - Factory: `timeOffTestsCommon.ts`. Mocks: `jest-mock-extended`. Transactions → sync callback. - Date utils: often real `DateUtilsAdapter` via `mockImplementation`. VCs: `getValidationClass`. SCs: `serialize`. **Integration** (`test-integration/**/timeOff/`) — ~23 files. `CreateCommunity.new()` → SQL → commands → HTTP. Isolation by instance. **No unit tests for:** `timeOffJobsService`, `timeOffCycleBalanceCorrectionService`, `timeOffUsersPoliciesSyncIssuesCorrectionService`, `timeOffCorrectionService`. No controller unit tests (integration covers). --- ## 7. Services **Before coding:** map your task to the correct service using this table. If the description is ambiguous, read the service file. Don't add to `TimeOffService` unless the logic genuinely crosses multiple sub-domains (→ DZ #14). | Service | Responsibility | |---------|---------------| | `TimeOffService` | **God service** (~7793 lines) — requests, approvals, balance, policies, events, jobs, corrections, reports | | `TimeOffRequestService` | Collaborator calendar, `isUserOfflineAtDate`, `editApprovedRequest`, bulk approval (`reviewMassiveRequestStep`) | | `TimeOffPolicyService` | Policy listing + management, AR law validation (`validateArgentinianLawRules/OrThrow`, `validateRequireMultiplesOfSeven`, `validateRequireMondayStart`), audience segmentation cleanup | | `TimeOffUsersService` | User CRUD, `PolicyUser` management, approver resolution, visibility checks, `listConflicts` | | `TimeOffQueueService` | SQS consumer for `TIME_OFF_AUDIENCES`: `handleUserCreated` + `handleUserSegmentationUpdated` (FF-gated) and `handleUserDeleted` (no FF guard). Delegates to `TimeOffPoliciesSyncService` | | `TimeOffBlockedDatesService` | CRUD for `PolicyBlockedDate` records; `validateBlockedDates` (request creation guard); `syncPolicyBlockedDates` (bulk sync for policy edit); overlap validation | | `TimeOffJobsService` | Thin entry point: `dailyJob` delegates entirely to `TimeOffService.dailyJob` | | `TimeOffCycleService` | Cycle lifecycle: `createCycle`, `expireCycle(WithEvents)`, `getCyclesWithBalancesAndDefaults`; `getPolicyAllowanceAmountForUser` (AR law + years-of-service + proration); `listBalancesByCycle` (Public API) | | `TimeOffEventService` | Thin CRUD wrapper: `createEvents`, `updateEvents`, `deleteEvents`, `listEvents`, `createEventCharges`; `listUserEvents` (paginated history, visibility-guarded) | | `TimeOffBalanceService` | Balance computation (parallel to `TimeOffService.getBalance` — DZ #1) | | `TimeOffExpiryRemindersService` | DevOps job: sends push + bell + email when a cycle balance is about to expire. Two types: `CYCLE_END` (N days/weeks/months before cycle ends) and `CARRYOVER_EXPIRY` (N before carried-over balance expires). Stamps `cycleEndReminderSentAt` / `carryoverExpiryReminderSentAt` to avoid re-sending. Uses `TimeOffBalanceService` (not `TimeOffService.getBalance` — see DZ #1). | | `TimeOffCorrectionService` | DevOps: `detectAndFixObsoleteConflicts` — scans `PolicyUserConflict` records across instances/users and removes stale ones; dry-run supported | | `TimeOffBatchProcessingService` | Drives daily job in paginated batches (100 users/batch) across MONTHLY/YEARLY/HIRING_DATE/UNLIMITED policy types; manages `JobTrack` records | | `TimeOffFemsaBalanceService` | External FEMSA (KOF/Coca-Cola) API: `getBalance(employee, userId, instanceId)` — fetches leave balance by employee + country (MX/GT/BR), OAuth token cached in-memory | | `TimeOffListRequestsService` | `listRequests` with collaborator vs manager role split, team visibility filter, segmentation filter, step reviews, attachment hydration; `getRequestById` (auth-guarded) | | `TimeOffPoliciesSyncService` | Policy-user sync on audience changes — full decision matrix in DZ #5. Key methods: `syncUserPolicies`, `syncUserPoliciesForNewUserOptimized`, `syncUserPoliciesWithPreFetchedData` (batch) | | `TimeOffUtilsService` | Shared utils; `resolveRemnantCap(policy, cycleAllocation)` for FIXED/PERCENTAGE carryover cap | | `TimeOffCycleBalanceCorrectionService` | DevOps: `fixMissingCycleAdditionEvents` (backfills missing `CYCLE_ADDITION` for cycles that should have gotten allowance); `fixMissingHiringDateCycles` (creates missing hiring-date cycles) | | `TimeOffUsersPoliciesSyncIssuesCorrectionService` | DevOps: `detectAndFixPoliciesUsersSyncIssues` — re-evaluates audience eligibility per instance and reconciles `PolicyUser` assignments via `TimeOffPoliciesSyncService.syncUserPolicies` | | `TimeOffPolicyOperationsService` | Async policy-edit fan-out (SQS consumer for `TIME_OFF_POLICY_OPERATIONS`): `dispatchPolicyEdit` (enqueued post-`editPolicy`; retries with backoff, skip-init when queue URL unset), `handlePolicyEditDispatch` (paginates policy users → fans out APPLY_CHUNK messages, one `sendBatchMessages` call per page), `handlePolicyEditApplyChunk` (bulk per-chunk balance-adjustment events; idempotency pre-SELECT by `temporalCorrelationId=jobId`; resolves current cycles via `rebuildCyclesBulk` so `expired` flags are recomputed). Calls back into `TimeOffService` via `Container.get` for `build*TransitionEvents` + `checkPendingChargesForPolicy` | | `TimeOffPolicyOperationsQueueConsumer` | Thin SQS routing wrapper for `TIME_OFF_POLICY_OPERATIONS`: routes `TIME_OFF_POLICY_EDIT_DISPATCH` / `TIME_OFF_POLICY_EDIT_APPLY_CHUNK` to `TimeOffPolicyOperationsService`; skip-init in `init()` when queue URL unset |