erp-suite/docs/02-especificaciones-tecnicas/saas-platform/stripe/SPEC-STRIPE-INTEGRATION.md

55 KiB

Especificación Técnica: EPIC-MGN-017 - Integración Stripe

1. Resumen

Campo Valor
Épica EPIC-MGN-017
Módulo billing
Story Points 34 SP
Sprints 22-24
Dependencias MGN-016 Billing, MGN-004 Tenants

2. Modelo de Datos

2.1 Diagrama ER

┌─────────────────────────────────────────────────────────────────────────────────┐
│                         STRIPE INTEGRATION SCHEMA                                │
├─────────────────────────────────────────────────────────────────────────────────┤
│                                                                                 │
│  ┌──────────────────────┐         ┌──────────────────────┐                     │
│  │    core_tenants.     │         │   billing.plans      │                     │
│  │      tenants         │         │                      │                     │
│  │  ─────────────────   │         │  ─────────────────   │                     │
│  │  id                  │◄───┐    │  id                  │                     │
│  │  name                │    │    │  name                │                     │
│  │  status              │    │    │  stripe_product_id   │◄────────┐          │
│  │  plan_id  ───────────┼────┼────► stripe_price_id     │         │          │
│  └──────────────────────┘    │    │  price_per_user     │         │          │
│                              │    │  features           │         │          │
│                              │    └──────────────────────┘         │          │
│                              │                                      │          │
│  ┌──────────────────────┐    │    ┌──────────────────────┐         │          │
│  │ billing.stripe_      │    │    │ billing.             │         │          │
│  │    customers         │    │    │   subscriptions      │         │          │
│  │  ─────────────────   │    │    │  ─────────────────   │         │          │
│  │  id                  │    │    │  id                  │         │          │
│  │  tenant_id  ─────────┼────┘    │  tenant_id           │         │          │
│  │  stripe_customer_id  │◄────────┤  stripe_subscription_id        │          │
│  │  email               │         │  stripe_price_id ────┼─────────┘          │
│  │  payment_method_id   │         │  quantity            │                    │
│  │  default_source      │         │  status              │                    │
│  │  created_at          │         │  current_period_start│                    │
│  │  updated_at          │         │  current_period_end  │                    │
│  └──────────────────────┘         │  cancel_at_period_end│                    │
│                                   └──────────────────────┘                     │
│                                                                                 │
│  ┌──────────────────────┐         ┌──────────────────────┐                     │
│  │ billing.stripe_      │         │ billing.             │                     │
│  │    webhook_events    │         │   payment_methods    │                     │
│  │  ─────────────────   │         │  ─────────────────   │                     │
│  │  id                  │         │  id                  │                     │
│  │  stripe_event_id     │         │  tenant_id           │                     │
│  │  event_type          │         │  stripe_pm_id        │                     │
│  │  payload             │         │  type (card/bank)    │                     │
│  │  processed           │         │  last_four           │                     │
│  │  processed_at        │         │  brand               │                     │
│  │  error               │         │  exp_month           │                     │
│  │  created_at          │         │  exp_year            │                     │
│  └──────────────────────┘         │  is_default          │                     │
│                                   └──────────────────────┘                     │
│                                                                                 │
└─────────────────────────────────────────────────────────────────────────────────┘

2.2 DDL

-- ============================================================================
-- STRIPE INTEGRATION TABLES
-- Schema: billing
-- ============================================================================

-- Stripe Customers (1:1 con tenants)
CREATE TABLE billing.stripe_customers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL UNIQUE REFERENCES core_tenants.tenants(id) ON DELETE RESTRICT,

    -- Stripe IDs
    stripe_customer_id VARCHAR(50) NOT NULL UNIQUE,

    -- Customer info (cached from Stripe)
    email VARCHAR(255),
    name VARCHAR(255),
    phone VARCHAR(50),

    -- Default payment method
    default_payment_method_id VARCHAR(50),

    -- Billing address (for invoices)
    billing_address JSONB,
    -- {
    --   "line1": "Calle 123",
    --   "line2": "Col. Centro",
    --   "city": "CDMX",
    --   "state": "CDMX",
    --   "postal_code": "06600",
    --   "country": "MX"
    -- }

    -- Tax info
    tax_id VARCHAR(20), -- RFC en México
    tax_exempt VARCHAR(20) DEFAULT 'none', -- none, exempt, reverse

    -- Metadata
    metadata JSONB DEFAULT '{}',

    -- Timestamps
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT valid_stripe_customer_id CHECK (stripe_customer_id ~ '^cus_[a-zA-Z0-9]+$')
);

-- Índices
CREATE INDEX idx_stripe_customers_stripe_id ON billing.stripe_customers(stripe_customer_id);
CREATE INDEX idx_stripe_customers_email ON billing.stripe_customers(email);

-- Payment Methods
CREATE TABLE billing.payment_methods (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
    stripe_customer_id UUID NOT NULL REFERENCES billing.stripe_customers(id) ON DELETE CASCADE,

    -- Stripe IDs
    stripe_pm_id VARCHAR(50) NOT NULL UNIQUE,

    -- Type
    type VARCHAR(20) NOT NULL, -- card, bank_transfer, oxxo

    -- Card details (if type = card)
    card_brand VARCHAR(20), -- visa, mastercard, amex
    card_last_four VARCHAR(4),
    card_exp_month SMALLINT,
    card_exp_year SMALLINT,
    card_funding VARCHAR(20), -- credit, debit, prepaid

    -- Bank details (if type = bank_transfer)
    bank_name VARCHAR(100),
    bank_last_four VARCHAR(4),

    -- Status
    is_default BOOLEAN DEFAULT false,
    is_valid BOOLEAN DEFAULT true,

    -- Timestamps
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT valid_stripe_pm_id CHECK (stripe_pm_id ~ '^pm_[a-zA-Z0-9]+$')
);

-- Índices
CREATE INDEX idx_payment_methods_tenant ON billing.payment_methods(tenant_id);
CREATE INDEX idx_payment_methods_stripe_pm ON billing.payment_methods(stripe_pm_id);
CREATE INDEX idx_payment_methods_default ON billing.payment_methods(tenant_id) WHERE is_default = true;

-- Trigger para un solo default por tenant
CREATE OR REPLACE FUNCTION billing.ensure_single_default_payment_method()
RETURNS TRIGGER AS $$
BEGIN
    IF NEW.is_default = true THEN
        UPDATE billing.payment_methods
        SET is_default = false
        WHERE tenant_id = NEW.tenant_id
          AND id != NEW.id
          AND is_default = true;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_single_default_payment_method
    BEFORE INSERT OR UPDATE ON billing.payment_methods
    FOR EACH ROW
    EXECUTE FUNCTION billing.ensure_single_default_payment_method();

-- Webhook Events (idempotency)
CREATE TABLE billing.stripe_webhook_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Stripe event info
    stripe_event_id VARCHAR(50) NOT NULL UNIQUE,
    event_type VARCHAR(100) NOT NULL,
    api_version VARCHAR(20),

    -- Related entities
    tenant_id UUID REFERENCES core_tenants.tenants(id),
    stripe_customer_id VARCHAR(50),
    stripe_subscription_id VARCHAR(50),
    stripe_invoice_id VARCHAR(50),
    stripe_payment_intent_id VARCHAR(50),

    -- Payload
    payload JSONB NOT NULL,

    -- Processing status
    status VARCHAR(20) DEFAULT 'pending', -- pending, processing, processed, failed
    processed_at TIMESTAMPTZ,
    error_message TEXT,
    retry_count INT DEFAULT 0,

    -- Timestamps
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT valid_stripe_event_id CHECK (stripe_event_id ~ '^evt_[a-zA-Z0-9]+$'),
    CONSTRAINT valid_status CHECK (status IN ('pending', 'processing', 'processed', 'failed'))
);

-- Índices para procesamiento de webhooks
CREATE INDEX idx_webhook_events_stripe_id ON billing.stripe_webhook_events(stripe_event_id);
CREATE INDEX idx_webhook_events_type ON billing.stripe_webhook_events(event_type);
CREATE INDEX idx_webhook_events_status ON billing.stripe_webhook_events(status) WHERE status != 'processed';
CREATE INDEX idx_webhook_events_created ON billing.stripe_webhook_events(created_at);
CREATE INDEX idx_webhook_events_tenant ON billing.stripe_webhook_events(tenant_id);

-- Extender tabla subscriptions existente
ALTER TABLE billing.subscriptions ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(50) UNIQUE;
ALTER TABLE billing.subscriptions ADD COLUMN IF NOT EXISTS stripe_price_id VARCHAR(50);
ALTER TABLE billing.subscriptions ADD COLUMN IF NOT EXISTS stripe_status VARCHAR(30);
ALTER TABLE billing.subscriptions ADD COLUMN IF NOT EXISTS cancel_at_period_end BOOLEAN DEFAULT false;
ALTER TABLE billing.subscriptions ADD COLUMN IF NOT EXISTS canceled_at TIMESTAMPTZ;
ALTER TABLE billing.subscriptions ADD COLUMN IF NOT EXISTS trial_end TIMESTAMPTZ;

CREATE INDEX idx_subscriptions_stripe_id ON billing.subscriptions(stripe_subscription_id);

-- Extender tabla plans con Stripe IDs
ALTER TABLE billing.plans ADD COLUMN IF NOT EXISTS stripe_product_id VARCHAR(50);
ALTER TABLE billing.plans ADD COLUMN IF NOT EXISTS stripe_price_id VARCHAR(50);
ALTER TABLE billing.plans ADD COLUMN IF NOT EXISTS stripe_price_id_yearly VARCHAR(50);

-- Extender tabla invoices con Stripe IDs
ALTER TABLE billing.invoices ADD COLUMN IF NOT EXISTS stripe_invoice_id VARCHAR(50) UNIQUE;
ALTER TABLE billing.invoices ADD COLUMN IF NOT EXISTS stripe_payment_intent_id VARCHAR(50);
ALTER TABLE billing.invoices ADD COLUMN IF NOT EXISTS stripe_hosted_invoice_url TEXT;
ALTER TABLE billing.invoices ADD COLUMN IF NOT EXISTS stripe_pdf_url TEXT;

CREATE INDEX idx_invoices_stripe_id ON billing.invoices(stripe_invoice_id);

-- Vista para estado de suscripción Stripe
CREATE OR REPLACE VIEW billing.vw_stripe_subscription_status AS
SELECT
    t.id AS tenant_id,
    t.name AS tenant_name,
    sc.stripe_customer_id,
    s.stripe_subscription_id,
    s.stripe_status,
    s.quantity AS user_count,
    p.name AS plan_name,
    p.price_per_user,
    (s.quantity * p.price_per_user) AS monthly_amount,
    s.current_period_start,
    s.current_period_end,
    s.cancel_at_period_end,
    pm.card_brand,
    pm.card_last_four
FROM core_tenants.tenants t
JOIN billing.stripe_customers sc ON sc.tenant_id = t.id
LEFT JOIN billing.subscriptions s ON s.tenant_id = t.id AND s.status = 'active'
LEFT JOIN billing.plans p ON p.id = s.plan_id
LEFT JOIN billing.payment_methods pm ON pm.tenant_id = t.id AND pm.is_default = true;

3. API Endpoints

3.1 Stripe Customer Management

// ============================================================================
// CUSTOMER ENDPOINTS
// Base: /api/v1/billing/stripe
// ============================================================================

/**
 * POST /api/v1/billing/stripe/customers
 * Crear Stripe Customer para el tenant actual
 *
 * Permisos: billing:manage
 */
interface CreateStripeCustomerRequest {
  email: string;
  name?: string;
  phone?: string;
  tax_id?: string;  // RFC
  billing_address?: {
    line1: string;
    line2?: string;
    city: string;
    state: string;
    postal_code: string;
    country: string;  // Default: MX
  };
}

interface CreateStripeCustomerResponse {
  id: string;
  stripe_customer_id: string;
  email: string;
  created_at: string;
}

/**
 * GET /api/v1/billing/stripe/customers/current
 * Obtener Stripe Customer del tenant actual
 *
 * Permisos: billing:read
 */
interface GetCurrentCustomerResponse {
  id: string;
  stripe_customer_id: string;
  email: string;
  name?: string;
  default_payment_method?: PaymentMethodSummary;
  subscription?: SubscriptionSummary;
}

/**
 * PATCH /api/v1/billing/stripe/customers/current
 * Actualizar datos del customer
 *
 * Permisos: billing:manage
 */
interface UpdateStripeCustomerRequest {
  email?: string;
  name?: string;
  phone?: string;
  tax_id?: string;
  billing_address?: BillingAddress;
}

3.2 Payment Methods

// ============================================================================
// PAYMENT METHODS ENDPOINTS
// Base: /api/v1/billing/stripe/payment-methods
// ============================================================================

/**
 * POST /api/v1/billing/stripe/payment-methods/setup-intent
 * Crear SetupIntent para agregar nueva tarjeta via Stripe Elements
 *
 * Permisos: billing:manage
 */
interface CreateSetupIntentResponse {
  client_secret: string;
  setup_intent_id: string;
}

/**
 * POST /api/v1/billing/stripe/payment-methods
 * Confirmar y guardar payment method después del SetupIntent
 *
 * Permisos: billing:manage
 */
interface AttachPaymentMethodRequest {
  payment_method_id: string;  // pm_xxx del frontend
  set_as_default?: boolean;
}

interface AttachPaymentMethodResponse {
  id: string;
  stripe_pm_id: string;
  type: 'card' | 'bank_transfer';
  card_brand?: string;
  card_last_four?: string;
  is_default: boolean;
}

/**
 * GET /api/v1/billing/stripe/payment-methods
 * Listar payment methods del tenant
 *
 * Permisos: billing:read
 */
interface ListPaymentMethodsResponse {
  data: PaymentMethod[];
  default_payment_method_id?: string;
}

interface PaymentMethod {
  id: string;
  stripe_pm_id: string;
  type: 'card' | 'bank_transfer';
  card_brand?: string;
  card_last_four?: string;
  card_exp_month?: number;
  card_exp_year?: number;
  is_default: boolean;
  created_at: string;
}

/**
 * DELETE /api/v1/billing/stripe/payment-methods/:id
 * Eliminar payment method
 *
 * Permisos: billing:manage
 */

/**
 * POST /api/v1/billing/stripe/payment-methods/:id/set-default
 * Establecer como default
 *
 * Permisos: billing:manage
 */

3.3 Subscriptions

// ============================================================================
// SUBSCRIPTION ENDPOINTS
// Base: /api/v1/billing/stripe/subscriptions
// ============================================================================

/**
 * POST /api/v1/billing/stripe/subscriptions
 * Crear nueva suscripción
 *
 * Permisos: billing:manage
 */
interface CreateSubscriptionRequest {
  plan_id: string;           // UUID del plan en nuestro sistema
  payment_method_id?: string; // Si no se provee, usa default
  quantity?: number;          // Número de usuarios (default: usuarios activos)
  coupon_code?: string;       // Código de descuento
}

interface CreateSubscriptionResponse {
  id: string;
  stripe_subscription_id: string;
  status: 'active' | 'trialing' | 'past_due' | 'incomplete';
  current_period_start: string;
  current_period_end: string;
  plan: {
    id: string;
    name: string;
    price_per_user: number;
  };
  quantity: number;
  total_amount: number;
  // Si requiere acción adicional (3DS)
  requires_action?: boolean;
  client_secret?: string;
}

/**
 * GET /api/v1/billing/stripe/subscriptions/current
 * Obtener suscripción activa
 *
 * Permisos: billing:read
 */
interface GetCurrentSubscriptionResponse {
  id: string;
  stripe_subscription_id: string;
  status: SubscriptionStatus;
  plan: PlanSummary;
  quantity: number;
  current_period_start: string;
  current_period_end: string;
  cancel_at_period_end: boolean;
  trial_end?: string;
  next_invoice?: {
    amount: number;
    date: string;
  };
}

/**
 * PATCH /api/v1/billing/stripe/subscriptions/current
 * Actualizar suscripción (cambiar plan, quantity)
 *
 * Permisos: billing:manage
 */
interface UpdateSubscriptionRequest {
  plan_id?: string;      // Upgrade/downgrade
  quantity?: number;     // Ajuste manual de usuarios
}

interface UpdateSubscriptionResponse {
  id: string;
  status: string;
  proration_amount?: number;  // Monto de prorrata
  effective_date: string;
}

/**
 * POST /api/v1/billing/stripe/subscriptions/current/cancel
 * Cancelar suscripción (al final del período)
 *
 * Permisos: billing:manage
 */
interface CancelSubscriptionRequest {
  reason?: string;
  feedback?: string;
  cancel_immediately?: boolean;  // Default: false (cancela al final del período)
}

interface CancelSubscriptionResponse {
  status: 'canceled' | 'cancel_scheduled';
  cancel_at: string;
  effective_date: string;
}

/**
 * POST /api/v1/billing/stripe/subscriptions/current/reactivate
 * Reactivar suscripción cancelada (antes de que expire)
 *
 * Permisos: billing:manage
 */

3.4 Invoices

// ============================================================================
// INVOICE ENDPOINTS
// Base: /api/v1/billing/stripe/invoices
// ============================================================================

/**
 * GET /api/v1/billing/stripe/invoices
 * Listar facturas del tenant
 *
 * Permisos: billing:read
 */
interface ListInvoicesRequest {
  page?: number;
  limit?: number;  // Default: 10, max: 100
  status?: 'draft' | 'open' | 'paid' | 'void' | 'uncollectible';
  from_date?: string;  // ISO date
  to_date?: string;
}

interface ListInvoicesResponse {
  data: Invoice[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    total_pages: number;
  };
}

interface Invoice {
  id: string;
  stripe_invoice_id: string;
  number: string;
  status: InvoiceStatus;
  amount_due: number;
  amount_paid: number;
  currency: string;
  period_start: string;
  period_end: string;
  due_date?: string;
  paid_at?: string;
  hosted_invoice_url: string;
  pdf_url: string;
  lines: InvoiceLine[];
}

interface InvoiceLine {
  description: string;
  quantity: number;
  unit_amount: number;
  amount: number;
  period?: {
    start: string;
    end: string;
  };
}

/**
 * GET /api/v1/billing/stripe/invoices/:id
 * Obtener detalle de factura
 *
 * Permisos: billing:read
 */

/**
 * POST /api/v1/billing/stripe/invoices/:id/pay
 * Reintentar pago de factura pendiente
 *
 * Permisos: billing:manage
 */
interface PayInvoiceRequest {
  payment_method_id?: string;  // Opcional, usa default si no se provee
}

3.5 Billing Portal

// ============================================================================
// BILLING PORTAL ENDPOINTS
// Base: /api/v1/billing/stripe/portal
// ============================================================================

/**
 * POST /api/v1/billing/stripe/portal/session
 * Crear sesión de Stripe Customer Portal
 *
 * Permisos: billing:manage
 */
interface CreatePortalSessionRequest {
  return_url: string;  // URL a donde regresar después del portal
}

interface CreatePortalSessionResponse {
  url: string;  // URL del Customer Portal
}

3.6 Webhooks

// ============================================================================
// WEBHOOK ENDPOINT
// Base: /webhooks/stripe
// ============================================================================

/**
 * POST /webhooks/stripe
 * Recibir eventos de Stripe
 *
 * Headers requeridos:
 * - Stripe-Signature: firma del evento
 *
 * Sin autenticación (usa verificación de firma)
 */

// Eventos manejados:
const HANDLED_EVENTS = [
  // Subscriptions
  'customer.subscription.created',
  'customer.subscription.updated',
  'customer.subscription.deleted',
  'customer.subscription.trial_will_end',

  // Invoices
  'invoice.created',
  'invoice.finalized',
  'invoice.paid',
  'invoice.payment_failed',
  'invoice.payment_action_required',
  'invoice.upcoming',

  // Payments
  'payment_intent.succeeded',
  'payment_intent.payment_failed',
  'payment_intent.requires_action',

  // Payment Methods
  'payment_method.attached',
  'payment_method.detached',
  'payment_method.updated',

  // Customer
  'customer.updated',
  'customer.deleted',

  // Charges (para disputas)
  'charge.dispute.created',
  'charge.refunded',
];

4. Servicios Backend

4.1 StripeService

// src/modules/billing/services/stripe.service.ts

import Stripe from 'stripe';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

@Injectable()
export class StripeService {
  private stripe: Stripe;

  constructor(
    private configService: ConfigService,
    @InjectRepository(StripeCustomer)
    private customerRepo: Repository<StripeCustomer>,
    @InjectRepository(PaymentMethod)
    private paymentMethodRepo: Repository<PaymentMethod>,
    @InjectRepository(Subscription)
    private subscriptionRepo: Repository<Subscription>,
  ) {
    this.stripe = new Stripe(
      this.configService.get('STRIPE_SECRET_KEY'),
      { apiVersion: '2023-10-16' }
    );
  }

  // ========================================
  // CUSTOMER MANAGEMENT
  // ========================================

  async createCustomer(tenantId: string, data: CreateCustomerDto): Promise<StripeCustomer> {
    // 1. Verificar que no exista
    const existing = await this.customerRepo.findOne({ where: { tenantId } });
    if (existing) {
      throw new ConflictException('Stripe customer already exists for this tenant');
    }

    // 2. Crear en Stripe
    const stripeCustomer = await this.stripe.customers.create({
      email: data.email,
      name: data.name,
      phone: data.phone,
      metadata: {
        tenant_id: tenantId,
        environment: this.configService.get('NODE_ENV'),
      },
      address: data.billing_address ? {
        line1: data.billing_address.line1,
        line2: data.billing_address.line2,
        city: data.billing_address.city,
        state: data.billing_address.state,
        postal_code: data.billing_address.postal_code,
        country: data.billing_address.country || 'MX',
      } : undefined,
      tax_id_data: data.tax_id ? [{
        type: 'mx_rfc',
        value: data.tax_id,
      }] : undefined,
    });

    // 3. Guardar en DB local
    const customer = this.customerRepo.create({
      tenantId,
      stripeCustomerId: stripeCustomer.id,
      email: data.email,
      name: data.name,
      phone: data.phone,
      taxId: data.tax_id,
      billingAddress: data.billing_address,
    });

    return this.customerRepo.save(customer);
  }

  async getCustomerByTenantId(tenantId: string): Promise<StripeCustomer | null> {
    return this.customerRepo.findOne({
      where: { tenantId },
      relations: ['paymentMethods'],
    });
  }

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

  async createSetupIntent(tenantId: string): Promise<Stripe.SetupIntent> {
    const customer = await this.getCustomerByTenantId(tenantId);
    if (!customer) {
      throw new NotFoundException('Stripe customer not found');
    }

    return this.stripe.setupIntents.create({
      customer: customer.stripeCustomerId,
      payment_method_types: ['card'],
      usage: 'off_session',  // Para cobros recurrentes
    });
  }

  async attachPaymentMethod(
    tenantId: string,
    paymentMethodId: string,
    setAsDefault: boolean = false,
  ): Promise<PaymentMethod> {
    const customer = await this.getCustomerByTenantId(tenantId);
    if (!customer) {
      throw new NotFoundException('Stripe customer not found');
    }

    // 1. Adjuntar a customer en Stripe
    const stripepm = await this.stripe.paymentMethods.attach(paymentMethodId, {
      customer: customer.stripeCustomerId,
    });

    // 2. Si es default, actualizar en Stripe
    if (setAsDefault) {
      await this.stripe.customers.update(customer.stripeCustomerId, {
        invoice_settings: {
          default_payment_method: paymentMethodId,
        },
      });
    }

    // 3. Guardar en DB local
    const paymentMethod = this.paymentMethodRepo.create({
      tenantId,
      stripeCustomerId: customer.id,
      stripePmId: stripepm.id,
      type: stripepm.type,
      cardBrand: stripepm.card?.brand,
      cardLastFour: stripepm.card?.last4,
      cardExpMonth: stripepm.card?.exp_month,
      cardExpYear: stripepm.card?.exp_year,
      cardFunding: stripepm.card?.funding,
      isDefault: setAsDefault,
    });

    return this.paymentMethodRepo.save(paymentMethod);
  }

  async listPaymentMethods(tenantId: string): Promise<PaymentMethod[]> {
    return this.paymentMethodRepo.find({
      where: { tenantId },
      order: { isDefault: 'DESC', createdAt: 'DESC' },
    });
  }

  async deletePaymentMethod(tenantId: string, paymentMethodId: string): Promise<void> {
    const pm = await this.paymentMethodRepo.findOne({
      where: { id: paymentMethodId, tenantId },
    });

    if (!pm) {
      throw new NotFoundException('Payment method not found');
    }

    if (pm.isDefault) {
      throw new BadRequestException('Cannot delete default payment method');
    }

    // Detach de Stripe
    await this.stripe.paymentMethods.detach(pm.stripePmId);

    // Eliminar de DB
    await this.paymentMethodRepo.remove(pm);
  }

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

  async createSubscription(
    tenantId: string,
    planId: string,
    quantity?: number,
    paymentMethodId?: string,
  ): Promise<Subscription> {
    const customer = await this.getCustomerByTenantId(tenantId);
    if (!customer) {
      throw new NotFoundException('Stripe customer not found');
    }

    // Obtener plan con Stripe price ID
    const plan = await this.planRepo.findOne({ where: { id: planId } });
    if (!plan || !plan.stripePriceId) {
      throw new NotFoundException('Plan not found or not configured in Stripe');
    }

    // Contar usuarios activos si no se provee quantity
    if (!quantity) {
      quantity = await this.countActiveUsers(tenantId);
    }

    // Crear suscripción en Stripe
    const stripeSubscription = await this.stripe.subscriptions.create({
      customer: customer.stripeCustomerId,
      items: [{
        price: plan.stripePriceId,
        quantity,
      }],
      default_payment_method: paymentMethodId,
      payment_behavior: 'default_incomplete',  // Permite manejar 3DS
      payment_settings: {
        save_default_payment_method: 'on_subscription',
      },
      expand: ['latest_invoice.payment_intent'],
      metadata: {
        tenant_id: tenantId,
        plan_id: planId,
      },
    });

    // Guardar en DB local
    const subscription = this.subscriptionRepo.create({
      tenantId,
      planId,
      stripeSubscriptionId: stripeSubscription.id,
      stripePriceId: plan.stripePriceId,
      stripeStatus: stripeSubscription.status,
      quantity,
      status: this.mapStripeStatus(stripeSubscription.status),
      currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
      currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
    });

    const saved = await this.subscriptionRepo.save(subscription);

    // Verificar si requiere acción (3DS)
    const invoice = stripeSubscription.latest_invoice as Stripe.Invoice;
    const paymentIntent = invoice?.payment_intent as Stripe.PaymentIntent;

    if (paymentIntent?.status === 'requires_action') {
      return {
        ...saved,
        requiresAction: true,
        clientSecret: paymentIntent.client_secret,
      };
    }

    return saved;
  }

  async updateSubscriptionQuantity(
    tenantId: string,
    newQuantity: number,
  ): Promise<Subscription> {
    const subscription = await this.subscriptionRepo.findOne({
      where: { tenantId, status: 'active' },
    });

    if (!subscription) {
      throw new NotFoundException('Active subscription not found');
    }

    // Actualizar en Stripe (genera prorrata automática)
    const stripeSubscription = await this.stripe.subscriptions.retrieve(
      subscription.stripeSubscriptionId
    );

    await this.stripe.subscriptions.update(subscription.stripeSubscriptionId, {
      items: [{
        id: stripeSubscription.items.data[0].id,
        quantity: newQuantity,
      }],
      proration_behavior: 'create_prorations',
    });

    // Actualizar en DB local
    subscription.quantity = newQuantity;
    return this.subscriptionRepo.save(subscription);
  }

  async cancelSubscription(
    tenantId: string,
    cancelImmediately: boolean = false,
  ): Promise<Subscription> {
    const subscription = await this.subscriptionRepo.findOne({
      where: { tenantId, status: 'active' },
    });

    if (!subscription) {
      throw new NotFoundException('Active subscription not found');
    }

    if (cancelImmediately) {
      await this.stripe.subscriptions.cancel(subscription.stripeSubscriptionId);
      subscription.status = 'canceled';
      subscription.canceledAt = new Date();
    } else {
      await this.stripe.subscriptions.update(subscription.stripeSubscriptionId, {
        cancel_at_period_end: true,
      });
      subscription.cancelAtPeriodEnd = true;
    }

    return this.subscriptionRepo.save(subscription);
  }

  // ========================================
  // USER COUNT SYNC
  // ========================================

  async syncUserCount(tenantId: string): Promise<void> {
    const subscription = await this.subscriptionRepo.findOne({
      where: { tenantId, status: 'active' },
    });

    if (!subscription) {
      return; // No hay suscripción activa
    }

    const currentUserCount = await this.countActiveUsers(tenantId);

    if (currentUserCount !== subscription.quantity) {
      await this.updateSubscriptionQuantity(tenantId, currentUserCount);
    }
  }

  private async countActiveUsers(tenantId: string): Promise<number> {
    return this.userRepo.count({
      where: { tenantId, status: 'active' },
    });
  }

  // ========================================
  // BILLING PORTAL
  // ========================================

  async createBillingPortalSession(
    tenantId: string,
    returnUrl: string,
  ): Promise<string> {
    const customer = await this.getCustomerByTenantId(tenantId);
    if (!customer) {
      throw new NotFoundException('Stripe customer not found');
    }

    const session = await this.stripe.billingPortal.sessions.create({
      customer: customer.stripeCustomerId,
      return_url: returnUrl,
    });

    return session.url;
  }

  // ========================================
  // HELPERS
  // ========================================

  private mapStripeStatus(stripeStatus: string): SubscriptionStatus {
    const statusMap: Record<string, SubscriptionStatus> = {
      'active': 'active',
      'trialing': 'trialing',
      'past_due': 'past_due',
      'canceled': 'canceled',
      'unpaid': 'suspended',
      'incomplete': 'pending',
      'incomplete_expired': 'canceled',
    };
    return statusMap[stripeStatus] || 'pending';
  }
}

4.2 StripeWebhookService

// src/modules/billing/services/stripe-webhook.service.ts

@Injectable()
export class StripeWebhookService {
  private stripe: Stripe;

  constructor(
    private configService: ConfigService,
    @InjectRepository(StripeWebhookEvent)
    private webhookEventRepo: Repository<StripeWebhookEvent>,
    private stripeService: StripeService,
    private subscriptionService: SubscriptionService,
    private invoiceService: InvoiceService,
    private notificationService: NotificationService,
    private eventBus: EventBus,
  ) {
    this.stripe = new Stripe(this.configService.get('STRIPE_SECRET_KEY'));
  }

  async handleWebhook(
    payload: Buffer,
    signature: string,
  ): Promise<{ received: boolean }> {
    // 1. Verificar firma
    let event: Stripe.Event;
    try {
      event = this.stripe.webhooks.constructEvent(
        payload,
        signature,
        this.configService.get('STRIPE_WEBHOOK_SECRET'),
      );
    } catch (err) {
      throw new BadRequestException(`Webhook signature verification failed: ${err.message}`);
    }

    // 2. Verificar idempotencia
    const existing = await this.webhookEventRepo.findOne({
      where: { stripeEventId: event.id },
    });

    if (existing) {
      // Ya procesado
      return { received: true };
    }

    // 3. Guardar evento
    const webhookEvent = this.webhookEventRepo.create({
      stripeEventId: event.id,
      eventType: event.type,
      apiVersion: event.api_version,
      payload: event.data,
      status: 'processing',
    });

    await this.webhookEventRepo.save(webhookEvent);

    // 4. Procesar evento
    try {
      await this.processEvent(event);

      webhookEvent.status = 'processed';
      webhookEvent.processedAt = new Date();
    } catch (error) {
      webhookEvent.status = 'failed';
      webhookEvent.errorMessage = error.message;
      webhookEvent.retryCount += 1;

      // Re-lanzar para que Stripe reintente
      throw error;
    } finally {
      await this.webhookEventRepo.save(webhookEvent);
    }

    return { received: true };
  }

  private async processEvent(event: Stripe.Event): Promise<void> {
    const handlers: Record<string, (data: any) => Promise<void>> = {
      // Subscriptions
      'customer.subscription.created': this.handleSubscriptionCreated.bind(this),
      'customer.subscription.updated': this.handleSubscriptionUpdated.bind(this),
      'customer.subscription.deleted': this.handleSubscriptionDeleted.bind(this),
      'customer.subscription.trial_will_end': this.handleTrialWillEnd.bind(this),

      // Invoices
      'invoice.created': this.handleInvoiceCreated.bind(this),
      'invoice.finalized': this.handleInvoiceFinalized.bind(this),
      'invoice.paid': this.handleInvoicePaid.bind(this),
      'invoice.payment_failed': this.handleInvoicePaymentFailed.bind(this),
      'invoice.payment_action_required': this.handlePaymentActionRequired.bind(this),

      // Payment Intents
      'payment_intent.succeeded': this.handlePaymentSucceeded.bind(this),
      'payment_intent.payment_failed': this.handlePaymentFailed.bind(this),

      // Disputes
      'charge.dispute.created': this.handleDisputeCreated.bind(this),
    };

    const handler = handlers[event.type];
    if (handler) {
      await handler(event.data.object);
    }
  }

  // ========================================
  // SUBSCRIPTION HANDLERS
  // ========================================

  private async handleSubscriptionCreated(subscription: Stripe.Subscription): Promise<void> {
    const tenantId = subscription.metadata.tenant_id;
    if (!tenantId) return;

    await this.subscriptionService.syncFromStripe(tenantId, subscription);

    this.eventBus.emit('subscription.created', {
      tenantId,
      subscriptionId: subscription.id,
    });
  }

  private async handleSubscriptionUpdated(subscription: Stripe.Subscription): Promise<void> {
    const tenantId = subscription.metadata.tenant_id;
    if (!tenantId) return;

    await this.subscriptionService.syncFromStripe(tenantId, subscription);

    // Verificar cambios relevantes
    if (subscription.cancel_at_period_end) {
      await this.notificationService.sendSubscriptionCancelScheduled(tenantId);
    }

    this.eventBus.emit('subscription.updated', {
      tenantId,
      subscriptionId: subscription.id,
      status: subscription.status,
    });
  }

  private async handleSubscriptionDeleted(subscription: Stripe.Subscription): Promise<void> {
    const tenantId = subscription.metadata.tenant_id;
    if (!tenantId) return;

    await this.subscriptionService.markAsCanceled(tenantId, subscription.id);
    await this.notificationService.sendSubscriptionCanceled(tenantId);

    this.eventBus.emit('subscription.canceled', {
      tenantId,
      subscriptionId: subscription.id,
    });
  }

  private async handleTrialWillEnd(subscription: Stripe.Subscription): Promise<void> {
    const tenantId = subscription.metadata.tenant_id;
    if (!tenantId) return;

    await this.notificationService.sendTrialEndingNotification(tenantId, subscription.trial_end);
  }

  // ========================================
  // INVOICE HANDLERS
  // ========================================

  private async handleInvoiceCreated(invoice: Stripe.Invoice): Promise<void> {
    await this.invoiceService.createFromStripe(invoice);
  }

  private async handleInvoiceFinalized(invoice: Stripe.Invoice): Promise<void> {
    await this.invoiceService.updateFromStripe(invoice);
  }

  private async handleInvoicePaid(invoice: Stripe.Invoice): Promise<void> {
    const tenantId = await this.getTenantIdFromCustomer(invoice.customer as string);
    if (!tenantId) return;

    await this.invoiceService.markAsPaid(invoice.id);

    // Activar tenant si estaba suspendido
    await this.tenantService.activateIfSuspended(tenantId);

    await this.notificationService.sendInvoicePaid(tenantId, invoice);

    this.eventBus.emit('invoice.paid', {
      tenantId,
      invoiceId: invoice.id,
      amount: invoice.amount_paid,
    });
  }

  private async handleInvoicePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
    const tenantId = await this.getTenantIdFromCustomer(invoice.customer as string);
    if (!tenantId) return;

    await this.invoiceService.markAsPaymentFailed(invoice.id);

    // Enviar notificación de pago fallido
    await this.notificationService.sendPaymentFailed(tenantId, invoice);

    // Incrementar contador de dunning
    await this.dunningService.recordFailedPayment(tenantId);

    this.eventBus.emit('invoice.payment_failed', {
      tenantId,
      invoiceId: invoice.id,
      attemptCount: invoice.attempt_count,
    });
  }

  private async handlePaymentActionRequired(invoice: Stripe.Invoice): Promise<void> {
    const tenantId = await this.getTenantIdFromCustomer(invoice.customer as string);
    if (!tenantId) return;

    // Notificar que se requiere acción (probablemente 3DS)
    await this.notificationService.sendPaymentActionRequired(tenantId, invoice);
  }

  // ========================================
  // PAYMENT HANDLERS
  // ========================================

  private async handlePaymentSucceeded(paymentIntent: Stripe.PaymentIntent): Promise<void> {
    const tenantId = paymentIntent.metadata?.tenant_id;
    if (!tenantId) return;

    await this.paymentService.recordSuccessfulPayment(paymentIntent);

    this.eventBus.emit('payment.succeeded', {
      tenantId,
      paymentIntentId: paymentIntent.id,
      amount: paymentIntent.amount,
    });
  }

  private async handlePaymentFailed(paymentIntent: Stripe.PaymentIntent): Promise<void> {
    const tenantId = paymentIntent.metadata?.tenant_id;
    if (!tenantId) return;

    await this.paymentService.recordFailedPayment(paymentIntent);
  }

  // ========================================
  // DISPUTE HANDLERS
  // ========================================

  private async handleDisputeCreated(dispute: Stripe.Dispute): Promise<void> {
    // Alertar inmediatamente - las disputas tienen deadlines estrictos
    await this.notificationService.sendDisputeAlert(dispute);

    // Log para review manual
    this.logger.warn('Dispute created', {
      disputeId: dispute.id,
      chargeId: dispute.charge,
      amount: dispute.amount,
      reason: dispute.reason,
    });
  }

  // ========================================
  // HELPERS
  // ========================================

  private async getTenantIdFromCustomer(stripeCustomerId: string): Promise<string | null> {
    const customer = await this.stripeCustomerRepo.findOne({
      where: { stripeCustomerId },
    });
    return customer?.tenantId || null;
  }
}

4.3 User Count Event Listener

// src/modules/billing/listeners/user-count.listener.ts

@Injectable()
export class UserCountListener {
  constructor(private stripeService: StripeService) {}

  @OnEvent('user.created')
  @OnEvent('user.activated')
  async handleUserAdded(event: { tenantId: string; userId: string }) {
    await this.stripeService.syncUserCount(event.tenantId);
  }

  @OnEvent('user.deactivated')
  @OnEvent('user.deleted')
  async handleUserRemoved(event: { tenantId: string; userId: string }) {
    await this.stripeService.syncUserCount(event.tenantId);
  }
}

5. Frontend Integration

5.1 Stripe Elements Setup

// src/components/billing/StripeProvider.tsx

import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';

const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY);

export function StripeProvider({ children }: { children: React.ReactNode }) {
  return (
    <Elements
      stripe={stripePromise}
      options={{
        locale: 'es',
        appearance: {
          theme: 'stripe',
          variables: {
            colorPrimary: '#0066cc',
          },
        },
      }}
    >
      {children}
    </Elements>
  );
}

5.2 Payment Method Form

// src/components/billing/AddPaymentMethodForm.tsx

import { useStripe, useElements, CardElement } from '@stripe/react-stripe-js';
import { useMutation } from '@tanstack/react-query';

export function AddPaymentMethodForm({ onSuccess }: { onSuccess: () => void }) {
  const stripe = useStripe();
  const elements = useElements();
  const [error, setError] = useState<string | null>(null);
  const [isProcessing, setIsProcessing] = useState(false);

  // 1. Obtener SetupIntent
  const { mutateAsync: createSetupIntent } = useMutation({
    mutationFn: () => billingApi.createSetupIntent(),
  });

  // 2. Guardar payment method
  const { mutateAsync: attachPaymentMethod } = useMutation({
    mutationFn: (paymentMethodId: string) =>
      billingApi.attachPaymentMethod(paymentMethodId, true),
  });

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

    if (!stripe || !elements) return;

    setIsProcessing(true);
    setError(null);

    try {
      // Obtener client_secret
      const { client_secret } = await createSetupIntent();

      // Confirmar con Stripe
      const { error: stripeError, setupIntent } = await stripe.confirmCardSetup(
        client_secret,
        {
          payment_method: {
            card: elements.getElement(CardElement)!,
          },
        }
      );

      if (stripeError) {
        setError(stripeError.message || 'Error al procesar la tarjeta');
        return;
      }

      // Guardar en backend
      await attachPaymentMethod(setupIntent.payment_method as string);

      onSuccess();
    } catch (err) {
      setError('Error al guardar el método de pago');
    } finally {
      setIsProcessing(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <CardElement
        options={{
          style: {
            base: {
              fontSize: '16px',
              color: '#424770',
              '::placeholder': {
                color: '#aab7c4',
              },
            },
          },
          hidePostalCode: true,
        }}
      />

      {error && <div className="text-red-500 mt-2">{error}</div>}

      <Button
        type="submit"
        disabled={!stripe || isProcessing}
        className="mt-4"
      >
        {isProcessing ? 'Procesando...' : 'Agregar Tarjeta'}
      </Button>
    </form>
  );
}

5.3 Subscription Management

// src/pages/billing/SubscriptionPage.tsx

export function SubscriptionPage() {
  const { data: subscription, isLoading } = useQuery({
    queryKey: ['subscription'],
    queryFn: () => billingApi.getCurrentSubscription(),
  });

  const { mutate: cancelSubscription } = useMutation({
    mutationFn: () => billingApi.cancelSubscription(),
    onSuccess: () => {
      queryClient.invalidateQueries(['subscription']);
      toast.success('Suscripción programada para cancelación');
    },
  });

  const { mutate: openBillingPortal } = useMutation({
    mutationFn: () => billingApi.createBillingPortalSession(window.location.href),
    onSuccess: (url) => {
      window.location.href = url;
    },
  });

  if (isLoading) return <Skeleton />;

  return (
    <div className="space-y-6">
      <Card>
        <CardHeader>
          <CardTitle>Tu Suscripción</CardTitle>
        </CardHeader>
        <CardContent>
          <div className="grid grid-cols-2 gap-4">
            <div>
              <Label>Plan</Label>
              <p className="text-lg font-semibold">{subscription.plan.name}</p>
            </div>
            <div>
              <Label>Usuarios</Label>
              <p className="text-lg font-semibold">{subscription.quantity}</p>
            </div>
            <div>
              <Label>Próximo cobro</Label>
              <p className="text-lg font-semibold">
                ${subscription.quantity * subscription.plan.price_per_user} MXN
              </p>
              <p className="text-sm text-gray-500">
                {format(new Date(subscription.current_period_end), 'dd MMM yyyy')}
              </p>
            </div>
            <div>
              <Label>Estado</Label>
              <Badge variant={subscription.status === 'active' ? 'success' : 'warning'}>
                {subscription.status}
              </Badge>
            </div>
          </div>

          {subscription.cancel_at_period_end && (
            <Alert variant="warning" className="mt-4">
              Tu suscripción se cancelará el{' '}
              {format(new Date(subscription.current_period_end), 'dd MMM yyyy')}
            </Alert>
          )}
        </CardContent>
        <CardFooter className="space-x-4">
          <Button onClick={() => openBillingPortal()}>
            Administrar Facturación
          </Button>
          {!subscription.cancel_at_period_end && (
            <Button
              variant="destructive"
              onClick={() => cancelSubscription()}
            >
              Cancelar Suscripción
            </Button>
          )}
        </CardFooter>
      </Card>
    </div>
  );
}

6. Testing

6.1 Stripe Test Mode

# Tarjetas de prueba
4242424242424242  # Visa - siempre exitosa
4000000000000002  # Visa - rechazada
4000002500003155  # Visa - requiere 3DS
4000000000009995  # Visa - fondos insuficientes

# Webhook testing con Stripe CLI
stripe listen --forward-to localhost:3000/webhooks/stripe

# Trigger eventos manualmente
stripe trigger invoice.payment_succeeded
stripe trigger customer.subscription.updated

6.2 Integration Tests

// tests/integration/stripe.test.ts

describe('Stripe Integration', () => {
  describe('Subscription Creation', () => {
    it('should create subscription with valid payment method', async () => {
      // Arrange
      const tenant = await createTestTenant();
      const customer = await stripeService.createCustomer(tenant.id, {
        email: 'test@example.com',
      });

      // Simular payment method (en tests usamos token de prueba)
      const paymentMethod = await stripe.paymentMethods.create({
        type: 'card',
        card: { token: 'tok_visa' },
      });

      await stripeService.attachPaymentMethod(
        tenant.id,
        paymentMethod.id,
        true
      );

      // Act
      const subscription = await stripeService.createSubscription(
        tenant.id,
        testPlan.id,
        5
      );

      // Assert
      expect(subscription.status).toBe('active');
      expect(subscription.quantity).toBe(5);
      expect(subscription.stripeSubscriptionId).toMatch(/^sub_/);
    });

    it('should handle 3DS authentication', async () => {
      // Usar tarjeta que requiere 3DS
      const paymentMethod = await stripe.paymentMethods.create({
        type: 'card',
        card: { token: 'tok_threeDSecure2Required' },
      });

      // ... setup ...

      const subscription = await stripeService.createSubscription(
        tenant.id,
        testPlan.id,
        1
      );

      expect(subscription.requiresAction).toBe(true);
      expect(subscription.clientSecret).toBeDefined();
    });
  });

  describe('Webhook Processing', () => {
    it('should process invoice.paid and activate tenant', async () => {
      // Arrange
      const tenant = await createSuspendedTenant();
      const invoice = createMockInvoice(tenant.id);

      // Act
      await webhookService.handleWebhook(
        Buffer.from(JSON.stringify({
          id: 'evt_test',
          type: 'invoice.paid',
          data: { object: invoice },
        })),
        generateSignature(invoice)
      );

      // Assert
      const updatedTenant = await tenantRepo.findOne(tenant.id);
      expect(updatedTenant.status).toBe('active');
    });

    it('should be idempotent for duplicate events', async () => {
      const event = createMockEvent('invoice.paid');

      // Primera llamada
      await webhookService.handleWebhook(event.payload, event.signature);

      // Segunda llamada (duplicado)
      await webhookService.handleWebhook(event.payload, event.signature);

      // Verificar que solo se procesó una vez
      const events = await webhookEventRepo.find({
        where: { stripeEventId: event.id },
      });
      expect(events.length).toBe(1);
    });
  });
});

7. Configuración de Stripe

7.1 Products y Prices en Stripe Dashboard

// Productos a crear en Stripe
{
  "products": [
    {
      "name": "ERP Construcción - Starter",
      "description": "Plan básico para pequeñas constructoras",
      "metadata": {
        "plan_code": "construccion_starter",
        "vertical": "construccion"
      }
    },
    {
      "name": "ERP Construcción - Growth",
      "description": "Plan avanzado con todas las funcionalidades",
      "metadata": {
        "plan_code": "construccion_growth",
        "vertical": "construccion"
      }
    },
    {
      "name": "ERP Construcción - Enterprise",
      "description": "Plan personalizado para grandes constructoras",
      "metadata": {
        "plan_code": "construccion_enterprise",
        "vertical": "construccion"
      }
    }
  ],
  "prices": [
    {
      "product": "ERP Construcción - Starter",
      "unit_amount": 49900,
      "currency": "mxn",
      "recurring": {
        "interval": "month",
        "usage_type": "licensed"
      },
      "billing_scheme": "per_unit",
      "metadata": {
        "price_per_user": true
      }
    },
    {
      "product": "ERP Construcción - Growth",
      "unit_amount": 99900,
      "currency": "mxn",
      "recurring": {
        "interval": "month",
        "usage_type": "licensed"
      },
      "billing_scheme": "per_unit"
    }
  ]
}

7.2 Webhook Endpoints

Endpoint URL: https://api.erp-suite.com/webhooks/stripe
Events to listen:
- customer.subscription.created
- customer.subscription.updated
- customer.subscription.deleted
- customer.subscription.trial_will_end
- invoice.created
- invoice.finalized
- invoice.paid
- invoice.payment_failed
- invoice.payment_action_required
- payment_intent.succeeded
- payment_intent.payment_failed
- payment_method.attached
- payment_method.detached
- charge.dispute.created

7.3 Customer Portal Configuration

Features habilitadas:
- Update payment methods
- Update billing information
- View invoice history
- Download invoices
- Cancel subscription (at period end only)

8. Seguridad

8.1 Checklist de Seguridad

  • Stripe Secret Key solo en backend (nunca en frontend)
  • Webhook signature verification obligatoria
  • Idempotencia en procesamiento de webhooks
  • Rate limiting en endpoints de billing
  • Logs de todas las operaciones Stripe
  • Encriptación de payment method IDs en reposo
  • Validación de tenant ownership en cada operación

8.2 Variables de Entorno

# .env (ejemplo)
STRIPE_SECRET_KEY=sk_live_xxx  # O sk_test_xxx para desarrollo
STRIPE_PUBLISHABLE_KEY=pk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_API_VERSION=2023-10-16

Creado por: Requirements-Analyst Fecha: 2025-12-05 Versión: 1.0