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 { 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 }