# Guía de Implementación: Pagos con Stripe **Versión:** 1.0.0 **Tiempo estimado:** 3-5 horas **Complejidad:** Alta --- ## Pre-requisitos - [ ] Cuenta de Stripe (test o live) - [ ] Proyecto NestJS existente - [ ] PostgreSQL como base de datos - [ ] URL pública para webhooks (ngrok para desarrollo) --- ## Paso 1: Configurar Stripe Dashboard ### 1.1 Crear cuenta y obtener claves 1. Ir a [dashboard.stripe.com](https://dashboard.stripe.com) 2. En **Developers > API keys**, obtener: - Publishable key: `pk_test_xxx` o `pk_live_xxx` - Secret key: `sk_test_xxx` o `sk_live_xxx` ### 1.2 Crear productos y precios 1. Ir a **Products > Add product** 2. Crear cada plan con sus precios: ``` Producto: Basic Plan ├── Price ID (monthly): price_basic_monthly └── Price ID (yearly): price_basic_yearly Producto: Pro Plan ├── Price ID (monthly): price_pro_monthly └── Price ID (yearly): price_pro_yearly ``` ### 1.3 Configurar webhook 1. Ir a **Developers > Webhooks** 2. Add endpoint: `https://api.example.com/webhooks/stripe` 3. Seleccionar eventos: - `checkout.session.completed` - `invoice.paid` - `invoice.payment_failed` - `customer.subscription.updated` - `customer.subscription.deleted` 4. Copiar **Signing secret**: `whsec_xxx` --- ## Paso 2: Instalar Dependencias ```bash npm install stripe ``` --- ## Paso 3: Variables de Entorno ```env # .env STRIPE_SECRET_KEY=sk_test_xxx STRIPE_PUBLISHABLE_KEY=pk_test_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx # URLs FRONTEND_URL=http://localhost:3000 STRIPE_SUCCESS_URL=${FRONTEND_URL}/checkout/success STRIPE_CANCEL_URL=${FRONTEND_URL}/pricing ``` --- ## Paso 4: Crear Estructura de Directorios ```bash mkdir -p src/modules/payments/services mkdir -p src/modules/payments/controllers mkdir -p src/modules/payments/dto mkdir -p src/modules/payments/types ``` --- ## Paso 5: Definir Tipos ```typescript // src/modules/payments/types/payments.types.ts export type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'cancelled' | 'unpaid'; export type BillingCycle = 'monthly' | 'yearly'; export type PaymentStatus = 'pending' | 'succeeded' | 'failed' | 'refunded'; export interface StripeCustomer { id: string; userId: string; stripeCustomerId: string; email?: string; defaultPaymentMethodId?: string; createdAt: Date; updatedAt: Date; } export interface SubscriptionPlan { id: string; name: string; slug: string; description?: string; priceMonthly: number; priceYearly?: number; currency: string; stripePriceIdMonthly?: string; stripePriceIdYearly?: string; stripeProductId?: string; features: { name: string; included: boolean }[]; isActive: boolean; sortOrder: number; createdAt: Date; updatedAt: Date; } export interface Subscription { id: string; userId: string; planId: string; stripeSubscriptionId?: string; stripeCustomerId?: string; status: SubscriptionStatus; billingCycle: BillingCycle; currentPeriodStart?: Date; currentPeriodEnd?: Date; cancelAtPeriodEnd: boolean; cancelledAt?: Date; currentPrice?: number; currency: string; createdAt: Date; updatedAt: Date; } export interface SubscriptionWithPlan extends Subscription { plan: SubscriptionPlan; } export interface CheckoutSession { sessionId: string; url: string; expiresAt: Date; } export interface CreateCheckoutInput { userId: string; planId: string; billingCycle?: BillingCycle; successUrl: string; cancelUrl: string; promoCode?: string; } export interface BillingPortalSession { url: string; returnUrl: string; } ``` --- ## Paso 6: Crear Servicio de Stripe ```typescript // src/modules/payments/services/stripe.service.ts import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConfigService } from '@nestjs/config'; import Stripe from 'stripe'; import { StripeCustomerEntity } from '../entities/stripe-customer.entity'; import { SubscriptionPlanEntity } from '../entities/subscription-plan.entity'; import { StripeCustomer, CheckoutSession, CreateCheckoutInput, BillingPortalSession, } from '../types/payments.types'; @Injectable() export class StripeService { private readonly stripe: Stripe; private readonly logger = new Logger(StripeService.name); constructor( @InjectRepository(StripeCustomerEntity) private readonly customerRepo: Repository, @InjectRepository(SubscriptionPlanEntity) private readonly planRepo: Repository, private readonly config: ConfigService, ) { this.stripe = new Stripe(config.get('STRIPE_SECRET_KEY'), { apiVersion: '2023-10-16', }); } // ========================================================================== // Customer Management // ========================================================================== async getOrCreateCustomer(userId: string, email: string): Promise { // Check existing let customer = await this.customerRepo.findOne({ where: { userId } }); if (customer) { return this.transformCustomer(customer); } // Create in Stripe const stripeCustomer = await this.stripe.customers.create({ email, metadata: { userId }, }); // Save to DB customer = this.customerRepo.create({ userId, stripeCustomerId: stripeCustomer.id, email, }); await this.customerRepo.save(customer); this.logger.log(`Customer created: ${userId} -> ${stripeCustomer.id}`); return this.transformCustomer(customer); } async getCustomerByUserId(userId: string): Promise { const customer = await this.customerRepo.findOne({ where: { userId } }); return customer ? this.transformCustomer(customer) : null; } private transformCustomer(entity: StripeCustomerEntity): StripeCustomer { return { id: entity.id, userId: entity.userId, stripeCustomerId: entity.stripeCustomerId, email: entity.email, defaultPaymentMethodId: entity.defaultPaymentMethodId, createdAt: entity.createdAt, updatedAt: entity.updatedAt, }; } // ========================================================================== // Checkout Sessions // ========================================================================== async createCheckoutSession(input: CreateCheckoutInput): Promise { // Get or create customer const userEmail = await this.getUserEmail(input.userId); const customer = await this.getOrCreateCustomer(input.userId, userEmail); // Get plan const plan = await this.planRepo.findOne({ where: { id: input.planId } }); if (!plan) { throw new Error('Plan not found'); } // Get price ID const priceId = input.billingCycle === 'yearly' ? plan.stripePriceIdYearly : plan.stripePriceIdMonthly; if (!priceId) { throw new Error('Stripe price not configured for this plan'); } // Create session const sessionConfig: Stripe.Checkout.SessionCreateParams = { customer: customer.stripeCustomerId, mode: 'subscription', line_items: [{ price: priceId, quantity: 1 }], success_url: input.successUrl, cancel_url: input.cancelUrl, metadata: { userId: input.userId, planId: input.planId, billingCycle: input.billingCycle || 'monthly', }, subscription_data: { metadata: { userId: input.userId, planId: input.planId, }, }, }; // Apply promo code if (input.promoCode) { const couponId = await this.getCouponIdByCode(input.promoCode); if (couponId) { sessionConfig.discounts = [{ coupon: couponId }]; } } const session = await this.stripe.checkout.sessions.create(sessionConfig); this.logger.log(`Checkout session created: ${session.id}`); return { sessionId: session.id, url: session.url!, expiresAt: new Date(session.expires_at * 1000), }; } private async getCouponIdByCode(code: string): Promise { try { const promotionCodes = await this.stripe.promotionCodes.list({ code, limit: 1, }); if (promotionCodes.data.length > 0 && promotionCodes.data[0].active) { return promotionCodes.data[0].coupon.id; } return null; } catch { return null; } } private async getUserEmail(userId: string): Promise { // Implementar según tu sistema de usuarios // Por ejemplo: return this.userService.findById(userId).then(u => u.email); throw new Error('Implement getUserEmail method'); } // ========================================================================== // Billing Portal // ========================================================================== async createBillingPortalSession( userId: string, returnUrl: string, ): Promise { const customer = await this.getCustomerByUserId(userId); if (!customer) { throw new Error('Customer not found'); } const session = await this.stripe.billingPortal.sessions.create({ customer: customer.stripeCustomerId, return_url: returnUrl, }); return { url: session.url, returnUrl, }; } // ========================================================================== // Payment Methods // ========================================================================== async listPaymentMethods(userId: string): Promise { const customer = await this.getCustomerByUserId(userId); if (!customer) return []; const paymentMethods = await this.stripe.paymentMethods.list({ customer: customer.stripeCustomerId, type: 'card', }); return paymentMethods.data; } async setDefaultPaymentMethod(userId: string, paymentMethodId: string): Promise { const customer = await this.getCustomerByUserId(userId); if (!customer) throw new Error('Customer not found'); await this.stripe.customers.update(customer.stripeCustomerId, { invoice_settings: { default_payment_method: paymentMethodId }, }); await this.customerRepo.update( { userId }, { defaultPaymentMethodId: paymentMethodId }, ); } // ========================================================================== // Subscriptions // ========================================================================== async cancelSubscription( stripeSubscriptionId: string, immediately: boolean = false, ): Promise { if (immediately) { return this.stripe.subscriptions.cancel(stripeSubscriptionId); } return this.stripe.subscriptions.update(stripeSubscriptionId, { cancel_at_period_end: true, }); } async resumeSubscription(stripeSubscriptionId: string): Promise { return this.stripe.subscriptions.update(stripeSubscriptionId, { cancel_at_period_end: false, }); } async updateSubscriptionPlan( stripeSubscriptionId: string, newPriceId: string, ): Promise { const subscription = await this.stripe.subscriptions.retrieve(stripeSubscriptionId); return this.stripe.subscriptions.update(stripeSubscriptionId, { items: [{ id: subscription.items.data[0].id, price: newPriceId, }], proration_behavior: 'create_prorations', }); } // ========================================================================== // Webhooks // ========================================================================== constructWebhookEvent(payload: Buffer, signature: string): Stripe.Event { const webhookSecret = this.config.get('STRIPE_WEBHOOK_SECRET'); return this.stripe.webhooks.constructEvent(payload, signature, webhookSecret); } // ========================================================================== // Invoices // ========================================================================== async listInvoices(userId: string, limit: number = 10): Promise { const customer = await this.getCustomerByUserId(userId); if (!customer) return []; const invoices = await this.stripe.invoices.list({ customer: customer.stripeCustomerId, limit, }); return invoices.data; } // ========================================================================== // Refunds // ========================================================================== async createRefund( chargeId: string, amount?: number, reason?: string, ): Promise { return this.stripe.refunds.create({ charge: chargeId, amount: amount ? Math.round(amount * 100) : undefined, reason: reason as Stripe.RefundCreateParams.Reason, }); } } ``` --- ## Paso 7: Crear Servicio de Suscripciones ```typescript // src/modules/payments/services/subscription.service.ts import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { SubscriptionEntity } from '../entities/subscription.entity'; import { SubscriptionPlanEntity } from '../entities/subscription-plan.entity'; import { StripeService } from './stripe.service'; import { Subscription, SubscriptionWithPlan, SubscriptionPlan, SubscriptionStatus, BillingCycle, } from '../types/payments.types'; @Injectable() export class SubscriptionService { private readonly logger = new Logger(SubscriptionService.name); constructor( @InjectRepository(SubscriptionEntity) private readonly subscriptionRepo: Repository, @InjectRepository(SubscriptionPlanEntity) private readonly planRepo: Repository, private readonly stripeService: StripeService, ) {} async getSubscriptionByUserId(userId: string): Promise { const subscription = await this.subscriptionRepo.findOne({ where: { userId, status: In(['active', 'trialing', 'past_due']), }, relations: ['plan'], order: { createdAt: 'DESC' }, }); if (!subscription) return null; return this.transformSubscription(subscription); } async hasActiveSubscription(userId: string): Promise { const count = await this.subscriptionRepo.count({ where: { userId, status: In(['active', 'trialing']), }, }); return count > 0; } async getPlans(): Promise { const plans = await this.planRepo.find({ where: { isActive: true }, order: { sortOrder: 'ASC' }, }); return plans.map(this.transformPlan); } async cancelSubscription( userId: string, immediately: boolean = false, reason?: string, ): Promise { const subscription = await this.getSubscriptionByUserId(userId); if (!subscription) { throw new Error('No active subscription found'); } // Cancel in Stripe if (subscription.stripeSubscriptionId) { await this.stripeService.cancelSubscription( subscription.stripeSubscriptionId, immediately, ); } // Update local const updateData: Partial = { cancellationReason: reason, }; if (immediately) { updateData.status = 'cancelled'; updateData.cancelledAt = new Date(); } else { updateData.cancelAtPeriodEnd = true; } await this.subscriptionRepo.update({ id: subscription.id }, updateData); this.logger.log(`Subscription cancelled: ${subscription.id}`); return this.getSubscriptionByUserId(userId); } async resumeSubscription(userId: string): Promise { const subscription = await this.getSubscriptionByUserId(userId); if (!subscription || !subscription.cancelAtPeriodEnd) { throw new Error('No subscription to resume'); } // Resume in Stripe if (subscription.stripeSubscriptionId) { await this.stripeService.resumeSubscription(subscription.stripeSubscriptionId); } // Update local await this.subscriptionRepo.update( { id: subscription.id }, { cancelAtPeriodEnd: false, cancellationReason: null }, ); this.logger.log(`Subscription resumed: ${subscription.id}`); return this.getSubscriptionByUserId(userId); } async changePlan( userId: string, newPlanId: string, billingCycle?: BillingCycle, ): Promise { const subscription = await this.getSubscriptionByUserId(userId); if (!subscription) { throw new Error('No active subscription found'); } const newPlan = await this.planRepo.findOne({ where: { id: newPlanId } }); if (!newPlan) { throw new Error('Plan not found'); } const cycle = billingCycle || subscription.billingCycle; const priceId = cycle === 'yearly' ? newPlan.stripePriceIdYearly : newPlan.stripePriceIdMonthly; // Update in Stripe if (subscription.stripeSubscriptionId && priceId) { await this.stripeService.updateSubscriptionPlan( subscription.stripeSubscriptionId, priceId, ); } // Update local const newPrice = cycle === 'yearly' ? (newPlan.priceYearly || newPlan.priceMonthly * 12) : newPlan.priceMonthly; await this.subscriptionRepo.update( { id: subscription.id }, { planId: newPlanId, billingCycle: cycle, currentPrice: newPrice }, ); this.logger.log(`Subscription plan changed: ${subscription.id} -> ${newPlanId}`); return this.getSubscriptionByUserId(userId); } // Called from webhook handler async createFromStripeEvent( userId: string, planId: string, stripeSubscriptionId: string, stripeCustomerId: string, billingCycle: BillingCycle, periodStart: Date, periodEnd: Date, ): Promise { const plan = await this.planRepo.findOne({ where: { id: planId } }); const price = billingCycle === 'yearly' ? (plan.priceYearly || plan.priceMonthly * 12) : plan.priceMonthly; const subscription = this.subscriptionRepo.create({ userId, planId, stripeSubscriptionId, stripeCustomerId, status: 'active', billingCycle, currentPeriodStart: periodStart, currentPeriodEnd: periodEnd, currentPrice: price, currency: plan.currency, }); await this.subscriptionRepo.save(subscription); this.logger.log(`Subscription created from Stripe: ${subscription.id}`); return this.transformSubscription(subscription); } async updateFromStripeEvent( stripeSubscriptionId: string, data: { status?: SubscriptionStatus; currentPeriodStart?: Date; currentPeriodEnd?: Date; cancelAtPeriodEnd?: boolean; }, ): Promise { await this.subscriptionRepo.update( { stripeSubscriptionId }, data, ); } private transformSubscription(entity: SubscriptionEntity): SubscriptionWithPlan { return { id: entity.id, userId: entity.userId, planId: entity.planId, stripeSubscriptionId: entity.stripeSubscriptionId, stripeCustomerId: entity.stripeCustomerId, status: entity.status, billingCycle: entity.billingCycle, currentPeriodStart: entity.currentPeriodStart, currentPeriodEnd: entity.currentPeriodEnd, cancelAtPeriodEnd: entity.cancelAtPeriodEnd, cancelledAt: entity.cancelledAt, currentPrice: entity.currentPrice, currency: entity.currency, createdAt: entity.createdAt, updatedAt: entity.updatedAt, plan: entity.plan ? this.transformPlan(entity.plan) : null, }; } private transformPlan(entity: SubscriptionPlanEntity): SubscriptionPlan { return { id: entity.id, name: entity.name, slug: entity.slug, description: entity.description, priceMonthly: entity.priceMonthly, priceYearly: entity.priceYearly, currency: entity.currency, stripePriceIdMonthly: entity.stripePriceIdMonthly, stripePriceIdYearly: entity.stripePriceIdYearly, stripeProductId: entity.stripeProductId, features: entity.features, isActive: entity.isActive, sortOrder: entity.sortOrder, createdAt: entity.createdAt, updatedAt: entity.updatedAt, }; } } ``` --- ## Paso 8: Crear Controller ```typescript // src/modules/payments/controllers/payments.controller.ts import { Controller, Get, Post, Body, Query, UseGuards, Request, RawBodyRequest, Headers, BadRequestException, } from '@nestjs/common'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { StripeService } from '../services/stripe.service'; import { SubscriptionService } from '../services/subscription.service'; import { CreateCheckoutDto, CancelSubscriptionDto } from '../dto'; import Stripe from 'stripe'; @Controller('payments') export class PaymentsController { constructor( private readonly stripeService: StripeService, private readonly subscriptionService: SubscriptionService, ) {} // ========================================================================== // Plans // ========================================================================== @Get('plans') async getPlans() { return this.subscriptionService.getPlans(); } // ========================================================================== // Subscription // ========================================================================== @Get('subscription') @UseGuards(JwtAuthGuard) async getSubscription(@Request() req) { return this.subscriptionService.getSubscriptionByUserId(req.user.id); } @Post('checkout') @UseGuards(JwtAuthGuard) async createCheckout(@Request() req, @Body() dto: CreateCheckoutDto) { return this.stripeService.createCheckoutSession({ userId: req.user.id, planId: dto.planId, billingCycle: dto.billingCycle, successUrl: dto.successUrl, cancelUrl: dto.cancelUrl, promoCode: dto.promoCode, }); } @Get('billing-portal') @UseGuards(JwtAuthGuard) async getBillingPortal(@Request() req, @Query('returnUrl') returnUrl: string) { return this.stripeService.createBillingPortalSession(req.user.id, returnUrl); } @Post('subscription/cancel') @UseGuards(JwtAuthGuard) async cancelSubscription( @Request() req, @Body() dto: CancelSubscriptionDto, ) { return this.subscriptionService.cancelSubscription( req.user.id, dto.immediately, dto.reason, ); } @Post('subscription/resume') @UseGuards(JwtAuthGuard) async resumeSubscription(@Request() req) { return this.subscriptionService.resumeSubscription(req.user.id); } @Post('subscription/change-plan') @UseGuards(JwtAuthGuard) async changePlan( @Request() req, @Body() dto: { planId: string; billingCycle?: 'monthly' | 'yearly' }, ) { return this.subscriptionService.changePlan( req.user.id, dto.planId, dto.billingCycle, ); } // ========================================================================== // Payment Methods // ========================================================================== @Get('payment-methods') @UseGuards(JwtAuthGuard) async listPaymentMethods(@Request() req) { return this.stripeService.listPaymentMethods(req.user.id); } // ========================================================================== // Invoices // ========================================================================== @Get('invoices') @UseGuards(JwtAuthGuard) async listInvoices(@Request() req, @Query('limit') limit?: number) { return this.stripeService.listInvoices(req.user.id, limit || 10); } } // Webhook controller (separado para raw body) @Controller('webhooks') export class StripeWebhookController { constructor( private readonly stripeService: StripeService, private readonly subscriptionService: SubscriptionService, ) {} @Post('stripe') async handleWebhook( @Request() req: RawBodyRequest, @Headers('stripe-signature') signature: string, ) { if (!signature) { throw new BadRequestException('Missing stripe-signature header'); } let event: Stripe.Event; try { event = this.stripeService.constructWebhookEvent(req.rawBody, signature); } catch (err) { throw new BadRequestException(`Webhook signature verification failed`); } switch (event.type) { case 'checkout.session.completed': await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session); 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; case 'invoice.payment_failed': await this.handlePaymentFailed(event.data.object as Stripe.Invoice); break; } return { received: true }; } private async handleCheckoutCompleted(session: Stripe.Checkout.Session) { const { userId, planId, billingCycle } = session.metadata!; if (session.mode === 'subscription' && session.subscription) { const stripeSubscription = await this.stripeService.getSubscription( session.subscription as string, ); await this.subscriptionService.createFromStripeEvent( userId, planId, stripeSubscription.id, session.customer as string, billingCycle as 'monthly' | 'yearly', new Date(stripeSubscription.current_period_start * 1000), new Date(stripeSubscription.current_period_end * 1000), ); } } private async handleSubscriptionUpdated(subscription: Stripe.Subscription) { await this.subscriptionService.updateFromStripeEvent(subscription.id, { status: this.mapStripeStatus(subscription.status), currentPeriodStart: new Date(subscription.current_period_start * 1000), currentPeriodEnd: new Date(subscription.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, }); } private async handleSubscriptionDeleted(subscription: Stripe.Subscription) { await this.subscriptionService.updateFromStripeEvent(subscription.id, { status: 'cancelled', }); } private async handlePaymentFailed(invoice: Stripe.Invoice) { if (invoice.subscription) { await this.subscriptionService.updateFromStripeEvent( invoice.subscription as string, { status: 'past_due' }, ); } } private mapStripeStatus(status: Stripe.Subscription.Status): SubscriptionStatus { const statusMap: Record = { active: 'active', trialing: 'trialing', past_due: 'past_due', canceled: 'cancelled', unpaid: 'unpaid', }; return statusMap[status] || 'active'; } } ``` --- ## Paso 9: Migraciones SQL ```sql -- migrations/001_create_payments_tables.sql CREATE SCHEMA IF NOT EXISTS financial; -- Stripe Customers CREATE TABLE financial.stripe_customers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL UNIQUE REFERENCES public.users(id), stripe_customer_id VARCHAR(100) NOT NULL UNIQUE, email VARCHAR(255), default_payment_method_id VARCHAR(100), metadata JSONB DEFAULT '{}', created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Subscription Plans CREATE TABLE financial.subscription_plans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(100) NOT NULL, slug VARCHAR(50) NOT NULL UNIQUE, description TEXT, price_monthly DECIMAL(10, 2) NOT NULL, price_yearly DECIMAL(10, 2), currency VARCHAR(3) DEFAULT 'usd', stripe_price_id_monthly VARCHAR(100), stripe_price_id_yearly VARCHAR(100), stripe_product_id VARCHAR(100), features JSONB DEFAULT '[]', is_active BOOLEAN DEFAULT true, sort_order INTEGER DEFAULT 0, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Subscriptions CREATE TABLE financial.subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES public.users(id), plan_id UUID NOT NULL REFERENCES financial.subscription_plans(id), stripe_subscription_id VARCHAR(100) UNIQUE, stripe_customer_id VARCHAR(100), status VARCHAR(20) NOT NULL DEFAULT 'active', billing_cycle VARCHAR(20) NOT NULL DEFAULT 'monthly', current_period_start TIMESTAMP WITH TIME ZONE, current_period_end TIMESTAMP WITH TIME ZONE, cancel_at_period_end BOOLEAN DEFAULT false, cancelled_at TIMESTAMP WITH TIME ZONE, cancellation_reason TEXT, current_price DECIMAL(10, 2), currency VARCHAR(3) DEFAULT 'usd', created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); -- Indexes CREATE INDEX idx_subscriptions_user ON financial.subscriptions(user_id); CREATE INDEX idx_subscriptions_status ON financial.subscriptions(status); CREATE INDEX idx_subscriptions_stripe ON financial.subscriptions(stripe_subscription_id); -- Trigger for updated_at CREATE TRIGGER update_subscriptions_timestamp BEFORE UPDATE ON financial.subscriptions FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); ``` --- ## Paso 10: Configurar Raw Body para Webhooks ```typescript // main.ts import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule, { rawBody: true, // Enable raw body for webhooks }); await app.listen(3000); } bootstrap(); ``` --- ## Variables de Entorno ```env # Stripe STRIPE_SECRET_KEY=sk_test_xxx STRIPE_PUBLISHABLE_KEY=pk_test_xxx STRIPE_WEBHOOK_SECRET=whsec_xxx # URLs FRONTEND_URL=http://localhost:3000 ``` --- ## Checklist de Implementación - [ ] Cuenta Stripe configurada - [ ] Productos y precios creados en Stripe - [ ] Webhook endpoint configurado - [ ] Dependencia `stripe` instalada - [ ] Variables de entorno configuradas - [ ] Entidades creadas (StripeCustomer, SubscriptionPlan, Subscription) - [ ] StripeService implementado - [ ] SubscriptionService implementado - [ ] Controllers implementados - [ ] Webhook handler implementado - [ ] Raw body habilitado para webhooks - [ ] Migraciones ejecutadas - [ ] Build pasa sin errores - [ ] Test: crear checkout session - [ ] Test: webhook recibido correctamente --- ## Verificar Funcionamiento ```bash # Usar Stripe CLI para testing local stripe listen --forward-to localhost:3000/webhooks/stripe # Trigger test events stripe trigger checkout.session.completed stripe trigger invoice.paid ``` --- ## Troubleshooting ### "No signatures found matching the expected signature" - Verificar STRIPE_WEBHOOK_SECRET - Verificar que raw body está habilitado ### Webhook no llega - Verificar URL en Stripe dashboard - Usar ngrok para desarrollo local ### Subscription not created - Verificar metadata en checkout session - Revisar logs del webhook handler --- **Versión:** 1.0.0 **Sistema:** SIMCO Catálogo