10 — Billing con Stripe¶
Resumen del flujo¶
1. Usuario elige plan en el dashboard (tab Bot o tab Website)
2. POST /api/dashboard/subscriptions/{bots|websites}/subscribe/
→ backend crea Checkout Session en Stripe con metadata del recurso
3. Browser redirige a checkout.stripe.com → usuario paga
4. Stripe envía webhook checkout.session.completed → backend crea Subscription + Invoice
5. Stripe redirige a STRIPE_SUCCESS_URL → dashboard muestra el plan activo
El ciclo es idéntico para bots y websites. Lo que cambia es el endpoint de suscripción y la metadata del checkout.
Configuración inicial en Stripe¶
1. Crear productos y precios¶
En https://dashboard.stripe.com/products, crea un Stripe Product por cada plan de pago de cada producto. El plan Free no necesita Stripe Product (no genera checkout).
Ejemplo con los productos del demo:
BD plans.name |
BD plans.product_type |
Stripe Price | plans.stripe_price_id |
|---|---|---|---|
| Starter | bot | $29/mo recurring | price_xxx_bot_starter |
| Pro | bot | $99/mo recurring | price_xxx_bot_pro |
| SmartWebsite Standard | website | $29/mo recurring | price_xxx_website_standard |
Copia los Price IDs y guárdalos en la BD:
UPDATE plans SET stripe_price_id = 'price_xxx_bot_starter' WHERE name = 'Starter' AND product_type = 'bot';
UPDATE plans SET stripe_price_id = 'price_xxx_bot_pro' WHERE name = 'Pro' AND product_type = 'bot';
UPDATE plans SET stripe_price_id = 'price_xxx_website_standard' WHERE name = 'SmartWebsite Standard' AND product_type = 'website';
2. Configurar webhook¶
En https://dashboard.stripe.com/webhooks:
- Endpoint URL:
https://app.tu-dominio.com/api/billing/stripe/webhook/ - Eventos mínimos:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failed- Signing secret: copia el
whsec_xxx→STRIPE_WEBHOOK_SECRETen tu.env.
3. Activar Customer Portal¶
En https://dashboard.stripe.com/settings/billing/portal:
- Permite: cambiar plan, cancelar, cambiar método de pago, descargar facturas.
- Return URL:
https://app.tu-dominio.com/dashboard
Variables de entorno relevantes¶
STRIPE_SECRET_KEY=sk_live_xxx # OBLIGATORIO
STRIPE_WEBHOOK_SECRET=whsec_xxx # OBLIGATORIO
BILLING_WEBSITES_ENABLED=1 # Activa el flujo billing para producto 'website'
# Si no se pone (o =0), los webhooks de website se ignoran silenciosamente
# Opcionales — se construyen automáticamente si no se ponen
STRIPE_DASHBOARD_BASE_URL=https://app.tu-dominio.com
STRIPE_SUCCESS_URL=https://app.tu-dominio.com/dashboard/products/...
STRIPE_CANCEL_URL=https://app.tu-dominio.com/dashboard/...
STRIPE_PORTAL_RETURN_URL=https://app.tu-dominio.com/dashboard
Endpoints de suscripción (dashboard)¶
Bot — iniciar checkout¶
POST /api/dashboard/subscriptions/bots/<bot_id>/subscribe/
Authorization: Bearer <access_token>
Content-Type: application/json
{ "plan_id": "<uuid>", "locale": "es" }
→ 200 { "checkout_url": "https://checkout.stripe.com/c/pay/cs_xxx" }
El frontend hace window.location.href = checkout_url.
Website — iniciar checkout¶
POST /api/dashboard/subscriptions/websites/subscribe/
Authorization: Bearer <access_token>
Content-Type: application/json
{ "plan_id": "<uuid>", "locale": "es" }
→ 200 { "checkout_url": "https://checkout.stripe.com/c/pay/cs_xxx" }
Si el account no tiene website todavía, el backend crea uno automáticamente antes de generar la sesión.
Suscripción activa de bot¶
GET /api/dashboard/subscriptions/bots/<bot_id>/current/
Authorization: Bearer <access_token>
→ 200 {
"status": "active",
"plan": { "id": "uuid", "name": "Pro", "price": "99.00" },
"current_period_end": "2026-06-25T00:00:00Z",
"cancel_at_period_end": false
}
Suscripción activa de website¶
GET /api/dashboard/subscriptions/websites/current/
Authorization: Bearer <access_token>
→ 200 {
"status": "active",
"plan": { "id": "uuid", "name": "SmartWebsite Standard", "price": "29.00" },
"current_period_end": "2026-06-25T00:00:00Z",
"cancel_at_period_end": false,
"website_id": "uuid"
}
Devuelve 404 si no hay suscripción activa de website para la cuenta.
Cancelar suscripción de bot (via Customer Portal)¶
POST /api/dashboard/subscriptions/bots/<bot_id>/cancel/
Authorization: Bearer <access_token>
{ "return_url": "https://app.tu-dominio.com/dashboard" }
→ 200 { "portal_url": "https://billing.stripe.com/p/session/xxx" }
El frontend redirige al portal donde el usuario confirma la cancelación. El webhook customer.subscription.deleted actualiza la BD.
Portal de facturación (gestión general)¶
POST /api/dashboard/billing/portal/
Authorization: Bearer <access_token>
{ "return_url": "https://app.tu-dominio.com/dashboard" }
→ 200 { "portal_url": "https://billing.stripe.com/p/session/xxx" }
Cambio de plan (bot)¶
POST /api/dashboard/billing/change-plan/
Authorization: Bearer <access_token>
{ "bot_slug": "acme", "plan_id": "<uuid>" }
→ 200 { "status": "ok", ... } # upgrade inmediato o downgrade programado
GET /api/dashboard/billing/preview-plan-change/?bot_slug=acme&plan_id=<uuid>
→ 200 { "proration_preview": {...}, "effective": "immediate|period_end" }
Requiere JWT con acceso al bot (_has_bot_access).
Facturas de la cuenta¶
GET /api/dashboard/invoices/
Authorization: Bearer <access_token>
# Parámetros opcionales:
?product_type=bot|website # filtrar por tipo de producto
?bot_id=<uuid> # filtrar por bot concreto
→ 200 [
{
"id": "uuid",
"invoice_number": "XXXX-0001",
"amount": 29.0,
"currency": "USD",
"status": "paid",
"description": "1 × SmartWebsite Standard (at $29.00 / month)",
"date": "2026-05-25",
"period_start": "2026-05-25T00:00:00Z",
"period_end": "2026-06-25T00:00:00Z",
"pdf_url": "/media/invoices/invoice_XXXX_0001.pdf",
"pdfUrl": "/api/dashboard/invoices/<uuid>/pdf/",
"product_type": "website"
}
]
bot_id IS NULL → factura de website. bot_id IS NOT NULL → factura de bot. El campo product_type se devuelve ya calculado.
Webhook (/api/billing/stripe/webhook/)¶
Implementado en backend_django/api/views_stripe_webhook.py.
Verificación de firma¶
event = stripe.Webhook.construct_event(
payload=request.body,
sig_header=request.META.get('HTTP_STRIPE_SIGNATURE'),
secret=settings.STRIPE_WEBHOOK_SECRET
)
Si la firma no valida → 400. Stripe reintenta hasta 3 días.
Idempotencia¶
Cada event.id se persiste en stripe_webhook_events. Eventos duplicados se ignoran.
Eventos manejados¶
| Evento | Acción |
|---|---|
checkout.session.completed |
Crea Subscription + activa el recurso (bot o website) |
customer.subscription.updated |
Sincroniza status, periodo, cancel_at_period_end |
customer.subscription.deleted |
Status canceled; baja el bot a Free / website a inactive |
invoice.payment_succeeded |
Crea Invoice; genera PDF; envía email "Factura disponible" |
invoice.payment_failed |
Status past_due; envía email "Pago fallido" |
El webhook detecta el producto por metadata.product_type de la sesión de checkout. Valores aceptados: bot, website. Cualquier otro → se ignora con log de warning.
Metadata del checkout (ambos productos)¶
Pruebas locales¶
stripe listen --forward-to localhost:8000/api/billing/stripe/webhook/
# Anota el whsec_xxx local y ponlo en STRIPE_WEBHOOK_SECRET (y reinicia Django)
stripe trigger checkout.session.completed
stripe events resend <evt_xxx> # reenviar un evento real de test
Generación de facturas PDF¶
Cuando llega invoice.payment_succeeded, el backend:
- Crea fila en
invoicescon elstripe_hosted_invoice_urlde Stripe. services/invoice_service.pygenera un PDF custom con ReportLab y lo guarda enmedia/invoices/.- El endpoint
/api/dashboard/invoices/<id>/pdf/sirve el PDF generado (o redirige alstripe_hosted_invoice_urlsi el PDF local falla).
Reglas de negocio¶
- 1 suscripción activa por bot — constraint
uq_subscriptions_bot_usable_producten BD. - 1 suscripción activa por website — constraint
uq_subscriptions_website_usable_producten BD. - El plan Free no genera checkout. Si el usuario selecciona Free desde un plan de pago, el backend cancela la suscripción Stripe directamente (downgrade inmediato).
- Upgrade de plan: checkout o cambio inmediato vía Stripe según endpoint; webhook sincroniza BD.
- Downgrade de plan: programado al fin de periodo (
schedule_subscription_plan_downgradeen Stripe); el webhook aplica límites cuando el periodo cambia. - Cancelación:
cancel_at_period_enden Stripe; emails transaccionales vía webhook (send_cancel_pending_email,send_payment_recovered_email). - Cierre de cuenta:
POST /api/account/close/— no desactiva bots al instante; grace period hasta fin de periodo Stripe (ver20-emails.md). subscriptions.bot_ides nullable — las suscripciones de website tienenbot_id = NULLywebsite_id = <uuid>.invoices.bot_ides nullable — mismo criterio que subscriptions.- Montos en BD:
invoices.amounten dólares (el webhook convierte centavos Stripe constripe_cents_to_decimal). - Enforcement post-webhook: idiomas y
enabled_channelsse escriben entenant_backend_configs.extravíaplan_policy_engine(SSOT:plans.features.limits). BILLING_WEBSITES_ENABLED=1es obligatorio para que el webhook procese eventos de website. Sin esa variable, los eventos se ignoran (failsafe para instancias que no tienen el producto website habilitado).
Testing de tarjetas Stripe¶
| Número | Resultado |
|---|---|
4242 4242 4242 4242 |
Pago exitoso |
4000 0027 6000 3184 |
Requiere 3D Secure |
4000 0000 0000 9995 |
Fondos insuficientes |
Usa siempre sk_test_xxx en desarrollo. En producción sustituye por sk_live_xxx y recrea el webhook (el whsec_xxx es distinto en live).