# Design — Panier typé, checkout durci & réservation d'atelier en ligne

**Date** : 2026-07-20
**Statut** : validé en brainstorm, pré-plan d'implémentation
**Sous-projet** : 2/3 de la refonte « Ateliers, Réservation & Bons Cadeaux »
**Dépend de** : sous-projet 1 (page `/ateliers` v3, `Atelier.etapes`, bloc résa → mailto à remplacer)

---

## 1. Contexte & objectif

Le checkout actuel vend des pièces uniques : panier session = tableau plat
d'IDs produits, commande créée dans `initier_paiement` (total recalculé
serveur ✔), capture PayPal dans `retour_paypal`. La cartographie complète
(agent d'exploration, 2026-07-20) a identifié 13 risques, dont :

| # | Risque | Localisation |
|---|--------|--------------|
| R1 | Mutations panier en GET / sans CSRF | `PanierController.php:105`, `ApiCliController.php:152,176` |
| R2 | TOCTOU double-vente pièce unique (check `VENDU` avant capture, sans verrou) | `PaymentController.php:217-225` |
| R3 | Produits `VENDU` non filtrés à la création d'ordre | `PaymentController.php:110`, `ProduitRepository.php:19` |
| R4 | Montant capturé jamais comparé au total commande | `PayPalService.php:282-311` |
| R5 | `retour_paypal` sans try/catch : crash possible APRÈS débit | `PaymentController.php:193-300` |
| R6 | Validation adresse morte (comparaison enum↔string toujours fausse) | `PaymentController.php:75` |
| R7 | Adresse flushée avant paiement (orphelines) | `PaymentController.php:98-99` |
| R8 | ID livraison accepté sans filtre de visibilité | `PaymentController.php:70` |
| R9 | Entité `Client` sérialisée en session (`client_ob`), utilisée pour les emails post-capture | `ClientLoginController.php:99`, `PaymentController.php:271` |
| R10 | Incohérence clés session `client_id` vs `cli_id` | `ApiCliController.php:28,36` |
| R11 | Guard `/paiement` uniquement visuel | `PanierController.php:85` |
| R12 | `sendEmailAPI` : envoi d'emails arbitraires sans auth ni CSRF | `ApiCliController.php:86` |
| R13 | Pas de garde explicite anti double-capture | `PaymentController.php:193` |

Objectif : refondre le panier en lignes typées (prérequis réservation et
bons cadeaux), corriger R1-R13, mettre à jour l'intégration PayPal, et
livrer la réservation d'atelier en ligne de bout en bout (formulaire,
paiement, emails, espace client ; le back-office réservations existe déjà).

## 2. Décisions produit (actées)

| Question | Décision |
|----------|----------|
| Compte client pour réserver | **Obligatoire** (même règle que l'achat produit) |
| Paiement de la résa | **Total en ligne** à la réservation (prix × places) |
| Panier mixte produits + résa | **Oui**, une seule commande ; livraison uniquement pour les produits physiques |
| Annulation client | **Par téléphone/email** (workflow admin existant) — pas d'annulation en ligne |
| Hold de place | À l'initiation du paiement, expiration 45 min si non payé |
| Panier 100 % atelier | `Livraison` dédiée « Sur place — atelier » (0 €, non listée), pas d'adresse postale |
| `sendEmailAPI` legacy | Supprimé |

## 3. Design

### S1 — Panier typé + CartService

Nouvelle forme session, même clé `panier` :

```php
// Ligne produit :  ['type' => 'produit', 'id' => 42]
// Ligne atelier :  ['type' => 'atelier', 'creneauId' => 7, 'places' => 2]
```

- **Migration douce** : au premier accès, un tableau plat d'entiers
  (ancienne forme) est converti en lignes `produit`. Aucune invalidation de
  session utilisateur.
- **`App\Service\CartService`** (nouveau, seul point d'accès au panier) :
  - `getLines(): array` (normalise + migre), `addProduit(int $id)`,
    `addAtelier(int $creneauId, int $places)`,
    `removeProduit(int $id)` / `removeAtelier(int $creneauId)`
    (suppression par identité, jamais par index), `clear()` ;
  - `loadCart(): CartData` — recharge les entités (produits en statut
    `VENTE` uniquement, créneaux `OUVERT` futurs avec places suffisantes),
    **écarte silencieusement les lignes devenues invalides** (produit
    vendu, créneau passé/complet) et retourne : lignes hydratées, total
    produits, total ateliers, total général, `hasPhysical`, `hasAtelier` ;
  - une seule ligne atelier par créneau (re-ajout = remplace `places`).
- `PanierController` et `ApiCliController` délèguent tout à `CartService`.
- **Mutations en POST + CSRF** : les routes d'ajout/retrait passent en
  `methods: POST` (`DELETE` conservé côté API) et exigent le token du meta
  CSRF déjà présent dans le layout ; l'ancienne route GET
  `app_ajouter_au_panier` disparaît (les templates l'appelant sont adaptés).
  → couvre R1.

### S2 — Checkout mixte

- `/paiement` : **guard serveur** (session `client_id` sinon redirect
  login avec retour) → R11. Récap sépare visuellement produits / réservation
  (atelier, date longue, places, sous-total).
- **Livraison conditionnelle** : le choix (et la validation d'adresse
  postale) n'apparaît que si `hasPhysical`. Panier 100 % atelier →
  seulement nom/prénom/téléphone.
- Fixture DB (migration) : `Livraison` « Sur place — atelier », prix 0,
  type retrait, visibilité cachée. Sélectionnée automatiquement côté
  serveur quand `!hasPhysical` (jamais listée) — satisfait le NOT NULL de
  `Commande.livraison` sans migration de schéma risquée.
- Validation serveur réécrite dans `initier_paiement` :
  - comparaison de type livraison **en enum** (fix R6) ;
  - si livraison à domicile requise : adresse/cp/ville obligatoires ;
  - ID livraison accepté seulement si `visbilite = VISIBLE` (ou l'ID
    interne « Sur place » posé par le serveur) → R8 ;
  - adresse construite **sans flush** avant la création de commande → R7.

### S3 — Paiement durci

`initier_paiement` :
- produits rechargés avec filtre `status_boutique = VENTE` → R3 ; créneau
  rechargé `OUVERT` + futur + capacité ;
- total = produits + (prix atelier × places) + livraison, recalculé
  serveur ; items PayPal incluent une ligne « Atelier … » ;
- **hold résa** créé ici (voir S4), dans la même transaction que la
  commande.

`retour_paypal` :
- try/catch global : après capture réussie, toute erreur secondaire
  (emails, analytics) est loggée sans casser la confirmation → R5 ;
- garde anti re-capture : si `commande->getPayment() !== null` →
  redirection confirmation directe (idempotent) → R13 ;
- **vérification du montant** : `captured.amount == commande->getTotal()`
  et devise EUR ; écart → commande `CANCELED` + note interne + alerte
  admin (notification existante), produits/résa non validés ; le
  remboursement de l'écart est géré manuellement par l'admin alerté → R4 ;
- **transaction + verrous pessimistes** (`PESSIMISTIC_WRITE`) sur les
  produits et le créneau pendant la finalisation : produits `VENTE` →
  `VENDU` (sinon anomalie → même traitement que l'écart de montant),
  résa `A_PAYER` → `PAYE` avec re-check capacité sous verrou → R2 ;
- client relu depuis la DB (`commande->getClient()`), plus aucun usage de
  `client_ob` dans le flow → R9.

### S4 — Réservation en ligne

- **Page atelier** : le clic créneau ouvre le mini-formulaire de résa
  (remplace la confirmation mailto) : nombre de places (1 → min(restantes,
  capacité)) et téléphone **obligatoire** (exigence cahier des charges).
  Non connecté → lien login avec retour. Soumission (POST + CSRF) →
  `CartService::addAtelier` (téléphone stocké dans la ligne) → redirect
  `/panier`.
- **Hold** : à `initier_paiement`, création `AtelierReservation` :
  `creneau`, `client`, nom/prénom (du compte), téléphone, `places`,
  `statut = CONFIRMEE`, `source = SITE`, `paiement = A_PAYER`, **nouvelle
  relation `commande`** (ManyToOne nullable + migration). Les places sont
  ainsi décomptées immédiatement (`getPlacesRestantes()` compte les
  non-annulées).
- **Expiration** : la commande cron existante balaye les résas
  `source = SITE`, `paiement = A_PAYER`, `createdAt < now − 45 min` →
  `statut = ANNULEE` (motif « paiement non finalisé ») ; la commande
  associée `WAITING` → `CANCELED`.
- Capture → `paiement = PAYE` (S3). L'admin voit la résa comme les autres
  (aucun changement back-office).

### S5 — Emails

- **Confirmation de commande v2** : migrée sur `AppMailer` + template
  `emails/v2/commande_confirmee.html.twig` (layout v2 existant), listant
  produits et/ou réservation (atelier, date longue, places, adresse de
  l'atelier, consignes tenue). Remplace l'email legacy
  `confirmation_commande.html.twig` ; l'email admin est conservé mais
  pointé vers la route admin actuelle.
- **Rappel J-1** : `app:ateliers:rappel` (pattern des commandes cron
  existantes) : chaque exécution envoie l'email v2
  `emails/v2/atelier_rappel.html.twig` aux résas **`statut = CONFIRMEE` et
  `paiement = PAYE`** dont le créneau est dans les prochaines 24 h, avec
  marqueur anti-doublon (colonne `rappelEnvoyeAt` sur `AtelierReservation`,
  incluse dans la migration).

### S6 — Espace client

- Onglet « Mes réservations » dans le compte (pattern de l'onglet
  affûtages existant) : date longue, atelier, places, montant, statut
  (payée / annulée), consigne « pour annuler ou reporter, appelez-nous ».

### S7 — Nettoyage legacy

- `sendEmailAPI` supprimé (R12) ; route GET d'ajout panier supprimée ;
  clé `cli_id` unifiée sur `client_id` (R10) ; méthodes mortes
  (`afficherPanier` DA1, `PayPalService::createCommande`) supprimées.

### S8 — Tests

- `CartServiceTest` : typage, migration douce ancienne forme, lignes
  invalides écartées, totaux, remplacement de ligne atelier.
- `CheckoutTest` (WebTestCase) : guard `/paiement`, livraison
  conditionnelle, validation adresse (fixée), livraison cachée refusée,
  panier 100 % atelier → « Sur place » auto.
- `PaymentFlowTest` : mock PayPal (client HTTP mocké) — capture OK crée
  Payment + VENDU + résa PAYE ; montant divergent → anomalie ; double
  retour → idempotent ; produit vendu entre-temps → anomalie sans
  validation.
- `ReservationEnLigneTest` : formulaire page atelier (POST), hold à
  l'initiation, expiration cron, rappel J-1 (anti-doublon), onglet compte.
- Suite existante (149) reste verte.

## 4. Hors scope (explicite)

- Bons cadeaux et option envoi papier — sous-projet 3.
- Annulation/report en ligne par le client.
- Webhooks PayPal (amélioration future notée).
- Refonte visuelle panier/paiement (déjà DA 2.0 ; seuls les blocs résa
  s'y ajoutent).
- Multi-ateliers côté client.

## 5. Critères de succès

- Une réservation d'atelier se paie en ligne de bout en bout, apparaît en
  admin (payée) et dans « Mes réservations », emails confirmation + J-1.
- Panier mixte produit + résa passe en une commande ; panier 100 % atelier
  sans adresse ni livraison.
- R1-R13 corrigés et couverts par des tests.
- Suite complète verte.
