# ET-MGN-015-002: API de Gestión de Suscripciones **Módulo:** MGN-015 - Billing y Suscripciones **RF Relacionado:** [RF-MGN-015-002](../../../requerimientos-funcionales/mgn-015/RF-MGN-015-002-gestion-suscripciones-tenant.md) **Tipo:** Backend API **Estado:** Especificado **Fecha:** 2025-11-24 ## Descripción Técnica API REST para la gestión de suscripciones de tenants. Permite a los tenant owners gestionar su suscripción y a los super admins gestionar cualquier suscripción del sistema. ## Endpoints ### GET /api/v1/billing/subscription Obtiene la suscripción actual del tenant autenticado. **Autorización:** `tenant_owner`, `admin` **Response 200:** ```json { "success": true, "data": { "id": "uuid", "tenant_id": "uuid", "plan": { "id": "uuid", "code": "professional", "name": "Plan Profesional", "price_monthly": 499.00, "features": { ... } }, "status": "active", "billing_cycle": "monthly", "current_period_start": "2024-01-01T00:00:00Z", "current_period_end": "2024-02-01T00:00:00Z", "cancel_at_period_end": false, "trial_ends_at": null, "usage": { "users": { "current": 8, "limit": 25, "percentage": 32 }, "companies": { "current": 1, "limit": 3, "percentage": 33 }, "storage_gb": { "current": 5.2, "limit": 50, "percentage": 10 } }, "created_at": "2024-01-01T00:00:00Z" } } ``` ### GET /api/v1/billing/subscriptions (Admin) Lista todas las suscripciones del sistema. **Autorización:** `super_admin` **Query Parameters:** | Parámetro | Tipo | Descripción | |-----------|------|-------------| | status | string | Filtrar por estado | | plan_id | UUID | Filtrar por plan | | page | number | Página (default: 1) | | limit | number | Registros por página (default: 20) | **Response 200:** ```json { "success": true, "data": [ { "id": "uuid", "tenant": { "id": "uuid", "name": "Demo Company" }, "plan": { "id": "uuid", "code": "professional", "name": "Plan Profesional" }, "status": "active", "billing_cycle": "monthly", "current_period_end": "2024-02-01T00:00:00Z" } ], "meta": { "total": 150, "page": 1, "limit": 20, "pages": 8 } } ``` ### POST /api/v1/billing/subscription/upgrade Upgrade a un plan superior. **Autorización:** `tenant_owner` **Request Body:** ```json { "plan_id": "uuid", "billing_cycle": "yearly", "payment_method_id": "uuid" } ``` **Response 200:** ```json { "success": true, "data": { "subscription": { ... }, "prorated_amount": 250.00, "invoice": { "id": "uuid", "total": 250.00, "status": "paid" } }, "message": "Plan actualizado exitosamente" } ``` **Response 402:** ```json { "success": false, "error": "El pago no pudo ser procesado", "details": { "decline_code": "insufficient_funds" } } ``` ### POST /api/v1/billing/subscription/downgrade Programa downgrade para fin del período. **Autorización:** `tenant_owner` **Request Body:** ```json { "plan_id": "uuid" } ``` **Response 200:** ```json { "success": true, "data": { "subscription": { ... }, "scheduled_plan": { "id": "uuid", "code": "basic", "name": "Plan Básico" }, "effective_date": "2024-02-01T00:00:00Z" }, "message": "Cambio de plan programado para el fin del período actual" } ``` **Response 400:** ```json { "success": false, "error": "El uso actual excede los límites del nuevo plan", "details": { "users": { "current": 15, "new_limit": 10 }, "companies": { "current": 2, "new_limit": 1 } } } ``` ### POST /api/v1/billing/subscription/cancel Cancela la suscripción al fin del período. **Autorización:** `tenant_owner` **Request Body:** ```json { "reason": "too_expensive", "feedback": "El precio es muy alto para nuestro presupuesto actual" } ``` **Response 200:** ```json { "success": true, "data": { "subscription": { "status": "pending_cancellation", "cancel_at_period_end": true }, "access_until": "2024-02-01T00:00:00Z" }, "message": "Suscripción cancelada. Tendrás acceso hasta el 2024-02-01" } ``` ### POST /api/v1/billing/subscription/reactivate Reactiva una suscripción cancelada (antes del fin del período). **Autorización:** `tenant_owner` **Response 200:** ```json { "success": true, "data": { "subscription": { "status": "active", "cancel_at_period_end": false } }, "message": "Suscripción reactivada exitosamente" } ``` ### PUT /api/v1/billing/subscription/billing-cycle Cambia el ciclo de facturación. **Autorización:** `tenant_owner` **Request Body:** ```json { "billing_cycle": "yearly" } ``` **Response 200:** ```json { "success": true, "data": { "subscription": { ... }, "new_cycle": "yearly", "effective_date": "2024-02-01T00:00:00Z", "savings": 998.00 }, "message": "Ciclo de facturación cambiará a anual en la próxima renovación" } ``` ### GET /api/v1/billing/subscription/history Historial de cambios de la suscripción. **Autorización:** `tenant_owner`, `admin` **Response 200:** ```json { "success": true, "data": [ { "id": "uuid", "action": "upgrade", "from_plan": "basic", "to_plan": "professional", "changed_at": "2024-01-15T10:30:00Z", "changed_by": { "id": "uuid", "email": "owner@company.com" } }, { "id": "uuid", "action": "created", "to_plan": "basic", "changed_at": "2024-01-01T00:00:00Z" } ] } ``` ## Service Implementation ```typescript // subscriptions.service.ts export class SubscriptionsService { async getCurrentSubscription(tenantId: string) { const subscription = await db.queryOne(` SELECT s.*, row_to_json(sp.*) as plan FROM billing.subscriptions s JOIN billing.subscription_plans sp ON s.plan_id = sp.id WHERE s.tenant_id = $1 AND s.status IN ('active', 'trialing', 'past_due', 'pending_cancellation') `, [tenantId]); if (!subscription) { throw new NotFoundError('Suscripción no encontrada'); } // Agregar uso actual subscription.usage = await this.getCurrentUsage(tenantId, subscription.plan); return subscription; } async upgrade(tenantId: string, data: UpgradeDto) { const currentSub = await this.getCurrentSubscription(tenantId); const newPlan = await plansService.findById(data.plan_id); // Validar que sea upgrade (precio mayor) if (newPlan.price_monthly <= currentSub.plan.price_monthly) { throw new BadRequestError('Para downgrade usa el endpoint correspondiente'); } // Calcular prorrateo const daysRemaining = this.getDaysRemaining(currentSub.current_period_end); const dailyDiff = (newPlan.price_monthly - currentSub.plan.price_monthly) / 30; const proratedAmount = dailyDiff * daysRemaining; // Procesar pago const payment = await paymentsService.charge(tenantId, { amount: proratedAmount, payment_method_id: data.payment_method_id, description: `Upgrade a ${newPlan.name} (prorrateo)` }); if (!payment.success) { throw new PaymentFailedError(payment.error); } // Actualizar suscripción const updated = await db.queryOne(` UPDATE billing.subscriptions SET plan_id = $1, billing_cycle = $2, updated_at = NOW() WHERE tenant_id = $3 RETURNING * `, [data.plan_id, data.billing_cycle || currentSub.billing_cycle, tenantId]); // Registrar en historial await this.logChange(tenantId, { action: 'upgrade', from_plan_id: currentSub.plan.id, to_plan_id: data.plan_id, metadata: { prorated_amount: proratedAmount } }); return { subscription: updated, prorated_amount: proratedAmount, invoice: payment.invoice }; } async downgrade(tenantId: string, data: DowngradeDto) { const currentSub = await this.getCurrentSubscription(tenantId); const newPlan = await plansService.findById(data.plan_id); // Validar límites vs uso actual const usage = currentSub.usage; const violations = []; if (newPlan.max_users && usage.users.current > newPlan.max_users) { violations.push({ resource: 'users', current: usage.users.current, new_limit: newPlan.max_users }); } if (newPlan.max_companies && usage.companies.current > newPlan.max_companies) { violations.push({ resource: 'companies', current: usage.companies.current, new_limit: newPlan.max_companies }); } if (violations.length > 0) { throw new BadRequestError('El uso actual excede los límites del nuevo plan', { violations }); } // Programar downgrade para fin del período await db.query(` UPDATE billing.subscriptions SET scheduled_plan_id = $1, updated_at = NOW() WHERE tenant_id = $2 `, [data.plan_id, tenantId]); await this.logChange(tenantId, { action: 'downgrade_scheduled', from_plan_id: currentSub.plan.id, to_plan_id: data.plan_id, metadata: { effective_date: currentSub.current_period_end } }); return { subscription: currentSub, scheduled_plan: newPlan, effective_date: currentSub.current_period_end }; } async cancel(tenantId: string, data: CancelDto) { const subscription = await this.getCurrentSubscription(tenantId); await db.query(` UPDATE billing.subscriptions SET status = 'pending_cancellation', cancel_at_period_end = true, canceled_at = NOW(), cancellation_reason = $1, cancellation_feedback = $2, updated_at = NOW() WHERE tenant_id = $3 `, [data.reason, data.feedback, tenantId]); await this.logChange(tenantId, { action: 'cancellation_scheduled', metadata: { reason: data.reason, access_until: subscription.current_period_end } }); // Enviar email de confirmación await emailService.send({ to: await this.getTenantOwnerEmail(tenantId), template: 'subscription_cancelled', data: { access_until: subscription.current_period_end } }); return { subscription: { status: 'pending_cancellation', cancel_at_period_end: true }, access_until: subscription.current_period_end }; } private async getCurrentUsage(tenantId: string, plan: any) { const [users, companies, storage] = await Promise.all([ db.queryOne('SELECT COUNT(*) FROM auth.users WHERE tenant_id = $1 AND status = $2', [tenantId, 'active']), db.queryOne('SELECT COUNT(*) FROM companies.companies WHERE tenant_id = $1', [tenantId]), db.queryOne('SELECT COALESCE(SUM(size_bytes), 0) / 1073741824.0 as gb FROM storage.files WHERE tenant_id = $1', [tenantId]) ]); return { users: { current: parseInt(users.count), limit: plan.max_users, percentage: plan.max_users ? Math.round((users.count / plan.max_users) * 100) : 0 }, companies: { current: parseInt(companies.count), limit: plan.max_companies, percentage: plan.max_companies ? Math.round((companies.count / plan.max_companies) * 100) : 0 }, storage_gb: { current: parseFloat(storage.gb.toFixed(2)), limit: plan.max_storage_gb, percentage: plan.max_storage_gb ? Math.round((storage.gb / plan.max_storage_gb) * 100) : 0 } }; } private async logChange(tenantId: string, data: any) { await db.query(` INSERT INTO billing.subscription_history (tenant_id, subscription_id, action, from_plan_id, to_plan_id, metadata, changed_by) SELECT $1, id, $2, $3, $4, $5, $6 FROM billing.subscriptions WHERE tenant_id = $1 `, [tenantId, data.action, data.from_plan_id, data.to_plan_id, data.metadata, data.changed_by]); } } ``` ## Jobs Programados ```typescript // jobs/subscription-renewal.job.ts export async function processSubscriptionRenewals() { // Buscar suscripciones que vencen mañana const subscriptions = await db.query(` SELECT s.*, t.name as tenant_name, pm.provider_token FROM billing.subscriptions s JOIN auth.tenants t ON s.tenant_id = t.id LEFT JOIN billing.payment_methods pm ON pm.tenant_id = s.tenant_id AND pm.is_default = true WHERE s.status = 'active' AND s.current_period_end <= NOW() + INTERVAL '1 day' AND s.cancel_at_period_end = false `); for (const sub of subscriptions) { try { await renewSubscription(sub); } catch (error) { logger.error('Renewal failed', { tenant_id: sub.tenant_id, error }); await handleRenewalFailure(sub, error); } } } async function renewSubscription(subscription: any) { const plan = await plansService.findById(subscription.plan_id); const amount = subscription.billing_cycle === 'yearly' ? plan.price_yearly : plan.price_monthly; // Aplicar downgrade programado si existe if (subscription.scheduled_plan_id) { await db.query(` UPDATE billing.subscriptions SET plan_id = scheduled_plan_id, scheduled_plan_id = NULL WHERE id = $1 `, [subscription.id]); } // Crear factura y cobrar const invoice = await invoicesService.createForRenewal(subscription, amount); const payment = await paymentsService.charge(subscription.tenant_id, { amount, invoice_id: invoice.id }); if (payment.success) { // Extender período await db.query(` UPDATE billing.subscriptions SET current_period_start = current_period_end, current_period_end = current_period_end + CASE billing_cycle WHEN 'monthly' THEN INTERVAL '1 month' WHEN 'quarterly' THEN INTERVAL '3 months' WHEN 'semi_annual' THEN INTERVAL '6 months' WHEN 'yearly' THEN INTERVAL '1 year' END, updated_at = NOW() WHERE id = $1 `, [subscription.id]); } } ``` ## Validaciones Zod ```typescript export const upgradeSchema = z.object({ plan_id: z.string().uuid(), billing_cycle: z.enum(['monthly', 'quarterly', 'semi_annual', 'yearly']).optional(), payment_method_id: z.string().uuid().optional() }); export const downgradeSchema = z.object({ plan_id: z.string().uuid() }); export const cancelSchema = z.object({ reason: z.enum([ 'too_expensive', 'missing_features', 'switched_to_competitor', 'not_using', 'other' ]), feedback: z.string().max(1000).optional() }); ``` ## Notas de Implementación 1. **Transacciones:** Upgrades deben ser atómicos (pago + actualización) 2. **Idempotencia:** Manejar reintentos de manera segura 3. **Webhooks:** Recibir confirmaciones del gateway de pagos 4. **Logs:** Registrar todos los cambios en subscription_history 5. **Notificaciones:** Enviar emails en cada evento importante