---
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.trading.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)
