Skip to content

Plataforma técnica · Orchestrator

Gastos Service

Orchestrator Gastos Contabilizacion

GastosService es el único service del dominio gastos. Extiende BaseService para withTransaction + outbox + validateRequired + assertExists. No tiene clase base intermedia.

export class GastosService extends BaseService {
constructor() { super("GastosService"); }
// 14 métodos públicos
}
ÁreaMétodosTX
CategoríasgetCategorias, createCategoria, updateCategoria, getConceptosImpuestoscreate/update
Gastos CRUDgetGastos, createGasto, deleteGastocreate sí, resto no
LifecyclecontabilizarGasto, reversarGasto, contabilizarPendientesGrupocontabilizar*
Importación masivaimportarImpuestosPorRango, importarIvaNoRecuperablePorRango
Consultas auxiliaresgetResumenAnual, getComprasDisponibles, getProveedoresGastoNo

Lee directamente sin transacción. El parámetro soloActivas solo aplica si la tabla gastos.categorias_gasto tiene la columna activo — el repo lo detecta con categoriasHasActivoColumn y degrada graciosamente en tenants viejos.

createCategoria / updateCategoria — guard de concepto

Section titled “createCategoria / updateCategoria — guard de concepto”

Ambos métodos llaman validateConceptoImpuestoMap(client, conceptoOperacionId) antes del repo. La regla:

private async validateConceptoImpuestoMap(pool, conceptoOperacionId?: number | null) {
if (conceptoOperacionId === undefined || conceptoOperacionId === null) return;
if (!Number.isInteger(Number(conceptoOperacionId)) || Number(conceptoOperacionId) < 1) {
throw new ValidationError("concepto_operacion_id debe ser un entero positivo");
}
const concepto = await GastosRepository.findConceptoImpuestoById(pool, Number(conceptoOperacionId));
if (!concepto) {
throw new ValidationError(
"concepto_operacion_id no corresponde a un concepto de compras habilitado para gastos",
);
}
}

El whitelist de conceptos válidos vive en SQL (findConceptoImpuestoById):

  • tipo_concepto = 'OTRO' (impuestos específicos, otros)
  • O id = 30 (IVA no recuperable)
  • O codigo = 'GASTO-IVA' (IVA tratado como gasto)
  • Activo y orientado a compras (tipo_operacion IN ('COMPRA', 'AMBOS'))

Esto evita que una categoría apunte a un concepto de venta o a un IVA normal — son errores de configuración que romperían el motor balance.

updateCategoria solo valida si el DTO incluye explícitamente la propiedad (hasOwnProperty), no si está undefined. Permite actualizar otros campos sin tocar la relación.

async createGasto(
ctx: ServiceContext,
data: CreateGastoDTO,
): Promise<ServiceResult<Gasto | CreateGastoMasivoResult>>

El método público bifurca en 3 paths según el DTO. Todo dentro de withTransaction (un solo COMMIT garantiza atomicidad del path masivo).

flowchart TB
  IN["createGasto(data)"]
  REQ["validateRequired<br/>(categoria_id, rut_proveedor)"]
  TX["withTransaction"]
  CAT["findCategoriaById<br/>+ assertExists"]
  TRIM["trim rut_proveedor"]
  P1{"vincular_sii<br/>&& !compra_detalle_id?"}
  MAS["createGastoMasivoDesdeSii<br/>(loop por cada compra)"]
  P2{"compra_detalle_id?"}
  DUP["¿compra ya asociada?<br/>→ ValidationError"]
  HYD1["findCompraDisponibleByDetalleId<br/>+ hydratePayloadFromCompra"]
  HYD2["hydratePeriodo<br/>(deriva año/mes desde fecha_documento)"]
  VAL["validateMontos<br/>(neto > 0, iva >= 0)"]
  CR["GastosRepository.createGasto"]
  OUT1["Gasto"]
  OUT2["CreateGastoMasivoResult"]

  IN --> REQ --> TX --> CAT --> TRIM --> P1
  P1 -- sí --> MAS --> OUT2
  P1 -- no --> P2
  P2 -- sí --> DUP --> HYD1 --> HYD2
  P2 -- no --> HYD2
  HYD2 --> VAL --> CR --> OUT1

Path 1: masivo desde SII (vincular_sii: true sin compra_detalle_id)

Section titled “Path 1: masivo desde SII (vincular_sii: true sin compra_detalle_id)”
private async createGastoMasivoDesdeSii(
client: DbExec,
base: CreateGastoDTO,
userId: string,
): Promise<CreateGastoMasivoResult>
  • Busca compras pendientes del proveedor en (base.año, base.mes) con mesExacto: true.
  • Si 0 compras → ValidationError("No hay compras SII pendientes para ese proveedor en el período seleccionado").
  • Loop secuencial dentro de la misma TX: crea un gasto por compra. Si una falla, rollback completo.
  • Retorna { vinculacion: "SII_MASIVO", creados, gastos }.

Path 2: vinculado a una compra específica (compra_detalle_id presente)

Section titled “Path 2: vinculado a una compra específica (compra_detalle_id presente)”
  • Verifica con SELECT que la compra no esté ya asociada a otro gasto. Si lo está → ValidationError("La compra SII ya está asociada a un gasto"). Es protección contra doble-contabilización.
  • findCompraDisponibleByDetalleId reagrega NETO/IVA/OTRO por documento_id (una compra SII puede tener varias líneas de detalle; el gasto se crea por documento, no por línea).
  • hydratePayloadFromCompra sobreescribe rut_proveedor, tipo_dte, folio, fecha_documento, monto_neto, monto_iva, glosa con los datos canónicos del SII.
  • Solo aplica las validaciones genéricas: validateRequired para rut_proveedor, fecha_documento, monto_neto, después hydratePeriodo y validateMontos.

hydratePeriodo — derivar año/mes desde fecha

Section titled “hydratePeriodo — derivar año/mes desde fecha”
private hydratePeriodo(payload: CreateGastoDTO) {
// Si el caller pasó año/mes válidos → respeta
if (Number.isInteger(año) && año ∈ [2000,2100] && mes ∈ [1,12]) {
payload.año = año; payload.mes = mes; return;
}
// Sino, parsea fecha_documento YYYY-MM-DD
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(fechaDoc);
if (!match) throw new ValidationError("fecha_documento debe tener formato YYYY-MM-DD");
payload.año = Number(match[1]);
payload.mes = Number(match[2]);
}

La regla: año/mes ganan si están presentes, sino se derivan de la fecha. Esto permite re-asignar el período contable (gasto del 30/marzo registrado en abril) sin sobrescribir la fecha documental.

normalizeFechaDocumento — acepta Date, ISO, parseable

Section titled “normalizeFechaDocumento — acepta Date, ISO, parseable”
private normalizeFechaDocumento(value: unknown): string {
if (value instanceof Date) return value.toISOString().slice(0, 10);
const isoPrefix = /^(\d{4}-\d{2}-\d{2})/.exec(raw);
if (isoPrefix) return isoPrefix[1];
const parsed = new Date(raw);
if (!Number.isNaN(parsed.getTime())) return parsed.toISOString().slice(0, 10);
throw new ValidationError("fecha_documento debe tener formato YYYY-MM-DD");
}

Tres formatos aceptados; el resto falla con error explícito. Defensivo frente a inputs HTTP/CSV.

contabilizarGasto — único método con outbox

Section titled “contabilizarGasto — único método con outbox”
async contabilizarGasto(
ctx: ServiceContext,
id: number,
periodo?: { año?: number; mes?: number },
): Promise<ServiceResult<Gasto>>
flowchart TB
  IN["contabilizarGasto(id, periodo?)"]
  TX["withTransaction(ctx, async (client, outbox) => ...)"]
  FN["GastosRepository.contabilizarGasto<br/>→ SELECT * FROM gastos.fn_contabilizar_gasto(id, userId, año, mes)"]
  CHK["if !gasto → ValidationError<br/>'Gasto no encontrado o ya está contabilizado'"]
  EVT["findCompraContabilizadaEventPayload(client, gasto.id)"]
  COND{"payload existe?"}
  QUE["outbox.queue('compra:contabilizada', payload)"]
  FETCH["findGastoById<br/>(re-fetch con JOINs para respuesta completa)"]
  COMMIT["COMMIT → flushOutbox"]

  IN --> TX --> FN --> CHK --> EVT --> COND
  COND -- sí --> QUE --> FETCH --> COMMIT
  COND -- no --> FETCH

Convenciones:

  • El period override (año?, mes?) se pasa a fn_contabilizar_gasto solo si es válido (2000-2100 y 1-12). Sino se pasa null y la función SQL usa el período original del gasto.
  • El evento compra:contabilizada solo se emite si el gasto tiene compra_detalle_id vinculado. Gastos manuales (sin SII) no notifican porque no hay compra en el outbox cross-dominio que cerrar.
  • El re-fetch (findGastoById) trae JOINs con categorias_gasto (nombre, cuenta_gasto_codigo) y proveedores_clasificacion (razon_social) para la respuesta HTTP.
async reversarGasto(ctx, id): Promise<ServiceResult<Gasto>>
  • Sin transacción (solo UPDATE simple con guard).
  • Repo: UPDATE ... SET estado = 'PENDIENTE', fecha_contabilizacion = NULL WHERE id = $1 AND estado = 'CONTABILIZADO' RETURNING *.
  • Si rowCount == 0 → ValidationError("Gasto no encontrado o no está contabilizado").
  • No revierte el detalle contable (gastos.gastos_detalle). El asiento permanece y queda como huérfano hasta que la próxima contabilización lo sobreescriba — comportamiento heredado del SP que conviene reemplazar si se necesita reversa contable real.
  • No emite evento de reversa (no hay compra:descontabilizada definido en DomainEventMap).
async contabilizarPendientesGrupo(
ctx: ServiceContext,
filtros: { año: number; mesHasta: number; categoria_id?: number },
): Promise<ServiceResult<{ procesados: number }>>
  • Valida año y mesHasta ∈ [1, 12].
  • Repo lista ids candidatos (WHERE año = $1 AND mes <= $2 AND estado = 'PENDIENTE').
  • Loop secuencial: por cada id llama contabilizarGasto (la función SQL) y si OK, encola payload de compra:contabilizada.
  • Single TX para los N gastos. Si uno falla, rollback de todos.
  • Outbox flush emite N eventos compra:contabilizada post-COMMIT.

Dos métodos simétricos que validan input y delegan a funciones SQL.

async importarImpuestosPorRango(
ctx: ServiceContext,
filtros: ImportarImpuestosRangoDTO,
): Promise<ServiceResult<ImportarImpuestosRangoResult>>
interface ImportarImpuestosRangoDTO {
fecha_desde: string;
fecha_hasta: string;
codigo_desde?: number; // default 20
codigo_hasta?: number; // default 27
}

Validaciones:

  • fecha_desde y fecha_hasta normalizadas a YYYY-MM-DD con normalizeFechaDocumento.
  • fecha_desde <= fecha_hasta.
  • Ambos códigos enteros ≥ 0 y desde <= hasta.

Delega a gastos.fn_importar_impuestos(fecha_desde, fecha_hasta, codigo_desde, codigo_hasta, userId).

Output:

interface ImportarImpuestosRangoResult {
fecha_desde: string;
fecha_hasta: string;
codigo_desde: number;
codigo_hasta: number;
encontrados: number; // compras candidatas en el rango
creados: number; // gastos efectivamente creados
duplicados: number; // ya tenían gasto vinculado
sin_categoria: number; // conceptos sin categoria_gasto mapeada
categorias_sin_mapear: string[]; // nombres de los conceptos sin mapeo
}

categorias_sin_mapear es accionable: cada nombre representa un concepto SII que requiere parametrizar una CategoriaGasto correspondiente. Mientras estén ahí, esas compras quedan pendientes.

MétodoOutput
getResumenAnual(ctx, año?)Agregado por (año, categoría, cuenta_gasto_codigo) con total_neto, total_iva, total, documentos, estadoPENDIENTE/CONTABILIZADO/MIXTO
getComprasDisponibles(ctx, {año?, mes?, rutProveedor?})Compras SII pendientes elegibles para gasto. Requiere proveedores_clasificacion.genera_gasto = true.
getProveedoresGasto(ctx, {año?, mes?})Proveedores con genera_gasto = true + compras_pendientes agregado.

getComprasDisponibles y getProveedoresGasto filtran por proveedores_clasificacion.genera_gasto = true — la clasificación de proveedores tiene flags por destino (genera_existencias para inventario, genera_gasto para este dominio) y aquí se respeta la decisión del catálogo.

ErrorCausa
ValidationError("categoria_id requerido") y similaresvalidateRequired en createGasto/createCategoria
ValidationError("concepto_operacion_id no corresponde a un concepto de compras habilitado para gastos")El concepto pasado no pasa el whitelist (NETO/IVA no admitidos)
ValidationError("La compra SII ya está asociada a un gasto")Doble-vinculación a la misma compra
ValidationError("La compra seleccionada no existe o ya está contabilizada")findCompraDisponibleByDetalleId retornó null
ValidationError("No hay compras SII pendientes para ese proveedor en el período seleccionado")Path masivo sin compras candidatas
ValidationError("fecha_documento debe tener formato YYYY-MM-DD")normalizeFechaDocumento falló
ValidationError("monto_neto debe ser mayor a 0") / "monto_iva no puede ser negativo"validateMontos
ValidationError("año es obligatorio") / "mesHasta debe estar entre 1 y 12"contabilizarPendientesGrupo
ValidationError("fecha_desde no puede ser mayor a fecha_hasta")Rango invertido en importadores
ValidationError("Gasto no encontrado o ya está contabilizado")contabilizarGasto con id inexistente o ya CONTABILIZADO
ValidationError("Gasto no encontrado o no está contabilizado")reversarGasto con id inexistente o no CONTABILIZADO
NotFoundError (vía assertExists)CategoriaGasto o Gasto no existen tras un upsert

Promise<ServiceResult<Gasto | CreateGastoMasivoResult>> deja al caller distinguir por el discriminante vinculacion: "SII_MASIVO":

const res = await svc.createGasto(ctx, dto);
if ("vinculacion" in res.data) {
// Path masivo: res.data.creados, res.data.gastos[]
} else {
// Path single: res.data es el Gasto
}

Alternativa rechazada: dos métodos públicos separados (createGasto y createGastosMasivo). El union type ganó porque el caller HTTP recibe el mismo body shape (CreateGastoDTO) y solo el flag vincular_sii cambia el comportamiento — separar métodos obligaría a la UI a pre-decidir antes de armar el payload.

Por qué la contabilización no es idempotente

Section titled “Por qué la contabilización no es idempotente”

contabilizarGasto falla si el gasto ya está CONTABILIZADO. La razón: contabilizar es un side-effect irreversible (genera líneas en gastos.gastos_detalle que alimentan el balance). Una segunda llamada que no falle silenciosamente debe garantizar que no duplica líneas — hoy fn_contabilizar_gasto no lo garantiza, así que el guard al nivel TS evita el problema.

Si se necesita idempotencia real (e.g., retry de un job), el patrón es:

  1. Consultar estado del gasto.
  2. Si ya CONTABILIZADO → tratar como éxito sin llamar al método.
  3. Si PENDIENTE → llamar contabilizarGasto.

contabilizarPendientesGrupo ya filtra por PENDIENTE antes del loop, así que es seguro reintentarlo.