Configuration
1. Tableau de bord Stripe
- https://dashboard.stripe.com > Developers > API Keys > copiez la Secret Key
- Webhooks > Add endpoint > URL :
https://[tenant].studeia.com/api/webhooks/stripe - Events to send :
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failed
- Copiez le Webhook signing secret
2. Créer les produits + prices
Pour chaque plan B2B de Studeia (mini, growth, pro_100) :
- Products > Add product > nom (ex : "Studeia Mini")
- Pricing : Recurring monthly + BRL R$ 250,00
- Copiez le price_id (ex :
price_1TZk...) - Répétez pour USD si vous souhaitez prendre en charge les clients internationaux
3. Variables d'environnement
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_MINI=price_...
STRIPE_PRICE_GROWTH=price_...
STRIPE_PRICE_PRO_100=price_...
STRIPE_PRICE_MINI_USD=price_...
STRIPE_PRICE_GROWTH_USD=price_...
STRIPE_PRICE_PRO_100_USD=price_...
Flux de checkout
L'admin clique sur "Souscrire Mini" dans /institution/billing
↓
POST /api/institution/billing/checkout
Body: { planSlug: "mini" }
↓
Studeia createCheckoutSession() (lib/billing/create-checkout.ts) :
1. resolveCustomerIdForProvider() — récupère/crée stripeCustomerId
2. Détecte la devise via getCurrencyFromHeaders() (côté serveur, anti-fraude)
3. Stripe Checkout Session avec line_items=[price_id correct]
4. Retourne { url } du Stripe Checkout
↓
Le frontend redirige : window.location.href = data.url
↓
L'admin paie sur Stripe Checkout
↓
Stripe envoie le webhook checkout.session.completed
↓
Studeia applyWebhookEvent() :
1. Valide la signature HMAC
2. stripe.subscriptions.retrieve(subscriptionId) — toujours re-fetch (ne fait pas confiance aux métadonnées)
3. Valide le price_id contre la liste blanche
4. Vérification croisée du tenantId entre session.metadata et subscription.metadata
5. Crée/met à jour TenantSubscription avec currentPeriodEnd RÉEL
6. Promeut Tenant.plan
7. Crée PaymentLog (idempotent via [provider, externalEventId])
Durcissement (règles 129-139)
- Le webhook retourne 5xx en cas d'échec : Stripe réessaie pendant 3 jours
- Garde d'ordonnancement :
lastEventAt+lastEventIddans TenantSubscription. Les événements hors ordre sont ignorés avec avertissement + PaymentLog - Idempotence : unique [provider, externalEventId] dans PaymentLog
- Schéma Zod :
z.enum(PAID_PLAN_SLUGS)dans le body du checkout (anti-typo) - Suppression des PII : PaymentLog.rawPayload passe par
redactPaymentPayload()(supprime email/name/address/billing_details/cpfCnpj/card.last4 avant persistance) - Blocage past_due :
isAccessBlocked(status)dans les layouts (remplacestatus === "suspended"). Le cron/api/cron/billing-grace-expireeffectue la transition après une période de grâce de 7 jours
Portail Stripe self-manage
POST /api/institution/billing/portal retourne l'URL temporaire du portail Stripe :
- L'admin change de plan (upgrade/downgrade)
- Met à jour sa carte
- Consulte les factures
- Annule l'abonnement
- Sans passer par le support Studeia
Multi-devise
| Aspect | BRL | USD |
|---|---|---|
| Détection | Par défaut | En-tête x-vercel-ip-country / cf-ipcountry |
| Price IDs | STRIPE_PRICE_MINI, _GROWTH, _PRO_100 | STRIPE_PRICE_MINI_USD, _GROWTH_USD, _PRO_100_USD |
| Fallback Asaas | Oui (PIX/boleto) | NON (Asaas est Brésil uniquement) |
| Webhook | Même endpoint | Même endpoint |