[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
UPDATEsobrebk_asientos, el sistema no es auditablemente correcto.REVOKE UPDATE ON bk_asientos FROM app_rolees 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 tablabk_*, 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)