Docs MindStack Suite
08
Modelo de Datos — MindStack
Esquema de base de datos del portal: tenants, subscriptions, plans, billing y partners
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 08] Contrarian: La tabla más crítica del schema de MindStack no es ms_tenants ni ms_subscriptions — es ms_billing_events. Todo lo que afecte dinero debe quedar en el audit log de billing con timestamp, monto, y el estado antes y después del cambio. Si hay una discrepancia entre lo que ONVO dice que cobró y lo que MindStack dice que cobró, el audit log es el árbitro. Sin él, el soporte es un caos. Executor: El schema de MindStack debe ser lo más simple posible en el MVP. No construir tablas de metering sofisticadas hasta tener 50+ clientes que realmente necesiten facturación por uso. Empezar con planes flat-rate — es más fácil de soportar y suficiente para validar el modelo.


1. Prefijo de tablas: ms_*

Todas las tablas del portal MindStack usan el prefijo ms_ para convivir en la misma base de datos PostgreSQL de Cheryx sin colisiones.


2. Schema completo

-- ─── TENANTS ─────────────────────────────────────────────────────────────────
CREATE TABLE ms_tenants (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    nombre_empresa  TEXT NOT NULL,
    pais            CHAR(2) NOT NULL DEFAULT 'CR',  -- ISO 3166-1
    email_admin     TEXT NOT NULL UNIQUE,
    telefono        TEXT,
    plan_tier       TEXT NOT NULL DEFAULT 'trial',   -- trial | starter | growth | pro | enterprise
    estado          TEXT NOT NULL DEFAULT 'active',  -- active | suspended | churned
    partner_id      UUID REFERENCES ms_partners(id), -- contador que los refirió
    onboarding_step INTEGER NOT NULL DEFAULT 0,      -- 0-5, para personalizar el wizard
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    activated_at    TIMESTAMPTZ,                     -- cuando completaron onboarding D7
    churned_at      TIMESTAMPTZ
);

-- ─── USUARIOS ────────────────────────────────────────────────────────────────
CREATE TABLE ms_usuarios (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES ms_tenants(id) ON DELETE CASCADE,
    email           TEXT NOT NULL UNIQUE,
    nombre          TEXT NOT NULL,
    rol             TEXT NOT NULL DEFAULT 'owner',  -- owner | admin | viewer
    mfa_secret      BYTEA,                           -- TOTP secret cifrado
    ultimo_login    TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_ms_usuarios_tenant ON ms_usuarios(tenant_id);

-- ─── PLANES ──────────────────────────────────────────────────────────────────
CREATE TABLE ms_planes (
    id              TEXT PRIMARY KEY,  -- 'stockmind_growth_monthly'
    modulo          TEXT NOT NULL,     -- stockmind | salesmind | bookmind | paymind
    nombre          TEXT NOT NULL,
    tier            TEXT NOT NULL,     -- starter | growth | pro | enterprise
    periodo         TEXT NOT NULL,     -- monthly | annual
    precio_usd      NUMERIC(10,2) NOT NULL,
    precio_anual_usd NUMERIC(10,2),   -- precio total si paga anual
    features        JSONB NOT NULL DEFAULT '{}',
    activo          BOOLEAN NOT NULL DEFAULT true,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ─── SUSCRIPCIONES ───────────────────────────────────────────────────────────
CREATE TABLE ms_subscriptions (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES ms_tenants(id),
    modulo          TEXT NOT NULL,
    plan_id         TEXT NOT NULL REFERENCES ms_planes(id),
    estado          TEXT NOT NULL DEFAULT 'trial',
    -- trial | active | past_due | cancelled | paused
    period_start    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    period_end      TIMESTAMPTZ NOT NULL,
    trial_end       TIMESTAMPTZ,
    cancelled_at    TIMESTAMPTZ,
    cancel_reason   TEXT,
    onvo_sub_id     TEXT,             -- ID de suscripción en ONVO Pay
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE(tenant_id, modulo)         -- un solo registro activo por módulo por tenant
);

CREATE INDEX idx_ms_subs_tenant ON ms_subscriptions(tenant_id);
CREATE INDEX idx_ms_subs_period_end ON ms_subscriptions(period_end)
    WHERE estado IN ('active', 'trial');

-- ─── BILLING EVENTS (inmutable) ───────────────────────────────────────────────
CREATE TABLE ms_billing_events (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES ms_tenants(id),
    subscription_id UUID REFERENCES ms_subscriptions(id),
    tipo            TEXT NOT NULL,
    -- payment_success | payment_failed | trial_started | trial_ended |
    -- subscription_activated | subscription_cancelled | refund | credit_applied
    monto_usd       NUMERIC(10,2),
    moneda          CHAR(3) DEFAULT 'USD',
    onvo_payment_id TEXT,
    metadata        JSONB NOT NULL DEFAULT '{}',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Inmutable: nadie puede modificar ni borrar eventos de billing
REVOKE UPDATE ON ms_billing_events FROM app_role;
REVOKE DELETE ON ms_billing_events FROM app_role;

CREATE INDEX idx_ms_billing_tenant ON ms_billing_events(tenant_id);
CREATE INDEX idx_ms_billing_created ON ms_billing_events(created_at DESC);

-- ─── CRÉDITOS DE REFERIDOS ────────────────────────────────────────────────────
CREATE TABLE ms_creditos (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES ms_tenants(id),
    monto_usd       NUMERIC(10,2) NOT NULL,
    origen          TEXT NOT NULL,   -- referral | promo | goodwill
    referido_id     UUID REFERENCES ms_tenants(id),
    estado          TEXT NOT NULL DEFAULT 'available',  -- available | applied | expired
    vence_at        TIMESTAMPTZ NOT NULL,  -- 12 meses desde generación
    aplicado_at     TIMESTAMPTZ,
    aplicado_en     UUID REFERENCES ms_billing_events(id),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ─── PARTNERS (contadores CPA) ────────────────────────────────────────────────
CREATE TABLE ms_partners (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    nombre          TEXT NOT NULL,
    email           TEXT NOT NULL UNIQUE,
    tipo            TEXT NOT NULL DEFAULT 'contador',  -- contador | agencia | reseller
    pais            CHAR(2) NOT NULL DEFAULT 'CR',
    referral_code   TEXT NOT NULL UNIQUE,
    tasa_comision   NUMERIC(5,4) NOT NULL DEFAULT 0.20,  -- 20% default
    estado          TEXT NOT NULL DEFAULT 'active',
    comisiones_acumuladas_usd NUMERIC(12,2) NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ─── COMISIONES DE PARTNERS ───────────────────────────────────────────────────
CREATE TABLE ms_comisiones (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    partner_id      UUID NOT NULL REFERENCES ms_partners(id),
    tenant_id       UUID NOT NULL REFERENCES ms_tenants(id),
    billing_event_id UUID NOT NULL REFERENCES ms_billing_events(id),
    monto_usd       NUMERIC(10,2) NOT NULL,
    tasa_aplicada   NUMERIC(5,4) NOT NULL,
    estado          TEXT NOT NULL DEFAULT 'pending',  -- pending | pagada
    pagada_at       TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- ─── USO POR MÓDULO (para triggers de expansion) ─────────────────────────────
CREATE TABLE ms_uso_modulo (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES ms_tenants(id),
    modulo          TEXT NOT NULL,
    metrica         TEXT NOT NULL,
    -- stockmind: 'oc_generadas', 'skus_activos'
    -- salesmind: 'pipeline_valor_usd', 'contactos'
    -- bookmind: 'fe_emitidas', 'empleados_en_fe'
    -- paymind: 'empleados_activos'
    valor           NUMERIC(15,2) NOT NULL,
    periodo_inicio  DATE NOT NULL,
    periodo_fin     DATE NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE(tenant_id, modulo, metrica, periodo_inicio)
);

-- ─── OFERTAS DE EXPANSION (cross-sell in-app) ─────────────────────────────────
CREATE TABLE ms_expansion_offers (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES ms_tenants(id),
    modulo_destino  TEXT NOT NULL,
    mensaje         TEXT NOT NULL,
    vista_at        TIMESTAMPTZ,
    accion          TEXT,  -- 'trial_started' | 'dismissed' | null
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE(tenant_id, modulo_destino)  -- una sola oferta activa por módulo por tenant
);

3. Queries operacionales clave

-- MRR actual del portal
SELECT
    SUM(p.precio_usd) AS mrr_usd,
    COUNT(*) AS suscripciones_activas
FROM ms_subscriptions s
JOIN ms_planes p ON s.plan_id = p.id
WHERE s.estado = 'active'
AND p.periodo = 'monthly';

-- Tenants en riesgo de churn (sin login en 7+ días)
SELECT t.id, t.email_admin, t.nombre_empresa,
       MAX(u.ultimo_login) AS ultimo_login
FROM ms_tenants t
JOIN ms_usuarios u ON u.tenant_id = t.id
JOIN ms_subscriptions s ON s.tenant_id = t.id AND s.estado = 'active'
WHERE MAX(u.ultimo_login) < NOW() - INTERVAL '7 days'
GROUP BY t.id
ORDER BY ultimo_login ASC;

-- Conversión de trial a pago por cohorte mensual
SELECT
    DATE_TRUNC('month', s.created_at) AS cohorte,
    COUNT(*) FILTER (WHERE s.estado != 'trial') AS convertidos,
    COUNT(*) AS total_trials,
    ROUND(100.0 * COUNT(*) FILTER (WHERE s.estado != 'trial') / COUNT(*), 1) AS pct_conversion
FROM ms_subscriptions s
WHERE s.trial_end IS NOT NULL
GROUP BY 1
ORDER BY 1 DESC;

Ver también: Doc 07 (SRD Backend — servicios que usan estas tablas) · Doc 09 (Motor Core — lógica de suscripciones) · Doc 10 (Seguridad — roles y acceso a ms_billing_events)