From b0e6f83655e38562a0716710b17cf4e4dd09d8a2 Mon Sep 17 00:00:00 2001 From: ordinarthur <@arthurbarre.js@gmail.com> Date: Mon, 18 May 2026 12:04:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(billing):=20essai=2014=20j=20Pro=20avec=20?= =?UTF-8?q?CB=20=C3=A0=20l'inscription=20(Stripe=20trial=5Fperiod=5Fdays)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 218 +++----- .../api/app/controllers/billing_controller.ts | 295 ++++------- .../app/jobs/send_trial_recap_email_job.ts | 286 +++++++++++ apps/api/app/mails/trial_recap_email.tsx | 200 ++++++++ apps/api/app/services/billing.ts | 28 +- apps/api/app/services/stripe.ts | 57 +++ apps/api/app/services/stripe_billing.ts | 391 ++++++++++++++ apps/api/config/queue.ts | 10 +- ...dd_trial_ends_at_to_organizations_table.ts | 38 ++ apps/api/database/schema.ts | 50 +- apps/api/start/queue.ts | 10 +- apps/api/start/routes.ts | 3 + apps/api/tests/helpers/stripe_mock.ts | 112 ++++ apps/api/tests/unit/billing.spec.ts | 145 ++++++ apps/api/tests/unit/stripe_billing.spec.ts | 481 ++++++++++++++++++ apps/api/tests/unit/trial_recap_job.spec.ts | 181 +++++++ apps/landing/src/copy.ts | 4 +- .../components/billing/PlanLimitBanner.tsx | 42 +- apps/web/src/lib/billing.test.tsx | 69 +++ apps/web/src/lib/billing.ts | 41 +- apps/web/src/routes/onboarding/billing.tsx | 141 +++++ 21 files changed, 2449 insertions(+), 353 deletions(-) create mode 100644 apps/api/app/jobs/send_trial_recap_email_job.ts create mode 100644 apps/api/app/mails/trial_recap_email.tsx create mode 100644 apps/api/app/services/stripe_billing.ts create mode 100644 apps/api/database/migrations/1779000000000_add_trial_ends_at_to_organizations_table.ts create mode 100644 apps/api/tests/helpers/stripe_mock.ts create mode 100644 apps/api/tests/unit/stripe_billing.spec.ts create mode 100644 apps/api/tests/unit/trial_recap_job.spec.ts create mode 100644 apps/web/src/routes/onboarding/billing.tsx diff --git a/CLAUDE.md b/CLAUDE.md index a545fbd..ee88a65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,200 +1,144 @@ # 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). -- **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é. +1. **3 clics max** pour lancer une relance sur une facture neuve. 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é. -4. **Respectueux du client final** — le ton monte avec le retard, jamais avant. Pas d'agressivité par défaut. -5. **Le rubis est une vraie devise produit** — 1 rubis = 10 min libérées. La gamification doit être tangible et défendable. +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. **Respect du client final** — le ton monte avec le retard, jamais avant. +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. | -| **Couleur primaire** | `#9F1239` — rubis profond légèrement violacé. *Anti-Coca-Cola.* | -| **Couleur secondaires** | `#771328` (deep), `#C9415C` (light), `#FBE4EA` (glow) | -| **Neutres** | Crème `#FAF7F2`, encre chaude `#1A1410`. Jamais de blanc pur, jamais de noir pur. | -| **Typo display** | Bricolage Grotesque (500–800), self-hosted via `@fontsource-variable/bricolage-grotesque` | -| **Typo body** | Inter (400–700), self-hosted via `@fontsource-variable/inter` | -| **Icônes** | Lucide (regular weight) | -| **Pas de** | or, bleu, vert, violet, emojis joaillerie 💎💰, mot "recouvrement" en com publique | +| **Logo** | Direction A — gem facetté géométrique. Le ◆ est un symbole produit. | +| **Primaire** | `#9F1239` rubis profond légèrement violacé (*anti-Coca-Cola*) | +| **Secondaires** | `#771328` deep · `#C9415C` light · `#FBE4EA` glow | +| **Neutres** | Crème `#FAF7F2` · encre chaude `#1A1410`. **Jamais** de blanc/noir purs. | +| **Typo display** | Bricolage Grotesque 500-800 (`@fontsource-variable/bricolage-grotesque`) | +| **Typo body** | Inter 400-700 (`@fontsource-variable/inter`) | +| **Icônes** | Lucide regular | +| **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 -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." - ✗ "Optimisez votre processus de recouvrement amiable." ## Glossaire -- **Rubis** : unité de gamification. **1 rubis = 10 minutes libérées** = 1 relance qu'on n'a pas eu à faire à la main. -- **Plan de relance** : cadence d'emails automatisés (ex. J+3, J+10, J+20). Chaque facture est associée à un plan. -- **Étape** : un email programmé dans un plan (ex. "J+10 — relance ferme"). -- **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 du plan. **Toujours sous validation manuelle** via modale de confirmation, jamais auto. -- **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`. -- **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 `` (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. +- **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 (J+3, J+10, J+20...). 4 plans par défaut : *Standard B2B*, *Rapide*, *Patient*, *Ferme*. +- **Confirmation** *(ex « check-in »)* : email envoyé **à l'utilisateur** pour confirmer paiement avant la relance suivante. Remplace l'intégration banking en V1. +- **Mise en demeure** : étape ferme, **toujours sous validation manuelle** via modale. 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). +- **Factur-X** : PDF/A-3 + XML CII embarqué, format obligatoire B2B FR au 1er sept 2027. **Roadmap V1.5**. - **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 -### 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 -- 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/.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+** — 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). -### OUT (V2 ou plus tard) - -- **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) +## Pricing | 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 | -| **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"*. - -## 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. +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`. ## Stack technique | Couche | Choix | Source | |---|---|---| -| Backend (API) | **AdonisJS v7** (TS, MVC, Lucid ORM, auth & jobs intégrés) | ADR-014 | -| App SPA (`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) | — | -| Design system | **`@rubis/ui`** — Tailwind v4 tokens + composants TSX | — | -| Base de données | **PostgreSQL** | ADR-014 | -| Hosting | **Proxmox + K3s** (perso) | ADR-014 | -| OCR provider | à benchmarker | ADR-020 (en attente) | -| Email outbound | à benchmarker | ADR-021 (en attente) | -| **Génération PDF (factures natives)** | **`@react-pdf/renderer`** côté API (Node), 4 templates dans `apps/api/app/pdf-templates/` | ADR-025 | +| API | **AdonisJS v7** (Lucid, auth Bearer, jobs BullMQ, mail) | ADR-014 | +| SaaS `app.rubis.pro` | **React 19 + Vite + TanStack Router/Query** | ADR-014 | +| Landing+blog `rubis.pro` | **Astro 6 SSR** | — | +| Design system | **`@rubis/ui`** — Tailwind v4 + composants TSX | — | +| DB | **PostgreSQL** (LXC Proxmox existant) | ADR-014, ADR-016 | +| Storage | **MinIO** (LXC Proxmox existant) | ADR-018 | +| Hosting | **Proxmox + K3s + Traefik** | ADR-014 | +| PDF natif | **`@react-pdf/renderer`** côté API, templates `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 | Fichier | Rôle | |---|---| -| `/CLAUDE.md` (ce fichier) | Contexte top-level, toujours en tête | -| `/apps/landing/` | Landing publique + blog (Astro 6 SSR) — déployée sur `rubis.pro` | -| `/apps/landing/public/favicon.{svg,ico,png}` | Set complet de favicons + apple-touch-icon | -| `/apps/landing/public/site.webmanifest` | Manifest PWA (theme `#9F1239`, background `#FAF7F2`) | -| `/packages/ui/` | Design system partagé (tokens Tailwind v4 + composants TSX) | -| `/docs/produit.md` | Spec produit haut niveau (features, IN/OUT V1, pricing). Inclut la section "Édition native des factures". | -| `/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. | -| `/apps/api/app/pdf-templates/` | 4 templates `@react-pdf/renderer` (Classique, Moderne, Minimal, Élégant) + dispatcher. Génération PDF native côté serveur. | -| `/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). | -| `/apps/web/src/routes/_app/factures_.nouvelle.tsx` | Éditeur split-view : édition à gauche, preview PDF live à droite (debounce 500 ms). | -| `/docs/marque.md` | Référence marque écrite (palette, typo, voix, do/don't) | -| `/docs/decisions.md` | Log de décisions avec rationale (format ADR-light) | -| `/docs/wireframes-mvp.html` | Wireframes low-fi des 13 écrans MVP | -| `/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 | +| `/docs/produit.md` | Spec produit (features, IN/OUT V1, flow native) | +| `/docs/flow.md` | Cycle de vie facture, statuts, transitions, edge cases | +| `/docs/marque.md` | Référence marque écrite | +| `/docs/decisions.md` | Log ADR (rationale par décision) | +| `/docs/tech/architecture.md` | Architecture technique (composants, topologie) | +| `/docs/tech/frontend.md` | Guide implémentation frontend | +| `/docs/tech/backend.md` | Guide backend (jobs, mail, conventions Adonis) | +| `/docs/tech/landing-optimisations.md` | Audit landing + plan d'exécution (mai 2026) | +| `/docs/marketing/playbook.md` | Playbook acquisition : ICP, Dream 100, outreach | +| `/docs/munitions-marketing.md` | Stats marché, concurrents, copy, positionnement | +| `/docs/tech/stripe-trial-with-card.md` | Funnel signup essai 14j + CB à l'inscription | +| `/.claude/deploy-memory.md` | Procédure de déploiement | +| `/.claude/skills/push/SKILL.md` | Skill `/push` — bump version + changelog + commit | ## Déploiement -- **Domaine principal** : https://rubis.pro (landing + blog Astro) + https://app.rubis.pro (SaaS React) -- **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. +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`. -## 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 clés | +| Flux | Provider | Adresse | DNS | |---|---|---|---| -| **Sortant** (relances, check-in, 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.` | +| **Sortant** (relances, auth) | Resend | `relances@rubis.pro` | `send.rubis.pro` MX+SPF, `resend._domainkey` DKIM, `_dmarc` | +| **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 -- **Conversion 1 rubis = 10 min** validée mais à confirmer en user testing après MVP -- **Wordmark "rubis" avec gem-i** (direction C) à monter en complément du logo A à un moment -- **Provider OCR** à benchmarker (Mindee, Document AI, Textract, Tesseract) +- **Pricing Free** à réarbitrer (cf. landing-optimisations.md §2) +- **Provider OCR** à benchmarker (Mindee / Document AI / Textract / Tesseract) — ADR-020 +- **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.* diff --git a/apps/api/app/controllers/billing_controller.ts b/apps/api/app/controllers/billing_controller.ts index 730af1d..78423a6 100644 --- a/apps/api/app/controllers/billing_controller.ts +++ b/apps/api/app/controllers/billing_controller.ts @@ -1,13 +1,22 @@ import vine from '@vinejs/vine' import type { HttpContext } from '@adonisjs/core/http' import { Exception } from '@adonisjs/core/exceptions' -import { DateTime } from 'luxon' import logger from '@adonisjs/core/services/logger' import Organization from '#models/organization' -import User from '#models/user' 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 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 { - 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 { /** * GET /api/v1/billing/subscription — auth. @@ -64,10 +46,53 @@ export default class BillingController { 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. - * Crée une session Stripe Checkout et renvoie l'URL hostée — le SPA - * redirige vers Stripe pour que l'user paye en sécurité. + * Crée une session Stripe Checkout standard (sans essai). Pour les + * users post-trial qui upgrade, ou Free direct payant. * * Body: { plan: 'pro'|'business', cycle: 'monthly'|'yearly' } */ @@ -79,28 +104,8 @@ export default class BillingController { const org = await Organization.findOrFail(organizationId) const customerId = await ensureStripeCustomer(org, user) - 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`, - // 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 } }) + const result = await createCheckoutSession({ org, customerId, plan, cycle }) + return response.json({ data: { url: result.url } }) } /** @@ -186,12 +191,14 @@ export default class BillingController { * POST /api/v1/billing/webhook — public (auth via signature Stripe). * * 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 - * - customer.subscription.updated → renouvellement, plan change - * - customer.subscription.deleted → annulation effective → free - * - invoice.payment_failed → past_due (UI rappelle l'user) + * - checkout.session.completed → 1er paiement OK / trial démarré + * - customer.subscription.{created,updated} → renouvellement, plan change, trial→active + * - customer.subscription.deleted → annulation effective → free + * - 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 * qu'il arrive une seule fois (Stripe peut re-livrer). @@ -209,8 +216,6 @@ export default class BillingController { if (!sig) { 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() if (!raw) { 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') try { - switch (event.type) { - 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 - } + await dispatchWebhookEvent(event) } catch (err) { // 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 @@ -262,132 +249,34 @@ export default class BillingController { return response.json({ received: true }) } +} - // ----------------------------------------------------------------------- - // Handlers webhook - // ----------------------------------------------------------------------- - - private async handleCheckoutCompleted(session: Stripe.Checkout.Session) { - const orgId = session.metadata?.['organization_id'] - if (!orgId) { - logger.warn({ session: session.id }, 'checkout.completed sans organization_id en metadata') +/** + * 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é. + */ +export async function dispatchWebhookEvent(event: Stripe.Event): Promise { + switch (event.type) { + case 'checkout.session.completed': + await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session) 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 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) + case 'customer.subscription.created': + case 'customer.subscription.updated': + await handleSubscriptionUpdate(event.data.object as Stripe.Subscription) return - } - await this.applySubscriptionToOrg(orgId, subscription) - } - - private async handleSubscriptionDeleted(subscription: Stripe.Subscription) { - 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 - 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') + case 'customer.subscription.deleted': + await handleSubscriptionDeleted(event.data.object as Stripe.Subscription) + return + case 'customer.subscription.trial_will_end': + await handleTrialWillEnd(event.data.object as Stripe.Subscription) + return + case 'invoice.payment_failed': + await handlePaymentFailed(event.data.object as Stripe.Invoice) + return + default: + // On ignore les autres events. Stripe en envoie beaucoup, on n'en + // a besoin que d'une poignée. 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: ` (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 } } diff --git a/apps/api/app/jobs/send_trial_recap_email_job.ts b/apps/api/app/jobs/send_trial_recap_email_job.ts new file mode 100644 index 0000000..eefa6f6 --- /dev/null +++ b/apps/api/app/jobs/send_trial_recap_email_job.ts @@ -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:` 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/apps/api/app/mails/trial_recap_email.tsx b/apps/api/app/mails/trial_recap_email.tsx new file mode 100644 index 0000000..69b6121 --- /dev/null +++ b/apps/api/app/mails/trial_recap_email.tsx @@ -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 ( + + Bonjour {user.firstName}, + + Plus que {trial.daysRemaining} {dayPlural} avant que votre + essai bascule en Pro mensuel et que Rubis prélève{' '} + {trial.priceFormatted} sur votre carte, le{' '} + {trial.endsAtFormatted}. Petit récap de ce que Rubis a + fait pour vous depuis le début : + + +
+ ◆ Votre essai en chiffres + + Factures importées + {usage.invoicesImported} + + + Relances envoyées + {usage.remindersSent} + + + Encaissé + {usage.eurosCollected} + + + Rubis gagnés + + {usage.rubisEarned} · ≈ {usage.hoursLiberated} libérées + + +
+ + + 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. + + + Si vous voulez annuler, c'est en un clic depuis votre espace Rubis : + + +
+ +
+ + + Une question ? Répondez à ce mail, je le lis personnellement.{'\n'} + Arthur — fondateur de Rubis + +
+ ) +} diff --git a/apps/api/app/services/billing.ts b/apps/api/app/services/billing.ts index a452729..d909f35 100644 --- a/apps/api/app/services/billing.ts +++ b/apps/api/app/services/billing.ts @@ -99,7 +99,10 @@ export type EnforcementResult = * Règle : * - Plans payants → toujours autorisé * - 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 * pour saisie manuelle, N pour upload OCR multi-fichiers). @@ -121,6 +124,19 @@ export async function canCreateInvoices( 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) if (current + delta <= caps.activeInvoicesLimit) { return { allowed: true } @@ -149,6 +165,10 @@ export type OrgSubscriptionState = { gracePeriodEndsAt: string | null /** Status Stripe (`active`, `trialing`, `past_due`, `canceled`...). null pour les Free. */ 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. */ billingCycle: 'monthly' | 'yearly' | null /** ISO date de fin de période courante (= prochaine facture Stripe). */ @@ -172,6 +192,10 @@ export async function getOrgSubscriptionState( const now = DateTime.utc() const inGracePeriod = plan === 'free' && !!org.gracePeriodEndsAt && org.gracePeriodEndsAt > now + const inTrial = + org.subscriptionStatus === 'trialing' && + !!org.trialEndsAt && + org.trialEndsAt > now return { plan, @@ -180,6 +204,8 @@ export async function getOrgSubscriptionState( inGracePeriod, gracePeriodEndsAt: org.gracePeriodEndsAt?.toISO() ?? null, subscriptionStatus: org.subscriptionStatus ?? null, + inTrial, + trialEndsAt: org.trialEndsAt?.toISO() ?? null, billingCycle: org.billingCycle === 'monthly' || org.billingCycle === 'yearly' ? org.billingCycle diff --git a/apps/api/app/services/stripe.ts b/apps/api/app/services/stripe.ts index 68fc52f..43b93c9 100644 --- a/apps/api/app/services/stripe.ts +++ b/apps/api/app/services/stripe.ts @@ -1,6 +1,21 @@ import Stripe from 'stripe' 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 * quand la clé n'est pas définie. Toute fonction qui nécessite Stripe @@ -27,6 +42,20 @@ export function getStripe(): 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 * 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] +/** + * 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 * (signal que `stripe:setup` n'a pas été lancé ou que les lookup_keys diff --git a/apps/api/app/services/stripe_billing.ts b/apps/api/app/services/stripe_billing.ts new file mode 100644 index 0000000..80883b7 --- /dev/null +++ b/apps/api/app/services/stripe_billing.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = + 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 { + _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 { + 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é' + ) +} diff --git a/apps/api/config/queue.ts b/apps/api/config/queue.ts index 502f0e1..fddcdcf 100644 --- a/apps/api/config/queue.ts +++ b/apps/api/config/queue.ts @@ -18,7 +18,14 @@ export const redisConnection: RedisOptions = { * Liste des queues. La concurrence est appliquée côté worker. * 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 const queueConcurrency: Record = { @@ -27,4 +34,5 @@ export const queueConcurrency: Record = { checkins: 5, kpis: 1, 'payment-thanks': 5, + 'trial-recap': 3, } diff --git a/apps/api/database/migrations/1779000000000_add_trial_ends_at_to_organizations_table.ts b/apps/api/database/migrations/1779000000000_add_trial_ends_at_to_organizations_table.ts new file mode 100644 index 0000000..7de5a89 --- /dev/null +++ b/apps/api/database/migrations/1779000000000_add_trial_ends_at_to_organizations_table.ts @@ -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') + }) + } +} diff --git a/apps/api/database/schema.ts b/apps/api/database/schema.ts index 640465a..e3a8d15 100644 --- a/apps/api/database/schema.ts +++ b/apps/api/database/schema.ts @@ -160,11 +160,21 @@ export class CheckinTaskSchema 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 @column() declare address: string | null @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 @column() declare contactLastName: string | null @@ -183,7 +193,11 @@ export class ClientSchema extends BaseModel { @column() declare phone: string | null @column() + declare siren: string | null + @column() declare siret: string | null + @column() + declare tvaIntra: string | null @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime | null } @@ -262,21 +276,35 @@ export class ImportDraftSchema 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 @column() + declare amountHtCents: number | null + @column() declare amountTtcCents: number @column() + declare amountTvaCents: number | null + @column() declare clientId: string + @column() + declare clientSnapshot: any | null @column.dateTime({ autoCreate: true }) declare createdAt: DateTime @column.dateTime() declare dueDate: DateTime + @column() + declare footerNotes: string | null @column({ isPrimary: true }) declare id: string + @column() + declare isNative: boolean @column.dateTime() declare issueDate: DateTime @column() + declare issuerSnapshot: any | null + @column() + declare lines: any | null + @column() declare notes: string | null @column() declare numero: string @@ -285,19 +313,31 @@ export class InvoiceSchema extends BaseModel { @column.dateTime() declare paidAt: DateTime | null @column() + declare paymentTermsDays: number | null + @column.dateTime() + declare pdfGeneratedAt: DateTime | null + @column() declare pdfStorageKey: string | null @column() declare planId: string | null @column() declare rubisEarned: number @column() + declare sequenceNumber: number | null + @column() 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 }) declare updatedAt: DateTime | null } 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 @column() declare billingCycle: string | null @@ -318,6 +358,8 @@ export class OrganizationSchema extends BaseModel { @column({ isPrimary: true }) declare id: string @column() + declare invoiceSettings: any | null + @column() declare monthlyVolumeBucket: string | null @column() declare name: string @@ -341,6 +383,8 @@ export class OrganizationSchema extends BaseModel { declare stripeSubscriptionId: string | null @column() declare subscriptionStatus: string | null + @column.dateTime() + declare trialEndsAt: DateTime | null @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime | null @column.dateTime() diff --git a/apps/api/start/queue.ts b/apps/api/start/queue.ts index a81284b..38c6819 100644 --- a/apps/api/start/queue.ts +++ b/apps/api/start/queue.ts @@ -20,6 +20,10 @@ import { registerWorker, shutdownQueue } from '#services/queue' import { sendRelanceJob } from '#jobs/send_relance_job' import { sendCheckinJob } from '#jobs/send_checkin_job' import { sendPaymentThanksJob } from '#jobs/send_payment_thanks_job' +import { + sendTrialRecapEmailJob, + type TrialRecapJobData, +} from '#jobs/send_trial_recap_email_job' if (app.getEnvironment() === 'web') { try { @@ -35,7 +39,11 @@ if (app.getEnvironment() === 'web') { await sendPaymentThanksJob(job.data) }) - logger.info('BullMQ workers ready (relances, checkins, payment-thanks)') + registerWorker('trial-recap', async (job) => { + await sendTrialRecapEmailJob(job.data) + }) + + logger.info('BullMQ workers ready (relances, checkins, payment-thanks, trial-recap)') app.terminating(async () => { logger.info('shutting down BullMQ workers') diff --git a/apps/api/start/routes.ts b/apps/api/start/routes.ts index e2a2e72..4c34eb2 100644 --- a/apps/api/start/routes.ts +++ b/apps/api/start/routes.ts @@ -352,6 +352,9 @@ router router .get('subscription', [controllers.Billing, 'subscription']) .as('subscription') + router + .post('start-trial', [controllers.Billing, 'startTrial']) + .as('start_trial') router .post('checkout', [controllers.Billing, 'checkout']) .as('checkout') diff --git a/apps/api/tests/helpers/stripe_mock.ts b/apps/api/tests/helpers/stripe_mock.ts new file mode 100644 index 0000000..62dda2b --- /dev/null +++ b/apps/api/tests/helpers/stripe_mock.ts @@ -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 + customers?: Partial + checkout?: { sessions?: Partial } + billingPortal?: { sessions?: Partial } + prices?: Partial + webhooks?: Partial +} + +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 +} diff --git a/apps/api/tests/unit/billing.spec.ts b/apps/api/tests/unit/billing.spec.ts index 0926081..40fadee 100644 --- a/apps/api/tests/unit/billing.spec.ts +++ b/apps/api/tests/unit/billing.spec.ts @@ -272,3 +272,148 @@ test.group('billing — getOrgSubscriptionState', (group) => { 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) + }) +}) diff --git a/apps/api/tests/unit/stripe_billing.spec.ts b/apps/api/tests/unit/stripe_billing.spec.ts new file mode 100644 index 0000000..1427c8c --- /dev/null +++ b/apps/api/tests/unit/stripe_billing.spec.ts @@ -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> = [] + installStripeMock({ + prices: { + list: (async () => ({ + data: [{ id: 'price_pro_monthly_test' }], + })) as unknown as import('stripe').default['prices']['list'], + }, + checkout: { + sessions: { + create: (async (params: Record) => { + 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 } + metadata?: Record + } + 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') + }) +}) diff --git a/apps/api/tests/unit/trial_recap_job.spec.ts b/apps/api/tests/unit/trial_recap_job.spec.ts new file mode 100644 index 0000000..45820fd --- /dev/null +++ b/apps/api/tests/unit/trial_recap_job.spec.ts @@ -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 { + 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 { + 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) + }) +}) diff --git a/apps/landing/src/copy.ts b/apps/landing/src/copy.ts index cbaadd6..f3d736a 100644 --- a/apps/landing/src/copy.ts +++ b/apps/landing/src/copy.ts @@ -57,7 +57,7 @@ export const copy = { body_bold: "5 heures par semaine", body_c: ".", 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", feature1: "14 jours gratuits puis Free 2 factures", feature2: "Hébergement souverain", @@ -325,7 +325,7 @@ export const copy = { 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.", 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.", }, footnotes: { diff --git a/apps/web/src/components/billing/PlanLimitBanner.tsx b/apps/web/src/components/billing/PlanLimitBanner.tsx index 67488d3..265ba01 100644 --- a/apps/web/src/components/billing/PlanLimitBanner.tsx +++ b/apps/web/src/components/billing/PlanLimitBanner.tsx @@ -1,13 +1,14 @@ 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 { formatDate } from "@/lib/format"; /** * 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 * - "Approche" : ratio ≥ 80 % du quota → ton conseil * - "Atteinte" : ratio ≥ 100 % du quota → ton blocant + CTA upgrade @@ -17,7 +18,44 @@ import { formatDate } from "@/lib/format"; */ export function PlanLimitBanner({ className }: { className?: string }) { const { data: sub } = useSubscription(); + const trialDays = useTrialDaysRemaining(); 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 ( +
+
+ ); + } + if (sub.plan !== "free") return null; const limit = sub.caps.activeInvoicesLimit; if (limit === null) return null; diff --git a/apps/web/src/lib/billing.test.tsx b/apps/web/src/lib/billing.test.tsx index e807396..19271f1 100644 --- a/apps/web/src/lib/billing.test.tsx +++ b/apps/web/src/lib/billing.test.tsx @@ -6,6 +6,7 @@ import type { ReactNode } from "react"; import { useIsAtFreeLimit, useSubscription, + useTrialDaysRemaining, type SubscriptionState, } from "@/lib/billing"; import { api } from "@/lib/api"; @@ -43,6 +44,8 @@ function fakeState(overrides: Partial = {}): SubscriptionStat inGracePeriod: false, gracePeriodEndsAt: null, subscriptionStatus: null, + inTrial: false, + trialEndsAt: null, billingCycle: null, currentPeriodEnd: null, hasStripeCustomer: false, @@ -165,4 +168,70 @@ describe("useIsAtFreeLimit", () => { await waitFor(() => expect(api.get).toHaveBeenCalled()); 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)); + }); }); diff --git a/apps/web/src/lib/billing.ts b/apps/web/src/lib/billing.ts index bbe67a6..43b5a60 100644 --- a/apps/web/src/lib/billing.ts +++ b/apps/web/src/lib/billing.ts @@ -18,6 +18,9 @@ export type SubscriptionState = { inGracePeriod: boolean; gracePeriodEndsAt: 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; currentPeriodEnd: string | null; 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. * 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 - * l'utilise pour afficher un banner "limite atteinte" et bloquer - * l'upload côté UI avant même de toucher l'API. + * True si l'org est sur Free, hors grace period, hors trial, et ≥ + * limit. Le SPA l'utilise pour afficher un banner "limite atteinte" et + * bloquer l'upload côté UI avant même de toucher l'API. */ export function useIsAtFreeLimit(): boolean { const { data: state } = useSubscription(); if (!state) return false; if (state.plan !== "free") return false; if (state.inGracePeriod) return false; + if (state.inTrial) return false; const limit = state.caps.activeInvoicesLimit; if (limit === null) return false; 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)); +} diff --git a/apps/web/src/routes/onboarding/billing.tsx b/apps/web/src/routes/onboarding/billing.tsx new file mode 100644 index 0000000..5506869 --- /dev/null +++ b/apps/web/src/routes/onboarding/billing.tsx @@ -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 ( +
+ Avant de commencer +

+ Essayez Rubis Pro 14 jours. +

+

+ 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. +

+ +
+ + +
    +
  • + Factures et OCR illimités pendant l'essai +
  • +
  • +
  • +
  • +
  • +
+ +
+ + ou + +
+ + +

+ 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. +

+
+
+ ); +}