Docs BookMind
08
Modelo de Datos — BookMind
Esquema PostgreSQL bk_*, tablas FE, GL, AR/AP, bancos y tesorería
Versión
v1.0
Fecha
Mayo 2026
Audiencia
Founders · Dev
Estado
DRAFT
BOOKMIND
El cerebro y las manos de tu finanza.
Módulo #3 — Cheryx Suite

[CONSEJO — Doc 08] Contrarian: En un sistema contable, la inmutabilidad no es solo una buena práctica — es un requerimiento legal. Un asiento contable ya registrado no puede modificarse: se corrige con contra-asiento. Si el schema permite UPDATE sobre bk_asientos, el sistema no es auditablemente correcto. REVOKE UPDATE ON bk_asientos FROM app_role es obligatorio, no opcional. First Principles: El plan de cuentas es la columna vertebral del GL. Todo lo demás (FE, AR, AP, bancos) son capas que alimentan el plan de cuentas. Diseñar el plan de cuentas con suficiente flexibilidad para adaptarse a PCGA CR, NIF MX y NIIF CO (que tienen diferencias en la numeración de cuentas), manteniendo un mapeo estable interno que no cambie aunque el cliente renumere sus cuentas. Auditor Seg.: Los datos financieros del cliente son los más sensibles. El modelo de datos debe garantizar que ningún tenant pueda ver datos de otro. RLS en cada tabla bk_*, verificado con test de SQL que intente un cross-tenant query — debe retornar cero filas, no un error.


1. Tablas principales

1.1 bk_empresas — configuración fiscal por tenant

CREATE TABLE bk_empresas (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL REFERENCES sm_tenants(id) ON DELETE CASCADE,
    razon_social    VARCHAR(255) NOT NULL,
    paises          JSONB NOT NULL DEFAULT '[]',
    -- ej: [{"pais": "CR", "cedula_juridica": "3-101-XXXXXX",
    --        "actividad_economica": "741001", "regimen": "tradicional"},
    --       {"pais": "MX", "rfc": "DXY9012345ABC", "uso_cfdi": "G03"}]
    moneda_funcional CHAR(3) NOT NULL DEFAULT 'USD',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_bk_empresas_tenant ON bk_empresas(tenant_id);
ALTER TABLE bk_empresas ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bk_empresas
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

1.2 bk_plan_cuentas — plan de cuentas configurable

CREATE TABLE bk_plan_cuentas (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    empresa_id      UUID NOT NULL REFERENCES bk_empresas(id),
    codigo          VARCHAR(20) NOT NULL,     -- ej: "1101", "4101.01"
    nombre          VARCHAR(255) NOT NULL,
    tipo            VARCHAR(20) NOT NULL,     -- 'activo','pasivo','patrimonio','ingreso','gasto'
    padre_id        UUID REFERENCES bk_plan_cuentas(id),  -- árbol jerárquico
    permite_asientos BOOLEAN NOT NULL DEFAULT true,
    pais            CHAR(2),                 -- NULL = aplica a todos los países
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (tenant_id, empresa_id, codigo)
);
ALTER TABLE bk_plan_cuentas ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bk_plan_cuentas
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

1.3 bk_fe_documents — facturas electrónicas emitidas y recibidas

CREATE TABLE bk_fe_documents (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id           UUID NOT NULL,
    empresa_id          UUID NOT NULL REFERENCES bk_empresas(id),
    tipo                VARCHAR(10) NOT NULL,  -- 'emitida', 'recibida'
    pais                CHAR(2) NOT NULL,      -- 'CR', 'MX', 'CO'
    -- CR: clave numérica 50 dígitos
    clave_hacienda      VARCHAR(100),
    numero_consecutivo  VARCHAR(30),
    -- MX: UUID del CFDI
    uuid_cfdi           UUID,
    -- CO: CUFE
    cufe                VARCHAR(200),
    fecha_emision       DATE NOT NULL,
    receptor_cedula     VARCHAR(30),
    receptor_nombre     VARCHAR(255),
    receptor_email      VARCHAR(255),
    subtotal            NUMERIC(15,2) NOT NULL,
    impuesto_total      NUMERIC(15,2) NOT NULL DEFAULT 0,
    total               NUMERIC(15,2) NOT NULL,
    moneda              CHAR(3) NOT NULL,
    tipo_cambio_usd     NUMERIC(10,4),  -- tipo de cambio al momento de emisión
    estado              VARCHAR(20) NOT NULL DEFAULT 'en_cola',
    -- estados: en_cola, emitida, rechazada, contingencia, anulada
    motivo_rechazo      TEXT,
    xml_enviado         TEXT,           -- XML completo enviado a la autoridad
    xml_respuesta       TEXT,           -- XML de respuesta de la autoridad
    idempotency_key     VARCHAR(64) UNIQUE NOT NULL,
    contabilizado       BOOLEAN NOT NULL DEFAULT false,
    asiento_id          UUID,           -- FK a bk_asientos cuando se contabiliza
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    -- INMUTABLE: no updated_at. Los cambios de estado son en columnas específicas.
    estado_changed_at   TIMESTAMPTZ
);
CREATE INDEX idx_bk_fe_tenant_estado ON bk_fe_documents(tenant_id, estado);
CREATE INDEX idx_bk_fe_tenant_fecha ON bk_fe_documents(tenant_id, fecha_emision DESC);
CREATE INDEX idx_bk_fe_contingencia ON bk_fe_documents(estado) WHERE estado = 'contingencia';
ALTER TABLE bk_fe_documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bk_fe_documents
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

1.4 bk_asientos — asientos contables (INMUTABLES)

CREATE TABLE bk_asientos (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    empresa_id      UUID NOT NULL REFERENCES bk_empresas(id),
    fecha           DATE NOT NULL,
    descripcion     TEXT NOT NULL,
    tipo_origen     VARCHAR(30) NOT NULL,
    -- tipos: 'auto_fe_emitida', 'auto_fe_recibida', 'auto_conciliacion',
    --         'manual', 'ajuste', 'apertura', 'cierre'
    referencia_id   UUID,           -- ID del documento origen (bk_fe_documents, etc.)
    referencia_tipo VARCHAR(30),    -- 'fe', 'banco', 'manual'
    total_debito    NUMERIC(15,2) NOT NULL,
    total_credito   NUMERIC(15,2) NOT NULL,
    -- INVARIANTE: total_debito == total_credito (verificado por constraint)
    CONSTRAINT balance_asiento CHECK (total_debito = total_credito),
    es_ajuste       BOOLEAN NOT NULL DEFAULT false,
    periodo_cerrado BOOLEAN NOT NULL DEFAULT false,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
    -- NO updated_at: los asientos son INMUTABLES
    -- Las correcciones se hacen con contra-asientos (nuevo asiento con valores inversos)
);

-- Partición por año para performance (asientos crecen sin límite)
-- En V1.0 sin partición, agregar en V1.1 cuando supere 1M filas

CREATE INDEX idx_bk_asientos_tenant_fecha ON bk_asientos(tenant_id, fecha DESC);
ALTER TABLE bk_asientos ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bk_asientos
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

-- Inmutabilidad enforced a nivel DB
REVOKE UPDATE ON bk_asientos FROM app_role;
REVOKE DELETE ON bk_asientos FROM app_role;

1.5 bk_asiento_lineas — líneas de cada asiento

CREATE TABLE bk_asiento_lineas (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    asiento_id      UUID NOT NULL REFERENCES bk_asientos(id),
    tenant_id       UUID NOT NULL,
    cuenta_id       UUID NOT NULL REFERENCES bk_plan_cuentas(id),
    tipo            CHAR(1) NOT NULL CHECK (tipo IN ('D', 'C')),  -- Débito / Crédito
    monto           NUMERIC(15,2) NOT NULL CHECK (monto > 0),
    descripcion     TEXT,
    tercero_nombre  VARCHAR(255),  -- nombre de cliente/proveedor para trazabilidad
    tercero_cedula  VARCHAR(30)
    -- NO updated_at: inmutable junto con su asiento padre
);
CREATE INDEX idx_bk_lineas_asiento ON bk_asiento_lineas(asiento_id);
CREATE INDEX idx_bk_lineas_cuenta ON bk_asiento_lineas(tenant_id, cuenta_id, tipo);
ALTER TABLE bk_asiento_lineas ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bk_asiento_lineas
    USING (tenant_id = current_setting('app.current_tenant')::UUID);
REVOKE UPDATE ON bk_asiento_lineas FROM app_role;
REVOKE DELETE ON bk_asiento_lineas FROM app_role;

1.6 bk_cuentas_bancarias — cuentas bancarias conectadas

CREATE TABLE bk_cuentas_bancarias (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    empresa_id      UUID NOT NULL REFERENCES bk_empresas(id),
    banco_nombre    VARCHAR(100) NOT NULL,
    numero_cuenta   VARCHAR(50),       -- últimos 4 dígitos (no almacenar completo)
    pais            CHAR(2) NOT NULL,
    moneda          CHAR(3) NOT NULL,
    proveedor       VARCHAR(20) NOT NULL,  -- 'belvo' | 'prometeo'
    proveedor_account_id VARCHAR(100),    -- ID interno del proveedor
    ultima_sync     TIMESTAMPTZ,
    cuenta_gl_id    UUID REFERENCES bk_plan_cuentas(id),  -- cuenta 1101 asociada
    activa          BOOLEAN NOT NULL DEFAULT true,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
ALTER TABLE bk_cuentas_bancarias ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bk_cuentas_bancarias
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

1.7 bk_movimientos_bancarios — movimientos importados de bancos

CREATE TABLE bk_movimientos_bancarios (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id           UUID NOT NULL,
    cuenta_bancaria_id  UUID NOT NULL REFERENCES bk_cuentas_bancarias(id),
    fecha               DATE NOT NULL,
    descripcion         TEXT,
    monto               NUMERIC(15,2) NOT NULL,  -- positivo = ingreso, negativo = egreso
    tipo                VARCHAR(10) NOT NULL,     -- 'credito' | 'debito'
    referencia_banco    VARCHAR(100),             -- número de referencia del banco
    estado_conciliacion VARCHAR(20) NOT NULL DEFAULT 'pendiente',
    -- estados: pendiente, emparejado, ignorado
    asiento_linea_id    UUID REFERENCES bk_asiento_lineas(id),  -- si está emparejado
    match_score         SMALLINT,               -- 0-100, confianza del auto-match
    proveedor_tx_id     VARCHAR(100) UNIQUE,    -- ID de Belvo/Prometeo (idempotencia)
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
    -- INMUTABLE: los movimientos bancarios no se editan, solo se concilian
);
CREATE INDEX idx_bk_movimientos_cuenta_fecha ON bk_movimientos_bancarios(cuenta_bancaria_id, fecha DESC);
CREATE INDEX idx_bk_movimientos_pendientes ON bk_movimientos_bancarios(tenant_id, estado_conciliacion) WHERE estado_conciliacion = 'pendiente';
ALTER TABLE bk_movimientos_bancarios ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bk_movimientos_bancarios
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

1.8 bk_forecast_results — resultados del motor de tesorería

CREATE TABLE bk_forecast_results (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    empresa_id      UUID NOT NULL REFERENCES bk_empresas(id),
    generado_en     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    horizonte_dias  SMALLINT NOT NULL DEFAULT 90,
    saldo_inicial   NUMERIC(15,2) NOT NULL,
    moneda_funcional CHAR(3) NOT NULL,
    series_json     JSONB NOT NULL,
    -- [{fecha, p10, p50, p90, confianza}, ...]
    alertas_json    JSONB NOT NULL DEFAULT '[]',
    -- [{tipo, dia_estimado, severidad, saldo_p50, descripcion, accion_sugerida}]
    dias_historial  INTEGER NOT NULL,
    confianza_modelo VARCHAR(10) NOT NULL  -- 'alta', 'media', 'baja'
);
CREATE INDEX idx_bk_forecast_tenant_recent ON bk_forecast_results(tenant_id, generado_en DESC);
ALTER TABLE bk_forecast_results ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bk_forecast_results
    USING (tenant_id = current_setting('app.current_tenant')::UUID);

1.9 bk_integraciones — credenciales FE encriptadas

CREATE TABLE bk_integraciones (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id       UUID NOT NULL,
    empresa_id      UUID NOT NULL REFERENCES bk_empresas(id),
    pais            CHAR(2) NOT NULL,      -- 'CR', 'MX', 'CO'
    tipo            VARCHAR(30) NOT NULL,   -- 'hacienda_cr', 'sat_mx', 'dian_co', 'belvo', 'prometeo'
    estado          VARCHAR(20) NOT NULL DEFAULT 'activa',
    credenciales_enc BYTEA NOT NULL,       -- pgcrypto: pgp_sym_encrypt(creds_json, SECRET)
    ultima_verificacion TIMESTAMPTZ,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (tenant_id, empresa_id, pais, tipo)
);
ALTER TABLE bk_integraciones ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON bk_integraciones
    USING (tenant_id = current_setting('app.current_tenant')::UUID);
REVOKE SELECT ON bk_integraciones FROM app_role;  -- solo FEService interno puede leer
GRANT SELECT ON bk_integraciones TO fe_service_role;

2. Queries críticas

2.1 Balance de comprobación a una fecha

SELECT
    pc.codigo,
    pc.nombre,
    pc.tipo,
    COALESCE(SUM(CASE WHEN al.tipo = 'D' THEN al.monto ELSE 0 END), 0) AS total_debito,
    COALESCE(SUM(CASE WHEN al.tipo = 'C' THEN al.monto ELSE 0 END), 0) AS total_credito,
    COALESCE(SUM(CASE WHEN al.tipo = 'D' THEN al.monto ELSE -al.monto END), 0) AS saldo
FROM bk_plan_cuentas pc
LEFT JOIN bk_asiento_lineas al ON al.cuenta_id = pc.id
LEFT JOIN bk_asientos a ON a.id = al.asiento_id AND a.fecha <= :fecha_corte
WHERE pc.tenant_id = :tenant_id AND pc.empresa_id = :empresa_id
    AND pc.permite_asientos = true
GROUP BY pc.id, pc.codigo, pc.nombre, pc.tipo
ORDER BY pc.codigo;

2.2 Aging AR (cuentas por cobrar vencidas)

SELECT
    fe.receptor_nombre,
    fe.receptor_cedula,
    fe.clave_hacienda,
    fe.fecha_emision,
    fe.total - COALESCE(pagos.total_pagado, 0) AS saldo_pendiente,
    CURRENT_DATE - fe.fecha_emision AS dias_vencido,
    CASE
        WHEN CURRENT_DATE - fe.fecha_emision <= 30 THEN '0-30'
        WHEN CURRENT_DATE - fe.fecha_emision <= 60 THEN '31-60'
        WHEN CURRENT_DATE - fe.fecha_emision <= 90 THEN '61-90'
        ELSE '>90'
    END AS bucket_aging
FROM bk_fe_documents fe
LEFT JOIN (
    SELECT referencia_id, SUM(monto) AS total_pagado
    FROM bk_asiento_lineas al
    JOIN bk_asientos a ON a.id = al.asiento_id
    WHERE al.tipo = 'C' AND a.referencia_tipo = 'pago_ar'
    GROUP BY referencia_id
) pagos ON pagos.referencia_id = fe.id
WHERE fe.tenant_id = :tenant_id
    AND fe.tipo = 'emitida'
    AND fe.estado = 'emitida'
    AND fe.total > COALESCE(pagos.total_pagado, 0)
ORDER BY dias_vencido DESC;

2.3 Ingresos del mes (para estado de resultados)

SELECT
    pc.codigo,
    pc.nombre,
    SUM(CASE WHEN al.tipo = 'C' THEN al.monto ELSE -al.monto END) AS monto
FROM bk_asiento_lineas al
JOIN bk_asientos a ON a.id = al.asiento_id
JOIN bk_plan_cuentas pc ON pc.id = al.cuenta_id
WHERE al.tenant_id = :tenant_id
    AND a.fecha BETWEEN :fecha_inicio AND :fecha_fin
    AND pc.tipo = 'ingreso'
GROUP BY pc.codigo, pc.nombre
ORDER BY pc.codigo;

3. Convenciones del esquema

Convención Regla
Prefijo Todas las tablas usan prefijo bk_
UUID gen_random_uuid() como PK por defecto
Inmutabilidad bk_asientos y bk_asiento_lineas: sin updated_at, REVOKE UPDATE/DELETE
RLS Habilitado en todas las tablas con política tenant_id
Montos NUMERIC(15,2) — nunca FLOAT para valores monetarios
Auditoría Toda operación de FEService registrada en sm_audit_log (tabla compartida)
Migraciones Alembic — naming: YYYYMMDD_HHMM_bk_descripcion_breve.py
JSON Usar JSONB (con índice GIN cuando se busca) — nunca TEXT para JSON

4. Diagrama de relaciones simplificado

sm_tenants (StockMind)
    └── bk_empresas (1 tenant : N empresas — para holding multi-empresa)
            ├── bk_plan_cuentas (árbol jerárquico de cuentas)
            │       └── bk_asiento_lineas → bk_asientos
            ├── bk_fe_documents → bk_asientos (contabilización automática)
            ├── bk_cuentas_bancarias → bk_movimientos_bancarios
            │       └── (conciliación) → bk_asiento_lineas
            ├── bk_forecast_results
            └── bk_integraciones (credenciales FE encriptadas)

Ver también: Doc 07 (SRD Backend — API y workers que usan estas tablas) · Doc 09 (Motor Core — cómo lee bk_asientos para el forecast) · Doc 10 (Seguridad — RLS y pgcrypto en bk_integraciones)