PD-84 — Plan d'implémentation¶
Story : PD-84 — Encadrement contractuel de l'offre gratuite pour dossiers probatoires mineurs Version : 1.1.0 Date : 2026-02-24 Spec : PD-84-specification.md v1.3.0 Tests : PD-84-tests.md v1.3.0
Phase 0 — Go/No-Go¶
| Hypothèse | Vérification | Résultat |
|---|---|---|
Table users avec champ plan existe | user.entity.ts ligne 56-57 : plan: string (free/premium/business) | OK — champ existant, à enrichir avec enum typé |
| Service d'audit PD-31 disponible | audit-log.service.ts : log() et logAsync() opérationnels | OK — interface LogAuditParams + fallback BullMQ |
| AuditActionType extensible | audit-action.types.ts : enum extensible, FOLDER_CREATE déjà défini | OK — ajouter les types PD-84 manquants |
| Deposit/document module existant (PD-60/PD-79) | deposit.service.ts, deposit.controller.ts, category-config.service.ts | OK — patterns de scellement réutilisables |
| RLS (Row Level Security) fonctionnel | rls.middleware.ts + rls-context.service.ts + subscribers | OK — isolation par owner_user_id native |
| Framework de test Jest opérationnel | jest.config.ts + test/unit/modules/ | OK — Jest pour unit/integ, pattern NestJS TestingModule |
Verdict Go/No-Go : GO
Aucune dépendance bloquante. Le champ User.plan existe déjà. L'audit PD-31, le RLS et le framework de test sont opérationnels.
Contraintes techniques¶
Dépendances inter-PD¶
| Story | Statut | Nature de la dépendance |
|---|---|---|
| PD-15 (schema users) | DONE | Table users existante — enrichir plan avec enum |
| PD-31 (audit log) | DONE | AuditLogService.logAsync() pour événements non-bloquants |
| PD-37 (HSM audit signature) | DONE | Signature HSM automatique via PD-31 |
| PD-60 (document upload) | DONE | DepositService pour scellement — PD-84 ajoute les gardes de quota |
| PD-79 (catégorie B2C_EVIDENCE_MINOR) | DONE | CategoryConfigService pour validation catégorie |
| PD-85 (export dossier) | TODO → STUB | Endpoints export retournent PREMIUM_REQUIRED — implémentation réelle hors périmètre |
Framework de test¶
- Runner : Jest (CommonJS natif, pas de dépendance ESM)
- Tests unitaires : mocks TypeORM repositories + NestJS
Test.createTestingModule() - Tests d'intégration : PostgreSQL réel avec RLS, advisory locks testables
- Tests de concurrence (TC-LIM-01/02) : barrière de synchronisation via
Promise.all()+ réinitialisation entre itérations - Tests SLA (TC-SLA-01) : injectable
ClockProviderpour mesure p95
Compatibilité ESM/CJS¶
- Aucune dépendance ESM-only identifiée pour PD-84
- Runner Jest standard suffit (pas besoin de Vitest)
1. Découpage en composants¶
COMP-01 — Freemium Module (src/modules/freemium/)¶
Responsabilité : Module NestJS dédié aux règles freemium PD-84. Orchestration des quotas, gestion des dossiers probatoires, capabilities calculées, transition de plan.
| Sous-composant | Fichier | Responsabilité |
|---|---|---|
| FreemiumModule | freemium.module.ts | Déclaration NestJS, imports (TypeORM, Audit, Config) |
| FolderController | controllers/folder.controller.ts | Endpoints REST : POST/GET/POST-close dossiers |
| FolderDocumentController | controllers/folder-document.controller.ts | Endpoint REST : POST documents dans un dossier |
| ExportController | controllers/export.controller.ts | Endpoints REST : exports composite/archive (stub refus FREE) |
| PlanController | controllers/plan.controller.ts | Endpoint REST : PUT /account/plan (stub test/dev) |
| CapabilityController | controllers/capability.controller.ts | Endpoint REST : GET /capabilities |
| FolderService | services/folder.service.ts | Logique métier dossiers : création, clôture, listing, quotas |
| FolderDocumentService | services/folder-document.service.ts | Logique métier ajout document : quota 100, délégation scellement PD-60 |
| CapabilityService | services/capability.service.ts | Calcul CapabilityState à la volée depuis plan_type |
| PlanService | services/plan.service.ts | Transition de plan + SLA propagation + audit |
| QuotaGuard | guards/quota.guard.ts | Vérification atomique des quotas (folders + documents) |
| FolderOwnerGuard | guards/folder-owner.guard.ts | Vérification propriété du dossier (RLS + check explicite) |
| PlanStubGuard | guards/plan-stub.guard.ts | Garde ENABLE_PLAN_STUB=true pour endpoint PUT /account/plan |
COMP-02 — Entités & Migrations (src/database/)¶
Responsabilité : Schéma PostgreSQL pour ProbatoryFolder, extension User.plan, types audit PD-84.
| Sous-composant | Fichier | Responsabilité |
|---|---|---|
| ProbatoryFolder entity | src/modules/freemium/entities/probatory-folder.entity.ts | Entité TypeORM : folder_id, owner, status, category, sealed_document_count |
| PlanType enum | src/modules/freemium/enums/plan-type.enum.ts | Enum FREE / PREMIUM |
| FolderStatus enum | src/modules/freemium/enums/folder-status.enum.ts | Enum ACTIVE / CLOSED_READ_ONLY |
| FolderCategory enum | src/modules/freemium/enums/folder-category.enum.ts | Enum incluant B2C_EVIDENCE_MINOR |
| AccountRole enum | src/modules/freemium/enums/account-role.enum.ts | Enum MINOR / LEGAL_GUARDIAN / OTHER (nullable, non utilisé par PD-84) |
| Migration | src/database/migrations/174XXXXXXXX-PD84-CreateProbatoryFoldersTable.ts | Table probatory_folders + index + RLS + migration plan column + ajout account_role (nullable) sur users |
COMP-03 — DTOs & Validation (src/modules/freemium/dto/)¶
Responsabilité : Validation des entrées, sérialisation des réponses.
| Sous-composant | Fichier | Responsabilité |
|---|---|---|
| CreateFolderDto | create-folder.dto.ts | Validation : display_name (string, non vide), category (enum valide) |
| CloseFolderDto | (pas de body requis) | — |
| AddDocumentDto | add-document.dto.ts | Validation : document_type, métadonnées scellement (délégué PD-60) |
| ChangePlanDto | change-plan.dto.ts | Validation : plan_type (enum FREE / PREMIUM) |
| FolderResponseDto | folder-response.dto.ts | Sérialisation : folder_id, display_name, status, sealed_document_count, capabilities |
| CapabilityResponseDto | capability-response.dto.ts | Sérialisation : 4 booléens de capabilities |
| FreemiumErrorResponse | freemium-error-response.dto.ts | Structure erreur : code, message, (optionnel) upgrade_cta |
COMP-04 — Exceptions PD-84 (src/modules/freemium/exceptions/)¶
Responsabilité : Codes erreur métier PD-84 avec mapping HTTP.
| Sous-composant | Fichier | Responsabilité |
|---|---|---|
| FreemiumException | freemium.exception.ts | Classe d'exception + codes ERR-84-XX + mapping HTTP status |
COMP-05 — Extension Audit PD-31¶
Responsabilité : Ajout des types d'action audit spécifiques PD-84 dans l'enum existant.
| Sous-composant | Fichier | Responsabilité |
|---|---|---|
| AuditActionType (extension) | src/modules/audit/types/audit-action.types.ts | Ajout : FOLDER_CLOSE, QUOTA_FOLDER_REFUSED, QUOTA_DOCUMENT_REFUSED, PLAN_CHANGE, EXPORT_REFUSED |
COMP-06 — Configuration¶
Responsabilité : Variables d'environnement PD-84.
| Variable | Default | Usage |
|---|---|---|
ENABLE_PLAN_STUB | false | Active l'endpoint PUT /account/plan (dev/test uniquement) |
FREEMIUM_MAX_ACTIVE_FOLDERS | 3 | Quota dossiers actifs plan FREE |
FREEMIUM_MAX_DOCUMENTS_PER_FOLDER | 100 | Quota documents par dossier plan FREE |
PLAN_PROPAGATION_TIMEOUT_MS | 30000 | Borne absolue SLA propagation |
2. Flux techniques¶
2.1 Flux nominal — Création d'un dossier (POST /folders)¶
Client → JwtAuthGuard → RlsMiddleware → FolderController.create()
│
├─ 1. Extraire userId du JWT (@CurrentUser)
├─ 2. Valider CreateFolderDto (class-validator pipe)
│ └─ category ∉ FolderCategory → 400 INVALID_FOLDER_CATEGORY
├─ 3. FolderService.createFolder(userId, dto)
│ └─ Transaction sérialisée (pg_advisory_xact_lock sur hash(userId)):
│ ├─ a. Charger User.plan
│ ├─ b. Si plan === FREE: compter dossiers ACTIVE du user
│ │ └─ count >= FREEMIUM_MAX_ACTIVE_FOLDERS → throw QUOTA_FOLDER_LIMIT_REACHED
│ ├─ c. Si plan === PREMIUM: pas de vérification quota
│ ├─ d. INSERT ProbatoryFolder (status=ACTIVE, sealed_document_count=0)
│ └─ e. AuditLogService.logAsync(FOLDER_CREATE, folderId, metadata)
└─ 4. Retourner 201 + FolderResponseDto
2.2 Flux nominal — Ajout de document scellé (POST /folders/{folderId}/documents)¶
Client → JwtAuthGuard → RlsMiddleware → FolderOwnerGuard → FolderDocumentController.addDocument()
│
├─ 1. Extraire userId + folderId
├─ 2. FolderDocumentService.addDocument(userId, folderId, dto)
│ └─ Transaction sérialisée (pg_advisory_xact_lock sur hash(folderId)):
│ ├─ a. Charger ProbatoryFolder avec lock FOR UPDATE
│ │ └─ folder.status === CLOSED_READ_ONLY → throw FOLDER_CLOSED_READ_ONLY
│ ├─ b. Charger User.plan
│ ├─ c. Si plan === FREE: vérifier sealed_document_count
│ │ └─ count >= FREEMIUM_MAX_DOCUMENTS → throw QUOTA_DOCUMENT_LIMIT_REACHED
│ ├─ d. Si plan === PREMIUM: pas de vérification quota document
│ ├─ e. Déléguer scellement à DepositService (PD-60/PD-79) — même pipeline FREE/PREMIUM
│ ├─ f. Incrémenter sealed_document_count (+1 atomique)
│ └─ g. AuditLogService.logAsync(DOCUMENT_SEAL, documentId, {folderId, plan})
└─ 3. Retourner 201 + DocumentResponseDto (avec probatory_seal_ref, integrity_state, anchoring_state)
2.3 Flux nominal — Clôture d'un dossier (POST /folders/{folderId}/close)¶
Client → JwtAuthGuard → RlsMiddleware → FolderOwnerGuard → FolderController.close()
│
├─ 1. Extraire userId + folderId
├─ 2. FolderService.closeFolder(userId, folderId)
│ └─ Transaction atomique :
│ ├─ a. Charger ProbatoryFolder avec lock FOR UPDATE
│ ├─ b. folder.status === CLOSED_READ_ONLY → throw 409 FOLDER_ALREADY_CLOSED (idempotence)
│ ├─ c. UPDATE status = CLOSED_READ_ONLY, closed_at = NOW()
│ └─ d. AuditLogService.logAsync(FOLDER_CLOSE, folderId, {sealed_document_count, closed_reason})
└─ 3. Retourner 200 + FolderResponseDto
2.4 Flux nominal — Transition de plan (PUT /account/plan)¶
Client → JwtAuthGuard → PlanStubGuard → PlanController.changePlan()
│
├─ 1. PlanStubGuard vérifie ENABLE_PLAN_STUB === true
│ └─ Si false → 404 Not Found (endpoint invisible en production)
├─ 2. Valider ChangePlanDto (plan_type: FREE | PREMIUM)
├─ 3. PlanService.changePlan(userId, planType)
│ └─ Transaction :
│ ├─ a. Charger User.plan courant
│ ├─ b. Si identique → retourner directement (idempotent)
│ ├─ c. UPDATE User.plan = newPlan, premium_activated_at = NOW() (si PREMIUM)
│ └─ d. AuditLogService.logAsync(PLAN_CHANGE, userId, {from, to})
└─ 4. Retourner 200 + { plan_type, capabilities }
SLA — Garantie par construction (MAJ-01)¶
L'architecture de transition de plan est entièrement synchrone : un seul UPDATE User.plan + COMMIT dans la même transaction PostgreSQL. Les capabilities sont calculées à la volée depuis User.plan (pas de cache, pas de propagation asynchrone). Par conséquent :
- SLA p95 < 5s : garanti par construction — une transaction PostgreSQL
UPDATE+COMMITprend typiquement < 50ms. PLAN_STATE_INCONSISTENT(HTTP 500) : garde-fou théorique pour un timeout de transaction >PLAN_PROPAGATION_TIMEOUT_MS(30s). En architecture synchrone, ce cas ne se produit pas, mais le code le gère par défense en profondeur.- TC-SLA-01 : mesure p95 sur N=50 transitions réelles (pas de mock). Le test valide que l'architecture synchrone tient la SLA spécifiée. L'instrumentation est intégrée au
PlanService.changePlan(): le temps est mesuré entre le début de la transaction et le COMMIT, puis loggé dans l'événement auditPLAN_CHANGE(champduration_msdans les metadata). - Pas d'observabilité externe requise : la SLA est une propriété structurelle de l'architecture, pas un objectif d'exploitation nécessitant un monitoring dédié.
2.5 Flux nominal — Export refusé en FREE (POST /folders/{folderId}/exports/*)¶
Client → JwtAuthGuard → RlsMiddleware → FolderOwnerGuard → ExportController.exportComposite()
│
├─ 1. Charger User.plan
├─ 2. Si plan === FREE → throw PREMIUM_REQUIRED (422)
│ └─ AuditLogService.logAsync(EXPORT_REFUSED, folderId, {plan, export_type, reason})
└─ 3. Si plan === PREMIUM → 501 Not Implemented (PD-85 pas implémenté)
2.6 Flux nominal — Capabilities (GET /capabilities)¶
Client → JwtAuthGuard → CapabilityController.getCapabilities()
│
├─ 1. Charger User.plan
├─ 2. CapabilityService.computeCapabilities(plan)
│ └─ FREE → { can_export_composite: false, can_export_structured_archive: false, ... }
│ └─ PREMIUM → { can_export_composite: true, can_export_structured_archive: true, ... }
└─ 3. Retourner 200 + CapabilityResponseDto
2.7 Flux downgrade PREMIUM → FREE¶
PUT /account/plan { "plan_type": "FREE" }
│
├─ 1. PlanService.changePlan(userId, FREE)
│ ├─ UPDATE User.plan = FREE
│ └─ Audit PLAN_CHANGE (PREMIUM → FREE)
├─ 2. Effets immédiats (calcul à la volée, aucune action batch) :
│ ├─ GET /capabilities → toutes capabilities false
│ ├─ POST /folders → refusé si count(ACTIVE) > 3
│ ├─ POST /folders/{id}/documents → refusé si sealed_document_count > 100
│ └─ POST /folders/{id}/exports/* → refusé PREMIUM_REQUIRED
└─ 3. Dossiers et documents existants : AUCUNE suppression, AUCUNE modification
2.8 Diagrammes Mermaid¶
2.8.1 Graphe de dépendances entre composants¶
graph TD
subgraph "COMP-01 — Freemium Module"
FC[FolderController]
FDC[FolderDocumentController]
EC[ExportController]
PC[PlanController]
CC[CapabilityController]
FS[FolderService]
FDS[FolderDocumentService]
CS[CapabilityService]
PS[PlanService]
QG[QuotaGuard]
FOG[FolderOwnerGuard]
PSG[PlanStubGuard]
end
subgraph "COMP-02 — Entités & Migrations"
PF[ProbatoryFolder Entity]
PT[PlanType Enum]
FSE[FolderStatus Enum]
FCE[FolderCategory Enum]
MIG[Migration PD-84]
end
subgraph "COMP-03 — DTOs"
CFD[CreateFolderDto]
ADD[AddDocumentDto]
CPD[ChangePlanDto]
FRD[FolderResponseDto]
CRD[CapabilityResponseDto]
end
subgraph "COMP-04 — Exceptions"
FEX[FreemiumException]
end
subgraph "COMP-05 — Audit Extension"
AAT[AuditActionType]
end
subgraph "Services externes (PD existants)"
ALS[AuditLogService — PD-31]
DS[DepositService — PD-60/PD-79]
RLS[RLS Middleware — PD-15]
JWT[JwtAuthGuard — PD-28]
end
FC --> FS
FC --> CFD
FC --> FRD
FDC --> FDS
FDC --> ADD
EC --> CS
EC --> FEX
PC --> PS
PC --> CPD
PC --> PSG
CC --> CS
CC --> CRD
FS --> PF
FS --> QG
FS --> ALS
FS --> FEX
FDS --> PF
FDS --> DS
FDS --> ALS
FDS --> FEX
CS --> PT
PS --> ALS
PS --> PT
PS --> FEX
FOG --> PF
FOG --> RLS
FC --> JWT
FDC --> JWT
FDC --> FOG
EC --> JWT
EC --> FOG
PC --> JWT
CC --> JWT
PF --> FSE
PF --> FCE
PF --> MIG 2.8.2 Diagramme de séquence — Création dossier + ajout document + clôture (flux nominal)¶
sequenceDiagram
participant C as Client
participant JWT as JwtAuthGuard
participant RLS as RlsMiddleware
participant FC as FolderController
participant FS as FolderService
participant PG as PostgreSQL
participant ALS as AuditLogService<br/>(PD-31)
Note over C,ALS: Flux 2.1 — Création dossier (POST /folders)
C->>JWT: POST /folders {display_name, category}
JWT->>RLS: userId extrait du JWT
RLS->>FC: create(userId, dto)
FC->>FS: createFolder(userId, dto)
FS->>PG: pg_advisory_xact_lock(hash(userId))
FS->>PG: SELECT COUNT(*) FROM probatory_folders<br/>WHERE owner_user_id = userId AND status = 'ACTIVE'
alt count < 3 (FREE) ou PREMIUM
FS->>PG: INSERT probatory_folders (status=ACTIVE)
FS->>ALS: logAsync(FOLDER_CREATE, folderId)
FS-->>FC: FolderResponseDto
FC-->>C: 201 Created
else count >= 3 (FREE)
FS->>ALS: logAsync(QUOTA_FOLDER_REFUSED)
FS-->>FC: throw QUOTA_FOLDER_LIMIT_REACHED
FC-->>C: 422 + code erreur + upgrade CTA
end 2.8.3 Diagramme de séquence — Ajout document scellé¶
sequenceDiagram
participant C as Client
participant FOG as FolderOwnerGuard
participant FDC as FolderDocumentController
participant FDS as FolderDocumentService
participant PG as PostgreSQL
participant DS as DepositService<br/>(PD-60)
participant ALS as AuditLogService<br/>(PD-31)
Note over C,ALS: Flux 2.2 — Ajout document (POST /folders/{id}/documents)
C->>FOG: POST /folders/{folderId}/documents
FOG->>PG: Vérifier ownership (RLS + check explicite)
FOG->>FDC: addDocument(userId, folderId, dto)
FDC->>FDS: addDocument(userId, folderId, dto)
FDS->>PG: pg_advisory_xact_lock(hash(folderId))
FDS->>PG: SELECT probatory_folders FOR UPDATE
alt folder.status = CLOSED_READ_ONLY
FDS-->>FDC: throw FOLDER_CLOSED_READ_ONLY
FDC-->>C: 422
else folder.status = ACTIVE
FDS->>PG: Vérifier sealed_document_count (FREE)
alt count < 100 ou PREMIUM
FDS->>DS: createDeposit(dto, folderId)
DS-->>FDS: deposit result (seal_ref, integrity, anchoring)
FDS->>PG: UPDATE sealed_document_count += 1
FDS->>ALS: logAsync(DOCUMENT_SEAL)
FDS-->>FDC: DocumentResponseDto
FDC-->>C: 201 Created
else count >= 100 (FREE)
FDS->>ALS: logAsync(QUOTA_DOCUMENT_REFUSED)
FDS-->>FDC: throw QUOTA_DOCUMENT_LIMIT_REACHED
FDC-->>C: 422 + message Premium
end
end 2.8.4 Diagramme de séquence — Transition de plan (upgrade/downgrade)¶
sequenceDiagram
participant C as Client
participant PSG as PlanStubGuard
participant PC as PlanController
participant PS as PlanService
participant CS as CapabilityService
participant PG as PostgreSQL
participant ALS as AuditLogService<br/>(PD-31)
Note over C,ALS: Flux 2.4 — PUT /account/plan
C->>PSG: PUT /account/plan {plan_type: PREMIUM}
alt ENABLE_PLAN_STUB = false
PSG-->>C: 404 Not Found (endpoint invisible)
else ENABLE_PLAN_STUB = true
PSG->>PC: changePlan(userId, dto)
PC->>PS: changePlan(userId, PREMIUM)
PS->>PG: SELECT User.plan
alt plan identique (idempotent)
PS-->>PC: plan inchangé
else plan différent
PS->>PG: UPDATE User.plan = PREMIUM,<br/>premium_activated_at = NOW()
PS->>ALS: logAsync(PLAN_CHANGE, {from: FREE, to: PREMIUM})
end
PC->>CS: computeCapabilities(PREMIUM)
CS-->>PC: {can_export_composite: true, ...}
PC-->>C: 200 + {plan_type, capabilities}
end 3. Mapping invariants → mécanismes¶
| Invariant ID | Exigence | Mécanisme | Composant | Observable | Risque |
|---|---|---|---|---|---|
| INV-84-01 | Preuves gratuites = même valeur probatoire | Pipeline de scellement PD-60/PD-79 identique FREE/PREMIUM — aucun branchement par plan | COMP-01 FolderDocumentService (délégation DepositService) | TC-11 : comparer attributs scellement FREE vs PREMIUM | Faible — le scellement est en aval, PD-84 ne le touche pas |
| INV-84-02 | Aucune dégradation crypto selon plan | Pas de condition if(plan===FREE) dans le chemin de scellement | COMP-01 FolderDocumentService | TC-SEC-01 : parité attributs crypto | Faible |
| INV-84-03 | Mineur peut constituer dossier minimal | Quotas 3 dossiers + 100 docs suffisants ; audit non-bloquant (logAsync) | COMP-01 FolderService, FolderDocumentService | TC-01, TC-SEC-05 : flux minimal sans erreur | Moyen — si PD-31 indisponible, logAsync absorbe l'erreur (HYP-v2-01) |
| INV-84-04 | Quotas exacts, déterministes, traçables | pg_advisory_xact_lock + SELECT COUNT FOR UPDATE + audit chaque refus | COMP-01 FolderService, COMP-02 migration | TC-02, TC-04, TC-LIM-01, TC-LIM-02 : concurrence 30 itérations | Moyen — verrou advisory nécessite hash stable du userId/folderId |
| INV-84-05 | Montée Premium immédiate sans prérequis | PUT /account/plan + capabilities calculées à la volée | COMP-01 PlanService, CapabilityService | TC-10, TC-SLA-01 : p95 < 5s | Faible — pas de cache à invalider |
| INV-84-06 | Auto-déverrouillage Premium sur dossiers existants | Capabilities calculées depuis User.plan (pas persistées) — changement plan = effet immédiat | COMP-01 CapabilityService | TC-10, TC-18 : capabilities reflètent plan_type courant | Faible — vue calculée, pas de désynchronisation possible |
| INV-84-07 | Export grisé + CTA Premium visible | L'API backend fournit les données structurées permettant le rendu UI : (1) GET /capabilities retourne can_export_*: false (permet de griser le bouton), (2) FolderResponseDto inclut un champ export_cta: { text: "Disponible en Premium", action: "upgrade" } (permet d'afficher le CTA), (3) ExportController retourne PREMIUM_REQUIRED avec upgrade_cta dans le body d'erreur. Le rendu visuel (bouton grisé, pop-up CTA) relève de ProbatioVault-app (mobile iOS). | COMP-01 CapabilityController, ExportController, COMP-03 FolderResponseDto | TC-06 : vérifie que la réponse JSON contient capabilities.can_export_composite: false ET export_cta.text non vide | Faible — données structurées fournies, rendu app mobile |
| INV-84-08 | Clôture libère slot + conserve preuves | Transaction atomique : UPDATE status + compteur ACTIVE recalculé ; documents non touchés | COMP-01 FolderService.closeFolder | TC-07, TC-LIM-03 : clôture puis réallocation slot | Faible |
| INV-84-09 | Plan universel sans distinction d'âge | Aucun branchement par account_role dans les services PD-84. Le champ account_role (MINOR, LEGAL_GUARDIAN, OTHER) est ajouté dans la migration PD-84 comme colonne nullable sur la table users (type enum PostgreSQL account_role_type, default NULL). Aucun service PD-84 ne lit ni ne conditionne sur ce champ — il prépare les stories suivantes de l'epic PD-185. | COMP-01 (tous services) + COMP-02 (migration) | TC-15 : même comportement MINOR/OTHER (le champ existe mais est ignoré par les services) | Faible |
| INV-84-10 | Quota représentant légal séparé du mineur | RLS + isolation par owner_user_id ; comptage par userId | COMP-02 entity + RLS policy | TC-09 : deux comptes, quotas indépendants | Faible — RLS déjà en place |
| INV-84-11 | À 100 docs, blocage + message Premium | Vérification sealed_document_count >= 100 en transaction avec message explicite | COMP-01 FolderDocumentService | TC-04 : refus 101e + message | Faible |
| INV-84-12 | Contenu preuves chiffré côté client | Hérité PD-60/PD-79 — PD-84 ne déchiffre jamais le contenu | COMP-01 (par non-intervention) | TC-SEC-03 : API n'expose pas de contenu en clair | Faible |
| INV-84-13 | Parcours compréhensible adolescent (proxy : 5 étapes) | Test fonctionnel TC-14 : scénario nominal sans erreur bloquante | COMP-01 (flux nominal) | TC-14 : 0 erreur sur 5 étapes | Non testable complet — UX hors périmètre |
| INV-84-14 | Pas d'export probatoire complet gratuit | Guard plan FREE → PREMIUM_REQUIRED sur tous les endpoints export | COMP-01 ExportController | TC-05 : refus export composite + archive | Faible |
| INV-84-15 | Toute décision de refus auditée | AuditLogService.logAsync() sur chaque refus quota, clôture, changement plan, export refusé | COMP-05 AuditActionType extension | TC-13 : 5 événements d'audit distincts | Moyen — si PD-31 down, événements en queue BullMQ |
4. Mapping critères d'acceptation → mécanismes¶
| Critère ID | Mécanisme(s) | Composant | Observable | Risque |
|---|---|---|---|---|
| CA-84-01 | FolderService.createFolder() : INSERT si count(ACTIVE) < 3 (FREE) ou illimité (PREMIUM) | COMP-01 | HTTP 201 + folder ACTIVE | Faible |
| CA-84-02 | pg_advisory_xact_lock(hash(userId)) + COUNT >= 3 → throw QUOTA_FOLDER_LIMIT_REACHED | COMP-01 | HTTP 422 + code erreur métier | Faible |
| CA-84-03 | FolderDocumentService.addDocument() : INSERT si count < 100 (FREE) | COMP-01 | HTTP 201 + sealed_document_count = 100 | Faible |
| CA-84-04 | COUNT >= 100 → throw QUOTA_DOCUMENT_LIMIT_REACHED + message Premium | COMP-01, COMP-04 | HTTP 422 + code + message | Faible |
| CA-84-05 | ExportController : check User.plan === FREE → throw PREMIUM_REQUIRED | COMP-01 | HTTP 422 + code | Faible |
| CA-84-06 | GET /capabilities retourne can_export_*: false + FolderResponseDto inclut export_cta: { text, action } + ExportController retourne PREMIUM_REQUIRED avec upgrade_cta dans le body. L'API backend fournit toutes les données structurées pour que l'app mobile iOS affiche le bouton grisé et le CTA. | COMP-01, COMP-03 | TC-06 : réponse JSON avec capabilities.can_export_*: false ET export_cta.text non vide | Faible — rendu visuel = ProbatioVault-app |
| CA-84-07 | FolderService.closeFolder() : UPDATE status, recalcul slots | COMP-01 | HTTP 200 + status CLOSED + slot libéré | Faible |
| CA-84-08 | Refus systématique ajout doc : FolderDocumentService vérifie folder.status === CLOSED_READ_ONLY → throw FOLDER_CLOSED_READ_ONLY (HTTP 422). Export verrouillé : ExportController vérifie User.plan → FREE → throw PREMIUM_REQUIRED (le verrouillage export est indépendant de la clôture : il est contrôlé par le plan, pas par le statut du dossier). Après downgrade PREMIUM→FREE, les exports sont re-verrouillés immédiatement car User.plan repasse à FREE. | COMP-01 FolderDocumentService + ExportController | TC-08 (refus ajout doc clôturé) + TC-05 (refus export FREE) + TC-19 (export re-verrouillé après downgrade) | Faible |
| CA-84-09 | RLS policy owner_user_id + comptage par userId isolé | COMP-02 | Quotas indépendants entre U_MINOR_A et U_GUARDIAN_B | Faible |
| CA-84-10 | PlanService.changePlan() + CapabilityService.computeCapabilities() à la volée | COMP-01 | Capabilities PREMIUM immédiatement après changement | Faible |
| CA-84-11 | Pas de branchement plan dans le pipeline de scellement PD-60 | COMP-01 (non-intervention) | Attributs scellement identiques | Faible |
| CA-84-12 | Aucun cron/scheduler de reset quota — vérification par absence de code | COMP-01 | TC-12 : quotas stables après avance horloge | Faible |
| CA-84-13 | AuditLogService.logAsync() après chaque événement sensible (5 types) | COMP-05 | 5 événements audit avec actor, type, timestamp, resource_id | Moyen |
| CA-84-14 | Flux nominal 5 étapes sans erreur bloquante | COMP-01 (tous controllers) | TC-14 : 0 erreur | Non testable complet |
5. Mapping tests (TC-*) → mécanismes + observables¶
| Test ID | Référence spec | Mécanisme(s) | Point(s) d'observation | Niveau de test |
|---|---|---|---|---|
| TC-01 | INV-84-03, INV-84-04, CA-84-01 | FolderService.createFolder() x3 | 3 dossiers ACTIVE créés, GET /folders retourne 3 | Integration |
| TC-02 | INV-84-04, CA-84-02 | FolderService.createFolder() → exception quota | HTTP 422 + QUOTA_FOLDER_LIMIT_REACHED + audit event | Integration |
| TC-03 | INV-84-03, INV-84-04, CA-84-03 | FolderDocumentService.addDocument() à 99→100 | sealed_document_count = 100 + seal attributes | Integration |
| TC-04 | INV-84-04, INV-84-11, CA-84-04 | FolderDocumentService.addDocument() → exception quota | HTTP 422 + QUOTA_DOCUMENT_LIMIT_REACHED + message Premium | Integration |
| TC-05 | INV-84-14, CA-84-05 | ExportController → PREMIUM_REQUIRED | HTTP 422 sur composite + archive | Unit |
| TC-06 | INV-84-07, CA-84-06 | CapabilityController.getCapabilities() + réponse export_cta | JSON : capabilities false + CTA texte | Unit (API) / E2E (UI) |
| TC-07 | INV-84-03, INV-84-08, CA-84-07 | FolderService.closeFolder() + slot réallocation | HTTP 200 + CLOSED_READ_ONLY + slot libéré + nouvelle création OK | Integration |
| TC-08 | INV-84-08, CA-84-08 | FolderDocumentService.addDocument() sur dossier clôturé | HTTP 422 + FOLDER_CLOSED_READ_ONLY | Unit |
| TC-09 | INV-84-10, CA-84-09 | Deux userIds distincts, comptage indépendant | Quotas non interférents | Integration |
| TC-10 | INV-84-05, INV-84-06, CA-84-10 | PlanService.changePlan() + CapabilityService | Capabilities PREMIUM immédiatement après PUT | Integration |
| TC-11 | INV-84-01, INV-84-02, CA-84-11 | Scellement PD-60 sans branchement plan | Attributs hash/horodatage/ancrage identiques FREE/PREMIUM | Integration |
| TC-12 | INV-84-04, CA-84-12 | Absence de cron reset quota | Quotas stables après avance horloge mock | Unit |
| TC-13 | INV-84-15, CA-84-13 | AuditLogService.logAsync() × 5 actions | 5 événements audit avec structure complète | Integration |
| TC-14 | INV-84-13, CA-84-14 | Flux nominal 5 étapes | 0 erreur bloquante | Integration |
| TC-15 | INV-84-09, INV-84-05 | FolderService pour MINOR et OTHER | Même comportement, pas de branchement role | Unit |
| TC-16 | F-84-19 | CreateFolderDto validation + FolderService | HTTP 400 INVALID_FOLDER_CATEGORY | Unit |
| TC-17 | INV-84-08 | FolderService.closeFolder() sur dossier déjà clôturé | HTTP 409 FOLDER_ALREADY_CLOSED | Unit |
| TC-18 | INV-84-06 | CapabilityService après upgrade + downgrade | Capabilities reflètent plan courant à chaque bascule | Integration |
| TC-19 | Spec 3.2.4 | Downgrade : conservation dossiers + re-verrouillage quotas | 4 dossiers conservés, refus création 5e, refus ajout 111e doc, export re-verrouillé | Integration |
| TC-LIM-01 | INV-84-04 | pg_advisory_xact_lock sur création parallèle | 1 succès + 1 refus × 30 itérations déterministes | Integration (concurrence) |
| TC-LIM-02 | INV-84-04, INV-84-11 | pg_advisory_xact_lock sur ajout document parallèle | 1 succès + 1 refus × 30 itérations déterministes | Integration (concurrence) |
| TC-LIM-03 | INV-84-08 | Clôture au plafond + réallocation slot | Slot libéré, nouveau dossier OK, ancien en lecture seule | Integration |
| TC-LIM-04 | INV-84-05, INV-84-06 | Upgrade pendant tentative d'export | 1er refus, upgrade, 2e autorisé | Integration |
| TC-SLA-01 | INV-84-05, INV-84-06, CA-84-10 | Mesure p95 sur N=50 transitions | p95 < 5s, max < 30s, sinon PLAN_STATE_INCONSISTENT | Performance |
| TC-SEC-01 | SEC-84-01 | Pipeline scellement identique | Attributs crypto identiques | Security |
| TC-SEC-02 | SEC-84-02 | FolderOwnerGuard + RLS | Refus accès cross-user sur GET/POST/CLOSE | Security |
| TC-SEC-03 | SEC-84-04 | Réponses API sans contenu en clair | JSON ne contient que métadonnées | Security |
| TC-SEC-04 | SEC-84-03 | Audit événements sensibles | Journalisation avec acteur + timestamp | Security |
| TC-SEC-05 | SEC-84-05 | Flux minimal FREE réalisable | Créer dossier + ajouter doc en FREE | Security |
| TC-10-bis | ECT-08 | PlanStubGuard vérifie ENABLE_PLAN_STUB | Endpoint invisible si env var false | Unit |
| TC-12-bis | CA-84-12 | Alias TC-12 — absence de reset mensuel | Même observable que TC-12 | Unit |
6. Gestion des erreurs¶
6.1 Codes erreur PD-84¶
| Code | HTTP | Condition | Message utilisateur | Composant |
|---|---|---|---|---|
QUOTA_FOLDER_LIMIT_REACHED | 422 | count(ACTIVE folders) >= 3 en FREE | "Limite de 3 dossiers actifs atteinte. Passez en Premium pour des dossiers illimités." | FolderService |
QUOTA_DOCUMENT_LIMIT_REACHED | 422 | sealed_document_count >= 100 en FREE | "Limite de 100 documents atteinte. Passez en Premium pour des dossiers illimités." | FolderDocumentService |
PREMIUM_REQUIRED | 422 | Tentative export en plan FREE | "Disponible en Premium" | ExportController |
FOLDER_CLOSED_READ_ONLY | 422 | Ajout document sur dossier clôturé | "Ce dossier est clôturé et en lecture seule." | FolderDocumentService |
FOLDER_ALREADY_CLOSED | 409 | Clôture sur dossier déjà clôturé | "Ce dossier est déjà clôturé." | FolderService |
INVALID_FOLDER_CATEGORY | 400 | Catégorie dossier non reconnue | "Catégorie de dossier invalide." | CreateFolderDto / FolderService |
FOLDER_NOT_FOUND | 404 | folderId inexistant ou non accessible | "Dossier introuvable." | FolderOwnerGuard |
PLAN_STATE_INCONSISTENT | 500 | SLA propagation > 30s | "État de plan incohérent. Veuillez réessayer." | PlanService |
6.2 Pattern d'exception¶
// Même pattern que MerkleException (PD-237)
export type FreemiumErrorCode =
| 'QUOTA_FOLDER_LIMIT_REACHED'
| 'QUOTA_DOCUMENT_LIMIT_REACHED'
| 'PREMIUM_REQUIRED'
| 'FOLDER_CLOSED_READ_ONLY'
| 'FOLDER_ALREADY_CLOSED'
| 'INVALID_FOLDER_CATEGORY'
| 'FOLDER_NOT_FOUND'
| 'PLAN_STATE_INCONSISTENT';
export class FreemiumException extends HttpException {
constructor(code: FreemiumErrorCode, message: string) { ... }
}
6.3 Audit des erreurs¶
Chaque erreur métier de type refus (quota, export, plan) génère un événement audit via AuditLogService.logAsync(). L'audit est non-bloquant : si PD-31 est indisponible, le refus est retourné au client et l'événement est en queue BullMQ pour retry. Ceci respecte INV-84-03 (la constitution de dossier n'est jamais bloquée par l'audit) et le cadrage SEC-84-03/ECT-v2-03.
7. Impacts sécurité¶
7.1 Risques et mitigations¶
| Risque | Mitigation | Référence |
|---|---|---|
| PUT /account/plan accessible en production | Triple verrou : (1) PlanStubGuard vérifie ENABLE_PLAN_STUB === true (désactivé par défaut, retourne 404 si false). (2) Test contractuel TC-10-bis : valide que ENABLE_PLAN_STUB=false rend l'endpoint invisible (404). (3) Check CI : script scripts/check-env-production.sh vérifie que ENABLE_PLAN_STUB n'est pas true dans les fichiers .env.production* — échec = pipeline bloqué. | SEC-v2-03, ECT-v2-05 |
| Dépassement quota par concurrence | pg_advisory_xact_lock sur hash(userId) pour création dossier, hash(folderId) pour ajout document. Sérialisation au niveau DB. | INV-84-04, ECT-02 |
| Accès horizontal (cross-user) | RLS policy owner_user_id + FolderOwnerGuard explicite (double vérification) | SEC-84-02 |
| Amplification audit par flood de requêtes quota | Rate limiting global existant (PD-19). PD-84 ne définit pas de rate limiting additionnel (hors périmètre, spec 10.2). | SEC-v2-01 — risque accepté |
| Dégradation crypto selon plan | Aucun branchement plan dans le pipeline de scellement. Le chemin de code PD-60/PD-79 est identique. | INV-84-01, INV-84-02 |
7.2 Journalisation¶
Événements auditables PD-84 à ajouter à AuditActionType :
| Type | Quand | Métadonnées |
|---|---|---|
FOLDER_CLOSE | Clôture d'un dossier | { folderId, sealed_document_count, closed_reason } |
QUOTA_FOLDER_REFUSED | Refus création 4e dossier FREE | { userId, plan, active_count, max_allowed } |
QUOTA_DOCUMENT_REFUSED | Refus 101e document FREE | { folderId, plan, document_count, max_allowed } |
PLAN_CHANGE | Transition de plan (upgrade/downgrade) | { userId, from_plan, to_plan } |
EXPORT_REFUSED | Tentative export en FREE | { userId, folderId, plan, export_type, reason } |
Note : FOLDER_CREATE existe déjà dans AuditActionType.
7.3 RGPD¶
- PD-84 ne collecte aucune donnée personnelle supplémentaire au-delà de
owner_user_id(déjà couvert par PD-15). - Le champ
account_role(MINOR, LEGAL_GUARDIAN, OTHER) est ajouté comme colonne nullable dans la migration PD-84 mais n'est utilisé par aucun service PD-84. Sa valorisation et sa gestion RGPD (consentement parental < 16 ans) relèvent de l'epic PD-185. - Le contenu des preuves reste chiffré côté client (INV-84-12, hérité PD-60).
8. Hypothèses techniques¶
| ID | Hypothèse | Impact si faux |
|---|---|---|
| H-01 | Le champ User.plan (string 'free'/'premium') peut être migré vers un enum PostgreSQL plan_type ('FREE'/'PREMIUM') via migration ALTER COLUMN sans downtime | Si faux : utiliser une migration en deux temps (ajout colonne + backfill + suppression ancienne). Risque faible — le champ est petit et indexé. |
| H-02 | pg_advisory_xact_lock est disponible et performant sous charge concurrente PD-84 | Si faux : utiliser SELECT ... FOR UPDATE SKIP LOCKED ou contrainte UNIQUE + transaction. Risque faible — pattern déjà utilisé dans PD-60 deposit. |
| H-03 | AuditLogService.logAsync() absorbe les erreurs PD-31 sans bloquer les opérations métier | Si faux : encapsuler dans un try/catch supplémentaire. Vérifié dans le code source — logAsync retourne { queued: true } en cas de fallback BullMQ. |
| H-04 | L'horloge injectable (Clock/TimeProvider) est disponible pour TC-12 et TC-SLA-01 | Si faux : créer un ClockProvider injectable (service NestJS) avec implémentation SystemClock et MockClock pour les tests. Pattern standard. |
| H-05 | Le pipeline de scellement PD-60/PD-79 (DepositService.createDeposit()) accepte un folderId en paramètre | Si faux : étendre l'interface CreateDepositDto pour inclure folderId optionnel. La relation deposit→folder est ajoutée dans PD-84. |
| H-06 | La table probatory_folders sera protégée par les mêmes RLS policies que les autres tables vault_secure | Si faux : ajouter les policies RLS dans la migration (pattern existant dans les autres migrations). |
| H-07 | Le rate limiting global existant (PD-19) suffit à protéger contre l'amplification audit (SEC-v2-01) | Si faux : ajouter un rate limiter spécifique sur les endpoints de quota. Risque accepté par la spec (hors périmètre PD-84). |
| H-08 | ENABLE_PLAN_STUB n'est jamais true dans les fichiers .env.production* | Si faux : le check CI scripts/check-env-production.sh bloque le pipeline. Triple verrou : guard runtime + test contractuel + check CI. |
9. Points de vigilance (risques, dette, pièges)¶
9.1 Risques¶
-
Migration du champ
User.plan: Le champ actuel est unstring('free'). La migration vers un enum PostgreSQL doit gérer les valeurs existantes ('free' → 'FREE', 'premium' → 'PREMIUM'). Prévoir un script de backfill dans la migration. -
Atomicité des quotas sous concurrence : Les TC-LIM-01 et TC-LIM-02 exigent un résultat déterministe sur 30 itérations. Le
pg_advisory_xact_lockdoit utiliser un hash stable (bigint) du userId/folderId. UtiliserhashCode(uuid)via une fonction PL/pgSQL ou un hash côté application. -
Couplage avec PD-60 pour le scellement : PD-84 ajoute un
folderIdau flow de dépôt. Si l'interfaceDepositServicechange dans une story ultérieure, PD-84 doit être mis à jour. Minimiser le couplage en passant lefolderIdcomme métadonnée optionnelle. -
TC-SLA-01 (N=50) : Le test de performance SLA nécessite un environnement stable (pas de contention CI). Prévoir une tolérance ou un marqueur
@Slowpour l'exécuter séparément. -
TC-06 (test UI) : Ce test valide des éléments visuels ("bouton grisé", "CTA visible"). En l'absence de frontend, il sera couvert côté API uniquement (vérification du JSON retourné). Le test E2E complet relève de l'application mobile (PD-185 epic).
9.2 Dette technique identifiée¶
-
Champ
account_role: La spec définitMINOR,LEGAL_GUARDIAN,OTHER. La migration PD-84 crée la colonneaccount_role(enum PostgreSQLaccount_role_type, nullable, default NULL) sur la tableusers. Aucun service PD-84 ne lit ni ne conditionne sur ce champ — il prépare les stories suivantes de l'epic PD-185. La valorisation du champ relèvera du flux d'inscription mineur (PD-185). -
Champ
closed_reason: Modélisé dans la spec (section 3.1) mais jamais peuplé par les endpoints PD-84 (AMB-v2-03). Il sera nullable en base, prêt pour enrichissement futur. -
Export PREMIUM : Les endpoints export retournent
PREMIUM_REQUIREDen FREE et501 Not Implementeden PREMIUM (PD-85 pas encore implémenté). C'est un stub explicite.
9.3 Pièges à éviter¶
-
Ne PAS toucher au pipeline de scellement PD-60/PD-79 : PD-84 ajoute des gardes de quota en amont du scellement. Le scellement lui-même est identique FREE/PREMIUM.
-
Ne PAS persister CapabilityState : C'est une vue calculée (ECT-06). Toute persistance introduirait un risque de désynchronisation.
-
Ne PAS ajouter de cron/scheduler de reset quota : INV-84-04 interdit toute remise à zéro périodique.
-
crypto.randomUUID() : Utiliser pour tout identifiant (pas Math.random()). Convention backend existante.
10. Hors périmètre¶
| Élément | Raison | Story de référence |
|---|---|---|
| Tarification Premium, paiement, facturation | Couvert par story tarification dédiée | — |
| Implémentation technique des exports (composite, archive) | PD-85 | Stub PREMIUM_REQUIRED |
| Hotline juridique/psy, transfert Legal PRE | Epic PD-185 | — |
| API e-Enfance, détection contenu sensible | PD-87, PD-86 | — |
| Rate limiting spécifique endpoints PD-84 | Infrastructure gateway transverse | ECT-v2-04 |
| Validation UX complète adolescent (SUS, questionnaire) | Campaign UX transverse | ECT-v2-01 |
| Consentement parental RGPD (< 16 ans) | Gouvernance conformité transverse | SEC-v2-02 |
| Frontend/UI mobile iOS | ProbatioVault-app | TC-06 stub API only |
| Downgrade automatique (expiration abonnement) | Story tarification — PD-84 fournit le mécanisme PUT /account/plan | — |
Checklist INV/CA pre-Gate 5¶
| Invariant/CA | Tâche couvrant | Test couvrant |
|---|---|---|
| INV-84-01 | FolderDocumentService (délégation PD-60 sans branchement plan) | TC-11, TC-SEC-01 |
| INV-84-02 | FolderDocumentService (même pipeline scellement) | TC-11, TC-SEC-01 |
| INV-84-03 | FolderService.createFolder + FolderDocumentService.addDocument | TC-01, TC-03, TC-SEC-05 |
| INV-84-04 | pg_advisory_xact_lock + COUNT + audit | TC-02, TC-03, TC-04, TC-13, TC-LIM-01, TC-LIM-02, TC-16 |
| INV-84-05 | PlanService.changePlan + CapabilityService | TC-10, TC-15, TC-SLA-01 |
| INV-84-06 | CapabilityService (vue calculée) | TC-10, TC-18 |
| INV-84-07 | CapabilityController + ExportController (CTA dans réponse) | TC-06 |
| INV-84-08 | FolderService.closeFolder (transaction atomique) | TC-07, TC-08, TC-LIM-03 |
| INV-84-09 | Aucun branchement account_role dans les services | TC-15 |
| INV-84-10 | RLS + comptage par owner_user_id | TC-09 |
| INV-84-11 | FolderDocumentService (quota 100 + message) | TC-04, TC-LIM-02 |
| INV-84-12 | Non-intervention sur chiffrement PD-60 | TC-SEC-03 |
| INV-84-13 | Flux nominal 5 étapes (proxy testable) | TC-14 |
| INV-84-14 | ExportController → PREMIUM_REQUIRED | TC-05 |
| INV-84-15 | AuditLogService.logAsync sur 5 types d'événements | TC-13, TC-SEC-04 |
| CA-84-01 | FolderService.createFolder | TC-01 |
| CA-84-02 | FolderService.createFolder → exception quota | TC-02 |
| CA-84-03 | FolderDocumentService.addDocument (99→100) | TC-03 |
| CA-84-04 | FolderDocumentService.addDocument → exception quota | TC-04 |
| CA-84-05 | ExportController → PREMIUM_REQUIRED | TC-05 |
| CA-84-06 | CapabilityController + réponse CTA | TC-06 |
| CA-84-07 | FolderService.closeFolder | TC-07 |
| CA-84-08 | FolderDocumentService check status clôturé + ExportController check plan FREE | TC-08, TC-05, TC-19 |
| CA-84-09 | RLS isolation + comptage indépendant | TC-09 |
| CA-84-10 | PlanService + CapabilityService | TC-10, TC-SLA-01 |
| CA-84-11 | Pipeline scellement PD-60 identique | TC-11 |
| CA-84-12 | Absence de cron reset | TC-12 |
| CA-84-13 | AuditLogService.logAsync × 5 | TC-13 |
| CA-84-14 | Flux nominal 5 étapes | TC-14 |
Couverture : 15/15 INV couverts, 14/14 CA couverts. Aucune ligne vide.