Aller au contenu

Absence Management (ABSMGT) — Technical Architecture

Module: ABSMGT (Application Plane)
Product: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite
Country Scope: CI (Côte d'Ivoire) — MVP
Version: 1.0
Date: 2026-03-12
Author: ARCHITECT (Claude Code)
Input documents: - docs/prd/absence-management-prd.md (PRD+FSD v1.0) - docs/design/absence-management-design.md (UX/UI v1.0) - docs/architecture/platform-saas-blueprint.md (Blueprint v2) - docs/architecture/PROGRESS.md (Cross-module decisions, ADR-01 through ADR-15) - docs/architecture/control-plane-arch.md (Control Plane reference) - docs/architecture/budget-lines-management-arch.md (BUDMGT patterns)


Table of Contents

  1. System Context
  2. Data Model
  3. API Design
  4. Multi-Tenant Strategy
  5. Offline & Sync Architecture
  6. Sequence Diagrams
  7. Security Architecture
  8. Performance Considerations
  9. Migration Strategy
  10. Integration Points
  11. Vertical Slice Decomposition
  12. Technical Risks & Mitigations

1. System Context

1.1 Module Positioning

ABSMGT is an Application Plane module in com.altarys.papillon.modules.absence. It handles tenant business data (leave requests, balances, accruals, public holidays, maternity declarations). It is not a Control Plane module.

┌──────────────────────────────────────────────────────────────────────────┐
│                         ALTARYS PLATFORM                                │
│                                                                         │
│  ┌────────────────────────────┐    ┌─────────────────────────────────┐  │
│  │    CONTROL PLANE            │    │     APPLICATION PLANE            │  │
│  │                             │    │                                  │  │
│  │  tenant/                    │    │  absence/ ◀── THIS MODULE       │  │
│  │    ModuleActivated     │    │    REST API endpoints            │  │
│  │    TenantSettings           │    │    LeaveApproved (async event)  │  │
│  │    PublicHoliday (shared)   │    │    LeaveCancelled               │  │
│  │                             │    │    LeaveBalanceSettlement       │  │
│  │  organization/ ◀────────────────  │    ManagerChanged listener     │  │
│  │    EmployeeCreated     │    │                                  │  │
│  │    EmployeeUpdated     │    │  payroll/ ─────────────────────→│  │
│  │    ManagerChanged      │    │    Consumes: LeaveApproved      │  │
│  │                             │    │    Consumes: LeaveCancelled     │  │
│  │  notification/ ◀────────────────  │    Consumes: Settlement        │  │
│  │    LeaveRequestSubmitted    │    │                                  │  │
│  │    LeaveApproved           │    │  employee-mgmt/ ──────────────→ │  │
│  │    LeaveRejected           │    │    Consumes: EmployeeTerminated │  │
│  │                             │    │                                  │  │
│  │  audit/                     │    │  qr-attendance/ (future)        │  │
│  │    All mutations logged     │    │    Integrates with Absence data │  │
│  │                             │    │                                  │  │
│  └────────────────────────────┘    └─────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────────────┘

1.2 Key Constraints (Locked by ADRs & CLAUDE.md)

Constraint Source Implication for ABSMGT
ScopedValue for TenantContext ADR-01 Every service method accesses TenantContext.current()
Spring Data JDBC only CLAUDE.md No lazy loading, explicit tenant_id in all queries
Pessimistic locking for balance mutations ADR-13 SELECT FOR UPDATE on leave_employee_profile row during approval/cancellation
Event-driven read models (not Java interfaces) ADR-14 (revised) Subscribe to EmployeeCreated, cache locally. No compile-time dependencies on org module.
BigDecimal for financials CLAUDE.md All balance calculations use BigDecimal (jours ouvrables, calendaires, ouvrés)
Application Events for async ADR-14 Publish LeaveApproved, LeaveCancelled, LeaveBalanceSettlement to Payroll
Cursor-based pagination ADR-12 List endpoints use keyset pagination (no offset drift with concurrent writes)
Audit trail immutable ADR-10 Every balance change creates ledger entry (never update/delete, only append)
Scheduled batch accrual User decision Cron job runs monthly, idempotent (checks last_accrual_date)
Tenant DB for holidays + platform seed User decision All holidays (fixed + variable) in Tenant DB, seeded from platform defaults at tenant creation

1.3 Dual Frontend Support (Design from Day One)

  • Papillon HR Suite (frontend/): SME-focused, mobile-first, warm brand (amber)
  • ALTARYS ENTERPRISE (frontend-enterprise/, V2): Enterprise-focused, desktop-first, corporate brand (navy)
  • Single backend API: Both frontends consume the same REST endpoints
  • Design tokens: Theme injection enables both brands from one codebase

2. Data Model

2.1 Entity-Relationship Diagram

erDiagram
    LeaveRequest ||--|| LeaveEmployeeProfile : "belongs_to"
    LeaveRequest ||--o{ LeaveBalanceLedger : "generates"
    LeaveRequest ||--o{ Attachment : "has_documents"
    LeaveEmployeeProfile ||--o{ LeaveBalanceLedger : "accumulates"
    LeaveEmployeeProfile ||--o{ CachedEmployeeData : "references"
    MaternityDeclaration ||--|| LeaveRequest : "creates"
    ExceptionalLeaveConfig ||--o{ LeaveRequest : "configures"
    PublicHoliday ||--o{ LeaveRequest : "excludes_from"

    LeaveRequest {
        uuid id PK
        uuid tenant_id FK
        uuid employee_id FK
        varchar leave_type_code FK
        varchar event_type_code "nullable, for EXCEPTIONAL"
        date start_date
        date end_date
        boolean half_day_start
        boolean half_day_end
        decimal number_of_days "in tenant deduction unit"
        decimal number_of_days_ouvrables "source of truth"
        varchar status "DRAFT|PENDING_DOCUMENT|PENDING_APPROVAL|APPROVED|AUTO_APPROVED|SELF_APPROVED|REJECTED|CANCELLED_BY_EMPLOYEE|CANCELLED_BY_HR|IN_PROGRESS|COMPLETED|EARLY_RETURN"
        text reason "nullable"
        text rejection_reason "nullable"
        text cancellation_reason "nullable"
        uuid approver_id "nullable, FK to user"
        boolean is_retroactive
        uuid supporting_document_id "nullable"
        boolean submitted_offline
        timestamp created_at
        timestamp updated_at
        int version "for offline sync conflict detection"
        uuid created_by
    }

    LeaveEmployeeProfile {
        uuid id PK
        uuid tenant_id FK
        uuid employee_id FK "UNIQUE per tenant"
        decimal annual_balance_ouvrables "cached, source of truth is ledger"
        decimal exceptional_days_used_this_period "counter for 10-day cap"
        decimal sick_days_current_rolling_12m "12-month rolling counter"
        varchar sick_leave_phase "FULL_PAY|HALF_PAY|ZERO_PAY|null"
        int number_of_children_under_21 "for bonus calc if HR Core not active"
        date reference_period_start
        date reference_period_end
        date last_accrual_date "last month for which accrual was processed"
        timestamp created_at
        timestamp updated_at
        int version
    }

    LeaveBalanceLedger {
        uuid id PK "immutable, append-only"
        uuid tenant_id FK
        uuid employee_id FK
        varchar leave_type "ANNUAL|SICK_COUNTER|EXCEPTIONAL_COUNTER"
        varchar transaction_type "MONTHLY_ACCRUAL|SENIORITY_BONUS|CHILDREN_BONUS|LEAVE_DEBIT|CANCEL_CREDIT|EARLY_RETURN_CREDIT|OVERLAP_CREDIT|CARRY_OVER|EXPIRY|MANUAL_ADJUSTMENT|SETTLEMENT"
        decimal amount "in jours ouvrables, signed"
        decimal amount_calendaires "computed: amount * 1.25"
        decimal amount_ouvres "computed: amount * 5/6"
        uuid reference_id "FK to leave_request or adjustment, nullable"
        text reason "required for MANUAL_ADJUSTMENT, EXPIRY, SETTLEMENT"
        timestamp created_at
        uuid created_by
        index idx_employee_leave_type_date(tenant_id, employee_id, leave_type, created_at)
    }

    MaternityDeclaration {
        uuid id PK
        uuid tenant_id FK
        uuid employee_id FK
        date expected_delivery_date
        date actual_delivery_date "nullable, updated after birth"
        uuid leave_request_id FK "the auto-created leave_request"
        timestamp created_at
        uuid created_by
    }

    ExceptionalLeaveConfig {
        uuid id PK
        uuid tenant_id FK
        varchar event_type_code "MARRIAGE_SELF, DEATH_SPOUSE, etc."
        decimal duration_days "tenant-configured, must be >= CCI minimum"
        decimal cci_minimum_days "system floor"
        varchar country_code "CI"
        timestamp created_at
        timestamp updated_at
        unique(tenant_id, event_type_code)
    }

    PublicHoliday {
        uuid id PK
        uuid tenant_id FK "null for platform-level, tenant_id for tenant override"
        varchar country_code "CI, BJ, CM (future)"
        int year
        date date
        varchar name_fr
        varchar holiday_code "CHRISTMAS, EID_FITR, etc."
        boolean is_fixed
        varchar source "PLATFORM|TENANT_OVERRIDE"
        timestamp created_at
        unique(tenant_id, country_code, year, holiday_code)
    }

    CachedEmployeeData {
        uuid id PK
        uuid tenant_id FK
        uuid employee_id FK
        varchar employee_matricule
        varchar first_name
        varchar last_name
        varchar category "category code for sick leave indemnization table"
        date hire_date
        uuid org_unit_id
        uuid current_manager_id "nullable"
        varchar employee_status "ACTIVE|INACTIVE|TERMINATED"
        timestamp synced_at "last update from event"
        index idx_employee_id(tenant_id, employee_id)
    }

    LeaveTypeConfig {
        uuid id PK
        uuid tenant_id FK
        varchar code "ANNUAL, SICK, MATERNITY, etc."
        varchar name_fr "Congé annuel, etc."
        boolean is_paid
        boolean requires_document
        boolean requires_approval
        boolean accrual_based "true for ANNUAL"
        decimal max_days_per_year "nullable, cap per year"
        boolean is_system "true for CCI-mandated types"
        boolean is_active "tenant can disable non-system types"
        varchar country_code "CI"
        timestamp created_at
        timestamp updated_at
        unique(tenant_id, code)
    }

    AbsenceSettings {
        uuid id PK
        uuid tenant_id FK "UNIQUE per tenant"
        varchar deduction_unit "JOURS_CALENDAIRES|JOURS_OUVRES|JOURS_OUVRABLES"
        varchar reference_period_type "CALENDAR_YEAR|ANNIVERSARY"
        varchar carry_over_policy "STRICT_EXPIRY|PARTIAL_CARRY_OVER|UNLIMITED"
        decimal partial_carry_over_max_days "nullable, used when PARTIAL_CARRY_OVER"
        timestamp created_at
        timestamp updated_at
        unique(tenant_id)
    }

    SickLeaveIndemnizationRule {
        uuid id PK
        uuid tenant_id FK
        varchar country_code "CI"
        varchar employee_category "OUVRIER|CADRE"
        int seniority_min_years "inclusive"
        int seniority_max_years "exclusive, null for unlimited"
        int full_pay_months
        int half_pay_months
        int total_months "computed: full + half"
        timestamp created_at
        timestamp updated_at
        unique(tenant_id, country_code, employee_category, seniority_min_years)
    }

    LeaveTypeConfig ||--o{ LeaveRequest : "defines"
    AbsenceSettings ||--|| LeaveEmployeeProfile : "configures"
    SickLeaveIndemnizationRule ||--o{ LeaveEmployeeProfile : "determines_phase"

Note: Attachment is not a module-owned entity.
supporting_document_id on LeaveRequest references the platform's document storage service (MinIO).
Document CRUD is handled by the Control Plane's basic attachment API.

2.2 Tenant Isolation per Entity

All ABSMGT entities are tenant-scoped and stored in the Tenant DB (except configuration seeding data which comes from Platform DB via events).

Entity Storage Tenant Column Notes
LeaveRequest Tenant DB tenant_id Employee's own leave requests
LeaveEmployeeProfile Tenant DB tenant_id Per-employee leave state
LeaveBalanceLedger Tenant DB tenant_id Immutable, append-only ledger
MaternityDeclaration Tenant DB tenant_id HR-created maternity tracking
ExceptionalLeaveConfig Tenant DB tenant_id Tenant-customized permission durations
PublicHoliday Tenant DB tenant_id Seeded from platform defaults, tenant can override
CachedEmployeeData Tenant DB tenant_id Local read-model, synced via events
LeaveTypeConfig Tenant DB tenant_id Seeded from platform defaults, tenant can toggle active/inactive
AbsenceSettings Tenant DB tenant_id (UNIQUE) One row per tenant, tenant-scoped config
SickLeaveIndemnizationRule Tenant DB tenant_id CCI defaults seeded at tenant creation, future sector-specific overrides

Critical rule: Every query includes WHERE tenant_id = :tenantId. No exceptions.

2.3 Key Design Decisions

Ledger Pattern: Every balance change is immutable (append-only). This provides: - Full auditability (every change has timestamp, reason, user) - Zero reconciliation issues (ledger = single source of truth) - Easy to recompute balances (SUM the ledger) - Cached balance field on LeaveEmployeeProfile for performance (updated after each transaction)

Cached Employee Data: Local read-model built from Control Plane's EmployeeCreated, EmployeeUpdated, ManagerChanged. This: - Eliminates compile-time coupling to organization module - Zero latency for leave request validation (no API calls) - Enables independent team scaling - Still works with BYO-DB (data in tenant's own DB)

Pessimistic Locking: SELECT FOR UPDATE on leave_employee_profile during balance mutations: - Prevents double-approval (two requests approving same leave simultaneously) - Lock hold time < 50 ms (only during ledger insert + cached balance update) - Works across all 4 DB profiles (Platform, Schema, DB-per-tenant, BYO-DB)

Version Column: On LeaveRequest for offline sync conflict detection (ADR-05): - Frontend sends version, backend validates - If mismatch: 409 Conflict, user resolves - Does NOT prevent server-side concurrency (that's the pessimistic lock's job)

Sunday Substitution Rule (FR-ABS-103): If a public holiday falls on a Sunday, the following Monday is observed. This is computed at query time in HolidayService.getEffectiveHolidays(year) — no extra DB rows are created. The service checks day-of-week for each holiday and returns Monday if Sunday. This avoids data duplication and admin confusion. The substitution logic applies only to leave day computation, not to the public_holiday table itself.


3. API Design

3.1 REST Endpoint Mapping

Base path: /api/v1/absence/

Leave Request Endpoints

POST   /api/v1/absence/leave-requests
       Request: { leaveType, startDate, endDate, halfDayStart, halfDayEnd, eventType, reason, supportingDocumentId }
       Response: LeaveRequestResponse (201 Created | 422 Unprocessable Entity)
       RBAC: EMPLOYEE (self) | HR_MANAGER (any)

GET    /api/v1/absence/leave-requests
       Query: ?status=PENDING_APPROVAL&from=2026-01-01&to=2026-03-31&sort=createdAt,desc&cursor=...
       Response: LeaveRequestListResponse (paginated, cursor-based)
       RBAC: EMPLOYEE (self) | MANAGER (direct reports) | HR_MANAGER (all) | DG (all)

GET    /api/v1/absence/leave-requests/:id
       Response: LeaveRequestDetailResponse
       RBAC: EMPLOYEE (self) | MANAGER (if subordinate) | HR_MANAGER | DG

PATCH  /api/v1/absence/leave-requests/:id/cancel
       Request: { reason }
       Response: LeaveRequestResponse (status=CANCELLED_BY_EMPLOYEE | CANCELLED_BY_HR)
       RBAC: EMPLOYEE (self, before start_date) | HR_MANAGER | DG

PATCH  /api/v1/absence/leave-requests/:id/approve
       Request: {}
       Response: LeaveRequestResponse (status=APPROVED)
       Publishes: LeaveApproved
       RBAC: MANAGER (direct reports) | HR_MANAGER | DG

PATCH  /api/v1/absence/leave-requests/:id/reject
       Request: { rejectionReason }
       Response: LeaveRequestResponse (status=REJECTED)
       RBAC: MANAGER (direct reports) | HR_MANAGER | DG

GET    /api/v1/absence/leave-requests/:id/supporting-document
       Response: Binary file stream
       RBAC: HR_MANAGER | DG (all docs) | others cannot view medical certs

Leave Balance Endpoints

GET    /api/v1/absence/my-balance
       Response: LeaveBalanceResponse {
         annual: { ouvrables: 26.4, calendaires: 33, ouvrés: 22 },
         exceptional: { used: 2, remaining: 8, cap: 10 },
         sick: { usedThis12m: 5, phase: "FULL_PAY" }
       }
       RBAC: EMPLOYEE (self) | MANAGER (for balance check) | HR_MANAGER | DG

GET    /api/v1/absence/employees/:employeeId/balance
       Query: ?includeHistory=true
       Response: LeaveBalanceResponse + LeaveBalanceLedgerResponse (if history=true)
       RBAC: HR_MANAGER | DG only

GET    /api/v1/absence/employees/:employeeId/balance/ledger
       Query: ?from=2026-01-01&to=2026-12-31&type=ANNUAL&cursor=...
       Response: LedgerEntriesResponse (paginated, immutable entries)
       RBAC: HR_MANAGER | DG

Team Calendar Endpoints

GET    /api/v1/absence/team-calendar
       Query: ?month=2026-03&scope=ORG_UNIT|COMPANY&orgUnitId=...
       Response: TeamCalendarResponse {
         days: [{ date, absenceCount, employees: [{ name, status: ABSENT }] }]
       }
       Note: MANAGER sees direct reports only, HR_MANAGER sees entire company
       RBAC: EMPLOYEE (presence-only: sees absence count per day, no employee names or leave types) | MANAGER (direct reports) | HR_MANAGER | DG

GET    /api/v1/absence/team-calendar/my-team
       Query: ?month=2026-03
       Response: TeamCalendarResponse (for current user's direct reports)
       RBAC: MANAGER | HR_MANAGER | DG

HR Dashboard & Configuration

GET    /api/v1/absence/dashboard/summary
       Response: DashboardSummaryResponse {
         pendingApprovalsCount: 3,
         leaveBalanceSummary: { ... },
         absenceRatePercent: 8.5,
         upcomingLeavesThisWeek: [ ... ]
       }
       RBAC: HR_MANAGER | DG

POST   /api/v1/absence/maternity-declarations
       Request: { employeeId, expectedDeliveryDate }
       Response: MaternityDeclarationResponse
       Publishes: LeaveApproved (AUTO_APPROVED)
       RBAC: HR_MANAGER | DG

PATCH  /api/v1/absence/employees/:employeeId/adjust-balance
       Request: { leaveType, amount, reason, unit: OUVRABLES|CALENDAIRES|OUVRES }
       Response: LeaveEmployeeProfileResponse
       Publishes: AuditEntryRequested
       RBAC: HR_MANAGER | DG

GET    /api/v1/absence/config/leave-types
       Response: LeaveTypeConfigResponse[]
       RBAC: EMPLOYEE (active types) | HR_MANAGER (all)

PATCH  /api/v1/absence/config/leave-types/:code
       Request: { nameFr, isPaid, requiresDocument, ... }
       Response: LeaveTypeConfigResponse
       RBAC: HR_MANAGER | DG

GET    /api/v1/absence/config/public-holidays
       Query: ?year=2026
       Response: PublicHolidayResponse[]
       RBAC: All (read-only)

PATCH  /api/v1/absence/config/public-holidays
       Request: { countryCode, year, holidayCode, date, nameFr }
       Response: PublicHolidayResponse (creates override)
       RBAC: HR_MANAGER | DG

GET    /api/v1/absence/config/exceptional-leave
       Response: ExceptionalLeaveConfigResponse[]
       RBAC: All (read-only)

PATCH  /api/v1/absence/config/exceptional-leave/:eventTypeCode
       Request: { durationDays }
       Response: ExceptionalLeaveConfigResponse
       Validation: durationDays >= CCI minimum
       RBAC: HR_MANAGER | DG

GET    /api/v1/absence/config/settings
       Response: AbsenceSettingsResponse {
         referencePeriodsType: CALENDAR_YEAR|ANNIVERSARY,
         carryOverPolicy: STRICT_EXPIRY|PARTIAL_CARRY_OVER|UNLIMITED,
         deductionUnit: JOURS_CALENDAIRES|JOURS_OUVRES|JOURS_OUVRABLES,
         ...
       }
       RBAC: HR_MANAGER | DG

PATCH  /api/v1/absence/config/settings
       Request: { ... settings ... }
       Response: AbsenceSettingsResponse
       RBAC: HR_MANAGER | DG

Error Responses

All errors follow the standard ApiResponse envelope:

{
  "data": null,
  "meta": { "timestamp": "2026-03-12T10:30:00Z", "path": "/api/v1/absence/leave-requests" },
  "errors": [
    {
      "code": "INSUFFICIENT_BALANCE",
      "message": "Votre solde disponible est de 5 jours. Vous demandez 10 jours.",
      "fieldPath": "numberOfDays"
    }
  ]
}

Common business errors (422 Unprocessable Entity): - INSUFFICIENT_BALANCE — Not enough days - EXCEPTIONAL_DAYS_EXCEEDED — Exceeds 10-day annual cap - HIRE_DATE_NOT_ELIGIBLE — Less than 12 months service (ANNUAL only) - DOCUMENT_REQUIRED_MISSING — Medical cert not uploaded - OVERLAP_WITH_APPROVED_LEAVE — Conflicting approved request - MATERNITY_OVERLAP — Cannot request during maternity period - CCI_MINIMUM_NOT_MET — Permission duration below legal floor


4. Multi-Tenant Strategy

4.1 Strategy per Database Profile

All 4 profiles supported (MVP scope):

Profile Strategy Absence Implications
Shared DB All tenants in one DB, tenant_id on every table tenant_id in WHERE clause always. RLS as defense-in-depth.
Schema-per-tenant Each tenant gets own schema, dynamic schema routing Connection routed to correct schema at request time. tenant_id still present for consistency.
DB-per-tenant Each tenant gets own database, dynamic DataSource routing DataSource routed per TenantContext. Every query sees only tenant's data.
BYO-DB Tenant provides their own DB connection, validated & pooled Absence schema migrated to tenant's database. Data stays on tenant's infrastructure. tenant_id present but logically immaterial (single-tenant DB).

Design principle: All entities include tenant_id column regardless of profile. Queries always filter by tenant_id in WHERE clause. This: - Prevents accidental cross-tenant queries - Works across all 4 profiles without code changes - Enables gradual migration between profiles

4.2 Tenant-Scoped Configuration

Each tenant receives customized configuration seeded at tenant creation:

Configuration Seeded From Tenant Override? Per-Profile
Leave types (ANNUAL, SICK, MATERNITY, etc.) Platform defaults (CI) No (system types are immutable) All
Permission exceptionnelle durations CCI minimums (e.g., marriage = 4 days) Yes (tenant can increase above CCI min) All
Public holidays (fixed) Platform (Jan 1, May 1, Aug 7, etc.) No All
Public holidays (variable) Platform admin sets annually Yes (tenant can change EID_FITR date, e.g.) All
Reference period Tenant choice at signup Yes All
Carry-over policy Tenant choice at signup Yes All
Deduction unit Default: JOURS_CALENDAIRES Yes All

Seeding flow: 1. Platform admin creates CI tenant → TenantProvisioned published 2. Absence module listens → seeds leave types, CCI-minimum permissions, fixed holidays, variable holidays (platform defaults) 3. Tenant admin can override: permission durations, variable holiday dates, carry-over policy, reference period


5. Offline & Sync Architecture

5.1 Offline Capability

What's Available Offline

  • ✅ View own leave balance (cached from last sync)
  • ✅ View pending leave requests (cached list)
  • ✅ View approved leaves (cached calendar)
  • Create new leave request (saved to local storage, auto-submit on reconnect)
  • ✅ View team calendar (cached, read-only)
  • ❌ Approve/reject (requires manager role check on backend)
  • ❌ HR configuration (read-only online)

Leave Request Lifecycle (Offline → Online)

┌─ OFFLINE ─────────────┐
│ Employee opens app    │
│ (no connection)       │
│                       │
│ 1. View own balance   │
│    (cached from last  │
│    sync: 26 days JC)  │
│                       │
│ 2. Fill leave form:   │
│    - Leave type:      │
│      ANNUAL           │
│    - Dates: 20-24 Mar │
│    - Days calc'd:     │
│      5 days           │
│                       │
│ 3. Save draft offline │
│    (IndexedDB)        │
│    Status: DRAFT      │
│                       │
│ 4. Submit locally     │
│    (no network)       │
│    Status: DRAFT → ?  │
│    (queued for sync)  │
│                       │
└───────────────────────┘
          ↓ (reconnect)
┌─ ONLINE ──────────────┐
│ App detects network   │
│ (background sync)     │
│                       │
│ 1. Fetch latest       │
│    employee balance   │
│    (validate: still   │
│     26 days, no       │
│     conflict)         │
│                       │
│ 2. POST leaf-         │
│    requests with      │
│    request body       │
│    submitted_offline  │
│    = true             │
│                       │
│ 3. Backend creates    │
│    PENDING_APPROVAL   │
│    (or PENDING_DOC    │
│     if doc required)  │
│                       │
│ 4. Publish:           │
│    LeaveRequestSubmit │
│    -tedNotification   │
│                       │
│ 5. App syncs:         │
│    IndexedDB → delete │
│    draft, cache new   │
│    request ID         │
│                       │
└───────────────────────┘

Conflict Detection & Resolution

Scenario: Employee goes offline with 26-day balance. Takes another 5-day leave online while offline (different user on same account). Comes back online. What happens?

┌─ SYNC CONFLICT ───────────────────────┐
│ User's cached leave request:          │
│ { start: 20-Mar, end: 24-Mar,         │
│   version: 5 }                        │
│                                       │
│ Backend check:                        │
│ 1. Fetch leaveEmployeeProfile         │
│    version = 6 (changed while offline)│
│                                       │
│ 2. Balance was 26 days                │
│    5 days approved on 19-Mar          │
│    New balance: 21 days               │
│                                       │
│ 3. Employee's request = 5 days        │
│    Still fits!                        │
│    (21 >= 5)                          │
│                                       │
│ 4. Approve anyway? NO                 │
│    → Return 409 CONFLICT with:        │
│    { new_balance: 21,                 │
│      new_version: 6,                  │
│      conflicting_requests: [...] }    │
│                                       │
│ 5. Frontend shows overlay:            │
│    "Votre solde a changé."            │
│    Old: 26 jours → New: 21 jours      │
│    Requests approuvées: 20-24-Mar     │
│    Réessayer? Annuler?                │
│                                       │
└───────────────────────────────────────┘

Resolution strategy: - Automatic (optimistic): If new balance still covers the request, approve (rare, timing) - User-resolved: Show conflict overlay, user decides to retry or cancel - Never silent failure: Always inform user of conflicts

Service Worker Scope

  • Caches GET endpoints for /absence/* (list, balance, calendar)
  • Queues POST/PATCH with submitted_offline=true flag
  • Syncs on reconnect (exponential backoff for retries)
  • Never caches POST bodies (prevent double-submit if user force-closes)

6. Sequence Diagrams

6.1 Leave Request Submission (Sync Path)

sequenceDiagram
    participant E as Employee
    participant UI as Frontend<br/>(React)
    participant API as Backend API<br/>(/api/v1/absence)
    participant DB as Tenant DB
    participant EMP_CACHE as CachedEmployeeData<br/>(event-synced)
    participant AUD as AuditService
    participant NOTIF as NotificationService
    participant PUB as Event<br/>Publisher

    E->>UI: Fill leave request form<br/>(annual, 20-24 Mar)
    UI->>UI: Compute days using<br/>local holiday cache<br/>(5 jours calendaires)
    UI->>UI: Calc approval route:<br/>show "À approuver par:<br/>Chef Dept A"
    E->>UI: Click "Soumettre"

    UI->>API: POST /api/v1/absence/leave-requests<br/>{ leaveType: ANNUAL,<br/>startDate: 2026-03-20,<br/>endDate: 2026-03-24,<br/>numberOfDays: 5 }

    API->>API: Validate tenant & auth<br/>(TenantContext, JWT)

    API->>EMP_CACHE: SELECT * FROM cached_employee<br/>WHERE employee_id = :id<br/>AND tenant_id = :tid
    EMP_CACHE-->>API: EmployeeData { hireDate,<br/>orgUnitId, managerId }

    API->>DB: SELECT * FROM leave_employee_profile<br/>WHERE employee_id = :id<br/>AND tenant_id = :tid<br/>FOR UPDATE (lock row)
    DB-->>API: LeaveEmployeeProfile { balance: 26.4 }

    API->>API: Validate:<br/>- Hire date + 12m <= now? YES<br/>- Balance 26.4 >= 5? YES<br/>- Overlap check? NO<br/>✓ All pass

    API->>DB: INSERT INTO leave_request (...)<br/>status = PENDING_APPROVAL,<br/>number_of_days_ouvrables = 4 (5 / 1.25)<br/>approver_id = manager_from_cached_employee
    DB-->>API: leave_request_id = :uuid

    API->>DB: INSERT INTO leave_balance_ledger<br/>{ transaction_type: RESERVE (pending debit),<br/>amount: 0 }<br/>(balance not deducted yet, only reserved)

    API->>PUB: publishEvent(AuditEvent)<br/>{ action: LEAVE_REQUEST_SUBMITTED,<br/>entityType: LEAVE_REQUEST,<br/>entityId: leave_request_id,<br/>userId: employee_id,<br/>payload: { status: PENDING_APPROVAL } }
    PUB-->>AUD: (async to audit module consumer)

    API->>NOTIF: publishNotification()<br/>{ type: LEAVE_REQUEST_SUBMITTED,<br/>recipientId: manager_id,<br/>title: "Demande de congé en attente",<br/>body: "Employé A demande 5 jours..." }

    API->>PUB: publishEvent(LeaveRequestSubmittedNotification)
    PUB-->>NOTIF: (async)

    API-->>UI: 201 Created<br/>{ id: leave_request_id,<br/>status: PENDING_APPROVAL,<br/>createdAt: now }

    UI->>UI: Show success toast<br/>"Demande envoyée à<br/>Chef Dept A"
    UI->>UI: Refresh requests list
    UI-->>E: Request appears in<br/>"En attente" section

6.2 Leave Approval + Balance Deduction

sequenceDiagram
    participant MGR as Manager
    participant UI as Frontend
    participant API as Backend API
    participant DB as Tenant DB
    participant LOCK as SELECT FOR UPDATE
    participant LEDGER as LeaveBalanceLedger
    participant EVENT as Event Publisher
    participant PAYROLL as Payroll Module<br/>(async listener)
    participant NOTIF as Notification

    MGR->>UI: Open pending approvals
    UI->>API: GET /api/v1/absence/leave-requests?status=PENDING_APPROVAL
    API-->>UI: [{ id, employeeId, dates,<br/>days, managerApprovalRoute }]

    MGR->>UI: Click approve on request #1
    UI->>API: PATCH /api/v1/absence/leave-requests/:id/approve

    API->>API: Verify MGR is authorized<br/>(MANAGER perm check)

    API->>DB: SELECT * FROM leave_request<br/>WHERE id = :id AND tenant_id = :tid<br/>(fetch current state)
    DB-->>API: LeaveRequest { status: PENDING_APPROVAL,<br/>numberOfDaysOuvrables: 4 }

    API->>DB: SELECT * FROM leave_employee_profile<br/>WHERE employee_id = :empId<br/>AND tenant_id = :tid<br/>FOR UPDATE (PESSIMISTIC LOCK)
    LOCK-->>DB: ✓ Row locked
    DB-->>API: LeaveEmployeeProfile { balance: 26.4,<br/>version: 5 }

    API->>API: Final validation<br/>- Balance 26.4 >= 4? YES ✓<br/>- Status still PENDING_APPROVAL? YES ✓<br/>- No concurrent cancel? YES ✓

    API->>DB: INSERT INTO leave_balance_ledger<br/>{ transaction_type: LEAVE_DEBIT,<br/>amount: -4 (negative, debit),<br/>amount_calendaires: -5,<br/>reference_id: leave_request_id,<br/>created_at: now,<br/>created_by: manager_id }
    DB-->>API: ✓ Ledger entry created

    API->>DB: UPDATE leave_employee_profile<br/>SET annual_balance_ouvrables = 22.4,<br/>version = 6,<br/>updated_at = now<br/>WHERE employee_id = :empId<br/>AND tenant_id = :tid<br/>AND version = 5
    DB-->>API: ✓ Rows affected = 1<br/>(version match, balance updated)

    API->>DB: UPDATE leave_request<br/>SET status = APPROVED,<br/>approver_id = :managerId,<br/>updated_at = now<br/>WHERE id = :id AND tenant_id = :tid
    DB-->>API: ✓ Updated

    API->>EVENT: publishEvent(LeaveApproved)<br/>{ tenantId, employeeId, leaveRequestId,<br/>leaveType: ANNUAL,<br/>startDate, endDate,<br/>numberOfDays: 5 (calendaires),<br/>numberOfDaysOuvrables: 4,<br/>isPaid: true,<br/>payRate: 100,<br/>sickLeavePhase: null,<br/>halfDayStart: false,<br/>halfDayEnd: false }

    PAYROLL->>PAYROLL: @EventListener onLeaveApproved()<br/>{ Register approved leave<br/>for next payroll run }

    API->>NOTIF: publishNotification()<br/>{ type: LEAVE_APPROVED,<br/>recipientId: employee_id,<br/>title: "Congé approuvé",<br/>body: "Votre demande du 20-24 mar..." }

    API-->>UI: 200 OK<br/>{ status: APPROVED,<br/>approver_id: manager_id,<br/>approved_at: now }

    UI->>UI: Remove from pending list
    UI->>UI: Show success toast<br/>"Approuvé par Chef Dept A"
    UI-->>MGR: Request now in "Approuvés"

    NOTIF-->>EMP: In-app notification<br/>"Congé approuvé ✓"

6.3 Maternity Leave Auto-Creation

sequenceDiagram
    participant HR as HR Manager
    participant UI as Frontend
    participant API as Backend API
    participant DB as Tenant DB
    participant OVERLAP as Overlap Logic
    participant LEDGER as LeaveBalanceLedger
    participant EVENT as Event Publisher
    participant PAYROLL as Payroll (async)

    HR->>UI: Navigate to<br/>"Maternité - Nouvelle"
    UI->>API: GET /api/v1/absence/employees?roles=EMPLOYEE&search=...
    API-->>UI: [employees...]

    HR->>UI: Select employee,<br/>enter expected delivery<br/>date: 15-May-2026

    HR->>UI: Click "Créer congé maternité"
    UI->>API: POST /api/v1/absence/maternity-declarations<br/>{ employeeId: :uuid,<br/>expectedDeliveryDate: 2026-05-15 }

    API->>API: Validate:<br/>- Employee exists & active? YES<br/>- Already has active maternity? NO<br/>✓ Proceed

    API->>API: Compute maternity dates<br/>start = 2026-05-15 - 42d = 2026-04-03<br/>end = 2026-05-15 + 56d = 2026-07-10<br/>(6 weeks before + 8 weeks after)

    API->>DB: SELECT * FROM leave_request<br/>WHERE employee_id = :empId<br/>AND status = APPROVED<br/>AND (start_date, end_date) OVERLAP<br/>(2026-04-03, 2026-07-10)
    DB-->>API: [existing_annual_request<br/>{ id: :uuid, start: 2026-04-15,<br/>end: 2026-04-20, days: 4 }]

    API->>OVERLAP: Resolve overlap<br/>{ existingRequest: ANNUAL,<br/>newRequest: MATERNITY }
    OVERLAP-->>API: Action: SPLIT_AND_CREDIT<br/>{ creditBackDays: 4 (calendaires) }

    API->>DB: Begin transaction

    API->>DB: INSERT INTO leave_request<br/>{ id: :uuid_new,<br/>leave_type: MATERNITY,<br/>start: 2026-04-03,<br/>end: 2026-07-10,<br/>status: AUTO_APPROVED,<br/>created_by: HR_id }
    DB-->>API: ✓ Maternity leave created

    API->>DB: INSERT INTO maternity_declaration<br/>{ leave_request_id: :uuid_new,<br/>employee_id: :empId,<br/>expected_delivery_date: 2026-05-15 }

    API->>DB: UPDATE leave_request<br/>SET status = CANCELLED_BY_HR,<br/>cancellation_reason = "Auto-cancelled due to maternity overlap",<br/>updated_at = now<br/>WHERE id = :uuid_old (annual req)

    API->>DB: SELECT * FROM leave_employee_profile<br/>WHERE employee_id = :empId<br/>AND tenant_id = :tid<br/>FOR UPDATE
    DB-->>API: { balance: 22.4 }

    API->>LEDGER: Insert CANCEL_CREDIT<br/>{ amount: +4 ouvrables<br/>(= 5 calendaires),<br/>reference_id: :uuid_old,<br/>reason: "Annual leave overlapped with maternity" }

    API->>DB: UPDATE leave_employee_profile<br/>SET annual_balance_ouvrables = 26.4<br/>(22.4 + 4),<br/>version = 7,<br/>updated_at = now

    API->>EVENT: publishEvent(LeaveApproved)<br/>{ leaveType: MATERNITY,<br/>startDate: 2026-04-03,<br/>endDate: 2026-07-10,<br/>numberOfDays: 42 (calendar),<br/>isPaid: true,<br/>payRate: 100,<br/>sickLeavePhase: null }

    PAYROLL->>PAYROLL: Register maternity<br/>for payroll (100% salary,<br/>eventual CNPS reimbursement claim)

    API->>DB: Commit transaction

    API-->>UI: 201 Created<br/>{ maternity_declaration_id,<br/>leave_request_id,<br/>expectedDeliveryDate,<br/>effectiveDateRange: [2026-04-03, 2026-07-10],<br/>creditBackNotification: "4 jours de congé ont été retournés" }

    UI->>UI: Show success<br/>"Congé maternité créé"
    UI-->>HR: Redirect to maternity detail view

7. Security Architecture

7.1 RBAC Permissions

Permission EMPLOYEE MANAGER HR_MANAGER DG
View own balance
Submit own leave ✅ (on behalf) ✅ (on behalf)
Cancel own pending
Cancel own approved (before start)
View team calendar (presence only) ✅ (no names) ✅ (direct reports) ✅ (all) ✅ (all)
Approve/reject (direct reports) ✅ (any) ✅ (any)
Cancel others' leave ✅ (with reason) ✅ (with reason)
View medical documents
Create maternity declaration
Adjust balance (manual) ✅ (with reason) ✅ (with reason)
Configure leave types
Configure holidays
Configure carry-over policy
View HR dashboard ✅ (team-level) ✅ (full) ✅ (full)

7.2 Data Access Rules

FR-ABS-140: MANAGER can see leave type and dates but NOT supporting documents (medical certs, etc.). Only HR_MANAGER and DG.

FR-ABS-141: EMPLOYEE can only see their own leave data. Team calendar shows only "Absent" status, no names or leave types (except for managers viewing direct reports, and HR/DG).

FR-ABS-142: All balance adjustments (MANUAL_ADJUSTMENT ledger entries) require a reason and produce immutable audit trail.

FR-ABS-143: Audit trail captures: - Every leave request creation, status change, cancellation - Every balance adjustment (manual or automatic) - Every configuration change (leave types, holidays, carry-over policy) - Who did what, when, before/after values

7.3 Input Validation

All endpoints validate: - Date ranges: start_date <= end_date, dates not in past (except retroactive flag) - Numbers: balance amounts >= 0, durations as positive integers - Enum values: leave type must exist in leave_type_config - Foreign keys: employee_id, org_unit_id exist and belong to tenant - Text fields: max lengths, no XSS (sanitized on input) - Files: supporting documents validated (MIME type, size < 10 MB)


8. Performance Considerations

8.1 Query Optimization

Key paths: - Leave request submission: < 500 ms - Fetch cached employee (indexed on tenant_id, employee_id) - Validate balance (< 50 ms lock on leave_employee_profile) - Insert leave_request + ledger entry - Total: < 200 ms typical

  • Balance query: < 100 ms
  • Cached balance on leave_employee_profile (single row fetch)
  • If not cached, SUM(ledger) with index on (tenant_id, employee_id, leave_type, created_at)

  • Team calendar: < 1 sec for 50-person team

  • Single query: SELECT DISTINCT employee_id, start_date, end_date FROM leave_request WHERE tenant_id = :tid AND status = APPROVED AND month(start_date) = :month
  • Index on (tenant_id, status, start_date)

Indexing strategy:

-- leave_request
CREATE INDEX idx_leave_request_tenant_status
  ON leave_request(tenant_id, status, created_at DESC);
CREATE INDEX idx_leave_request_employee_dates
  ON leave_request(tenant_id, employee_id, start_date, end_date);

-- leave_employee_profile
CREATE UNIQUE INDEX idx_leave_profile_employee
  ON leave_employee_profile(tenant_id, employee_id);

-- leave_balance_ledger
CREATE INDEX idx_ledger_employee_type
  ON leave_balance_ledger(tenant_id, employee_id, leave_type, created_at);

-- public_holiday
CREATE INDEX idx_holiday_country_year
  ON public_holiday(tenant_id, country_code, year);

-- cached_employee_data
CREATE INDEX idx_cached_emp_id
  ON cached_employee_data(tenant_id, employee_id);

8.2 Caching Strategy

What's cached: - Tenant config (leave types, exceptional durations, settings): Redis, expires daily - Public holidays: Caffeine in-memory (rarely changes), invalidate on config update - Cached employee data: Tenant DB table (event-synced from organization module) - Leave balances: Cached field on leave_employee_profile, updated after each transaction

Cache invalidation: - Employee updated → EmployeeUpdated → refresh cached_employee_data row - Holiday changed → PublicHolidayChanged → invalidate Caffeine cache - Balance mutation → immediately update leave_employee_profile.cached_balance within same transaction

8.3 Connection Pooling

HikariCP configuration per DB profile: - Tenant DB: maximumPoolSize = 10, minimumIdle = 2, connectionTimeout = 10s - Platform DB (for reading config): maximumPoolSize = 5, minimumIdle = 1 - BYO-DB: Separate pool per external tenant connection, validated on borrow


9. Migration Strategy

All 4 tenant profiles supported. Migrations run on every profile independently.

9.1 Flyway Migrations

db/absence/
├── V001__create_leave_request_table.sql
├── V002__create_leave_employee_profile_table.sql
├── V003__create_leave_balance_ledger_table.sql
├── V004__create_maternity_declaration_table.sql
├── V005__create_exceptional_leave_config_table.sql
├── V006__create_public_holiday_table.sql
├── V007__create_cached_employee_data_table.sql
├── V008__create_indexes.sql
├── V009__create_leave_type_config_table.sql
├── V010__create_absence_settings_table.sql
├── V011__create_sick_leave_indemnization_rule_table.sql
└── U001__undo_all.sql (test only)

Key migrations:

V001__create_leave_request_table.sql:

CREATE TABLE leave_request (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    employee_id UUID NOT NULL,
    leave_type_code VARCHAR(20) NOT NULL,
    event_type_code VARCHAR(30),
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    half_day_start BOOLEAN NOT NULL DEFAULT FALSE,
    half_day_end BOOLEAN NOT NULL DEFAULT FALSE,
    number_of_days DECIMAL(5, 2) NOT NULL,
    number_of_days_ouvrables DECIMAL(5, 2) NOT NULL,
    status VARCHAR(30) NOT NULL DEFAULT 'DRAFT',
    reason TEXT,
    rejection_reason TEXT,
    cancellation_reason TEXT,
    approver_id UUID,
    is_retroactive BOOLEAN NOT NULL DEFAULT FALSE,
    supporting_document_id UUID,
    submitted_offline BOOLEAN NOT NULL DEFAULT FALSE,
    version INT NOT NULL DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    created_by UUID NOT NULL,
    -- tenant_id enforced by TenantContext (no FK: tenant table is in Platform DB)
    CHECK (start_date <= end_date)
);
-- Indexes created separately in V008

V003__create_leave_balance_ledger_table.sql:

CREATE TABLE leave_balance_ledger (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    employee_id UUID NOT NULL,
    leave_type VARCHAR(50) NOT NULL, -- ANNUAL, SICK_COUNTER, EXCEPTIONAL_COUNTER
    transaction_type VARCHAR(30) NOT NULL,
    amount DECIMAL(8, 2) NOT NULL,
    amount_calendaires DECIMAL(8, 2) GENERATED ALWAYS AS (amount * CAST(1.25 AS DECIMAL(3, 2))) STORED,
    amount_ouvres DECIMAL(8, 2) GENERATED ALWAYS AS (amount * CAST(5.0 / 6.0 AS DECIMAL(5, 4))) STORED,
    reference_id UUID, -- FK to leave_request or adjustment
    reason TEXT,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    created_by UUID NOT NULL,
    -- tenant_id enforced by TenantContext (no FK: tenant table is in Platform DB)
    CONSTRAINT immutable_ledger_check CHECK (amount != 0) -- No zero-value entries
);
-- Append-only, no UPDATE or DELETE

9.2 Seeding at Tenant Creation

When a new CI tenant is provisioned:

  1. Leave types: Seeded with defaults
  2. ANNUAL (paid, requires approval, accrual-based)
  3. SICK (paid via CCI table, requires approval + document)
  4. MATERNITY (paid, auto-approved, no approval)
  5. PATERNITY (paid, requires approval + birth cert)
  6. EXCEPTIONAL (paid, requires approval + justificatif)
  7. UNPAID (not paid, requires approval)
  8. UNJUSTIFIED (not paid, HR records)

  9. Exceptional leave config: CCI minimums

  10. MARRIAGE_SELF = 4 days
  11. DEATH_SPOUSE = 5 days
  12. etc. (full list per PRD section B.1)

  13. Public holidays: Fixed + platform-default variable

  14. Fixed: Jan 1, May 1, Aug 7, etc.
  15. Variable: EID_FITR, EID_ADHA, MAWLID, LAYLAT_QADR (platform admin sets annually)

  16. Tenant settings:

  17. reference_period_type = CALENDAR_YEAR (default)
  18. carry_over_policy = STRICT_EXPIRY (default)
  19. deduction_unit = JOURS_CALENDAIRES (default for CI)

10. Integration Points

10.1 Events Published (Async)

Event Published When Payload Consumers
LeaveApproved Leave request approved (or auto-approved for maternity) tenantId, employeeId, leaveRequestId, leaveType, startDate, endDate, numberOfDays (calendaires), numberOfDaysOuvrables, isPaid, payRate, sickLeavePhase, halfDayStart, halfDayEnd Payroll, Notification
LeaveCancelled Approved leave cancelled employeeId, leaveRequestId, leaveType, startDate, endDate, numberOfDaysCredited Payroll, Notification
LeaveBalanceSettlement Employee terminated, remaining balance computed employeeId, tenantId, remainingBalance, lastAccrualDate, terminationDate Payroll
LeaveRequestSubmittedNotification New request submitted employeeId, leaveRequestId, approverId, leaveType, dates Notification
LeaveRejectedNotification Request rejected employeeId, leaveRequestId, rejectionReason Notification
MaternityLeaveCreated Maternity declaration saved employeeId, expectedDeliveryDate, leaveRequestId, matpushDates Notification, Employee Mgmt (protection from dismissal)

10.2 Events Consumed (Async Listeners)

Event Emitted By Action in ABSMGT
EmployeeCreated Control Plane (organization) Insert row in cached_employee_data, initialize leave_employee_profile, schedule first accrual
EmployeeUpdated Control Plane (organization) Update cached_employee_data (name, category, org unit, hire date)
EmployeeStatusChanged Control Plane If TERMINATED → cancel pending requests, compute settlement, publish LeaveBalanceSettlement
ManagerChanged Control Plane (organization) Payload: tenantId, employeeId, oldManagerId, newManagerId. Action: Update approval routing for all pending requests from affected employees
EmployeeReassigned Control Plane Update org unit, recompute approver for pending requests
ModuleActivated Control Plane (tenant) If absence module activated, seed leave types + config
TenantProvisioned Control Plane (tenant) Seed leave types, exceptional config, public holidays for CI

10.3 External APIs (Synchronous)

None in MVP. All employee/org data comes via event-driven read model (cached_employee_data table).

Note: The PRD mentions REST API calls to /api/v1/cp/employees/{id} and /api/v1/cp/org-units/{id}/manager, but with event-driven caching strategy (ADR-14 revised), these are not needed. The organization module publishes events, absence subscribes, and maintains its own cache.


11. Vertical Slice Decomposition

ABSMGT is decomposed into 19 vertical slices (thin end-to-end stories) organized into 4 dependency tiers. Each slice is independently testable and shippable.

Concurrency Tiers

Tier 0: Foundation (Sequential — must ship first)

Slice AB-001: Dev Environment + Test Infrastructure - Scope: Docker Compose setup, TestContainers config, mock event publisher - Files: - docker-compose.yml (PostgreSQL + Redis) - test/testcontainers/AbsenceTestContainersConfiguration.java - test/testcontainers/AbsenceTestFixtures.java (sample data generators) - test/fixtures/TestLeaveRequestFixture.java - Complexity: S - Dependencies: None - Acceptance Criteria: - ✅ docker-compose up -d starts Postgres + Redis - ✅ Tests run via TestContainers - ✅ Sample leave requests can be created and queried in tests

Slice AB-002: Data Model + Migrations - Scope: Flyway migrations, Spring Data JDBC entities, schema creation - Files: - db/absence/V001__create_leave_request_table.sql - db/absence/V002__create_leave_employee_profile_table.sql - db/absence/V003__create_leave_balance_ledger_table.sql - db/absence/V004__create_maternity_declaration_table.sql - db/absence/V005__create_exceptional_leave_config_table.sql - db/absence/V006__create_public_holiday_table.sql - db/absence/V007__create_cached_employee_data_table.sql - db/absence/V008__create_indexes.sql - db/absence/V009__create_leave_type_config_table.sql - db/absence/V010__create_absence_settings_table.sql - db/absence/V011__create_sick_leave_indemnization_rule_table.sql - src/main/java/com/altarys/.../absence/domain/LeaveRequest.java - src/main/java/com/altarys/.../absence/domain/LeaveEmployeeProfile.java - src/main/java/com/altarys/.../absence/domain/LeaveBalanceLedger.java - src/main/java/com/altarys/.../absence/repository/LeaveRequestRepository.java (Spring Data JDBC) - src/main/java/com/altarys/.../absence/repository/LeaveEmployeeProfileRepository.java - src/main/java/com/altarys/.../absence/repository/LeaveBalanceLedgerRepository.java - Complexity: M - Dependencies: None - Acceptance Criteria: - ✅ All tables created, indexes applied - ✅ Spring Data JDBC repositories auto-discover entities - ✅ Schema migrations reversible and tested

Slice AB-003: Event Listeners Setup (Employee Cache Sync) - Scope: Listen to EmployeeCreated, EmployeeUpdated, ManagerChanged; cache in local table - Files: - src/main/java/com/altarys/.../absence/domain/CachedEmployeeData.java - src/main/java/com/altarys/.../absence/repository/CachedEmployeeDataRepository.java - src/main/java/com/altarys/.../absence/event/EmployeeEventListener.java - src/test/.../absence/EmployeeEventListenerTest.java - Complexity: S - Dependencies: AB-002 (tables exist) - Acceptance Criteria: - ✅ EmployeeCreated triggers cache insert - ✅ EmployeeUpdated updates cache - ✅ ManagerChanged updates manager_id in cache - ✅ Listener is idempotent (duplicate events OK)

Slice AB-004: Scheduled Accrual Job - Scope: Monthly cron job, create MONTHLY_ACCRUAL ledger entries for all active employees - Files: - src/main/java/com/altarys/.../absence/service/AccrualScheduler.java - src/main/java/com/altarys/.../absence/service/AccrualService.java - src/test/.../absence/AccrualSchedulerTest.java - Complexity: M - Dependencies: AB-002 (ledger table), AB-003 (employee cache) - Acceptance Criteria: - ✅ Cron job runs monthly (mock in test) - ✅ Creates one MONTHLY_ACCRUAL entry per active employee - ✅ Idempotent (last_accrual_date prevents double-processing) - ✅ Handles catch-up for missed months - ✅ Runs in TenantContext for each tenant - ✅ Skips accrual for employees on UNPAID leave, UNJUSTIFIED absence, or suspension (FR-ABS-017)

Tier 1: Core Workflows (Can run in parallel after Tier 0)

Slice AB-005: Leave Request Creation (Sync Path) - Scope: POST /api/v1/absence/leave-requests, create request in PENDING_APPROVAL or PENDING_DOCUMENT status - Files: - src/main/java/com/altarys/.../absence/api/LeaveRequestController.java (POST endpoint) - src/main/java/com/altarys/.../absence/api/CreateLeaveRequestRequest.java - src/main/java/com/altarys/.../absence/api/LeaveRequestResponse.java - src/main/java/com/altarys/.../absence/service/LeaveRequestService.java (createRequest method) - src/main/java/com/altarys/.../absence/service/LeaveValidationService.java (balance check, conflicts, eligibility) - src/main/java/com/altarys/.../absence/service/ApproverResolutionService.java (org-unit-based routing) - src/test/.../absence/LeaveRequestCreationTest.java - Complexity: M - Dependencies: AB-002, AB-003 (employee cache for validation), AB-004 (accrual so balances are accurate) - Acceptance Criteria: - ✅ POST /api/v1/absence/leave-requests accepts valid request - ✅ Validates hire date eligibility (ANNUAL only after 12 months) - ✅ Validates balance >= days (uses cached balance) - ✅ Resolves approver from org unit (uses cached manager) - ✅ Returns 201 with leave_request_id - ✅ Sets status = PENDING_APPROVAL or PENDING_DOCUMENT

Slice AB-006: Leave Approval + Deduction (Pessimistic Lock) - Scope: PATCH /api/v1/absence/leave-requests/:id/approve, deduct balance via ledger - Files: - src/main/java/com/altarys/.../absence/api/LeaveRequestController.java (PATCH approve) - src/main/java/com/altarys/.../absence/service/LeaveRequestService.java (approveRequest method) - src/main/java/com/altarys/.../absence/service/BalanceMutationService.java (pessimistic lock, ledger insert, cache update) - src/main/java/com/altarys/.../absence/event/LeaveApprovedEventPublisher.java - src/test/.../absence/LeaveApprovalConcurrencyTest.java (concurrent approval scenarios) - Complexity: M - Dependencies: AB-002, AB-005 (request exists) - Acceptance Criteria: - ✅ PATCH approve locks row (SELECT FOR UPDATE) - ✅ Inserts LEAVE_DEBIT ledger entry - ✅ Updates cached balance atomically (version incremented) - ✅ Publishes LeaveApproved - ✅ Concurrent approvals don't double-deduct (lock prevents) - ✅ Returns 200 with updated request

Slice AB-007: Leave Cancellation (By Employee) - Scope: PATCH /api/v1/absence/leave-requests/:id/cancel, credit balance back if APPROVED - Files: - src/main/java/com/altarys/.../absence/api/LeaveRequestController.java (PATCH cancel) - src/main/java/com/altarys/.../absence/service/LeaveRequestService.java (cancelRequest method) - src/test/.../absence/LeaveCancellationTest.java - Complexity: S - Dependencies: AB-002, AB-005 - Acceptance Criteria: - ✅ Employee can cancel PENDING_APPROVAL or PENDING_DOCUMENT anytime - ✅ Employee can cancel APPROVED only before start_date - ✅ Cancellation credits balance back (CANCEL_CREDIT ledger) - ✅ Publishes LeaveCancelled - ✅ Cannot cancel IN_PROGRESS or COMPLETED

Slice AB-008: Leave Rejection (By Manager/HR) - Scope: PATCH /api/v1/absence/leave-requests/:id/reject, with mandatory reason - Files: - src/main/java/com/altarys/.../absence/api/LeaveRequestController.java (PATCH reject) - src/main/java/com/altarys/.../absence/service/LeaveRequestService.java (rejectRequest method) - src/test/.../absence/LeaveRejectionTest.java - Complexity: S - Dependencies: AB-002, AB-005 - Acceptance Criteria: - ✅ Manager/HR can reject PENDING_APPROVAL requests - ✅ Requires rejection reason - ✅ Publishes rejection notification - ✅ No balance impact (was never deducted)

Slice AB-009: Maternity Leave Auto-Creation - Scope: POST /api/v1/absence/maternity-declarations, auto-create 14-week leave, handle overlaps - Files: - src/main/java/com/altarys/.../absence/api/MaternityController.java - src/main/java/com/altarys/.../absence/api/CreateMaternityDeclarationRequest.java - src/main/java/com/altarys/.../absence/service/MaternityService.java (createDeclaration, auto-approve, overlap resolution) - src/main/java/com/altarys/.../absence/domain/MaternityDeclaration.java - src/test/.../absence/MaternityLeaveTest.java (overlap scenarios, credit-back) - Complexity: M - Dependencies: AB-002, AB-006 (balance mutation) - Acceptance Criteria: - ✅ Creates leave with status = AUTO_APPROVED - ✅ Computes 14-week period (6 before + 8 after expected delivery) - ✅ Detects overlapping ANNUAL leaves - ✅ Credits overlapping days back via ledger - ✅ Publishes LeaveApproved (100% paid, maternity flag)

Slice AB-010: View Leave Balance - Scope: GET /api/v1/absence/my-balance, return cached balance in all 3 units - Files: - src/main/java/com/altarys/.../absence/api/BalanceController.java - src/main/java/com/altarys/.../absence/api/LeaveBalanceResponse.java - src/main/java/com/altarys/.../absence/service/BalanceQueryService.java (reads cached balance, computes conversions) - src/test/.../absence/BalanceQueryTest.java - Complexity: S - Dependencies: AB-002, AB-004 (balance computed and cached) - Acceptance Criteria: - ✅ GET /my-balance returns { annual, exceptional, sick } - ✅ Each breakdown shows ouvrables, calendaires, ouvrés - ✅ Includes ledger history (optional query param) - ✅ < 100 ms response time

Slice AB-011: View Leave Request List - Scope: GET /api/v1/absence/leave-requests, cursor-based pagination, role-aware filtering - Files: - src/main/java/com/altarys/.../absence/api/LeaveRequestController.java (GET list) - src/main/java/com/altarys/.../absence/api/LeaveRequestListResponse.java - src/main/java/com/altarys/.../absence/service/LeaveRequestQueryService.java (cursor pagination, RBAC filtering) - src/test/.../absence/LeaveRequestListTest.java - Complexity: S - Dependencies: AB-002, AB-005 - Acceptance Criteria: - ✅ Employees see only their own requests - ✅ Managers see direct reports + own - ✅ HR/DG see all - ✅ Cursor pagination works (no offset drift) - ✅ Filtering by status, date range

Tier 2: Advanced Workflows (After Tier 1 merges)

Slice AB-012: Sick Leave Indemnization (CCI Table Lookup) - Scope: When sick leave is approved, determine pay phase (FULL/HALF/ZERO) based on 12-month rolling count + category - Files: - src/main/java/com/altarys/.../absence/service/SickLeaveIndemnizationService.java - src/main/java/com/altarys/.../absence/domain/SickLeavePhase.java - src/main/java/com/altarys/.../absence/repository/SickLeaveIndemnizationRepository.java (CCI table lookup) - src/test/.../absence/SickLeaveIndemnizationTest.java - Complexity: M - Dependencies: AB-002 (ledger for rolling 12m count), AB-006 (approval) - Acceptance Criteria: - ✅ Fetches CCI table by employee category + seniority - ✅ Counts sick days in rolling 12 months - ✅ Determines phase (FULL/HALF/ZERO) - ✅ Includes phase in LeaveApproved - ✅ Payroll uses phase to calculate deduction

Slice AB-013: Team Calendar View - Scope: GET /api/v1/absence/team-calendar, show absences by day, role-aware detail level - Files: - src/main/java/com/altarys/.../absence/api/TeamCalendarController.java - src/main/java/com/altarys/.../absence/api/TeamCalendarResponse.java - src/main/java/com/altarys/.../absence/service/TeamCalendarService.java (absence aggregation) - src/test/.../absence/TeamCalendarTest.java - Complexity: S - Dependencies: AB-002 (leave request data) - Acceptance Criteria: - ✅ Shows all APPROVED leaves for team - ✅ Employees see "Absent" only (no details) - ✅ Managers see direct reports' absences with names - ✅ HR/DG see full company calendar - ✅ < 1 sec for 50-person team

Slice AB-014: HR Dashboard (Summary) - Scope: GET /api/v1/absence/dashboard/summary, pending count, absence rate, balance summary - Files: - src/main/java/com/altarys/.../absence/api/DashboardController.java - src/main/java/com/altarys/.../absence/api/DashboardSummaryResponse.java - src/main/java/com/altarys/.../absence/service/DashboardService.java (on-the-fly aggregations) - src/test/.../absence/DashboardTest.java - Complexity: S - Dependencies: AB-002, AB-005, AB-010 - Acceptance Criteria: - ✅ Returns pending approval count - ✅ Balance summary by employee - ✅ Absence rate % (days off / working days) - ✅ Upcoming leaves this week

Slice AB-015: Manual Balance Adjustment (HR) - Scope: PATCH /api/v1/absence/employees/:id/adjust-balance, with mandatory reason - Files: - src/main/java/com/altarys/.../absence/api/BalanceController.java (PATCH adjust) - src/main/java/com/altarys/.../absence/service/BalanceMutationService.java (MANUAL_ADJUSTMENT ledger) - src/test/.../absence/ManualAdjustmentTest.java - Complexity: S - Dependencies: AB-002, AB-006 (uses same lock pattern) - Acceptance Criteria: - ✅ HR/DG can adjust any employee's balance - ✅ Requires reason (audited) - ✅ Creates MANUAL_ADJUSTMENT ledger entry - ✅ Updates cached balance - ✅ Publishes AuditEntry event

Slice AB-016: Employee Termination Handler (Event Listener) - Scope: Listen to EmployeeTerminated, cancel pending requests, compute settlement - Files: - src/main/java/com/altarys/.../absence/event/EmployeeTerminationListener.java - src/main/java/com/altarys/.../absence/service/TerminationSettlementService.java - src/main/java/com/altarys/.../absence/event/LeaveBalanceSettlementEventPublisher.java - src/test/.../absence/EmployeeTerminationTest.java - Complexity: M - Dependencies: AB-002, AB-005, AB-010 - Acceptance Criteria: - ✅ Listener receives EmployeeTerminated - ✅ Cancels all PENDING requests - ✅ Preserves APPROVED within notice period - ✅ Computes final balance - ✅ Publishes LeaveBalanceSettlement to Payroll

Tier 3: Configuration & Public Holidays (After Tier 2)

Slice AB-017: Absence Settings Configuration (HR) - Scope: GET/PATCH /api/v1/absence/config/settings — reference period, carry-over policy, deduction unit - Files: - src/main/java/com/altarys/.../absence/api/ConfigController.java (settings endpoints) - src/main/java/com/altarys/.../absence/api/AbsenceSettingsResponse.java - src/main/java/com/altarys/.../absence/domain/AbsenceSettings.java - src/main/java/com/altarys/.../absence/repository/AbsenceSettingsRepository.java - src/main/java/com/altarys/.../absence/service/AbsenceSettingsService.java - src/test/.../absence/AbsenceSettingsTest.java - Complexity: S - Dependencies: AB-002 - Acceptance Criteria: - ✅ GET returns current settings (reference period, carry-over, deduction unit) - ✅ PATCH updates settings (HR/DG only) - ✅ Seeded with CI defaults at module activation - ✅ Changing deduction unit does not alter stored ouvrables values

Slice AB-018: Leave Type Configuration (HR) - Scope: GET/PATCH /api/v1/absence/config/leave-types, tenant can customize. Uses LeaveTypeConfig entity (defined in ERD section 2.1, migrated in V009). - Files: - src/main/java/com/altarys/.../absence/api/ConfigController.java - src/main/java/com/altarys/.../absence/api/LeaveTypeConfigResponse.java - src/main/java/com/altarys/.../absence/domain/LeaveTypeConfig.java - src/main/java/com/altarys/.../absence/repository/LeaveTypeConfigRepository.java - src/main/java/com/altarys/.../absence/service/LeaveTypeConfigService.java - src/test/.../absence/LeaveTypeConfigTest.java - Complexity: S - Dependencies: AB-002 - Acceptance Criteria: - ✅ HR/DG can enable/disable leave types - ✅ Cannot modify system types (ANNUAL, SICK, MATERNITY) - ✅ Custom types supported for future - ✅ Config cached in Redis

Slice AB-019: Public Holidays & Exceptions Configuration (HR) - Scope: GET/PATCH /api/v1/absence/config/public-holidays, tenant overrides, exceptional leave durations - Files: - src/main/java/com/altarys/.../absence/api/ConfigController.java (holidays endpoints) - src/main/java/com/altarys/.../absence/api/PublicHolidayResponse.java - src/main/java/com/altarys/.../absence/api/ExceptionalLeaveConfigResponse.java - src/main/java/com/altarys/.../absence/service/HolidayConfigService.java - src/main/java/com/altarys/.../absence/service/ExceptionalLeaveConfigService.java - src/test/.../absence/HolidayConfigTest.java - Complexity: S - Dependencies: AB-002 - Acceptance Criteria: - ✅ Lists fixed + variable holidays per year - ✅ HR/DG can override variable dates - ✅ Exceptional leave durations >= CCI minimum enforced - ✅ Changes invalidate Caffeine cache - ✅ Seeded at tenant creation with CI defaults


12. Technical Risks & Mitigations

# Risk Severity Probability Mitigation
R1 Race condition: two managers approve same request simultaneously High Medium Pessimistic lock (SELECT FOR UPDATE) on leave_employee_profile + version check on leave_request. Lock hold time < 50ms. If second approval arrives after first, ledger insert succeeds but balance update fails (version mismatch). Second approver sees 409 Conflict.
R2 Employee balance goes negative due to concurrent mutations High Low Every mutation locked + validated in same transaction. Balance never updated without checking sufficient funds first.
R3 Maternity overlap not detected, double-paid salary High Low Overlap resolution logic detects MATERNITY conflicts before save. Overlapping ANNUAL leaves explicitly credited back with ledger entry. Test scenario: maternity created while ANNUAL already approved.
R4 Public holiday calculation includes weekends incorrectly Medium Low Public holidays stored as absolute dates. Leave day calculation uses business day logic (Mon-Sat or Mon-Fri per config). Public holiday exclusion is date-based, not weekday-based.
R5 Cached employee data stale during manager change Medium Low ManagerChanged published immediately. Absence listener updates cache synchronously. Pending requests' approver_id updated in listener. Max staleness = event publish latency (~100ms).
R6 Concurrent accrual job processes same employee twice Medium Low Accrual job checks last_accrual_date before inserting. If cron runs twice (misconfiguration), second insert fails on unique constraint. Alerting on constraint violations.
R7 BYO-DB tenant schema migrations fail, blocking deployment High Low All migrations are reversible (Flyway undo). Test migrations against all 4 profiles in CI. BYO-DB connection validated at tenant creation. Migration errors rolled back atomically.
R8 Ledger becomes large, balance queries slow Medium Low Indexed on (tenant_id, employee_id, leave_type, created_at). At MVP scale (20 employees/tenant, ~20 years), < 100 rows per employee. SUM query < 10ms. If > 10,000 tenants, consider materialized view (V2).
R9 Supporting document (medical cert) lost during upload High Low Documents stored in MinIO with versioning (basic). Hard requirement check: manager cannot approve without document (button disabled). If upload fails, retry or cancel request. No "approve anyway" bypass.
R10 Offline sync submits same leave request twice Low Low Frontend service worker queues requests by (employeeId, start_date, end_date, version) hash. On sync success, deletes from queue. If re-sync triggered, detects duplicate and skips. Backend can also detect via unique constraint if needed.
R11 Seniority bonus calculation includes mid-period change Low Low FR-ABS-012: Bonuses applied only at start of reference period. Mid-period seniority changes apply next period. Prevents surprise balance changes mid-month.
R12 Timezone issues with date calculations Medium Low All dates stored as DATE (no time component). Accrual, carry-over, reference period boundaries are date-based. LocalDate arithmetic in Java (no TimeZone confusion). Tenant's timezone stored but not used for balance logic (only for display).

Summary

ABSMGT is architected as a ledger-based absence management system with: - Event-driven employee cache (no compile-time coupling, independent scaling) - Pessimistic locking for balance safety (SELECT FOR UPDATE) - Immutable ledger for auditability (append-only, zero reconciliation) - Offline-first requests with sync conflict detection (version-based) - 19 vertical slices in 4 dependency tiers (Tier 0 foundation, Tier 1-3 can parallelize)

All 4 multi-tenant DB profiles supported. Ready for 100,000+ tenants at MVP scale.


End of ABSMGT Technical Architecture v1.0