Aller au contenu

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 ClockProvider pour 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 + COMMIT prend 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 audit PLAN_CHANGE (champ duration_ms dans 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 ExportControllerPREMIUM_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

  1. Migration du champ User.plan : Le champ actuel est un string ('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.

  2. 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_lock doit utiliser un hash stable (bigint) du userId/folderId. Utiliser hashCode(uuid) via une fonction PL/pgSQL ou un hash côté application.

  3. Couplage avec PD-60 pour le scellement : PD-84 ajoute un folderId au flow de dépôt. Si l'interface DepositService change dans une story ultérieure, PD-84 doit être mis à jour. Minimiser le couplage en passant le folderId comme métadonnée optionnelle.

  4. 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 @Slow pour l'exécuter séparément.

  5. 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

  1. Champ account_role : La spec définit MINOR, LEGAL_GUARDIAN, OTHER. La migration PD-84 crée la colonne account_role (enum PostgreSQL account_role_type, nullable, default NULL) sur la table users. 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).

  2. 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.

  3. Export PREMIUM : Les endpoints export retournent PREMIUM_REQUIRED en FREE et 501 Not Implemented en PREMIUM (PD-85 pas encore implémenté). C'est un stub explicite.

9.3 Pièges à éviter

  1. 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.

  2. Ne PAS persister CapabilityState : C'est une vue calculée (ECT-06). Toute persistance introduirait un risque de désynchronisation.

  3. Ne PAS ajouter de cron/scheduler de reset quota : INV-84-04 interdit toute remise à zéro périodique.

  4. 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.