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