Story AB-020: Manual Balance Adjustment (Backend + Frontend)¶
Module: absence-management Slice: VS-AB-015 from architecture Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 20 Depends on: AB-012 Concurrent with: AB-021 Merge order: Any order with AB-021 Estimated complexity: S PRD User Stories: N/A - Technical Story
Objective¶
Enable HR managers and DGs to manually adjust an employee's leave balance (add or remove days) with a mandatory reason, creating an audited MANUAL_ADJUSTMENT ledger entry. Uses the same pessimistic lock pattern as leave approval (from AB-012's BalanceMutationService) to prevent concurrent balance corruption.
Backend Scope¶
Entities & Records¶
No new entities. This story extends the existing ledger model from AB-012.
Ledger entry transaction type (extend enum):
| Value | Description |
|---|---|
| MANUAL_ADJUSTMENT | Manual balance adjustment by HR/DG — signed amount (positive=add, negative=remove) |
DTOs:
public record AdjustBalanceRequest(
@NotBlank String leaveType, // "ANNUAL" (MVP only)
@NotNull BigDecimal amount, // positive=add, negative=remove, never zero
@NotBlank @Size(min = 10) String reason,
@NotNull LeaveUnit unit // OUVRABLES, CALENDAIRES, OUVRES
) {}
Repository Layer¶
No new repositories. Uses existing ledger and LeaveEmployeeProfile repositories from AB-012.
Service Layer¶
BalanceMutationService.java (extend from AB-012):
- adjustBalance(UUID tenantId, UUID employeeId, String leaveType, BigDecimal amount, String reason, LeaveUnit unit) -> LeaveEmployeeProfile:
1. Acquire pessimistic lock on LeaveEmployeeProfile (SELECT FOR UPDATE)
2. Convert amount to jours ouvrables if input unit is CALENDAIRES or OUVRES
3. Validate resulting balance is not negative (block if remove would go below 0)
4. Insert MANUAL_ADJUSTMENT ledger entry (signed: positive for add, negative for remove)
5. Update cached annual_balance_ouvrables
6. Publish AuditEntryRequested event with reason, amount, employeeId, performedBy
7. Release lock (end of transaction)
API Endpoints¶
| Method | Path | Request Body | Response | Auth |
|---|---|---|---|---|
| PATCH | /api/v1/absence/employees/{employeeId}/adjust-balance | AdjustBalanceRequest | LeaveEmployeeProfileResponse | HR_MANAGER, DG |
Validation Rules¶
reasonis mandatory: not blank, minimum 10 charactersamountmust not be zeroleaveTypemust be "ANNUAL" (MVP — only annual balance is adjustable)unitmust be a valid LeaveUnit enum value- Resulting balance must not be negative (block if remove would cause balance < 0)
- Only HR_MANAGER or DG roles can invoke this endpoint
Multi-Tenant Considerations¶
- All operations scoped by tenant_id from TenantResolutionFilter
- Pessimistic lock is tenant-isolated (SELECT FOR UPDATE on tenant-scoped LeaveEmployeeProfile)
- Ledger entry records tenant_id for audit trail
- Employee must belong to the same tenant as the authenticated user
Flyway Migrations¶
No new migrations. MANUAL_ADJUSTMENT is already part of the transaction_type enum from AB-012 ledger setup.
Frontend Scope¶
Pages & Routes¶
/absences/employes/:id/ajustement— Manual Balance Adjustment form page- Access: HR_MANAGER, DG
Components¶
ManualBalanceAdjustmentPage.tsx— Full page (mobile) / Modal (desktop) for balance adjustmentBalancePreviewCard.tsx— Displays current balance and live-calculated new balanceAdjustmentForm.tsx— Form with direction radio, amount input, reason textarea
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | Skeleton for balance display area | — |
| Empty | N/A (form always renders) | — |
| Error (negative result) | Inline error below amount input | "Le solde ne peut pas etre negatif." |
| Error (no reason) | Inline error on textarea | "Veuillez indiquer le motif de l'ajustement." |
| Error (reason too short) | Inline error on textarea | "Le motif doit contenir au moins 10 caracteres." |
| Error (API) | Toast | "Impossible d'effectuer l'ajustement." |
| Offline | Form blocked, banner displayed | "Cette action necessite une connexion internet" |
| Success | Toast + navigate back | "Solde ajuste -- {+/-n} jours" |
Responsive Behavior¶
- 360px (mobile): Full page layout.
- Page title: "Ajustement de solde" with employee name subtitle
- Current balance display: "Solde actuel : {n} JC"
- RadioGroup: "Ajouter des jours" / "Retirer des jours"
- Input (type=number): "Nombre de jours (calendaires)"
- Live calculation: "Nouveau solde : {n} JC" (updates as user types)
- Textarea (required): label "Motif (obligatoire)", placeholder "Expliquez la raison..."
- Warning banner: "Cet ajustement sera enregistre dans l'historique d'audit."
- Button (primary, full-width): "Confirmer l'ajustement"
- 768px (tablet): Same layout, wider form with max-width 500px centered.
- 1024px+ (desktop): Modal or side panel, max-width 500px. Same form content.
Interactions¶
- Direction toggle: RadioGroup switches between "Ajouter des jours" and "Retirer des jours". Toggling updates the sign of the live calculation.
- Live balance preview: As the user types an amount, "Nouveau solde : {n} JC" updates in real-time. If result would be negative, show inline error immediately.
- Submit: Calls PATCH endpoint. On success, toast "Solde ajuste -- {+/-n} jours" and navigate back to employee balance detail (AB-021).
- Offline blocking: If device is offline, form inputs are disabled and a banner is shown. No offline queueing for this action.
Acceptance Criteria¶
AC-064: HR can adjust any employee's balance
Given I am authenticated as HR_MANAGER or DG
When I submit a manual balance adjustment for an employee with amount=5, reason="Rattrapage jours non comptabilises en janvier", unit=OUVRABLES
Then the balance is updated by +5 jours ouvrables
And a MANUAL_ADJUSTMENT ledger entry is created with positive amount, reason, and created_by
And an AuditEntryRequested event is published
AC-065: Adjustment requires mandatory reason
Given I try to submit a balance adjustment
When the reason field is empty or less than 10 characters
Then the submission is blocked with "Veuillez indiquer le motif de l'ajustement."
And no ledger entry is created
AC-066: Adjustment is audited in the ledger
Given a manual adjustment is submitted with reason "Correction suite a erreur de saisie" and amount=-3
When the adjustment is processed
Then a MANUAL_ADJUSTMENT ledger entry is created with amount=-3, reason="Correction suite a erreur de saisie", and created_by={current user}
And an AuditEntryRequested event is published
And the cached annual_balance_ouvrables is decremented by 3
OHADA & Regulatory Rules¶
- FR-ABS-120: Ledger transaction types — MANUAL_ADJUSTMENT is a valid transaction type for the leave balance ledger
- FR-ABS-121: Ledger entry fields — each entry must include: date, transaction_type, amount, reason, created_by, employee_id, tenant_id
- FR-ABS-123: Immutable ledger — corrections are always new entries, never modifications of existing entries. A MANUAL_ADJUSTMENT to fix a previous error creates a new compensating entry.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— Pessimistic locking, service layer patterns, event publishingdocs/standards/database-guidelines.md— BigDecimal for financial/balance calculations, tenant isolationdocs/standards/api-guidelines.md— PATCH semantics, standard ApiResponse envelope, 403/422 error formatsdocs/standards/react-typescript-guidelines.md— Form patterns, inline validation, toast notificationsdocs/standards/design-system.md— RadioGroup, Input, Button, Toast, Banner componentsdocs/standards/offline-sync-guidelines.md— Online-only action pattern (no offline queueing)docs/standards/multi-datasource-patterns.md— Tenant-scoped data access via JdbcClient
Testing Requirements¶
Unit Tests¶
- BalanceMutationService: adjustBalance with positive amount (add days)
- BalanceMutationService: adjustBalance with negative amount (remove days)
- BalanceMutationService: reject adjustment that would cause negative balance
- BalanceMutationService: reject blank or short reason (< 10 chars)
- BalanceMutationService: reject zero amount
- BalanceMutationService: unit conversion (CALENDAIRES/OUVRES to OUVRABLES)
Integration Tests¶
- PATCH /api/v1/absence/employees/{id}/adjust-balance — full flow with pessimistic lock
- PATCH endpoint — 403 for EMPLOYE role (unauthorized)
- PATCH endpoint — 422 for negative resulting balance
- PATCH endpoint — 422 for missing/short reason
- Tenant isolation: adjustment for tenant A employee not accessible from tenant B
- Audit event published with correct payload
What QA Will Validate¶
- Adjustment form renders correctly on mobile (360px), tablet (768px), desktop (1024px+)
- Live balance preview updates as user types amount
- Inline validation errors display for: empty reason, short reason, negative resulting balance
- Offline state blocks form and shows banner
- Success toast displays and navigates back to balance detail
- Ledger entry visible in employee balance detail (AB-021) after adjustment
Out of Scope¶
- Batch balance adjustments (adjusting multiple employees at once) — Enterprise V2
- Adjusting sick or exceptional leave counters — MVP adjusts annual balance only
- Approval workflow for adjustments — MVP allows direct adjustment by HR/DG without secondary approval
- Undo/reverse adjustment UI — corrections are new MANUAL_ADJUSTMENT entries per FR-ABS-123
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses
- [ ] All frontend pages render correctly at 360px, 768px, 1024px
- [ ] All UI states implemented (loading, error variants, offline, success)
- [ ] French micro-copy matches design spec exactly
- [ ] All acceptance criteria pass manually
- [ ] Unit tests written and passing
- [ ] Integration tests written and passing
- [ ] Multi-tenant isolation verified (no cross-tenant data leaks)
- [ ] Offline write+sync works (Papillon) — N/A (online-only action)
- [ ] No TypeScript
any— all types explicit - [ ] No Java raw types — all generics explicit
- [ ] API responses follow standard envelope format
- [ ] Audit trail entries created for every data mutation