ZOE — Documento técnico de plataforma

Núcleo de gestión: multi-tenant, proyectos y motor tributario

Cómo se separa la información de cada organización, cómo viaja un trabajo desde el prospecto hasta el cobro, y con qué reglas se calculan las retenciones colombianas, el costo real de un proyecto y el reparto entre socios.

Alcance · núcleo de gestión · 70 módulos · 55 entidades Base · PostgreSQL · Prisma 6 Pruebas · 679 País · Colombia — UVT, RETEFUENTE, RETEICA, RETEIVA
01

Qué cubre este documento

ZOE administra el ciclo completo de una empresa de servicios: quién es el cliente, qué se le vendió, quién lo ejecuta, cuánto costó de verdad y a quién le toca la utilidad. Este documento describe el núcleo de gestión — la parte que resuelve ese ciclo.

Alcance, dicho de frente

La plataforma tiene más módulos de los que aquí se describen. Este documento cubre el núcleo de gestión: organizaciones, proyectos, dinero, tributario, costos y reparto. Deja fuera de forma deliberada los módulos de producción de contenido. Se dice para que nadie lea esto como el inventario completo del sistema.

El criterio de diseño que atraviesa todo lo que sigue es uno solo: una cifra que se ve confiable y está mal es peor que ninguna cifra. Casi todas las decisiones del modelo de datos que se explican aquí existen porque en algún momento el sistema dio un número creíble y falso.

Organización

Multi-tenant con unidades

Cada organización vive aislada, y dentro se divide en unidades de negocio que comparten usuarios, roles y finanzas.

Operación

Prospecto → proyecto → cobro

Cotización, conversión a proyecto, fases con entregables, tareas internas y cobros atados a su factura.

Fiscal

Retenciones por reglas, no por tarifa

La tarifa depende del tercero y del año. Todo es dato con vigencia, y nada se da por bueno sin confirmación humana.

Resultado

Utilidad y reparto

Presupuesto contra gasto real para saber la utilidad de un proyecto en curso, y reparto entre socios con su rastro contable.

02

Multi-tenant y unidades de negocio

La unidad de aislamiento es el tenant: una organización con su propio nombre, identificador, logotipo y paleta. Cada fila de negocio del sistema lleva su tenantId.

Un usuario, varias organizaciones

La relación entre persona y organización no es un campo sino una tabla, UserTenant, con restricción de unicidad sobre el par usuario–organización. Un usuario puede pertenecer a varias, con un rol distinto en cada una, y puede quedar inactivo en una sin desaparecer de las demás.

Cadena de accesoUna sola dirección
Usuario ─ UserTenant ─ Rol ─ Permisos │ Tenant ─ Unidad de negocio ─ Proyectos · Pagos · Órdenes
Los roles pertenecen al tenant, no al sistema: cada organización define los suyos. Los marcados como isSystem vienen predefinidos y no se editan, para que nadie se quede sin administrador por accidente.

Unidades de negocio

Dentro de una organización, la unidad de negocio agrupa proyectos, pagos y órdenes. Cada unidad lleva su color, y una bandera isAcademic que cambia qué campos pide un proyecto. La misma instalación sostiene así líneas de negocio distintas sin duplicar usuarios ni contabilidad.

Por qué la unidad no es solo una etiqueta

Los proyectos, los pagos y las órdenes de compra cuelgan de la unidad. Eso permite responder «cuánto dejó esta línea de negocio» sin filtrar a mano, y es lo que hace que el reparto de utilidades pueda calcularse por unidad y no solo por organización.

03

Del prospecto al cobro

El recorrido comercial es una cadena de estados, y cada salto deja un registro que sobrevive al siguiente.

EtapaEstadosQué decide
ClientePROSPECT · ACTIVE · INACTIVE · COMPLETEDSi entra al embudo comercial o al operativo
InteracciónCALL · EMAIL · WHATSAPP · MEETING · NOTEHistorial de contacto, con su fecha
CotizaciónDRAFT · SENT · APPROVED · REJECTED · EXPIREDPrecio, renglones y tratamiento de IVA
ProyectoPENDING · IN_PROGRESS · IN_REVIEW · CORRECTIONS · COMPLETED · CANCELLEDEjecución y facturación

La conversión de cotización a proyecto

Aprobar una cotización crea el proyecto, y el vínculo entre ambos es una llave foránea. Merece explicación porque antes no lo era.

Un vínculo que se pierde al editar un campo de texto no es un vínculo

La relación cotización–proyecto vivía como una marca dentro del campo de notas. Editar las notas la borraba, y entonces la cotización volvía a aparecer como convertible: se podía convertir por segunda vez, creando un segundo proyecto y cobrándole la misma deuda dos veces al mismo cliente. La misma lección se aplicó después a los datos estructurados de una propuesta, que también vivían dentro de las notas.

Renglones de cotización

Los renglones se guardan como estructura, no como texto: descripción, cantidad, precio unitario, descuento, tratamiento de IVA, tarifa, total e IVA calculado. El total del renglón es la base, y el IVA va aparte — la misma convención que en la factura y en la orden, para que ninguna suma tenga que adivinar si un número ya trae impuesto dentro.

04

Fases, entregables y tareas

Un proyecto se descompone en fases, cada fase produce entregables, y el trabajo interno se organiza en tareas. Son tres niveles con estados propios porque responden a preguntas distintas.

NivelEstadosPregunta que responde
FasePENDING · IN_PROGRESS · COMPLETED · BLOCKED¿Por dónde va el proyecto?
EntregablePENDING · IN_PROGRESS · SUBMITTED · APPROVED · REJECTED¿Qué se le entregó al cliente y lo aceptó?
TareaTODO · IN_PROGRESS · IN_REVIEW · DONE¿Quién está haciendo qué ahora?

El entregable tiene SUBMITTED y REJECTED porque entregar no es lo mismo que terminar: un entregable enviado y devuelto es un estado real del negocio, y colapsarlo en «pendiente» borra la información de que ya hubo una vuelta.

Quién trabaja y quién cobra son dos tablas

ProjectMember y ProjectPartner no se fusionaron a propósito

ProjectMember significa quién trabaja en las tareas. ProjectPartner significa a quién le toca parte de la utilidad, y por la misma fila, quién puede ver el proyecto. Si fueran el mismo modelo, agregar un revisor a un proyecto le daría derecho económico sobre él. Mantener el reparto y el acceso en una sola fuente evita además que se desincronicen.

05

El dinero y su imputación

Todo movimiento de plata es un Payment con tipo, método, estado y fecha de pago. Los métodos incluyen los locales — transferencia, Nequi, Daviplata — porque un sistema que solo entiende «tarjeta» no sirve para registrar cómo se cobra de verdad.

El tipo que evita contar dos veces

PaymentTypeINCOME · EXPENSE · REFUND · TRASPASO
TRASPASO es plata que cambia de bolsillo sin ser ingreso ni gasto: un retiro de cajero, un traslado entre cuentas. Sin ese tipo, la misma plata se contaba dos veces — el retiro como gasto, y además aquello en lo que se fue. Todas las sumas del sistema filtran explícitamente por INCOME o EXPENSE, así que un traspaso aparece en los listados y no suma en ningún total.

A qué factura se imputa un cobro

Un cobro puede atarse a la factura contra la que se giró. Cuando no lo está, el sistema aplica imputación por antigüedad: lo cobrado va contra la factura más vieja primero.

Por qué la antigüedad es respaldo y no regla

Con un proyecto facturado en dos partes — un anticipo y un saldo, que es como se cobra una obra — el sistema tenía que adivinar. La imputación por antigüedad es la regla usual del comercio, pero es una suposición: si el cliente giró contra la factura nueva y dejó la vieja pendiente, la pantalla lo decía al revés. Ahora el vínculo es explícito cuando se conoce.

El campo sigue siendo opcional, y no por compatibilidad: hay giros que de verdad no tienen factura todavía. Un anticipo entra antes de que exista el documento, y los ingresos que llegan por importación masiva se registran sin saber contra qué van. Para esos, la antigüedad sigue siendo el respaldo, y existe una operación para atarlos después.

Borrar un papel no devuelve la plata

Las relaciones de Payment usan SetNull y no Cascade. Con borrado en cascada, eliminar un cliente o un proyecto borraba también sus pagos, y el ingreso histórico de meses ya cerrados cambiaba de forma retroactiva e irrecuperable. Anular una factura tampoco puede borrar la plata que el cliente giró: el pago vuelve a quedar sin factura y la imputación por antigüedad lo recoge, que es justo lo que debe pasar.

Cuotas programadas

PaymentSchedule guarda el plan de pagos con estados PENDING · PAID · OVERDUE · CANCELLED. El vencimiento no se calcula al vuelo: se declara, para que «está en mora» sea un hecho registrado y no una inferencia que cambia según cuándo se abra la pantalla.

06

Motor tributario

El sistema tributario colombiano no tiene «una tarifa»: tiene reglas. El 4 % de servicios se vuelve 6 % si el proveedor no es declarante; el suministro es 2,5 % o 3,5 % por lo mismo; y por debajo de la cuantía mínima en UVT no se retiene nada. Por eso las tarifas son datos y no constantes en el código.

Las cuatro tablas que sostienen el cálculo

TablaQué guardaPor qué existe
TaxConceptEl concepto de retención: servicios generales, transporte de carga, comprasLa tarifa no vive aquí, porque depende del tercero
TaxConceptRateTarifa según la condición del tercero, con cuatro decimalesUn mismo concepto tiene tarifas distintas por declarante o no declarante
TaxConceptThresholdCuantía mínima en UVT, con vigenciaEstos valores se mueven, y la historia no se puede reescribir
UvtValueValor del UVT por añoEl mínimo se evalúa contra el UVT del año del documento
Por qué las cuantías mínimas tienen vigencia y no son un campo

El Decreto 572 de 2025 bajó servicios de 4 a 2 UVT y compras de 27 a 10 UVT desde el 1 de junio de 2025. El Consejo de Estado suspendió esos artículos el 7 de mayo de 2026, revocó la suspensión el 2 de junio, y desde el 1 de julio volvieron a regir los valores nuevos. Dos cambios en catorce meses, con el juicio de nulidad todavía abierto. Un campo único obligaría a reescribir la historia en cada vuelta.

La fecha que manda es la del documento

Un costo guarda la fecha del documento del proveedor, distinta de la fecha en que se registró. Registrar hoy una factura de marzo del año pasado debe usar el UVT y los umbrales de marzo del año pasado. Recalcular la historia con valores nuevos destruye la credibilidad de un sistema contable.

El perfil del tercero

TaxParty guarda lo que el motor necesita para escoger la tarifa: tipo de persona, si declara renta, si es responsable de IVA, si es gran contribuyente, si es autorretenedor — a quien no se le retiene — y si es agente de retención de IVA. Sin esa ficha el motor no puede decidir, y lo dice en vez de inventar.

Nada se da por bueno sin que alguien lo confirme

Tarifas, cuantías, valores de UVT y tarifas de ICA llevan una bandera isConfirmed. Ninguna se presenta como definitiva hasta que una persona la aprueba en pantalla. Un número inventado en un motor tributario es peor que ninguno, porque se ve confiable.

Y el motor guarda con cada cálculo las advertencias que emitió — tarifa sin confirmar, tercero sin ficha, UVT sin confirmar. Antes esas advertencias solo vivían en la vista previa del formulario, así que al guardar, el número quedaba pareciendo definitivo justo cuando dejaba de poder revisarse.

ICA es municipal

La tarifa de ICA depende del municipio y de la actividad, y se guarda por milaje en su propia tabla. Sin ella solo queda marcar el valor como provisional, que es exactamente el problema que tiene una hoja de cálculo.

07

Presupuesto y costo real

Saber si un proyecto deja dinero exige dos cosas separadas: lo que se espera gastar y lo que se gastó. Tenerlas juntas es lo que permite hablar de utilidad antes de cerrar.

El número enorme, tranquilizador y falso

Sin partidas de presupuesto, la utilidad de un proyecto en ejecución no se puede calcular — y lo peor es que sí se puede calcular mal: precio menos gastado hasta hoy. En un proyecto de instalación solar eso daba veinte millones de utilidad cuando todavía no se habían comprado ni los paneles ni el inversor. Es la peor clase de número: grande, tranquilizador y falso.

Con las partidas declaradas, las tres cifras salen solas: lo que falta por gastar, lo que costará al final, y la utilidad que queda si el presupuesto aguanta.

Un costo no es un pago

Un ProjectCost es un frente de trabajo — la compra, el alquiler o el servicio que alguien prestó — con su tratamiento fiscal y la carpeta donde viven sus soportes. Los pagos cuelgan de él, en plural.

Por qué la separación importa
1 costo de un proveedor ├─ pago 1 (transferencia) ├─ pago 2 (transferencia) ├─ retención practicada, todavía por girar a la DIAN └─ soporte: FACTURA_PENDIENTE
Un solo costo, dos giros, una retención pendiente y una factura que no ha llegado. Cuatro hechos distintos que un único registro «gasto» no puede representar sin mentir en alguno.

El gasto que nadie previó

El vínculo entre un gasto y su partida de presupuesto es opcional a propósito. Un gasto que nadie previó es un hecho normal, y esconderlo bajo una partida inventada sería peor que dejarlo aparte: la pantalla los agrupa como «no estaba presupuestado», que es información útil sobre cómo se planeó.

El vínculo con la orden de compra que lo autorizó usa SetNull: si se borra la orden, el gasto sigue existiendo. La plata se gastó de verdad; borrar el papel que la autorizó no la devuelve.

IVA por renglón, no por factura

Tratar la factura como un todo infla el impuesto

El tratamiento de IVA va por ítem — GRAVADO · EXCLUIDO · EXENTO — porque en una misma factura conviven renglones excluidos y renglones gravados al 19 %. En un caso real, tratar la factura como un bloque inflaba el IVA en más de un millón de pesos.

Las retenciones también tienen tabla propia, porque cada una tiene su propia base: 4 % sobre lo gravado y 1 % sobre el transporte, no sobre el total. Y los valores quedan congelados: se guarda la tarifa, la base y el UVT que se usaron en ese momento. Si al año siguiente cambia el UVT o una tarifa, esa factura sigue diciendo exactamente lo que dijo.

08

Reparto entre socios

Un reparto es una decisión autorizada, no un movimiento de caja. Puede haber varios por proyecto, porque la plata se reparte a medida que entra y no solo al cerrar.

Cuatro decimales, porque con dos un tercio no existe

La participación de cada socio se guarda con cuatro decimales. Con dos, 33,33 × 3 = 99,99 y la validación de suma lo rechaza: tres socios iguales — el caso más común de una sociedad — no se podían representar. Con 33,3333 la suma da 99,9999 y el desvío cabe holgado en la tolerancia, que además se pudo endurecer.

De la decisión al asiento

Al confirmar un reparto se genera un pago de egreso por socio, de modo que el efectivo disponible baja solo y nadie tiene que acordarse de registrarlo aparte. El vínculo con ese pago es una llave foránea real, no un texto: antes se escribía una vez y nadie garantizaba que apuntara a algo, así que el pago se podía borrar por detrás y el reparto quedaba diciendo que se pagó algo que no existe.

Ese vínculo usa SetNull: si el pago desaparece, el registro de a quién le tocaba cuánto tiene que sobrevivir. Es la decisión, no el movimiento de plata.

09

Auditoría y borrado

Dos mecanismos sostienen la trazabilidad: un registro de auditoría y un borrado que no destruye.

Rastro

AuditLog

Registra los cambios sensibles con su autor y su momento. Es lo que permite responder «quién cambió este precio» meses después.

Reversible

Papelera

El borrado es lógico. Un registro eliminado sale de las pantallas pero sigue existiendo, y con él las relaciones que sostenía.

La regla general del esquema es la misma en todas partes: ninguna eliminación puede borrar hacia atrás un hecho económico. Por eso las relaciones que tocan dinero usan SetNull y no Cascade, y por eso la papelera existe en vez de un DELETE.

10

Decisiones que costaron caro

Estas quedaron documentadas dentro del propio esquema, donde las encuentra quien vaya a tocar ese código.

Qué pasóQué se aprendió
Un comentario de estilo JSDoc dentro del esquema PrismaEl entorno local toleraba /** */, pero el build de producción corre una versión anterior de la herramienta que lo rechaza al validar. El despliegue moría siempre en el mismo punto mientras se seguía sirviendo la build anterior, así que en pantalla no se veía ningún cambio y parecía que el código no llegaba. Solo // y ///.
Texto libre dentro de un comentario de esquemaEse texto se copia tal cual al cliente generado. Escribir ahí la secuencia que cierra un bloque de comentario lo termina antes de tiempo y deja el archivo de tipos corrupto, con cien errores en un archivo que nadie edita.
Las pantallas de finanzas ordenaban por fecha de pagoSin índice, cada carga ordenaba en memoria la tabla completa de la organización. El panel agrupaba por proyecto en cada render, y borrar un proyecto hacía un recorrido secuencial por la llave foránea.
Vínculos guardados como marcas dentro de campos de textoEditar el texto los borraba. Un dato que se pierde al editar otro campo no es un dato.
11

Arquitectura y ejecución

Monorepo con dos paquetes, una base de datos y un despliegue.

Estructura
apps/web/ Next.js 15 · React 19 · Tailwind 4 · Zod 4 packages/db/ Esquema Prisma 6 y cliente compartido docs/ Especificaciones y planes scripts/ Utilidades de mantenimiento
Orquestado con Turborepo y pnpm. La generación del cliente de base de datos es una tarea propia, para que el resto del pipeline dependa de ella de forma explícita.
CapacidadQué cubre
OrganizacionesMulti-tenant con roles por organización, unidades de negocio y portal externo de acceso restringido
ComercialClientes, historial de interacciones, cotizaciones con renglones estructurados y conversión trazable a proyecto
EjecuciónProyectos con fases, entregables y tareas, con estados propios en cada nivel
FinanzasCobros y gastos, métodos locales, cuotas programadas e imputación explícita a factura
TributarioRetención en la fuente, ICA e IVA con tarifas por condición del tercero, cuantías con vigencia y UVT por año
CostosPresupuesto por partidas contra gasto real, con soportes y retenciones congeladas
RepartoParticipaciones con cuatro decimales y generación automática del egreso por socio
ControlRegistro de auditoría, borrado lógico y comentarios sobre las entidades
InventarioProductos, movimientos de stock, proveedores y órdenes de servicio o compra

Pruebas

679 pruebas corren en cada cambio, sobre el corredor nativo de Node. Cubren de forma deliberada la lógica que decide plata: imputación de cobros, alcance de rutas por rol, detección de duplicados y la conversión de cobro a factura.