Aller au contenu

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) → Tenant
  • TenantSettingsRepository.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 TenantStatusChanged Application Event on every transition:
    public record TenantStatusChanged(
        UUID tenantId, TenantStatus oldStatus, TenantStatus newStatus,
        @Nullable String reason, Instant timestamp
    ) {}
    

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–12
  • working_days_per_week: 1–7
  • working_hours_per_day: 1–24
  • matricule_prefix: 1–10 chars, alphanumeric only
  • matricule_padding: 2–8
  • rccm: max 50 chars
  • nif: max 50 chars
  • atmp_risk_class: must match a valid risk class code from country config
  • legal_form: must be one of SARL, SA, SAS, SUARL, EI (or null)

Multi-Tenant Considerations

  • Tenant entity itself is in the platform DB (not tenant-scoped — it IS the tenant)
  • TenantSettings is 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: tenants table
  • V001__create_tenant_settings.sql — Tenant DB: tenant_settings table

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 field
  • MatriculePreview.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 CNPS helper 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 patterns
  • docs/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 responses
  • docs/standards/react-typescript-guidelines.md — §Auto-save pattern, §Form components
  • docs/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