Aller au contenu

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): - LeaveEmployeeProfilelast_accrual_date, annual_balance_ouvrables - LeaveBalanceLedger — insert MONTHLY_ACCRUAL entries - CachedEmployeeDataemployee_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 LeaveEmployeeProfile for the tenant
  • For each profile, lookup CachedEmployeeData to check employee_status
  • If status is in ACCRUAL_PAUSE_STATUSES → skip, log reason
  • If status is in ACCRUAL_CONTINUE_STATUSES → call accrualService.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.lastAccrualDate and 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_ACCRUAL ledger entry with reason = "Acquisition mensuelle - {month}/{year} (rattrapage)"
    • Update annualBalanceOuvrables incrementally per month
    • Set lastAccrualDate to last day of each processed month
  • 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 — see docs/standards/multi-datasource-patterns.md)
  • For DB-per-tenant and BYO-DB profiles, the TenantDataSourceRouter resolves 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 jobs
  • docs/standards/database-guidelines.md — §BigDecimal for financial calculations
  • docs/standards/multi-datasource-patterns.md — §platformJdbcClient for platform DB access, §Never use CrudRepository for platform tables
  • docs/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.6
  • AccrualService.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 logged
  • AccrualScheduler.processAccrualForTenant(): employee with SICK status → accrual continues normally
  • AccrualScheduler.processAccrualForTenant(): employee with MATERNITY status → accrual continues normally
  • AccrualScheduler.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_date 3 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