# 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 ```sql -- ============================================================================ -- 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 ```typescript // ============================================================================ // 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 ```typescript // ============================================================================ // 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 ```typescript // ============================================================================ // 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 ```typescript // ============================================================================ // 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 ```typescript // ============================================================================ // 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 ```typescript // ============================================================================ // 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 ```typescript // 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, @InjectRepository(PaymentMethod) private paymentMethodRepo: Repository, @InjectRepository(Subscription) private subscriptionRepo: Repository, ) { this.stripe = new Stripe( this.configService.get('STRIPE_SECRET_KEY'), { apiVersion: '2023-10-16' } ); } // ======================================== // CUSTOMER MANAGEMENT // ======================================== async createCustomer(tenantId: string, data: CreateCustomerDto): Promise { // 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 { return this.customerRepo.findOne({ where: { tenantId }, relations: ['paymentMethods'], }); } // ======================================== // PAYMENT METHODS // ======================================== async createSetupIntent(tenantId: string): Promise { 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 { 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 { return this.paymentMethodRepo.find({ where: { tenantId }, order: { isDefault: 'DESC', createdAt: 'DESC' }, }); } async deletePaymentMethod(tenantId: string, paymentMethodId: string): Promise { 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 { 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 { 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 { 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 { 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 { return this.userRepo.count({ where: { tenantId, status: 'active' }, }); } // ======================================== // BILLING PORTAL // ======================================== async createBillingPortalSession( tenantId: string, returnUrl: string, ): Promise { 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 = { 'active': 'active', 'trialing': 'trialing', 'past_due': 'past_due', 'canceled': 'canceled', 'unpaid': 'suspended', 'incomplete': 'pending', 'incomplete_expired': 'canceled', }; return statusMap[stripeStatus] || 'pending'; } } ``` ### 4.2 StripeWebhookService ```typescript // src/modules/billing/services/stripe-webhook.service.ts @Injectable() export class StripeWebhookService { private stripe: Stripe; constructor( private configService: ConfigService, @InjectRepository(StripeWebhookEvent) private webhookEventRepo: Repository, 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 { const handlers: Record Promise> = { // 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 { 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 { 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 { 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 { 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 { await this.invoiceService.createFromStripe(invoice); } private async handleInvoiceFinalized(invoice: Stripe.Invoice): Promise { await this.invoiceService.updateFromStripe(invoice); } private async handleInvoicePaid(invoice: Stripe.Invoice): Promise { 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 { 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 { 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 { 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 { const tenantId = paymentIntent.metadata?.tenant_id; if (!tenantId) return; await this.paymentService.recordFailedPayment(paymentIntent); } // ======================================== // DISPUTE HANDLERS // ======================================== private async handleDisputeCreated(dispute: Stripe.Dispute): Promise { // 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 { const customer = await this.stripeCustomerRepo.findOne({ where: { stripeCustomerId }, }); return customer?.tenantId || null; } } ``` ### 4.3 User Count Event Listener ```typescript // 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 ```typescript // 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 ( {children} ); } ``` ### 5.2 Payment Method Form ```typescript // 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(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 (
{error &&
{error}
} ); } ``` ### 5.3 Subscription Management ```typescript // 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 ; return (
Tu Suscripción

{subscription.plan.name}

{subscription.quantity}

${subscription.quantity * subscription.plan.price_per_user} MXN

{format(new Date(subscription.current_period_end), 'dd MMM yyyy')}

{subscription.status}
{subscription.cancel_at_period_end && ( Tu suscripción se cancelará el{' '} {format(new Date(subscription.current_period_end), 'dd MMM yyyy')} )}
{!subscription.cancel_at_period_end && ( )}
); } ``` --- ## 6. Testing ### 6.1 Stripe Test Mode ```bash # 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 ```typescript // 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 ```json // 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 ```bash # .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