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 (
OUVRIERorCADRE) - Calculate seniority in years from
hire_date - Look up CCI rule from
SickLeaveIndemnizationRuleRepositoryby (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 * 30→FULL_PAY(payRate=100) - Elif
cumulativeDays <= (fullPayMonths + halfPayMonths) * 30→HALF_PAY(payRate=50) - Else →
ZERO_PAY(payRate=0)
- If
- Update
LeaveEmployeeProfile.sick_leave_phaseandsick_days_current_rolling_12m -
If phase == ZERO_PAY → publish
SickLeaveZeroPayAlertfor 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 inLeaveApprovedpayload
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
IllegalStateExceptionand alert ops
Multi-Tenant Considerations¶
- CCI indemnization rules are seeded per tenant at creation time (Flyway + tenant onboarding)
- All queries include
tenantIdfromScopedValue<TenantContext> - Tenants cannot customize CCI tables in MVP (read-only seed data) — customization is V2
- Rolling 12-month sick day count queries
LeaveBalanceLedgerscoped 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 handlingdocs/standards/database-guidelines.md— §Multi-tenant repository pattern, §Tenant isolation in every query, §Seed data patternsdocs/standards/api-guidelines.md— §Internal service communication via Application Eventsdocs/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=4mSickLeaveIndemnizationService.determinePhase(): OUVRIER + 3 years seniority → looks up (OUVRIER, 1-5) → fullPay=1m, halfPay=3mSickLeaveIndemnizationService.determinePhase(): cumulative=25 days, fullPay=1m(30d) → FULL_PAYSickLeaveIndemnizationService.determinePhase(): cumulative=35 days, fullPay=1m(30d), halfPay=3m(90d) → HALF_PAYSickLeaveIndemnizationService.determinePhase(): cumulative=125 days, fullPay=1m(30d), halfPay=3m(90d) → ZERO_PAYSickLeaveIndemnizationService.determinePhase(): cumulative=90 days exactly (boundary), fullPay=3m(90d) → FULL_PAY (inclusive)SickLeaveIndemnizationService.determinePhase(): cumulative=91 days, fullPay=3m(90d) → HALF_PAYSickLeaveIndemnizationService.determinePhase(): ZERO_PAY reached → SickLeaveZeroPayAlert publishedSickLeaveIndemnizationService.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