# Migration billing : essai 14 j Pro avec CB à l'inscription

> Version : 0.1 · Dernière maj : 2026-05-18
> Référence d'arbitrage : `docs/tech/landing-optimisations.md` §3 + §6
> Statut : **À implémenter** — pas encore en code. Ce doc décrit l'archi cible + plan d'exécution.

Document de chantier pour passer le funnel signup d'**aucun engagement** (Free 2 factures par défaut, Pro via Checkout au moment où l'usage dépasse la limite) à **essai 14 jours Pro avec carte bancaire à l'inscription** (Stripe `trial_period_days`, prélèvement automatique au J+14, possibilité d'annuler en un clic).

**Pourquoi** : un essai sans CB convertit 2 à 3 fois moins qu'un essai avec CB demandée (cf. études Profitwell, ChartMogul). Le segment cœur de Rubis (TPE-PME françaises sans crédit manager) a besoin d'être confronté à la décision payante dès qu'il a senti la valeur — sinon il oublie.

---

## État actuel (avant migration)

### Flow signup courant

1. `POST /api/v1/auth/signup` — `NewAccountController.store` crée Organization vide + provision les plans par défaut + crée User.
2. Émet une `AuthSession` (Bearer + refresh cookie).
3. **Aucun appel Stripe** à l'inscription. `org.plan = 'free'`, `stripeCustomerId = null`, `stripeSubscriptionId = null`, `gracePeriodEndsAt = null` (nouvelles orgs).
4. User est redirigé vers `/onboarding/compte` → `/onboarding/entreprise` → `/onboarding/signature`.

### Upgrade Pro plus tard

1. User va sur `/parametres/abonnement`, clique « Passer Pro ».
2. SPA appelle `POST /api/v1/billing/checkout` → Stripe Checkout `mode: 'subscription'`.
3. User paye via Checkout, est ramené sur `/parametres/abonnement?checkout=success`.
4. Webhook `customer.subscription.created` → `org.plan = 'pro'`, `stripeSubscriptionId` posé.

### Limites du modèle actuel

- Le segment cœur ne paye jamais : 2 factures Free (cf. ADR-023) suffisent pour beaucoup, et la friction d'aller chercher Checkout dans les Settings est forte.
- Pas de tunnel forcé : on perd les indécis qui se disent « j'essaie plus tard » et ne reviennent pas.
- L'orga historique (3 mois grace illimitée) habituait les users à un comportement opposé.

---

## État cible

### Flow signup cible

1. `POST /api/v1/auth/signup` — Organization + User créés comme aujourd'hui.
2. **Nouveau** : `POST /api/v1/billing/start-trial` (auth, appelé juste après signup) → crée Stripe Customer + Checkout Session avec `subscription_data.trial_period_days: 14` + `payment_method_collection: 'always'`.
3. SPA redirige vers l'URL Stripe Checkout.
4. User remplit sa CB sur Checkout, est ramené sur `/onboarding/compte`. La subscription est créée en `status: 'trialing'`, `org.plan = 'pro'`, `subscriptionStatus = 'trialing'`.
5. Onboarding standard se poursuit (compte → entreprise → signature).
6. À J+14, Stripe prélève automatiquement et passe la subscription en `status: 'active'`.

### Fallback « pas de CB »

Bouton secondaire sur le Checkout-redirect screen (apps/web) :

> **Pas de CB ? Démarrer en Free (2 factures)**

→ skip le Checkout, l'org reste `free` avec `gracePeriodEndsAt: null`. Cap immédiat à 2 factures. Récupère les ultra-réticents sans casser le funnel principal.

### Email J+12 (recap avant prélèvement)

Déclenché par le webhook Stripe `customer.subscription.trial_will_end` (Stripe l'émet automatiquement 3 jours avant `trial_end` = J+11 dans notre cas — assez proche du J+12 proposé par le doc). Si on veut exactement J+12, basculer sur un scheduler interne (BullMQ / cron node-cron) qui lit `trial_end - 2 days`.

Contenu : récap usage des 12 premiers jours (factures relancées, € récupérés, rubis gagnés) + rappel date prélèvement + lien annulation 1-clic vers Customer Portal.

---

## Architecture cible

### Modifications DB

| Champ | Avant | Après | Notes |
|---|---|---|---|
| `organizations.plan` | `'free'\|'pro'\|'business'` | `'free'\|'pro'\|'business'` | Inchangé. `'pro'` couvre trial + paid. |
| `organizations.subscription_status` | `'active'\|'past_due'\|'canceled'\|...` | + `'trialing'` | Stripe l'émet déjà, on le persiste tel quel. |
| `organizations.trial_ends_at` | n'existe pas | `timestamp with timezone NULL` | Date de fin d'essai (= Stripe `subscription.trial_end`). Sert au teaser UI sans rappel Stripe à chaque page. |
| `organizations.grace_period_ends_at` | conservé | conservé | Pour les orgs historiques. Nouvelles orgs : `null`. |

Migration : ajouter `trial_ends_at` ; backfill à null pour les orgs existantes (elles ne sont pas en trial).

### Modifications API

| Endpoint | État | Action |
|---|---|---|
| `POST /api/v1/billing/start-trial` | Nouveau | Crée Customer + Checkout Session avec `trial_period_days: 14`, return URL. |
| `POST /api/v1/billing/checkout` | Existant | Conserver — pour les users qui sont passés Free (fallback) et veulent upgrader plus tard. **Sans trial dans ce cas** (déjà eu son essai). |
| `POST /api/v1/billing/webhook` | Existant | Étendre pour gérer `customer.subscription.trial_will_end` → trigger email J+12. |
| `services/billing.ts` `canCreateInvoices` | Existant | Bypass quota si `subscription_status === 'trialing'` (Pro illimité pendant l'essai). |

### Modifications Frontend (apps/web)

1. **Nouveau** : `src/routes/onboarding/billing.tsx` — étape entre `/signup` et `/onboarding/compte`.
   - Bouton primaire : « Démarrer l'essai 14 jours » → appelle `start-trial` → redirige vers Stripe Checkout.
   - Bouton secondaire : « Pas de carte ? Commencer en Free (2 factures) » → skip, redirige `/onboarding/compte`.
2. **Modifier** : `src/main.tsx` ou le guard `_app` → forcer le passage par `/onboarding/billing` tant que `org.stripeCustomerId` est null **et** que l'org ne s'est pas explicitement déclarée Free (cookie ou flag DB `org.skippedTrial`).
3. **Modifier** : `src/components/billing/PlanLimitBanner.tsx` → afficher un mini-rappel pendant le trialing (« Essai Pro · X jours restants »).
4. **Modifier** : copy signup (`src/routes/signup.tsx`) → préciser « Carte demandée à l'étape suivante, non prélevée avant J+14 ».
5. **Modifier** : landing `Hero.tsx` / `FinalCTA.tsx` — pouvoir réactiver le sous-texte CTA « CB demandée, non prélevée avant J+14 » (actuellement masqué — cf. cecbddc).

### Modifications Emails (apps/api)

1. **Nouveau template React Email** : `emails/trial_recap.tsx` — recap J+12.
2. **Nouveau template** : `emails/trial_ended_success.tsx` — confirme le passage en payant.
3. **Nouveau template** : `emails/trial_ended_failed.tsx` — paiement échoué, fallback Free + lien CB.

Contenu détaillé des trois emails en annexe ↓.

---

## Risques et mitigations

| Risque | Impact | Mitigation |
|---|---|---|
| Le Stripe Checkout est hosté → on perd le contrôle UX à l'étape la plus stressante du tunnel | Conversion -10 à -20 % vs Elements custom | Accepter en V1, on rebascule sur Payment Element custom en V2 si la conversion plafonne. Checkout est plus rapide à shipper et fiable côté compliance. |
| Webhook `trial_will_end` peut être manqué (réseau, redéploiement) | Email J+12 raté → user surpris par le prélèvement | Doubler avec un job cron quotidien (`trial-recap-cron`) qui scanne les orgs avec `subscription_status = 'trialing'` ET `trial_ends_at` dans 1-3 jours et envoie l'email idempotemment (table `email_log` avec dédoublonnage). |
| Org historique (3 mois grace) avec orgs récentes mêlées dans la table | Migration backfill incorrecte | Le champ `grace_period_ends_at` reste intouché. Le nouveau flow ne pose `trial_ends_at` que sur les orgs qui passent par `start-trial`. Pas de conflit. |
| Stripe locale FR + 3D Secure | Friction supplémentaire sur certaines cartes | Locale `fr` déjà passée au Checkout, 3DS géré nativement par Stripe. Tester en mode test avec cartes 3DS `4000 0027 6000 3184`. |
| User ferme Stripe Checkout sans valider → state incohérent | Compte créé sans CB ni redirect onboarding | Côté webhook `checkout.session.expired` → ne rien faire. Côté UI, gérer le cas `?checkout=cancel` sur `/onboarding/billing` : afficher le fallback Free + bouton « réessayer avec CB ». |
| Migration des orgs en grace period (ils ne devraient pas voir l'écran billing) | UX confuse pour les early users | Le guard `_app` ne force `/onboarding/billing` que si `org.gracePeriodEndsAt` est null. Les orgs historiques continuent leur vie sans changement. |

---

## Plan d'exécution

### PR 1 — Backend (1 j)

1. Migration `add_trial_ends_at_to_organizations.ts`.
2. Endpoint `POST /api/v1/billing/start-trial` dans `billing_controller.ts`.
3. Étendre le webhook handler :
   - `checkout.session.completed` → persister `trialEndsAt` depuis `subscription.trial_end`.
   - `customer.subscription.trial_will_end` → trigger email recap (nouveau service).
   - `customer.subscription.updated` (passage `trialing → active`) → email confirmation.
   - `customer.subscription.updated` (passage `trialing → past_due`) → email fallback.
4. Bypass `canCreateInvoices` quand `subscription_status === 'trialing'`.
5. Tests unitaires (`tests/unit/billing.spec.ts`).
6. Cron de redondance `trial-recap-cron` (BullMQ ou node-cron quotidien).

### PR 2 — Frontend tunnel (0,5 j)

1. Route `src/routes/onboarding/billing.tsx`.
2. Guard `_app` : forcer `/onboarding/billing` si trial pas encore offert.
3. Banner « Essai Pro · X jours restants » dans `PlanLimitBanner.tsx`.
4. Copy signup + landing (réactiver le sous-texte CTA « CB demandée, non prélevée avant J+14 »).

### PR 3 — Emails (0,5 j)

1. Template `emails/trial_recap.tsx`.
2. Template `emails/trial_ended_success.tsx`.
3. Template `emails/trial_ended_failed.tsx`.
4. Tester via Resend / mode dev.

### PR 4 — Tests end-to-end (0,5 j)

1. Tester en mode test Stripe :
   - Carte normale `4242 4242 4242 4242` → trial OK → prélèvement à J+14 OK.
   - Carte 3DS `4000 0027 6000 3184` → 3DS OK → trial OK.
   - Carte déclinée à J+14 `4000 0000 0000 0341` → email fallback Free OK.
2. Vérifier idempotence webhook (renvoyer 2× le même event).

Total : **2,5 j de dev**, alignés sur l'estimation initiale du doc landing-optimisations.md §6.

---

## Annexe — Contenu des emails transactionnels

### Email J+12 — Recap d'essai

**Sujet** : Plus que 2 jours d'essai — voilà ce que Rubis a déjà fait pour vous.

**Preheader** : X factures relancées, Y € récupérés, Z minutes libérées. Récap avant le prélèvement.

**Body** :

```
Bonjour {{prenom}},

12 jours que vous testez Rubis. Petit récap avant le prélèvement de
votre abonnement Pro dans 2 jours (le {{date_prelevement}}).

Ce que Rubis a fait pour vous depuis le {{date_signup}} :

  ◆ {{nb_factures_importees}} factures importées
  ◆ {{nb_relances_envoyees}} relances envoyées automatiquement
  ◆ {{euros_recuperes}} € encaissés
  ◆ {{nb_rubis_gagnes}} rubis ≈ {{heures_liberees}} de votre temps
    que vous n'avez pas passé à relancer

[ICI un bloc visuel avec les 4 chiffres, gros, rubis sur crème]

Si tout est OK, vous n'avez rien à faire — votre essai bascule en Pro
mensuel à {{prix_pro_ttc}} TTC le {{date_prelevement}}. Vous gardez
toutes vos données, tous vos plans de relance, toutes vos relances en
cours.

Si vous voulez annuler, c'est en un clic depuis vos paramètres :

  [Annuler mon essai →]

Vos relances en cours s'arrêteront le {{date_prelevement}} et vous
basculerez automatiquement sur le plan Free (2 factures actives).

Une question ? Répondez à ce mail, je le lis personnellement.

Belle journée,
Arthur — fondateur de Rubis
```

**Variables à interpoler** :

- `{{prenom}}` — `user.fullName.split(' ')[0]`
- `{{date_signup}}` — `org.createdAt` formaté FR
- `{{date_prelevement}}` — `org.trialEndsAt` formaté FR
- `{{prix_pro_ttc}}` — `19 €` ou `49 €` selon plan
- `{{nb_factures_importees}}` — count(invoices where org_id=… and created_at >= signup_date)
- `{{nb_relances_envoyees}}` — count(reminder_logs where status='sent' and org_id=…)
- `{{euros_recuperes}}` — sum(amount_cents) where status='paid' and paid_at >= signup_date
- `{{nb_rubis_gagnes}}` — org.rubisCount
- `{{heures_liberees}}` — formatRubisToHours(rubisCount)

### Email J+14 — Trial → active (succès)

**Sujet** : Bienvenue dans Rubis Pro — votre paiement est passé.

**Body** :

```
Bonjour {{prenom}},

Votre essai est terminé et le prélèvement de {{prix_pro_ttc}} a été
effectué avec succès. Vous êtes officiellement en Pro.

Pas de changement de votre côté — toutes vos factures, plans de
relance et automatisations continuent comme avant, sans aucune
interruption.

Trois choses utiles à connaître maintenant :

  ◆ Votre facture Stripe est disponible dans vos paramètres
    [Voir mes factures Rubis →]

  ◆ Vous pouvez changer de carte ou de cycle (annuel = 2 mois
    gratuits) en un clic
    [Gérer mon abonnement →]

  ◆ Vous pouvez annuler à tout moment depuis le même écran. Aucune
    question posée.

Merci de votre confiance.

Arthur — fondateur de Rubis
```

### Email J+14 — Trial → past_due (échec paiement)

**Sujet** : Votre essai Rubis a expiré — paiement à régulariser.

**Body** :

```
Bonjour {{prenom}},

Votre essai 14 jours est arrivé à son terme, mais le prélèvement de
{{prix_pro_ttc}} sur votre carte n'a pas pu aboutir
({{stripe_decline_code_humain}}).

Pas de panique : pendant 7 jours, votre compte reste en Pro et toutes
vos relances continuent comme avant. Il vous suffit de mettre à jour
votre carte pour rester sur Pro :

  [Mettre à jour ma carte →]

Au-delà de 7 jours sans paiement valide, votre compte bascule
automatiquement sur le plan Free (2 factures actives). Vos données
restent intactes, mais les relances au-delà de 2 factures simultanées
sont mises en pause.

Une question ? Répondez à ce mail, je vous réponds dans la journée.

Belle journée,
Arthur — fondateur de Rubis
```

---

## Décisions ouvertes

À documenter en ADR au moment de l'implémentation :

1. **Tunnel forcé vs onboarding optionnel** : l'écran `/onboarding/billing` doit-il être bloquant (pas d'accès au reste sans choix CB ou Free) ou skippable (« plus tard ») ? Recommandation : bloquant, sinon on perd le bénéfice principal du tunnel.
2. **Stripe Checkout vs Payment Element custom** : tradeoff UX vs time-to-ship. Recommandation V1 : Checkout (compliance gratuite, locale FR native). V2 si la conversion plafonne : Payment Element intégré.
3. **Email J+12 timing exact** : Stripe émet `trial_will_end` à J+11 par défaut. On peut soit accepter (1 jour d'écart sans importance), soit overrider via cron interne pour avoir J+12 pile. Recommandation : accepter le J+11 Stripe (moins de complexité), reformulater le copy "Plus que 3 jours" au lieu de "Plus que 2 jours" si besoin.
4. **Org historique en grace** : on les laisse purger naturellement leurs 3 mois, ou on les force à passer par l'écran trial dès leur prochain login ? Recommandation : laisser purger — moins de churn brutal.

---

*Document maintenu en parallèle de `docs/tech/landing-optimisations.md`. Implémentation à faire dans une session dédiée avec accès Stripe test mode + clé webhook signing secret de test.*
