# Stripe Billing Implementation Plan (Laravel Cashier)

## Résumé

### Principe de base : deux horloges indépendantes

| | Facturation | Certification |
|---|---|---|
| Durée | 1 an | 6 mois |
| Champ | `user_plan` (`free`/`pro`) | `user_accredited` / `user_first_accredited` |
| Qui gère | Stripe (pas de cron applicatif) | Endpoint API interne |

Dépendance **unidirectionnelle** : on ne peut être (ré)accrédité que si `user_plan === PRO`, mais une certification expirée n'affecte jamais l'abonnement.

### État d'implémentation au 28/08/2026

| Section | État |
|---|---|
| §1 Package & config | ✅ livré — Cashier v16.7 installé, migrations publiées et exécutées, `services.stripe.pro_price`, exclusion CSRF `stripe/*`, variables `.env` (clés Stripe à renseigner depuis le Dashboard) |
| §2 Modèles | ✅ livré — enum `PRO` + migration de renommage, `Billable`, modèle `Subscription` (`cancel_at` + `autoRenews()`), `useSubscriptionModel()`, **correctif `UserObserver` fait** (+ 2 tests Pest) |
| §3 Checkout | ✅ livré — `SubscriptionCheckoutRequest`, `SubscriptionController` (`edit`/`checkout`/`portal`), 3 routes, 10 tests Pest, journalisation + rattrapage des erreurs Stripe |
| §4 Webhook | ✅ livré — `StripeWebhookController` (3 handlers + `syncFromPayload()`), `Cashier::ignoreRoutes()`, route `cashier.webhook` dans `web.php`, 7 tests Pest |
| §5 Endpoint d'accréditation | ✅ livré (contrôleur + route + 6 tests Pest) — gate désormais sur `UserPlan::PRO` |
| §6 API mobile | ✅ livré — `subscribed`, `subscription_ends_at`, `subscription_auto_renews` dans `AuthResource` (+ 3 tests Pest) |
| §7 Frontend | ✅ livré — `Subscription.vue` (2 états), entrée de nav `Subscription`, types TS, `wayfinder:generate` |
| §8 Cross-repo | ✅ app mobile corrigée (accepte `pro` et `premium`) ; ✅ `coloid-api` (branche `v2`) compare désormais à `'pro'` aux 2 endroits — ⚠️ swap sec, pas le helper tolérant recommandé : v2 ne doit pas tourner contre une Auth API servant encore `premium` |

### Les 8 étapes

1. **Package & config** — `laravel/cashier` + migrations publiées (tables `subscriptions`), clés Stripe en `.env`, `CASHIER_CURRENCY=eur`, exclusion CSRF pour `stripe/*`. Le Product/Price (intervalle 1 an) est créé à la main dans le Dashboard.

2. **Modèles** — renommage de l'enum `UserPlan::PREMIUM` → `PRO`, trait `Billable` sur `User`, modèle `Subscription` custom avec colonne `cancel_at` et accesseur `autoRenews()`. L'accesseur `certificationExpiresAt()` est abandonné (voir §2).
   ⚠️ La migration de renommage ne peut pas suivre l'ordre « données puis `->change()` » prévu ici : la contrainte doit d'abord être **élargie** à `['free','premium','pro']`, puis les lignes déplacées, puis la contrainte resserrée. Détail et message d'erreur en §2.
   ✅ **Correctif obligatoire préalable fait** : `UserObserver::updated()` accédait à `$user->profile` sans null-check — ça aurait crashé quand le webhook met à jour `user_plan` hors requête HTTP, et Stripe aurait rejoué le webhook indéfiniment.

3. **Checkout** — le choix du renouvellement automatique se fait *à la création de la session* : si `auto_renew` est décoché, on passe `subscription_data.cancel_at = now()+1an`, ce qui fait annuler l'abonnement par Stripe au bout d'un an. Sinon, facturation récurrente Stripe normale. Portail de facturation Stripe pour la gestion ultérieure.

4. **Webhook** — `StripeWebhookController` étend celui de Cashier, appelle `parent::` puis synchronise `user_plan` selon `stripe_status === 'active'`, et recopie `cancel_at` en local.

5. **Endpoint d'accréditation** — ✅ **livré.** `POST /api/user/accreditation` (Sanctum), gate 403 si pas payant, bump `user_accredited` à `now()`, `user_first_accredited` seulement à la première fois, réponse `AuthResource`. Sert à la fois première certification et renouvellement 6 mois. La réserve « rien n'autorise le *pourquoi* de l'appel » est levée côté appelant : `coloid-api` ne pousse une accréditation que pour une partie de certification dépassant les seuils SODA, avec `game_id` UNIQUE (pas de double certification) et un flag `synced` pour rejouer un push échoué.

6. **API mobile** — ⏳ `AuthResource` expose déjà `user_first_accredited`. Restent `subscribed`, `subscription_ends_at`, `subscription_auto_renews`. `certification_expires_at` est **abandonné côté Laravel** : c'est `coloid-api` qui dérive la validité 6 mois (`CERTIFICATION_VALIDITY_MONTHS`) — voir §6.

7. **Frontend** — page `/settings/subscription` (titre « CoLo-ID Pro », sous-titre « Manage your subscription ») : sans abonnement, un formulaire de préférence `Renew automatically every year` + bouton ; avec abonnement, les dates et un bouton « Manage my subscription » vers le portail Stripe — **et pas de case à cocher**, qui serait inerte. Redirection vers Stripe via `Inertia::location()`. Plus l'entrée de nav, les types TS et `wayfinder:generate`.

8. **Suivi cross-repo** — ✅ l'app mobile accepte désormais les deux valeurs (`plan === "pro" || plan === "premium"`), ce qui **supprime la contrainte d'ordre de déploiement**. ⚠️ En revanche `coloid-api` compare `$user->plan` à `'premium'` en dur à 2 endroits (`api.php:1580` et `api.php:2495`) : c'est le nouveau bloquant du renommage.

### Journalisation (ajoutée le 28/08/2026, hors plan initial)

Canal Log dédié `stripe` (`config/logging.php` → `storage/logs/stripe.log`, quotidien, 90 jours),
volontairement hors du stack par défaut pour que l'historique lié à l'argent se lise seul :

| Où | Niveau | Ligne |
|---|---|---|
| `checkout()` | `info` | `Checkout session opened` — user, `stripe_id`, `session_id`, `auto_renew`, `cancel_at` |
| `checkout()` | `error` | `Checkout session failed` — + `stripe_error` |
| `portal()` | `info` / `error` | `Billing portal opened` / `Billing portal failed` |
| Webhook | `info` | `Renewal changed` (seulement si `cancel_at` bouge réellement), `Plan changed` (avec ancien/nouveau) |
| Webhook | `info` | `Webhook received` — écrit **avant** le dispatch, donc toute livraison est visible même si rien ne la traite |
| Webhook | `warning` | `Webhook for an unknown customer, ignored`, `No local subscription row for this event` |
| Webhook | `error` | `Webhook signature rejected` — depuis `bootstrap/app.php` (voir ci-dessous) |

Aucun payload complet n'est journalisé — uniquement des identifiants et l'état changé.

⚠️ **Une signature invalide est rejetée par le middleware de Cashier avant tout contrôleur**, et un
403 `HttpException` n'est pas reporté : une livraison rejetée ne laissait donc *aucune* trace, ce
qui la rendait indiscernable d'une livraison jamais arrivée. `bootstrap/app.php` attrape désormais
`AccessDeniedHttpException` sur `stripe/*`, journalise `Webhook signature rejected` et renvoie un
403 JSON — le gestionnaire `respond()` de l'app, qui habille tous les 403 en page d'erreur Inertia,
laisse maintenant `stripe/*` tranquille : l'appelant est une machine qui lit le code de statut.

Les deux appels Stripe (`checkout()`, `portal()`) attrapent `ApiErrorException` : la ligne d'erreur
part dans ce canal et l'utilisateur reçoit une erreur de validation `stripe` (rendue par
`<InputError>`) au lieu d'un 500. C'est ce qui rattrape les deux mauvaises configurations les plus
faciles à faire : un `prod_…` au lieu d'un `price_…` (§1) et le portail client non configuré dans
le Dashboard (§1, point 3).

### 🐛 URL d'endpoint webhook sans chemin — trouvé en parcours manuel (28/08/2026)

Après un paiement réussi (`cs_test_a12…` → `status=complete`, `payment_status=paid`,
`sub_1U9MXG…` bien créé côté Stripe), `users.stripe_id` était renseigné mais **la table
`subscriptions` restait vide** et `user_plan` n'avait pas bougé : aucun webhook n'atteignait
l'application.

Cause : l'endpoint était déclaré dans le Dashboard sur la **racine du site**
(`https://<hôte>/`) au lieu de `https://<hôte>/stripe/webhook`. Stripe postait donc sur `/`, qui
n'accepte que `GET`/`HEAD` → `405 MethodNotAllowedHttpException` à chaque livraison.

`users.stripe_id` est renseigné à la **création de la session** Checkout, pas au paiement : le voir
rempli ne prouve donc rien sur l'arrivée des webhooks. Les événements souscrits, eux, étaient les
bons (les trois `customer.subscription.*` + les recommandés, sans `invoice.payment_succeeded`).

À vérifier à chaque redémarrage d'expose : le sous-domaine `*.sharedwithexpose.com` change, donc
l'URL de l'endpoint doit être remise à jour dans le Dashboard (et le `whsec_` de cet endpoint
recopié dans `.env`).

Note au passage : la clé limitée a visiblement **Points de terminaison de webhook : lecture**
(`WebhookEndpoint::all()` fonctionne), ce qui dépasse la ticklist de §1. Sans conséquence — la
lecture seule ne permet pas de créer d'endpoint — mais la ticklist et la clé ne sont pas
exactement alignées.

### Vérification

Test Pest sur le webhook `customer.subscription.deleted`, puis parcours manuel Stripe test mode **deux fois** (une par choix de renouvellement), plus deux tests d'accréditation (Pro avec `Carbon::setTestNow` à plusieurs mois d'écart, et Free → 403).

Reste à faire, hors code de ce dépôt :
- 🟠 **Le parcours manuel Stripe en test mode** (§Vérification n°2), deux fois, une par choix de
  renouvellement. Rien dans la suite Pest ne touche l'API Stripe : le checkout, la redirection 303
  et la signature du webhook ne sont vérifiés que là. La question ouverte sur la levée d'un
  `cancel_at` depuis le portail se règle dans le même passage.

Réglé depuis :
- ✅ **§8.2 `coloid-api`** — les deux gardes comparent maintenant à `'pro'` (branche `v2`). Le
  blocage est levé ; il reste une contrainte d'ordre de déploiement, décrite en §8.2 et §8.3.
- ✅ **§2 `UserObserver`** — corrigé et couvert par `tests/Feature/UserObserverTest.php`. Le correctif
  a au passage remis 4 tests au vert (18 → 14 échecs préexistants, voir §Vérification n°8).

À noter au passage (hors périmètre, documenté §2) : `UserObserver::updated()` recompose `$user->name`
*après* la sauvegarde, donc la valeur n'est jamais persistée. Comportement ancien, sans lien avec la
facturation, laissé tel quel — le corriger demande de déplacer la recomposition dans `updating()`.


## Context

Per `CoLo-ID Pro v2 – Chiffrages.md` and follow-up clarification, there are two independent
6-month/1-year clocks that must not be conflated:

- **Billing (1 year, renewal is the user's choice):** a Stripe subscription activates CoLo-ID Pro
  features for 1 year. On the payment page the user chooses whether it **auto-renews** — if so,
  Stripe bills and renews it automatically every 1 year until canceled; if not, Stripe
  auto-cancels it at the 1-year mark and the account reverts to free. Either way this governs
  `user_plan` (`free`/`pro`) only, and Stripe — not app code/cron — owns the renewal billing.
- **Certification (6 months, gated by billing, one-directional):** the user's competency
  certification (if they have one) must be renewed every 6 months, tracked via
  `user_accredited`/`user_first_accredited`. **A user can only be (re-)accredited while they hold
  an active Pro subscription** — accrediting is gated on `user_plan === PRO`. The reverse is not
  true: a Pro subscriber whose certification lapses stays Pro; a lapsed/expired certification never
  touches billing.

No discount/coupon logic for now.

The mobile app already reads account status (`user_plan`) from this Laravel "Account API" through
an intermediary PHP API (`coloid-api`), so `user_plan` stays the single source of truth those
consumers read — nothing here should introduce a parallel status field. The repo now has
`UserPlan` (`free`/`premium`), `user_accredited` **and** `user_first_accredited` wired end-to-end
(model, migration, `AuthResource`), plus the accreditation endpoint of §5 — that part of the plan
has shipped. Since §1 landed, the billing *plumbing* is in place too — Cashier v16.7, the
`subscriptions` tables and the Stripe config — but no billing *behaviour* yet: no `Billable` trait,
no checkout, no webhook, no `pro` enum value.

The `UserPlan` enum value is being renamed from `premium` to `pro` to match the doc's wording
("free ou pro"). The mobile app (`fr.anamorphik.coloid/src/stores/auth.js`) merely *compares*
against the string (`state?.userData?.plan === "premium"`, `this.userData.plan !== "premium"`) —
it's not a hardcoded/baked-in value, just a plain equality check. **That mobile change has since
been made** — and made tolerantly (`=== "pro" || === "premium"`), so the deploy-ordering hazard the
plan worried about no longer exists.

**Correction to an earlier assumption:** `coloid-api` (the PHP intermediary) *does* branch on the
`plan` value — it no longer merely forwards the payload. Two sites compare it to the literal
`'premium'`:

- `classes/api.php:1580` — gates which game type/level a user may start
  (`if ($user->plan !== 'premium' && ($type !== 'free-training' || $level !== 'novice'))`);
- `classes/api.php:2495` — gates the retry of unsynced accreditations
  (`if ($user->plan === 'premium' && self::retryUnsyncedAccreditations($uid))`).

Both must be updated in lockstep with the enum rename, or paid users lose access to every non-novice
level and pending accreditations stop being recovered. **The rename has since landed (§2), so that
lockstep was not achieved** — this repo already serves `"pro"` while `coloid-api` still tests for
`'premium'`. See §8.2, now the one open blocker.

## 1. Package & config

✅ **This whole section has shipped.** Installed version is `laravel/cashier` **v16.7.0**. What
follows is the plan as written, annotated with what was actually done.

- ✅ `composer require laravel/cashier`, then `valet php artisan vendor:publish --tag="cashier-migrations"`
  — this publishes exactly the `subscriptions` (+ `subscription_items`) table the doc asks for
  ("liée à `users.id`"), plus a migration adding `stripe_id`, `pm_type`, `pm_last_four`,
  `trial_ends_at` to `users`. **As published**, Cashier v16 also ships two extra migrations
  (`add_meter_id_to_subscription_items_table`, `add_meter_event_name_to_subscription_items_table`)
  — five files in total, dated `2026_08_27_1538*`, all applied.
- ⚠️ **Discovered during install:** Cashier auto-registers `POST stripe/webhook` under the route
  name `cashier.webhook`, pointing at its own `WebhookController`. The custom controller of §4
  cannot simply add a route on the same URI — set `CASHIER_WEBHOOK_ROUTE=false` in `.env` first,
  or point Cashier's config at the custom class.
- ✅ **Done** — `database/migrations/2026_08_26_000000_add_user_first_accredited_to_users_table.php`
  (nullable `timestamp`, `after('user_accredited')`, in the style of
  `2026_02_25_000002_change_user_accredited_to_datetime.php`). Note the actual filename/date differs
  from the one this plan first proposed.
- ✅ `config/services.php` — added `'stripe' => ['pro_price' => env('STRIPE_PRO_PRICE_ID')]`
  (mirrors the existing postmark/resend/slack entries).
- ✅ `.env` / `.env.example` — `STRIPE_KEY`, `STRIPE_SECRET`, `STRIPE_WEBHOOK_SECRET`,
  `STRIPE_PRO_PRICE_ID`, `CASHIER_CURRENCY=eur`. The Stripe Product/Price itself (1-year interval)
  is created manually in the Stripe Dashboard (test mode first) — not code.
- ✅ `bootstrap/app.php` — `$middleware->validateCsrfTokens(except: ['stripe/*'])` inside the existing `withMiddleware()` closure
  so the webhook route isn't blocked by CSRF.

### Où trouver les valeurs `.env`

#### `STRIPE_PRO_PRICE_ID` — un identifiant de **Prix** (`price_…`), pas de Produit (`prod_…`)

⚠️ **Piège.** Un `prod_…` ne fonctionne pas. La valeur part dans `line_items[0].price` de la session
Checkout :

```
config('services.stripe.pro_price')
  → User::newSubscription('default', $price)          (SubscriptionController::checkout())
  → SubscriptionBuilder::price()  →  $items['price_…']
  → line_items[0].price                                (Checkout Session)
```

Stripe refuse un identifiant de produit dans ce champ.

**Où le récupérer :** Dashboard → *Catalogue de produits* → ouvrir le produit CoLo-ID Pro → section
*Tarification* → la ligne du tarif → copier son ID d'API (`price_…`). Un produit peut porter plusieurs
tarifs : c'est l'ID du tarif qui fige le montant, la devise et l'intervalle. Vérifier que le tarif est
bien **récurrent, 1 an, EUR** — la devise doit correspondre à `CASHIER_CURRENCY=eur`.

En CLI : `stripe prices list --product prod_…` (affiche aussi `recurring.interval`).

#### `STRIPE_WEBHOOK_SECRET` — la clé de signature (`whsec_…`), deux sources distinctes

- **En local :** `stripe listen --forward-to https://coloid-auth.test/stripe/webhook` affiche au
  démarrage « Your webhook signing secret is `whsec_…` ». C'est la seule option en local : Stripe ne
  peut pas joindre `coloid-auth.test`.
- **En environnement joignable :** Dashboard → *Développeurs* → *Webhooks* → créer un endpoint sur
  `https://<hôte>/stripe/webhook` → l'ouvrir → *Clé secrète de signature* → *Révéler*.

Ce sont **deux valeurs différentes** : utiliser celle du Dashboard en local fait échouer la
vérification de signature sur chaque événement transféré.

🔴 **Sécurité — vaut dès maintenant.** `WebhookController::__construct()` n'enregistre le middleware
`VerifyWebhookSignature` **que si le secret est non vide**. Avec `STRIPE_WEBHOOK_SECRET=` (son état
actuel), l'endpoint accepte n'importe quel POST non authentifié — et `stripe/*` est déjà exclu du
CSRF (§1). Dès que §4 synchronise `user_plan` à partir du payload, un secret vide permet à quiconque
atteint l'URL de se donner le plan Pro. À renseigner **avant** que §4 n'atteigne un environnement
joignable.

**Ne pas utiliser `php artisan cashier:webhook`** pour créer l'endpoint : la commande appelle
`webhookEndpoints->create` et exigerait la permission « Points de terminaison de webhook : écriture »
sur la clé limitée, volontairement non accordée. Créer l'endpoint à la main dans le Dashboard.

### Webhooks à configurer

`WebhookCommand::DEFAULT_EVENTS` de Cashier en liste 8. Pour cette application :

| Événement | Verdict |
|---|---|
| `customer.subscription.created` | **Requis** — §4 passe `user_plan` à `PRO` et recopie `cancel_at` |
| `customer.subscription.updated` | **Requis** — renouvellements, changements de statut, `cancel_at` modifié depuis le portail |
| `customer.subscription.deleted` | **Requis** — se déclenche au bout d'un an quand `cancel_at` échoit ; §4 repasse `user_plan` à `FREE` |
| `customer.updated` | Recommandé — garde `pm_type` / `pm_last_four` à jour ; ne demande que la permission Moyens de paiement **lecture** déjà accordée |
| `customer.deleted` | Recommandé — remet `stripe_id` à null et marque les abonnements annulés si un client est supprimé dans le Dashboard |
| `payment_method.automatically_updated` | Recommandé — carte mise à jour automatiquement par le réseau |
| `invoice.payment_succeeded` | **À ne pas cocher** — voir l'avertissement ci-dessous |
| `invoice.payment_action_required` | À ne pas cocher — sans effet tant que `CASHIER_PAYMENT_NOTIFICATION` n'est pas défini |

`checkout.session.completed` est inutile : Cashier se synchronise sur `customer.subscription.created`.

⚠️ **Pourquoi `invoice.payment_succeeded` est exclu — et ce que ça change pour la clé.**
`handleInvoicePaymentSucceeded()` appelle **`subscriptions->update()`** pour effacer la métadonnée
`is_on_session_checkout` que pose `Checkout::create()`. S'abonner à cet événement imposerait donc
**Abonnements : écriture** sur la clé limitée ; sans cette permission le handler lève une erreur et
Stripe rejoue l'événement indéfiniment. La métadonnée est inerte ici (elle n'est lue que par
`invoiceIsOnSessionCheckout()` pour supprimer les notifications de paiement, désactivées). Ne pas
s'abonner à l'événement garde donc la clé à quatre permissions.

Si `CASHIER_PAYMENT_NOTIFICATION` est un jour activé, il faut ajouter **ensemble** les deux
événements `invoice.*` et la permission Abonnements : écriture.

### Permissions de la clé API Stripe (clé limitée / restricted key)

`STRIPE_SECRET` peut être une **clé limitée** (`rk_test_…` / `rk_live_…`) plutôt que la clé secrète
complète. Le périmètre ci-dessous n'est pas un choix de confort : c'est exactement l'ensemble des
appels API que le code effectue, tracés dans Cashier v16.7. Une clé trop large annule l'intérêt de la
clé limitée ; une clé trop étroite échoue en pleine session de paiement avec une erreur `permission`
peu lisible.

**Appels réellement effectués :**

| Notre code | Interne Cashier | Appel API Stripe |
|---|---|---|
| `SubscriptionController::checkout()` | `createOrGetStripeCustomer()` | `customers->create` / `customers->retrieve` |
| idem | `Checkout::create()` | `checkout->sessions->create` |
| `SubscriptionController::portal()` | `billingPortalUrl()` | `billingPortal->sessions->create` |
| `SubscriptionController::edit()` | — | **aucun** (tout en base locale — c'est la raison d'être de la colonne `cancel_at`) |
| Webhook §4, abonnements | `handleCustomerSubscription{Created,Updated,Deleted}` | **aucun** — les handlers lisent uniquement le payload |
| Webhook §4, `customer.updated` | `updateDefaultPaymentMethodFromStripe()` | `customers->retrieve` avec expansion `invoice_settings.default_payment_method` |

Vérifié : aucun `prices->retrieve`, aucun appel coupon/code promo (on n'en pose pas), aucun appel de
taux de taxe (`taxRates()` renvoie vide), aucun `paymentIntents` (`CASHIER_PAYMENT_NOTIFICATION`
n'est pas défini, donc `handleInvoicePaymentActionRequired` sort immédiatement et la route
`stripe/payment/{id}` de Cashier est du code mort ici).

Ce tableau ne vaut que pour les événements effectivement souscrits : voir « Webhooks à configurer »
ci-dessus, dont l'exclusion d'`invoice.payment_succeeded` est ce qui maintient ce périmètre.

**À cocher pour que §1 à §4 fonctionnent :**

| Ressource Stripe (FR / EN) | Niveau |
|---|---|
| Clients / Customers | **Écriture** |
| Sessions Checkout / Checkout Sessions | **Écriture** |
| Portail client / Customer portal | **Écriture** |
| Moyens de paiement / Payment Methods | **Lecture** |

`Moyens de paiement : lecture` sert uniquement à l'expansion du handler `customer.updated`, qui
renseigne `pm_type` / `pm_last_four`. Tout le reste reste sur **Aucune / None**.

**À ajouter seulement si ces travaux arrivent :**

| Ressource | Niveau | Requis par |
|---|---|---|
| Abonnements / Subscriptions | Écriture | ~~(a) annulation/reprise côté application~~ — **plus nécessaire** : le portail Stripe couvre les deux sens, vérifié le 31/08/2026 (§Vérification n°2). Reste (b) : l'abonnement à `invoice.payment_succeeded`, dont le handler Cashier écrit dans l'abonnement — raison pour laquelle « Webhooks à configurer » ci-dessus l'exclut |
| Produits, Prix / Products, Prices | Lecture | afficher le prix ou le nom du plan dans l'app au lieu de le coder en dur |
| Factures / Invoices | Lecture | lister les factures dans l'app plutôt que via le portail |

**Jamais nécessaire :** points de terminaison de webhook (l'endpoint est créé à la main dans le
Dashboard), Payment Intents, paiements/Charges, remboursements, jetons, coupons, compteurs/Billing v2.

**Trois points qui ne sont pas des permissions de clé :**

1. **Recevoir les webhooks ne demande aucune permission.** `stripe/webhook` est authentifié par la
   signature (`STRIPE_WEBHOOK_SECRET`), pas par la clé API. `stripe listen` s'authentifie avec le
   login du CLI, pas non plus avec cette clé.
2. **`STRIPE_KEY` est la clé publiable** (`pk_test_…`), pas une seconde clé limitée — les permissions
   ne s'y appliquent pas. Elle est aujourd'hui **inutilisée par notre implémentation** : le Checkout
   hébergé signifie pas de Stripe.js, et Cashier ne lit `cashier.key` que dans son `PaymentController`,
   mort ici. À renseigner quand même par cohérence ; rien ne casse si elle reste vide.
3. **Le portail client doit être activé et configuré dans le Dashboard** (le mode test a sa propre
   configuration), sinon `billingPortal->sessions->create` échoue quelles que soient les permissions.
   C'est le portail vers lequel `portal()` (§3) envoie l'utilisateur.

## 2. Model changes

✅ **This whole section has shipped**, including the `UserObserver` fix. What follows is the plan as
written, annotated with what was actually done.

- ✅ `app/Enums/UserPlan.php` — the `PREMIUM` case is now `PRO`, value `'pro'`, with `label()` and
  `options()` returning `Pro`.
- ✅ New migration — shipped as
  `database/migrations/2026_08_27_160000_rename_premium_to_pro_in_users_table.php` (filename/date
  differ from the one proposed here).
  ⚠️ **The ordering this plan proposed does not work.** Data-migrating first fails: the column's
  existing CHECK/enum constraint does not yet allow `'pro'`, so the `UPDATE` is rejected
  (`SQLSTATE[23000] ... CHECK constraint failed: user_plan` on SQLite, and the equivalent on a MySQL
  enum). Narrowing the constraint first fails the other way, on the rows still holding `'premium'`.
  **As shipped**, the migration widens to `['free', 'premium', 'pro']`, moves the rows, then narrows
  to `array_column(UserPlan::cases(), 'value')` — with a symmetric three-step `down()`. Verified by
  a full `migrate:rollback` / `migrate` round-trip against 2 real `premium` rows.
- ✅ `app/Models/User.php` — `user_first_accredited` is in `$fillable` and exposed via a
  `userFirstAccredited()` accessor mirroring the existing `userAccredited()` one (both cast to
  ISO-8601 strings), and `use Laravel\Cashier\Billable;` is now in place.
- ~~`certificationExpiresAt()` accessor on `User`~~ — **dropped.** `coloid-api` already owns the
  6-month validity: `API::certificationExpiresAt()` derives it from the raw `user_accredited` this
  API serves, using its own `CERTIFICATION_VALIDITY_MONTHS` constant, and its migration comment
  states the split explicitly ("the 6-month validity is derived by this API, the Auth API stores raw
  dates only"). Adding a second derivation here would duplicate the constant across two repos. If
  the web Subscription page (§7) needs the date, compute it there or add the accessor **without** exposing
  it in `AuthResource` — one source of truth per consumer path.
- ✅ `app/Models/Subscription.php` (new) — extends `Laravel\Cashier\Subscription`, adds `cancel_at`
  to `$casts` (`datetime`) and exposes an `autoRenews(): bool` accessor (`is_null($this->cancel_at)`).
  Register it via `Cashier::useSubscriptionModel(Subscription::class)` in
  `AppServiceProvider::boot()`. This is needed because Cashier's own `subscriptions` table has no
  column for "will this renew" — the app needs to remember the choice made at checkout (§3) to
  display it later without a live Stripe API call on every page load.
- ✅ New migration — shipped as
  `database/migrations/2026_08_27_160001_add_cancel_at_to_subscriptions_table.php`, adding a
  nullable `timestamp` `cancel_at` to Cashier's `subscriptions` table.
  Note: Cashier declares its casts as a `protected $casts` **property**, so the custom model
  declares only `['cancel_at' => 'datetime']` in a `casts()` method — Eloquent merges the two.
- Naming note: `App\Models\Subscription` (this model) and
  `App\Http\Controllers\Settings\SubscriptionController` (§3) coexist fine, but the controller
  is about the *page*, the model about Cashier's row — keep the `use` statements explicit.
- ✅ **Required fix — done.** `app/Observers/UserObserver.php::updated()` now uses
  `$user->profile?->getDirty()` and only recomposes the name when a profile exists, covered by
  `tests/Feature/UserObserverTest.php` (one test for the profile-less webhook path, one that the
  name is still recomposed when a profile is present). Fixing it also turned 4 previously failing
  tests green — logout, the 2FA redirect, password update and account deletion were all hitting
  the same null profile.
  The original problem, for the record: `updated()` did
  `$user->profile->getDirty()` and `$user->profile->firstname` unconditionally. Once the webhook
  controller calls `$user->update(['user_plan' => ...])` outside an HTTP request, this will throw
  on a null `profile` — and this is the exact code path syncing `premium` status. A crash here
  means Stripe retries the webhook forever and the user never actually gets flipped to premium.
  Guard both accesses with `$user->profile?->...` before wiring the webhook.
- ⚠️ **Noticed while fixing the above, deliberately left alone:** `updated()` assigns the recomposed
  `$user->name` *after* the model has been saved, so the new value only ever lives on the in-memory
  instance and is never written to the database. Long-standing behaviour, unrelated to billing, and
  not fixed here because calling `save()` inside `updated()` re-enters the observer and risks
  infinite recursion — doing it properly means moving the recomposition to `updating()` (or to
  `UserProfile`'s own observer, since firstname/lastname live there). Worth its own change, with
  its own test, rather than a drive-by in the billing work.

## 3. Checkout flow (1 year, user-chosen auto-renewal, no coupon)

✅ **This whole section has shipped**, with three deviations from the plan noted inline below.
The page it renders landed with §7, so the route is now usable in a browser and linked from the
settings sidebar.

Stripe offers three integration styles for recurring payments (shareable Payment Links, prebuilt
Checkout, custom flow). **This plan uses prebuilt Checkout** — `->checkout()` in Cashier creates a
Checkout Session server-side and redirects to Stripe's hosted page. No card fields, no `Stripe.js`,
no PCI surface in the Vue app; Stripe handles the card form, SCA/3DS, wallets, receipts and failed
payments. Payment Links were ruled out (no clean way to attach Cashier's `stripe_id`, no
programmatic `cancel_at`), and a custom flow would mean Stripe Elements for no benefit here.

**What Stripe's hosted page cannot do:** offer the auto-renew choice. `subscription_data.cancel_at`
is a *session-creation* parameter — Checkout has no "do not renew" toggle, a subscription there is
recurring by nature. So the choice must be captured in-app, before the redirect. That is the only
reason §7 has a form at all: it is a **preference** form, not a payment form.

- ✅ `app/Http/Requests/Settings/SubscriptionCheckoutRequest.php` (new) — `['auto_renew' => ['required', 'boolean']]`,
  following the "always use Form Requests" convention. No default: the user must make an
  explicit choice.
- ✅ `app/Http/Controllers/Settings/SubscriptionController.php` (new, same thin-controller pattern as
  `ProfileController`):
  - `edit()` — `Inertia::render('settings/Subscription', ['subscribed' => ..., 'endsAt' => ..., 'autoRenews' => ...])`.
  - `checkout(SubscriptionCheckoutRequest $request)` —
    ```php
    // Guard first: without it, an already-subscribed user submitting this route
    // creates a SECOND Stripe subscription and gets charged twice. Cashier does
    // not prevent this on its own.
    abort_if($request->user()->subscribed('default'), 409);

    $sessionOptions = ['success_url' => ..., 'cancel_url' => ...];
    if (! $request->boolean('auto_renew')) {
        $sessionOptions['subscription_data'] = ['cancel_at' => now()->addYears(1)->timestamp];
    }
    $checkout = $request->user()
        ->newSubscription('default', config('services.stripe.pro_price'))
        ->checkout($sessionOptions);

    // See "Redirecting to Stripe" below.
    return Inertia::location($checkout->url);
    ```
    ✅ Shipped as written, with `success_url` and `cancel_url` both pointing at
    `route('subscription.edit')`. Note the return type is Symfony's `Response`, not `Inertia\Response`
    — `Inertia::location()` returns a 409, so a `: Response` hint using the Inertia class fails.
    Checked while implementing: Cashier merges `$sessionOptions` into its own payload with
    `array_merge_recursive`, so `subscription_data.cancel_at` lands *alongside* Cashier's
    `billing_mode`/`metadata` rather than replacing them, and `success_url`/`cancel_url` are assigned
    scalar-wise in `Checkout::create()` (no risk of `array_merge_recursive` turning them into arrays).
    This is the entire mechanism for the user's choice: setting `subscription_data.cancel_at` at
    Checkout-session creation makes Stripe auto-cancel the subscription after one 1-year period
    instead of renewing it — no manual cron needed either way. Omitting `cancel_at` leaves Stripe's
    normal recurring billing in place, so **Stripe itself** (not app code) re-charges and renews
    the subscription every 1 year until the user cancels — matching "Stripe should handle the
    renewal." Either path produces a Stripe *subscription* object, so Cashier's `subscriptions`
    table is populated as the doc wants regardless of the choice.
  - ✅ `portal()` — **shipped as `Inertia::location($request->user()->billingPortalUrl(route('subscription.edit')))`.**
    `redirectToBillingPortal()` returns a `RedirectResponse`, which is exactly the 303-under-Inertia
    problem this section warns about two paragraphs down; `billingPortalUrl()` gives the raw URL to
    hand to `Inertia::location()` instead.
    **Guard added, not in the plan:** `abort_unless($request->user()->hasStripeId(), 404)`. Cashier's
    `billingPortalUrl()` calls `assertCustomerExists()`, which throws for a user who has never
    checked out — an uncaught 500 rather than a clean 404.
    The plan's original wording: `$request->user()->redirectToBillingPortal(route('subscription.edit'))`, so
    users can view invoices / turn off auto-renewal (cancel at period end) / cancel early via
    Stripe's hosted portal at any point after checkout — no custom code needed for that later
    toggle, only for the initial choice above. Same 303 caveat as `checkout()`: wrap it in
    `Inertia::location()` rather than returning Cashier's redirect directly.
- ✅ `routes/settings.php` — added `settings/subscription` (GET, `subscription.edit`),
  `settings/subscription/checkout` (POST, `subscription.checkout`) and
  `settings/subscription/portal` (POST, `subscription.portal`), inside the existing
  `Route::middleware('auth')` group, following the same naming convention as `profile.edit`.

### What the page cannot show yet — `endsAt`

⚠️ **Found while writing `edit()`.** Cashier's `subscriptions` table has **no `current_period_end`
column**, and `ends_at` is only written when a subscription is *canceled* (it stores the grace
period). So the local database holds a date in exactly one of the two states this feature has:

| State | Local date available |
|---|---|
| `auto_renew` off — `cancel_at` set at checkout | ✅ `cancel_at` — the expiry |
| `auto_renew` on — normal recurring billing | ❌ nothing: `ends_at` and `cancel_at` are both null |

`edit()` therefore passes `'endsAt' => $subscription?->cancel_at ?? $subscription?->ends_at`, which
is null for an auto-renewing subscription. **§7 must not promise a renewal date it cannot get**:
either show "Renews automatically" with no date (what §7 already describes, so this is consistent),
or add a Stripe API call — which is precisely the per-page-load call the `cancel_at` column exists
to avoid. Recommendation: no date in the renewing state; the Stripe portal shows it.

### 🐛 `stripe_id` vide — trouvé en parcours manuel (28/08/2026)

`GET /settings/subscription` puis « Subscribe » renvoyait **Server error** :
`Stripe\Exception\InvalidArgumentException: The resource ID cannot be null or whitespace`, levée
depuis `customers->retrieve('')`.

Cause : l'utilisateur 3 avait `users.stripe_id = ''` (chaîne vide, pas `NULL`). Cashier teste
`! is_null($this->stripe_id)` dans `hasStripeId()`, donc une chaîne vide se lit comme « ce client
Stripe existe déjà » et `createOrGetStripeCustomer()` part chercher le client d'identifiant vide au
lieu d'en créer un.

Corrigé en deux temps :

- `User::hasStripeId()` surcharge celui de Cashier avec `filled($this->stripe_id)` — vide vaut
  absent. Une seule surcharge couvre `checkout()`, `portal()`, `billingPortalUrl()` et la recherche
  du webhook, qui en dépendent tous.
- `database/migrations/2026_08_28_120000_normalize_blank_stripe_id_in_users_table.php` repasse à
  `NULL` les lignes déjà dans cet état (`down()` volontairement vide : une chaîne vide ne porte
  aucune information à restaurer).

### Tests

✅ `tests/Feature/Settings/SubscriptionTest.php` — 10 tests, none of which touch the Stripe API:
guests redirected; the three `edit()` prop shapes (no subscription / auto-renewing / will-not-renew,
the last confirming the `cancel_at` cast round-trips and `autoRenews()` is false); `auto_renew`
required; the 409 double-subscription guard; the 404 portal guard; the page rendering as a full
document (the regression guard for the missing Vue component); a blank `stripe_id` not counting as
a customer; and the `stripe` log channel being configured.

The `edit()` tests request the route with `X-Inertia` headers so the response is Inertia's JSON page
object rather than the blade root — that is what lets them assert on props without §7's `.vue` file
existing. Assertions use `assertJsonPath('props.*')`; `assertInertia()` does not accept an XHR
response.

### Redirecting to Stripe (the 303)

`->checkout()` returns a Cashier `Checkout` (a Responsable) whose response is a **303 to
`checkout.stripe.com`**. Returning it as-is breaks under Inertia: the `<Form>` submit is an XHR, the
browser follows the 303 to another origin, and the request dies on CORS.

**Decision — use `Inertia::location($checkout->url)`.** It is Inertia's documented mechanism for
external redirects (a 409 carrying `X-Inertia-Location`, which the client turns into a real
`window.location` visit). Why this over a plain non-Inertia `<form method="POST">`, which would also
work: this app shares no CSRF token as an Inertia prop (checked in `HandleInertiaRequests::share()`),
so a raw HTML form would need the token plumbed into the page by hand, whereas Inertia's axios sends
the `XSRF-TOKEN` cookie automatically. It also keeps the page identical in shape to every other
settings page, which all use `<Form>` from `@inertiajs/vue3`. Simplest *and* most robust here.

The same applies to `portal()`.

## 4. Webhook → sync `user_plan`

✅ **This whole section has shipped.** What follows is the plan as written, annotated with what was
actually done.

- ✅ `app/Http/Controllers/StripeWebhookController.php` (new) extends
  `Laravel\Cashier\Http\Controllers\WebhookController`, overrides
  `handleCustomerSubscriptionCreated()`, `handleCustomerSubscriptionUpdated()` and
  `handleCustomerSubscriptionDeleted()`: call `parent::` first (keeps Cashier's own `subscriptions`
  row in sync), then:
  - look up the user by `stripe_id` and set `user_plan` to `PRO` while `stripe_status === 'active'`,
    else `FREE`;
  - on `created`/`updated`, also write the Stripe payload's `cancel_at` (unix timestamp or `null`)
    onto the local `Subscription` row's `cancel_at` column (§2), so the auto-renew choice made at
    checkout stays correct even if changed later from Stripe's dashboard or the billing portal.
  **As shipped**, the three overrides are one-liners around a shared `syncFromPayload()`, and
  `cancel_at` is written on `deleted` too rather than only on `created`/`updated` — the payload
  carries it in all three, and one code path is less to keep in step. The write goes through
  `forceFill(['user_plan' => ...])->save()` only when the plan actually changes, so a replayed
  webhook does not re-fire `UserObserver::updated()` for nothing.
- ✅ Route (outside `auth` middleware, in `routes/web.php`):
  `Route::post('stripe/webhook', [StripeWebhookController::class, 'handleWebhook'])->name('cashier.webhook');`.
  Locally, verify with `stripe listen --forward-to https://coloid-auth.test/stripe/webhook`.
  ⚠️ **`CASHIER_WEBHOOK_ROUTE=false` does not exist in Cashier v16** — that env var is from an older
  release. What v16 reads is the `Cashier::$registersRoutes` static, so **as shipped**
  `AppServiceProvider::register()` calls `Cashier::ignoreRoutes()` (in `register()`, not `boot()`,
  since Cashier registers its routes from its own `boot()`). That drops Cashier's whole route group,
  which is also its only other route, `stripe/payment/{id}` — dead code here, as §1 already
  established (`CASHIER_PAYMENT_NOTIFICATION` is unset). Our route keeps the name `cashier.webhook`
  so `php artisan cashier:webhook` and Cashier's own `route()` lookups still resolve, and a test
  asserts the name points at our controller rather than Cashier's.
- **Côté Stripe**, l'endpoint est créé **à la main** dans le Dashboard (pas via
  `php artisan cashier:webhook`, qui exigerait une permission non accordée à la clé limitée). Les
  événements à cocher, et le piège d'`invoice.payment_succeeded`, sont dans « Webhooks à configurer »
  (§1). Les trois indispensables pour ce contrôleur :
  `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`.
- ✅ **`STRIPE_WEBHOOK_SECRET` est renseigné** (`.env`, 28/08/2026) et les endpoints sont créés
  côté Dashboard. L'avertissement reste valable pour tout nouvel environnement : à secret vide, Cashier n'enregistre pas la vérification de signature et
  l'endpoint — déjà exclu du CSRF — accepte n'importe quel POST, donc n'importe qui peut se faire
  passer `user_plan` à `pro`. Détail en §1, « Où trouver les valeurs `.env` ».

## 5. Minimal accreditation endpoint

✅ **This whole section has shipped.** What follows is the plan as written, annotated with what the
delivered code actually does.

- `app/Http/Controllers/Api/AccreditationController.php` — `store()`:
  ```php
  abort_unless($user->user_plan === UserPlan::PRO, 403, 'An active Pro subscription is required to be accredited.');
  
  $user->user_accredited = now();
  $user->user_first_accredited ??= now();
  $user->save();
  ```
  **Required gate:** a user can only be (re-)accredited while their plan is the paid one — checked
  directly against the same `user_plan` the Stripe webhook (§4) will maintain, so there's a single
  source of truth for "is this user currently paying" rather than a second subscription lookup.
  **As shipped**, the gate reads `$user->user_plan === UserPlan::PREMIUM` with the message
  `'An active subscription is required to be accredited.'` — it becomes `UserPlan::PRO` as part of
  the §2 rename, not as separate work.
  **Also as shipped**, `store()` returns `new AuthResource((object) ['token' => '', 'user' => $user->load('profile')])`
  — the same shape as `GET /api/user`, so the caller gets the refreshed accreditation dates back
  without a second round-trip. (The empty `token` is the existing convention of that resource.)
- ✅ `routes/api.php` — `Route::post('/user/accreditation', [AccreditationController::class, 'store'])->name('user.accreditation.store');`
  inside the existing `auth:sanctum` group, next to `/user/training`.
- ✅ Covered by `tests/Feature/Api/AccreditationStoreTest.php` (6 tests: guest rejected, paid user
  accredited, later call bumps `user_accredited` but keeps `user_first_accredited`, free user gets
  403 with both dates untouched, response exposes both dates, dates are ISO-8601).
- ~~Note: nothing authorizes *why* the endpoint is called~~ — **resolved on the caller side.**
  `coloid-api` only pushes an accreditation for a certification game whose se/sp clear the SODA
  "Diagnose & Leave" thresholds, records it in its own `accreditations` table with a UNIQUE
  `game_id` (a replayed `endSession` cannot double-certify), and calls
  `POST /api/user/accreditation` with the user's own token and no payload. A failed push is
  retried on the user's next login (`synced` flag), but only for a paid plan — a free user's
  pending row waits until they upgrade rather than collecting a 403 on every login.
- This same endpoint serves both "first certification" and "6-month renewal": each successful call
  simply bumps `user_accredited` (and therefore `certificationExpiresAt`) to `now() + 6 months`,
  while `user_first_accredited` is set only once. The dependency is one-directional (§Context): this
  endpoint reads `user_plan` to gate itself, but never writes it — a lapsed certification never
  touches billing.

## 6. Expose to the mobile app

- ✅ `app/Http/Resources/Api/AuthResource.php` — `'user_first_accredited' => $this->user->user_first_accredited`
  is now served next to the existing `user_accredited` line.
- ~~`'certification_expires_at' => $this->user->certification_expires_at`~~ — dropped, see §2:
  `coloid-api` derives it and already hands the mobile app its own `certification_expires_at`
  (alongside `accredited_at` / `first_accredited`, which are its renamings of these fields).
- ✅ **Shipped** — `'subscribed'`, `'subscription_ends_at'` and `'subscription_auto_renews'` are
  served next to the accreditation dates, so `coloid-api` → mobile app can show the 1-year Pro
  billing state (including whether it will renew), plus the independent 6-month certification
  expiry, without a second endpoint.
  **Deviation from the plan:** `subscription_ends_at` is `$subscription?->cancel_at ?? $subscription?->ends_at`,
  not the plain `?->ends_at` written above — the same expression `SubscriptionController::edit()`
  uses, and for the same reason (§3, "What the page cannot show yet"): `ends_at` is only written
  once a subscription is *canceled*, so on its own it would be null for the entire year of a
  subscription the user explicitly chose not to renew, which is precisely the date that matters.
  It stays null while the subscription auto-renews.
  Covered by `tests/Feature/Api/AuthResourceSubscriptionTest.php` (3 tests, one per state).

## 7. Frontend

✅ **This whole section has shipped.** What follows is the plan as written, annotated with what was
actually done.

- ✅ `resources/js/pages/settings/Subscription.vue` (new), served at **`/settings/subscription`**.
  Props are `subscribed`, `endsAt`, `autoRenews` (as passed by `SubscriptionController::edit()`), not
  the `subscription_*` names used by the mobile API in §6.
  mirrors `Password.vue`'s pattern (Wayfinder action import, `<Form>` from `@inertiajs/vue3`,
  `AppLayout` > `SettingsLayout`, `HeadingSmall`, `breadcrumbItems`). Header:
  `<HeadingSmall title="CoLo-ID Pro" description="Manage your subscription" />`.

  The page has **two mutually exclusive states**, driven by the `subscribed` prop:

  - **No active subscription** — the preference form:
    - a checkbox, `Renew automatically every year`, bound to `auto_renew`, unchecked by default;
    - helper text for the alternative ("Otherwise your subscription expires after 1 year and is
      not renewed");
    - a submit button posting to `subscription.checkout`.
  - **Active subscription** — no form, read-only status:
    - the dates: renewal or expiry (`subscription_ends_at`), and, per `subscription_auto_renews`,
      either "Renews automatically" or "Expires on {date}, no renewal";
      ⚠️ **there is no renewal date to show** — see "What the page cannot show yet" in §3. The prop is
      null whenever the subscription auto-renews, so "Renews automatically" has to stand on its own;
    - a **`Manage my subscription`** button posting to `subscription.portal`.

  **The checkbox must not be rendered in the subscribed state.** It would be inert — unchecking it
  changes nothing on Stripe, since `cancel_at` lives on the Stripe subscription and is only read at
  session creation — and if it stayed wired to `subscription.checkout` it would trigger a second
  subscription (hence the `abort_if` guard in §3). Turning auto-renew off or back on after purchase
  goes through the Stripe portal, not through this page.
  ⚠️ **Deviation, found while building the form.** The checkbox cannot carry `auto_renew` itself.
  `Checkbox` is reka-ui's, and an unchecked checkbox submits *nothing*, which the
  `required|boolean` rule rejects with a validation error instead of meaning "no"; a checked one
  submits the string `"on"`, which Laravel's `boolean` rule also rejects (it accepts `1`/`0`/
  `true`/`false`, not `"on"`). **As shipped**, the checkbox is `v-model`-bound to a local ref and a
  sibling `<input type="hidden" name="auto_renew" :value="autoRenew ? 1 : 0">` is what the `<Form>`
  actually submits — always present, always `1` or `0`. (`Login.vue` gets away with `name` on the
  checkbox because `remember` is optional there.)

  - Separately, the certification's 6-month expiry if the user has one — a distinct piece of status
    from the subscription, not a combined "expiry". Note §2/§6: this repo no longer derives that
    date, so the web page has to compute it locally if it wants to show it.
- ✅ `resources/js/layouts/settings/Layout.vue` — added
  `{ title: 'Subscription', href: editSubscription(), icon: CreditCard }` to `sidebarNavItems`,
  alongside Profile/Password/Delete Account, with
  `import { edit as editSubscription } from '@/routes/subscription';` and `CreditCard` added to the
  existing `lucide-vue-next` import.
- ✅ `resources/js/types/index.d.ts` — `user_plan: 'free' | 'pro'` came with the §2 rename;
  `user_accredited` and `user_first_accredited` are now declared too.
  **Two deviations from the list this plan gave:**
  - `certification_expires_at` is **not** added. This repo does not serve it — §2/§6 handed that
    derivation to `coloid-api`. Declaring it on `User` would type a field the Laravel API never
    sends.
  - `subscribed` / `subscription_ends_at` / `subscription_auto_renews` are declared **optional**
    (`?`). They exist only on the mobile API's `AuthResource` payload; the web `auth.user` prop is
    the serialised `User` model (`HandleInertiaRequests::share()`), which has no such attributes.
    The `Subscription.vue` page reads its own `subscribed`/`endsAt`/`autoRenews` props instead.
- ✅ Ran `valet php artisan wayfinder:generate` (per CLAUDE.md) — `@/routes/subscription` and
  `@/actions/.../SubscriptionController` now exist.

## 8. Cross-repo follow-up

### 8.1 Mobile app (`fr.anamorphik.coloid`) — ✅ done

- `src/stores/auth.js` — `isUserPremium` now reads
  `(state) => state?.userData?.plan === "pro" || state?.userData?.plan === "premium"`, and the
  second site was refactored to `if (!this.isUserPremium)` so there is a single check left.
  Accepting **both** values (rather than swapping to `"pro"`) is better than what this plan asked
  for: it removes the deploy-ordering constraint entirely — an old build and a new build both work
  against either backend value. The `|| === "premium"` arm can be dropped later, once no deployed
  backend serves `premium` any more.
- ❌ `src/locales/fr_FR.json` — still says `"Premium Account": "Compte Premium"` and
  `"Premium Training": "Entraînement Premium"`. Display-label only, no logic impact; worth a pass
  if the product wording moves to "Pro".

### 8.2 Intermediary API (`coloid-api`) — ✅ done (28/08/2026)

Both gates now read `'pro'` on the `v2` branch of `coloid-api`:

- `classes/api.php:1580` — `if ($user->plan !== 'pro' && ($type !== 'free-training' || $level !== 'novice'))`;
- `classes/api.php:2495` — `if ($user->plan === 'pro' && self::retryUnsyncedAccreditations($uid))`.

⚠️ **Done as a straight swap, not as the tolerant helper this plan recommended.** There is no
`API::isPaidPlan()` and no `in_array($user->plan, ['pro', 'premium'], true)`, so `coloid-api` now
accepts *only* `"pro"` — the mirror image of the break it had before. That is correct against this
branch, but it reintroduces a deploy-ordering constraint in the other direction: `coloid-api` v2
must not run against an Auth API still serving `"premium"` (i.e. `main`). See §8.3.

### 8.3 Deploy ordering

Half-resolved, and the remaining half is now the urgent one.

- **Mobile app:** genuinely unconstrained. 8.1 accepts both spellings, so any build works against
  any backend, in any deploy order.
- **`coloid-api`:** still constrained, now in the opposite direction. 8.2 shipped as a straight
  swap to `'pro'`, so `coloid-api` v2 and this branch must go out **together**: v2 against an Auth
  API still on `main` (serving `"premium"`) breaks exactly the same two gates, just for the mirror
  reason. Making 8.2 tolerant (`in_array($user->plan, ['pro', 'premium'], true)`) would remove the
  constraint in both directions and is still worth doing — it is a two-line change in one helper.

## Verification

1. ✅ `tests/Feature/StripeWebhookTest.php` (7 tests) — `created` flips `user_plan` to `pro`,
   `created` with a `cancel_at` records it and makes `autoRenews()` false, `updated` mirrors a
   `cancel_at` set from the portal, `deleted` flips back to `free`, a non-`active` status (`unpaid`)
   also reverts to `free` without deleting the row, an unknown customer is ignored, and the
   `cashier.webhook` route name resolves to *our* controller rather than Cashier's. The tests post
   unsigned payloads with `cashier.webhook.secret` nulled — the same condition that makes the
   endpoint dangerous in production (see §1), used deliberately here.
2. Manually, twice — once per auto-renew choice — in Stripe test mode with `stripe listen`, run
   through `/settings/subscription` → Checkout → pay with `4242...` test card:
   - **`auto_renew` unchecked:** confirm the Stripe subscription has `cancel_at` ~1 year out and
     the local `subscriptions.cancel_at` column matches; `autoRenews()` is `false`.
   - **`auto_renew` checked:** confirm no `cancel_at` is set on the Stripe subscription (normal
     recurring billing) and the local column is `null`; `autoRenews()` is `true`.
   - Either way, confirm `user_plan` becomes `pro` in DB and `/api/user` reflects it, and that the
     page comes back in its subscribed state with no checkbox.
   - Also confirm the redirect itself: the `<Form>` submit must land on Stripe's page, not fail on
     CORS — that is what `Inertia::location()` (§3) is there to prevent.
   - ✅ **Question tranchée (31/08/2026) : le portail sait lever un `cancel_at` posé au checkout.**
     Observé sur `sub_1U9McX…`, acheté le 28/08 avec `auto_renew` décoché (donc
     `cancel_at = 2027-08-28` posé à la création de la session) : le portail l'a ramené à `NULL`,
     puis une nouvelle annulation l'a reposé à la même date, cette fois via
     `cancel_at_period_end = true` + `canceled_at`. Les deux sens fonctionnent depuis le portail
     seul. **Aucune route applicative `stopCancelation()` n'est nécessaire, et la clé limitée n'a
     donc pas besoin d'`Abonnements : écriture`.**
     À noter : `status` reste `active` jusqu'à la fin de la période payée — annuler ne coupe pas
     l'accès le jour même, et l'app doit continuer d'afficher Pro jusqu'à `cancel_at`.
3. ✅ Mobile app (§8.1): `isUserPremium` accepts both `"pro"` and `"premium"` — verified in
   `fr.anamorphik.coloid/src/stores/auth.js`. Re-check after 8.2 that a paying user can still start
   a non-novice game through `coloid-api`.
4. ✅ Covered by `tests/Feature/Api/AccreditationStoreTest.php` — two calls several months apart
   (`Carbon::setTestNow`) bump `user_accredited` while `user_first_accredited` stays at the first
   date, and neither call touches `user_plan`. Cashier is installed now, so the `subscribed`
   assertion can be added whenever §6 lands.
5. ✅ Covered by the same file: a `FREE` user gets a 403 and both dates stay `null`.
6. ✅ Done with §2: `AccreditationStoreTest` now builds its users with `UserPlan::PRO` and all 6
   tests pass.
7. ✅ `tests/Feature/UserObserverTest.php` (new, §2) — a user with no profile can be updated
   (the webhook path), and a user *with* a profile still gets their name recomposed.
8. ⚠️ **Baseline for reading `vendor/bin/pest` output.** Still **14 failed / 57 passed** after §4,
   §6 and §7 — the same 14 Feature tests in the auth/settings suites that fail on `main` — e.g. `DashboardTest` expects 200 and gets a
   302. They predate this work and are unrelated to billing, but they mean "the suite is red" is
   not by itself a signal that a billing change broke something: compare against 14, not 0. (It
   was 18 before the §2 `UserObserver` fix, which took logout, the 2FA redirect, password update
   and account deletion green.) Diagnosing the remaining 14 is worth its own task.
