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}Métodos por área
Section titled “Métodos por área”| Área | Métodos | TX |
|---|---|---|
| Categorías | getCategorias, createCategoria, updateCategoria, getConceptosImpuestos | create/update sí |
| Gastos CRUD | getGastos, createGasto, deleteGasto | create sí, resto no |
| Lifecycle | contabilizarGasto, reversarGasto, contabilizarPendientesGrupo | contabilizar* sí |
| Importación masiva | importarImpuestosPorRango, importarIvaNoRecuperablePorRango | Sí |
| Consultas auxiliares | getResumenAnual, getComprasDisponibles, getProveedoresGasto | No |
Categorías
Section titled “Categorías”getCategorias(ctx, soloActivas = true)
Section titled “getCategorias(ctx, soloActivas = true)”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.
Gastos: el orquestador createGasto
Section titled “Gastos: el orquestador createGasto”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)conmesExacto: 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. findCompraDisponibleByDetalleIdreagrega NETO/IVA/OTRO pordocumento_id(una compra SII puede tener varias líneas de detalle; el gasto se crea por documento, no por línea).hydratePayloadFromComprasobreescriberut_proveedor,tipo_dte,folio,fecha_documento,monto_neto,monto_iva,glosacon los datos canónicos del SII.
Path 3: manual (sin SII)
Section titled “Path 3: manual (sin SII)”- Solo aplica las validaciones genéricas:
validateRequiredpararut_proveedor,fecha_documento,monto_neto, despuéshydratePeriodoyvalidateMontos.
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.
Lifecycle
Section titled “Lifecycle”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 afn_contabilizar_gastosolo si es válido (2000-2100y1-12). Sino se pasanully la función SQL usa el período original del gasto. - El evento
compra:contabilizadasolo se emite si el gasto tienecompra_detalle_idvinculado. Gastos manuales (sin SII) no notifican porque no hay compra en el outbox cross-dominio que cerrar. - El re-fetch (
findGastoById) trae JOINs concategorias_gasto(nombre, cuenta_gasto_codigo) yproveedores_clasificacion(razon_social) para la respuesta HTTP.
reversarGasto
Section titled “reversarGasto”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:descontabilizadadefinido enDomainEventMap).
contabilizarPendientesGrupo — bulk
Section titled “contabilizarPendientesGrupo — bulk”async contabilizarPendientesGrupo( ctx: ServiceContext, filtros: { año: number; mesHasta: number; categoria_id?: number },): Promise<ServiceResult<{ procesados: number }>>- Valida
añoymesHasta ∈ [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 decompra:contabilizada. - Single TX para los N gastos. Si uno falla, rollback de todos.
- Outbox flush emite N eventos
compra:contabilizadapost-COMMIT.
Importación masiva por rango
Section titled “Importación masiva por rango”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_desdeyfecha_hastanormalizadas aYYYY-MM-DDconnormalizeFechaDocumento.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.
async importarIvaNoRecuperablePorRango( ctx: ServiceContext, filtros: ImportarIvaNoRecuperableDTO,): Promise<ServiceResult<ImportarIvaNoRecuperableResult>>
interface ImportarIvaNoRecuperableDTO { fecha_desde: string; fecha_hasta: string; concepto_id?: number; // default 30 (IVA no recuperable)}Validaciones: mismas que el anterior + concepto_id entero positivo.
Delega a gastos.fn_importar_iva_no_recuperable(fecha_desde, fecha_hasta, concepto_id, userId).
Output con la misma forma: encontrados/creados/duplicados/sin_categoria/categorias_sin_mapear.
Consultas auxiliares
Section titled “Consultas auxiliares”| Método | Output |
|---|---|
getResumenAnual(ctx, año?) | Agregado por (año, categoría, cuenta_gasto_codigo) con total_neto, total_iva, total, documentos, estado ∈ PENDIENTE/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.
Errores
Section titled “Errores”| Error | Causa |
|---|---|
ValidationError("categoria_id requerido") y similares | validateRequired 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 |
Por qué createGasto retorna union type
Section titled “Por qué createGasto retorna union type”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:
- Consultar estado del gasto.
- Si ya CONTABILIZADO → tratar como éxito sin llamar al método.
- Si PENDIENTE → llamar
contabilizarGasto.
contabilizarPendientesGrupo ya filtra por PENDIENTE antes del loop, así que es seguro reintentarlo.