CreaRack-SL

Servicio apply_event — Mapeo de eventos Stripe a estado local

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 HMAC
  • billing.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:

StripeLocal
trialingtrialing
activeactive
past_duepast_due
unpaidpast_due
canceledcanceled
incompletenone (checkout sin completar)
incomplete_expiredcanceled
(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"] por current_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]]