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¶
- System Context
- Data Model
- API Design
- Multi-Tenant Strategy
- Offline & Sync Architecture
- Sequence Diagrams
- Security Architecture
- Performance Considerations
- Migration Strategy
- Integration Points
- Vertical Slice Decomposition
- 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:
Attachmentis not a module-owned entity.
supporting_document_idonLeaveRequestreferences 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=trueflag - 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:
- Leave types: Seeded with defaults
- ANNUAL (paid, requires approval, accrual-based)
- SICK (paid via CCI table, requires approval + document)
- MATERNITY (paid, auto-approved, no approval)
- PATERNITY (paid, requires approval + birth cert)
- EXCEPTIONAL (paid, requires approval + justificatif)
- UNPAID (not paid, requires approval)
-
UNJUSTIFIED (not paid, HR records)
-
Exceptional leave config: CCI minimums
- MARRIAGE_SELF = 4 days
- DEATH_SPOUSE = 5 days
-
etc. (full list per PRD section B.1)
-
Public holidays: Fixed + platform-default variable
- Fixed: Jan 1, May 1, Aug 7, etc.
-
Variable: EID_FITR, EID_ADHA, MAWLID, LAYLAT_QADR (platform admin sets annually)
-
Tenant settings:
reference_period_type= CALENDAR_YEAR (default)carry_over_policy= STRICT_EXPIRY (default)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