Story TENANT-008: Lifecycle hardening — suspend / reactivate + 90-day soft-delete (events only)¶
Module: tenant-admin Slice: S5 (suspend/reactivate) + S6 event-emission part (Vertical Slice Decomposition §11) Side: [ADMIN] + [BACKEND] Version target: [V0.1.0] Priority: 8 Depends on: TENANT-004 Can develop concurrently with: TENANT-005, TENANT-007 Merge order: After TENANT-004 Estimated complexity: M PRD User Stories: TENANT-US-04, TENANT-US-05 (AC-05.1 only) Wireframe: docs/wireframes/tenant-admin/TENANT-008/ (design ref ADM-004 suspend modal + ADM-005 reactivate modal)
Objective¶
Production-hardening of the tenant lifecycle (the V0.1.0 milestone). A platform admin can suspend a tenant (with an audited reason) and later reactivate it; the lifecycle daily job soft-deletes a tenant that has been SUSPENDED for ≥ 90 days. This story owns the emission of TenantSuspended, TenantReactivated, and TenantSoftDeleted; the consumption side (AUTH revoking sessions, CP returning 503, PAY/OP freezing/pausing) lives in those modules (per arch §0.1, covered by OP-005 + AUTH-006). The actual PII purge on soft-delete is deferred to V1 (arch §0.3) — V0.1.0 emits TenantSoftDeleted but does not purge.
Backend Scope¶
Entities¶
Uses tenant (status, suspended_at, suspension_reason_code/text, deleted_at) from TENANT-002. Optional tenant_lifecycle_audit reversal record (arch §9.2): prior state, new state, actor, reason — to allow safe manual reversal through the service (never raw SQL).
Migrations¶
- (Optional)
db/migration/platform/V4__tenant_lifecycle_audit.sqlif the reversal record is implemented.
Service Layer¶
TenantLifecycleService.suspend(tenantId, reasonCode, reasonText)(TR-003): allowed only from ACTIVE; set status=SUSPENDED, suspended_at=now(), reason fields; writeTENANT_SUSPENDEDaudit; publishTenantSuspendedAFTER_COMMIT (outbox-persisted). Illegal transition → reject.reactivate(tenantId)(TR-004): allowed only from SUSPENDED; set status=ACTIVE, clear suspended_at + reason; writeTENANT_REACTIVATEDaudit; publishTenantReactivated. The 90-day grace clock resets on reactivation. Suspend/reactivate may occur N times within the grace.TenantLifecycleJob(@Scheduled, 02:00Africa/Abidjan, TR-005):SELECT id FROM tenant WHERE status='SUSPENDED' AND suspended_at <= now() - 90d(indexed scan on(status, suspended_at)); for each, set status=DELETED, deleted_at=now(), publishTenantSoftDeleted(persisted inspring-modulith-events-jdbc). Each transition in its own transaction.- Events (
…controlplane.tenant.events):TenantSuspended{tenantId, reasonCode, suspendedAt},TenantReactivated{tenantId, reactivatedAt},TenantSoftDeleted{tenantId, deletedAt}. - Lifecycle transitions are gated by this service (no raw SQL
UPDATE tenant SET status) — CI test forbids it (Technical Risk R7). - Cache invalidation: publish triggers invalidation of
TenantConfigCacheentries for the tenant.
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
| POST | /api/control/tenants/{id}/suspend |
{reasonCode, reasonText?} |
200 · 409 illegal transition | papillon-platform-admin |
| POST | /api/control/tenants/{id}/reactivate |
— | 200 · 409 illegal transition | papillon-platform-admin |
reasonCode ∈ {MANUAL_BILLING, MANUAL_LEGAL, MANUAL_TENANT_REQUEST, MANUAL_OTHER}.
Validation Rules¶
- suspend only from ACTIVE; reactivate only from SUSPENDED (B.3.2 lifecycle validity).
reasonCoderequired and from the enum;reasonTextoptional, audit-only, NEVER customer-facing.
Multi-Tenant Considerations¶
Lifecycle operates on the platform tenant row (the directory). Propagation to other modules is via events carrying tenantId (async, AFTER_COMMIT, outbox). Eventual consistency target ≤ 30 s (NFR-04). The daily job is bounded by N(SUSPENDED ≥ 90d).
Audit & Logging¶
TENANT_SUSPENDED(actor, reason_code, reason_text, prev_status, new_status),TENANT_REACTIVATED,TENANT_SOFT_DELETED. Best-effort via outbox (NFR-09); a regulator-relevant event must not be lost (Technical Risk R9 — outbox queue, alert on publication failure).
Frontend Scope¶
Pages & Routes¶
Adds suspend/reactivate actions to the minimal tenant detail (/admin/tenants/:id) via two modals (design ADM-004 / ADM-005).
Components¶
Reuse Modal, radio group, Button (danger / success variants), InlineBanner, Badge.
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | Confirm button → loading; modal stays open during API call |
— |
| Empty | N/A | — |
| Error | InlineBanner in modal; button re-enabled | « Impossible de suspendre. Réessayez. » |
| Offline / low network | Confirm blocked | « Connexion requise pour cette action. » |
| Success | Modal closes; toast; detail badge updates | « Cabinet suspendu. » / « Cabinet réactivé. » |
Responsive Behavior¶
Modal 560 px desktop, 100 % viewport minus 32 px on mobile, centered.
Connectivity Behavior (CUSTOMER stories only)¶
N/A — admin-side.
Interactions¶
- Suspend modal (design D4): danger warning banner « ⛔ Cette action révoque immédiatement l'accès de tous les opérateurs du cabinet et bloque les nouveaux paiements. »; reason radios « Facturation impayée » / « Raison légale ou réglementaire » / « Demande du cabinet » / « Autre »; optional detail textarea; confirm « ⛔ Confirmer la suspension ». No "type the name to confirm".
- Reactivate modal: info « ✅ La réactivation rétablira l'accès des opérateurs et relancera les campagnes planifiées. »; shows « Suspendu depuis : {date} ({n} jours) · Motif : {reason} »; confirm « ✅ Réactiver le cabinet ».
- SUSPENDED tenant banner (all detail tabs): « ⚠ Ce cabinet est suspendu depuis le {date}. Sans réactivation, il sera supprimé le {date+90j}. Les données d'audit et factures seront conservées 10 ans. Les données personnelles des assurés seront supprimées. »
Acceptance Criteria¶
AC-04.1: Suspend = hard freeze (emission side)
When I confirm suspension with a reason text and a reason code (MANUAL_BILLING / MANUAL_LEGAL / MANUAL_TENANT_REQUEST / MANUAL_OTHER)
Then tenant.status becomes SUSPENDED and TenantSuspended is published within the AFTER_COMMIT phase
And consumers act within 30 s (NFR-04): AUTH revokes operator access, signed-link verification returns 503, PAY refuses new initiations, OP pauses scheduled sends
Consumer behaviour (AUTH/CP/PAY/OP) is implemented in those modules (OP-005 + AUTH-006). TENANT verifies it emits the event with the correct payload; cross-module propagation is asserted by integration tests where those listeners exist.
AC-04.2: Audit + reason persisted
When suspension occurs
Then one audit entry is written with event=TENANT_SUSPENDED, actor, reason_code, reason_text, timestamp, prev_status, new_status
And the reason is NOT shown on the customer-facing 503 page
AC-04.3: Reactivation symmetric
When I reactivate a SUSPENDED tenant
Then status returns to ACTIVE within 30 s, TenantReactivated is emitted, an audit entry is written
And the 90-day grace clock resets; suspend/reactivate can occur N times within the grace
AC-05.1: 90-day grace clock + soft-delete event
Given a tenant has been SUSPENDED for >= 90 days continuously
When the daily lifecycle job runs (02:00 Africa/Abidjan)
Then the tenant transitions to DELETED (soft), deleted_at is set, and TenantSoftDeleted is emitted (outbox-persisted)
And the clock resets to zero on any prior reactivation
AC-05.2 (PII purge) and AC-05.3 (fiscal/audit retention) are deferred to V1 (arch §0.3): V0.1.0 emits the event but performs no purge. See Out of Scope.
Compliance Rules¶
- Loi 2013-450 / data-minimization: the soft-delete model (PII purge after 90-day grace; audit + invoices retained 10 years) is assumption TV-05 (
PRÉLIMINAIRE,[LEGAL-REVIEW]). In V0.1.0 only the lifecycle transition + event emission ship; the purge that realises minimization is V1. Do not claim minimization compliance from the event alone. - Suspension reason text is audit-only and MUST NOT surface on any customer-facing page (CIMA/ARTCI hygiene).
Standards & Conventions¶
docs/standards/java-spring-guidelines.md—@Scheduled,@TransactionalEventListener(AFTER_COMMIT), events past-tense in…tenant.events, Spring Modulith outbox.docs/standards/api-guidelines.md— 409 on illegal transition; envelope.docs/standards/multi-tenant-model.md— lifecycle propagation via events.docs/standards/design-system.md— Modal, Button danger/success, Badge.docs/standards/compliance-discipline.md— TV-05[LEGAL-REVIEW].docs/standards/critical-rules.md— audit on every mutation.
Testing Requirements¶
Unit Tests¶
- Illegal transitions rejected (suspend from non-ACTIVE; reactivate from non-SUSPENDED).
- Grace-clock reset on reactivation.
Integration Tests¶
- Suspend →
TenantSuspendedpublished once (AFTER_COMMIT) +TENANT_SUSPENDEDaudit; reactivate symmetric. - Daily job soft-deletes only tenants SUSPENDED ≥ 90d; emits
TenantSoftDeleted; each in its own transaction. - CI guard: no raw
UPDATE tenant SET statusoutsideTenantLifecycleService(Technical Risk R7). - Outbox persistence of lifecycle events (re-delivery on failure).
What QA Will Validate¶
- Suspend/reactivate modals render all states; French copy matches ADM-004/005; SUSPENDED banner shows correct purge date (+90j); soft-delete job flips status without purging PII in V0.
Out of Scope¶
- PII purge + source-file deletion on soft-delete (AC-05.2) — V1 (TR-006 purge worker).
- Fiscal/audit retention enforcement + "Cabinets supprimés" view (AC-05.3) — V1.
- Automatic suspension triggers (billing arrears, regulatory kill-switch) — V2.
- Consumer-side reactions (AUTH session kill, CP 503, PAY/OP freeze) — owned by AUTH/CP/PAY/OP (OP-005 + AUTH-006).
Definition of Done¶
- [ ] suspend/reactivate endpoints enforce legal transitions (409 otherwise); reason enum validated
- [ ]
TenantSuspended/TenantReactivated/TenantSoftDeletedemitted AFTER_COMMIT, outbox-persisted, once each - [ ] daily lifecycle job (02:00 Africa/Abidjan) soft-deletes SUSPENDED ≥ 90d; grace resets on reactivation
- [ ] NO PII purge in V0 (explicitly deferred); event emitted only
- [ ] suspend/reactivate modals + SUSPENDED banner render at 360/768/1024; all 5 UI states
- [ ] French micro-copy matches ADM-004 / ADM-005 exactly
- [ ] AC-04.1, AC-04.2, AC-04.3, AC-05.1 pass; reason never customer-facing
- [ ] TENANT_SUSPENDED/REACTIVATED/SOFT_DELETED audit entries present
- [ ] CI guard against raw status UPDATE; unit + integration tests passing