--- id: "RF-PAY-005" title: "Sistema de Webhooks de Stripe" type: "Requirement" status: "Done" priority: "Alta" epic: "OQI-005" project: "trading-platform" version: "1.0.0" created_date: "2025-12-05" updated_date: "2026-01-04" --- # RF-PAY-005: Sistema de Webhooks de Stripe **Version:** 1.0.0 **Fecha:** 2025-12-05 **Estado:** ✅ Implementado **Prioridad:** P0 (Crítica) **Story Points:** 5 **Épica:** [OQI-005](../_MAP.md) --- ## Descripción El sistema debe recibir, validar y procesar webhooks de Stripe para mantener sincronizado el estado de pagos, suscripciones y eventos críticos, garantizando integridad de datos y acciones automatizadas. --- ## Objetivo de Negocio - Mantener estado de pagos sincronizado en tiempo real - Automatizar acciones post-pago (otorgar acceso, enviar emails) - Detectar fraudes y chargebacks inmediatamente - Manejar fallos de renovación de suscripciones - Cumplir con flujo asíncrono de Stripe --- ## Webhooks Implementados ### Webhooks de Payment Intent | Evento | Prioridad | Acción | |--------|-----------|--------| | `payment_intent.succeeded` | P0 | Actualizar pago a succeeded, otorgar acceso a recurso | | `payment_intent.payment_failed` | P0 | Actualizar pago a failed, enviar email de error | | `payment_intent.canceled` | P1 | Actualizar pago a canceled | | `payment_intent.requires_action` | P1 | Log para análisis (3DS pendiente) | ### Webhooks de Subscription | Evento | Prioridad | Acción | |--------|-----------|--------| | `customer.subscription.created` | P0 | Crear registro en BD, enviar email de bienvenida | | `customer.subscription.updated` | P0 | Sincronizar plan, status, period_end | | `customer.subscription.deleted` | P0 | Marcar como canceled, revocar acceso premium | | `customer.subscription.trial_will_end` | P1 | Enviar email recordatorio 3 días antes | ### Webhooks de Invoice | Evento | Prioridad | Acción | |--------|-----------|--------| | `invoice.payment_succeeded` | P0 | Generar factura PDF, crear Payment record | | `invoice.payment_failed` | P0 | Actualizar suscripción a past_due, enviar email de fallo | | `invoice.finalized` | P1 | Preparar factura antes de cobro | | `invoice.updated` | P2 | Sincronizar cambios de invoice | ### Webhooks de Chargebacks/Disputas | Evento | Prioridad | Acción | |--------|-----------|--------| | `charge.dispute.created` | P0 | Alertar admin, marcar pago en disputa | | `charge.dispute.closed` | P0 | Actualizar estado de disputa (won/lost) | | `charge.refunded` | P0 | Crear nota de crédito, actualizar Payment | ### Webhooks de Customer | Evento | Prioridad | Acción | |--------|-----------|--------| | `customer.updated` | P2 | Sincronizar datos de customer | | `customer.deleted` | P2 | Log para auditoría | --- ## Requisitos Funcionales ### RF-PAY-005.1: Endpoint de Webhook **DEBE:** 1. Exponer endpoint público: `POST /api/v1/payments/webhook` 2. **NO requerir** autenticación JWT 3. Validar firma de Stripe usando `STRIPE_WEBHOOK_SECRET` 4. Parsear evento con `stripe.webhooks.constructEvent()` 5. Responder `200 OK` inmediatamente (< 5s) **Implementación:** ```typescript @Post('webhook') @HttpCode(200) async handleWebhook(@Req() req: Request) { const sig = req.headers['stripe-signature']; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( req.body, sig, process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { throw new BadRequestException(`Webhook signature verification failed`); } // Procesar evento de forma asíncrona await this.webhookQueue.add('process-webhook', { event }); return { received: true }; } ``` ### RF-PAY-005.2: Validación de Firma **DEBE:** 1. Verificar `stripe-signature` header 2. Usar secret específico del webhook endpoint 3. Rechazar eventos sin firma válida (400) 4. Prevenir replay attacks (timestamp tolerance 5 min) **Seguridad:** ```typescript // Stripe valida automáticamente: // 1. Firma HMAC-SHA256 correcta // 2. Timestamp no más antiguo de 5 minutos // 3. Payload no modificado ``` ### RF-PAY-005.3: Procesamiento Asíncrono **DEBE:** 1. Responder `200 OK` antes de procesar evento 2. Agregar evento a cola (Bull/BullMQ) 3. Procesar en background worker 4. Retry automático con backoff exponencial (3 intentos) 5. Dead Letter Queue (DLQ) para eventos fallidos **Flujo:** ``` Webhook → Validar Firma → Responder 200 → Queue → Worker → Procesar ↓ Retry (si falla) ↓ DLQ (después de 3 intentos) ``` ### RF-PAY-005.4: Idempotencia **DEBE:** 1. Guardar `event.id` en tabla `webhook_events` 2. Verificar si evento ya fue procesado 3. Skip procesamiento si `processed = true` 4. Marcar como procesado después de completar **Implementación:** ```typescript async processWebhook(event: Stripe.Event) { const existing = await db.webhookEvent.findOne({ stripeEventId: event.id }); if (existing?.processed) { logger.info(`Event ${event.id} already processed, skipping`); return; } // Procesar evento... await db.webhookEvent.upsert({ stripeEventId: event.id, type: event.type, processed: true, processedAt: new Date(), }); } ``` ### RF-PAY-005.5: Manejo de Eventos Específicos #### `payment_intent.succeeded` ```typescript async handlePaymentIntentSucceeded(paymentIntent: Stripe.PaymentIntent) { // 1. Actualizar Payment en BD await db.payment.update( { stripePaymentIntentId: paymentIntent.id }, { status: 'succeeded', updatedAt: new Date() } ); // 2. Otorgar acceso al recurso if (paymentIntent.metadata.type === 'course_purchase') { await this.courseService.grantAccess( paymentIntent.metadata.userId, paymentIntent.metadata.courseId ); } // 3. Generar factura await this.invoiceService.generate(paymentIntent.id); // 4. Enviar email de confirmación await this.emailService.sendPaymentConfirmation(paymentIntent); } ``` #### `customer.subscription.updated` ```typescript async handleSubscriptionUpdated(subscription: Stripe.Subscription) { await db.subscription.update( { stripeSubscriptionId: subscription.id }, { status: subscription.status, currentPeriodStart: new Date(subscription.current_period_start * 1000), currentPeriodEnd: new Date(subscription.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, } ); // Si cambió a canceled, revocar acceso if (subscription.status === 'canceled') { await this.subscriptionService.revokeAccess(subscription.metadata.userId); } } ``` #### `invoice.payment_failed` ```typescript async handleInvoicePaymentFailed(invoice: Stripe.Invoice) { // 1. Actualizar suscripción a past_due await db.subscription.update( { stripeSubscriptionId: invoice.subscription }, { status: 'past_due' } ); // 2. Enviar email urgente await this.emailService.sendPaymentFailedAlert(invoice); // 3. Alertar admin si es cliente high-value if (invoice.amount_due > 10000) { // $100 USD await this.slackService.notifyAdmin(`Payment failed for high-value customer: ${invoice.customer_email}`); } } ``` ### RF-PAY-005.6: Logging y Auditoría **DEBE:** 1. Registrar todos los eventos recibidos en `webhook_events` 2. Incluir payload completo del evento 3. Timestamp de recepción y procesamiento 4. Estado de procesamiento (pending, success, failed) 5. Error message si falló **Modelo:** ```typescript @Entity({ name: 'webhook_events', schema: 'billing' }) class WebhookEvent { id: string; // UUID stripeEventId: string; // evt_xxx (UNIQUE) type: string; // payment_intent.succeeded payload: object; // JSON completo del evento processed: boolean; // true si se procesó exitosamente processedAt?: Date; attempts: number; // Número de intentos de procesamiento lastError?: string; // Último error si falló receivedAt: Date; createdAt: Date; } ``` --- ## Flujo de Webhook ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Stripe │ │ Webhook │ │ Queue │ │ Worker │ │ │ │ Endpoint │ │ │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ POST /webhook │ │ │ │ { event } │ │ │ │──────────────────▶│ │ │ │ │ │ │ │ │ 1. Validate │ │ │ │ signature │ │ │ │ │ │ │ │ 2. Parse event │ │ │ │ │ │ │ │ │ 3. Add to queue │ │ │──────────────────▶│ │ │ │ │ │ │◀──────────────────│ │ │ │ 200 OK │ │ │ │ { received: true }│ │ │ │ │ │ │ │ │ │ 4. Dequeue event │ │ │ │──────────────────▶│ │ │ │ │ │ │ │ │ 5. Check │ │ │ │ idempotency │ │ │ │ │ │ │ │ 6. Process │ │ │ │ event │ │ │ │ │ │ │ │ 7. Update DB │ │ │ │ │ │ │◀──────────────────│ │ │ │ Done │ │ │ │ │ ``` --- ## Reglas de Negocio ### RN-001: Orden de Eventos Stripe **NO garantiza** orden de eventos. Sistema DEBE: - Usar timestamps de Stripe para ordenar - Manejar eventos fuera de orden - No asumir que `created` llega antes de `updated` ### RN-002: Retry de Stripe Stripe reintenta webhooks automáticamente: - Cada 1h durante 3 días si respuesta != 200 - Sistema debe ser idempotente (procesar mismo evento N veces sin problema) ### RN-003: Testing de Webhooks - Usar Stripe CLI para testing local: `stripe listen --forward-to localhost:3000/payments/webhook` - Usar eventos de test en staging - Nunca usar producción para testing --- ## Configuración de Webhooks en Stripe ### Dashboard de Stripe 1. Ir a **Developers → Webhooks** 2. Click "Add endpoint" 3. URL: `https://api.orbiquant.com/payments/webhook` 4. Seleccionar eventos: **Eventos esenciales (P0):** ``` payment_intent.succeeded payment_intent.payment_failed customer.subscription.created customer.subscription.updated customer.subscription.deleted invoice.payment_succeeded invoice.payment_failed charge.dispute.created ``` **Eventos opcionales (P1-P2):** ``` customer.subscription.trial_will_end invoice.finalized charge.refunded customer.updated ``` 5. Copiar **Signing secret:** `whsec_...` --- ## Seguridad ### Validación Obligatoria - **SIEMPRE** validar firma de Stripe - **NUNCA** confiar en payload sin validar - Rechazar eventos con timestamp > 5 min (prevenir replay) ### Rate Limiting - Stripe puede enviar múltiples eventos simultáneos - No aplicar rate limiting al endpoint de webhook - Aplicar rate limiting en el worker si es necesario ### Secrets Management ```env # Desarrollo STRIPE_WEBHOOK_SECRET=whsec_test_xxx # Producción STRIPE_WEBHOOK_SECRET=whsec_xxx # NUNCA commitear secrets al repo ``` --- ## Monitoreo y Alertas ### Métricas a Rastrear - Webhooks recibidos/min - Tasa de procesamiento exitoso (%) - Eventos en DLQ - Latencia promedio de procesamiento - Eventos duplicados detectados (idempotencia) ### Alertas Críticas | Condición | Alerta | Canal | |-----------|--------|-------| | > 10 eventos en DLQ | High | Slack + Email | | Tasa de fallo > 5% | Medium | Slack | | Latencia > 30s | Low | Logs | | Disputa de pago | High | Slack + SMS | --- ## Manejo de Errores ### Errores Recuperables (Retry) - Timeout de DB - Error de red temporal - Servicio externo no disponible **Estrategia:** Retry con backoff exponencial (1s, 5s, 25s) ### Errores No Recuperables (DLQ) - Evento malformado - Usuario no existe - Pago ya procesado (idempotencia) - Validación de negocio falla **Estrategia:** Mover a DLQ, alertar admin, investigar manualmente ### Logging de Errores ```typescript try { await this.processEvent(event); } catch (error) { logger.error('Webhook processing failed', { eventId: event.id, eventType: event.type, error: error.message, stack: error.stack, }); await db.webhookEvent.update( { stripeEventId: event.id }, { lastError: error.message, attempts: db.raw('attempts + 1'), } ); throw error; // Para que Bull/BullMQ reintente } ``` --- ## Testing ### Stripe CLI ```bash # Escuchar webhooks localmente stripe listen --forward-to localhost:3000/api/v1/payments/webhook # Trigger evento específico stripe trigger payment_intent.succeeded # Trigger con metadata stripe trigger payment_intent.succeeded \ --add payment_intent:metadata.userId=user_123 \ --add payment_intent:metadata.type=course_purchase ``` ### Tests de Integración ```typescript describe('Webhook Handler', () => { it('should process payment_intent.succeeded', async () => { const event = stripe.webhooks.constructEvent( mockPayload, mockSignature, WEBHOOK_SECRET ); await webhookHandler.handle(event); const payment = await db.payment.findOne({ ... }); expect(payment.status).toBe('succeeded'); }); it('should be idempotent', async () => { await webhookHandler.handle(event); await webhookHandler.handle(event); // Procesar 2 veces const events = await db.webhookEvent.find({ stripeEventId: event.id }); expect(events).toHaveLength(1); // Solo 1 registro }); }); ``` --- ## Configuración Requerida ```env # Stripe Webhook STRIPE_WEBHOOK_SECRET=whsec_xxx # Queue (Redis) REDIS_HOST=localhost REDIS_PORT=6379 WEBHOOK_QUEUE_NAME=stripe-webhooks # Retry Config WEBHOOK_MAX_ATTEMPTS=3 WEBHOOK_RETRY_DELAY=1000 # ms WEBHOOK_RETRY_BACKOFF=exponential ``` --- ## Criterios de Aceptación - [ ] Endpoint `/payments/webhook` recibe eventos de Stripe - [ ] Firma de Stripe se valida correctamente - [ ] Eventos inválidos son rechazados con 400 - [ ] Endpoint responde 200 en < 5 segundos - [ ] Eventos se procesan de forma asíncrona en cola - [ ] Sistema es idempotente (mismos eventos no duplican acciones) - [ ] payment_intent.succeeded otorga acceso a recurso - [ ] customer.subscription.updated sincroniza estado en BD - [ ] invoice.payment_failed envía email de alerta - [ ] Eventos fallidos se mueven a DLQ después de 3 intentos - [ ] Todos los eventos se registran en `webhook_events` - [ ] Alertas se disparan para eventos críticos --- ## Especificación Técnica Relacionada - [ET-PAY-005: Webhook Processing](../especificaciones/ET-PAY-005-webhooks.md) ## Historias de Usuario Relacionadas - [US-PAY-002: Suscribirse a Plan](../historias-usuario/US-PAY-002-suscribirse.md) - [US-PAY-005: Comprar Curso](../historias-usuario/US-PAY-005-comprar-curso.md)