Aller au contenu

Story AB-017: Sick Leave Indemnization Engine (Backend)

Module: absence-management Slice: VS-AB-012 from architecture Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 17 Depends on: AB-012, AB-014 Can develop concurrently with: AB-016 Merge order: After AB-014 Estimated complexity: M PRD User Stories: US-ABS-005


Objective

Implement the CCI indemnization table lookup engine that determines sick leave pay phases (FULL_PAY, HALF_PAY, ZERO_PAY) based on employee category, seniority, and cumulative sick days in a rolling 12-month window. Reference: CCI indemnization brackets are defined in docs/architecture/absence-management-arch.md (§Sick Leave Indemnization) and docs/research/3_validated/absence-management-legal-validated.md. This phase information is included in the LeaveApproved for Payroll to calculate correct pay.


Backend Scope

Entities & Records

SickLeaveIndemnizationRule (from AB-002):

public class SickLeaveIndemnizationRule {
    private UUID id;
    private UUID tenantId;
    private String category;                // OUVRIER or CADRE
    private int seniorityMinYears;           // inclusive lower bound
    private int seniorityMaxYears;           // exclusive upper bound (Integer.MAX_VALUE for open-ended)
    private int fullPayMonths;
    private int halfPayMonths;
    private Instant createdAt;
    private Instant updatedAt;
}

Enums:

public enum EmployeeCategory {
    OUVRIER,    // Ouvriers & Employes
    CADRE       // Cadres & Agents de Maitrise
}

public enum SickLeavePhase {
    FULL_PAY,   // 100% salary
    HALF_PAY,   // 50% salary
    ZERO_PAY    // 0% — unpaid
}

Response record:

public record SickLeavePhaseResult(
    SickLeavePhase phase,
    int payRatePercent,          // 100, 50, or 0
    int cumulativeSickDays,     // rolling 12-month total
    int fullPayDaysRemaining,
    int halfPayDaysRemaining,
    boolean isZeroPayReached
) {}

Repository Layer

SickLeaveIndemnizationRuleRepository.java: - findByTenantIdAndCategoryAndSeniority(UUID tenantId, String category, int seniorityYears) — returns the matching CCI rule for the employee's category and seniority bracket - findAllByTenantId(UUID tenantId) — returns all rules for a tenant (for seeding verification) - saveAll(List<SickLeaveIndemnizationRule> rules) — bulk insert for tenant creation seeding

Service Layer

SickLeaveIndemnizationService.java:

  • determinePhase(UUID employeeId, UUID tenantId): SickLeavePhaseResult
  • Get employee category from CachedEmployeeData (OUVRIER or CADRE)
  • Calculate seniority in years from hire_date
  • Look up CCI rule from SickLeaveIndemnizationRuleRepository by (category, seniority bracket)
  • Count cumulative sick days in rolling 12 months from LeaveBalanceLedger (entries with type=SICK_LEAVE_DEDUCTION within last 365 days)
  • Determine current phase:
    • If cumulativeDays <= fullPayMonths * 30FULL_PAY (payRate=100)
    • Elif cumulativeDays <= (fullPayMonths + halfPayMonths) * 30HALF_PAY (payRate=50)
    • Else → ZERO_PAY (payRate=0)
  • Update LeaveEmployeeProfile.sick_leave_phase and sick_days_current_rolling_12m
  • If phase == ZERO_PAY → publish SickLeaveZeroPayAlert for HR notification (FR-ABS-082)

  • Hook into approval flow: When sick leave is approved (listens to internal approval event from AB-012), call determinePhase() and include phase + payRate in LeaveApproved payload

CCI Indemnization Tables (seeded at tenant creation via Flyway seed data):

Ouvriers & Employes:

Seniority Full Pay Half Pay Zero After
< 1 year 1 month 3 months 4 months
1-5 years 1 month 3 months 4 months
6-10 years 2 months 3 months 5 months
11-15 years 3 months 3 months 6 months
> 15 years 4 months 4 months 8 months

Cadres & Agents de Maitrise:

Seniority Full Pay Half Pay Zero After
< 1 year 1 month 3 months 4 months
1-5 years 2 months 3 months 5 months
6-10 years 3 months 4 months 7 months
11-15 years 4 months 4 months 8 months
> 15 years 5 months 5 months 10 months

API Endpoints

No new public API endpoints. This is a backend calculation engine consumed internally: - Invoked by the approval flow (AB-012) when sick leave is approved - Results displayed in HR Balance Detail (AB-021) - Phase information included in LeaveApproved for Payroll module consumption

Validation Rules

  • Employee must exist and be ACTIVE
  • Employee category must be resolvable (OUVRIER or CADRE) — if missing, log warning and default to OUVRIER (more conservative pay schedule)
  • Seniority calculation uses hire_date — if null, treat as < 1 year
  • CCI rules must exist for the tenant — if missing (data corruption), throw IllegalStateException and alert ops

Multi-Tenant Considerations

  • CCI indemnization rules are seeded per tenant at creation time (Flyway + tenant onboarding)
  • All queries include tenantId from ScopedValue<TenantContext>
  • Tenants cannot customize CCI tables in MVP (read-only seed data) — customization is V2
  • Rolling 12-month sick day count queries LeaveBalanceLedger scoped to tenant
  • Works across all 4 DB profiles (designed for BYO-DB first)

Frontend Scope

No Frontend Scope — this is a backend calculation engine. Results are displayed in: - HR Balance Detail (AB-021) — shows current phase and cumulative sick days - Payroll module — consumes LeaveApproved with phase and payRate


Acceptance Criteria

AC-054: CCI table lookup by category and seniority
Given an employee with category = CADRE and seniority = 7 years
When their sick leave is approved
Then the system looks up CCI rule: 3 months full pay, 4 months half pay

AC-055: Rolling 12-month sick day count determines phase
Given an employee has accumulated 95 sick days in the rolling 12-month period
When the CCI rule says full_pay = 3 months (90 days)
Then the current phase is HALF_PAY (exceeded full pay threshold)

AC-056: Phase included in LeaveApproved for Payroll
Given sick leave is approved with phase = HALF_PAY
When the LeaveApproved is published
Then the event payload includes sickLeavePhase = "HALF_PAY" and payRate = 50

OHADA & Regulatory Rules

  • FR-ABS-080: CCI indemnization tables differentiated by employee category (Ouvrier/Cadre) and seniority brackets
  • FR-ABS-081: Phase tracking based on cumulative sick days in rolling 12 months — resets when 12-month window clears
  • FR-ABS-082: Alert HR when employee reaches ZERO_PAY phase (publish SickLeaveZeroPayAlert)
  • FR-ABS-083: CNPS contributions during sick leave based on amount actually paid (TV-ABS-02) — Payroll module responsibility

Standards & Conventions

  • docs/standards/java-spring-guidelines.md — §Service layer patterns, §Application Events, §Enum handling
  • docs/standards/database-guidelines.md — §Multi-tenant repository pattern, §Tenant isolation in every query, §Seed data patterns
  • docs/standards/api-guidelines.md — §Internal service communication via Application Events
  • docs/standards/multi-datasource-patterns.md — §Tenant-scoped repository queries, §JdbcClient patterns

Testing Requirements

Unit Tests

  • SickLeaveIndemnizationService.determinePhase(): CADRE + 7 years seniority → looks up (CADRE, 6-10) → fullPay=3m, halfPay=4m
  • SickLeaveIndemnizationService.determinePhase(): OUVRIER + 3 years seniority → looks up (OUVRIER, 1-5) → fullPay=1m, halfPay=3m
  • SickLeaveIndemnizationService.determinePhase(): cumulative=25 days, fullPay=1m(30d) → FULL_PAY
  • SickLeaveIndemnizationService.determinePhase(): cumulative=35 days, fullPay=1m(30d), halfPay=3m(90d) → HALF_PAY
  • SickLeaveIndemnizationService.determinePhase(): cumulative=125 days, fullPay=1m(30d), halfPay=3m(90d) → ZERO_PAY
  • SickLeaveIndemnizationService.determinePhase(): cumulative=90 days exactly (boundary), fullPay=3m(90d) → FULL_PAY (inclusive)
  • SickLeaveIndemnizationService.determinePhase(): cumulative=91 days, fullPay=3m(90d) → HALF_PAY
  • SickLeaveIndemnizationService.determinePhase(): ZERO_PAY reached → SickLeaveZeroPayAlert published
  • SickLeaveIndemnizationService.determinePhase(): missing category → defaults to OUVRIER
  • Seniority calculation: hire_date=2020-01-15, today=2026-03-13 → 6 years

Integration Tests

  • Sick leave approval triggers determinePhase() and LeaveApproved includes phase
  • LeaveApproved payload: FULL_PAY → payRate=100
  • LeaveApproved payload: HALF_PAY → payRate=50
  • LeaveApproved payload: ZERO_PAY → payRate=0
  • CCI rules seeded at tenant creation → all 10 rows present (5 OUVRIER + 5 CADRE)
  • Multi-tenant: tenant A sick days do not affect tenant B phase calculation

What QA Will Validate

  • N/A (backend-only — results verified via AB-021 HR Balance Detail and Payroll integration)

Out of Scope

  • Licenciement pour inaptitude (Employee Management V2)
  • CNPS contribution calculation (Payroll module)
  • Tenant customization of CCI tables (V2)
  • Partial sick leave / progressive return to work (V2)
  • Frontend display of phase information (AB-021)

Definition of Done

  • [ ] SickLeaveIndemnizationService implemented with determinePhase() method
  • [ ] CCI indemnization rules seeded via Flyway migration for both categories (10 rows)
  • [ ] Phase determination integrates with approval flow (AB-012)
  • [ ] LeaveApproved includes sickLeavePhase and payRate fields
  • [ ] SickLeaveZeroPayAlert published when ZERO_PAY reached
  • [ ] All acceptance criteria pass manually
  • [ ] Unit tests written and passing
  • [ ] Integration tests written and passing
  • [ ] Multi-tenant isolation verified — rules and cumulative counts scoped to tenant
  • [ ] No Java raw types — all generics explicit
  • [ ] Audit trail entries for phase transitions