feat(billing): essai 14 j Pro avec CB à l'inscription (Stripe trial_period_days)
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m19s
Build & Deploy API / build-and-deploy (push) Successful in 1m44s
Build & Deploy Web / build-and-deploy (push) Successful in 41s

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>
This commit is contained in:
ordinarthur 2026-05-18 12:04:41 +02:00
parent f9cba50b5e
commit b0e6f83655
21 changed files with 2449 additions and 353 deletions

218
CLAUDE.md
View File

@ -1,200 +1,144 @@
# Rubis Sur l'Ongle # Rubis Sur l'Ongle
> **Le SaaS de relance de factures impayées pour TPE-PME françaises.** Drag-and-drop, OCR, plans de relance automatiques. 1 rubis = 10 minutes libérées. > **SaaS de relance de factures impayées pour TPE-PME françaises.** Drag-and-drop, OCR, plans de relance automatiques.
Ce fichier est le contexte top-level. Il est court, dense, scannable. Pour les détails, voir `/docs/`. Contexte top-level — court, dense, scannable. Détails dans `/docs/`.
--- ## État actuel (mai 2026)
## En une phrase Produit shippé, landing live sur `rubis.pro`, **0 client payant**. Focus 30 jours : acquisition (réseau direct + experts-comptables + SEO), **pas de nouvelle feature avant 1er client demandeur**. Audit landing en cours, cf. `/docs/tech/landing-optimisations.md`.
Vos factures se relancent toutes seules pendant que vous travaillez. ## Promesse
## Cible *Vos factures se relancent toutes seules pendant que vous travaillez.* On vend du temps libéré (5h/semaine récupérées), pas de la trésorerie.
TPE-PME françaises, 5 à 50 salariés, qui émettent 10 à 200 factures par mois, sans crédit manager dédié. Le décideur teste lui-même le produit (pas de cycle de vente long). **Cible** : TPE-PME 5-50 salariés, 10-200 factures/mois, sans crédit manager dédié. Le décideur teste lui-même (pas de cycle de vente long).
## Promesse de valeur ## Principes produit
- **5 heures par semaine récupérées** (benchmark : 8h → <3h après automatisation). 1. **3 clics max** pour lancer une relance sur une facture neuve.
- **Tonalité émotionnelle** : on vend du temps libéré, pas de la trésorerie. Le rubis gagné est la métrique-héros, pas le DSO.
- **2 à 3 clics maximum** pour lancer une relance sur une nouvelle facture.
## Principes produit (toujours valides)
1. **3 clics maximum** pour lancer une relance sur une facture neuve. Idéalement 2 si bien configuré.
2. **Mobile et desktop** — la photo de facture depuis le téléphone est un usage clé. 2. **Mobile et desktop** — la photo de facture depuis le téléphone est un usage clé.
3. **La relance reste l'âme du produit** — c'est notre cœur de promesse. L'**édition native de factures**, ajoutée en V1.1 (cf. ADR-025), est une *extension douce* pour les utilisateurs sans outil de facturation existant. On reste sous-positionnés vs les vrais outils (Pennylane, Sellsy), pas concurrents frontaux. On ne fait toujours pas CRM ni comptabilité. 3. **La relance est l'âme du produit.** L'édition native de factures (V1.1, ADR-025) est une *extension douce*, pas un pivot. On reste sous-positionnés vs Pennylane/Sellsy, jamais frontaux.
4. **Respectueux du client final** — le ton monte avec le retard, jamais avant. Pas d'agressivité par défaut. 4. **Respect du client final** — le ton monte avec le retard, jamais avant.
5. **Le rubis est une vraie devise produit** — 1 rubis = 10 min libérées. La gamification doit être tangible et défendable. 5. **Le rubis est une vraie devise** — tangible, défendable, métrique-héros (pas le DSO).
## Identité de marque (TLDR) ## Pas de (produit)
Pas de CRM, pas de comptabilité, pas de gestion RH, pas de marketplace marketing, pas de scoring crédit, pas de chasse multi-pays. Si une feature ressemble à ça, c'est OUT.
## Identité de marque
| | | | | |
|---|---| |---|---|
| **Logo** | Direction A — gem facetté géométrique. Le ◆ est un symbole produit autant qu'un logo. | | **Logo** | Direction A — gem facetté géométrique. Le ◆ est un symbole produit. |
| **Couleur primaire** | `#9F1239` — rubis profond légèrement violacé. *Anti-Coca-Cola.* | | **Primaire** | `#9F1239` rubis profond légèrement violacé (*anti-Coca-Cola*) |
| **Couleur secondaires** | `#771328` (deep), `#C9415C` (light), `#FBE4EA` (glow) | | **Secondaires** | `#771328` deep · `#C9415C` light · `#FBE4EA` glow |
| **Neutres** | Crème `#FAF7F2`, encre chaude `#1A1410`. Jamais de blanc pur, jamais de noir pur. | | **Neutres** | Crème `#FAF7F2` · encre chaude `#1A1410`. **Jamais** de blanc/noir purs. |
| **Typo display** | Bricolage Grotesque (500800), self-hosted via `@fontsource-variable/bricolage-grotesque` | | **Typo display** | Bricolage Grotesque 500-800 (`@fontsource-variable/bricolage-grotesque`) |
| **Typo body** | Inter (400700), self-hosted via `@fontsource-variable/inter` | | **Typo body** | Inter 400-700 (`@fontsource-variable/inter`) |
| **Icônes** | Lucide (regular weight) | | **Icônes** | Lucide regular |
| **Pas de** | or, bleu, vert, violet, emojis joaillerie 💎💰, mot "recouvrement" en com publique | | **Pas de** | or, bleu, vert, violet, emojis joaillerie 💎💰, mot « recouvrement » en com publique |
Voir `/docs/marque.md` pour la référence complète et `/docs/brand-identity.html` pour la présentation visuelle. Détails : `/docs/marque.md` · visuel : `/docs/brand-identity.html`.
## Voix ## Voix
Direct, concret, chaleureux, précis, empathique. *On parle comme un bon associé, pas comme une DAF.* Direct, concret, chaleureux, précis, empathique. *Comme un bon associé, pas comme une DAF.*
- ✓ "Vos factures relancées toutes seules." - ✓ "Vos factures relancées toutes seules."
- ✗ "Optimisez votre processus de recouvrement amiable." - ✗ "Optimisez votre processus de recouvrement amiable."
## Glossaire ## Glossaire
- **Rubis** : unité de gamification. **1 rubis = 10 minutes libérées** = 1 relance qu'on n'a pas eu à faire à la main. - **Rubis** : unité de gamification. **1 rubis = 10 minutes libérées** = 1 relance évitée à la main.
- **Plan de relance** : cadence d'emails automatisés (ex. J+3, J+10, J+20). Chaque facture est associée à un plan. - **Plan de relance** : cadence d'emails automatisés (J+3, J+10, J+20...). 4 plans par défaut : *Standard B2B*, *Rapide*, *Patient*, *Ferme*.
- **Étape** : un email programmé dans un plan (ex. "J+10 — relance ferme"). - **Confirmation** *(ex « check-in »)* : email envoyé **à l'utilisateur** pour confirmer paiement avant la relance suivante. Remplace l'intégration banking en V1.
- **Confirmation** *(anciennement « check-in »)* : email envoyé **à l'utilisateur** (pas au client) pour confirmer si une facture a été payée avant l'envoi de la prochaine relance. Remplace l'intégration banking en V1. - **Mise en demeure** : étape ferme, **toujours sous validation manuelle** via modale. Jamais auto.
- **Mise en demeure** : étape ferme du plan. **Toujours sous validation manuelle** via modale de confirmation, jamais auto. - **Facture native** : facture créée dans Rubis via `/factures/nouvelle` (vs OCR/saisie). PDF généré côté serveur via `@react-pdf/renderer`. Snapshots client + émetteur immuables figés à l'émission. Drapeau `invoices.is_native = true`. Numérotation **strict séquentielle par org** via verrou row-level (conforme art. 242 nonies A du CGI).
- **Facture native** : facture **créée dans Rubis via l'éditeur** `/factures/nouvelle` (vs. facture importée par OCR/saisie manuelle). PDF généré côté serveur via `@react-pdf/renderer`, snapshots client + émetteur immuables figés à l'émission. Drapeau `invoices.is_native = true`. - **Factur-X** : PDF/A-3 + XML CII embarqué, format obligatoire B2B FR au 1er sept 2027. **Roadmap V1.5**.
- **Numéro de séquence** : compteur strict séquentiel par organisation (`invoices.sequence_number`), alloué à l'émission d'une facture native via verrou row-level. Conforme art. 242 nonies A du CGI (chronologie continue). Le numéro affiché est `<prefix><seq padé>` (ex. `FAC-2026-0042`). Brouillons exclus du compteur.
- **Snapshot** : copie figée des données du client (`client_snapshot`) et de l'émetteur (`issuer_snapshot`) au moment de l'émission d'une facture native. Garantit l'immutabilité légale : la facture reste intacte même si le client change d'adresse ou si l'org modifie ses settings.
- **Factur-X** : format de facturation électronique mixte PDF/A-3 + XML CII embarqué, conforme à la réforme française B2B (obligation d'émission au 1er septembre 2027 pour TPE-PME). Roadmap V1.5 — pas en V1.
- **DSO** : Days Sales Outstanding. Métrique secondaire dans l'app, jamais dans la com publique. - **DSO** : Days Sales Outstanding. Métrique secondaire dans l'app, jamais dans la com publique.
- **LME** : loi de modernisation de l'économie (2008). Plafonne les délais de paiement à 60 jours (ou 45 jours fin de mois). Sanctions DGCCRF jusqu'à 2 M€. - **LME** : loi 2008, plafonne les délais de paiement à 60j (ou 45j fin de mois). Sanctions DGCCRF jusqu'à 2 M€.
## Périmètre V1 ## Périmètre V1
### IN **IN** — Auth email/password + Google/Microsoft SSO · Onboarding 3 étapes · Upload OCR (PDF/PNG/JPG) + saisie manuelle · **Édition native factures** (V1.1, cf. ADR-025) · Bibliothèque de plans + éditeur · Confirmation par email · Dashboard rubis + KPIs · Liste filtrable · Timeline relances · Stripe billing + période de grâce · Mode démo · Web responsive · Blog `rubis.pro/blog` (SSR Astro, contenu DB, IA hebdo + review humaine) · Changelog `rubis.pro/changelog` (SSG, content collections, toast SPA via `apps/web/src/version.ts`).
- Auth email/password + Google SSO + Microsoft SSO **OUT V2+** — SMS (plan le plus cher) · Multi-utilisateurs (plans payants) · Intégration banking (archi V1 doit l'anticiper) · Multi-langues/devises (FR/EUR only en V1) · Intégrations ERP/compta (Sage, Pennylane, Quickbooks).
- Onboarding 3 étapes (compte, entreprise, signature email)
- Upload drag-and-drop + OCR factures (PDF, PNG, JPG)
- Saisie manuelle (fallback)
- **Édition native de factures** (V1.1) — éditeur `/factures/nouvelle` avec lignes structurées, 4 thèmes pré-faits (Classique, Moderne, Minimal, Élégant), couleur d'accent paramétrable, génération PDF côté serveur via `@react-pdf/renderer`. Settings de facturation sur `/parametres/facturation` (identité émetteur, RIB, mentions légales, numérotation strict séquentielle). PDF classique en V1, **Factur-X visé en V1.5** (Q3-Q4 2026), avant l'échéance d'émission TPE-PME au 1er sept 2027. Détails dans `/docs/produit.md` et ADR-025.
- Bibliothèque de plans (4 plans fournis par défaut : *Standard B2B*, *Rapide*, *Patient*, *Ferme*)
- Éditeur de plan (cadence + templates email avec variables)
- Confirmation par email à l'utilisateur (cadence configurable) → confirme si payé → relance ou stop. *Anciennement « check-in ».*
- Dashboard avec compteur rubis + KPIs (à relancer, encaissé, DSO)
- Liste filtrable des factures
- Détail facture avec timeline des relances
- Stripe billing (checkout, portal, webhook) + période de grâce post-signup
- Mode démo (sandbox in-app sans engagement)
- App mobile (web responsive)
- **Blog `rubis.pro/blog`** — SSR par `apps/landing` (Astro 6), contenu en DB (`posts`) servi par `apps/api` via `/api/v1/posts/*`, admin de validation côté `app.rubis.pro/admin/blog`, génération hebdomadaire IA via cron (Sonnet 4.6) avec review humaine obligatoire. Détails dans `/docs/tech/architecture.md`.
- **Changelog `rubis.pro/changelog`** — SSG par `apps/landing`, contenu en MD versionné dans `apps/landing/src/content/changelog/<version>.md` (Astro content collections, schéma Zod), RSS à `/changelog/rss.xml`. Toast SPA "Nouvelle version" déclenché par `apps/web/src/components/version-toast.tsx` quand `APP_VERSION` (`apps/web/src/version.ts`) diffère de `localStorage["rubis:last-seen-version"]`. Workflow release : `/push` (cf. `.claude/skills/push/SKILL.md`) bump la version + crée le .md + commit + push.
### OUT (V2 ou plus tard) ## Pricing
- **SMS** — uniquement plan le plus cher en V2
- **Multi-utilisateurs** — uniquement plans payants en V2
- **Intégration banking / réconciliation auto** — l'architecture V1 doit l'anticiper, mais l'implémentation est V2+
- Multi-langues, multi-devises (FR/EUR only en V1)
- Intégration ERP/comptable (Sage, Pennylane, Quickbooks)
## Pricing (esquisse, à valider)
| Plan | Prix | Limite | | Plan | Prix | Limite |
|---|---|---| |---|---|---|
| **Free** | 0 € | 2 factures actives en relance, 1 utilisateur | | **Free** | 0 € | 2 factures actives, 1 utilisateur (cf. ADR-023, mai 2026) |
| **Pro** | 19 €/mois | Factures illimitées, OCR illimité, 1 utilisateur | | **Pro** | 19 €/mois | Factures illimitées, OCR illimité, 1 utilisateur |
| **Business** | 49 €/mois | + multi-utilisateurs, + branding email, + SMS (V2) | | **Business** | 49 €/mois | + multi-users + branding email + SMS (V2) |
Argument de vente : *"moins cher qu'une heure de votre temps mensuel"*. Argument : *"moins cher qu'une heure de votre temps mensuel"*. Funnel signup : essai 14 j Pro avec **CB à l'inscription** (Stripe `trial_period_days`), prélèvement auto à J+14 avec rappel à J+12. Fallback Free pour les sans-CB. Cf. `/docs/tech/stripe-trial-with-card.md`.
## Décisions clés validées (résumé)
Voir `/docs/decisions.md` pour le log complet avec rationale.
- 1 rubis = 10 minutes libérées
- Logo direction A (gem facetté), wordmark à monter en parallèle plus tard
- Palette rubis chaude, sans or, sans bleu
- Typo Bricolage Grotesque + Inter
- Iconographie Lucide
- Mise en demeure : validation manuelle obligatoire (modale)
- SMS et multi-users : V2 + plans payants seulement
- Banking intégration : pas en V1, remplacée par check-in emails
- **Édition native de factures** : extension douce (V1.1), pas pivot vers facturation complète. Conformité Factur-X visée en V1.5, PDP partenaire évaluée en V2 si demandes clients (cf. ADR-025).
- **Numérotation strict séquentielle** : compteur par org alloué en transaction (verrou row-level), brouillons exclus du compteur — choix vs flexible motivé par art. 242 nonies A du CGI.
## Stack technique ## Stack technique
| Couche | Choix | Source | | Couche | Choix | Source |
|---|---|---| |---|---|---|
| Backend (API) | **AdonisJS v7** (TS, MVC, Lucid ORM, auth & jobs intégrés) | ADR-014 | | API | **AdonisJS v7** (Lucid, auth Bearer, jobs BullMQ, mail) | ADR-014 |
| App SPA (`app.rubis.pro`) | **React 19 + Vite + TanStack Router/Query** | ADR-014 | | SaaS `app.rubis.pro` | **React 19 + Vite + TanStack Router/Query** | ADR-014 |
| Landing + blog (`rubis.pro`) | **Astro 6 SSR** (pages statiques prerenderées + blog en SSR) | — | | Landing+blog `rubis.pro` | **Astro 6 SSR** | — |
| Design system | **`@rubis/ui`** — Tailwind v4 tokens + composants TSX | — | | Design system | **`@rubis/ui`** — Tailwind v4 + composants TSX | — |
| Base de données | **PostgreSQL** | ADR-014 | | DB | **PostgreSQL** (LXC Proxmox existant) | ADR-014, ADR-016 |
| Hosting | **Proxmox + K3s** (perso) | ADR-014 | | Storage | **MinIO** (LXC Proxmox existant) | ADR-018 |
| OCR provider | à benchmarker | ADR-020 (en attente) | | Hosting | **Proxmox + K3s + Traefik** | ADR-014 |
| Email outbound | à benchmarker | ADR-021 (en attente) | | PDF natif | **`@react-pdf/renderer`** côté API, templates `apps/api/app/pdf-templates/` | ADR-025 |
| **Génération PDF (factures natives)** | **`@react-pdf/renderer`** côté API (Node), 4 templates dans `apps/api/app/pdf-templates/` | ADR-025 | | OCR / Email | à benchmarker | ADR-020 / ADR-021 |
**Architecture** : monorepo Turborepo (`apps/api` AdonisJS, `apps/web` React SaaS, `apps/landing` Astro public, `packages/shared` types/schemas, `packages/ui` design system). API REST Bearer-auth, deux frontends qui consomment `@rubis/ui` pour un brand visuel unifié, PG et MinIO existants sur LXC Proxmox. Détails dans `/docs/tech/architecture.md`. **Repo** : monorepo Turborepo (`apps/api`, `apps/web`, `apps/landing`, `packages/shared` Zod, `packages/ui`). API REST Bearer-auth (ADR-017). Détails : `/docs/tech/architecture.md`.
**Décisions cadres** : ADR-014 (stack), ADR-015 (monorepo), ADR-016 (PG Proxmox existant), ADR-017 (Bearer tokens), ADR-018 (MinIO existant). **Convention cross-cutting — UUID partout.** PK et FK applicatives en UUID v4 (`gen_random_uuid()`). **Jamais d'`increments`/`serial`**, même pour les tables internes. Protège de l'énumération, simplifie la fédération multi-tenant, évite les fuites par incrément.
### Conventions techniques (cross-cutting) ## Comment bosser avec moi (Arthur)
- **Identifiants : UUID partout.** Toutes les PK et FK applicatives sont des UUID v4 (PG `uuid` avec default `gen_random_uuid()`). **Jamais d'`increments`/`serial`**, même pour les tables internes (auth tokens, sessions, refresh tokens, etc.). Les UUID protègent de l'énumération, simplifient la fédération multi-tenant et évitent les fuites de volumes par incrément. Les transformers exposent les UUID directement en string — pas de cast nécessaire. 1. **Acquisition avant produit.** Quand je propose une feature, vérifier qu'un user réel l'a demandée. Si c'est une hypothèse, me ramener au plan acquisition.
2. **Lire l'ADR avant de toucher un sous-système** (`/docs/decisions.md`). Si décision absente, écrire un nouvel ADR.
3. **Pas de personal brand sur les réseaux.** LinkedIn propre + GitHub OK, pas de mise en scène. Pour le copy public, ton direct sans posture.
4. **Réutiliser l'infra existante** (PG, MinIO, K3s, Traefik) plutôt que provisionner du neuf.
5. **Réponses en français**, prose dense, peu de bullets sauf si liste vraiment nécessaire. Pushback bienvenu, pas de flatterie.
## Documents associés ## Documents associés
| Fichier | Rôle | | Fichier | Rôle |
|---|---| |---|---|
| `/CLAUDE.md` (ce fichier) | Contexte top-level, toujours en tête | | `/docs/produit.md` | Spec produit (features, IN/OUT V1, flow native) |
| `/apps/landing/` | Landing publique + blog (Astro 6 SSR) — déployée sur `rubis.pro` | | `/docs/flow.md` | Cycle de vie facture, statuts, transitions, edge cases |
| `/apps/landing/public/favicon.{svg,ico,png}` | Set complet de favicons + apple-touch-icon | | `/docs/marque.md` | Référence marque écrite |
| `/apps/landing/public/site.webmanifest` | Manifest PWA (theme `#9F1239`, background `#FAF7F2`) | | `/docs/decisions.md` | Log ADR (rationale par décision) |
| `/packages/ui/` | Design system partagé (tokens Tailwind v4 + composants TSX) | | `/docs/tech/architecture.md` | Architecture technique (composants, topologie) |
| `/docs/produit.md` | Spec produit haut niveau (features, IN/OUT V1, pricing). Inclut la section "Édition native des factures". | | `/docs/tech/frontend.md` | Guide implémentation frontend |
| `/docs/flow.md` | **Comportement produit deep-dive** : cycle de vie d'une facture, statuts + transitions, surfaces UI, mécanique de confirmation (check-in), mode démo, edge cases. Flow native = lignes structurées + snapshots immuables. | | `/docs/tech/backend.md` | Guide backend (jobs, mail, conventions Adonis) |
| `/apps/api/app/pdf-templates/` | 4 templates `@react-pdf/renderer` (Classique, Moderne, Minimal, Élégant) + dispatcher. Génération PDF native côté serveur. | | `/docs/tech/landing-optimisations.md` | Audit landing + plan d'exécution (mai 2026) |
| `/apps/web/src/routes/_app/parametres_.facturation.tsx` | Page de paramétrage de l'éditeur de factures (identité émetteur, RIB, mentions, numérotation, thème par défaut). | | `/docs/marketing/playbook.md` | Playbook acquisition : ICP, Dream 100, outreach |
| `/apps/web/src/routes/_app/factures_.nouvelle.tsx` | Éditeur split-view : édition à gauche, preview PDF live à droite (debounce 500 ms). | | `/docs/munitions-marketing.md` | Stats marché, concurrents, copy, positionnement |
| `/docs/marque.md` | Référence marque écrite (palette, typo, voix, do/don't) | | `/docs/tech/stripe-trial-with-card.md` | Funnel signup essai 14j + CB à l'inscription |
| `/docs/decisions.md` | Log de décisions avec rationale (format ADR-light) | | `/.claude/deploy-memory.md` | Procédure de déploiement |
| `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP | | `/.claude/skills/push/SKILL.md` | Skill `/push` — bump version + changelog + commit |
| `/docs/brand-identity.html` | Présentation visuelle de la marque (4 logos, applications) |
| `/docs/munitions-marketing.md` | Stats marché, concurrents, positionnement, copy |
| `/docs/marketing/playbook.md` | Playbook acquisition premiers clients : ICP, Dream 100, channels, templates outreach |
| `/docs/tech/architecture.md` | Architecture technique : composants, flux, topologie, conventions |
| `/docs/tech/frontend.md` | Guide d'implémentation frontend (deps, Tailwind, TanStack, Tuyau) |
| `/Dockerfile` | Build nginx-alpine servant `/landing/` sur port 80 |
| `/k3s/` | Manifests Kubernetes (namespace, deployment, service) |
| `/.claude/deploy-memory.md` | Procédure de déploiement (Gitea CI ou manuel) |
| `/.claude/skills/push/SKILL.md` | Skill Claude Code `/push` — release automatisée (bump version + entrée changelog + commit + push) |
| `/apps/landing/src/content/changelog/` | Une entrée Markdown par version (source de vérité du changelog public `/changelog`) |
| `/apps/web/src/version.ts` | Constante `APP_VERSION` — déclenche le toast "Nouvelle version" dans la SPA |
## Déploiement ## Déploiement
- **Domaine principal** : https://rubis.pro (landing + blog Astro) + https://app.rubis.pro (SaaS React) Domaines : **`rubis.pro`** (landing+blog) · **`app.rubis.pro`** (SaaS). Compat 301 depuis `rubis.arthurbarre.fr` / `app.rubis.arthurbarre.fr`. Images : `git.arthurbarre.fr/ordinarthur/rubis-{landing,api,web}:latest`. Détails : `.claude/deploy-memory.md`.
- **Image landing** : `git.arthurbarre.fr/ordinarthur/rubis-landing:latest` (Astro Node SSR, port 4321)
- **Image API** : `git.arthurbarre.fr/ordinarthur/rubis-api:latest` (port 3333)
- **Image SPA** : `git.arthurbarre.fr/ordinarthur/rubis-web:latest` (nginx + proxy /api → rubis-api)
- **Compat** : `rubis.arthurbarre.fr` / `app.rubis.arthurbarre.fr` redirigent en 301 vers `rubis.pro` / `app.rubis.pro` (config Traefik dans repo proxmox)
- Voir `.claude/deploy-memory.md` pour la procédure complète.
## Email infrastructure (`rubis.pro`) ## Email infrastructure
Deux flux distincts qui cohabitent sur le même domaine via des sous-domaines DNS séparés : | Flux | Provider | Adresse | DNS |
| Flux | Provider | Adresse | DNS clés |
|---|---|---|---| |---|---|---|---|
| **Sortant** (relances, check-in, auth) | Resend | `relances@rubis.pro` | `send.rubis.pro` (MX + SPF), `resend._domainkey` (DKIM), `_dmarc` | | **Sortant** (relances, auth) | Resend | `relances@rubis.pro` | `send.rubis.pro` MX+SPF, `resend._domainkey` DKIM, `_dmarc` |
| **Entrant** (humain) | OVH MX Plan | `contact@rubis.pro` (général + RGPD), `dev@rubis.pro` (notifs tech) | MX `@``mx*.mail.ovh.net.` | | **Entrant** (humain) | OVH MX Plan | `contact@rubis.pro`, `dev@rubis.pro` | MX `@``mx*.mail.ovh.net.` |
Détails dans `/docs/tech/backend.md` §12.5. Détails : `/docs/tech/backend.md` §12.5.
## Questions ouvertes ## Questions ouvertes
- **Conversion 1 rubis = 10 min** validée mais à confirmer en user testing après MVP - **Pricing Free** à réarbitrer (cf. landing-optimisations.md §2)
- **Wordmark "rubis" avec gem-i** (direction C) à monter en complément du logo A à un moment - **Provider OCR** à benchmarker (Mindee / Document AI / Textract / Tesseract) — ADR-020
- **Provider OCR** à benchmarker (Mindee, Document AI, Textract, Tesseract) - **Conversion 1 rubis = 10 min** à confirmer en user testing après premiers clients
- **Analytics RGPD** (Plausible self-hosted vs Umami) — futur ADR
--- ---
*Dernière mise à jour : 2026-05-09 · Maintenu par Arthur + Claude.* *Dernière mise à jour : 2026-05-17 · Maintenu par Arthur + Claude.*

View File

@ -1,13 +1,22 @@
import vine from '@vinejs/vine' import vine from '@vinejs/vine'
import type { HttpContext } from '@adonisjs/core/http' import type { HttpContext } from '@adonisjs/core/http'
import { Exception } from '@adonisjs/core/exceptions' import { Exception } from '@adonisjs/core/exceptions'
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger' import logger from '@adonisjs/core/services/logger'
import Organization from '#models/organization' import Organization from '#models/organization'
import User from '#models/user'
import { getOrgSubscriptionState } from '#services/billing' import { getOrgSubscriptionState } from '#services/billing'
import { getStripe, STRIPE_LOOKUP_KEYS, getPriceByLookup } from '#services/stripe' import { getStripe } from '#services/stripe'
import {
createCheckoutSession,
createTrialCheckoutSession,
ensureStripeCustomer,
handleCheckoutCompleted,
handlePaymentFailed,
handleSubscriptionDeleted,
handleSubscriptionUpdate,
handleTrialWillEnd,
TrialAlreadyConsumedError,
} from '#services/stripe_billing'
import env from '#start/env' import env from '#start/env'
import type Stripe from 'stripe' import type Stripe from 'stripe'
@ -26,33 +35,6 @@ const checkoutValidator = vine.compile(
}) })
) )
function lookupKeyFor(plan: 'pro' | 'business', cycle: 'monthly' | 'yearly') {
if (plan === 'pro') {
return cycle === 'monthly' ? STRIPE_LOOKUP_KEYS.pro_monthly : STRIPE_LOOKUP_KEYS.pro_yearly
}
return cycle === 'monthly' ? STRIPE_LOOKUP_KEYS.business_monthly : STRIPE_LOOKUP_KEYS.business_yearly
}
/**
* Crée ou retrouve le Stripe Customer associé à une org. On stocke
* `stripeCustomerId` sur l'org dès la 1re fois pour éviter les doublons.
*/
async function ensureStripeCustomer(org: Organization, user: User): Promise<string> {
if (org.stripeCustomerId) return org.stripeCustomerId
const stripe = getStripe()
const customer = await stripe.customers.create({
email: user.email,
name: org.name || user.fullName || user.email,
metadata: {
organization_id: org.id,
user_id: user.id,
},
})
org.stripeCustomerId = customer.id
await org.save()
return customer.id
}
export default class BillingController { export default class BillingController {
/** /**
* GET /api/v1/billing/subscription auth. * GET /api/v1/billing/subscription auth.
@ -64,10 +46,53 @@ export default class BillingController {
return response.json({ data: state }) return response.json({ data: state })
} }
/**
* POST /api/v1/billing/start-trial auth.
*
* Démarrer l'essai 14 j Pro avec CB à l'inscription. Crée le Stripe
* Customer si pas déjà, puis une Checkout Session avec
* `trial_period_days`. Renvoie l'URL hostée Stripe vers laquelle le
* SPA redirige.
*
* 409 si l'org a déjà consommé son essai (idempotence garde-fou).
*
* Body: { plan: 'pro'|'business' = 'pro', cycle: 'monthly'|'yearly' = 'monthly' }
*/
async startTrial({ auth, request, response }: HttpContext) {
const organizationId = requireOrgId(auth)
const user = auth.getUserOrFail()
// Body optionnel : valeurs par défaut Pro mensuel (cas le plus
// probable depuis le tunnel onboarding).
const body = request.body() as { plan?: string; cycle?: string }
const plan = body.plan === 'business' ? 'business' : 'pro'
const cycle = body.cycle === 'yearly' ? 'yearly' : 'monthly'
const org = await Organization.findOrFail(organizationId)
const customerId = await ensureStripeCustomer(org, user)
try {
const result = await createTrialCheckoutSession({
org,
customerId,
plan,
cycle,
})
return response.json({ data: { url: result.url } })
} catch (err) {
if (err instanceof TrialAlreadyConsumedError) {
throw new Exception('Essai déjà consommé', {
status: 409,
code: 'trial_already_consumed',
})
}
throw err
}
}
/** /**
* POST /api/v1/billing/checkout auth. * POST /api/v1/billing/checkout auth.
* Crée une session Stripe Checkout et renvoie l'URL hostée le SPA * Crée une session Stripe Checkout standard (sans essai). Pour les
* redirige vers Stripe pour que l'user paye en sécurité. * users post-trial qui upgrade, ou Free direct payant.
* *
* Body: { plan: 'pro'|'business', cycle: 'monthly'|'yearly' } * Body: { plan: 'pro'|'business', cycle: 'monthly'|'yearly' }
*/ */
@ -79,28 +104,8 @@ export default class BillingController {
const org = await Organization.findOrFail(organizationId) const org = await Organization.findOrFail(organizationId)
const customerId = await ensureStripeCustomer(org, user) const customerId = await ensureStripeCustomer(org, user)
const price = await getPriceByLookup(lookupKeyFor(plan, cycle)) const result = await createCheckoutSession({ org, customerId, plan, cycle })
const stripe = getStripe() return response.json({ data: { url: result.url } })
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
success_url: `${webUrl}/parametres/abonnement?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${webUrl}/parametres/abonnement?checkout=cancel`,
// On stocke org_id en metadata pour pouvoir lier côté webhook
// sans avoir besoin de regarder le customer.
subscription_data: {
metadata: { organization_id: organizationId, plan },
},
metadata: { organization_id: organizationId, plan },
allow_promotion_codes: true,
billing_address_collection: 'auto',
locale: 'fr',
})
return response.json({ data: { url: session.url } })
} }
/** /**
@ -186,12 +191,14 @@ export default class BillingController {
* POST /api/v1/billing/webhook public (auth via signature Stripe). * POST /api/v1/billing/webhook public (auth via signature Stripe).
* *
* Stripe envoie les events de subscription ici. On vérifie la signature * Stripe envoie les events de subscription ici. On vérifie la signature
* via le webhook secret puis on dispatch : * via le webhook secret puis on dispatch vers les handlers du service
* `stripe_billing` (pour la testabilité).
* *
* - checkout.session.completed premier paiement OK, set plan * - checkout.session.completed 1er paiement OK / trial démarré
* - customer.subscription.updated renouvellement, plan change * - customer.subscription.{created,updated} renouvellement, plan change, trialactive
* - customer.subscription.deleted annulation effective free * - customer.subscription.deleted annulation effective free
* - invoice.payment_failed past_due (UI rappelle l'user) * - customer.subscription.trial_will_end email recap J-3 avant trial_end
* - invoice.payment_failed past_due
* *
* Idempotent : on traite chaque event en read-then-write sans assumer * Idempotent : on traite chaque event en read-then-write sans assumer
* qu'il arrive une seule fois (Stripe peut re-livrer). * qu'il arrive une seule fois (Stripe peut re-livrer).
@ -209,8 +216,6 @@ export default class BillingController {
if (!sig) { if (!sig) {
throw new Exception('Signature Stripe manquante', { status: 400, code: 'missing_signature' }) throw new Exception('Signature Stripe manquante', { status: 400, code: 'missing_signature' })
} }
// Adonis a déjà parsé le body en JSON : Stripe a besoin du raw, on le
// récupère via `request.raw()`.
const raw = request.raw() const raw = request.raw()
if (!raw) { if (!raw) {
throw new Exception('Raw body indisponible', { throw new Exception('Raw body indisponible', {
@ -233,25 +238,7 @@ export default class BillingController {
logger.info({ type: event.type, id: event.id }, 'Stripe webhook reçu') logger.info({ type: event.type, id: event.id }, 'Stripe webhook reçu')
try { try {
switch (event.type) { await dispatchWebhookEvent(event)
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
break
case 'customer.subscription.created':
case 'customer.subscription.updated':
await this.handleSubscriptionUpdate(event.data.object as Stripe.Subscription)
break
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription)
break
case 'invoice.payment_failed':
await this.handlePaymentFailed(event.data.object as Stripe.Invoice)
break
default:
// On ignore les autres events. Stripe en envoie beaucoup, on
// n'en a besoin que d'une poignée.
break
}
} catch (err) { } catch (err) {
// En cas d'erreur de traitement, on log mais on renvoie 200 quand // En cas d'erreur de traitement, on log mais on renvoie 200 quand
// même : Stripe va retry plein de fois sinon. Mieux vaut perdre un // même : Stripe va retry plein de fois sinon. Mieux vaut perdre un
@ -262,132 +249,34 @@ export default class BillingController {
return response.json({ received: true }) return response.json({ received: true })
} }
}
// ----------------------------------------------------------------------- /**
// Handlers webhook * Dispatcher webhook handler typé. Export séparé pour les tests qui
// ----------------------------------------------------------------------- * peuvent construire un `Stripe.Event` factice et vérifier que le bon
* handler est appelé.
private async handleCheckoutCompleted(session: Stripe.Checkout.Session) { */
const orgId = session.metadata?.['organization_id'] export async function dispatchWebhookEvent(event: Stripe.Event): Promise<void> {
if (!orgId) { switch (event.type) {
logger.warn({ session: session.id }, 'checkout.completed sans organization_id en metadata') case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
return return
} case 'customer.subscription.created':
if (!session.subscription || typeof session.subscription !== 'string') return case 'customer.subscription.updated':
const stripe = getStripe() await handleSubscriptionUpdate(event.data.object as Stripe.Subscription)
const subscription = await stripe.subscriptions.retrieve(session.subscription, {
expand: ['items.data.price'],
})
await this.applySubscriptionToOrg(orgId, subscription)
}
private async handleSubscriptionUpdate(subscription: Stripe.Subscription) {
const orgId = subscription.metadata?.['organization_id']
if (!orgId) {
// Fallback : remonter via stripeCustomerId
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id
const org = await Organization.findBy('stripeCustomerId', customerId)
if (!org) {
logger.warn(
{ subscriptionId: subscription.id, customerId },
'subscription.updated : org introuvable'
)
return
}
await this.applySubscriptionToOrg(org.id, subscription)
return return
} case 'customer.subscription.deleted':
await this.applySubscriptionToOrg(orgId, subscription) await handleSubscriptionDeleted(event.data.object as Stripe.Subscription)
} return
case 'customer.subscription.trial_will_end':
private async handleSubscriptionDeleted(subscription: Stripe.Subscription) { await handleTrialWillEnd(event.data.object as Stripe.Subscription)
const customerId = return
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id case 'invoice.payment_failed':
const org = await Organization.findBy('stripeCustomerId', customerId) await handlePaymentFailed(event.data.object as Stripe.Invoice)
if (!org) return return
org.plan = 'free' default:
org.stripeSubscriptionId = null // On ignore les autres events. Stripe en envoie beaucoup, on n'en
org.subscriptionStatus = 'canceled' // a besoin que d'une poignée.
org.billingCycle = null
org.currentPeriodEnd = null
org.cancelAtPeriodEnd = false
await org.save()
logger.info({ orgId: org.id }, 'Org redescendue en plan free (subscription deleted)')
}
private async handlePaymentFailed(invoice: Stripe.Invoice) {
const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
if (!customerId) return
const org = await Organization.findBy('stripeCustomerId', customerId)
if (!org) return
org.subscriptionStatus = 'past_due'
await org.save()
logger.warn({ orgId: org.id, invoiceId: invoice.id }, 'Paiement échoué — org marquée past_due')
}
/**
* Applique l'état d'une Stripe Subscription à une org : plan, cycle,
* status, period_end. Idempotent.
*/
private async applySubscriptionToOrg(orgId: string, subscription: Stripe.Subscription) {
const org = await Organization.find(orgId)
if (!org) {
logger.warn({ orgId }, 'applySubscriptionToOrg : org introuvable')
return return
}
const item = subscription.items.data[0]
if (!item) return
const price = item.price as Stripe.Price
const lookupKey = price.lookup_key as string | null
const plan = this.planFromLookupKey(lookupKey)
const cycle = this.cycleFromLookupKey(lookupKey)
org.plan = plan
org.stripeSubscriptionId = subscription.id
org.subscriptionStatus = subscription.status
org.billingCycle = cycle
org.currentPeriodEnd = item.current_period_end
? DateTime.fromSeconds(item.current_period_end)
: null
// Détection de l'annulation programmée. Stripe expose DEUX mécaniques :
// - `cancel_at_period_end: true` (booléen) → utilisé par l'API directe
// (`stripe.subscriptions.update --cancel-at-period-end=true`)
// - `cancel_at: <timestamp>` (epoch) → utilisé par le Customer Portal
// qui schedule un cancel à une date précise (généralement = period_end).
//
// Sémantiquement c'est la même chose : "le sub s'éteindra à cette date".
// On unifie en un seul booléen pour le reste de l'app.
org.cancelAtPeriodEnd =
!!subscription.cancel_at_period_end || !!subscription.cancel_at
await org.save()
logger.info(
{
orgId,
plan,
cycle,
status: subscription.status,
subscriptionId: subscription.id,
cancelAtPeriodEnd: !!subscription.cancel_at_period_end,
cancelAt: subscription.cancel_at,
},
'Subscription appliquée à l\'org'
)
}
private planFromLookupKey(key: string | null): 'free' | 'pro' | 'business' {
if (!key) return 'free'
if (key.includes('business')) return 'business'
if (key.includes('pro')) return 'pro'
return 'free'
}
private cycleFromLookupKey(key: string | null): 'monthly' | 'yearly' | null {
if (!key) return null
if (key.endsWith('_yearly')) return 'yearly'
if (key.endsWith('_monthly')) return 'monthly'
return null
} }
} }

View File

@ -0,0 +1,286 @@
import { DateTime } from 'luxon'
import db from '@adonisjs/lucid/services/db'
import mail from '@adonisjs/mail/services/main'
import { render } from '@react-email/components'
import logger from '@adonisjs/core/services/logger'
import Organization from '#models/organization'
import User from '#models/user'
import env from '#start/env'
import { DEFAULT_BRAND } from '#services/brand'
import { formatAmountFr, formatDateFr } from '#services/template'
import { getQueue } from '#services/queue'
import { getStripe } from '#services/stripe'
import { TrialRecapEmail } from '#mails/trial_recap_email'
/**
* Job d'envoi de l'email recap d'essai 14 j. Enqueué par le webhook
* `customer.subscription.trial_will_end` (Stripe émet à J-3 avant
* `trial_end`).
*
* Idempotence : on utilise `jobId = trial-recap:<subscriptionId>` côté
* BullMQ pour qu'un re-deliver Stripe ne crée pas un 2e job. Si le job
* est déjà passé (completed), enqueue est un no-op.
*
* Tolère les org en mode démo : `clock.now()` reste prod ici (l'essai
* est piloté par Stripe, pas par virtualNow). On loggue juste un info.
*/
export type TrialRecapJobData = {
organizationId: string
/** Stripe subscription id — sert de clé de dédoublonnage. */
subscriptionId: string
}
export async function enqueueTrialRecapEmail(
organizationId: string,
subscriptionId: string
): Promise<void> {
const queue = getQueue('trial-recap')
await queue.add(
'send_trial_recap',
{ organizationId, subscriptionId } satisfies TrialRecapJobData,
{
/**
* Clé déterministe = idempotence. BullMQ refuse silencieusement les
* duplicates. Si Stripe redélivre le webhook trial_will_end, on
* n'envoie pas un 2e email.
*/
jobId: `trial-recap:${subscriptionId}`,
attempts: 3,
backoff: { type: 'exponential', delay: 30_000 },
removeOnComplete: { age: 7 * 24 * 3600 },
removeOnFail: { age: 14 * 24 * 3600 },
}
)
}
// ---------------------------------------------------------------------------
// Stats aggregation — pure, testable sans dépendance mail
// ---------------------------------------------------------------------------
export type TrialUsageStats = {
invoicesImported: number
remindersSent: number
eurosCollectedCents: number
rubisEarned: number
}
/**
* Agrège les compteurs d'usage de l'org depuis `since` (typiquement la
* date de création de l'org). Une seule passe SQL pour rester rapide.
*/
export async function computeTrialUsageStats(
organizationId: string,
since: DateTime
): Promise<TrialUsageStats> {
const sinceJs = since.toJSDate()
const inv = (await db
.from('invoices')
.where('organization_id', organizationId)
.where('created_at', '>=', sinceJs)
.select(db.raw(`count(*)::int as imported`))
.first()) as { imported: number } | undefined
const paid = (await db
.from('invoices')
.where('organization_id', organizationId)
.where('status', 'paid')
.where('paid_at', '>=', sinceJs)
.select(
db.raw(`coalesce(sum(amount_ttc_cents), 0)::int as euros_cents`),
db.raw(`coalesce(sum(rubis_earned), 0)::int as rubis`)
)
.first()) as { euros_cents: number; rubis: number } | undefined
const reminders = (await db
.from('relance_tasks')
.where('organization_id', organizationId)
.where('status', 'sent')
.where('sent_at', '>=', sinceJs)
.select(db.raw(`count(*)::int as sent`))
.first()) as { sent: number } | undefined
return {
invoicesImported: inv?.imported ?? 0,
remindersSent: reminders?.sent ?? 0,
eurosCollectedCents: paid?.euros_cents ?? 0,
rubisEarned: paid?.rubis ?? 0,
}
}
/**
* Helper de présentation : convertit un nombre de rubis (= 10 min chacun)
* en chaîne « Xh Ym ». Aligné sur `apps/web/src/lib/format.ts`
* `formatRubisToHours` pour cohérence visuelle.
*/
export function formatRubisToHoursFr(rubis: number): string {
const totalMinutes = rubis * 10
const hours = Math.floor(totalMinutes / 60)
const minutes = totalMinutes % 60
if (hours === 0) return `${minutes} min`
if (minutes === 0) return `${hours} h`
return `${hours} h ${String(minutes).padStart(2, '0')}`
}
// ---------------------------------------------------------------------------
// Worker
// ---------------------------------------------------------------------------
export async function sendTrialRecapEmailJob(data: TrialRecapJobData): Promise<void> {
const { organizationId, subscriptionId } = data
logger.info({ organizationId, subscriptionId }, 'sendTrialRecapEmailJob: pick-up')
const org = await Organization.find(organizationId)
if (!org) {
logger.warn({ organizationId }, 'sendTrialRecapEmailJob: org introuvable, skip')
return
}
// Garde-fou : on ne spamme pas un user qui n'est plus en essai (status
// passé en active/canceled entre temps).
if (org.subscriptionStatus !== 'trialing' || !org.trialEndsAt) {
logger.info(
{ organizationId, status: org.subscriptionStatus },
'sendTrialRecapEmailJob: org plus en essai, skip'
)
return
}
// 1er user de l'org = destinataire. V1 mono-user, en V2 (Business multi-
// users) on enverra au billing_contact dédié.
const user = await User.query().where('organization_id', org.id).first()
if (!user) {
logger.warn({ organizationId }, 'sendTrialRecapEmailJob: aucun user dans org, skip')
return
}
const since = org.createdAt
const stats = await computeTrialUsageStats(org.id, since)
const now = DateTime.utc()
const daysRemaining = Math.max(
1,
Math.ceil(org.trialEndsAt.diff(now, 'days').days)
)
// Prix : par défaut Pro mensuel (19 €). On regarde la subscription
// Stripe pour récupérer le prix réel si différent (yearly, business).
const priceFormatted = await resolvePriceForRecap(subscriptionId)
// Portal URL : on tente de créer une session Customer Portal. Fallback
// sur le lien direct dans l'app si Stripe est down (best-effort, le
// recap doit partir même si le portail n'est pas joignable).
const portalUrl = await safeCreatePortalUrl(org.stripeCustomerId)
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
const firstName = (user.fullName ?? '').split(' ')[0] || user.email.split('@')[0]
const subject = `Plus que ${daysRemaining} ${daysRemaining > 1 ? 'jours' : 'jour'} d'essai · récap avant prélèvement`
const htmlBody = await render(
TrialRecapEmail({
tokens: DEFAULT_BRAND,
user: { firstName },
trial: {
daysRemaining,
endsAtFormatted: formatDateFr(org.trialEndsAt.toJSDate()),
priceFormatted,
},
usage: {
invoicesImported: stats.invoicesImported,
remindersSent: stats.remindersSent,
eurosCollected: formatAmountFr(stats.eurosCollectedCents),
rubisEarned: stats.rubisEarned,
hoursLiberated: formatRubisToHoursFr(stats.rubisEarned),
},
portalUrl,
landingUrl,
})
)
const textBody = `Bonjour ${firstName},
Plus que ${daysRemaining} jour(s) avant que votre essai bascule en Pro et que ${priceFormatted} soit prélevé sur votre carte, le ${formatDateFr(org.trialEndsAt.toJSDate())}.
Récap depuis le début :
${stats.invoicesImported} factures importées
${stats.remindersSent} relances envoyées
${formatAmountFr(stats.eurosCollectedCents)} encaissés
${stats.rubisEarned} rubis ${formatRubisToHoursFr(stats.rubisEarned)} libérées
Si tout est OK, vous n'avez rien à faire. Sinon, annulez en un clic :
${portalUrl}
Arthur, fondateur de Rubis`
const driver = env.get('MAIL_DRIVER', 'smtp')
logger.info(
{ organizationId, to: user.email, driver },
'sendTrialRecapEmailJob: envoi via driver'
)
try {
const mailer = mail.use(driver)
await mailer.send((m) => {
m.from(fromAddress, fromName)
.to(user.email, user.fullName ?? user.email)
.subject(subject)
.html(htmlBody)
.text(textBody)
})
logger.info(
{ organizationId, subscriptionId, driver },
'sendTrialRecapEmailJob: send OK'
)
} catch (err) {
logger.error(
{ err, organizationId, subscriptionId },
'sendTrialRecapEmailJob: échec envoi'
)
throw err
}
}
/**
* Récupère le prix mensuel/annuel TTC de la subscription pour l'afficher
* dans le recap. Fallback `19 €` si Stripe injoignable on préfère un
* email partiellement faux à un email pas envoyé du tout.
*/
async function resolvePriceForRecap(subscriptionId: string): Promise<string> {
try {
const stripe = getStripe()
const sub = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['items.data.price'],
})
const item = sub.items.data[0]
if (!item) return '19 €'
const price = item.price
const amount = price.unit_amount
if (typeof amount !== 'number') return '19 €'
// Stripe stocke en cents → division par 100. Locale FR.
return formatAmountFr(amount)
} catch (err) {
logger.warn({ err, subscriptionId }, 'resolvePriceForRecap: fallback 19 €')
return '19 €'
}
}
async function safeCreatePortalUrl(customerId: string | null): Promise<string> {
const fallback = `${env.get('WEB_URL', 'http://localhost:5173')}/parametres/abonnement`
if (!customerId) return fallback
try {
const stripe = getStripe()
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: fallback,
locale: 'fr',
})
return session.url || fallback
} catch (err) {
logger.warn({ err, customerId }, 'safeCreatePortalUrl: fallback link app')
return fallback
}
}

View File

@ -0,0 +1,200 @@
/**
* Template email recap d'essai 14 j — envoyé à l'utilisateur ~J+11 (3
* jours avant trial_end, déclenché par le webhook Stripe
* `customer.subscription.trial_will_end`).
*
* Toujours en branding Rubis (notif Rubis user, jamais customisable
* marque blanche).
*
* Variables interpolées :
* - statistiques d'usage des X premiers jours (factures, relances, récup, rubis)
* - date du prélèvement (= trial_end)
* - prix mensuel/annuel TTC
* - lien Customer Portal (annuler / changer CB)
*/
import * as React from 'react'
import { Section, Text, Button } from '@react-email/components'
import type { BrandTokens } from '#services/brand'
import { sp } from './_brand.js'
import { EmailLayout } from './_layout.js'
export type TrialRecapEmailProps = {
tokens: BrandTokens
user: { firstName: string }
trial: {
/** Jours restants avant prélèvement (Stripe émet à J-3 par défaut). */
daysRemaining: number
/** Date du prélèvement, formatée FR (« 23 mai 2026 »). */
endsAtFormatted: string
/** Prix TTC formaté (« 19 € »). */
priceFormatted: string
}
usage: {
invoicesImported: number
remindersSent: number
/** Montant des factures passées en `paid` depuis le signup, formaté FR. */
eurosCollected: string
rubisEarned: number
/** Heures libérées en string (« 24h48 »). */
hoursLiberated: string
}
/** URL Customer Portal Stripe pour annuler / mettre à jour CB. */
portalUrl: string
/** URL landing publique (footer). */
landingUrl?: string | null
}
export function TrialRecapEmail({
tokens,
user,
trial,
usage,
portalUrl,
landingUrl,
}: TrialRecapEmailProps) {
const greetingStyle: React.CSSProperties = {
color: tokens.text,
fontSize: '15px',
lineHeight: '1.6',
margin: `0 0 ${sp.lg} 0`,
}
const introStyle: React.CSSProperties = {
color: tokens.text,
fontSize: '15px',
lineHeight: '1.6',
margin: `0 0 ${sp.lg} 0`,
}
const statsCardStyle: React.CSSProperties = {
backgroundColor: tokens.primaryGlow,
border: `1px solid ${tokens.primary}30`,
borderRadius: tokens.radiusCard,
padding: `${sp.md} ${sp.lg}`,
margin: `${sp.lg} 0`,
}
const statsTitleStyle: React.CSSProperties = {
color: tokens.primaryDeep,
fontSize: '12px',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.08em',
margin: `0 0 ${sp.md} 0`,
}
const statRowStyle: React.CSSProperties = {
display: 'block',
margin: `${sp.sm} 0`,
fontSize: '14px',
lineHeight: '1.4',
}
const statLabelStyle: React.CSSProperties = {
display: 'inline-block',
color: tokens.textMuted,
fontWeight: 500,
minWidth: '200px',
}
const statValueStyle: React.CSSProperties = {
color: tokens.text,
fontWeight: 700,
fontVariantNumeric: 'tabular-nums',
}
const noticeStyle: React.CSSProperties = {
color: tokens.text,
fontSize: '14px',
lineHeight: '1.6',
margin: `${sp.lg} 0 ${sp.md} 0`,
}
const buttonWrapStyle: React.CSSProperties = {
textAlign: 'center' as const,
margin: `${sp.lg} 0 ${sp.md} 0`,
}
const buttonStyle: React.CSSProperties = {
backgroundColor: tokens.white,
color: tokens.primary,
border: `1px solid ${tokens.primary}`,
borderRadius: tokens.radiusButton,
padding: '12px 22px',
fontSize: '14px',
fontWeight: 600,
textDecoration: 'none',
display: 'inline-block',
}
const fineprintStyle: React.CSSProperties = {
color: tokens.textVeryMuted,
fontSize: '12.5px',
lineHeight: '1.5',
margin: `${sp.md} 0 0 0`,
}
const dayPlural = trial.daysRemaining > 1 ? 'jours' : 'jour'
return (
<EmailLayout
tokens={tokens}
preview={`Plus que ${trial.daysRemaining} ${dayPlural} d'essai — récap avant le prélèvement de ${trial.priceFormatted}.`}
brandSubtitle={`Essai · plus que ${trial.daysRemaining} ${dayPlural}`}
landingUrl={landingUrl}
>
<Text style={greetingStyle}>Bonjour {user.firstName},</Text>
<Text style={introStyle}>
Plus que <strong>{trial.daysRemaining} {dayPlural}</strong> avant que votre
essai bascule en Pro mensuel et que Rubis prélève{' '}
<strong>{trial.priceFormatted}</strong> sur votre carte, le{' '}
<strong>{trial.endsAtFormatted}</strong>. Petit récap de ce que Rubis a
fait pour vous depuis le début :
</Text>
<Section style={statsCardStyle}>
<Text style={statsTitleStyle}> Votre essai en chiffres</Text>
<Text style={statRowStyle}>
<span style={statLabelStyle}>Factures importées</span>
<span style={statValueStyle}>{usage.invoicesImported}</span>
</Text>
<Text style={statRowStyle}>
<span style={statLabelStyle}>Relances envoyées</span>
<span style={statValueStyle}>{usage.remindersSent}</span>
</Text>
<Text style={statRowStyle}>
<span style={statLabelStyle}>Encaissé</span>
<span style={statValueStyle}>{usage.eurosCollected}</span>
</Text>
<Text style={statRowStyle}>
<span style={statLabelStyle}>Rubis gagnés</span>
<span style={statValueStyle}>
{usage.rubisEarned} · {usage.hoursLiberated} libérées
</span>
</Text>
</Section>
<Text style={noticeStyle}>
Si tout est OK, vous n'avez rien à faire votre essai bascule en Pro
au {trial.endsAtFormatted} et vous gardez vos données, vos plans et
vos relances en cours.
</Text>
<Text style={noticeStyle}>
Si vous voulez annuler, c'est en un clic depuis votre espace Rubis :
</Text>
<Section style={buttonWrapStyle}>
<Button href={portalUrl} style={buttonStyle}>
Gérer mon abonnement
</Button>
</Section>
<Text style={fineprintStyle}>
Une question ? Répondez à ce mail, je le lis personnellement.{'\n'}
Arthur fondateur de Rubis
</Text>
</EmailLayout>
)
}

View File

@ -99,7 +99,10 @@ export type EnforcementResult =
* Règle : * Règle :
* - Plans payants toujours autorisé * - Plans payants toujours autorisé
* - Free pendant la période de grâce autorisé sans limite * - Free pendant la période de grâce autorisé sans limite
* - Free après période de grâce bloque si `current + delta > limit` * - Free pendant l'essai 14 j Stripe (status='trialing') autorisé sans
* limite (l'user a déjà donné sa CB, il a accès Pro complet)
* - Free après période de grâce ET hors essai bloque si
* `current + delta > limit`
* *
* `delta` = nombre de factures qu'on s'apprête à créer (typiquement 1 * `delta` = nombre de factures qu'on s'apprête à créer (typiquement 1
* pour saisie manuelle, N pour upload OCR multi-fichiers). * pour saisie manuelle, N pour upload OCR multi-fichiers).
@ -121,6 +124,19 @@ export async function canCreateInvoices(
return { allowed: true } return { allowed: true }
} }
// Free + essai 14 j Stripe actif → unlimited. `subscriptionStatus`
// est posé par le webhook checkout.session.completed lors du démarrage
// du trial. `trial_ends_at` agit en garde-fou : si le webhook trial->
// active a été manqué, on évite de laisser l'unlimited en place
// indéfiniment.
if (
org.subscriptionStatus === 'trialing' &&
org.trialEndsAt &&
org.trialEndsAt > now
) {
return { allowed: true }
}
const current = await countActiveInvoices(organizationId) const current = await countActiveInvoices(organizationId)
if (current + delta <= caps.activeInvoicesLimit) { if (current + delta <= caps.activeInvoicesLimit) {
return { allowed: true } return { allowed: true }
@ -149,6 +165,10 @@ export type OrgSubscriptionState = {
gracePeriodEndsAt: string | null gracePeriodEndsAt: string | null
/** Status Stripe (`active`, `trialing`, `past_due`, `canceled`...). null pour les Free. */ /** Status Stripe (`active`, `trialing`, `past_due`, `canceled`...). null pour les Free. */
subscriptionStatus: string | null subscriptionStatus: string | null
/** True ssi essai 14 j Pro actuellement actif (status=trialing + trial_ends_at futur). */
inTrial: boolean
/** ISO de fin d'essai, null si jamais d'essai démarré. */
trialEndsAt: string | null
/** 'monthly' | 'yearly' | null pour les Free. */ /** 'monthly' | 'yearly' | null pour les Free. */
billingCycle: 'monthly' | 'yearly' | null billingCycle: 'monthly' | 'yearly' | null
/** ISO date de fin de période courante (= prochaine facture Stripe). */ /** ISO date de fin de période courante (= prochaine facture Stripe). */
@ -172,6 +192,10 @@ export async function getOrgSubscriptionState(
const now = DateTime.utc() const now = DateTime.utc()
const inGracePeriod = const inGracePeriod =
plan === 'free' && !!org.gracePeriodEndsAt && org.gracePeriodEndsAt > now plan === 'free' && !!org.gracePeriodEndsAt && org.gracePeriodEndsAt > now
const inTrial =
org.subscriptionStatus === 'trialing' &&
!!org.trialEndsAt &&
org.trialEndsAt > now
return { return {
plan, plan,
@ -180,6 +204,8 @@ export async function getOrgSubscriptionState(
inGracePeriod, inGracePeriod,
gracePeriodEndsAt: org.gracePeriodEndsAt?.toISO() ?? null, gracePeriodEndsAt: org.gracePeriodEndsAt?.toISO() ?? null,
subscriptionStatus: org.subscriptionStatus ?? null, subscriptionStatus: org.subscriptionStatus ?? null,
inTrial,
trialEndsAt: org.trialEndsAt?.toISO() ?? null,
billingCycle: billingCycle:
org.billingCycle === 'monthly' || org.billingCycle === 'yearly' org.billingCycle === 'monthly' || org.billingCycle === 'yearly'
? org.billingCycle ? org.billingCycle

View File

@ -1,6 +1,21 @@
import Stripe from 'stripe' import Stripe from 'stripe'
import env from '#start/env' import env from '#start/env'
/**
* Durée de l'essai Pro V1 (cf. landing-optimisations.md §3). Centralisé
* ici plutôt qu'éparpillé en magic numbers : si on bascule sur 7 ou 21
* jours par la suite (A/B test), un seul point de modification.
*/
export const TRIAL_PERIOD_DAYS = 14
/**
* Combien de jours avant `trial_end` Stripe émet `customer.subscription.trial_will_end`.
* Stripe fixe ce délai à **3 jours** dans le système, non-configurable. On
* l'expose ici pour documenter et calibrer le copy de l'email
* (« plus que 3 jours »).
*/
export const STRIPE_TRIAL_WILL_END_DAYS = 3
/** /**
* Singleton client Stripe lazy init pour ne pas crasher en dev/test * Singleton client Stripe lazy init pour ne pas crasher en dev/test
* quand la clé n'est pas définie. Toute fonction qui nécessite Stripe * quand la clé n'est pas définie. Toute fonction qui nécessite Stripe
@ -27,6 +42,20 @@ export function getStripe(): Stripe {
return _stripe return _stripe
} }
/**
* **Test-only.** Injecte un client Stripe mocké (typiquement un objet
* partiel avec stubs sur les méthodes utilisées). Permet aux tests
* webhook + endpoint d'éviter la dépendance réseau et de contrôler les
* réponses Stripe.
*
* NE PAS utiliser en code applicatif c'est uniquement consommé par les
* helpers de test (`apps/api/tests/helpers/stripe_mock.ts`). Le nom
* préfixé `__` est le signal "interne".
*/
export function __setStripeForTests(mock: Stripe | null): void {
_stripe = mock
}
/** /**
* Lookup keys utilisés pour identifier les Prices Stripe sans hardcoder * Lookup keys utilisés pour identifier les Prices Stripe sans hardcoder
* d'IDs en env. Les Prices sont créées par `node ace stripe:setup` avec * d'IDs en env. Les Prices sont créées par `node ace stripe:setup` avec
@ -41,6 +70,34 @@ export const STRIPE_LOOKUP_KEYS = {
export type StripeLookupKey = (typeof STRIPE_LOOKUP_KEYS)[keyof typeof STRIPE_LOOKUP_KEYS] export type StripeLookupKey = (typeof STRIPE_LOOKUP_KEYS)[keyof typeof STRIPE_LOOKUP_KEYS]
/**
* Helpers de mapping lookup_key plan/cycle, partagés entre le webhook
* (qui lit la subscription Stripe) et le checkout (qui écrit dans le
* subscription_data). Centraliser ici évite les divergences.
*/
export function planFromLookupKey(key: string | null | undefined): 'free' | 'pro' | 'business' {
if (!key) return 'free'
if (key.includes('business')) return 'business'
if (key.includes('pro')) return 'pro'
return 'free'
}
export function cycleFromLookupKey(key: string | null | undefined): 'monthly' | 'yearly' | null {
if (!key) return null
if (key.endsWith('_yearly')) return 'yearly'
if (key.endsWith('_monthly')) return 'monthly'
return null
}
export function lookupKeyFor(plan: 'pro' | 'business', cycle: 'monthly' | 'yearly'): StripeLookupKey {
if (plan === 'pro') {
return cycle === 'monthly' ? STRIPE_LOOKUP_KEYS.pro_monthly : STRIPE_LOOKUP_KEYS.pro_yearly
}
return cycle === 'monthly'
? STRIPE_LOOKUP_KEYS.business_monthly
: STRIPE_LOOKUP_KEYS.business_yearly
}
/** /**
* Récupère un Price Stripe via son lookup_key. Throw si introuvable * Récupère un Price Stripe via son lookup_key. Throw si introuvable
* (signal que `stripe:setup` n'a pas é lancé ou que les lookup_keys * (signal que `stripe:setup` n'a pas é lancé ou que les lookup_keys

View File

@ -0,0 +1,391 @@
/**
* Orchestration billing Stripe fonctions pures testables, sans
* dépendance HTTP. Le controller (`apps/api/app/controllers/billing_controller.ts`)
* ne fait plus que parser la requête et déléguer ici.
*
* Toutes les fonctions sont conçues pour être appelées avec un payload
* Stripe pré-construit (Subscription, Checkout.Session, Invoice...) ce
* qui permet aux tests d'injecter des objets factices sans hit réseau.
*
* cf. docs/tech/stripe-trial-with-card.md pour l'archi cible.
*/
import { DateTime } from 'luxon'
import logger from '@adonisjs/core/services/logger'
import type Stripe from 'stripe'
import Organization from '#models/organization'
import User from '#models/user'
import env from '#start/env'
import {
TRIAL_PERIOD_DAYS,
cycleFromLookupKey,
getPriceByLookup,
getStripe,
lookupKeyFor,
planFromLookupKey,
} from '#services/stripe'
// ---------------------------------------------------------------------------
// Customer creation (idempotent)
// ---------------------------------------------------------------------------
/**
* Crée ou retrouve le Stripe Customer associé à une org. On stocke
* `stripeCustomerId` sur l'org dès la 1re fois pour éviter les doublons.
*
* Idempotent : appel répétés retourne le même ID si déjà posé.
*/
export async function ensureStripeCustomer(org: Organization, user: User): Promise<string> {
if (org.stripeCustomerId) return org.stripeCustomerId
const stripe = getStripe()
const customer = await stripe.customers.create({
email: user.email,
name: org.name || user.fullName || user.email,
metadata: {
organization_id: org.id,
user_id: user.id,
},
})
org.stripeCustomerId = customer.id
await org.save()
return customer.id
}
// ---------------------------------------------------------------------------
// Checkout Session creation
// ---------------------------------------------------------------------------
export type CheckoutPlan = 'pro' | 'business'
export type CheckoutCycle = 'monthly' | 'yearly'
/**
* Result type : URL hostée Stripe à laquelle rediriger l'user.
*/
export type CheckoutSessionResult = {
url: string
sessionId: string
}
/**
* Crée une session Checkout standard (sans essai), pour les users qui
* ont déjà eu leur trial ou qui passent du Free direct payant.
*
* Pré-condition : `org.stripeCustomerId` doit être posé. Appeler
* `ensureStripeCustomer` avant si besoin.
*/
export async function createCheckoutSession(opts: {
org: Organization
customerId: string
plan: CheckoutPlan
cycle: CheckoutCycle
}): Promise<CheckoutSessionResult> {
const { org, customerId, plan, cycle } = opts
const price = await getPriceByLookup(lookupKeyFor(plan, cycle))
const stripe = getStripe()
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
success_url: `${webUrl}/parametres/abonnement?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${webUrl}/parametres/abonnement?checkout=cancel`,
subscription_data: {
metadata: { organization_id: org.id, plan },
},
metadata: { organization_id: org.id, plan },
allow_promotion_codes: true,
billing_address_collection: 'auto',
locale: 'fr',
})
if (!session.url) {
throw new Error('Stripe a renvoyé une session sans URL')
}
return { url: session.url, sessionId: session.id }
}
/**
* Crée une session Checkout en mode **essai 14 jours avec CB
* collectée**. Stripe place la subscription en `status: 'trialing'` dès
* la complétion de la session. À J+14, Stripe prélève automatiquement
* et passe la subscription en `active` (ou `past_due` si CB refusée).
*
* Garde-fou anti-double-trial : si l'org a déjà un `stripeSubscriptionId`
* ou un `trialEndsAt` posé (donc trial déjà consommé), on throw le
* caller doit rediriger vers `createCheckoutSession` standard.
*
* Pré-condition : `org.stripeCustomerId` doit être posé.
*/
export async function createTrialCheckoutSession(opts: {
org: Organization
customerId: string
plan: CheckoutPlan
cycle: CheckoutCycle
}): Promise<CheckoutSessionResult> {
const { org, customerId, plan, cycle } = opts
if (org.trialEndsAt || org.stripeSubscriptionId) {
throw new TrialAlreadyConsumedError()
}
const price = await getPriceByLookup(lookupKeyFor(plan, cycle))
const stripe = getStripe()
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: price.id, quantity: 1 }],
/**
* Redirige sur `/onboarding/compte` après collecte CB le tunnel
* onboarding reprend la main. Le webhook `checkout.session.completed`
* persiste le `trialEndsAt` en parallèle.
*/
success_url: `${webUrl}/onboarding/compte?trial=started&session_id={CHECKOUT_SESSION_ID}`,
/**
* Si l'user ferme Checkout sans valider, on le ramène sur l'écran
* billing avec un toast d'erreur il peut réessayer OU cliquer le
* fallback Free.
*/
cancel_url: `${webUrl}/onboarding/billing?trial=cancel`,
subscription_data: {
trial_period_days: TRIAL_PERIOD_DAYS,
metadata: { organization_id: org.id, plan },
},
metadata: { organization_id: org.id, plan, is_trial: 'true' },
/**
* Important : on collecte la CB même pour un trial (sinon Stripe
* ne pourra pas prélever à J+14). `payment_method_collection`
* par défaut est 'always', on l'expose pour la lisibilité.
*/
payment_method_collection: 'always',
allow_promotion_codes: true,
billing_address_collection: 'auto',
locale: 'fr',
})
if (!session.url) {
throw new Error('Stripe a renvoyé une session sans URL')
}
return { url: session.url, sessionId: session.id }
}
/**
* Sentinel error pour signaler à l'API qu'un user tente de démarrer un
* essai après en avoir déjà eu un. Le controller mappe ça en 409
* `trial_already_consumed`.
*/
export class TrialAlreadyConsumedError extends Error {
constructor() {
super('Essai déjà consommé pour cette organisation')
this.name = 'TrialAlreadyConsumedError'
}
}
// ---------------------------------------------------------------------------
// Webhook handlers — fonctions pures testables
// ---------------------------------------------------------------------------
/**
* Applique l'état d'une Stripe Subscription à une org : plan, cycle,
* status, period_end, trial_end. Idempotent appeler 2× avec le même
* payload donne le même état final.
*
* **Pourquoi pas privé** : extrait du controller pour testabilité.
* Les tests construisent des `Stripe.Subscription` partiels et vérifient
* les colonnes DB après appel.
*/
export async function applySubscriptionToOrg(
orgId: string,
subscription: Stripe.Subscription
): Promise<void> {
const org = await Organization.find(orgId)
if (!org) {
logger.warn({ orgId }, 'applySubscriptionToOrg : org introuvable')
return
}
const item = subscription.items.data[0]
if (!item) return
const price = item.price as Stripe.Price
const lookupKey = price.lookup_key
const plan = planFromLookupKey(lookupKey)
const cycle = cycleFromLookupKey(lookupKey)
org.plan = plan
org.stripeSubscriptionId = subscription.id
org.subscriptionStatus = subscription.status
org.billingCycle = cycle
org.currentPeriodEnd = item.current_period_end
? DateTime.fromSeconds(item.current_period_end)
: null
/**
* `trial_end` n'est posé par Stripe que pendant un trial. Une fois
* passé en `active`, il reste populé (date dans le passé) on le
* conserve pour l'historique sans logique métier dessus. À la
* cancellation `null` on garde l'ancienne valeur côté org pour
* pouvoir reconstituer "vous aviez commencé en essai".
*/
if (subscription.trial_end) {
org.trialEndsAt = DateTime.fromSeconds(subscription.trial_end)
}
org.cancelAtPeriodEnd =
!!subscription.cancel_at_period_end || !!subscription.cancel_at
await org.save()
logger.info(
{
orgId,
plan,
cycle,
status: subscription.status,
subscriptionId: subscription.id,
trialEnd: subscription.trial_end,
cancelAtPeriodEnd: !!subscription.cancel_at_period_end,
},
'Subscription appliquée à l\'org'
)
}
/**
* Handler `checkout.session.completed`. Fetch la subscription créée
* et l'applique à l'org. Lookup d'org via metadata.organization_id.
*/
export async function handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
const orgId = session.metadata?.['organization_id']
if (!orgId) {
logger.warn(
{ sessionId: session.id },
'checkout.completed sans organization_id en metadata'
)
return
}
if (!session.subscription || typeof session.subscription !== 'string') return
const stripe = getStripe()
const subscription = await stripe.subscriptions.retrieve(session.subscription, {
expand: ['items.data.price'],
})
await applySubscriptionToOrg(orgId, subscription)
}
/**
* Handler `customer.subscription.{created,updated}`. Idempotent.
*
* Cherche l'org via `metadata.organization_id` puis via
* `stripeCustomerId` en fallback (cas des subscriptions modifiées
* côté Customer Portal qui ne propagent pas toujours la metadata).
*/
export async function handleSubscriptionUpdate(subscription: Stripe.Subscription): Promise<void> {
const orgId = subscription.metadata?.['organization_id']
if (orgId) {
await applySubscriptionToOrg(orgId, subscription)
return
}
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id
const org = await Organization.findBy('stripeCustomerId', customerId)
if (!org) {
logger.warn(
{ subscriptionId: subscription.id, customerId },
'subscription.update : org introuvable'
)
return
}
await applySubscriptionToOrg(org.id, subscription)
}
/**
* Handler `customer.subscription.deleted`. Bascule l'org sur Free.
*/
export async function handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id
const org = await Organization.findBy('stripeCustomerId', customerId)
if (!org) return
org.plan = 'free'
org.stripeSubscriptionId = null
org.subscriptionStatus = 'canceled'
org.billingCycle = null
org.currentPeriodEnd = null
org.cancelAtPeriodEnd = false
// On NE remet PAS `trialEndsAt` à null : ça reste utile pour
// empêcher un user de relancer un trial après avoir cancel.
await org.save()
logger.info({ orgId: org.id }, 'Org redescendue en plan free (subscription deleted)')
}
/**
* Handler `invoice.payment_failed`. Marque l'org en past_due (l'UI
* affichera la bannière "votre paiement a échoué").
*/
export async function handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
if (!customerId) return
const org = await Organization.findBy('stripeCustomerId', customerId)
if (!org) return
org.subscriptionStatus = 'past_due'
await org.save()
logger.warn(
{ orgId: org.id, invoiceId: invoice.id },
'Paiement échoué — org marquée past_due'
)
}
/**
* Enqueueur de l'email recap d'essai. Indirection module-level qui
* permet aux tests d'injecter un spy via `__setTrialRecapEnqueueForTests`
* sans avoir besoin d'un Redis mock. En prod ça résout vers
* `#jobs/send_trial_recap_email_job#enqueueTrialRecapEmail` via dynamic
* import (évite le cycle services jobs au load).
*/
let _enqueueTrialRecap: (orgId: string, subscriptionId: string) => Promise<void> =
async (orgId, subscriptionId) => {
const { enqueueTrialRecapEmail } = await import('#jobs/send_trial_recap_email_job')
return enqueueTrialRecapEmail(orgId, subscriptionId)
}
/**
* **Test-only.** Override l'enqueueur du recap. Permet aux tests de
* vérifier que `handleTrialWillEnd` enqueue pour la bonne org sans
* démarrer un Worker BullMQ.
*/
export function __setTrialRecapEnqueueForTests(
fn: (orgId: string, subscriptionId: string) => Promise<void>
): void {
_enqueueTrialRecap = fn
}
/**
* Handler `customer.subscription.trial_will_end`. Émis par Stripe 3
* jours avant `trial_end` (non-configurable). On enqueue le job
* d'envoi de l'email recap.
*
* Le job est idempotent via `jobId` BullMQ déterministe basé sur
* subscriptionId un re-deliver Stripe ne crée pas un 2e envoi.
*/
export async function handleTrialWillEnd(subscription: Stripe.Subscription): Promise<void> {
const customerId =
typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer.id
const org = await Organization.findBy('stripeCustomerId', customerId)
if (!org) {
logger.warn({ subscriptionId: subscription.id }, 'trial_will_end : org introuvable')
return
}
await _enqueueTrialRecap(org.id, subscription.id)
logger.info(
{ orgId: org.id, subscriptionId: subscription.id },
'trial_will_end : job recap enqueué'
)
}

View File

@ -18,7 +18,14 @@ export const redisConnection: RedisOptions = {
* Liste des queues. La concurrence est appliquée côté worker. * Liste des queues. La concurrence est appliquée côté worker.
* Ajouter une queue ici ajouter un Worker correspondant dans #start/queue.ts. * Ajouter une queue ici ajouter un Worker correspondant dans #start/queue.ts.
*/ */
export const queueNames = ['ocr', 'relances', 'checkins', 'kpis', 'payment-thanks'] as const export const queueNames = [
'ocr',
'relances',
'checkins',
'kpis',
'payment-thanks',
'trial-recap',
] as const
export type QueueName = (typeof queueNames)[number] export type QueueName = (typeof queueNames)[number]
export const queueConcurrency: Record<QueueName, number> = { export const queueConcurrency: Record<QueueName, number> = {
@ -27,4 +34,5 @@ export const queueConcurrency: Record<QueueName, number> = {
checkins: 5, checkins: 5,
kpis: 1, kpis: 1,
'payment-thanks': 5, 'payment-thanks': 5,
'trial-recap': 3,
} }

View File

@ -0,0 +1,38 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
/**
* Essai 14 jours Pro avec CB à l'inscription colonne `trial_ends_at`.
*
* Posée par le webhook Stripe `checkout.session.completed` quand la session
* est créée avec `subscription_data.trial_period_days = 14`. Permet à l'UI
* d'afficher un bandeau "Essai Pro — X jours restants" sans rappeler Stripe
* à chaque page render.
*
* Sémantique :
* - `subscription_status = 'trialing'` ET `trial_ends_at > now()`
* usage Pro illimité, banner countdown affiché.
* - À J+14, Stripe passe la subscription en `active` ou `past_due` et
* émet le webhook correspondant on garde `trial_ends_at` posé
* pour l'historique (utile au futur Marketing : "vous avez commencé
* le X, payé à partir du Y").
* - Les orgs historiques (grace 3 mois posée par migration 1778157876956)
* n'utilisent jamais cette colonne `trial_ends_at` reste null pour
* elles. Pas de backfill, pas d'effet sur leur quota.
*
* cf. docs/tech/stripe-trial-with-card.md
*/
export default class extends BaseSchema {
protected tableName = 'organizations'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.timestamp('trial_ends_at', { useTz: true }).nullable()
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('trial_ends_at')
})
}
}

View File

@ -160,11 +160,21 @@ export class CheckinTaskSchema extends BaseModel {
} }
export class ClientSchema extends BaseModel { export class ClientSchema extends BaseModel {
static $columns = ['address', 'contactFirstName', 'contactLastName', 'createdAt', 'email', 'id', 'name', 'notes', 'organizationId', 'phone', 'siret', 'updatedAt'] as const static $columns = ['address', 'addressCity', 'addressCountry', 'addressLine1', 'addressLine2', 'addressZip', 'contactFirstName', 'contactLastName', 'createdAt', 'email', 'id', 'name', 'notes', 'organizationId', 'phone', 'siren', 'siret', 'tvaIntra', 'updatedAt'] as const
$columns = ClientSchema.$columns $columns = ClientSchema.$columns
@column() @column()
declare address: string | null declare address: string | null
@column() @column()
declare addressCity: string | null
@column()
declare addressCountry: string | null
@column()
declare addressLine1: string | null
@column()
declare addressLine2: string | null
@column()
declare addressZip: string | null
@column()
declare contactFirstName: string | null declare contactFirstName: string | null
@column() @column()
declare contactLastName: string | null declare contactLastName: string | null
@ -183,7 +193,11 @@ export class ClientSchema extends BaseModel {
@column() @column()
declare phone: string | null declare phone: string | null
@column() @column()
declare siren: string | null
@column()
declare siret: string | null declare siret: string | null
@column()
declare tvaIntra: string | null
@column.dateTime({ autoCreate: true, autoUpdate: true }) @column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null declare updatedAt: DateTime | null
} }
@ -262,21 +276,35 @@ export class ImportDraftSchema extends BaseModel {
} }
export class InvoiceSchema extends BaseModel { export class InvoiceSchema extends BaseModel {
static $columns = ['amountTtcCents', 'clientId', 'createdAt', 'dueDate', 'id', 'issueDate', 'notes', 'numero', 'organizationId', 'paidAt', 'pdfStorageKey', 'planId', 'rubisEarned', 'status', 'updatedAt'] as const static $columns = ['amountHtCents', 'amountTtcCents', 'amountTvaCents', 'clientId', 'clientSnapshot', 'createdAt', 'dueDate', 'footerNotes', 'id', 'isNative', 'issueDate', 'issuerSnapshot', 'lines', 'notes', 'numero', 'organizationId', 'paidAt', 'paymentTermsDays', 'pdfGeneratedAt', 'pdfStorageKey', 'planId', 'rubisEarned', 'sequenceNumber', 'status', 'themeAccentColor', 'themeSlug', 'tvaBreakdown', 'updatedAt'] as const
$columns = InvoiceSchema.$columns $columns = InvoiceSchema.$columns
@column() @column()
declare amountHtCents: number | null
@column()
declare amountTtcCents: number declare amountTtcCents: number
@column() @column()
declare amountTvaCents: number | null
@column()
declare clientId: string declare clientId: string
@column()
declare clientSnapshot: any | null
@column.dateTime({ autoCreate: true }) @column.dateTime({ autoCreate: true })
declare createdAt: DateTime declare createdAt: DateTime
@column.dateTime() @column.dateTime()
declare dueDate: DateTime declare dueDate: DateTime
@column()
declare footerNotes: string | null
@column({ isPrimary: true }) @column({ isPrimary: true })
declare id: string declare id: string
@column()
declare isNative: boolean
@column.dateTime() @column.dateTime()
declare issueDate: DateTime declare issueDate: DateTime
@column() @column()
declare issuerSnapshot: any | null
@column()
declare lines: any | null
@column()
declare notes: string | null declare notes: string | null
@column() @column()
declare numero: string declare numero: string
@ -285,19 +313,31 @@ export class InvoiceSchema extends BaseModel {
@column.dateTime() @column.dateTime()
declare paidAt: DateTime | null declare paidAt: DateTime | null
@column() @column()
declare paymentTermsDays: number | null
@column.dateTime()
declare pdfGeneratedAt: DateTime | null
@column()
declare pdfStorageKey: string | null declare pdfStorageKey: string | null
@column() @column()
declare planId: string | null declare planId: string | null
@column() @column()
declare rubisEarned: number declare rubisEarned: number
@column() @column()
declare sequenceNumber: number | null
@column()
declare status: 'pending' | 'awaiting_user_confirmation' | 'in_relance' | 'paid' | 'litigation' | 'cancelled' declare status: 'pending' | 'awaiting_user_confirmation' | 'in_relance' | 'paid' | 'litigation' | 'cancelled'
@column()
declare themeAccentColor: string | null
@column()
declare themeSlug: string | null
@column()
declare tvaBreakdown: any | null
@column.dateTime({ autoCreate: true, autoUpdate: true }) @column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null declare updatedAt: DateTime | null
} }
export class OrganizationSchema extends BaseModel { export class OrganizationSchema extends BaseModel {
static $columns = ['billingCycle', 'brandSettings', 'cancelAtPeriodEnd', 'createdAt', 'currentPeriodEnd', 'demoMode', 'demoSpeedFactor', 'gracePeriodEndsAt', 'id', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'plan', 'powensTokenEncrypted', 'powensUserId', 'reconciliationMode', 'rubisCount', 'siret', 'stripeCustomerId', 'stripeSubscriptionId', 'subscriptionStatus', 'updatedAt', 'virtualNow'] as const static $columns = ['billingCycle', 'brandSettings', 'cancelAtPeriodEnd', 'createdAt', 'currentPeriodEnd', 'demoMode', 'demoSpeedFactor', 'gracePeriodEndsAt', 'id', 'invoiceSettings', 'monthlyVolumeBucket', 'name', 'onboardingCompletedAt', 'plan', 'powensTokenEncrypted', 'powensUserId', 'reconciliationMode', 'rubisCount', 'siret', 'stripeCustomerId', 'stripeSubscriptionId', 'subscriptionStatus', 'trialEndsAt', 'updatedAt', 'virtualNow'] as const
$columns = OrganizationSchema.$columns $columns = OrganizationSchema.$columns
@column() @column()
declare billingCycle: string | null declare billingCycle: string | null
@ -318,6 +358,8 @@ export class OrganizationSchema extends BaseModel {
@column({ isPrimary: true }) @column({ isPrimary: true })
declare id: string declare id: string
@column() @column()
declare invoiceSettings: any | null
@column()
declare monthlyVolumeBucket: string | null declare monthlyVolumeBucket: string | null
@column() @column()
declare name: string declare name: string
@ -341,6 +383,8 @@ export class OrganizationSchema extends BaseModel {
declare stripeSubscriptionId: string | null declare stripeSubscriptionId: string | null
@column() @column()
declare subscriptionStatus: string | null declare subscriptionStatus: string | null
@column.dateTime()
declare trialEndsAt: DateTime | null
@column.dateTime({ autoCreate: true, autoUpdate: true }) @column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null declare updatedAt: DateTime | null
@column.dateTime() @column.dateTime()

View File

@ -20,6 +20,10 @@ import { registerWorker, shutdownQueue } from '#services/queue'
import { sendRelanceJob } from '#jobs/send_relance_job' import { sendRelanceJob } from '#jobs/send_relance_job'
import { sendCheckinJob } from '#jobs/send_checkin_job' import { sendCheckinJob } from '#jobs/send_checkin_job'
import { sendPaymentThanksJob } from '#jobs/send_payment_thanks_job' import { sendPaymentThanksJob } from '#jobs/send_payment_thanks_job'
import {
sendTrialRecapEmailJob,
type TrialRecapJobData,
} from '#jobs/send_trial_recap_email_job'
if (app.getEnvironment() === 'web') { if (app.getEnvironment() === 'web') {
try { try {
@ -35,7 +39,11 @@ if (app.getEnvironment() === 'web') {
await sendPaymentThanksJob(job.data) await sendPaymentThanksJob(job.data)
}) })
logger.info('BullMQ workers ready (relances, checkins, payment-thanks)') registerWorker<TrialRecapJobData>('trial-recap', async (job) => {
await sendTrialRecapEmailJob(job.data)
})
logger.info('BullMQ workers ready (relances, checkins, payment-thanks, trial-recap)')
app.terminating(async () => { app.terminating(async () => {
logger.info('shutting down BullMQ workers') logger.info('shutting down BullMQ workers')

View File

@ -352,6 +352,9 @@ router
router router
.get('subscription', [controllers.Billing, 'subscription']) .get('subscription', [controllers.Billing, 'subscription'])
.as('subscription') .as('subscription')
router
.post('start-trial', [controllers.Billing, 'startTrial'])
.as('start_trial')
router router
.post('checkout', [controllers.Billing, 'checkout']) .post('checkout', [controllers.Billing, 'checkout'])
.as('checkout') .as('checkout')

View File

@ -0,0 +1,112 @@
import type Stripe from 'stripe'
import { __setStripeForTests } from '#services/stripe'
/**
* Helper de test : injecte un client Stripe mocké au niveau du singleton
* `getStripe()`. Toutes les fonctions du service `stripe_billing` qui
* appellent Stripe taperont alors sur ce mock.
*
* Usage typique dans un test :
*
* const mock = installStripeMock({
* subscriptions: {
* retrieve: async () => fakeSubscription(...)
* },
* })
* await handleCheckoutCompleted(fakeSession({...}))
* uninstallStripeMock()
*
* Pour les `group.each.teardown`, appeler `uninstallStripeMock()`.
*
* NB : le mock n'a pas besoin d'implémenter tout Stripe, seulement les
* méthodes utilisées par le code testé. On le typecast en `Stripe`
* pragmatiquement.
*/
export type StripeMockSpec = {
subscriptions?: Partial<Stripe['subscriptions']>
customers?: Partial<Stripe['customers']>
checkout?: { sessions?: Partial<Stripe['checkout']['sessions']> }
billingPortal?: { sessions?: Partial<Stripe['billingPortal']['sessions']> }
prices?: Partial<Stripe['prices']>
webhooks?: Partial<Stripe['webhooks']>
}
export function installStripeMock(spec: StripeMockSpec): Stripe {
const mock = spec as unknown as Stripe
__setStripeForTests(mock)
return mock
}
export function uninstallStripeMock(): void {
__setStripeForTests(null)
}
// ---------------------------------------------------------------------------
// Factories d'objets Stripe — payloads partiels typés
// ---------------------------------------------------------------------------
/**
* Crée un objet `Stripe.Subscription` minimal pour les tests. Seuls
* `items.data[0].price.lookup_key` + `status` + dates sont consommés par
* les handlers ; on remplit le reste avec des stubs no-op pour satisfaire
* le type runtime.
*/
export function fakeSubscription(input: {
id?: string
customerId?: string
status?: Stripe.Subscription.Status
lookupKey?: string | null
currentPeriodEnd?: number | null
trialEnd?: number | null
cancelAtPeriodEnd?: boolean
cancelAt?: number | null
organizationId?: string | null
}): Stripe.Subscription {
const item = {
id: 'si_test',
price: {
id: 'price_test',
object: 'price',
lookup_key: input.lookupKey ?? 'rubis_pro_monthly',
} as unknown as Stripe.Price,
current_period_end: input.currentPeriodEnd ?? null,
} as unknown as Stripe.SubscriptionItem
return {
id: input.id ?? 'sub_test',
object: 'subscription',
customer: input.customerId ?? 'cus_test',
status: input.status ?? 'active',
items: { data: [item] },
cancel_at_period_end: input.cancelAtPeriodEnd ?? false,
cancel_at: input.cancelAt ?? null,
trial_end: input.trialEnd ?? null,
metadata: input.organizationId
? { organization_id: input.organizationId, plan: 'pro' }
: {},
} as unknown as Stripe.Subscription
}
export function fakeCheckoutSession(input: {
id?: string
organizationId?: string | null
subscriptionId?: string | null
}): Stripe.Checkout.Session {
return {
id: input.id ?? 'cs_test',
object: 'checkout.session',
subscription: input.subscriptionId ?? null,
metadata: input.organizationId ? { organization_id: input.organizationId } : {},
} as unknown as Stripe.Checkout.Session
}
export function fakeInvoice(input: {
customerId?: string | null
status?: string
}): Stripe.Invoice {
return {
id: 'in_test',
object: 'invoice',
customer: input.customerId ?? 'cus_test',
status: input.status ?? 'open',
} as unknown as Stripe.Invoice
}

View File

@ -272,3 +272,148 @@ test.group('billing — getOrgSubscriptionState', (group) => {
assert.equal(state.activeInvoicesCount, ACTIVE_STATUSES.length) assert.equal(state.activeInvoicesCount, ACTIVE_STATUSES.length)
}) })
}) })
// ---------------------------------------------------------------------------
// Trial 14 j — bypass quota Free, inTrial state, trial_end persisté
// ---------------------------------------------------------------------------
test.group('billing — essai 14 j (trial bypass + état)', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('Free + status=trialing + trial_ends_at futur → canCreateInvoices unlimited', async ({
assert,
}) => {
// Org en essai Pro 14 j : pas encore Pro côté `plan` (Stripe ne flip
// qu'au prélèvement à J+14), mais `subscriptionStatus=trialing`
// signale l'essai actif. On veut quand même autoriser un usage
// illimité — l'user a donné sa CB, il a accès Pro complet.
const { org } = await createTestUser()
org.plan = 'free'
org.gracePeriodEndsAt = null
org.subscriptionStatus = 'trialing'
org.trialEndsAt = DateTime.utc().plus({ days: 10 })
await org.save()
const client = await makeClientFor(org)
for (let i = 0; i < 50; i++) await makeInvoice(org, client, 'pending')
const result = await canCreateInvoices(org.id, 5)
assert.isTrue(result.allowed)
})
test('Free + status=trialing mais trial_ends_at passé → quota Free s\'applique', async ({
assert,
}) => {
// Garde-fou : si le webhook trial→active a été manqué et que
// `trial_ends_at` est dans le passé, on retombe sur le cap Free
// pour ne pas laisser un illimité à vie.
const { org } = await createTestUser()
org.plan = 'free'
org.gracePeriodEndsAt = null
org.subscriptionStatus = 'trialing'
org.trialEndsAt = DateTime.utc().minus({ days: 1 })
await org.save()
const client = await makeClientFor(org)
for (let i = 0; i < 2; i++) await makeInvoice(org, client, 'pending')
const result = await canCreateInvoices(org.id, 1)
assert.isFalse(result.allowed)
if (!result.allowed) {
assert.equal(result.reason, 'free_limit_active_invoices')
assert.equal(result.limit, 2)
}
})
test('Free + trial_ends_at futur mais status=active → pas en trial → quota s\'applique', async ({
assert,
}) => {
// Vérifie l'AND logique : il faut les DEUX conditions (status trialing
// ET trial_ends_at futur). Si l'un manque, pas de bypass.
const { org } = await createTestUser()
org.plan = 'free'
org.gracePeriodEndsAt = null
org.subscriptionStatus = 'active' // pas trialing !
org.trialEndsAt = DateTime.utc().plus({ days: 10 })
await org.save()
const client = await makeClientFor(org)
for (let i = 0; i < 2; i++) await makeInvoice(org, client, 'pending')
const result = await canCreateInvoices(org.id, 1)
assert.isFalse(result.allowed)
})
test('Trial déjà consommé + redescente en Free → trial_ends_at conservé pour historique', async ({
assert,
}) => {
// Sémantique : `trial_ends_at` est posé une fois, jamais effacé. Sert
// à empêcher de relancer un trial après annulation.
const { org } = await createTestUser()
org.plan = 'free'
org.subscriptionStatus = 'canceled'
org.trialEndsAt = DateTime.utc().minus({ days: 5 })
await org.save()
// Le quota Free s'applique normalement (pas de bypass car status ≠
// trialing).
const client = await makeClientFor(org)
for (let i = 0; i < 2; i++) await makeInvoice(org, client, 'pending')
const result = await canCreateInvoices(org.id, 1)
assert.isFalse(result.allowed)
})
test('getOrgSubscriptionState expose inTrial + trialEndsAt ISO', async ({ assert }) => {
const { org } = await createTestUser()
const trialEnd = DateTime.utc().plus({ days: 12 }).startOf('second')
org.plan = 'free'
org.gracePeriodEndsAt = null
org.subscriptionStatus = 'trialing'
org.trialEndsAt = trialEnd
await org.save()
const state = await getOrgSubscriptionState(org.id)
assert.isTrue(state.inTrial)
assert.isNotNull(state.trialEndsAt)
// Comparaison ISO côté second — Postgres tronque les microsecondes
// côté écriture.
const expected = trialEnd.toISO()
assert.isNotNull(expected)
assert.equal(
DateTime.fromISO(state.trialEndsAt!).toUnixInteger(),
trialEnd.toUnixInteger()
)
})
test('getOrgSubscriptionState : inTrial=false si pas en essai (sub status null)', async ({
assert,
}) => {
const { org } = await createTestUser()
org.plan = 'free'
org.subscriptionStatus = null
org.trialEndsAt = null
await org.save()
const state = await getOrgSubscriptionState(org.id)
assert.isFalse(state.inTrial)
assert.isNull(state.trialEndsAt)
})
test('getOrgSubscriptionState : inTrial=false si trial_ends_at passé', async ({ assert }) => {
const { org } = await createTestUser()
org.plan = 'free'
org.subscriptionStatus = 'trialing'
org.trialEndsAt = DateTime.utc().minus({ minutes: 1 })
await org.save()
const state = await getOrgSubscriptionState(org.id)
assert.isFalse(state.inTrial)
})
test('Grace period prime sur trial pour le bypass canCreateInvoices', async ({ assert }) => {
// Cas : une org historique avec grace 3 mois entame *aussi* un trial.
// Les deux conditions autorisent le bypass — on vérifie juste que
// l'usage illimité est bien accordé sans regarder lequel a primé.
const { org } = await createTestUser()
org.plan = 'free'
org.gracePeriodEndsAt = DateTime.utc().plus({ months: 2 })
org.subscriptionStatus = 'trialing'
org.trialEndsAt = DateTime.utc().plus({ days: 10 })
await org.save()
const client = await makeClientFor(org)
for (let i = 0; i < 20; i++) await makeInvoice(org, client, 'pending')
const result = await canCreateInvoices(org.id, 1)
assert.isTrue(result.allowed)
})
})

View File

@ -0,0 +1,481 @@
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
import { DateTime } from 'luxon'
import {
applySubscriptionToOrg,
createTrialCheckoutSession,
handleCheckoutCompleted,
handlePaymentFailed,
handleSubscriptionDeleted,
handleSubscriptionUpdate,
handleTrialWillEnd,
TrialAlreadyConsumedError,
__setTrialRecapEnqueueForTests,
} from '#services/stripe_billing'
import { dispatchWebhookEvent } from '#controllers/billing_controller'
import Organization from '#models/organization'
import { createTestUser } from '../helpers/auth.js'
import {
fakeCheckoutSession,
fakeInvoice,
fakeSubscription,
installStripeMock,
uninstallStripeMock,
} from '../helpers/stripe_mock.js'
/**
* Tests des handlers webhook + helper trial du service `stripe_billing`.
*
* Stratégie de mocking : on injecte un client Stripe partiel via
* `installStripeMock()` (cf. helpers). Tous les appels SDK (retrieve,
* sessions.create, billingPortal.create) sont stubés.
*
* Toutes les assertions DB tournent dans une transaction `withGlobalTransaction`
* (auto-rollback per test) pour isoler les modifs.
*/
test.group('stripe_billing — applySubscriptionToOrg', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('persiste plan, cycle, status, trial_end depuis une subscription Stripe', async ({
assert,
}) => {
const { org } = await createTestUser()
const trialEndEpoch = Math.floor(Date.now() / 1000) + 14 * 24 * 3600
const periodEndEpoch = trialEndEpoch + 30 * 24 * 3600
const sub = fakeSubscription({
id: 'sub_test_apply',
customerId: 'cus_test_apply',
status: 'trialing',
lookupKey: 'rubis_pro_monthly',
currentPeriodEnd: periodEndEpoch,
trialEnd: trialEndEpoch,
})
await applySubscriptionToOrg(org.id, sub)
await org.refresh()
assert.equal(org.plan, 'pro')
assert.equal(org.subscriptionStatus, 'trialing')
assert.equal(org.billingCycle, 'monthly')
assert.equal(org.stripeSubscriptionId, 'sub_test_apply')
assert.isNotNull(org.trialEndsAt)
assert.equal(org.trialEndsAt?.toUnixInteger(), trialEndEpoch)
assert.equal(org.currentPeriodEnd?.toUnixInteger(), periodEndEpoch)
assert.isFalse(org.cancelAtPeriodEnd)
})
test('détecte cancel_at_period_end (true) ET cancel_at (timestamp)', async ({ assert }) => {
const { org: orgA } = await createTestUser()
const { org: orgB } = await createTestUser()
// Cas A : cancel_at_period_end = true (API directe)
await applySubscriptionToOrg(
orgA.id,
fakeSubscription({
id: 'sub_cancel_a',
lookupKey: 'rubis_pro_monthly',
cancelAtPeriodEnd: true,
})
)
await orgA.refresh()
assert.isTrue(orgA.cancelAtPeriodEnd)
// Cas B : cancel_at = timestamp (Customer Portal)
await applySubscriptionToOrg(
orgB.id,
fakeSubscription({
id: 'sub_cancel_b',
lookupKey: 'rubis_pro_monthly',
cancelAt: Math.floor(Date.now() / 1000) + 86_400,
})
)
await orgB.refresh()
assert.isTrue(orgB.cancelAtPeriodEnd)
})
test('idempotent : 2 appels successifs → même état final', async ({ assert }) => {
const { org } = await createTestUser()
const sub = fakeSubscription({
id: 'sub_test_idem',
status: 'active',
lookupKey: 'rubis_business_yearly',
})
await applySubscriptionToOrg(org.id, sub)
const firstState = (await Organization.findOrFail(org.id)).$attributes
await applySubscriptionToOrg(org.id, sub)
const secondState = (await Organization.findOrFail(org.id)).$attributes
assert.equal(firstState['plan'], 'business')
assert.equal(firstState['billingCycle'], 'yearly')
assert.equal(firstState['subscriptionStatus'], secondState['subscriptionStatus'])
assert.equal(firstState['stripeSubscriptionId'], secondState['stripeSubscriptionId'])
})
test('org introuvable → no-op silencieux (pas de throw)', async ({ assert }) => {
// L'UUID non existant ne doit pas casser le webhook (Stripe retry sinon).
await assert.doesNotReject(() =>
applySubscriptionToOrg(
'00000000-0000-0000-0000-000000000000',
fakeSubscription({ lookupKey: 'rubis_pro_monthly' })
)
)
})
test('lookup_key inconnu → plan retombe sur free', async ({ assert }) => {
const { org } = await createTestUser()
await applySubscriptionToOrg(
org.id,
fakeSubscription({ lookupKey: 'unknown_key_xyz' })
)
await org.refresh()
assert.equal(org.plan, 'free')
assert.isNull(org.billingCycle)
})
})
test.group('stripe_billing — handleSubscriptionUpdate (lookup org)', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('lookup via metadata.organization_id', async ({ assert }) => {
const { org } = await createTestUser()
await handleSubscriptionUpdate(
fakeSubscription({
lookupKey: 'rubis_pro_monthly',
organizationId: org.id,
status: 'active',
})
)
await org.refresh()
assert.equal(org.plan, 'pro')
assert.equal(org.subscriptionStatus, 'active')
})
test('fallback : lookup via stripeCustomerId si metadata absente', async ({ assert }) => {
const { org } = await createTestUser()
org.stripeCustomerId = 'cus_lookup_fallback'
await org.save()
await handleSubscriptionUpdate(
fakeSubscription({
customerId: 'cus_lookup_fallback',
lookupKey: 'rubis_pro_monthly',
status: 'active',
// pas de metadata.organization_id
})
)
await org.refresh()
assert.equal(org.plan, 'pro')
})
test('aucun org trouvé → no-op silencieux', async ({ assert }) => {
await assert.doesNotReject(() =>
handleSubscriptionUpdate(
fakeSubscription({
customerId: 'cus_doesnt_exist_anywhere',
lookupKey: 'rubis_pro_monthly',
})
)
)
})
})
test.group('stripe_billing — handleSubscriptionDeleted', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('Pro avec sub → org passe en free + clear stripe fields', async ({ assert }) => {
const { org } = await createTestUser()
org.plan = 'pro'
org.subscriptionStatus = 'active'
org.billingCycle = 'monthly'
org.stripeCustomerId = 'cus_to_delete'
org.stripeSubscriptionId = 'sub_to_delete'
org.currentPeriodEnd = DateTime.utc().plus({ days: 10 })
org.cancelAtPeriodEnd = true
await org.save()
await handleSubscriptionDeleted(
fakeSubscription({ customerId: 'cus_to_delete', id: 'sub_to_delete' })
)
await org.refresh()
assert.equal(org.plan, 'free')
assert.equal(org.subscriptionStatus, 'canceled')
assert.isNull(org.stripeSubscriptionId)
assert.isNull(org.billingCycle)
assert.isNull(org.currentPeriodEnd)
assert.isFalse(org.cancelAtPeriodEnd)
})
test('trial_ends_at conservé après cancellation (garde-fou anti-relance trial)', async ({
assert,
}) => {
const { org } = await createTestUser()
const oldTrialEnd = DateTime.utc().minus({ days: 30 })
org.plan = 'pro'
org.stripeCustomerId = 'cus_keep_trial'
org.trialEndsAt = oldTrialEnd
await org.save()
await handleSubscriptionDeleted(
fakeSubscription({ customerId: 'cus_keep_trial' })
)
await org.refresh()
assert.isNotNull(org.trialEndsAt)
assert.equal(org.trialEndsAt?.toUnixInteger(), oldTrialEnd.toUnixInteger())
})
test('customer inconnu → no-op', async ({ assert }) => {
await assert.doesNotReject(() =>
handleSubscriptionDeleted(fakeSubscription({ customerId: 'cus_ghost' }))
)
})
})
test.group('stripe_billing — handlePaymentFailed', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('invoice.payment_failed → org marquée past_due', async ({ assert }) => {
const { org } = await createTestUser()
org.plan = 'pro'
org.subscriptionStatus = 'active'
org.stripeCustomerId = 'cus_payment_failed'
await org.save()
await handlePaymentFailed(fakeInvoice({ customerId: 'cus_payment_failed' }))
await org.refresh()
assert.equal(org.subscriptionStatus, 'past_due')
// Plan reste pro pendant le grace period Stripe (smart retries).
assert.equal(org.plan, 'pro')
})
test('customer null → no-op (jamais d\'invoice détachée)', async ({ assert }) => {
await assert.doesNotReject(() => handlePaymentFailed(fakeInvoice({ customerId: null })))
})
test('customer inconnu → no-op', async ({ assert }) => {
await assert.doesNotReject(() =>
handlePaymentFailed(fakeInvoice({ customerId: 'cus_ghost' }))
)
})
})
test.group('stripe_billing — handleTrialWillEnd (enqueue recap)', (group) => {
let enqueueCalls: Array<{ orgId: string; subscriptionId: string }> = []
group.each.setup(() => testUtils.db().withGlobalTransaction())
group.each.setup(() => {
enqueueCalls = []
__setTrialRecapEnqueueForTests(async (orgId, subscriptionId) => {
enqueueCalls.push({ orgId, subscriptionId })
})
return () => {
// Reset entre les tests pour ne pas leak entre suites.
__setTrialRecapEnqueueForTests(async () => {})
}
})
test('enqueue recap pour l\'org matchée via customerId', async ({ assert }) => {
const { org } = await createTestUser()
org.stripeCustomerId = 'cus_trial_will_end'
await org.save()
await handleTrialWillEnd(
fakeSubscription({
id: 'sub_trial_99',
customerId: 'cus_trial_will_end',
status: 'trialing',
})
)
assert.lengthOf(enqueueCalls, 1)
assert.equal(enqueueCalls[0]?.orgId, org.id)
assert.equal(enqueueCalls[0]?.subscriptionId, 'sub_trial_99')
})
test('customer inconnu → pas d\'enqueue', async ({ assert }) => {
await handleTrialWillEnd(
fakeSubscription({ customerId: 'cus_ghost', status: 'trialing' })
)
assert.lengthOf(enqueueCalls, 0)
})
})
test.group('stripe_billing — handleCheckoutCompleted (avec Stripe mock)', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
group.each.teardown(() => uninstallStripeMock())
test('retrieve subscription Stripe puis apply → org Pro trialing', async ({ assert }) => {
const { org } = await createTestUser()
const trialEndEpoch = Math.floor(Date.now() / 1000) + 14 * 24 * 3600
installStripeMock({
subscriptions: {
retrieve: (async () =>
fakeSubscription({
id: 'sub_completed',
status: 'trialing',
lookupKey: 'rubis_pro_monthly',
trialEnd: trialEndEpoch,
organizationId: org.id,
})) as unknown as import('stripe').default['subscriptions']['retrieve'],
},
})
await handleCheckoutCompleted(
fakeCheckoutSession({
id: 'cs_done',
organizationId: org.id,
subscriptionId: 'sub_completed',
})
)
await org.refresh()
assert.equal(org.plan, 'pro')
assert.equal(org.subscriptionStatus, 'trialing')
assert.equal(org.trialEndsAt?.toUnixInteger(), trialEndEpoch)
})
test('session sans subscription → no-op', async ({ assert }) => {
const { org } = await createTestUser()
await handleCheckoutCompleted(
fakeCheckoutSession({ organizationId: org.id, subscriptionId: null })
)
await org.refresh()
assert.equal(org.plan, 'free') // pas touché
})
test('session sans organization_id metadata → no-op', async ({ assert }) => {
await handleCheckoutCompleted(
fakeCheckoutSession({
organizationId: null,
subscriptionId: 'sub_should_be_ignored',
})
)
// Pas d'assertion DB nécessaire — le no-op réussit silencieusement.
assert.isTrue(true)
})
})
test.group('stripe_billing — createTrialCheckoutSession (idempotence)', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
group.each.teardown(() => uninstallStripeMock())
test('throw TrialAlreadyConsumedError si trialEndsAt déjà posé', async ({ assert }) => {
const { org } = await createTestUser()
org.trialEndsAt = DateTime.utc().minus({ days: 30 })
org.stripeCustomerId = 'cus_test_idem'
await org.save()
await assert.rejects(
() =>
createTrialCheckoutSession({
org,
customerId: 'cus_test_idem',
plan: 'pro',
cycle: 'monthly',
}),
TrialAlreadyConsumedError
)
})
test('throw TrialAlreadyConsumedError si stripeSubscriptionId déjà posé', async ({
assert,
}) => {
const { org } = await createTestUser()
org.stripeSubscriptionId = 'sub_existing'
org.stripeCustomerId = 'cus_test_idem2'
await org.save()
await assert.rejects(
() =>
createTrialCheckoutSession({
org,
customerId: 'cus_test_idem2',
plan: 'pro',
cycle: 'monthly',
}),
TrialAlreadyConsumedError
)
})
test('happy path : crée la session Checkout avec trial_period_days=14', async ({
assert,
}) => {
const { org } = await createTestUser()
org.stripeCustomerId = 'cus_happy'
await org.save()
const sessionCalls: Array<Record<string, unknown>> = []
installStripeMock({
prices: {
list: (async () => ({
data: [{ id: 'price_pro_monthly_test' }],
})) as unknown as import('stripe').default['prices']['list'],
},
checkout: {
sessions: {
create: (async (params: Record<string, unknown>) => {
sessionCalls.push(params)
return { id: 'cs_new', url: 'https://checkout.stripe.test/cs_new' }
}) as unknown as import('stripe').default['checkout']['sessions']['create'],
},
},
})
const result = await createTrialCheckoutSession({
org,
customerId: 'cus_happy',
plan: 'pro',
cycle: 'monthly',
})
assert.equal(result.url, 'https://checkout.stripe.test/cs_new')
assert.lengthOf(sessionCalls, 1)
const params = sessionCalls[0] as {
subscription_data?: { trial_period_days?: number; metadata?: Record<string, string> }
metadata?: Record<string, string>
}
assert.equal(params.subscription_data?.trial_period_days, 14)
assert.equal(params.subscription_data?.metadata?.['organization_id'], org.id)
assert.equal(params.metadata?.['is_trial'], 'true')
})
})
test.group('stripe_billing — dispatchWebhookEvent (router)', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('event inconnu → no-op silencieux (pas de throw)', async ({ assert }) => {
const evt = {
id: 'evt_unknown',
type: 'customer.tax_id.created',
data: { object: {} },
} as unknown as import('stripe').default.Event
await assert.doesNotReject(() => dispatchWebhookEvent(evt))
})
test('customer.subscription.deleted route bien vers handleSubscriptionDeleted', async ({
assert,
}) => {
const { org } = await createTestUser()
org.plan = 'pro'
org.stripeCustomerId = 'cus_routed'
org.stripeSubscriptionId = 'sub_routed'
await org.save()
const evt = {
id: 'evt_deleted',
type: 'customer.subscription.deleted',
data: {
object: fakeSubscription({ customerId: 'cus_routed', id: 'sub_routed' }),
},
} as unknown as import('stripe').default.Event
await dispatchWebhookEvent(evt)
await org.refresh()
assert.equal(org.plan, 'free')
assert.equal(org.subscriptionStatus, 'canceled')
})
})

View File

@ -0,0 +1,181 @@
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
import { DateTime } from 'luxon'
import {
computeTrialUsageStats,
formatRubisToHoursFr,
} from '#jobs/send_trial_recap_email_job'
import Client from '#models/client'
import Invoice from '#models/invoice'
import Organization from '#models/organization'
import { createTestUser } from '../helpers/auth.js'
/**
* Tests des helpers du job trial-recap : agrégation stats + format
* heures. Le worker `sendTrialRecapEmailJob` lui-même n'est pas testé
* direct ici (il tape réseau mail + Stripe) l'intégration est validée
* en bout de chaîne via Stripe test mode lors du déploiement.
*/
async function makeClientFor(org: Organization): Promise<Client> {
return Client.create({
organizationId: org.id,
name: `Client ${Math.random().toString(36).slice(2, 8)}`,
email: `client-${Math.random().toString(36).slice(2, 8)}@spec.test`,
contactFirstName: null,
contactLastName: null,
phone: null,
address: null,
siret: null,
notes: null,
})
}
async function makeInvoice(
org: Organization,
client: Client,
opts: {
status: Invoice['status']
createdAt?: DateTime
paidAt?: DateTime | null
amountTtcCents?: number
rubisEarned?: number
}
): Promise<Invoice> {
const issue = DateTime.utc().minus({ days: 30 })
return Invoice.create({
organizationId: org.id,
clientId: client.id,
planId: null,
numero: `F-${Math.random().toString(36).slice(2, 10)}`,
amountTtcCents: opts.amountTtcCents ?? 100_00,
issueDate: issue,
dueDate: issue.plus({ days: 30 }),
paidAt: opts.paidAt ?? null,
status: opts.status,
pdfStorageKey: null,
rubisEarned: opts.rubisEarned ?? 0,
notes: null,
})
}
test.group('trial_recap_job — formatRubisToHoursFr', () => {
test('0 rubis = 0 min', ({ assert }) => {
assert.equal(formatRubisToHoursFr(0), '0 min')
})
test('1 rubis = 10 min', ({ assert }) => {
assert.equal(formatRubisToHoursFr(1), '10 min')
})
test('5 rubis = 50 min', ({ assert }) => {
assert.equal(formatRubisToHoursFr(5), '50 min')
})
test('6 rubis = 1 heure pile', ({ assert }) => {
assert.equal(formatRubisToHoursFr(6), '1 h')
})
test('149 rubis = 24 h 50', ({ assert }) => {
// 149 × 10 = 1490 min = 24 h + 50 min
assert.equal(formatRubisToHoursFr(149), '24 h 50')
})
test('60 rubis = 10 h pile', ({ assert }) => {
assert.equal(formatRubisToHoursFr(60), '10 h')
})
test('zero-pad minutes < 10 (8 rubis = 1 h 20)', ({ assert }) => {
assert.equal(formatRubisToHoursFr(8), '1 h 20')
})
})
test.group('trial_recap_job — computeTrialUsageStats', (group) => {
group.each.setup(() => testUtils.db().withGlobalTransaction())
test('agrège factures + paid + rubis depuis le signup', async ({ assert }) => {
const { org } = await createTestUser()
const client = await makeClientFor(org)
const signup = DateTime.utc().minus({ days: 12 })
// 3 factures importées depuis signup (1 paid + 2 pending)
await makeInvoice(org, client, {
status: 'paid',
paidAt: DateTime.utc().minus({ days: 5 }),
amountTtcCents: 250_00,
rubisEarned: 4,
})
await makeInvoice(org, client, { status: 'pending' })
await makeInvoice(org, client, { status: 'in_relance' })
const stats = await computeTrialUsageStats(org.id, signup)
// Une seule paid → 250 € + 4 rubis
assert.equal(stats.invoicesImported, 3)
assert.equal(stats.eurosCollectedCents, 250_00)
assert.equal(stats.rubisEarned, 4)
// remindersSent dépend de relance_tasks — 0 vu qu'on n'en crée pas
// dans ce test → check au prochain test.
})
test('factures avant `since` sont exclues du compteur', async ({ assert }) => {
const { org } = await createTestUser()
const client = await makeClientFor(org)
// Une facture vieille (2 mois avant), une récente (1 jour après signup).
const oldInvoice = await makeInvoice(org, client, {
status: 'paid',
paidAt: DateTime.utc().minus({ months: 2 }),
rubisEarned: 99,
})
oldInvoice.createdAt = DateTime.utc().minus({ months: 2 })
await oldInvoice.save()
await makeInvoice(org, client, {
status: 'pending',
})
const signup = DateTime.utc().minus({ days: 7 })
const stats = await computeTrialUsageStats(org.id, signup)
// Seule la facture créée dans la fenêtre devrait être comptée (1).
// L'ancienne paid (paid_at avant signup) est exclue de euros/rubis.
assert.equal(stats.invoicesImported, 1)
assert.equal(stats.eurosCollectedCents, 0)
assert.equal(stats.rubisEarned, 0)
})
test('multi-paid → somme correcte des euros et rubis', async ({ assert }) => {
const { org } = await createTestUser()
const client = await makeClientFor(org)
const signup = DateTime.utc().minus({ days: 10 })
await makeInvoice(org, client, {
status: 'paid',
paidAt: DateTime.utc().minus({ days: 8 }),
amountTtcCents: 100_00,
rubisEarned: 2,
})
await makeInvoice(org, client, {
status: 'paid',
paidAt: DateTime.utc().minus({ days: 3 }),
amountTtcCents: 350_50,
rubisEarned: 6,
})
const stats = await computeTrialUsageStats(org.id, signup)
assert.equal(stats.eurosCollectedCents, 450_50)
assert.equal(stats.rubisEarned, 8)
})
test('aucune activité → tous les compteurs à 0', async ({ assert }) => {
const { org } = await createTestUser()
const stats = await computeTrialUsageStats(org.id, DateTime.utc().minus({ days: 1 }))
assert.equal(stats.invoicesImported, 0)
assert.equal(stats.remindersSent, 0)
assert.equal(stats.eurosCollectedCents, 0)
assert.equal(stats.rubisEarned, 0)
})
})

View File

@ -57,7 +57,7 @@ export const copy = {
body_bold: "5 heures par semaine", body_bold: "5 heures par semaine",
body_c: ".", body_c: ".",
ctaPrimary: "Démarrer mon essai 14 jours →", ctaPrimary: "Démarrer mon essai 14 jours →",
ctaPrimaryHint: "Première relance envoyée en 5 minutes · 14 jours sans engagement", ctaPrimaryHint: "Première relance envoyée en 5 minutes · CB demandée, non prélevée avant J+14",
ctaSecondary: "Voir les tarifs", ctaSecondary: "Voir les tarifs",
feature1: "14 jours gratuits puis Free 2 factures", feature1: "14 jours gratuits puis Free 2 factures",
feature2: "Hébergement souverain", feature2: "Hébergement souverain",
@ -325,7 +325,7 @@ export const copy = {
title: "Récupérez vos premières heures dès aujourd'hui.", title: "Récupérez vos premières heures dès aujourd'hui.",
body: "14 jours gratuits, puis le plan Free continue avec 2 factures actives. Pas de carte demandée pour démarrer.", body: "14 jours gratuits, puis le plan Free continue avec 2 factures actives. Pas de carte demandée pour démarrer.",
cta: "Démarrer mon essai 14 jours →", cta: "Démarrer mon essai 14 jours →",
ctaHint: "Première relance envoyée en 5 minutes · 14 jours sans engagement", ctaHint: "Première relance envoyée en 5 minutes · CB demandée, non prélevée avant J+14",
hint: "Inscription en 30 secondes. Annulation 1-clic à tout moment.", hint: "Inscription en 30 secondes. Annulation 1-clic à tout moment.",
}, },
footnotes: { footnotes: {

View File

@ -1,13 +1,14 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { ArrowRight, Sparkles, Zap } from "lucide-react"; import { ArrowRight, Clock, Sparkles, Zap } from "lucide-react";
import { useSubscription } from "@/lib/billing"; import { useSubscription, useTrialDaysRemaining } from "@/lib/billing";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
/** /**
* Banner d'enforcement plan Free. * Banner d'enforcement plan Free.
* *
* - "Essai" : essai 14 j actif countdown rubis (toujours affiché)
* - Hidden : plan Pro/Business OU période de grâce active * - Hidden : plan Pro/Business OU période de grâce active
* - "Approche" : ratio 80 % du quota ton conseil * - "Approche" : ratio 80 % du quota ton conseil
* - "Atteinte" : ratio 100 % du quota ton blocant + CTA upgrade * - "Atteinte" : ratio 100 % du quota ton blocant + CTA upgrade
@ -17,7 +18,44 @@ import { formatDate } from "@/lib/format";
*/ */
export function PlanLimitBanner({ className }: { className?: string }) { export function PlanLimitBanner({ className }: { className?: string }) {
const { data: sub } = useSubscription(); const { data: sub } = useSubscription();
const trialDays = useTrialDaysRemaining();
if (!sub) return null; if (!sub) return null;
// Essai 14 j Pro actif → countdown discret rubis-glow, toujours visible
// pendant la fenêtre. Pas blocant — l'user a accès Pro complet.
if (sub.inTrial && trialDays !== null) {
const dayLabel = trialDays > 1 ? "jours" : "jour";
return (
<div
className={cn(
"rounded-card border border-rubis/25 bg-rubis-glow/40 px-4 py-3",
"flex items-center gap-3 text-[12.5px] text-ink-2",
className,
)}
>
<Clock size={14} className="text-rubis shrink-0" aria-hidden="true" />
<p className="leading-snug flex-1 min-w-0">
<strong className="text-ink font-semibold">Essai Pro</strong> plus que{" "}
<strong className="font-medium">
{trialDays} {dayLabel}
</strong>
{sub.trialEndsAt ? (
<>
{" · prélèvement le "}
<strong className="font-medium">{formatDate(sub.trialEndsAt)}</strong>
</>
) : null}
</p>
<Link
to="/parametres/abonnement"
className="shrink-0 text-[12.5px] font-medium text-rubis hover:underline underline-offset-4"
>
Gérer
</Link>
</div>
);
}
if (sub.plan !== "free") return null; if (sub.plan !== "free") return null;
const limit = sub.caps.activeInvoicesLimit; const limit = sub.caps.activeInvoicesLimit;
if (limit === null) return null; if (limit === null) return null;

View File

@ -6,6 +6,7 @@ import type { ReactNode } from "react";
import { import {
useIsAtFreeLimit, useIsAtFreeLimit,
useSubscription, useSubscription,
useTrialDaysRemaining,
type SubscriptionState, type SubscriptionState,
} from "@/lib/billing"; } from "@/lib/billing";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@ -43,6 +44,8 @@ function fakeState(overrides: Partial<SubscriptionState> = {}): SubscriptionStat
inGracePeriod: false, inGracePeriod: false,
gracePeriodEndsAt: null, gracePeriodEndsAt: null,
subscriptionStatus: null, subscriptionStatus: null,
inTrial: false,
trialEndsAt: null,
billingCycle: null, billingCycle: null,
currentPeriodEnd: null, currentPeriodEnd: null,
hasStripeCustomer: false, hasStripeCustomer: false,
@ -165,4 +168,70 @@ describe("useIsAtFreeLimit", () => {
await waitFor(() => expect(api.get).toHaveBeenCalled()); await waitFor(() => expect(api.get).toHaveBeenCalled());
await waitFor(() => expect(result.current).toBe(false)); await waitFor(() => expect(result.current).toBe(false));
}); });
it("retourne false pendant l'essai 14 j (inTrial=true bypass le quota)", async () => {
vi.spyOn(api, "get").mockResolvedValue(
fakeState({
plan: "free",
activeInvoicesCount: 10,
inGracePeriod: false,
inTrial: true,
trialEndsAt: new Date(Date.now() + 7 * 24 * 3600_000).toISOString(),
subscriptionStatus: "trialing",
}),
);
const { result } = renderHook(() => useIsAtFreeLimit(), {
wrapper: makeWrapper(),
});
await waitFor(() => expect(api.get).toHaveBeenCalled());
await waitFor(() => expect(result.current).toBe(false));
});
});
describe("useTrialDaysRemaining", () => {
it("retourne null hors essai", async () => {
vi.spyOn(api, "get").mockResolvedValue(
fakeState({ plan: "free", inTrial: false }),
);
const { result } = renderHook(() => useTrialDaysRemaining(), {
wrapper: makeWrapper(),
});
await waitFor(() => expect(api.get).toHaveBeenCalled());
await waitFor(() => expect(result.current).toBe(null));
});
it("retourne le nombre de jours arrondi sup quand essai actif", async () => {
const sevenDays = 7 * 24 * 3600 * 1000;
vi.spyOn(api, "get").mockResolvedValue(
fakeState({
plan: "free",
inTrial: true,
// 6.5 jours → arrondi sup = 7
trialEndsAt: new Date(Date.now() + sevenDays - 12 * 3600 * 1000).toISOString(),
subscriptionStatus: "trialing",
}),
);
const { result } = renderHook(() => useTrialDaysRemaining(), {
wrapper: makeWrapper(),
});
await waitFor(() => expect(api.get).toHaveBeenCalled());
await waitFor(() => expect(result.current).toBe(7));
});
it("retourne 0 si trialEndsAt déjà passé (garde-fou)", async () => {
vi.spyOn(api, "get").mockResolvedValue(
fakeState({
plan: "free",
inTrial: true,
trialEndsAt: new Date(Date.now() - 1000).toISOString(),
subscriptionStatus: "trialing",
}),
);
const { result } = renderHook(() => useTrialDaysRemaining(), {
wrapper: makeWrapper(),
});
await waitFor(() => expect(api.get).toHaveBeenCalled());
await waitFor(() => expect(result.current).toBe(0));
});
}); });

View File

@ -18,6 +18,9 @@ export type SubscriptionState = {
inGracePeriod: boolean; inGracePeriod: boolean;
gracePeriodEndsAt: string | null; gracePeriodEndsAt: string | null;
subscriptionStatus: string | null; subscriptionStatus: string | null;
/** Essai 14 j Pro actuellement actif (CB déposée, trial_end futur). */
inTrial: boolean;
trialEndsAt: string | null;
billingCycle: BillingCycle | null; billingCycle: BillingCycle | null;
currentPeriodEnd: string | null; currentPeriodEnd: string | null;
hasStripeCustomer: boolean; hasStripeCustomer: boolean;
@ -46,6 +49,23 @@ export function useStartCheckout() {
}); });
} }
/**
* Démarre l'essai 14 j Pro avec CB à l'inscription (Stripe Checkout +
* `trial_period_days`). Renvoie l'URL Stripe caller fait redirect.
*
* 409 `trial_already_consumed` si l'org a déjà eu son essai caller
* doit basculer sur `useStartCheckout` (sans trial).
*/
export function useStartTrial() {
return useMutation({
mutationFn: (opts: { plan?: "pro" | "business"; cycle?: BillingCycle } = {}) =>
api.post<{ url: string }>("/api/v1/billing/start-trial", {
plan: opts.plan ?? "pro",
cycle: opts.cycle ?? "monthly",
}),
});
}
/** /**
* Ouvre le Customer Portal Stripe pour gérer abonnement / CB / annuler. * Ouvre le Customer Portal Stripe pour gérer abonnement / CB / annuler.
* Disponible seulement si l'org a déjà un Stripe customer. * Disponible seulement si l'org a déjà un Stripe customer.
@ -71,16 +91,31 @@ export function useReactivateSubscription() {
} }
/** /**
* True si l'org est sur Free, hors grace period, et limit. Le SPA * True si l'org est sur Free, hors grace period, hors trial, et
* l'utilise pour afficher un banner "limite atteinte" et bloquer * limit. Le SPA l'utilise pour afficher un banner "limite atteinte" et
* l'upload côté UI avant même de toucher l'API. * bloquer l'upload côté UI avant même de toucher l'API.
*/ */
export function useIsAtFreeLimit(): boolean { export function useIsAtFreeLimit(): boolean {
const { data: state } = useSubscription(); const { data: state } = useSubscription();
if (!state) return false; if (!state) return false;
if (state.plan !== "free") return false; if (state.plan !== "free") return false;
if (state.inGracePeriod) return false; if (state.inGracePeriod) return false;
if (state.inTrial) return false;
const limit = state.caps.activeInvoicesLimit; const limit = state.caps.activeInvoicesLimit;
if (limit === null) return false; if (limit === null) return false;
return state.activeInvoicesCount >= limit; return state.activeInvoicesCount >= limit;
} }
/**
* Jours restants avant la fin de l'essai 14 j (Pro), arrondi au sup
* pour que "1 jour" reste affiché jusqu'au dernier moment. null hors
* trial.
*/
export function useTrialDaysRemaining(): number | null {
const { data: state } = useSubscription();
if (!state || !state.inTrial || !state.trialEndsAt) return null;
const endMs = new Date(state.trialEndsAt).getTime();
const nowMs = Date.now();
if (endMs <= nowMs) return 0;
return Math.ceil((endMs - nowMs) / (1000 * 60 * 60 * 24));
}

View File

@ -0,0 +1,141 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { toast } from "sonner";
import { ArrowRight, CreditCard, Shield, Sparkles } from "lucide-react";
import { z } from "zod";
import { useStartTrial } from "@/lib/billing";
import { Button } from "@rubis/ui";
import { Eyebrow } from "@rubis/ui";
import { Gem } from "@rubis/ui";
const searchSchema = z.object({
trial: z.enum(["cancel"]).optional(),
});
export const Route = createFileRoute("/onboarding/billing")({
validateSearch: searchSchema,
component: OnboardingBilling,
});
/**
* Écran d'onboarding billing — l'user choisit entre :
* - Démarrer l'essai 14 j Pro avec CB (recommandé)
* - Continuer en Free direct (2 factures, pas de CB)
*
* Cet écran est opt-in : il n'est pas (encore) forcé entre signup et
* `/onboarding/compte`. Une fois validé par A/B test, on l'insère en
* étape obligatoire (cf. docs/tech/stripe-trial-with-card.md §3).
*
* Search params :
* - `?trial=cancel` l'user a fermé Stripe Checkout sans valider, on
* affiche un toast d'erreur + on reste sur cet écran (bouton de retry
* + fallback Free).
*/
function OnboardingBilling() {
const navigate = useNavigate();
const search = Route.useSearch();
const startTrial = useStartTrial();
const [submitting, setSubmitting] = useState<"trial" | "free" | null>(null);
// Toast si retour de Stripe Checkout avec ?trial=cancel.
if (search.trial === "cancel") {
setTimeout(() => {
toast.error(
"Essai non démarré — vous pouvez réessayer ou continuer en Free (2 factures).",
);
}, 0);
}
const handleStartTrial = async () => {
setSubmitting("trial");
try {
const result = await startTrial.mutateAsync({});
// Redirect direct vers Stripe Checkout.
window.location.href = result.url;
} catch (err) {
setSubmitting(null);
// 409 trial_already_consumed → on rebascule sur /parametres/abonnement
// où l'user peut upgrade sans trial.
if (
typeof err === "object" &&
err !== null &&
"status" in err &&
(err as { status: number }).status === 409
) {
toast.info("Essai déjà utilisé. Choisissez votre plan directement.");
void navigate({ to: "/parametres/abonnement" });
return;
}
toast.error("Impossible de démarrer l'essai. Réessayez dans un instant.");
}
};
const handleSkipToFree = () => {
setSubmitting("free");
// Pas d'appel API : on reste en Free par défaut. On continue
// l'onboarding standard.
void navigate({ to: "/onboarding/compte" });
};
return (
<div>
<Eyebrow>Avant de commencer</Eyebrow>
<h1 className="mt-3 font-display text-[34px] font-bold leading-[1.1] tracking-[-0.022em] text-ink">
Essayez Rubis Pro 14 jours.
</h1>
<p className="mt-3 max-w-md text-[15px] leading-relaxed text-ink-2">
Accès complet aux automatisations, sans contrainte. Carte demandée, non
prélevée avant le 14e jour. Annulation en un clic depuis votre compte,
sans question posée.
</p>
<div className="mt-10 flex flex-col gap-3">
<Button
size="lg"
loading={submitting === "trial"}
disabled={submitting !== null}
onClick={handleStartTrial}
>
<Sparkles size={16} aria-hidden="true" /> Démarrer mon essai 14 jours{" "}
<ArrowRight size={16} aria-hidden="true" />
</Button>
<ul className="text-[12.5px] text-ink-3 flex flex-col gap-1.5 mt-1 pl-1">
<li className="inline-flex items-center gap-2">
<Gem size={9} /> Factures et OCR illimités pendant l'essai
</li>
<li className="inline-flex items-center gap-2">
<Shield size={11} aria-hidden="true" /> Aucun prélèvement avant J+14 ·
rappel email à J+12
</li>
<li className="inline-flex items-center gap-2">
<CreditCard size={11} aria-hidden="true" /> Annulation en un clic, sans
question posée
</li>
</ul>
<div className="my-3 flex items-center gap-3 text-[12px] uppercase tracking-[0.16em] text-ink-3">
<span className="h-px flex-1 bg-line" />
ou
<span className="h-px flex-1 bg-line" />
</div>
<Button
size="md"
variant="secondary"
loading={submitting === "free"}
disabled={submitting !== null}
onClick={handleSkipToFree}
>
Pas de carte ? Commencer en Free (2 factures)
</Button>
<p className="text-[12px] text-ink-3 leading-relaxed">
Le plan Free fait tourner Rubis sur 2 factures actives en
permanence assez pour tester, pas assez pour la production solo.
Vous pourrez passer Pro à tout moment.
</p>
</div>
</div>
);
}