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
| Status | Semántica |
|---|---|
processed | Evento mapeado con éxito a un cambio de Organization (customer vinculado, status actualizado, etc.) |
skipped | Evento no manejado (tipo desconocido) o customer no resoluble en la BD. Se responde 200 (Stripe no reintenta — es una decisión) |
error | Fallo 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_hintreceived_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]]