--- id: "ET-INV-003" title: "Integración Stripe para Depósitos" type: "Technical Specification" status: "Done" priority: "Alta" epic: "OQI-004" project: "trading-platform" version: "1.0.0" created_date: "2025-12-05" updated_date: "2026-01-04" --- # ET-INV-003: Integración Stripe para Depósitos **Epic:** OQI-004 Cuentas de Inversión **Versión:** 1.0 **Fecha:** 2025-12-05 **Responsable:** Requirements-Analyst --- ## 1. Descripción Define la integración con Stripe Payment Intents para procesar depósitos en cuentas de inversión: - Creación de Payment Intents para depósitos - Confirmación de pagos - Manejo de webhooks de Stripe - Actualización de balances tras pagos exitosos - Manejo de errores y pagos fallidos --- ## 2. Arquitectura de Integración ``` ┌─────────────────────────────────────────────────────────────────┐ │ Stripe Integration Flow │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Frontend Backend Stripe │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ User │ │ │ │ │ │ │ │ Action │──────────►│ Create │──────►│ Payment │ │ │ └──────────┘ │ Intent │ │ Intent │ │ │ └──────────┘ └──────────┘ │ │ │ │ │ │ ▼ │ │ │ ┌──────────┐ │ │ │ │ Return │◄─────────────┘ │ │ │ Secret │ │ │ └──────────┘ │ │ │ │ │ ▼ │ │ ┌──────────┐ ┌──────────┐ │ │ │ Stripe │◄──────────│ Confirm │ │ │ │ Elements │ │ Payment │ │ │ └──────────┘ └──────────┘ │ │ │ │ │ │ │ │ ▼ │ │ ┌──────────┐ ┌──────────┐ │ │ │ Payment │───────────────────────►│ Webhook │ │ │ │ Success │ │ Handler │ │ │ └──────────┘ └──────────┘ │ │ │ │ │ ▼ │ │ ┌──────────┐ │ │ │ Update │ │ │ │ Balance │ │ │ └──────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## 3. Flujo de Depósitos con Stripe ### 3.1 Creación de Cuenta Nueva (Depósito Inicial) ``` 1. Usuario selecciona producto y monto inicial 2. Backend crea Payment Intent en Stripe 3. Backend crea cuenta en estado "pending" 4. Backend crea transacción en estado "pending" 5. Frontend confirma pago con Stripe Elements 6. Stripe envía webhook payment_intent.succeeded 7. Backend actualiza transacción a "completed" 8. Backend actualiza balance de cuenta 9. Backend notifica ML Engine para iniciar trading ``` ### 3.2 Depósito Adicional ``` 1. Usuario solicita depósito adicional 2. Backend valida cuenta activa 3. Backend crea Payment Intent 4. Backend crea transacción "pending" 5. Frontend confirma pago 6. Webhook actualiza balance 7. Backend notifica ML Engine del nuevo capital ``` --- ## 4. Implementación Stripe Service ### 4.1 Stripe Service Class ```typescript // src/services/stripe/stripe-investment.service.ts import Stripe from 'stripe'; import { AppError } from '../../utils/errors'; export interface CreateDepositPaymentIntentDto { user_id: string; account_id?: string; // Opcional para nuevas cuentas product_id: string; amount: number; payment_method_id: string; customer_id?: string; } export class StripeInvestmentService { private stripe: Stripe; constructor() { this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-11-20.acacia', typescript: true, }); } /** * Crea un Payment Intent para depósito de inversión */ async createDepositPaymentIntent( data: CreateDepositPaymentIntentDto ): Promise { try { // Crear o recuperar Stripe Customer const customerId = await this.ensureCustomer(data.user_id, data.customer_id); // Crear Payment Intent const paymentIntent = await this.stripe.paymentIntents.create({ amount: Math.round(data.amount * 100), // Convertir a centavos currency: 'usd', customer: customerId, payment_method: data.payment_method_id, confirmation_method: 'manual', confirm: false, metadata: { type: 'investment_deposit', user_id: data.user_id, product_id: data.product_id, account_id: data.account_id || 'new_account', }, description: `Investment deposit - ${data.product_id}`, statement_descriptor: 'ORBIQUANT INV', }); return paymentIntent; } catch (error) { if (error instanceof Stripe.errors.StripeError) { throw new AppError(`Stripe error: ${error.message}`, 400); } throw error; } } /** * Confirma un Payment Intent */ async confirmPaymentIntent( paymentIntentId: string, paymentMethodId?: string ): Promise { try { const params: Stripe.PaymentIntentConfirmParams = {}; if (paymentMethodId) { params.payment_method = paymentMethodId; } const paymentIntent = await this.stripe.paymentIntents.confirm( paymentIntentId, params ); return paymentIntent; } catch (error) { if (error instanceof Stripe.errors.StripeError) { throw new AppError(`Payment confirmation failed: ${error.message}`, 400); } throw error; } } /** * Recupera un Payment Intent */ async getPaymentIntent(paymentIntentId: string): Promise { try { return await this.stripe.paymentIntents.retrieve(paymentIntentId); } catch (error) { if (error instanceof Stripe.errors.StripeError) { throw new AppError(`Payment Intent not found: ${error.message}`, 404); } throw error; } } /** * Cancela un Payment Intent */ async cancelPaymentIntent(paymentIntentId: string): Promise { try { return await this.stripe.paymentIntents.cancel(paymentIntentId); } catch (error) { if (error instanceof Stripe.errors.StripeError) { throw new AppError(`Cannot cancel payment: ${error.message}`, 400); } throw error; } } /** * Asegura que el usuario tiene un Stripe Customer */ private async ensureCustomer( userId: string, existingCustomerId?: string ): Promise { if (existingCustomerId) { // Verificar que el customer existe try { await this.stripe.customers.retrieve(existingCustomerId); return existingCustomerId; } catch { // Si no existe, crear uno nuevo } } // Crear nuevo customer // Nota: En producción, buscar en DB si ya existe customer_id para este usuario const customer = await this.stripe.customers.create({ metadata: { user_id: userId, }, }); return customer.id; } /** * Adjunta un Payment Method a un Customer */ async attachPaymentMethod( paymentMethodId: string, customerId: string ): Promise { try { return await this.stripe.paymentMethods.attach(paymentMethodId, { customer: customerId, }); } catch (error) { if (error instanceof Stripe.errors.StripeError) { throw new AppError(`Cannot attach payment method: ${error.message}`, 400); } throw error; } } /** * Lista Payment Methods de un Customer */ async listPaymentMethods(customerId: string): Promise { try { const paymentMethods = await this.stripe.paymentMethods.list({ customer: customerId, type: 'card', }); return paymentMethods.data; } catch (error) { if (error instanceof Stripe.errors.StripeError) { throw new AppError(`Cannot list payment methods: ${error.message}`, 400); } throw error; } } } ``` ### 4.2 Webhook Handler ```typescript // src/services/stripe/stripe-webhook.service.ts import Stripe from 'stripe'; import { Request } from 'express'; import { InvestmentRepository } from '../../modules/investment/investment.repository'; import { MLEngineService } from '../ml-engine/ml-engine.service'; import { logger } from '../../utils/logger'; export class StripeWebhookService { private stripe: Stripe; private investmentRepo: InvestmentRepository; private mlEngineService: MLEngineService; private webhookSecret: string; constructor() { this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-11-20.acacia', }); this.investmentRepo = new InvestmentRepository(); this.mlEngineService = new MLEngineService(); 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 del webhook 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}`); } // Procesar evento según tipo switch (event.type) { 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; default: logger.info('Unhandled webhook event type', { type: event.type }); } } /** * Maneja pago exitoso */ private async handlePaymentIntentSucceeded( paymentIntent: Stripe.PaymentIntent ): Promise { const { metadata } = paymentIntent; if (metadata.type !== 'investment_deposit') { return; // No es un depósito de inversión } logger.info('Processing successful investment deposit', { payment_intent_id: paymentIntent.id, amount: paymentIntent.amount / 100, account_id: metadata.account_id, }); try { // Buscar transacción pendiente const transaction = await this.investmentRepo.getTransactionByStripePaymentIntent( paymentIntent.id ); if (!transaction) { logger.error('Transaction not found for payment intent', { payment_intent_id: paymentIntent.id, }); return; } if (transaction.status === 'completed') { logger.warn('Transaction already completed', { transaction_id: transaction.id }); return; // Evitar procesamiento duplicado } // Actualizar cuenta const account = await this.investmentRepo.getAccountById(transaction.account_id); if (!account) { throw new Error(`Account not found: ${transaction.account_id}`); } const newBalance = account.current_balance + transaction.amount; // Actualizar en transacción await this.investmentRepo.updateAccount(account.id, { current_balance: newBalance, total_deposited: account.total_deposited + transaction.amount, status: 'active', }); // Actualizar transacción await this.investmentRepo.updateTransaction(transaction.id, { status: 'completed', balance_after: newBalance, processed_at: new Date(), }); // Notificar ML Engine await this.mlEngineService.notifyDeposit({ account_id: account.id, product_id: account.product_id, amount: transaction.amount, new_balance: newBalance, }); logger.info('Investment deposit processed successfully', { account_id: account.id, amount: transaction.amount, new_balance: newBalance, }); } catch (error: any) { logger.error('Error processing successful payment', { error: error.message, payment_intent_id: paymentIntent.id, }); throw error; } } /** * Maneja pago fallido */ private async handlePaymentIntentFailed( paymentIntent: Stripe.PaymentIntent ): Promise { const { metadata } = paymentIntent; if (metadata.type !== 'investment_deposit') { return; } logger.warn('Investment deposit payment failed', { payment_intent_id: paymentIntent.id, reason: paymentIntent.last_payment_error?.message, }); try { const transaction = await this.investmentRepo.getTransactionByStripePaymentIntent( paymentIntent.id ); if (transaction) { await this.investmentRepo.updateTransaction(transaction.id, { status: 'failed', notes: paymentIntent.last_payment_error?.message || 'Payment failed', }); // Si es depósito inicial, marcar cuenta como failed if (metadata.account_id === 'new_account') { // La cuenta fue creada pero el pago falló // Opción: eliminar cuenta o marcarla como "pending_payment" } } } catch (error: any) { logger.error('Error handling failed payment', { error: error.message, payment_intent_id: paymentIntent.id, }); } } /** * Maneja pago cancelado */ private async handlePaymentIntentCanceled( paymentIntent: Stripe.PaymentIntent ): Promise { const { metadata } = paymentIntent; if (metadata.type !== 'investment_deposit') { return; } logger.info('Investment deposit payment canceled', { payment_intent_id: paymentIntent.id, }); try { const transaction = await this.investmentRepo.getTransactionByStripePaymentIntent( paymentIntent.id ); if (transaction) { await this.investmentRepo.updateTransaction(transaction.id, { status: 'cancelled', }); } } catch (error: any) { logger.error('Error handling canceled payment', { error: error.message, payment_intent_id: paymentIntent.id, }); } } } ``` ### 4.3 Webhook Route ```typescript // src/routes/webhooks.routes.ts import { Router, Request, Response } from 'express'; import { StripeWebhookService } from '../services/stripe/stripe-webhook.service'; import { logger } from '../utils/logger'; const router = Router(); const webhookService = new StripeWebhookService(); /** * Endpoint para webhooks de Stripe * IMPORTANTE: No usar bodyParser JSON aquí, necesitamos raw body */ router.post( '/stripe', async (req: Request, res: Response) => { try { await webhookService.handleWebhook(req); res.status(200).json({ received: true }); } catch (error: any) { logger.error('Webhook processing error', { error: error.message }); res.status(400).send(`Webhook Error: ${error.message}`); } } ); export default router; ``` ### 4.4 App Configuration para Webhooks ```typescript // src/app.ts import express from 'express'; import webhookRoutes from './routes/webhooks.routes'; const app = express(); // IMPORTANTE: Webhook route ANTES de bodyParser JSON app.use( '/webhooks', express.raw({ type: 'application/json' }), // Raw body para webhooks webhookRoutes ); // Resto de middlewares app.use(express.json()); app.use(express.urlencoded({ extended: true })); // ... otras rutas export default app; ``` --- ## 5. Frontend Integration ### 5.1 Stripe Elements Component ```typescript // src/components/investment/DepositForm.tsx import React, { useState } from 'react'; import { loadStripe } from '@stripe/stripe-js'; import { Elements, CardElement, useStripe, useElements, } from '@stripe/react-stripe-js'; import { investmentApi } from '../../api/investment.api'; const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY!); interface DepositFormProps { accountId?: string; productId: string; minAmount: number; onSuccess: () => void; } const DepositFormContent: React.FC = ({ accountId, productId, minAmount, onSuccess, }) => { const stripe = useStripe(); const elements = useElements(); const [amount, setAmount] = useState(minAmount); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!stripe || !elements) { return; } setLoading(true); setError(null); try { // Crear Payment Method const cardElement = elements.getElement(CardElement); if (!cardElement) { throw new Error('Card element not found'); } const { error: pmError, paymentMethod } = await stripe.createPaymentMethod({ type: 'card', card: cardElement, }); if (pmError || !paymentMethod) { throw new Error(pmError?.message || 'Failed to create payment method'); } // Crear depósito o cuenta const response = accountId ? await investmentApi.deposit(accountId, { amount, payment_method_id: paymentMethod.id, }) : await investmentApi.createAccount({ product_id: productId, initial_investment: amount, payment_method_id: paymentMethod.id, }); const { payment_intent } = response.data; // Confirmar Payment Intent const { error: confirmError, paymentIntent } = await stripe.confirmCardPayment( payment_intent.client_secret ); if (confirmError) { throw new Error(confirmError.message); } if (paymentIntent?.status === 'succeeded') { onSuccess(); } else { throw new Error('Payment not completed'); } } catch (err: any) { setError(err.message || 'An error occurred'); } finally { setLoading(false); } }; return (
setAmount(Number(e.target.value))} disabled={loading} required /> Minimum: ${minAmount}
{error &&
{error}
}
); }; export const DepositForm: React.FC = (props) => { return ( ); }; ``` --- ## 6. Configuración ### 6.1 Variables de Entorno ```bash # Stripe Keys STRIPE_SECRET_KEY=sk_test_51abc... STRIPE_PUBLISHABLE_KEY=pk_test_51abc... STRIPE_WEBHOOK_SECRET=whsec_abc123... # Stripe Settings STRIPE_API_VERSION=2024-11-20.acacia STRIPE_CURRENCY=usd # Frontend REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_51abc... ``` ### 6.2 Configuración de Webhook en Stripe Dashboard 1. Ir a Stripe Dashboard > Developers > Webhooks 2. Crear endpoint: `https://api.trading.com/webhooks/stripe` 3. Seleccionar eventos: - `payment_intent.succeeded` - `payment_intent.payment_failed` - `payment_intent.canceled` 4. Copiar Webhook Secret y agregar a `.env` --- ## 7. Seguridad ### 7.1 Validación de Webhooks ```typescript // Siempre verificar firma del webhook const event = stripe.webhooks.constructEvent( req.body, signature, webhookSecret ); ``` ### 7.2 Idempotencia ```typescript // Verificar que transacción no esté ya procesada if (transaction.status === 'completed') { logger.warn('Transaction already completed'); return; } ``` ### 7.3 Metadata Segura ```typescript // No incluir información sensible en metadata metadata: { type: 'investment_deposit', user_id: userId, product_id: productId, // NO incluir: passwords, tokens, PII } ``` --- ## 8. Manejo de Errores ### 8.1 Errores de Stripe ```typescript try { // Stripe operation } catch (error) { if (error instanceof Stripe.errors.StripeCardError) { // Card declined return { error: 'Card was declined' }; } else if (error instanceof Stripe.errors.StripeInvalidRequestError) { // Invalid parameters return { error: 'Invalid request' }; } else if (error instanceof Stripe.errors.StripeAuthenticationError) { // Authentication failed logger.error('Stripe authentication error'); return { error: 'Payment service error' }; } } ``` ### 8.2 Retry Logic ```typescript // Implementar retry para webhooks fallidos const MAX_RETRIES = 3; let retries = 0; while (retries < MAX_RETRIES) { try { await processWebhook(event); break; } catch (error) { retries++; if (retries === MAX_RETRIES) { logger.error('Max retries reached', { event_id: event.id }); throw error; } await sleep(1000 * retries); // Exponential backoff } } ``` --- ## 9. Testing ### 9.1 Test Cards de Stripe ```typescript // Test cards para diferentes escenarios const TEST_CARDS = { success: '4242424242424242', declined: '4000000000000002', insufficientFunds: '4000000000009995', expiredCard: '4000000000000069', processingError: '4000000000000119', }; ``` ### 9.2 Test de Webhooks ```typescript // tests/stripe/webhook.test.ts import { StripeWebhookService } from '../../src/services/stripe/stripe-webhook.service'; import Stripe from 'stripe'; describe('Stripe Webhook Service', () => { let webhookService: StripeWebhookService; let stripe: Stripe; beforeAll(() => { webhookService = new StripeWebhookService(); stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); }); it('should process payment_intent.succeeded', async () => { // Crear evento de prueba const event = stripe.webhooks.generateTestHeaderString({ payload: JSON.stringify({ type: 'payment_intent.succeeded', data: { object: { id: 'pi_test_123', amount: 500000, metadata: { type: 'investment_deposit', account_id: 'acc_123', user_id: 'user_123', }, }, }, }), secret: process.env.STRIPE_WEBHOOK_SECRET!, }); // Procesar webhook await webhookService.handleWebhook(mockRequest(event)); // Verificar que balance se actualizó const account = await getAccount('acc_123'); expect(account.current_balance).toBe(5000); }); }); ``` --- ## 10. Monitoreo ### 10.1 Logs Importantes ```typescript logger.info('Payment Intent created', { payment_intent_id: paymentIntent.id, amount: paymentIntent.amount / 100, account_id: accountId, }); logger.info('Payment succeeded', { payment_intent_id: paymentIntent.id, transaction_id: transaction.id, new_balance: newBalance, }); logger.error('Payment failed', { payment_intent_id: paymentIntent.id, error: error.message, user_id: userId, }); ``` ### 10.2 Métricas - Total de depósitos procesados - Tasa de éxito de pagos - Tiempo promedio de procesamiento - Errores de webhook --- ## 11. Referencias - [Stripe Payment Intents API](https://stripe.com/docs/payments/payment-intents) - [Stripe Webhooks Guide](https://stripe.com/docs/webhooks) - [Stripe React Elements](https://stripe.com/docs/stripe-js/react) - [Best Practices for Stripe](https://stripe.com/docs/security/best-practices)