Skip to content

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.

ResponsabilidadMétodosTablas tocadas
CRUD de representanteslist, getActive, getById, create, update, delete, setAsActiveadministracion.representantes_legales.
Cuenta corriente socioactivarCuentaSocio, desactivarCuentaSocio, getSaldoSocio, getMovimientosSocioadministracion.plan_contable, financieros.bancos, financieros.movimientos_bancarios.

La tabla administracion.representantes_legales tiene nombres de columnas legacy distintos al modelo del dominio. LegalRepRepository.mapRow traduce:

Columna DBCampo dominio LegalRep
fecha_nombramientofecha_desde
fecha_terminofecha_hasta
activovigente

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.

LegalRep:

CampoTipoNotas
idUUID
rutstringFormato ^\d{7,8}-[\dkK]$.
nombres / apellidosstring
fecha_desde / fecha_hastaDateVigencia.
vigentebooleanSolo uno puede estar vigente = true (ver setAsActive).
cargostring?
email / telefono / direccion / observacionesstring?
cuenta_contable_codigostring?Sub-cuenta bajo 2204000 si tiene cuenta socio.
tiene_cuenta_sociobooleanFlag activador.
saldo_socionumber?Derivado (no persistido): viene del JOIN con bancos/movimientos.
created_at / updated_at / created_by / updated_by

Query con JOIN a financieros.bancos y financieros.movimientos_bancarios para calcular saldo_socio derivado en la misma fila:

saldo_socio (derivado)
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.

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.

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):

CampoTipoObligatorioNotas
rutstringFormato ^\d{7,8}-[\dkK]$.
nombresstringSe persiste en representantes_legales.nombres.
apellidosstringSe persiste en representantes_legales.apellidos.
fecha_desdestring | DateSe mapea a fecha_nombramiento.
fecha_hastastring | Date | nullNoSe mapea a fecha_termino.
vigentebooleanNoSe mapea a activo; usar con cuidado fuera de setAsActive.
cargo, email, telefono, direccion, observacionesstring | nullNoDatos descriptivos y de contacto.

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 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.

Transacción multi-row para garantizar la invariante “solo uno vigente”:

BEGIN;
UPDATE administracion.representantes_legales
SET activo = false, updated_by = $1, updated_at = NOW()
WHERE activo = true;
UPDATE administracion.representantes_legales
SET activo = true, updated_by = $1, updated_at = NOW()
WHERE id = $2
RETURNING *;
COMMIT;

Vive en el repository (LegalRepRepository.setAsActive), no en el service — porque la TX es puramente de SQL y no necesita el outbox.

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:

  1. Crea una sub-cuenta nueva en el plan bajo 2204000.
  2. Persiste el código en representantes_legales.cuenta_contable_codigo.
  3. 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.

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:

getNextCuentaCodigo
SELECT COALESCE(MAX(codigo::bigint), 2204000) + 1 AS next_codigo
FROM administracion.plan_contable
WHERE 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:

CampoValor
codigo_padre'2204000'
nombreCta. Cte. {nombres} {apellidos} ({rut})
tipo'PASIVO'
naturaleza'C' (credit)
nivel4
es_imputabletrue
requiere_tercerotrue
requiere_ccfalse
requiere_proyectofalse
orden_presentacion224

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á.

Devuelve SaldoSocio:

CampoCálculo
banco_idbancos.id
nombre_bancobancos.nombre
cuenta_contable_codigobancos.cuenta_contable_codigo
saldo_actualsaldo_inicial + SUM(ABONO) - SUM(CARGO) (CONCILIADO)
total_abonosSUM(ABONO) (CONCILIADO)
total_cargosSUM(CARGO) (CONCILIADO)
cantidad_movimientosCOUNT(*) 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").

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.

Documentados en Administración API. Resumen:

MétodoRutaService callRol
GET/api/admin/representatives?activeOnly=truelist(ctx, activeOnly)Autenticado
GET/api/admin/representatives/:id/saldo-sociogetSaldoSocio(ctx, id)Autenticado
GET/api/admin/representatives/:id/movimientos-socio?anio=2026&mes=5getMovimientosSocio(ctx, id, anio, mes)Autenticado
GET/api/admin/representatives/:idgetById(ctx, id)Autenticado
POST/api/admin/representativescreate(ctx, data)Autenticado
PUT/api/admin/representatives/:idupdate(ctx, id, updates)Autenticado
DELETE/api/admin/representatives/:iddelete(ctx, id)Autenticado
POST/api/admin/representatives/:id/set-activesetAsActive(ctx, id)ADMIN / SUPER_ADMIN
POST/api/admin/representatives/:id/activar-cuentaactivarCuentaSocio(ctx, id)ADMIN / SUPER_ADMIN
POST/api/admin/representatives/:id/desactivar-cuentadesactivarCuentaSocio(ctx, id)ADMIN / SUPER_ADMIN

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(...).