Implémente le chantier #6 de docs/tech/landing-optimisations.md. Le
funnel signup propose maintenant un essai 14 j Pro avec carte demandée
mais non prélevée — prélèvement automatique à J+14 avec rappel à J+11
(webhook customer.subscription.trial_will_end de Stripe).
Couverture tests : 60 tests unitaires sur la couche billing
- billing.spec.ts (25) — quota Free, bypass trial, inTrial state
- stripe_billing.spec.ts (24) — handlers webhook, idempotence, dispatcher
- trial_recap_job.spec.ts (11) — stats aggregation, formatRubisToHoursFr
+ 3 nouveaux tests vitest côté SPA (useTrialDaysRemaining,
useIsAtFreeLimit bypass trial).
Backend :
- Migration 1779000000000_add_trial_ends_at_to_organizations
- PLAN_CAPS bypass quand status=trialing AND trial_ends_at futur
- getOrgSubscriptionState expose inTrial + trialEndsAt
- Refactor handlers webhook en service stripe_billing.ts (pures,
testables) — extraction depuis le controller. dispatchWebhookEvent
routeur typé également extrait pour les tests.
- createTrialCheckoutSession avec subscription_data.trial_period_days=14,
garde-fou TrialAlreadyConsumedError contre re-trial.
- handleTrialWillEnd → enqueue job recap (BullMQ jobId déterministe
basé sur subscriptionId, idempotent contre re-delivery Stripe).
- Endpoint POST /api/v1/billing/start-trial.
- Email template trial_recap (React Email, branding Rubis figé) avec
stats: factures importées, relances envoyées, € récupérés, rubis +
heures libérées.
Infra de test :
- tests/helpers/stripe_mock.ts : __setStripeForTests injection +
factories fakeSubscription / fakeCheckoutSession / fakeInvoice.
- __setTrialRecapEnqueueForTests : permet de spy l'enqueue sans Redis.
Frontend :
- /onboarding/billing.tsx (opt-in, pas encore forcé dans le flow) :
bouton primaire essai 14j + fallback "Free 2 factures".
- PlanLimitBanner : nouveau état "Essai Pro · X jours restants" qui
prime sur les autres bandeaux. Discret rubis-glow, non blocant.
- useStartTrial hook + useTrialDaysRemaining (arrondi sup).
- SubscriptionState typé avec inTrial + trialEndsAt.
Landing :
- Sous-texte CTA réactivé : « CB demandée, non prélevée avant J+14 »
(Hero + FinalCTA), maintenant promesse véridique.
Notes ouvertes (à décider ultérieurement) :
- Tunnel /onboarding/billing FORCÉ entre signup et /onboarding/compte :
guard reste à activer (risque cassage du signup actuel sinon).
Pour l'instant l'écran est accessible mais opt-in.
- Cron de redondance trial-recap : pas encore implémenté (le
jobId déterministe BullMQ couvre déjà la double-livraison Stripe).
À ajouter si on observe des trial sans recap en prod.
- Tests E2E avec Stripe test mode à faire avant le go-live (cartes
3DS 4000 0027 6000 3184, declined 4000 0000 0000 0341).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Quand l'user annule via le Customer Portal Stripe, la subscription reste
`active` jusqu'à la fin du cycle (cancel_at_period_end=true) — Stripe
n'envoie le `subscription.deleted` qu'à period_end. Avant ce commit, l'UI
affichait toujours "prochaine facture le 28 mai" comme avant l'annulation,
ce qui faisait croire à l'user qu'il allait re-payer.
Backend :
- Migration `cancel_at_period_end boolean DEFAULT false` sur orgs.
- `applySubscriptionToOrg` : lit le flag du Stripe Subscription et
persiste sur l'org.
- `handleSubscriptionDeleted` : reset le flag à false (cohérence DB).
- `OrgSubscriptionState` : nouveau champ `cancelAtPeriodEnd: boolean`.
- Endpoint `POST /api/v1/billing/reactivate` :
• Idempotent (si déjà actif → no-op + 200)
• Appelle `subscriptions.update(id, { cancel_at_period_end: false })`
• Persist le nouvel état sur l'org
Frontend :
- Hook `useReactivateSubscription` (mutation + invalidate billing query).
- `CurrentPlanStrip` :
• Détecte `isCancelling = plan !== 'free' && cancelAtPeriodEnd`
• Switch border/bg en mode rubis-deep + rubis-glow pour attirer l'œil
• Icône Clock à la place de Gem (visuel "compte à rebours")
• Badge "ANNULÉ" en uppercase
• Sous-titre : "Accès Pro jusqu'au DD/MM, puis retour automatique
au plan Free."
• Bouton primary "Réactiver" (RotateCcw icon) qui remplace "Gérer"
• Masque la progress bar Free (non pertinente)
- `SubscriptionState` type étendu avec `cancelAtPeriodEnd`.
- Test factory updated.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>