Plataforma técnica · Orchestrator
F29 Generator Service
Orchestrator Declaraciones F29
F29GeneratorService es el engine de cálculo del Formulario 29. Reemplaza al stored procedure declaraciones.sp_generar_f29 con lógica TypeScript testeable. Su función única es: dado un período y RUT, leer las fuentes operacionales, calcular cada línea del F29, persistir header + detalle y emitir f29:generada.
No expone consultas, listados ni transiciones de estado — esas viven en DeclaracionesService.
Dos entry points
Section titled “Dos entry points”| Método | Abre TX | Emite hook | Cuándo usarlo |
|---|---|---|---|
generate(ctx, params) | ✅ | ✅ f29:generada | Generación standalone (handler HTTP, job programado). |
generateInTransaction(ctx, params, client) | ❌ | ❌ | Cuando el caller ya tiene TX abierta y quiere componer. |
Ambos delegan internamente en generateWithClient (privado).
DeclaracionesService.registrarAjusteBajaRemanente usa generateInTransaction porque regenera el F29 dentro de su propia TX de aplicación de ajuste — no necesita un segundo evento f29:generada (el caller decide si emite algo distinto).
Flujo de cálculo
Section titled “Flujo de cálculo”flowchart TB IN["generate(periodo, rut)"] VAL["validateRequired<br/>(periodo, rut_contribuyente)"] TX["withTransaction"] CALC["calculateValues<br/>(7 fuentes en paralelo + lógica)"] DEL["DELETE F29 BORRADOR previo<br/>del mismo período+rut"] INS["INSERT header<br/>(con remanente efectivo post-ajuste)"] LINES["insertDetailLines<br/>(líneas 15, 16, 31, 40, 41, 89, 89B,<br/>114, 115, 142, 151, 152, 154, 502...)"] EMIT["outbox.queue<br/>(f29:generada)"] COMMIT["COMMIT → flushOutbox"] IN --> VAL --> TX --> CALC --> DEL --> INS --> LINES --> EMIT --> COMMIT
calculateValues — composición de fuentes
Section titled “calculateValues — composición de fuentes”Lee 7 grupos de datos y los combina en un objeto F29Calculations:
| Línea | Concepto | Origen |
|---|---|---|
| 15 | Ventas netas afectas | cvd con conceptos VENTA-PRD-NET, VENTA-BOL-NET, NC-VENTA-NET, FACT-COMP-NET. |
| 16 | Ventas exentas | cvd con concepto VENTA-EXE-NET. |
| 31 | Débito fiscal del período | IVA ventas (VENTA-PRD-IVA, NC-VENTA-IVA) + IVA boletas (VENTA-BOL-IVA). |
Todas las queries excluyen cvd.estado = 'ANULADO'.
| Línea | Concepto | Origen |
|---|---|---|
| 40 | Crédito fiscal compras | cvd con COMP-MER-IVA y NC-COMPRA-IVA, excluyendo compras con tipo_compra = 'Activo Fijo'. |
| 41 | Crédito fiscal activo fijo | SUM(iva_activo_fijo) directo desde operaciones_sii.compras con tipo_compra = 'Activo Fijo'. |
| 34 | IVA retenido (facturas de compra) | cvd con FACT-COMP-IVA-RET. |
SELECT COALESCE(SUM(monto_neto), 0) AS total, COALESCE(SUM(monto_retencion), 0) AS retencionesFROM remuneraciones.honorariosWHERE año = $1 AND mes = $2 AND estado IN ('RECIBIDA', 'APROBADA', 'CONTABILIZADO', 'PAGADA');| Línea | Significado |
|---|---|
| 152 | Total honorarios pagados a terceros |
| 154 | Retenciones efectuadas (lo que la empresa paga al SII) |
Notar que honorarios BORRADOR se excluyen — el período debe estar al menos RECIBIDA.
PPM = round(basePpm × tasaPpm) donde basePpm = ventasNetas + ventasExentas.
Resolución de tasaPpm:
flowchart TB
IN["resolverTasaPpm()"]
EMP["CompanyRepository.getCurrent(client)"]
EST{"empresa.usar_ppm_estrategico<br/>y tasa_ppm_estrategica != null?"}
T1["tasaPpm = tasa_ppm_estrategica<br/>(manual override)"]
REG{"empresa.regimen_id?"}
ERR1["ValidationError 'sin régimen tributario'"]
ESC["CommonDataService.getPpmScale<br/>(año, regimenId, ingresosUF)"]
HIT{"escala encontrada?"}
ERR2["ValidationError 'sin escala PPM'"]
T2["tasaPpm = escala.tasa_ppm"]
WARN["if escala.año_vigencia != año:<br/>console.warn fallback año previo"]
IN --> EMP --> EST
EST -- sí --> T1
EST -- no --> REG
REG -- no --> ERR1
REG -- sí --> ESC --> HIT
HIT -- no --> ERR2
HIT -- sí --> T2 --> WARN La escala usa el ingresos_brutos_año_anterior_uf de la empresa para resolver el tramo correcto (régimen 14D N°3, por ejemplo, tiene escalas diferenciadas por tamaño). El fallback warning aparece cuando la escala vigente es de un año previo (e.g., pidieron 2026 pero solo hay datos de 2025) — útil para detectar parámetros desactualizados sin romper la generación.
SELECT COALESCE(SUM(ld.monto), 0) AS totalFROM remuneraciones.liquidaciones liqJOIN remuneraciones.liquidaciones_detalle ld ON ld.liquidacion_id = liq.idJOIN remuneraciones.conceptos_remuneracion c ON c.id = ld.concepto_idWHERE liq.año = $1 AND liq.mes = $2 AND liq.estado IN ('CALCULADA', 'APROBADA', 'PAGADA') AND c.codigo = 'DESC-004';Concepto DESC-004 es el descuento de Impuesto Único Segunda Categoría (IUSC) calculado por el PayrollEngine.
Lee el remanente_siguiente del F29 del mes inmediatamente anterior (último no anulado):
SELECT COALESCE(remanente_siguiente, 0) AS totalFROM declaraciones.declaraciones_f29WHERE año = $1 AND mes = $2 AND estado != 'ANULADO'ORDER BY created_at DESC LIMIT 1;Luego lo actualiza por UTM según Art. 27 DL 825:
// trunc(remanente / UTM_mes_actual) × UTM_mes_siguienteconst enUtm = parseFloat((remanenteAnterior / utmActual).toFixed(2));const remanenteAnteriorActualizado = Math.round(enUtm * utmSiguiente);
// Línea 573: la diferencia se reporta como "actualización"const actualizacionRemanenteAnterior = remanenteAnteriorActualizado - remanenteAnterior;Si falla la lectura de UTM (cualquiera de los dos meses sin valor): se loguea warning y se usa el remanente nominal sin actualizar — la generación no falla, pero el F29 quedará mal cuadrado.
Ajuste de baja de remanente
Section titled “Ajuste de baja de remanente”Si el SII notificó una reducción del remanente (registrada por DeclaracionesService.registrarAjusteBajaRemanente), se lee del table ajustes_remanente_f29 y se resta:
remanenteAnteriorEfectivo = max(0, remanenteAnteriorActualizado - ajusteBajaRemanente);El header guarda remanenteAnteriorEfectivo; el detalle:
- Línea 89 preserva el bruto actualizado (
remanenteAnteriorActualizado). - Línea 89B informa la baja SII aplicada (
ajusteBajaRemanente).
Determinación final
Section titled “Determinación final”Una vez calculadas las fuentes, las operaciones son aritméticas directas:
| Cálculo | Fórmula |
|---|---|
ivaDeterminado | totalDebito - totalCredito - remanenteAnteriorEfectivo |
remanenteSiguiente | Si ivaDeterminado < 0: abs(ivaDeterminado). Si ≥ 0: 0. |
totalAPagar | max(0, ivaDeterminado) + retencionesHonorarios + impuestoUnico + ppm |
Insert de detalle — insertDetailLines
Section titled “Insert de detalle — insertDetailLines”Una vez calculado todo, se insertan las líneas del formulario en declaraciones_f29_detalle. La función helper insertDetail:
if (monto === 0 && !['15', '31', '40'].includes(codigoLinea)) return;Las líneas de ventas netas (15), débito fiscal (31) y crédito fiscal compras (40) se insertan siempre, incluso si son cero — son las que el SII espera ver explícitas. El resto se omite si su monto es cero.
Líneas que se generan (no exhaustivo):
| Código | Descripción |
|---|---|
| 15 | Total Ventas Netas |
| 16 | Total Ventas Exentas |
| 31 | Débito Fiscal del período |
| 34 | IVA retenido (facturas de compra) |
| 40 | Crédito Fiscal compras |
| 41 | Crédito Fiscal activo fijo |
| 89 | Remanente anterior actualizado |
| 89B | Ajuste baja remanente SII |
| 114 | Impuesto único trabajadores |
| 115 | (idem agregado) |
| 142 | PPM ventas y servicios |
| 151 | Total honorarios pagados |
| 152 | (agregado) |
| 154 | Retenciones honorarios |
| 502 | IVA determinado / Remanente siguiente |
| 573 | Actualización del remanente anterior por UTM |
(La lista exacta vive en insertDetailLines; esta tabla es referencial.)
Hook emitido
Section titled “Hook emitido”| Evento | Cuándo | Payload |
|---|---|---|
f29:generada | generate() post-COMMIT | { declaracionId, periodo: { anio, mes }, rutContribuyente } |
No se emite desde generateInTransaction(client) — el caller decide. Detalle en index › Hooks emitidos.
Errores estructurados
Section titled “Errores estructurados”| Error | Causa |
|---|---|
ValidationError("Formato de periodo inválido. Use YYYY-MM.") | El DeclaracionesService.generateF29 lo lanza antes de llamar. |
ValidationError("Configuración de empresa no encontrada para el tenant actual.") | CompanyRepository.getCurrent retornó null. |
ValidationError("La empresa <rut> no tiene régimen tributario configurado...") | empresa.regimen_id == null y no hay PPM estratégico. |
ValidationError("No se pudo determinar tasa PPM (Régimen ID: X, Año: Y, ...)") | getPpmScale retornó null (no hay escala para esa combinación). |
PG 23505 (unique violation) | Ya existe F29 NO-borrador para el período. Lo traduce DeclaracionesService. |
Por qué dejaron el SP
Section titled “Por qué dejaron el SP”sp_generar_f29.sql quedó en la base como referencia histórica pero no se invoca. La razón del rewrite a TS:
- Testabilidad: este servicio se cubre con unit tests usando mocks de
CommonDataServicey queries fixture. El SP requería un Postgres con todas las tablas seedeadas. - Composición:
generateInTransaction(client)permite componer la generación dentro de la TX del ajuste de baja — imposible con un SP que abre su propia TX. - Errores explícitos:
ValidationErrorcon mensajes accionables en vez deRAISE EXCEPTIONcon códigos numéricos. - Lógica nueva (corrección UTM con cap, ajuste baja remanente, PPM estratégico vs régimen) es más legible en TS.