# 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 <new flag>=true AND <implied flag>=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.<flag>`; 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?.<flag> ?? dao.<flatColumn> ?? 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 |
