Skip to content

Plataforma técnica · Orchestrator

Balance Service

Orchestrator Reportes Balance

BalanceService es el entry point del motor balance. Extiende ReportesServiceSupport que aporta las funciones de construcción (buildBalanceClasificado, buildEstadoResultados, buildRenta). Este service expone dos responsabilidades:

  1. Cálculo on-demand — leer las líneas agregadas y proyectar el reporte completo.
  2. Lifecycle del reporte guardado — persistir como BORRADOR, aprobar, reversar, eliminar.
MétodoTipoPropósito
calcular(ctx, anio, tipo, {mes})LecturaCómputo en memoria del ReporteAnual completo. No persiste.
guardar(ctx, anio, tipo, reporte)EscrituraInserta el reporte como BORRADOR en reportes.balance_guardado.
listar(ctx)LecturaLista resumida de todos los balances guardados (sin detalle).
getById(ctx, id)LecturaDetalle completo de un balance guardado.
aprobar(ctx, id)TransiciónBORRADOR → APROBADO.
reversarAprobacion(ctx, id)TransiciónAPROBADO → BORRADOR. Falla si el estado no era APROBADO.
eliminar(ctx, id)DestrucciónBorra. Falla si el estado no es BORRADOR.
flowchart TB
  IN["calcular(anio, tipo, {mes})"]
  VAL["Validate<br/>(anio 2000-2100, mes 1-12)"]
  POOL["getPool(ctx.tenantDb)"]
  PAR["Promise.all"]
  Q1["calcularLineasBalance(anio, mes)<br/>→ fn_balance_lineas(anio, mes)"]
  Q2["calcularRenta(anio, mes)<br/>→ vista de renta"]
  MAP["if tipo == IFRS:<br/>mapearNombresIfrs"]
  BC["buildBalanceClasificado(lineas)"]
  ER["buildEstadoResultados(lineas)"]
  RT["buildRenta(rentaRaw)"]
  OUT["ReporteAnual"]

  IN --> VAL --> POOL --> PAR
  PAR --> Q1 --> MAP --> BC
  MAP --> ER
  PAR --> Q2 --> RT
  BC & ER & RT --> OUT
async calcular(
ctx: ServiceContext,
anio: number,
tipo: "TRIBUTARIO" | "IFRS",
periodo?: { mes?: number },
): Promise<ServiceResult<ReporteAnual>>

Validaciones:

  • anio entero en [2000, 2100] o ValidationError("Año inválido")
  • mes entero en [1, 12]. Default 12 si no se pasa (balance anual completo).

Output (ReporteAnual):

interface ReporteAnual {
anio: number;
mes: number;
modo_periodo: "ANUAL_ACUMULADO"; // siempre acumulado desde enero
tipo_presentacion: "TRIBUTARIO" | "IFRS";
lineas_balance: BalanceLinea[]; // raw nivel 1-4 con debe/haber/activo/pasivo/...
balance_clasificado: BalanceClasificado; // activos cte/no-cte, pasivos, patrimonio, cuadra
estado_resultados: EstadoResultados; // margen bruto → resultado del ejercicio
renta: BalanceTributario; // base imponible + impuesto estimado
}
tipoQué cambia
TRIBUTARIOUsa nombres del plan_contable tal cual.
IFRSmapearNombresIfrs reemplaza nombre por nombre_ifrs (si existe) en cada línea. La clasificación, agregación y totales no cambian — solo la etiqueta.

La presentación IFRS no separa cuentas distintas: el plan contable es único y cada cuenta tiene un nombre_ifrs opcional. Para distinciones de fondo (criterios de reconocimiento), el modelo asume que las categorías ya generaron las líneas correctamente.

stateDiagram-v2
  [*] --> BORRADOR : guardar(reporte)
  BORRADOR --> APROBADO : aprobar(id)
  APROBADO --> BORRADOR : reversarAprobacion(id)
  BORRADOR --> [*] : eliminar(id)
  APROBADO --> APROBADO : (no se puede eliminar)
MétodoGuard
guardarSin guard. Cualquier reporte se persiste como BORRADOR.
aprobarSin guard explícito; se asume que el caller validó.
reversarAprobacionFalla con ValidationError("Solo se pueden reversar balances en estado APROBADO") si el repo retorna false.
eliminarFalla con ValidationError("Solo se pueden eliminar balances en estado BORRADOR") si el repo retorna false.

Los guards reales viven en SQL del CierrePeriodoRepository: las queries de update y delete tienen WHERE estado = 'APROBADO' o WHERE estado = 'BORRADOR'. Si la condición no se cumple, la query afecta 0 filas y el repo retorna false — el service lo traduce a error de validación.

guardar inserta en reportes.balance_guardado:

INSERT INTO reportes.balance_guardado
(anio, tipo_presentacion, estado, total_activos, total_pasivos,
total_patrimonio, base_imponible, ppm, resultado_ejercicio,
detalle, calculado_por)
VALUES ($1, $2, 'BORRADOR', $3, $4, $5, $6, $7, $8, $9::jsonb, $10)
RETURNING *
  • Columnas top-level (total_*, base_imponible, ppm, resultado_ejercicio) son indexables para listar resúmenes sin parsear el JSON.
  • detalle JSONB guarda el ReporteAnual completo, incluido lineas_balance[] con todas las cuentas nivel 1-4.

listar solo lee las columnas indexables + extrae mes y modo_periodo del JSON. getById deserializa detalle completo.

ErrorCausa
ValidationError("Año inválido")`anio < 2000
ValidationError("Mes inválido (1-12)")mes fuera de rango
ValidationError("Falta configuración contable de FACT-COMP-NET...")Propagado de BalanceRepository; el concepto FACT-COMP-NET no está parametrizado en operaciones_sii.config_conceptos_contables
ValidationError("Solo se pueden reversar balances en estado APROBADO")reversarAprobacion sobre un balance que no estaba APROBADO
ValidationError("Solo se pueden eliminar balances en estado BORRADOR")eliminar sobre un balance que no estaba en BORRADOR
NotFoundError (vía assertExists)getById con id inexistente

Por qué BalanceService no abre transacciones

Section titled “Por qué BalanceService no abre transacciones”

calcular es read-only puro. guardar es un único INSERT, aprobar/reversarAprobacion/eliminar son únicos UPDATE/DELETE — la atomicidad del statement basta. No hay encolado de eventos en este service.

El único lugar del dominio que usa withTransaction con outbox es CierrePeriodoService.cerrarPeriodo, porque allí sí hay efecto cross-dominio (ciclo:cierre).

buildBalanceClasificado calcula:

cuadra: Math.round(Math.abs(total_activos - total_pasivos_patrimonio)) === 0,
diferencia: total_activos - total_pasivos_patrimonio,

cuadra: false no es un error técnico, es una señal de auditoría. El motor entrega el reporte igual y deja que el caller (UI o caller de cierre) decida si bloquear el avance. Causas habituales: líneas contables incompletas, categorías mal parametrizadas, IVA F29 no contabilizado, depreciación pendiente, costo de ventas sin asentar.