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>
121 lines
3.9 KiB
TypeScript
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
|
|
}
|