Resumen
Función core de la lógica de facturación que traduce cada evento de Stripe (verificado y autenticado) en cambios de estado en el modelo Organization. Implementa la decisión arquitectónica de “verdad en Stripe, caché mínima local”.
Ubicación: billing/services.py:apply_event(event: dict) → (status: str, org_id: int|None)
Función pública
def apply_event(event: dict) -> tuple[str, int | None]:
"""
Aplica un evento ya verificado.
Devuelve (status, org_id) donde:
- status ∈ {"processed", "skipped"}
- org_id = id de la org que se modificó, o None si es skipped
"""
Llamada por:
billing.api.stripe_webhook()— el endpoint receptor, tras verificar firma HMACbilling.tasks.reconcile_stripe_subscriptions()— reconciliación diaria (no, son subscription objects vivos)
Responsabilidad: mapear SOLO el estado de suscripción (subscription_status, current_period_end, stripe_customer_id). NO toma decisiones de producto como desactivar org, cambiar plan, etc. (PR-3+).
Eventos manejados
1. checkout.session.completed
Triggers: Usuario completa checkout en Stripe
Input:
{
"client_reference_id": "<org_id>", // O nada, se intenta por customer
"customer": "cus_..."
}
Acción:
org = Organization.objects.filter(pk=obj["client_reference_id"]).first()
if org is None:
org = _org_by_customer(obj.get("customer"))
if org.stripe_customer_id != obj["customer"]:
org.stripe_customer_id = obj["customer"]
org.save(update_fields=["stripe_customer_id", "updated_at"])
return "processed", org.id
Semántica: Liga el customer de Stripe a la org. Si ya hay una liga, es un no-op. Si no se resuelve la org → skipped.
2. customer.subscription.created / customer.subscription.updated
Triggers: Nueva suscripción o cambio de estado (p. ej. trial → active)
Input:
{
"customer": "cus_...",
"status": "trialing" | "active" | "past_due" | "unpaid" | "canceled" | "incomplete" | "incomplete_expired",
"current_period_end": 1692316800, // Epoch
"items": { // API Basil (2025-03+): items pueden tener current_period_end
"data": [{"current_period_end": 1692316800}, ...]
}
}
Acción:
org = _org_by_customer(obj.get("customer"))
if org is None:
return "skipped", None
sync_subscription_to_org(org, obj)
return "processed", org.id
Helper sync_subscription_to_org(org, sub):
- Mapea
sub["status"]→STRIPE_STATUS_MAP→Organization.subscription_status - Extrae
current_period_end(con fallback a items para API Basil) - Guarda solo si cambió
- Devuelve True/False para que reconciliación diaria sepa si hubo divergencia
Mapping de status:
| Stripe | Local |
|---|---|
trialing | trialing |
active | active |
past_due | past_due |
unpaid | past_due |
canceled | canceled |
incomplete | none (checkout sin completar) |
incomplete_expired | canceled |
| (unknown) | (conserva local, loguea warning) |
3. customer.subscription.deleted
Triggers: Suscripción cancelada en Stripe (ej. admin manualmente)
Input:
{"customer": "cus_..."}
Acción:
org = _org_by_customer(obj.get("customer"))
if org and org.subscription_status != "canceled":
org.subscription_status = "canceled"
org.save(update_fields=["subscription_status", "updated_at"])
return "processed", org.id
4. invoice.payment_failed
Triggers: Pago rechazado (tarjeta vencida, fondos insuficientes, etc.)
Input:
{"customer": "cus_..."}
Acción:
org = _org_by_customer(obj.get("customer"))
if org and org.subscription_status != "past_due":
org.subscription_status = "past_due"
org.save(update_fields=["subscription_status", "updated_at"])
return "processed", org.id
5. Tipos no manejados
Cualquier otro event_type (ej. customer.tax_id.created) → return "skipped", None. Responde 200 (Stripe no reintenta).
Helpers
_org_by_customer(customer_id: str) → Organization | None
Busca org por stripe_customer_id. Si no existe → None → skipped.
_period_end_from_subscription(sub: dict) → datetime | None
Extrae el fin de periodo del objeto subscription de Stripe.
- Primero intenta
sub.get("current_period_end")(API pre-Basil) - Si nada, escanea
sub["items"]["data"]porcurrent_period_end(API Basil 2025-03+) - Convierte epoch (int) a
datetime(tz=UTC) - Retorna None si nada
Decisiones de diseño
Caché mínima
La verdad vive en Stripe. Localmente guardamos solo:
stripe_customer_id(liga org → customer)subscription_status(estado actual)current_period_end(cuándo se renueva / vence)
No guardamos:
- El ID de la suscripción (Stripe tiene múltiples por customer si hay cambios)
- Items de facturación (esos viven en API de Stripe)
- Historial (usa Stripe Billing Logs)
Agnóstico al eje de licencia
Importante: Este código NO asume cómo se cobra (por rack, por equipo, por sitio, etc.). Eso se decide en PR-3 cuando llame al checkout, tras las conversaciones MSP de septiembre.
Todos los campos stripe_* en Organization son agnósticos — valen igual con cualquier modelo de precios.
No toca decisiones de producto
PR-2 NO cambia:
Organization.is_active— desactivar por impago es policy de negocio (PR-3+)Organization.plan— qué plan se activa/desactiva llega con el checkout (PR-3+)
Estas decisiones dependen de cómo se venda, que aún no está definido.
Testing
tests/billing/test_stripe_webhook.py cubre:
- Checkout completed (customer linked)
- Subscription created/updated (status + period_end)
- Period end fallback a items (API Basil)
- Subscription deleted (canceled)
- Invoice payment_failed (past_due)
- Unknown status (local conservado, warning logueda)
- Customer no resoluble (skipped)
- Unknown event type (skipped)
Véase también
- [[entity—billing—endpoint—stripe-webhook]]
- [[entity—billing—model—stripe-event]]
- [[entity—billing—task—stripe-reconciliation]]
- [[entity—core—model—organization]]
- [[feature—billing—stripe-pr2-webhooks]]
Referenciado desde
- Endpoint POST /api/billing/webhook/stripe — Receptor de eventos de Stripe
- Feature · Stripe PR-2: Webhooks + suscripciones en Organization (v1.67.0)
- Modelo StripeEvent — Ledger global de eventos de Stripe
- Organization · Campos Stripe (v1.67.0)
- Tarea Huey reconcile_stripe_subscriptions — Sincronización diaria Stripe ↔ local