# Souscription en ligne d'un forfait + paiement Stripe (CB + Alma 2x/3x/4x + Klarna 3x)

> **Statut** : design validé, prêt pour plan d'implémentation
> **Auteur** : R. Galofaro
> **Date** : 2026-05-20
> **Jalon plan de mise en prod** : JALON 1 (Stripe end-to-end)
> **Effort estimé** : 3-4 jours
> **Pré-requis livrés** : `t_subscription_plans`, `t_subscriptions`, `t_payments`, `t_processed_webhooks`, `stripe/stripe-php` ^15 dans composer

---

## 1. Objectif

Permettre à un client connecté de souscrire en ligne à un forfait de la grille tarifaire et de le payer immédiatement, avec choix entre paiement comptant (CB) ou échelonné sans frais (Alma 2x/3x/4x, Klarna 3x). Le forfait devient actif automatiquement à confirmation du paiement.

**Hors scope V1** :
- Pack Entreprise B2B (devis manuel — Alma/Klarna sont B2C uniquement)
- Renouvellement automatique annuel (Alma/Klarna ne supportent pas `mode: subscription` Stripe en 2026 ; renouvellement reste manuel via email J-15 du Jalon B.3)
- Refunds en self-service côté client (uniquement admin via dashboard Stripe pour V1)
- Génération PDF de facture (Jalon C numérotation factures)

## 2. Approche retenue

**Stripe Checkout hosted** (page de paiement hébergée par Stripe) avec trois `payment_method_types` activés :

| Méthode | Modalité | Frais client | Plafond | Couvre |
|---|---|---|---|---|
| `card` | comptant CB | 0 | aucun | tous packs |
| `alma` | 2x / 3x / 4x sans frais | 0 | 50€ – 2 000€ | Bronze→Platinum (sous 2 000€) |
| `klarna` | 3x sans frais ou 30j différé | 0 | variable | tous packs B2C |

**Pourquoi cette stack** :
- Une seule intégration (`stripe/stripe-php`), pas de SDK secondaire Alma/Klarna
- Stripe affiche automatiquement les boutons côte à côte ; le client choisit au moment du paiement
- Le commerçant reçoit l'intégralité du montant immédiatement (Alma/Klarna assument le risque crédit)
- Conforme PCI-DSS sans scope, 3DS géré par Stripe, SCA conforme PSD2
- Pas de JS Stripe à intégrer dans le front (compatible HTML/CSS/JS vanilla du projet)

**Pourquoi `mode: 'payment'` one-shot** :
- Alma et Klarna ne supportent pas `mode: 'subscription'` sur Stripe en 2026
- Les packs ALDANA sont des engagements à durée fixe (`duration_months = 12`), pas des abonnements à reconduction tacite
- Le renouvellement manuel via email J-15 est déjà prévu (CRON Jalon B.3)
- Évite la complexité `customer.subscription.created` / `invoice.paid` / SCA récurrent

## 3. Flow utilisateur

```
┌─────────────────────────────────────────────────────────────────┐
│  Page Tarifs (/tarifs ou /emerald/tarifs)                       │
│  ┌─────────────────────────────────────┐                         │
│  │ Pack Yogi Gold                      │                         │
│  │ 1 200 €                             │                         │
│  │ ou 4 × 300 € sans frais (Alma)      │                         │
│  │ ou 3 × 400 € sans frais (Klarna)    │                         │
│  │ [ Souscrire ]                       │  ← form POST CSRF      │
│  └─────────────────────────────────────┘                         │
└─────────────────────────────────────────────────────────────────┘
                          │
        ┌─────────────────┴─────────────────┐
        │ Non connecté                      │ Connecté
        ▼                                   ▼
  /login?return=<retour>          POST /checkout/start
        │                                   │
        │                  ┌────────────────┴────────────────┐
        │                  │ 1. Vérif plan actif, prix > 0    │
        │                  │ 2. INSERT t_payments status=pending│
        │                  │ 3. Stripe\Checkout\Session::create│
        │                  │    (card + alma si <2000€ + klarna)│
        │                  │ 4. UPDATE t_payments              │
        │                  │    SET stripe_checkout_session_id  │
        │                  │ 5. 303 redirect → Stripe          │
        │                  └────────────────┬────────────────┘
        │                                   ▼
        │              ┌─────────────────────────────────────┐
        │              │  checkout.stripe.com (page Stripe)  │
        │              │  Choix méthode : CB / Alma / Klarna │
        │              │  Saisie infos, 3DS, autorisation    │
        │              └─────────────────────────────────────┘
        │                          │
        │           ┌──────────────┴──────────────┐
        │           │ Succès                       │ Annulation
        │           ▼                              ▼
        │   /checkout/success?session_id=…  /checkout/cancel
        │   (affichage "merci")             (UPDATE t_payments
        │                                    SET status=failed)
        │
        │   Parallèlement : Stripe POST → /stripe/webhook
        │      checkout.session.completed
        │      → vérif signature
        │      → idempotence via t_processed_webhooks
        │      → UPDATE t_payments status=succeeded
        │      → INSERT t_subscriptions status=active
        │
        └→ Client revient sur /mon-compte/forfaits → voit son engagement actif
```

**Source de vérité = webhook**, pas le `success_url` (sécurité). Le `success_url` est purement cosmétique.

## 4. Architecture des composants

### Service `StripeCheckoutService`

**Fichier** : `src/Services/StripeCheckoutService.php`

Wrapper autour du SDK `stripe/stripe-php`. Trois responsabilités :

```
class StripeCheckoutService
{
    public function isConfigured(): bool;
    public function createSession(array $plan, int $userID, string $userEmail, string $returnUrl): string;
        // retourne l'URL Stripe Checkout
    public function verifyWebhook(string $rawBody, string $sigHeader): \Stripe\Event;
        // throws SignatureVerificationException
    public function computeInstallments(int $priceCents): array;
        // retourne ['alma' => [2 => 'X €', 3 => 'Y €', 4 => 'Z €'], 'klarna' => [3 => 'W €']]
        // pour affichage page tarifs ; omet Alma si > 2000€
}
```

Méthodes de calcul :
- `computeInstallments(120000)` → `['alma' => [2 => '600,00 €', 3 => '400,00 €', 4 => '300,00 €'], 'klarna' => [3 => '400,00 €']]`
- `computeInstallments(250000)` → `['alma' => [], 'klarna' => [3 => '833,33 €']]` (Alma omis car > 2000€)
- Arrondi : centimes entiers, dernier paiement absorbe le reste

Construction `Checkout\Session::create()` :
- `payment_method_types`: `['card', 'klarna']` + `'alma'` si prix ≤ 2 000€
- `mode`: `'payment'`
- `currency`: `'eur'`
- `locale`: `'fr'`
- `customer_email`: email du user (pré-rempli)
- `line_items`: une seule ligne, `price_data.product_data.name` = nom du pack, `unit_amount` = `price_cents`
- `success_url`: `APP_URL . '/checkout/success?session_id={CHECKOUT_SESSION_ID}'`
- `cancel_url`: `APP_URL . '/checkout/cancel?payment_id=<ID>'`
- `metadata`: `['payment_ID' => …, 'user_ID' => …, 'plan_ID' => …]` (récupéré côté webhook)
- `payment_intent_data.metadata`: pareil (propagation au PaymentIntent pour traçabilité Stripe)
- `expires_at`: now + 30 min (auto-cleanup côté Stripe)

### Controller `CheckoutController`

**Fichier** : `src/Controllers/CheckoutController.php`

Quatre actions :

```
class CheckoutController extends BaseController
{
    public function start(): void;     // POST, auth requis, CSRF
    public function success(): void;   // GET, auth requis
    public function cancel(): void;    // GET, auth requis
    public function webhook(): void;   // POST, sans CSRF, sans session
}
```

**`start()`** :
1. Lire `plan_id` ou `plan_slug` depuis `$_POST`
2. `PlanRepository::findByID()` → vérif `active=1` et `price_cents > 0` et `requires_quote=0`
3. Si pack `requires_quote=1` (Entreprise) → flash error + redirect
4. Si Stripe non configuré (`!$service->isConfigured()`) → flash error + redirect `/tarifs`
5. `PaymentRepository::create()` → INSERT `t_payments` status=`pending`, type=`subscription_purchase`
6. `StripeCheckoutService::createSession(...)` → URL Stripe
7. `PaymentRepository::setSessionId($paymentID, $sessionId)`
8. `header('Location: ' . $checkoutUrl, true, 303); exit;`

**`success()`** :
1. Lire `session_id` depuis `$_GET`
2. `PaymentRepository::findBySessionId($sessionId)` → vérif appartient au user en session (sécurité IDOR)
3. Afficher `views/pages/checkout/success.php` :
   - Si `status=succeeded` → "Votre engagement est actif, retrouvez-le dans Mes engagements"
   - Sinon → "Paiement en cours de confirmation, vous recevrez un email dans quelques instants" (le webhook va arriver)

**`cancel()`** :
1. Lire `payment_id`
2. `PaymentRepository::markFailed($paymentID, 'cancelled_by_user')` (si encore `pending`)
3. Afficher `views/pages/checkout/cancel.php` : "Paiement annulé, aucune somme prélevée"

**`webhook()`** :
1. Lire `php://input` et `HTTP_STRIPE_SIGNATURE`
2. `StripeCheckoutService::verifyWebhook()` → `\Stripe\Event`
3. Idempotence : `INSERT INTO t_processed_webhooks (event_id, event_type) VALUES (?, ?)` — si échec UNIQUE, return 200 immédiat
4. Switch sur `$event->type` :
   - `checkout.session.completed` → `handleSessionCompleted($event->data->object)`
   - `payment_intent.payment_failed` → `handlePaymentFailed(...)`
   - autres → log + ignore
5. Toujours retourner HTTP 200 (sinon Stripe retry)

`handleSessionCompleted($session)` (transactionnel) :
1. Récupérer `payment_ID` depuis `$session->metadata->payment_ID`
2. `PaymentRepository::markSucceeded($paymentID, $session->payment_intent)`
3. Calculer `valid_from = today`, `valid_until = today + plan.duration_months mois` (ou `plan.validity_days` jours pour les carnets)
4. `SubscriptionRepository::createFromPayment($paymentID, $userID, $planID, $sessionsLeft, $validFrom, $validUntil)`
5. Trigger email confirmation (via `EmailService` existant, hors scope strict mais à brancher)

### Repository ajouts

**`PaymentRepository`** (méthodes manquantes) :
- `create(int $userID, ?int $subscriptionID, int $amountCents, string $type): int` — retourne `payment_ID`
- `findBySessionId(string $sessionId): ?array`
- `setSessionId(int $paymentID, string $sessionId): void`
- `markSucceeded(int $paymentID, ?string $paymentIntentId): void`
- `markFailed(int $paymentID, string $reason): void`

**`SubscriptionRepository`** (méthode manquante) :
- `createFromPayment(int $paymentID, int $userID, int $planID, ?int $sessionsLeft, string $validFrom, string $validUntil): int`
  - Transactionnel (`BEGIN; INSERT t_subscriptions; UPDATE t_payments SET subscription_ID; COMMIT;`)
  - Calque sur `adminCreate()` existant mais sans créer un nouveau payment (déjà existant en pending)
  - Le `subscription_ID` est lié au payment via la colonne existante `t_payments.subscription_ID`

### Migration BDD

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

Ajoute deux colonnes idempotentes à `t_payments` :

```sql
-- stripe_checkout_session_id : pour retrouver un payment depuis le retour success_url
ALTER TABLE t_payments ADD COLUMN stripe_checkout_session_id VARCHAR(255) NULL AFTER stripe_charge_id;
ALTER TABLE t_payments ADD UNIQUE INDEX uq_stripe_session (stripe_checkout_session_id);

-- failure_reason : pour logguer le motif d'échec (annulé user, refus banque, etc.)
ALTER TABLE t_payments ADD COLUMN failure_reason VARCHAR(255) NULL AFTER status;
```

Pattern idempotent (vérif `INFORMATION_SCHEMA` avant ALTER) sur le même modèle que la migration 024.

### Routes ajoutées

**`config/routes.php`** :

```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]],
```

### Vues à modifier ou créer

| Vue | Modif |
|---|---|
| `views/pages/public/tarifs.php` | Remplacer "Voir le planning" par `<form method="POST" action="/checkout/start">` avec `plan_id` hidden + CSRF + bouton "Souscrire". Afficher mentions 3x/4x sous le prix. |
| `views/pages/emerald/tarifs.php` | Idem (boucler sur `$plans` dynamiques au lieu du hardcode actuel). |
| `views/pages/account/forfaits.php` | Si `empty($active)` → bouton "Voir les tarifs" déjà présent. Si forfait actif → ajouter "Renouveler" ou "Souscrire un nouveau" pointant `/tarifs`. |
| `views/pages/checkout/success.php` | Nouveau — confirmation + lien `/mon-compte/forfaits`. |
| `views/pages/checkout/cancel.php` | Nouveau — annulation + lien `/tarifs`. |

### Contrôleur Public à enrichir

Pour `/tarifs` (et `/emerald/tarifs`), passer aux vues :
- `$plans` (`PlanRepository::listActive()`)
- `$inclusions` par plan (`PlanInclusionRepository::findByPlan()`)
- `$installments` par plan (`StripeCheckoutService::computeInstallments($plan['price_cents'])`)
- `$stripeEnabled` (`$service->isConfigured()`)

Si `$stripeEnabled === false`, on désactive le bouton "Souscrire" et on affiche le message "paiement en ligne en cours d'activation" actuel (pas de régression).

## 5. Cas limites et règles

### Sécurité

- **CSRF** sur `/checkout/start` (token vérifié par le routeur). Webhook explicitement `'csrf' => false`.
- **Signature webhook** : `\Stripe\Webhook::constructEvent($body, $sig, $secret)` obligatoire — refuser HTTP 400 si invalide. **Jamais traiter un payload non signé.**
- **IDOR** sur `/checkout/success` : vérifier `$payment['user_ID'] === $_SESSION['user_ID']`, sinon 404.
- **Replay attack** : `t_processed_webhooks.event_id` PRIMARY KEY garantit qu'un event Stripe retransmis n'est traité qu'une fois.
- **Montant** : Stripe est source de vérité du montant payé ; on stocke `amount_cents` dans `t_payments` au moment du `create()`. Au webhook, on vérifie `$session->amount_total === $payment['amount_cents']` (sécurité contre manipulation côté client — métadonnée).
- **Plan désactivé entre création session et webhook** : le webhook crée quand même le forfait (le client a payé). Politique : `active=0` empêche nouvelles souscriptions, n'affecte pas les existantes.

### Mode dégradé

Trois variables `.env` doivent être présentes ET non vides :
- `STRIPE_SECRET_KEY` (sk_test_... ou sk_live_...)
- `STRIPE_PUBLISHABLE_KEY` (pk_test_... ou pk_live_...)
- `STRIPE_WEBHOOK_SECRET` (whsec_...)

Si l'une manque, `StripeCheckoutService::isConfigured()` retourne `false`. Comportement :
- Pages tarifs : bouton "Souscrire" disabled + tooltip "Paiement en ligne en cours d'activation"
- `POST /checkout/start` : flash error + redirect `/tarifs`
- `POST /stripe/webhook` : HTTP 503 (pour que Stripe retry plus tard si secret manquant)

### Double souscription

Si le user a déjà un forfait `active` qui chevauche la nouvelle période :
- **Pas de blocage** : le user peut cumuler un Bronze visio + un drop-in Gold
- **V1** : autorisé directement, sans modale de confirmation
- **V2 (hors scope)** : page intermédiaire `/checkout/confirm` affichant le forfait existant et demandant validation avant d'aller sur Stripe

### Pack au-dessus 2 000 €

- `payment_method_types` omet `'alma'`
- Mention 3x/4x Alma masquée sur la carte tarif (`$installments['alma']` vide)
- Klarna et CB restent disponibles

### Réconciliation : webhook arrive avant success_url

Possible si réseau client lent. La page `/checkout/success` :
1. Cherche `t_payments` par `stripe_checkout_session_id`
2. Si `status === 'succeeded'` → "Votre engagement est actif"
3. Sinon → "Paiement reçu, confirmation en cours" + auto-refresh JS 3s (max 3 essais)

### Réconciliation : webhook arrive en retard / jamais

Mitigation V1 : page `success` poll 3× toutes les 3s. Si toujours `pending`, message "vous recevrez un email de confirmation, contactez-nous si problème". L'admin peut voir le payment `pending` dans `/admin/paiements` et reconcilier manuellement.

Mitigation V2 (hors scope) : CRON `ReconcilePendingPayments` qui interroge Stripe pour les payments `pending` > 1h et finalise.

### Test local (Windows / WAMP)

Stripe CLI permet le forwarding webhook vers `localhost`. À documenter dans `docs/checkout-developpement-local.md` :

```
stripe login
stripe listen --forward-to http://localhost:8000/stripe/webhook
# copie le whsec_... affiché dans .env (STRIPE_WEBHOOK_SECRET)
```

Cartes de test Stripe :
- CB succès : `4242 4242 4242 4242`
- CB 3DS : `4000 0027 6000 3184`
- Alma : Alma sandbox redirige toujours en succès en mode test
- Klarna : Klarna sandbox propose des scénarios succès / refus

## 6. Mise à jour `.env.example`

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

## 7. Conformité légale

- **CGV** : la page `/cgv` existe. Doit lister : montant TTC, durée engagement, modalités paiement (comptant + 2x/3x/4x), absence de droit de rétractation pour les prestations de service (article L221-28 13° Code conso si exécution commencée avec accord exprès), conditions remboursement.
- **Mentions sur Checkout** : Stripe Checkout affiche automatiquement les conditions de Alma/Klarna sur leur écran de redirection (réglementé CCD II UE 2025).
- **Cookies** : Stripe Checkout ouvert sur `checkout.stripe.com`, hors scope cookies aldanayoga.com. Le redirect ne nécessite pas de consentement préalable (paiement = service essentiel).
- **RGPD** : transmission email + nom à Stripe (sous-traitant). À mentionner dans `/politique-de-confidentialite` (Jalon RGPD).

## 8. Critères d'acceptation V1

1. ✅ Page `/tarifs` affiche les 5 packs (et Entreprise) avec mention `4 × 300 € sans frais (Alma)` pour Yogi Gold
2. ✅ Page `/emerald/tarifs` affiche les mêmes packs dynamiquement (plus de hardcode)
3. ✅ Bouton "Souscrire" sur pack Bronze (utilisateur connecté) → redirige vers Stripe Checkout
4. ✅ Sur Stripe Checkout : 3 boutons visibles (CB, Alma, Klarna) ; pack > 2000€ → 2 boutons (CB, Klarna)
5. ✅ Paiement CB test `4242…` réussi → retour `/checkout/success` → `t_payments.status=succeeded` + `t_subscriptions` créé avec bonnes dates
6. ✅ Paiement annulé → `/checkout/cancel` → `t_payments.status=failed`
7. ✅ Webhook signature invalide → HTTP 400, aucune modif BDD
8. ✅ Webhook même event_id 2 fois → traité 1 seule fois (idempotence)
9. ✅ User non connecté clique "Souscrire" → redirect `/login?return=…` puis retour automatique sur le checkout
10. ✅ Pack Entreprise : bouton "Demander un devis" (pas "Souscrire"), pointe vers contact (formulaire dédié hors scope V1)
11. ✅ Stripe non configuré (.env vide) : bouton désactivé, message clair, aucun crash
12. ✅ `/mon-compte/forfaits` montre le forfait actif immédiatement après confirmation webhook
13. ✅ Admin voit le paiement dans `/admin/paiements` avec type `subscription_purchase`

## 9. Risques et mitigations

| Risque | Impact | Mitigation |
|---|---|---|
| Webhook secret manquant en prod → forfaits jamais créés | bloquant | Check `isConfigured()` au boot, log critique si absent |
| Stripe CLI non installé dev → webhook impossible à tester local | dev only | Doc claire dans `docs/checkout-developpement-local.md` |
| Alma rejette pour montant trop bas en test (50€ min) | minor | Tester en sandbox avec montant > 60€ |
| Race condition double-webhook (Stripe retry concurrent) | doublons | INSERT UNIQUE event_id avant tout — fenêtre de race < 1ms |
| User ferme onglet pendant Stripe Checkout, revient demain | UX | Session Stripe expire à 30 min, payment reste `pending` puis nettoyé V2 |
| Plan modifié (prix changé) entre création session et paiement | rare | Prix figé dans `t_payments.amount_cents` à la création, webhook vérifie cohérence |
| Différence centimes arrondi 3x/4x Alma vs notre affichage | cosmétique | Affichage = floor, dernier versement absorbe le reste. Stripe gère le calcul réel. |

## 10. Out of scope (futurs jalons)

- **Renouvellement automatique annuel** : Jalon B.3, email J-15 manuel
- **Refunds client self-service** : Jalon B.2 admin uniquement
- **Facture PDF numérotée** : Jalon C.3
- **Stripe Customer persistant** (pour 1-click renouvellement) : V2
- **A/B testing affichage prix mensuel vs annuel** : marketing V2
- **Promo codes / coupons Stripe** : V2
- **Paiement événements** (`event_purchase`) : déjà prévu schéma, à brancher Jalon Événements

---

## Annexe A — Diagramme séquence webhook

```
Browser           ALDANA                Stripe              BDD
   │                │                     │                  │
   │ POST /checkout/start                 │                  │
   ├───────────────>│                     │                  │
   │                │ INSERT t_payments pending             │
   │                ├─────────────────────────────────────>│
   │                │ Session::create([card,alma,klarna])  │
   │                ├────────────────────>│                  │
   │                │<────────────────────│ session_id + url │
   │                │ UPDATE session_id                      │
   │                ├─────────────────────────────────────>│
   │<───────────────│ 303 → checkout.stripe.com             │
   │                │                     │                  │
   │ saisie CB / choix Alma 3x                              │
   ├───────────────────────────────────────>│                │
   │                │                     │ autorisation 3DS │
   │                │                     │ ←────────────   │
   │                │                     │                  │
   │<──────────────────────────────────────│ 303 /success   │
   │                │                     │                  │
   │ GET /checkout/success?session_id=…   │                  │
   ├───────────────>│                     │                  │
   │                │ SELECT t_payments WHERE session_id=?  │
   │                ├─────────────────────────────────────>│
   │                │                                       │
   │                │       (en parallèle)                   │
   │                │<────────────────────│ POST /webhook   │
   │                │ verify signature                       │
   │                │ INSERT t_processed_webhooks UNIQUE    │
   │                ├─────────────────────────────────────>│
   │                │ UPDATE t_payments status=succeeded    │
   │                │ INSERT t_subscriptions active         │
   │                ├─────────────────────────────────────>│
   │                │ ──────> 200 OK                         │
   │<───────────────│ vue success.php "Votre engagement est actif" (auto-refresh si pending)
   │                │                     │                  │
```

## Annexe B — Calcul mention 3x/4x

```php
function computeInstallments(int $priceCents): array
{
    $result = ['alma' => [], 'klarna' => []];

    // Alma : 50€-2000€
    if ($priceCents >= 5_000 && $priceCents <= 200_000) {
        foreach ([2, 3, 4] as $n) {
            $perInstallment = intdiv($priceCents, $n);
            $result['alma'][$n] = number_format($perInstallment / 100, 2, ',', ' ') . ' €';
        }
    }

    // Klarna : pas de plafond légal, on retient 3x
    $perInstallment = intdiv($priceCents, 3);
    $result['klarna'][3] = number_format($perInstallment / 100, 2, ',', ' ') . ' €';

    return $result;
}
```

Affichage côté vue (le tableau Alma est indexé par nombre d'échéances `[2,3,4]`, on prend le plus grand) :

```php
<?php if (!empty($installments['alma'])):
    $maxN  = array_key_last($installments['alma']);
    $perN  = $installments['alma'][$maxN];
?>
    <p class="plan-installment">
        ou <?= e((string) $maxN) ?> × <strong><?= e($perN) ?></strong> sans frais (Alma)
    </p>
<?php endif; ?>
<?php if (!empty($installments['klarna'][3])): ?>
    <p class="plan-installment">ou 3 × <?= e($installments['klarna'][3]) ?> avec Klarna</p>
<?php endif; ?>
```

---

**FIN DU SPEC**
