Saltar a contenido

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.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.payment_succeeded
  • invoice.payment_failed
  • Signing secret: copia el whsec_xxxSTRIPE_WEBHOOK_SECRET en tu .env.
sudo systemctl restart blimx-django

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)

{
  "product_type": "bot",
  "bot_id": "<uuid>",
  "account_id": "<uuid>",
  "plan_id": "<uuid>"
}
{
  "product_type": "website",
  "website_id": "<uuid>",
  "account_id": "<uuid>",
  "plan_id": "<uuid>"
}

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:

  1. Crea fila en invoices con el stripe_hosted_invoice_url de Stripe.
  2. services/invoice_service.py genera un PDF custom con ReportLab y lo guarda en media/invoices/.
  3. El endpoint /api/dashboard/invoices/<id>/pdf/ sirve el PDF generado (o redirige al stripe_hosted_invoice_url si el PDF local falla).

Reglas de negocio

  • 1 suscripción activa por bot — constraint uq_subscriptions_bot_usable_product en BD.
  • 1 suscripción activa por website — constraint uq_subscriptions_website_usable_product en 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_downgrade en Stripe); el webhook aplica límites cuando el periodo cambia.
  • Cancelación: cancel_at_period_end en 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 (ver 20-emails.md).
  • subscriptions.bot_id es nullable — las suscripciones de website tienen bot_id = NULL y website_id = <uuid>.
  • invoices.bot_id es nullable — mismo criterio que subscriptions.
  • Montos en BD: invoices.amount en dólares (el webhook convierte centavos Stripe con stripe_cents_to_decimal).
  • Enforcement post-webhook: idiomas y enabled_channels se escriben en tenant_backend_configs.extra vía plan_policy_engine (SSOT: plans.features.limits).
  • BILLING_WEBSITES_ENABLED=1 es 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).