Docs MindStack Suite
09
Motor Core — MindStack
Subscription engine, trial management, billing lifecycle y metering
Versión
v1.0
Fecha
Mayo 2026
Audiencia
Dev
Estado
DRAFT
MINDSTACK
El sistema operativo de tu PyME. Todo en uno. Sin complicaciones.
Portal — Cheryx Suite

[CONSEJO — Doc 09] Contrarian: El motor de suscripciones de MindStack es tan crítico como el motor de planilla de PayMind — ambos procesan dinero real de clientes reales. Un bug que cobra de más destruye la confianza. Un bug que no cobra deja revenue en la mesa. La diferencia es que los errores del motor de suscripciones se ven en el estado de cuenta del cliente en segundos, no en la quincena siguiente. First Principles: Una suscripción SaaS tiene exactamente 5 estados posibles: trial → active → past_due → cancelled → churned. El motor solo necesita manejar transiciones entre esos 5 estados de forma correcta y con audit log completo. Todo lo demás (proration, créditos, upgrades) son casos especiales que se construyen cuando hay demanda real.


1. Máquina de estados de suscripción

                    ┌─────────┐
          registro  │  TRIAL  │ ←─── trial_started
          ─────────►│         │
                    └────┬────┘
                         │ pago exitoso
                         ▼
                    ┌─────────┐
         renovación │ ACTIVE  │ ←─── payment_success
         ─────────► │         │
                    └────┬────┘
                    │    │    │
           fallo    │    │    │ cancelación
           pago     │    │    │ voluntaria
                    ▼    │    ▼
              ┌──────────┐  ┌───────────┐
              │ PAST_DUE │  │ CANCELLED │
              └────┬─────┘  └─────┬─────┘
                   │               │
         grace period              │ period_end
         (3 días)  │               │ pasa
                   │               ▼
                   │          ┌─────────┐
                   └─────────►│ CHURNED │
                              └─────────┘

2. Trial management

class TrialService:
    TRIAL_DIAS = 14
    GRACE_PERIOD_PAST_DUE_DIAS = 3

    async def iniciar_trial(
        self,
        tenant_id: UUID,
        modulo: str,
        plan_id: str
    ) -> Subscription:

        # Verificar que no tenga trial previo del mismo módulo
        existente = await self.db.fetchone(
            "SELECT id FROM ms_subscriptions WHERE tenant_id=:tid AND modulo=:mod",
            {'tid': tenant_id, 'mod': modulo}
        )
        if existente:
            raise ConflictError(f"Ya existe una suscripción para {modulo}")

        trial_end = datetime.now() + timedelta(days=self.TRIAL_DIAS)

        sub = await self.db.execute(
            """INSERT INTO ms_subscriptions
               (tenant_id, modulo, plan_id, estado, period_start, period_end, trial_end)
               VALUES (:tid, :mod, :plan, 'trial', NOW(), :end, :trial_end)
               RETURNING id""",
            {'tid': tenant_id, 'mod': modulo, 'plan': plan_id,
             'end': trial_end, 'trial_end': trial_end}
        )

        await self.billing_events.registrar(
            tenant_id=tenant_id,
            subscription_id=sub.id,
            tipo='trial_started',
            metadata={'modulo': modulo, 'plan_id': plan_id, 'trial_end': str(trial_end)}
        )

        # Activar acceso en el módulo inmediatamente
        await self.modulo_gateway.activar_acceso(tenant_id, modulo, plan_id, trial_end)

        # Iniciar secuencia de onboarding
        await self.onboarding.iniciar_secuencia(tenant_id, modulo)

        return sub

    async def verificar_trials_vencidos(self):
        """Cron: ejecutar diariamente. Cancela trials no convertidos."""
        vencidos = await self.db.fetch(
            """SELECT id, tenant_id, modulo FROM ms_subscriptions
               WHERE estado = 'trial'
               AND trial_end < NOW()"""
        )
        for sub in vencidos:
            await self._cancelar_trial(sub)
            await self.notification.enviar_trial_vencido(sub.tenant_id, sub.modulo)

3. Billing lifecycle — renovaciones

class BillingLifecycleService:

    async def procesar_renovaciones_proximas(self):
        """Cron: ejecutar diariamente a las 6am.
        Marca como past_due las suscripciones que vencen y ONVO no ha renovado."""

        proximas_vencer = await self.db.fetch(
            """SELECT s.*, p.precio_usd, p.periodo
               FROM ms_subscriptions s
               JOIN ms_planes p ON s.plan_id = p.id
               WHERE s.estado = 'active'
               AND s.period_end <= NOW() + INTERVAL '1 day'
               AND s.onvo_sub_id IS NULL"""  # Sin suscripción recurrente ONVO
        )

        for sub in proximas_vencer:
            # Verificar si ONVO tiene un pago pendiente o falló
            ultimo_pago = await self.billing_events.ultimo_pago(sub.id)
            if ultimo_pago and ultimo_pago.tipo == 'payment_failed':
                await self._pasar_a_past_due(sub)
            elif not ultimo_pago or (datetime.now() - ultimo_pago.created_at).days > 35:
                # Sin pago en >35 días → past_due
                await self._pasar_a_past_due(sub)

    async def _pasar_a_past_due(self, sub):
        await self.db.execute(
            "UPDATE ms_subscriptions SET estado='past_due' WHERE id=:id",
            {'id': sub.id}
        )
        await self.billing_events.registrar(
            tenant_id=sub.tenant_id,
            subscription_id=sub.id,
            tipo='payment_failed',
            metadata={'reason': 'no_payment_received'}
        )
        # Grace period: 3 días antes de suspender acceso
        await self.notification.enviar_pago_fallido(sub.tenant_id, sub.modulo)

    async def manejar_grace_period_vencido(self):
        """Suspende acceso después del grace period de 3 días."""
        past_due_vencidos = await self.db.fetch(
            """SELECT id, tenant_id, modulo FROM ms_subscriptions
               WHERE estado = 'past_due'
               AND period_end < NOW() - INTERVAL '3 days'"""
        )
        for sub in past_due_vencidos:
            await self.db.execute(
                "UPDATE ms_subscriptions SET estado='cancelled', cancelled_at=NOW() WHERE id=:id",
                {'id': sub.id}
            )
            await self.modulo_gateway.suspender_acceso(sub.tenant_id, sub.modulo)
            await self.notification.enviar_acceso_suspendido(sub.tenant_id, sub.modulo)

4. Upgrade / downgrade de plan

async def cambiar_plan(
    self,
    tenant_id: UUID,
    modulo: str,
    nuevo_plan_id: str
) -> Subscription:

    sub_actual = await self.get_activa(tenant_id, modulo)
    plan_nuevo = await self.planes.get(nuevo_plan_id)
    plan_actual = await self.planes.get(sub_actual.plan_id)

    es_upgrade = plan_nuevo.precio_usd > plan_actual.precio_usd

    if es_upgrade:
        # Upgrade: efectivo inmediatamente, próximo cobro al nuevo precio
        # No se hace proration en el MVP — simplificar el soporte
        await self.db.execute(
            "UPDATE ms_subscriptions SET plan_id=:nuevo WHERE id=:id",
            {'nuevo': nuevo_plan_id, 'id': sub_actual.id}
        )
        await self.modulo_gateway.actualizar_limites(tenant_id, modulo, nuevo_plan_id)
    else:
        # Downgrade: efectivo al final del período actual
        await self.db.execute(
            "UPDATE ms_subscriptions SET pending_plan_id=:nuevo WHERE id=:id",
            {'nuevo': nuevo_plan_id, 'id': sub_actual.id}
        )

    await self.billing_events.registrar(
        tenant_id=tenant_id,
        subscription_id=sub_actual.id,
        tipo='plan_changed',
        metadata={'de': sub_actual.plan_id, 'a': nuevo_plan_id, 'upgrade': es_upgrade}
    )

5. Guardrails del motor

# Guardrail Descripción
G1 Un solo trial por módulo por tenant Si el mismo email intenta trial de un módulo que ya probó → bloquear con mensaje claro
G2 No activar acceso sin billing_event registrado Toda activación debe tener su billing_event correspondiente
G3 Grace period máximo 3 días Después de 3 días past_due, suspender acceso sin excepción
G4 Webhook de ONVO debe verificar firma HMAC Rechazar cualquier webhook sin firma válida
G5 Créditos no retroactivos Los créditos de referidos solo aplican a facturas futuras, nunca a pasadas
G6 Audit log inmutable ms_billing_events no admite UPDATE ni DELETE desde app_role

6. Tests obligatorios

def test_trial_no_duplicado():
    """Un tenant no puede tener dos trials del mismo módulo."""
    tenant = crear_tenant_test()
    trial_service.iniciar_trial(tenant.id, 'stockmind', 'stockmind_growth_monthly')
    with pytest.raises(ConflictError):
        trial_service.iniciar_trial(tenant.id, 'stockmind', 'stockmind_starter_monthly')

def test_past_due_suspende_acceso_en_3_dias():
    """Después del grace period, el acceso debe estar suspendido."""
    sub = crear_suscripcion_past_due(hace_dias=4)
    billing.manejar_grace_period_vencido()
    assert get_acceso(sub.tenant_id, sub.modulo) == 'suspended'

def test_billing_events_son_inmutables():
    """No se puede modificar un billing_event."""
    event = crear_billing_event_test()
    with pytest.raises(Exception):
        db.execute("UPDATE ms_billing_events SET monto_usd=0 WHERE id=:id", {'id': event.id})

def test_webhook_firma_invalida_es_rechazado():
    """Webhooks sin firma HMAC válida deben retornar 403."""
    response = client.post("/webhooks/onvo/payment_success",
                           json={}, headers={"X-ONVO-Signature": "invalida"})
    assert response.status_code == 403

Ver también: Doc 07 (SRD Backend — endpoints de suscripción) · Doc 08 (Modelo de Datos — ms_subscriptions, ms_billing_events) · Doc 10 (Seguridad — protección de webhooks)