14 KiB
ET-MGN-015-002: API de Gestión de Suscripciones
Módulo: MGN-015 - Billing y Suscripciones RF Relacionado: RF-MGN-015-002 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:
{
"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:
{
"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:
{
"plan_id": "uuid",
"billing_cycle": "yearly",
"payment_method_id": "uuid"
}
Response 200:
{
"success": true,
"data": {
"subscription": { ... },
"prorated_amount": 250.00,
"invoice": {
"id": "uuid",
"total": 250.00,
"status": "paid"
}
},
"message": "Plan actualizado exitosamente"
}
Response 402:
{
"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:
{
"plan_id": "uuid"
}
Response 200:
{
"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:
{
"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:
{
"reason": "too_expensive",
"feedback": "El precio es muy alto para nuestro presupuesto actual"
}
Response 200:
{
"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:
{
"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:
{
"billing_cycle": "yearly"
}
Response 200:
{
"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:
{
"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
// 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
// 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
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
- Transacciones: Upgrades deben ser atómicos (pago + actualización)
- Idempotencia: Manejar reintentos de manera segura
- Webhooks: Recibir confirmaciones del gateway de pagos
- Logs: Registrar todos los cambios en subscription_history
- Notificaciones: Enviar emails en cada evento importante