ML Engine Updates: - Updated BTCUSD with Polygon API data (2024-2025): 215,699 new records - Re-trained all ML models: Attention (R²: 0.223), Base, Metamodel (87.3% confidence) - Backtest results: +176.71R profit with aggressive_filter strategy Documentation Consolidation: - Created docs/99-analisis/_MAP.md index with 13 new analysis documents - Consolidated inventories: removed duplicates from orchestration/inventarios/ - Updated ML_INVENTORY.yml with BTCUSD metrics and training results - Added execution reports: FASE11-BTCUSD, correction issues, alignment validation Architecture & Integration: - Updated all module documentation with NEXUS v3.4 frontmatter - Fixed _MAP.md indexes across all folders - Updated orchestration plans and traces Files: 229 changed, 5064 insertions(+), 1872 deletions(-) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
566 lines
17 KiB
Markdown
566 lines
17 KiB
Markdown
---
|
|
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)
|