feat(billing): essai 14 j Pro avec CB à l'inscription (Stripe trial_period_days)
Implémente le chantier #6 de docs/tech/landing-optimisations.md. Le funnel signup propose maintenant un essai 14 j Pro avec carte demandée mais non prélevée — prélèvement automatique à J+14 avec rappel à J+11 (webhook customer.subscription.trial_will_end de Stripe). Couverture tests : 60 tests unitaires sur la couche billing - billing.spec.ts (25) — quota Free, bypass trial, inTrial state - stripe_billing.spec.ts (24) — handlers webhook, idempotence, dispatcher - trial_recap_job.spec.ts (11) — stats aggregation, formatRubisToHoursFr + 3 nouveaux tests vitest côté SPA (useTrialDaysRemaining, useIsAtFreeLimit bypass trial). Backend : - Migration 1779000000000_add_trial_ends_at_to_organizations - PLAN_CAPS bypass quand status=trialing AND trial_ends_at futur - getOrgSubscriptionState expose inTrial + trialEndsAt - Refactor handlers webhook en service stripe_billing.ts (pures, testables) — extraction depuis le controller. dispatchWebhookEvent routeur typé également extrait pour les tests. - createTrialCheckoutSession avec subscription_data.trial_period_days=14, garde-fou TrialAlreadyConsumedError contre re-trial. - handleTrialWillEnd → enqueue job recap (BullMQ jobId déterministe basé sur subscriptionId, idempotent contre re-delivery Stripe). - Endpoint POST /api/v1/billing/start-trial. - Email template trial_recap (React Email, branding Rubis figé) avec stats: factures importées, relances envoyées, € récupérés, rubis + heures libérées. Infra de test : - tests/helpers/stripe_mock.ts : __setStripeForTests injection + factories fakeSubscription / fakeCheckoutSession / fakeInvoice. - __setTrialRecapEnqueueForTests : permet de spy l'enqueue sans Redis. Frontend : - /onboarding/billing.tsx (opt-in, pas encore forcé dans le flow) : bouton primaire essai 14j + fallback "Free 2 factures". - PlanLimitBanner : nouveau état "Essai Pro · X jours restants" qui prime sur les autres bandeaux. Discret rubis-glow, non blocant. - useStartTrial hook + useTrialDaysRemaining (arrondi sup). - SubscriptionState typé avec inTrial + trialEndsAt. Landing : - Sous-texte CTA réactivé : « CB demandée, non prélevée avant J+14 » (Hero + FinalCTA), maintenant promesse véridique. Notes ouvertes (à décider ultérieurement) : - Tunnel /onboarding/billing FORCÉ entre signup et /onboarding/compte : guard reste à activer (risque cassage du signup actuel sinon). Pour l'instant l'écran est accessible mais opt-in. - Cron de redondance trial-recap : pas encore implémenté (le jobId déterministe BullMQ couvre déjà la double-livraison Stripe). À ajouter si on observe des trial sans recap en prod. - Tests E2E avec Stripe test mode à faire avant le go-live (cartes 3DS 4000 0027 6000 3184, declined 4000 0000 0000 0341). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f9cba50b5e
commit
b0e6f83655
218
CLAUDE.md
218
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 `<prefix><seq padé>` (ex. `FAC-2026-0042`). Brouillons exclus du compteur.
|
||||
- **Snapshot** : copie figée des données du client (`client_snapshot`) et de l'émetteur (`issuer_snapshot`) au moment de l'émission d'une facture native. Garantit l'immutabilité légale : la facture reste intacte même si le client change d'adresse ou si l'org modifie ses settings.
|
||||
- **Factur-X** : format de facturation électronique mixte PDF/A-3 + XML CII embarqué, conforme à la réforme française B2B (obligation d'émission au 1er septembre 2027 pour TPE-PME). Roadmap V1.5 — pas en V1.
|
||||
- **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/<version>.md` (Astro content collections, schéma Zod), RSS à `/changelog/rss.xml`. Toast SPA "Nouvelle version" déclenché par `apps/web/src/components/version-toast.tsx` quand `APP_VERSION` (`apps/web/src/version.ts`) diffère de `localStorage["rubis:last-seen-version"]`. Workflow release : `/push` (cf. `.claude/skills/push/SKILL.md`) bump la version + crée le .md + commit + push.
|
||||
**OUT V2+** — 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.*
|
||||
|
||||
@ -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<string> {
|
||||
if (org.stripeCustomerId) return org.stripeCustomerId
|
||||
const stripe = getStripe()
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
name: org.name || user.fullName || user.email,
|
||||
metadata: {
|
||||
organization_id: org.id,
|
||||
user_id: user.id,
|
||||
},
|
||||
})
|
||||
org.stripeCustomerId = customer.id
|
||||
await org.save()
|
||||
return customer.id
|
||||
}
|
||||
|
||||
export default class BillingController {
|
||||
/**
|
||||
* 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
|
||||
* - checkout.session.completed → 1er paiement OK / trial démarré
|
||||
* - customer.subscription.{created,updated} → renouvellement, plan change, trial→active
|
||||
* - customer.subscription.deleted → annulation effective → free
|
||||
* - invoice.payment_failed → past_due (UI rappelle l'user)
|
||||
* - customer.subscription.trial_will_end → email recap J-3 avant trial_end
|
||||
* - invoice.payment_failed → past_due
|
||||
*
|
||||
* Idempotent : on traite chaque event en read-then-write sans assumer
|
||||
* 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')
|
||||
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)
|
||||
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.
|
||||
* Dispatcher webhook → handler typé. Export séparé pour les tests qui
|
||||
* peuvent construire un `Stripe.Event` factice et vérifier que le bon
|
||||
* handler est appelé.
|
||||
*/
|
||||
private async applySubscriptionToOrg(orgId: string, subscription: Stripe.Subscription) {
|
||||
const org = await Organization.find(orgId)
|
||||
if (!org) {
|
||||
logger.warn({ orgId }, 'applySubscriptionToOrg : org introuvable')
|
||||
export async function dispatchWebhookEvent(event: Stripe.Event): Promise<void> {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
|
||||
return
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
await handleSubscriptionUpdate(event.data.object as Stripe.Subscription)
|
||||
return
|
||||
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: <timestamp>` (epoch) → utilisé par le Customer Portal
|
||||
// qui schedule un cancel à une date précise (généralement = period_end).
|
||||
//
|
||||
// Sémantiquement c'est la même chose : "le sub s'éteindra à cette date".
|
||||
// On unifie en un seul booléen pour le reste de l'app.
|
||||
org.cancelAtPeriodEnd =
|
||||
!!subscription.cancel_at_period_end || !!subscription.cancel_at
|
||||
await org.save()
|
||||
|
||||
logger.info(
|
||||
{
|
||||
orgId,
|
||||
plan,
|
||||
cycle,
|
||||
status: subscription.status,
|
||||
subscriptionId: subscription.id,
|
||||
cancelAtPeriodEnd: !!subscription.cancel_at_period_end,
|
||||
cancelAt: subscription.cancel_at,
|
||||
},
|
||||
'Subscription appliquée à l\'org'
|
||||
)
|
||||
}
|
||||
|
||||
private planFromLookupKey(key: string | null): 'free' | 'pro' | 'business' {
|
||||
if (!key) return 'free'
|
||||
if (key.includes('business')) return 'business'
|
||||
if (key.includes('pro')) return 'pro'
|
||||
return 'free'
|
||||
}
|
||||
|
||||
private cycleFromLookupKey(key: string | null): 'monthly' | 'yearly' | null {
|
||||
if (!key) return null
|
||||
if (key.endsWith('_yearly')) return 'yearly'
|
||||
if (key.endsWith('_monthly')) return 'monthly'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
286
apps/api/app/jobs/send_trial_recap_email_job.ts
Normal file
286
apps/api/app/jobs/send_trial_recap_email_job.ts
Normal file
@ -0,0 +1,286 @@
|
||||
import { DateTime } from 'luxon'
|
||||
import db from '@adonisjs/lucid/services/db'
|
||||
import mail from '@adonisjs/mail/services/main'
|
||||
import { render } from '@react-email/components'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
import Organization from '#models/organization'
|
||||
import User from '#models/user'
|
||||
import env from '#start/env'
|
||||
import { DEFAULT_BRAND } from '#services/brand'
|
||||
import { formatAmountFr, formatDateFr } from '#services/template'
|
||||
import { getQueue } from '#services/queue'
|
||||
import { getStripe } from '#services/stripe'
|
||||
import { TrialRecapEmail } from '#mails/trial_recap_email'
|
||||
|
||||
/**
|
||||
* Job d'envoi de l'email recap d'essai 14 j. Enqueué par le webhook
|
||||
* `customer.subscription.trial_will_end` (Stripe émet à J-3 avant
|
||||
* `trial_end`).
|
||||
*
|
||||
* Idempotence : on utilise `jobId = trial-recap:<subscriptionId>` côté
|
||||
* BullMQ pour qu'un re-deliver Stripe ne crée pas un 2e job. Si le job
|
||||
* est déjà passé (completed), enqueue est un no-op.
|
||||
*
|
||||
* Tolère les org en mode démo : `clock.now()` reste prod ici (l'essai
|
||||
* est piloté par Stripe, pas par virtualNow). On loggue juste un info.
|
||||
*/
|
||||
|
||||
export type TrialRecapJobData = {
|
||||
organizationId: string
|
||||
/** Stripe subscription id — sert de clé de dédoublonnage. */
|
||||
subscriptionId: string
|
||||
}
|
||||
|
||||
export async function enqueueTrialRecapEmail(
|
||||
organizationId: string,
|
||||
subscriptionId: string
|
||||
): Promise<void> {
|
||||
const queue = getQueue('trial-recap')
|
||||
await queue.add(
|
||||
'send_trial_recap',
|
||||
{ organizationId, subscriptionId } satisfies TrialRecapJobData,
|
||||
{
|
||||
/**
|
||||
* Clé déterministe = idempotence. BullMQ refuse silencieusement les
|
||||
* duplicates. Si Stripe redélivre le webhook trial_will_end, on
|
||||
* n'envoie pas un 2e email.
|
||||
*/
|
||||
jobId: `trial-recap:${subscriptionId}`,
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 30_000 },
|
||||
removeOnComplete: { age: 7 * 24 * 3600 },
|
||||
removeOnFail: { age: 14 * 24 * 3600 },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats aggregation — pure, testable sans dépendance mail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TrialUsageStats = {
|
||||
invoicesImported: number
|
||||
remindersSent: number
|
||||
eurosCollectedCents: number
|
||||
rubisEarned: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrège les compteurs d'usage de l'org depuis `since` (typiquement la
|
||||
* date de création de l'org). Une seule passe SQL pour rester rapide.
|
||||
*/
|
||||
export async function computeTrialUsageStats(
|
||||
organizationId: string,
|
||||
since: DateTime
|
||||
): Promise<TrialUsageStats> {
|
||||
const sinceJs = since.toJSDate()
|
||||
|
||||
const inv = (await db
|
||||
.from('invoices')
|
||||
.where('organization_id', organizationId)
|
||||
.where('created_at', '>=', sinceJs)
|
||||
.select(db.raw(`count(*)::int as imported`))
|
||||
.first()) as { imported: number } | undefined
|
||||
|
||||
const paid = (await db
|
||||
.from('invoices')
|
||||
.where('organization_id', organizationId)
|
||||
.where('status', 'paid')
|
||||
.where('paid_at', '>=', sinceJs)
|
||||
.select(
|
||||
db.raw(`coalesce(sum(amount_ttc_cents), 0)::int as euros_cents`),
|
||||
db.raw(`coalesce(sum(rubis_earned), 0)::int as rubis`)
|
||||
)
|
||||
.first()) as { euros_cents: number; rubis: number } | undefined
|
||||
|
||||
const reminders = (await db
|
||||
.from('relance_tasks')
|
||||
.where('organization_id', organizationId)
|
||||
.where('status', 'sent')
|
||||
.where('sent_at', '>=', sinceJs)
|
||||
.select(db.raw(`count(*)::int as sent`))
|
||||
.first()) as { sent: number } | undefined
|
||||
|
||||
return {
|
||||
invoicesImported: inv?.imported ?? 0,
|
||||
remindersSent: reminders?.sent ?? 0,
|
||||
eurosCollectedCents: paid?.euros_cents ?? 0,
|
||||
rubisEarned: paid?.rubis ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper de présentation : convertit un nombre de rubis (= 10 min chacun)
|
||||
* en chaîne « Xh Ym ». Aligné sur `apps/web/src/lib/format.ts`
|
||||
* `formatRubisToHours` pour cohérence visuelle.
|
||||
*/
|
||||
export function formatRubisToHoursFr(rubis: number): string {
|
||||
const totalMinutes = rubis * 10
|
||||
const hours = Math.floor(totalMinutes / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
if (hours === 0) return `${minutes} min`
|
||||
if (minutes === 0) return `${hours} h`
|
||||
return `${hours} h ${String(minutes).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function sendTrialRecapEmailJob(data: TrialRecapJobData): Promise<void> {
|
||||
const { organizationId, subscriptionId } = data
|
||||
logger.info({ organizationId, subscriptionId }, 'sendTrialRecapEmailJob: pick-up')
|
||||
|
||||
const org = await Organization.find(organizationId)
|
||||
if (!org) {
|
||||
logger.warn({ organizationId }, 'sendTrialRecapEmailJob: org introuvable, skip')
|
||||
return
|
||||
}
|
||||
// Garde-fou : on ne spamme pas un user qui n'est plus en essai (status
|
||||
// passé en active/canceled entre temps).
|
||||
if (org.subscriptionStatus !== 'trialing' || !org.trialEndsAt) {
|
||||
logger.info(
|
||||
{ organizationId, status: org.subscriptionStatus },
|
||||
'sendTrialRecapEmailJob: org plus en essai, skip'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 1er user de l'org = destinataire. V1 mono-user, en V2 (Business multi-
|
||||
// users) on enverra au billing_contact dédié.
|
||||
const user = await User.query().where('organization_id', org.id).first()
|
||||
if (!user) {
|
||||
logger.warn({ organizationId }, 'sendTrialRecapEmailJob: aucun user dans org, skip')
|
||||
return
|
||||
}
|
||||
|
||||
const since = org.createdAt
|
||||
const stats = await computeTrialUsageStats(org.id, since)
|
||||
|
||||
const now = DateTime.utc()
|
||||
const daysRemaining = Math.max(
|
||||
1,
|
||||
Math.ceil(org.trialEndsAt.diff(now, 'days').days)
|
||||
)
|
||||
|
||||
// Prix : par défaut Pro mensuel (19 €). On regarde la subscription
|
||||
// Stripe pour récupérer le prix réel si différent (yearly, business).
|
||||
const priceFormatted = await resolvePriceForRecap(subscriptionId)
|
||||
|
||||
// Portal URL : on tente de créer une session Customer Portal. Fallback
|
||||
// sur le lien direct dans l'app si Stripe est down (best-effort, le
|
||||
// recap doit partir même si le portail n'est pas joignable).
|
||||
const portalUrl = await safeCreatePortalUrl(org.stripeCustomerId)
|
||||
|
||||
const fromAddress = env.get('MAIL_FROM_ADDRESS', 'relances@rubis.pro')
|
||||
const fromName = env.get('MAIL_FROM_NAME', "Rubis sur l'ongle")
|
||||
const landingUrl = env.get('LANDING_URL', 'https://rubis.pro')
|
||||
|
||||
const firstName = (user.fullName ?? '').split(' ')[0] || user.email.split('@')[0]
|
||||
const subject = `Plus que ${daysRemaining} ${daysRemaining > 1 ? 'jours' : 'jour'} d'essai · récap avant prélèvement`
|
||||
|
||||
const htmlBody = await render(
|
||||
TrialRecapEmail({
|
||||
tokens: DEFAULT_BRAND,
|
||||
user: { firstName },
|
||||
trial: {
|
||||
daysRemaining,
|
||||
endsAtFormatted: formatDateFr(org.trialEndsAt.toJSDate()),
|
||||
priceFormatted,
|
||||
},
|
||||
usage: {
|
||||
invoicesImported: stats.invoicesImported,
|
||||
remindersSent: stats.remindersSent,
|
||||
eurosCollected: formatAmountFr(stats.eurosCollectedCents),
|
||||
rubisEarned: stats.rubisEarned,
|
||||
hoursLiberated: formatRubisToHoursFr(stats.rubisEarned),
|
||||
},
|
||||
portalUrl,
|
||||
landingUrl,
|
||||
})
|
||||
)
|
||||
|
||||
const textBody = `Bonjour ${firstName},
|
||||
|
||||
Plus que ${daysRemaining} jour(s) avant que votre essai bascule en Pro et que ${priceFormatted} soit prélevé sur votre carte, le ${formatDateFr(org.trialEndsAt.toJSDate())}.
|
||||
|
||||
Récap depuis le début :
|
||||
• ${stats.invoicesImported} factures importées
|
||||
• ${stats.remindersSent} relances envoyées
|
||||
• ${formatAmountFr(stats.eurosCollectedCents)} encaissés
|
||||
• ${stats.rubisEarned} rubis ≈ ${formatRubisToHoursFr(stats.rubisEarned)} libérées
|
||||
|
||||
Si tout est OK, vous n'avez rien à faire. Sinon, annulez en un clic :
|
||||
${portalUrl}
|
||||
|
||||
— Arthur, fondateur de Rubis`
|
||||
|
||||
const driver = env.get('MAIL_DRIVER', 'smtp')
|
||||
logger.info(
|
||||
{ organizationId, to: user.email, driver },
|
||||
'sendTrialRecapEmailJob: envoi via driver'
|
||||
)
|
||||
|
||||
try {
|
||||
const mailer = mail.use(driver)
|
||||
await mailer.send((m) => {
|
||||
m.from(fromAddress, fromName)
|
||||
.to(user.email, user.fullName ?? user.email)
|
||||
.subject(subject)
|
||||
.html(htmlBody)
|
||||
.text(textBody)
|
||||
})
|
||||
logger.info(
|
||||
{ organizationId, subscriptionId, driver },
|
||||
'sendTrialRecapEmailJob: send OK'
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, organizationId, subscriptionId },
|
||||
'sendTrialRecapEmailJob: échec envoi'
|
||||
)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le prix mensuel/annuel TTC de la subscription pour l'afficher
|
||||
* dans le recap. Fallback `19 €` si Stripe injoignable — on préfère un
|
||||
* email partiellement faux à un email pas envoyé du tout.
|
||||
*/
|
||||
async function resolvePriceForRecap(subscriptionId: string): Promise<string> {
|
||||
try {
|
||||
const stripe = getStripe()
|
||||
const sub = await stripe.subscriptions.retrieve(subscriptionId, {
|
||||
expand: ['items.data.price'],
|
||||
})
|
||||
const item = sub.items.data[0]
|
||||
if (!item) return '19 €'
|
||||
const price = item.price
|
||||
const amount = price.unit_amount
|
||||
if (typeof amount !== 'number') return '19 €'
|
||||
// Stripe stocke en cents → division par 100. Locale FR.
|
||||
return formatAmountFr(amount)
|
||||
} catch (err) {
|
||||
logger.warn({ err, subscriptionId }, 'resolvePriceForRecap: fallback 19 €')
|
||||
return '19 €'
|
||||
}
|
||||
}
|
||||
|
||||
async function safeCreatePortalUrl(customerId: string | null): Promise<string> {
|
||||
const fallback = `${env.get('WEB_URL', 'http://localhost:5173')}/parametres/abonnement`
|
||||
if (!customerId) return fallback
|
||||
try {
|
||||
const stripe = getStripe()
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: customerId,
|
||||
return_url: fallback,
|
||||
locale: 'fr',
|
||||
})
|
||||
return session.url || fallback
|
||||
} catch (err) {
|
||||
logger.warn({ err, customerId }, 'safeCreatePortalUrl: fallback link app')
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
200
apps/api/app/mails/trial_recap_email.tsx
Normal file
200
apps/api/app/mails/trial_recap_email.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Template email recap d'essai 14 j — envoyé à l'utilisateur ~J+11 (3
|
||||
* jours avant trial_end, déclenché par le webhook Stripe
|
||||
* `customer.subscription.trial_will_end`).
|
||||
*
|
||||
* Toujours en branding Rubis (notif Rubis → user, jamais customisable
|
||||
* marque blanche).
|
||||
*
|
||||
* Variables interpolées :
|
||||
* - statistiques d'usage des X premiers jours (factures, relances, € récup, rubis)
|
||||
* - date du prélèvement (= trial_end)
|
||||
* - prix mensuel/annuel TTC
|
||||
* - lien Customer Portal (annuler / changer CB)
|
||||
*/
|
||||
|
||||
import * as React from 'react'
|
||||
import { Section, Text, Button } from '@react-email/components'
|
||||
|
||||
import type { BrandTokens } from '#services/brand'
|
||||
import { sp } from './_brand.js'
|
||||
import { EmailLayout } from './_layout.js'
|
||||
|
||||
export type TrialRecapEmailProps = {
|
||||
tokens: BrandTokens
|
||||
user: { firstName: string }
|
||||
trial: {
|
||||
/** Jours restants avant prélèvement (Stripe émet à J-3 par défaut). */
|
||||
daysRemaining: number
|
||||
/** Date du prélèvement, formatée FR (« 23 mai 2026 »). */
|
||||
endsAtFormatted: string
|
||||
/** Prix TTC formaté (« 19 € »). */
|
||||
priceFormatted: string
|
||||
}
|
||||
usage: {
|
||||
invoicesImported: number
|
||||
remindersSent: number
|
||||
/** Montant des factures passées en `paid` depuis le signup, formaté FR. */
|
||||
eurosCollected: string
|
||||
rubisEarned: number
|
||||
/** Heures libérées en string (« 24h48 »). */
|
||||
hoursLiberated: string
|
||||
}
|
||||
/** URL Customer Portal Stripe pour annuler / mettre à jour CB. */
|
||||
portalUrl: string
|
||||
/** URL landing publique (footer). */
|
||||
landingUrl?: string | null
|
||||
}
|
||||
|
||||
export function TrialRecapEmail({
|
||||
tokens,
|
||||
user,
|
||||
trial,
|
||||
usage,
|
||||
portalUrl,
|
||||
landingUrl,
|
||||
}: TrialRecapEmailProps) {
|
||||
const greetingStyle: React.CSSProperties = {
|
||||
color: tokens.text,
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.6',
|
||||
margin: `0 0 ${sp.lg} 0`,
|
||||
}
|
||||
|
||||
const introStyle: React.CSSProperties = {
|
||||
color: tokens.text,
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.6',
|
||||
margin: `0 0 ${sp.lg} 0`,
|
||||
}
|
||||
|
||||
const statsCardStyle: React.CSSProperties = {
|
||||
backgroundColor: tokens.primaryGlow,
|
||||
border: `1px solid ${tokens.primary}30`,
|
||||
borderRadius: tokens.radiusCard,
|
||||
padding: `${sp.md} ${sp.lg}`,
|
||||
margin: `${sp.lg} 0`,
|
||||
}
|
||||
|
||||
const statsTitleStyle: React.CSSProperties = {
|
||||
color: tokens.primaryDeep,
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
margin: `0 0 ${sp.md} 0`,
|
||||
}
|
||||
|
||||
const statRowStyle: React.CSSProperties = {
|
||||
display: 'block',
|
||||
margin: `${sp.sm} 0`,
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.4',
|
||||
}
|
||||
|
||||
const statLabelStyle: React.CSSProperties = {
|
||||
display: 'inline-block',
|
||||
color: tokens.textMuted,
|
||||
fontWeight: 500,
|
||||
minWidth: '200px',
|
||||
}
|
||||
|
||||
const statValueStyle: React.CSSProperties = {
|
||||
color: tokens.text,
|
||||
fontWeight: 700,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}
|
||||
|
||||
const noticeStyle: React.CSSProperties = {
|
||||
color: tokens.text,
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
margin: `${sp.lg} 0 ${sp.md} 0`,
|
||||
}
|
||||
|
||||
const buttonWrapStyle: React.CSSProperties = {
|
||||
textAlign: 'center' as const,
|
||||
margin: `${sp.lg} 0 ${sp.md} 0`,
|
||||
}
|
||||
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
backgroundColor: tokens.white,
|
||||
color: tokens.primary,
|
||||
border: `1px solid ${tokens.primary}`,
|
||||
borderRadius: tokens.radiusButton,
|
||||
padding: '12px 22px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
textDecoration: 'none',
|
||||
display: 'inline-block',
|
||||
}
|
||||
|
||||
const fineprintStyle: React.CSSProperties = {
|
||||
color: tokens.textVeryMuted,
|
||||
fontSize: '12.5px',
|
||||
lineHeight: '1.5',
|
||||
margin: `${sp.md} 0 0 0`,
|
||||
}
|
||||
|
||||
const dayPlural = trial.daysRemaining > 1 ? 'jours' : 'jour'
|
||||
|
||||
return (
|
||||
<EmailLayout
|
||||
tokens={tokens}
|
||||
preview={`Plus que ${trial.daysRemaining} ${dayPlural} d'essai — récap avant le prélèvement de ${trial.priceFormatted}.`}
|
||||
brandSubtitle={`Essai · plus que ${trial.daysRemaining} ${dayPlural}`}
|
||||
landingUrl={landingUrl}
|
||||
>
|
||||
<Text style={greetingStyle}>Bonjour {user.firstName},</Text>
|
||||
<Text style={introStyle}>
|
||||
Plus que <strong>{trial.daysRemaining} {dayPlural}</strong> avant que votre
|
||||
essai bascule en Pro mensuel et que Rubis prélève{' '}
|
||||
<strong>{trial.priceFormatted}</strong> sur votre carte, le{' '}
|
||||
<strong>{trial.endsAtFormatted}</strong>. Petit récap de ce que Rubis a
|
||||
fait pour vous depuis le début :
|
||||
</Text>
|
||||
|
||||
<Section style={statsCardStyle}>
|
||||
<Text style={statsTitleStyle}>◆ Votre essai en chiffres</Text>
|
||||
<Text style={statRowStyle}>
|
||||
<span style={statLabelStyle}>Factures importées</span>
|
||||
<span style={statValueStyle}>{usage.invoicesImported}</span>
|
||||
</Text>
|
||||
<Text style={statRowStyle}>
|
||||
<span style={statLabelStyle}>Relances envoyées</span>
|
||||
<span style={statValueStyle}>{usage.remindersSent}</span>
|
||||
</Text>
|
||||
<Text style={statRowStyle}>
|
||||
<span style={statLabelStyle}>Encaissé</span>
|
||||
<span style={statValueStyle}>{usage.eurosCollected}</span>
|
||||
</Text>
|
||||
<Text style={statRowStyle}>
|
||||
<span style={statLabelStyle}>Rubis gagnés</span>
|
||||
<span style={statValueStyle}>
|
||||
{usage.rubisEarned} · ≈ {usage.hoursLiberated} libérées
|
||||
</span>
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text style={noticeStyle}>
|
||||
Si tout est OK, vous n'avez rien à faire — votre essai bascule en Pro
|
||||
au {trial.endsAtFormatted} et vous gardez vos données, vos plans et
|
||||
vos relances en cours.
|
||||
</Text>
|
||||
<Text style={noticeStyle}>
|
||||
Si vous voulez annuler, c'est en un clic depuis votre espace Rubis :
|
||||
</Text>
|
||||
|
||||
<Section style={buttonWrapStyle}>
|
||||
<Button href={portalUrl} style={buttonStyle}>
|
||||
Gérer mon abonnement
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={fineprintStyle}>
|
||||
Une question ? Répondez à ce mail, je le lis personnellement.{'\n'}
|
||||
Arthur — fondateur de Rubis
|
||||
</Text>
|
||||
</EmailLayout>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
391
apps/api/app/services/stripe_billing.ts
Normal file
391
apps/api/app/services/stripe_billing.ts
Normal file
@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Orchestration billing Stripe — fonctions pures testables, sans
|
||||
* dépendance HTTP. Le controller (`apps/api/app/controllers/billing_controller.ts`)
|
||||
* ne fait plus que parser la requête et déléguer ici.
|
||||
*
|
||||
* Toutes les fonctions sont conçues pour être appelées avec un payload
|
||||
* Stripe pré-construit (Subscription, Checkout.Session, Invoice...) ce
|
||||
* qui permet aux tests d'injecter des objets factices sans hit réseau.
|
||||
*
|
||||
* cf. docs/tech/stripe-trial-with-card.md pour l'archi cible.
|
||||
*/
|
||||
import { DateTime } from 'luxon'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import type Stripe from 'stripe'
|
||||
|
||||
import Organization from '#models/organization'
|
||||
import User from '#models/user'
|
||||
import env from '#start/env'
|
||||
import {
|
||||
TRIAL_PERIOD_DAYS,
|
||||
cycleFromLookupKey,
|
||||
getPriceByLookup,
|
||||
getStripe,
|
||||
lookupKeyFor,
|
||||
planFromLookupKey,
|
||||
} from '#services/stripe'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Customer creation (idempotent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Crée ou retrouve le Stripe Customer associé à une org. On stocke
|
||||
* `stripeCustomerId` sur l'org dès la 1re fois pour éviter les doublons.
|
||||
*
|
||||
* Idempotent : appel répétés → retourne le même ID si déjà posé.
|
||||
*/
|
||||
export async function ensureStripeCustomer(org: Organization, user: User): Promise<string> {
|
||||
if (org.stripeCustomerId) return org.stripeCustomerId
|
||||
const stripe = getStripe()
|
||||
const customer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
name: org.name || user.fullName || user.email,
|
||||
metadata: {
|
||||
organization_id: org.id,
|
||||
user_id: user.id,
|
||||
},
|
||||
})
|
||||
org.stripeCustomerId = customer.id
|
||||
await org.save()
|
||||
return customer.id
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checkout Session creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CheckoutPlan = 'pro' | 'business'
|
||||
export type CheckoutCycle = 'monthly' | 'yearly'
|
||||
|
||||
/**
|
||||
* Result type : URL hostée Stripe à laquelle rediriger l'user.
|
||||
*/
|
||||
export type CheckoutSessionResult = {
|
||||
url: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une session Checkout standard (sans essai), pour les users qui
|
||||
* ont déjà eu leur trial ou qui passent du Free direct payant.
|
||||
*
|
||||
* Pré-condition : `org.stripeCustomerId` doit être posé. Appeler
|
||||
* `ensureStripeCustomer` avant si besoin.
|
||||
*/
|
||||
export async function createCheckoutSession(opts: {
|
||||
org: Organization
|
||||
customerId: string
|
||||
plan: CheckoutPlan
|
||||
cycle: CheckoutCycle
|
||||
}): Promise<CheckoutSessionResult> {
|
||||
const { org, customerId, plan, cycle } = opts
|
||||
const price = await getPriceByLookup(lookupKeyFor(plan, cycle))
|
||||
const stripe = getStripe()
|
||||
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
customer: customerId,
|
||||
line_items: [{ price: price.id, quantity: 1 }],
|
||||
success_url: `${webUrl}/parametres/abonnement?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${webUrl}/parametres/abonnement?checkout=cancel`,
|
||||
subscription_data: {
|
||||
metadata: { organization_id: org.id, plan },
|
||||
},
|
||||
metadata: { organization_id: org.id, plan },
|
||||
allow_promotion_codes: true,
|
||||
billing_address_collection: 'auto',
|
||||
locale: 'fr',
|
||||
})
|
||||
|
||||
if (!session.url) {
|
||||
throw new Error('Stripe a renvoyé une session sans URL')
|
||||
}
|
||||
return { url: session.url, sessionId: session.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une session Checkout en mode **essai 14 jours avec CB
|
||||
* collectée**. Stripe place la subscription en `status: 'trialing'` dès
|
||||
* la complétion de la session. À J+14, Stripe prélève automatiquement
|
||||
* et passe la subscription en `active` (ou `past_due` si CB refusée).
|
||||
*
|
||||
* Garde-fou anti-double-trial : si l'org a déjà un `stripeSubscriptionId`
|
||||
* ou un `trialEndsAt` posé (donc trial déjà consommé), on throw — le
|
||||
* caller doit rediriger vers `createCheckoutSession` standard.
|
||||
*
|
||||
* Pré-condition : `org.stripeCustomerId` doit être posé.
|
||||
*/
|
||||
export async function createTrialCheckoutSession(opts: {
|
||||
org: Organization
|
||||
customerId: string
|
||||
plan: CheckoutPlan
|
||||
cycle: CheckoutCycle
|
||||
}): Promise<CheckoutSessionResult> {
|
||||
const { org, customerId, plan, cycle } = opts
|
||||
|
||||
if (org.trialEndsAt || org.stripeSubscriptionId) {
|
||||
throw new TrialAlreadyConsumedError()
|
||||
}
|
||||
|
||||
const price = await getPriceByLookup(lookupKeyFor(plan, cycle))
|
||||
const stripe = getStripe()
|
||||
const webUrl = env.get('WEB_URL', 'http://localhost:5173')
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
customer: customerId,
|
||||
line_items: [{ price: price.id, quantity: 1 }],
|
||||
/**
|
||||
* Redirige sur `/onboarding/compte` après collecte CB — le tunnel
|
||||
* onboarding reprend la main. Le webhook `checkout.session.completed`
|
||||
* persiste le `trialEndsAt` en parallèle.
|
||||
*/
|
||||
success_url: `${webUrl}/onboarding/compte?trial=started&session_id={CHECKOUT_SESSION_ID}`,
|
||||
/**
|
||||
* Si l'user ferme Checkout sans valider, on le ramène sur l'écran
|
||||
* billing avec un toast d'erreur — il peut réessayer OU cliquer le
|
||||
* fallback Free.
|
||||
*/
|
||||
cancel_url: `${webUrl}/onboarding/billing?trial=cancel`,
|
||||
subscription_data: {
|
||||
trial_period_days: TRIAL_PERIOD_DAYS,
|
||||
metadata: { organization_id: org.id, plan },
|
||||
},
|
||||
metadata: { organization_id: org.id, plan, is_trial: 'true' },
|
||||
/**
|
||||
* Important : on collecte la CB même pour un trial (sinon Stripe
|
||||
* ne pourra pas prélever à J+14). `payment_method_collection`
|
||||
* par défaut est 'always', on l'expose pour la lisibilité.
|
||||
*/
|
||||
payment_method_collection: 'always',
|
||||
allow_promotion_codes: true,
|
||||
billing_address_collection: 'auto',
|
||||
locale: 'fr',
|
||||
})
|
||||
|
||||
if (!session.url) {
|
||||
throw new Error('Stripe a renvoyé une session sans URL')
|
||||
}
|
||||
return { url: session.url, sessionId: session.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel error pour signaler à l'API qu'un user tente de démarrer un
|
||||
* essai après en avoir déjà eu un. Le controller mappe ça en 409
|
||||
* `trial_already_consumed`.
|
||||
*/
|
||||
export class TrialAlreadyConsumedError extends Error {
|
||||
constructor() {
|
||||
super('Essai déjà consommé pour cette organisation')
|
||||
this.name = 'TrialAlreadyConsumedError'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook handlers — fonctions pures testables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Applique l'état d'une Stripe Subscription à une org : plan, cycle,
|
||||
* status, period_end, trial_end. Idempotent — appeler 2× avec le même
|
||||
* payload donne le même état final.
|
||||
*
|
||||
* **Pourquoi pas privé** : extrait du controller pour testabilité.
|
||||
* Les tests construisent des `Stripe.Subscription` partiels et vérifient
|
||||
* les colonnes DB après appel.
|
||||
*/
|
||||
export async function applySubscriptionToOrg(
|
||||
orgId: string,
|
||||
subscription: Stripe.Subscription
|
||||
): Promise<void> {
|
||||
const org = await Organization.find(orgId)
|
||||
if (!org) {
|
||||
logger.warn({ orgId }, 'applySubscriptionToOrg : org introuvable')
|
||||
return
|
||||
}
|
||||
const item = subscription.items.data[0]
|
||||
if (!item) return
|
||||
const price = item.price as Stripe.Price
|
||||
const lookupKey = price.lookup_key
|
||||
const plan = planFromLookupKey(lookupKey)
|
||||
const cycle = cycleFromLookupKey(lookupKey)
|
||||
|
||||
org.plan = plan
|
||||
org.stripeSubscriptionId = subscription.id
|
||||
org.subscriptionStatus = subscription.status
|
||||
org.billingCycle = cycle
|
||||
|
||||
org.currentPeriodEnd = item.current_period_end
|
||||
? DateTime.fromSeconds(item.current_period_end)
|
||||
: null
|
||||
|
||||
/**
|
||||
* `trial_end` n'est posé par Stripe que pendant un trial. Une fois
|
||||
* passé en `active`, il reste populé (date dans le passé) — on le
|
||||
* conserve pour l'historique sans logique métier dessus. À la
|
||||
* cancellation `null` — on garde l'ancienne valeur côté org pour
|
||||
* pouvoir reconstituer "vous aviez commencé en essai".
|
||||
*/
|
||||
if (subscription.trial_end) {
|
||||
org.trialEndsAt = DateTime.fromSeconds(subscription.trial_end)
|
||||
}
|
||||
|
||||
org.cancelAtPeriodEnd =
|
||||
!!subscription.cancel_at_period_end || !!subscription.cancel_at
|
||||
await org.save()
|
||||
|
||||
logger.info(
|
||||
{
|
||||
orgId,
|
||||
plan,
|
||||
cycle,
|
||||
status: subscription.status,
|
||||
subscriptionId: subscription.id,
|
||||
trialEnd: subscription.trial_end,
|
||||
cancelAtPeriodEnd: !!subscription.cancel_at_period_end,
|
||||
},
|
||||
'Subscription appliquée à l\'org'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler `checkout.session.completed`. Fetch la subscription créée
|
||||
* et l'applique à l'org. Lookup d'org via metadata.organization_id.
|
||||
*/
|
||||
export async function handleCheckoutCompleted(session: Stripe.Checkout.Session): Promise<void> {
|
||||
const orgId = session.metadata?.['organization_id']
|
||||
if (!orgId) {
|
||||
logger.warn(
|
||||
{ sessionId: session.id },
|
||||
'checkout.completed sans organization_id en metadata'
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!session.subscription || typeof session.subscription !== 'string') return
|
||||
|
||||
const stripe = getStripe()
|
||||
const subscription = await stripe.subscriptions.retrieve(session.subscription, {
|
||||
expand: ['items.data.price'],
|
||||
})
|
||||
await applySubscriptionToOrg(orgId, subscription)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler `customer.subscription.{created,updated}`. Idempotent.
|
||||
*
|
||||
* Cherche l'org via `metadata.organization_id` puis via
|
||||
* `stripeCustomerId` en fallback (cas des subscriptions modifiées
|
||||
* côté Customer Portal qui ne propagent pas toujours la metadata).
|
||||
*/
|
||||
export async function handleSubscriptionUpdate(subscription: Stripe.Subscription): Promise<void> {
|
||||
const orgId = subscription.metadata?.['organization_id']
|
||||
if (orgId) {
|
||||
await applySubscriptionToOrg(orgId, subscription)
|
||||
return
|
||||
}
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string'
|
||||
? subscription.customer
|
||||
: subscription.customer.id
|
||||
const org = await Organization.findBy('stripeCustomerId', customerId)
|
||||
if (!org) {
|
||||
logger.warn(
|
||||
{ subscriptionId: subscription.id, customerId },
|
||||
'subscription.update : org introuvable'
|
||||
)
|
||||
return
|
||||
}
|
||||
await applySubscriptionToOrg(org.id, subscription)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler `customer.subscription.deleted`. Bascule l'org sur Free.
|
||||
*/
|
||||
export async function handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string'
|
||||
? subscription.customer
|
||||
: subscription.customer.id
|
||||
const org = await Organization.findBy('stripeCustomerId', customerId)
|
||||
if (!org) return
|
||||
org.plan = 'free'
|
||||
org.stripeSubscriptionId = null
|
||||
org.subscriptionStatus = 'canceled'
|
||||
org.billingCycle = null
|
||||
org.currentPeriodEnd = null
|
||||
org.cancelAtPeriodEnd = false
|
||||
// On NE remet PAS `trialEndsAt` à null : ça reste utile pour
|
||||
// empêcher un user de relancer un trial après avoir cancel.
|
||||
await org.save()
|
||||
logger.info({ orgId: org.id }, 'Org redescendue en plan free (subscription deleted)')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler `invoice.payment_failed`. Marque l'org en past_due (l'UI
|
||||
* affichera la bannière "votre paiement a échoué").
|
||||
*/
|
||||
export async function handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
|
||||
const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id
|
||||
if (!customerId) return
|
||||
const org = await Organization.findBy('stripeCustomerId', customerId)
|
||||
if (!org) return
|
||||
org.subscriptionStatus = 'past_due'
|
||||
await org.save()
|
||||
logger.warn(
|
||||
{ orgId: org.id, invoiceId: invoice.id },
|
||||
'Paiement échoué — org marquée past_due'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueueur de l'email recap d'essai. Indirection module-level qui
|
||||
* permet aux tests d'injecter un spy via `__setTrialRecapEnqueueForTests`
|
||||
* sans avoir besoin d'un Redis mock. En prod ça résout vers
|
||||
* `#jobs/send_trial_recap_email_job#enqueueTrialRecapEmail` via dynamic
|
||||
* import (évite le cycle services ↔ jobs au load).
|
||||
*/
|
||||
let _enqueueTrialRecap: (orgId: string, subscriptionId: string) => Promise<void> =
|
||||
async (orgId, subscriptionId) => {
|
||||
const { enqueueTrialRecapEmail } = await import('#jobs/send_trial_recap_email_job')
|
||||
return enqueueTrialRecapEmail(orgId, subscriptionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* **Test-only.** Override l'enqueueur du recap. Permet aux tests de
|
||||
* vérifier que `handleTrialWillEnd` enqueue pour la bonne org sans
|
||||
* démarrer un Worker BullMQ.
|
||||
*/
|
||||
export function __setTrialRecapEnqueueForTests(
|
||||
fn: (orgId: string, subscriptionId: string) => Promise<void>
|
||||
): void {
|
||||
_enqueueTrialRecap = fn
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler `customer.subscription.trial_will_end`. Émis par Stripe 3
|
||||
* jours avant `trial_end` (non-configurable). On enqueue le job
|
||||
* d'envoi de l'email recap.
|
||||
*
|
||||
* Le job est idempotent via `jobId` BullMQ déterministe basé sur
|
||||
* subscriptionId — un re-deliver Stripe ne crée pas un 2e envoi.
|
||||
*/
|
||||
export async function handleTrialWillEnd(subscription: Stripe.Subscription): Promise<void> {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string'
|
||||
? subscription.customer
|
||||
: subscription.customer.id
|
||||
const org = await Organization.findBy('stripeCustomerId', customerId)
|
||||
if (!org) {
|
||||
logger.warn({ subscriptionId: subscription.id }, 'trial_will_end : org introuvable')
|
||||
return
|
||||
}
|
||||
|
||||
await _enqueueTrialRecap(org.id, subscription.id)
|
||||
|
||||
logger.info(
|
||||
{ orgId: org.id, subscriptionId: subscription.id },
|
||||
'trial_will_end : job recap enqueué'
|
||||
)
|
||||
}
|
||||
@ -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<QueueName, number> = {
|
||||
@ -27,4 +34,5 @@ export const queueConcurrency: Record<QueueName, number> = {
|
||||
checkins: 5,
|
||||
kpis: 1,
|
||||
'payment-thanks': 5,
|
||||
'trial-recap': 3,
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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<TrialRecapJobData>('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')
|
||||
|
||||
@ -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')
|
||||
|
||||
112
apps/api/tests/helpers/stripe_mock.ts
Normal file
112
apps/api/tests/helpers/stripe_mock.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import type Stripe from 'stripe'
|
||||
import { __setStripeForTests } from '#services/stripe'
|
||||
|
||||
/**
|
||||
* Helper de test : injecte un client Stripe mocké au niveau du singleton
|
||||
* `getStripe()`. Toutes les fonctions du service `stripe_billing` qui
|
||||
* appellent Stripe taperont alors sur ce mock.
|
||||
*
|
||||
* Usage typique dans un test :
|
||||
*
|
||||
* const mock = installStripeMock({
|
||||
* subscriptions: {
|
||||
* retrieve: async () => fakeSubscription(...)
|
||||
* },
|
||||
* })
|
||||
* await handleCheckoutCompleted(fakeSession({...}))
|
||||
* uninstallStripeMock()
|
||||
*
|
||||
* Pour les `group.each.teardown`, appeler `uninstallStripeMock()`.
|
||||
*
|
||||
* NB : le mock n'a pas besoin d'implémenter tout Stripe, seulement les
|
||||
* méthodes utilisées par le code testé. On le typecast en `Stripe`
|
||||
* pragmatiquement.
|
||||
*/
|
||||
export type StripeMockSpec = {
|
||||
subscriptions?: Partial<Stripe['subscriptions']>
|
||||
customers?: Partial<Stripe['customers']>
|
||||
checkout?: { sessions?: Partial<Stripe['checkout']['sessions']> }
|
||||
billingPortal?: { sessions?: Partial<Stripe['billingPortal']['sessions']> }
|
||||
prices?: Partial<Stripe['prices']>
|
||||
webhooks?: Partial<Stripe['webhooks']>
|
||||
}
|
||||
|
||||
export function installStripeMock(spec: StripeMockSpec): Stripe {
|
||||
const mock = spec as unknown as Stripe
|
||||
__setStripeForTests(mock)
|
||||
return mock
|
||||
}
|
||||
|
||||
export function uninstallStripeMock(): void {
|
||||
__setStripeForTests(null)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factories d'objets Stripe — payloads partiels typés
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Crée un objet `Stripe.Subscription` minimal pour les tests. Seuls
|
||||
* `items.data[0].price.lookup_key` + `status` + dates sont consommés par
|
||||
* les handlers ; on remplit le reste avec des stubs no-op pour satisfaire
|
||||
* le type runtime.
|
||||
*/
|
||||
export function fakeSubscription(input: {
|
||||
id?: string
|
||||
customerId?: string
|
||||
status?: Stripe.Subscription.Status
|
||||
lookupKey?: string | null
|
||||
currentPeriodEnd?: number | null
|
||||
trialEnd?: number | null
|
||||
cancelAtPeriodEnd?: boolean
|
||||
cancelAt?: number | null
|
||||
organizationId?: string | null
|
||||
}): Stripe.Subscription {
|
||||
const item = {
|
||||
id: 'si_test',
|
||||
price: {
|
||||
id: 'price_test',
|
||||
object: 'price',
|
||||
lookup_key: input.lookupKey ?? 'rubis_pro_monthly',
|
||||
} as unknown as Stripe.Price,
|
||||
current_period_end: input.currentPeriodEnd ?? null,
|
||||
} as unknown as Stripe.SubscriptionItem
|
||||
return {
|
||||
id: input.id ?? 'sub_test',
|
||||
object: 'subscription',
|
||||
customer: input.customerId ?? 'cus_test',
|
||||
status: input.status ?? 'active',
|
||||
items: { data: [item] },
|
||||
cancel_at_period_end: input.cancelAtPeriodEnd ?? false,
|
||||
cancel_at: input.cancelAt ?? null,
|
||||
trial_end: input.trialEnd ?? null,
|
||||
metadata: input.organizationId
|
||||
? { organization_id: input.organizationId, plan: 'pro' }
|
||||
: {},
|
||||
} as unknown as Stripe.Subscription
|
||||
}
|
||||
|
||||
export function fakeCheckoutSession(input: {
|
||||
id?: string
|
||||
organizationId?: string | null
|
||||
subscriptionId?: string | null
|
||||
}): Stripe.Checkout.Session {
|
||||
return {
|
||||
id: input.id ?? 'cs_test',
|
||||
object: 'checkout.session',
|
||||
subscription: input.subscriptionId ?? null,
|
||||
metadata: input.organizationId ? { organization_id: input.organizationId } : {},
|
||||
} as unknown as Stripe.Checkout.Session
|
||||
}
|
||||
|
||||
export function fakeInvoice(input: {
|
||||
customerId?: string | null
|
||||
status?: string
|
||||
}): Stripe.Invoice {
|
||||
return {
|
||||
id: 'in_test',
|
||||
object: 'invoice',
|
||||
customer: input.customerId ?? 'cus_test',
|
||||
status: input.status ?? 'open',
|
||||
} as unknown as Stripe.Invoice
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
481
apps/api/tests/unit/stripe_billing.spec.ts
Normal file
481
apps/api/tests/unit/stripe_billing.spec.ts
Normal file
@ -0,0 +1,481 @@
|
||||
import { test } from '@japa/runner'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
import {
|
||||
applySubscriptionToOrg,
|
||||
createTrialCheckoutSession,
|
||||
handleCheckoutCompleted,
|
||||
handlePaymentFailed,
|
||||
handleSubscriptionDeleted,
|
||||
handleSubscriptionUpdate,
|
||||
handleTrialWillEnd,
|
||||
TrialAlreadyConsumedError,
|
||||
__setTrialRecapEnqueueForTests,
|
||||
} from '#services/stripe_billing'
|
||||
import { dispatchWebhookEvent } from '#controllers/billing_controller'
|
||||
import Organization from '#models/organization'
|
||||
|
||||
import { createTestUser } from '../helpers/auth.js'
|
||||
import {
|
||||
fakeCheckoutSession,
|
||||
fakeInvoice,
|
||||
fakeSubscription,
|
||||
installStripeMock,
|
||||
uninstallStripeMock,
|
||||
} from '../helpers/stripe_mock.js'
|
||||
|
||||
/**
|
||||
* Tests des handlers webhook + helper trial du service `stripe_billing`.
|
||||
*
|
||||
* Stratégie de mocking : on injecte un client Stripe partiel via
|
||||
* `installStripeMock()` (cf. helpers). Tous les appels SDK (retrieve,
|
||||
* sessions.create, billingPortal.create) sont stubés.
|
||||
*
|
||||
* Toutes les assertions DB tournent dans une transaction `withGlobalTransaction`
|
||||
* (auto-rollback per test) pour isoler les modifs.
|
||||
*/
|
||||
|
||||
test.group('stripe_billing — applySubscriptionToOrg', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test('persiste plan, cycle, status, trial_end depuis une subscription Stripe', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { org } = await createTestUser()
|
||||
const trialEndEpoch = Math.floor(Date.now() / 1000) + 14 * 24 * 3600
|
||||
const periodEndEpoch = trialEndEpoch + 30 * 24 * 3600
|
||||
|
||||
const sub = fakeSubscription({
|
||||
id: 'sub_test_apply',
|
||||
customerId: 'cus_test_apply',
|
||||
status: 'trialing',
|
||||
lookupKey: 'rubis_pro_monthly',
|
||||
currentPeriodEnd: periodEndEpoch,
|
||||
trialEnd: trialEndEpoch,
|
||||
})
|
||||
|
||||
await applySubscriptionToOrg(org.id, sub)
|
||||
await org.refresh()
|
||||
|
||||
assert.equal(org.plan, 'pro')
|
||||
assert.equal(org.subscriptionStatus, 'trialing')
|
||||
assert.equal(org.billingCycle, 'monthly')
|
||||
assert.equal(org.stripeSubscriptionId, 'sub_test_apply')
|
||||
assert.isNotNull(org.trialEndsAt)
|
||||
assert.equal(org.trialEndsAt?.toUnixInteger(), trialEndEpoch)
|
||||
assert.equal(org.currentPeriodEnd?.toUnixInteger(), periodEndEpoch)
|
||||
assert.isFalse(org.cancelAtPeriodEnd)
|
||||
})
|
||||
|
||||
test('détecte cancel_at_period_end (true) ET cancel_at (timestamp)', async ({ assert }) => {
|
||||
const { org: orgA } = await createTestUser()
|
||||
const { org: orgB } = await createTestUser()
|
||||
|
||||
// Cas A : cancel_at_period_end = true (API directe)
|
||||
await applySubscriptionToOrg(
|
||||
orgA.id,
|
||||
fakeSubscription({
|
||||
id: 'sub_cancel_a',
|
||||
lookupKey: 'rubis_pro_monthly',
|
||||
cancelAtPeriodEnd: true,
|
||||
})
|
||||
)
|
||||
await orgA.refresh()
|
||||
assert.isTrue(orgA.cancelAtPeriodEnd)
|
||||
|
||||
// Cas B : cancel_at = timestamp (Customer Portal)
|
||||
await applySubscriptionToOrg(
|
||||
orgB.id,
|
||||
fakeSubscription({
|
||||
id: 'sub_cancel_b',
|
||||
lookupKey: 'rubis_pro_monthly',
|
||||
cancelAt: Math.floor(Date.now() / 1000) + 86_400,
|
||||
})
|
||||
)
|
||||
await orgB.refresh()
|
||||
assert.isTrue(orgB.cancelAtPeriodEnd)
|
||||
})
|
||||
|
||||
test('idempotent : 2 appels successifs → même état final', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
const sub = fakeSubscription({
|
||||
id: 'sub_test_idem',
|
||||
status: 'active',
|
||||
lookupKey: 'rubis_business_yearly',
|
||||
})
|
||||
|
||||
await applySubscriptionToOrg(org.id, sub)
|
||||
const firstState = (await Organization.findOrFail(org.id)).$attributes
|
||||
await applySubscriptionToOrg(org.id, sub)
|
||||
const secondState = (await Organization.findOrFail(org.id)).$attributes
|
||||
|
||||
assert.equal(firstState['plan'], 'business')
|
||||
assert.equal(firstState['billingCycle'], 'yearly')
|
||||
assert.equal(firstState['subscriptionStatus'], secondState['subscriptionStatus'])
|
||||
assert.equal(firstState['stripeSubscriptionId'], secondState['stripeSubscriptionId'])
|
||||
})
|
||||
|
||||
test('org introuvable → no-op silencieux (pas de throw)', async ({ assert }) => {
|
||||
// L'UUID non existant ne doit pas casser le webhook (Stripe retry sinon).
|
||||
await assert.doesNotReject(() =>
|
||||
applySubscriptionToOrg(
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
fakeSubscription({ lookupKey: 'rubis_pro_monthly' })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
test('lookup_key inconnu → plan retombe sur free', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
await applySubscriptionToOrg(
|
||||
org.id,
|
||||
fakeSubscription({ lookupKey: 'unknown_key_xyz' })
|
||||
)
|
||||
await org.refresh()
|
||||
assert.equal(org.plan, 'free')
|
||||
assert.isNull(org.billingCycle)
|
||||
})
|
||||
})
|
||||
|
||||
test.group('stripe_billing — handleSubscriptionUpdate (lookup org)', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test('lookup via metadata.organization_id', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
await handleSubscriptionUpdate(
|
||||
fakeSubscription({
|
||||
lookupKey: 'rubis_pro_monthly',
|
||||
organizationId: org.id,
|
||||
status: 'active',
|
||||
})
|
||||
)
|
||||
await org.refresh()
|
||||
assert.equal(org.plan, 'pro')
|
||||
assert.equal(org.subscriptionStatus, 'active')
|
||||
})
|
||||
|
||||
test('fallback : lookup via stripeCustomerId si metadata absente', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
org.stripeCustomerId = 'cus_lookup_fallback'
|
||||
await org.save()
|
||||
|
||||
await handleSubscriptionUpdate(
|
||||
fakeSubscription({
|
||||
customerId: 'cus_lookup_fallback',
|
||||
lookupKey: 'rubis_pro_monthly',
|
||||
status: 'active',
|
||||
// pas de metadata.organization_id
|
||||
})
|
||||
)
|
||||
await org.refresh()
|
||||
assert.equal(org.plan, 'pro')
|
||||
})
|
||||
|
||||
test('aucun org trouvé → no-op silencieux', async ({ assert }) => {
|
||||
await assert.doesNotReject(() =>
|
||||
handleSubscriptionUpdate(
|
||||
fakeSubscription({
|
||||
customerId: 'cus_doesnt_exist_anywhere',
|
||||
lookupKey: 'rubis_pro_monthly',
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.group('stripe_billing — handleSubscriptionDeleted', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test('Pro avec sub → org passe en free + clear stripe fields', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
org.plan = 'pro'
|
||||
org.subscriptionStatus = 'active'
|
||||
org.billingCycle = 'monthly'
|
||||
org.stripeCustomerId = 'cus_to_delete'
|
||||
org.stripeSubscriptionId = 'sub_to_delete'
|
||||
org.currentPeriodEnd = DateTime.utc().plus({ days: 10 })
|
||||
org.cancelAtPeriodEnd = true
|
||||
await org.save()
|
||||
|
||||
await handleSubscriptionDeleted(
|
||||
fakeSubscription({ customerId: 'cus_to_delete', id: 'sub_to_delete' })
|
||||
)
|
||||
await org.refresh()
|
||||
|
||||
assert.equal(org.plan, 'free')
|
||||
assert.equal(org.subscriptionStatus, 'canceled')
|
||||
assert.isNull(org.stripeSubscriptionId)
|
||||
assert.isNull(org.billingCycle)
|
||||
assert.isNull(org.currentPeriodEnd)
|
||||
assert.isFalse(org.cancelAtPeriodEnd)
|
||||
})
|
||||
|
||||
test('trial_ends_at conservé après cancellation (garde-fou anti-relance trial)', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { org } = await createTestUser()
|
||||
const oldTrialEnd = DateTime.utc().minus({ days: 30 })
|
||||
org.plan = 'pro'
|
||||
org.stripeCustomerId = 'cus_keep_trial'
|
||||
org.trialEndsAt = oldTrialEnd
|
||||
await org.save()
|
||||
|
||||
await handleSubscriptionDeleted(
|
||||
fakeSubscription({ customerId: 'cus_keep_trial' })
|
||||
)
|
||||
await org.refresh()
|
||||
assert.isNotNull(org.trialEndsAt)
|
||||
assert.equal(org.trialEndsAt?.toUnixInteger(), oldTrialEnd.toUnixInteger())
|
||||
})
|
||||
|
||||
test('customer inconnu → no-op', async ({ assert }) => {
|
||||
await assert.doesNotReject(() =>
|
||||
handleSubscriptionDeleted(fakeSubscription({ customerId: 'cus_ghost' }))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.group('stripe_billing — handlePaymentFailed', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test('invoice.payment_failed → org marquée past_due', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
org.plan = 'pro'
|
||||
org.subscriptionStatus = 'active'
|
||||
org.stripeCustomerId = 'cus_payment_failed'
|
||||
await org.save()
|
||||
|
||||
await handlePaymentFailed(fakeInvoice({ customerId: 'cus_payment_failed' }))
|
||||
await org.refresh()
|
||||
assert.equal(org.subscriptionStatus, 'past_due')
|
||||
// Plan reste pro pendant le grace period Stripe (smart retries).
|
||||
assert.equal(org.plan, 'pro')
|
||||
})
|
||||
|
||||
test('customer null → no-op (jamais d\'invoice détachée)', async ({ assert }) => {
|
||||
await assert.doesNotReject(() => handlePaymentFailed(fakeInvoice({ customerId: null })))
|
||||
})
|
||||
|
||||
test('customer inconnu → no-op', async ({ assert }) => {
|
||||
await assert.doesNotReject(() =>
|
||||
handlePaymentFailed(fakeInvoice({ customerId: 'cus_ghost' }))
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.group('stripe_billing — handleTrialWillEnd (enqueue recap)', (group) => {
|
||||
let enqueueCalls: Array<{ orgId: string; subscriptionId: string }> = []
|
||||
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
group.each.setup(() => {
|
||||
enqueueCalls = []
|
||||
__setTrialRecapEnqueueForTests(async (orgId, subscriptionId) => {
|
||||
enqueueCalls.push({ orgId, subscriptionId })
|
||||
})
|
||||
return () => {
|
||||
// Reset entre les tests pour ne pas leak entre suites.
|
||||
__setTrialRecapEnqueueForTests(async () => {})
|
||||
}
|
||||
})
|
||||
|
||||
test('enqueue recap pour l\'org matchée via customerId', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
org.stripeCustomerId = 'cus_trial_will_end'
|
||||
await org.save()
|
||||
|
||||
await handleTrialWillEnd(
|
||||
fakeSubscription({
|
||||
id: 'sub_trial_99',
|
||||
customerId: 'cus_trial_will_end',
|
||||
status: 'trialing',
|
||||
})
|
||||
)
|
||||
assert.lengthOf(enqueueCalls, 1)
|
||||
assert.equal(enqueueCalls[0]?.orgId, org.id)
|
||||
assert.equal(enqueueCalls[0]?.subscriptionId, 'sub_trial_99')
|
||||
})
|
||||
|
||||
test('customer inconnu → pas d\'enqueue', async ({ assert }) => {
|
||||
await handleTrialWillEnd(
|
||||
fakeSubscription({ customerId: 'cus_ghost', status: 'trialing' })
|
||||
)
|
||||
assert.lengthOf(enqueueCalls, 0)
|
||||
})
|
||||
})
|
||||
|
||||
test.group('stripe_billing — handleCheckoutCompleted (avec Stripe mock)', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
group.each.teardown(() => uninstallStripeMock())
|
||||
|
||||
test('retrieve subscription Stripe puis apply → org Pro trialing', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
const trialEndEpoch = Math.floor(Date.now() / 1000) + 14 * 24 * 3600
|
||||
|
||||
installStripeMock({
|
||||
subscriptions: {
|
||||
retrieve: (async () =>
|
||||
fakeSubscription({
|
||||
id: 'sub_completed',
|
||||
status: 'trialing',
|
||||
lookupKey: 'rubis_pro_monthly',
|
||||
trialEnd: trialEndEpoch,
|
||||
organizationId: org.id,
|
||||
})) as unknown as import('stripe').default['subscriptions']['retrieve'],
|
||||
},
|
||||
})
|
||||
|
||||
await handleCheckoutCompleted(
|
||||
fakeCheckoutSession({
|
||||
id: 'cs_done',
|
||||
organizationId: org.id,
|
||||
subscriptionId: 'sub_completed',
|
||||
})
|
||||
)
|
||||
await org.refresh()
|
||||
assert.equal(org.plan, 'pro')
|
||||
assert.equal(org.subscriptionStatus, 'trialing')
|
||||
assert.equal(org.trialEndsAt?.toUnixInteger(), trialEndEpoch)
|
||||
})
|
||||
|
||||
test('session sans subscription → no-op', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
await handleCheckoutCompleted(
|
||||
fakeCheckoutSession({ organizationId: org.id, subscriptionId: null })
|
||||
)
|
||||
await org.refresh()
|
||||
assert.equal(org.plan, 'free') // pas touché
|
||||
})
|
||||
|
||||
test('session sans organization_id metadata → no-op', async ({ assert }) => {
|
||||
await handleCheckoutCompleted(
|
||||
fakeCheckoutSession({
|
||||
organizationId: null,
|
||||
subscriptionId: 'sub_should_be_ignored',
|
||||
})
|
||||
)
|
||||
// Pas d'assertion DB nécessaire — le no-op réussit silencieusement.
|
||||
assert.isTrue(true)
|
||||
})
|
||||
})
|
||||
|
||||
test.group('stripe_billing — createTrialCheckoutSession (idempotence)', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
group.each.teardown(() => uninstallStripeMock())
|
||||
|
||||
test('throw TrialAlreadyConsumedError si trialEndsAt déjà posé', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
org.trialEndsAt = DateTime.utc().minus({ days: 30 })
|
||||
org.stripeCustomerId = 'cus_test_idem'
|
||||
await org.save()
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
createTrialCheckoutSession({
|
||||
org,
|
||||
customerId: 'cus_test_idem',
|
||||
plan: 'pro',
|
||||
cycle: 'monthly',
|
||||
}),
|
||||
TrialAlreadyConsumedError
|
||||
)
|
||||
})
|
||||
|
||||
test('throw TrialAlreadyConsumedError si stripeSubscriptionId déjà posé', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { org } = await createTestUser()
|
||||
org.stripeSubscriptionId = 'sub_existing'
|
||||
org.stripeCustomerId = 'cus_test_idem2'
|
||||
await org.save()
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
createTrialCheckoutSession({
|
||||
org,
|
||||
customerId: 'cus_test_idem2',
|
||||
plan: 'pro',
|
||||
cycle: 'monthly',
|
||||
}),
|
||||
TrialAlreadyConsumedError
|
||||
)
|
||||
})
|
||||
|
||||
test('happy path : crée la session Checkout avec trial_period_days=14', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { org } = await createTestUser()
|
||||
org.stripeCustomerId = 'cus_happy'
|
||||
await org.save()
|
||||
|
||||
const sessionCalls: Array<Record<string, unknown>> = []
|
||||
installStripeMock({
|
||||
prices: {
|
||||
list: (async () => ({
|
||||
data: [{ id: 'price_pro_monthly_test' }],
|
||||
})) as unknown as import('stripe').default['prices']['list'],
|
||||
},
|
||||
checkout: {
|
||||
sessions: {
|
||||
create: (async (params: Record<string, unknown>) => {
|
||||
sessionCalls.push(params)
|
||||
return { id: 'cs_new', url: 'https://checkout.stripe.test/cs_new' }
|
||||
}) as unknown as import('stripe').default['checkout']['sessions']['create'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await createTrialCheckoutSession({
|
||||
org,
|
||||
customerId: 'cus_happy',
|
||||
plan: 'pro',
|
||||
cycle: 'monthly',
|
||||
})
|
||||
|
||||
assert.equal(result.url, 'https://checkout.stripe.test/cs_new')
|
||||
assert.lengthOf(sessionCalls, 1)
|
||||
const params = sessionCalls[0] as {
|
||||
subscription_data?: { trial_period_days?: number; metadata?: Record<string, string> }
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
assert.equal(params.subscription_data?.trial_period_days, 14)
|
||||
assert.equal(params.subscription_data?.metadata?.['organization_id'], org.id)
|
||||
assert.equal(params.metadata?.['is_trial'], 'true')
|
||||
})
|
||||
})
|
||||
|
||||
test.group('stripe_billing — dispatchWebhookEvent (router)', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test('event inconnu → no-op silencieux (pas de throw)', async ({ assert }) => {
|
||||
const evt = {
|
||||
id: 'evt_unknown',
|
||||
type: 'customer.tax_id.created',
|
||||
data: { object: {} },
|
||||
} as unknown as import('stripe').default.Event
|
||||
await assert.doesNotReject(() => dispatchWebhookEvent(evt))
|
||||
})
|
||||
|
||||
test('customer.subscription.deleted route bien vers handleSubscriptionDeleted', async ({
|
||||
assert,
|
||||
}) => {
|
||||
const { org } = await createTestUser()
|
||||
org.plan = 'pro'
|
||||
org.stripeCustomerId = 'cus_routed'
|
||||
org.stripeSubscriptionId = 'sub_routed'
|
||||
await org.save()
|
||||
|
||||
const evt = {
|
||||
id: 'evt_deleted',
|
||||
type: 'customer.subscription.deleted',
|
||||
data: {
|
||||
object: fakeSubscription({ customerId: 'cus_routed', id: 'sub_routed' }),
|
||||
},
|
||||
} as unknown as import('stripe').default.Event
|
||||
|
||||
await dispatchWebhookEvent(evt)
|
||||
await org.refresh()
|
||||
assert.equal(org.plan, 'free')
|
||||
assert.equal(org.subscriptionStatus, 'canceled')
|
||||
})
|
||||
})
|
||||
181
apps/api/tests/unit/trial_recap_job.spec.ts
Normal file
181
apps/api/tests/unit/trial_recap_job.spec.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { test } from '@japa/runner'
|
||||
import testUtils from '@adonisjs/core/services/test_utils'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
import {
|
||||
computeTrialUsageStats,
|
||||
formatRubisToHoursFr,
|
||||
} from '#jobs/send_trial_recap_email_job'
|
||||
import Client from '#models/client'
|
||||
import Invoice from '#models/invoice'
|
||||
import Organization from '#models/organization'
|
||||
|
||||
import { createTestUser } from '../helpers/auth.js'
|
||||
|
||||
/**
|
||||
* Tests des helpers du job trial-recap : agrégation stats + format
|
||||
* heures. Le worker `sendTrialRecapEmailJob` lui-même n'est pas testé
|
||||
* direct ici (il tape réseau mail + Stripe) — l'intégration est validée
|
||||
* en bout de chaîne via Stripe test mode lors du déploiement.
|
||||
*/
|
||||
|
||||
async function makeClientFor(org: Organization): Promise<Client> {
|
||||
return Client.create({
|
||||
organizationId: org.id,
|
||||
name: `Client ${Math.random().toString(36).slice(2, 8)}`,
|
||||
email: `client-${Math.random().toString(36).slice(2, 8)}@spec.test`,
|
||||
contactFirstName: null,
|
||||
contactLastName: null,
|
||||
phone: null,
|
||||
address: null,
|
||||
siret: null,
|
||||
notes: null,
|
||||
})
|
||||
}
|
||||
|
||||
async function makeInvoice(
|
||||
org: Organization,
|
||||
client: Client,
|
||||
opts: {
|
||||
status: Invoice['status']
|
||||
createdAt?: DateTime
|
||||
paidAt?: DateTime | null
|
||||
amountTtcCents?: number
|
||||
rubisEarned?: number
|
||||
}
|
||||
): Promise<Invoice> {
|
||||
const issue = DateTime.utc().minus({ days: 30 })
|
||||
return Invoice.create({
|
||||
organizationId: org.id,
|
||||
clientId: client.id,
|
||||
planId: null,
|
||||
numero: `F-${Math.random().toString(36).slice(2, 10)}`,
|
||||
amountTtcCents: opts.amountTtcCents ?? 100_00,
|
||||
issueDate: issue,
|
||||
dueDate: issue.plus({ days: 30 }),
|
||||
paidAt: opts.paidAt ?? null,
|
||||
status: opts.status,
|
||||
pdfStorageKey: null,
|
||||
rubisEarned: opts.rubisEarned ?? 0,
|
||||
notes: null,
|
||||
})
|
||||
}
|
||||
|
||||
test.group('trial_recap_job — formatRubisToHoursFr', () => {
|
||||
test('0 rubis = 0 min', ({ assert }) => {
|
||||
assert.equal(formatRubisToHoursFr(0), '0 min')
|
||||
})
|
||||
|
||||
test('1 rubis = 10 min', ({ assert }) => {
|
||||
assert.equal(formatRubisToHoursFr(1), '10 min')
|
||||
})
|
||||
|
||||
test('5 rubis = 50 min', ({ assert }) => {
|
||||
assert.equal(formatRubisToHoursFr(5), '50 min')
|
||||
})
|
||||
|
||||
test('6 rubis = 1 heure pile', ({ assert }) => {
|
||||
assert.equal(formatRubisToHoursFr(6), '1 h')
|
||||
})
|
||||
|
||||
test('149 rubis = 24 h 50', ({ assert }) => {
|
||||
// 149 × 10 = 1490 min = 24 h + 50 min
|
||||
assert.equal(formatRubisToHoursFr(149), '24 h 50')
|
||||
})
|
||||
|
||||
test('60 rubis = 10 h pile', ({ assert }) => {
|
||||
assert.equal(formatRubisToHoursFr(60), '10 h')
|
||||
})
|
||||
|
||||
test('zero-pad minutes < 10 (8 rubis = 1 h 20)', ({ assert }) => {
|
||||
assert.equal(formatRubisToHoursFr(8), '1 h 20')
|
||||
})
|
||||
})
|
||||
|
||||
test.group('trial_recap_job — computeTrialUsageStats', (group) => {
|
||||
group.each.setup(() => testUtils.db().withGlobalTransaction())
|
||||
|
||||
test('agrège factures + paid + rubis depuis le signup', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
const client = await makeClientFor(org)
|
||||
const signup = DateTime.utc().minus({ days: 12 })
|
||||
|
||||
// 3 factures importées depuis signup (1 paid + 2 pending)
|
||||
await makeInvoice(org, client, {
|
||||
status: 'paid',
|
||||
paidAt: DateTime.utc().minus({ days: 5 }),
|
||||
amountTtcCents: 250_00,
|
||||
rubisEarned: 4,
|
||||
})
|
||||
await makeInvoice(org, client, { status: 'pending' })
|
||||
await makeInvoice(org, client, { status: 'in_relance' })
|
||||
|
||||
const stats = await computeTrialUsageStats(org.id, signup)
|
||||
|
||||
// Une seule paid → 250 € + 4 rubis
|
||||
assert.equal(stats.invoicesImported, 3)
|
||||
assert.equal(stats.eurosCollectedCents, 250_00)
|
||||
assert.equal(stats.rubisEarned, 4)
|
||||
// remindersSent dépend de relance_tasks — 0 vu qu'on n'en crée pas
|
||||
// dans ce test → check au prochain test.
|
||||
})
|
||||
|
||||
test('factures avant `since` sont exclues du compteur', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
const client = await makeClientFor(org)
|
||||
|
||||
// Une facture vieille (2 mois avant), une récente (1 jour après signup).
|
||||
const oldInvoice = await makeInvoice(org, client, {
|
||||
status: 'paid',
|
||||
paidAt: DateTime.utc().minus({ months: 2 }),
|
||||
rubisEarned: 99,
|
||||
})
|
||||
oldInvoice.createdAt = DateTime.utc().minus({ months: 2 })
|
||||
await oldInvoice.save()
|
||||
|
||||
await makeInvoice(org, client, {
|
||||
status: 'pending',
|
||||
})
|
||||
|
||||
const signup = DateTime.utc().minus({ days: 7 })
|
||||
const stats = await computeTrialUsageStats(org.id, signup)
|
||||
|
||||
// Seule la facture créée dans la fenêtre devrait être comptée (1).
|
||||
// L'ancienne paid (paid_at avant signup) est exclue de euros/rubis.
|
||||
assert.equal(stats.invoicesImported, 1)
|
||||
assert.equal(stats.eurosCollectedCents, 0)
|
||||
assert.equal(stats.rubisEarned, 0)
|
||||
})
|
||||
|
||||
test('multi-paid → somme correcte des euros et rubis', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
const client = await makeClientFor(org)
|
||||
const signup = DateTime.utc().minus({ days: 10 })
|
||||
|
||||
await makeInvoice(org, client, {
|
||||
status: 'paid',
|
||||
paidAt: DateTime.utc().minus({ days: 8 }),
|
||||
amountTtcCents: 100_00,
|
||||
rubisEarned: 2,
|
||||
})
|
||||
await makeInvoice(org, client, {
|
||||
status: 'paid',
|
||||
paidAt: DateTime.utc().minus({ days: 3 }),
|
||||
amountTtcCents: 350_50,
|
||||
rubisEarned: 6,
|
||||
})
|
||||
|
||||
const stats = await computeTrialUsageStats(org.id, signup)
|
||||
assert.equal(stats.eurosCollectedCents, 450_50)
|
||||
assert.equal(stats.rubisEarned, 8)
|
||||
})
|
||||
|
||||
test('aucune activité → tous les compteurs à 0', async ({ assert }) => {
|
||||
const { org } = await createTestUser()
|
||||
const stats = await computeTrialUsageStats(org.id, DateTime.utc().minus({ days: 1 }))
|
||||
assert.equal(stats.invoicesImported, 0)
|
||||
assert.equal(stats.remindersSent, 0)
|
||||
assert.equal(stats.eurosCollectedCents, 0)
|
||||
assert.equal(stats.rubisEarned, 0)
|
||||
})
|
||||
})
|
||||
@ -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: {
|
||||
|
||||
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-card border border-rubis/25 bg-rubis-glow/40 px-4 py-3",
|
||||
"flex items-center gap-3 text-[12.5px] text-ink-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Clock size={14} className="text-rubis shrink-0" aria-hidden="true" />
|
||||
<p className="leading-snug flex-1 min-w-0">
|
||||
<strong className="text-ink font-semibold">Essai Pro</strong> — plus que{" "}
|
||||
<strong className="font-medium">
|
||||
{trialDays} {dayLabel}
|
||||
</strong>
|
||||
{sub.trialEndsAt ? (
|
||||
<>
|
||||
{" · prélèvement le "}
|
||||
<strong className="font-medium">{formatDate(sub.trialEndsAt)}</strong>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<Link
|
||||
to="/parametres/abonnement"
|
||||
className="shrink-0 text-[12.5px] font-medium text-rubis hover:underline underline-offset-4"
|
||||
>
|
||||
Gérer
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sub.plan !== "free") return null;
|
||||
const limit = sub.caps.activeInvoicesLimit;
|
||||
if (limit === null) return null;
|
||||
|
||||
@ -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<SubscriptionState> = {}): 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));
|
||||
});
|
||||
});
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
141
apps/web/src/routes/onboarding/billing.tsx
Normal file
141
apps/web/src/routes/onboarding/billing.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowRight, CreditCard, Shield, Sparkles } from "lucide-react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useStartTrial } from "@/lib/billing";
|
||||
import { Button } from "@rubis/ui";
|
||||
import { Eyebrow } from "@rubis/ui";
|
||||
import { Gem } from "@rubis/ui";
|
||||
|
||||
const searchSchema = z.object({
|
||||
trial: z.enum(["cancel"]).optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/onboarding/billing")({
|
||||
validateSearch: searchSchema,
|
||||
component: OnboardingBilling,
|
||||
});
|
||||
|
||||
/**
|
||||
* Écran d'onboarding billing — l'user choisit entre :
|
||||
* - Démarrer l'essai 14 j Pro avec CB (recommandé)
|
||||
* - Continuer en Free direct (2 factures, pas de CB)
|
||||
*
|
||||
* Cet écran est opt-in : il n'est pas (encore) forcé entre signup et
|
||||
* `/onboarding/compte`. Une fois validé par A/B test, on l'insère en
|
||||
* étape obligatoire (cf. docs/tech/stripe-trial-with-card.md §3).
|
||||
*
|
||||
* Search params :
|
||||
* - `?trial=cancel` → l'user a fermé Stripe Checkout sans valider, on
|
||||
* affiche un toast d'erreur + on reste sur cet écran (bouton de retry
|
||||
* + fallback Free).
|
||||
*/
|
||||
function OnboardingBilling() {
|
||||
const navigate = useNavigate();
|
||||
const search = Route.useSearch();
|
||||
const startTrial = useStartTrial();
|
||||
const [submitting, setSubmitting] = useState<"trial" | "free" | null>(null);
|
||||
|
||||
// Toast si retour de Stripe Checkout avec ?trial=cancel.
|
||||
if (search.trial === "cancel") {
|
||||
setTimeout(() => {
|
||||
toast.error(
|
||||
"Essai non démarré — vous pouvez réessayer ou continuer en Free (2 factures).",
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
setSubmitting("trial");
|
||||
try {
|
||||
const result = await startTrial.mutateAsync({});
|
||||
// Redirect direct vers Stripe Checkout.
|
||||
window.location.href = result.url;
|
||||
} catch (err) {
|
||||
setSubmitting(null);
|
||||
// 409 trial_already_consumed → on rebascule sur /parametres/abonnement
|
||||
// où l'user peut upgrade sans trial.
|
||||
if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"status" in err &&
|
||||
(err as { status: number }).status === 409
|
||||
) {
|
||||
toast.info("Essai déjà utilisé. Choisissez votre plan directement.");
|
||||
void navigate({ to: "/parametres/abonnement" });
|
||||
return;
|
||||
}
|
||||
toast.error("Impossible de démarrer l'essai. Réessayez dans un instant.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipToFree = () => {
|
||||
setSubmitting("free");
|
||||
// Pas d'appel API : on reste en Free par défaut. On continue
|
||||
// l'onboarding standard.
|
||||
void navigate({ to: "/onboarding/compte" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Eyebrow>Avant de commencer</Eyebrow>
|
||||
<h1 className="mt-3 font-display text-[34px] font-bold leading-[1.1] tracking-[-0.022em] text-ink">
|
||||
Essayez Rubis Pro 14 jours.
|
||||
</h1>
|
||||
<p className="mt-3 max-w-md text-[15px] leading-relaxed text-ink-2">
|
||||
Accès complet aux automatisations, sans contrainte. Carte demandée, non
|
||||
prélevée avant le 14e jour. Annulation en un clic depuis votre compte,
|
||||
sans question posée.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
loading={submitting === "trial"}
|
||||
disabled={submitting !== null}
|
||||
onClick={handleStartTrial}
|
||||
>
|
||||
<Sparkles size={16} aria-hidden="true" /> Démarrer mon essai 14 jours{" "}
|
||||
<ArrowRight size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
|
||||
<ul className="text-[12.5px] text-ink-3 flex flex-col gap-1.5 mt-1 pl-1">
|
||||
<li className="inline-flex items-center gap-2">
|
||||
<Gem size={9} /> Factures et OCR illimités pendant l'essai
|
||||
</li>
|
||||
<li className="inline-flex items-center gap-2">
|
||||
<Shield size={11} aria-hidden="true" /> Aucun prélèvement avant J+14 ·
|
||||
rappel email à J+12
|
||||
</li>
|
||||
<li className="inline-flex items-center gap-2">
|
||||
<CreditCard size={11} aria-hidden="true" /> Annulation en un clic, sans
|
||||
question posée
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="my-3 flex items-center gap-3 text-[12px] uppercase tracking-[0.16em] text-ink-3">
|
||||
<span className="h-px flex-1 bg-line" />
|
||||
ou
|
||||
<span className="h-px flex-1 bg-line" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="md"
|
||||
variant="secondary"
|
||||
loading={submitting === "free"}
|
||||
disabled={submitting !== null}
|
||||
onClick={handleSkipToFree}
|
||||
>
|
||||
Pas de carte ? Commencer en Free (2 factures)
|
||||
</Button>
|
||||
<p className="text-[12px] text-ink-3 leading-relaxed">
|
||||
Le plan Free fait tourner Rubis sur 2 factures actives en
|
||||
permanence — assez pour tester, pas assez pour la production solo.
|
||||
Vous pourrez passer Pro à tout moment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user