rubis/apps/api/app/services/stripe.ts
ordinarthur b0e6f83655
All checks were successful
Build & Deploy Landing / build-and-deploy (push) Successful in 1m19s
Build & Deploy API / build-and-deploy (push) Successful in 1m44s
Build & Deploy Web / build-and-deploy (push) Successful in 41s
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>
2026-05-18 12:04:41 +02:00

121 lines
3.9 KiB
TypeScript

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
* appelle `getStripe()` qui throw si la clé manque.
*/
let _stripe: Stripe | null = null
export function getStripe(): Stripe {
if (_stripe) return _stripe
const key = env.get('STRIPE_SECRET_KEY')
if (!key) {
throw new Error(
'STRIPE_SECRET_KEY manquante. Configurer la clé dans .env avant d\'utiliser le billing.'
)
}
_stripe = new Stripe(key, {
apiVersion: '2026-04-22.dahlia',
typescript: true,
appInfo: {
name: 'Rubis sur l\'ongle',
version: '1.0.0',
},
})
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
* ces lookup_keys, et le code les retrouve via `prices.list({lookup_keys})`.
*/
export const STRIPE_LOOKUP_KEYS = {
pro_monthly: 'rubis_pro_monthly',
pro_yearly: 'rubis_pro_yearly',
business_monthly: 'rubis_business_monthly',
business_yearly: 'rubis_business_yearly',
} as const
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
* ont changé).
*/
export async function getPriceByLookup(key: StripeLookupKey): Promise<Stripe.Price> {
const stripe = getStripe()
const result = await stripe.prices.list({
lookup_keys: [key],
limit: 1,
expand: ['data.product'],
})
const price = result.data[0]
if (!price) {
throw new Error(
`Stripe Price introuvable pour lookup_key="${key}". Lancer \`node ace stripe:setup\` ?`
)
}
return price
}