trading-platform/docs/02-definicion-modulos/OQI-005-payments-stripe/especificaciones/ET-PAY-002-stripe-api.md

21 KiB

ET-PAY-002: Integración Stripe API Completa

Epic: OQI-005 Pagos y Stripe Versión: 1.0 Fecha: 2025-12-05 Responsable: Requirements-Analyst


1. Descripción

Integración completa con Stripe API para:

  • Gestión de Customers
  • Payment Intents (one-time payments)
  • Subscriptions (pagos recurrentes)
  • Payment Methods
  • Invoices
  • Refunds
  • Stripe Elements (frontend)

2. Arquitectura de Integración

┌─────────────────────────────────────────────────────────────────┐
│                   Stripe Integration Stack                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                   │
│  Frontend                Backend              Stripe API         │
│  ┌──────────────┐      ┌──────────────┐    ┌──────────────┐    │
│  │   Stripe     │      │   Stripe     │    │   Stripe     │    │
│  │  Elements    │─────►│   Service    │───►│   Platform   │    │
│  └──────────────┘      └──────────────┘    └──────────────┘    │
│        │                      │                    │            │
│        │                      │                    │            │
│        ▼                      ▼                    ▼            │
│  ┌──────────────┐      ┌──────────────┐    ┌──────────────┐   │
│  │   React      │      │  Database    │    │  Webhooks    │   │
│  │ Components   │      │   Persist    │◄───│   Handler    │   │
│  └──────────────┘      └──────────────┘    └──────────────┘   │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

3. Stripe Service Implementation

3.1 Core Stripe Service

// src/services/stripe/stripe.service.ts

import Stripe from 'stripe';
import { AppError } from '../../utils/errors';
import { logger } from '../../utils/logger';

export class StripeService {
  private stripe: Stripe;

  constructor() {
    const secretKey = process.env.STRIPE_SECRET_KEY!;

    this.stripe = new Stripe(secretKey, {
      apiVersion: '2024-11-20.acacia',
      typescript: true,
      maxNetworkRetries: 3,
      timeout: 30000,
    });
  }

  // ============ CUSTOMERS ============

  /**
   * Crea un customer en Stripe
   */
  async createCustomer(params: {
    email: string;
    name?: string;
    phone?: string;
    metadata?: Record<string, string>;
  }): Promise<Stripe.Customer> {
    try {
      const customer = await this.stripe.customers.create({
        email: params.email,
        name: params.name,
        phone: params.phone,
        metadata: params.metadata || {},
      });

      logger.info('Stripe customer created', { customer_id: customer.id });
      return customer;
    } catch (error: any) {
      logger.error('Failed to create Stripe customer', { error: error.message });
      throw new AppError('Failed to create customer', 500);
    }
  }

  /**
   * Actualiza un customer
   */
  async updateCustomer(
    customerId: string,
    params: Stripe.CustomerUpdateParams
  ): Promise<Stripe.Customer> {
    try {
      return await this.stripe.customers.update(customerId, params);
    } catch (error: any) {
      throw new AppError('Failed to update customer', 500);
    }
  }

  /**
   * Obtiene un customer
   */
  async getCustomer(customerId: string): Promise<Stripe.Customer> {
    try {
      return await this.stripe.customers.retrieve(customerId) as Stripe.Customer;
    } catch (error: any) {
      throw new AppError('Customer not found', 404);
    }
  }

  // ============ PAYMENT INTENTS ============

  /**
   * Crea un Payment Intent
   */
  async createPaymentIntent(params: {
    amount: number;
    currency?: string;
    customer_id?: string;
    payment_method?: string;
    metadata?: Record<string, string>;
    description?: string;
  }): Promise<Stripe.PaymentIntent> {
    try {
      const paymentIntent = await this.stripe.paymentIntents.create({
        amount: Math.round(params.amount * 100), // Convertir a centavos
        currency: params.currency || 'usd',
        customer: params.customer_id,
        payment_method: params.payment_method,
        confirmation_method: 'manual',
        confirm: false,
        metadata: params.metadata || {},
        description: params.description,
      });

      logger.info('Payment Intent created', {
        payment_intent_id: paymentIntent.id,
        amount: params.amount,
      });

      return paymentIntent;
    } catch (error: any) {
      logger.error('Failed to create Payment Intent', { error: error.message });
      throw new AppError('Failed to create payment intent', 500);
    }
  }

  /**
   * Confirma un Payment Intent
   */
  async confirmPaymentIntent(
    paymentIntentId: string,
    paymentMethodId?: string
  ): Promise<Stripe.PaymentIntent> {
    try {
      const params: Stripe.PaymentIntentConfirmParams = {};
      if (paymentMethodId) {
        params.payment_method = paymentMethodId;
      }

      return await this.stripe.paymentIntents.confirm(paymentIntentId, params);
    } catch (error: any) {
      throw new AppError('Payment confirmation failed', 400);
    }
  }

  /**
   * Cancela un Payment Intent
   */
  async cancelPaymentIntent(paymentIntentId: string): Promise<Stripe.PaymentIntent> {
    try {
      return await this.stripe.paymentIntents.cancel(paymentIntentId);
    } catch (error: any) {
      throw new AppError('Failed to cancel payment', 400);
    }
  }

  // ============ PAYMENT METHODS ============

  /**
   * Adjunta Payment Method a Customer
   */
  async attachPaymentMethod(
    paymentMethodId: string,
    customerId: string
  ): Promise<Stripe.PaymentMethod> {
    try {
      return await this.stripe.paymentMethods.attach(paymentMethodId, {
        customer: customerId,
      });
    } catch (error: any) {
      throw new AppError('Failed to attach payment method', 400);
    }
  }

  /**
   * Desvincula Payment Method
   */
  async detachPaymentMethod(paymentMethodId: string): Promise<Stripe.PaymentMethod> {
    try {
      return await this.stripe.paymentMethods.detach(paymentMethodId);
    } catch (error: any) {
      throw new AppError('Failed to detach payment method', 400);
    }
  }

  /**
   * Lista Payment Methods de un Customer
   */
  async listPaymentMethods(
    customerId: string,
    type: 'card' | 'bank_account' = 'card'
  ): Promise<Stripe.PaymentMethod[]> {
    try {
      const paymentMethods = await this.stripe.paymentMethods.list({
        customer: customerId,
        type,
      });

      return paymentMethods.data;
    } catch (error: any) {
      throw new AppError('Failed to list payment methods', 500);
    }
  }

  /**
   * Establece Payment Method como default
   */
  async setDefaultPaymentMethod(
    customerId: string,
    paymentMethodId: string
  ): Promise<Stripe.Customer> {
    try {
      return await this.stripe.customers.update(customerId, {
        invoice_settings: {
          default_payment_method: paymentMethodId,
        },
      });
    } catch (error: any) {
      throw new AppError('Failed to set default payment method', 400);
    }
  }

  // ============ SUBSCRIPTIONS ============

  /**
   * Crea una suscripción
   */
  async createSubscription(params: {
    customer_id: string;
    price_id: string;
    trial_days?: number;
    metadata?: Record<string, string>;
  }): Promise<Stripe.Subscription> {
    try {
      const subscriptionParams: Stripe.SubscriptionCreateParams = {
        customer: params.customer_id,
        items: [{ price: params.price_id }],
        metadata: params.metadata || {},
        payment_behavior: 'default_incomplete',
        expand: ['latest_invoice.payment_intent'],
      };

      if (params.trial_days) {
        subscriptionParams.trial_period_days = params.trial_days;
      }

      const subscription = await this.stripe.subscriptions.create(subscriptionParams);

      logger.info('Subscription created', {
        subscription_id: subscription.id,
        customer_id: params.customer_id,
      });

      return subscription;
    } catch (error: any) {
      logger.error('Failed to create subscription', { error: error.message });
      throw new AppError('Failed to create subscription', 500);
    }
  }

  /**
   * Actualiza una suscripción
   */
  async updateSubscription(
    subscriptionId: string,
    params: Stripe.SubscriptionUpdateParams
  ): Promise<Stripe.Subscription> {
    try {
      return await this.stripe.subscriptions.update(subscriptionId, params);
    } catch (error: any) {
      throw new AppError('Failed to update subscription', 500);
    }
  }

  /**
   * Cancela una suscripción
   */
  async cancelSubscription(
    subscriptionId: string,
    immediate: boolean = false
  ): Promise<Stripe.Subscription> {
    try {
      if (immediate) {
        return await this.stripe.subscriptions.cancel(subscriptionId);
      } else {
        return await this.stripe.subscriptions.update(subscriptionId, {
          cancel_at_period_end: true,
        });
      }
    } catch (error: any) {
      throw new AppError('Failed to cancel subscription', 500);
    }
  }

  /**
   * Reactiva una suscripción cancelada
   */
  async resumeSubscription(subscriptionId: string): Promise<Stripe.Subscription> {
    try {
      return await this.stripe.subscriptions.update(subscriptionId, {
        cancel_at_period_end: false,
      });
    } catch (error: any) {
      throw new AppError('Failed to resume subscription', 500);
    }
  }

  // ============ INVOICES ============

  /**
   * Obtiene una factura
   */
  async getInvoice(invoiceId: string): Promise<Stripe.Invoice> {
    try {
      return await this.stripe.invoices.retrieve(invoiceId);
    } catch (error: any) {
      throw new AppError('Invoice not found', 404);
    }
  }

  /**
   * Lista facturas de un customer
   */
  async listInvoices(
    customerId: string,
    limit: number = 10
  ): Promise<Stripe.Invoice[]> {
    try {
      const invoices = await this.stripe.invoices.list({
        customer: customerId,
        limit,
      });

      return invoices.data;
    } catch (error: any) {
      throw new AppError('Failed to list invoices', 500);
    }
  }

  /**
   * Paga una factura manualmente
   */
  async payInvoice(invoiceId: string): Promise<Stripe.Invoice> {
    try {
      return await this.stripe.invoices.pay(invoiceId);
    } catch (error: any) {
      throw new AppError('Failed to pay invoice', 400);
    }
  }

  // ============ REFUNDS ============

  /**
   * Crea un reembolso
   */
  async createRefund(params: {
    payment_intent_id: string;
    amount?: number;
    reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
    metadata?: Record<string, string>;
  }): Promise<Stripe.Refund> {
    try {
      const refundParams: Stripe.RefundCreateParams = {
        payment_intent: params.payment_intent_id,
        metadata: params.metadata || {},
      };

      if (params.amount) {
        refundParams.amount = Math.round(params.amount * 100);
      }

      if (params.reason) {
        refundParams.reason = params.reason;
      }

      const refund = await this.stripe.refunds.create(refundParams);

      logger.info('Refund created', {
        refund_id: refund.id,
        payment_intent: params.payment_intent_id,
        amount: params.amount,
      });

      return refund;
    } catch (error: any) {
      logger.error('Failed to create refund', { error: error.message });
      throw new AppError('Failed to create refund', 500);
    }
  }

  /**
   * Obtiene un reembolso
   */
  async getRefund(refundId: string): Promise<Stripe.Refund> {
    try {
      return await this.stripe.refunds.retrieve(refundId);
    } catch (error: any) {
      throw new AppError('Refund not found', 404);
    }
  }

  // ============ PRICES ============

  /**
   * Lista precios de un producto
   */
  async listPrices(productId?: string): Promise<Stripe.Price[]> {
    try {
      const params: Stripe.PriceListParams = { active: true };
      if (productId) {
        params.product = productId;
      }

      const prices = await this.stripe.prices.list(params);
      return prices.data;
    } catch (error: any) {
      throw new AppError('Failed to list prices', 500);
    }
  }

  // ============ SETUP INTENTS ============

  /**
   * Crea Setup Intent para guardar payment method
   */
  async createSetupIntent(customerId: string): Promise<Stripe.SetupIntent> {
    try {
      return await this.stripe.setupIntents.create({
        customer: customerId,
        payment_method_types: ['card'],
      });
    } catch (error: any) {
      throw new AppError('Failed to create setup intent', 500);
    }
  }
}

4. Frontend Integration

4.1 Stripe Elements Configuration

// src/config/stripe.config.ts

export const STRIPE_ELEMENTS_OPTIONS = {
  fonts: [
    {
      cssSrc: 'https://fonts.googleapis.com/css?family=Roboto',
    },
  ],
  locale: 'en' as const,
};

export const CARD_ELEMENT_OPTIONS = {
  style: {
    base: {
      fontSize: '16px',
      color: '#424770',
      '::placeholder': {
        color: '#aab7c4',
      },
      fontFamily: '"Roboto", sans-serif',
    },
    invalid: {
      color: '#9e2146',
      iconColor: '#9e2146',
    },
  },
  hidePostalCode: false,
};

4.2 Payment Form Component

// src/components/payments/PaymentForm.tsx

import React, { useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import {
  Elements,
  CardElement,
  useStripe,
  useElements,
} from '@stripe/react-stripe-js';
import { paymentApi } from '../../api/payment.api';
import { CARD_ELEMENT_OPTIONS } from '../../config/stripe.config';

const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY!);

interface PaymentFormProps {
  amount: number;
  onSuccess: (paymentIntentId: string) => void;
  onError: (error: string) => void;
}

const PaymentFormContent: React.FC<PaymentFormProps> = ({
  amount,
  onSuccess,
  onError,
}) => {
  const stripe = useStripe();
  const elements = useElements();
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!stripe || !elements) return;

    setLoading(true);

    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 Payment Intent en backend
      const response = await paymentApi.createPaymentIntent({
        amount,
        payment_method_id: paymentMethod.id,
      });

      const { client_secret } = response.data.payment_intent;

      // Confirmar pago
      const { error: confirmError, paymentIntent } = await stripe.confirmCardPayment(
        client_secret
      );

      if (confirmError) {
        throw new Error(confirmError.message);
      }

      if (paymentIntent?.status === 'succeeded') {
        onSuccess(paymentIntent.id);
      }
    } catch (err: any) {
      onError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div className="form-group">
        <label>Card Details</label>
        <CardElement options={CARD_ELEMENT_OPTIONS} />
      </div>

      <button type="submit" disabled={!stripe || loading}>
        {loading ? 'Processing...' : `Pay $${amount.toFixed(2)}`}
      </button>
    </form>
  );
};

export const PaymentForm: React.FC<PaymentFormProps> = (props) => {
  return (
    <Elements stripe={stripePromise}>
      <PaymentFormContent {...props} />
    </Elements>
  );
};

4.3 Subscription Form Component

// src/components/payments/SubscriptionForm.tsx

import React, { useState } from 'react';
import { useStripe, useElements, CardElement } from '@stripe/react-stripe-js';
import { subscriptionApi } from '../../api/subscription.api';

interface SubscriptionFormProps {
  priceId: string;
  planName: string;
  onSuccess: () => void;
}

export const SubscriptionForm: React.FC<SubscriptionFormProps> = ({
  priceId,
  planName,
  onSuccess,
}) => {
  const stripe = useStripe();
  const elements = useElements();
  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 suscripción en backend
      const response = await subscriptionApi.create({
        price_id: priceId,
        payment_method_id: paymentMethod.id,
      });

      const { subscription, client_secret } = response.data;

      // Si requiere confirmación
      if (client_secret) {
        const { error: confirmError } = await stripe.confirmCardPayment(client_secret);

        if (confirmError) {
          throw new Error(confirmError.message);
        }
      }

      onSuccess();
    } catch (err: any) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h3>Subscribe to {planName}</h3>

      <CardElement />

      {error && <div className="error">{error}</div>}

      <button type="submit" disabled={!stripe || loading}>
        {loading ? 'Processing...' : 'Subscribe'}
      </button>
    </form>
  );
};

5. Configuración

5.1 Variables de Entorno

# Backend
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_API_VERSION=2024-11-20.acacia

# Frontend
REACT_APP_STRIPE_PUBLISHABLE_KEY=pk_test_...

6. Testing

6.1 Test con Stripe Test Mode

// tests/stripe/payment-intent.test.ts

import { StripeService } from '../../src/services/stripe/stripe.service';

describe('Stripe Payment Intents', () => {
  let stripeService: StripeService;

  beforeAll(() => {
    stripeService = new StripeService();
  });

  it('should create payment intent', async () => {
    const paymentIntent = await stripeService.createPaymentIntent({
      amount: 100.00,
      metadata: { test: 'true' },
    });

    expect(paymentIntent.amount).toBe(10000); // 100 * 100 centavos
    expect(paymentIntent.currency).toBe('usd');
  });

  it('should confirm payment intent with test card', async () => {
    // Usar tarjeta de prueba: 4242424242424242
    // Implementar lógica de confirmación
  });
});

7. Referencias