ML Engine Updates: - Updated BTCUSD with Polygon API data (2024-2025): 215,699 new records - Re-trained all ML models: Attention (R²: 0.223), Base, Metamodel (87.3% confidence) - Backtest results: +176.71R profit with aggressive_filter strategy Documentation Consolidation: - Created docs/99-analisis/_MAP.md index with 13 new analysis documents - Consolidated inventories: removed duplicates from orchestration/inventarios/ - Updated ML_INVENTORY.yml with BTCUSD metrics and training results - Added execution reports: FASE11-BTCUSD, correction issues, alignment validation Architecture & Integration: - Updated all module documentation with NEXUS v3.4 frontmatter - Fixed _MAP.md indexes across all folders - Updated orchestration plans and traces Files: 229 changed, 5064 insertions(+), 1872 deletions(-) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
26 KiB
26 KiB
| id | title | type | status | priority | epic | project | version | created_date | updated_date |
|---|---|---|---|---|---|---|---|---|---|
| ET-INV-003 | Integración Stripe para Depósitos | Technical Specification | Done | Alta | OQI-004 | trading-platform | 1.0.0 | 2025-12-05 | 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
// 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<Stripe.PaymentIntent> {
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<Stripe.PaymentIntent> {
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<Stripe.PaymentIntent> {
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<Stripe.PaymentIntent> {
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<string> {
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<Stripe.PaymentMethod> {
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<Stripe.PaymentMethod[]> {
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
// 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<void> {
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<void> {
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<void> {
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<void> {
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
// 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
// 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
// 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<DepositFormProps> = ({
accountId,
productId,
minAmount,
onSuccess,
}) => {
const stripe = useStripe();
const elements = useElements();
const [amount, setAmount] = useState<number>(minAmount);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Amount (USD)</label>
<input
type="number"
min={minAmount}
step="0.01"
value={amount}
onChange={(e) => setAmount(Number(e.target.value))}
disabled={loading}
required
/>
<small>Minimum: ${minAmount}</small>
</div>
<div className="form-group">
<label>Card Details</label>
<CardElement
options={{
style: {
base: {
fontSize: '16px',
color: '#424770',
'::placeholder': {
color: '#aab7c4',
},
},
invalid: {
color: '#9e2146',
},
},
}}
/>
</div>
{error && <div className="error-message">{error}</div>}
<button type="submit" disabled={!stripe || loading}>
{loading ? 'Processing...' : `Deposit $${amount.toFixed(2)}`}
</button>
</form>
);
};
export const DepositForm: React.FC<DepositFormProps> = (props) => {
return (
<Elements stripe={stripePromise}>
<DepositFormContent {...props} />
</Elements>
);
};
6. Configuración
6.1 Variables de Entorno
# 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
- Ir a Stripe Dashboard > Developers > Webhooks
- Crear endpoint:
https://api.trading.com/webhooks/stripe - Seleccionar eventos:
payment_intent.succeededpayment_intent.payment_failedpayment_intent.canceled
- Copiar Webhook Secret y agregar a
.env
7. Seguridad
7.1 Validación de Webhooks
// Siempre verificar firma del webhook
const event = stripe.webhooks.constructEvent(
req.body,
signature,
webhookSecret
);
7.2 Idempotencia
// Verificar que transacción no esté ya procesada
if (transaction.status === 'completed') {
logger.warn('Transaction already completed');
return;
}
7.3 Metadata Segura
// 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
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
// 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
// Test cards para diferentes escenarios
const TEST_CARDS = {
success: '4242424242424242',
declined: '4000000000000002',
insufficientFunds: '4000000000009995',
expiredCard: '4000000000000069',
processingError: '4000000000000119',
};
9.2 Test de Webhooks
// 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
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