# Panier typé + checkout durci + résa atelier en ligne — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Réservation d'atelier payée en ligne de bout en bout + panier en lignes typées + correction R1-R13 (spec `docs/superpowers/specs/2026-07-20-panier-resa-checkout-design.md`).

**Architecture:** `CartService` unique (session lignes typées, migration douce) ; hold de place `ATTENTE` transactionnel à l'initiation ; capture idempotente sous verrous avec vérification de montant ; emails v2 via `AppMailer` ; expiration + rappel J-1 intégrés au `app:cron` existant (lock OVH déjà schedulé).

**Tech Stack:** Symfony, Doctrine (verrous pessimistes), PayPal Orders v2 (service mockable en test), Twig, PHPUnit (MySQL réel).

## Global Constraints

- Contrats JSON front PRÉSERVÉS : `addToCart` → `{success, message, cartCount, productName?}` ; `delProductCart` → `{success, message}` **+ ajout `cartCount`** (le front le lit déjà — bug connu).
- Champs POST checkout inchangés : `prenom, nom, num_tel, adresse, cp, ville, info_supp, livraison` (+ nouveau `_token`).
- Hold résa : `statut = ATTENTE`, `paiement = A_PAYER` (compte dans `getPlacesRestantes`) ; capture → `CONFIRMEE` + `PAYE` ; expiration 45 min → `ANNULEE`. (Précision vs spec : `ATTENTE` existe déjà dans l'enum — plus propre que CONFIRMEE dès le hold.)
- CSRF public : nouveau `<meta name="app-csrf" content="{{ csrf_token('public_api') }}">` dans `base.html.twig` ; JS envoie `X-CSRF-Token` ; serveur vérifie id `public_api`. Forms HTML : hidden `_token` (`csrf_token('checkout')` / `csrf_token('resa')`).
- Suite existante (149) verte à chaque commit. Messages commit français.
- MySQL local requis ; tests PayPal : `PayPalService` remplacé par stub dans le container de test (`static::getContainer()->set(...)`).

## Tâches

### T1 — Socle : entité, migration, CSRF public

**Files:** Modify `src/Entity/AtelierReservation.php`, `templates/base.html.twig` ; Create `migrations/Version20260721060000.php`.

- `AtelierReservation` : `+ commande` (`ManyToOne(Commande::class)`, JoinColumn nullable, pas d'inverse) ; `+ rappelEnvoyeAt` (`?\DateTimeImmutable`, nullable) ; getters/setters.
- Migration :
```sql
ALTER TABLE atelier_reservation ADD commande_id INT DEFAULT NULL, ADD rappel_envoye_at DATETIME DEFAULT NULL;
ALTER TABLE atelier_reservation ADD CONSTRAINT FK_resa_commande FOREIGN KEY (commande_id) REFERENCES commande (id) ON DELETE SET NULL;
CREATE INDEX IDX_resa_commande ON atelier_reservation (commande_id);
INSERT INTO livraison (libelle, prix, description, type_livraison, visbilite) VALUES ('Sur place — atelier', 0.00, 'Réservation d''atelier — rendez-vous à la coutellerie', 'no_livraison', 'no_visible');
```
- `base.html.twig` head : meta `app-csrf` (id `public_api`).
- Run migrations dev + test. Commit.

### T2 — CartService + tests

**Files:** Create `src/Service/CartService.php`, `tests/Gestion/CartServiceTest.php`.

Signatures (session via `RequestStack`) :
```php
final class CartService
{
    public const SESSION_KEY = 'panier';
    public function __construct(private RequestStack $rs, private ProduitRepository $produits, private AtelierCreneauRepository $creneaux) {}
    /** @return list<array> lignes normalisées ; migre l'ancienne forme (ints) en lignes produit ; écarte l'inconnu */
    public function getLines(): array;
    public function addProduit(int $id): array;              // ['ok'=>bool,'reason'=>?string('absent'|'deja'|'indisponible'),'produit'=>?Produit]
    public function addAtelier(int $creneauId, int $places, string $telephone): array; // ['ok'=>bool,'reason'=>?string('creneau'|'places')]
    public function removeProduit(int $id): void;
    public function removeAtelier(int $creneauId): void;
    public function clear(): void;
    public function count(): int;                            // nb lignes (badge)
    /** Recharge entités, écarte lignes invalides (produit non VENTE, créneau non OUVERT/passé/places insuffisantes), sauve si nettoyé.
     *  @return array{produits: Produit[], resa: ?array{creneau: AtelierCreneau, places: int, telephone: string, sousTotal: float},
     *               totalProduits: float, total: float, hasPhysical: bool, hasAtelier: bool} */
    public function loadCart(): array;
}
```
- `addAtelier` : créneau `OUVERT` futur, `1 <= places <= placesRestantes` ; une seule ligne atelier par créneau (ré-ajout remplace).
- Tests (KernelTestCase, `RequestStack` avec `Request` + session `MockArraySessionStorage`) : migration douce `[3,7]` → lignes produit ; add/remove produit et atelier ; remplacement ligne atelier ; `loadCart` écarte produit VENDU et créneau complet ; totaux (produit prixVente + atelier prix×places).

### T3 — Endpoints panier refondus + front + nettoyage legacy

**Files:** Modify `src/Controller/ApiCliController.php`, `src/Controller/PanierController.php`, `public/scripts/script.js`, `templates/panier/newindex.html.twig` ; templates appelant l'ancienne route GET.

- `addToCart` : garde POST, ajoute vérif `X-CSRF-Token` (`public_api`) → 403 JSON si absent/invalide ; délègue à `CartService::addProduit` ; réponses identiques (`cartCount = $cart->count()`).
- `delProductCart` : DELETE + CSRF ; délègue ; réponse `{success, message, cartCount}`.
- Nouvelle route `POST /panier/atelier` (`app_panier_add_atelier`, dans `PanierController`) : form `creneau`, `places`, `telephone`, `_token` (`resa`) ; connecté requis (redirect login sinon) ; `CartService::addAtelier` ; flash + redirect `app_panier`.
- Nouvelle route `POST /panier/atelier/retirer` (`app_panier_remove_atelier`) : retire la ligne résa (CSRF).
- `script.js::addToCartAjax` : ajoute header `X-CSRF-Token` lu du meta `app-csrf`. `deleteProductV2` (template panier) : idem.
- **Suppressions** : route GET `app_ajouter_au_panier` (+ tous les `href/onclick` la référençant → basculer sur `addToCartAjax`), `sendEmailAPI` (`ApiCliController:86`), `PanierController::afficherPanier` (DA1), `PayPalService::createCommande` (legacy), clé session `cli_id` → `client_id` (`ApiCliController::index`).
- `PanierController::afficherPanierV2`/`paiementV2` : données via `CartService::loadCart()`.
- Tests (WebTestCase `CheckoutTest`) : CSRF manquant → 403 ; ajout/suppression OK avec token ; contrat JSON exact ; route GET supprimée → 404 ; `sendEmailAPI` → 404.

### T4 — Page atelier : formulaire de résa réel

**Files:** Modify `templates/ateliers/index.html.twig`, `public/scripts/ateliers.js`, `tests/Gestion/AtelierPublicTest.php`.

- Bloc `.atl-confirm` remplacé : après sélection d'un créneau → formulaire inline (POST `app_panier_add_atelier`) : hidden `creneau` (id), select `places` (options 1..min(restantes, capacité), rendu par créneau côté serveur via `data-max`), input tel `telephone` (pré-rempli `app.session.client_ob` si dispo — sinon vide), `_token`, bouton « Réserver ce créneau — {prix} » (JS met à jour le montant places×prix affiché).
- Non connecté (`app.session.get('client_id') is null`) : à la place du formulaire, CTA « Se connecter pour réserver » → `path('app_client_login', {route: 'ateliers'})`.
- Slots portent `data-creneau-id`, `data-max`. JS : remplit hidden + max du select + montant.
- Tests : slot porte `data-creneau-id` ; connecté → formulaire présent (POST simulé crée ligne panier — vérifier redirect + session) ; non connecté → lien login.

### T5 — Checkout mixte (templates + validation)

**Files:** Modify `templates/panier/newindex.html.twig`, `templates/panier/newpaiement.html.twig`, `src/Controller/PanierController.php`.

- Panier : après les lignes produits, bloc ligne résa si `resa` (atelier, date longue, places, tél, sous-total, bouton retirer POST). Total = `loadCart().total`.
- Paiement : hidden `_token` (`checkout`) ; récap avec section résa ; **bloc livraison rendu uniquement si `hasPhysical`** (sinon aucun radio — le serveur choisira « Sur place ») ; champs adresse rendus uniquement si `hasPhysical` (le JS `no_livraison` existant continue de gérer le cas Click&Collect quand il y a des produits).
- Tests : panier mixte affiche les deux blocs ; panier 100 % atelier → pas de bloc livraison ni adresse.

### T6 — `initier_paiement` refondu

**Files:** Modify `src/Controller/PaymentController.php`, `src/Service/PayPalService.php`.

Ordre strict :
1. Guard `client_id` (+ CSRF `checkout` sur `_token` → 403).
2. `CartService::loadCart()` ; vide → 400.
3. Livraison : si `hasPhysical` → ID posté doit être `VISIBLE` (sinon 400) ; comparaison de type **en enum** (`LivraisonType::LIVRAISON === $livraison->getTypeLivraison()`) pour exiger adresse/cp/ville ; si `!hasPhysical` → `findOneBy(['libelle' => 'Sur place — atelier'])` côté serveur, aucun champ adresse requis.
4. Adresse construite en mémoire (AUCUN flush avant la commande).
5. **Transaction** (`$em->wrapInTransaction`) :
   - si résa : créneau rechargé avec `PESSIMISTIC_WRITE`, re-check `OUVERT` + futur + `places <= placesRestantes` (sinon rollback → erreur « créneau complet ») ; création `AtelierReservation` (client, nom/prénom du client DB, tel de la ligne, places, `ATTENTE`, `SITE`, `A_PAYER`) ;
   - `PayPalService::createOrder(items…)` — items = produits + ligne « Atelier {titre} — {dateLongue} » (`unit_amount = prix atelier`, `quantity = places`) ; **`createOrder` prend un tableau d'items génériques `[{name, price, qty}]`** (signature adaptée, l'appel actuel produit-only réécrit) ;
   - `Commande` (référence = orderId, `WAITING`, total serveur, livraison, adresse, client) + `ProductCommande` par produit ; `résa->setCommande($commande)`.
6. JSON `{paypal_url}` inchangé.

Tests (stub `PayPalService`) : hold créé `ATTENTE/A_PAYER` lié commande ; créneau complet → pas de commande ; livraison cachée refusée ; panier atelier pur → livraison « Sur place » auto ; adresse exigée seulement si type LIVRAISON.

### T7 — `retour_paypal` refondu

**Files:** Modify `src/Controller/PaymentController.php`, `src/Service/PayPalService.php`.

- `capturePayment` NE persiste plus : retourne `{status, captureId, amount, currency, payerEmail}` bruts (la finalisation devient responsabilité du contrôleur, testable).
- Contrôleur, try/catch global :
  1. commande introuvable → erreur ; **`commande->getPayment() !== null` → redirect confirmation (idempotent)** ;
  2. capture PayPal ; `status !== COMPLETED` → erreur (résa du hold → `ANNULEE`, motif « paiement refusé ») ;
  3. **anomalie si `amount != commande->getTotal()` ou devise ≠ EUR** → Payment enregistré, commande `CANCELED` + note interne + notification admin (service existant), produits non passés VENDU, résa `ANNULEE` (motif « anomalie montant ») → page erreur dédiée « contactez-nous » ;
  4. sinon **transaction + verrous** : produits `PESSIMISTIC_WRITE` — un produit non `VENTE` → traite comme anomalie (3) ; produits → `VENDU` ; résa (si présente) → `CONFIRMEE` + `PAYE` ; Payment créé + commande `RECEIVED` ;
  5. post-traitements non bloquants (try/catch individuels) : analytics, notification vente, `CartService::clear()`, emails T8 ;
  6. redirect confirmation. Toute exception → log + page erreur générique (jamais de 500 brut après débit).
- Client relu via `$commande->getClient()` (fin de `client_ob` dans ce flow).

Tests : capture OK → tout finalisé + idempotence au 2ᵉ hit ; montant divergent → anomalie complète ; produit vendu entre-temps → anomalie ; résa seule → pas de produits touchés.

### T8 — Emails v2

**Files:** Modify `src/Service/Mail/AppMailer.php` ; Create `templates/emails/v2/commande_confirmee.html.twig`, `templates/emails/v2/atelier_rappel.html.twig` ; Modify `src/Controller/PaymentController.php`.

- `AppMailer::sendOrderConfirmed(Commande $commande): bool` — sujet « Merci ! Votre commande {shortRef} est confirmée », contexte + `resa` (`AtelierReservationRepository::findOneBy(['commande' => $commande])`).
- Template `commande_confirmee` (layout v2) : lignes produits (libellé + prix), bloc résa conditionnel (atelier, date longue, places, adresse boutique, consigne tenue), total, lien confirmation.
- `AppMailer::sendAtelierRappel(AtelierReservation $resa): bool` — garde email client ; sujet « C'est demain ! Votre atelier chez Vulcan' » ; template : date/heure, adresse, tenue, téléphone.
- `retour_paypal` : email client legacy remplacé par `sendOrderConfirmed` ; email admin existant conservé (lien corrigé vers `gestion_commande_detail`).

### T9 — Cron : expiration holds + rappel J-1

**Files:** Create `src/Service/ReservationLifecycleService.php` ; Modify `src/Command/CronCommand.php` ; tests.

```php
final class ReservationLifecycleService
{
    // Holds SITE + ATTENTE + A_PAYER + createdAt < now-45min  → ANNULEE (motif « paiement non finalisé »),
    // commande liée WAITING → CANCELED. Retourne le nombre expiré.
    public function expireStaleHolds(): int;
    // Résas CONFIRMEE + PAYE + rappelEnvoyeAt IS NULL + startAt entre now et now+24h → sendAtelierRappel + rappelEnvoyeAt=now.
    public function sendRappelsJMoins1(): int;
}
```
- `CronCommand` : 2 nouveaux `step()` (« Expiration réservations non payées », « Rappels atelier J-1 »).
- Tests : hold vieux de 50 min expiré + commande annulée ; hold récent intact ; rappel envoyé une seule fois (mailer null profile → assert via `rappelEnvoyeAt` + `assertEmailCount` si dispo).

### T10 — Compte « Mes réservations »

**Files:** Modify `templates/compte/_tabs.html.twig`, `src/Controller/CompteController.php`, `src/Repository/AtelierReservationRepository.php` ; Create `templates/compte/newreservations.html.twig`.

- Onglet « Mes réservations » (`current: 'reservations'`) entre « Mes affûtages » et « Vos informations ».
- Route `GET /mes-reservations` (`app_mes_reservations`), pattern `mesAffutagesV2` : `findForClient(Client)` (par relation client, ordre `startAt` du créneau DESC via join).
- Template : cartes (atelier, date longue, places, montant = places × prix, statut badge — Confirmée/En attente/Annulée), note « pour annuler ou reporter : {téléphone} ».
- Tests : résa visible pour son client, invisible pour un autre, guard login.

### T11 — Suite complète + QA navigateur

- `php vendor/phpunit/phpunit/phpunit` → 100 % vert (149 + nouveaux).
- QA Playwright : parcours réel — page atelier connecté → formulaire résa → panier (ligne résa) → paiement (récap, pas de livraison si atelier seul) ; ajout produit → panier mixte → bloc livraison présent ; onglet compte. Screenshots desktop + mobile. Le clic final PayPal n'est PAS exécuté en QA (sandbox) — vérifier seulement que `initier_paiement` renvoie une erreur propre sans credentials ou une URL PayPal avec.
- Commit final + mise à jour mémoire projet.
