Configuración
1. Stripe Dashboard
- https://dashboard.stripe.com > Developers > API Keys > copia 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
- Copia el Webhook signing secret
2. Crear productos + prices
Para cada plan B2B de Studeia (mini, growth, pro_100):
- Products > Add product > nombre (ej: "Studeia Mini")
- Pricing: Recurring monthly + BRL R$ 250.00
- Copia el price_id (ej:
price_1TZk...) - Repite para USD si deseas soportar clientes internacionales
3. Env vars
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_...
Flujo de checkout
El admin hace clic en "Contratar Mini" en /institution/billing
↓
POST /api/institution/billing/checkout
Body: { planSlug: "mini" }
↓
Studeia createCheckoutSession() (lib/billing/create-checkout.ts):
1. resolveCustomerIdForProvider() — obtiene/crea stripeCustomerId
2. Detecta moneda mediante getCurrencyFromHeaders() (server-side, anti-fraude)
3. Stripe Checkout Session con line_items=[price_id correcto]
4. Retorna { url } del Stripe Checkout
↓
Frontend redirige: window.location.href = data.url
↓
El admin paga en Stripe Checkout
↓
Stripe envía webhook checkout.session.completed
↓
Studeia applyWebhookEvent():
1. Valida la firma HMAC
2. stripe.subscriptions.retrieve(subscriptionId) — siempre re-fetch (no confía en metadata)
3. Valida price_id contra allowlist
4. Cross-check tenantId entre session.metadata y subscription.metadata
5. Crea/actualiza TenantSubscription con currentPeriodEnd REAL
6. Promueve Tenant.plan
7. Crea PaymentLog (idempotente mediante [provider, externalEventId])
Hardening (reglas 129-139)
- Webhook retorna 5xx en caso de fallo: Stripe reintenta durante 3 días
- Ordering guard:
lastEventAt+lastEventIden TenantSubscription. Los eventos fuera de orden se omiten con warning + PaymentLog - Idempotencia: unique [provider, externalEventId] en PaymentLog
- Schema Zod:
z.enum(PAID_PLAN_SLUGS)en el body del checkout (anti-typo) - PII redact: PaymentLog.rawPayload pasa por
redactPaymentPayload()(elimina email/name/address/billing_details/cpfCnpj/card.last4 antes de persistir) - Past_due gate:
isAccessBlocked(status)en layouts (reemplazastatus === "suspended"). Cron/api/cron/billing-grace-expirehace la transición tras el período de gracia de 7 días
Portal Stripe self-manage
POST /api/institution/billing/portal retorna una URL temporal del portal de Stripe:
- El admin cambia de plan (upgrade/downgrade)
- Actualiza la tarjeta
- Ve facturas
- Cancela la suscripción
- Sin pasar por el soporte de Studeia
Multi-currency
| Aspecto | BRL | USD |
|---|---|---|
| Detección | Por defecto | Header x-vercel-ip-country / cf-ipcountry |
| Price IDs | STRIPE_PRICE_MINI, _GROWTH, _PRO_100 | STRIPE_PRICE_MINI_USD, _GROWTH_USD, _PRO_100_USD |
| Asaas fallback | Sí (PIX/boleto) | NO (Asaas es solo para Brasil) |
| Webhook | Mismo endpoint | Mismo endpoint |