Initial commit - Horux Despachos NL
This commit is contained in:
265
apps/api/src/services/activos-fijos.service.ts
Normal file
265
apps/api/src/services/activos-fijos.service.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user