Story CP-002: Tenant CRUD + Lifecycle State Machine¶
Module: control-plane Slice: Slice 2 from architecture Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 2 Depends on: CP-001 Estimated complexity: L
Objective¶
Create the core Tenant entity with its 10-state lifecycle state machine, the TenantSettings entity for per-tenant configuration, and the REST API for tenant information retrieval and settings management. The Tenant is the foundational business entity of the platform — every other module, every user, every employee exists within a tenant's context. The state machine governs the entire commercial lifecycle from registration to deletion.
Backend Scope¶
Entities & Records¶
Tenant entity (platform DB):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK, server-generated |
| name | String | NOT NULL, min 2, max 200 |
| country_code | String(2) | NOT NULL, ISO 3166-1 alpha-2 ("CI" for MVP) |
| status | TenantStatus enum | NOT NULL |
| db_profile | String | NOT NULL ("SHARED" or "SCHEMA" or "DB_PER_TENANT" or "BYO_DB") |
| segment | String | Nullable (S1-S6, set at billing) |
| onboarding_channel | String | NOT NULL ("SELF" or "CRM" or "ADMIN") |
| salesperson_id | UUID | Nullable (FK, populated for Channel B) |
| db_connection_url | String | Nullable (Profile C only) |
| trial_used | boolean | NOT NULL, default false |
| trial_start_date | LocalDate | Nullable |
| trial_end_date | LocalDate | Nullable |
| activated_date | Instant | Nullable |
| suspended_date | Instant | Nullable |
| suspended_reason | String | Nullable |
| deleted_date | Instant | Nullable |
| created_date | Instant | NOT NULL |
| updated_date | Instant | NOT NULL |
TenantStatus enum:
public enum TenantStatus {
PENDING_VERIFICATION, // Channel A: email not yet verified
PENDING, // Channel B: CRM-created, password not set
TRIAL, // Free trial active (14 days)
PAYMENT_REQUIRED, // Must pay within 14 days
ACTIVE_PAYMENT_PENDING,// Bank transfer chosen, 10-day grace
ACTIVE_INCOMPLETE, // Paid but RCCM/NIF missing
ACTIVE, // Fully active and compliant
SUSPENDED, // Read-only (expired or manual)
DELETED, // Soft-deleted
PROVISIONING_FAILED // Internal only, not visible to tenant
}
TenantSettings entity (tenant DB):
| Field | Type | Constraints |
|---|---|---|
| id | UUID v7 | PK |
| tenant_id | UUID | NOT NULL, unique |
| fiscal_year_start_month | int | NOT NULL, default 1 (January) |
| working_days_per_week | int | NOT NULL, default 5 |
| working_hours_per_day | int | NOT NULL, default 8 |
| currency | String(3) | NOT NULL, default "XOF" |
| logo_url | String | Nullable |
| matricule_prefix | String | NOT NULL, max 10 (set to company trigram at provisioning, e.g. "ALT") |
| matricule_padding | int | NOT NULL, default 3 |
| default_approver_user_id | UUID | Nullable (FK to User, set after user slice) |
| atmp_risk_class | String | Nullable (links to risk class code) |
| legal_form | String | Nullable (SARL, SA, SAS, SUARL, EI) |
| rccm | String | Nullable, max 50 |
| nif | String | Nullable, max 50 |
| address | String | Nullable, max 500 |
| sector | String | Nullable |
| created_date | Instant | NOT NULL |
| updated_date | Instant | NOT NULL |
DTOs:
public record TenantResponse(
UUID id, String name, String countryCode, TenantStatus status,
String segment, String onboardingChannel, boolean trialUsed,
LocalDate trialEndDate, Instant activatedDate, Instant createdDate
) {}
public record TenantSettingsResponse(
int fiscalYearStartMonth, int workingDaysPerWeek, int workingHoursPerDay,
String currency, String logoUrl, String matriculePrefix, int matriculePadding,
UUID defaultApproverUserId, String atmpRiskClass, String legalForm,
String rccm, String nif, String address, String sector
) {}
public record UpdateTenantSettingsRequest(
@Nullable Integer fiscalYearStartMonth,
@Nullable Integer workingDaysPerWeek,
@Nullable Integer workingHoursPerDay,
@Nullable String logoUrl,
@Nullable String matriculePrefix,
@Nullable Integer matriculePadding,
@Nullable UUID defaultApproverUserId,
@Nullable String atmpRiskClass,
@Nullable String legalForm,
@Nullable String rccm,
@Nullable String nif,
@Nullable String address,
@Nullable String sector
) {}
Repository Layer¶
TenantRepository.java— Platform-scoped (NOT tenant-aware). Methods:findById(UUID id) → Optional<Tenant>findByAdminEmail(String email) → Optional<Tenant>findByStatus(TenantStatus status) → List<Tenant>findAll(Pageable) → Page<Tenant>save(Tenant) → TenantTenantSettingsRepository.java— Tenant-aware. Methods:findByTenantId(UUID tenantId) → Optional<TenantSettings>save(TenantSettings) → TenantSettings
Service Layer¶
TenantService.java:
- getTenantInfo(UUID tenantId) → TenantResponse — Returns tenant details for the current tenant
- getSettings(UUID tenantId) → TenantSettingsResponse — Returns tenant configuration
- updateSettings(UUID tenantId, UpdateTenantSettingsRequest) → TenantSettingsResponse — Partial update of tenant settings. Each field saves independently (auto-save pattern from design spec).
TenantStateMachine.java:
- Encapsulates ALL valid state transitions. Rejects invalid transitions with IllegalStateTransitionException.
- transition(Tenant, TenantStatus targetStatus, @Nullable String reason) → Tenant
- Valid transitions (from PRD FR-CP-010 through FR-CP-017):
| From | To | Condition |
|---|---|---|
| PENDING_VERIFICATION | TRIAL | Email verified + trial selected |
| PENDING_VERIFICATION | PAYMENT_REQUIRED | Email verified + no trial |
| PENDING | PAYMENT_REQUIRED | Password set, no payment |
| PENDING | ACTIVE_PAYMENT_PENDING | Password set + bank transfer |
| PENDING | ACTIVE | Password set + API payment |
| TRIAL | ACTIVE | Payment received |
| TRIAL | ACTIVE_INCOMPLETE | Payment received, RCCM/NIF missing |
| TRIAL | ACTIVE_PAYMENT_PENDING | Bank transfer chosen |
| TRIAL | SUSPENDED | 14 days expired |
| PAYMENT_REQUIRED | TRIAL | One-time trial requested (trial_used must be false) |
| PAYMENT_REQUIRED | ACTIVE | Payment received |
| PAYMENT_REQUIRED | ACTIVE_INCOMPLETE | Payment received, RCCM/NIF missing |
| PAYMENT_REQUIRED | ACTIVE_PAYMENT_PENDING | Bank transfer chosen |
| PAYMENT_REQUIRED | SUSPENDED | 14 days expired |
| ACTIVE_PAYMENT_PENDING | ACTIVE | Payment confirmed |
| ACTIVE_PAYMENT_PENDING | ACTIVE_INCOMPLETE | Confirmed, RCCM/NIF missing |
| ACTIVE_PAYMENT_PENDING | SUSPENDED | 10 days expired |
| ACTIVE_INCOMPLETE | ACTIVE | RCCM + NIF provided |
| ACTIVE_INCOMPLETE | SUSPENDED | 3 months elapsed without RCCM/NIF (scheduled job) |
| ACTIVE | SUSPENDED | Subscription expired + grace OR manual suspension |
| SUSPENDED | ACTIVE | Payment/reactivation |
| SUSPENDED | DELETED | 30 days no action (soft delete) |
- Publishes
TenantStatusChangedApplication Event on every transition:
API Endpoints¶
| Method | Path | Request Body | Response | Auth |
|---|---|---|---|---|
| GET | /api/v1/tenant | — | TenantResponse | Any authenticated user |
| GET | /api/v1/tenant/settings | — | TenantSettingsResponse | ROLE_DG, ROLE_RH_MANAGER |
| PATCH | /api/v1/tenant/settings | UpdateTenantSettingsRequest | TenantSettingsResponse | ROLE_DG, ROLE_RH_MANAGER |
| GET | /api/v1/tenant/default-approver | — | { userId, fullName } |
Any authenticated user |
Validation Rules¶
fiscal_year_start_month: 1–12working_days_per_week: 1–7working_hours_per_day: 1–24matricule_prefix: 1–10 chars, alphanumeric onlymatricule_padding: 2–8rccm: max 50 charsnif: max 50 charsatmp_risk_class: must match a valid risk class code from country configlegal_form: must be one of SARL, SA, SAS, SUARL, EI (or null)
Multi-Tenant Considerations¶
Tenantentity itself is in the platform DB (not tenant-scoped — it IS the tenant)TenantSettingsis in the tenant DB (tenant-scoped)- API endpoints for settings use
TenantContext.current().tenantId()— a tenant can only read/modify their own settings - Admin endpoints (for platform admin to view/manage any tenant) are in CP-012 (Admin Console)
Flyway Migrations¶
V001__create_tenant.sql— Platform DB:tenantstableV001__create_tenant_settings.sql— Tenant DB:tenant_settingstable
Frontend Scope¶
Pages & Routes¶
Company Settings page:
- Route: /admin/settings/company (inside authenticated tenant shell)
- Layout: Standard admin page layout
Components¶
CompanySettingsPage.tsx— Full settings form with auto-save per fieldMatriculePreview.tsx— Live preview: "{prefix}-{YYYY}-{MM}-{example}" (e.g., "ALT-2026-03-001")RiskClassSelector.tsx— Dropdown for AT/MP risk class
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | Skeleton: 12 field placeholders in form layout | — |
| Empty | N/A — settings always exist (seeded at provisioning) | — |
| Error | Red toast at top | "Une erreur est survenue. Veuillez réessayer." |
| Offline | Read-only: all fields disabled, orange banner at top | "Hors ligne — les modifications seront possibles une fois la connexion rétablie." |
| Success (per field) | Brief green checkmark next to field label (150ms fade in, 2s hold, 300ms fade out) | "Enregistré ✓" |
| Success (offline save) | Orange dot next to field | "Modification enregistrée hors ligne" |
Responsive Behavior¶
- 360px (mobile): Single-column form. All fields stacked vertically. Sections separated by dividers: "Informations de l'entreprise", "Configuration", "Format Matricule Employé", "Approbateur par défaut".
- 768px (tablet): Same as mobile but wider inputs.
- 1024px+ (desktop): Two-column form layout:
- Left column: Company identity (name, legal form, RCCM, NIF, address, sector)
- Right column: Configuration (fiscal year, working days, hours, AT/MP class, matricule format, default approver)
Interactions¶
- Auto-save: Every field saves on blur (no Save button). Brief green checkmark appears next to field label on success.
- Logo upload: Tap placeholder → file picker → upload to MinIO → show thumbnail. Max 2MB, PNG/JPG only.
- Matricule preview: Live preview updates as prefix/padding change. Format:
{prefix}-{YYYY}-{MM}-{zeroPadded}. - Risk class info:
ℹ Utilisé pour le calcul des cotisations CNPShelper text below dropdown. - NIF warning: If NIF is empty and tenant is ACTIVE_INCOMPLETE, show
⚠ Requis pour la facturation normalisée (FNE)below field.
Acceptance Criteria¶
AC-008: Get current tenant info
Given I am authenticated as a user of tenant "Acme SARL"
When I call GET /api/v1/tenant
Then I receive the tenant's name, status, country, segment, and key dates
AC-009: Get tenant settings
Given I am authenticated with role DG
When I call GET /api/v1/tenant/settings
Then I receive all configuration fields including fiscal year, working days, matricule format
AC-010: Update tenant settings (auto-save)
Given I am authenticated with role RH_MANAGER
When I call PATCH /api/v1/tenant/settings with {"workingDaysPerWeek": 6}
Then the working days setting is updated to 6
And an audit trail entry is created
AC-011: Settings auto-save on field blur
Given I am on the Company Settings page
When I change the "Nombre de jours ouvrables" field to 6 and tab out
Then a PATCH request is sent automatically
And a green checkmark "Enregistré ✓" appears next to the field label
AC-012: Matricule preview updates live
Given I am on the Company Settings page
When I change the matricule prefix to "ACC" and padding to 5
Then the preview shows "Aperçu : ACC-2026-03-00001"
AC-013: Valid state transitions accepted
Given a tenant in TRIAL status
When the billing engine confirms payment and RCCM+NIF are present
Then the state machine transitions the tenant to ACTIVE
And a TenantStatusChanged event is published
AC-014: Invalid state transitions rejected
Given a tenant in ACTIVE status
When a transition to TRIAL is attempted
Then the state machine throws IllegalStateTransitionException
And no state change occurs
AC-015: ACTIVE_INCOMPLETE NIF warning
Given a tenant with status ACTIVE_INCOMPLETE (NIF is empty)
When I view Company Settings
Then the NIF field shows "⚠ Requis pour la facturation normalisée (FNE)" below it
AC-016: Settings read-only offline
Given the device is offline
When I view Company Settings
Then all fields are displayed but disabled
And an orange banner shows "Hors ligne — les modifications seront possibles une fois la connexion rétablie."
AC-114: Scheduled job downgrades ACTIVE_INCOMPLETE after 3 months
Given a tenant has been in ACTIVE_INCOMPLETE status for 3 or more months without providing RCCM and NIF
When the daily scheduled job runs
Then the tenant's status is transitioned to SUSPENDED (read-only access)
And a TenantStatusChanged event is published
And the tenant receives a notification explaining that read-only mode has been activated and how to restore full access by providing RCCM/NIF
OHADA & Regulatory Rules¶
- RCCM: Registre du Commerce et du Crédit Mobilier — required for formal businesses in OHADA zone. Optional at registration to reduce friction, but needed for FNE-compliant invoicing.
- NIF: Numéro d'Identification Fiscale — required for FNE. Without it, invoices are proforma only (FR-CP-023).
- Legal forms (SARL, SA, SAS, SUARL, EI): OHADA Uniform Act on General Commercial Law defines these. The tenant admin selects their company's legal form.
- AT/MP risk class: Décret n° 2024-901 (CI) — employer's risk class determines AT/MP contribution rate (2%–5%). Must be configurable per tenant.
- 3-month ACTIVE_INCOMPLETE read-only: After 3 months without providing RCCM/NIF, modules become read-only. This is a business enforcement rule (FR-CP-013), not a regulatory mandate, but it ensures ALTARYS can issue proper invoices.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— §Entities, §Records & sealed types, §Application Events, §Service layer patternsdocs/standards/database-guidelines.md— §Flyway migrations, §UUID v7 primary keys, §Audit columns (created_date, updated_date)docs/standards/api-guidelines.md— §PATCH semantics, §Standard envelope format, §Error responsesdocs/standards/react-typescript-guidelines.md— §Auto-save pattern, §Form componentsdocs/standards/offline-sync-guidelines.md— §Read-only offline mode
Testing Requirements¶
Unit Tests¶
- TenantStateMachine: test ALL 21 valid transitions
- TenantStateMachine: test ALL invalid transitions (every pair not in the valid list)
- TenantStateMachine: verify TenantStatusChanged event is published on every valid transition
- TenantSettings validation: boundary values for fiscal_year_start_month (1, 12, 0, 13)
- Matricule preview generation
Integration Tests¶
- GET /api/v1/tenant — returns correct tenant info
- GET /api/v1/tenant/settings — returns settings, forbidden for EMPLOYEE role
- PATCH /api/v1/tenant/settings — updates individual field, creates audit entry
- PATCH /api/v1/tenant/settings with invalid data — returns 400 with validation errors
- Tenant isolation: tenant A cannot read tenant B's settings
What QA Will Validate¶
- Company Settings page loads with correct data
- Auto-save works on every field (change → blur → checkmark)
- Matricule preview updates live
- NIF warning shows for ACTIVE_INCOMPLETE tenants
- Settings are read-only when offline
- Responsive layout at 360px, 768px, 1024px
Out of Scope¶
- Tenant creation/registration — that's CP-008 (Self-Registration)
- Default approver selection UI (needs User entity from CP-003)
- Logo upload to MinIO — infrastructure. Show placeholder. Implement upload when MinIO is configured.
- Admin console tenant management — that's CP-012
- Tenant deletion/archival process — V2 operational procedure
- DB Profile B and D routing — MVP (all 4 profiles are in scope)
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses
- [ ] All frontend pages render correctly at 360px, 768px, 1024px
- [ ] All 5 UI states implemented (loading, empty, error, 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) — Read-only offline for settings
- [ ] 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