rubis/apps/api/tests/helpers/stripe_mock.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

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
}