@startuml uml-sequence-stripe-webhook !include _style.iuml title 9. Secuencia — Webhook Stripe con idempotencia y dispatch autoactivate on participant "Stripe API" as STRIPE box "API web (Next.js / Vercel)" #F5F5F5 participant "POST /api/v1/\nwebhooks/stripe" as RT participant "verifyAndConstruct\nEvent" as VER participant "recordAndDispatch\nEvent" as DISP participant "handle*Succeeded /\nFailed / Canceled /\nRefunded" as HND end box box "Postgres (Neon)" #F5F5F5 database "webhook_events,\npagos" as DB end box STRIPE -> RT : POST /webhooks/stripe\n(rawBody, stripe-signature) RT -> VER : verifyAndConstructEvent(rawBody, signature) VER -> VER : stripe.webhooks.constructEvent\n(rawBody, signature, whsec_secret) note right of VER Si firma invalida -> BadRequestError 400. webhook_secret se carga lazy desde STRIPE_WEBHOOK_SECRET (no se loggea). end note VER --> RT : Stripe.Event { id, type, data } RT -> DISP : recordAndDispatchEvent(event) DISP -> DB : INSERT INTO webhook_events\n(proveedor='stripe', external_id=event.id,\n event_type, payload)\nON CONFLICT (proveedor, external_id) DO NOTHING\nRETURNING id, xmax::text DB --> DISP : insertedRow o null alt event.id ya procesado (duplicado) DISP --> RT : { status: 'duplicate' } RT --> STRIPE : 200 { received: true, status: 'duplicate' } else evento nuevo DISP -> HND : dispatch(event) alt event.type = "payment_intent.succeeded" HND -> STRIPE : retrieve(payment_method)\npara obtener brand+last4 STRIPE --> HND : PaymentMethod { card: { brand, last4 } } HND -> DB : UPDATE pagos\nSET estatus='procesado',\nmetodo='tarjeta', raw_status,\nreferencia_pasarela='Visa **** 4242',\nwebhook_received_at=NOW()\nWHERE payment_intent_id = $1 DB --> HND : ok else event.type = "payment_intent.payment_failed" HND -> DB : UPDATE pagos SET estatus='fallido',\nraw_status, webhook_received_at WHERE pi_id DB --> HND : ok else event.type = "payment_intent.canceled" HND -> DB : UPDATE pagos SET estatus='fallido' (canceled) DB --> HND : ok else event.type = "charge.refunded" HND -> DB : UPDATE pagos SET estatus='reembolsado'\nWHERE pi_id = charge.payment_intent DB --> HND : ok else otro tipo no manejado HND --> DISP : { status: 'unhandled' } end HND --> DISP : result alt sin error DISP -> DB : UPDATE webhook_events\nSET processed_at=NOW() WHERE id else error en dispatch DISP -> DB : UPDATE webhook_events\nSET error=msg,\nretry_count=retry_count+1 note right of DISP Stripe reintenta automaticamente. Idempotencia garantiza que no se dupliquen efectos en pagos. end note end DB --> DISP : ok DISP --> RT : { status: 'processed' | 'unhandled' | 'error' } RT --> STRIPE : 200 { received: true, status } end footer Fuente: apps/web/src/app/api/v1/webhooks/stripe/route.ts, apps/web/src/lib/payments/webhook.ts, migrations/007_pagos_facturas_extensions.sql (webhook_events) @enduml