Sistema de gestión de cartera
SaaS multiempresa
REST API
IA predictiva
v1.0
Plataforma web para administración de cuentas por cobrar de múltiples empresas. Gestiona clientes, facturas, pagos, cartera vencida y análisis de riesgo mediante inteligencia artificial.
Base de datos
PostgreSQL 15
Arquitectura general
El frontend en Vue.js 3 consume una API REST desarrollada en Flask. El backend centraliza toda la lógica de negocio, autenticación, autorización y ejecución de modelos de IA. Todos los datos se almacenan en PostgreSQL. La infraestructura corre sobre Docker con Nginx como reverse proxy y Gunicorn como servidor WSGI.
El módulo de inteligencia artificial está disponible únicamente para empresas con plan Enterprise. El control se aplica en el backend con @requiere_plan y en el frontend con el composable usePlan().
Modelo multiempresa
Cada registro del sistema lleva un campo empresa_id que lo aísla completamente. Ninguna empresa puede ver ni modificar datos de otra. El empresa_id se extrae siempre del token JWT, nunca de parámetros del cliente.
Planes comerciales
Básico
Clientes · Facturas · Pagos · Reportes básicos
Profesional
Dashboard avanzado · Reportes financieros · Exportación · KPIs
Enterprise IA
Predicción mora · Segmentación · Reportes inteligentes · Recomendaciones
Frontend — stack y estructura
Vue.js 3 · Composition API
Frontend
Stack tecnológico
Estructura de carpetas
frontend/
├── src/
│ ├── assets/
│ ├── components/
│ │ ├── common/
│ │ ├── charts/
│ │ └── layout/
│ ├── views/
│ │ ├── auth/
│ │ ├── dashboard/
│ │ ├── clientes/
│ │ ├── facturas/
│ │ ├── pagos/
│ │ ├── cartera/
│ │ ├── reportes/
│ │ └── configuracion/
│ ├── router/
│ ├── stores/
│ ├── services/
│ ├── utils/
│ ├── middleware/
│ ├── App.vue
│ └── main.js
├── .env
├── vite.config.js
└── package.json
Todas las vistas deben cargarse con () => import(...) (lazy loading). Nunca importar vistas estáticamente en el router.
Variables de entorno
VITE_API_URL=https://api.tudominio.com/api/v1
VITE_APP_NAME=GesCartera
Convenciones
- Composition API con
<script setup> en todos los componentes
- PascalCase para vistas y componentes · camelCase para stores y services
- Todo error de API manejado con
try/catch y toast de notificación
- No lógica de negocio en el frontend — saldos, estados y clasificaciones los retorna la API
Frontend — módulos y vistas
Vistas, formularios y comportamiento de UI
Frontend
Autenticación
Vistas: LoginView · RecuperarPasswordView · ResetPasswordView
Al iniciar sesión, almacenar access_token y refresh_token en el store de Pinia. Implementar interceptor de Axios para adjuntar el token y refrescarlo automáticamente ante error 401.
Dashboard
Vista única que carga todos los KPIs con una sola llamada a GET /api/v1/dashboard/resumen. Usar skeleton loaders mientras cargan los datos.
| Gráfica | Tipo | Datos |
| Evolución de cartera | Line chart | Últimos 6–12 meses |
| Evolución de recaudo | Bar chart | Por mes |
| Cartera por edades | Doughnut | 0–30, 31–60, 61–90, +90 días |
| Clientes por riesgo | Pie chart | Bajo, medio, alto |
| Comparativo mensual | Grouped bar | Mes actual vs anterior |
Facturas — estados
Pendiente
Pagada
Vencida
En cobro
Castigada
Deshabilitar edición para facturas en estado Pagada y Castigada. Al registrar un pago, actualizar el saldo reactivamente sin recargar la página.
Pagos — lógica de UI
- Mostrar saldo pendiente de la factura seleccionada en tiempo real
- Validar que el valor pagado no exceda el saldo pendiente
- Emitir alerta si el pago cubre la totalidad de la factura (cambio a Pagada)
Reportes — planes
| Reporte | Plan mínimo |
| Cartera general, vencida, recaudo, morosos | Básico |
| Desempeño financiero · Exportación PDF/Excel | Profesional |
| Narrativas IA · Recomendaciones automáticas | Enterprise |
Frontend — estado y routing
Pinia · Vue Router · Axios interceptors
Frontend
Store Pinia — estructura estándar
export const useClientesStore = defineStore('clientes', {
state: () => ({
clientes: [],
clienteActual: null,
loading: false,
error: null,
pagination: { page: 1, per_page: 20, total: 0 },
}),
actions: {
async fetchClientes(params = {}) {
this.loading = true
try {
const res = await ClientesService.getAll(params)
this.clientes = res.data.items
this.pagination = res.data.pagination
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
}
}
})
Guards de navegación
router.beforeEach((to, from, next) => {
const auth = useAuthStore()
if (!to.meta.public && !auth.isAuthenticated) return next('/login')
if (to.meta.roles && !to.meta.roles.includes(auth.userRole))
return next('/dashboard')
next()
})
Interceptor Axios — refresh automático
api.interceptors.response.use(
res => res,
async err => {
if (err.response?.status === 401) {
await useAuthStore().refresh() // POST /auth/refresh
}
return Promise.reject(err)
}
)
Paginación
Todos los listados usan paginación del lado del servidor con parámetros page y per_page. La respuesta incluye el objeto pagination con total y pages.
Frontend — auth y control de planes
Frontend
Control de acceso por plan
// utils/usePlan.js
export function usePlan() {
const auth = useAuthStore()
const plan = auth.empresa?.plan // 'basico' | 'profesional' | 'enterprise'
return {
tieneIA: plan === 'enterprise',
tieneReportesAvanzados: ['profesional','enterprise'].includes(plan),
tieneExportacion: ['profesional','enterprise'].includes(plan),
}
}
// Uso en componente:
const { tieneIA } = usePlan()
// <SeccionIA v-if="tieneIA" />
// <BannerUpgrade v-else />
El control de planes en el frontend es solo presentación. La validación real ocurre en el backend con @requiere_plan. Nunca asumir que el frontend es suficiente para proteger endpoints.
Permisos por rol
| Rol | Crear | Editar | Exportar | Config empresa |
| admin | ✓ | ✓ | ✓ | ✓ |
| supervisor | ✓ | ✓ | ✓ | — |
| analista | ✓ | — | ✓ | — |
| operador | Solo pagos | — | — | — |
Backend — stack y estructura
Python · Flask · PostgreSQL
Backend
Stack tecnológico
Estructura de carpetas
backend/
├── app/
│ ├── __init__.py
│ ├── config.py
│ ├── extensions.py
│ ├── models/
│ │ ├── empresa.py · usuario.py · cliente.py
│ │ ├── factura.py · pago.py · acuerdo_pago.py
│ │ └── prediccion_ia.py · audit_log.py
│ ├── schemas/
│ ├── api/
│ │ ├── auth/ · empresas/ · usuarios/
│ │ ├── clientes/ · facturas/ · pagos/
│ │ ├── cartera/ · reportes/ · dashboard/ · ia/
│ ├── services/
│ ├── ia/
│ │ ├── prediccion_mora.py
│ │ ├── segmentacion_clientes.py
│ │ ├── reporte_inteligente.py
│ │ └── modelos/
│ ├── middleware/
│ └── utils/
├── migrations/
├── tests/
├── .env · requirements.txt · Dockerfile · wsgi.py
Variables de entorno
FLASK_ENV=production
DATABASE_URL=postgresql://user:pass@localhost:5432/cartera_db
JWT_SECRET_KEY=<generado con secrets.token_hex(32)>
JWT_ACCESS_TOKEN_EXPIRES=900 # 15 minutos
JWT_REFRESH_TOKEN_EXPIRES=604800 # 7 días
CORS_ORIGINS=https://app.tudominio.com
Backend — seguridad y JWT
Backend
Tokens JWT
| Token | Vigencia | Payload |
| access_token | 15 minutos | user_id, empresa_id, rol, plan |
| refresh_token | 7 días | user_id |
El empresa_id se extrae siempre del JWT — nunca de la request. Todo query lleva filter_by(empresa_id=get_empresa_id()). Un query sin este filtro es un bug de seguridad crítico.
Decoradores de control de acceso
@jwt_required()
@requiere_roles('admin', 'supervisor', 'analista')
@requiere_plan('enterprise')
def post(self):
...
Regla de aislamiento multiempresa
# CORRECTO
Cliente.query.filter_by(id=cliente_id, empresa_id=get_empresa_id()).first()
# INCORRECTO — nunca sin empresa_id
Cliente.query.filter_by(id=cliente_id).first()
Hash de contraseñas (Argon2id)
from argon2 import PasswordHasher
ph = PasswordHasher()
hashear = lambda pwd: ph.hash(pwd)
verificar = lambda h, pwd: ph.verify(h, pwd)
Acciones auditadas obligatoriamente
LOGINCREATE_FACTURAREGISTER_PAGOUPDATE_FACTURADELETE_CLIENTECAMBIO_ESTADOEJECUTAR_IAEXPORTAR_REPORTE
Protecciones activas
| Protección | Implementación |
| SQL Injection | SQLAlchemy ORM — nunca queries en string raw |
| XSS | Marshmallow sanitiza inputs · Respuestas JSON |
| CSRF | API stateless con JWT · CORS restringido por origen |
| Fuerza bruta | Rate limiting en endpoints de auth |
| Contraseñas | Argon2id — estándar de seguridad actual |
| Transporte | HTTPS obligatorio en producción |
Backend — módulo de IA
Solo plan Enterprise · Scikit-Learn · XGBoost
Backend
Los modelos requieren datos históricos reales. En una instalación nueva la IA no tiene valor predictivo hasta acumular 3–6 meses de historial de pagos.
Variables del modelo de predicción
| Variable | Tipo | Descripción |
| facturas_vencidas_total | int | Facturas que alguna vez estuvieron vencidas |
| valor_deuda_acumulada | decimal | Suma de saldos pendientes actuales |
| frecuencia_mora | float 0–1 | Proporción de facturas que entraron en mora |
| dias_promedio_retraso | int | Promedio de días de retraso en pagos |
| antiguedad_dias | int | Días desde la creación del cliente |
| facturas_pagadas_total | int | Facturas saldadas correctamente |
Niveles de riesgo
Bajoscore < 0.30
Medioscore 0.30–0.65
Altoscore > 0.65
Segmentación de clientes
| Segmento | Criterio |
| Excelente pagador | Sin mora histórica · score < 0.15 |
| Buen pagador | Mora < 10% · score < 0.30 |
| Pagador ocasional | Mora 10–30% · score 0.30–0.50 |
| Cliente de riesgo | Score 0.50–0.65 |
| Cliente crítico | Score > 0.65 |
Ejemplo de narrativa generada
"El 72% de la cartera vencida corresponde a clientes con más de 90 días de mora."
"Se observa un incremento del 15% en la cartera vencida durante el último trimestre."
"Se recomienda priorizar acciones de cobro sobre los 20 clientes con mayor nivel de riesgo."
Base de datos — modelo de datos
PostgreSQL 15+ · 10 tablas · ENUMs · Constraints
Base de datos
Tipos enumerados (ENUMs)
plan_empresarol_usuarioestado_clienteestado_facturametodo_pagoestado_acuerdonivel_riesgosegmento_clienteaccion_audit
Tablas
| Columna | Tipo | Restricciones / Notas |
| id | SERIAL | Primary key |
| nombre | VARCHAR(200) | NOT NULL |
| nit | VARCHAR(30) | NOT NULL UNIQUE |
| plan | plan_empresa | basico | profesional | enterprise · Default: basico |
| dias_alerta_vencimiento | INT | Default 5 · Configurable por empresa |
| tasa_mora_mensual | NUMERIC(5,2) | Default 0.00 |
| activa | BOOLEAN | Default TRUE |
| creada_en / actualizada_en | TIMESTAMPTZ | Auto-updated por trigger |
| Columna | Tipo | Restricciones / Notas |
| id | SERIAL | Primary key |
| empresa_id | INT | FK → empresas ON DELETE CASCADE |
| correo | VARCHAR(150) | UNIQUE (empresa_id, correo) |
| password_hash | VARCHAR(256) | NOT NULL · Hash Argon2id |
| rol | rol_usuario | admin | supervisor | analista | operador |
| ultimo_login | TIMESTAMPTZ | Nullable · Se actualiza en cada login |
| Columna | Tipo | Restricciones / Notas |
| id | SERIAL | Primary key |
| empresa_id | INT | FK → empresas ON DELETE CASCADE |
| documento | VARCHAR(30) | UNIQUE (empresa_id, documento) |
| tipo_documento | VARCHAR(20) | NIT | CC | CE | Pasaporte |
| estado | estado_cliente | activo | inactivo · Default: activo |
| nombre, correo, telefono, ciudad | VARCHAR | Datos de contacto |
| Columna | Tipo | Restricciones / Notas |
| id | SERIAL | Primary key |
| empresa_id | INT | FK → empresas |
| cliente_id | INT | FK → clientes ON DELETE RESTRICT |
| numero | VARCHAR(50) | UNIQUE (empresa_id, numero) |
| valor_total | NUMERIC(18,2) | CHECK > 0 |
| saldo_pendiente | NUMERIC(18,2) | CHECK ≤ valor_total · Actualizado por trigger al insertar pago |
| estado | estado_factura | pendiente | pagada | vencida | en_cobro | castigada |
| fecha_vencimiento | DATE | CHECK ≥ fecha_emision |
| Columna | Tipo | Restricciones / Notas |
| id | SERIAL | Primary key |
| empresa_id | INT | FK → empresas |
| factura_id | INT | FK → facturas ON DELETE RESTRICT |
| valor_pagado | NUMERIC(18,2) | CHECK > 0 · El trigger valida que no exceda el saldo |
| metodo_pago | metodo_pago | efectivo | transferencia | cheque | tarjeta | otro |
| referencia | VARCHAR(100) | Número de transacción / cheque · Nullable |
| registrado_por | INT | FK → usuarios ON DELETE SET NULL |
| Columna | Tipo | Restricciones / Notas |
| id | SERIAL | Primary key |
| cliente_id | INT | FK → clientes |
| monto_total | NUMERIC(18,2) | CHECK > 0 |
| cuotas | INT | CHECK ≥ 1 |
| valor_cuota | NUMERIC(18,2) | CHECK > 0 |
| estado | estado_acuerdo | vigente | cumplido | incumplido |
| Columna | Tipo | Notas |
| acuerdo_id | INT | FK → acuerdos_pago · PK compuesta |
| factura_id | INT | FK → facturas · PK compuesta |
| Columna | Tipo | Restricciones / Notas |
| cliente_id | INT | FK → clientes |
| nivel_riesgo | nivel_riesgo | bajo | medio | alto |
| segmento | segmento_cliente | excelente | bueno | ocasional | riesgo | critico |
| score | NUMERIC(6,4) | CHECK BETWEEN 0 AND 1 |
| version_modelo | VARCHAR(20) | Trazabilidad al reentrenar el modelo |
| variables (6 columnas) | INT / NUMERIC | Snapshot de variables usadas en la predicción |
| Columna | Tipo | Notas |
| tipo | VARCHAR(50) | cartera | cartera_vencida | recaudo | morosos | desempeno |
| filtros_json | JSONB | Parámetros usados: fechas, filtros, etc. |
| generado_por | INT | FK → usuarios ON DELETE SET NULL |
| Columna | Tipo | Notas |
| id | BIGSERIAL | BIGINT — alto volumen de registros |
| accion | accion_audit | LOGIN | CREATE | UPDATE | DELETE | REGISTER_PAGO | etc. |
| entidad / entidad_id | VARCHAR / INT | 'factura', 'pago', 'cliente'… |
| detalle_json | JSONB | Snapshot antes/después del cambio |
| ip_origen | INET | IP del cliente que realizó la acción |
| timestamp | TIMESTAMPTZ | Inmutable — sin trigger de update |
Base de datos — vistas y triggers
Base de datos
Vistas disponibles
| Vista | Descripción | Usado por |
v_cartera_vencida | Facturas vencidas con días de mora y rango de antigüedad calculados | Módulo cartera, reportes |
v_resumen_cartera | KPIs agregados por empresa: total, vencida, vigente, morosos | Dashboard |
v_recaudo_mensual | Recaudo mensual por empresa | Dashboard, reportes |
v_prediccion_vigente | Última predicción de riesgo por cliente (DISTINCT ON) | IA, listado clientes |
Trigger — trg_aplicar_pago
Al insertar en pagos, descuenta el saldo_pendiente de la factura. Si el saldo llega a cero, cambia el estado a pagada. También valida que el pago no exceda el saldo.
Este trigger garantiza consistencia a nivel de base de datos, independientemente de si el pago se inserta desde la API o directamente en la BD.
Trigger — timestamps automáticos
Las tablas empresas, usuarios, clientes, facturas y acuerdos_pago tienen triggers BEFORE UPDATE que actualizan automáticamente actualizada_en.
Función — marcar_facturas_vencidas()
Marca como vencida toda factura pendiente cuya fecha_vencimiento ya pasó. Debe ejecutarse con un cron job diario.
-- Con pg_cron (instalar extensión)
SELECT cron.schedule('0 1 * * *', 'SELECT marcar_facturas_vencidas()');
-- Se ejecuta todos los días a la 1:00 AM
Base de datos — índices y rendimiento
Base de datos
La arquitectura soporta más de 1.000 empresas, 100.000 clientes y millones de facturas. Los índices son críticos para tiempos de respuesta bajos a ese volumen.
Índices por tabla
| Tabla | Columnas indexadas | Justificación |
| empresas | plan, activa | Filtrar empresas por plan o estado |
| usuarios | empresa_id, correo | Login y listado por empresa |
| clientes | empresa_id, estado, ciudad | Filtros más usados en el listado |
| clientes | GIN sobre nombre (tsvector) | Búsqueda de texto por nombre |
| facturas | empresa_id, cliente_id, estado | Filtros principales |
| facturas | fecha_vencimiento | Cálculo de cartera vencida y alertas |
| facturas | (empresa_id, estado) | Índice compuesto para dashboard |
| pagos | empresa_id, factura_id, fecha_pago | Historial y recaudo mensual |
| predicciones_ia | empresa_id, cliente_id, nivel_riesgo, generada_en DESC | Segmentación y última predicción |
| audit_log | empresa_id, usuario_id, timestamp DESC, (entidad, entidad_id) | Consultas de auditoría |
Capacidad esperada
Buenas prácticas
- Usar
NUMERIC(18,2) para valores monetarios — nunca FLOAT (imprecisión flotante)
- Usar
TIMESTAMPTZ para fechas con hora — almacena en UTC
audit_log usa BIGSERIAL (BIGINT) por el alto volumen esperado
- Evaluar particionamiento de
audit_log por empresa_id al superar 10M filas
- Configurar backups automáticos diarios con retención mínima de 30 días