Plataforma técnica · Orchestrator
Legal Rep Service
Orchestrator Administración Representantes Legales
LegalRepService administra los representantes legales del tenant (apoderados, socios firmantes) y opcionalmente su cuenta corriente socio — el mecanismo contable para rastrear retiros y aportes informales por persona.
Código fuente: orchestrator/src/domain/legal-representatives/LegalRepService.ts, LegalRepRepository.ts y types.ts.
Dos responsabilidades
Section titled “Dos responsabilidades”| Responsabilidad | Métodos | Tablas tocadas |
|---|---|---|
| CRUD de representantes | list, getActive, getById, create, update, delete, setAsActive | administracion.representantes_legales. |
| Cuenta corriente socio | activarCuentaSocio, desactivarCuentaSocio, getSaldoSocio, getMovimientosSocio | administracion.plan_contable, financieros.bancos, financieros.movimientos_bancarios. |
Mapeo legacy ↔ dominio
Section titled “Mapeo legacy ↔ dominio”La tabla administracion.representantes_legales tiene nombres de columnas legacy distintos al modelo del dominio. LegalRepRepository.mapRow traduce:
| Columna DB | Campo dominio LegalRep |
|---|---|
fecha_nombramiento | fecha_desde |
fecha_termino | fecha_hasta |
activo | vigente |
El service y el frontend solo conocen los nombres del dominio; el repository hace la traducción en ambos sentidos. Existe una columna nombre_completo como GENERATED column en PostgreSQL — no se setea desde el código.
Modelo de Dominio
Section titled “Modelo de Dominio”LegalRep:
| Campo | Tipo | Notas |
|---|---|---|
id | UUID | |
rut | string | Formato ^\d{7,8}-[\dkK]$. |
nombres / apellidos | string | |
fecha_desde / fecha_hasta | Date | Vigencia. |
vigente | boolean | Solo uno puede estar vigente = true (ver setAsActive). |
cargo | string? | |
email / telefono / direccion / observaciones | string? | |
cuenta_contable_codigo | string? | Sub-cuenta bajo 2204000 si tiene cuenta socio. |
tiene_cuenta_socio | boolean | Flag activador. |
saldo_socio | number? | Derivado (no persistido): viene del JOIN con bancos/movimientos. |
created_at / updated_at / created_by / updated_by | — |
CRUD básico
Section titled “CRUD básico”list(ctx, activeOnly?)
Section titled “list(ctx, activeOnly?)”Query con JOIN a financieros.bancos y financieros.movimientos_bancarios para calcular saldo_socio derivado en la misma fila:
saldo_socio = COALESCE(b.saldo_inicial, 0)+ COALESCE(SUM(CASE WHEN m.tipo_movimiento='ABONO' THEN m.monto ELSE 0 END), 0)- COALESCE(SUM(CASE WHEN m.tipo_movimiento='CARGO' THEN m.monto ELSE 0 END), 0)Solo cuenta movimientos estado = 'CONCILIADO'. Si el rep no tiene tiene_cuenta_socio = true o no hay banco asociado, saldo_socio = NULL.
Ordenado por activo DESC, fecha_nombramiento DESC — vigente primero.
getActive(ctx)
Section titled “getActive(ctx)”Atajo interno: list(activeOnly: true)[0] o null. El router HTTP actual no expone una ruta /active; desde API se usa GET /api/admin/representatives?activeOnly=true.
create(ctx, data)
Section titled “create(ctx, data)”Valida RUT regex y crea con vigente = data.vigente ?? false. Operativamente la activación debe hacerse vía setAsActive, porque ese método desactiva a los demás representantes y mantiene la invariante de un representante vigente principal.
Payload esperado (CreateLegalRepDTO):
| Campo | Tipo | Obligatorio | Notas |
|---|---|---|---|
rut | string | Sí | Formato ^\d{7,8}-[\dkK]$. |
nombres | string | Sí | Se persiste en representantes_legales.nombres. |
apellidos | string | Sí | Se persiste en representantes_legales.apellidos. |
fecha_desde | string | Date | Sí | Se mapea a fecha_nombramiento. |
fecha_hasta | string | Date | null | No | Se mapea a fecha_termino. |
vigente | boolean | No | Se mapea a activo; usar con cuidado fuera de setAsActive. |
cargo, email, telefono, direccion, observaciones | string | null | No | Datos descriptivos y de contacto. |
update(ctx, id, updates)
Section titled “update(ctx, id, updates)”Valida RUT si viene en el patch. El repository hace mapeo explícito de campos dominio → DB y usa buildUpdate para SET dinámico.
El patch acepta los mismos campos opcionales de UpdateLegalRepDTO. Si no hay campos válidos, buildUpdate devuelve error de validación.
delete(ctx, id)
Section titled “delete(ctx, id)”DELETE físico (sin tombstone). Si el rep tiene cuenta_contable_codigo, la cuenta del plan no se elimina — queda huérfana intencionadamente para preservar historia de asientos.
setAsActive(ctx, id)
Section titled “setAsActive(ctx, id)”Transacción multi-row para garantizar la invariante “solo uno vigente”:
BEGIN;UPDATE administracion.representantes_legalesSET activo = false, updated_by = $1, updated_at = NOW()WHERE activo = true;
UPDATE administracion.representantes_legalesSET activo = true, updated_by = $1, updated_at = NOW()WHERE id = $2RETURNING *;COMMIT;Vive en el repository (LegalRepRepository.setAsActive), no en el service — porque la TX es puramente de SQL y no necesita el outbox.
Cuenta Corriente Socio
Section titled “Cuenta Corriente Socio”Por qué existe
Section titled “Por qué existe”Los socios suelen mover plata entre la empresa y sus bolsillos: retiros para gastos personales, aportes que no son aumento de capital, devoluciones. La práctica contable es asignarles una sub-cuenta del pasivo 2204000 (Cuentas por pagar socios) y registrar cada movimiento como un asiento normal.
El sistema permite “activar” una cuenta socio para un representante, lo que:
- Crea una sub-cuenta nueva en el plan bajo
2204000. - Persiste el código en
representantes_legales.cuenta_contable_codigo. - Marca
tiene_cuenta_socio = true.
El banco asociado (en financieros.bancos) lo crea el usuario manualmente en el módulo Finanzas — el servicio no lo crea automáticamente.
activarCuentaSocio(ctx, id) — algoritmo
Section titled “activarCuentaSocio(ctx, id) — algoritmo”flowchart TB
IN["activarCuentaSocio(id)"]
FIND["findById(id)"]
CHK1{"rep existe?"}
CHK2{"tiene_cuenta_socio?"}
NEXT["getNextCuentaCodigo:<br/>SELECT codigo FROM plan_contable<br/>WHERE codigo='2204000' FOR UPDATE<br/>then MAX(codigo::bigint)+1<br/>WHERE codigo_padre='2204000'"]
PROBE["SELECT codigo FROM plan_contable<br/>WHERE codigo_padre='2204000'<br/>AND nombre = 'Cta. Cte. {nombres} {apellidos} ({rut})'"]
EXIST{"ya hay sub-cuenta<br/>con ese nombre?"}
REUSE["cuentaCodigo = existing.codigo<br/>(idempotente · recovery de intento previo)"]
CREATE["INSERT INTO plan_contable<br/>(codigo, codigo_padre='2204000',<br/>tipo='PASIVO', naturaleza='C',<br/>nivel=4, es_imputable=true,<br/>requiere_tercero=true)"]
UPD["UPDATE representantes_legales<br/>SET cuenta_contable_codigo=...,<br/>tiene_cuenta_socio=true"]
IN --> FIND --> CHK1
CHK1 -- no --> ERR1["Error 'not found'"]
CHK1 -- sí --> CHK2
CHK2 -- sí --> ERR2["ValidationError 'ya tiene cuenta activa'"]
CHK2 -- no --> NEXT --> PROBE --> EXIST
EXIST -- sí --> REUSE --> UPD
EXIST -- no --> CREATE --> UPD Todo dentro de withTransaction. Detalle de las decisiones:
FOR UPDATE sobre el padre 2204000: previene que dos requests simultáneos calculen el mismo next_codigo y colisionen al insertar. El lock se libera al COMMIT.
Cálculo MAX(codigo::bigint) + 1 con base mínima 2204000:
SELECT COALESCE(MAX(codigo::bigint), 2204000) + 1 AS next_codigoFROM administracion.plan_contableWHERE codigo_padre = '2204000'Si nunca hubo hijos, el primer codigo es 2204001. El cast a ::bigint es necesario porque codigo es TEXT pero hay que ordenarlo numéricamente.
Recovery idempotente (probe + REUSE): si un intento previo creó la sub-cuenta pero falló antes del UPDATE final, un retry encontraría la cuenta por nombre exacto y la reutilizaría en vez de crear duplicada. El match es por nombre legible Cta. Cte. {nombres} {apellidos} ({rut}). Si el operador renombró manualmente la cuenta, la idempotencia se rompe — caso raro.
Atributos fijos de la sub-cuenta creada:
| Campo | Valor |
|---|---|
codigo_padre | '2204000' |
nombre | Cta. Cte. {nombres} {apellidos} ({rut}) |
tipo | 'PASIVO' |
naturaleza | 'C' (credit) |
nivel | 4 |
es_imputable | true |
requiere_tercero | true |
requiere_cc | false |
requiere_proyecto | false |
orden_presentacion | 224 |
desactivarCuentaSocio(ctx, id)
Section titled “desactivarCuentaSocio(ctx, id)”Solo flippa tiene_cuenta_socio = false. Preserva cuenta_contable_codigo para que los asientos históricos sigan referenciando una cuenta válida. No elimina la sub-cuenta del plan ni el banco asociado.
Si el usuario reactiva después, activarCuentaSocio detectará la sub-cuenta existente por nombre y la reutilizará.
getSaldoSocio(ctx, id)
Section titled “getSaldoSocio(ctx, id)”Devuelve SaldoSocio:
| Campo | Cálculo |
|---|---|
banco_id | bancos.id |
nombre_banco | bancos.nombre |
cuenta_contable_codigo | bancos.cuenta_contable_codigo |
saldo_actual | saldo_inicial + SUM(ABONO) - SUM(CARGO) (CONCILIADO) |
total_abonos | SUM(ABONO) (CONCILIADO) |
total_cargos | SUM(CARGO) (CONCILIADO) |
cantidad_movimientos | COUNT(*) CONCILIADO |
Pre-requisitos: el rep debe tener tiene_cuenta_socio = true y la sub-cuenta debe estar enlazada a un banco en financieros.bancos.cuenta_contable_codigo. Si falta el banco, lanza Error("Banco asociado no encontrado para esta cuenta socio").
getMovimientosSocio(ctx, id, anio?, mes?)
Section titled “getMovimientosSocio(ctx, id, anio?, mes?)”Lista los movimientos del banco asociado, opcionalmente filtrados por año/mes. Trae categoria_nombre con LEFT JOIN a financieros.categorias_movimiento. Ordenado por fecha descendente.
Endpoints
Section titled “Endpoints”Documentados en Administración API. Resumen:
| Método | Ruta | Service call | Rol |
|---|---|---|---|
GET | /api/admin/representatives?activeOnly=true | list(ctx, activeOnly) | Autenticado |
GET | /api/admin/representatives/:id/saldo-socio | getSaldoSocio(ctx, id) | Autenticado |
GET | /api/admin/representatives/:id/movimientos-socio?anio=2026&mes=5 | getMovimientosSocio(ctx, id, anio, mes) | Autenticado |
GET | /api/admin/representatives/:id | getById(ctx, id) | Autenticado |
POST | /api/admin/representatives | create(ctx, data) | Autenticado |
PUT | /api/admin/representatives/:id | update(ctx, id, updates) | Autenticado |
DELETE | /api/admin/representatives/:id | delete(ctx, id) | Autenticado |
POST | /api/admin/representatives/:id/set-active | setAsActive(ctx, id) | ADMIN / SUPER_ADMIN |
POST | /api/admin/representatives/:id/activar-cuenta | activarCuentaSocio(ctx, id) | ADMIN / SUPER_ADMIN |
POST | /api/admin/representatives/:id/desactivar-cuenta | desactivarCuentaSocio(ctx, id) | ADMIN / SUPER_ADMIN |
Por qué no hay hook
Section titled “Por qué no hay hook”Las mutaciones de representantes legales son meta-configuración. Activar/desactivar una cuenta socio crea estructura contable pero no genera asientos — los asientos los generará el módulo Finanzas cuando el operador registre movimientos en el banco asociado.
Si en el futuro se quiere disparar un evento cuenta_socio:activada (e.g. para auto-suscribir el rep a un dashboard), agregar al DomainEventMap y emitir desde dentro de withTransaction vía outbox.queue(...).