import type { Pool } from 'pg'; import { resolveContribuyenteContext } from '../utils/contribuyente-context.js'; /** * Activos fijos: CFDIs tipo I con uso_cfdi I01-I08 recibidos por el * contribuyente bajo régimen fiscal aplicable. Vista INFORMATIVA — no * modifica gastos ni ISR (el sistema sigue tratándolos como gasto del * periodo, igual que el SAT). Esta vista permite que el contador haga * su seguimiento de deducción mensual proporcional y decida si la * aplica o no en su declaración. * * % anual de deducción según LISR Art. 34. Mensual = anual / 12. */ export const PORCENTAJES_ANUALES: Record = { I01: { concepto: 'Construcciones', pct: 0.05 }, I02: { concepto: 'Mobiliario y equipo de oficina', pct: 0.10 }, I03: { concepto: 'Equipo de transporte', pct: 0.25 }, I04: { concepto: 'Equipo de cómputo y accesorios', pct: 0.30 }, I05: { concepto: 'Dados, troqueles, moldes, matrices', pct: 0.35 }, I06: { concepto: 'Comunicaciones telefónicas', pct: 0.10 }, I07: { concepto: 'Comunicaciones satelitales', pct: 0.08 }, I08: { concepto: 'Otra maquinaria y equipo', pct: 0.10 }, }; const USOS_CFDI = Object.keys(PORCENTAJES_ANUALES); const REGIMENES_APLICABLES = ['601', '606', '611', '612', '625', '626']; export type EstadoActivo = 'activo' | 'agotado' | 'baja_venta' | 'baja_desecho' | 'baja_otro'; export interface ActivoFijoItem { cfdiId: number; uuid: string; fechaEmision: string; rfcEmisor: string; nombreEmisor: string; usoCfdi: string; concepto: string; porcentajeAnual: number; porcentajeMensual: number; total: number; iva: number; moi: number; acumuladoHastaMesAnterior: number; acreditableEsteMes: number; saldoPendiente: number; estado: EstadoActivo; baja: { fechaBaja: string; motivo: string; comentario: string | null } | null; } export interface ActivosFijosTotales { cantidad: number; totalMoi: number; totalAcumuladoPrevio: number; totalEsteMes: number; totalSaldoPendiente: number; cantidadActivos: number; cantidadAgotados: number; cantidadDeBaja: number; } function clamp(v: number, lo: number, hi: number): number { return Math.max(lo, Math.min(hi, v)); } function diffMeses(start: Date, end: Date): number { return (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth()) + 1; } export async function listActivosFijos( pool: Pool, tenantId: string, año: number, mes: number, contribuyenteId?: string | null, filtroEstado?: 'todos' | 'activos' | 'baja' | 'agotados', ): Promise<{ items: ActivoFijoItem[]; totales: ActivosFijosTotales; usosExcluidos: string[] }> { const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); const esReceptor = ctx.esReceptor; const esPM = ctx.rfcLength === 12; // Lee usos excluidos del contribuyente (lista de claves a saltarse, ej. // I06/I07 cuando son gastos regulares y no activos fijos reales). let usosExcluidos: string[] = []; if (contribuyenteId) { const { rows } = await pool.query<{ activos_fijos_usos_excluidos: string[] | null }>( `SELECT activos_fijos_usos_excluidos FROM contribuyentes WHERE entidad_id = $1`, [contribuyenteId.replace(/[^a-f0-9-]/gi, '')], ); usosExcluidos = (rows[0]?.activos_fijos_usos_excluidos ?? []).filter(u => USOS_CFDI.includes(u)); } const usosAplicables = USOS_CFDI.filter(u => !usosExcluidos.includes(u)); // Filtro de régimen: 626 solo aplica si el contribuyente es PM. const regsAplicables = esPM ? REGIMENES_APLICABLES : REGIMENES_APLICABLES.filter(r => r !== '626'); const usosArray = `ARRAY[${usosAplicables.map(u => `'${u}'`).join(',')}]`; const regsArray = `ARRAY[${regsAplicables.map(r => `'${r}'`).join(',')}]`; const { rows } = await pool.query( `SELECT c.id AS cfdi_id, c.uuid, c.fecha_emision, c.rfc_emisor, c.nombre_emisor, c.uso_cfdi, c.total_mxn, c.iva_traslado_mxn, c.ieps_traslado_mxn, c.impuestos_locales_trasladado_mxn, c.regimen_fiscal_receptor, b.fecha_baja, b.motivo, b.comentario FROM cfdis c LEFT JOIN activos_fijos_baja b ON b.cfdi_id = c.id WHERE ${esReceptor} AND c.tipo_comprobante = 'I' AND c.uso_cfdi = ANY(${usosArray}) AND c.regimen_fiscal_receptor = ANY(${regsArray}) AND c.status NOT IN ('Cancelado','0') ORDER BY c.fecha_emision DESC`, ); const items: ActivoFijoItem[] = []; const periodoFin = new Date(año, mes - 1, 1); // primer día del mes filtrado for (const r of rows) { const fechaEmision = new Date(r.fecha_emision); const moi = Number(r.total_mxn ?? 0) - Number(r.iva_traslado_mxn ?? 0) - Number(r.ieps_traslado_mxn ?? 0) - Number(r.impuestos_locales_trasladado_mxn ?? 0); const meta = PORCENTAJES_ANUALES[r.uso_cfdi]; if (!meta) continue; const pctAnual = meta.pct; const pctMensual = pctAnual / 12; // Fecha de baja (si existe) limita los meses aplicables const fechaBaja = r.fecha_baja ? new Date(r.fecha_baja) : null; // Mes ancla del periodo filtrado const mesEjAnchor = new Date(año, mes - 1, 1); const mesAdqAnchor = new Date(fechaEmision.getFullYear(), fechaEmision.getMonth(), 1); // Meses transcurridos hasta el mes filtrado (incluido) let mesesHasta = diffMeses(mesAdqAnchor, mesEjAnchor); let mesesHastaPrev = mesesHasta - 1; // Recortar si hay baja: máximo el mes de la baja inclusive if (fechaBaja) { const mesBaja = new Date(fechaBaja.getFullYear(), fechaBaja.getMonth(), 1); const mesesHastaBaja = diffMeses(mesAdqAnchor, mesBaja); mesesHasta = Math.min(mesesHasta, mesesHastaBaja); mesesHastaPrev = Math.min(mesesHastaPrev, mesesHastaBaja); } mesesHasta = Math.max(0, mesesHasta); mesesHastaPrev = Math.max(0, mesesHastaPrev); const acumHasta = clamp(moi * pctMensual * mesesHasta, 0, moi); const acumPrev = clamp(moi * pctMensual * mesesHastaPrev, 0, moi); const acreditable = Math.max(0, acumHasta - acumPrev); const saldo = Math.max(0, moi - acumHasta); let estado: EstadoActivo = 'activo'; if (fechaBaja) { estado = `baja_${r.motivo}` as EstadoActivo; } else if (saldo === 0) { estado = 'agotado'; } if (filtroEstado === 'activos' && estado !== 'activo') continue; if (filtroEstado === 'agotados' && estado !== 'agotado') continue; if (filtroEstado === 'baja' && !estado.startsWith('baja_')) continue; items.push({ cfdiId: r.cfdi_id, uuid: r.uuid, fechaEmision: fechaEmision.toISOString().slice(0, 10), rfcEmisor: r.rfc_emisor, nombreEmisor: r.nombre_emisor ?? '', usoCfdi: r.uso_cfdi, concepto: meta.concepto, porcentajeAnual: pctAnual, porcentajeMensual: pctMensual, total: Number(r.total_mxn ?? 0), iva: Number(r.iva_traslado_mxn ?? 0), moi, acumuladoHastaMesAnterior: Math.round(acumPrev * 100) / 100, acreditableEsteMes: Math.round(acreditable * 100) / 100, saldoPendiente: Math.round(saldo * 100) / 100, estado, baja: fechaBaja ? { fechaBaja: fechaBaja.toISOString().slice(0, 10), motivo: r.motivo, comentario: r.comentario } : null, }); } const totales: ActivosFijosTotales = { cantidad: items.length, totalMoi: 0, totalAcumuladoPrevio: 0, totalEsteMes: 0, totalSaldoPendiente: 0, cantidadActivos: 0, cantidadAgotados: 0, cantidadDeBaja: 0, }; for (const i of items) { totales.totalMoi += i.moi; totales.totalAcumuladoPrevio += i.acumuladoHastaMesAnterior; totales.totalEsteMes += i.acreditableEsteMes; totales.totalSaldoPendiente += i.saldoPendiente; if (i.estado === 'activo') totales.cantidadActivos++; else if (i.estado === 'agotado') totales.cantidadAgotados++; else totales.cantidadDeBaja++; } totales.totalMoi = Math.round(totales.totalMoi * 100) / 100; totales.totalAcumuladoPrevio = Math.round(totales.totalAcumuladoPrevio * 100) / 100; totales.totalEsteMes = Math.round(totales.totalEsteMes * 100) / 100; totales.totalSaldoPendiente = Math.round(totales.totalSaldoPendiente * 100) / 100; return { items, totales, usosExcluidos }; } /** Lee los usos CFDI excluidos para un contribuyente. */ export async function getUsosExcluidos(pool: Pool, contribuyenteId: string): Promise { const { rows } = await pool.query<{ activos_fijos_usos_excluidos: string[] | null }>( `SELECT activos_fijos_usos_excluidos FROM contribuyentes WHERE entidad_id = $1`, [contribuyenteId.replace(/[^a-f0-9-]/gi, '')], ); return (rows[0]?.activos_fijos_usos_excluidos ?? []).filter(u => USOS_CFDI.includes(u)); } /** Guarda la lista de usos excluidos (filtra a I01-I08 y deduplica). */ export async function setUsosExcluidos( pool: Pool, contribuyenteId: string, usos: string[], ): Promise { const valid = [...new Set(usos.filter(u => USOS_CFDI.includes(u)))]; await pool.query( `UPDATE contribuyentes SET activos_fijos_usos_excluidos = $2::jsonb WHERE entidad_id = $1`, [contribuyenteId.replace(/[^a-f0-9-]/gi, ''), JSON.stringify(valid)], ); return valid; } export async function darDeBaja( pool: Pool, cfdiId: number, fechaBaja: string, motivo: 'venta' | 'desecho' | 'otro', userId: string, comentario: string | null, ): Promise { await pool.query( `INSERT INTO activos_fijos_baja (cfdi_id, fecha_baja, motivo, comentario, dado_de_baja_por) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (cfdi_id) DO UPDATE SET fecha_baja = EXCLUDED.fecha_baja, motivo = EXCLUDED.motivo, comentario = EXCLUDED.comentario, dado_de_baja_por = EXCLUDED.dado_de_baja_por`, [cfdiId, fechaBaja, motivo, comentario, userId], ); } export async function revertirBaja(pool: Pool, cfdiId: number): Promise { const { rowCount } = await pool.query( `DELETE FROM activos_fijos_baja WHERE cfdi_id = $1`, [cfdiId], ); return (rowCount ?? 0) > 0; }