Story AB-005: Scheduled Accrual Job — Edge Cases + Multi-Tenant¶
Module: absence-management Slice: VS-AB-004 (part 2) from architecture Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 5 Depends on: AB-004 Can develop concurrently with: AB-006, AB-007, AB-008 Merge order: After AB-004 Estimated complexity: M PRD User Stories: N/A - Technical Story
Objective¶
Extend the accrual job to handle production edge cases: multi-tenant iteration (the scheduler runs for every active tenant), catch-up for missed months (e.g., after system downtime), and pause/continue rules based on employee leave status per OHADA labor law. Without this story, the accrual job only works for a single tenant and cannot recover from missed runs.
Backend Scope¶
Entities & Records¶
Entities used (defined in AB-002, used in AB-004):
- LeaveEmployeeProfile — last_accrual_date, annual_balance_ouvrables
- LeaveBalanceLedger — insert MONTHLY_ACCRUAL entries
- CachedEmployeeData — employee_status for pause/continue checks
Enums (extend from AB-004):
// Employee statuses that PAUSE accrual (FR-ABS-017)
public static final Set<String> ACCRUAL_PAUSE_STATUSES = Set.of(
"UNPAID_LEAVE", // Conge sans solde
"UNJUSTIFIED_ABSENCE", // Absence injustifiee
"SUSPENDED" // Suspension disciplinaire
);
// Employee statuses that CONTINUE accrual (FR-ABS-016)
public static final Set<String> ACCRUAL_CONTINUE_STATUSES = Set.of(
"ACTIVE",
"SICK", // Maladie
"MATERNITY", // Conge de maternite
"PATERNITY", // Conge de paternite
"EXCEPTIONAL", // Permissions exceptionnelles
"WORK_ACCIDENT" // Accident du travail
);
Repository Layer¶
No new repositories. Uses existing:
- LeaveEmployeeProfileRepository.findAllByTenantId(UUID tenantId)
- CachedEmployeeDataRepository.findByTenantIdAndEmployeeId(UUID tenantId, UUID employeeId)
- LeaveBalanceLedgerRepository.save(LeaveBalanceLedger entry)
Platform-scoped query needed:
- Retrieve all active tenant IDs — uses @Qualifier("platformJdbcClient") JdbcClient to query SELECT id FROM tenant WHERE status = 'ACTIVE' from platform DB (not a Spring Data repository — see docs/standards/multi-datasource-patterns.md)
Service Layer¶
AccrualScheduler.java — extend from AB-004:
@Scheduled(cron = "0 0 2 1 * *") // 1st of each month at 02:00
public void runMonthlyAccrual() {
List<UUID> tenantIds = getAllActiveTenantIds(); // platform DB query
for (UUID tenantId : tenantIds) {
try {
ScopedValue.where(TENANT_CONTEXT, new TenantContext(tenantId, ...))
.run(() -> processAccrualForTenant(tenantId));
} catch (Exception e) {
log.error("Accrual failed for tenant {}: {}", tenantId, e.getMessage());
// Continue processing other tenants — do NOT abort the entire job
}
}
log.info("Monthly accrual completed for {} tenants", tenantIds.size());
}
processAccrualForTenant(UUID tenantId):- Find all
LeaveEmployeeProfilefor the tenant - For each profile, lookup
CachedEmployeeDatato checkemployee_status - If status is in
ACCRUAL_PAUSE_STATUSES→ skip, log reason - If status is in
ACCRUAL_CONTINUE_STATUSES→ callaccrualService.processMonthlyAccrual(profile) - If status is unknown → log warning, skip (fail safe)
AccrualService.java — extend from AB-004:
processMonthlyAccrualWithCatchUp(LeaveEmployeeProfile profile):- Calculate months missed: difference between
profile.lastAccrualDateand current month - If 0 months missed → already processed, skip
- If 1 month missed → normal processing (single accrual)
- If 2+ months missed → process each missed month sequentially:
- For each missed month, create a
MONTHLY_ACCRUALledger entry withreason = "Acquisition mensuelle - {month}/{year} (rattrapage)" - Update
annualBalanceOuvrablesincrementally per month - Set
lastAccrualDateto last day of each processed month
- For each missed month, create a
- Log:
"Catch-up: processed {} missed months for employee {} tenant {}", missedCount, employeeId, tenantId
API Endpoints¶
N/A — Scheduled job extension, no API endpoints.
Validation Rules¶
- All BigDecimal calculations use
RoundingMode.HALF_UP,scale(4) - Catch-up must not exceed 12 months (safety limit) — if more than 12 months missed, log error and skip employee (requires manual investigation)
- Tenant failures must not abort the entire job — each tenant is processed independently
- Unknown employee status must be treated as a skip (fail-safe), not a continue
Multi-Tenant Considerations¶
- The scheduler iterates over ALL active tenants from the platform DB
- Each tenant's accrual runs within a
ScopedValue<TenantContext>scope - Tenant isolation: each tenant's employees are processed independently; a failure in one tenant does not affect others
- Platform DB access uses
@Qualifier("platformJdbcClient") JdbcClient(not a CrudRepository — seedocs/standards/multi-datasource-patterns.md) - For DB-per-tenant and BYO-DB profiles, the
TenantDataSourceRouterresolves the correct database connection per tenant
Frontend Scope¶
N/A — Backend infrastructure story.
Pages & Routes¶
N/A
Components¶
N/A
UI States (ALL REQUIRED)¶
N/A — Backend only.
Responsive Behavior¶
N/A
Interactions¶
N/A
Acceptance Criteria¶
AC-014: Catch-up for missed months
Given an employee's last_accrual_date is 2026-01-31 and today is 2026-04-01
When the accrual job runs
Then 3 MONTHLY_ACCRUAL entries are created (Feb, Mar, Apr) and last_accrual_date is updated to 2026-04-30
AC-015: Job runs in TenantContext for each tenant
Given there are 3 active tenants
When the accrual job runs
Then each tenant's employees are processed within that tenant's TenantContext (ScopedValue)
AC-016: Accrual skipped for paused employees
Given an employee has status = UNPAID_LEAVE (conge sans solde)
When the accrual job processes this employee
Then no MONTHLY_ACCRUAL entry is created and a log entry records the skip reason
OHADA & Regulatory Rules¶
- FR-ABS-016 (CT Art. 25.4, CCI Art. 25.8): Accrual CONTINUES during the following absences — these periods count as effective work for leave accrual purposes:
- Sick leave (conge de maladie) — up to 6 months per rolling 12-month period
- Maternity leave (conge de maternite) — 14 weeks
- Paternity leave (conge de paternite) — 3 days
- Permissions exceptionnelles (exceptional leave for family events)
-
Work accident leave (accident du travail)
-
FR-ABS-017 (CT Art. 25.5): Accrual PAUSES during the following absences — these periods do NOT count as effective work:
- Conge sans solde (unpaid leave) — voluntary absence without pay
- Absence injustifiee (unjustified absence) — unapproved absence
- Suspension disciplinaire (disciplinary suspension) — employer-imposed suspension
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— §Scheduled jobs, §ScopedValue pattern, §Transaction boundaries, §Error handling in batch jobsdocs/standards/database-guidelines.md— §BigDecimal for financial calculationsdocs/standards/multi-datasource-patterns.md— §platformJdbcClient for platform DB access, §Never use CrudRepository for platform tablesdocs/standards/api-guidelines.md— §System user ID convention
Testing Requirements¶
Unit Tests¶
AccrualService.processMonthlyAccrualWithCatchUp(): 3 missed months → 3 ledger entries created, balance incremented by 6.6AccrualService.processMonthlyAccrualWithCatchUp(): 0 missed months → no entries (already processed)AccrualService.processMonthlyAccrualWithCatchUp(): 13 missed months → error logged, employee skipped (safety limit)AccrualScheduler.processAccrualForTenant(): employee with UNPAID_LEAVE status → skipped, reason loggedAccrualScheduler.processAccrualForTenant(): employee with SICK status → accrual continues normallyAccrualScheduler.processAccrualForTenant(): employee with MATERNITY status → accrual continues normallyAccrualScheduler.processAccrualForTenant(): employee with unknown status → skipped (fail-safe)
Integration Tests¶
- Multi-tenant: create 2 tenants with employees → run accrual → verify each tenant's employees processed independently
- Catch-up: set
last_accrual_date3 months behind → run accrual → verify 3 entries in DB with correct dates - Tenant failure isolation: tenant A has invalid data causing exception, tenant B is valid → verify tenant B still processed
- Pause status: employee with UNPAID_LEAVE → verify no ledger entry in DB
What QA Will Validate¶
- N/A — No user-facing functionality. Developer validates via integration tests. Accrual correctness verified in balance display stories.
Out of Scope¶
- Seniority bonus calculation (FR-ABS-010 to FR-ABS-012) — these are reference-period-boundary operations, not monthly accrual. Separate story.
- Children bonus (FR-ABS-013 to FR-ABS-015) — reference-period-boundary operation. Separate story.
- Carry-over processing (FR-ABS-022) — triggered at reference period boundary, not monthly. Separate story.
- Accrual rate configuration (allowing tenants to override the 2.2 CCI rate) — V2
- Retry mechanism for failed tenant processing — V2 (current behavior: log error, skip tenant, continue)
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses — N/A (scheduled job, no endpoints)
- [ ] All frontend pages render correctly at 360px, 768px, 1024px — N/A (backend only)
- [ ] All 5 UI states implemented (loading, empty, error, offline, success) — N/A
- [ ] French micro-copy matches design spec exactly — N/A
- [ ] All acceptance criteria pass manually
- [ ] Unit tests written and passing
- [ ] Integration tests written and passing
- [ ] Multi-tenant isolation verified — Each tenant processed in its own TenantContext, failures isolated
- [ ] Offline write+sync works (Papillon) / N/A (Enterprise) — N/A (backend only)
- [ ] No TypeScript
any— N/A - [ ] No Java raw types — all generics explicit
- [ ] API responses follow standard envelope format — N/A (no endpoints)
- [ ] Audit trail entries for every data mutation — Ledger entries serve as audit trail; skipped employees logged with reason