CreaRack-SL

Modelo StripeEvent — Ledger global de eventos de Stripe

Resumen

Tabla Django que registra cada evento recibido del webhook de Stripe, con su payload completo, estado de procesamiento y resultado. Es un ledger de auditoría inmutable, sin FK a Organization (global, sin RLS).

Ubicación: billing/models.py:StripeEvent

Estructura

class StripeEvent(models.Model):
    event_id = CharField(max_length=255, unique=True)  # evt_...
    event_type = CharField(max_length=100)              # customer.subscription.updated, etc.
    payload = JSONField()                                # Objeto completo de Stripe
    organization_id_hint = IntegerField(null=True)      # Auditoría, NO es FK
    
    STATUS_CHOICES = [
        ("processed", "Processed"),    # Aplicado con éxito
        ("skipped", "Skipped"),        # Tipo no manejado o customer no resoluble
        ("error", "Error"),            # Fallo de procesado (se reintenta)
    ]
    status = CharField(max_length=20, choices=STATUS_CHOICES)
    error = TextField(blank=True)           # Traceback si status=error
    
    received_at = DateTimeField(auto_now_add=True)
    processed_at = DateTimeField(null=True)
    
    class Meta:
        ordering = ["-received_at"]

Detalles de diseño

Sin FK a Organization (global, fuera de RLS)

La tabla es intencionalmente GLOBAL sin relación con tenant, al igual que InvitationCode:

Los webhooks llegan del exterior sin autenticación de tenant. Stripe envía eventos sin saber qué org está detrás de un customer_id. Por eso el procesado es cross-org: primero resolvemos el customer → org, luego aplicamos.

Implicación: organization_id_hint es un entero suelto para auditoría, no una FK. Así si una org se borra, los eventos quedan para investigación histórica.

Idempotencia garantizada por event_id único

Cada evento de Stripe tiene un id único (p. ej. evt_1234...). Si Stripe reenvía el mismo evento (timeout de conexión, etc.), la BD lo detecta por PRIMARY KEY y el procesado es un no-op:

record, created = StripeEvent.objects.get_or_create(
    event_id=event["id"],
    defaults={"event_type": ..., "payload": ..., "status": "skipped"}
)
if not created and record.status in ("processed", "skipped"):
    return 200  # Ya se procesó

Estados semántica

StatusSemántica
processedEvento mapeado con éxito a un cambio de Organization (customer vinculado, status actualizado, etc.)
skippedEvento no manejado (tipo desconocido) o customer no resoluble en la BD. Se responde 200 (Stripe no reintenta — es una decisión)
errorFallo de procesado (ej. pk inválido, error de BD). Se responde 500 (Stripe reintenta)

Sin historial de cambios (inmutable por diseño)

Una vez escrito (processed_at = now), no se modifica. No hay UPDATE post-creación. Así es un ledger de solo-lectura para auditoría.

Admin Django

billing/admin.py:StripeEventAdmin: solo-lectura. Sin permisos add_permission ni change_permission.

Columnas visibles:

  • event_id, event_type, status, organization_id_hint
  • received_at, processed_at

Filtrable por status y event_type, buscable por event_id.

Migración

core/migrations/0032_organization_stripe_fields.py + billing/migrations/0001_initial.py

Cargada en v1.67.0.

Véase también

  • [[entity—billing—endpoint—stripe-webhook]]
  • [[entity—billing—service—event-applier]]
  • [[entity—core—model—organization]]
  • [[feature—billing—stripe-pr2-webhooks]]
  • [[concept—saas—multi-tenancy]]