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>
113 lines
3.5 KiB
TypeScript
113 lines
3.5 KiB
TypeScript
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
|
|
}
|