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>
507 lines
14 KiB
Markdown
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)
|