1800 lines
55 KiB
Markdown
1800 lines
55 KiB
Markdown
# 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<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
|
|
|
|
```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<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
|
|
|
|
```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 (
|
|
<Elements
|
|
stripe={stripePromise}
|
|
options={{
|
|
locale: 'es',
|
|
appearance: {
|
|
theme: 'stripe',
|
|
variables: {
|
|
colorPrimary: '#0066cc',
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
{children}
|
|
</Elements>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 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<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
|
|
|
|
```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 <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
|
|
|
|
```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
|