workspace-v1/shared/libs/payments/IMPLEMENTATION.md
rckrdmrd 66161b1566 feat: Workspace-v1 complete migration with NEXUS v3.4
Sistema NEXUS v3.4 migrado con:

Estructura principal:
- core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles)
- core/catalog: Catalogo de funcionalidades reutilizables
- shared/knowledge-base: Base de conocimiento compartida
- devtools/scripts: Herramientas de desarrollo
- control-plane/registries: Control de servicios y CI/CD
- orchestration/: Configuracion de orquestacion de agentes

Proyectos incluidos (11):
- gamilit (submodule -> GitHub)
- trading-platform (OrbiquanTIA)
- erp-suite con 5 verticales:
  - erp-core, construccion, vidrio-templado
  - mecanicas-diesel, retail, clinicas
- betting-analytics
- inmobiliaria-analytics
- platform_marketing_content
- pos-micro, erp-basico

Configuracion:
- .gitignore completo para Node.js/Python/Docker
- gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git)
- Sistema de puertos estandarizado (3005-3199)

Generated with NEXUS v3.4 Migration System
EPIC-010: Configuracion Git y Repositorios
2026-01-04 03:37:42 -06:00

31 KiB

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
  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

npm install stripe

Paso 3: Variables de Entorno

# .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

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

// 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

// 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<StripeCustomerEntity>,
    @InjectRepository(SubscriptionPlanEntity)
    private readonly planRepo: Repository<SubscriptionPlanEntity>,
    private readonly config: ConfigService,
  ) {
    this.stripe = new Stripe(config.get<string>('STRIPE_SECRET_KEY'), {
      apiVersion: '2023-10-16',
    });
  }

  // ==========================================================================
  // Customer Management
  // ==========================================================================

  async getOrCreateCustomer(userId: string, email: string): Promise<StripeCustomer> {
    // 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<StripeCustomer | null> {
    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<CheckoutSession> {
    // 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<string | null> {
    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<string> {
    // 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<BillingPortalSession> {
    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<Stripe.PaymentMethod[]> {
    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<void> {
    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<Stripe.Subscription> {
    if (immediately) {
      return this.stripe.subscriptions.cancel(stripeSubscriptionId);
    }
    return this.stripe.subscriptions.update(stripeSubscriptionId, {
      cancel_at_period_end: true,
    });
  }

  async resumeSubscription(stripeSubscriptionId: string): Promise<Stripe.Subscription> {
    return this.stripe.subscriptions.update(stripeSubscriptionId, {
      cancel_at_period_end: false,
    });
  }

  async updateSubscriptionPlan(
    stripeSubscriptionId: string,
    newPriceId: string,
  ): Promise<Stripe.Subscription> {
    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<string>('STRIPE_WEBHOOK_SECRET');
    return this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
  }

  // ==========================================================================
  // Invoices
  // ==========================================================================

  async listInvoices(userId: string, limit: number = 10): Promise<Stripe.Invoice[]> {
    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<Stripe.Refund> {
    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

// 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<SubscriptionEntity>,
    @InjectRepository(SubscriptionPlanEntity)
    private readonly planRepo: Repository<SubscriptionPlanEntity>,
    private readonly stripeService: StripeService,
  ) {}

  async getSubscriptionByUserId(userId: string): Promise<SubscriptionWithPlan | null> {
    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<boolean> {
    const count = await this.subscriptionRepo.count({
      where: {
        userId,
        status: In(['active', 'trialing']),
      },
    });
    return count > 0;
  }

  async getPlans(): Promise<SubscriptionPlan[]> {
    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<Subscription> {
    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<SubscriptionEntity> = {
      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<Subscription> {
    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<Subscription> {
    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<Subscription> {
    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<void> {
    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

// 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<Request>,
    @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<string, SubscriptionStatus> = {
      active: 'active',
      trialing: 'trialing',
      past_due: 'past_due',
      canceled: 'cancelled',
      unpaid: 'unpaid',
    };
    return statusMap[status] || 'active';
  }
}

Paso 9: Migraciones 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

// 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

# 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

# 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