Files
HoruxDespachosNuevo/apps/api/src/services/activos-fijos.service.ts

266 lines
9.9 KiB
TypeScript

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<string, { concepto: string; pct: number }> = {
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<string[]> {
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<string[]> {
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<void> {
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<boolean> {
const { rowCount } = await pool.query(
`DELETE FROM activos_fijos_baja WHERE cfdi_id = $1`,
[cfdiId],
);
return (rowCount ?? 0) > 0;
}