# Souscription en ligne + paiement Stripe BNPL — 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:** Permettre à un client connecté de souscrire en ligne à un forfait et de le payer immédiatement (CB comptant, Alma 2x/3x/4x, Klarna 3x), avec création automatique du forfait via webhook Stripe.

**Architecture:** Stripe Checkout *hosted* avec `mode: 'payment'` (one-shot) et `payment_method_types: ['card', 'alma', 'klarna']`. Webhook idempotent via `t_processed_webhooks`. Source de vérité = webhook (le `success_url` est cosmétique). Mode dégradé propre si clés Stripe absentes.

**Tech Stack:** PHP 8.2 strict, PSR-4 `App\`, PDO singleton, `stripe/stripe-php` ^15 (déjà dans composer), PHPUnit 10.5, MySQL utf8mb4.

**Spec source:** [`docs/superpowers/specs/2026-05-20-souscription-paiement-stripe-bnpl-design.md`](../specs/2026-05-20-souscription-paiement-stripe-bnpl-design.md)

---

## Conventions du codebase à respecter

- `declare(strict_types=1);` en tête de **tout** fichier PHP créé
- Échapper toutes les sorties avec `e($var ?? '')`
- CSRF token sur tout POST sauf webhook (`'csrf' => false` dans route)
- Migrations idempotentes (vérif `INFORMATION_SCHEMA` avant `ALTER`)
- Tables `t_*`, colonnes snake_case, PK `*_ID` en `INT UNSIGNED`
- Tests dans `tests/Unit/` (sans BDD) et `tests/Feature/` (avec BDD)
- Bootstrap test : `tests/bootstrap.php` charge `.env` via `safeLoad()`

---

## File Structure

**Files créés :**

| Chemin | Rôle |
|---|---|
| `database/migrations/025_alter_payments_add_session_id.sql` | Ajoute `stripe_checkout_session_id` UNIQUE + `failure_reason` à `t_payments` |
| `src/Services/StripeCheckoutService.php` | Wrapper SDK Stripe : `isConfigured()`, `createSession()`, `verifyWebhook()`, `computeInstallments()` |
| `src/Controllers/CheckoutController.php` | Actions `start()`, `success()`, `cancel()`, `webhook()` |
| `views/pages/checkout/success.php` | Vue retour succès (avec poll JS si webhook en retard) |
| `views/pages/checkout/cancel.php` | Vue retour annulation |
| `tests/Unit/StripeCheckoutServiceTest.php` | Tests unitaires `computeInstallments` |
| `tests/Feature/CheckoutWebhookTest.php` | Tests handler webhook (signature, idempotence, création forfait) |
| `docs/checkout-developpement-local.md` | Documentation Stripe CLI pour test local Windows/WAMP |

**Files modifiés :**

| Chemin | Modif |
|---|---|
| `.env.example` | Ajout `STRIPE_ENABLE_BNPL=true` (les 3 clés existent déjà) |
| `config/routes.php` | 4 nouvelles routes checkout |
| `src/Repositories/PaymentRepository.php` | `create()`, `findBySessionId()`, `setSessionId()`, `markSucceeded()`, `markFailed()` |
| `src/Repositories/SubscriptionRepository.php` | `createFromPayment()` |
| `src/Controllers/PublicController.php` | `tarifs()` passe `$installments` et `$stripeEnabled` à la vue |
| `src/Controllers/EmeraldController.php` | `tarifs()` idem |
| `views/pages/public/tarifs.php` | Boutons "Souscrire" actifs + mentions 3x/4x dynamiques |
| `views/pages/emerald/tarifs.php` | Dynamisation (loop sur `$plans`) + idem |
| `views/pages/account/forfaits.php` | CTA "Souscrire un autre engagement" si déjà actif |

---

## Task Decomposition

### Task 1 : Migration BDD 025 — colonnes Stripe session sur `t_payments`

**Files:**
- Create: `database/migrations/025_alter_payments_add_session_id.sql`

- [ ] **Step 1: Créer la migration idempotente**

Fichier `database/migrations/025_alter_payments_add_session_id.sql` :

```sql
-- ALDANA migration 025
-- Ajoute stripe_checkout_session_id (UNIQUE) et failure_reason à t_payments
-- pour le flow Stripe Checkout (CB + Alma + Klarna).
-- Idempotent : ré-exécutable sans erreur (vérif INFORMATION_SCHEMA avant ALTER).

-- ============================================================
-- 1. Colonne stripe_checkout_session_id
-- ============================================================
SET @col_exists := (
    SELECT COUNT(*) FROM information_schema.columns
    WHERE table_schema = DATABASE()
      AND table_name = 't_payments'
      AND column_name = 'stripe_checkout_session_id'
);
SET @sql := IF(@col_exists = 0,
    'ALTER TABLE t_payments ADD COLUMN stripe_checkout_session_id VARCHAR(255) NULL AFTER stripe_charge_id',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

-- Index UNIQUE (idempotent)
SET @idx_exists := (
    SELECT COUNT(*) FROM information_schema.statistics
    WHERE table_schema = DATABASE()
      AND table_name = 't_payments'
      AND index_name = 'uq_stripe_session'
);
SET @sql := IF(@idx_exists = 0,
    'ALTER TABLE t_payments ADD UNIQUE INDEX uq_stripe_session (stripe_checkout_session_id)',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

-- ============================================================
-- 2. Colonne failure_reason
-- ============================================================
SET @col_exists := (
    SELECT COUNT(*) FROM information_schema.columns
    WHERE table_schema = DATABASE()
      AND table_name = 't_payments'
      AND column_name = 'failure_reason'
);
SET @sql := IF(@col_exists = 0,
    'ALTER TABLE t_payments ADD COLUMN failure_reason VARCHAR(255) NULL AFTER status',
    'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
```

- [ ] **Step 2: Exécuter la migration en local**

Run depuis WAMP MySQL (phpMyAdmin ou CLI) :
```
mysql -u root aldana < database/migrations/025_alter_payments_add_session_id.sql
```

Expected: aucune erreur, deux nouvelles colonnes visibles dans `DESCRIBE t_payments;`

- [ ] **Step 3: Re-exécuter la migration pour vérifier l'idempotence**

Run la même commande une deuxième fois.
Expected: aucune erreur, structure inchangée.

- [ ] **Step 4: Commit**

```bash
git add database/migrations/025_alter_payments_add_session_id.sql
git commit -m "feat(stripe): migration 025 ajoute stripe_checkout_session_id et failure_reason à t_payments"
```

---

### Task 2 : `StripeCheckoutService::computeInstallments` (TDD)

**Files:**
- Create: `tests/Unit/StripeCheckoutServiceTest.php`
- Create: `src/Services/StripeCheckoutService.php` (squelette + cette méthode uniquement)

- [ ] **Step 1: Écrire le test unitaire qui échoue**

Fichier `tests/Unit/StripeCheckoutServiceTest.php` :

```php
<?php
declare(strict_types=1);

namespace Tests\Unit;

use App\Services\StripeCheckoutService;
use PHPUnit\Framework\TestCase;

final class StripeCheckoutServiceTest extends TestCase
{
    public function test_compute_installments_includes_alma_when_price_under_2000_euros(): void
    {
        $service = new StripeCheckoutService();
        $result = $service->computeInstallments(120000); // 1200 €

        $this->assertArrayHasKey('alma', $result);
        $this->assertSame('600,00 €', $result['alma'][2]);
        $this->assertSame('400,00 €', $result['alma'][3]);
        $this->assertSame('300,00 €', $result['alma'][4]);
        $this->assertSame('400,00 €', $result['klarna'][3]);
    }

    public function test_compute_installments_omits_alma_above_2000_euros(): void
    {
        $service = new StripeCheckoutService();
        $result = $service->computeInstallments(250000); // 2500 €

        $this->assertSame([], $result['alma']);
        $this->assertSame('833,33 €', $result['klarna'][3]);
    }

    public function test_compute_installments_omits_alma_below_50_euros(): void
    {
        $service = new StripeCheckoutService();
        $result = $service->computeInstallments(4500); // 45 €

        $this->assertSame([], $result['alma']);
    }

    public function test_compute_installments_at_alma_boundaries(): void
    {
        $service = new StripeCheckoutService();
        $low  = $service->computeInstallments(5000);   // 50 € exact
        $high = $service->computeInstallments(200000); // 2000 € exact

        $this->assertNotEmpty($low['alma']);
        $this->assertNotEmpty($high['alma']);
    }
}
```

- [ ] **Step 2: Run test, vérifier qu'il échoue**

Run: `vendor/bin/phpunit tests/Unit/StripeCheckoutServiceTest.php`
Expected: FAIL avec `Class "App\Services\StripeCheckoutService" not found`

- [ ] **Step 3: Implémenter le squelette + `computeInstallments`**

Fichier `src/Services/StripeCheckoutService.php` :

```php
<?php
declare(strict_types=1);

namespace App\Services;

final class StripeCheckoutService
{
    private const ALMA_MIN_CENTS = 5_000;    // 50 €
    private const ALMA_MAX_CENTS = 200_000;  // 2 000 €

    /**
     * Renvoie les montants par échéance à afficher sur les cartes tarif.
     * Tableau structure :
     *   ['alma' => [2 => 'X €', 3 => 'Y €', 4 => 'Z €'], 'klarna' => [3 => 'W €']]
     * Si le prix sort de la plage Alma (50€-2000€), 'alma' est un tableau vide.
     */
    public function computeInstallments(int $priceCents): array
    {
        $result = ['alma' => [], 'klarna' => []];

        if ($priceCents >= self::ALMA_MIN_CENTS && $priceCents <= self::ALMA_MAX_CENTS) {
            foreach ([2, 3, 4] as $n) {
                $perInstallment = intdiv($priceCents, $n);
                $result['alma'][$n] = $this->formatEuros($perInstallment);
            }
        }

        $perInstallment = intdiv($priceCents, 3);
        $result['klarna'][3] = $this->formatEuros($perInstallment);

        return $result;
    }

    private function formatEuros(int $cents): string
    {
        return number_format($cents / 100, 2, ',', ' ') . ' €';
    }
}
```

Note : `intdiv(250000, 3) = 83333` → `833,33 €` (le résiduel d'1 cent sera absorbé par Stripe sur le dernier versement réel, l'affichage est conservatif).

- [ ] **Step 4: Run test, vérifier qu'il passe**

Run: `vendor/bin/phpunit tests/Unit/StripeCheckoutServiceTest.php`
Expected: PASS (4 tests)

- [ ] **Step 5: Commit**

```bash
git add src/Services/StripeCheckoutService.php tests/Unit/StripeCheckoutServiceTest.php
git commit -m "feat(stripe): StripeCheckoutService::computeInstallments avec tests TDD"
```

---

### Task 3 : `StripeCheckoutService::isConfigured()` + `createSession()` + `verifyWebhook()`

**Files:**
- Modify: `src/Services/StripeCheckoutService.php`

Pas de TDD pour `createSession()` / `verifyWebhook()` car appels SDK Stripe externes (mockage trop lourd pour V1). `isConfigured()` est testée par le flow réel en Task 5.

- [ ] **Step 1: Compléter le service avec les méthodes Stripe**

Modifier `src/Services/StripeCheckoutService.php` — ajouter les méthodes :

```php
    public function isConfigured(): bool
    {
        return !empty($_ENV['STRIPE_SECRET_KEY'])
            && !empty($_ENV['STRIPE_PUBLISHABLE_KEY'])
            && !empty($_ENV['STRIPE_WEBHOOK_SECRET']);
    }

    /**
     * Crée une Stripe Checkout Session pour un achat de forfait one-shot.
     * Retourne ['session_id' => string, 'url' => string].
     */
    public function createSession(
        array $plan,
        int $userID,
        string $userEmail,
        int $paymentID,
        string $successUrl,
        string $cancelUrl
    ): array {
        if (!$this->isConfigured()) {
            throw new \RuntimeException('Stripe is not configured');
        }

        \Stripe\Stripe::setApiKey($_ENV['STRIPE_SECRET_KEY']);

        $methods = ['card', 'klarna'];
        if (($plan['price_cents'] ?? 0) >= self::ALMA_MIN_CENTS
            && ($plan['price_cents'] ?? 0) <= self::ALMA_MAX_CENTS
            && ($_ENV['STRIPE_ENABLE_BNPL'] ?? 'true') === 'true'
        ) {
            $methods[] = 'alma';
        }
        if (($_ENV['STRIPE_ENABLE_BNPL'] ?? 'true') !== 'true') {
            $methods = ['card'];
        }

        $session = \Stripe\Checkout\Session::create([
            'payment_method_types' => $methods,
            'mode'                 => 'payment',
            'locale'               => 'fr',
            'customer_email'       => $userEmail,
            'line_items' => [[
                'price_data' => [
                    'currency'     => 'eur',
                    'unit_amount'  => (int) $plan['price_cents'],
                    'product_data' => [
                        'name'        => (string) $plan['name'],
                        'description' => $this->buildProductDescription($plan),
                    ],
                ],
                'quantity' => 1,
            ]],
            'metadata' => [
                'payment_ID' => (string) $paymentID,
                'user_ID'    => (string) $userID,
                'plan_ID'    => (string) $plan['plan_ID'],
            ],
            'payment_intent_data' => [
                'metadata' => [
                    'payment_ID' => (string) $paymentID,
                    'user_ID'    => (string) $userID,
                    'plan_ID'    => (string) $plan['plan_ID'],
                ],
            ],
            'success_url' => $successUrl,
            'cancel_url'  => $cancelUrl,
            'expires_at'  => time() + (30 * 60),
        ]);

        return [
            'session_id' => (string) $session->id,
            'url'        => (string) $session->url,
        ];
    }

    /**
     * Vérifie la signature du webhook et renvoie l'event Stripe.
     * Throws \Stripe\Exception\SignatureVerificationException si invalide.
     */
    public function verifyWebhook(string $rawBody, string $sigHeader): \Stripe\Event
    {
        if (!$this->isConfigured()) {
            throw new \RuntimeException('Stripe is not configured');
        }
        return \Stripe\Webhook::constructEvent(
            $rawBody,
            $sigHeader,
            (string) $_ENV['STRIPE_WEBHOOK_SECRET']
        );
    }

    private function buildProductDescription(array $plan): string
    {
        $descParts = [];
        if (!empty($plan['sessions_count'])) {
            $descParts[] = $plan['sessions_count'] . ' séance' . ($plan['sessions_count'] > 1 ? 's' : '');
        }
        if (!empty($plan['validity_days'])) {
            $descParts[] = 'valable ' . $plan['validity_days'] . ' jours';
        } elseif (!empty($plan['duration_months'])) {
            $descParts[] = 'engagement ' . $plan['duration_months'] . ' mois';
        }
        return $descParts === [] ? (string) ($plan['description'] ?? '') : implode(' · ', $descParts);
    }
```

- [ ] **Step 2: Vérifier la syntaxe du fichier**

Run: `php -l src/Services/StripeCheckoutService.php`
Expected: `No syntax errors detected`

- [ ] **Step 3: Re-run les tests unitaires (régression)**

Run: `vendor/bin/phpunit tests/Unit/StripeCheckoutServiceTest.php`
Expected: PASS (4 tests, computeInstallments inchangée)

- [ ] **Step 4: Commit**

```bash
git add src/Services/StripeCheckoutService.php
git commit -m "feat(stripe): isConfigured/createSession/verifyWebhook dans StripeCheckoutService"
```

---

### Task 4 : `PaymentRepository` — méthodes manquantes

**Files:**
- Modify: `src/Repositories/PaymentRepository.php`

- [ ] **Step 1: Ajouter les 5 méthodes au repository**

Lire d'abord le fichier existant (en haut, après les méthodes existantes), puis ajouter ces méthodes avant la fermeture `}` finale de la classe :

```php
    /**
     * Crée un payment en statut 'pending' (avant redirection Stripe).
     * Le subscription_ID est NULL à ce stade (forfait pas encore créé).
     */
    public function create(int $userID, int $amountCents, string $type): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO t_payments (user_ID, amount_cents, currency, type, status)
             VALUES (?, ?, "EUR", ?, "pending")'
        );
        $stmt->execute([$userID, $amountCents, $type]);
        return (int) $this->pdo->lastInsertId();
    }

    public function setSessionId(int $paymentID, string $sessionId): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE t_payments SET stripe_checkout_session_id = ? WHERE payment_ID = ?'
        );
        $stmt->execute([$sessionId, $paymentID]);
    }

    public function findBySessionId(string $sessionId): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM t_payments WHERE stripe_checkout_session_id = ? LIMIT 1'
        );
        $stmt->execute([$sessionId]);
        $row = $stmt->fetch();
        return $row ?: null;
    }

    public function findByID(int $paymentID): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM t_payments WHERE payment_ID = ?');
        $stmt->execute([$paymentID]);
        $row = $stmt->fetch();
        return $row ?: null;
    }

    /**
     * Marque un payment comme réussi (appelé par le webhook).
     * Idempotent : ne touche pas un payment déjà 'succeeded'.
     */
    public function markSucceeded(int $paymentID, ?string $paymentIntentId): int
    {
        $stmt = $this->pdo->prepare(
            'UPDATE t_payments
             SET status = "succeeded", stripe_payment_intent_id = ?
             WHERE payment_ID = ? AND status = "pending"'
        );
        $stmt->execute([$paymentIntentId, $paymentID]);
        return $stmt->rowCount();
    }

    public function markFailed(int $paymentID, string $reason): int
    {
        $stmt = $this->pdo->prepare(
            'UPDATE t_payments
             SET status = "failed", failure_reason = ?
             WHERE payment_ID = ? AND status = "pending"'
        );
        $stmt->execute([$reason, $paymentID]);
        return $stmt->rowCount();
    }

    public function attachSubscription(int $paymentID, int $subscriptionID): void
    {
        $stmt = $this->pdo->prepare(
            'UPDATE t_payments SET subscription_ID = ? WHERE payment_ID = ?'
        );
        $stmt->execute([$subscriptionID, $paymentID]);
    }
```

- [ ] **Step 2: Vérifier la syntaxe**

Run: `php -l src/Repositories/PaymentRepository.php`
Expected: `No syntax errors detected`

- [ ] **Step 3: Commit**

```bash
git add src/Repositories/PaymentRepository.php
git commit -m "feat(stripe): PaymentRepository étendu pour le flow Checkout (create/setSessionId/markSucceeded/...)"
```

---

### Task 5 : `SubscriptionRepository::createFromPayment` (TDD partiel)

**Files:**
- Modify: `src/Repositories/SubscriptionRepository.php`

- [ ] **Step 1: Ajouter la méthode `createFromPayment`**

Avant la fermeture `}` finale de la classe `SubscriptionRepository` :

```php
    /**
     * Crée un t_subscriptions à partir d'un t_payments existant (post-webhook Stripe).
     * Transactionnel : marque le payment 'succeeded' + crée la subscription + lie les deux.
     * Idempotent : si un t_subscriptions existe déjà avec ce payment_ID lié, return son ID.
     *
     * @return int subscription_ID
     */
    public function createFromPayment(
        int $paymentID,
        int $userID,
        int $planID,
        ?int $sessionsLeft,
        string $validFrom,
        string $validUntil,
        ?string $stripePaymentIntentId = null
    ): int {
        $this->pdo->beginTransaction();
        try {
            // Idempotence : un payment ne génère qu'un t_subscriptions
            $check = $this->pdo->prepare(
                'SELECT subscription_ID FROM t_payments WHERE payment_ID = ? LIMIT 1'
            );
            $check->execute([$paymentID]);
            $existing = $check->fetch();
            if ($existing && !empty($existing['subscription_ID'])) {
                $this->pdo->commit();
                return (int) $existing['subscription_ID'];
            }

            $ins = $this->pdo->prepare(
                'INSERT INTO t_subscriptions
                    (user_ID, plan_ID, sessions_left, valid_from, valid_until, status, stripe_payment_intent_id)
                 VALUES (?, ?, ?, ?, ?, "active", ?)'
            );
            $ins->execute([$userID, $planID, $sessionsLeft, $validFrom, $validUntil, $stripePaymentIntentId]);
            $subID = (int) $this->pdo->lastInsertId();

            $upd = $this->pdo->prepare(
                'UPDATE t_payments
                 SET subscription_ID = ?,
                     status = "succeeded",
                     stripe_payment_intent_id = COALESCE(stripe_payment_intent_id, ?)
                 WHERE payment_ID = ?'
            );
            $upd->execute([$subID, $stripePaymentIntentId, $paymentID]);

            $this->pdo->commit();
            return $subID;
        } catch (\Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            throw $e;
        }
    }
```

- [ ] **Step 2: Vérifier la syntaxe**

Run: `php -l src/Repositories/SubscriptionRepository.php`
Expected: `No syntax errors detected`

- [ ] **Step 3: Commit**

```bash
git add src/Repositories/SubscriptionRepository.php
git commit -m "feat(stripe): SubscriptionRepository::createFromPayment transactionnel et idempotent"
```

---

### Task 6 : `CheckoutController::start` + routes

**Files:**
- Create: `src/Controllers/CheckoutController.php`
- Modify: `config/routes.php`

- [ ] **Step 1: Créer le controller avec l'action `start()` uniquement**

Fichier `src/Controllers/CheckoutController.php` :

```php
<?php
declare(strict_types=1);

namespace App\Controllers;

use App\Repositories\PaymentRepository;
use App\Repositories\PlanRepository;
use App\Repositories\SubscriptionRepository;
use App\Services\StripeCheckoutService;

final class CheckoutController extends BaseController
{
    public function start(): void
    {
        $planID = (int) ($_POST['plan_id'] ?? 0);
        $userID = (int) $_SESSION['user_ID'];

        if ($planID <= 0) {
            flash('error', 'Forfait invalide.');
            redirect('/tarifs');
        }

        $plan = (new PlanRepository())->findByID($planID);
        if (!$plan || (int) $plan['active'] !== 1) {
            flash('error', 'Ce forfait n\'est plus disponible.');
            redirect('/tarifs');
        }

        if ((int) ($plan['requires_quote'] ?? 0) === 1) {
            flash('info', 'Ce forfait nécessite un devis. Contactez Aurane.');
            redirect('/contact');
        }

        if ((int) $plan['price_cents'] <= 0) {
            flash('error', 'Ce forfait n\'a pas de prix configuré.');
            redirect('/tarifs');
        }

        $service = new StripeCheckoutService();
        if (!$service->isConfigured()) {
            flash('error', 'Le paiement en ligne est en cours d\'activation. Contactez Aurane.');
            redirect('/tarifs');
        }

        $paymentRepo = new PaymentRepository();
        $paymentID = $paymentRepo->create($userID, (int) $plan['price_cents'], 'subscription_purchase');

        try {
            $baseUrl = rtrim((string) config('app.url'), '/');
            $session = $service->createSession(
                $plan,
                $userID,
                (string) ($_SESSION['user_email'] ?? ''),
                $paymentID,
                $baseUrl . '/checkout/success?session_id={CHECKOUT_SESSION_ID}',
                $baseUrl . '/checkout/cancel?payment_id=' . $paymentID
            );
        } catch (\Throwable $e) {
            error_log('[checkout] createSession failed: ' . $e->getMessage());
            $paymentRepo->markFailed($paymentID, 'stripe_create_session_error');
            flash('error', 'Impossible de démarrer le paiement. Réessayez ou contactez Aurane.');
            redirect('/tarifs');
        }

        $paymentRepo->setSessionId($paymentID, $session['session_id']);

        header('Location: ' . $session['url'], true, 303);
        exit;
    }

    public function success(): void
    {
        // Sera implémenté en Task 7
        http_response_code(501);
        echo 'Not implemented yet';
    }

    public function cancel(): void
    {
        // Sera implémenté en Task 7
        http_response_code(501);
        echo 'Not implemented yet';
    }

    public function webhook(): void
    {
        // Sera implémenté en Task 8
        http_response_code(501);
    }
}
```

- [ ] **Step 2: Ajouter les routes**

Modifier `config/routes.php` — ajouter après les routes Booking (ligne ~67) :

```php
    // ---- Checkout (auth requise sauf webhook) ----
    'POST /checkout/start'   => ['App\Controllers\CheckoutController', 'start',   ['middleware' => 'auth']],
    'GET /checkout/success'  => ['App\Controllers\CheckoutController', 'success', ['middleware' => 'auth']],
    'GET /checkout/cancel'   => ['App\Controllers\CheckoutController', 'cancel',  ['middleware' => 'auth']],
    'POST /stripe/webhook'   => ['App\Controllers\CheckoutController', 'webhook', ['csrf' => false]],
```

- [ ] **Step 3: Vérifier la syntaxe**

Run: `php -l src/Controllers/CheckoutController.php`
Expected: `No syntax errors detected`

Run: `php -l config/routes.php`
Expected: `No syntax errors detected`

- [ ] **Step 4: Commit**

```bash
git add src/Controllers/CheckoutController.php config/routes.php
git commit -m "feat(stripe): CheckoutController::start + 4 routes /checkout/* et /stripe/webhook"
```

---

### Task 7 : `CheckoutController::success` + `cancel` + vues

**Files:**
- Modify: `src/Controllers/CheckoutController.php`
- Create: `views/pages/checkout/success.php`
- Create: `views/pages/checkout/cancel.php`

- [ ] **Step 1: Implémenter `success()` et `cancel()`**

Remplacer les corps de `success()` et `cancel()` dans `src/Controllers/CheckoutController.php` :

```php
    public function success(): void
    {
        $sessionId = (string) ($_GET['session_id'] ?? '');
        $userID = (int) $_SESSION['user_ID'];

        $payment = null;
        if ($sessionId !== '') {
            $payment = (new PaymentRepository())->findBySessionId($sessionId);
            // Sécurité IDOR : payment doit appartenir au user en session
            if ($payment && (int) $payment['user_ID'] !== $userID) {
                $payment = null;
            }
        }

        $this->render(
            'pages.checkout.success',
            [
                'payment'    => $payment,
                'isConfirmed' => $payment && $payment['status'] === 'succeeded',
            ],
            title: 'Paiement confirmé',
            description: 'Confirmation de votre souscription ALDANA.'
        );
    }

    public function cancel(): void
    {
        $paymentID = (int) ($_GET['payment_id'] ?? 0);
        if ($paymentID > 0) {
            $repo = new PaymentRepository();
            $payment = $repo->findByID($paymentID);
            if ($payment && (int) $payment['user_ID'] === (int) $_SESSION['user_ID']) {
                $repo->markFailed($paymentID, 'cancelled_by_user');
            }
        }

        $this->render(
            'pages.checkout.cancel',
            [],
            title: 'Paiement annulé',
            description: 'Vous avez annulé le paiement, aucune somme n\'a été prélevée.'
        );
    }
```

- [ ] **Step 2: Créer la vue `success.php`**

Fichier `views/pages/checkout/success.php` :

```php
<?php declare(strict_types=1); ?>
<?php /** @var ?array $payment */ /** @var bool $isConfirmed */ ?>

<section class="page-header">
    <div class="container">
        <div class="reveal">
            <span class="overline">L'engagement</span>
            <h1>Merci, votre paiement a bien été reçu.</h1>
        </div>
    </div>
</section>

<section style="padding-top: 0;">
    <div class="container" style="max-width: 40rem;">
        <div class="account-card">
            <?php if ($isConfirmed): ?>
                <h3>Votre engagement est actif.</h3>
                <p class="text-muted mt-2">
                    Vous pouvez dès à présent réserver vos séances dans votre espace membre.
                </p>
                <a href="/mon-compte/forfaits" class="btn btn-primary mt-4">Voir mes engagements</a>
                <a href="/planning" class="btn btn-outline mt-4 ml-2">Réserver une séance</a>
            <?php else: ?>
                <h3>Confirmation en cours…</h3>
                <p class="text-muted mt-2">
                    Votre paiement est en cours de validation par notre partenaire.
                    Votre engagement sera actif dans quelques secondes. Cette page se met à jour automatiquement.
                </p>
                <noscript>
                    <p class="text-muted mt-2">
                        Activez JavaScript ou rechargez la page dans quelques instants pour voir l'état final.
                    </p>
                </noscript>
                <p class="text-muted mt-4" style="font-size: 0.875rem;">
                    Si la confirmation n'arrive pas, vous recevrez un email dès activation.
                </p>
            <?php endif; ?>
        </div>
    </div>
</section>

<?php if (!$isConfirmed): ?>
<script>
(function () {
    let tries = 0;
    const maxTries = 6;        // 6 × 3s = 18s
    const intervalMs = 3000;
    const id = setInterval(() => {
        tries++;
        if (tries >= maxTries) {
            clearInterval(id);
            return;
        }
        // simple reload, le serveur re-cherche le payment
        window.location.reload();
    }, intervalMs);
})();
</script>
<?php endif; ?>
```

- [ ] **Step 3: Créer la vue `cancel.php`**

Fichier `views/pages/checkout/cancel.php` :

```php
<?php declare(strict_types=1); ?>

<section class="page-header">
    <div class="container">
        <div class="reveal">
            <span class="overline">L'engagement</span>
            <h1>Paiement annulé.</h1>
        </div>
    </div>
</section>

<section style="padding-top: 0;">
    <div class="container" style="max-width: 40rem;">
        <div class="account-card">
            <p class="text-muted mt-2">
                Vous avez quitté la page de paiement, aucune somme n'a été prélevée.
                Vous pouvez reprendre votre souscription à tout moment.
            </p>
            <a href="/tarifs" class="btn btn-primary mt-4">Revenir aux tarifs</a>
            <a href="/contact" class="btn btn-outline mt-4 ml-2">Une question ?</a>
        </div>
    </div>
</section>
```

- [ ] **Step 4: Vérifier que le rendu PHP n'a pas d'erreurs**

Run: `php -l src/Controllers/CheckoutController.php`
Run: `php -l views/pages/checkout/success.php`
Run: `php -l views/pages/checkout/cancel.php`
Expected: `No syntax errors detected` pour chacun

- [ ] **Step 5: Commit**

```bash
git add src/Controllers/CheckoutController.php views/pages/checkout/
git commit -m "feat(stripe): CheckoutController success/cancel + vues retour avec poll JS"
```

---

### Task 8 : `CheckoutController::webhook` (TDD sur idempotence)

**Files:**
- Modify: `src/Controllers/CheckoutController.php`
- Create: `tests/Feature/CheckoutWebhookTest.php`

- [ ] **Step 1: Écrire le test feature qui échoue**

Fichier `tests/Feature/CheckoutWebhookTest.php` :

```php
<?php
declare(strict_types=1);

namespace Tests\Feature;

use App\Helpers\Database;
use PDO;
use PHPUnit\Framework\TestCase;

/**
 * Tests d'idempotence sur le handler webhook.
 * On ne mocke pas Stripe : on appelle directement la méthode privée de traitement
 * via reflection, en simulant les payloads d'event.
 */
final class CheckoutWebhookTest extends TestCase
{
    private PDO $pdo;

    protected function setUp(): void
    {
        $this->pdo = Database::getInstance()->getConnection();
        // Nettoyage : on supprime les artefacts de test précédents
        $this->pdo->exec("DELETE FROM t_processed_webhooks WHERE event_id LIKE 'evt_test_%'");
    }

    public function test_processed_webhooks_unique_constraint_prevents_replay(): void
    {
        $eventId = 'evt_test_' . bin2hex(random_bytes(8));

        // Premier insert : OK
        $stmt = $this->pdo->prepare(
            'INSERT INTO t_processed_webhooks (event_id, event_type) VALUES (?, ?)'
        );
        $stmt->execute([$eventId, 'checkout.session.completed']);
        $this->assertSame(1, $stmt->rowCount());

        // Deuxième insert même event_id : doit lever une PDOException (UNIQUE violation)
        $this->expectException(\PDOException::class);
        $stmt->execute([$eventId, 'checkout.session.completed']);
    }

    public function test_compute_valid_until_for_pack_with_duration_months(): void
    {
        // valid_until calculé à partir de duration_months pour un pack annuel
        $plan = ['duration_months' => 12, 'validity_days' => null];

        $controllerRef = new \ReflectionClass(\App\Controllers\CheckoutController::class);
        $method = $controllerRef->getMethod('computeValidUntil');
        $method->setAccessible(true);
        $controller = $controllerRef->newInstanceWithoutConstructor();

        $result = $method->invoke($controller, '2026-05-20', $plan);
        $this->assertSame('2027-05-20', $result);
    }

    public function test_compute_valid_until_for_carnet_with_validity_days(): void
    {
        $plan = ['duration_months' => null, 'validity_days' => 90];

        $controllerRef = new \ReflectionClass(\App\Controllers\CheckoutController::class);
        $method = $controllerRef->getMethod('computeValidUntil');
        $method->setAccessible(true);
        $controller = $controllerRef->newInstanceWithoutConstructor();

        $result = $method->invoke($controller, '2026-05-20', $plan);
        $this->assertSame('2026-08-18', $result);
    }
}
```

- [ ] **Step 2: Run test, vérifier qu'il échoue**

Run: `vendor/bin/phpunit tests/Feature/CheckoutWebhookTest.php`
Expected:
- Le test `processed_webhooks_unique_constraint` doit PASSER (la table existe déjà avec UNIQUE)
- Les deux tests `compute_valid_until_*` doivent FAIL (méthode `computeValidUntil` n'existe pas)

- [ ] **Step 3: Implémenter `webhook()` et les helpers privés**

Remplacer la méthode `webhook()` dans `src/Controllers/CheckoutController.php` :

```php
    public function webhook(): void
    {
        $service = new StripeCheckoutService();
        if (!$service->isConfigured()) {
            http_response_code(503);
            echo 'Stripe not configured';
            return;
        }

        $rawBody  = (string) file_get_contents('php://input');
        $sigHeader = (string) ($_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '');

        try {
            $event = $service->verifyWebhook($rawBody, $sigHeader);
        } catch (\Throwable $e) {
            error_log('[stripe-webhook] signature error: ' . $e->getMessage());
            http_response_code(400);
            echo 'Invalid signature';
            return;
        }

        // Idempotence : INSERT UNIQUE sur event_id. Si doublon → on a déjà traité.
        $pdo = \App\Helpers\Database::getInstance()->getConnection();
        try {
            $ins = $pdo->prepare(
                'INSERT INTO t_processed_webhooks (event_id, event_type) VALUES (?, ?)'
            );
            $ins->execute([$event->id, $event->type]);
        } catch (\PDOException $e) {
            if ((int) $e->errorInfo[1] === 1062) {
                // duplicate key → déjà traité
                http_response_code(200);
                echo 'Already processed';
                return;
            }
            throw $e;
        }

        try {
            match ($event->type) {
                'checkout.session.completed'   => $this->handleSessionCompleted($event->data->object),
                'payment_intent.payment_failed' => $this->handlePaymentFailed($event->data->object),
                default                          => null,
            };
        } catch (\Throwable $e) {
            error_log('[stripe-webhook] handler error: ' . $e->getMessage());
            // On a déjà inséré t_processed_webhooks : on accepte le 200 quand même
            // pour éviter le retry Stripe en boucle. L'admin verra le payment pending dans /admin/paiements.
        }

        http_response_code(200);
        echo 'OK';
    }

    private function handleSessionCompleted(\Stripe\Checkout\Session $session): void
    {
        $paymentID = (int) ($session->metadata['payment_ID'] ?? 0);
        $userID    = (int) ($session->metadata['user_ID'] ?? 0);
        $planID    = (int) ($session->metadata['plan_ID'] ?? 0);

        if ($paymentID <= 0 || $userID <= 0 || $planID <= 0) {
            error_log("[stripe-webhook] missing metadata on session {$session->id}");
            return;
        }

        $paymentRepo = new PaymentRepository();
        $payment = $paymentRepo->findByID($paymentID);
        if (!$payment) {
            error_log("[stripe-webhook] payment#{$paymentID} not found");
            return;
        }

        // Sécurité : montant payé doit correspondre au montant attendu
        $amountTotal = (int) $session->amount_total;
        if ($amountTotal !== (int) $payment['amount_cents']) {
            error_log(
                "[stripe-webhook] amount mismatch payment#{$paymentID}: "
                . "expected={$payment['amount_cents']} received={$amountTotal}"
            );
            $paymentRepo->markFailed($paymentID, 'amount_mismatch');
            return;
        }

        $plan = (new PlanRepository())->findByID($planID);
        if (!$plan) {
            error_log("[stripe-webhook] plan#{$planID} not found");
            return;
        }

        $validFrom  = date('Y-m-d');
        $validUntil = $this->computeValidUntil($validFrom, $plan);
        $sessionsLeft = $plan['sessions_count'] !== null ? (int) $plan['sessions_count'] : null;

        (new SubscriptionRepository())->createFromPayment(
            $paymentID,
            $userID,
            $planID,
            $sessionsLeft,
            $validFrom,
            $validUntil,
            (string) ($session->payment_intent ?? '')
        );
    }

    private function handlePaymentFailed(\Stripe\PaymentIntent $intent): void
    {
        $paymentID = (int) ($intent->metadata['payment_ID'] ?? 0);
        if ($paymentID <= 0) {
            return;
        }
        $reason = (string) ($intent->last_payment_error->message ?? 'unknown');
        (new PaymentRepository())->markFailed($paymentID, substr($reason, 0, 255));
    }

    /**
     * Calcule la date de fin de validité du forfait.
     * Priorité : validity_days (carnets) > duration_months (packs annuels).
     */
    private function computeValidUntil(string $validFrom, array $plan): string
    {
        $dt = new \DateTime($validFrom);
        if (!empty($plan['validity_days'])) {
            $dt->modify('+' . (int) $plan['validity_days'] . ' days');
        } elseif (!empty($plan['duration_months'])) {
            $dt->modify('+' . (int) $plan['duration_months'] . ' months');
        } else {
            // fallback défensif : 365 jours
            $dt->modify('+365 days');
        }
        return $dt->format('Y-m-d');
    }
```

Ajouter au début du fichier (sous les autres `use`) :

```php
use App\Helpers\Database;
```

(déjà importé indirectement, mais explicite c'est mieux pour la classe `Database`)

- [ ] **Step 4: Run les tests, vérifier qu'ils passent tous**

Run: `vendor/bin/phpunit tests/Feature/CheckoutWebhookTest.php`
Expected: PASS (3 tests)

- [ ] **Step 5: Re-run la suite complète**

Run: `vendor/bin/phpunit`
Expected: PASS (tous les tests Unit + Feature passent)

- [ ] **Step 6: Commit**

```bash
git add src/Controllers/CheckoutController.php tests/Feature/CheckoutWebhookTest.php
git commit -m "feat(stripe): webhook idempotent + handler checkout.session.completed avec tests"
```

---

### Task 9 : Vue tarifs publique — bouton "Souscrire" + mentions 3x/4x

**Files:**
- Modify: `src/Controllers/PublicController.php` (méthode `tarifs()`)
- Modify: `views/pages/public/tarifs.php`

- [ ] **Step 1: Enrichir le controller `tarifs()`**

Dans `src/Controllers/PublicController.php`, remplacer la méthode `tarifs()` actuelle :

```php
    public function tarifs(): void
    {
        $plans = (new PlanRepository())->listActive();

        $service = new \App\Services\StripeCheckoutService();
        $installments = [];
        foreach ($plans as $plan) {
            $installments[(int) $plan['plan_ID']] = $service->computeInstallments((int) $plan['price_cents']);
        }

        $this->render(
            'pages.public.tarifs',
            [
                'plans'         => $plans,
                'installments'  => $installments,
                'stripeEnabled' => $service->isConfigured(),
            ],
            title: 'Tarifs',
            description: 'Tarifs des cours de yoga ALDANA à Versailles : séance unitaire, carnets, abonnement mensuel.'
        );
    }
```

- [ ] **Step 2: Refondre la vue `views/pages/public/tarifs.php`**

Remplacer **intégralement** le contenu actuel :

```php
<?php
declare(strict_types=1);
/** @var array $plans */
/** @var array $installments */
/** @var bool $stripeEnabled */

function planSubLabel(array $plan): string {
    if ($plan['type'] === 'monthly_unlimited') return '/mois';
    if ($plan['type'] === 'annual_unlimited') return '/an';
    if ($plan['type'] === 'annual_tiered')    return '/an';
    return '';
}
?>
<section class="page-header">
    <div class="container">
        <div class="reveal">
            <span class="overline">L'engagement</span>
            <h1>Tarifs</h1>
            <p class="lead text-muted">
                Choisissez la formule qui accompagne votre pratique. Paiement comptant ou en 3x/4x sans frais.
            </p>
        </div>
    </div>
</section>

<section style="padding-top: 0;">
    <div class="container">
        <?php if (empty($plans)): ?>
            <p class="text-muted text-center">Tarifs bientôt disponibles.</p>
        <?php else: ?>
            <div class="grid-cols-auto">
                <?php foreach ($plans as $plan):
                    $planID         = (int) $plan['plan_ID'];
                    $requiresQuote  = (int) ($plan['requires_quote'] ?? 0) === 1;
                    $almaSchedule   = $installments[$planID]['alma']   ?? [];
                    $klarnaSchedule = $installments[$planID]['klarna'] ?? [];
                ?>
                    <div class="plan reveal">
                        <h3 class="plan-name"><?= e($plan['name']) ?></h3>
                        <p class="plan-desc text-muted"><?= nl2br(e($plan['description'] ?? '')) ?></p>
                        <div class="plan-price">
                            <?= e(number_format($plan['price_cents'] / 100, 0, ',', ' ')) ?> €<small><?= e(planSubLabel($plan)) ?></small>
                        </div>

                        <?php if (!empty($plan['sessions_count'])): ?>
                            <p class="text-muted" style="font-size: 0.875rem;">
                                <?= e((string) $plan['sessions_count']) ?> séance<?= (int) $plan['sessions_count'] > 1 ? 's' : '' ?>
                                <?php if (!empty($plan['validity_days'])): ?>
                                    · Valable <?= e((string) $plan['validity_days']) ?> jours
                                <?php endif; ?>
                            </p>
                        <?php elseif ($plan['type'] === 'monthly_unlimited'): ?>
                            <p class="text-muted" style="font-size: 0.875rem;">Séances illimitées · Renouvellement mensuel</p>
                        <?php endif; ?>

                        <?php if (!empty($almaSchedule)):
                            $maxN = array_key_last($almaSchedule);
                        ?>
                            <p class="plan-installment text-muted" style="font-size: 0.875rem; margin-top: 0.5rem;">
                                ou <?= e((string) $maxN) ?> × <strong><?= e($almaSchedule[$maxN]) ?></strong> sans frais (Alma)
                            </p>
                        <?php endif; ?>
                        <?php if (!empty($klarnaSchedule[3])): ?>
                            <p class="plan-installment text-muted" style="font-size: 0.875rem;">
                                ou 3 × <strong><?= e($klarnaSchedule[3]) ?></strong> avec Klarna
                            </p>
                        <?php endif; ?>

                        <?php if ($requiresQuote): ?>
                            <a href="/contact" class="btn btn-outline mt-6" style="width: 100%; text-align: center;">
                                Demander un devis
                            </a>
                        <?php elseif (!$stripeEnabled): ?>
                            <button type="button" class="btn btn-outline mt-6" style="width: 100%;" disabled title="Paiement en ligne en cours d'activation">
                                Bientôt disponible
                            </button>
                        <?php else: ?>
                            <form method="POST" action="/checkout/start" style="margin-top: 1.5rem;">
                                <?= csrf_field() ?>
                                <input type="hidden" name="plan_id" value="<?= e((string) $planID) ?>">
                                <button type="submit" class="btn btn-primary" style="width: 100%;">
                                    Souscrire
                                </button>
                            </form>
                        <?php endif; ?>
                    </div>
                <?php endforeach; ?>
            </div>

            <?php if (!$stripeEnabled): ?>
                <p class="text-muted text-center mt-8" style="font-size: 0.875rem;">
                    Le paiement en ligne sera bientôt disponible. Pour souscrire,
                    contactez Aurane par <a href="/contact" style="color: var(--primary);">email ou téléphone</a>.
                </p>
            <?php else: ?>
                <p class="text-muted text-center mt-8" style="font-size: 0.875rem;">
                    Paiement sécurisé par Stripe — CB, Alma et Klarna.
                    En souscrivant, vous acceptez nos <a href="/cgv" style="color: var(--primary);">conditions générales</a>.
                </p>
            <?php endif; ?>
        <?php endif; ?>
    </div>
</section>

<section style="background: var(--muted); padding-block: 4rem;">
    <div class="container text-center reveal" style="max-width: 38rem; margin-inline: auto;">
        <span class="overline">Cours particuliers</span>
        <h2 class="mb-3">Une pratique sur-mesure</h2>
        <p class="text-muted mb-6">
            Les cours particuliers font l'objet d'un devis personnalisé selon le format et le lieu choisi.
        </p>
        <a href="/cours-particuliers" class="btn btn-outline">Découvrir les cours particuliers</a>
    </div>
</section>
```

- [ ] **Step 3: Vérifier la syntaxe**

Run: `php -l src/Controllers/PublicController.php`
Run: `php -l views/pages/public/tarifs.php`
Expected: `No syntax errors detected` pour chacun

- [ ] **Step 4: Test manuel rapide (smoke test)**

Lancer le serveur :
```
composer serve
```
Ouvrir http://localhost:8000/tarifs

Expected:
- Les packs s'affichent avec leur prix
- Sous chaque pack ≤ 2000€ : "ou 4 × X € sans frais (Alma)" et "ou 3 × Y € avec Klarna"
- Pack Entreprise : bouton "Demander un devis"
- Autres packs : bouton "Souscrire" (si .env Stripe rempli) ou "Bientôt disponible" sinon
- Pas d'erreur PHP, pas de mention "Le paiement en ligne sera bientôt disponible" si Stripe activé

- [ ] **Step 5: Commit**

```bash
git add src/Controllers/PublicController.php views/pages/public/tarifs.php
git commit -m "feat(stripe): page /tarifs publique avec boutons Souscrire et mentions 3x/4x"
```

---

### Task 10 : Vue tarifs Emerald — dynamisation + bouton "Souscrire"

**Files:**
- Modify: `src/Controllers/EmeraldController.php` (méthode `tarifs()`)
- Modify: `views/pages/emerald/tarifs.php`

- [ ] **Step 1: Enrichir le controller `tarifs()` Emerald**

Dans `src/Controllers/EmeraldController.php`, remplacer la méthode `tarifs()` :

```php
    public function tarifs(): void
    {
        $plans = (new \App\Repositories\PlanRepository())->listActive();
        $service = new \App\Services\StripeCheckoutService();
        $installments = [];
        foreach ($plans as $plan) {
            $installments[(int) $plan['plan_ID']] = $service->computeInstallments((int) $plan['price_cents']);
        }

        $this->render(
            'pages.emerald.tarifs',
            [
                'plans'         => $plans,
                'installments'  => $installments,
                'stripeEnabled' => $service->isConfigured(),
            ],
            title: 'Tarifs',
            description: 'Les tarifs des cours et forfaits — direction émeraude.'
        );
    }
```

- [ ] **Step 2: Refondre la vue `views/pages/emerald/tarifs.php`**

Remplacer **intégralement** le contenu actuel par une version dynamique :

```php
<?php
declare(strict_types=1);
/** @var array $plans */
/** @var array $installments */
/** @var bool $stripeEnabled */
?>
<section class="em-section em-tarifs em-panel-emerald">
    <header class="em-tarifs-header">
        <h1><span class="script em-bio-script">Tarifs</span></h1>
        <p>Cours collectifs, cours particuliers, retraites — choisir le format qui correspond à son rythme. Paiement comptant ou en 3x/4x sans frais.</p>
    </header>

    <?php if (empty($plans)): ?>
        <p style="text-align: center;">Tarifs bientôt disponibles.</p>
    <?php else: ?>
        <div class="em-tarifs-grid">
            <?php foreach ($plans as $plan):
                $planID         = (int) $plan['plan_ID'];
                $requiresQuote  = (int) ($plan['requires_quote'] ?? 0) === 1;
                $almaSchedule   = $installments[$planID]['alma']   ?? [];
                $klarnaSchedule = $installments[$planID]['klarna'] ?? [];
                $tier           = (string) ($plan['tier_label'] ?? '');
                $cardClasses    = 'em-tarif-card' . ($tier === 'GOLD' || $tier === 'PLATINUM' ? ' em-tarif-card--featured' : '');
            ?>
                <article class="<?= e($cardClasses) ?>">
                    <?php if ($tier !== ''): ?>
                        <span class="em-tarif-tag"><?= e($tier) ?></span>
                    <?php endif; ?>
                    <h3 class="em-tarif-name"><?= e($plan['name']) ?></h3>
                    <p class="em-tarif-price"><?= e(number_format($plan['price_cents'] / 100, 0, ',', ' ')) ?><span>€</span></p>
                    <p class="em-tarif-desc"><?= e($plan['description'] ?? '') ?></p>

                    <?php if (!empty($almaSchedule)):
                        $maxN = array_key_last($almaSchedule);
                    ?>
                        <p class="em-tarif-installment">ou <?= e((string) $maxN) ?> × <?= e($almaSchedule[$maxN]) ?> sans frais (Alma)</p>
                    <?php endif; ?>
                    <?php if (!empty($klarnaSchedule[3])): ?>
                        <p class="em-tarif-installment">ou 3 × <?= e($klarnaSchedule[3]) ?> avec Klarna</p>
                    <?php endif; ?>

                    <?php if ($requiresQuote): ?>
                        <a href="/emerald/contact" class="em-btn">Demander un devis</a>
                    <?php elseif (!$stripeEnabled): ?>
                        <button type="button" class="em-btn" disabled>Bientôt disponible</button>
                    <?php else: ?>
                        <form method="POST" action="/checkout/start">
                            <?= csrf_field() ?>
                            <input type="hidden" name="plan_id" value="<?= e((string) $planID) ?>">
                            <button type="submit" class="em-btn">Souscrire</button>
                        </form>
                    <?php endif; ?>
                </article>
            <?php endforeach; ?>
        </div>
    <?php endif; ?>
</section>
```

- [ ] **Step 3: Ajouter le style minimal `.em-tarif-installment`**

Lire `public/assets/css/aldana-emerald.css` (chercher `.em-tarif-desc`), puis ajouter **juste après** sa déclaration :

```css
.em-tarif-installment {
    font-size: 0.85rem;
    color: var(--em-muted, #6b7c70);
    margin: 0.25rem 0;
    font-style: italic;
}
```

- [ ] **Step 4: Vérifier la syntaxe**

Run: `php -l src/Controllers/EmeraldController.php`
Run: `php -l views/pages/emerald/tarifs.php`
Expected: `No syntax errors detected`

- [ ] **Step 5: Test manuel**

Ouvrir http://localhost:8000/emerald/tarifs

Expected:
- 5 packs Bronze/Silver/Gold/Platinum/Entreprise s'affichent dynamiquement
- Gold et Platinum ont le tag "featured"
- Mentions 3x/4x présentes
- Boutons "Souscrire" / "Demander un devis" / "Bientôt disponible" selon contexte

- [ ] **Step 6: Commit**

```bash
git add src/Controllers/EmeraldController.php views/pages/emerald/tarifs.php public/assets/css/aldana-emerald.css
git commit -m "feat(stripe): /emerald/tarifs dynamisé + bouton Souscrire + mentions BNPL"
```

---

### Task 11 : CTA renouveler sur `/mon-compte/forfaits`

**Files:**
- Modify: `views/pages/account/forfaits.php`

- [ ] **Step 1: Ajouter un CTA "Souscrire un autre engagement" en bas de la liste**

Dans `views/pages/account/forfaits.php`, remplacer le bloc `<p class="text-muted mt-4">...` à la fin :

```php
    <p class="text-muted mt-4" style="font-size: 0.875rem;">
        Pour renouveler ou changer de formule, vous pouvez souscrire un nouvel engagement à tout moment.
    </p>
    <a href="/tarifs" class="btn btn-outline mt-2">Souscrire un autre engagement</a>
<?php endif; ?>
```

Et dans la branche `empty($active)`, le bouton existant pointe déjà sur `/tarifs` → OK.

- [ ] **Step 2: Vérifier la syntaxe**

Run: `php -l views/pages/account/forfaits.php`
Expected: `No syntax errors detected`

- [ ] **Step 3: Test manuel**

Ouvrir http://localhost:8000/mon-compte/forfaits (connecté)

Expected:
- Si forfait actif : bouton "Souscrire un autre engagement" en bas → pointe /tarifs
- Si pas de forfait : bouton "Voir les tarifs" déjà présent

- [ ] **Step 4: Commit**

```bash
git add views/pages/account/forfaits.php
git commit -m "feat(stripe): CTA Souscrire un autre engagement sur /mon-compte/forfaits"
```

---

### Task 12 : `.env.example` + documentation dev local Stripe CLI

**Files:**
- Modify: `.env.example`
- Create: `docs/checkout-developpement-local.md`

- [ ] **Step 1: Ajouter `STRIPE_ENABLE_BNPL` à `.env.example`**

Lire `.env.example`, puis remplacer la section Stripe existante :

```env
# Stripe (mode TEST en local, LIVE en prod)
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Activation BNPL (Alma 2x/3x/4x, Klarna 3x) — false = CB uniquement, utile en staging
STRIPE_ENABLE_BNPL=true
```

- [ ] **Step 2: Créer la documentation dev local**

Fichier `docs/checkout-developpement-local.md` :

```markdown
# Test local du flow Stripe Checkout (WAMP / Windows)

## 1. Pré-requis

- Compte Stripe en mode **TEST** activé (https://dashboard.stripe.com)
- `stripe/stripe-php` ^15 installé (déjà dans composer)
- Stripe CLI installé localement (https://docs.stripe.com/stripe-cli)

## 2. Récupérer les clés API TEST

Dashboard Stripe → **Développeurs** → **Clés API**

Copier dans `.env` :

```
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
```

## 3. Démarrer le webhook forwarding

Dans un terminal séparé, depuis n'importe quel dossier :

```
stripe login
stripe listen --forward-to http://localhost:8000/stripe/webhook
```

Stripe CLI affiche :
```
> Ready! Your webhook signing secret is whsec_XXXXXXXX (^C to quit)
```

Copier ce `whsec_...` dans `.env` :
```
STRIPE_WEBHOOK_SECRET=whsec_XXXXXXXX
```

**Laisser ce terminal ouvert pendant tout le développement.**

## 4. Activer Alma et Klarna en mode TEST

Dashboard Stripe → **Paramètres** → **Méthodes de paiement** :
- Activer **Alma** (France)
- Activer **Klarna** (France)

Les deux fonctionnent en sandbox sans config supplémentaire.

## 5. Tester le flow

1. Ouvrir http://localhost:8000/tarifs
2. Cliquer "Souscrire" sur un pack
3. Sur la page Stripe Checkout : 3 boutons visibles (CB, Alma, Klarna)
4. Choisir CB et utiliser une **carte de test** :

| Cas | Numéro |
|---|---|
| Succès | `4242 4242 4242 4242` |
| 3DS exigé | `4000 0027 6000 3184` |
| Refus | `4000 0000 0000 0002` |

Date d'expiration : n'importe quelle date future. CVC : 3 chiffres.

5. Vérifier dans le terminal Stripe CLI : `--> checkout.session.completed [evt_...]`
6. Vérifier dans la BDD : `SELECT * FROM t_payments ORDER BY payment_ID DESC LIMIT 1;` → `status=succeeded`
7. Vérifier dans la BDD : `SELECT * FROM t_subscriptions ORDER BY subscription_ID DESC LIMIT 1;` → forfait actif
8. Rafraîchir `/mon-compte/forfaits` → forfait apparaît

## 6. Cas Alma / Klarna en sandbox

- **Alma test** : la redirection Alma propose un bouton "Approuver" — accepter le paiement.
- **Klarna test** : Klarna affiche un écran de simulation avec scénarios (Success, Decline, Authentication Required).

## 7. Mode dégradé (Stripe non configuré)

Si vous voulez tester l'UI sans Stripe :
- Vider `STRIPE_SECRET_KEY` dans `.env`
- Recharger /tarifs → les boutons "Souscrire" deviennent "Bientôt disponible" (disabled)
- Aucun crash, message clair côté utilisateur.

## 8. Troubleshooting

| Symptôme | Cause probable |
|---|---|
| `Invalid signature` au webhook | `STRIPE_WEBHOOK_SECRET` ne correspond pas au `whsec_` du CLI actuel — recopier |
| Bouton "Souscrire" → "Le paiement en ligne est en cours d'activation" | une des 3 clés `STRIPE_*` est vide dans `.env` |
| Webhook reçu mais forfait pas créé | regarder `storage/logs/app.log` et le terminal Stripe CLI pour l'erreur exacte |
| Webhook reçu deux fois (Stripe retry) | normal — vérifier `t_processed_webhooks.event_id`, doit être présent une fois |
```

- [ ] **Step 3: Commit**

```bash
git add .env.example docs/checkout-developpement-local.md
git commit -m "docs(stripe): ajout STRIPE_ENABLE_BNPL et guide test local Stripe CLI"
```

---

### Task 13 : Recette finale — critères d'acceptation du spec

**Files:** aucun (validation manuelle)

- [ ] **Step 1: Recette manuelle** — pour chaque critère du spec, vérifier en local

Cocher chacun :

- [ ] `/tarifs` affiche les 5 packs avec mention `4 × X €` pour les packs ≤ 2000€
- [ ] `/emerald/tarifs` affiche les mêmes packs dynamiquement
- [ ] Bouton "Souscrire" connecté → Stripe Checkout
- [ ] Sur Stripe Checkout : 3 boutons (CB + Alma + Klarna) pour Bronze à Platinum
- [ ] Paiement CB `4242…` réussi → /checkout/success → `t_payments.status=succeeded` + `t_subscriptions` créé
- [ ] Paiement annulé → /checkout/cancel → `t_payments.status=failed`
- [ ] Webhook avec signature invalide → HTTP 400 (`stripe trigger` avec mauvais secret)
- [ ] Webhook même event_id 2 fois → idempotent (vérifier `t_processed_webhooks`)
- [ ] User non connecté clique "Souscrire" → /login?return=… → retour automatique post-login
- [ ] Pack Entreprise → bouton "Demander un devis" (pas Souscrire)
- [ ] Vider une clé Stripe dans `.env` → boutons désactivés, pas de crash
- [ ] `/mon-compte/forfaits` montre le forfait immédiatement après webhook
- [ ] `/admin/paiements` montre le paiement avec type `subscription_purchase`

- [ ] **Step 2: Run la suite de tests complète**

Run: `vendor/bin/phpunit`
Expected: tous les tests PASS

- [ ] **Step 3: Lint PSR-12**

Run: `composer lint`
Expected: 0 erreur (warnings acceptables)

- [ ] **Step 4: Commit final (si modifs résiduelles de lint)**

```bash
git status
# si du diff résiduel après corrections lint :
git add -u
git commit -m "chore(stripe): corrections PSR-12 finales sur le flow Checkout"
```

---

## Self-Review (effectué post-rédaction)

### Spec coverage

| Section spec | Couverte par |
|---|---|
| §3 Flow utilisateur | T6 (start), T7 (success/cancel), T8 (webhook) |
| §4 StripeCheckoutService | T2 (computeInstallments), T3 (isConfigured/createSession/verifyWebhook) |
| §4 CheckoutController | T6, T7, T8 |
| §4 PaymentRepository ajouts | T4 |
| §4 SubscriptionRepository::createFromPayment | T5 |
| §4 Migration 025 | T1 |
| §4 Routes | T6 |
| §4 Vues à modifier/créer | T7 (checkout/), T9 (public/tarifs), T10 (emerald/tarifs), T11 (forfaits) |
| §5 Sécurité (CSRF, signature, IDOR, replay, amount check) | T6 (CSRF), T7 (IDOR), T8 (signature, replay, amount) |
| §5 Mode dégradé | T6 (start), T9 (vue) |
| §5 Pack > 2000€ | T2 + T3 (omit alma) |
| §5 Réconciliation webhook avant success | T7 (poll JS) |
| §5 Test local Windows | T12 (doc) |
| §6 `.env.example` | T12 |
| §8 Critères d'acceptation | T13 |

Tous les éléments du spec sont couverts.

### Placeholder scan
- Aucun "TBD", "TODO", "implement later"
- Tous les blocs de code sont complets
- Chaque commande a son output attendu

### Type consistency
- `computeInstallments(int): array` cohérent T2 → T3 → T9 → T10
- `createFromPayment(int, int, int, ?int, string, string, ?string): int` cohérent T5 → T8
- `markFailed(int, string): int` cohérent T4 → T6 → T7 → T8
- `findBySessionId(string): ?array` cohérent T4 → T7

### Scope
Plan focalisé sur un seul flow utilisateur (souscription V1). Aucune dérive vers refunds, renouvellement auto ou facture PDF. OK.
