# 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { // 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 { // Marcar evento como procesado // Guardar en Redis con TTL de 7 días } private async executePostPaymentActions(payment: any): Promise { // 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; constructor() { this.client = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379', }); this.client.connect(); } async isProcessed(eventId: string): Promise { const key = `webhook:processed:${eventId}`; const exists = await this.client.exists(key); return exists === 1; } async markAsProcessed(eventId: string): Promise { 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)