[CONSEJO — Doc 07] Contrarian: El backend de MindStack tiene un riesgo no obvio: es el sistema de billing real de Cheryx. Un bug en el cálculo de una factura puede cobrar de más a un cliente, o no cobrar y perder revenue. La precisión del motor de suscripciones tiene que ser tratada con la misma rigurosidad que el motor de planilla de PayMind — determinístico, con audit log, e inmutable una vez procesada. Executor: En el MVP, ONVO Pay maneja el estado del pago externamente (webhooks). No construir lógica de pagos propia — solo escuchar los webhooks de ONVO y actualizar el estado de la suscripción en la DB. Eso es el 80% del trabajo. El 20% restante (proration, upgrades, créditos) se construye cuando hay clientes reales que los necesitan.
1. Arquitectura
┌─────────────────────────────────────────────────────────────┐
│ MindStack Backend │
│ │
│ FastAPI + Python 3.12 │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Auth Service │ │ Billing │ │ Subscription │ │
│ │ │ │ Service │ │ Engine │ │
│ │ JWT + MFA │ │ │ │ │ │
│ │ OAuth Google │ │ ONVO Pay │ │ Plans, Trials │ │
│ │ │ │ webhooks │ │ Metering, Expand │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Tenant │ │ Partner │ │ Notification │ │
│ │ Service │ │ Service │ │ Service │ │
│ │ │ │ │ │ │ │
│ │ Multi-tenant │ │ CPA referrals│ │ Email (Resend) │ │
│ │ isolation │ │ commissions │ │ WhatsApp │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ │
│ PostgreSQL 16 · Redis · Celery workers │
└─────────────────────────────────────────────────────────────┘
│
│ Redis Events
▼
┌───────────────────────────────┐
│ Módulos MindStack Suite │
│ StockMind · SalesMind │
│ BookMind · PayMind │
└───────────────────────────────┘
2. API endpoints principales
Auth
POST /auth/register # Registro nuevo usuario
POST /auth/login # Login email+password
POST /auth/google # OAuth Google
POST /auth/refresh # Refresh JWT
POST /auth/mfa/setup # Configurar TOTP
POST /auth/mfa/verify # Verificar TOTP
POST /auth/logout
Subscriptions
GET /subscriptions/plans # Planes disponibles por módulo
GET /subscriptions/my # Suscripciones activas del tenant
POST /subscriptions/trial # Iniciar trial de un módulo
POST /subscriptions/checkout # Crear sesión de checkout ONVO Pay
POST /subscriptions/upgrade # Cambiar de plan
POST /subscriptions/cancel # Cancelar suscripción
GET /subscriptions/invoices # Historial de facturas
POST /subscriptions/apply-credit # Aplicar crédito de referidos
Billing webhooks
POST /webhooks/onvo/payment_success # Pago exitoso → activar suscripción
POST /webhooks/onvo/payment_failed # Pago fallido → notificar + grace period
POST /webhooks/onvo/subscription_end # Fin de período → renovar o cancelar
POST /webhooks/tilopay/payment_success # Backup payment processor
Partners
POST /partners/register # Registro de contador como partner
GET /partners/referrals # Referidos del partner
GET /partners/commissions # Comisiones generadas
POST /partners/generate-link # Generar link de referido único
Tenant
GET /tenant/profile # Datos del tenant
PUT /tenant/profile # Actualizar perfil
GET /tenant/usage # Uso por módulo (para metering)
GET /tenant/modules/status # Estado de cada módulo activo
3. Flujo de checkout — ONVO Pay
class CheckoutService:
async def crear_sesion_checkout(
self,
tenant_id: UUID,
plan_id: str,
modulo: str,
periodo: Literal['monthly', 'annual']
) -> CheckoutSession:
plan = await self.plans.get(plan_id, modulo)
precio = plan.precio_anual if periodo == 'annual' else plan.precio_mensual
# Crear intención de pago en ONVO Pay
session = await self.onvo.crear_sesion({
'amount': precio,
'currency': 'USD',
'description': f'MindStack {modulo} {plan.nombre} - {periodo}',
'metadata': {
'tenant_id': str(tenant_id),
'plan_id': plan_id,
'modulo': modulo,
'periodo': periodo
},
'success_url': f'https://mindstack.cheryx.tech/checkout/success?session_id={{CHECKOUT_SESSION_ID}}',
'cancel_url': f'https://mindstack.cheryx.tech/precios',
})
# Guardar sesión pendiente (idempotencia)
await self.db.execute(
"INSERT INTO ms_checkout_sessions VALUES (:id, :tenant_id, :plan_id, 'pending', NOW())",
{'id': session.id, 'tenant_id': tenant_id, 'plan_id': plan_id}
)
return session
4. Webhook handler — activación de suscripción
@router.post("/webhooks/onvo/payment_success")
async def onvo_payment_success(payload: ONVOWebhookPayload, db: AsyncSession):
# Verificar firma HMAC del webhook
if not verificar_firma_onvo(payload, request.headers.get('X-ONVO-Signature')):
raise HTTPException(403)
meta = payload.metadata
tenant_id = UUID(meta['tenant_id'])
modulo = meta['modulo']
plan_id = meta['plan_id']
periodo = meta['periodo']
# Activar o renovar suscripción
subs = await db.execute(
"SELECT id FROM ms_subscriptions WHERE tenant_id=:tid AND modulo=:mod",
{'tid': tenant_id, 'mod': modulo}
)
if subs.scalar():
# Renovación
await db.execute(
"UPDATE ms_subscriptions SET status='active', period_end=:end WHERE tenant_id=:tid AND modulo=:mod",
{'end': calcular_period_end(periodo), 'tid': tenant_id, 'mod': modulo}
)
else:
# Nueva suscripción
await db.execute(
"INSERT INTO ms_subscriptions (tenant_id, modulo, plan_id, status, period_start, period_end) "
"VALUES (:tid, :mod, :plan, 'active', NOW(), :end)",
{'tid': tenant_id, 'mod': modulo, 'plan': plan_id, 'end': calcular_period_end(periodo)}
)
# Notificar al módulo vía Redis
await redis.publish(f'ms:{modulo}:subscription_activated', {
'tenant_id': str(tenant_id),
'plan_id': plan_id,
'period_end': str(calcular_period_end(periodo))
})
# Registrar en audit log
await audit_log.registrar('ms_subscription_activated', tenant_id, {
'modulo': modulo, 'plan_id': plan_id, 'onvo_payment_id': payload.payment_id
})
# Email de bienvenida / renovación
await notification_service.enviar_bienvenida(tenant_id, modulo)
5. Expansion MRR — triggers automáticos
class ExpansionTriggerService:
"""Monitorea uso y dispara ofertas de cross-sell en el momento correcto."""
TRIGGERS = {
'stockmind_to_salesmind': {
'condicion': lambda uso: uso.get('oc_generadas_30d', 0) >= 50,
'mensaje': 'Ya sabes qué comprar. ¿Sabes qué más puedes vender?',
'delay_dias': 30, # No mostrar antes del día 30
},
'salesmind_to_bookmind': {
'condicion': lambda uso: uso.get('pipeline_valor_usd', 0) >= 50000,
'mensaje': 'Tu pipeline predice tu tesorería. ¿La estás viendo?',
'delay_dias': 30,
},
'bookmind_to_paymind': {
'condicion': lambda uso: uso.get('empleados_en_fe', 0) >= 15,
'mensaje': 'Con 15+ personas, la planilla manual ya no escala.',
'delay_dias': 30,
},
}
async def evaluar_triggers(self, tenant_id: UUID):
uso = await self.metering.get_uso_actual(tenant_id)
subs_activas = await self.subscriptions.get_activas(tenant_id)
for trigger_key, config in self.TRIGGERS.items():
modulo_origen, _, modulo_destino = trigger_key.split('_to_')
if modulo_origen not in subs_activas:
continue
if modulo_destino in subs_activas:
continue # Ya tiene el módulo
if not config['condicion'](uso):
continue
# Verificar delay mínimo
suscripcion = subs_activas[modulo_origen]
dias_activo = (datetime.now() - suscripcion.period_start).days
if dias_activo < config['delay_dias']:
continue
# Disparar oferta in-app (una sola vez)
await self.ofertas.crear_si_no_existe(tenant_id, modulo_destino, config['mensaje'])
6. Workers de background
| Worker | Función | Frecuencia |
|---|---|---|
billing_worker |
Procesar renovaciones próximas a vencer | Diario 6am |
expansion_worker |
Evaluar triggers de cross-sell por tenant | Diario |
commission_worker |
Calcular y acreditar comisiones de partners | Mensual |
churn_risk_worker |
Detectar tenants sin login >7 días | Diario |
email_drip_worker |
Ejecutar secuencias de onboarding por día | Cada hora |
usage_aggregate_worker |
Agregar métricas de uso por módulo | Cada 6 horas |
Ver también: Doc 08 (Modelo de Datos — tablas ms_) · Doc 09 (Motor Core — subscription engine) · Doc 10 (Seguridad — auth y PCI)*