21 KiB
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
});
});