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

14 KiB

id title type status priority epic project version created_date updated_date
ET-PAY-003 Manejo de Webhooks Stripe Technical Specification Done Alta OQI-005 trading-platform 1.0.0 2025-12-05 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

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

// 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

// 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

// 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

# Stripe Webhooks
STRIPE_WEBHOOK_SECRET=whsec_...

# Redis for idempotency
REDIS_URL=redis://localhost:6379

7. Testing

// 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