266 lines
9.9 KiB
TypeScript
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;
|
|
}
|