trading-platform/docs/02-definicion-modulos/OQI-005-payments-stripe/especificaciones/ET-PAY-003-webhooks.md
rckrdmrd a7cca885f0 feat: Major platform documentation and architecture updates
Changes include:
- Updated architecture documentation
- Enhanced module definitions (OQI-001 to OQI-008)
- ML integration documentation updates
- Trading strategies documentation
- Orchestration and inventory updates
- Docker configuration updates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:33:35 -06:00

507 lines
14 KiB
Markdown

---
id: "ET-PAY-003"
title: "Manejo de Webhooks Stripe"
type: "Technical Specification"
status: "Done"
priority: "Alta"
epic: "OQI-005"
project: "trading-platform"
version: "1.0.0"
created_date: "2025-12-05"
updated_date: "2026-01-04"
---
# ET-PAY-003: Manejo de Webhooks Stripe
**Epic:** OQI-005 Pagos y Stripe
**Versión:** 1.0
**Fecha:** 2025-12-05
**Responsable:** Requirements-Analyst
---
## 1. Descripción
Sistema robusto para manejar webhooks de Stripe con:
- Validación de firma
- Procesamiento de eventos
- Idempotencia
- Retry logic
- Logging y monitoreo
---
## 2. Eventos Soportados
```typescript
export const SUPPORTED_WEBHOOK_EVENTS = {
// Payment Intents
'payment_intent.succeeded': 'Pago exitoso',
'payment_intent.payment_failed': 'Pago fallido',
'payment_intent.canceled': 'Pago cancelado',
// Charges
'charge.succeeded': 'Cargo exitoso',
'charge.failed': 'Cargo fallido',
'charge.refunded': 'Cargo reembolsado',
// Customers
'customer.created': 'Customer creado',
'customer.updated': 'Customer actualizado',
'customer.deleted': 'Customer eliminado',
// Payment Methods
'payment_method.attached': 'Método de pago adjuntado',
'payment_method.detached': 'Método de pago removido',
'payment_method.updated': 'Método de pago actualizado',
// Subscriptions
'customer.subscription.created': 'Suscripción creada',
'customer.subscription.updated': 'Suscripción actualizada',
'customer.subscription.deleted': 'Suscripción cancelada',
'customer.subscription.trial_will_end': 'Trial terminando',
// Invoices
'invoice.created': 'Factura creada',
'invoice.finalized': 'Factura finalizada',
'invoice.paid': 'Factura pagada',
'invoice.payment_failed': 'Pago de factura fallido',
'invoice.payment_action_required': 'Acción requerida',
// Refunds
'charge.refund.updated': 'Reembolso actualizado',
};
```
---
## 3. Webhook Handler Implementation
```typescript
// src/services/stripe/webhook-handler.service.ts
import Stripe from 'stripe';
import { Request } from 'express';
import { PaymentRepository } from '../../modules/payments/payment.repository';
import { SubscriptionRepository } from '../../modules/subscriptions/subscription.repository';
import { logger } from '../../utils/logger';
export class StripeWebhookHandler {
private stripe: Stripe;
private paymentRepo: PaymentRepository;
private subscriptionRepo: SubscriptionRepository;
private webhookSecret: string;
constructor() {
this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
this.paymentRepo = new PaymentRepository();
this.subscriptionRepo = new SubscriptionRepository();
this.webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
}
/**
* Procesa webhook de Stripe
*/
async handleWebhook(req: Request): Promise<void> {
const signature = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
// Verificar firma
event = this.stripe.webhooks.constructEvent(
req.body,
signature,
this.webhookSecret
);
} catch (err: any) {
logger.error('Webhook signature verification failed', { error: err.message });
throw new Error(`Webhook Error: ${err.message}`);
}
// Log del evento
logger.info('Stripe webhook received', {
event_id: event.id,
event_type: event.type,
created: event.created,
});
// Verificar idempotencia
const isProcessed = await this.checkIfProcessed(event.id);
if (isProcessed) {
logger.warn('Event already processed', { event_id: event.id });
return;
}
// Procesar según tipo de evento
try {
await this.routeEvent(event);
await this.markAsProcessed(event.id);
} catch (error: any) {
logger.error('Error processing webhook event', {
event_id: event.id,
event_type: event.type,
error: error.message,
});
throw error;
}
}
/**
* Enruta evento al handler correspondiente
*/
private async routeEvent(event: Stripe.Event): Promise<void> {
switch (event.type) {
// Payment Intents
case 'payment_intent.succeeded':
await this.handlePaymentIntentSucceeded(event.data.object as Stripe.PaymentIntent);
break;
case 'payment_intent.payment_failed':
await this.handlePaymentIntentFailed(event.data.object as Stripe.PaymentIntent);
break;
case 'payment_intent.canceled':
await this.handlePaymentIntentCanceled(event.data.object as Stripe.PaymentIntent);
break;
// Subscriptions
case 'customer.subscription.created':
await this.handleSubscriptionCreated(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
// Invoices
case 'invoice.paid':
await this.handleInvoicePaid(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await this.handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
break;
// Payment Methods
case 'payment_method.attached':
await this.handlePaymentMethodAttached(event.data.object as Stripe.PaymentMethod);
break;
default:
logger.info('Unhandled webhook event type', { type: event.type });
}
}
// ============ PAYMENT INTENT HANDLERS ============
private async handlePaymentIntentSucceeded(
paymentIntent: Stripe.PaymentIntent
): Promise<void> {
logger.info('Processing payment intent succeeded', {
payment_intent_id: paymentIntent.id,
amount: paymentIntent.amount / 100,
});
try {
// Buscar pago en DB
const payment = await this.paymentRepo.getByStripePaymentIntent(paymentIntent.id);
if (!payment) {
logger.error('Payment not found for payment intent', {
payment_intent_id: paymentIntent.id,
});
return;
}
// Actualizar pago
await this.paymentRepo.update(payment.id, {
status: 'succeeded',
stripe_charge_id: paymentIntent.latest_charge as string,
paid_at: new Date(paymentIntent.created * 1000),
updated_at: new Date(),
});
// Ejecutar acciones post-pago según tipo
await this.executePostPaymentActions(payment);
logger.info('Payment intent processed successfully', {
payment_id: payment.id,
payment_intent_id: paymentIntent.id,
});
} catch (error: any) {
logger.error('Error handling payment intent succeeded', {
error: error.message,
payment_intent_id: paymentIntent.id,
});
throw error;
}
}
private async handlePaymentIntentFailed(
paymentIntent: Stripe.PaymentIntent
): Promise<void> {
logger.warn('Payment intent failed', {
payment_intent_id: paymentIntent.id,
error: paymentIntent.last_payment_error?.message,
});
const payment = await this.paymentRepo.getByStripePaymentIntent(paymentIntent.id);
if (payment) {
await this.paymentRepo.update(payment.id, {
status: 'failed',
failure_code: paymentIntent.last_payment_error?.code,
failure_message: paymentIntent.last_payment_error?.message,
updated_at: new Date(),
});
}
}
private async handlePaymentIntentCanceled(
paymentIntent: Stripe.PaymentIntent
): Promise<void> {
const payment = await this.paymentRepo.getByStripePaymentIntent(paymentIntent.id);
if (payment) {
await this.paymentRepo.update(payment.id, {
status: 'canceled',
updated_at: new Date(),
});
}
}
// ============ SUBSCRIPTION HANDLERS ============
private async handleSubscriptionCreated(
subscription: Stripe.Subscription
): Promise<void> {
logger.info('Processing subscription created', {
subscription_id: subscription.id,
customer: subscription.customer,
});
// La suscripción debería ya existir (creada por el backend)
// Este webhook es confirmación
const sub = await this.subscriptionRepo.getByStripeId(subscription.id);
if (sub) {
await this.subscriptionRepo.update(sub.id, {
status: subscription.status as any,
current_period_start: new Date(subscription.current_period_start * 1000),
current_period_end: new Date(subscription.current_period_end * 1000),
});
}
}
private async handleSubscriptionUpdated(
subscription: Stripe.Subscription
): Promise<void> {
logger.info('Processing subscription updated', {
subscription_id: subscription.id,
status: subscription.status,
});
const sub = await this.subscriptionRepo.getByStripeId(subscription.id);
if (sub) {
await this.subscriptionRepo.update(sub.id, {
status: subscription.status as any,
current_period_start: new Date(subscription.current_period_start * 1000),
current_period_end: new Date(subscription.current_period_end * 1000),
cancel_at_period_end: subscription.cancel_at_period_end,
canceled_at: subscription.canceled_at
? new Date(subscription.canceled_at * 1000)
: null,
});
}
}
private async handleSubscriptionDeleted(
subscription: Stripe.Subscription
): Promise<void> {
logger.info('Processing subscription deleted', {
subscription_id: subscription.id,
});
const sub = await this.subscriptionRepo.getByStripeId(subscription.id);
if (sub) {
await this.subscriptionRepo.update(sub.id, {
status: 'canceled',
canceled_at: new Date(),
});
}
}
// ============ INVOICE HANDLERS ============
private async handleInvoicePaid(invoice: Stripe.Invoice): Promise<void> {
logger.info('Processing invoice paid', {
invoice_id: invoice.id,
subscription: invoice.subscription,
});
// Actualizar invoice en DB
// Crear payment record si no existe
}
private async handleInvoicePaymentFailed(
invoice: Stripe.Invoice
): Promise<void> {
logger.warn('Invoice payment failed', {
invoice_id: invoice.id,
attempt_count: invoice.attempt_count,
});
// Notificar al usuario
// Actualizar estado de suscripción si aplica
}
// ============ PAYMENT METHOD HANDLERS ============
private async handlePaymentMethodAttached(
paymentMethod: Stripe.PaymentMethod
): Promise<void> {
logger.info('Payment method attached', {
payment_method_id: paymentMethod.id,
customer: paymentMethod.customer,
});
// Guardar payment method en DB
}
// ============ HELPERS ============
private async checkIfProcessed(eventId: string): Promise<boolean> {
// Implementar lógica para verificar si evento ya fue procesado
// Puede usar Redis o tabla en DB
return false;
}
private async markAsProcessed(eventId: string): Promise<void> {
// Marcar evento como procesado
// Guardar en Redis con TTL de 7 días
}
private async executePostPaymentActions(payment: any): Promise<void> {
// Ejecutar acciones específicas según tipo de pago
if (payment.payment_type === 'investment_deposit') {
// Notificar al módulo de inversión
} else if (payment.payment_type === 'subscription') {
// Activar beneficios de suscripción
}
}
}
```
---
## 4. Webhook Route
```typescript
// src/routes/webhooks.routes.ts
import { Router, Request, Response } from 'express';
import { StripeWebhookHandler } from '../services/stripe/webhook-handler.service';
const router = Router();
const webhookHandler = new StripeWebhookHandler();
router.post('/stripe', async (req: Request, res: Response) => {
try {
await webhookHandler.handleWebhook(req);
res.status(200).json({ received: true });
} catch (error: any) {
res.status(400).send(`Webhook Error: ${error.message}`);
}
});
export default router;
```
---
## 5. Idempotencia con Redis
```typescript
// src/services/cache/webhook-cache.service.ts
import { createClient } from 'redis';
export class WebhookCacheService {
private client: ReturnType<typeof createClient>;
constructor() {
this.client = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
});
this.client.connect();
}
async isProcessed(eventId: string): Promise<boolean> {
const key = `webhook:processed:${eventId}`;
const exists = await this.client.exists(key);
return exists === 1;
}
async markAsProcessed(eventId: string): Promise<void> {
const key = `webhook:processed:${eventId}`;
const TTL = 7 * 24 * 60 * 60; // 7 días
await this.client.setEx(key, TTL, '1');
}
}
```
---
## 6. Configuración
```bash
# Stripe Webhooks
STRIPE_WEBHOOK_SECRET=whsec_...
# Redis for idempotency
REDIS_URL=redis://localhost:6379
```
---
## 7. Testing
```typescript
// tests/webhooks/stripe-webhook.test.ts
import { StripeWebhookHandler } from '../../src/services/stripe/webhook-handler.service';
import Stripe from 'stripe';
describe('Stripe Webhooks', () => {
let webhookHandler: StripeWebhookHandler;
beforeAll(() => {
webhookHandler = new StripeWebhookHandler();
});
it('should process payment_intent.succeeded event', async () => {
const mockEvent = {
type: 'payment_intent.succeeded',
data: {
object: {
id: 'pi_test_123',
amount: 10000,
status: 'succeeded',
},
},
};
// Test processing
});
});
```
---
## 8. Referencias
- [Stripe Webhooks Documentation](https://stripe.com/docs/webhooks)
- [Best Practices for Webhook](https://stripe.com/docs/webhooks/best-practices)