Initial commit - Horux Despachos NL
This commit is contained in:
148
apps/api/src/services/_shared/cfdi-filters.ts
Normal file
148
apps/api/src/services/_shared/cfdi-filters.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Helpers para construir fragmentos AND adicionales en WHERE clauses según
|
||||
* los toggles "Considerar activos" y "Considerar NCs" de la UI de impuestos.
|
||||
*
|
||||
* - considerarActivos === false → excluir facturas relacionadas a activos:
|
||||
* 1) I directo con uso_cfdi I01-I08.
|
||||
* 2) P pagando una I-activo (vía uuid_relacionado).
|
||||
* 3) E que referencia una I-activo o una P-de-activo (vía cfdis_relacionados,
|
||||
* cualquier tipoRel — cubre NCs tipoRel=01, devoluciones tipoRel=03, etc.).
|
||||
* 4) Anticipo (I PUE/PPD) que es referenciado por una I/07 PPD con uso_cfdi
|
||||
* de activo (la I/07 "aplica" el anticipo al activo, así que el anticipo
|
||||
* también es para un activo).
|
||||
* - considerarNCs === false → excluir TODAS las facturas tipo E (cualquier
|
||||
* tipo_relacion). Además, los callers que aplican la compensación I/07 PPD
|
||||
* ↔ E (ingresos Grupo 1 y deducciones) deben saltarla cuando este flag es
|
||||
* false (la compensación lee valores de E para compensar; sin E, no aplica).
|
||||
*
|
||||
* Cuando ambos son true (default backend = "include todo"), retorna string
|
||||
* vacío. Esto preserva el comportamiento histórico para callers que no pasan
|
||||
* los flags (ej. dashboard, reportes).
|
||||
*
|
||||
* Las versiones `Alias` se usan en subqueries con alias de tabla
|
||||
* (ej. `cfdis e` en SUM_E_REFERENCING_*). El filtro de NCs aplica directo;
|
||||
* el de activos aplica también pero algunos predicados son no-op funcional
|
||||
* en subqueries que filtran por tipo_comprobante específico (Postgres los
|
||||
* optimiza away).
|
||||
*/
|
||||
|
||||
const ACTIVOS_USOS = "('I01','I02','I03','I04','I05','I06','I07','I08')";
|
||||
|
||||
/**
|
||||
* Predicado SQL que detecta si el row actual (sin alias de tabla, asume
|
||||
* `FROM cfdis`) referencia un activo directamente (I), indirectamente vía
|
||||
* pago (P→I), o transitivamente vía relación (E→I, E→P→I).
|
||||
*
|
||||
* IMPORTANTE — qualifying outer refs: dentro de los subqueries `cfdis i_act`
|
||||
* y `cfdis r_act`, la tabla interna también tiene columnas `uuid_relacionado`
|
||||
* y `cfdis_relacionados`. Una referencia no-qualificada las resolvería a las
|
||||
* columnas internas (NO al row outer), volviendo el predicado a no-op.
|
||||
* Por eso usamos `cfdis.uuid_relacionado` y `cfdis.cfdis_relacionados`
|
||||
* explícitamente — fuerza la resolución al outer.
|
||||
*/
|
||||
function activosExclusionNoAlias(): string {
|
||||
return `
|
||||
AND NOT (tipo_comprobante = 'I' AND uso_cfdi IN ${ACTIVOS_USOS})
|
||||
AND NOT (tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i_act
|
||||
WHERE LOWER(i_act.uuid) = LOWER(cfdis.uuid_relacionado)
|
||||
AND i_act.tipo_comprobante = 'I'
|
||||
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
))
|
||||
AND NOT (tipo_comprobante = 'E' AND cfdis.cfdis_relacionados IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM cfdis r_act
|
||||
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(cfdis.cfdis_relacionados), '|'))
|
||||
AND (
|
||||
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
|
||||
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis pi_act
|
||||
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
|
||||
AND pi_act.tipo_comprobante = 'I'
|
||||
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
))
|
||||
)
|
||||
))
|
||||
AND NOT (tipo_comprobante = 'I' AND EXISTS (
|
||||
-- Anticipo: CFDI tipo I (puede no tener uso_cfdi de activo) que es
|
||||
-- referenciado por una I/07 PPD con uso_cfdi de activo. La I/07 PPD
|
||||
-- "aplica" el anticipo a la compra del activo, así que el anticipo
|
||||
-- también debe filtrarse cuando se desactiva "Considerar activos".
|
||||
SELECT 1 FROM cfdis i07_act
|
||||
WHERE i07_act.tipo_comprobante = 'I'
|
||||
AND i07_act.metodo_pago = 'PPD'
|
||||
AND COALESCE(i07_act.cfdi_tipo_relacion, '') = '07'
|
||||
AND i07_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
AND i07_act.status NOT IN ('Cancelado', '0')
|
||||
AND i07_act.cfdis_relacionados IS NOT NULL
|
||||
AND LOWER(cfdis.uuid) = ANY(string_to_array(LOWER(i07_act.cfdis_relacionados), '|'))
|
||||
))
|
||||
`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Misma lógica que activosExclusionNoAlias pero referenciando columnas con
|
||||
* el alias de tabla externo (ej. 'e' en `FROM cfdis e`).
|
||||
*/
|
||||
function activosExclusionAlias(alias: string): string {
|
||||
return `
|
||||
AND NOT (${alias}.tipo_comprobante = 'I' AND ${alias}.uso_cfdi IN ${ACTIVOS_USOS})
|
||||
AND NOT (${alias}.tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i_act
|
||||
WHERE LOWER(i_act.uuid) = LOWER(${alias}.uuid_relacionado)
|
||||
AND i_act.tipo_comprobante = 'I'
|
||||
AND i_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
))
|
||||
AND NOT (${alias}.tipo_comprobante = 'E' AND ${alias}.cfdis_relacionados IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM cfdis r_act
|
||||
WHERE LOWER(r_act.uuid) = ANY(string_to_array(LOWER(${alias}.cfdis_relacionados), '|'))
|
||||
AND (
|
||||
(r_act.tipo_comprobante = 'I' AND r_act.uso_cfdi IN ${ACTIVOS_USOS})
|
||||
OR (r_act.tipo_comprobante = 'P' AND EXISTS (
|
||||
SELECT 1 FROM cfdis pi_act
|
||||
WHERE LOWER(pi_act.uuid) = LOWER(r_act.uuid_relacionado)
|
||||
AND pi_act.tipo_comprobante = 'I'
|
||||
AND pi_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
))
|
||||
)
|
||||
))
|
||||
AND NOT (${alias}.tipo_comprobante = 'I' AND EXISTS (
|
||||
SELECT 1 FROM cfdis i07_act
|
||||
WHERE i07_act.tipo_comprobante = 'I'
|
||||
AND i07_act.metodo_pago = 'PPD'
|
||||
AND COALESCE(i07_act.cfdi_tipo_relacion, '') = '07'
|
||||
AND i07_act.uso_cfdi IN ${ACTIVOS_USOS}
|
||||
AND i07_act.status NOT IN ('Cancelado', '0')
|
||||
AND i07_act.cfdis_relacionados IS NOT NULL
|
||||
AND LOWER(${alias}.uuid) = ANY(string_to_array(LOWER(i07_act.cfdis_relacionados), '|'))
|
||||
))
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export function buildExtraFilters(
|
||||
considerarActivos: boolean,
|
||||
considerarNCs: boolean,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (!considerarActivos) {
|
||||
parts.push(activosExclusionNoAlias());
|
||||
}
|
||||
if (!considerarNCs) {
|
||||
parts.push(`AND NOT (tipo_comprobante = 'E')`);
|
||||
}
|
||||
return parts.length > 0 ? ' ' + parts.join(' ') : '';
|
||||
}
|
||||
|
||||
export function buildExtraFiltersAlias(
|
||||
alias: string,
|
||||
considerarActivos: boolean,
|
||||
considerarNCs: boolean,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (!considerarActivos) {
|
||||
parts.push(activosExclusionAlias(alias));
|
||||
}
|
||||
if (!considerarNCs) {
|
||||
parts.push(`AND NOT (${alias}.tipo_comprobante = 'E')`);
|
||||
}
|
||||
return parts.length > 0 ? ' ' + parts.join(' ') : '';
|
||||
}
|
||||
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;
|
||||
}
|
||||
147
apps/api/src/services/admin-clientes.service.ts
Normal file
147
apps/api/src/services/admin-clientes.service.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Métricas para la página de Gestión de Clientes (admin global).
|
||||
*
|
||||
* Distinto de `admin-dashboard.service.ts` que provee KPIs generales fijos
|
||||
* a 30 días. Aquí el rango es parametrizado y se enfoca en suscripciones
|
||||
* activas por plan, ingresos del periodo, clientes que no renovaron, y
|
||||
* usuarios por cliente.
|
||||
*/
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
export interface ClientesStatsRange {
|
||||
from: Date;
|
||||
to: Date;
|
||||
}
|
||||
|
||||
export interface ClientesStats {
|
||||
/** Suscripciones activas (status=authorized) agrupadas por plan. */
|
||||
suscripcionesPorPlan: Array<{ plan: string; count: number }>;
|
||||
/** Ingresos del periodo (payments approved con createdAt en rango). */
|
||||
ingresos: {
|
||||
total: number;
|
||||
paymentsCount: number;
|
||||
};
|
||||
/** Clientes cuya suscripción venció en el rango y NO se renovó. */
|
||||
noRenovaciones: Array<{
|
||||
tenantId: string;
|
||||
tenantNombre: string;
|
||||
rfc: string;
|
||||
plan: string;
|
||||
currentPeriodEnd: string;
|
||||
statusActual: string;
|
||||
}>;
|
||||
/** Cuenta de usuarios activos por tenant. */
|
||||
usuariosPorCliente: Array<{
|
||||
tenantId: string;
|
||||
tenantNombre: string;
|
||||
rfc: string;
|
||||
activeUsers: number;
|
||||
owners: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function getClientesStats(range: ClientesStatsRange): Promise<ClientesStats> {
|
||||
// 1) Suscripciones activas agrupadas por plan
|
||||
const subsByPlan = await prisma.subscription.groupBy({
|
||||
by: ['plan'],
|
||||
where: { status: 'authorized' },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const suscripcionesPorPlan = subsByPlan.map(s => ({
|
||||
plan: String(s.plan),
|
||||
count: s._count._all,
|
||||
}));
|
||||
|
||||
// 2) Ingresos del periodo
|
||||
const payments = await prisma.payment.aggregate({
|
||||
where: {
|
||||
status: 'approved',
|
||||
createdAt: { gte: range.from, lte: range.to },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
_count: true,
|
||||
});
|
||||
const ingresos = {
|
||||
total: Number(payments._sum.amount || 0),
|
||||
paymentsCount: payments._count,
|
||||
};
|
||||
|
||||
// 3) Clientes que NO renovaron: subs cuyo currentPeriodEnd cae en el rango
|
||||
// y que están en status terminal (cancelled, trial_expired, paused) o sin
|
||||
// payment posterior aprobado. Nota: un sub `authorized` con periodEnd
|
||||
// pasado es un "se renovó automáticamente" — para detectar no-renovaciones
|
||||
// miramos status efectivo + ausencia de payment en los siguientes 7 días.
|
||||
const subsExpiradas = await prisma.subscription.findMany({
|
||||
where: {
|
||||
currentPeriodEnd: { gte: range.from, lte: range.to },
|
||||
status: { in: ['cancelled', 'trial_expired', 'paused'] },
|
||||
},
|
||||
select: {
|
||||
tenantId: true,
|
||||
plan: true,
|
||||
status: true,
|
||||
currentPeriodEnd: true,
|
||||
tenant: { select: { id: true, nombre: true, rfc: true } },
|
||||
},
|
||||
});
|
||||
const noRenovaciones = subsExpiradas.map(s => ({
|
||||
tenantId: s.tenantId,
|
||||
tenantNombre: s.tenant?.nombre ?? '',
|
||||
rfc: s.tenant?.rfc ?? '',
|
||||
plan: String(s.plan),
|
||||
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
|
||||
statusActual: s.status,
|
||||
}));
|
||||
|
||||
// 4) Usuarios por cliente (memberships activos por tenant)
|
||||
const memberships = await prisma.tenantMembership.findMany({
|
||||
where: { active: true },
|
||||
select: { tenantId: true, isOwner: true, tenant: { select: { nombre: true, rfc: true } } },
|
||||
});
|
||||
const byTenant = new Map<string, { nombre: string; rfc: string; total: number; owners: number }>();
|
||||
for (const m of memberships) {
|
||||
const ent = byTenant.get(m.tenantId) ?? {
|
||||
nombre: m.tenant?.nombre ?? '',
|
||||
rfc: m.tenant?.rfc ?? '',
|
||||
total: 0,
|
||||
owners: 0,
|
||||
};
|
||||
ent.total += 1;
|
||||
if (m.isOwner) ent.owners += 1;
|
||||
byTenant.set(m.tenantId, ent);
|
||||
}
|
||||
const usuariosPorCliente = Array.from(byTenant.entries()).map(([tenantId, v]) => ({
|
||||
tenantId,
|
||||
tenantNombre: v.nombre,
|
||||
rfc: v.rfc,
|
||||
activeUsers: v.total,
|
||||
owners: v.owners,
|
||||
})).sort((a, b) => b.activeUsers - a.activeUsers);
|
||||
|
||||
return { suscripcionesPorPlan, ingresos, noRenovaciones, usuariosPorCliente };
|
||||
}
|
||||
|
||||
/** Lista usuarios activos de un tenant (con email + rol). Para el drill-down de la UI. */
|
||||
export async function getTenantUsuarios(tenantId: string) {
|
||||
const memberships = await prisma.tenantMembership.findMany({
|
||||
where: { tenantId, active: true },
|
||||
select: {
|
||||
isOwner: true,
|
||||
joinedAt: true,
|
||||
user: { select: { id: true, email: true, nombre: true, active: true, lastLogin: true } },
|
||||
rol: { select: { nombre: true } },
|
||||
},
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
});
|
||||
return memberships
|
||||
.filter(m => m.user.active)
|
||||
.map(m => ({
|
||||
userId: m.user.id,
|
||||
email: m.user.email,
|
||||
nombre: m.user.nombre,
|
||||
rol: m.rol?.nombre ?? 'sin_rol',
|
||||
isOwner: m.isOwner,
|
||||
joinedAt: m.joinedAt.toISOString(),
|
||||
lastLogin: m.user.lastLogin?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
89
apps/api/src/services/admin-dashboard.service.ts
Normal file
89
apps/api/src/services/admin-dashboard.service.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
export async function getDashboardMetrics() {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const [
|
||||
totalTenants,
|
||||
activeTenants,
|
||||
trialTenants,
|
||||
cancelledSubs,
|
||||
recentSignups,
|
||||
connectorStatuses,
|
||||
payments,
|
||||
] = await Promise.all([
|
||||
prisma.tenant.count(),
|
||||
prisma.tenant.count({ where: { active: true } }),
|
||||
prisma.tenant.count({ where: { trialEndsAt: { gt: now } } }),
|
||||
prisma.subscription.count({ where: { status: 'cancelled' } }),
|
||||
prisma.tenant.count({ where: { createdAt: { gte: thirtyDaysAgo } } }),
|
||||
prisma.tenant.findMany({
|
||||
where: { dbMode: 'BYO', connectorTunnelHostname: { not: null } },
|
||||
select: { id: true, nombre: true, rfc: true, connectorLastSeen: true, connectorVersion: true },
|
||||
}),
|
||||
prisma.payment.aggregate({
|
||||
where: { status: 'approved', createdAt: { gte: thirtyDaysAgo } },
|
||||
_sum: { amount: true },
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const connectors = connectorStatuses.map(t => {
|
||||
let status: 'connected' | 'degraded' | 'disconnected' = 'disconnected';
|
||||
if (t.connectorLastSeen) {
|
||||
const diff = now.getTime() - t.connectorLastSeen.getTime();
|
||||
if (diff < 60_000) status = 'connected';
|
||||
else if (diff < 300_000) status = 'degraded';
|
||||
}
|
||||
return { id: t.id, nombre: t.nombre, rfc: t.rfc, status, lastSeen: t.connectorLastSeen?.toISOString(), version: t.connectorVersion };
|
||||
});
|
||||
|
||||
const connectorsDown = connectors.filter(c => c.status === 'disconnected').length;
|
||||
|
||||
return {
|
||||
tenants: { total: totalTenants, active: activeTenants, trial: trialTenants, cancelled: cancelledSubs },
|
||||
signupsLast30Days: recentSignups,
|
||||
revenue: { last30Days: Number(payments._sum.amount || 0), paymentsCount: payments._count },
|
||||
connectors: { total: connectors.length, down: connectorsDown, list: connectors },
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAllDespachos(filters?: { vertical?: string; status?: string; search?: string }) {
|
||||
const where: any = {};
|
||||
if (filters?.vertical) where.verticalProfile = filters.vertical;
|
||||
if (filters?.status === 'active') where.active = true;
|
||||
if (filters?.status === 'inactive') where.active = false;
|
||||
if (filters?.search) {
|
||||
where.OR = [
|
||||
{ nombre: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ rfc: { contains: filters.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true, nombre: true, rfc: true, plan: true, active: true, verticalProfile: true,
|
||||
dbMode: true, connectorLastSeen: true, createdAt: true, trialEndsAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
return tenants.map(t => ({
|
||||
...t,
|
||||
connectorLastSeen: t.connectorLastSeen?.toISOString(),
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
trialEndsAt: t.trialEndsAt?.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getRecentActivity(limit = 20) {
|
||||
const logs = await prisma.auditLog.findMany({
|
||||
where: { action: { in: ['user.register', 'user.login', 'subscription.created', 'subscription.cancelled', 'subscription.upgraded', 'price.updated'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
return logs;
|
||||
}
|
||||
683
apps/api/src/services/alertas-auto.service.ts
Normal file
683
apps/api/src/services/alertas-auto.service.ts
Normal file
@@ -0,0 +1,683 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { getRegimenesActivosClavesEfectivos } from './regimen.service.js';
|
||||
import { contarTareasProximasVencer } from './tareas.service.js';
|
||||
|
||||
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
||||
|
||||
/** Sanitize a contribuyente UUID for safe inline SQL injection */
|
||||
function sanitizeUuid(id: string): string {
|
||||
return id.replace(/[^a-f0-9-]/gi, '');
|
||||
}
|
||||
|
||||
export interface AlertaAuto {
|
||||
id: string;
|
||||
tipo: string;
|
||||
titulo: string;
|
||||
mensaje: string;
|
||||
prioridad: 'alta' | 'media' | 'baja';
|
||||
detalle?: string; // ruta para drill-down
|
||||
valor?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* CFDI con discrepancia: facturas recibidas donde regimen_fiscal_receptor
|
||||
* no coincide con los regímenes activos del tenant.
|
||||
*/
|
||||
async function alertaDiscrepanciaRegimen(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<AlertaAuto | null> {
|
||||
const activos = await getRegimenesActivosClavesEfectivos(tenantId, pool, contribuyenteId);
|
||||
if (activos.length === 0) return null;
|
||||
|
||||
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
|
||||
|
||||
const { rows: [r] } = await pool.query(`
|
||||
SELECT COUNT(*)::int as total
|
||||
FROM cfdis
|
||||
WHERE type = 'RECIBIDO' AND status = 'Vigente'
|
||||
AND fecha_cancelacion IS NULL
|
||||
AND regimen_fiscal_receptor IS NOT NULL
|
||||
AND regimen_fiscal_receptor != ALL($1)
|
||||
AND id NOT IN (SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'discrepancia-regimen')
|
||||
${cf}
|
||||
`, [activos]);
|
||||
|
||||
const total = r?.total || 0;
|
||||
if (total === 0) return null;
|
||||
|
||||
return {
|
||||
id: 'discrepancia-regimen',
|
||||
tipo: 'discrepancia',
|
||||
titulo: 'CFDI con Discrepancia de Regimen',
|
||||
mensaje: `${total} factura(s) recibida(s) con regimen fiscal del receptor que no coincide con los regimenes activos.`,
|
||||
prioridad: 'alta',
|
||||
detalle: '/alertas/discrepancia-regimen',
|
||||
valor: total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula el Índice de Herfindahl-Hirschman (IHH).
|
||||
* IHH = Σ (cuota_de_mercado_i)^2 × 10000
|
||||
*/
|
||||
async function calcularIHH(
|
||||
pool: Pool,
|
||||
type: 'EMITIDO' | 'RECIBIDO',
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<number> {
|
||||
const rfcField = type === 'EMITIDO' ? 'rfc_receptor' : 'rfc_emisor';
|
||||
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT ${rfcField} as rfc, SUM(total_mxn) as total
|
||||
FROM cfdis
|
||||
WHERE type = $1 AND tipo_comprobante = 'I' AND ${VIGENTE}
|
||||
AND total_mxn > 0
|
||||
${cf}
|
||||
GROUP BY ${rfcField}
|
||||
`, [type]);
|
||||
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
const totalGeneral = rows.reduce((s: number, r: any) => s + Number(r.total), 0);
|
||||
if (totalGeneral === 0) return 0;
|
||||
|
||||
let ihh = 0;
|
||||
for (const row of rows) {
|
||||
const cuota = Number(row.total) / totalGeneral;
|
||||
ihh += cuota * cuota;
|
||||
}
|
||||
|
||||
return Math.round(ihh * 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Concentración de clientes: IHH >= 2500 en facturas emitidas
|
||||
*/
|
||||
async function alertaConcentracionClientes(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
|
||||
const ihh = await calcularIHH(pool, 'EMITIDO', contribuyenteId);
|
||||
if (ihh < 2500) return null;
|
||||
|
||||
return {
|
||||
id: 'concentracion-clientes',
|
||||
tipo: 'concentracion',
|
||||
titulo: 'Concentracion de Clientes',
|
||||
mensaje: `El indice HHI de clientes es ${ihh.toLocaleString()} (>=2500 indica alta concentracion). Dependencia excesiva en pocos clientes.`,
|
||||
prioridad: ihh >= 5000 ? 'alta' : 'media',
|
||||
detalle: '/alertas/concentracion-clientes',
|
||||
valor: ihh,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Concentración de proveedores: IHH >= 2500 en facturas recibidas
|
||||
*/
|
||||
async function alertaConcentracionProveedores(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
|
||||
const ihh = await calcularIHH(pool, 'RECIBIDO', contribuyenteId);
|
||||
if (ihh < 2500) return null;
|
||||
|
||||
return {
|
||||
id: 'concentracion-proveedores',
|
||||
tipo: 'concentracion',
|
||||
titulo: 'Concentracion de Proveedores',
|
||||
mensaje: `El indice HHI de proveedores es ${ihh.toLocaleString()} (>=2500 indica alta concentracion). Dependencia excesiva en pocos proveedores.`,
|
||||
prioridad: ihh >= 5000 ? 'alta' : 'media',
|
||||
detalle: '/alertas/concentracion-proveedores',
|
||||
valor: ihh,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Riesgo cambiario: >10% de facturas en moneda != MXN
|
||||
*/
|
||||
async function alertaRiesgoCambiario(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
|
||||
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
|
||||
const { rows: [r] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*)::int as total,
|
||||
COUNT(CASE WHEN moneda IS NOT NULL AND moneda != 'MXN' THEN 1 END)::int as no_mxn
|
||||
FROM cfdis
|
||||
WHERE ${VIGENTE} AND tipo_comprobante = 'I'
|
||||
${cf}
|
||||
`);
|
||||
|
||||
const total = r?.total || 0;
|
||||
const noMxn = r?.no_mxn || 0;
|
||||
if (total === 0 || noMxn === 0) return null;
|
||||
|
||||
const porcentaje = Math.round((noMxn / total) * 10000) / 100;
|
||||
if (porcentaje <= 10) return null;
|
||||
|
||||
return {
|
||||
id: 'riesgo-cambiario',
|
||||
tipo: 'riesgo',
|
||||
titulo: 'Riesgo Cambiario',
|
||||
mensaje: `${porcentaje}% de las facturas (${noMxn} de ${total}) estan en moneda extranjera. Exposicion a fluctuaciones del tipo de cambio.`,
|
||||
prioridad: porcentaje > 30 ? 'alta' : 'media',
|
||||
valor: porcentaje,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Riesgo de cancelaciones: >10% de facturas canceladas en últimos 5 años
|
||||
*/
|
||||
async function alertaRiesgoCancelaciones(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
|
||||
const hace5 = new Date();
|
||||
hace5.setFullYear(hace5.getFullYear() - 5);
|
||||
const fechaDesde = hace5.toISOString().split('T')[0];
|
||||
|
||||
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
|
||||
|
||||
const { rows: [r] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*)::int as total,
|
||||
COUNT(CASE WHEN status IN ('Cancelado', '0') THEN 1 END)::int as cancelados
|
||||
FROM cfdis
|
||||
WHERE fecha_emision >= $1::date
|
||||
${cf}
|
||||
`, [fechaDesde]);
|
||||
|
||||
const total = r?.total || 0;
|
||||
const cancelados = r?.cancelados || 0;
|
||||
if (total === 0 || cancelados === 0) return null;
|
||||
|
||||
const porcentaje = Math.round((cancelados / total) * 10000) / 100;
|
||||
if (porcentaje <= 10) return null;
|
||||
|
||||
return {
|
||||
id: 'riesgo-cancelaciones',
|
||||
tipo: 'riesgo',
|
||||
titulo: 'Riesgo de Cancelaciones',
|
||||
mensaje: `${porcentaje}% de las facturas (${cancelados} de ${total}) en los ultimos 5 años han sido canceladas.`,
|
||||
prioridad: porcentaje > 25 ? 'alta' : 'media',
|
||||
detalle: '/alertas/cancelaciones',
|
||||
valor: porcentaje,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gastos recibidos pagados en efectivo > $2,000 — Art. 27 fracción III LISR.
|
||||
* No son deducibles. Surface el total + conteo para que el contador entienda
|
||||
* el impacto y, si aplica, gestione cambio de forma de pago con el proveedor.
|
||||
*/
|
||||
async function alertaRiesgoTransaccional(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
|
||||
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
|
||||
const { rows: [r] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*)::int AS facturas,
|
||||
COALESCE(SUM(COALESCE(total_mxn, 0)), 0)::numeric(14,2) AS monto
|
||||
FROM cfdis
|
||||
WHERE ${VIGENTE}
|
||||
AND type = 'RECIBIDO'
|
||||
AND tipo_comprobante = 'I'
|
||||
AND metodo_pago = 'PUE'
|
||||
AND forma_pago = '01'
|
||||
AND COALESCE(total_mxn, 0) > 2000
|
||||
${cf}
|
||||
`);
|
||||
|
||||
const facturas = r?.facturas || 0;
|
||||
const monto = Number(r?.monto || 0);
|
||||
if (facturas === 0) return null;
|
||||
|
||||
return {
|
||||
id: 'gastos-no-deducibles-efectivo',
|
||||
tipo: 'riesgo',
|
||||
titulo: 'Gastos no deducibles (efectivo > $2,000)',
|
||||
mensaje: `${facturas} factura${facturas === 1 ? '' : 's'} recibida${facturas === 1 ? '' : 's'} por $${monto.toLocaleString('es-MX')} MXN se pagaron en efectivo (>$2,000). Art. 27 fracción III LISR las hace NO deducibles para ISR.`,
|
||||
prioridad: monto > 50000 ? 'alta' : 'media',
|
||||
detalle: '/impuestos',
|
||||
valor: monto,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estatus lista negra: si el RFC del tenant/contribuyente aparece en la lista negra
|
||||
*/
|
||||
async function alertaListaNegraPropia(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<AlertaAuto | null> {
|
||||
let rfc: string | undefined;
|
||||
|
||||
if (contribuyenteId) {
|
||||
const safeId = sanitizeUuid(contribuyenteId);
|
||||
const { rows } = await pool.query(
|
||||
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
|
||||
[safeId],
|
||||
);
|
||||
rfc = rows[0]?.rfc;
|
||||
} else {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { rfc: true },
|
||||
});
|
||||
rfc = tenant?.rfc;
|
||||
}
|
||||
|
||||
if (!rfc) return null;
|
||||
|
||||
const registro = await prisma.listaNegra.findUnique({
|
||||
where: { rfc },
|
||||
});
|
||||
if (!registro) return null;
|
||||
|
||||
return {
|
||||
id: 'lista-negra-propia',
|
||||
tipo: 'lista-negra',
|
||||
titulo: 'RFC en Lista Negra del SAT',
|
||||
mensaje: `Tu RFC (${rfc}) aparece en la lista del Art. 69-B del CFF con situacion: ${registro.situacion}.`,
|
||||
prioridad: 'alta',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factura emitida a cliente en lista negra
|
||||
*/
|
||||
async function alertaClienteListaNegra(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
|
||||
// Fallback: consultar directo si dblink no funciona
|
||||
const listaRfcs = await prisma.listaNegra.findMany({
|
||||
where: { situacion: { in: ['Definitivo', 'Presunto'] } },
|
||||
select: { rfc: true },
|
||||
});
|
||||
const rfcSet = new Set(listaRfcs.map(l => l.rfc));
|
||||
|
||||
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT DISTINCT rfc_receptor as rfc
|
||||
FROM cfdis
|
||||
WHERE type = 'EMITIDO' AND ${VIGENTE} AND tipo_comprobante = 'I'
|
||||
${cf}
|
||||
`);
|
||||
|
||||
const clientesEnLista = rows.filter((r: any) => rfcSet.has(r.rfc));
|
||||
if (clientesEnLista.length === 0) return null;
|
||||
|
||||
return {
|
||||
id: 'lista-negra-clientes',
|
||||
tipo: 'lista-negra',
|
||||
titulo: 'Facturas Emitidas a Clientes en Lista Negra',
|
||||
mensaje: `${clientesEnLista.length} cliente(s) a los que has facturado aparecen en la lista negra del SAT (Art. 69-B).`,
|
||||
prioridad: 'alta',
|
||||
detalle: '/alertas/lista-negra-clientes',
|
||||
valor: clientesEnLista.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Factura recibida de proveedor en lista negra
|
||||
*/
|
||||
async function alertaProveedorListaNegra(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
|
||||
const listaRfcs = await prisma.listaNegra.findMany({
|
||||
where: { situacion: { in: ['Definitivo', 'Presunto'] } },
|
||||
select: { rfc: true },
|
||||
});
|
||||
const rfcSet = new Set(listaRfcs.map(l => l.rfc));
|
||||
|
||||
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT DISTINCT rfc_emisor as rfc
|
||||
FROM cfdis
|
||||
WHERE type = 'RECIBIDO' AND ${VIGENTE} AND tipo_comprobante = 'I'
|
||||
${cf}
|
||||
`);
|
||||
|
||||
const proveedoresEnLista = rows.filter((r: any) => rfcSet.has(r.rfc));
|
||||
if (proveedoresEnLista.length === 0) return null;
|
||||
|
||||
return {
|
||||
id: 'lista-negra-proveedores',
|
||||
tipo: 'lista-negra',
|
||||
titulo: 'Facturas Recibidas de Proveedores en Lista Negra',
|
||||
mensaje: `${proveedoresEnLista.length} proveedor(es) de los que has recibido facturas aparecen en la lista negra del SAT (Art. 69-B).`,
|
||||
prioridad: 'alta',
|
||||
detalle: '/alertas/lista-negra-proveedores',
|
||||
valor: proveedoresEnLista.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Facturas de periodos anteriores canceladas este mes.
|
||||
* Detecta CFDIs cuya fecha_cancelacion cae en el mes actual pero
|
||||
* cuya fecha_emision es de un mes anterior.
|
||||
*/
|
||||
async function alertaCancelacionPeriodoAnterior(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
|
||||
const ahora = new Date();
|
||||
const inicioMes = `${ahora.getFullYear()}-${String(ahora.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
|
||||
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
|
||||
|
||||
const { rows: [r] } = await pool.query(`
|
||||
SELECT COUNT(*)::int as total,
|
||||
COALESCE(SUM(COALESCE(total_mxn, 0)), 0) as monto
|
||||
FROM cfdis
|
||||
WHERE status IN ('Cancelado', '0')
|
||||
AND fecha_cancelacion >= $1::date
|
||||
AND fecha_emision < $1::date
|
||||
${cf}
|
||||
`, [inicioMes]);
|
||||
|
||||
const total = r?.total || 0;
|
||||
if (total === 0) return null;
|
||||
|
||||
const monto = Number(r.monto);
|
||||
const montoFmt = monto.toLocaleString('es-MX', { style: 'currency', currency: 'MXN' });
|
||||
|
||||
return {
|
||||
id: 'cancelacion-periodo-anterior',
|
||||
tipo: 'cancelacion-retroactiva',
|
||||
titulo: 'Facturas de Periodos Anteriores Canceladas',
|
||||
mensaje: `${total} factura(s) emitida(s) en meses anteriores fueron canceladas este mes por un total de ${montoFmt}. Esto puede afectar declaraciones ya presentadas.`,
|
||||
prioridad: 'alta',
|
||||
detalle: '/alertas/cancelaciones-periodo-anterior',
|
||||
valor: total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CFDIs tipo E (Egreso / nota de crédito) con cfdi_tipo_relacion != '07'
|
||||
* cuya(s) referencia(s) en cfdis_relacionados también aparecen en otro CFDI
|
||||
* con cfdi_tipo_relacion = '07'. Señal de que el emisor debió usar 07
|
||||
* (aplicación de anticipo) pero puso 01/02/03/04: inflá gastos e IVA
|
||||
* acreditable contra un anticipo ya consumido.
|
||||
*
|
||||
* La detección usa overlap de arrays (`&&`) sobre cfdis_relacionados
|
||||
* pipe-separados — si X y Y comparten al menos un UUID referenciado y Y
|
||||
* es 07, X es sospechoso.
|
||||
*/
|
||||
const SOSPECHOSA_TIPO_RELACION_WHERE = `
|
||||
c.tipo_comprobante = 'E'
|
||||
AND c.status NOT IN ('Cancelado', '0')
|
||||
AND c.cfdi_tipo_relacion IS NOT NULL
|
||||
AND c.cfdi_tipo_relacion <> '07'
|
||||
AND c.cfdis_relacionados IS NOT NULL
|
||||
AND c.cfdis_relacionados <> ''
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM cfdis y
|
||||
WHERE y.id <> c.id
|
||||
AND y.cfdi_tipo_relacion = '07'
|
||||
AND y.status NOT IN ('Cancelado', '0')
|
||||
AND y.cfdis_relacionados IS NOT NULL
|
||||
AND y.cfdis_relacionados <> ''
|
||||
AND string_to_array(LOWER(y.cfdis_relacionados), '|')
|
||||
&& string_to_array(LOWER(c.cfdis_relacionados), '|')
|
||||
)
|
||||
AND c.id NOT IN (
|
||||
SELECT cfdi_id FROM cfdi_descartados WHERE tipo_alerta = 'tipo-relacion-sospechosa'
|
||||
)
|
||||
`;
|
||||
|
||||
async function alertaTipoRelacionSospechosa(
|
||||
pool: Pool,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<AlertaAuto | null> {
|
||||
const cf = contribuyenteId ? `AND c.contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
|
||||
const { rows: [r] } = await pool.query(`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM cfdis c
|
||||
WHERE ${SOSPECHOSA_TIPO_RELACION_WHERE}
|
||||
${cf}
|
||||
`);
|
||||
|
||||
const total = r?.total || 0;
|
||||
if (total === 0) return null;
|
||||
|
||||
return {
|
||||
id: 'tipo-relacion-sospechosa',
|
||||
tipo: 'cfdi-inconsistente',
|
||||
titulo: 'Nota de Crédito con Tipo de Relación sospechoso',
|
||||
mensaje: `${total} CFDI(s) tipo E con TipoRelacion distinto de 07 referencian un CFDI tratado como anticipo por otra factura. Revisa si deberían haberse emitido como 07 (aplicación de anticipo).`,
|
||||
prioridad: 'alta',
|
||||
detalle: '/alertas/tipo-relacion-sospechosa',
|
||||
valor: total,
|
||||
};
|
||||
}
|
||||
|
||||
/** Exportado para reutilizar en el controller de drill-down. */
|
||||
export const SOSPECHOSA_TIPO_RELACION_WHERE_EXPORT = SOSPECHOSA_TIPO_RELACION_WHERE;
|
||||
|
||||
/**
|
||||
* Tareas operativas próximas a vencer (≤3 días). Solo aplica cuando hay un
|
||||
* contribuyente seleccionado — sin contribuyente, no se puede contar
|
||||
* porque las tareas son siempre per-contribuyente.
|
||||
*/
|
||||
async function alertaTareasProximasVencer(
|
||||
pool: Pool,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<AlertaAuto | null> {
|
||||
if (!contribuyenteId) return null;
|
||||
const { total } = await contarTareasProximasVencer(pool, contribuyenteId);
|
||||
if (total === 0) return null;
|
||||
return {
|
||||
id: 'tareas-proximas-vencer',
|
||||
tipo: 'tareas',
|
||||
titulo: 'Tareas próximas a vencer',
|
||||
mensaje: `${total} tarea(s) operativa(s) vencen en los próximos 3 días.`,
|
||||
prioridad: 'media',
|
||||
detalle: '/configuracion/obligaciones',
|
||||
valor: total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RESICO PF cerca de salir del régimen por exceso de ingresos anuales.
|
||||
*
|
||||
* Art. 113-E LISR — el contribuyente PF en RESICO debe salir del régimen si
|
||||
* sus ingresos del ejercicio exceden $3,500,000. **Importante:** el SAT
|
||||
* considera ingresos acumulados de TODOS los regímenes del contribuyente, no
|
||||
* solo los del 626. Por eso este query no filtra por `regimen_fiscal_emisor`.
|
||||
*
|
||||
* Umbrales:
|
||||
* - $2,500,000 → alerta media (margen ~$1M)
|
||||
* - $3,000,000 → alerta alta (margen ~$500k al límite)
|
||||
* - $3,500,000 → alerta alta crítica ("ya superaste")
|
||||
*
|
||||
* Aplica solo cuando:
|
||||
* 1. Hay un contribuyente seleccionado (per-tenant no tiene sentido — la
|
||||
* alerta es por entidad fiscal individual)
|
||||
* 2. RFC de 13 caracteres (Persona Física — RESICO PM no tiene este límite)
|
||||
* 3. Régimen 626 está en su lista de regímenes activos
|
||||
*
|
||||
* Cálculo de ingresos: agregado de CFDIs emitidos vigentes del año en curso:
|
||||
* + I PUE (cobradas al emitir)
|
||||
* + P (complementos de pago — cobros de PPD anteriores)
|
||||
* - E PUE (notas de crédito netan)
|
||||
*
|
||||
* Sin desglose por régimen ni filtro de conciliación. Se usa `total_mxn` como
|
||||
* proxy de ingreso (incluye IVA — sobreestima ~16%, conservador para alerta).
|
||||
*/
|
||||
async function alertaResicoPfLimiteIngresos(
|
||||
pool: Pool,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<AlertaAuto | null> {
|
||||
if (!contribuyenteId) return null;
|
||||
|
||||
const safeId = sanitizeUuid(contribuyenteId);
|
||||
|
||||
// Verificar elegibilidad: PF (RFC 13) + régimen 626 activo
|
||||
const { rows: contribRows } = await pool.query(
|
||||
`SELECT rfc, regimen_fiscal FROM contribuyentes WHERE entidad_id = $1`,
|
||||
[safeId],
|
||||
);
|
||||
const contrib = contribRows[0];
|
||||
if (!contrib) return null;
|
||||
|
||||
const rfc: string = contrib.rfc || '';
|
||||
if (rfc.length !== 13) return null; // PM no aplica
|
||||
|
||||
const regimenesCsv: string = contrib.regimen_fiscal || '';
|
||||
const regimenes = regimenesCsv.split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||
if (!regimenes.includes('626')) return null;
|
||||
|
||||
// Suma ingresos del año en curso, agregado de TODOS los regímenes
|
||||
const año = new Date().getFullYear();
|
||||
const { rows: [r] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(
|
||||
CASE
|
||||
WHEN tipo_comprobante = 'I' AND metodo_pago = 'PUE' THEN COALESCE(total_mxn, 0)
|
||||
WHEN tipo_comprobante = 'P' THEN COALESCE(monto_pago_mxn, 0)
|
||||
WHEN tipo_comprobante = 'E' AND metodo_pago = 'PUE' THEN -COALESCE(total_mxn, 0)
|
||||
ELSE 0
|
||||
END
|
||||
), 0)::numeric AS ingresos
|
||||
FROM cfdis
|
||||
WHERE type = 'EMITIDO'
|
||||
AND status NOT IN ('Cancelado', '0')
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND contribuyente_id = $2
|
||||
`, [año, safeId]);
|
||||
|
||||
const ingresos = Number(r?.ingresos || 0);
|
||||
const UMBRAL_AVISO = 2_500_000;
|
||||
const UMBRAL_ALTO = 3_000_000;
|
||||
const LIMITE_LEGAL = 3_500_000; // Art. 113-E LISR
|
||||
|
||||
if (ingresos < UMBRAL_AVISO) return null;
|
||||
|
||||
const ingresosFmt = ingresos.toLocaleString('es-MX', {
|
||||
style: 'currency', currency: 'MXN', maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
let prioridad: 'alta' | 'media' = 'media';
|
||||
let titulo = `RESICO PF cerca del límite anual`;
|
||||
let mensaje = `Ingresos acumulados ${año} (todos los regímenes): ${ingresosFmt}. Límite RESICO PF: $3,500,000 (Art. 113-E LISR). Se considera ingresos de TODOS los regímenes, no solo del 626.`;
|
||||
|
||||
if (ingresos >= LIMITE_LEGAL) {
|
||||
prioridad = 'alta';
|
||||
titulo = `RESICO PF: límite anual EXCEDIDO`;
|
||||
mensaje = `Ingresos acumulados ${año} (todos los regímenes): ${ingresosFmt}. Excede el límite de $3,500,000 del Art. 113-E LISR. El contribuyente debe salir de RESICO PF y tributar bajo régimen general (PF Empresarial).`;
|
||||
} else if (ingresos >= UMBRAL_ALTO) {
|
||||
prioridad = 'alta';
|
||||
titulo = `RESICO PF: cerca del límite ($3M+)`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'resico-pf-limite-ingresos',
|
||||
tipo: 'limite-regimen',
|
||||
titulo,
|
||||
mensaje,
|
||||
prioridad,
|
||||
valor: ingresos,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alerta si la última Opinión de Cumplimiento no es Positiva.
|
||||
*/
|
||||
async function alertaOpinionCumplimiento(pool: Pool, contribuyenteId?: string | null): Promise<AlertaAuto | null> {
|
||||
let rfcFilter = '';
|
||||
if (contribuyenteId) {
|
||||
const safeId = sanitizeUuid(contribuyenteId);
|
||||
const { rows: rfcRows } = await pool.query(
|
||||
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
|
||||
[safeId],
|
||||
);
|
||||
const rfc: string | undefined = rfcRows[0]?.rfc;
|
||||
if (rfc) rfcFilter = `WHERE rfc = '${rfc.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT estatus, fecha_consulta
|
||||
FROM opiniones_cumplimiento
|
||||
${rfcFilter}
|
||||
ORDER BY fecha_consulta DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const { estatus, fecha_consulta } = rows[0];
|
||||
if (estatus === 'Positiva') return null;
|
||||
|
||||
const fecha = new Date(fecha_consulta).toLocaleDateString('es-MX');
|
||||
|
||||
return {
|
||||
id: 'opinion-cumplimiento-negativa',
|
||||
tipo: 'opinion-cumplimiento',
|
||||
titulo: `Opinión de Cumplimiento: ${estatus}`,
|
||||
mensaje: `Tu Opinión de Cumplimiento ante el SAT es ${estatus}. Última consulta: ${fecha}. Revisa tus obligaciones fiscales.`,
|
||||
prioridad: 'alta',
|
||||
valor: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera todas las alertas automáticas para un tenant.
|
||||
*/
|
||||
export async function generarAlertasAutomaticas(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<AlertaAuto[]> {
|
||||
const alertas = await Promise.all([
|
||||
alertaListaNegraPropia(pool, tenantId, contribuyenteId),
|
||||
alertaClienteListaNegra(pool, contribuyenteId),
|
||||
alertaProveedorListaNegra(pool, contribuyenteId),
|
||||
alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId),
|
||||
alertaConcentracionClientes(pool, contribuyenteId),
|
||||
alertaConcentracionProveedores(pool, contribuyenteId),
|
||||
alertaRiesgoCambiario(pool, contribuyenteId),
|
||||
alertaRiesgoCancelaciones(pool, contribuyenteId),
|
||||
alertaRiesgoTransaccional(pool, contribuyenteId),
|
||||
alertaCancelacionPeriodoAnterior(pool, contribuyenteId),
|
||||
alertaOpinionCumplimiento(pool, contribuyenteId),
|
||||
alertaTipoRelacionSospechosa(pool, contribuyenteId),
|
||||
alertaTareasProximasVencer(pool, contribuyenteId),
|
||||
alertaResicoPfLimiteIngresos(pool, contribuyenteId),
|
||||
]);
|
||||
|
||||
return alertas.filter((a): a is AlertaAuto => a !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Breakdown mensual de discrepancias de régimen de los últimos N meses.
|
||||
* Cuenta facturas RECIBIDAS donde regimen_fiscal_receptor no coincide con
|
||||
* los regímenes activos del tenant. Útil para el correo semanal — el cliente
|
||||
* ve cuántas facturas con error le emitieron mes por mes.
|
||||
*/
|
||||
export async function getDiscrepanciasPorMes(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
monthsBack = 6,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<Array<{ año: number; mes: number; count: number; label: string }>> {
|
||||
const activos = await getRegimenesActivosClavesEfectivos(tenantId, pool, contribuyenteId);
|
||||
if (activos.length === 0) return [];
|
||||
|
||||
const desde = new Date();
|
||||
desde.setMonth(desde.getMonth() - (monthsBack - 1));
|
||||
desde.setDate(1);
|
||||
const desdeStr = desde.toISOString().split('T')[0];
|
||||
|
||||
const cf = contribuyenteId ? `AND contribuyente_id = '${sanitizeUuid(contribuyenteId)}'` : '';
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
EXTRACT(YEAR FROM fecha_emision)::int as año,
|
||||
EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||
COUNT(*)::int as count
|
||||
FROM cfdis
|
||||
WHERE type = 'RECIBIDO' AND ${VIGENTE}
|
||||
AND regimen_fiscal_receptor IS NOT NULL
|
||||
AND regimen_fiscal_receptor != ALL($1)
|
||||
AND fecha_emision >= $2::date
|
||||
${cf}
|
||||
GROUP BY año, mes
|
||||
ORDER BY año DESC, mes DESC
|
||||
`, [activos, desdeStr]);
|
||||
|
||||
const NOMBRES_MES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
|
||||
|
||||
return rows.map((r: any) => ({
|
||||
año: r.año,
|
||||
mes: r.mes,
|
||||
count: r.count,
|
||||
label: `${NOMBRES_MES[r.mes - 1]} ${r.año}`,
|
||||
}));
|
||||
}
|
||||
299
apps/api/src/services/alertas-manuales.service.ts
Normal file
299
apps/api/src/services/alertas-manuales.service.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { generarEventosFiscales } from './calendario-fiscal.service.js';
|
||||
import { isDespachoTenant } from '@horux/shared';
|
||||
|
||||
interface AlertaManualGenerada {
|
||||
tipo: string;
|
||||
titulo: string;
|
||||
mensaje: string;
|
||||
prioridad: 'alta' | 'media';
|
||||
fechaVencimiento: string;
|
||||
}
|
||||
|
||||
// Mapeo de eventos del calendario a tipos de alerta (legacy Horux360)
|
||||
const EVENTO_A_ALERTA: Record<string, { prefijo: string; prioridad: 'alta' | 'media' }> = {
|
||||
'Declaración mensual ISR': { prefijo: 'decl-isr', prioridad: 'alta' },
|
||||
'Declaración mensual IVA': { prefijo: 'decl-iva', prioridad: 'alta' },
|
||||
'Declaración mensual IEPS': { prefijo: 'decl-ieps', prioridad: 'media' },
|
||||
'Pago provisional ISR': { prefijo: 'pago-isr', prioridad: 'alta' },
|
||||
'Pago provisional IVA': { prefijo: 'pago-iva', prioridad: 'alta' },
|
||||
'Pago provisional IEPS': { prefijo: 'pago-ieps', prioridad: 'media' },
|
||||
'Declaración de sueldos y salarios': { prefijo: 'decl-sueldos', prioridad: 'media' },
|
||||
'DIOT': { prefijo: 'diot', prioridad: 'media' },
|
||||
'Contabilidad electrónica': { prefijo: 'contabilidad', prioridad: 'media' },
|
||||
'Declaración anual PM': { prefijo: 'decl-anual-pm', prioridad: 'alta' },
|
||||
'Declaración anual PF': { prefijo: 'decl-anual-pf', prioridad: 'alta' },
|
||||
'Informativa Sueldos y Salarios': { prefijo: 'inf-sueldos', prioridad: 'media' },
|
||||
};
|
||||
|
||||
/**
|
||||
* For despachos: generate alerts from the contribuyente's actual obligations
|
||||
* (obligaciones_contribuyente) instead of the static fiscal calendar.
|
||||
* Only generates alerts for obligations that the contribuyente actually has.
|
||||
*/
|
||||
async function sincronizarDesdeObligacionesContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
): Promise<{ creadas: number; existentes: number }> {
|
||||
const hoy = new Date();
|
||||
const currentPeriodo = hoy.toISOString().substring(0, 7); // "2026-04"
|
||||
|
||||
// Get active obligations for this contribuyente
|
||||
const { rows: obligaciones } = await pool.query(`
|
||||
SELECT id, nombre, frecuencia, fecha_limite AS "fechaLimite", created_at AS "createdAt"
|
||||
FROM obligaciones_contribuyente
|
||||
WHERE contribuyente_id = $1 AND activa = true
|
||||
`, [contribuyenteId]);
|
||||
|
||||
// Get existing completions
|
||||
const { rows: completions } = await pool.query(`
|
||||
SELECT op.obligacion_id, op.periodo
|
||||
FROM obligacion_periodos op
|
||||
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
|
||||
WHERE oc.contribuyente_id = $1 AND op.completada = true
|
||||
`, [contribuyenteId]);
|
||||
const completionSet = new Set(completions.map(c => `${c.obligacion_id}:${c.periodo}`));
|
||||
|
||||
let creadas = 0;
|
||||
let existentes = 0;
|
||||
|
||||
for (const ob of obligaciones) {
|
||||
const obStartPeriodo = ob.createdAt
|
||||
? new Date(ob.createdAt).toISOString().substring(0, 7)
|
||||
: '2000-01';
|
||||
|
||||
// Check current and previous month
|
||||
for (let offset = 0; offset <= 1; offset++) {
|
||||
const d = new Date(hoy.getFullYear(), hoy.getMonth() - offset, 1);
|
||||
const periodo = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
if (periodo < obStartPeriodo) continue;
|
||||
if (!appliesToPeriod(ob.frecuencia, periodo)) continue;
|
||||
if (completionSet.has(`${ob.id}:${periodo}`)) continue;
|
||||
|
||||
// Generate alert type unique per obligation+period
|
||||
const tipoUnico = `ob-${ob.id}-${periodo}`;
|
||||
|
||||
const { rows: existing } = await pool.query(
|
||||
`SELECT id FROM alertas WHERE tipo = $1`,
|
||||
[tipoUnico],
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
existentes++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine deadline (day 17 of next month for mensual)
|
||||
const [y, m] = periodo.split('-').map(Number);
|
||||
const nextMonth = m === 12 ? 1 : m + 1;
|
||||
const nextYear = m === 12 ? y + 1 : y;
|
||||
const fechaVencimiento = `${nextYear}-${String(nextMonth).padStart(2, '0')}-17`;
|
||||
|
||||
const deadlineDate = new Date(fechaVencimiento + 'T23:59:59');
|
||||
const isPastDue = deadlineDate < hoy;
|
||||
const prioridad = isPastDue ? 'alta' : 'media';
|
||||
const statusLabel = isPastDue ? 'Vencida' : 'Pendiente';
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, [
|
||||
tipoUnico,
|
||||
`${ob.nombre} - ${statusLabel}`,
|
||||
`${ob.fechaLimite || 'Sin fecha límite especificada'}. Periodo: ${periodo}`,
|
||||
prioridad,
|
||||
fechaVencimiento,
|
||||
]);
|
||||
creadas++;
|
||||
}
|
||||
}
|
||||
|
||||
return { creadas, existentes };
|
||||
}
|
||||
|
||||
function appliesToPeriod(frecuencia: string | null, periodo: string): boolean {
|
||||
const [, month] = periodo.split('-').map(Number);
|
||||
switch (frecuencia) {
|
||||
case 'mensual': return true;
|
||||
case 'bimestral': return month % 2 === 1;
|
||||
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
||||
case 'anual': return month === 3 || month === 4;
|
||||
case 'eventual': return false;
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera alertas manuales para eventos fiscales vencidos que no han sido resueltos.
|
||||
* Para despachos: usa obligaciones per-contribuyente.
|
||||
* Para Horux360: usa el calendario fiscal estático (legacy).
|
||||
*/
|
||||
export async function sincronizarAlertasManuales(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<{ creadas: number; existentes: number }> {
|
||||
// Check if this is a despacho tenant
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { rfc: true, createdAt: true },
|
||||
});
|
||||
if (!tenant) return { creadas: 0, existentes: 0 };
|
||||
|
||||
// Despacho: use per-contribuyente obligations
|
||||
if (isDespachoTenant(tenant.rfc)) {
|
||||
if (contribuyenteId) {
|
||||
return sincronizarDesdeObligacionesContribuyente(pool, contribuyenteId);
|
||||
}
|
||||
// "Todos los RFCs": don't generate new alerts — individual contribuyente alerts already exist
|
||||
return { creadas: 0, existentes: 0 };
|
||||
}
|
||||
|
||||
// Legacy Horux360: use static fiscal calendar
|
||||
const hoy = new Date();
|
||||
const añoActual = hoy.getFullYear();
|
||||
const fechaCreacion = tenant.createdAt || hoy;
|
||||
|
||||
const eventosActual = await generarEventosFiscales(tenantId, añoActual);
|
||||
const eventosAnterior = await generarEventosFiscales(tenantId, añoActual - 1);
|
||||
const todosEventos = [...eventosAnterior, ...eventosActual];
|
||||
|
||||
const vencidos = todosEventos.filter(e => {
|
||||
const fecha = new Date(e.fechaLimite + 'T23:59:59');
|
||||
return fecha < hoy && fecha >= fechaCreacion;
|
||||
});
|
||||
|
||||
let creadas = 0;
|
||||
let existentes = 0;
|
||||
|
||||
for (const evento of vencidos) {
|
||||
const config = EVENTO_A_ALERTA[evento.titulo];
|
||||
if (!config) continue;
|
||||
|
||||
const tipoUnico = `${config.prefijo}-${evento.fechaLimite}`;
|
||||
|
||||
const { rows: existing } = await pool.query(
|
||||
`SELECT id, resuelta FROM alertas WHERE tipo = $1`,
|
||||
[tipoUnico]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
existentes++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const esPago = evento.titulo.startsWith('Pago');
|
||||
const accion = esPago ? 'Pago pendiente' : 'No presentada';
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, [
|
||||
tipoUnico,
|
||||
`${evento.titulo} - ${accion}`,
|
||||
`${evento.descripcion}. Fecha limite: ${new Date(evento.fechaLimite + 'T00:00:00').toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' })}`,
|
||||
config.prioridad,
|
||||
evento.fechaLimite,
|
||||
]);
|
||||
|
||||
creadas++;
|
||||
}
|
||||
|
||||
return { creadas, existentes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene alertas manuales pendientes (no resueltas).
|
||||
* Filters by contribuyente or by user's accessible contribuyentes (for clientes).
|
||||
*/
|
||||
export async function getAlertasManualesPendientes(
|
||||
pool: Pool,
|
||||
contribuyenteId?: string | null,
|
||||
userId?: string | null,
|
||||
role?: string | null,
|
||||
): Promise<any[]> {
|
||||
let contribuyenteFilter = '';
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (contribuyenteId) {
|
||||
// Specific contribuyente selected
|
||||
params.push(contribuyenteId);
|
||||
contribuyenteFilter = `AND (
|
||||
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
||||
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id = $${params.length}
|
||||
)
|
||||
)`;
|
||||
} else if (role === 'cliente' && userId) {
|
||||
// Client with "Todos los RFCs" — only their accessible contribuyentes
|
||||
params.push(userId);
|
||||
contribuyenteFilter = `AND (
|
||||
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
||||
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id IN (
|
||||
SELECT entidad_id FROM cliente_accesos WHERE user_id = $${params.length}
|
||||
)
|
||||
)
|
||||
)`;
|
||||
} else if (role === 'auxiliar' && userId) {
|
||||
// Auxiliar: only their subcarteras' contribuyentes
|
||||
params.push(userId);
|
||||
contribuyenteFilter = `AND (
|
||||
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
||||
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id IN (
|
||||
SELECT ce.entidad_id FROM cartera_entidades ce
|
||||
JOIN carteras c ON c.id = ce.cartera_id
|
||||
WHERE c.auxiliar_user_id = $${params.length}
|
||||
UNION
|
||||
SELECT ce.entidad_id FROM cartera_entidades ce
|
||||
JOIN cartera_auxiliares ca ON ca.cartera_id = ce.cartera_id
|
||||
WHERE ca.auxiliar_user_id = $${params.length}
|
||||
)
|
||||
)
|
||||
)`;
|
||||
} else if (role === 'supervisor' && userId) {
|
||||
// Supervisor: only their carteras' contribuyentes
|
||||
params.push(userId);
|
||||
contribuyenteFilter = `AND (
|
||||
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
||||
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id IN (
|
||||
SELECT ce.entidad_id FROM cartera_entidades ce
|
||||
JOIN carteras c ON c.id = ce.cartera_id AND c.parent_id IS NULL
|
||||
WHERE c.supervisor_user_id = $${params.length}
|
||||
)
|
||||
)
|
||||
)`;
|
||||
}
|
||||
|
||||
// Exclude alerts for inactive obligations
|
||||
const inactiveFilter = `AND NOT (
|
||||
tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
||||
SELECT id::text FROM obligaciones_contribuyente WHERE activa = false
|
||||
)
|
||||
)`;
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
FROM alertas
|
||||
WHERE resuelta = false
|
||||
${inactiveFilter}
|
||||
${contribuyenteFilter}
|
||||
ORDER BY
|
||||
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
||||
fecha_vencimiento ASC
|
||||
`, params);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca una alerta como resuelta (presentada/pagada).
|
||||
*/
|
||||
export async function resolverAlerta(pool: Pool, id: string): Promise<void> {
|
||||
await pool.query(
|
||||
`UPDATE alertas SET resuelta = true, leida = true WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
}
|
||||
108
apps/api/src/services/alertas.service.ts
Normal file
108
apps/api/src/services/alertas.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { Pool } from 'pg';
|
||||
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';
|
||||
|
||||
export async function getAlertas(
|
||||
pool: Pool,
|
||||
filters: { leida?: boolean; resuelta?: boolean; prioridad?: string }
|
||||
): Promise<AlertaFull[]> {
|
||||
let whereClause = 'WHERE 1=1';
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.leida !== undefined) {
|
||||
whereClause += ` AND leida = $${paramIndex++}`;
|
||||
params.push(filters.leida);
|
||||
}
|
||||
if (filters.resuelta !== undefined) {
|
||||
whereClause += ` AND resuelta = $${paramIndex++}`;
|
||||
params.push(filters.resuelta);
|
||||
}
|
||||
if (filters.prioridad) {
|
||||
whereClause += ` AND prioridad = $${paramIndex++}`;
|
||||
params.push(filters.prioridad);
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
FROM alertas
|
||||
${whereClause}
|
||||
ORDER BY
|
||||
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
||||
created_at DESC
|
||||
`, params);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getAlertaById(pool: Pool, id: number): Promise<AlertaFull | null> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
FROM alertas
|
||||
WHERE id = $1
|
||||
`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function createAlerta(pool: Pool, data: AlertaCreate): Promise<AlertaFull> {
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
`, [data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function updateAlerta(pool: Pool, id: number, data: AlertaUpdate): Promise<AlertaFull> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (data.leida !== undefined) {
|
||||
sets.push(`leida = $${paramIndex++}`);
|
||||
params.push(data.leida);
|
||||
}
|
||||
if (data.resuelta !== undefined) {
|
||||
sets.push(`resuelta = $${paramIndex++}`);
|
||||
params.push(data.resuelta);
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
UPDATE alertas
|
||||
SET ${sets.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
`, params);
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function deleteAlerta(pool: Pool, id: number): Promise<void> {
|
||||
await pool.query(`DELETE FROM alertas WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
export async function getStats(pool: Pool): Promise<AlertasStats> {
|
||||
const { rows: [stats] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*)::int as total,
|
||||
COUNT(CASE WHEN leida = false THEN 1 END)::int as "noLeidas",
|
||||
COUNT(CASE WHEN prioridad = 'alta' AND resuelta = false THEN 1 END)::int as alta,
|
||||
COUNT(CASE WHEN prioridad = 'media' AND resuelta = false THEN 1 END)::int as media,
|
||||
COUNT(CASE WHEN prioridad = 'baja' AND resuelta = false THEN 1 END)::int as baja
|
||||
FROM alertas
|
||||
`);
|
||||
return stats;
|
||||
}
|
||||
|
||||
export async function markAllAsRead(pool: Pool): Promise<void> {
|
||||
await pool.query(`UPDATE alertas SET leida = true WHERE leida = false`);
|
||||
}
|
||||
653
apps/api/src/services/auth.service.ts
Normal file
653
apps/api/src/services/auth.service.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { hashPassword, verifyPassword } from '../auth/passwords.js';
|
||||
import { generateAccessToken, generateRefreshToken, verifyToken } from '../auth/tokens.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { auditLog } from '../utils/audit.js';
|
||||
import { getPlatformRoles } from '../utils/platform-admin.js';
|
||||
import { getUserTenants, verifyMembership } from '../utils/memberships.js';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { invalidateTokenVersionCache } from '../middlewares/auth.middleware.js';
|
||||
import type { LoginRequest, RegisterRequest, LoginResponse, Role } from '@horux/shared';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: data.usuario.email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new AppError(400, 'El email ya está registrado');
|
||||
}
|
||||
|
||||
const existingTenant = await prisma.tenant.findUnique({
|
||||
where: { rfc: data.empresa.rfc },
|
||||
});
|
||||
|
||||
if (existingTenant) {
|
||||
throw new AppError(400, 'El RFC ya está registrado');
|
||||
}
|
||||
|
||||
// Provision a dedicated database for this tenant
|
||||
const databaseName = await tenantDb.provisionDatabase(data.empresa.rfc);
|
||||
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
nombre: data.empresa.nombre,
|
||||
rfc: data.empresa.rfc.toUpperCase(),
|
||||
plan: 'trial',
|
||||
databaseName,
|
||||
},
|
||||
});
|
||||
|
||||
const passwordHash = await hashPassword(data.usuario.password);
|
||||
const adminRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
|
||||
if (!adminRol) throw new AppError(500, 'Rol admin no encontrado en catálogo');
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.usuario.email.toLowerCase(),
|
||||
passwordHash,
|
||||
nombre: data.usuario.nombre,
|
||||
lastTenantId: tenant.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Crea membership owner del caller en el tenant recién creado (fase 4 multi-tenant)
|
||||
await prisma.tenantMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tenantId: tenant.id,
|
||||
rolId: adminRol.id,
|
||||
isOwner: true,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const ownerRole: Role = 'owner';
|
||||
const tokenPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: ownerRole,
|
||||
tenantId: tenant.id,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
const refreshToken = generateRefreshToken(tokenPayload);
|
||||
|
||||
await prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nombre: user.nombre,
|
||||
role: ownerRole,
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.nombre,
|
||||
tenantRfc: tenant.rfc,
|
||||
plan: tenant.plan,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: data.email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Credenciales inválidas');
|
||||
}
|
||||
|
||||
if (!user.active) {
|
||||
throw new AppError(401, 'Usuario desactivado');
|
||||
}
|
||||
|
||||
const isValidPassword = await verifyPassword(data.password, user.passwordHash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
throw new AppError(401, 'Credenciales inválidas');
|
||||
}
|
||||
|
||||
// Resuelve el tenant activo desde memberships. Prefiere `lastTenantId` si
|
||||
// existe Y el user tiene membership activa ahí; sino cae al primer membership
|
||||
// por joinedAt ASC.
|
||||
const allMemberships = await prisma.tenantMembership.findMany({
|
||||
where: { userId: user.id, active: true, tenant: { active: true } },
|
||||
include: { tenant: true, rol: true },
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
});
|
||||
|
||||
let activeTenant;
|
||||
let activeRole: Role;
|
||||
|
||||
if (allMemberships.length === 0) {
|
||||
// Edge case: user sin membership activa. Si tiene platformRoles (admin
|
||||
// global), permite login con cualquier tenant activo como nominal — su
|
||||
// trabajo real es vía impersonación desde /clientes. Sin esto, no podría
|
||||
// ni entrar a la plataforma para crear el primer cliente.
|
||||
const earlyPlatformRoles = await getPlatformRoles(user.id);
|
||||
if (earlyPlatformRoles.length === 0) {
|
||||
throw new AppError(401, 'No tienes acceso a ninguna empresa activa');
|
||||
}
|
||||
const fallbackTenant = await prisma.tenant.findFirst({
|
||||
where: { active: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (!fallbackTenant) {
|
||||
throw new AppError(503, 'No hay tenants activos en el sistema. Ejecuta `pnpm db:seed` para bootstrap.');
|
||||
}
|
||||
activeTenant = fallbackTenant;
|
||||
activeRole = 'visor' as Role; // mínimo — la autorización real viene de platformRoles
|
||||
} else {
|
||||
const preferred = user.lastTenantId
|
||||
? allMemberships.find(m => m.tenantId === user.lastTenantId)
|
||||
: null;
|
||||
const activeMembership = preferred ?? allMemberships[0];
|
||||
activeTenant = activeMembership.tenant;
|
||||
activeRole = activeMembership.rol.nombre as Role;
|
||||
}
|
||||
|
||||
// `loginCount` se incrementa SOLO en login (NO en refresh) — es la métrica
|
||||
// que dispara el auto-dismiss del onboarding tras N sesiones.
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
lastLogin: new Date(),
|
||||
lastTenantId: activeTenant.id,
|
||||
loginCount: { increment: 1 },
|
||||
},
|
||||
select: { loginCount: true, onboardingDismissedAt: true },
|
||||
});
|
||||
|
||||
auditLog({
|
||||
userId: user.id,
|
||||
tenantId: activeTenant.id,
|
||||
action: 'user.login',
|
||||
metadata: { email: user.email, tenantRfc: activeTenant.rfc },
|
||||
});
|
||||
|
||||
const [platformRoles, tenants] = await Promise.all([
|
||||
getPlatformRoles(user.id),
|
||||
getUserTenants(user.id),
|
||||
]);
|
||||
|
||||
const tokenPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: activeRole,
|
||||
tenantId: activeTenant.id,
|
||||
platformRoles,
|
||||
tokenVersion: user.tokenVersion,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
const refreshToken = generateRefreshToken(tokenPayload);
|
||||
|
||||
await prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nombre: user.nombre,
|
||||
role: activeRole,
|
||||
tenantId: activeTenant.id,
|
||||
tenantName: activeTenant.nombre,
|
||||
tenantRfc: activeTenant.rfc,
|
||||
plan: activeTenant.plan,
|
||||
platformRoles,
|
||||
tenants,
|
||||
loginCount: updatedUser.loginCount,
|
||||
onboardingDismissedAt: updatedUser.onboardingDismissedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
// Use a transaction to prevent race conditions
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const storedToken = await tx.refreshToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (!storedToken) {
|
||||
throw new AppError(401, 'Token inválido');
|
||||
}
|
||||
|
||||
if (storedToken.expiresAt < new Date()) {
|
||||
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
|
||||
throw new AppError(401, 'Token expirado');
|
||||
}
|
||||
|
||||
const payload = verifyToken(token);
|
||||
|
||||
const user = await tx.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
});
|
||||
|
||||
if (!user || !user.active) {
|
||||
throw new AppError(401, 'Usuario no encontrado o desactivado');
|
||||
}
|
||||
|
||||
// Re-valida que el user sigue teniendo membership activa en el tenant del
|
||||
// JWT. Si lo removieron de ahí, cae al primer membership disponible.
|
||||
const currentMembership = await tx.tenantMembership.findFirst({
|
||||
where: { userId: user.id, tenantId: payload.tenantId, active: true, tenant: { active: true } },
|
||||
include: { tenant: true, rol: true },
|
||||
});
|
||||
let activeMembership = currentMembership;
|
||||
if (!activeMembership) {
|
||||
activeMembership = await tx.tenantMembership.findFirst({
|
||||
where: { userId: user.id, active: true, tenant: { active: true } },
|
||||
include: { tenant: true, rol: true },
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
});
|
||||
}
|
||||
if (!activeMembership) {
|
||||
throw new AppError(401, 'No tienes acceso a ninguna empresa activa');
|
||||
}
|
||||
|
||||
// Use deleteMany to avoid error if already deleted (race condition)
|
||||
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
|
||||
|
||||
const platformRoles = await getPlatformRoles(user.id);
|
||||
|
||||
const newTokenPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: activeMembership.rol.nombre as Role,
|
||||
tenantId: activeMembership.tenantId,
|
||||
platformRoles,
|
||||
tokenVersion: user.tokenVersion,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(newTokenPayload);
|
||||
const refreshToken = generateRefreshToken(newTokenPayload);
|
||||
|
||||
await tx.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(token: string): Promise<void> {
|
||||
// Busca el refreshToken antes de borrarlo para capturar el userId en auditoría
|
||||
const rt = await prisma.refreshToken.findFirst({
|
||||
where: { token },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (rt) {
|
||||
const tenantId = (await prisma.user.findUnique({
|
||||
where: { id: rt.userId },
|
||||
select: { lastTenantId: true },
|
||||
}))?.lastTenantId ?? undefined;
|
||||
|
||||
auditLog({
|
||||
userId: rt.userId,
|
||||
tenantId,
|
||||
action: 'user.logout',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Password reset
|
||||
// ============================================================================
|
||||
|
||||
const PASSWORD_RESET_EXPIRY_MS = 60 * 60 * 1000; // 1 hora
|
||||
|
||||
/**
|
||||
* Solicita recuperación de contraseña. No revela si el email existe (anti-enumeration).
|
||||
*
|
||||
* Si el email es válido y user activo:
|
||||
* - Invalida cualquier token previo no usado del mismo user
|
||||
* - Genera token criptográficamente seguro (32 bytes hex)
|
||||
* - Envía email con link de reset (expira en 1h)
|
||||
*
|
||||
* Rate limit: aplicar en la capa de rutas (3/hora por IP).
|
||||
*/
|
||||
export async function requestPasswordReset(email: string): Promise<void> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
select: { id: true, email: true, nombre: true, active: true, lastTenantId: true },
|
||||
});
|
||||
|
||||
// Respuesta idéntica para email existente/no-existente (anti-enumeration)
|
||||
if (!user || !user.active) {
|
||||
console.log(`[PasswordReset] Request para email inexistente o inactivo: ${normalizedEmail}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalida tokens previos no usados (marca como usados)
|
||||
await prisma.passwordResetToken.updateMany({
|
||||
where: { userId: user.id, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + PASSWORD_RESET_EXPIRY_MS);
|
||||
|
||||
await prisma.passwordResetToken.create({
|
||||
data: { userId: user.id, token, expiresAt },
|
||||
});
|
||||
|
||||
const resetUrl = `${env.FRONTEND_URL}/reset-password?token=${token}`;
|
||||
emailService.sendPasswordReset(user.email, { nombre: user.nombre, resetUrl })
|
||||
.catch(err => console.error('[EMAIL] Password reset notification failed:', err));
|
||||
|
||||
auditLog({
|
||||
userId: user.id,
|
||||
tenantId: user.lastTenantId ?? undefined,
|
||||
action: 'user.password_reset_requested',
|
||||
metadata: { email: user.email },
|
||||
});
|
||||
|
||||
console.log(`[PasswordReset] Token emitido para ${normalizedEmail}, expira ${expiresAt.toISOString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirma recuperación de contraseña con token válido + nueva contraseña.
|
||||
*
|
||||
* Validaciones:
|
||||
* - Password mínimo 8 caracteres
|
||||
* - Token existe, no usado, no expirado
|
||||
*
|
||||
* Al éxito:
|
||||
* - Actualiza password hash
|
||||
* - Marca token como usado (single-use)
|
||||
* - Borra todos los refresh tokens del user (invalida sesiones activas → re-login forzado)
|
||||
*/
|
||||
export async function confirmPasswordReset(token: string, newPassword: string): Promise<void> {
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
throw new AppError(400, 'La contraseña debe tener al menos 8 caracteres');
|
||||
}
|
||||
|
||||
const record = await prisma.passwordResetToken.findUnique({
|
||||
where: { token },
|
||||
select: { id: true, userId: true, usedAt: true, expiresAt: true },
|
||||
});
|
||||
|
||||
if (!record) throw new AppError(400, 'Token inválido');
|
||||
if (record.usedAt) throw new AppError(400, 'Este enlace ya fue usado. Solicita uno nuevo.');
|
||||
if (record.expiresAt < new Date()) throw new AppError(400, 'El enlace expiró. Solicita uno nuevo.');
|
||||
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
|
||||
await prisma.$transaction([
|
||||
// Actualiza hash + incrementa tokenVersion (invalida access tokens vivos)
|
||||
prisma.user.update({
|
||||
where: { id: record.userId },
|
||||
data: { passwordHash, tokenVersion: { increment: 1 } },
|
||||
}),
|
||||
prisma.passwordResetToken.update({
|
||||
where: { id: record.id },
|
||||
data: { usedAt: new Date() },
|
||||
}),
|
||||
// Invalida todos los refresh tokens activos del user
|
||||
prisma.refreshToken.deleteMany({
|
||||
where: { userId: record.userId },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Propaga el incremento al cache del middleware (en todos los PM2 workers)
|
||||
invalidateTokenVersionCache(record.userId);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: record.userId },
|
||||
select: { lastTenantId: true, email: true },
|
||||
});
|
||||
|
||||
auditLog({
|
||||
userId: record.userId,
|
||||
tenantId: user?.lastTenantId ?? undefined,
|
||||
action: 'user.password_reset_completed',
|
||||
metadata: { email: user?.email },
|
||||
});
|
||||
|
||||
console.log(`[PasswordReset] Completado para user ${record.userId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Change password (authenticated) + Logout all
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Cambia la contraseña de un user autenticado. Requiere password actual para
|
||||
* prevenir cambios por alguien con acceso temporal a la sesión (ej: laptop
|
||||
* compartida dejada abierta). Incrementa tokenVersion — invalida TODAS las
|
||||
* sesiones activas del user (forza re-login en otros dispositivos).
|
||||
*/
|
||||
export async function changePassword(params: {
|
||||
userId: string;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}): Promise<void> {
|
||||
if (params.newPassword.length < 8) {
|
||||
throw new AppError(400, 'La contraseña debe tener al menos 8 caracteres');
|
||||
}
|
||||
if (params.currentPassword === params.newPassword) {
|
||||
throw new AppError(400, 'La nueva contraseña debe ser distinta a la actual');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: params.userId },
|
||||
select: { id: true, passwordHash: true, email: true, lastTenantId: true, active: true },
|
||||
});
|
||||
if (!user || !user.active) throw new AppError(401, 'Usuario no encontrado');
|
||||
|
||||
const validCurrent = await verifyPassword(params.currentPassword, user.passwordHash);
|
||||
if (!validCurrent) throw new AppError(401, 'Contraseña actual incorrecta');
|
||||
|
||||
const newHash = await hashPassword(params.newPassword);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash: newHash, tokenVersion: { increment: 1 } },
|
||||
}),
|
||||
prisma.refreshToken.deleteMany({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
invalidateTokenVersionCache(user.id);
|
||||
|
||||
auditLog({
|
||||
userId: user.id,
|
||||
tenantId: user.lastTenantId ?? undefined,
|
||||
action: 'user.password_changed',
|
||||
metadata: { email: user.email },
|
||||
});
|
||||
|
||||
console.log(`[ChangePassword] Completado para user ${user.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cierra todas las sesiones activas del user. Usado por el botón
|
||||
* "Cerrar todas las sesiones" en /configuracion/seguridad. Incrementa
|
||||
* tokenVersion + borra refresh tokens. El user se queda sin acceso y debe
|
||||
* re-loguearse (incluyendo la sesión actual, por diseño — es lo que el
|
||||
* usuario pidió explícitamente).
|
||||
*/
|
||||
export async function logoutAllSessions(userId: string): Promise<void> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { lastTenantId: true, email: true },
|
||||
});
|
||||
if (!user) throw new AppError(404, 'Usuario no encontrado');
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
}),
|
||||
prisma.refreshToken.deleteMany({ where: { userId } }),
|
||||
]);
|
||||
|
||||
invalidateTokenVersionCache(userId);
|
||||
|
||||
auditLog({
|
||||
userId,
|
||||
tenantId: user.lastTenantId ?? undefined,
|
||||
action: 'user.sessions_invalidated',
|
||||
metadata: { email: user.email, reason: 'logout_all' },
|
||||
});
|
||||
|
||||
console.log(`[LogoutAll] Sesiones invalidadas para user ${userId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Onboarding dismiss
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Marca el onboarding como dismissed. Idempotente — si ya estaba seteado, no
|
||||
* sobrescribe el timestamp original (preserva la fecha del primer dismiss).
|
||||
*/
|
||||
export async function dismissOnboarding(userId: string): Promise<{ onboardingDismissedAt: Date }> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { onboardingDismissedAt: true },
|
||||
});
|
||||
if (!user) throw new AppError(404, 'Usuario no encontrado');
|
||||
|
||||
if (user.onboardingDismissedAt) {
|
||||
return { onboardingDismissedAt: user.onboardingDismissedAt };
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { onboardingDismissedAt: new Date() },
|
||||
select: { onboardingDismissedAt: true },
|
||||
});
|
||||
return { onboardingDismissedAt: updated.onboardingDismissedAt! };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Switch tenant (multi-membership)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Cambia el tenant activo del user. Valida que tenga membership activa en el
|
||||
* tenant destino, luego emite un nuevo par de tokens apuntando a ese tenantId
|
||||
* (con el rol que tiene en ese tenant específico). El refresh token actual se
|
||||
* invalida — el user opera con el par nuevo desde este momento.
|
||||
*
|
||||
* Casos de uso:
|
||||
* - Owner con varias empresas cambia entre ellas
|
||||
* - Contador que atiende múltiples clientes cambia de empresa activa
|
||||
*
|
||||
* Un user con 1 sola membership no debería llamarlo (no cambia nada), pero si
|
||||
* lo hace funciona igual: le da tokens nuevos apuntando al mismo tenant.
|
||||
*/
|
||||
export async function switchTenant(params: {
|
||||
userId: string;
|
||||
currentRefreshToken: string;
|
||||
targetTenantId: string;
|
||||
}): Promise<LoginResponse> {
|
||||
const membership = await verifyMembership(params.userId, params.targetTenantId);
|
||||
if (!membership) {
|
||||
throw new AppError(403, 'No tienes acceso a esa empresa');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: params.userId },
|
||||
});
|
||||
if (!user || !user.active) throw new AppError(401, 'Usuario no encontrado');
|
||||
|
||||
const targetTenant = await prisma.tenant.findUnique({
|
||||
where: { id: params.targetTenantId },
|
||||
});
|
||||
if (!targetTenant || !targetTenant.active) {
|
||||
throw new AppError(404, 'Empresa no encontrada o desactivada');
|
||||
}
|
||||
|
||||
// Persiste el target como "último tenant activo" — al re-loguear caerá aquí
|
||||
// sin tener que volver a hacer switch.
|
||||
const previousTenantId = user.lastTenantId;
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastTenantId: targetTenant.id },
|
||||
});
|
||||
|
||||
// Invalida el refresh token actual (puede no existir si el caller pasó el
|
||||
// access token por error — deleteMany es idempotente).
|
||||
await prisma.refreshToken.deleteMany({ where: { token: params.currentRefreshToken } });
|
||||
|
||||
const [platformRoles, tenants] = await Promise.all([
|
||||
getPlatformRoles(user.id),
|
||||
getUserTenants(user.id),
|
||||
]);
|
||||
|
||||
const tokenPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: membership.rolNombre,
|
||||
tenantId: targetTenant.id,
|
||||
platformRoles,
|
||||
tokenVersion: user.tokenVersion,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
const refreshToken = generateRefreshToken(tokenPayload);
|
||||
|
||||
await prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
auditLog({
|
||||
userId: user.id,
|
||||
tenantId: targetTenant.id,
|
||||
action: 'user.tenant_switched',
|
||||
metadata: { from: previousTenantId ?? null, to: targetTenant.id, targetRfc: targetTenant.rfc },
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nombre: user.nombre,
|
||||
role: membership.rolNombre,
|
||||
tenantId: targetTenant.id,
|
||||
tenantName: targetTenant.nombre,
|
||||
tenantRfc: targetTenant.rfc,
|
||||
plan: targetTenant.plan,
|
||||
platformRoles,
|
||||
tenants,
|
||||
},
|
||||
};
|
||||
}
|
||||
63
apps/api/src/services/bancos.service.ts
Normal file
63
apps/api/src/services/bancos.service.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export interface Banco {
|
||||
id: number;
|
||||
banco: string;
|
||||
terminacionCuenta: string;
|
||||
creadoEn: string;
|
||||
}
|
||||
|
||||
export async function getBancos(pool: Pool, contribuyenteId?: string | null): Promise<Banco[]> {
|
||||
const conditions = [];
|
||||
const params: unknown[] = [];
|
||||
if (contribuyenteId) {
|
||||
params.push(contribuyenteId);
|
||||
conditions.push(`contribuyente_id = $${params.length}`);
|
||||
}
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, banco, terminacion_cuenta as "terminacionCuenta",
|
||||
creado_en as "creadoEn"
|
||||
FROM bancos ${where} ORDER BY banco
|
||||
`, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function createBanco(pool: Pool, data: { banco: string; terminacionCuenta: string; contribuyenteId?: string }): Promise<Banco> {
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO bancos (banco, terminacion_cuenta, contribuyente_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, banco, terminacion_cuenta as "terminacionCuenta", creado_en as "creadoEn"
|
||||
`, [data.banco, data.terminacionCuenta, data.contribuyenteId || null]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function updateBanco(pool: Pool, id: number, data: { banco?: string; terminacionCuenta?: string }): Promise<Banco> {
|
||||
const fields: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.banco) { fields.push(`banco = $${idx++}`); params.push(data.banco); }
|
||||
if (data.terminacionCuenta) { fields.push(`terminacion_cuenta = $${idx++}`); params.push(data.terminacionCuenta); }
|
||||
|
||||
if (fields.length === 0) throw new Error('Nada que actualizar');
|
||||
|
||||
params.push(id);
|
||||
const { rows } = await pool.query(`
|
||||
UPDATE bancos SET ${fields.join(', ')} WHERE id = $${idx}
|
||||
RETURNING id, banco, terminacion_cuenta as "terminacionCuenta", creado_en as "creadoEn"
|
||||
`, params);
|
||||
|
||||
if (rows.length === 0) throw new Error('Banco no encontrado');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function deleteBanco(pool: Pool, id: number): Promise<void> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT COUNT(*)::int as count FROM conciliaciones WHERE id_banco = $1`, [id]
|
||||
);
|
||||
if (rows[0].count > 0) {
|
||||
throw new Error('No se puede eliminar un banco con conciliaciones asociadas');
|
||||
}
|
||||
await pool.query(`DELETE FROM bancos WHERE id = $1`, [id]);
|
||||
}
|
||||
271
apps/api/src/services/calendario-fiscal.service.ts
Normal file
271
apps/api/src/services/calendario-fiscal.service.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import { getRegimenesActivosClaves } from './regimen.service.js';
|
||||
import { getObligaciones } from './obligaciones.service.js';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
interface EventoGenerado {
|
||||
titulo: string;
|
||||
tipo: string;
|
||||
fechaLimite: string;
|
||||
recurrencia: string;
|
||||
completado: boolean;
|
||||
descripcion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtener días inhábiles del año como Set de strings 'YYYY-MM-DD'
|
||||
*/
|
||||
async function getDiasInhabiles(año: number): Promise<Set<string>> {
|
||||
const rows = await prisma.diaInhabil.findMany({
|
||||
where: {
|
||||
fecha: {
|
||||
gte: new Date(`${año}-01-01`),
|
||||
lte: new Date(`${año}-12-31`),
|
||||
},
|
||||
},
|
||||
});
|
||||
return new Set(rows.map(r => r.fecha.toISOString().split('T')[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Si la fecha cae en día inhábil (sábado, domingo, festivo), recorrer al siguiente día hábil
|
||||
*/
|
||||
function siguienteDiaHabil(fecha: Date, inhabiles: Set<string>): Date {
|
||||
const d = new Date(fecha);
|
||||
while (true) {
|
||||
const dow = d.getDay();
|
||||
const str = d.toISOString().split('T')[0];
|
||||
if (dow !== 0 && dow !== 6 && !inhabiles.has(str)) {
|
||||
return d;
|
||||
}
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Agregar N días hábiles a una fecha
|
||||
*/
|
||||
function agregarDiasHabiles(fecha: Date, dias: number, inhabiles: Set<string>): Date {
|
||||
const d = new Date(fecha);
|
||||
let added = 0;
|
||||
while (added < dias) {
|
||||
d.setDate(d.getDate() + 1);
|
||||
const dow = d.getDay();
|
||||
const str = d.toISOString().split('T')[0];
|
||||
if (dow !== 0 && dow !== 6 && !inhabiles.has(str)) {
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula días adicionales por RFC según Resolución Miscelánea Fiscal
|
||||
* Sexto dígito numérico del RFC:
|
||||
* 1-2: +1 día, 3-4: +2, 5-6: +3, 7-8: +4, 9-0: +5
|
||||
*/
|
||||
function diasExtensionRfc(rfc: string): number {
|
||||
// Extraer sexto dígito numérico
|
||||
const numeros = rfc.replace(/[^0-9]/g, '');
|
||||
if (numeros.length < 6) return 0;
|
||||
const sexto = parseInt(numeros[5]);
|
||||
|
||||
if (sexto === 1 || sexto === 2) return 1;
|
||||
if (sexto === 3 || sexto === 4) return 2;
|
||||
if (sexto === 5 || sexto === 6) return 3;
|
||||
if (sexto === 7 || sexto === 8) return 4;
|
||||
return 5; // 9 o 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera los eventos fiscales para un tenant en un año dado,
|
||||
* basándose en el catálogo central y las reglas del SAT.
|
||||
*/
|
||||
export async function generarEventosFiscales(
|
||||
tenantId: string,
|
||||
año: number,
|
||||
): Promise<EventoGenerado[]> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { rfc: true },
|
||||
});
|
||||
if (!tenant) return [];
|
||||
|
||||
const rfc = tenant.rfc;
|
||||
const inhabiles = await getDiasInhabiles(año);
|
||||
|
||||
// Regímenes activos del tenant
|
||||
const activos = await getRegimenesActivosClaves(tenantId);
|
||||
const activosSet = new Set(activos);
|
||||
|
||||
const catalogo = await prisma.eventoFiscalCatalogo.findMany({
|
||||
where: { activo: true },
|
||||
});
|
||||
|
||||
const eventos: EventoGenerado[] = [];
|
||||
const hoy = new Date();
|
||||
|
||||
for (const cat of catalogo) {
|
||||
// Filtrar por régimen: si el evento es para regímenes específicos,
|
||||
// verificar que el tenant tenga al menos uno de ellos activo
|
||||
if (cat.regimenes !== 'todos' && activos.length > 0) {
|
||||
const regimenesEvento = cat.regimenes.split(',').map(r => r.trim());
|
||||
const aplica = regimenesEvento.some(r => activosSet.has(r));
|
||||
if (!aplica) continue;
|
||||
}
|
||||
|
||||
if (cat.recurrencia === 'mensual') {
|
||||
for (let mes = 1; mes <= 12; mes++) {
|
||||
// Mes relativo: 1 = mes posterior al que se declara
|
||||
const mesObligacion = mes; // mes que se declara
|
||||
const mesVencimiento = mes + cat.mesRelativo;
|
||||
const añoVencimiento = mesVencimiento > 12 ? año + 1 : año;
|
||||
const mesReal = mesVencimiento > 12 ? mesVencimiento - 12 : mesVencimiento;
|
||||
|
||||
// Fecha base
|
||||
const lastDay = new Date(añoVencimiento, mesReal, 0).getDate();
|
||||
const dia = Math.min(cat.diaBase, lastDay);
|
||||
let fechaLimite = new Date(añoVencimiento, mesReal - 1, dia);
|
||||
|
||||
// Ajustar a día hábil
|
||||
fechaLimite = siguienteDiaHabil(fechaLimite, inhabiles);
|
||||
|
||||
// Extensión por RFC
|
||||
if (cat.usaExtensionRfc) {
|
||||
const diasExtra = diasExtensionRfc(rfc);
|
||||
fechaLimite = agregarDiasHabiles(fechaLimite, diasExtra, inhabiles);
|
||||
}
|
||||
|
||||
const completado = fechaLimite < hoy;
|
||||
|
||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||
|
||||
eventos.push({
|
||||
titulo: cat.titulo,
|
||||
tipo: cat.tipo,
|
||||
fechaLimite: fechaLimite.toISOString().split('T')[0],
|
||||
recurrencia: cat.recurrencia,
|
||||
completado,
|
||||
descripcion: `${cat.titulo} — ${meses[mesObligacion - 1]} ${año}`,
|
||||
});
|
||||
}
|
||||
} else if (cat.recurrencia === 'anual' && cat.mesFijo) {
|
||||
const lastDay = new Date(año, cat.mesFijo, 0).getDate();
|
||||
const dia = Math.min(cat.diaBase, lastDay);
|
||||
let fechaLimite = new Date(año, cat.mesFijo - 1, dia);
|
||||
|
||||
fechaLimite = siguienteDiaHabil(fechaLimite, inhabiles);
|
||||
|
||||
eventos.push({
|
||||
titulo: cat.titulo,
|
||||
tipo: cat.tipo,
|
||||
fechaLimite: fechaLimite.toISOString().split('T')[0],
|
||||
recurrencia: cat.recurrencia,
|
||||
completado: fechaLimite < hoy,
|
||||
descripcion: `${cat.titulo} — Ejercicio ${año - 1}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ordenar por fecha
|
||||
eventos.sort((a, b) => a.fechaLimite.localeCompare(b.fechaLimite));
|
||||
|
||||
return eventos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera eventos de calendario a partir de las obligaciones reales de un contribuyente.
|
||||
* Usado en tenants despacho — reemplaza el catálogo estático por las obligaciones
|
||||
* registradas en obligaciones_contribuyente con su estado de cumplimiento en obligacion_periodos.
|
||||
*/
|
||||
export async function generarEventosDesdeObligaciones(
|
||||
pool: Pool,
|
||||
contribuyenteId: string | null,
|
||||
año: number,
|
||||
): Promise<EventoGenerado[]> {
|
||||
if (!contribuyenteId) return [];
|
||||
|
||||
const inhabiles = await getDiasInhabiles(año);
|
||||
const obligaciones = await getObligaciones(pool, contribuyenteId);
|
||||
const activas = obligaciones.filter(o => o.activa);
|
||||
const eventos: EventoGenerado[] = [];
|
||||
|
||||
// Get completion records for this contribuyente
|
||||
const { rows: completions } = await pool.query(`
|
||||
SELECT op.obligacion_id, op.periodo, op.completada
|
||||
FROM obligacion_periodos op
|
||||
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
|
||||
WHERE oc.contribuyente_id = $1
|
||||
`, [contribuyenteId]);
|
||||
|
||||
const completionMap = new Map<string, boolean>();
|
||||
for (const c of completions) {
|
||||
completionMap.set(`${c.obligacion_id}:${c.periodo}`, c.completada);
|
||||
}
|
||||
|
||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||
|
||||
for (const ob of activas) {
|
||||
const freq = ob.frecuencia || 'mensual';
|
||||
|
||||
// Determine which months this obligation applies to
|
||||
const monthsToGenerate: number[] = [];
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
if (freq === 'mensual') monthsToGenerate.push(m);
|
||||
else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m);
|
||||
else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m);
|
||||
else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m);
|
||||
// 'eventual' and unknown: skip auto-generation
|
||||
}
|
||||
|
||||
for (const mes of monthsToGenerate) {
|
||||
// Parse day from fechaLimite text; default to 17
|
||||
let diaBase = 17;
|
||||
if (ob.fechaLimite) {
|
||||
const matchDia = ob.fechaLimite.match(/d[íi]a?\s*(\d+)/i);
|
||||
if (matchDia) diaBase = parseInt(matchDia[1]);
|
||||
// "Último día" → last day of month
|
||||
if (ob.fechaLimite.toLowerCase().includes('ltimo d')) diaBase = 0;
|
||||
}
|
||||
|
||||
// Deadline is usually next month for mensual/bimestral/trimestral obligations
|
||||
let mesVencimiento = mes + 1;
|
||||
let añoVencimiento = año;
|
||||
if (mesVencimiento > 12) { mesVencimiento = 1; añoVencimiento++; }
|
||||
|
||||
// For annual obligations the deadline month IS the month (marzo/abril)
|
||||
if (freq === 'anual') {
|
||||
mesVencimiento = mes;
|
||||
añoVencimiento = año;
|
||||
}
|
||||
|
||||
const lastDayOfMonth = new Date(añoVencimiento, mesVencimiento, 0).getDate();
|
||||
const dia = diaBase === 0 ? lastDayOfMonth : Math.min(diaBase, lastDayOfMonth);
|
||||
let fechaLimite = new Date(añoVencimiento, mesVencimiento - 1, dia);
|
||||
fechaLimite = siguienteDiaHabil(fechaLimite, inhabiles);
|
||||
|
||||
const periodo = `${año}-${String(mes).padStart(2, '0')}`;
|
||||
const isCompleted = completionMap.get(`${ob.id}:${periodo}`) === true;
|
||||
const isPastDue = !isCompleted && fechaLimite < new Date();
|
||||
|
||||
// Type encodes the status for calendar coloring
|
||||
const tipoEvento = isCompleted
|
||||
? 'obligacion-completada'
|
||||
: isPastDue
|
||||
? 'obligacion-atrasada'
|
||||
: 'obligacion-pendiente';
|
||||
|
||||
eventos.push({
|
||||
titulo: ob.nombre,
|
||||
tipo: tipoEvento,
|
||||
fechaLimite: fechaLimite.toISOString().split('T')[0],
|
||||
recurrencia: freq,
|
||||
completado: isCompleted,
|
||||
descripcion: `${ob.nombre} — ${meses[mes - 1]} ${año}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
eventos.sort((a, b) => a.fechaLimite.localeCompare(b.fechaLimite));
|
||||
return eventos;
|
||||
}
|
||||
160
apps/api/src/services/cartera.service.ts
Normal file
160
apps/api/src/services/cartera.service.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
export interface CarteraRow {
|
||||
id: string;
|
||||
supervisorUserId: string | null;
|
||||
auxiliarUserId: string | null;
|
||||
parentId: string | null;
|
||||
nombre: string;
|
||||
descripcion: string | null;
|
||||
createdAt: string;
|
||||
entidadesCount?: number;
|
||||
subcarterasCount?: number;
|
||||
}
|
||||
|
||||
const BASE_SELECT = `
|
||||
SELECT c.id, c.supervisor_user_id AS "supervisorUserId",
|
||||
c.auxiliar_user_id AS "auxiliarUserId", c.parent_id AS "parentId",
|
||||
c.nombre, c.descripcion, c.created_at AS "createdAt",
|
||||
(SELECT count(*) FROM cartera_entidades ce WHERE ce.cartera_id = c.id)::int AS "entidadesCount",
|
||||
(SELECT count(*) FROM carteras sc WHERE sc.parent_id = c.id)::int AS "subcarterasCount"
|
||||
FROM carteras c
|
||||
`;
|
||||
|
||||
/**
|
||||
* List top-level carteras (parent_id IS NULL).
|
||||
* If supervisorUserId is provided, filter by that supervisor.
|
||||
*/
|
||||
export async function listCarteras(pool: Pool, supervisorUserId?: string): Promise<CarteraRow[]> {
|
||||
const conditions = ['c.parent_id IS NULL'];
|
||||
const params: unknown[] = [];
|
||||
if (supervisorUserId) {
|
||||
params.push(supervisorUserId);
|
||||
conditions.push(`c.supervisor_user_id = $${params.length}`);
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`${BASE_SELECT} WHERE ${conditions.join(' AND ')} ORDER BY c.created_at DESC`,
|
||||
params,
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* List subcarteras of a parent cartera.
|
||||
*/
|
||||
export async function listSubcarteras(pool: Pool, parentId: string): Promise<CarteraRow[]> {
|
||||
const { rows } = await pool.query(
|
||||
`${BASE_SELECT} WHERE c.parent_id = $1 ORDER BY c.nombre`,
|
||||
[parentId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getCarteraById(pool: Pool, id: string): Promise<CarteraRow | null> {
|
||||
const { rows } = await pool.query(`${BASE_SELECT} WHERE c.id = $1`, [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function createCartera(pool: Pool, data: {
|
||||
supervisorUserId: string;
|
||||
nombre: string;
|
||||
descripcion?: string;
|
||||
}): Promise<CarteraRow> {
|
||||
const { rows: [row] } = await pool.query(`
|
||||
INSERT INTO carteras (supervisor_user_id, nombre, descripcion)
|
||||
VALUES ($1, $2, $3) RETURNING id
|
||||
`, [data.supervisorUserId, data.nombre, data.descripcion ?? null]);
|
||||
return (await getCarteraById(pool, row.id))!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subcartera within a parent cartera, assigned to an auxiliar.
|
||||
*/
|
||||
export async function createSubcartera(pool: Pool, data: {
|
||||
parentId: string;
|
||||
auxiliarUserId: string;
|
||||
nombre: string;
|
||||
descripcion?: string;
|
||||
}): Promise<CarteraRow> {
|
||||
const { rows: [row] } = await pool.query(`
|
||||
INSERT INTO carteras (parent_id, auxiliar_user_id, nombre, descripcion)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id
|
||||
`, [data.parentId, data.auxiliarUserId, data.nombre, data.descripcion ?? null]);
|
||||
return (await getCarteraById(pool, row.id))!;
|
||||
}
|
||||
|
||||
export async function updateCartera(pool: Pool, id: string, data: {
|
||||
nombre?: string;
|
||||
descripcion?: string;
|
||||
supervisorUserId?: string;
|
||||
}): Promise<CarteraRow | null> {
|
||||
const existing = await getCarteraById(pool, id);
|
||||
if (!existing) return null;
|
||||
const sets: string[] = [];
|
||||
const vals: unknown[] = [];
|
||||
let idx = 1;
|
||||
if (data.nombre !== undefined) { sets.push(`nombre = $${idx}`); vals.push(data.nombre); idx++; }
|
||||
if (data.descripcion !== undefined) { sets.push(`descripcion = $${idx}`); vals.push(data.descripcion); idx++; }
|
||||
if (data.supervisorUserId !== undefined) { sets.push(`supervisor_user_id = $${idx}`); vals.push(data.supervisorUserId); idx++; }
|
||||
if (sets.length === 0) return existing;
|
||||
vals.push(id);
|
||||
await pool.query(`UPDATE carteras SET ${sets.join(', ')} WHERE id = $${idx}`, vals);
|
||||
return (await getCarteraById(pool, id))!;
|
||||
}
|
||||
|
||||
export async function deleteCartera(pool: Pool, id: string): Promise<boolean> {
|
||||
const { rowCount } = await pool.query('DELETE FROM carteras WHERE id = $1', [id]);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
// Entidades in cartera
|
||||
export async function addEntidadToCartera(pool: Pool, carteraId: string, entidadId: string): Promise<void> {
|
||||
await pool.query('INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [carteraId, entidadId]);
|
||||
}
|
||||
|
||||
export async function removeEntidadFromCartera(pool: Pool, carteraId: string, entidadId: string): Promise<void> {
|
||||
await pool.query('DELETE FROM cartera_entidades WHERE cartera_id = $1 AND entidad_id = $2', [carteraId, entidadId]);
|
||||
}
|
||||
|
||||
export async function getCarteraEntidades(pool: Pool, carteraId: string): Promise<string[]> {
|
||||
const { rows } = await pool.query('SELECT entidad_id AS "entidadId" FROM cartera_entidades WHERE cartera_id = $1', [carteraId]);
|
||||
return rows.map(r => r.entidadId);
|
||||
}
|
||||
|
||||
// Auxiliares assigned to a supervisor
|
||||
export async function getAuxiliaresDelSupervisor(pool: Pool, supervisorUserId: string): Promise<Array<{ auxiliarUserId: string }>> {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT auxiliar_user_id AS "auxiliarUserId" FROM auxiliar_supervisores WHERE supervisor_user_id = $1',
|
||||
[supervisorUserId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Legacy auxiliares in cartera (backward compat)
|
||||
export async function addAuxiliarToCartera(pool: Pool, carteraId: string, auxiliarUserId: string): Promise<void> {
|
||||
await pool.query('INSERT INTO cartera_auxiliares (cartera_id, auxiliar_user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [carteraId, auxiliarUserId]);
|
||||
}
|
||||
|
||||
export async function removeAuxiliarFromCartera(pool: Pool, carteraId: string, auxiliarUserId: string): Promise<void> {
|
||||
await pool.query('DELETE FROM cartera_auxiliares WHERE cartera_id = $1 AND auxiliar_user_id = $2', [carteraId, auxiliarUserId]);
|
||||
}
|
||||
|
||||
export async function getCarteraAuxiliares(pool: Pool, carteraId: string): Promise<string[]> {
|
||||
const { rows } = await pool.query('SELECT auxiliar_user_id AS "auxiliarUserId" FROM cartera_auxiliares WHERE cartera_id = $1', [carteraId]);
|
||||
return rows.map(r => r.auxiliarUserId);
|
||||
}
|
||||
|
||||
// Supervisors list (for the invite form dropdown)
|
||||
export async function getSupervisores(pool: Pool, tenantId: string): Promise<Array<{ userId: string; nombre: string; email: string }>> {
|
||||
// Query tenant_memberships joined with users for supervisor role (rolId=9)
|
||||
const memberships = await prisma.tenantMembership.findMany({
|
||||
where: { tenantId, rolId: 9, active: true },
|
||||
include: { user: { select: { id: true, nombre: true, email: true } } },
|
||||
});
|
||||
return memberships.map(m => ({
|
||||
userId: m.user.id,
|
||||
nombre: m.user.nombre,
|
||||
email: m.user.email,
|
||||
}));
|
||||
}
|
||||
769
apps/api/src/services/cfdi.service.ts
Normal file
769
apps/api/src/services/cfdi.service.ts
Normal file
@@ -0,0 +1,769 @@
|
||||
import type { Pool } from 'pg';
|
||||
import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared';
|
||||
import { markForInvalidation } from './metricas.service.js';
|
||||
import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../utils/saldo.js';
|
||||
|
||||
// Common SELECT columns mapping DB → camelCase
|
||||
const CFDI_SELECT = `
|
||||
id, year, month, type, uuid, serie, folio, status,
|
||||
fecha_emision as "fechaEmision",
|
||||
rfc_emisor_id as "rfcEmisorId", rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
|
||||
rfc_receptor_id as "rfcReceptorId", rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
|
||||
subtotal, subtotal_mxn as "subtotalMxn",
|
||||
descuento, descuento_mxn as "descuentoMxn",
|
||||
total, total_mxn as "totalMxn",
|
||||
saldo_insoluto as "saldoInsoluto",
|
||||
moneda, tipo_cambio as "tipoCambio",
|
||||
tipo_comprobante as "tipoComprobante",
|
||||
metodo_pago as "metodoPago", forma_pago as "formaPago",
|
||||
uso_cfdi as "usoCfdi",
|
||||
pac, fecha_cert_sat as "fechaCertSat",
|
||||
fecha_cancelacion as "fechaCancelacion",
|
||||
uuid_relacionado as "uuidRelacionado",
|
||||
isr_retencion as "isrRetencion", isr_retencion_mxn as "isrRetencionMxn",
|
||||
iva_traslado as "ivaTraslado", iva_traslado_mxn as "ivaTrasladoMxn",
|
||||
iva_retencion as "ivaRetencion", iva_retencion_mxn as "ivaRetencionMxn",
|
||||
ieps_traslado as "iepsTraslado", ieps_traslado_mxn as "iepsTrasladoMxn",
|
||||
ieps_retencion as "iepsRetencion", ieps_retencion_mxn as "iepsRetencionMxn",
|
||||
impuestos_locales_trasladado as "impuestosLocalesTrasladado",
|
||||
impuestos_locales_trasladado_mxn as "impuestosLocalesTrasladoMxn",
|
||||
impuestos_locales_retenidos as "impuestosLocalesRetenidos",
|
||||
impuestos_locales_retenidos_mxn as "impuestosLocalesRetenidosMxn",
|
||||
monto_pago as "montoPago", monto_pago_mxn as "montoPagoMxn",
|
||||
fecha_pago_p as "fechaPagoP", num_parcialidad as "numParcialidad",
|
||||
isr_retencion_pago as "isrRetencionPago", isr_retencion_pago_mxn as "isrRetencionPagoMxn",
|
||||
iva_traslado_pago as "ivaTrasladoPago", iva_traslado_pago_mxn as "ivaTrasladoPagoMxn",
|
||||
iva_retencion_pago as "ivaRetencionPago", iva_retencion_pago_mxn as "ivaRetencionPagoMxn",
|
||||
ieps_traslado_pago as "iepsTrasladoPago", ieps_traslado_pago_mxn as "iepsTrasladoPagoMxn",
|
||||
ieps_retencion_pago as "iepsRetencionPago", ieps_retencion_pago_mxn as "iepsRetencionPagoMxn",
|
||||
saldo_pendiente as "saldoPendiente", saldo_pendiente_mxn as "saldoPendienteMxn",
|
||||
fecha_liquidacion as "fechaLiquidacion",
|
||||
fecha_pago as "fechaPago",
|
||||
fecha_inicial_pago as "fechaInicialPago",
|
||||
fecha_final_pago as "fechaFinalPago",
|
||||
num_dias_pagados as "numDiasPagados",
|
||||
num_seguro_social as "numSeguroSocial", puesto,
|
||||
salario_base_cot_apor as "salarioBaseCotApor",
|
||||
salario_base_cot_apor_mxn as "salarioBaseCotAporMxn",
|
||||
salario_diario_integrado as "salarioDiarioIntegrado",
|
||||
salario_diario_integrado_mxn as "salarioDiarioIntegradoMxn",
|
||||
total_percepciones as "totalPercepciones",
|
||||
total_percepciones_mxn as "totalPercepcionesMxn",
|
||||
total_deducciones as "totalDeducciones",
|
||||
total_deducciones_mxn as "totalDeduccionesMxn",
|
||||
imp_retenidos_nomina as "impRetenidosNomina",
|
||||
imp_retenidos_nomina_mxn as "impRetenidosNominaMxn",
|
||||
otras_deducciones_nomina as "otrasDeduccionesNomina",
|
||||
otras_deducciones_nomina_mxn as "otrasDeduccionesNominaMxn",
|
||||
subsidio_causado as "subsidioCausado",
|
||||
subsidio_causado_mxn as "subsidioCausadoMxn",
|
||||
conciliado,
|
||||
regimen_fiscal_emisor as "regimenFiscalEmisor",
|
||||
regimen_fiscal_receptor as "regimenFiscalReceptor",
|
||||
xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||
xml_original as "xmlOriginal",
|
||||
cfdi_tipo_relacion as "cfdiTipoRelacion",
|
||||
cfdis_relacionados as "cfdisRelacionados",
|
||||
last_sat_sync as "lastSatSync",
|
||||
sat_sync_job_id as "satSyncJobId",
|
||||
source, facturapi_id as "facturapiId",
|
||||
creado_en as "creadoEn", actualizado_en as "actualizadoEn",
|
||||
contribuyente_id AS "contribuyenteId"
|
||||
`;
|
||||
|
||||
export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiListResponse> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let whereClause = 'WHERE 1=1';
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// El filtro "tipo" (EMITIDO / RECIBIDO) usa la posición del RFC del
|
||||
// contribuyente cuando viene contribuyenteId — más confiable que la
|
||||
// columna `type`, que puede quedar inconsistente cuando dos
|
||||
// contribuyentes del mismo tenant se facturan entre sí. Se aplica
|
||||
// abajo cuando ya conocemos el RFC vía la subquery.
|
||||
if (filters.tipo && !filters.contribuyenteId) {
|
||||
whereClause += ` AND type = $${paramIndex++}`;
|
||||
params.push(filters.tipo);
|
||||
}
|
||||
|
||||
if (filters.tipoComprobante) {
|
||||
whereClause += ` AND tipo_comprobante = $${paramIndex++}`;
|
||||
params.push(filters.tipoComprobante);
|
||||
}
|
||||
|
||||
if (filters.estado) {
|
||||
whereClause += ` AND status = $${paramIndex++}`;
|
||||
params.push(filters.estado);
|
||||
}
|
||||
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND fecha_emision >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
|
||||
if (filters.rfc) {
|
||||
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.rfc}%`);
|
||||
}
|
||||
|
||||
if (filters.emisor) {
|
||||
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.emisor}%`);
|
||||
}
|
||||
|
||||
if (filters.receptor) {
|
||||
whereClause += ` AND (rfc_receptor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.receptor}%`);
|
||||
}
|
||||
|
||||
if (filters.search) {
|
||||
whereClause += ` AND (uuid ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex} OR rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.search}%`);
|
||||
}
|
||||
|
||||
if (filters.contribuyenteId) {
|
||||
// Lado del contribuyente: si filters.tipo viene, restringe a EMITIDO
|
||||
// (rfc_emisor = X) o RECIBIDO (rfc_receptor = X). Si no, ambos lados
|
||||
// (OR contribuyente_id = X para casos donde el RFC no quedó
|
||||
// correctamente asignado pero el tenant lo poseía).
|
||||
if (filters.tipo === 'EMITIDO') {
|
||||
whereClause += ` AND rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
|
||||
params.push(filters.contribuyenteId);
|
||||
} else if (filters.tipo === 'RECIBIDO') {
|
||||
whereClause += ` AND rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
|
||||
params.push(filters.contribuyenteId);
|
||||
} else {
|
||||
whereClause += ` AND (contribuyente_id = $${paramIndex} OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex}) OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++}))`;
|
||||
params.push(filters.contribuyenteId);
|
||||
}
|
||||
}
|
||||
|
||||
params.push(limit, offset);
|
||||
const { rows: dataWithCount } = await pool.query(`
|
||||
SELECT ${CFDI_SELECT},
|
||||
COUNT(*) OVER() as total_count
|
||||
FROM cfdis
|
||||
${whereClause}
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`, params);
|
||||
|
||||
const total = Number(dataWithCount[0]?.total_count || 0);
|
||||
const data = dataWithCount.map(({ total_count, ...cfdi }: any) => cfdi) as Cfdi[];
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista paginada de conceptos (cfdi_conceptos) — reusa los mismos filtros de
|
||||
* `getCfdis` aplicados contra la tabla `cfdis` joined. Devuelve TODAS las
|
||||
* columnas non-MXN del concepto + fecha/uuid/RFCs del CFDI padre, para
|
||||
* alimentar la pestaña "Conceptos" en /cfdi y su export a Excel.
|
||||
*/
|
||||
export async function getConceptosList(
|
||||
pool: Pool,
|
||||
filters: CfdiFilters & {
|
||||
uuidLike?: string;
|
||||
claveProdServ?: string;
|
||||
descripcionConcepto?: string;
|
||||
orderBy?: 'fecha' | 'importe';
|
||||
orderDir?: 'asc' | 'desc';
|
||||
},
|
||||
): Promise<{
|
||||
data: any[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let whereClause = 'WHERE 1=1';
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.tipo && !filters.contribuyenteId) {
|
||||
whereClause += ` AND c.type = $${paramIndex++}`;
|
||||
params.push(filters.tipo);
|
||||
}
|
||||
if (filters.tipoComprobante) {
|
||||
whereClause += ` AND c.tipo_comprobante = $${paramIndex++}`;
|
||||
params.push(filters.tipoComprobante);
|
||||
}
|
||||
if (filters.estado) {
|
||||
whereClause += ` AND c.status = $${paramIndex++}`;
|
||||
params.push(filters.estado);
|
||||
}
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND c.fecha_emision >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND c.fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
if (filters.rfc) {
|
||||
whereClause += ` AND (c.rfc_emisor ILIKE $${paramIndex} OR c.rfc_receptor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.rfc}%`);
|
||||
}
|
||||
if (filters.emisor) {
|
||||
whereClause += ` AND (c.rfc_emisor ILIKE $${paramIndex} OR c.nombre_emisor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.emisor}%`);
|
||||
}
|
||||
if (filters.receptor) {
|
||||
whereClause += ` AND (c.rfc_receptor ILIKE $${paramIndex} OR c.nombre_receptor ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.receptor}%`);
|
||||
}
|
||||
if (filters.search) {
|
||||
whereClause += ` AND (c.uuid ILIKE $${paramIndex} OR c.nombre_emisor ILIKE $${paramIndex} OR c.nombre_receptor ILIKE $${paramIndex} OR c.rfc_emisor ILIKE $${paramIndex} OR c.rfc_receptor ILIKE $${paramIndex} OR cc.descripcion ILIKE $${paramIndex++})`;
|
||||
params.push(`%${filters.search}%`);
|
||||
}
|
||||
if (filters.contribuyenteId) {
|
||||
if (filters.tipo === 'EMITIDO') {
|
||||
whereClause += ` AND c.rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
|
||||
params.push(filters.contribuyenteId);
|
||||
} else if (filters.tipo === 'RECIBIDO') {
|
||||
whereClause += ` AND c.rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++})`;
|
||||
params.push(filters.contribuyenteId);
|
||||
} else {
|
||||
whereClause += ` AND (c.contribuyente_id = $${paramIndex} OR c.rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex}) OR c.rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = $${paramIndex++}))`;
|
||||
params.push(filters.contribuyenteId);
|
||||
}
|
||||
}
|
||||
|
||||
// Filtros específicos de la tabla Conceptos (popovers en headers).
|
||||
if (filters.uuidLike) {
|
||||
whereClause += ` AND c.uuid ILIKE $${paramIndex++}`;
|
||||
params.push(`%${filters.uuidLike}%`);
|
||||
}
|
||||
if (filters.claveProdServ) {
|
||||
whereClause += ` AND cc.clave_prod_serv ILIKE $${paramIndex++}`;
|
||||
params.push(`%${filters.claveProdServ}%`);
|
||||
}
|
||||
if (filters.descripcionConcepto) {
|
||||
whereClause += ` AND cc.descripcion ILIKE $${paramIndex++}`;
|
||||
params.push(`%${filters.descripcionConcepto}%`);
|
||||
}
|
||||
|
||||
// Ordenamiento configurable. Default: fecha DESC, id ASC (estable).
|
||||
const orderDir = filters.orderDir === 'asc' ? 'ASC' : 'DESC';
|
||||
let orderClause = `ORDER BY c.fecha_emision ${orderDir}, cc.id ASC`;
|
||||
if (filters.orderBy === 'importe') {
|
||||
orderClause = `ORDER BY cc.importe ${orderDir}, cc.id ASC`;
|
||||
}
|
||||
|
||||
params.push(limit, offset);
|
||||
// SELECT * de cfdi_conceptos para devolver todas las columnas non-MXN
|
||||
// (las MXN también se traen por simplicidad — el frontend las ignora al
|
||||
// exportar; el filtro "no terminen en _mxn" se aplica en el cliente).
|
||||
const { rows: dataWithCount } = await pool.query(`
|
||||
SELECT
|
||||
c.fecha_emision AS "fechaEmision",
|
||||
c.uuid AS "uuid",
|
||||
c.rfc_emisor AS "rfcEmisor",
|
||||
c.rfc_receptor AS "rfcReceptor",
|
||||
c.nombre_emisor AS "nombreEmisor",
|
||||
c.nombre_receptor AS "nombreReceptor",
|
||||
c.tipo_comprobante AS "tipoComprobante",
|
||||
c.status AS "status",
|
||||
cc.*,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM cfdi_conceptos cc
|
||||
JOIN cfdis c ON c.id = cc.cfdi_id
|
||||
${whereClause}
|
||||
${orderClause}
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`, params);
|
||||
|
||||
const total = Number(dataWithCount[0]?.total_count || 0);
|
||||
const data = dataWithCount.map(({ total_count, ...row }: any) => row);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCfdiById(pool: Pool, id: string): Promise<Cfdi | null> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT ${CFDI_SELECT}
|
||||
FROM cfdis
|
||||
WHERE id = $1
|
||||
`, [id]);
|
||||
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getConceptos(pool: Pool, cfdiId: string): Promise<any[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
id, cfdi_id as "cfdiId",
|
||||
clave_prod_serv as "claveProdServ",
|
||||
no_identificacion as "noIdentificacion",
|
||||
descripcion, cantidad,
|
||||
clave_unidad as "claveUnidad", unidad,
|
||||
valor_unitario as "valorUnitario",
|
||||
valor_unitario_mxn as "valorUnitarioMxn",
|
||||
importe, importe_mxn as "importeMxn",
|
||||
descuento, descuento_mxn as "descuentoMxn",
|
||||
isr_retencion as "isrRetencion",
|
||||
isr_retencion_mxn as "isrRetencionMxn",
|
||||
iva_traslado as "ivaTraslado",
|
||||
iva_traslado_mxn as "ivaTrasladoMxn",
|
||||
iva_retencion as "ivaRetencion",
|
||||
iva_retencion_mxn as "ivaRetencionMxn",
|
||||
ieps_traslado as "iepsTraslado",
|
||||
ieps_traslado_mxn as "iepsTrasladoMxn",
|
||||
ieps_retencion as "iepsRetencion",
|
||||
ieps_retencion_mxn as "iepsRetencionMxn"
|
||||
FROM cfdi_conceptos
|
||||
WHERE cfdi_id = $1
|
||||
ORDER BY id
|
||||
`, [cfdiId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getXmlById(pool: Pool, id: string): Promise<string | null> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT xml_original FROM cfdis WHERE id = $1
|
||||
`, [id]);
|
||||
|
||||
return rows[0]?.xml_original || null;
|
||||
}
|
||||
|
||||
export interface CreateCfdiData {
|
||||
uuid: string;
|
||||
type: 'EMITIDO' | 'RECIBIDO';
|
||||
serie?: string;
|
||||
folio?: string;
|
||||
status?: string;
|
||||
fechaEmision: string;
|
||||
rfcEmisor: string;
|
||||
nombreEmisor: string;
|
||||
rfcReceptor: string;
|
||||
nombreReceptor: string;
|
||||
subtotal: number;
|
||||
subtotalMxn?: number;
|
||||
descuento?: number;
|
||||
descuentoMxn?: number;
|
||||
total: number;
|
||||
totalMxn?: number;
|
||||
saldoInsoluto?: string;
|
||||
moneda?: string;
|
||||
tipoCambio?: number;
|
||||
tipoComprobante?: string;
|
||||
metodoPago?: string;
|
||||
formaPago?: string;
|
||||
usoCfdi?: string;
|
||||
pac?: string;
|
||||
fechaCertSat?: string;
|
||||
fechaCancelacion?: string;
|
||||
uuidRelacionado?: string;
|
||||
isrRetencion?: number;
|
||||
isrRetencionMxn?: number;
|
||||
ivaTraslado?: number;
|
||||
ivaTrasladoMxn?: number;
|
||||
ivaRetencion?: number;
|
||||
ivaRetencionMxn?: number;
|
||||
iepsTraslado?: number;
|
||||
iepsTrasladoMxn?: number;
|
||||
iepsRetencion?: number;
|
||||
iepsRetencionMxn?: number;
|
||||
impuestosLocalesTrasladado?: number;
|
||||
impuestosLocalesTrasladoMxn?: number;
|
||||
impuestosLocalesRetenidos?: number;
|
||||
impuestosLocalesRetenidosMxn?: number;
|
||||
montoPago?: number;
|
||||
montoPagoMxn?: number;
|
||||
fechaPagoP?: string;
|
||||
numParcialidad?: string;
|
||||
isrRetencionPago?: number;
|
||||
isrRetencionPagoMxn?: number;
|
||||
ivaTrasladoPago?: number;
|
||||
ivaTrasladoPagoMxn?: number;
|
||||
ivaRetencionPago?: number;
|
||||
ivaRetencionPagoMxn?: number;
|
||||
iepsTrasladoPago?: number;
|
||||
iepsTrasladoPagoMxn?: number;
|
||||
iepsRetencionPago?: number;
|
||||
iepsRetencionPagoMxn?: number;
|
||||
saldoPendiente?: number;
|
||||
saldoPendienteMxn?: number;
|
||||
fechaLiquidacion?: string;
|
||||
fechaPago?: string;
|
||||
fechaInicialPago?: string;
|
||||
fechaFinalPago?: string;
|
||||
numDiasPagados?: number;
|
||||
numSeguroSocial?: string;
|
||||
puesto?: string;
|
||||
salarioBaseCotApor?: number;
|
||||
salarioBaseCotAporMxn?: number;
|
||||
salarioDiarioIntegrado?: number;
|
||||
salarioDiarioIntegradoMxn?: number;
|
||||
totalPercepciones?: number;
|
||||
totalPercepcionesMxn?: number;
|
||||
totalDeducciones?: number;
|
||||
totalDeduccionesMxn?: number;
|
||||
impRetenidosNomina?: number;
|
||||
impRetenidosNominaMxn?: number;
|
||||
otrasDeduccionesNomina?: number;
|
||||
otrasDeduccionesNominaMxn?: number;
|
||||
subsidioCausado?: number;
|
||||
subsidioCausadoMxn?: number;
|
||||
conciliado?: string;
|
||||
regimenFiscalEmisor?: string;
|
||||
regimenFiscalReceptor?: string;
|
||||
xmlUrl?: string;
|
||||
pdfUrl?: string;
|
||||
xmlOriginal?: string;
|
||||
cfdiTipoRelacion?: string;
|
||||
cfdisRelacionados?: string;
|
||||
source?: string;
|
||||
contribuyenteId?: string;
|
||||
}
|
||||
|
||||
function computeMxn(value: number | undefined, tipoCambio: number): number {
|
||||
return (value || 0) * tipoCambio;
|
||||
}
|
||||
|
||||
export async function createCfdi(pool: Pool, data: CreateCfdiData): Promise<Cfdi> {
|
||||
if (!data.uuid) throw new Error('UUID es requerido');
|
||||
if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida');
|
||||
if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido');
|
||||
if (!data.rfcReceptor) throw new Error('RFC Receptor es requerido');
|
||||
|
||||
const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||
? `${data.fechaEmision}T12:00:00`
|
||||
: data.fechaEmision;
|
||||
|
||||
const fechaEmision = new Date(dateStr);
|
||||
if (isNaN(fechaEmision.getTime())) {
|
||||
throw new Error(`Fecha de emisión inválida: ${data.fechaEmision}`);
|
||||
}
|
||||
|
||||
const year = String(fechaEmision.getFullYear());
|
||||
const month = String(fechaEmision.getMonth() + 1).padStart(2, '0');
|
||||
const tc = data.tipoCambio || 1;
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO cfdis (
|
||||
year, month, type, uuid, serie, folio, status, fecha_emision,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, subtotal_mxn, descuento, descuento_mxn,
|
||||
total, total_mxn, saldo_insoluto, moneda, tipo_cambio,
|
||||
tipo_comprobante, metodo_pago, forma_pago, uso_cfdi,
|
||||
pac, fecha_cert_sat, fecha_cancelacion, uuid_relacionado,
|
||||
isr_retencion, isr_retencion_mxn, iva_traslado, iva_traslado_mxn,
|
||||
iva_retencion, iva_retencion_mxn, ieps_traslado, ieps_traslado_mxn,
|
||||
ieps_retencion, ieps_retencion_mxn,
|
||||
impuestos_locales_trasladado, impuestos_locales_trasladado_mxn,
|
||||
impuestos_locales_retenidos, impuestos_locales_retenidos_mxn,
|
||||
monto_pago, monto_pago_mxn, fecha_pago_p, num_parcialidad,
|
||||
isr_retencion_pago, isr_retencion_pago_mxn,
|
||||
iva_traslado_pago, iva_traslado_pago_mxn,
|
||||
iva_retencion_pago, iva_retencion_pago_mxn,
|
||||
ieps_traslado_pago, ieps_traslado_pago_mxn,
|
||||
ieps_retencion_pago, ieps_retencion_pago_mxn,
|
||||
saldo_pendiente, saldo_pendiente_mxn,
|
||||
fecha_liquidacion, fecha_pago, fecha_inicial_pago, fecha_final_pago,
|
||||
num_dias_pagados, num_seguro_social, puesto,
|
||||
salario_base_cot_apor, salario_base_cot_apor_mxn,
|
||||
salario_diario_integrado, salario_diario_integrado_mxn,
|
||||
total_percepciones, total_percepciones_mxn,
|
||||
total_deducciones, total_deducciones_mxn,
|
||||
imp_retenidos_nomina, imp_retenidos_nomina_mxn,
|
||||
otras_deducciones_nomina, otras_deducciones_nomina_mxn,
|
||||
subsidio_causado, subsidio_causado_mxn,
|
||||
conciliado,
|
||||
regimen_fiscal_emisor, regimen_fiscal_receptor,
|
||||
xml_url, pdf_url, xml_original,
|
||||
cfdi_tipo_relacion, cfdis_relacionados,
|
||||
source,
|
||||
contribuyente_id
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,
|
||||
$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
|
||||
$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,
|
||||
$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,
|
||||
$41,$42,$43,$44,$45,$46,$47,$48,$49,$50,
|
||||
$51,$52,$53,$54,$55,$56,$57,$58,$59,$60,
|
||||
$61,$62,$63,$64,$65,$66,$67,$68,$69,$70,
|
||||
$71,$72,$73,$74,$75,$76,$77,$78,$79,$80,
|
||||
$81,$82,$83,$84,$85,$86,
|
||||
$87,$88,
|
||||
$89
|
||||
)
|
||||
RETURNING ${CFDI_SELECT}
|
||||
`, [
|
||||
year, month,
|
||||
data.type || 'ingreso',
|
||||
data.uuid,
|
||||
data.serie || null,
|
||||
data.folio || null,
|
||||
data.status || 'vigente',
|
||||
fechaEmision,
|
||||
data.rfcEmisor,
|
||||
data.nombreEmisor || 'Sin nombre',
|
||||
data.rfcReceptor,
|
||||
data.nombreReceptor || 'Sin nombre',
|
||||
data.subtotal || 0,
|
||||
data.subtotalMxn ?? computeMxn(data.subtotal, tc),
|
||||
data.descuento || 0,
|
||||
data.descuentoMxn ?? computeMxn(data.descuento, tc),
|
||||
data.total || 0,
|
||||
data.totalMxn ?? computeMxn(data.total, tc),
|
||||
data.saldoInsoluto || null,
|
||||
data.moneda || 'MXN',
|
||||
tc,
|
||||
data.tipoComprobante || null,
|
||||
data.metodoPago || null,
|
||||
data.formaPago || null,
|
||||
data.usoCfdi || null,
|
||||
data.pac || null,
|
||||
data.fechaCertSat || null,
|
||||
data.fechaCancelacion || null,
|
||||
data.uuidRelacionado || null,
|
||||
data.isrRetencion || 0,
|
||||
data.isrRetencionMxn ?? computeMxn(data.isrRetencion, tc),
|
||||
data.ivaTraslado || 0,
|
||||
data.ivaTrasladoMxn ?? computeMxn(data.ivaTraslado, tc),
|
||||
data.ivaRetencion || 0,
|
||||
data.ivaRetencionMxn ?? computeMxn(data.ivaRetencion, tc),
|
||||
data.iepsTraslado || 0,
|
||||
data.iepsTrasladoMxn ?? computeMxn(data.iepsTraslado, tc),
|
||||
data.iepsRetencion || 0,
|
||||
data.iepsRetencionMxn ?? computeMxn(data.iepsRetencion, tc),
|
||||
data.impuestosLocalesTrasladado || 0,
|
||||
data.impuestosLocalesTrasladoMxn ?? computeMxn(data.impuestosLocalesTrasladado, tc),
|
||||
data.impuestosLocalesRetenidos || 0,
|
||||
data.impuestosLocalesRetenidosMxn ?? computeMxn(data.impuestosLocalesRetenidos, tc),
|
||||
data.montoPago || 0,
|
||||
data.montoPagoMxn ?? computeMxn(data.montoPago, tc),
|
||||
data.fechaPagoP || null,
|
||||
data.numParcialidad || null,
|
||||
data.isrRetencionPago || 0,
|
||||
data.isrRetencionPagoMxn ?? computeMxn(data.isrRetencionPago, tc),
|
||||
data.ivaTrasladoPago || 0,
|
||||
data.ivaTrasladoPagoMxn ?? computeMxn(data.ivaTrasladoPago, tc),
|
||||
data.ivaRetencionPago || 0,
|
||||
data.ivaRetencionPagoMxn ?? computeMxn(data.ivaRetencionPago, tc),
|
||||
data.iepsTrasladoPago || 0,
|
||||
data.iepsTrasladoPagoMxn ?? computeMxn(data.iepsTrasladoPago, tc),
|
||||
data.iepsRetencionPago || 0,
|
||||
data.iepsRetencionPagoMxn ?? computeMxn(data.iepsRetencionPago, tc),
|
||||
data.saldoPendiente || 0,
|
||||
data.saldoPendienteMxn ?? computeMxn(data.saldoPendiente, tc),
|
||||
data.fechaLiquidacion || null,
|
||||
data.fechaPago || null,
|
||||
data.fechaInicialPago || null,
|
||||
data.fechaFinalPago || null,
|
||||
data.numDiasPagados || 0,
|
||||
data.numSeguroSocial || null,
|
||||
data.puesto || null,
|
||||
data.salarioBaseCotApor || 0,
|
||||
data.salarioBaseCotAporMxn ?? computeMxn(data.salarioBaseCotApor, tc),
|
||||
data.salarioDiarioIntegrado || 0,
|
||||
data.salarioDiarioIntegradoMxn ?? computeMxn(data.salarioDiarioIntegrado, tc),
|
||||
data.totalPercepciones || 0,
|
||||
data.totalPercepcionesMxn ?? computeMxn(data.totalPercepciones, tc),
|
||||
data.totalDeducciones || 0,
|
||||
data.totalDeduccionesMxn ?? computeMxn(data.totalDeducciones, tc),
|
||||
data.impRetenidosNomina || 0,
|
||||
data.impRetenidosNominaMxn ?? computeMxn(data.impRetenidosNomina, tc),
|
||||
data.otrasDeduccionesNomina || 0,
|
||||
data.otrasDeduccionesNominaMxn ?? computeMxn(data.otrasDeduccionesNomina, tc),
|
||||
data.subsidioCausado || 0,
|
||||
data.subsidioCausadoMxn ?? computeMxn(data.subsidioCausado, tc),
|
||||
data.conciliado || null,
|
||||
data.regimenFiscalEmisor || null,
|
||||
data.regimenFiscalReceptor || null,
|
||||
data.xmlUrl || null,
|
||||
data.pdfUrl || null,
|
||||
data.xmlOriginal || null,
|
||||
data.cfdiTipoRelacion || null,
|
||||
data.cfdisRelacionados || null,
|
||||
data.source || 'manual',
|
||||
data.contribuyenteId ?? null,
|
||||
]);
|
||||
|
||||
// Retroactive invalidation hook: mark cached metrics stale for prior-year CFDIs
|
||||
try {
|
||||
const cfdiDate = new Date(data.fechaEmision || new Date());
|
||||
const cfdiYear = cfdiDate.getFullYear();
|
||||
const currentYear = new Date().getFullYear();
|
||||
if (cfdiYear < currentYear && data.contribuyenteId) {
|
||||
await markForInvalidation(pool, data.contribuyenteId, cfdiYear, cfdiDate.getMonth() + 1, 'CFDI_INSERT');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Metricas] Invalidation hook failed (non-blocking):', err);
|
||||
}
|
||||
|
||||
// Recompute saldo_pendiente_mxn de los CFDIs afectados por este insert.
|
||||
// Un I PPD recalcula su propio saldo (considera anticipo si es I/07); un
|
||||
// P o E no-07 recalcula los I PPD que referencia.
|
||||
try {
|
||||
const afectados = uuidsAfectadosPorCfdi({
|
||||
uuid: data.uuid!,
|
||||
tipoComprobante: data.tipoComprobante ?? null,
|
||||
metodoPago: data.metodoPago ?? null,
|
||||
cfdiTipoRelacion: data.cfdiTipoRelacion ?? null,
|
||||
uuidRelacionado: data.uuidRelacionado ?? null,
|
||||
cfdisRelacionados: data.cfdisRelacionados ?? null,
|
||||
});
|
||||
if (afectados.length > 0) {
|
||||
await recomputarSaldoPendiente(pool, afectados);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Saldo] Recompute hook failed (non-blocking):', err);
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export interface BatchInsertResult {
|
||||
inserted: number;
|
||||
duplicates: number;
|
||||
errors: number;
|
||||
errorMessages: string[];
|
||||
}
|
||||
|
||||
export async function createManyCfdis(pool: Pool, cfdis: CreateCfdiData[]): Promise<number> {
|
||||
const result = await createManyCfdisBatch(pool, cfdis);
|
||||
return result.inserted;
|
||||
}
|
||||
|
||||
export async function createManyCfdisBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
||||
const result: BatchInsertResult = {
|
||||
inserted: 0,
|
||||
duplicates: 0,
|
||||
errors: 0,
|
||||
errorMessages: []
|
||||
};
|
||||
|
||||
if (cfdis.length === 0) return result;
|
||||
|
||||
for (const cfdi of cfdis) {
|
||||
try {
|
||||
await createCfdi(pool, cfdi);
|
||||
result.inserted++;
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.message || 'Error desconocido';
|
||||
if (errorMsg.includes('duplicate') || errorMsg.includes('unique')) {
|
||||
result.duplicates++;
|
||||
} else {
|
||||
result.errors++;
|
||||
if (result.errorMessages.length < 10) {
|
||||
result.errorMessages.push(`${cfdi.uuid?.substring(0, 8) || 'N/A'}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteCfdi(pool: Pool, id: string): Promise<void> {
|
||||
// Fetch before deleting so we can fire the invalidation hook
|
||||
const { rows: pre } = await pool.query(
|
||||
`SELECT fecha_emision, contribuyente_id FROM cfdis WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
await pool.query(`DELETE FROM cfdis WHERE id = $1`, [id]);
|
||||
|
||||
// Retroactive invalidation hook: mark cached metrics stale for prior-year CFDIs
|
||||
try {
|
||||
if (pre[0]) {
|
||||
const cfdiDate = new Date(pre[0].fecha_emision || new Date());
|
||||
const cfdiYear = cfdiDate.getFullYear();
|
||||
const currentYear = new Date().getFullYear();
|
||||
if (cfdiYear < currentYear && pre[0].contribuyente_id) {
|
||||
await markForInvalidation(pool, pre[0].contribuyente_id, cfdiYear, cfdiDate.getMonth() + 1, 'CFDI_INSERT');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Metricas] Invalidation hook failed (non-blocking):', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEmisores(pool: Pool, search: string, limit: number = 10, contribuyenteId?: string): Promise<{ rfc: string; nombre: string }[]> {
|
||||
let whereClause = 'WHERE rfc_emisor ILIKE $1 OR nombre_emisor ILIKE $1';
|
||||
const params: any[] = [`%${search}%`, limit];
|
||||
if (contribuyenteId) {
|
||||
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||
whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`;
|
||||
}
|
||||
const { rows } = await pool.query(`
|
||||
SELECT DISTINCT rfc_emisor as rfc, nombre_emisor as nombre
|
||||
FROM cfdis
|
||||
${whereClause}
|
||||
ORDER BY nombre_emisor
|
||||
LIMIT $2
|
||||
`, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getReceptores(pool: Pool, search: string, limit: number = 10, contribuyenteId?: string): Promise<{ rfc: string; nombre: string }[]> {
|
||||
let whereClause = 'WHERE rfc_receptor ILIKE $1 OR nombre_receptor ILIKE $1';
|
||||
const params: any[] = [`%${search}%`, limit];
|
||||
if (contribuyenteId) {
|
||||
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||
whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`;
|
||||
}
|
||||
const { rows } = await pool.query(`
|
||||
SELECT DISTINCT rfc_receptor as rfc, nombre_receptor as nombre
|
||||
FROM cfdis
|
||||
${whereClause}
|
||||
ORDER BY nombre_receptor
|
||||
LIMIT $2
|
||||
`, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getResumenCfdis(pool: Pool, año: number, mes: number, contribuyenteId?: string) {
|
||||
let whereClause = `WHERE status NOT IN ('Cancelado', '0') AND year = $1 AND month = $2`;
|
||||
if (contribuyenteId) {
|
||||
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||
whereClause += ` AND (contribuyente_id = '${safeId}' OR rfc_emisor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}') OR rfc_receptor = (SELECT rfc FROM contribuyentes WHERE entidad_id = '${safeId}'))`;
|
||||
}
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN total_mxn ELSE 0 END), 0) as total_ingresos,
|
||||
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN total_mxn ELSE 0 END), 0) as total_egresos,
|
||||
COUNT(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN 1 END) as count_ingresos,
|
||||
COUNT(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN 1 END) as count_egresos,
|
||||
COALESCE(SUM(CASE WHEN type = 'EMITIDO' THEN iva_traslado_mxn ELSE 0 END), 0) as iva_trasladado,
|
||||
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' THEN iva_traslado_mxn ELSE 0 END), 0) as iva_acreditable
|
||||
FROM cfdis
|
||||
${whereClause}
|
||||
`, [String(año), String(mes).padStart(2, '0')]);
|
||||
|
||||
const r = rows[0];
|
||||
return {
|
||||
totalIngresos: Number(r?.total_ingresos || 0),
|
||||
totalEgresos: Number(r?.total_egresos || 0),
|
||||
countIngresos: Number(r?.count_ingresos || 0),
|
||||
countEgresos: Number(r?.count_egresos || 0),
|
||||
ivaTrasladado: Number(r?.iva_trasladado || 0),
|
||||
ivaAcreditable: Number(r?.iva_acreditable || 0),
|
||||
};
|
||||
}
|
||||
257
apps/api/src/services/conciliacion.service.ts
Normal file
257
apps/api/src/services/conciliacion.service.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
||||
|
||||
export interface ConciliacionCfdi {
|
||||
id: number;
|
||||
uuid: string;
|
||||
type: string;
|
||||
fechaEmision: string;
|
||||
rfcEmisor: string;
|
||||
nombreEmisor: string;
|
||||
rfcReceptor: string;
|
||||
nombreReceptor: string;
|
||||
total: number;
|
||||
totalMxn: number;
|
||||
metodoPago: string | null;
|
||||
conciliado: string | null;
|
||||
idConciliacion: number | null;
|
||||
conciliacion: {
|
||||
id: number;
|
||||
fechaDePago: string;
|
||||
banco: string;
|
||||
terminacionCuenta: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export async function getCfdisConConciliacion(
|
||||
pool: Pool,
|
||||
filters: {
|
||||
tipo: string;
|
||||
fechaInicio?: string;
|
||||
fechaFin?: string;
|
||||
regimen?: string;
|
||||
estado?: string;
|
||||
contribuyenteId?: string;
|
||||
}
|
||||
): Promise<ConciliacionCfdi[]> {
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
let where = `WHERE c.type = $${idx++} AND c.${VIGENTE}`;
|
||||
params.push(filters.tipo);
|
||||
|
||||
// Excluir PPD en recibidos
|
||||
if (filters.tipo === 'RECIBIDO') {
|
||||
where += ` AND (c.metodo_pago IS NULL OR c.metodo_pago != 'PPD')`;
|
||||
}
|
||||
|
||||
// Excluir PPD en emitidos para todos los regimenes excepto 605 y 616
|
||||
if (filters.tipo === 'EMITIDO') {
|
||||
where += ` AND NOT (c.metodo_pago = 'PPD' AND (c.regimen_fiscal_emisor IS NULL OR c.regimen_fiscal_emisor NOT IN ('605','616')))`;
|
||||
}
|
||||
|
||||
if (filters.fechaInicio) {
|
||||
where += ` AND c.fecha_emision >= $${idx++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
if (filters.fechaFin) {
|
||||
where += ` AND c.fecha_emision <= ($${idx++}::date + interval '1 day')`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
if (filters.regimen) {
|
||||
const regimenCol = filters.tipo === 'EMITIDO' ? 'regimen_fiscal_emisor' : 'regimen_fiscal_receptor';
|
||||
where += ` AND c.${regimenCol} = $${idx++}`;
|
||||
params.push(filters.regimen);
|
||||
}
|
||||
if (filters.estado === 'conciliado') {
|
||||
where += ` AND c.conciliado = 'true'`;
|
||||
} else if (filters.estado === 'pendiente') {
|
||||
where += ` AND (c.conciliado IS NULL OR c.conciliado != 'true')`;
|
||||
}
|
||||
|
||||
if (filters.contribuyenteId) {
|
||||
const safeId = filters.contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||
where += ` AND c.contribuyente_id = '${safeId}'`;
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
c.id, c.uuid, c.type,
|
||||
c.fecha_emision as "fechaEmision",
|
||||
c.rfc_emisor as "rfcEmisor", c.nombre_emisor as "nombreEmisor",
|
||||
c.rfc_receptor as "rfcReceptor", c.nombre_receptor as "nombreReceptor",
|
||||
c.total, c.total_mxn as "totalMxn",
|
||||
c.tipo_comprobante as "tipoComprobante",
|
||||
c.monto_pago_mxn as "montoPagoMxn",
|
||||
c.metodo_pago as "metodoPago",
|
||||
c.conciliado,
|
||||
c.id_conciliacion as "idConciliacion",
|
||||
con.id as "conId",
|
||||
con.fecha_de_pago as "conFechaDePago",
|
||||
b.banco as "conBanco",
|
||||
b.terminacion_cuenta as "conTerminacionCuenta"
|
||||
FROM cfdis c
|
||||
LEFT JOIN conciliaciones con ON con.id_cfdi = c.id
|
||||
LEFT JOIN bancos b ON b.id = con.id_banco
|
||||
${where}
|
||||
ORDER BY c.fecha_emision DESC
|
||||
`, params);
|
||||
|
||||
return rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
uuid: r.uuid,
|
||||
type: r.type,
|
||||
fechaEmision: r.fechaEmision,
|
||||
rfcEmisor: r.rfcEmisor,
|
||||
nombreEmisor: r.nombreEmisor,
|
||||
rfcReceptor: r.rfcReceptor,
|
||||
nombreReceptor: r.nombreReceptor,
|
||||
total: Number(r.total),
|
||||
totalMxn: Number(r.totalMxn),
|
||||
tipoComprobante: r.tipoComprobante,
|
||||
montoPagoMxn: Number(r.montoPagoMxn || 0),
|
||||
// P usa monto_pago_mxn, PPD conciliada no suma (evitar duplicar con su P), resto usa total_mxn
|
||||
montoMxn: r.tipoComprobante === 'P'
|
||||
? Number(r.montoPagoMxn || 0)
|
||||
: (r.metodoPago === 'PPD' && r.conciliado === 'true') ? 0 : Number(r.totalMxn || 0),
|
||||
metodoPago: r.metodoPago,
|
||||
conciliado: r.conciliado,
|
||||
idConciliacion: r.idConciliacion,
|
||||
conciliacion: r.conId ? {
|
||||
id: r.conId,
|
||||
fechaDePago: r.conFechaDePago,
|
||||
banco: r.conBanco,
|
||||
terminacionCuenta: r.conTerminacionCuenta,
|
||||
} : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function conciliar(
|
||||
pool: Pool,
|
||||
data: { cfdiIds: number[]; fechaDePago: string; idBanco: number },
|
||||
tenantCreatedYear: number,
|
||||
): Promise<number> {
|
||||
const fechaPago = new Date(data.fechaDePago + 'T12:00:00');
|
||||
const anio = String(fechaPago.getFullYear());
|
||||
const mes = String(fechaPago.getMonth() + 1).padStart(2, '0');
|
||||
|
||||
if (fechaPago.getFullYear() < tenantCreatedYear) {
|
||||
throw new Error(`Solo se puede conciliar del año ${tenantCreatedYear} en adelante`);
|
||||
}
|
||||
|
||||
const { rows: bancoRows } = await pool.query(`SELECT id FROM bancos WHERE id = $1`, [data.idBanco]);
|
||||
if (bancoRows.length === 0) throw new Error('Banco no encontrado');
|
||||
|
||||
const { rows: cfdis } = await pool.query(`
|
||||
SELECT id, conciliado FROM cfdis
|
||||
WHERE id = ANY($1) AND ${VIGENTE}
|
||||
`, [data.cfdiIds]);
|
||||
|
||||
if (cfdis.length !== data.cfdiIds.length) {
|
||||
throw new Error('Algunos CFDIs no existen o estan cancelados');
|
||||
}
|
||||
|
||||
const yaConc = cfdis.filter((c: any) => c.conciliado === 'true');
|
||||
if (yaConc.length > 0) {
|
||||
throw new Error(`${yaConc.length} CFDIs ya estan conciliados`);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const cfdiId of data.cfdiIds) {
|
||||
const { rows: inserted } = await pool.query(`
|
||||
INSERT INTO conciliaciones (anio, mes, id_cfdi, fecha_de_pago, id_banco)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`, [anio, mes, cfdiId, data.fechaDePago, data.idBanco]);
|
||||
|
||||
await pool.query(`
|
||||
UPDATE cfdis SET conciliado = 'true', id_conciliacion = $1 WHERE id = $2
|
||||
`, [inserted[0].id, cfdiId]);
|
||||
|
||||
count++;
|
||||
|
||||
// Auto-conciliar PPD si esta factura tipo P lleva saldo pendiente a 0
|
||||
await autoConciliarPpd(pool, cfdiId, anio, mes, data.fechaDePago, data.idBanco);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuando se concilia una factura tipo P con saldo_pendiente = 0,
|
||||
* auto-concilia la factura PPD original relacionada con los mismos datos.
|
||||
*/
|
||||
async function autoConciliarPpd(
|
||||
pool: Pool,
|
||||
cfdiId: number,
|
||||
anio: string,
|
||||
mes: string,
|
||||
fechaDePago: string,
|
||||
idBanco: number,
|
||||
): Promise<void> {
|
||||
// Verificar si es tipo P con saldo pendiente 0
|
||||
const { rows: pRows } = await pool.query(`
|
||||
SELECT tipo_comprobante, uuid_relacionado, saldo_pendiente
|
||||
FROM cfdis WHERE id = $1
|
||||
`, [cfdiId]);
|
||||
|
||||
const pCfdi = pRows[0];
|
||||
if (!pCfdi || pCfdi.tipo_comprobante !== 'P') return;
|
||||
if (!pCfdi.uuid_relacionado) return;
|
||||
|
||||
const saldoPendiente = Number(pCfdi.saldo_pendiente || 0);
|
||||
if (saldoPendiente !== 0) return;
|
||||
|
||||
// Buscar la factura PPD original por UUID
|
||||
const { rows: ppdRows } = await pool.query(`
|
||||
SELECT id, conciliado FROM cfdis
|
||||
WHERE uuid = $1 AND metodo_pago = 'PPD' AND ${VIGENTE}
|
||||
`, [pCfdi.uuid_relacionado]);
|
||||
|
||||
if (ppdRows.length === 0) return;
|
||||
const ppd = ppdRows[0];
|
||||
if (ppd.conciliado === 'true') return; // ya conciliada
|
||||
|
||||
// Auto-conciliar la PPD con los mismos datos
|
||||
const { rows: inserted } = await pool.query(`
|
||||
INSERT INTO conciliaciones (anio, mes, id_cfdi, fecha_de_pago, id_banco)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`, [anio, mes, ppd.id, fechaDePago, idBanco]);
|
||||
|
||||
await pool.query(`
|
||||
UPDATE cfdis SET conciliado = 'true', id_conciliacion = $1 WHERE id = $2
|
||||
`, [inserted[0].id, ppd.id]);
|
||||
}
|
||||
|
||||
export async function desconciliar(pool: Pool, conciliacionId: number): Promise<void> {
|
||||
// Buscar la conciliacion y el CFDI asociado
|
||||
const { rows } = await pool.query(`
|
||||
SELECT con.id_cfdi, c.tipo_comprobante, c.uuid_relacionado
|
||||
FROM conciliaciones con
|
||||
JOIN cfdis c ON c.id = con.id_cfdi
|
||||
WHERE con.id = $1
|
||||
`, [conciliacionId]);
|
||||
if (rows.length === 0) throw new Error('Conciliacion no encontrada');
|
||||
|
||||
const cfdi = rows[0];
|
||||
|
||||
// Desconciliar el CFDI principal
|
||||
await pool.query(`UPDATE cfdis SET conciliado = NULL, id_conciliacion = NULL WHERE id_conciliacion = $1`, [conciliacionId]);
|
||||
await pool.query(`DELETE FROM conciliaciones WHERE id = $1`, [conciliacionId]);
|
||||
|
||||
// Si es tipo P, también desconciliar la PPD auto-conciliada
|
||||
if (cfdi.tipo_comprobante === 'P' && cfdi.uuid_relacionado) {
|
||||
const { rows: ppdRows } = await pool.query(`
|
||||
SELECT c.id_conciliacion FROM cfdis c
|
||||
WHERE c.uuid = $1 AND c.conciliado = 'true' AND c.metodo_pago = 'PPD'
|
||||
`, [cfdi.uuid_relacionado]);
|
||||
|
||||
if (ppdRows.length > 0 && ppdRows[0].id_conciliacion) {
|
||||
const ppdConcId = ppdRows[0].id_conciliacion;
|
||||
await pool.query(`UPDATE cfdis SET conciliado = NULL, id_conciliacion = NULL WHERE id_conciliacion = $1`, [ppdConcId]);
|
||||
await pool.query(`DELETE FROM conciliaciones WHERE id = $1`, [ppdConcId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
156
apps/api/src/services/connector.service.ts
Normal file
156
apps/api/src/services/connector.service.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import { encryptAesGcm, decryptAesGcm, deriveAesKey } from '@horux/core';
|
||||
import { env } from '../config/env.js';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const secret = env.CONNECTOR_ENCRYPTION_KEY || env.FIEL_ENCRYPTION_KEY;
|
||||
return deriveAesKey(secret);
|
||||
}
|
||||
|
||||
export async function provisionConnector(tenantId: string): Promise<{
|
||||
tunnelHostname: string;
|
||||
horuxToken: string;
|
||||
dockerRunCommand: string;
|
||||
}> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { nombre: true, rfc: true, dbMode: true },
|
||||
});
|
||||
if (!tenant) throw new Error('Tenant no encontrado');
|
||||
|
||||
const slug = tenant.rfc.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const tunnelDomain = env.CLOUDFLARE_TUNNEL_DOMAIN || 'tunnel.horux.mx';
|
||||
const hostname = `${slug}.${tunnelDomain}`;
|
||||
|
||||
// Generate a secure token for the connector
|
||||
const horuxToken = randomBytes(32).toString('hex');
|
||||
|
||||
// Encrypt the token for storage — format: iv(16) + ciphertext + tag(16), base64
|
||||
const key = getEncryptionKey();
|
||||
const { encrypted, iv, tag } = encryptAesGcm(Buffer.from(horuxToken, 'utf-8'), key);
|
||||
const tokenEncoded = Buffer.concat([iv, encrypted, tag]).toString('base64');
|
||||
|
||||
// TODO: Call Cloudflare API to create tunnel when CLOUDFLARE_API_TOKEN is configured
|
||||
// For now, store the config and let the user manually configure cloudflared
|
||||
if (env.CLOUDFLARE_API_TOKEN) {
|
||||
console.log(`[Connector] Would create Cloudflare tunnel for ${hostname} — API integration pending`);
|
||||
}
|
||||
|
||||
await prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
dbMode: 'BYO',
|
||||
connectorTokenEnc: tokenEncoded,
|
||||
connectorTunnelHostname: hostname,
|
||||
},
|
||||
});
|
||||
|
||||
const dockerRunCommand = [
|
||||
'docker run -d --name horux-connector',
|
||||
` -e HORUX_TOKEN="${horuxToken}"`,
|
||||
` -e HORUX_API_URL="${env.CORS_ORIGIN || 'https://horuxfin.com'}"`,
|
||||
' -e POSTGRES_HOST="localhost"',
|
||||
' -e POSTGRES_PORT="5432"',
|
||||
' horux/connector:latest',
|
||||
].join(' \\\n');
|
||||
|
||||
return { tunnelHostname: hostname, horuxToken, dockerRunCommand };
|
||||
}
|
||||
|
||||
export async function recordHeartbeat(tenantId: string, data: {
|
||||
version: string;
|
||||
uptimeSeconds: number;
|
||||
postgresPingMs: number;
|
||||
pgVersion?: string;
|
||||
lastMigration?: string;
|
||||
status?: string;
|
||||
errorMsg?: string;
|
||||
}): Promise<void> {
|
||||
await Promise.all([
|
||||
prisma.connectorHeartbeat.create({
|
||||
data: {
|
||||
tenantId,
|
||||
latencyMs: data.postgresPingMs,
|
||||
version: data.version,
|
||||
pgVersion: data.pgVersion,
|
||||
status: data.status || 'OK',
|
||||
errorMsg: data.errorMsg,
|
||||
},
|
||||
}),
|
||||
prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
connectorLastSeen: new Date(),
|
||||
connectorVersion: data.version,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function verifyConnectorToken(token: string): Promise<string | null> {
|
||||
// Find tenant by trying to decrypt stored tokens.
|
||||
// This is O(N) — for production, use a hashed token lookup table.
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { dbMode: 'BYO', connectorTokenEnc: { not: null } },
|
||||
select: { id: true, connectorTokenEnc: true },
|
||||
});
|
||||
|
||||
const key = getEncryptionKey();
|
||||
// Stored format: iv(16 bytes) + ciphertext + tag(16 bytes), base64-encoded
|
||||
const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
for (const t of tenants) {
|
||||
if (!t.connectorTokenEnc) continue;
|
||||
try {
|
||||
const blob = Buffer.from(t.connectorTokenEnc, 'base64');
|
||||
const iv = blob.subarray(0, IV_LENGTH);
|
||||
const tag = blob.subarray(blob.length - TAG_LENGTH);
|
||||
const ciphertext = blob.subarray(IV_LENGTH, blob.length - TAG_LENGTH);
|
||||
const decrypted = decryptAesGcm(ciphertext, iv, tag, key);
|
||||
if (decrypted.toString('utf-8') === token) {
|
||||
return t.id;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getConnectorStatus(tenantId: string): Promise<{
|
||||
configured: boolean;
|
||||
tunnelHostname?: string;
|
||||
lastSeen?: string;
|
||||
version?: string;
|
||||
status: 'connected' | 'degraded' | 'disconnected' | 'not_configured';
|
||||
}> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { dbMode: true, connectorTunnelHostname: true, connectorLastSeen: true, connectorVersion: true },
|
||||
});
|
||||
|
||||
if (!tenant || tenant.dbMode !== 'BYO' || !tenant.connectorTunnelHostname) {
|
||||
return { configured: false, status: 'not_configured' };
|
||||
}
|
||||
|
||||
const lastSeen = tenant.connectorLastSeen;
|
||||
const now = new Date();
|
||||
let status: 'connected' | 'degraded' | 'disconnected' = 'disconnected';
|
||||
|
||||
if (lastSeen) {
|
||||
const diffMs = now.getTime() - lastSeen.getTime();
|
||||
if (diffMs < 60_000) status = 'connected';
|
||||
else if (diffMs < 300_000) status = 'degraded';
|
||||
}
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
tunnelHostname: tenant.connectorTunnelHostname ?? undefined,
|
||||
lastSeen: lastSeen?.toISOString(),
|
||||
version: tenant.connectorVersion ?? undefined,
|
||||
status,
|
||||
};
|
||||
}
|
||||
402
apps/api/src/services/constancia.service.ts
Normal file
402
apps/api/src/services/constancia.service.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { writeFileSync, unlinkSync, mkdirSync, rmdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { getDecryptedFiel } from './fiel.service.js';
|
||||
import { getDecryptedFielContribuyente } from './contribuyente-fiel.service.js';
|
||||
import { loginSatCsf } from './sat/sat-csf-login.js';
|
||||
import { extractCsfPdf } from './sat/sat-csf-scraper.js';
|
||||
import { parseCsfPdf, type ConstanciaSituacionFiscal, type Domicilio, type RegimenCsf } from './sat/sat-csf-parser.js';
|
||||
|
||||
const PROCESS_TIMEOUT = 180_000;
|
||||
|
||||
export interface ConstanciaRow {
|
||||
id: number;
|
||||
rfc: string;
|
||||
idCif: string | null;
|
||||
razonSocial: string | null;
|
||||
estatusPadron: string | null;
|
||||
fechaEmision: string | null;
|
||||
datos: ConstanciaSituacionFiscal;
|
||||
fechaConsulta: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function rowToConstancia(r: any): ConstanciaRow {
|
||||
return {
|
||||
id: r.id,
|
||||
rfc: r.rfc,
|
||||
idCif: r.id_cif,
|
||||
razonSocial: r.razon_social,
|
||||
estatusPadron: r.estatus_padron,
|
||||
fechaEmision: r.fecha_emision,
|
||||
datos: r.datos,
|
||||
fechaConsulta: r.fecha_consulta.toISOString(),
|
||||
createdAt: r.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga la CSF del portal SAT, la parsea, guarda en BD del tenant, y
|
||||
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta
|
||||
* el SAT. El auto-fill NO es destructivo para datos custom del usuario:
|
||||
* solo sobreescribe campos si la CSF tiene un valor no-vacío.
|
||||
*/
|
||||
export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
|
||||
const fiel = await getDecryptedFiel(tenantId);
|
||||
if (!fiel) throw new Error('No hay FIEL configurada o está vencida');
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
if (!tenant) throw new Error('Tenant no encontrado');
|
||||
|
||||
const tempId = randomUUID();
|
||||
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
|
||||
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
||||
const cerPath = join(tempDir, 'cert.cer');
|
||||
const keyPath = join(tempDir, 'key.key');
|
||||
|
||||
try {
|
||||
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
||||
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||
|
||||
// Headless por default. El fix de dispatchEvent en sat-csf-login cubre el
|
||||
// caso donde el click sintético no dispara el handler del SAT. Si algún
|
||||
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
|
||||
const headless = process.env.SAT_HEADLESS !== 'false';
|
||||
const browser = await chromium.launch({ headless });
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT),
|
||||
);
|
||||
|
||||
const resultPromise = (async () => {
|
||||
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password);
|
||||
const pdfBuffer = await extractCsfPdf(session);
|
||||
const csf = await parseCsfPdf(pdfBuffer);
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO constancias_situacion_fiscal
|
||||
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
|
||||
datos, fecha_consulta, created_at`,
|
||||
[
|
||||
csf.rfc,
|
||||
csf.idCIF,
|
||||
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
|
||||
csf.estatusPadron,
|
||||
csf.lugarFechaEmision,
|
||||
JSON.stringify(csf),
|
||||
pdfBuffer,
|
||||
],
|
||||
);
|
||||
|
||||
// Auto-fill domicilio del tenant + regímenes activos desde el CSF.
|
||||
// Se hace después del INSERT para que si algo falla en la sincronización
|
||||
// la CSF ya quedó guardada y el usuario puede verla.
|
||||
await sincronizarDatosFiscales(tenantId, csf).catch(err => {
|
||||
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
|
||||
});
|
||||
|
||||
return rowToConstancia(rows[0]);
|
||||
})();
|
||||
|
||||
return await Promise.race([resultPromise, timeoutPromise]);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
try { unlinkSync(cerPath); } catch { /* ok */ }
|
||||
try { unlinkSync(keyPath); } catch { /* ok */ }
|
||||
try { rmdirSync(tempDir); } catch { /* ok */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte el domicilio del CSF a los campos de `tenants` (calle compuesta
|
||||
* por tipoVialidad + nombreVialidad). Solo actualiza campos cuando el CSF
|
||||
* trae un valor — nunca pisa con null.
|
||||
*/
|
||||
function domicilioToTenantFields(d: Domicilio): Record<string, string | undefined> {
|
||||
const calleComponents = [d.tipoVialidad, d.nombreVialidad].filter(Boolean);
|
||||
const calle = calleComponents.length > 0 ? calleComponents.join(' ') : undefined;
|
||||
return {
|
||||
codigoPostal: d.codigoPostal,
|
||||
calle,
|
||||
numExterior: d.numeroExterior,
|
||||
numInterior: d.numeroInterior && d.numeroInterior.toUpperCase() !== 'SIN NUMERO' ? d.numeroInterior : undefined,
|
||||
colonia: d.colonia,
|
||||
ciudad: d.localidad,
|
||||
municipio: d.municipio,
|
||||
estado: d.entidadFederativa,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Matchea el nombre del régimen como aparece en la CSF contra el catálogo
|
||||
* `regimenes` (clave SAT + descripción). La CSF prefija "Régimen " o
|
||||
* "Régimen de " a veces, y el catálogo no — normalizamos ambos para matchear.
|
||||
*/
|
||||
function normalizeRegimenName(s: string): string {
|
||||
return s
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/^r[eé]gimen\s+(?:de\s+(?:las?|los)?\s*)?/i, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function matchRegimenesToCatalogo(regimenesCsf: RegimenCsf[]): Promise<number[]> {
|
||||
const activos = regimenesCsf.filter(r => !r.fechaFin);
|
||||
if (activos.length === 0) return [];
|
||||
|
||||
const catalogo = await prisma.regimen.findMany({ where: { activo: true } });
|
||||
const ids: number[] = [];
|
||||
|
||||
for (const rc of activos) {
|
||||
const nNormalizado = normalizeRegimenName(rc.nombre);
|
||||
const match = catalogo.find(c => {
|
||||
const catNorm = normalizeRegimenName(c.descripcion);
|
||||
return catNorm === nNormalizado || catNorm.includes(nNormalizado) || nNormalizado.includes(catNorm);
|
||||
});
|
||||
if (match) ids.push(match.id);
|
||||
}
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica el domicilio + regímenes activos de la CSF al tenant. Idempotente:
|
||||
* se puede llamar N veces, el resultado final refleja el último CSF.
|
||||
*/
|
||||
export async function sincronizarDatosFiscales(
|
||||
tenantId: string,
|
||||
csf: ConstanciaSituacionFiscal,
|
||||
): Promise<{ domicilioActualizado: boolean; regimenesSincronizados: number }> {
|
||||
// 1. Domicilio
|
||||
const fields = domicilioToTenantFields(csf.domicilio);
|
||||
const updates: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
if (v && v.trim().length > 0) updates[k] = v.trim();
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await prisma.tenant.update({ where: { id: tenantId }, data: updates });
|
||||
}
|
||||
|
||||
// 2. Regímenes activos — sobreescribe la lista completa con lo que diga la CSF
|
||||
const regimenIds = await matchRegimenesToCatalogo(csf.regimenes);
|
||||
if (regimenIds.length > 0) {
|
||||
await prisma.$transaction([
|
||||
prisma.tenantRegimenActivo.deleteMany({ where: { tenantId } }),
|
||||
prisma.tenantRegimenActivo.createMany({ data: regimenIds.map(regimenId => ({ tenantId, regimenId })) }),
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
domicilioActualizado: Object.keys(updates).length > 0,
|
||||
regimenesSincronizados: regimenIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listConstancias(pool: Pool, limit = 12, rfc?: string): Promise<ConstanciaRow[]> {
|
||||
const params: unknown[] = [limit];
|
||||
let rfcFilter = '';
|
||||
if (rfc) {
|
||||
rfcFilter = 'WHERE rfc = $2';
|
||||
params.push(rfc);
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
|
||||
datos, fecha_consulta, created_at
|
||||
FROM constancias_situacion_fiscal
|
||||
${rfcFilter}
|
||||
ORDER BY fecha_consulta DESC
|
||||
LIMIT $1`,
|
||||
params,
|
||||
);
|
||||
return rows.map(rowToConstancia);
|
||||
}
|
||||
|
||||
export async function getConstanciaPdf(pool: Pool, id: number): Promise<Buffer | null> {
|
||||
const { rows } = await pool.query(`SELECT pdf FROM constancias_situacion_fiscal WHERE id = $1`, [id]);
|
||||
return rows.length > 0 ? rows[0].pdf : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retención 5 años (CFF Art. 30). Se ejecuta en cron diario.
|
||||
*/
|
||||
export async function purgeConstanciasAntiguas(pool: Pool): Promise<{ deleted: number }> {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM constancias_situacion_fiscal WHERE created_at < NOW() - INTERVAL '5 years'`,
|
||||
);
|
||||
return { deleted: rowCount ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga la CSF para un contribuyente específico (modo despacho).
|
||||
* Usa la FIEL almacenada en la BD del tenant en lugar de la BD central.
|
||||
*/
|
||||
export async function consultarConstanciaContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
): Promise<ConstanciaRow> {
|
||||
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||
const fiel = await getDecryptedFielContribuyente(pool, safeId);
|
||||
if (!fiel) throw new Error('No hay FIEL configurada para este contribuyente o está vencida');
|
||||
|
||||
const tempId = randomUUID();
|
||||
const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
|
||||
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
||||
const cerPath = join(tempDir, 'cert.cer');
|
||||
const keyPath = join(tempDir, 'key.key');
|
||||
|
||||
try {
|
||||
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
||||
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||
|
||||
const headless = process.env.SAT_HEADLESS !== 'false';
|
||||
const browser = await chromium.launch({ headless });
|
||||
try {
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT),
|
||||
);
|
||||
|
||||
const resultPromise = (async () => {
|
||||
const session = await loginSatCsf(browser, cerPath, keyPath, fiel.password);
|
||||
const pdfBuffer = await extractCsfPdf(session);
|
||||
const csf = await parseCsfPdf(pdfBuffer);
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO constancias_situacion_fiscal
|
||||
(rfc, id_cif, razon_social, estatus_padron, fecha_emision, datos, pdf)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, rfc, id_cif, razon_social, estatus_padron, fecha_emision,
|
||||
datos, fecha_consulta, created_at`,
|
||||
[
|
||||
csf.rfc,
|
||||
csf.idCIF,
|
||||
csf.razonSocial ?? [csf.nombre, csf.primerApellido, csf.segundoApellido].filter(Boolean).join(' ') ?? null,
|
||||
csf.estatusPadron,
|
||||
csf.lugarFechaEmision,
|
||||
JSON.stringify(csf),
|
||||
pdfBuffer,
|
||||
],
|
||||
);
|
||||
|
||||
// Sync datos fiscales to contribuyente table
|
||||
try {
|
||||
const rawDom = csf.domicilio || {};
|
||||
|
||||
// The PDF parser sometimes captures label prefixes inside values
|
||||
// when the PDF has a two-column layout. Clean them out.
|
||||
function cleanDomField(val: string | undefined): string {
|
||||
if (!val) return '';
|
||||
// Remove embedded label prefixes like "Nombre de la Colonia: "
|
||||
return val
|
||||
.replace(/^.*(?:Nombre de la Colonia|Nombre del Municipio|Nombre de la Localidad|Nombre de la Entidad|Número Exterior|Número Interior|Tipo de Vialidad|Entre Calle|Y Calle|Código Postal)\s*:\s*/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Extract embedded values from fields that swallowed the next column
|
||||
function extractEmbedded(val: string | undefined, labelPrefix: string): string {
|
||||
if (!val) return '';
|
||||
const re = new RegExp(`${labelPrefix}\\s*:\\s*(.+)`, 'i');
|
||||
const m = val.match(re);
|
||||
return m ? m[1].trim() : '';
|
||||
}
|
||||
|
||||
// Check if values have embedded labels and extract the correct fields
|
||||
const rawNumInterior = rawDom.numeroInterior || '';
|
||||
const rawLocalidad = rawDom.localidad || '';
|
||||
|
||||
const colonia = rawDom.colonia
|
||||
|| extractEmbedded(rawNumInterior, 'Nombre de la Colonia')
|
||||
|| extractEmbedded(rawLocalidad, 'Nombre de la Colonia')
|
||||
|| '';
|
||||
const municipio = rawDom.municipio
|
||||
|| extractEmbedded(rawLocalidad, 'Nombre del Municipio o Demarcación Territorial')
|
||||
|| extractEmbedded(rawNumInterior, 'Nombre del Municipio')
|
||||
|| '';
|
||||
|
||||
// Map CSF field names → UI field names
|
||||
const domicilioMapped = {
|
||||
codigoPostal: cleanDomField(rawDom.codigoPostal),
|
||||
calle: cleanDomField(rawDom.nombreVialidad) || '',
|
||||
numExterior: cleanDomField(rawDom.numeroExterior),
|
||||
numInterior: cleanDomField(rawDom.numeroInterior),
|
||||
colonia: cleanDomField(colonia),
|
||||
ciudad: cleanDomField(rawDom.localidad) || cleanDomField(rawDom.municipio) || '',
|
||||
municipio: cleanDomField(municipio),
|
||||
estado: cleanDomField(rawDom.entidadFederativa),
|
||||
entreCalle: cleanDomField(rawDom.entreCalle),
|
||||
yCalle: cleanDomField(rawDom.yCalle),
|
||||
};
|
||||
|
||||
// Resolve ALL regímenes (not just the first)
|
||||
let regimenClaves: string[] = [];
|
||||
if (csf.regimenes?.length) {
|
||||
const { prisma: centralPrisma } = await import('../config/database.js');
|
||||
const allRegimenes = await centralPrisma.regimen.findMany({
|
||||
where: { activo: true },
|
||||
select: { clave: true, descripcion: true },
|
||||
});
|
||||
|
||||
// Normalize for accent-insensitive comparison
|
||||
const norm = (s: string) => s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
|
||||
|
||||
for (const reg of csf.regimenes) {
|
||||
if (reg.fechaFin) continue; // Skip inactive regimenes
|
||||
const regNorm = norm(reg.nombre);
|
||||
// Score-based: prefer the match with the highest overlap
|
||||
let bestMatch: { clave: string; score: number } | null = null;
|
||||
for (const r of allRegimenes) {
|
||||
const catNorm = norm(r.descripcion);
|
||||
// Exact match or containment
|
||||
if (regNorm === catNorm || regNorm.includes(catNorm) || catNorm.includes(regNorm)) {
|
||||
const score = catNorm.length; // Longer match = more specific = better
|
||||
if (!bestMatch || score > bestMatch.score) {
|
||||
bestMatch = { clave: r.clave, score };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestMatch) regimenClaves.push(bestMatch.clave);
|
||||
}
|
||||
}
|
||||
|
||||
await pool.query(`
|
||||
UPDATE contribuyentes SET
|
||||
regimen_fiscal = COALESCE($2, regimen_fiscal),
|
||||
codigo_postal = COALESCE($3, codigo_postal),
|
||||
domicilio = COALESCE($4, domicilio)
|
||||
WHERE entidad_id = $1
|
||||
`, [
|
||||
contribuyenteId,
|
||||
regimenClaves.length > 0 ? regimenClaves.join(',') : null,
|
||||
domicilioMapped.codigoPostal || null,
|
||||
JSON.stringify(domicilioMapped),
|
||||
]);
|
||||
console.log(`[CSF] Datos fiscales sincronizados para contribuyente ${contribuyenteId}: regímenes=${regimenClaves.join(',')}, CP=${domicilioMapped.codigoPostal}`);
|
||||
} catch (syncErr: any) {
|
||||
console.error(`[CSF] Error sincronizando datos fiscales:`, syncErr.message);
|
||||
}
|
||||
|
||||
return rowToConstancia(rows[0]);
|
||||
})();
|
||||
|
||||
return await Promise.race([resultPromise, timeoutPromise]);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
try { unlinkSync(cerPath); } catch { /* ok */ }
|
||||
try { unlinkSync(keyPath); } catch { /* ok */ }
|
||||
try { rmdirSync(tempDir); } catch { /* ok */ }
|
||||
}
|
||||
}
|
||||
493
apps/api/src/services/contribuyente-facturapi.service.ts
Normal file
493
apps/api/src/services/contribuyente-facturapi.service.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
import Facturapi from 'facturapi';
|
||||
import type { Pool } from 'pg';
|
||||
import { Credential } from '@nodecfdi/credentials/node';
|
||||
import { env } from '../config/env.js';
|
||||
import { encryptString, decryptToString } from './sat/sat-crypto.service.js';
|
||||
|
||||
function getUserClient(): Facturapi {
|
||||
if (!env.FACTURAPI_USER_KEY) throw new Error('FACTURAPI_USER_KEY no configurada');
|
||||
return new Facturapi(env.FACTURAPI_USER_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera una Live Secret Key para una organización Facturapi via PUT idempotente.
|
||||
* Si la org ya tiene live key, devuelve la existente; si no, crea una nueva.
|
||||
* Endpoint oficial Facturapi: PUT /v2/organizations/{id}/apikeys/live
|
||||
*/
|
||||
async function generateLiveKey(orgId: string): Promise<string> {
|
||||
const userKey = env.FACTURAPI_USER_KEY!;
|
||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${userKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
throw new Error(`Facturapi PUT /apikeys/live falló (${res.status}): ${errBody}`);
|
||||
}
|
||||
const key = (await res.text()).replace(/"/g, '').trim();
|
||||
if (!key.startsWith('sk_live_')) {
|
||||
throw new Error(`Respuesta inesperada de Facturapi (no es sk_live_*): ${key.slice(0, 10)}...`);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cifra y persiste la Live Secret Key de una organización.
|
||||
* AES-256-GCM con la clave derivada de FIEL_ENCRYPTION_KEY.
|
||||
*/
|
||||
async function persistEncryptedKey(pool: Pool, orgId: string, plaintextKey: string): Promise<void> {
|
||||
const { encrypted, iv, tag } = encryptString(plaintextKey);
|
||||
await pool.query(
|
||||
`UPDATE facturapi_orgs SET api_key_enc = $1, api_key_iv = $2, api_key_tag = $3 WHERE facturapi_org_id = $4`,
|
||||
[encrypted, iv, tag, orgId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene la Live Secret Key cacheada (descifra de BD) o la genera vía PUT
|
||||
* y la persiste si no existe (caso de orgs legacy creadas antes del refactor live).
|
||||
*/
|
||||
async function getOrgApiKey(pool: Pool, orgId: string): Promise<string> {
|
||||
const { rows } = await pool.query<{ api_key_enc: Buffer | null; api_key_iv: Buffer | null; api_key_tag: Buffer | null }>(
|
||||
`SELECT api_key_enc, api_key_iv, api_key_tag FROM facturapi_orgs WHERE facturapi_org_id = $1 LIMIT 1`,
|
||||
[orgId],
|
||||
);
|
||||
if (rows.length === 0) throw new Error(`Organización ${orgId} no encontrada en BD tenant`);
|
||||
|
||||
const row = rows[0];
|
||||
if (row.api_key_enc && row.api_key_iv && row.api_key_tag) {
|
||||
return decryptToString(row.api_key_enc, row.api_key_iv, row.api_key_tag);
|
||||
}
|
||||
|
||||
// Org legacy sin live key cacheada — generar y guardar (idempotente).
|
||||
const apiKey = await generateLiveKey(orgId);
|
||||
await persistEncryptedKey(pool, orgId, apiKey);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export async function createOrgContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
nombre: string
|
||||
): Promise<{ orgId: string; reused?: boolean; recreated?: boolean }> {
|
||||
const { rows: existing } = await pool.query(
|
||||
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1',
|
||||
[contribuyenteId]
|
||||
);
|
||||
const client = getUserClient();
|
||||
|
||||
// Caso 1: hay fila local → verificar si la org sigue viva en Facturapi.
|
||||
// Si existe en ambos lados, idempotente (devolver la existente).
|
||||
// Si existe solo local pero Facturapi no la tiene (eliminada allá, API key
|
||||
// cambió, etc.), recrear y actualizar el FK local — desbloquea el flujo
|
||||
// de CSD que si no se quedaba trabado.
|
||||
if (existing.length > 0) {
|
||||
const existingId = existing[0].facturapi_org_id;
|
||||
try {
|
||||
await client.organizations.retrieve(existingId);
|
||||
// Idempotente: si existe en ambos lados, asegurar que la live key está
|
||||
// cacheada (puede faltar en orgs legacy creadas antes del refactor live).
|
||||
await ensureLiveKeyCached(pool, existingId);
|
||||
return { orgId: existingId, reused: true };
|
||||
} catch {
|
||||
const org = await client.organizations.create({ name: nombre });
|
||||
await pool.query(
|
||||
'UPDATE facturapi_orgs SET facturapi_org_id = $2, csd_uploaded = false, active = true, api_key_enc = NULL, api_key_iv = NULL, api_key_tag = NULL WHERE contribuyente_id = $1',
|
||||
[contribuyenteId, org.id]
|
||||
);
|
||||
// Eager: generar y cachear live key para que la org quede lista para emitir.
|
||||
await ensureLiveKeyCached(pool, org.id);
|
||||
return { orgId: org.id, recreated: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Caso 2: no hay fila local → crear fresh.
|
||||
const org = await client.organizations.create({ name: nombre });
|
||||
await pool.query(
|
||||
'INSERT INTO facturapi_orgs (contribuyente_id, facturapi_org_id) VALUES ($1, $2)',
|
||||
[contribuyenteId, org.id]
|
||||
);
|
||||
// Eager: generar y cachear live key inmediatamente tras crear la org.
|
||||
await ensureLiveKeyCached(pool, org.id);
|
||||
return { orgId: org.id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Garantiza que la org tiene su Live Secret Key cifrada en BD. Si ya existe,
|
||||
* no-op. Si no, hace PUT live y la persiste. Idempotente — el endpoint
|
||||
* Facturapi PUT /apikeys/live es idempotente, devuelve la misma key si ya
|
||||
* existe en su lado.
|
||||
*/
|
||||
async function ensureLiveKeyCached(pool: Pool, orgId: string): Promise<void> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT 1 FROM facturapi_orgs WHERE facturapi_org_id = $1 AND api_key_enc IS NOT NULL LIMIT 1`,
|
||||
[orgId],
|
||||
);
|
||||
if (rows.length > 0) return;
|
||||
const apiKey = await generateLiveKey(orgId);
|
||||
await persistEncryptedKey(pool, orgId, apiKey);
|
||||
}
|
||||
|
||||
export async function getOrgStatusContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string
|
||||
): Promise<{ configured: boolean; orgId?: string; legalName?: string; hasCsd?: boolean }> {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT facturapi_org_id, csd_uploaded FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
|
||||
[contribuyenteId]
|
||||
);
|
||||
if (rows.length === 0) return { configured: false };
|
||||
|
||||
try {
|
||||
const client = getUserClient();
|
||||
const org = await client.organizations.retrieve(rows[0].facturapi_org_id);
|
||||
return {
|
||||
configured: true,
|
||||
orgId: org.id,
|
||||
legalName: org.legal?.name || undefined,
|
||||
hasCsd: !!org.certificate?.has_certificate,
|
||||
};
|
||||
} catch {
|
||||
return { configured: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadCsdContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
cerFile: string,
|
||||
keyFile: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const { rows } = await pool.query<{ facturapi_org_id: string; rfc: string }>(
|
||||
`SELECT fo.facturapi_org_id, c.rfc
|
||||
FROM facturapi_orgs fo
|
||||
JOIN contribuyentes c ON c.entidad_id = fo.contribuyente_id
|
||||
WHERE fo.contribuyente_id = $1 AND fo.active = true`,
|
||||
[contribuyenteId]
|
||||
);
|
||||
if (rows.length === 0) throw new Error('Primero debe crearse la organización Facturapi del contribuyente');
|
||||
|
||||
const { facturapi_org_id, rfc: contribuyenteRfc } = rows[0];
|
||||
|
||||
// Validación preventiva: que el certificado sea CSD (no FIEL), que el RFC
|
||||
// coincida con el contribuyente y que no esté vencido. Facturapi también
|
||||
// valida, pero su mensaje de error es poco específico ("Certificado no
|
||||
// válido") — el nuestro dice exactamente qué pasa.
|
||||
const cerData = Buffer.from(cerFile, 'base64');
|
||||
const keyData = Buffer.from(keyFile, 'base64');
|
||||
|
||||
let credential: Credential;
|
||||
try {
|
||||
credential = Credential.create(cerData.toString('binary'), keyData.toString('binary'), password);
|
||||
} catch {
|
||||
return { success: false, message: 'Los archivos .cer/.key no son válidos o la contraseña es incorrecta' };
|
||||
}
|
||||
|
||||
// Debe ser CSD (sello digital para facturar), no FIEL (e.firma para trámites).
|
||||
if (credential.isFiel()) {
|
||||
return { success: false, message: 'El certificado es una FIEL (e.firma), no un CSD. Sube el Certificado de Sello Digital.' };
|
||||
}
|
||||
|
||||
const certRfc = credential.certificate().rfc().toUpperCase();
|
||||
if (certRfc !== contribuyenteRfc.toUpperCase()) {
|
||||
return {
|
||||
success: false,
|
||||
message: `El RFC del CSD (${certRfc}) no coincide con el del contribuyente (${contribuyenteRfc}). Verifica que estés subiendo los archivos correctos.`,
|
||||
};
|
||||
}
|
||||
|
||||
const validUntil = new Date(String(credential.certificate().validToDateTime()));
|
||||
if (new Date() > validUntil) {
|
||||
return { success: false, message: `El CSD está vencido desde ${validUntil.toLocaleDateString('es-MX')}. Solicita al SAT uno nuevo.` };
|
||||
}
|
||||
|
||||
const client = getUserClient();
|
||||
try {
|
||||
await client.organizations.uploadCertificate(
|
||||
facturapi_org_id,
|
||||
cerData,
|
||||
keyData,
|
||||
password,
|
||||
);
|
||||
await pool.query(
|
||||
'UPDATE facturapi_orgs SET csd_uploaded = true WHERE contribuyente_id = $1',
|
||||
[contribuyenteId]
|
||||
);
|
||||
return { success: true, message: 'CSD subido correctamente' };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Error al subir CSD a Facturapi' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOrgClientContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string
|
||||
): Promise<Facturapi> {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true',
|
||||
[contribuyenteId]
|
||||
);
|
||||
if (rows.length === 0) throw new Error('Contribuyente no tiene organización Facturapi configurada');
|
||||
|
||||
const apiKey = await getOrgApiKey(pool, rows[0].facturapi_org_id);
|
||||
return new Facturapi(apiKey);
|
||||
}
|
||||
|
||||
export async function cancelInvoiceContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
facturapiId: string,
|
||||
motive: '01' | '02' | '03' | '04' = '02',
|
||||
substitution?: string,
|
||||
): Promise<any> {
|
||||
const client = await getOrgClientContribuyente(pool, contribuyenteId);
|
||||
const cancelData: any = { motive };
|
||||
if (motive === '01' && substitution) cancelData.substitution = substitution;
|
||||
return client.invoices.cancel(facturapiId, cancelData);
|
||||
}
|
||||
|
||||
function streamToBuffer(stream: any): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (Buffer.isBuffer(stream)) return resolve(stream);
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadPdfContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
facturapiId: string,
|
||||
): Promise<Buffer> {
|
||||
const client = await getOrgClientContribuyente(pool, contribuyenteId);
|
||||
const stream = await client.invoices.downloadPdf(facturapiId);
|
||||
return streamToBuffer(stream);
|
||||
}
|
||||
|
||||
export async function downloadXmlContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
facturapiId: string,
|
||||
): Promise<Buffer> {
|
||||
const client = await getOrgClientContribuyente(pool, contribuyenteId);
|
||||
const stream = await client.invoices.downloadXml(facturapiId);
|
||||
return streamToBuffer(stream);
|
||||
}
|
||||
|
||||
export async function sendInvoiceByEmailContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
facturapiId: string,
|
||||
email: string,
|
||||
): Promise<void> {
|
||||
const client = await getOrgClientContribuyente(pool, contribuyenteId);
|
||||
await client.invoices.sendByEmail(facturapiId, { email });
|
||||
}
|
||||
|
||||
export async function createInvoiceContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
data: any
|
||||
): Promise<any> {
|
||||
const client = await getOrgClientContribuyente(pool, contribuyenteId);
|
||||
|
||||
// Create/update customer in Facturapi
|
||||
const isForiegn = !!data.customer?.country && data.customer.country !== 'MEX';
|
||||
const customerData: any = {
|
||||
legal_name: data.customer?.legalName,
|
||||
tax_id: data.customer?.taxId,
|
||||
email: data.customer?.email,
|
||||
address: { zip: data.customer?.zip, ...(isForiegn ? { country: data.customer.country } : {}) },
|
||||
};
|
||||
if (!isForiegn && data.customer?.taxSystem) customerData.tax_system = data.customer.taxSystem;
|
||||
|
||||
let customerId: string;
|
||||
try {
|
||||
const existing = await client.customers.list({ search: data.customer?.taxId });
|
||||
const match = existing.data?.find((c: any) => c.tax_id === data.customer?.taxId);
|
||||
if (match) {
|
||||
await client.customers.update(match.id, customerData);
|
||||
customerId = match.id;
|
||||
} else {
|
||||
const created = await client.customers.create(customerData);
|
||||
customerId = created.id;
|
||||
}
|
||||
} catch {
|
||||
const created = await client.customers.create(customerData);
|
||||
customerId = created.id;
|
||||
}
|
||||
|
||||
// Build invoice payload (mirrors createInvoice logic in facturapi.service.ts)
|
||||
const tipo = data.type || 'I';
|
||||
const invoicePayload: any = { customer: customerId };
|
||||
|
||||
if (tipo !== 'I') invoicePayload.type = tipo;
|
||||
|
||||
if (data.items?.length) {
|
||||
invoicePayload.items = data.items.map((item: any) => ({
|
||||
quantity: item.quantity,
|
||||
product: {
|
||||
description: item.description,
|
||||
product_key: item.productKey,
|
||||
unit_key: item.unitKey || 'E48',
|
||||
unit_name: item.unitName || 'Servicio',
|
||||
price: item.price,
|
||||
tax_included: item.taxIncluded ?? true,
|
||||
taxes: item.taxes?.map((t: any) => ({
|
||||
type: t.type,
|
||||
rate: t.rate,
|
||||
factor: t.factor || 'Tasa',
|
||||
...(t.withholding ? { withholding: true } : {}),
|
||||
})) || [{ type: 'IVA', rate: 0.16 }],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
if (tipo === 'I' || tipo === 'E') {
|
||||
invoicePayload.use = data.use || 'G01';
|
||||
invoicePayload.payment_form = data.paymentForm || '99';
|
||||
invoicePayload.payment_method = data.paymentMethod || 'PUE';
|
||||
invoicePayload.currency = data.currency || 'MXN';
|
||||
if (data.exchangeRate && data.currency !== 'MXN') invoicePayload.exchange = data.exchangeRate;
|
||||
if (data.conditions) invoicePayload.conditions = data.conditions;
|
||||
}
|
||||
|
||||
if (data.series) invoicePayload.series = data.series;
|
||||
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
|
||||
|
||||
if (data.relatedDocuments?.length) {
|
||||
// Estructura SAT 4.0: agrupa N uuids por tipo de relación. Acepta tanto
|
||||
// el formato nuevo {relationship, uuids[]} como el legacy {relationship,
|
||||
// uuid} para compat durante transición de callers frontend.
|
||||
invoicePayload.related_documents = data.relatedDocuments.map((r: any) => ({
|
||||
relationship: r.relationship,
|
||||
documents: Array.isArray(r.uuids) ? r.uuids : (r.uuid ? [r.uuid] : []),
|
||||
}));
|
||||
}
|
||||
|
||||
if (data.complements?.length) invoicePayload.complements = data.complements;
|
||||
if (data.global) invoicePayload.global = data.global;
|
||||
|
||||
// Régimen fiscal del emisor: Facturapi NO acepta override per-invoice via
|
||||
// campo `issuer` (rechaza con "issuer is not allowed"). La única forma es
|
||||
// actualizar el `legal.tax_system` de la organización antes del emit.
|
||||
// Para contribuyentes con múltiples regímenes, esto significa un sync en
|
||||
// cada emit cuando el seleccionado difiere del actual en la org.
|
||||
if (data.issuerTaxSystem) {
|
||||
const { rows } = await pool.query<{ facturapi_org_id: string }>(
|
||||
`SELECT facturapi_org_id FROM facturapi_orgs WHERE contribuyente_id = $1 AND active = true`,
|
||||
[contribuyenteId],
|
||||
);
|
||||
if (rows.length > 0) {
|
||||
await ensureOrgLegalForEmit(pool, contribuyenteId, rows[0].facturapi_org_id, data.issuerTaxSystem);
|
||||
}
|
||||
}
|
||||
|
||||
return client.invoices.create(invoicePayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincroniza los datos fiscales de la organización Facturapi con la
|
||||
* información del contribuyente, usando el régimen seleccionado. Se llama
|
||||
* antes de cada emit cuando el user elige un régimen en el form, porque
|
||||
* Facturapi toma el TaxSystem del CFDI del `legal.tax_system` de la org
|
||||
* (no acepta override per-invoice). No-op si el `legal` ya coincide.
|
||||
*/
|
||||
async function ensureOrgLegalForEmit(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
orgId: string,
|
||||
chosenTaxSystem: string,
|
||||
): Promise<void> {
|
||||
const userKey = env.FACTURAPI_USER_KEY;
|
||||
if (!userKey) throw new Error('FACTURAPI_USER_KEY no configurada');
|
||||
|
||||
// Datos fiscales del contribuyente (razón social + domicilio)
|
||||
const { rows } = await pool.query<{
|
||||
rfc: string;
|
||||
razon_social: string | null;
|
||||
regimen_fiscal: string | null;
|
||||
codigo_postal: string | null;
|
||||
domicilio: any;
|
||||
}>(
|
||||
`SELECT c.rfc, r.razon_social, c.regimen_fiscal, c.codigo_postal, c.domicilio
|
||||
FROM contribuyentes c
|
||||
LEFT JOIN rfcs r ON UPPER(r.rfc) = UPPER(c.rfc)
|
||||
WHERE c.entidad_id = $1`,
|
||||
[contribuyenteId],
|
||||
);
|
||||
if (rows.length === 0) throw new Error('Contribuyente no encontrado');
|
||||
const contrib = rows[0];
|
||||
|
||||
// Validar que el régimen elegido esté entre los registrados del contrib
|
||||
const allowed = (contrib.regimen_fiscal || '')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
if (allowed.length > 0 && !allowed.includes(chosenTaxSystem)) {
|
||||
throw new Error(
|
||||
`El régimen ${chosenTaxSystem} no está registrado para este contribuyente ` +
|
||||
`(registrados: ${allowed.join(', ')})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Leer el legal actual de la org en Facturapi
|
||||
const getRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}`, {
|
||||
headers: { 'Authorization': `Bearer ${userKey}` },
|
||||
});
|
||||
if (!getRes.ok) {
|
||||
throw new Error(`No se pudo leer organización Facturapi (${getRes.status})`);
|
||||
}
|
||||
const org = (await getRes.json()) as any;
|
||||
const currentLegal = org.legal || {};
|
||||
|
||||
// Si el tax_system ya coincide y la razón social está seteada, no tocar
|
||||
// (evita updates innecesarios con latencia extra).
|
||||
if (
|
||||
currentLegal.tax_system === chosenTaxSystem &&
|
||||
currentLegal.legal_name &&
|
||||
currentLegal.legal_name === contrib.razon_social
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const domicilio = (contrib.domicilio || {}) as any;
|
||||
const legalPayload = {
|
||||
name: contrib.razon_social || currentLegal.name || '',
|
||||
legal_name: contrib.razon_social || currentLegal.legal_name || '',
|
||||
tax_system: chosenTaxSystem,
|
||||
address: {
|
||||
street: domicilio.calle || currentLegal.address?.street || '',
|
||||
exterior: domicilio.numExterior || currentLegal.address?.exterior || '',
|
||||
interior: domicilio.numInterior || currentLegal.address?.interior || '',
|
||||
neighborhood: domicilio.colonia || currentLegal.address?.neighborhood || '',
|
||||
city: domicilio.ciudad || currentLegal.address?.city || '',
|
||||
municipality: domicilio.municipio || currentLegal.address?.municipality || '',
|
||||
state: domicilio.estado || currentLegal.address?.state || '',
|
||||
zip: contrib.codigo_postal || domicilio.codigoPostal || currentLegal.address?.zip || '',
|
||||
},
|
||||
};
|
||||
|
||||
const putRes = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/legal`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${userKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(legalPayload),
|
||||
});
|
||||
|
||||
if (!putRes.ok) {
|
||||
const errText = await putRes.text();
|
||||
throw new Error(
|
||||
`Error actualizando datos fiscales de la organización Facturapi (${putRes.status}): ${errText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
202
apps/api/src/services/contribuyente-fiel.service.ts
Normal file
202
apps/api/src/services/contribuyente-fiel.service.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { Credential } from '@nodecfdi/credentials/node';
|
||||
import type { Pool } from 'pg';
|
||||
import { encryptFielCredentials, decryptFielCredentials } from './sat/sat-crypto.service.js';
|
||||
import type { FielStatus } from '@horux/shared';
|
||||
|
||||
export async function uploadFielContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
cerBase64: string,
|
||||
keyBase64: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; message: string; status?: FielStatus }> {
|
||||
try {
|
||||
const cerData = Buffer.from(cerBase64, 'base64');
|
||||
const keyData = Buffer.from(keyBase64, 'base64');
|
||||
|
||||
let credential: Credential;
|
||||
try {
|
||||
credential = Credential.create(cerData.toString('binary'), keyData.toString('binary'), password);
|
||||
} catch {
|
||||
return { success: false, message: 'Los archivos de la FIEL no son válidos o la contraseña es incorrecta' };
|
||||
}
|
||||
|
||||
if (!credential.isFiel()) {
|
||||
return { success: false, message: 'El certificado proporcionado no es una FIEL (e.firma). Parece ser un CSD.' };
|
||||
}
|
||||
|
||||
const certificate = credential.certificate();
|
||||
const rfc = certificate.rfc();
|
||||
const serialNumber = certificate.serialNumber().bytes();
|
||||
const validFrom = new Date(String(certificate.validFromDateTime()));
|
||||
const validUntil = new Date(String(certificate.validToDateTime()));
|
||||
|
||||
if (new Date() > validUntil) {
|
||||
return { success: false, message: 'La FIEL está vencida desde ' + validUntil.toLocaleDateString() };
|
||||
}
|
||||
|
||||
const enc = encryptFielCredentials(cerData, keyData, password);
|
||||
|
||||
// Check whether this contribuyente already had an active FIEL (to decide auto-sync)
|
||||
const { rows: existingRows } = await pool.query(
|
||||
`SELECT 1 FROM fiel_contribuyente WHERE contribuyente_id = $1 AND is_active = true`,
|
||||
[contribuyenteId]
|
||||
);
|
||||
const isFirstUpload = existingRows.length === 0;
|
||||
|
||||
await pool.query(`
|
||||
INSERT INTO fiel_contribuyente (
|
||||
contribuyente_id, rfc, cer_data, key_data, key_password_enc,
|
||||
cer_iv, cer_tag, key_iv, key_tag, password_iv, password_tag,
|
||||
serial_number, valid_from, valid_until, is_active
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, true)
|
||||
ON CONFLICT (contribuyente_id) DO UPDATE SET
|
||||
rfc = $2, cer_data = $3, key_data = $4, key_password_enc = $5,
|
||||
cer_iv = $6, cer_tag = $7, key_iv = $8, key_tag = $9,
|
||||
password_iv = $10, password_tag = $11,
|
||||
serial_number = $12, valid_from = $13, valid_until = $14,
|
||||
is_active = true, updated_at = now()
|
||||
`, [
|
||||
contribuyenteId, rfc,
|
||||
enc.encryptedCer, enc.encryptedKey, enc.encryptedPassword,
|
||||
enc.cerIv, enc.cerTag, enc.keyIv, enc.keyTag, enc.passwordIv, enc.passwordTag,
|
||||
serialNumber, validFrom, validUntil,
|
||||
]);
|
||||
|
||||
// Trigger auto-sync on first upload (fire-and-forget)
|
||||
if (isFirstUpload) {
|
||||
import('./opinion-cumplimiento.service.js').then(async ({ consultarOpinionContribuyente }) => {
|
||||
try {
|
||||
await consultarOpinionContribuyente(pool, contribuyenteId);
|
||||
} catch (err: any) {
|
||||
console.error(`[FIEL first-upload] Opinión falló para contribuyente ${contribuyenteId}:`, err.message || err);
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
import('./constancia.service.js').then(async ({ consultarConstanciaContribuyente }) => {
|
||||
try {
|
||||
await consultarConstanciaContribuyente(pool, contribuyenteId);
|
||||
} catch (err: any) {
|
||||
console.error(`[FIEL first-upload] CSF falló para contribuyente ${contribuyenteId}:`, err.message || err);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const daysUntilExpiration = Math.ceil((validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'FIEL configurada correctamente',
|
||||
status: { configured: true, rfc, serialNumber, validFrom: validFrom.toISOString(), validUntil: validUntil.toISOString(), isExpired: false, daysUntilExpiration },
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[FIEL Contribuyente Upload Error]', error);
|
||||
return { success: false, message: error.message || 'Error al procesar la FIEL' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFielStatusContribuyente(pool: Pool, contribuyenteId: string): Promise<FielStatus> {
|
||||
// Try per-contribuyente first (tenant BD)
|
||||
const { rows } = await pool.query(`
|
||||
SELECT rfc, serial_number AS "serialNumber", valid_from AS "validFrom", valid_until AS "validUntil", is_active AS "isActive"
|
||||
FROM fiel_contribuyente WHERE contribuyente_id = $1
|
||||
`, [contribuyenteId]);
|
||||
|
||||
if (rows.length === 0 || !rows[0].isActive) {
|
||||
// Fallback: check legacy tenant-level FIEL by matching RFC
|
||||
const { rows: contribRows } = await pool.query('SELECT rfc FROM contribuyentes WHERE entidad_id = $1', [contribuyenteId]);
|
||||
const rfc = contribRows[0]?.rfc;
|
||||
if (rfc) {
|
||||
const { getFielStatus } = await import('./fiel.service.js');
|
||||
// getFielStatus reads by tenantId — check if the legacy FIEL matches this RFC
|
||||
// We need prisma access, so import it
|
||||
const { prisma } = await import('../config/database.js');
|
||||
const legacyFiel = await prisma.fielCredential.findFirst({
|
||||
where: { rfc, isActive: true },
|
||||
select: { rfc: true, serialNumber: true, validFrom: true, validUntil: true, isActive: true },
|
||||
});
|
||||
if (legacyFiel) {
|
||||
const now = new Date();
|
||||
const isExpired = now > legacyFiel.validUntil;
|
||||
const daysUntilExpiration = Math.ceil((legacyFiel.validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
return {
|
||||
configured: true,
|
||||
rfc: legacyFiel.rfc,
|
||||
serialNumber: legacyFiel.serialNumber || undefined,
|
||||
validFrom: legacyFiel.validFrom.toISOString(),
|
||||
validUntil: legacyFiel.validUntil.toISOString(),
|
||||
isExpired,
|
||||
daysUntilExpiration: isExpired ? 0 : daysUntilExpiration,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { configured: false };
|
||||
}
|
||||
|
||||
const fiel = rows[0];
|
||||
const now = new Date();
|
||||
const isExpired = now > new Date(fiel.validUntil);
|
||||
const daysUntilExpiration = Math.ceil((new Date(fiel.validUntil).getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
rfc: fiel.rfc,
|
||||
serialNumber: fiel.serialNumber || undefined,
|
||||
validFrom: new Date(fiel.validFrom).toISOString(),
|
||||
validUntil: new Date(fiel.validUntil).toISOString(),
|
||||
isExpired,
|
||||
daysUntilExpiration: isExpired ? 0 : daysUntilExpiration,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDecryptedFielContribuyente(pool: Pool, contribuyenteId: string): Promise<{
|
||||
cerContent: string; keyContent: string; password: string; rfc: string;
|
||||
} | null> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT * FROM fiel_contribuyente WHERE contribuyente_id = $1 AND is_active = true
|
||||
`, [contribuyenteId]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
// Fallback: check legacy FIEL by matching RFC
|
||||
const { rows: contribRows } = await pool.query('SELECT rfc FROM contribuyentes WHERE entidad_id = $1', [contribuyenteId]);
|
||||
const rfc = contribRows[0]?.rfc;
|
||||
if (rfc) {
|
||||
const { prisma } = await import('../config/database.js');
|
||||
const legacyFiel = await prisma.fielCredential.findFirst({
|
||||
where: { rfc, isActive: true },
|
||||
});
|
||||
if (legacyFiel && new Date() <= legacyFiel.validUntil) {
|
||||
try {
|
||||
const { decryptFielCredentials } = await import('./sat/sat-crypto.service.js');
|
||||
const { cerData, keyData, password } = decryptFielCredentials(
|
||||
Buffer.from(legacyFiel.cerData), Buffer.from(legacyFiel.keyData), Buffer.from(legacyFiel.keyPasswordEncrypted),
|
||||
Buffer.from(legacyFiel.cerIv), Buffer.from(legacyFiel.cerTag),
|
||||
Buffer.from(legacyFiel.keyIv), Buffer.from(legacyFiel.keyTag),
|
||||
Buffer.from(legacyFiel.passwordIv), Buffer.from(legacyFiel.passwordTag)
|
||||
);
|
||||
return { cerContent: cerData.toString('binary'), keyContent: keyData.toString('binary'), password, rfc: legacyFiel.rfc };
|
||||
} catch (err) {
|
||||
console.error('[FIEL Contribuyente] Legacy decrypt failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const fiel = rows[0];
|
||||
|
||||
if (new Date() > new Date(fiel.valid_until)) return null;
|
||||
|
||||
try {
|
||||
const { cerData, keyData, password } = decryptFielCredentials(
|
||||
Buffer.from(fiel.cer_data), Buffer.from(fiel.key_data), Buffer.from(fiel.key_password_enc),
|
||||
Buffer.from(fiel.cer_iv), Buffer.from(fiel.cer_tag),
|
||||
Buffer.from(fiel.key_iv), Buffer.from(fiel.key_tag),
|
||||
Buffer.from(fiel.password_iv), Buffer.from(fiel.password_tag)
|
||||
);
|
||||
return { cerContent: cerData.toString('binary'), keyContent: keyData.toString('binary'), password, rfc: fiel.rfc };
|
||||
} catch (error) {
|
||||
console.error('[FIEL Contribuyente Decrypt Error]', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
187
apps/api/src/services/contribuyente.service.ts
Normal file
187
apps/api/src/services/contribuyente.service.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export interface CreateContribuyenteData {
|
||||
rfc: string;
|
||||
razonSocial: string;
|
||||
regimenFiscal?: string;
|
||||
codigoPostal?: string;
|
||||
domicilio?: Record<string, unknown>;
|
||||
supervisorUserId?: string;
|
||||
}
|
||||
|
||||
export interface ContribuyenteRow {
|
||||
id: string;
|
||||
tipo: string;
|
||||
nombre: string;
|
||||
identificador: string;
|
||||
supervisorUserId: string | null;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
rfc: string;
|
||||
regimenFiscal: string | null;
|
||||
codigoPostal: string | null;
|
||||
domicilio: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export async function listContribuyentes(pool: Pool, entidadIds?: string[]): Promise<ContribuyenteRow[]> {
|
||||
let query = `
|
||||
SELECT
|
||||
e.id, e.tipo, e.nombre, e.identificador,
|
||||
e.supervisor_user_id AS "supervisorUserId",
|
||||
e.active, e.created_at AS "createdAt",
|
||||
c.rfc, c.regimen_fiscal AS "regimenFiscal",
|
||||
c.codigo_postal AS "codigoPostal", c.domicilio
|
||||
FROM entidades_gestionadas e
|
||||
JOIN contribuyentes c ON c.entidad_id = e.id
|
||||
WHERE e.active = true
|
||||
`;
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (entidadIds !== undefined) {
|
||||
if (entidadIds.length === 0) return []; // No access = empty list
|
||||
query += ` AND e.id = ANY($1)`;
|
||||
params.push(entidadIds);
|
||||
}
|
||||
|
||||
query += ' ORDER BY e.created_at DESC';
|
||||
const { rows } = await pool.query(query, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getContribuyenteById(pool: Pool, id: string): Promise<ContribuyenteRow | null> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
e.id, e.tipo, e.nombre, e.identificador,
|
||||
e.supervisor_user_id AS "supervisorUserId",
|
||||
e.active, e.created_at AS "createdAt",
|
||||
c.rfc, c.regimen_fiscal AS "regimenFiscal",
|
||||
c.codigo_postal AS "codigoPostal", c.domicilio
|
||||
FROM entidades_gestionadas e
|
||||
JOIN contribuyentes c ON c.entidad_id = e.id
|
||||
WHERE e.id = $1
|
||||
`, [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function createContribuyente(pool: Pool, data: CreateContribuyenteData): Promise<ContribuyenteRow> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows: [entidad] } = await client.query(`
|
||||
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
|
||||
VALUES ('CONTRIBUYENTE', $1, $2, $3)
|
||||
RETURNING id
|
||||
`, [data.razonSocial, data.rfc.toUpperCase(), data.supervisorUserId ?? null]);
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal, domicilio)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, [entidad.id, data.rfc.toUpperCase(), data.regimenFiscal ?? null, data.codigoPostal ?? null, data.domicilio ? JSON.stringify(data.domicilio) : null]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Backfill: claim existing CFDIs that match this RFC
|
||||
await backfillCfdiContribuyente(pool, entidad.id, data.rfc.toUpperCase()).catch(
|
||||
(err) => console.error('[Contribuyente] Backfill CFDIs failed (non-blocking):', err)
|
||||
);
|
||||
|
||||
return (await getContribuyenteById(pool, entidad.id))!;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateContribuyente(pool: Pool, id: string, data: Partial<CreateContribuyenteData>): Promise<ContribuyenteRow | null> {
|
||||
const existing = await getContribuyenteById(pool, id);
|
||||
if (!existing) return null;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Update entidades_gestionadas if needed
|
||||
const entidadSets: string[] = [];
|
||||
const entidadVals: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.razonSocial) {
|
||||
entidadSets.push(`nombre = $${idx}`, `identificador = $${idx}`);
|
||||
entidadVals.push(data.razonSocial);
|
||||
idx++;
|
||||
}
|
||||
if (data.supervisorUserId !== undefined) {
|
||||
entidadSets.push(`supervisor_user_id = $${idx}`);
|
||||
entidadVals.push(data.supervisorUserId);
|
||||
idx++;
|
||||
}
|
||||
if (entidadSets.length > 0) {
|
||||
entidadSets.push('updated_at = now()');
|
||||
entidadVals.push(id);
|
||||
await client.query(`UPDATE entidades_gestionadas SET ${entidadSets.join(', ')} WHERE id = $${idx}`, entidadVals);
|
||||
}
|
||||
|
||||
// Update contribuyentes if needed
|
||||
const contribSets: string[] = [];
|
||||
const contribVals: unknown[] = [];
|
||||
idx = 1;
|
||||
|
||||
if (data.regimenFiscal !== undefined) { contribSets.push(`regimen_fiscal = $${idx}`); contribVals.push(data.regimenFiscal); idx++; }
|
||||
if (data.codigoPostal !== undefined) { contribSets.push(`codigo_postal = $${idx}`); contribVals.push(data.codigoPostal); idx++; }
|
||||
if (data.domicilio !== undefined) { contribSets.push(`domicilio = $${idx}`); contribVals.push(JSON.stringify(data.domicilio)); idx++; }
|
||||
|
||||
if (contribSets.length > 0) {
|
||||
contribVals.push(id);
|
||||
await client.query(`UPDATE contribuyentes SET ${contribSets.join(', ')} WHERE entidad_id = $${idx}`, contribVals);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return (await getContribuyenteById(pool, id))!;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deactivateContribuyente(pool: Pool, id: string): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
'UPDATE entidades_gestionadas SET active = false, updated_at = now() WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns contribuyente_id to CFDIs that match the RFC (emisor or receptor).
|
||||
* Runs after contribuyente creation and can be called manually for backfill.
|
||||
* Only updates CFDIs where contribuyente_id IS NULL (doesn't override).
|
||||
*/
|
||||
export async function backfillCfdiContribuyente(pool: Pool, contribuyenteId: string, rfc: string): Promise<number> {
|
||||
const { rowCount } = await pool.query(`
|
||||
UPDATE cfdis
|
||||
SET contribuyente_id = $1
|
||||
WHERE contribuyente_id IS NULL
|
||||
AND (rfc_emisor = $2 OR rfc_receptor = $2)
|
||||
`, [contribuyenteId, rfc]);
|
||||
const count = rowCount ?? 0;
|
||||
if (count > 0) {
|
||||
console.log(`[Backfill] Assigned ${count} CFDIs to contribuyente ${rfc} (${contribuyenteId})`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfills ALL contribuyentes in the tenant BD. Useful after initial SAT sync.
|
||||
*/
|
||||
export async function backfillAllContribuyentes(pool: Pool): Promise<number> {
|
||||
const { rows } = await pool.query('SELECT entidad_id, rfc FROM contribuyentes');
|
||||
let total = 0;
|
||||
for (const { entidad_id, rfc } of rows) {
|
||||
total += await backfillCfdiContribuyente(pool, entidad_id, rfc);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
1253
apps/api/src/services/dashboard.service.ts
Normal file
1253
apps/api/src/services/dashboard.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
399
apps/api/src/services/declaraciones.service.ts
Normal file
399
apps/api/src/services/declaraciones.service.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
// Mapeo: impuesto de la declaración → reglas para matchear obligaciones del
|
||||
// contribuyente. `include` son substrings que DEBE contener el nombre de la
|
||||
// obligación; `exclude` son substrings que NO debe contener. El exclude
|
||||
// resuelve ambigüedades como "IVA" matcheando "Declaración de proveedores
|
||||
// de IVA" (DIOT) — cuando subes pago de IVA normal, NO debe cerrar DIOT.
|
||||
const IMPUESTO_A_OBLIGACION_KEYWORDS: Record<string, { include: string[]; exclude: string[] }> = {
|
||||
IVA: { include: ['iva'], exclude: ['diot', 'proveedores de iva', 'informativa'] },
|
||||
ISR: { include: ['isr'], exclude: ['retenciones', 'asimilados a salarios'] },
|
||||
IEPS: { include: ['ieps'], exclude: [] },
|
||||
SUELDOS: { include: ['sueldos', 'salarios', 'nómina'], exclude: [] },
|
||||
DIOT: { include: ['diot', 'proveedores de iva'], exclude: [] },
|
||||
OTRO: { include: [], exclude: [] },
|
||||
};
|
||||
|
||||
/**
|
||||
* After uploading a declaration, find matching obligations for the contribuyente
|
||||
* and mark them as completed for the period. Also resolve the ob-* alerts.
|
||||
*
|
||||
* Guarda `declaracion_id` en `obligacion_periodos` para que la UI pueda
|
||||
* mostrar "Completada via Declaración #123" y permitir cross-link. Si la
|
||||
* declaración se borra, el FK pasa a NULL (ON DELETE SET NULL) y el
|
||||
* periodo sigue marcado completado — el usuario decidirá si re-abrirlo
|
||||
* manualmente.
|
||||
*/
|
||||
async function completarObligacionesPorDeclaracion(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
impuestos: string[],
|
||||
periodo: string,
|
||||
/** UUID del usuario que subió la declaración (obligacion_periodos.completada_por es uuid). */
|
||||
completadaPor: string,
|
||||
declaracionId: number,
|
||||
/** Periodicidad de la declaración. Si no se provee, se asume 'mensual'. */
|
||||
periodicidad: string = 'mensual',
|
||||
): Promise<number> {
|
||||
// Get active obligations for this contribuyente (incluye frecuencia para filtrar)
|
||||
const { rows: obligaciones } = await pool.query<{ id: string; nombre: string; frecuencia: string | null }>(
|
||||
`SELECT id, nombre, frecuencia FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND activa = true`,
|
||||
[contribuyenteId],
|
||||
);
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const impuesto of impuestos) {
|
||||
const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto];
|
||||
if (!rules || rules.include.length === 0) continue;
|
||||
|
||||
for (const ob of obligaciones) {
|
||||
const nombreLower = ob.nombre.toLowerCase();
|
||||
const matches = rules.include.some(kw => nombreLower.includes(kw))
|
||||
&& !rules.exclude.some(kw => nombreLower.includes(kw));
|
||||
if (!matches) continue;
|
||||
|
||||
// Filtro por periodicidad/frecuencia: una declaración mensual no debe
|
||||
// cerrar obligaciones anuales del mismo impuesto (ej. ISR mensual no
|
||||
// cubre "Declaración anual de ISR"). Si la obligación tiene frecuencia
|
||||
// explícita y no coincide con la periodicidad de la declaración, skip.
|
||||
// `eventual` obligaciones no se tocan automáticamente.
|
||||
const obFrec = (ob.frecuencia || '').toLowerCase();
|
||||
if (obFrec === 'eventual') continue;
|
||||
if (obFrec && obFrec !== periodicidad.toLowerCase()) continue;
|
||||
|
||||
// Mark obligation as completed for this period, with FK a la declaración
|
||||
await pool.query(`
|
||||
INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas, declaracion_id)
|
||||
VALUES ($1, $2, true, now(), $3, $4, $5)
|
||||
ON CONFLICT (obligacion_id, periodo)
|
||||
DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, declaracion_id = $5
|
||||
`, [ob.id, periodo, completadaPor, `Declaración ${impuesto} subida`, declaracionId]);
|
||||
|
||||
// Resolve the ob-* alert for this obligation+period
|
||||
await pool.query(
|
||||
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
|
||||
[`ob-${ob.id}-${periodo}`],
|
||||
);
|
||||
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declaraciones provisionales: PDF subido por el contador con la declaración
|
||||
* presentada al SAT + opcionalmente comprobante de pago. Al subir, se marcan
|
||||
* como resueltas las alertas correspondientes en la tabla `alertas` del tenant.
|
||||
*
|
||||
* El método legacy "marcar como realizado" desde /alertas sigue funcionando
|
||||
* para usuarios que no quieran subir el documento. Esta automatización es
|
||||
* adicional, no reemplaza.
|
||||
*/
|
||||
|
||||
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'SUELDOS' | 'DIOT' | 'OTRO';
|
||||
|
||||
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual';
|
||||
|
||||
export interface DeclaracionRow {
|
||||
id: number;
|
||||
año: number;
|
||||
mes: number;
|
||||
tipo: 'normal' | 'complementaria';
|
||||
periodicidad: Periodicidad;
|
||||
impuestos: string[];
|
||||
montoPago: number | null;
|
||||
pdfFilename: string | null;
|
||||
ligaPagoFilename: string | null;
|
||||
pdfPagoFilename: string | null;
|
||||
pagadoAt: string | null;
|
||||
creadoPor: string | null;
|
||||
notas: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tieneLigaPago: boolean;
|
||||
tienePagoPdf: boolean;
|
||||
}
|
||||
|
||||
// Mapeo Impuesto → prefijo de tipo de alerta (debe coincidir con
|
||||
// EVENTO_A_ALERTA en alertas-manuales.service.ts).
|
||||
const IMPUESTO_A_PREFIJO_DECL: Record<string, string[]> = {
|
||||
IVA: ['decl-iva'],
|
||||
ISR: ['decl-isr'],
|
||||
IEPS: ['decl-ieps'],
|
||||
SUELDOS: ['decl-sueldos'],
|
||||
DIOT: ['diot'],
|
||||
OTRO: [],
|
||||
};
|
||||
const IMPUESTO_A_PREFIJO_PAGO: Record<string, string[]> = {
|
||||
IVA: ['pago-iva'],
|
||||
ISR: ['pago-isr'],
|
||||
IEPS: ['pago-ieps'],
|
||||
SUELDOS: [], // sueldos solo es declaración informativa, no tiene pago provisional
|
||||
DIOT: [],
|
||||
OTRO: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Marca como resueltas las alertas cuyo `tipo` empieza con cualquiera de los
|
||||
* prefijos dados Y cuyo `fecha_vencimiento` cae en el mes/año dados.
|
||||
* Idempotente: re-llamar no crea efectos secundarios extra.
|
||||
*/
|
||||
async function resolverAlertasPorPeriodo(
|
||||
pool: Pool,
|
||||
prefijos: string[],
|
||||
año: number,
|
||||
mes: number,
|
||||
): Promise<number> {
|
||||
if (prefijos.length === 0) return 0;
|
||||
// El tipo es `prefijo-YYYY-MM-DD`. Buscar por LIKE prefijo-año-mes-%
|
||||
const mesStr = String(mes).padStart(2, '0');
|
||||
const conditions = prefijos.map((_, i) => `tipo LIKE $${i + 1}`).join(' OR ');
|
||||
const params = prefijos.map(p => `${p}-${año}-${mesStr}-%`);
|
||||
const { rowCount } = await pool.query(
|
||||
`UPDATE alertas SET resuelta = true
|
||||
WHERE (${conditions}) AND resuelta = false`,
|
||||
params,
|
||||
);
|
||||
return rowCount ?? 0;
|
||||
}
|
||||
|
||||
function rowToDeclaracion(r: any): DeclaracionRow {
|
||||
return {
|
||||
id: r.id,
|
||||
año: r.año,
|
||||
mes: r.mes,
|
||||
tipo: r.tipo,
|
||||
periodicidad: r.periodicidad || 'mensual',
|
||||
impuestos: r.impuestos || [],
|
||||
montoPago: r.monto_pago != null ? Number(r.monto_pago) : null,
|
||||
pdfFilename: r.pdf_filename,
|
||||
ligaPagoFilename: r.pdf_liga_pago_filename,
|
||||
pdfPagoFilename: r.pdf_pago_filename,
|
||||
pagadoAt: r.pagado_at?.toISOString() ?? null,
|
||||
creadoPor: r.creado_por,
|
||||
notas: r.notas,
|
||||
createdAt: r.created_at.toISOString(),
|
||||
updatedAt: r.updated_at.toISOString(),
|
||||
tieneLigaPago: !!r.pdf_liga_pago_filename,
|
||||
tienePagoPdf: !!r.pdf_pago_filename,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDeclaraciones(
|
||||
pool: Pool,
|
||||
fechaDesde?: string,
|
||||
fechaHasta?: string,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<DeclaracionRow[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (fechaDesde) {
|
||||
params.push(fechaDesde);
|
||||
conditions.push(`created_at >= $${params.length}::date`);
|
||||
}
|
||||
if (fechaHasta) {
|
||||
params.push(fechaHasta);
|
||||
conditions.push(`created_at < ($${params.length}::date + interval '1 day')`);
|
||||
}
|
||||
if (contribuyenteId) {
|
||||
// Sanitize UUID (hex + hyphens only)
|
||||
const safe = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||
if (safe) {
|
||||
params.push(safe);
|
||||
conditions.push(`contribuyente_id = $${params.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename,
|
||||
pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas,
|
||||
created_at, updated_at
|
||||
FROM declaraciones_provisionales
|
||||
${where}
|
||||
ORDER BY created_at DESC, año DESC, mes DESC`,
|
||||
params,
|
||||
);
|
||||
return rows.map(rowToDeclaracion);
|
||||
}
|
||||
|
||||
export async function createDeclaracion(
|
||||
pool: Pool,
|
||||
data: {
|
||||
año: number;
|
||||
mes: number;
|
||||
tipo: 'normal' | 'complementaria';
|
||||
periodicidad?: Periodicidad;
|
||||
impuestos: string[];
|
||||
montoPago?: number | null;
|
||||
pdfBase64: string; // PDF de la declaración (base64)
|
||||
pdfFilename: string;
|
||||
ligaPagoBase64?: string; // PDF de la liga de pago (opcional, base64)
|
||||
ligaPagoFilename?: string;
|
||||
notas?: string;
|
||||
/** Email del usuario (para declaraciones_provisionales.creado_por VARCHAR). */
|
||||
creadoPor: string;
|
||||
/** UUID del usuario (para obligacion_periodos.completada_por UUID). Opcional. */
|
||||
creadoPorUserId?: string;
|
||||
contribuyenteId?: string;
|
||||
},
|
||||
): Promise<{ declaracion: DeclaracionRow; alertasResueltas: number }> {
|
||||
const buf = Buffer.from(data.pdfBase64, 'base64');
|
||||
const ligaBuf = data.ligaPagoBase64 ? Buffer.from(data.ligaPagoBase64, 'base64') : null;
|
||||
const periodicidad = data.periodicidad || 'mensual';
|
||||
const montoPago = data.montoPago ?? null;
|
||||
// If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed)
|
||||
const pagadoAt = montoPago === 0 ? new Date() : null;
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO declaraciones_provisionales
|
||||
(año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_declaracion, pdf_filename,
|
||||
pdf_liga_pago, pdf_liga_pago_filename, notas, creado_por, pagado_at, contribuyente_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename,
|
||||
pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas,
|
||||
created_at, updated_at`,
|
||||
[data.año, data.mes, data.tipo, periodicidad, data.impuestos, montoPago,
|
||||
buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null,
|
||||
data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null],
|
||||
);
|
||||
|
||||
const declaracion = rowToDeclaracion(rows[0]);
|
||||
|
||||
// Auto-resolver alertas. Reglas:
|
||||
// - tipo='normal': resuelve alertas de declaración (decl-*) del mes.
|
||||
// El pago se resuelve por separado al subir comprobante.
|
||||
// - tipo='complementaria': sustituye a la normal en términos de
|
||||
// obligación de pago — al subirla se resuelven AMBAS (decl-* y
|
||||
// pago-*) porque el cliente pagará usando la complementaria,
|
||||
// no la normal. La alerta de declaración ya estaría resuelta
|
||||
// si la normal se subió antes; el resolver es idempotente.
|
||||
const prefijosDecl = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []);
|
||||
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosDecl, data.año, data.mes);
|
||||
if (data.tipo === 'complementaria' || montoPago === 0) {
|
||||
// complementaria: sustituye normal para pago → resolver ambas
|
||||
// monto 0: nada que pagar → resolver alertas de pago también
|
||||
const prefijosPago = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
||||
alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes);
|
||||
}
|
||||
|
||||
// Auto-complete obligaciones del contribuyente SOLO si la declaración
|
||||
// también cubre el pago (complementaria sustituye a la normal para el
|
||||
// pago; monto=0 significa "nada que pagar"). Una declaración normal con
|
||||
// monto>0 solo presenta el acuse — la obligación de pago sigue abierta
|
||||
// y se marca completada hasta que se suba el comprobante via
|
||||
// `uploadComprobantePago`. Esto mantiene las alertas `pago-*` y `ob-*`
|
||||
// visibles hasta que realmente se cierre el ciclo.
|
||||
const cubrePago = data.tipo === 'complementaria' || montoPago === 0;
|
||||
if (data.contribuyenteId && cubrePago) {
|
||||
if (!data.creadoPorUserId) {
|
||||
console.warn('[createDeclaracion] Sin creadoPorUserId — no se auto-completan obligaciones del contribuyente');
|
||||
} else {
|
||||
const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`;
|
||||
alertasResueltas += await completarObligacionesPorDeclaracion(
|
||||
pool, data.contribuyenteId, data.impuestos, periodo, data.creadoPorUserId, declaracion.id, periodicidad,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { declaracion, alertasResueltas };
|
||||
} catch (err: any) {
|
||||
if (err?.code === '23505') {
|
||||
throw new Error(`Ya existe una declaración tipo "normal" para ${data.mes}/${data.año}. Solo se permite una normal por mes; agrega una complementaria si necesitas corregirla.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadComprobantePago(
|
||||
pool: Pool,
|
||||
id: number,
|
||||
data: {
|
||||
pdfBase64: string;
|
||||
pdfFilename: string;
|
||||
/** UUID del usuario que sube el comprobante (para obligacion_periodos.completada_por). */
|
||||
uploadedByUserId?: string;
|
||||
},
|
||||
): Promise<{ declaracion: DeclaracionRow; alertasResueltas: number }> {
|
||||
const buf = Buffer.from(data.pdfBase64, 'base64');
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE declaraciones_provisionales
|
||||
SET pdf_pago = $1, pdf_pago_filename = $2, pagado_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $3
|
||||
RETURNING id, año, mes, tipo, periodicidad, impuestos, pdf_filename, pdf_liga_pago_filename,
|
||||
pdf_pago_filename, pagado_at, creado_por, notas, created_at, updated_at,
|
||||
contribuyente_id`,
|
||||
[buf, data.pdfFilename, id],
|
||||
);
|
||||
|
||||
if (rows.length === 0) throw new Error('Declaración no encontrada');
|
||||
const row = rows[0];
|
||||
const declaracion = rowToDeclaracion(row);
|
||||
|
||||
// Auto-resolver alertas de pago para los impuestos del periodo
|
||||
const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
|
||||
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes);
|
||||
|
||||
// Al subirse el comprobante de pago, la obligación ahora SÍ está completada
|
||||
// (declaración + pago). Marcar `obligacion_periodos.completada=true` y
|
||||
// resolver los `ob-*` alerts. Requires contribuyenteId (guardado en la
|
||||
// declaración) y userId (del caller).
|
||||
if (row.contribuyente_id && data.uploadedByUserId) {
|
||||
const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`;
|
||||
const periodicidad = row.periodicidad || 'mensual';
|
||||
alertasResueltas += await completarObligacionesPorDeclaracion(
|
||||
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId, declaracion.id, periodicidad,
|
||||
);
|
||||
}
|
||||
|
||||
return { declaracion, alertasResueltas };
|
||||
}
|
||||
|
||||
export async function deleteDeclaracion(pool: Pool, id: number): Promise<void> {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM declaraciones_provisionales WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (rowCount === 0) throw new Error('Declaración no encontrada');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup: borra declaraciones con created_at < hoy - 5 años. Cumple con
|
||||
* el plazo de retención del Art. 30 del CFF (contabilidad por 5 años).
|
||||
* Llamado por cron diario. Idempotente: si no hay viejas, no-op.
|
||||
*
|
||||
* Se ejecuta por-tenant (caller pasa el pool). Returns { deleted } para log.
|
||||
*/
|
||||
export async function purgeDeclaracionesAntiguas(pool: Pool): Promise<{ deleted: number }> {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM declaraciones_provisionales
|
||||
WHERE created_at < NOW() - INTERVAL '5 years'`,
|
||||
);
|
||||
return { deleted: rowCount ?? 0 };
|
||||
}
|
||||
|
||||
export async function getDeclaracionPdf(
|
||||
pool: Pool,
|
||||
id: number,
|
||||
variant: 'declaracion' | 'liga' | 'pago',
|
||||
): Promise<{ buffer: Buffer; filename: string } | null> {
|
||||
const col =
|
||||
variant === 'declaracion' ? 'pdf_declaracion' :
|
||||
variant === 'liga' ? 'pdf_liga_pago' : 'pdf_pago';
|
||||
const colName =
|
||||
variant === 'declaracion' ? 'pdf_filename' :
|
||||
variant === 'liga' ? 'pdf_liga_pago_filename' : 'pdf_pago_filename';
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${col} as data, ${colName} as filename FROM declaraciones_provisionales WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (rows.length === 0 || !rows[0].data) return null;
|
||||
return { buffer: Buffer.from(rows[0].data), filename: rows[0].filename || `declaracion-${id}.pdf` };
|
||||
}
|
||||
487
apps/api/src/services/despacho-stats.service.ts
Normal file
487
apps/api/src/services/despacho-stats.service.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
export interface ContribuyentesStats {
|
||||
totalContribuyentes: number;
|
||||
ultimaExtraccion: Date | null;
|
||||
/** % global del despacho de obligaciones+tareas del periodo seleccionado completadas vs total. */
|
||||
progresoDelMes: number;
|
||||
/** Declaraciones cuyo `created_at` cae en el periodo seleccionado. */
|
||||
declaracionesPresentadas: number;
|
||||
/** Subset del anterior con `pdf_pago` no nulo. */
|
||||
declaracionesPagadas: number;
|
||||
/** Obligaciones de declaración pendientes de periodos anteriores al seleccionado. */
|
||||
declaracionesAtrasadas: number;
|
||||
/** Tareas pendientes con fecha_limite anterior al inicio del periodo seleccionado. */
|
||||
tareasAtrasadas: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Métricas para la pestaña "Contribuyentes" del módulo Despacho (owner-only).
|
||||
*
|
||||
* Periodo: si se pasa `año`/`mes`, las métricas mensuales se calculan para
|
||||
* ese periodo. Default = mes en curso.
|
||||
*
|
||||
* - totalContribuyentes / ultimaExtraccion: independientes del periodo.
|
||||
* - progresoDelMes: % global obligaciones+tareas del periodo completadas.
|
||||
* - declaracionesPresentadas/pagadas: declaraciones con `created_at` en el
|
||||
* periodo seleccionado (presentación, no devengo).
|
||||
* - declaracionesAtrasadas: obligaciones-periodo NO completadas con periodo
|
||||
* anterior al seleccionado, donde la obligación sea de tipo declaración
|
||||
* (categoría contiene 'mensual'/'anual'/'declaración' — heurística laxa
|
||||
* ya que el catálogo no tiene un flag explícito).
|
||||
* - tareasAtrasadas: tareas-periodo NO completadas con fecha_limite anterior
|
||||
* al primer día del periodo seleccionado.
|
||||
*/
|
||||
export async function getContribuyentesStats(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
año?: number,
|
||||
mes?: number,
|
||||
): Promise<ContribuyentesStats> {
|
||||
const { rows: [{ count }] } = await pool.query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas e ON e.id = c.entidad_id
|
||||
WHERE e.active = true`,
|
||||
);
|
||||
|
||||
const last = await prisma.satSyncJob.findFirst({
|
||||
where: { tenantId, status: 'completed' },
|
||||
orderBy: { completedAt: 'desc' },
|
||||
select: { completedAt: true },
|
||||
});
|
||||
|
||||
// Periodo: usa el filtrado o cae al mes en curso.
|
||||
const now = new Date();
|
||||
const _año = año ?? now.getFullYear();
|
||||
const _mes = mes ?? now.getMonth() + 1;
|
||||
const periodoMes = `${_año}-${String(_mes).padStart(2, '0')}`;
|
||||
const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`;
|
||||
const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0];
|
||||
|
||||
const { rows: [progresoRow] } = await pool.query<{ total: number; completadas: number }>(
|
||||
`SELECT
|
||||
(SELECT COUNT(*)::int FROM obligacion_periodos op
|
||||
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
|
||||
WHERE oc.activa = true AND op.periodo = $1)
|
||||
+
|
||||
(SELECT COUNT(*)::int FROM tarea_periodos tp
|
||||
JOIN tareas_catalogo tc ON tc.id = tp.tarea_id
|
||||
WHERE tc.active = true AND tp.fecha_limite BETWEEN $2::date AND $3::date)
|
||||
AS total,
|
||||
(SELECT COUNT(*)::int FROM obligacion_periodos op
|
||||
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
|
||||
WHERE oc.activa = true AND op.periodo = $1 AND op.completada = true)
|
||||
+
|
||||
(SELECT COUNT(*)::int FROM tarea_periodos tp
|
||||
JOIN tareas_catalogo tc ON tc.id = tp.tarea_id
|
||||
WHERE tc.active = true AND tp.fecha_limite BETWEEN $2::date AND $3::date AND tp.completada = true)
|
||||
AS completadas`,
|
||||
[periodoMes, inicioMes, finMes],
|
||||
);
|
||||
const progresoDelMes = progresoRow.total > 0
|
||||
? Math.round((progresoRow.completadas / progresoRow.total) * 100)
|
||||
: 0;
|
||||
|
||||
const { rows: [decRow] } = await pool.query<{ presentadas: number; pagadas: number }>(
|
||||
`SELECT
|
||||
COUNT(*)::int AS presentadas,
|
||||
COUNT(*) FILTER (WHERE pdf_pago IS NOT NULL)::int AS pagadas
|
||||
FROM declaraciones_provisionales
|
||||
WHERE created_at >= $1::date AND created_at < ($2::date + interval '1 day')`,
|
||||
[inicioMes, finMes],
|
||||
);
|
||||
|
||||
// Atrasadas de periodos anteriores al seleccionado.
|
||||
// Para declaraciones (obligaciones) usamos `op.periodo < periodoMes`.
|
||||
// Heurística "es declaración": categoría contiene 'mensual', 'anual',
|
||||
// 'declaración' o el nombre incluye 'declaración' (case insensitive).
|
||||
const { rows: [atrRow] } = await pool.query<{ decl_atr: number; tar_atr: number }>(
|
||||
`SELECT
|
||||
(SELECT COUNT(*)::int FROM obligacion_periodos op
|
||||
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
|
||||
WHERE oc.activa = true
|
||||
AND op.completada = false
|
||||
AND op.periodo < $1
|
||||
AND (
|
||||
LOWER(COALESCE(oc.categoria, '')) ~ 'mensual|anual|declarac'
|
||||
OR LOWER(oc.nombre) LIKE '%declarac%'
|
||||
)
|
||||
) AS decl_atr,
|
||||
(SELECT COUNT(*)::int FROM tarea_periodos tp
|
||||
JOIN tareas_catalogo tc ON tc.id = tp.tarea_id
|
||||
WHERE tc.active = true
|
||||
AND tp.completada = false
|
||||
AND tp.fecha_limite < $2::date
|
||||
) AS tar_atr`,
|
||||
[periodoMes, inicioMes],
|
||||
);
|
||||
|
||||
return {
|
||||
totalContribuyentes: count,
|
||||
ultimaExtraccion: last?.completedAt ?? null,
|
||||
progresoDelMes,
|
||||
declaracionesPresentadas: decRow.presentadas,
|
||||
declaracionesPagadas: decRow.pagadas,
|
||||
declaracionesAtrasadas: atrRow.decl_atr,
|
||||
tareasAtrasadas: atrRow.tar_atr,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContribuyenteAsignado {
|
||||
contribuyenteId: string;
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
carteraNombre: string | null;
|
||||
obligacionesPendientes: number;
|
||||
obligacionesAtrasadas: number;
|
||||
obligacionesCompletadas: number;
|
||||
tareasPendientes: number;
|
||||
tareasAtrasadas: number;
|
||||
tareasCompletadas: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve los contribuyentes asignados al usuario actual según su rol y la
|
||||
* estructura de carteras:
|
||||
*
|
||||
* - **owner / cfo**: TODOS los contribuyentes del despacho.
|
||||
* - **supervisor**: contribuyentes que están en una cartera donde
|
||||
* `c.supervisor_user_id = userId` o en una subcartera de tales carteras.
|
||||
* - **auxiliar**: contribuyentes en carteras donde `c.auxiliar_user_id = userId`.
|
||||
* - **otros (contador, cliente, etc.)**: vacío.
|
||||
*
|
||||
* Las métricas se calculan usando el periodo `año`/`mes` como pivote: lo
|
||||
* "atrasado" es lo NO completado de periodos anteriores al filtrado.
|
||||
*/
|
||||
export async function getMisAsignados(
|
||||
pool: Pool,
|
||||
userId: string,
|
||||
userRole: string,
|
||||
año?: number,
|
||||
mes?: number,
|
||||
): Promise<ContribuyenteAsignado[]> {
|
||||
let baseFilter: string;
|
||||
const params: unknown[] = [];
|
||||
if (userRole === 'owner' || userRole === 'cfo') {
|
||||
baseFilter = `e.active = true`;
|
||||
} else if (userRole === 'supervisor') {
|
||||
params.push(userId);
|
||||
baseFilter = `e.active = true AND ce.cartera_id IN (
|
||||
SELECT id FROM carteras WHERE supervisor_user_id = $1
|
||||
UNION
|
||||
SELECT id FROM carteras WHERE parent_id IN (
|
||||
SELECT id FROM carteras WHERE supervisor_user_id = $1
|
||||
)
|
||||
)`;
|
||||
} else if (userRole === 'auxiliar') {
|
||||
params.push(userId);
|
||||
baseFilter = `e.active = true AND ce.cartera_id IN (
|
||||
SELECT id FROM carteras WHERE auxiliar_user_id = $1
|
||||
)`;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT c.entidad_id AS contribuyente_id, c.rfc, e.nombre,
|
||||
(SELECT cart.nombre FROM carteras cart
|
||||
JOIN cartera_entidades cee ON cee.cartera_id = cart.id
|
||||
WHERE cee.entidad_id = c.entidad_id LIMIT 1) AS cartera_nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas e ON e.id = c.entidad_id
|
||||
LEFT JOIN cartera_entidades ce ON ce.entidad_id = c.entidad_id
|
||||
WHERE ${baseFilter}
|
||||
ORDER BY e.nombre`,
|
||||
params,
|
||||
);
|
||||
|
||||
// Para cada contribuyente, contar pendientes/atrasados/completados de obligaciones+tareas.
|
||||
// Hacemos una sola query agregada por contribuyente con CTEs.
|
||||
const ids = rows.map(r => r.contribuyente_id);
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
// Periodo pivote: usa el filtrado o cae al mes en curso.
|
||||
const now = new Date();
|
||||
const _año = año ?? now.getFullYear();
|
||||
const _mes = mes ?? now.getMonth() + 1;
|
||||
const periodoMes = `${_año}-${String(_mes).padStart(2, '0')}`;
|
||||
const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`;
|
||||
const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0];
|
||||
|
||||
const { rows: stats } = await pool.query(
|
||||
`WITH obl AS (
|
||||
SELECT oc.contribuyente_id,
|
||||
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo = $1)::int AS pendientes,
|
||||
COUNT(*) FILTER (WHERE op.completada = false AND op.periodo < $1)::int AS atrasadas,
|
||||
COUNT(*) FILTER (WHERE op.completada = true AND op.periodo = $1)::int AS completadas
|
||||
FROM obligaciones_contribuyente oc
|
||||
LEFT JOIN obligacion_periodos op ON op.obligacion_id = oc.id
|
||||
WHERE oc.contribuyente_id = ANY($4::uuid[]) AND oc.activa = true
|
||||
GROUP BY oc.contribuyente_id
|
||||
),
|
||||
tar AS (
|
||||
SELECT tc.contribuyente_id,
|
||||
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS pendientes,
|
||||
COUNT(*) FILTER (WHERE tp.completada = false AND tp.fecha_limite < $2::date)::int AS atrasadas,
|
||||
COUNT(*) FILTER (WHERE tp.completada = true AND tp.fecha_limite BETWEEN $2::date AND $3::date)::int AS completadas
|
||||
FROM tareas_catalogo tc
|
||||
LEFT JOIN tarea_periodos tp ON tp.tarea_id = tc.id
|
||||
WHERE tc.contribuyente_id = ANY($4::uuid[]) AND tc.active = true
|
||||
GROUP BY tc.contribuyente_id
|
||||
)
|
||||
SELECT
|
||||
obl.contribuyente_id AS obl_id, obl.pendientes AS obl_pen, obl.atrasadas AS obl_atr, obl.completadas AS obl_com,
|
||||
tar.contribuyente_id AS tar_id, tar.pendientes AS tar_pen, tar.atrasadas AS tar_atr, tar.completadas AS tar_com
|
||||
FROM obl
|
||||
FULL OUTER JOIN tar ON tar.contribuyente_id = obl.contribuyente_id`,
|
||||
[periodoMes, inicioMes, finMes, ids],
|
||||
);
|
||||
|
||||
const statsMap = new Map<string, {
|
||||
obl: { pen: number; atr: number; com: number };
|
||||
tar: { pen: number; atr: number; com: number };
|
||||
}>();
|
||||
for (const s of stats) {
|
||||
const id = s.obl_id || s.tar_id;
|
||||
if (!id) continue;
|
||||
statsMap.set(id, {
|
||||
obl: { pen: s.obl_pen ?? 0, atr: s.obl_atr ?? 0, com: s.obl_com ?? 0 },
|
||||
tar: { pen: s.tar_pen ?? 0, atr: s.tar_atr ?? 0, com: s.tar_com ?? 0 },
|
||||
});
|
||||
}
|
||||
|
||||
const result = rows.map(r => {
|
||||
const s = statsMap.get(r.contribuyente_id);
|
||||
return {
|
||||
contribuyenteId: r.contribuyente_id,
|
||||
rfc: r.rfc,
|
||||
nombre: r.nombre,
|
||||
carteraNombre: r.cartera_nombre,
|
||||
obligacionesPendientes: s?.obl.pen ?? 0,
|
||||
obligacionesAtrasadas: s?.obl.atr ?? 0,
|
||||
obligacionesCompletadas: s?.obl.com ?? 0,
|
||||
tareasPendientes: s?.tar.pen ?? 0,
|
||||
tareasAtrasadas: s?.tar.atr ?? 0,
|
||||
tareasCompletadas: s?.tar.com ?? 0,
|
||||
};
|
||||
});
|
||||
// Ordena por atrasos descendente — los más rezagados arriba.
|
||||
result.sort((a, b) => {
|
||||
const atrasoA = a.obligacionesAtrasadas + a.tareasAtrasadas;
|
||||
const atrasoB = b.obligacionesAtrasadas + b.tareasAtrasadas;
|
||||
if (atrasoA !== atrasoB) return atrasoB - atrasoA;
|
||||
return a.nombre.localeCompare(b.nombre);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface MiembroEquipo {
|
||||
userId: string;
|
||||
nombre: string;
|
||||
email: string;
|
||||
rol: 'supervisor' | 'auxiliar';
|
||||
contribuyentes: number;
|
||||
obligacionesAtrasadas: number;
|
||||
tareasAtrasadas: number;
|
||||
totalPendientes: number;
|
||||
/** completadas + pendientes del periodo filtrado (sin atrasos). */
|
||||
totalPeriodo: number;
|
||||
completadasPeriodo: number;
|
||||
avancePct: number | null;
|
||||
}
|
||||
|
||||
export interface SupervisorConAuxiliares extends MiembroEquipo {
|
||||
auxiliares: MiembroEquipo[];
|
||||
}
|
||||
|
||||
export interface EquipoStatsResponse {
|
||||
supervisores: SupervisorConAuxiliares[];
|
||||
/** Auxiliares activos sin entrada en `auxiliar_supervisores`. Solo owner los ve. */
|
||||
huerfanos: MiembroEquipo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumen de avance por miembro del equipo, en estructura jerárquica
|
||||
* supervisor → auxiliares + lista de auxiliares "huérfanos" (sin supervisor).
|
||||
*
|
||||
* - **owner / cfo**: ve TODOS los supervisores + auxiliares sin supervisor.
|
||||
* - **supervisor**: ve solo a sí mismo con sus auxiliares (sin huérfanos).
|
||||
*
|
||||
* Métricas calculadas por periodo (`año`/`mes` o mes en curso).
|
||||
*/
|
||||
export async function getEquipoStats(
|
||||
pool: Pool,
|
||||
userId: string,
|
||||
userRole: string,
|
||||
tenantId: string,
|
||||
año?: number,
|
||||
mes?: number,
|
||||
): Promise<EquipoStatsResponse> {
|
||||
// 1. Construir mapa supervisor → auxiliares.
|
||||
//
|
||||
// La relación se infiere desde `carteras`:
|
||||
// - Si una cartera tiene `auxiliar_user_id` Y `supervisor_user_id` no nulos,
|
||||
// ese par directo cuenta.
|
||||
// - Si una subcartera (parent_id no nulo) tiene `auxiliar_user_id`, su
|
||||
// supervisor es el `supervisor_user_id` del parent.
|
||||
//
|
||||
// Fallback: tabla legacy `auxiliar_supervisores`. La unión con DISTINCT
|
||||
// evita duplicados si un auxiliar aparece en ambas fuentes.
|
||||
const { rows: paresRows } = await pool.query<{ supervisor_user_id: string; auxiliar_user_id: string }>(
|
||||
`SELECT DISTINCT supervisor_user_id, auxiliar_user_id FROM (
|
||||
SELECT c.supervisor_user_id, c.auxiliar_user_id
|
||||
FROM carteras c
|
||||
WHERE c.auxiliar_user_id IS NOT NULL
|
||||
AND c.supervisor_user_id IS NOT NULL
|
||||
UNION
|
||||
SELECT p.supervisor_user_id, sub.auxiliar_user_id
|
||||
FROM carteras sub
|
||||
JOIN carteras p ON p.id = sub.parent_id
|
||||
WHERE sub.auxiliar_user_id IS NOT NULL
|
||||
AND p.supervisor_user_id IS NOT NULL
|
||||
UNION
|
||||
SELECT supervisor_user_id, auxiliar_user_id FROM auxiliar_supervisores
|
||||
) t WHERE supervisor_user_id IS NOT NULL AND auxiliar_user_id IS NOT NULL`,
|
||||
);
|
||||
|
||||
let pares = paresRows.map(r => ({ supervisorId: r.supervisor_user_id, auxiliarId: r.auxiliar_user_id }));
|
||||
if (userRole === 'supervisor') {
|
||||
pares = pares.filter(p => p.supervisorId === userId);
|
||||
} else if (userRole !== 'owner' && userRole !== 'cfo') {
|
||||
return { supervisores: [], huerfanos: [] };
|
||||
}
|
||||
|
||||
// 2. Agrupar auxiliares por supervisor.
|
||||
const supervisorIds = [...new Set(pares.map(p => p.supervisorId))];
|
||||
const auxiliaresPorSup = new Map<string, string[]>();
|
||||
for (const p of pares) {
|
||||
if (!auxiliaresPorSup.has(p.supervisorId)) auxiliaresPorSup.set(p.supervisorId, []);
|
||||
auxiliaresPorSup.get(p.supervisorId)!.push(p.auxiliarId);
|
||||
}
|
||||
|
||||
// 3. Para cada user (supervisor o auxiliar), calcular su miembro.
|
||||
const result: SupervisorConAuxiliares[] = [];
|
||||
for (const supId of supervisorIds) {
|
||||
const supMiembro = await calcularMiembro(pool, supId, 'supervisor', año, mes);
|
||||
if (!supMiembro) continue;
|
||||
const auxiliares: MiembroEquipo[] = [];
|
||||
for (const auxId of auxiliaresPorSup.get(supId) ?? []) {
|
||||
const auxMiembro = await calcularMiembro(pool, auxId, 'auxiliar', año, mes);
|
||||
if (auxMiembro) auxiliares.push(auxMiembro);
|
||||
}
|
||||
auxiliares.sort((a, b) => b.totalPendientes - a.totalPendientes);
|
||||
result.push({ ...supMiembro, auxiliares });
|
||||
}
|
||||
|
||||
result.sort((a, b) => b.totalPendientes - a.totalPendientes);
|
||||
|
||||
// 4. Auxiliares "huérfanos" (sin entrada en auxiliar_supervisores). Solo
|
||||
// el owner los ve para que pueda asignarles supervisor.
|
||||
let huerfanos: MiembroEquipo[] = [];
|
||||
if (userRole === 'owner' || userRole === 'cfo') {
|
||||
const auxiliaresMapeados = new Set(pares.map(p => p.auxiliarId));
|
||||
const auxiliaresActivos = await prisma.tenantMembership.findMany({
|
||||
where: { tenantId, active: true, rol: { nombre: 'auxiliar' } },
|
||||
include: { user: { select: { id: true, active: true } } },
|
||||
});
|
||||
const huerfanosIds = auxiliaresActivos
|
||||
.filter(m => m.user.active && !auxiliaresMapeados.has(m.userId))
|
||||
.map(m => m.userId);
|
||||
for (const auxId of huerfanosIds) {
|
||||
const aux = await calcularMiembro(pool, auxId, 'auxiliar', año, mes);
|
||||
if (aux) huerfanos.push(aux);
|
||||
}
|
||||
huerfanos.sort((a, b) => b.totalPendientes - a.totalPendientes);
|
||||
}
|
||||
|
||||
return { supervisores: result, huerfanos };
|
||||
}
|
||||
|
||||
async function calcularMiembro(
|
||||
pool: Pool,
|
||||
uId: string,
|
||||
rol: 'supervisor' | 'auxiliar',
|
||||
año?: number,
|
||||
mes?: number,
|
||||
): Promise<MiembroEquipo | null> {
|
||||
const userInfo = await prisma.user.findUnique({
|
||||
where: { id: uId },
|
||||
select: { nombre: true, email: true, active: true },
|
||||
});
|
||||
if (!userInfo || !userInfo.active) return null;
|
||||
|
||||
const filter = rol === 'supervisor'
|
||||
? `ce.cartera_id IN (SELECT id FROM carteras WHERE supervisor_user_id = $1
|
||||
UNION SELECT id FROM carteras WHERE parent_id IN (SELECT id FROM carteras WHERE supervisor_user_id = $1))`
|
||||
: `ce.cartera_id IN (SELECT id FROM carteras WHERE auxiliar_user_id = $1)`;
|
||||
|
||||
const { rows: contribRows } = await pool.query<{ entidad_id: string }>(
|
||||
`SELECT DISTINCT ce.entidad_id FROM cartera_entidades ce WHERE ${filter}`,
|
||||
[uId],
|
||||
);
|
||||
const contribIds = contribRows.map(r => r.entidad_id);
|
||||
|
||||
// Periodo pivote
|
||||
const now = new Date();
|
||||
const _año = año ?? now.getFullYear();
|
||||
const _mes = mes ?? now.getMonth() + 1;
|
||||
const periodoMes = `${_año}-${String(_mes).padStart(2, '0')}`;
|
||||
const inicioMes = `${_año}-${String(_mes).padStart(2, '0')}-01`;
|
||||
const finMes = new Date(_año, _mes, 0).toISOString().split('T')[0];
|
||||
|
||||
let obl = 0, tar = 0, total = 0, completadas = 0;
|
||||
if (contribIds.length > 0) {
|
||||
const { rows: [agg] } = await pool.query<{
|
||||
obl_atr: number; tar_atr: number;
|
||||
obl_pen: number; obl_com: number;
|
||||
tar_pen: number; tar_com: number;
|
||||
}>(
|
||||
`SELECT
|
||||
(SELECT COUNT(*)::int FROM obligacion_periodos op
|
||||
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
|
||||
WHERE oc.contribuyente_id = ANY($1::uuid[])
|
||||
AND oc.activa = true AND op.completada = false AND op.periodo < $2) AS obl_atr,
|
||||
(SELECT COUNT(*)::int FROM tarea_periodos tp
|
||||
JOIN tareas_catalogo tc ON tc.id = tp.tarea_id
|
||||
WHERE tc.contribuyente_id = ANY($1::uuid[])
|
||||
AND tc.active = true AND tp.completada = false AND tp.fecha_limite < $3::date) AS tar_atr,
|
||||
(SELECT COUNT(*)::int FROM obligacion_periodos op
|
||||
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
|
||||
WHERE oc.contribuyente_id = ANY($1::uuid[])
|
||||
AND oc.activa = true AND op.periodo = $2 AND op.completada = false) AS obl_pen,
|
||||
(SELECT COUNT(*)::int FROM obligacion_periodos op
|
||||
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
|
||||
WHERE oc.contribuyente_id = ANY($1::uuid[])
|
||||
AND oc.activa = true AND op.periodo = $2 AND op.completada = true) AS obl_com,
|
||||
(SELECT COUNT(*)::int FROM tarea_periodos tp
|
||||
JOIN tareas_catalogo tc ON tc.id = tp.tarea_id
|
||||
WHERE tc.contribuyente_id = ANY($1::uuid[])
|
||||
AND tc.active = true AND tp.fecha_limite BETWEEN $3::date AND $4::date AND tp.completada = false) AS tar_pen,
|
||||
(SELECT COUNT(*)::int FROM tarea_periodos tp
|
||||
JOIN tareas_catalogo tc ON tc.id = tp.tarea_id
|
||||
WHERE tc.contribuyente_id = ANY($1::uuid[])
|
||||
AND tc.active = true AND tp.fecha_limite BETWEEN $3::date AND $4::date AND tp.completada = true) AS tar_com`,
|
||||
[contribIds, periodoMes, inicioMes, finMes],
|
||||
);
|
||||
obl = agg.obl_atr;
|
||||
tar = agg.tar_atr;
|
||||
completadas = agg.obl_com + agg.tar_com;
|
||||
total = completadas + agg.obl_pen + agg.tar_pen;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: uId,
|
||||
nombre: userInfo.nombre,
|
||||
email: userInfo.email,
|
||||
rol,
|
||||
contribuyentes: contribIds.length,
|
||||
obligacionesAtrasadas: obl,
|
||||
tareasAtrasadas: tar,
|
||||
totalPendientes: obl + tar,
|
||||
totalPeriodo: total,
|
||||
completadasPeriodo: completadas,
|
||||
avancePct: total > 0 ? Math.round((completadas / total) * 100) : null,
|
||||
};
|
||||
}
|
||||
147
apps/api/src/services/despacho.service.ts
Normal file
147
apps/api/src/services/despacho.service.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { hashPassword } from '../auth/passwords.js';
|
||||
import { generateAccessToken, generateRefreshToken } from '../auth/tokens.js';
|
||||
import type { DespachoSignupRequest } from '@horux/shared';
|
||||
import type { JWTPayload, Role } from '@horux/shared';
|
||||
import { emailService } from './email/email.service.js';
|
||||
|
||||
export async function signupDespacho(data: DespachoSignupRequest) {
|
||||
const { despacho, owner } = data;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { email: owner.email } });
|
||||
if (existingUser) {
|
||||
throw new Error('Ya existe un usuario con este email');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(owner.password);
|
||||
|
||||
const tenantSlug = `despacho_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
const databaseName = `horux_${tenantSlug}`;
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const tenant = await tx.tenant.create({
|
||||
data: {
|
||||
nombre: despacho.nombre,
|
||||
rfc: tenantSlug.toUpperCase(),
|
||||
plan: 'trial',
|
||||
databaseName: databaseName,
|
||||
verticalProfile: despacho.verticalProfile as any,
|
||||
dbMode: (despacho.plan === 'business_control' ? 'BYO' : 'MANAGED') as any,
|
||||
dbSchemaVersion: 0,
|
||||
trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
codigoPostal: despacho.codigoPostal,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email: owner.email.toLowerCase(),
|
||||
passwordHash,
|
||||
nombre: owner.nombre,
|
||||
lastTenantId: tenant.id,
|
||||
},
|
||||
});
|
||||
|
||||
const ownerRole = await tx.rol.findUnique({ where: { nombre: 'owner' } });
|
||||
if (!ownerRole) throw new Error('Rol owner no encontrado en BD');
|
||||
|
||||
await tx.tenantMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tenantId: tenant.id,
|
||||
rolId: ownerRole.id,
|
||||
isOwner: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { tenant, user };
|
||||
});
|
||||
|
||||
try {
|
||||
await tenantDb.provisionDatabase(tenantSlug, databaseName);
|
||||
} catch (err: any) {
|
||||
await prisma.tenant.delete({ where: { id: result.tenant.id } }).catch(() => {});
|
||||
await prisma.user.delete({ where: { id: result.user.id } }).catch(() => {});
|
||||
throw new Error(`Error al crear base de datos del despacho: ${err.message}`);
|
||||
}
|
||||
|
||||
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
|
||||
userId: result.user.id,
|
||||
email: result.user.email,
|
||||
role: 'owner' as Role,
|
||||
tenantId: result.tenant.id,
|
||||
tokenVersion: 0,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(payload);
|
||||
const refreshToken = generateRefreshToken(payload);
|
||||
|
||||
await prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: result.user.id,
|
||||
token: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
// Send welcome email (fire-and-forget)
|
||||
emailService.sendDespachoWelcome(owner.email, {
|
||||
nombre: result.user.nombre,
|
||||
despachoNombre: result.tenant.nombre,
|
||||
email: result.user.email,
|
||||
}).catch(err => console.error('[Signup] Welcome email failed:', err));
|
||||
|
||||
// If paid plan, create MP checkout via subscriptionService.subscribe()
|
||||
// que también crea la fila Subscription en BD (clave para que el webhook
|
||||
// pueda aplicar la dualidad firstYear→renewal tras el primer cobro aprobado).
|
||||
let paymentUrl: string | undefined;
|
||||
if (data.despacho.plan && data.despacho.plan !== 'trial') {
|
||||
try {
|
||||
const subscriptionService = await import('./payment/subscription.service.js');
|
||||
const result2 = await subscriptionService.subscribe({
|
||||
tenantId: result.tenant.id,
|
||||
plan: data.despacho.plan as any,
|
||||
// mi_empresa(+) acepta monthly/annual; los demás solo annual
|
||||
// — el subscribe valida y rechaza monthly cuando no aplica.
|
||||
frequency: data.despacho.frequency || 'annual',
|
||||
payerEmail: owner.email,
|
||||
});
|
||||
paymentUrl = result2.paymentUrl;
|
||||
} catch (err: any) {
|
||||
// Rollback: delete tenant + user since payment couldn't be set up
|
||||
await prisma.tenantMembership.deleteMany({ where: { tenantId: result.tenant.id } }).catch(() => {});
|
||||
await prisma.refreshToken.deleteMany({ where: { userId: result.user.id } }).catch(() => {});
|
||||
await prisma.tenant.delete({ where: { id: result.tenant.id } }).catch(() => {});
|
||||
await prisma.user.delete({ where: { id: result.user.id } }).catch(() => {});
|
||||
const msg = err?.message || '';
|
||||
if (msg.includes('MercadoPago no está configurado') || msg.includes('Unauthorized access')) {
|
||||
throw new Error('No se pudo procesar el cobro. Verifica que el sistema de pagos esté configurado o selecciona el plan Trial.');
|
||||
}
|
||||
throw new Error(msg || 'No se pudo procesar el cobro.');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
paymentUrl,
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email,
|
||||
nombre: result.user.nombre,
|
||||
role: 'owner' as Role,
|
||||
tenantId: result.tenant.id,
|
||||
tenantName: result.tenant.nombre,
|
||||
tenantRfc: result.tenant.rfc,
|
||||
plan: result.tenant.plan,
|
||||
tenants: [{
|
||||
id: result.tenant.id,
|
||||
nombre: result.tenant.nombre,
|
||||
rfc: result.tenant.rfc,
|
||||
plan: result.tenant.plan,
|
||||
role: 'owner' as Role,
|
||||
isOwner: true,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
129
apps/api/src/services/documentos-extras.service.ts
Normal file
129
apps/api/src/services/documentos-extras.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export interface DocumentoExtra {
|
||||
id: number;
|
||||
contribuyenteId: string | null;
|
||||
nombre: string;
|
||||
descripcion: string | null;
|
||||
categoria: string | null;
|
||||
pdfFilename: string;
|
||||
subidoPor: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface CreateExtraInput {
|
||||
contribuyenteId?: string | null;
|
||||
nombre: string;
|
||||
descripcion?: string | null;
|
||||
categoria?: string | null;
|
||||
pdfBase64: string;
|
||||
pdfFilename: string;
|
||||
subidoPor: string;
|
||||
}
|
||||
|
||||
function sanitizeUuid(id: string): string {
|
||||
return id.replace(/[^a-f0-9-]/gi, '');
|
||||
}
|
||||
|
||||
export async function createExtra(
|
||||
pool: Pool,
|
||||
data: CreateExtraInput,
|
||||
): Promise<DocumentoExtra> {
|
||||
const pdfBuffer = Buffer.from(data.pdfBase64, 'base64');
|
||||
const contribuyenteId = data.contribuyenteId
|
||||
? sanitizeUuid(data.contribuyenteId)
|
||||
: null;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO documentos_extras
|
||||
(contribuyente_id, nombre, descripcion, categoria, pdf, pdf_filename, subido_por)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, contribuyente_id AS "contribuyenteId", nombre, descripcion,
|
||||
categoria, pdf_filename AS "pdfFilename", subido_por AS "subidoPor",
|
||||
created_at AS "createdAt"`,
|
||||
[
|
||||
contribuyenteId,
|
||||
data.nombre,
|
||||
data.descripcion ?? null,
|
||||
data.categoria ?? null,
|
||||
pdfBuffer,
|
||||
data.pdfFilename,
|
||||
data.subidoPor,
|
||||
],
|
||||
);
|
||||
const r = rows[0];
|
||||
return { ...r, createdAt: r.createdAt?.toISOString?.() ?? r.createdAt };
|
||||
}
|
||||
|
||||
export async function listExtras(
|
||||
pool: Pool,
|
||||
contribuyenteId?: string | null,
|
||||
categoria?: string | null,
|
||||
): Promise<DocumentoExtra[]> {
|
||||
const params: any[] = [];
|
||||
const where: string[] = [];
|
||||
if (contribuyenteId) {
|
||||
params.push(sanitizeUuid(contribuyenteId));
|
||||
where.push(`contribuyente_id = $${params.length}`);
|
||||
}
|
||||
if (categoria) {
|
||||
params.push(categoria);
|
||||
where.push(`categoria = $${params.length}`);
|
||||
}
|
||||
const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
const { rows } = await pool.query(
|
||||
`SELECT id, contribuyente_id AS "contribuyenteId", nombre, descripcion,
|
||||
categoria, pdf_filename AS "pdfFilename", subido_por AS "subidoPor",
|
||||
created_at AS "createdAt"
|
||||
FROM documentos_extras
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 500`,
|
||||
params,
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
...r,
|
||||
createdAt: r.createdAt?.toISOString?.() ?? r.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getExtraPdf(
|
||||
pool: Pool,
|
||||
id: number,
|
||||
): Promise<{ buffer: Buffer; filename: string; nombre: string } | null> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT pdf, pdf_filename AS "pdfFilename", nombre
|
||||
FROM documentos_extras WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
return {
|
||||
buffer: rows[0].pdf,
|
||||
filename: rows[0].pdfFilename,
|
||||
nombre: rows[0].nombre,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteExtra(pool: Pool, id: number): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM documentos_extras WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function listCategorias(
|
||||
pool: Pool,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<string[]> {
|
||||
const params: any[] = [];
|
||||
let where = `WHERE categoria IS NOT NULL AND categoria != ''`;
|
||||
if (contribuyenteId) {
|
||||
params.push(sanitizeUuid(contribuyenteId));
|
||||
where += ` AND contribuyente_id = $${params.length}`;
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT categoria FROM documentos_extras ${where} ORDER BY categoria`,
|
||||
params,
|
||||
);
|
||||
return rows.map((r: any) => r.categoria);
|
||||
}
|
||||
210
apps/api/src/services/email/email.service.ts
Normal file
210
apps/api/src/services/email/email.service.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { createEmailTransport } from '@horux/core';
|
||||
import { env } from '../../config/env.js';
|
||||
|
||||
const transport = createEmailTransport(
|
||||
env.SMTP_USER && env.SMTP_PASS
|
||||
? {
|
||||
host: env.SMTP_HOST,
|
||||
port: parseInt(env.SMTP_PORT),
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASS,
|
||||
from: env.SMTP_FROM,
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
async function sendEmail(to: string, subject: string, html: string) {
|
||||
await transport.send(to, subject, html);
|
||||
}
|
||||
|
||||
export const emailService = {
|
||||
sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string }) => {
|
||||
const { welcomeEmail } = await import('./templates/welcome.js');
|
||||
await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data));
|
||||
},
|
||||
|
||||
sendPasswordReset: async (to: string, data: { nombre: string; resetUrl: string }) => {
|
||||
const { passwordResetEmail } = await import('./templates/password-reset.js');
|
||||
await sendEmail(to, 'Recuperación de contraseña - Horux360', passwordResetEmail(data));
|
||||
},
|
||||
|
||||
sendFielNotification: async (data: { clienteNombre: string; clienteRfc: string }) => {
|
||||
const { fielNotificationEmail } = await import('./templates/fiel-notification.js');
|
||||
await sendEmail(env.ADMIN_EMAIL, `[${data.clienteNombre}] subió su FIEL`, fielNotificationEmail(data));
|
||||
},
|
||||
|
||||
sendPaymentConfirmed: async (to: string, data: { nombre: string; amount: number; plan: string; date: string }) => {
|
||||
const { paymentConfirmedEmail } = await import('./templates/payment-confirmed.js');
|
||||
await sendEmail(to, 'Confirmación de pago - Horux360', paymentConfirmedEmail(data));
|
||||
},
|
||||
|
||||
sendPaymentFailed: async (to: string, data: { nombre: string; amount: number; plan: string }) => {
|
||||
const { paymentFailedEmail } = await import('./templates/payment-failed.js');
|
||||
await sendEmail(to, 'Problema con tu pago - Horux360', paymentFailedEmail(data));
|
||||
await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data));
|
||||
},
|
||||
|
||||
sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => {
|
||||
const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js');
|
||||
await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data));
|
||||
},
|
||||
|
||||
sendSubscriptionCancelled: async (to: string, data: { nombre: string; plan: string }) => {
|
||||
const { subscriptionCancelledEmail } = await import('./templates/subscription-cancelled.js');
|
||||
await sendEmail(to, 'Suscripción cancelada - Horux360', subscriptionCancelledEmail(data));
|
||||
await sendEmail(env.ADMIN_EMAIL, `Suscripción cancelada: ${data.nombre}`, subscriptionCancelledEmail(data));
|
||||
},
|
||||
|
||||
sendNewClientAdmin: async (data: {
|
||||
clienteNombre: string;
|
||||
clienteRfc: string;
|
||||
adminEmail: string;
|
||||
adminNombre: string;
|
||||
tempPassword: string;
|
||||
databaseName: string;
|
||||
plan: string;
|
||||
}) => {
|
||||
const { newClientAdminEmail } = await import('./templates/new-client-admin.js');
|
||||
await sendEmail(env.ADMIN_EMAIL, `Nuevo cliente: ${data.clienteNombre} (${data.clienteRfc})`, newClientAdminEmail(data));
|
||||
},
|
||||
|
||||
sendWeeklyUpdate: async (to: string, data: import('./templates/weekly-update.js').WeeklyUpdateData) => {
|
||||
const { weeklyUpdateEmail } = await import('./templates/weekly-update.js');
|
||||
await sendEmail(to, `Actualización semanal — ${data.empresa}`, weeklyUpdateEmail(data));
|
||||
},
|
||||
|
||||
sendDespachoWelcome: async (to: string, data: { nombre: string; despachoNombre: string; email: string }) => {
|
||||
const { despachoWelcomeEmail } = await import('./templates/despacho-welcome.js');
|
||||
await sendEmail(to, `Bienvenido a Horux Despachos — ${data.despachoNombre}`, despachoWelcomeEmail(data));
|
||||
},
|
||||
|
||||
sendTrialReminder: async (to: string, data: { nombre: string; despachoNombre: string; diasRestantes: number; wizardCompleto: boolean }) => {
|
||||
const { trialReminderEmail } = await import('./templates/trial-reminder.js');
|
||||
const subject = data.diasRestantes <= 3
|
||||
? `⚠️ Tu trial termina en ${data.diasRestantes} días — ${data.despachoNombre}`
|
||||
: `Quedan ${data.diasRestantes} días de trial — ${data.despachoNombre}`;
|
||||
await sendEmail(to, subject, trialReminderEmail(data));
|
||||
},
|
||||
|
||||
sendTrialExpired: async (to: string, data: { nombre: string; despachoNombre: string }) => {
|
||||
const { trialExpiredEmail } = await import('./templates/trial-reminder.js');
|
||||
await sendEmail(to, `Prueba finalizada — ${data.despachoNombre}`, trialExpiredEmail(data));
|
||||
},
|
||||
|
||||
/**
|
||||
* Notifica la subida de una declaración o documento extra al despacho.
|
||||
* `recipients` debe venir deduplicado por el caller. El subject se
|
||||
* genera a partir del kind y RFC del contribuyente.
|
||||
*/
|
||||
sendDocumentoSubido: async (
|
||||
recipients: string[],
|
||||
data: import('./templates/documento-subido.js').DocumentoSubidoData,
|
||||
) => {
|
||||
if (recipients.length === 0) return;
|
||||
const { documentoSubidoEmail } = await import('./templates/documento-subido.js');
|
||||
const html = documentoSubidoEmail(data);
|
||||
const subject = data.kind === 'declaracion'
|
||||
? `📄 Declaración subida — ${data.contribuyenteRfc}${data.declaracion ? ` (${data.declaracion.impuestos.join('/')} ${data.declaracion.periodo})` : ''}`
|
||||
: `📎 Documento subido — ${data.contribuyenteRfc}${data.extra ? `: ${data.extra.nombre}` : ''}`;
|
||||
// Envío secuencial; fire-and-forget a nivel del caller. Un error en un
|
||||
// destinatario NO debe impedir enviar al siguiente.
|
||||
for (const to of recipients) {
|
||||
try {
|
||||
await sendEmail(to, subject, html);
|
||||
} catch (err: any) {
|
||||
console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Notifica al auxiliar de la cartera que un supervisor/owner marcó como
|
||||
* completada una tarea con `solo_supervisor_completa=true`.
|
||||
*/
|
||||
sendTareaCompletada: async (
|
||||
to: string,
|
||||
data: import('./templates/tarea-completada.js').TareaCompletadaData,
|
||||
) => {
|
||||
const { tareaCompletadaEmail } = await import('./templates/tarea-completada.js');
|
||||
await sendEmail(
|
||||
to,
|
||||
`✓ ${data.tareaNombre} — ${data.contribuyenteRfc}`,
|
||||
tareaCompletadaEmail(data),
|
||||
);
|
||||
},
|
||||
|
||||
/** Aprobadores reciben aviso cuando se sube papelería que requiere aprobación. */
|
||||
sendPapeleriaAprobacionRequerida: async (
|
||||
to: string,
|
||||
data: import('./templates/papeleria.js').PapeleriaAprobacionRequeridaData,
|
||||
) => {
|
||||
const { papeleriaAprobacionRequeridaEmail } = await import('./templates/papeleria.js');
|
||||
await sendEmail(
|
||||
to,
|
||||
`📋 Papelería pendiente — ${data.contribuyenteRfc} (${data.periodo})`,
|
||||
papeleriaAprobacionRequeridaEmail(data),
|
||||
);
|
||||
},
|
||||
|
||||
/** Uploader recibe aviso cuando aprueban o rechazan su papelería. */
|
||||
sendPapeleriaDecision: async (
|
||||
to: string,
|
||||
data: import('./templates/papeleria.js').PapeleriaDecisionData,
|
||||
) => {
|
||||
const { papeleriaDecisionEmail } = await import('./templates/papeleria.js');
|
||||
const icon = data.estado === 'aprobado' ? '✅' : '❌';
|
||||
await sendEmail(
|
||||
to,
|
||||
`${icon} Documento ${data.estado} — ${data.contribuyenteRfc}`,
|
||||
papeleriaDecisionEmail(data),
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo
|
||||
* correo por destinatario con el batch completo. Caller debe deduplicar
|
||||
* recipients antes. Una alerta solo se notifica una vez (tracking en
|
||||
* `alertas_notificadas`).
|
||||
*/
|
||||
sendAlertasNuevas: async (
|
||||
recipients: string[],
|
||||
data: import('./templates/alertas-nuevas.js').AlertasNuevasData,
|
||||
) => {
|
||||
if (recipients.length === 0 || data.alertas.length === 0) return;
|
||||
const { alertasNuevasEmail } = await import('./templates/alertas-nuevas.js');
|
||||
const html = alertasNuevasEmail(data);
|
||||
const total = data.alertas.length;
|
||||
const subject = `🚨 ${total} alerta${total === 1 ? '' : 's'} nueva${total === 1 ? '' : 's'} — ${data.contribuyenteRfc}`;
|
||||
for (const to of recipients) {
|
||||
try {
|
||||
await sendEmail(to, subject, html);
|
||||
} catch (err: any) {
|
||||
console.error(`[Email] Fallo enviando alertas-nuevas a ${to}:`, err?.message || err);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Cron 8:30 AM — recordatorio próximo a vencer. Envía un correo por
|
||||
* destinatario. Caller dedupea. Cada ventana (3d/1d/0d) se envía a lo más
|
||||
* una vez por recordatorio (tracking en columnas `email_Xd_at`).
|
||||
*/
|
||||
sendRecordatorioProximo: async (
|
||||
recipients: string[],
|
||||
data: import('./templates/recordatorio-proximo.js').RecordatorioProximoData,
|
||||
) => {
|
||||
if (recipients.length === 0) return;
|
||||
const { recordatorioProximoEmail } = await import('./templates/recordatorio-proximo.js');
|
||||
const html = recordatorioProximoEmail(data);
|
||||
const prefix = data.ventana === '0d' ? '⏰' : data.ventana === '1d' ? '⚠️' : '🗓';
|
||||
const ventanaLabel = data.ventana === '0d' ? 'HOY' : data.ventana === '1d' ? 'mañana' : 'en 3 días';
|
||||
const subject = `${prefix} Recordatorio ${ventanaLabel}: ${data.titulo}`;
|
||||
for (const to of recipients) {
|
||||
try {
|
||||
await sendEmail(to, subject, html);
|
||||
} catch (err: any) {
|
||||
console.error(`[Email] Fallo enviando recordatorio-proximo a ${to}:`, err?.message || err);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
90
apps/api/src/services/email/templates/alertas-nuevas.ts
Normal file
90
apps/api/src/services/email/templates/alertas-nuevas.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export interface AlertaItem {
|
||||
/** Identificador interno (e.g., 'lista-negra-propia'). */
|
||||
alertaId: string;
|
||||
/** Nivel: 'high' | 'medium' | 'low'. */
|
||||
nivel: 'high' | 'medium' | 'low';
|
||||
/** Título corto. */
|
||||
titulo: string;
|
||||
/** Mensaje descriptivo. */
|
||||
mensaje: string;
|
||||
}
|
||||
|
||||
export interface AlertasNuevasData {
|
||||
/** RFC y nombre del contribuyente al que pertenecen las alertas. */
|
||||
contribuyenteRfc: string;
|
||||
contribuyenteNombre: string;
|
||||
/** Nombre del despacho. */
|
||||
despachoNombre: string;
|
||||
/** Lista de alertas nuevas (no se reportan resoluciones). */
|
||||
alertas: AlertaItem[];
|
||||
/** URL al sistema (ej. https://despachos.horuxfin.com/alertas). */
|
||||
link: string;
|
||||
}
|
||||
|
||||
const NIVEL_BADGE: Record<AlertaItem['nivel'], { label: string; bg: string; fg: string }> = {
|
||||
high: { label: 'Alta', bg: '#FEE2E2', fg: '#991B1B' },
|
||||
medium: { label: 'Media', bg: '#FEF3C7', fg: '#92400E' },
|
||||
low: { label: 'Baja', bg: '#DBEAFE', fg: '#1E40AF' },
|
||||
};
|
||||
|
||||
export function alertasNuevasEmail(data: AlertasNuevasData): string {
|
||||
const total = data.alertas.length;
|
||||
const conteoNivel = data.alertas.reduce<Record<AlertaItem['nivel'], number>>(
|
||||
(acc, a) => ({ ...acc, [a.nivel]: (acc[a.nivel] ?? 0) + 1 }),
|
||||
{ high: 0, medium: 0, low: 0 },
|
||||
);
|
||||
|
||||
const itemsHtml = data.alertas.map((a) => alertaItemHtml(a)).join('');
|
||||
|
||||
return baseTemplate(`
|
||||
${heading(`${total} alerta${total === 1 ? '' : 's'} nueva${total === 1 ? '' : 's'}`)}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">
|
||||
Detectamos alertas fiscales nuevas para
|
||||
<strong>${escapeHtml(data.contribuyenteNombre)}</strong>
|
||||
(RFC <span style="font-family:monospace;">${escapeHtml(data.contribuyenteRfc)}</span>)
|
||||
en el despacho <strong>${escapeHtml(data.despachoNombre)}</strong>.
|
||||
</p>
|
||||
${infoBox(`
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Resumen</p>
|
||||
<p style="margin:0;color:${C.textPrimary};">
|
||||
${conteoNivel.high > 0 ? `<strong style="color:${NIVEL_BADGE.high.fg};">${conteoNivel.high} alta</strong> · ` : ''}
|
||||
${conteoNivel.medium > 0 ? `<strong style="color:${NIVEL_BADGE.medium.fg};">${conteoNivel.medium} media</strong> · ` : ''}
|
||||
${conteoNivel.low > 0 ? `<strong style="color:${NIVEL_BADGE.low.fg};">${conteoNivel.low} baja</strong>` : ''}
|
||||
</p>
|
||||
`)}
|
||||
<div style="margin-top:20px;">
|
||||
${itemsHtml}
|
||||
</div>
|
||||
<div style="margin-top:24px;">
|
||||
${primaryButton('Ver alertas en el sistema', data.link)}
|
||||
</div>
|
||||
<p style="margin:24px 0 0;color:${C.textMuted};font-size:12px;">
|
||||
Recibes este correo porque eres responsable del contribuyente. Estas alertas
|
||||
ya fueron registradas — solo te avisaremos cuando aparezcan nuevas, no se
|
||||
repetirá esta notificación si la misma alerta sigue activa.
|
||||
</p>
|
||||
`);
|
||||
}
|
||||
|
||||
function alertaItemHtml(a: AlertaItem): string {
|
||||
const badge = NIVEL_BADGE[a.nivel];
|
||||
return `
|
||||
<div style="border-left:3px solid ${badge.fg};padding:12px 14px;margin:0 0 12px;background:#FAFAFA;border-radius:0 6px 6px 0;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin:0 0 6px;">
|
||||
<strong style="color:${C.textPrimary};font-size:14px;">${escapeHtml(a.titulo)}</strong>
|
||||
<span style="background:${badge.bg};color:${badge.fg};padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;">
|
||||
${badge.label}
|
||||
</span>
|
||||
</div>
|
||||
<p style="margin:0;color:${C.textMuted};font-size:13px;line-height:1.5;">${escapeHtml(a.mensaje)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[ch]!);
|
||||
}
|
||||
113
apps/api/src/services/email/templates/base.ts
Normal file
113
apps/api/src/services/email/templates/base.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Layout base de todos los emails. Espejea la identidad visual de horux360.com:
|
||||
* - Gradiente primary→secondary (azul→morado) en el header
|
||||
* - Tipografías Montserrat (headings) e Inter (body) cargadas vía Google Fonts
|
||||
* (Apple Mail, Outlook desktop las respetan; Gmail las ignora y cae al
|
||||
* stack sans-serif del sistema — ambos se ven correctos).
|
||||
* - Ancho 600px, bordes redondeados, sombra suave
|
||||
* - Footer en gris claro con color muted del design system
|
||||
*
|
||||
* Diseño limitado por restricciones de email HTML: usamos tablas + inline
|
||||
* styles, nada de flexbox/grid ni CSS externo. Las variables de CSS no
|
||||
* funcionan (Gmail las ignora), por eso los hex están hardcoded.
|
||||
*/
|
||||
|
||||
// Tokens del design system de horux360.com — replicados aquí para los emails
|
||||
const BRAND = {
|
||||
primary: '#2563EB',
|
||||
secondary: '#7C3AED',
|
||||
accent: '#10B981',
|
||||
textPrimary: '#1E293B',
|
||||
textMuted: '#64748B',
|
||||
bgLight: '#F8FAFC',
|
||||
bgWhite: '#FFFFFF',
|
||||
border: '#E2E8F0',
|
||||
gradient: 'linear-gradient(135deg, #2563EB 0%, #7C3AED 100%)',
|
||||
};
|
||||
|
||||
// Una sola tipografia (Inter) para mantener consistencia con el design system
|
||||
// del sitio. Stack fallback cubre los clientes que no soportan webfonts (Gmail).
|
||||
// IMPORTANTE: usar comillas simples para envolver familias con espacios.
|
||||
// Comillas dobles dentro de un style="..." rompen el atributo HTML.
|
||||
const FONT = "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
|
||||
|
||||
export function baseTemplate(content: string): string {
|
||||
const year = new Date().getFullYear();
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { margin:0; padding:0; }
|
||||
a { color: ${BRAND.primary}; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:${BRAND.bgLight};font-family:${FONT};color:${BRAND.textPrimary};">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:${BRAND.bgLight};padding:32px 12px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;background-color:${BRAND.bgWhite};border-radius:12px;overflow:hidden;box-shadow:0 10px 15px -3px rgba(0,0,0,0.08),0 4px 6px -4px rgba(0,0,0,0.05);">
|
||||
<!-- Header con gradiente de marca -->
|
||||
<tr>
|
||||
<td style="background:${BRAND.gradient};background-color:${BRAND.primary};padding:36px 32px;text-align:left;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="vertical-align:middle;">
|
||||
<span style="display:inline-block;font-family:${FONT};font-weight:700;font-size:32px;color:#ffffff;letter-spacing:-0.02em;line-height:1;">Horux 360</span>
|
||||
</td>
|
||||
<td style="vertical-align:middle;text-align:right;">
|
||||
<span style="display:inline-block;font-family:${FONT};font-size:18px;font-weight:500;color:#ffffff;letter-spacing:0;line-height:1.2;">Plataforma fiscal inteligente</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Body inyectado por cada template -->
|
||||
<tr>
|
||||
<td style="padding:40px 32px;font-family:${FONT};font-size:15px;line-height:1.65;color:${BRAND.textPrimary};">
|
||||
${content}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="background-color:${BRAND.bgLight};padding:24px 32px;text-align:center;font-family:${FONT};font-size:12px;color:${BRAND.textMuted};border-top:1px solid ${BRAND.border};">
|
||||
<p style="margin:0 0 8px;">
|
||||
<a href="https://horux360.com" style="color:${BRAND.primary};text-decoration:none;font-weight:500;">horux360.com</a>
|
||||
·
|
||||
<a href="https://horuxfin.com" style="color:${BRAND.primary};text-decoration:none;font-weight:500;">horuxfin.com</a>
|
||||
</p>
|
||||
<p style="margin:0;">© ${year} Horux 360 — Soluciones financieras y tecnológicas para empresas</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper para botón primario consistente con el design system. Usa color
|
||||
* sólido primary Y gradiente; el cliente que no soporte gradientes cae al
|
||||
* sólido. Sombra ligera para dar profundidad.
|
||||
*/
|
||||
export function primaryButton(label: string, href: string): string {
|
||||
return `<a href="${href}" style="display:inline-block;background-color:${BRAND.primary};background-image:${BRAND.gradient};color:#ffffff;padding:14px 28px;border-radius:8px;text-decoration:none;font-weight:600;font-size:15px;font-family:${FONT};box-shadow:0 4px 6px -1px rgba(37,99,235,0.3);">${label}</a>`;
|
||||
}
|
||||
|
||||
/** Caja destacada para info secundaria (credenciales, totales, etc). */
|
||||
export function infoBox(content: string): string {
|
||||
return `<div style="background-color:${BRAND.bgLight};border:1px solid ${BRAND.border};border-radius:8px;padding:16px 20px;margin:20px 0;font-family:${FONT};color:${BRAND.textPrimary};">${content}</div>`;
|
||||
}
|
||||
|
||||
/** Heading H2 con tipografía de marca. */
|
||||
export function heading(text: string): string {
|
||||
return `<h2 style="font-family:${FONT};font-weight:700;color:${BRAND.textPrimary};margin:0 0 16px;font-size:22px;letter-spacing:-0.01em;">${text}</h2>`;
|
||||
}
|
||||
|
||||
export const BRAND_COLORS = BRAND;
|
||||
31
apps/api/src/services/email/templates/despacho-welcome.ts
Normal file
31
apps/api/src/services/email/templates/despacho-welcome.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { baseTemplate, primaryButton, heading } from './base.js';
|
||||
|
||||
export function despachoWelcomeEmail(data: {
|
||||
nombre: string;
|
||||
despachoNombre: string;
|
||||
email: string;
|
||||
}): string {
|
||||
return baseTemplate(`
|
||||
${heading('¡Bienvenido a Horux Despachos!')}
|
||||
<p style="color: #374151; font-size: 16px; line-height: 1.6;">
|
||||
Hola <strong>${data.nombre}</strong>,
|
||||
</p>
|
||||
<p style="color: #374151; font-size: 16px; line-height: 1.6;">
|
||||
Tu despacho <strong>${data.despachoNombre}</strong> ha sido creado exitosamente.
|
||||
Tienes <strong>30 días de prueba gratis</strong> para explorar todas las funcionalidades.
|
||||
</p>
|
||||
<p style="color: #374151; font-size: 16px; line-height: 1.6;">
|
||||
<strong>Próximos pasos:</strong>
|
||||
</p>
|
||||
<ol style="color: #374151; font-size: 15px; line-height: 1.8; padding-left: 20px;">
|
||||
<li>Agrega tu primer contribuyente (RFC)</li>
|
||||
<li>Sube la FIEL del contribuyente</li>
|
||||
<li>Sube el CSD para emitir facturas</li>
|
||||
<li>Invita a tu equipo (supervisores y auxiliares)</li>
|
||||
</ol>
|
||||
${primaryButton('Ir a mi despacho', 'https://horuxfin.com/onboarding')}
|
||||
<p style="color: #6B7280; font-size: 13px; margin-top: 24px;">
|
||||
Tu cuenta: <strong>${data.email}</strong>
|
||||
</p>
|
||||
`);
|
||||
}
|
||||
102
apps/api/src/services/email/templates/documento-subido.ts
Normal file
102
apps/api/src/services/email/templates/documento-subido.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export interface DocumentoSubidoData {
|
||||
/** Kind: para el título/subject. */
|
||||
kind: 'declaracion' | 'extra';
|
||||
/** Quién subió el documento (email). */
|
||||
subidoPor: string;
|
||||
/** RFC del contribuyente. */
|
||||
contribuyenteRfc: string;
|
||||
/** Razón social / nombre del contribuyente. */
|
||||
contribuyenteNombre: string;
|
||||
/** Nombre del despacho (opcional, se incluye en el body cuando existe). */
|
||||
despachoNombre?: string;
|
||||
/** Si es declaración: periodo + tipo + impuestos + monto. */
|
||||
declaracion?: {
|
||||
periodo: string; // "Abril 2026"
|
||||
tipo: 'normal' | 'complementaria';
|
||||
impuestos: string[]; // ['IVA', 'ISR']
|
||||
montoPago: number | null;
|
||||
};
|
||||
/** Si es extra: nombre del documento + categoria. */
|
||||
extra?: {
|
||||
nombre: string;
|
||||
descripcion?: string | null;
|
||||
categoria?: string | null;
|
||||
};
|
||||
/** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */
|
||||
link: string;
|
||||
}
|
||||
|
||||
export function documentoSubidoEmail(data: DocumentoSubidoData): string {
|
||||
const titulo = data.kind === 'declaracion'
|
||||
? 'Nueva declaración subida'
|
||||
: 'Nuevo documento subido';
|
||||
|
||||
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
|
||||
? declaracionBlock(data.declaracion)
|
||||
: data.extra
|
||||
? extraBlock(data.extra)
|
||||
: '';
|
||||
|
||||
return baseTemplate(`
|
||||
${heading(titulo)}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">
|
||||
<strong>${escapeHtml(data.subidoPor)}</strong> subió un ${data.kind === 'declaracion' ? 'acuse de declaración' : 'documento'}
|
||||
para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>.
|
||||
</p>
|
||||
${infoBox(`
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Contribuyente</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};font-weight:600;">${escapeHtml(data.contribuyenteNombre)}</p>
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">RFC</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};font-family:monospace;">${escapeHtml(data.contribuyenteRfc)}</p>
|
||||
${contenidoEspecifico}
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Fecha</p>
|
||||
<p style="margin:0;color:${C.textPrimary};">${new Date().toLocaleString('es-MX')}</p>
|
||||
`)}
|
||||
<div style="margin-top:24px;">
|
||||
${primaryButton('Ver en el sistema', data.link)}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function declaracionBlock(d: NonNullable<DocumentoSubidoData['declaracion']>): string {
|
||||
const impuestosStr = d.impuestos.join(', ');
|
||||
const tipoLabel = d.tipo === 'complementaria' ? 'Complementaria' : 'Normal';
|
||||
const montoLabel = d.montoPago == null ? '—' : d.montoPago === 0 ? 'Sin pago' : formatCurrency(d.montoPago);
|
||||
return `
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Periodo</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(d.periodo)}</p>
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Tipo</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};">${tipoLabel}</p>
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Impuestos</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(impuestosStr)}</p>
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Monto a pagar</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};">${montoLabel}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string {
|
||||
return `
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};font-weight:600;">${escapeHtml(e.nombre)}</p>
|
||||
${e.categoria ? `
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Categoría</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.categoria)}</p>
|
||||
` : ''}
|
||||
${e.descripcion ? `
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Descripción</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.descripcion)}</p>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function formatCurrency(n: number): string {
|
||||
return n.toLocaleString('es-MX', { style: 'currency', currency: 'MXN', minimumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[ch]!);
|
||||
}
|
||||
17
apps/api/src/services/email/templates/fiel-notification.ts
Normal file
17
apps/api/src/services/email/templates/fiel-notification.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export function fielNotificationEmail(data: { clienteNombre: string; clienteRfc: string }): string {
|
||||
return baseTemplate(`
|
||||
${heading('e.firma cargada')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">El cliente <strong>${data.clienteNombre}</strong> ha subido su e.firma (FIEL).</p>
|
||||
${infoBox(`
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Empresa</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};font-weight:600;">${data.clienteNombre}</p>
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">RFC</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};font-family:monospace;">${data.clienteRfc}</p>
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Fecha</p>
|
||||
<p style="margin:0;color:${C.textPrimary};">${new Date().toLocaleString('es-MX')}</p>
|
||||
`)}
|
||||
<p style="color:${C.textPrimary};margin:0;">Ya puedes iniciar la sincronización de CFDIs para este cliente.</p>
|
||||
`);
|
||||
}
|
||||
58
apps/api/src/services/email/templates/new-client-admin.ts
Normal file
58
apps/api/src/services/email/templates/new-client-admin.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { baseTemplate, heading, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function sectionHeader(label: string, accentColor: string) {
|
||||
return `<tr>
|
||||
<td colspan="2" style="background-color:${accentColor};color:#ffffff;padding:10px 16px;font-weight:600;border-radius:8px 8px 0 0;font-size:14px;letter-spacing:0.02em;">
|
||||
${label}
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function row(label: string, value: string, isLast = false) {
|
||||
const border = isLast ? '' : `border-bottom:1px solid ${C.border};`;
|
||||
return `<tr>
|
||||
<td style="padding:10px 16px;${border}font-weight:500;color:${C.textMuted};width:40%;font-size:13px;">${label}</td>
|
||||
<td style="padding:10px 16px;${border}color:${C.textPrimary};font-size:14px;">${value}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
export function newClientAdminEmail(data: {
|
||||
clienteNombre: string;
|
||||
clienteRfc: string;
|
||||
adminEmail: string;
|
||||
adminNombre: string;
|
||||
tempPassword: string;
|
||||
databaseName: string;
|
||||
plan: string;
|
||||
}): string {
|
||||
return baseTemplate(`
|
||||
${heading('Nuevo cliente registrado')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 24px;">
|
||||
Se ha dado de alta un nuevo cliente en Horux 360. Detalles:
|
||||
</p>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:20px;border:1px solid ${C.border};border-radius:8px;overflow:hidden;">
|
||||
${sectionHeader('Datos del cliente', C.primary)}
|
||||
${row('Empresa', `<strong>${escapeHtml(data.clienteNombre)}</strong>`)}
|
||||
${row('RFC', `<span style="font-family:monospace;">${escapeHtml(data.clienteRfc)}</span>`)}
|
||||
${row('Plan', escapeHtml(data.plan), true)}
|
||||
</table>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:24px;border:1px solid ${C.border};border-radius:8px;overflow:hidden;">
|
||||
${sectionHeader('Credenciales del usuario', C.secondary)}
|
||||
${row('Nombre', escapeHtml(data.adminNombre))}
|
||||
${row('Email', `<span style="font-family:monospace;">${escapeHtml(data.adminEmail)}</span>`)}
|
||||
${row('Contraseña temporal', `<code style="background-color:${C.bgLight};padding:4px 10px;border-radius:6px;font-size:13px;color:#dc2626;border:1px solid ${C.border};">${escapeHtml(data.tempPassword)}</code>`, true)}
|
||||
</table>
|
||||
|
||||
<div style="background-color:#fef2f2;border-left:4px solid #ef4444;border-radius:8px;padding:12px 16px;margin:0 0 16px;">
|
||||
<p style="margin:0;color:#991b1b;font-size:13px;">
|
||||
<strong>⚠️ Confidencial:</strong> este correo contiene credenciales. No lo reenvíes ni lo compartas.
|
||||
</p>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
57
apps/api/src/services/email/templates/papeleria.ts
Normal file
57
apps/api/src/services/email/templates/papeleria.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { baseTemplate, primaryButton, infoBox, heading } from './base.js';
|
||||
|
||||
export interface PapeleriaAprobacionRequeridaData {
|
||||
contribuyenteRfc: string;
|
||||
contribuyenteNombre: string;
|
||||
despachoNombre?: string;
|
||||
nombreDocumento: string;
|
||||
descripcion: string | null;
|
||||
periodo: string;
|
||||
subidoPor: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export function papeleriaAprobacionRequeridaEmail(d: PapeleriaAprobacionRequeridaData): string {
|
||||
const body = `
|
||||
${heading('Papelería pendiente de aprobación')}
|
||||
<p>${d.subidoPor} subió un documento de papelería de trabajo que requiere tu aprobación:</p>
|
||||
<ul>
|
||||
<li><strong>Documento:</strong> ${d.nombreDocumento}</li>
|
||||
<li><strong>Contribuyente:</strong> ${d.contribuyenteNombre} (${d.contribuyenteRfc})</li>
|
||||
<li><strong>Periodo:</strong> ${d.periodo}</li>
|
||||
${d.descripcion ? `<li><strong>Descripción:</strong> ${d.descripcion}</li>` : ''}
|
||||
</ul>
|
||||
${infoBox('Revisa el documento y márcalo como aprobado o rechazado desde la sección de Documentos del despacho.')}
|
||||
<div style="margin-top: 24px;">
|
||||
${primaryButton('Ver documento', d.link)}
|
||||
</div>
|
||||
`;
|
||||
return baseTemplate(body);
|
||||
}
|
||||
|
||||
export interface PapeleriaDecisionData {
|
||||
contribuyenteRfc: string;
|
||||
contribuyenteNombre: string;
|
||||
nombreDocumento: string;
|
||||
estado: 'aprobado' | 'rechazado';
|
||||
revisor: string;
|
||||
comentario: string | null;
|
||||
periodo: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export function papeleriaDecisionEmail(d: PapeleriaDecisionData): string {
|
||||
const verbo = d.estado === 'aprobado' ? 'aprobó' : 'rechazó';
|
||||
const body = `
|
||||
${heading(`Documento ${d.estado}`)}
|
||||
<p>${d.revisor} ${verbo} el documento <strong>${d.nombreDocumento}</strong>
|
||||
del contribuyente ${d.contribuyenteNombre} (${d.contribuyenteRfc}), periodo ${d.periodo}.</p>
|
||||
${d.estado === 'rechazado' && d.comentario
|
||||
? infoBox(`<strong>Comentario:</strong> ${d.comentario}`)
|
||||
: ''}
|
||||
<div style="margin-top: 24px;">
|
||||
${primaryButton('Ver documento', d.link)}
|
||||
</div>
|
||||
`;
|
||||
return baseTemplate(body);
|
||||
}
|
||||
16
apps/api/src/services/email/templates/password-reset.ts
Normal file
16
apps/api/src/services/email/templates/password-reset.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { baseTemplate, heading, primaryButton, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export function passwordResetEmail(data: { nombre: string; resetUrl: string }): string {
|
||||
return baseTemplate(`
|
||||
${heading('Recuperación de contraseña')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">Hola <strong>${data.nombre}</strong>,</p>
|
||||
<p style="color:${C.textPrimary};margin:0 0 20px;">Recibimos una solicitud para restablecer tu contraseña en Horux 360. Haz clic en el botón para crear una nueva:</p>
|
||||
${primaryButton('Restablecer contraseña', data.resetUrl)}
|
||||
<p style="color:${C.textMuted};font-size:13px;margin:24px 0 8px;">O copia este enlace en tu navegador:</p>
|
||||
<p style="color:${C.textPrimary};font-size:12px;word-break:break-all;background-color:${C.bgLight};border:1px solid ${C.border};padding:10px 12px;border-radius:6px;font-family:monospace;">${data.resetUrl}</p>
|
||||
<div style="background-color:#fef3c7;border-left:4px solid #f59e0b;border-radius:4px;padding:12px 16px;margin:24px 0 0;">
|
||||
<p style="margin:0;color:#92400e;font-size:14px;"><strong>Este enlace expira en 1 hora.</strong></p>
|
||||
<p style="margin:6px 0 0;color:#92400e;font-size:13px;">Si tú no solicitaste este cambio, ignora este correo — tu contraseña no cambiará a menos que sigas el enlace.</p>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
16
apps/api/src/services/email/templates/payment-confirmed.ts
Normal file
16
apps/api/src/services/email/templates/payment-confirmed.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { baseTemplate, heading, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export function paymentConfirmedEmail(data: { nombre: string; amount: number; plan: string; date: string }): string {
|
||||
return baseTemplate(`
|
||||
${heading('Pago confirmado')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">Hola <strong>${data.nombre}</strong>,</p>
|
||||
<p style="color:${C.textPrimary};margin:0 0 20px;">Hemos recibido tu pago correctamente.</p>
|
||||
<div style="background-color:#ecfdf5;border-left:4px solid ${C.accent};border-radius:8px;padding:16px 20px;margin:20px 0;">
|
||||
<p style="margin:0 0 8px;color:${C.textMuted};font-size:13px;">Monto</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};font-size:22px;font-weight:700;">$${data.amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })} MXN</p>
|
||||
<p style="margin:0 0 4px;color:${C.textPrimary};"><span style="color:${C.textMuted};">Plan:</span> <strong>${data.plan}</strong></p>
|
||||
<p style="margin:0;color:${C.textPrimary};"><span style="color:${C.textMuted};">Fecha:</span> ${data.date}</p>
|
||||
</div>
|
||||
<p style="color:${C.textPrimary};margin:20px 0 0;">Tu suscripción está activa. Gracias por confiar en Horux 360.</p>
|
||||
`);
|
||||
}
|
||||
17
apps/api/src/services/email/templates/payment-failed.ts
Normal file
17
apps/api/src/services/email/templates/payment-failed.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { baseTemplate, heading, primaryButton, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export function paymentFailedEmail(data: { nombre: string; amount: number; plan: string }): string {
|
||||
return baseTemplate(`
|
||||
${heading('Problema con tu pago')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">Hola <strong>${data.nombre}</strong>,</p>
|
||||
<p style="color:${C.textPrimary};margin:0 0 20px;">No pudimos procesar tu pago. Esto suele deberse a fondos insuficientes, tarjeta vencida o rechazo del banco emisor.</p>
|
||||
<div style="background-color:#fef2f2;border-left:4px solid #ef4444;border-radius:8px;padding:16px 20px;margin:20px 0;">
|
||||
<p style="margin:0 0 8px;color:${C.textMuted};font-size:13px;">Monto pendiente</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};font-size:22px;font-weight:700;">$${data.amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })} MXN</p>
|
||||
<p style="margin:0;color:${C.textPrimary};"><span style="color:${C.textMuted};">Plan:</span> <strong>${data.plan}</strong></p>
|
||||
</div>
|
||||
<p style="color:${C.textPrimary};margin:0 0 24px;">Verifica tu método de pago en la plataforma para que tu servicio no se interrumpa:</p>
|
||||
${primaryButton('Actualizar método de pago', 'https://horuxfin.com/configuracion/suscripcion')}
|
||||
<p style="color:${C.textMuted};margin:24px 0 0;font-size:13px;">Si necesitas ayuda, responde a este correo y nuestro equipo te contactará.</p>
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export type VentanaRecordatorio = '3d' | '1d' | '0d';
|
||||
|
||||
export interface RecordatorioProximoData {
|
||||
/** Título del recordatorio. */
|
||||
titulo: string;
|
||||
/** Descripción opcional. */
|
||||
descripcion?: string | null;
|
||||
/** Notas opcionales. */
|
||||
notas?: string | null;
|
||||
/** Fecha límite (YYYY-MM-DD). */
|
||||
fechaLimite: string;
|
||||
/** Ventana de aviso: 3d / 1d / 0d. */
|
||||
ventana: VentanaRecordatorio;
|
||||
/** Nombre del despacho. */
|
||||
despachoNombre: string;
|
||||
/** URL al calendario. */
|
||||
link: string;
|
||||
}
|
||||
|
||||
const VENTANA_LABEL: Record<VentanaRecordatorio, { titulo: string; subtitulo: string; color: string }> = {
|
||||
'3d': { titulo: 'Recordatorio en 3 días', subtitulo: 'Tienes 3 días para atender este pendiente.', color: '#3B82F6' },
|
||||
'1d': { titulo: 'Recordatorio mañana', subtitulo: 'Esto vence mañana — prepara la documentación con tiempo.', color: '#F59E0B' },
|
||||
'0d': { titulo: 'Recordatorio HOY', subtitulo: 'Vence hoy. Revisa y atiende cuanto antes.', color: '#EF4444' },
|
||||
};
|
||||
|
||||
export function recordatorioProximoEmail(data: RecordatorioProximoData): string {
|
||||
const v = VENTANA_LABEL[data.ventana];
|
||||
const fechaFormateada = formatFecha(data.fechaLimite);
|
||||
|
||||
return baseTemplate(`
|
||||
${heading(v.titulo)}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">${escapeHtml(v.subtitulo)}</p>
|
||||
${infoBox(`
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Recordatorio</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};font-weight:600;">${escapeHtml(data.titulo)}</p>
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Fecha límite</p>
|
||||
<p style="margin:0 0 12px;color:${v.color};font-weight:600;">${escapeHtml(fechaFormateada)}</p>
|
||||
${data.descripcion ? `
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Descripción</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(data.descripcion)}</p>
|
||||
` : ''}
|
||||
${data.notas ? `
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Notas</p>
|
||||
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(data.notas)}</p>
|
||||
` : ''}
|
||||
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Despacho</p>
|
||||
<p style="margin:0;color:${C.textPrimary};">${escapeHtml(data.despachoNombre)}</p>
|
||||
`)}
|
||||
<div style="margin-top:24px;">
|
||||
${primaryButton('Ver en el calendario', data.link)}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function formatFecha(fecha: string): string {
|
||||
const [y, m, d] = fecha.split('-').map(Number);
|
||||
if (!y || !m || !d) return fecha;
|
||||
const dt = new Date(y, m - 1, d);
|
||||
return dt.toLocaleDateString('es-MX', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
})[ch]!);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { baseTemplate, heading, primaryButton, infoBox, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export function subscriptionCancelledEmail(data: { nombre: string; plan: string }): string {
|
||||
return baseTemplate(`
|
||||
${heading('Suscripción cancelada')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">Hola <strong>${data.nombre}</strong>,</p>
|
||||
<p style="color:${C.textPrimary};margin:0 0 20px;">Tu suscripción al plan <strong>${data.plan}</strong> ha sido cancelada.</p>
|
||||
${infoBox(`
|
||||
<p style="margin:0 0 6px;color:${C.textPrimary};">Tu acceso continuará activo hasta el final del período actual de facturación.</p>
|
||||
<p style="margin:0;color:${C.textMuted};font-size:14px;">Después de eso, solo tendrás acceso de lectura a tus datos.</p>
|
||||
`)}
|
||||
<p style="color:${C.textPrimary};margin:0 0 24px;">¿Cambiaste de opinión? Puedes reactivar tu suscripción antes del cierre del período sin cobro extra:</p>
|
||||
${primaryButton('Reactivar suscripción', 'https://horuxfin.com/configuracion/suscripcion')}
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { baseTemplate, heading, primaryButton, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export function subscriptionExpiringEmail(data: { nombre: string; plan: string; expiresAt: string }): string {
|
||||
return baseTemplate(`
|
||||
${heading('Tu suscripción vence pronto')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">Hola <strong>${data.nombre}</strong>,</p>
|
||||
<p style="color:${C.textPrimary};margin:0 0 20px;">Tu suscripción al plan <strong>${data.plan}</strong> vence el <strong>${data.expiresAt}</strong>.</p>
|
||||
<div style="background-color:#fffbeb;border-left:4px solid #f59e0b;border-radius:8px;padding:14px 18px;margin:20px 0;">
|
||||
<p style="margin:0;color:#92400e;font-size:14px;">Para evitar interrupciones en el servicio, verifica que tu método de pago esté actualizado.</p>
|
||||
</div>
|
||||
${primaryButton('Revisar suscripción', 'https://horuxfin.com/configuracion/suscripcion')}
|
||||
<p style="color:${C.textMuted};margin:24px 0 0;font-size:13px;">Si tienes alguna pregunta, responde a este correo.</p>
|
||||
`);
|
||||
}
|
||||
32
apps/api/src/services/email/templates/tarea-completada.ts
Normal file
32
apps/api/src/services/email/templates/tarea-completada.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { baseTemplate, primaryButton, infoBox, heading } from './base.js';
|
||||
|
||||
export interface TareaCompletadaData {
|
||||
destinatarioNombre: string;
|
||||
contribuyenteNombre: string;
|
||||
contribuyenteRfc: string;
|
||||
tareaNombre: string;
|
||||
tareaDescripcion: string | null;
|
||||
completadaPor: string;
|
||||
notas: string | null;
|
||||
fechaLimite: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export function tareaCompletadaEmail(data: TareaCompletadaData): string {
|
||||
const body = `
|
||||
${heading(`Tarea revisada: ${data.tareaNombre}`)}
|
||||
<p>Hola ${data.destinatarioNombre},</p>
|
||||
<p>
|
||||
${data.completadaPor} marcó como completada la tarea
|
||||
<strong>${data.tareaNombre}</strong> del contribuyente
|
||||
<strong>${data.contribuyenteNombre}</strong> (${data.contribuyenteRfc}),
|
||||
con fecha límite ${data.fechaLimite}.
|
||||
</p>
|
||||
${data.tareaDescripcion ? infoBox(data.tareaDescripcion) : ''}
|
||||
${data.notas ? `<p><strong>Notas del supervisor:</strong> ${data.notas}</p>` : ''}
|
||||
<div style="margin-top: 24px;">
|
||||
${primaryButton('Ver tareas del contribuyente', data.link)}
|
||||
</div>
|
||||
`;
|
||||
return baseTemplate(body);
|
||||
}
|
||||
58
apps/api/src/services/email/templates/trial-reminder.ts
Normal file
58
apps/api/src/services/email/templates/trial-reminder.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { baseTemplate, primaryButton, heading, infoBox } from './base.js';
|
||||
|
||||
export function trialReminderEmail(data: {
|
||||
nombre: string;
|
||||
despachoNombre: string;
|
||||
diasRestantes: number;
|
||||
wizardCompleto: boolean;
|
||||
}): string {
|
||||
const urgency = data.diasRestantes <= 3;
|
||||
|
||||
const message = data.diasRestantes > 7
|
||||
? `Te quedan <strong>${data.diasRestantes} días</strong> de prueba gratuita.`
|
||||
: urgency
|
||||
? `⚠️ Tu prueba gratuita termina en <strong>${data.diasRestantes} días</strong>.`
|
||||
: `Tu prueba gratuita termina en <strong>${data.diasRestantes} días</strong>.`;
|
||||
|
||||
const wizardNote = !data.wizardCompleto
|
||||
? infoBox('Aún no has completado la configuración inicial de tu despacho. Visita la página de onboarding para terminar.')
|
||||
: '';
|
||||
|
||||
return baseTemplate(`
|
||||
${heading(urgency ? '⚠️ Tu trial está por terminar' : 'Actualización de tu trial')}
|
||||
<p style="color: #374151; font-size: 16px; line-height: 1.6;">
|
||||
Hola <strong>${data.nombre}</strong>,
|
||||
</p>
|
||||
<p style="color: #374151; font-size: 16px; line-height: 1.6;">
|
||||
${message} en <strong>${data.despachoNombre}</strong>.
|
||||
</p>
|
||||
${wizardNote}
|
||||
<p style="color: #374151; font-size: 16px; line-height: 1.6;">
|
||||
Para seguir usando Horux Despachos sin interrupción, elige un plan antes de que termine tu prueba.
|
||||
</p>
|
||||
${primaryButton('Elegir plan', 'https://horuxfin.com/configuracion/suscripcion')}
|
||||
<p style="color: #6B7280; font-size: 13px; margin-top: 24px;">
|
||||
Si tienes dudas, responde a este correo o contáctanos en soporte@horuxfin.com.
|
||||
</p>
|
||||
`);
|
||||
}
|
||||
|
||||
export function trialExpiredEmail(data: {
|
||||
nombre: string;
|
||||
despachoNombre: string;
|
||||
}): string {
|
||||
return baseTemplate(`
|
||||
${heading('Tu prueba gratuita ha terminado')}
|
||||
<p style="color: #374151; font-size: 16px; line-height: 1.6;">
|
||||
Hola <strong>${data.nombre}</strong>,
|
||||
</p>
|
||||
<p style="color: #374151; font-size: 16px; line-height: 1.6;">
|
||||
El período de prueba de <strong>${data.despachoNombre}</strong> ha finalizado.
|
||||
Tu información sigue segura, pero el acceso está suspendido hasta que elijas un plan.
|
||||
</p>
|
||||
${primaryButton('Reactivar mi despacho', 'https://horuxfin.com/configuracion/suscripcion')}
|
||||
<p style="color: #6B7280; font-size: 13px; margin-top: 24px;">
|
||||
Tus datos se conservarán durante 30 días adicionales. Después de ese período, serán archivados.
|
||||
</p>
|
||||
`);
|
||||
}
|
||||
122
apps/api/src/services/email/templates/weekly-update.ts
Normal file
122
apps/api/src/services/email/templates/weekly-update.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { baseTemplate, heading, primaryButton, infoBox, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export interface WeeklyUpdateData {
|
||||
nombre: string;
|
||||
empresa: string;
|
||||
periodoLabel: string; // ej: "Abril 2026"
|
||||
kpis: {
|
||||
ingresos: number;
|
||||
egresos: number;
|
||||
utilidad: number;
|
||||
margen: number; // porcentaje
|
||||
ivaBalance: number; // positivo = a pagar, negativo = a favor
|
||||
ivaAFavorAcumulado: number;
|
||||
cfdisEmitidos: number;
|
||||
cfdisRecibidos: number;
|
||||
};
|
||||
alertas: Array<{
|
||||
titulo: string;
|
||||
mensaje: string;
|
||||
prioridad: 'alta' | 'media' | 'baja';
|
||||
}>;
|
||||
discrepanciasPorMes: Array<{ label: string; count: number }>;
|
||||
fechaGeneracion: string;
|
||||
}
|
||||
|
||||
function fmtMoney(n: number): string {
|
||||
return `$${n.toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function kpiBox(label: string, value: string, sublabel?: string, color?: string): string {
|
||||
const borderColor = color || C.border;
|
||||
const valueColor = color || C.textPrimary;
|
||||
return `<td style="padding:0;width:50%;vertical-align:top;">
|
||||
<div style="background-color:${C.bgLight};border:1px solid ${borderColor};border-radius:8px;padding:14px 16px;margin:4px;">
|
||||
<p style="margin:0;color:${C.textMuted};font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;">${label}</p>
|
||||
<p style="margin:6px 0 0;color:${valueColor};font-size:20px;font-weight:700;">${value}</p>
|
||||
${sublabel ? `<p style="margin:2px 0 0;color:${C.textMuted};font-size:11px;">${sublabel}</p>` : ''}
|
||||
</div>
|
||||
</td>`;
|
||||
}
|
||||
|
||||
const PRIORIDAD_COLOR: Record<string, { bg: string; border: string; text: string }> = {
|
||||
alta: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||
media: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||
baja: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||
};
|
||||
|
||||
export function weeklyUpdateEmail(data: WeeklyUpdateData): string {
|
||||
const { kpis } = data;
|
||||
const ivaLabel = kpis.ivaBalance >= 0 ? 'IVA a pagar' : 'IVA a favor';
|
||||
const ivaValor = Math.abs(kpis.ivaBalance);
|
||||
const ivaColor = kpis.ivaBalance >= 0 ? '#dc2626' : C.accent;
|
||||
const utilidadColor = kpis.utilidad >= 0 ? C.accent : '#dc2626';
|
||||
|
||||
const alertasHtml = data.alertas.length === 0
|
||||
? `<div style="background-color:#ecfdf5;border-left:4px solid ${C.accent};border-radius:8px;padding:14px 18px;margin:0 0 24px;">
|
||||
<p style="margin:0;color:#166534;font-size:14px;">✓ No hay alertas activas esta semana. Tu operación está en orden.</p>
|
||||
</div>`
|
||||
: data.alertas.map(a => {
|
||||
const colors = PRIORIDAD_COLOR[a.prioridad] || PRIORIDAD_COLOR.baja;
|
||||
return `<div style="background-color:${colors.bg};border-left:4px solid ${colors.border};border-radius:8px;padding:12px 16px;margin:0 0 10px;">
|
||||
<p style="margin:0 0 4px;color:${colors.text};font-size:14px;font-weight:600;">${a.titulo}</p>
|
||||
<p style="margin:0;color:${colors.text};font-size:13px;">${a.mensaje}</p>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const discrepanciasHtml = data.discrepanciasPorMes.length === 0
|
||||
? `<p style="margin:0;color:${C.textMuted};font-size:13px;text-align:center;padding:16px;">Sin discrepancias en los últimos meses. ✓</p>`
|
||||
: `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th align="left" style="padding:8px 12px;background-color:${C.bgLight};color:${C.textMuted};font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;border-bottom:1px solid ${C.border};">Mes</th>
|
||||
<th align="right" style="padding:8px 12px;background-color:${C.bgLight};color:${C.textMuted};font-size:12px;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;border-bottom:1px solid ${C.border};">Facturas con discrepancia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.discrepanciasPorMes.map(d => `<tr>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid ${C.border};color:${C.textPrimary};font-size:14px;">${d.label}</td>
|
||||
<td align="right" style="padding:10px 12px;border-bottom:1px solid ${C.border};color:${d.count > 0 ? '#dc2626' : C.textPrimary};font-size:14px;font-weight:600;">${d.count}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody>
|
||||
</table>`;
|
||||
|
||||
return baseTemplate(`
|
||||
${heading('Actualización semanal')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 8px;">Hola <strong>${data.nombre}</strong>,</p>
|
||||
<p style="color:${C.textPrimary};margin:0 0 24px;">Aquí tienes el resumen de tu actividad fiscal en <strong>${data.empresa}</strong> correspondiente al periodo <strong>${data.periodoLabel}</strong>.</p>
|
||||
|
||||
<h3 style="font-family:'Inter', sans-serif;font-weight:600;color:${C.textPrimary};margin:0 0 12px;font-size:16px;">Indicadores del periodo</h3>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 -4px 24px;border-collapse:separate;">
|
||||
<tr>
|
||||
${kpiBox('Ingresos', fmtMoney(kpis.ingresos), `${kpis.cfdisEmitidos} CFDIs emitidos`)}
|
||||
${kpiBox('Egresos', fmtMoney(kpis.egresos), `${kpis.cfdisRecibidos} CFDIs recibidos`)}
|
||||
</tr>
|
||||
<tr>
|
||||
${kpiBox('Utilidad', fmtMoney(kpis.utilidad), `Margen ${kpis.margen.toFixed(1)}%`, utilidadColor)}
|
||||
${kpiBox(ivaLabel, fmtMoney(ivaValor), undefined, ivaColor)}
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${kpis.ivaAFavorAcumulado > 0 ? infoBox(`
|
||||
<p style="margin:0;color:${C.textMuted};font-size:13px;">IVA a favor acumulado del año</p>
|
||||
<p style="margin:4px 0 0;color:${C.accent};font-size:18px;font-weight:700;">${fmtMoney(kpis.ivaAFavorAcumulado)}</p>
|
||||
`) : ''}
|
||||
|
||||
<h3 style="font-family:'Inter', sans-serif;font-weight:600;color:${C.textPrimary};margin:24px 0 12px;font-size:16px;">Alertas activas</h3>
|
||||
${alertasHtml}
|
||||
|
||||
<h3 style="font-family:'Inter', sans-serif;font-weight:600;color:${C.textPrimary};margin:24px 0 12px;font-size:16px;">Discrepancias de régimen por mes</h3>
|
||||
<p style="color:${C.textMuted};margin:0 0 12px;font-size:13px;">Facturas recibidas donde el régimen fiscal del receptor (tu RFC) no coincide con tus regímenes activos. Cada discrepancia podría representar una factura mal emitida que el SAT podría rechazar.</p>
|
||||
${discrepanciasHtml}
|
||||
|
||||
<div style="margin:32px 0 16px;text-align:center;">
|
||||
${primaryButton('Ver dashboard completo', 'https://horuxfin.com/dashboard')}
|
||||
</div>
|
||||
|
||||
<p style="color:${C.textMuted};margin:24px 0 0;font-size:12px;text-align:center;">
|
||||
Reporte generado el ${data.fechaGeneracion}.<br>
|
||||
Recibes este correo porque eres dueño o CFO de ${data.empresa} en Horux 360.
|
||||
</p>
|
||||
`);
|
||||
}
|
||||
17
apps/api/src/services/email/templates/welcome.ts
Normal file
17
apps/api/src/services/email/templates/welcome.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { baseTemplate, heading, primaryButton, infoBox, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string }): string {
|
||||
return baseTemplate(`
|
||||
${heading('Bienvenido a Horux 360')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">Hola <strong>${data.nombre}</strong>,</p>
|
||||
<p style="color:${C.textPrimary};margin:0 0 20px;">Tu cuenta ha sido creada exitosamente. Estas son tus credenciales de acceso:</p>
|
||||
${infoBox(`
|
||||
<p style="margin:0;color:${C.textMuted};font-size:13px;">Email</p>
|
||||
<p style="margin:2px 0 12px;color:${C.textPrimary};font-weight:600;font-family:monospace;">${data.email}</p>
|
||||
<p style="margin:0;color:${C.textMuted};font-size:13px;">Contraseña temporal</p>
|
||||
<p style="margin:2px 0 0;color:${C.textPrimary};font-weight:600;font-family:monospace;">${data.tempPassword}</p>
|
||||
`)}
|
||||
<p style="color:${C.textMuted};margin:0 0 24px;font-size:14px;">Por seguridad, te recomendamos cambiar tu contraseña la primera vez que inicies sesión.</p>
|
||||
${primaryButton('Iniciar sesión', 'https://horuxfin.com/login')}
|
||||
`);
|
||||
}
|
||||
125
apps/api/src/services/export.service.ts
Normal file
125
apps/api/src/services/export.service.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import ExcelJS from 'exceljs';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export async function exportCfdisToExcel(
|
||||
pool: Pool,
|
||||
filters: { tipo?: string; estado?: string; fechaInicio?: string; fechaFin?: string }
|
||||
): Promise<Buffer> {
|
||||
let whereClause = 'WHERE 1=1';
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.tipo) {
|
||||
whereClause += ` AND type = $${paramIndex++}`;
|
||||
params.push(filters.tipo);
|
||||
}
|
||||
if (filters.estado) {
|
||||
whereClause += ` AND status = $${paramIndex++}`;
|
||||
params.push(filters.estado);
|
||||
}
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND fecha_emision >= $${paramIndex++}`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND fecha_emision <= $${paramIndex++}`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
|
||||
const { rows: cfdis } = await pool.query(`
|
||||
SELECT uuid, type, serie, folio, fecha_emision, status,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, subtotal_mxn, descuento, descuento_mxn,
|
||||
iva_traslado, iva_traslado_mxn, isr_retencion, isr_retencion_mxn,
|
||||
iva_retencion, iva_retencion_mxn,
|
||||
total, total_mxn, moneda, tipo_cambio,
|
||||
metodo_pago, forma_pago, uso_cfdi
|
||||
FROM cfdis
|
||||
${whereClause}
|
||||
ORDER BY fecha_emision DESC
|
||||
`, params);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('CFDIs');
|
||||
|
||||
sheet.columns = [
|
||||
{ header: 'UUID', key: 'uuid', width: 40 },
|
||||
{ header: 'Tipo', key: 'type', width: 10 },
|
||||
{ header: 'Serie', key: 'serie', width: 10 },
|
||||
{ header: 'Folio', key: 'folio', width: 10 },
|
||||
{ header: 'Fecha Emisión', key: 'fecha_emision', width: 15 },
|
||||
{ header: 'RFC Emisor', key: 'rfc_emisor', width: 15 },
|
||||
{ header: 'Nombre Emisor', key: 'nombre_emisor', width: 30 },
|
||||
{ header: 'RFC Receptor', key: 'rfc_receptor', width: 15 },
|
||||
{ header: 'Nombre Receptor', key: 'nombre_receptor', width: 30 },
|
||||
{ header: 'Subtotal', key: 'subtotal', width: 15 },
|
||||
{ header: 'Subtotal MXN', key: 'subtotal_mxn', width: 15 },
|
||||
{ header: 'IVA Trasladado', key: 'iva_traslado', width: 15 },
|
||||
{ header: 'IVA Trasladado MXN', key: 'iva_traslado_mxn', width: 15 },
|
||||
{ header: 'Total', key: 'total', width: 15 },
|
||||
{ header: 'Total MXN', key: 'total_mxn', width: 15 },
|
||||
{ header: 'Moneda', key: 'moneda', width: 8 },
|
||||
{ header: 'T.C.', key: 'tipo_cambio', width: 10 },
|
||||
{ header: 'Estado', key: 'status', width: 12 },
|
||||
];
|
||||
|
||||
sheet.getRow(1).font = { bold: true };
|
||||
sheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FF4472C4' },
|
||||
};
|
||||
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
|
||||
cfdis.forEach((cfdi: any) => {
|
||||
sheet.addRow({
|
||||
...cfdi,
|
||||
fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'),
|
||||
subtotal: Number(cfdi.subtotal),
|
||||
subtotal_mxn: Number(cfdi.subtotal_mxn),
|
||||
iva_traslado: Number(cfdi.iva_traslado),
|
||||
iva_traslado_mxn: Number(cfdi.iva_traslado_mxn),
|
||||
total: Number(cfdi.total),
|
||||
total_mxn: Number(cfdi.total_mxn),
|
||||
});
|
||||
});
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
|
||||
export async function exportReporteToExcel(
|
||||
pool: Pool,
|
||||
tipo: 'estado-resultados' | 'flujo-efectivo',
|
||||
fechaInicio: string,
|
||||
fechaFin: string
|
||||
): Promise<Buffer> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet(tipo === 'estado-resultados' ? 'Estado de Resultados' : 'Flujo de Efectivo');
|
||||
|
||||
if (tipo === 'estado-resultados') {
|
||||
const { rows: [totales] } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN type = 'EMITIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN type = 'RECIBIDO' AND tipo_comprobante = 'I' THEN subtotal_mxn ELSE 0 END), 0) as egresos
|
||||
FROM cfdis
|
||||
WHERE status NOT IN ('Cancelado', '0') AND fecha_emision BETWEEN $1 AND $2
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
sheet.columns = [
|
||||
{ header: 'Concepto', key: 'concepto', width: 40 },
|
||||
{ header: 'Monto', key: 'monto', width: 20 },
|
||||
];
|
||||
|
||||
sheet.addRow({ concepto: 'INGRESOS', monto: '' });
|
||||
sheet.addRow({ concepto: 'Total Ingresos', monto: Number(totales?.ingresos || 0) });
|
||||
sheet.addRow({ concepto: '', monto: '' });
|
||||
sheet.addRow({ concepto: 'EGRESOS', monto: '' });
|
||||
sheet.addRow({ concepto: 'Total Egresos', monto: Number(totales?.egresos || 0) });
|
||||
sheet.addRow({ concepto: '', monto: '' });
|
||||
sheet.addRow({ concepto: 'UTILIDAD NETA', monto: Number(totales?.ingresos || 0) - Number(totales?.egresos || 0) });
|
||||
}
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return Buffer.from(buffer);
|
||||
}
|
||||
898
apps/api/src/services/facturapi.service.ts
Normal file
898
apps/api/src/services/facturapi.service.ts
Normal file
@@ -0,0 +1,898 @@
|
||||
import Facturapi from 'facturapi';
|
||||
import { env } from '../config/env.js';
|
||||
import { prisma } from '../config/database.js';
|
||||
import * as mpService from './payment/mercadopago.service.js';
|
||||
import { getTenantOwnerEmail } from '../utils/memberships.js';
|
||||
import { encryptString, decryptToString } from './sat/sat-crypto.service.js';
|
||||
|
||||
/**
|
||||
* Cliente Facturapi con User Key (nivel cuenta, para gestión de organizaciones).
|
||||
*/
|
||||
function getUserClient(): Facturapi {
|
||||
if (!env.FACTURAPI_USER_KEY) {
|
||||
throw new Error('FACTURAPI_USER_KEY no configurada');
|
||||
}
|
||||
return new Facturapi(env.FACTURAPI_USER_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera una Live Secret Key vía PUT idempotente. Devuelve la existente
|
||||
* si la org ya tiene una; crea nueva si no.
|
||||
*/
|
||||
async function generateLiveKey(orgId: string): Promise<string> {
|
||||
const userKey = env.FACTURAPI_USER_KEY!;
|
||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${userKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
throw new Error(`Facturapi PUT /apikeys/live falló (${res.status}): ${errBody}`);
|
||||
}
|
||||
const key = (await res.text()).replace(/"/g, '').trim();
|
||||
if (!key.startsWith('sk_live_')) {
|
||||
throw new Error(`Respuesta inesperada de Facturapi (no es sk_live_*): ${key.slice(0, 10)}...`);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cliente Facturapi con la Live Secret Key de la organización del tenant.
|
||||
* Cache cifrada en `Tenant.facturapiOrgKeyEnc/Iv/Tag` (AES-256-GCM con
|
||||
* derivación FIEL_ENCRYPTION_KEY). Si no hay cache, genera vía PUT y persiste.
|
||||
*/
|
||||
async function getOrgClient(tenantId: string): Promise<Facturapi> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: {
|
||||
facturapiOrgId: true,
|
||||
facturapiOrgKeyEnc: true,
|
||||
facturapiOrgKeyIv: true,
|
||||
facturapiOrgKeyTag: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tenant?.facturapiOrgId) {
|
||||
throw new Error('Tenant no tiene organización Facturapi configurada');
|
||||
}
|
||||
|
||||
// 1. Reutilizar Live Secret Key cacheada (descifrar de BD).
|
||||
if (tenant.facturapiOrgKeyEnc && tenant.facturapiOrgKeyIv && tenant.facturapiOrgKeyTag) {
|
||||
const apiKey = decryptToString(tenant.facturapiOrgKeyEnc, tenant.facturapiOrgKeyIv, tenant.facturapiOrgKeyTag);
|
||||
return new Facturapi(apiKey);
|
||||
}
|
||||
|
||||
// 2. Generar Live Secret Key vía PUT y persistir cifrada (lazy fallback
|
||||
// para tenants legacy creados antes del refactor live).
|
||||
const apiKey = await generateLiveKey(tenant.facturapiOrgId);
|
||||
const { encrypted, iv, tag } = encryptString(apiKey);
|
||||
await prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
facturapiOrgKeyEnc: encrypted,
|
||||
facturapiOrgKeyIv: iv,
|
||||
facturapiOrgKeyTag: tag,
|
||||
},
|
||||
});
|
||||
return new Facturapi(apiKey);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Organizaciones
|
||||
// ============================================
|
||||
|
||||
export async function createOrganization(tenantId: string): Promise<{ orgId: string }> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { nombre: true, rfc: true, facturapiOrgId: true },
|
||||
});
|
||||
|
||||
if (!tenant) throw new Error('Tenant no encontrado');
|
||||
if (tenant.facturapiOrgId) throw new Error('Tenant ya tiene organización Facturapi');
|
||||
|
||||
const client = getUserClient();
|
||||
const org = await client.organizations.create({ name: tenant.nombre });
|
||||
|
||||
// Eager: generar Live Secret Key inmediatamente y persistirla cifrada
|
||||
// para que la org quede lista para emitir desde el primer momento sin un
|
||||
// PUT extra al primer emit.
|
||||
const apiKey = await generateLiveKey(org.id);
|
||||
const { encrypted, iv, tag } = encryptString(apiKey);
|
||||
|
||||
await prisma.tenant.update({
|
||||
where: { id: tenantId },
|
||||
data: {
|
||||
facturapiOrgId: org.id,
|
||||
facturapiOrgKeyEnc: encrypted,
|
||||
facturapiOrgKeyIv: iv,
|
||||
facturapiOrgKeyTag: tag,
|
||||
},
|
||||
});
|
||||
|
||||
return { orgId: org.id };
|
||||
}
|
||||
|
||||
export async function getOrganizationStatus(tenantId: string): Promise<{
|
||||
configured: boolean;
|
||||
orgId?: string;
|
||||
legalName?: string;
|
||||
hasCsd?: boolean;
|
||||
}> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { facturapiOrgId: true },
|
||||
});
|
||||
|
||||
if (!tenant?.facturapiOrgId) {
|
||||
return { configured: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const client = getUserClient();
|
||||
const org = await client.organizations.retrieve(tenant.facturapiOrgId);
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
orgId: org.id,
|
||||
legalName: org.legal?.name || undefined,
|
||||
hasCsd: !!org.certificate?.has_certificate,
|
||||
};
|
||||
} catch {
|
||||
return { configured: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CSD (Certificado de Sello Digital)
|
||||
// ============================================
|
||||
|
||||
export async function uploadCsd(
|
||||
tenantId: string,
|
||||
cerFile: string, // base64
|
||||
keyFile: string, // base64
|
||||
password: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { facturapiOrgId: true },
|
||||
});
|
||||
|
||||
if (!tenant?.facturapiOrgId) {
|
||||
throw new Error('Primero debe crearse la organización en Facturapi');
|
||||
}
|
||||
|
||||
const client = getUserClient();
|
||||
|
||||
try {
|
||||
await client.organizations.uploadCertificate(
|
||||
tenant.facturapiOrgId,
|
||||
Buffer.from(cerFile, 'base64'),
|
||||
Buffer.from(keyFile, 'base64'),
|
||||
password,
|
||||
);
|
||||
|
||||
return { success: true, message: 'CSD subido correctamente' };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Error al subir CSD' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Clientes
|
||||
// ============================================
|
||||
|
||||
export interface FacturapiCustomerData {
|
||||
legalName: string;
|
||||
taxId: string; // RFC o Tax ID extranjero
|
||||
taxSystem?: string; // clave régimen fiscal (no aplica para extranjeros)
|
||||
email?: string;
|
||||
zip: string;
|
||||
country?: string; // ISO 3166 alpha-3 (solo extranjeros, ej: USA, SWE)
|
||||
}
|
||||
|
||||
export async function createOrUpdateCustomer(
|
||||
tenantId: string,
|
||||
data: FacturapiCustomerData
|
||||
): Promise<string> {
|
||||
const client = await getOrgClient(tenantId);
|
||||
|
||||
// Buscar si ya existe por búsqueda de texto
|
||||
let existingId: string | null = null;
|
||||
try {
|
||||
const existing = await client.customers.list({ search: data.taxId });
|
||||
if (existing.data && existing.data.length > 0) {
|
||||
const match = existing.data.find((c: any) => c.tax_id === data.taxId);
|
||||
if (match) existingId = match.id;
|
||||
}
|
||||
} catch { /* no existing */ }
|
||||
|
||||
const isForiegn = !!data.country && data.country !== 'MEX';
|
||||
|
||||
if (existingId) {
|
||||
const updateData: any = {
|
||||
legal_name: data.legalName,
|
||||
email: data.email,
|
||||
address: { zip: data.zip, ...(isForiegn ? { country: data.country } : {}) },
|
||||
};
|
||||
if (!isForiegn && data.taxSystem) updateData.tax_system = data.taxSystem;
|
||||
await client.customers.update(existingId, updateData);
|
||||
return existingId;
|
||||
}
|
||||
|
||||
const createData: any = {
|
||||
legal_name: data.legalName,
|
||||
email: data.email,
|
||||
address: { zip: data.zip, ...(isForiegn ? { country: data.country } : {}) },
|
||||
};
|
||||
if (isForiegn) {
|
||||
createData.tax_id = data.taxId; // Tax ID extranjero (NumRegIdTrib)
|
||||
} else {
|
||||
createData.tax_id = data.taxId; // RFC mexicano
|
||||
if (data.taxSystem) createData.tax_system = data.taxSystem;
|
||||
}
|
||||
|
||||
const customer = await client.customers.create(createData);
|
||||
|
||||
return customer.id;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Facturas (Emisión)
|
||||
// ============================================
|
||||
|
||||
export interface FacturapiLineItem {
|
||||
description: string;
|
||||
productKey: string; // ClaveProdServ SAT
|
||||
unitKey?: string; // ClaveUnidad SAT
|
||||
unitName?: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
taxIncluded?: boolean;
|
||||
taxes?: Array<{
|
||||
type: string; // 'IVA', 'ISR', 'IEPS'
|
||||
rate: number; // 0.16, 0.10, etc.
|
||||
factor?: string; // 'Tasa', 'Cuota', 'Exento'
|
||||
withholding?: boolean; // true = retención, false/undefined = traslado
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FacturapiInvoiceData {
|
||||
// Receptor
|
||||
customer: FacturapiCustomerData;
|
||||
// Conceptos
|
||||
items: FacturapiLineItem[];
|
||||
// Campos CFDI
|
||||
use: string; // UsoCFDI: G01, G03, etc.
|
||||
paymentForm: string; // FormaPago: 01, 03, 28, etc.
|
||||
paymentMethod?: string; // MetodoPago: PUE, PPD
|
||||
currency?: string; // MXN, USD
|
||||
exchangeRate?: number;
|
||||
// Opcionales
|
||||
series?: string;
|
||||
folioNumber?: number;
|
||||
conditions?: string;
|
||||
/**
|
||||
* Documentos CFDI relacionados. Estructura SAT 4.0: una entrada por tipo
|
||||
* de relación, agrupando N UUIDs. Facturapi en modo Live valida la
|
||||
* estructura estricta — el formato {uuid, relationship} suelto es rechazado.
|
||||
*/
|
||||
relatedDocuments?: Array<{ relationship: string; uuids: string[] }>;
|
||||
/**
|
||||
* Régimen fiscal del emisor (override del default de la organización).
|
||||
* Requerido cuando el contribuyente tiene múltiples régimenes y Facturapi
|
||||
* necesita saber cuál usar para esta factura específica. Se envía como
|
||||
* `issuer.tax_system` a Facturapi.
|
||||
*/
|
||||
issuerTaxSystem?: string;
|
||||
}
|
||||
|
||||
export async function createInvoice(
|
||||
tenantId: string,
|
||||
data: FacturapiInvoiceData
|
||||
): Promise<any> {
|
||||
const client = await getOrgClient(tenantId);
|
||||
|
||||
// Crear/actualizar cliente en Facturapi
|
||||
const customerId = await createOrUpdateCustomer(tenantId, data.customer);
|
||||
|
||||
const tipo = (data as any).type || 'I';
|
||||
const invoiceData: any = { customer: customerId };
|
||||
|
||||
// Tipo de comprobante
|
||||
if (tipo !== 'I') invoiceData.type = tipo;
|
||||
|
||||
// Items (solo para I, E, T — P no lleva conceptos)
|
||||
if (data.items?.length) {
|
||||
invoiceData.items = data.items.map(item => ({
|
||||
quantity: item.quantity,
|
||||
product: {
|
||||
description: item.description,
|
||||
product_key: item.productKey,
|
||||
unit_key: item.unitKey || 'E48',
|
||||
unit_name: item.unitName || 'Servicio',
|
||||
price: item.price,
|
||||
tax_included: item.taxIncluded ?? true,
|
||||
taxes: item.taxes?.map(t => ({
|
||||
type: t.type,
|
||||
rate: t.rate,
|
||||
factor: t.factor || 'Tasa',
|
||||
...(t.withholding ? { withholding: true } : {}),
|
||||
})) || [{ type: 'IVA', rate: 0.16 }],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Campos del comprobante (no aplican para tipo P)
|
||||
if (tipo === 'I' || tipo === 'E') {
|
||||
invoiceData.use = data.use || 'G01';
|
||||
invoiceData.payment_form = data.paymentForm || '99';
|
||||
invoiceData.payment_method = data.paymentMethod || 'PUE';
|
||||
invoiceData.currency = data.currency || 'MXN';
|
||||
if (data.exchangeRate && data.currency !== 'MXN') {
|
||||
invoiceData.exchange = data.exchangeRate;
|
||||
}
|
||||
if (data.conditions) invoiceData.conditions = data.conditions;
|
||||
}
|
||||
|
||||
if (data.series) invoiceData.series = data.series;
|
||||
if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
|
||||
|
||||
// Documentos relacionados (Ingreso / Egreso / Pago / Traslado).
|
||||
if (data.relatedDocuments?.length) {
|
||||
invoiceData.related_documents = data.relatedDocuments.map(r => ({
|
||||
relationship: r.relationship,
|
||||
documents: r.uuids,
|
||||
}));
|
||||
}
|
||||
|
||||
// Complemento de pago (tipo P)
|
||||
if ((data as any).complements?.length) {
|
||||
invoiceData.complements = (data as any).complements;
|
||||
}
|
||||
|
||||
// Factura global
|
||||
if ((data as any).global) {
|
||||
invoiceData.global = (data as any).global;
|
||||
}
|
||||
|
||||
// El régimen fiscal del emisor lo toma Facturapi del `legal.tax_system` de
|
||||
// la organización — NO acepta override per-invoice via campo `issuer` (la
|
||||
// API rechaza con "issuer is not allowed"). Si se pasa `issuerTaxSystem`,
|
||||
// debe actualizarse el `legal` de la org ANTES de crear el invoice. Para
|
||||
// el path tenant-level no lo hacemos (la org comparte régimen único); solo
|
||||
// el path contribuyente (contribuyente-facturapi.service.ts) implementa
|
||||
// el sync legal porque cada contribuyente tiene sus propios regímenes.
|
||||
|
||||
const invoice = await client.invoices.create(invoiceData);
|
||||
return invoice;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Cancelación
|
||||
// ============================================
|
||||
|
||||
// ============================================
|
||||
// Personalización (logo, color)
|
||||
// ============================================
|
||||
|
||||
export async function uploadLogo(tenantId: string, logoBase64: string): Promise<{ success: boolean; message: string }> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { facturapiOrgId: true },
|
||||
});
|
||||
if (!tenant?.facturapiOrgId) throw new Error('Organización no configurada');
|
||||
|
||||
const userClient = getUserClient();
|
||||
try {
|
||||
const buffer = Buffer.from(logoBase64, 'base64');
|
||||
await userClient.organizations.uploadLogo(tenant.facturapiOrgId, buffer);
|
||||
return { success: true, message: 'Logo subido correctamente' };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Error al subir logo' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateColor(tenantId: string, color: string): Promise<{ success: boolean; message: string }> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { facturapiOrgId: true },
|
||||
});
|
||||
if (!tenant?.facturapiOrgId) throw new Error('Organización no configurada');
|
||||
|
||||
const userClient = getUserClient();
|
||||
try {
|
||||
await userClient.organizations.updateCustomization(tenant.facturapiOrgId, { color: color.replace('#', '') });
|
||||
return { success: true, message: 'Color actualizado' };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message || 'Error al actualizar color' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCustomization(tenantId: string): Promise<{ logoUrl?: string; color?: string } | null> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { facturapiOrgId: true },
|
||||
});
|
||||
if (!tenant?.facturapiOrgId) return null;
|
||||
|
||||
const userClient = getUserClient();
|
||||
try {
|
||||
const org = await userClient.organizations.retrieve(tenant.facturapiOrgId);
|
||||
return {
|
||||
logoUrl: org.customization?.has_logo ? (org.logo_url ?? undefined) : undefined,
|
||||
color: org.customization?.color || undefined,
|
||||
};
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export async function sendInvoiceByEmail(
|
||||
tenantId: string,
|
||||
facturapiId: string,
|
||||
email: string
|
||||
): Promise<void> {
|
||||
const client = await getOrgClient(tenantId);
|
||||
await client.invoices.sendByEmail(facturapiId, { email });
|
||||
}
|
||||
|
||||
export async function cancelInvoice(
|
||||
tenantId: string,
|
||||
facturapiId: string,
|
||||
motive: '01' | '02' | '03' | '04' = '02',
|
||||
substitution?: string
|
||||
): Promise<any> {
|
||||
const client = await getOrgClient(tenantId);
|
||||
|
||||
const cancelData: any = { motive };
|
||||
if (motive === '01' && substitution) {
|
||||
cancelData.substitution = substitution;
|
||||
}
|
||||
|
||||
return client.invoices.cancel(facturapiId, cancelData);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Descargas
|
||||
// ============================================
|
||||
|
||||
export async function downloadPdf(tenantId: string, facturapiId: string): Promise<Buffer> {
|
||||
const client = await getOrgClient(tenantId);
|
||||
const stream = await client.invoices.downloadPdf(facturapiId);
|
||||
return streamToBuffer(stream);
|
||||
}
|
||||
|
||||
export async function downloadXml(tenantId: string, facturapiId: string): Promise<Buffer> {
|
||||
const client = await getOrgClient(tenantId);
|
||||
const stream = await client.invoices.downloadXml(facturapiId);
|
||||
return streamToBuffer(stream);
|
||||
}
|
||||
|
||||
export async function downloadZip(tenantId: string, facturapiId: string): Promise<Buffer> {
|
||||
const client = await getOrgClient(tenantId);
|
||||
const stream = await client.invoices.downloadZip(facturapiId);
|
||||
return streamToBuffer(stream);
|
||||
}
|
||||
|
||||
function streamToBuffer(stream: any): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (Buffer.isBuffer(stream)) return resolve(stream);
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Timbres
|
||||
// ============================================
|
||||
|
||||
export interface TimbreStatus {
|
||||
configured: boolean;
|
||||
// Campos flat — backward compat con UI existente que lee `limite/usados/disponibles`
|
||||
// al top level. Representan SOLO el pool mensual (no suman los paquetes).
|
||||
tipo?: string;
|
||||
limite?: number;
|
||||
usados?: number;
|
||||
disponibles?: number;
|
||||
periodoFin?: string;
|
||||
// Shape nuevo (fase B): separa mensual vs adicionales para la UI detallada
|
||||
mensual?: {
|
||||
tipo: string;
|
||||
limite: number;
|
||||
usados: number;
|
||||
disponibles: number;
|
||||
periodoFin: string;
|
||||
};
|
||||
adicionales?: {
|
||||
total: number; // suma de cantidades originales de paquetes vigentes
|
||||
usados: number; // suma de usados
|
||||
disponibles: number; // total - usados
|
||||
paquetes: Array<{
|
||||
id: number;
|
||||
cantidad: number;
|
||||
usados: number;
|
||||
disponibles: number;
|
||||
adquiridoEn: string;
|
||||
expiraEn: string;
|
||||
}>;
|
||||
};
|
||||
/** suma total disponible (mensual + adicionales vigentes). */
|
||||
totalDisponibles: number;
|
||||
}
|
||||
|
||||
export async function getTimbreStatus(tenantId: string): Promise<TimbreStatus> {
|
||||
const now = new Date();
|
||||
|
||||
const [suscripcion, paquetes] = await Promise.all([
|
||||
prisma.timbreSuscripcion.findUnique({ where: { tenantId } }),
|
||||
prisma.timbrePaquete.findMany({
|
||||
where: { tenantId, expiraEn: { gt: now } },
|
||||
orderBy: { expiraEn: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!suscripcion && paquetes.length === 0) {
|
||||
return { configured: false, totalDisponibles: 0 };
|
||||
}
|
||||
|
||||
const mensualVigente = suscripcion && now <= suscripcion.periodoFin;
|
||||
const mensual = mensualVigente
|
||||
? {
|
||||
tipo: suscripcion.tipo,
|
||||
limite: suscripcion.timbresLimite,
|
||||
usados: suscripcion.timbresUsados,
|
||||
disponibles: Math.max(0, suscripcion.timbresLimite - suscripcion.timbresUsados),
|
||||
periodoFin: suscripcion.periodoFin.toISOString().split('T')[0],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const paquetesDetail = paquetes.map(p => ({
|
||||
id: p.id,
|
||||
cantidad: p.cantidad,
|
||||
usados: p.usados,
|
||||
disponibles: Math.max(0, p.cantidad - p.usados),
|
||||
adquiridoEn: p.adquiridoEn.toISOString(),
|
||||
expiraEn: p.expiraEn.toISOString(),
|
||||
}));
|
||||
|
||||
const adicionales = paquetesDetail.length > 0
|
||||
? {
|
||||
total: paquetesDetail.reduce((s, p) => s + p.cantidad, 0),
|
||||
usados: paquetesDetail.reduce((s, p) => s + p.usados, 0),
|
||||
disponibles: paquetesDetail.reduce((s, p) => s + p.disponibles, 0),
|
||||
paquetes: paquetesDetail,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
// backward-compat flat: refleja el mensual si existe, sino deja undefined
|
||||
tipo: mensual?.tipo,
|
||||
limite: mensual?.limite,
|
||||
usados: mensual?.usados,
|
||||
disponibles: mensual?.disponibles,
|
||||
periodoFin: mensual?.periodoFin,
|
||||
// nuevo shape nested
|
||||
mensual,
|
||||
adicionales,
|
||||
totalDisponibles: (mensual?.disponibles || 0) + (adicionales?.disponibles || 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume 1 timbre respetando las reglas del feature:
|
||||
* 1) Intenta contra TimbreSuscripcion si está en periodo vigente y queda cupo.
|
||||
* 2) Si mensual agotado/vencido, consume del TimbrePaquete con menor expiraEn
|
||||
* (FIFO para no desperdiciar los próximos a vencer).
|
||||
* 3) Si no hay nada disponible, lanza error.
|
||||
*
|
||||
* La transacción protege contra race conditions en emisiones concurrentes.
|
||||
*/
|
||||
export async function consumeTimbre(tenantId: string): Promise<{ source: 'mensual' | 'paquete'; paqueteId?: number }> {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
|
||||
// 1) Intenta mensual
|
||||
const suscripcion = await tx.timbreSuscripcion.findUnique({ where: { tenantId } });
|
||||
if (suscripcion && now <= suscripcion.periodoFin && suscripcion.timbresUsados < suscripcion.timbresLimite) {
|
||||
await tx.timbreSuscripcion.update({
|
||||
where: { tenantId },
|
||||
data: { timbresUsados: { increment: 1 } },
|
||||
});
|
||||
return { source: 'mensual' as const };
|
||||
}
|
||||
|
||||
// 2) Fallback a paquetes adicionales FIFO por expiraEn
|
||||
const paquete = await tx.timbrePaquete.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
expiraEn: { gt: now },
|
||||
usados: { lt: prisma.timbrePaquete.fields.cantidad },
|
||||
},
|
||||
orderBy: { expiraEn: 'asc' },
|
||||
});
|
||||
|
||||
if (!paquete) {
|
||||
// Diferencia los mensajes de error para que el frontend sepa qué ofrecer
|
||||
if (!suscripcion) {
|
||||
throw new Error('No hay suscripción de timbres configurada');
|
||||
}
|
||||
if (now > suscripcion.periodoFin) {
|
||||
throw new Error('La suscripción de timbres ha expirado. Compra timbres adicionales o renueva tu plan.');
|
||||
}
|
||||
throw new Error('Se agotaron los timbres del plan mensual y no tienes paquetes adicionales. Compra un paquete para continuar.');
|
||||
}
|
||||
|
||||
await tx.timbrePaquete.update({
|
||||
where: { id: paquete.id },
|
||||
data: { usados: { increment: 1 } },
|
||||
});
|
||||
|
||||
return { source: 'paquete' as const, paqueteId: paquete.id };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revierte un consumo previo de timbre. Idempotente por fuente:
|
||||
* - mensual → decrementa timbresUsados (no baja de 0 gracias al guard)
|
||||
* - paquete → decrementa usados del paquete específico (mismo guard)
|
||||
*
|
||||
* Se invoca cuando la emisión en Facturapi falla después de haber consumido
|
||||
* (SAT nunca selló → el timbre no debe cobrarse).
|
||||
*/
|
||||
export async function refundTimbre(
|
||||
tenantId: string,
|
||||
consumed: { source: 'mensual' | 'paquete'; paqueteId?: number },
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (consumed.source === 'mensual') {
|
||||
const sub = await tx.timbreSuscripcion.findUnique({ where: { tenantId } });
|
||||
if (sub && sub.timbresUsados > 0) {
|
||||
await tx.timbreSuscripcion.update({
|
||||
where: { tenantId },
|
||||
data: { timbresUsados: { decrement: 1 } },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (consumed.source === 'paquete' && consumed.paqueteId != null) {
|
||||
const pkg = await tx.timbrePaquete.findUnique({ where: { id: consumed.paqueteId } });
|
||||
if (pkg && pkg.usados > 0) {
|
||||
await tx.timbrePaquete.update({
|
||||
where: { id: consumed.paqueteId },
|
||||
data: { usados: { decrement: 1 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset mensual de TimbreSuscripcion: para cada tenant cuyo periodoFin ya pasó,
|
||||
* resetea `timbresUsados=0` y avanza la ventana un mes (tipo='mensual') o un año
|
||||
* (tipo='anual'). Usado por cron diario. Idempotente: si no hay vencidas, no-op.
|
||||
*
|
||||
* Los paquetes adicionales NO se tocan aquí — su vigencia es 1 año fijo desde
|
||||
* la compra y el filtro `expiraEn > now` los excluye automáticamente cuando
|
||||
* caducan.
|
||||
*/
|
||||
export async function resetExpiredMonthlyTimbres(): Promise<{ reset: number }> {
|
||||
const now = new Date();
|
||||
const vencidas = await prisma.timbreSuscripcion.findMany({
|
||||
where: { periodoFin: { lt: now } },
|
||||
});
|
||||
|
||||
let count = 0;
|
||||
for (const s of vencidas) {
|
||||
const nextInicio = new Date(s.periodoFin);
|
||||
nextInicio.setDate(nextInicio.getDate() + 1);
|
||||
const nextFin = new Date(nextInicio);
|
||||
if (s.tipo === 'anual') {
|
||||
nextFin.setFullYear(nextFin.getFullYear() + 1);
|
||||
nextFin.setDate(nextFin.getDate() - 1);
|
||||
} else {
|
||||
nextFin.setMonth(nextFin.getMonth() + 1);
|
||||
nextFin.setDate(nextFin.getDate() - 1);
|
||||
}
|
||||
|
||||
await prisma.timbreSuscripcion.update({
|
||||
where: { id: s.id },
|
||||
data: {
|
||||
timbresUsados: 0,
|
||||
periodoInicio: nextInicio,
|
||||
periodoFin: nextFin,
|
||||
},
|
||||
});
|
||||
count++;
|
||||
console.log(`[Timbres] Reset mensual tenant ${s.tenantId}: nuevo periodo ${nextInicio.toISOString().split('T')[0]} → ${nextFin.toISOString().split('T')[0]}`);
|
||||
}
|
||||
|
||||
return { reset: count };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Paquetes adicionales: catálogo + compra + activación
|
||||
// ============================================
|
||||
|
||||
/** Lista los paquetes activos del catálogo, ordenados por cantidad ASC. */
|
||||
export async function listPaquetesCatalogo() {
|
||||
const rows = await prisma.timbrePaqueteCatalogo.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { cantidad: 'asc' },
|
||||
});
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
cantidad: r.cantidad,
|
||||
precio: Number(r.precio),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos los paquetes del catálogo (incluyendo inactivos). Para admin
|
||||
* global que edita precios — necesita ver los dados de baja también.
|
||||
*/
|
||||
export async function listAllPaquetesCatalogo() {
|
||||
const rows = await prisma.timbrePaqueteCatalogo.findMany({
|
||||
orderBy: { cantidad: 'asc' },
|
||||
});
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
cantidad: r.cantidad,
|
||||
precio: Number(r.precio),
|
||||
active: r.active,
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza precio y/o estado activo de un paquete del catálogo. Solo admin
|
||||
* global. Los cambios NO afectan paquetes ya vendidos (TimbrePaquete guarda
|
||||
* snapshot del precio al momento de compra).
|
||||
*/
|
||||
export async function updatePaqueteCatalogo(params: {
|
||||
id: number;
|
||||
precio?: number;
|
||||
active?: boolean;
|
||||
}) {
|
||||
const data: { precio?: number; active?: boolean } = {};
|
||||
if (params.precio !== undefined) {
|
||||
if (params.precio <= 0) throw new Error('El precio debe ser mayor a 0');
|
||||
data.precio = params.precio;
|
||||
}
|
||||
if (params.active !== undefined) data.active = params.active;
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
throw new Error('Nada que actualizar');
|
||||
}
|
||||
|
||||
const updated = await prisma.timbrePaqueteCatalogo.update({
|
||||
where: { id: params.id },
|
||||
data,
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
cantidad: updated.cantidad,
|
||||
precio: Number(updated.precio),
|
||||
active: updated.active,
|
||||
updatedAt: updated.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicia la compra de un paquete. Crea un Payment con status=pending y una
|
||||
* MP Preference (checkout one-shot). Retorna la URL a la que redirigir al user.
|
||||
*
|
||||
* Flujo post-pago:
|
||||
* 1. User paga en MP
|
||||
* 2. MP dispara webhook `payment.approved` con external_reference = `timbres-pack:{paymentId}`
|
||||
* 3. `applyApprovedTimbrePack` (fase webhook) crea el TimbrePaquete y emite factura
|
||||
*/
|
||||
export async function iniciarCompraPaquete(params: {
|
||||
tenantId: string;
|
||||
catalogoId: number;
|
||||
callerEmail: string; // email del user que inicia la compra (caller)
|
||||
}): Promise<{ paymentId: string; checkoutUrl: string }> {
|
||||
const paquete = await prisma.timbrePaqueteCatalogo.findUnique({
|
||||
where: { id: params.catalogoId },
|
||||
});
|
||||
if (!paquete || !paquete.active) {
|
||||
throw new Error('Paquete no disponible');
|
||||
}
|
||||
|
||||
// Email de pago: preferencia al owner del tenant (para continuidad del flujo
|
||||
// normal de facturación). Si no hay owner activo (caso edge: tenant sin
|
||||
// membership owner por ahora), caemos al email del caller para no bloquear.
|
||||
const ownerEmail = await getTenantOwnerEmail(params.tenantId);
|
||||
const payerEmail = ownerEmail || params.callerEmail;
|
||||
if (!payerEmail) {
|
||||
throw new Error('No se pudo determinar un email para el cobro');
|
||||
}
|
||||
|
||||
// Payment pre-creado como pending. El webhook lo aprueba. Guardamos cantidad
|
||||
// en un campo que podamos recuperar — usamos paymentMethod como placeholder
|
||||
// para carry-over del cantidad mientras no haya un campo metadata dedicado.
|
||||
// Mejor: el paqueteId del catálogo es único y persistente, no hace falta
|
||||
// guardar cantidad aparte.
|
||||
const payment = await prisma.payment.create({
|
||||
data: {
|
||||
tenantId: params.tenantId,
|
||||
amount: paquete.precio,
|
||||
status: 'pending',
|
||||
kind: 'timbres_pack',
|
||||
paymentMethod: `catalogo:${paquete.id}`, // marker para recuperar cantidad en webhook
|
||||
},
|
||||
});
|
||||
|
||||
const { preferenceId, checkoutUrl } = await mpService.createTimbrePackPreference({
|
||||
paymentId: payment.id,
|
||||
tenantId: params.tenantId,
|
||||
cantidad: paquete.cantidad,
|
||||
amount: Number(paquete.precio),
|
||||
payerEmail,
|
||||
});
|
||||
|
||||
// Guardamos el preferenceId por si lo necesitamos para debugging o cancel
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: { mpPaymentId: preferenceId }, // temporal hasta que llegue el paymentId real
|
||||
});
|
||||
|
||||
return { paymentId: payment.id, checkoutUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Activa el paquete una vez que MP confirmó el pago. Idempotente: si ya hay un
|
||||
* TimbrePaquete para este paymentId, no-op.
|
||||
*
|
||||
* Llamado desde el webhook cuando external_reference = timbres-pack:{paymentId}
|
||||
* Y status=approved.
|
||||
*/
|
||||
export async function activarPaqueteTrasPago(paymentId: string): Promise<{ created: boolean; paqueteId?: number }> {
|
||||
const existing = await prisma.timbrePaquete.findUnique({
|
||||
where: { paymentId },
|
||||
});
|
||||
if (existing) {
|
||||
console.log(`[Timbres] Paquete ya activado para payment ${paymentId} (idempotente)`);
|
||||
return { created: false, paqueteId: existing.id };
|
||||
}
|
||||
|
||||
const payment = await prisma.payment.findUnique({ where: { id: paymentId } });
|
||||
if (!payment) throw new Error(`Payment ${paymentId} no encontrado`);
|
||||
if (payment.kind !== 'timbres_pack') {
|
||||
throw new Error(`Payment ${paymentId} no es de tipo timbres_pack`);
|
||||
}
|
||||
|
||||
// Recupera cantidad del marker paymentMethod (catalogo:ID)
|
||||
const match = /^catalogo:(\d+)$/.exec(payment.paymentMethod || '');
|
||||
if (!match) {
|
||||
throw new Error(`Payment ${paymentId} sin marker de catálogo válido`);
|
||||
}
|
||||
const catalogoId = Number(match[1]);
|
||||
const catalogo = await prisma.timbrePaqueteCatalogo.findUnique({ where: { id: catalogoId } });
|
||||
if (!catalogo) {
|
||||
throw new Error(`Catálogo ${catalogoId} referenciado por Payment ${paymentId} ya no existe`);
|
||||
}
|
||||
|
||||
const adquiridoEn = new Date();
|
||||
const expiraEn = new Date(adquiridoEn);
|
||||
expiraEn.setFullYear(expiraEn.getFullYear() + 1);
|
||||
|
||||
const paquete = await prisma.timbrePaquete.create({
|
||||
data: {
|
||||
tenantId: payment.tenantId,
|
||||
paymentId: payment.id,
|
||||
cantidad: catalogo.cantidad,
|
||||
precio: payment.amount, // precio pagado (historial, snapshot del momento)
|
||||
adquiridoEn,
|
||||
expiraEn,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[Timbres] Activado paquete ${paquete.id} (${catalogo.cantidad} timbres) para tenant ${payment.tenantId}, expira ${expiraEn.toISOString().split('T')[0]}`);
|
||||
return { created: true, paqueteId: paquete.id };
|
||||
}
|
||||
313
apps/api/src/services/fiel.service.ts
Normal file
313
apps/api/src/services/fiel.service.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { Credential } from '@nodecfdi/credentials/node';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { encryptFielCredentials, encrypt, decryptFielCredentials } from './sat/sat-crypto.service.js';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import type { FielStatus } from '@horux/shared';
|
||||
|
||||
/**
|
||||
* Sube y valida credenciales FIEL
|
||||
*/
|
||||
export async function uploadFiel(
|
||||
tenantId: string,
|
||||
cerBase64: string,
|
||||
keyBase64: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean; message: string; status?: FielStatus }> {
|
||||
try {
|
||||
// Decodificar archivos de Base64
|
||||
const cerData = Buffer.from(cerBase64, 'base64');
|
||||
const keyData = Buffer.from(keyBase64, 'base64');
|
||||
|
||||
// Validar que los archivos sean válidos y coincidan
|
||||
let credential: Credential;
|
||||
try {
|
||||
credential = Credential.create(
|
||||
cerData.toString('binary'),
|
||||
keyData.toString('binary'),
|
||||
password
|
||||
);
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Los archivos de la FIEL no son válidos o la contraseña es incorrecta',
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar que sea una FIEL (no CSD)
|
||||
if (!credential.isFiel()) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'El certificado proporcionado no es una FIEL (e.firma). Parece ser un CSD.',
|
||||
};
|
||||
}
|
||||
|
||||
// Obtener información del certificado
|
||||
const certificate = credential.certificate();
|
||||
const rfc = certificate.rfc();
|
||||
const serialNumber = certificate.serialNumber().bytes();
|
||||
// validFromDateTime() y validToDateTime() retornan strings ISO o objetos DateTime
|
||||
const validFromRaw = certificate.validFromDateTime();
|
||||
const validUntilRaw = certificate.validToDateTime();
|
||||
const validFrom = new Date(String(validFromRaw));
|
||||
const validUntil = new Date(String(validUntilRaw));
|
||||
|
||||
// Verificar que no esté vencida
|
||||
if (new Date() > validUntil) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'La FIEL está vencida desde ' + validUntil.toLocaleDateString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Encriptar credenciales (per-component IV/tag)
|
||||
const {
|
||||
encryptedCer,
|
||||
encryptedKey,
|
||||
encryptedPassword,
|
||||
cerIv,
|
||||
cerTag,
|
||||
keyIv,
|
||||
keyTag,
|
||||
passwordIv,
|
||||
passwordTag,
|
||||
} = encryptFielCredentials(cerData, keyData, password);
|
||||
|
||||
// Detectar si es la primera subida (no existe fielCredential previo activo)
|
||||
// — se usa abajo para disparar Opinión de Cumplimiento + CSF iniciales.
|
||||
const existingFiel = await prisma.fielCredential.findUnique({
|
||||
where: { tenantId },
|
||||
select: { isActive: true },
|
||||
});
|
||||
const esPrimeraSubida = !existingFiel || !existingFiel.isActive;
|
||||
|
||||
// Guardar o actualizar en BD
|
||||
await prisma.fielCredential.upsert({
|
||||
where: { tenantId },
|
||||
create: {
|
||||
tenantId,
|
||||
rfc,
|
||||
cerData: encryptedCer,
|
||||
keyData: encryptedKey,
|
||||
keyPasswordEncrypted: encryptedPassword,
|
||||
cerIv,
|
||||
cerTag,
|
||||
keyIv,
|
||||
keyTag,
|
||||
passwordIv,
|
||||
passwordTag,
|
||||
serialNumber,
|
||||
validFrom,
|
||||
validUntil,
|
||||
isActive: true,
|
||||
},
|
||||
update: {
|
||||
rfc,
|
||||
cerData: encryptedCer,
|
||||
keyData: encryptedKey,
|
||||
keyPasswordEncrypted: encryptedPassword,
|
||||
cerIv,
|
||||
cerTag,
|
||||
keyIv,
|
||||
keyTag,
|
||||
passwordIv,
|
||||
passwordTag,
|
||||
serialNumber,
|
||||
validFrom,
|
||||
validUntil,
|
||||
isActive: true,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Save encrypted files to filesystem (dual storage)
|
||||
try {
|
||||
const fielDir = join(env.FIEL_STORAGE_PATH, rfc.toUpperCase());
|
||||
await mkdir(fielDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Re-encrypt for filesystem (independent keys from DB)
|
||||
const fsEncrypted = encryptFielCredentials(cerData, keyData, password);
|
||||
|
||||
await writeFile(join(fielDir, 'certificate.cer.enc'), fsEncrypted.encryptedCer, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'certificate.cer.iv'), fsEncrypted.cerIv, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'certificate.cer.tag'), fsEncrypted.cerTag, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'private_key.key.enc'), fsEncrypted.encryptedKey, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'private_key.key.iv'), fsEncrypted.keyIv, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'private_key.key.tag'), fsEncrypted.keyTag, { mode: 0o600 });
|
||||
|
||||
// Encrypt and store metadata
|
||||
const metadata = JSON.stringify({
|
||||
serial: serialNumber,
|
||||
validFrom: validFrom.toISOString(),
|
||||
validUntil: validUntil.toISOString(),
|
||||
uploadedAt: new Date().toISOString(),
|
||||
rfc: rfc.toUpperCase(),
|
||||
});
|
||||
const metaEncrypted = encrypt(Buffer.from(metadata, 'utf-8'));
|
||||
await writeFile(join(fielDir, 'metadata.json.enc'), metaEncrypted.encrypted, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'metadata.json.iv'), metaEncrypted.iv, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'metadata.json.tag'), metaEncrypted.tag, { mode: 0o600 });
|
||||
} catch (fsError) {
|
||||
console.error('[FIEL] Filesystem storage failed (DB storage OK):', fsError);
|
||||
}
|
||||
|
||||
// Notify admin that client uploaded FIEL
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { nombre: true, rfc: true },
|
||||
});
|
||||
if (tenant) {
|
||||
emailService.sendFielNotification({
|
||||
clienteNombre: tenant.nombre,
|
||||
clienteRfc: tenant.rfc,
|
||||
}).catch(err => console.error('[EMAIL] FIEL notification failed:', err));
|
||||
}
|
||||
|
||||
// Al primer upload de FIEL, disparar Opinión de Cumplimiento + CSF en
|
||||
// background. Fire-and-forget — no bloqueamos la respuesta porque ambos
|
||||
// procesos abren Playwright y tardan minutos. La CSF además autocompleta
|
||||
// domicilio y regímenes activos del tenant.
|
||||
if (esPrimeraSubida) {
|
||||
import('./opinion-cumplimiento.service.js').then(({ consultarOpinion }) =>
|
||||
consultarOpinion(tenantId),
|
||||
).catch(err => console.error(`[FIEL first-upload] Opinión falló para tenant ${tenantId}:`, err.message || err));
|
||||
|
||||
import('./constancia.service.js').then(({ consultarConstancia }) =>
|
||||
consultarConstancia(tenantId),
|
||||
).catch(err => console.error(`[FIEL first-upload] CSF falló para tenant ${tenantId}:`, err.message || err));
|
||||
}
|
||||
|
||||
const daysUntilExpiration = Math.ceil(
|
||||
(validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'FIEL configurada correctamente',
|
||||
status: {
|
||||
configured: true,
|
||||
rfc,
|
||||
serialNumber,
|
||||
validFrom: validFrom.toISOString(),
|
||||
validUntil: validUntil.toISOString(),
|
||||
isExpired: false,
|
||||
daysUntilExpiration,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[FIEL Upload Error]', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || 'Error al procesar la FIEL',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el estado de la FIEL de un tenant
|
||||
*/
|
||||
export async function getFielStatus(tenantId: string): Promise<FielStatus> {
|
||||
const fiel = await prisma.fielCredential.findUnique({
|
||||
where: { tenantId },
|
||||
select: {
|
||||
rfc: true,
|
||||
serialNumber: true,
|
||||
validFrom: true,
|
||||
validUntil: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!fiel || !fiel.isActive) {
|
||||
return { configured: false };
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const isExpired = now > fiel.validUntil;
|
||||
const daysUntilExpiration = Math.ceil(
|
||||
(fiel.validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
return {
|
||||
configured: true,
|
||||
rfc: fiel.rfc,
|
||||
serialNumber: fiel.serialNumber || undefined,
|
||||
validFrom: fiel.validFrom.toISOString(),
|
||||
validUntil: fiel.validUntil.toISOString(),
|
||||
isExpired,
|
||||
daysUntilExpiration: isExpired ? 0 : daysUntilExpiration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina la FIEL de un tenant
|
||||
*/
|
||||
export async function deleteFiel(tenantId: string): Promise<boolean> {
|
||||
try {
|
||||
await prisma.fielCredential.delete({
|
||||
where: { tenantId },
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene las credenciales desencriptadas para usar en sincronización
|
||||
* Solo debe usarse internamente por el servicio de SAT
|
||||
*/
|
||||
export async function getDecryptedFiel(tenantId: string): Promise<{
|
||||
cerContent: string;
|
||||
keyContent: string;
|
||||
password: string;
|
||||
rfc: string;
|
||||
} | null> {
|
||||
const fiel = await prisma.fielCredential.findUnique({
|
||||
where: { tenantId },
|
||||
});
|
||||
|
||||
if (!fiel || !fiel.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verificar que no esté vencida
|
||||
if (new Date() > fiel.validUntil) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Desencriptar credenciales (per-component IV/tag)
|
||||
const { cerData, keyData, password } = decryptFielCredentials(
|
||||
Buffer.from(fiel.cerData),
|
||||
Buffer.from(fiel.keyData),
|
||||
Buffer.from(fiel.keyPasswordEncrypted),
|
||||
Buffer.from(fiel.cerIv),
|
||||
Buffer.from(fiel.cerTag),
|
||||
Buffer.from(fiel.keyIv),
|
||||
Buffer.from(fiel.keyTag),
|
||||
Buffer.from(fiel.passwordIv),
|
||||
Buffer.from(fiel.passwordTag)
|
||||
);
|
||||
|
||||
return {
|
||||
cerContent: cerData.toString('binary'),
|
||||
keyContent: keyData.toString('binary'),
|
||||
password,
|
||||
rfc: fiel.rfc,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[FIEL Decrypt Error]', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un tenant tiene FIEL configurada y válida
|
||||
*/
|
||||
export async function hasFielConfigured(tenantId: string): Promise<boolean> {
|
||||
const status = await getFielStatus(tenantId);
|
||||
return status.configured && !status.isExpired;
|
||||
}
|
||||
1150
apps/api/src/services/impuestos.service.ts
Normal file
1150
apps/api/src/services/impuestos.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
179
apps/api/src/services/metabase.service.ts
Normal file
179
apps/api/src/services/metabase.service.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Metabase integration service.
|
||||
* Automatically registers newly-provisioned tenant databases in Metabase
|
||||
* (al crear tenant) y las elimina (al desactivar tenant). Auth via session
|
||||
* token con cache de 13 días (Metabase los expira a 14).
|
||||
*
|
||||
* Variables de entorno (todas opcionales — si METABASE_PASSWORD o
|
||||
* METABASE_PG_PASSWORD faltan, las llamadas se logean y skipean sin romper):
|
||||
* METABASE_URL (default http://192.168.10.170:3000)
|
||||
* METABASE_USERNAME (default ialcarazsalazar@consultoria-as.com)
|
||||
* METABASE_PASSWORD password de la cuenta admin Metabase
|
||||
* METABASE_PG_HOST (default 192.168.10.90)
|
||||
* METABASE_PG_PORT (default 5432)
|
||||
* METABASE_PG_USER (default postgres)
|
||||
* METABASE_PG_PASSWORD password Postgres que Metabase usa para conectar
|
||||
*/
|
||||
|
||||
const METABASE_URL = process.env.METABASE_URL || 'http://192.168.10.170:3000';
|
||||
const METABASE_USERNAME = process.env.METABASE_USERNAME || 'ialcarazsalazar@consultoria-as.com';
|
||||
const METABASE_PASSWORD = process.env.METABASE_PASSWORD || '';
|
||||
|
||||
// PostgreSQL connection details exposed to Metabase
|
||||
const PG_HOST = process.env.METABASE_PG_HOST || '192.168.10.90';
|
||||
const PG_PORT = parseInt(process.env.METABASE_PG_PORT || '5432', 10);
|
||||
const PG_USER = process.env.METABASE_PG_USER || 'postgres';
|
||||
const PG_PASSWORD = process.env.METABASE_PG_PASSWORD || '';
|
||||
|
||||
let cachedSessionToken: string | null = null;
|
||||
let tokenExpiresAt = 0;
|
||||
|
||||
async function getSessionToken(): Promise<string | null> {
|
||||
// Re-use cached token if still valid (Metabase sessions last 2 weeks by default)
|
||||
if (cachedSessionToken && Date.now() < tokenExpiresAt) {
|
||||
return cachedSessionToken;
|
||||
}
|
||||
|
||||
if (!METABASE_PASSWORD) {
|
||||
console.error('[METABASE] METABASE_PASSWORD not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${METABASE_URL}/api/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: METABASE_USERNAME,
|
||||
password: METABASE_PASSWORD,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error(`[METABASE] Auth failed: ${res.status} ${text}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json() as { id?: string };
|
||||
if (!data.id) {
|
||||
console.error('[METABASE] Auth response missing session id');
|
||||
return null;
|
||||
}
|
||||
|
||||
cachedSessionToken = data.id;
|
||||
tokenExpiresAt = Date.now() + 13 * 24 * 60 * 60 * 1000; // 13 days
|
||||
return cachedSessionToken;
|
||||
} catch (err) {
|
||||
console.error('[METABASE] Error fetching session token:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface RegisterDatabaseInput {
|
||||
nombre: string;
|
||||
dbName: string;
|
||||
}
|
||||
|
||||
export async function registerDatabase(input: RegisterDatabaseInput): Promise<void> {
|
||||
const sessionToken = await getSessionToken();
|
||||
if (!sessionToken) {
|
||||
console.error('[METABASE] Skipping database registration — no session token');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!PG_PASSWORD) {
|
||||
console.error('[METABASE] METABASE_PG_PASSWORD not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: input.nombre,
|
||||
engine: 'postgres',
|
||||
details: {
|
||||
host: PG_HOST,
|
||||
port: PG_PORT,
|
||||
dbname: input.dbName,
|
||||
user: PG_USER,
|
||||
password: PG_PASSWORD,
|
||||
ssl: false,
|
||||
'tunnel-enabled': false,
|
||||
'advanced-options': false,
|
||||
'schema-filters-type': 'all',
|
||||
},
|
||||
auto_run_queries: true,
|
||||
is_full_sync: true,
|
||||
is_on_demand: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${METABASE_URL}/api/database`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Metabase-Session': sessionToken,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
// 409 or duplicate name is not fatal — log and continue
|
||||
if (res.status === 400 && text.includes('already exists')) {
|
||||
console.log(`[METABASE] Database "${input.nombre}" already registered`);
|
||||
return;
|
||||
}
|
||||
console.error(`[METABASE] Register database failed: ${res.status} ${text}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json() as { id?: number };
|
||||
console.log(`[METABASE] Database "${input.nombre}" registered with id=${data.id}`);
|
||||
} catch (err) {
|
||||
console.error('[METABASE] Error registering database:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDatabase(databaseName: string): Promise<void> {
|
||||
const sessionToken = await getSessionToken();
|
||||
if (!sessionToken) {
|
||||
console.error('[METABASE] Skipping database deletion — no session token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Find database by name
|
||||
const listRes = await fetch(`${METABASE_URL}/api/database`, {
|
||||
headers: { 'X-Metabase-Session': sessionToken },
|
||||
});
|
||||
|
||||
if (!listRes.ok) {
|
||||
console.error(`[METABASE] Failed to list databases: ${listRes.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const listData = await listRes.json() as { data?: Array<{ id: number; name: string; details?: { dbname?: string } }> };
|
||||
const db = listData.data?.find(
|
||||
(d) => d.details?.dbname === databaseName || d.name.includes(databaseName)
|
||||
);
|
||||
|
||||
if (!db) {
|
||||
console.log(`[METABASE] No database found for ${databaseName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteRes = await fetch(`${METABASE_URL}/api/database/${db.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Metabase-Session': sessionToken },
|
||||
});
|
||||
|
||||
if (!deleteRes.ok) {
|
||||
console.error(`[METABASE] Delete database failed: ${deleteRes.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[METABASE] Database ${db.id} (${databaseName}) deleted`);
|
||||
} catch (err) {
|
||||
console.error('[METABASE] Error deleting database:', err);
|
||||
}
|
||||
}
|
||||
342
apps/api/src/services/metricas-compute.service.ts
Normal file
342
apps/api/src/services/metricas-compute.service.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import {
|
||||
calcularIngresosPorRegimen,
|
||||
calcularEgresosPorRegimen,
|
||||
calcularNcsEmitidasPorRegimen,
|
||||
calcularNcsRecibidasPorRegimen,
|
||||
calcularGastosNoDeduciblesEfectivoPorRegimen,
|
||||
} from './dashboard.service.js';
|
||||
import { getResumenIva } from './impuestos.service.js';
|
||||
import {
|
||||
upsertMetricaMensual,
|
||||
getPendingInvalidations,
|
||||
clearInvalidation,
|
||||
} from './metricas.service.js';
|
||||
|
||||
/**
|
||||
* Tanda A — Cimientos del sistema hot/cold de métricas pre-calculadas.
|
||||
*
|
||||
* Este módulo calcula las métricas mensuales agregando desde `cfdis` raw, y las
|
||||
* guarda en la tabla `metricas_mensuales` del tenant para que los consumers las
|
||||
* lean sin recomputar. Los consumers aún NO leen de la tabla (Tanda B), esto
|
||||
* solo llena y mantiene la tabla.
|
||||
*
|
||||
* Alcance Tanda A (campos poblados):
|
||||
* - ingresos_cobrados, egresos_pagados (flujo efectivo, respeta grupo de régimen)
|
||||
* - iva_trasladado_total, iva_acreditable, iva_retenido_cobrado, iva_resultado
|
||||
* - utilidad_realizada, flujo_entradas/salidas/neto
|
||||
* - cfdis_emitidos_count, cfdis_recibidos_count, cfdis_cancelados_count
|
||||
*
|
||||
* Fuera de alcance Tanda A (quedan en 0 — iteraciones futuras):
|
||||
* - Desglose IVA por tasa (16/8/0/exento)
|
||||
* - ISR causado/retenido/a_pagar (requiere tablas progresivas + coeficiente)
|
||||
* - IEPS trasladado/acreditable
|
||||
* - CxC/CxP saldo final + counts
|
||||
* - ingresos_devengados, egresos_devengados, utilidad_devengada (split PF vs PM)
|
||||
*/
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Compute para UN (contribuyente, anio, mes)
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Computa y hace upsert de métricas mensuales para un contribuyente en un mes.
|
||||
* Crea una fila por cada régimen detectado en los CFDIs del mes. Si no hay
|
||||
* CFDIs en el mes, no inserta nada (filas ausentes = mes sin actividad).
|
||||
*/
|
||||
export async function computeMetricaMensual(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
contribuyenteId: string,
|
||||
anio: number,
|
||||
mes: number,
|
||||
): Promise<{ filasEscritas: number }> {
|
||||
const safeContrib = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||
const fi = `${anio}-${String(mes).padStart(2, '0')}-01`;
|
||||
const lastDay = new Date(anio, mes, 0).getDate();
|
||||
const ff = `${anio}-${String(mes).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
// DELETE del cache del periodo ANTES de llamar a calcular{Ingresos,Egresos}.
|
||||
// Crítico: esas funciones hacen read-through cache, así que si encuentran
|
||||
// filas en metricas_mensuales leen valores viejos y el recompute propaga
|
||||
// datos stale. Al borrar primero, el read-through no encuentra nada y cae
|
||||
// al path on-the-fly (que es lo que queremos al recomputar).
|
||||
await pool.query(
|
||||
`DELETE FROM metricas_mensuales WHERE contribuyente_id = $1 AND anio = $2 AND mes = $3`,
|
||||
[safeContrib, anio, mes],
|
||||
);
|
||||
|
||||
// Reusa la lógica canónica de los servicios existentes. Paso `_ignorados=[]`
|
||||
// para que NO filtre régimenes ignorados por el tenant — en la tabla
|
||||
// almacenamos todos los datos; el consumer decide si filtrar ignorados.
|
||||
const [ingresos, egresos, resumenIva, ncsEmitidas, ncsRecibidas, noDeducibles] = await Promise.all([
|
||||
calcularIngresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, contribuyenteId),
|
||||
calcularEgresosPorRegimen(pool, tenantId, fi, ff, [], undefined, false, contribuyenteId),
|
||||
getResumenIva(pool, fi, ff, tenantId, false, contribuyenteId),
|
||||
calcularNcsEmitidasPorRegimen(pool, tenantId, fi, ff, [], undefined, false, contribuyenteId),
|
||||
calcularNcsRecibidasPorRegimen(pool, tenantId, fi, ff, [], undefined, false, contribuyenteId),
|
||||
calcularGastosNoDeduciblesEfectivoPorRegimen(pool, tenantId, fi, ff, [], undefined, false, contribuyenteId),
|
||||
]);
|
||||
|
||||
// Counts de CFDIs del mes (por régimen, usando FECHA_EFECTIVA del fix P)
|
||||
const { rows: countsRows } = await pool.query<{
|
||||
regimen: string | null;
|
||||
direction: 'E' | 'R';
|
||||
vigentes: string;
|
||||
cancelados: string;
|
||||
}>(`
|
||||
SELECT
|
||||
CASE WHEN type='EMITIDO' THEN regimen_fiscal_emisor ELSE regimen_fiscal_receptor END AS regimen,
|
||||
CASE WHEN type='EMITIDO' THEN 'E' ELSE 'R' END AS direction,
|
||||
COUNT(*) FILTER (WHERE status = 'Vigente') AS vigentes,
|
||||
COUNT(*) FILTER (WHERE status IN ('Cancelado','0')) AS cancelados
|
||||
FROM cfdis
|
||||
WHERE EXTRACT(YEAR FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $1
|
||||
AND EXTRACT(MONTH FROM (CASE WHEN tipo_comprobante='P' THEN fecha_pago_p ELSE fecha_emision END)) = $2
|
||||
AND contribuyente_id = $3
|
||||
GROUP BY 1, 2
|
||||
`, [anio, mes, safeContrib]);
|
||||
|
||||
// Indexa counts por régimen para lookup
|
||||
const emitidosPorReg = new Map<string | null, { vigentes: number; cancelados: number }>();
|
||||
const recibidosPorReg = new Map<string | null, { vigentes: number; cancelados: number }>();
|
||||
for (const r of countsRows) {
|
||||
const target = r.direction === 'E' ? emitidosPorReg : recibidosPorReg;
|
||||
target.set(r.regimen, {
|
||||
vigentes: Number(r.vigentes) || 0,
|
||||
cancelados: Number(r.cancelados) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Régimenes a procesar = unión de los que aparecen en ingresos, egresos,
|
||||
// NCs emitidas/recibidas o counts.
|
||||
const regimenes = new Set<string>();
|
||||
ingresos.porRegimen.forEach(r => regimenes.add(r.regimenClave));
|
||||
egresos.porRegimen.forEach(r => regimenes.add(r.regimenClave));
|
||||
ncsEmitidas.porRegimen.forEach(r => regimenes.add(r.regimenClave));
|
||||
ncsRecibidas.porRegimen.forEach(r => regimenes.add(r.regimenClave));
|
||||
noDeducibles.porRegimen.forEach(r => regimenes.add(r.regimenClave));
|
||||
emitidosPorReg.forEach((_, k) => { if (k) regimenes.add(k); });
|
||||
recibidosPorReg.forEach((_, k) => { if (k) regimenes.add(k); });
|
||||
|
||||
// (DELETE ya se hizo al inicio, ver comentario arriba.)
|
||||
if (regimenes.size === 0) {
|
||||
return { filasEscritas: 0 };
|
||||
}
|
||||
|
||||
let filasEscritas = 0;
|
||||
for (const regimen of regimenes) {
|
||||
const ing = ingresos.porRegimen.find(r => r.regimenClave === regimen)?.monto || 0;
|
||||
const egr = egresos.porRegimen.find(r => r.regimenClave === regimen)?.monto || 0;
|
||||
const ivaTras = resumenIva.trasladadoPorRegimen.find(r => r.regimenClave === regimen)?.monto || 0;
|
||||
const ivaAcr = resumenIva.acreditablePorRegimen.find(r => r.regimenClave === regimen)?.monto || 0;
|
||||
const ivaRet = resumenIva.retenidoPorRegimen.find(r => r.regimenClave === regimen)?.monto || 0;
|
||||
const ivaResultado = ivaTras - ivaAcr - ivaRet;
|
||||
const emitidos = emitidosPorReg.get(regimen) || { vigentes: 0, cancelados: 0 };
|
||||
const recibidos = recibidosPorReg.get(regimen) || { vigentes: 0, cancelados: 0 };
|
||||
const ncsEm = ncsEmitidas.porRegimen.find(r => r.regimenClave === regimen)?.monto || 0;
|
||||
const ncsRec = ncsRecibidas.porRegimen.find(r => r.regimenClave === regimen)?.monto || 0;
|
||||
const noDed = noDeducibles.porRegimen.find(r => r.regimenClave === regimen)?.monto || 0;
|
||||
|
||||
await upsertMetricaMensual(pool, contribuyenteId, anio, mes, regimen, {
|
||||
ivaTrasladadoTotal: ivaTras,
|
||||
ivaAcreditable: ivaAcr,
|
||||
ivaRetenidoCobrado: ivaRet,
|
||||
ivaResultado,
|
||||
ingresosCobrados: ing,
|
||||
egresosPagados: egr,
|
||||
utilidadRealizada: ing - egr,
|
||||
flujoEntradas: ing,
|
||||
flujoSalidas: egr,
|
||||
flujoNeto: ing - egr,
|
||||
ncsEmitidas: ncsEm,
|
||||
ncsRecibidas: ncsRec,
|
||||
gastosNoDeduciblesEfectivo: noDed,
|
||||
cfdisEmitidosCount: emitidos.vigentes,
|
||||
cfdisRecibidosCount: recibidos.vigentes,
|
||||
cfdisCanceladosCount: emitidos.cancelados + recibidos.cancelados,
|
||||
});
|
||||
filasEscritas++;
|
||||
}
|
||||
|
||||
return { filasEscritas };
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Backfill completo para un tenant
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BackfillOptions {
|
||||
/** Si es true, solo hace dry-run (log), no escribe. */
|
||||
dryRun?: boolean;
|
||||
/** Año desde el cual backfillear. Default: año del CFDI más antiguo. */
|
||||
desdeAnio?: number;
|
||||
/** Año hasta el cual (inclusive). Default: año actual - 1 (el año actual se calcula on-the-fly). */
|
||||
hastaAnio?: number;
|
||||
}
|
||||
|
||||
export interface BackfillResult {
|
||||
tenantId: string;
|
||||
contribuyentesProcesados: number;
|
||||
mesesProcesados: number;
|
||||
filasEscritas: number;
|
||||
errores: Array<{ contribuyenteId: string; anio: number; mes: number; error: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Itera contribuyentes × años × meses y llena `metricas_mensuales`. Diseñado
|
||||
* para correrse una vez (bootstrap) o ad-hoc cuando se detecten huecos.
|
||||
*/
|
||||
export async function backfillTenant(
|
||||
tenantId: string,
|
||||
opts: BackfillOptions = {},
|
||||
): Promise<BackfillResult> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { databaseName: true, rfc: true },
|
||||
});
|
||||
if (!tenant) throw new Error(`Tenant ${tenantId} no encontrado`);
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
|
||||
const { rows: contribs } = await pool.query<{ entidad_id: string }>(
|
||||
`SELECT entidad_id FROM contribuyentes`,
|
||||
);
|
||||
if (contribs.length === 0) {
|
||||
return {
|
||||
tenantId,
|
||||
contribuyentesProcesados: 0,
|
||||
mesesProcesados: 0,
|
||||
filasEscritas: 0,
|
||||
errores: [],
|
||||
};
|
||||
}
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const hastaAnio = opts.hastaAnio ?? currentYear - 1;
|
||||
|
||||
// Para cada contribuyente, determinar rango de años desde el CFDI más antiguo
|
||||
const result: BackfillResult = {
|
||||
tenantId,
|
||||
contribuyentesProcesados: 0,
|
||||
mesesProcesados: 0,
|
||||
filasEscritas: 0,
|
||||
errores: [],
|
||||
};
|
||||
|
||||
for (const c of contribs) {
|
||||
const { rows: [rango] } = await pool.query<{ min_anio: number | null }>(
|
||||
`SELECT EXTRACT(YEAR FROM MIN(fecha_emision))::int AS min_anio
|
||||
FROM cfdis WHERE contribuyente_id = $1`,
|
||||
[c.entidad_id],
|
||||
);
|
||||
if (!rango?.min_anio) continue; // sin CFDIs, skip
|
||||
|
||||
const desdeAnio = opts.desdeAnio ?? rango.min_anio;
|
||||
result.contribuyentesProcesados++;
|
||||
|
||||
for (let anio = desdeAnio; anio <= hastaAnio; anio++) {
|
||||
for (let mes = 1; mes <= 12; mes++) {
|
||||
result.mesesProcesados++;
|
||||
if (opts.dryRun) continue;
|
||||
try {
|
||||
const { filasEscritas } = await computeMetricaMensual(pool, tenantId, c.entidad_id, anio, mes);
|
||||
result.filasEscritas += filasEscritas;
|
||||
} catch (err: any) {
|
||||
result.errores.push({
|
||||
contribuyenteId: c.entidad_id,
|
||||
anio,
|
||||
mes,
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Procesamiento de invalidaciones (cron)
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProcessResult {
|
||||
procesadas: number;
|
||||
filasEscritas: number;
|
||||
errores: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee `metricas_invalidaciones`, recomputa cada (contribuyente, anio, mes)
|
||||
* marcado, y limpia la invalidación al terminar. Fail-safe: si una entrada
|
||||
* falla, loguea y continúa con la siguiente.
|
||||
*/
|
||||
export async function processInvalidations(pool: Pool, tenantId: string): Promise<ProcessResult> {
|
||||
const pending = await getPendingInvalidations(pool);
|
||||
if (pending.length === 0) return { procesadas: 0, filasEscritas: 0, errores: 0 };
|
||||
|
||||
let procesadas = 0;
|
||||
let filasEscritas = 0;
|
||||
let errores = 0;
|
||||
|
||||
for (const inv of pending) {
|
||||
try {
|
||||
const { filasEscritas: fe } = await computeMetricaMensual(
|
||||
pool, tenantId, inv.contribuyenteId, inv.anio, inv.mes,
|
||||
);
|
||||
filasEscritas += fe;
|
||||
await clearInvalidation(pool, inv.contribuyenteId, inv.anio, inv.mes);
|
||||
procesadas++;
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`[Metricas] Error computando (tenant=${tenantId}, contrib=${inv.contribuyenteId}, ${inv.anio}-${String(inv.mes).padStart(2, '0')}):`,
|
||||
err?.message || err,
|
||||
);
|
||||
errores++;
|
||||
}
|
||||
}
|
||||
|
||||
return { procesadas, filasEscritas, errores };
|
||||
}
|
||||
|
||||
/**
|
||||
* Itera todos los tenants activos y procesa sus invalidaciones pendientes.
|
||||
* Usado por el cron job.
|
||||
*/
|
||||
export async function processAllTenantsInvalidations(): Promise<{
|
||||
tenantsRevisados: number;
|
||||
totalProcesadas: number;
|
||||
totalFilasEscritas: number;
|
||||
totalErrores: number;
|
||||
}> {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, databaseName: true },
|
||||
});
|
||||
|
||||
let totalProcesadas = 0;
|
||||
let totalFilasEscritas = 0;
|
||||
let totalErrores = 0;
|
||||
|
||||
for (const t of tenants) {
|
||||
try {
|
||||
const pool = await tenantDb.getPool(t.id, t.databaseName);
|
||||
const r = await processInvalidations(pool, t.id);
|
||||
totalProcesadas += r.procesadas;
|
||||
totalFilasEscritas += r.filasEscritas;
|
||||
totalErrores += r.errores;
|
||||
} catch (err: any) {
|
||||
console.error(`[Metricas] Error procesando tenant ${t.id}:`, err?.message || err);
|
||||
totalErrores++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tenantsRevisados: tenants.length,
|
||||
totalProcesadas,
|
||||
totalFilasEscritas,
|
||||
totalErrores,
|
||||
};
|
||||
}
|
||||
225
apps/api/src/services/metricas.service.ts
Normal file
225
apps/api/src/services/metricas.service.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export interface MetricaMensual {
|
||||
anio: number;
|
||||
mes: number;
|
||||
regimenFiscal: string | null;
|
||||
ivaTrasladado16: number;
|
||||
ivaTrasladado8: number;
|
||||
ivaTrasladado0: number;
|
||||
ivaTrasladadoExento: number;
|
||||
ivaTrasladadoTotal: number;
|
||||
ivaAcreditable: number;
|
||||
ivaRetenidoCobrado: number;
|
||||
ivaRetenidoPagado: number;
|
||||
ivaResultado: number;
|
||||
ivaAFavorMes: number;
|
||||
isrIngresosBrutos: number;
|
||||
isrDeduccionesAutoriz: number;
|
||||
isrBase: number;
|
||||
isrCausado: number;
|
||||
isrRetenido: number;
|
||||
isrAPagar: number;
|
||||
cfdisEmitidosCount: number;
|
||||
cfdisRecibidosCount: number;
|
||||
cfdisCanceladosCount: number;
|
||||
ingresosDevengados: number;
|
||||
ingresosCobrados: number;
|
||||
egresosDevengados: number;
|
||||
egresosPagados: number;
|
||||
utilidadDevengada: number;
|
||||
utilidadRealizada: number;
|
||||
flujoEntradas: number;
|
||||
flujoSalidas: number;
|
||||
flujoNeto: number;
|
||||
cxcSaldoFinal: number;
|
||||
cxpSaldoFinal: number;
|
||||
// Surface-only — totales de notas de crédito tipo E PUE en el mes/régimen.
|
||||
// No participan en cálculos de ingresos/deducciones (ya no se restan); se
|
||||
// persisten para visibilidad y BI directo sin recomputar.
|
||||
ncsEmitidas: number;
|
||||
ncsRecibidas: number;
|
||||
// Art. 27 fracción III LISR — facturas recibidas pagadas en efectivo > $2,000.
|
||||
// Surface-only, no afecta deducciones (que ya las EXCLUYE).
|
||||
gastosNoDeduciblesEfectivo: number;
|
||||
cerrado: boolean;
|
||||
computedAt: string;
|
||||
}
|
||||
|
||||
export async function getMetricasMensuales(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
anio: number,
|
||||
regimenFiscal?: string
|
||||
): Promise<MetricaMensual[]> {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
if (anio < currentYear) {
|
||||
// COLD: read pre-computed
|
||||
const params: unknown[] = [contribuyenteId, anio];
|
||||
let query = `
|
||||
SELECT
|
||||
anio, mes,
|
||||
regimen_fiscal AS "regimenFiscal",
|
||||
iva_trasladado_16 AS "ivaTrasladado16",
|
||||
iva_trasladado_8 AS "ivaTrasladado8",
|
||||
iva_trasladado_0 AS "ivaTrasladado0",
|
||||
iva_trasladado_exento AS "ivaTrasladadoExento",
|
||||
iva_trasladado_total AS "ivaTrasladadoTotal",
|
||||
iva_acreditable AS "ivaAcreditable",
|
||||
iva_retenido_cobrado AS "ivaRetenidoCobrado",
|
||||
iva_retenido_pagado AS "ivaRetenidoPagado",
|
||||
iva_resultado AS "ivaResultado",
|
||||
iva_a_favor_mes AS "ivaAFavorMes",
|
||||
isr_ingresos_brutos AS "isrIngresosBrutos",
|
||||
isr_deducciones_autoriz AS "isrDeduccionesAutoriz",
|
||||
isr_base AS "isrBase",
|
||||
isr_causado AS "isrCausado",
|
||||
isr_retenido AS "isrRetenido",
|
||||
isr_a_pagar AS "isrAPagar",
|
||||
cfdis_emitidos_count AS "cfdisEmitidosCount",
|
||||
cfdis_recibidos_count AS "cfdisRecibidosCount",
|
||||
cfdis_cancelados_count AS "cfdisCanceladosCount",
|
||||
ingresos_devengados AS "ingresosDevengados",
|
||||
ingresos_cobrados AS "ingresosCobrados",
|
||||
egresos_devengados AS "egresosDevengados",
|
||||
egresos_pagados AS "egresosPagados",
|
||||
utilidad_devengada AS "utilidadDevengada",
|
||||
utilidad_realizada AS "utilidadRealizada",
|
||||
flujo_entradas AS "flujoEntradas",
|
||||
flujo_salidas AS "flujoSalidas",
|
||||
flujo_neto AS "flujoNeto",
|
||||
cxc_saldo_final AS "cxcSaldoFinal",
|
||||
cxp_saldo_final AS "cxpSaldoFinal",
|
||||
ncs_emitidas AS "ncsEmitidas",
|
||||
ncs_recibidas AS "ncsRecibidas",
|
||||
gastos_no_deducibles_efectivo AS "gastosNoDeduciblesEfectivo",
|
||||
cerrado,
|
||||
computed_at AS "computedAt"
|
||||
FROM metricas_mensuales
|
||||
WHERE contribuyente_id = $1 AND anio = $2
|
||||
`;
|
||||
if (regimenFiscal) {
|
||||
query += ' AND regimen_fiscal = $3';
|
||||
params.push(regimenFiscal);
|
||||
}
|
||||
query += ' ORDER BY mes';
|
||||
|
||||
const { rows } = await pool.query(query, params);
|
||||
return rows.map(r => ({ ...r, cerrado: r.cerrado ?? false, computedAt: r.computedAt?.toISOString?.() ?? '' }));
|
||||
}
|
||||
|
||||
// HOT: current year — return empty (caller should use dashboard/impuestos services for on-the-fly computation)
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function markForInvalidation(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
anio: number,
|
||||
mes: number,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
await pool.query(`
|
||||
INSERT INTO metricas_invalidaciones (contribuyente_id, anio, mes, reason)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (contribuyente_id, anio, mes) DO UPDATE SET marcado_at = now(), reason = $4
|
||||
`, [contribuyenteId, anio, mes, reason]);
|
||||
}
|
||||
|
||||
export async function getPendingInvalidations(pool: Pool): Promise<Array<{
|
||||
contribuyenteId: string;
|
||||
anio: number;
|
||||
mes: number;
|
||||
reason: string;
|
||||
}>> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT contribuyente_id AS "contribuyenteId", anio, mes, reason
|
||||
FROM metricas_invalidaciones
|
||||
ORDER BY anio, mes
|
||||
`);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function clearInvalidation(pool: Pool, contribuyenteId: string, anio: number, mes: number): Promise<void> {
|
||||
await pool.query(
|
||||
'DELETE FROM metricas_invalidaciones WHERE contribuyente_id = $1 AND anio = $2 AND mes = $3',
|
||||
[contribuyenteId, anio, mes]
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertMetricaMensual(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
anio: number,
|
||||
mes: number,
|
||||
regimenFiscal: string | null,
|
||||
data: Partial<MetricaMensual>
|
||||
): Promise<void> {
|
||||
await pool.query(`
|
||||
INSERT INTO metricas_mensuales (
|
||||
contribuyente_id, anio, mes, regimen_fiscal,
|
||||
iva_trasladado_total, iva_acreditable, iva_retenido_cobrado, iva_retenido_pagado, iva_resultado,
|
||||
isr_ingresos_brutos, isr_deducciones_autoriz, isr_base, isr_causado, isr_retenido, isr_a_pagar,
|
||||
cfdis_emitidos_count, cfdis_recibidos_count, cfdis_cancelados_count,
|
||||
ingresos_devengados, ingresos_cobrados, egresos_devengados, egresos_pagados,
|
||||
utilidad_devengada, utilidad_realizada,
|
||||
flujo_entradas, flujo_salidas, flujo_neto,
|
||||
cxc_saldo_final, cxp_saldo_final,
|
||||
ncs_emitidas, ncs_recibidas,
|
||||
gastos_no_deducibles_efectivo,
|
||||
computed_at, source_max_cfdi_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7, $8, $9,
|
||||
$10, $11, $12, $13, $14, $15,
|
||||
$16, $17, $18,
|
||||
$19, $20, $21, $22,
|
||||
$23, $24,
|
||||
$25, $26, $27,
|
||||
$28, $29,
|
||||
$30, $31,
|
||||
$32,
|
||||
now(), now()
|
||||
)
|
||||
ON CONFLICT (contribuyente_id, anio, mes, regimen_fiscal)
|
||||
DO UPDATE SET
|
||||
iva_trasladado_total = $5, iva_acreditable = $6, iva_retenido_cobrado = $7, iva_retenido_pagado = $8, iva_resultado = $9,
|
||||
isr_ingresos_brutos = $10, isr_deducciones_autoriz = $11, isr_base = $12,
|
||||
isr_causado = $13, isr_retenido = $14, isr_a_pagar = $15,
|
||||
cfdis_emitidos_count = $16, cfdis_recibidos_count = $17, cfdis_cancelados_count = $18,
|
||||
ingresos_devengados = $19, ingresos_cobrados = $20, egresos_devengados = $21, egresos_pagados = $22,
|
||||
utilidad_devengada = $23, utilidad_realizada = $24,
|
||||
flujo_entradas = $25, flujo_salidas = $26, flujo_neto = $27,
|
||||
cxc_saldo_final = $28, cxp_saldo_final = $29,
|
||||
ncs_emitidas = $30, ncs_recibidas = $31,
|
||||
gastos_no_deducibles_efectivo = $32,
|
||||
computed_at = now(), source_max_cfdi_at = now()
|
||||
`, [
|
||||
contribuyenteId, anio, mes, regimenFiscal,
|
||||
data.ivaTrasladadoTotal ?? 0, data.ivaAcreditable ?? 0, data.ivaRetenidoCobrado ?? 0, data.ivaRetenidoPagado ?? 0, data.ivaResultado ?? 0,
|
||||
data.isrIngresosBrutos ?? 0, data.isrDeduccionesAutoriz ?? 0, data.isrBase ?? 0,
|
||||
data.isrCausado ?? 0, data.isrRetenido ?? 0, data.isrAPagar ?? 0,
|
||||
data.cfdisEmitidosCount ?? 0, data.cfdisRecibidosCount ?? 0, data.cfdisCanceladosCount ?? 0,
|
||||
data.ingresosDevengados ?? 0, data.ingresosCobrados ?? 0, data.egresosDevengados ?? 0, data.egresosPagados ?? 0,
|
||||
data.utilidadDevengada ?? 0, data.utilidadRealizada ?? 0,
|
||||
data.flujoEntradas ?? 0, data.flujoSalidas ?? 0, data.flujoNeto ?? 0,
|
||||
data.cxcSaldoFinal ?? 0, data.cxpSaldoFinal ?? 0,
|
||||
data.ncsEmitidas ?? 0, data.ncsRecibidas ?? 0,
|
||||
data.gastosNoDeduciblesEfectivo ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
export async function closeMonth(pool: Pool, anio: number, mes: number): Promise<void> {
|
||||
await pool.query(
|
||||
'UPDATE metricas_mensuales SET cerrado = true WHERE anio = $1 AND mes = $2',
|
||||
[anio, mes]
|
||||
);
|
||||
}
|
||||
|
||||
export async function closeYear(pool: Pool, anio: number): Promise<void> {
|
||||
await pool.query(
|
||||
'UPDATE metricas_mensuales SET cerrado = true WHERE anio = $1',
|
||||
[anio]
|
||||
);
|
||||
}
|
||||
110
apps/api/src/services/notification-preferences.service.ts
Normal file
110
apps/api/src/services/notification-preferences.service.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
/**
|
||||
* Tipos de correos informativos cuyo envío puede desactivarse por
|
||||
* contribuyente. NO incluye correos transaccionales críticos
|
||||
* (welcome, password-reset, payment-*) — esos siempre se envían.
|
||||
*
|
||||
* Estado de implementación:
|
||||
* - documento_subido: ✅ implementado (notify-upload.service.ts)
|
||||
* - weekly_update: ⏳ pendiente (job es tenant-wide hoy)
|
||||
* - subscription_expiring: ⏳ pendiente (no es per-contribuyente hoy)
|
||||
* - recordatorio_fiscal: ⏳ placeholder para futuras alertas
|
||||
*/
|
||||
export const EMAIL_TYPES = [
|
||||
'documento_subido',
|
||||
'weekly_update',
|
||||
'subscription_expiring',
|
||||
'recordatorio_fiscal',
|
||||
] as const;
|
||||
|
||||
export type EmailType = (typeof EMAIL_TYPES)[number];
|
||||
|
||||
export type EmailPreferences = Record<EmailType, boolean>;
|
||||
|
||||
/**
|
||||
* Default: todo activado. Si el JSONB en BD viene vacío o falta una
|
||||
* key, asumimos `true` para preservar el comportamiento previo.
|
||||
*/
|
||||
function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences {
|
||||
const out = {} as EmailPreferences;
|
||||
for (const t of EMAIL_TYPES) {
|
||||
out[t] = raw[t] === false ? false : true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sanitizeUuid(id: string): string {
|
||||
return id.replace(/[^a-f0-9-]/gi, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee las preferencias de un contribuyente. Devuelve defaults (todo
|
||||
* activado) si no hay fila o la columna está vacía.
|
||||
*/
|
||||
export async function getContribuyenteEmailPreferences(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
): Promise<EmailPreferences> {
|
||||
const safeId = sanitizeUuid(contribuyenteId);
|
||||
const { rows } = await pool.query<{ email_preferences: Record<string, unknown> | null }>(
|
||||
`SELECT email_preferences FROM contribuyentes WHERE entidad_id = $1`,
|
||||
[safeId],
|
||||
);
|
||||
const raw = rows[0]?.email_preferences ?? {};
|
||||
return applyDefaults(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza las preferencias de un contribuyente. Solo persiste las
|
||||
* keys conocidas (filtra extras maliciosos). Merge sobre la columna
|
||||
* existente (no sobreescribe keys no enviadas).
|
||||
*/
|
||||
export async function setContribuyenteEmailPreferences(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
partial: Partial<EmailPreferences>,
|
||||
): Promise<EmailPreferences> {
|
||||
const safeId = sanitizeUuid(contribuyenteId);
|
||||
const merged: Record<string, boolean> = {};
|
||||
for (const t of EMAIL_TYPES) {
|
||||
if (t in partial) merged[t] = partial[t] === true;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE contribuyentes
|
||||
SET email_preferences = COALESCE(email_preferences, '{}'::jsonb) || $2::jsonb
|
||||
WHERE entidad_id = $1`,
|
||||
[safeId, JSON.stringify(merged)],
|
||||
);
|
||||
|
||||
return getContribuyenteEmailPreferences(pool, contribuyenteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee preferencias para múltiples contribuyentes en una sola query.
|
||||
* Útil para la UI de `/configuracion/notificaciones` que lista todos.
|
||||
*/
|
||||
export async function getEmailPreferencesPorContribuyente(
|
||||
pool: Pool,
|
||||
): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> {
|
||||
const { rows } = await pool.query<{
|
||||
entidad_id: string;
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
email_preferences: Record<string, unknown> | null;
|
||||
}>(
|
||||
`SELECT c.entidad_id, c.rfc, e.nombre, c.email_preferences
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas e ON e.id = c.entidad_id
|
||||
WHERE e.active = true
|
||||
ORDER BY e.nombre`,
|
||||
);
|
||||
|
||||
return rows.map(r => ({
|
||||
contribuyenteId: r.entidad_id,
|
||||
rfc: r.rfc,
|
||||
nombre: r.nombre,
|
||||
preferences: applyDefaults(r.email_preferences ?? {}),
|
||||
}));
|
||||
}
|
||||
390
apps/api/src/services/notifications.service.ts
Normal file
390
apps/api/src/services/notifications.service.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Notificaciones email automáticas (Option B — por evento).
|
||||
*
|
||||
* Cron diario 8:30 AM (`notifications.job.ts`) llama a las dos funciones
|
||||
* principales de este servicio para cada tenant activo:
|
||||
*
|
||||
* - `processNewAlertas(pool, tenantId)`: detecta alertas que aparecen por
|
||||
* primera vez (no están en `alertas_notificadas`) y manda un email
|
||||
* batched al supervisor + auxiliares + clientes del contribuyente.
|
||||
* Las alertas que dejaron de estar activas se marcan `resuelta_at`.
|
||||
*
|
||||
* - `processProximosRecordatorios(pool, tenantId)`: detecta recordatorios
|
||||
* cuya `fecha_limite` cae en las ventanas 3 días / 1 día / mismo día
|
||||
* y manda email a los responsables (cliente + auxiliar; si no hay
|
||||
* auxiliar también supervisor; si owner es supervisor sin auxiliares
|
||||
* también owner). Cada ventana se envía una sola vez (columnas
|
||||
* `email_3d_at`, `email_1d_at`, `email_0d_at`).
|
||||
*
|
||||
* Decisión MVP: una alerta solo se notifica una vez. Si vuelve a activarse
|
||||
* después de resolverse, no re-notifica (sería opt-in al borrar la fila
|
||||
* cuando `resuelta_at` se setea).
|
||||
*/
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { generarAlertasAutomaticas, type AlertaAuto } from './alertas-auto.service.js';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import type { AlertaItem } from './email/templates/alertas-nuevas.js';
|
||||
import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js';
|
||||
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Resolución de destinatarios
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface UserContact {
|
||||
userId: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve user IDs ligados a un contribuyente (supervisor + auxiliares de
|
||||
* carteras donde aparece + clientes con acceso). Retorna lista deduplicada.
|
||||
*/
|
||||
async function getUserIdsContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
): Promise<{ supervisor: string | null; auxiliares: string[]; clientes: string[] }> {
|
||||
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||
const { rows } = await pool.query<{
|
||||
supervisor_user_id: string | null;
|
||||
auxiliar_user_ids: string[];
|
||||
cliente_user_ids: string[];
|
||||
}>(`
|
||||
SELECT
|
||||
eg.supervisor_user_id,
|
||||
COALESCE((
|
||||
SELECT array_agg(DISTINCT c.auxiliar_user_id) FILTER (WHERE c.auxiliar_user_id IS NOT NULL)
|
||||
FROM cartera_entidades ce
|
||||
JOIN carteras c ON c.id = ce.cartera_id
|
||||
WHERE ce.entidad_id = eg.id
|
||||
), ARRAY[]::uuid[]) AS auxiliar_user_ids,
|
||||
COALESCE((
|
||||
SELECT array_agg(DISTINCT user_id) FROM cliente_accesos WHERE entidad_id = eg.id
|
||||
), ARRAY[]::uuid[]) AS cliente_user_ids
|
||||
FROM entidades_gestionadas eg
|
||||
WHERE eg.id = $1::uuid
|
||||
`, [safeId]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return { supervisor: null, auxiliares: [], clientes: [] };
|
||||
}
|
||||
const r = rows[0];
|
||||
return {
|
||||
supervisor: r.supervisor_user_id ?? null,
|
||||
auxiliares: r.auxiliar_user_ids ?? [],
|
||||
clientes: r.cliente_user_ids ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Owners activos del tenant (BD central). */
|
||||
async function getOwnerUserIds(tenantId: string): Promise<string[]> {
|
||||
const owners = await prisma.tenantMembership.findMany({
|
||||
where: { tenantId, isOwner: true, active: true },
|
||||
select: { userId: true },
|
||||
});
|
||||
return owners.map(o => o.userId);
|
||||
}
|
||||
|
||||
/** Resuelve emails para una lista de userIds; filtra inactivos. */
|
||||
async function getUserContacts(userIds: string[]): Promise<UserContact[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
const users = await prisma.user.findMany({
|
||||
where: { id: { in: userIds }, active: true },
|
||||
select: { id: true, email: true, active: true },
|
||||
});
|
||||
return users.map(u => ({ userId: u.id, email: u.email, active: u.active }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destinatarios de una alerta: supervisor + auxiliares + clientes del
|
||||
* contribuyente. Si el owner del tenant es supervisor, ya queda incluido
|
||||
* (no se duplica).
|
||||
*/
|
||||
async function recipientsForAlerta(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
contribuyenteId: string,
|
||||
): Promise<string[]> {
|
||||
const ids = await getUserIdsContribuyente(pool, contribuyenteId);
|
||||
const userIds = new Set<string>();
|
||||
if (ids.supervisor) userIds.add(ids.supervisor);
|
||||
ids.auxiliares.forEach(id => userIds.add(id));
|
||||
ids.clientes.forEach(id => userIds.add(id));
|
||||
const contacts = await getUserContacts([...userIds]);
|
||||
return [...new Set(contacts.map(c => c.email))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Destinatarios de un recordatorio. Los recordatorios del despacho son
|
||||
* tenant-level (no atados a contribuyente). Para públicos: clientes con
|
||||
* algún acceso + auxiliares de cualquier cartera; si no hay auxiliares,
|
||||
* supervisores; si owner aparece como supervisor, también recibe.
|
||||
*
|
||||
* Privados: solo el creador.
|
||||
*/
|
||||
async function recipientsForRecordatorio(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
recordatorio: { creadoPor: string; privado: boolean },
|
||||
): Promise<string[]> {
|
||||
if (recordatorio.privado) {
|
||||
const contacts = await getUserContacts([recordatorio.creadoPor]);
|
||||
return [...new Set(contacts.map(c => c.email))];
|
||||
}
|
||||
|
||||
// Recordatorio público: lee universos relevantes del tenant.
|
||||
const { rows: [r] } = await pool.query<{
|
||||
auxiliar_user_ids: string[];
|
||||
supervisor_user_ids: string[];
|
||||
cliente_user_ids: string[];
|
||||
}>(`
|
||||
SELECT
|
||||
COALESCE((
|
||||
SELECT array_agg(DISTINCT auxiliar_user_id)
|
||||
FROM carteras WHERE auxiliar_user_id IS NOT NULL
|
||||
), ARRAY[]::uuid[]) AS auxiliar_user_ids,
|
||||
COALESCE((
|
||||
SELECT array_agg(DISTINCT supervisor_user_id) FROM (
|
||||
SELECT supervisor_user_id FROM entidades_gestionadas WHERE supervisor_user_id IS NOT NULL
|
||||
UNION
|
||||
SELECT supervisor_user_id FROM carteras WHERE supervisor_user_id IS NOT NULL
|
||||
) sup
|
||||
), ARRAY[]::uuid[]) AS supervisor_user_ids,
|
||||
COALESCE((
|
||||
SELECT array_agg(DISTINCT user_id) FROM cliente_accesos
|
||||
), ARRAY[]::uuid[]) AS cliente_user_ids
|
||||
`);
|
||||
|
||||
const auxiliares = r?.auxiliar_user_ids ?? [];
|
||||
const supervisores = r?.supervisor_user_ids ?? [];
|
||||
const clientes = r?.cliente_user_ids ?? [];
|
||||
const owners = await getOwnerUserIds(tenantId);
|
||||
|
||||
// Regla del owner: clientes y auxiliares siempre. Si no hay auxiliares,
|
||||
// agregar supervisores. Si owner es supervisor y no hay auxiliares,
|
||||
// owner queda incluido vía la lista de supervisores.
|
||||
const userIds = new Set<string>();
|
||||
clientes.forEach(id => userIds.add(id));
|
||||
auxiliares.forEach(id => userIds.add(id));
|
||||
if (auxiliares.length === 0) {
|
||||
supervisores.forEach(id => userIds.add(id));
|
||||
// Solo si owner aparece como supervisor (intersección):
|
||||
for (const ownerId of owners) {
|
||||
if (supervisores.includes(ownerId)) userIds.add(ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
const contacts = await getUserContacts([...userIds]);
|
||||
return [...new Set(contacts.map(c => c.email))];
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Procesamiento de alertas
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ContribuyenteInfo {
|
||||
entidadId: string;
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
}
|
||||
|
||||
/** Lista contribuyentes activos del tenant. */
|
||||
async function listContribuyentes(pool: Pool): Promise<ContribuyenteInfo[]> {
|
||||
const { rows } = await pool.query<{ entidad_id: string; rfc: string; nombre: string }>(`
|
||||
SELECT eg.id AS entidad_id, c.rfc, eg.nombre
|
||||
FROM entidades_gestionadas eg
|
||||
JOIN contribuyentes c ON c.entidad_id = eg.id
|
||||
WHERE eg.active = true AND eg.tipo = 'CONTRIBUYENTE'
|
||||
`);
|
||||
return rows.map(r => ({ entidadId: r.entidad_id, rfc: r.rfc, nombre: r.nombre }));
|
||||
}
|
||||
|
||||
function mapAlertaToItem(a: AlertaAuto): AlertaItem {
|
||||
return {
|
||||
alertaId: a.id,
|
||||
nivel: a.prioridad === 'alta' ? 'high' : a.prioridad === 'media' ? 'medium' : 'low',
|
||||
titulo: a.titulo,
|
||||
mensaje: a.mensaje,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Para un (tenant, contribuyente):
|
||||
* 1. Genera alertas activas vía `generarAlertasAutomaticas`.
|
||||
* 2. Inserta filas nuevas en `alertas_notificadas` (ON CONFLICT DO NOTHING).
|
||||
* 3. Marca como resueltas las alertas previamente notificadas que NO están
|
||||
* activas hoy (UPDATE resuelta_at).
|
||||
* 4. Si hay alertas nuevas, envía email batched a los responsables.
|
||||
*/
|
||||
async function processAlertasContribuyente(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
tenant: { rfc: string; nombre: string },
|
||||
contribuyente: ContribuyenteInfo,
|
||||
): Promise<{ nuevas: number; resueltas: number }> {
|
||||
const alertasActivas = await generarAlertasAutomaticas(pool, tenantId, contribuyente.entidadId);
|
||||
const activosIds = alertasActivas.map(a => a.id);
|
||||
|
||||
// Re-notificación tras 30 días (D7, 2026-04-26): borra registros de
|
||||
// alertas que estuvieron resueltas más de 30 días. Si la alerta vuelve
|
||||
// a aparecer ahora, el INSERT siguiente la detecta como "nueva" y
|
||||
// vuelve a notificar. Si nunca se resolvió (resuelta_at IS NULL) o se
|
||||
// resolvió hace menos de 30 días, la fila se conserva y el INSERT no
|
||||
// dispara email.
|
||||
await pool.query(`
|
||||
DELETE FROM alertas_notificadas
|
||||
WHERE contribuyente_id = $1::uuid
|
||||
AND resuelta_at IS NOT NULL
|
||||
AND resuelta_at < NOW() - INTERVAL '30 days'
|
||||
`, [contribuyente.entidadId]);
|
||||
|
||||
// Detecta alertas nuevas: INSERT con ON CONFLICT DO NOTHING. RETURNING id
|
||||
// solo trae las filas insertadas (no las que chocaron con el UNIQUE),
|
||||
// así sabemos cuáles eran realmente nuevas. Tras la re-notificación de
|
||||
// 30 días, una alerta puede volver a notificarse si reapareció después
|
||||
// de >30 días resuelta.
|
||||
const nuevas: AlertaAuto[] = [];
|
||||
for (const a of alertasActivas) {
|
||||
const { rows } = await pool.query<{ id: number }>(`
|
||||
INSERT INTO alertas_notificadas (alerta_id, contribuyente_id)
|
||||
VALUES ($1, $2::uuid)
|
||||
ON CONFLICT (alerta_id, COALESCE(contribuyente_id::text, '')) DO NOTHING
|
||||
RETURNING id
|
||||
`, [a.id, contribuyente.entidadId]);
|
||||
if (rows.length > 0) nuevas.push(a);
|
||||
}
|
||||
|
||||
// Marca como resueltas las alertas previamente notificadas que ya no
|
||||
// aparecen activas hoy. Informativo (no genera email).
|
||||
let resueltas = 0;
|
||||
const updateQuery = activosIds.length > 0
|
||||
? `UPDATE alertas_notificadas SET resuelta_at = NOW()
|
||||
WHERE contribuyente_id = $1::uuid AND resuelta_at IS NULL
|
||||
AND alerta_id <> ALL($2::text[])`
|
||||
: `UPDATE alertas_notificadas SET resuelta_at = NOW()
|
||||
WHERE contribuyente_id = $1::uuid AND resuelta_at IS NULL`;
|
||||
const params: any[] = activosIds.length > 0
|
||||
? [contribuyente.entidadId, activosIds]
|
||||
: [contribuyente.entidadId];
|
||||
const { rowCount } = await pool.query(updateQuery, params);
|
||||
resueltas = rowCount ?? 0;
|
||||
|
||||
if (nuevas.length === 0) {
|
||||
return { nuevas: 0, resueltas };
|
||||
}
|
||||
|
||||
// Envía email batched a los responsables del contribuyente.
|
||||
const recipients = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId);
|
||||
if (recipients.length === 0) {
|
||||
console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`);
|
||||
return { nuevas: nuevas.length, resueltas };
|
||||
}
|
||||
|
||||
await emailService.sendAlertasNuevas(recipients, {
|
||||
contribuyenteRfc: contribuyente.rfc,
|
||||
contribuyenteNombre: contribuyente.nombre,
|
||||
despachoNombre: tenant.nombre,
|
||||
alertas: nuevas.map(mapAlertaToItem),
|
||||
link: `${FRONTEND_URL}/alertas`,
|
||||
});
|
||||
|
||||
return { nuevas: nuevas.length, resueltas };
|
||||
}
|
||||
|
||||
/** Procesa todas las alertas del tenant — itera contribuyentes activos. */
|
||||
export async function processNewAlertas(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
tenant: { rfc: string; nombre: string },
|
||||
): Promise<{ contribuyentes: number; nuevasTotal: number }> {
|
||||
const contribuyentes = await listContribuyentes(pool);
|
||||
let nuevasTotal = 0;
|
||||
for (const c of contribuyentes) {
|
||||
try {
|
||||
const { nuevas } = await processAlertasContribuyente(pool, tenantId, tenant, c);
|
||||
nuevasTotal += nuevas;
|
||||
} catch (err: any) {
|
||||
console.error(`[Notifications] Error procesando alertas de ${c.rfc} (tenant ${tenant.rfc}):`, err.message || err);
|
||||
}
|
||||
}
|
||||
return { contribuyentes: contribuyentes.length, nuevasTotal };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Procesamiento de recordatorios próximos
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface RecordatorioRow {
|
||||
id: number;
|
||||
titulo: string;
|
||||
descripcion: string | null;
|
||||
notas: string | null;
|
||||
fecha_limite: string;
|
||||
privado: boolean;
|
||||
creado_por: string;
|
||||
email_3d_at: Date | null;
|
||||
email_1d_at: Date | null;
|
||||
email_0d_at: Date | null;
|
||||
}
|
||||
|
||||
const VENTANA_DIAS: Record<VentanaRecordatorio, number> = {
|
||||
'3d': 3,
|
||||
'1d': 1,
|
||||
'0d': 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Procesa recordatorios cuya `fecha_limite` cae en alguna ventana (3d/1d/0d)
|
||||
* y que aún no tienen email enviado para esa ventana específica. Manda email
|
||||
* y marca la columna correspondiente.
|
||||
*/
|
||||
export async function processProximosRecordatorios(
|
||||
pool: Pool,
|
||||
tenantId: string,
|
||||
tenant: { rfc: string; nombre: string },
|
||||
): Promise<{ enviados: number }> {
|
||||
let enviados = 0;
|
||||
for (const ventana of (['3d', '1d', '0d'] as const)) {
|
||||
const dias = VENTANA_DIAS[ventana];
|
||||
const col = `email_${ventana}_at`;
|
||||
const { rows } = await pool.query<RecordatorioRow>(`
|
||||
SELECT id, titulo, descripcion, notas, fecha_limite::text AS fecha_limite,
|
||||
privado, creado_por, email_3d_at, email_1d_at, email_0d_at
|
||||
FROM recordatorios
|
||||
WHERE completado = false
|
||||
AND fecha_limite = (CURRENT_DATE + ${dias})::date
|
||||
AND ${col} IS NULL
|
||||
`);
|
||||
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const recipients = await recipientsForRecordatorio(pool, tenantId, {
|
||||
creadoPor: r.creado_por,
|
||||
privado: r.privado,
|
||||
});
|
||||
if (recipients.length === 0) {
|
||||
console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`);
|
||||
continue;
|
||||
}
|
||||
await emailService.sendRecordatorioProximo(recipients, {
|
||||
titulo: r.titulo,
|
||||
descripcion: r.descripcion,
|
||||
notas: r.notas,
|
||||
fechaLimite: r.fecha_limite,
|
||||
ventana,
|
||||
despachoNombre: tenant.nombre,
|
||||
link: `${FRONTEND_URL}/calendario`,
|
||||
});
|
||||
// Marca columna de ventana enviada.
|
||||
await pool.query(`UPDATE recordatorios SET ${col} = NOW() WHERE id = $1`, [r.id]);
|
||||
enviados++;
|
||||
} catch (err: any) {
|
||||
console.error(`[Notifications] Error en recordatorio ${r.id} (${tenant.rfc}, ${ventana}):`, err.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { enviados };
|
||||
}
|
||||
90
apps/api/src/services/notify-upload.service.ts
Normal file
90
apps/api/src/services/notify-upload.service.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { getContribuyenteEmailPreferences } from './notification-preferences.service.js';
|
||||
import type { DocumentoSubidoData } from './email/templates/documento-subido.js';
|
||||
|
||||
/**
|
||||
* Notifica a los destinatarios relevantes cuando se sube una declaración
|
||||
* o un documento extra. Destinatarios:
|
||||
* - Owners activos del despacho (getTenantOwnerEmails)
|
||||
* - Supervisor del contribuyente (entidades_gestionadas.supervisor_user_id),
|
||||
* si existe y no coincide con un owner ya incluido
|
||||
*
|
||||
* El uploader mismo SE EXCLUYE (no tiene sentido notificarle su propia acción).
|
||||
*
|
||||
* Fire-and-forget: el caller hace `.catch()` y esta función no re-lanza.
|
||||
* Fail-soft: si SMTP no está configurado, los envíos se loguean a consola
|
||||
* vía el transport de @horux/core.
|
||||
*/
|
||||
export async function notifyDocumentoSubido(params: {
|
||||
pool: Pool;
|
||||
tenantId: string;
|
||||
contribuyenteId: string | null;
|
||||
subidoPor: string;
|
||||
kind: DocumentoSubidoData['kind'];
|
||||
declaracion?: DocumentoSubidoData['declaracion'];
|
||||
extra?: DocumentoSubidoData['extra'];
|
||||
}): Promise<void> {
|
||||
const { pool, tenantId, contribuyenteId, subidoPor } = params;
|
||||
|
||||
// 1. Datos del contribuyente (desde BD tenant). Sin contribuyenteId no hay
|
||||
// subject informativo ni supervisor — skip.
|
||||
if (!contribuyenteId) return;
|
||||
|
||||
// Respeta preferencias de notificación del contribuyente. Si el user
|
||||
// desactivó `documento_subido` para este contribuyente, no enviar.
|
||||
const prefs = await getContribuyenteEmailPreferences(pool, contribuyenteId);
|
||||
if (!prefs.documento_subido) return;
|
||||
|
||||
const { rows } = await pool.query<{
|
||||
rfc: string;
|
||||
nombre: string;
|
||||
supervisor_user_id: string | null;
|
||||
}>(
|
||||
`SELECT c.rfc, eg.nombre, eg.supervisor_user_id
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
|
||||
WHERE c.entidad_id = $1`,
|
||||
[contribuyenteId.replace(/[^a-f0-9-]/gi, '')],
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
const contrib = rows[0];
|
||||
|
||||
// 2. Recipients. Owners primero; luego supervisor si aplica.
|
||||
const owners = await getTenantOwnerEmails(tenantId);
|
||||
const recipients = new Set<string>(owners);
|
||||
|
||||
if (contrib.supervisor_user_id) {
|
||||
const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id);
|
||||
if (supervisorEmail) recipients.add(supervisorEmail);
|
||||
}
|
||||
|
||||
// Excluir al uploader: no notificarle su propia acción.
|
||||
recipients.delete(subidoPor.toLowerCase());
|
||||
recipients.delete(subidoPor);
|
||||
|
||||
if (recipients.size === 0) return;
|
||||
|
||||
// 3. Datos del despacho (para mostrar en el body).
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { nombre: true },
|
||||
});
|
||||
|
||||
// 4. Link al sistema. Usa FRONTEND_URL del env.
|
||||
const link = `${env.FRONTEND_URL}/documentos`;
|
||||
|
||||
await emailService.sendDocumentoSubido(Array.from(recipients), {
|
||||
kind: params.kind,
|
||||
subidoPor,
|
||||
contribuyenteRfc: contrib.rfc,
|
||||
contribuyenteNombre: contrib.nombre,
|
||||
despachoNombre: tenant?.nombre,
|
||||
declaracion: params.declaracion,
|
||||
extra: params.extra,
|
||||
link,
|
||||
});
|
||||
}
|
||||
492
apps/api/src/services/obligaciones.service.ts
Normal file
492
apps/api/src/services/obligaciones.service.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js';
|
||||
|
||||
/**
|
||||
* Keyword-based matching: each catalog entry has discriminant keywords
|
||||
* that must ALL appear in the SAT description (normalized, lowercase, no accents).
|
||||
* Multiple keyword sets per entry allow for variant phrasings.
|
||||
*/
|
||||
const CATALOG_MATCH_RULES: Array<{ id: string; keywords: string[][] }> = [
|
||||
// ISR provisionales
|
||||
{ id: 'isr-provisional', keywords: [
|
||||
['pago provisional', 'isr', 'actividades empresariales'],
|
||||
['pago provisional', 'isr personas morales', 'general'],
|
||||
['pago provisional mensual de isr personas morales'],
|
||||
]},
|
||||
{ id: 'isr-resico-pm', keywords: [
|
||||
['isr', 'simplificado de confianza', 'pago provisional'],
|
||||
['isr', 'simplificado de confianza', 'pago provisional mensual'],
|
||||
]},
|
||||
{ id: 'isr-resico-pf', keywords: [
|
||||
['isr', 'simplificado de confianza', 'ajuste anual'],
|
||||
// Note: PF RESICO "pago provisional" is same id as PM; differentiate by RFC length at runtime
|
||||
]},
|
||||
|
||||
// IVA
|
||||
{ id: 'iva-mensual', keywords: [
|
||||
['pago definitivo', 'iva', 'mensual'],
|
||||
['pago definitivo mensual de iva'],
|
||||
]},
|
||||
|
||||
// DIOT
|
||||
{ id: 'diot', keywords: [
|
||||
['proveedores', 'iva'],
|
||||
['diot'],
|
||||
]},
|
||||
|
||||
// Anuales
|
||||
{ id: 'anual-isr-pm', keywords: [
|
||||
['declaracion anual', 'isr', 'personas morales'],
|
||||
['anual de isr del regimen', 'simplificado', 'personas morales'],
|
||||
]},
|
||||
{ id: 'anual-isr-pf', keywords: [
|
||||
['declaracion anual', 'isr', 'personas fisicas'],
|
||||
['ajuste anual', 'isr', 'declaracion anual', 'simplificado'],
|
||||
]},
|
||||
|
||||
// Retenciones ISR
|
||||
{ id: 'ret-isr-sueldos', keywords: [
|
||||
['retenciones', 'isr', 'sueldos y salarios'],
|
||||
['retenciones mensuales de isr por sueldos'],
|
||||
]},
|
||||
{ id: 'ret-isr-asimilados', keywords: [
|
||||
['retenciones', 'isr', 'asimilados a salarios'],
|
||||
['retenciones mensuales de isr por ingresos asimilados'],
|
||||
]},
|
||||
{ id: 'ret-isr-honorarios', keywords: [
|
||||
['retencion', 'isr', 'servicios profesionales'],
|
||||
['retenciones de isr por servicios profesionales'], // TPR variant (missing accent)
|
||||
]},
|
||||
|
||||
// Retenciones IVA
|
||||
{ id: 'ret-iva', keywords: [
|
||||
['retenciones de iva'],
|
||||
['retenciones', 'iva', 'mensual'],
|
||||
]},
|
||||
|
||||
// IEPS
|
||||
{ id: 'ieps', keywords: [
|
||||
['ieps'],
|
||||
]},
|
||||
|
||||
// RIF bimestral
|
||||
{ id: 'isr-provisional', keywords: [
|
||||
['bimestral del rif'],
|
||||
['pago definitivo bimestral del rif'],
|
||||
]},
|
||||
|
||||
// Arrendamiento
|
||||
{ id: 'isr-provisional', keywords: [
|
||||
['isr', 'arrendamiento de inmuebles', 'pago provisional'],
|
||||
['isr por arrendamiento de inmuebles pf'],
|
||||
]},
|
||||
|
||||
// Informativas (no tienen match directo en catálogo pero agrupar con DIM)
|
||||
{ id: 'dim', keywords: [
|
||||
['declaracion informativa anual', 'pagos y retenciones'],
|
||||
['declaracion informativa anual de clientes y proveedores'],
|
||||
['declaracion informativa anual de retenciones'],
|
||||
['declaracion informativa de iva con la anual'],
|
||||
]},
|
||||
];
|
||||
|
||||
function normalizeForMatch(s: string): string {
|
||||
return s
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[.,;:()]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function matchCsfToCatalog(descripcion: string, rfc: string): ObligacionFiscal | undefined {
|
||||
const norm = normalizeForMatch(descripcion);
|
||||
const esPM = rfc.length === 12;
|
||||
|
||||
for (const rule of CATALOG_MATCH_RULES) {
|
||||
for (const kwSet of rule.keywords) {
|
||||
const allMatch = kwSet.every(kw => norm.includes(normalizeForMatch(kw)));
|
||||
if (allMatch) {
|
||||
// Special case: RESICO ISR provisional — PM vs PF
|
||||
if (rule.id === 'isr-resico-pm' && !esPM) {
|
||||
return OBLIGACIONES_CATALOGO.find(c => c.id === 'isr-resico-pf');
|
||||
}
|
||||
if (rule.id === 'isr-resico-pf' && esPM) {
|
||||
return OBLIGACIONES_CATALOGO.find(c => c.id === 'isr-resico-pm');
|
||||
}
|
||||
return OBLIGACIONES_CATALOGO.find(c => c.id === rule.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface ObligacionContribuyente {
|
||||
id: string;
|
||||
contribuyenteId: string;
|
||||
catalogoId: string | null;
|
||||
nombre: string;
|
||||
fundamento: string | null;
|
||||
frecuencia: string | null;
|
||||
fechaLimite: string | null;
|
||||
categoria: string | null;
|
||||
activa: boolean;
|
||||
esRecomendada: boolean;
|
||||
esCustom: boolean;
|
||||
completada: boolean;
|
||||
completadaAt: string | null;
|
||||
completadaPor: string | null;
|
||||
periodoCompletado: string | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export function getCatalogo(): ObligacionFiscal[] {
|
||||
return OBLIGACIONES_CATALOGO;
|
||||
}
|
||||
|
||||
export async function getObligaciones(pool: Pool, contribuyenteId: string): Promise<ObligacionContribuyente[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId",
|
||||
nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria,
|
||||
activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom",
|
||||
completada, completada_at AS "completadaAt", completada_por AS "completadaPor",
|
||||
periodo_completado AS "periodoCompletado",
|
||||
created_at AS "createdAt"
|
||||
FROM obligaciones_contribuyente
|
||||
WHERE contribuyente_id = $1
|
||||
ORDER BY categoria, nombre
|
||||
`, [contribuyenteId]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads obligations from the latest Constancia de Situación Fiscal (CSF)
|
||||
* and populates obligaciones_contribuyente. Falls back to catalog-based
|
||||
* recommendations if no CSF exists.
|
||||
*/
|
||||
export async function initRecomendaciones(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
rfc: string,
|
||||
regimenes: string[],
|
||||
tieneNomina: boolean
|
||||
): Promise<number> {
|
||||
// Clean up alerts and periodos for existing recommended obligations before replacing
|
||||
await pool.query(
|
||||
`DELETE FROM alertas WHERE tipo LIKE 'ob-%' AND SUBSTRING(tipo FROM 4 FOR 36) IN (
|
||||
SELECT id::text FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND es_recomendada = true
|
||||
)`,
|
||||
[contribuyenteId],
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM obligacion_periodos WHERE obligacion_id IN (
|
||||
SELECT id FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND es_recomendada = true
|
||||
)`,
|
||||
[contribuyenteId],
|
||||
);
|
||||
// Clear previous recommended obligations (re-init replaces them)
|
||||
await pool.query(
|
||||
`DELETE FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND es_recomendada = true`,
|
||||
[contribuyenteId],
|
||||
);
|
||||
|
||||
// Try to get obligations from the latest CSF
|
||||
const { rows: csfRows } = await pool.query(`
|
||||
SELECT datos->'obligaciones' as obligaciones
|
||||
FROM constancias_situacion_fiscal
|
||||
WHERE rfc = $1
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
`, [rfc]);
|
||||
|
||||
const csfObligaciones = csfRows[0]?.obligaciones as Array<{
|
||||
descripcion: string;
|
||||
fechaInicio: string;
|
||||
fechaFin?: string;
|
||||
descripcionVencimiento: string;
|
||||
}> | null;
|
||||
|
||||
let count = 0;
|
||||
|
||||
if (csfObligaciones && csfObligaciones.length > 0) {
|
||||
// Use CSF obligations directly — these are the official SAT obligations
|
||||
// Only import ACTIVE obligations (no fechaFin = still in effect)
|
||||
const activeCsf = csfObligaciones.filter(ob => !ob.fechaFin);
|
||||
for (const ob of activeCsf) {
|
||||
// Keyword-based matching against catalog for enrichment (fundamento, categoria)
|
||||
const catalogMatch = matchCsfToCatalog(ob.descripcion, rfc);
|
||||
|
||||
const { rowCount } = await pool.query(`
|
||||
INSERT INTO obligaciones_contribuyente (
|
||||
contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, es_recomendada
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, [
|
||||
contribuyenteId,
|
||||
catalogMatch?.id || null,
|
||||
ob.descripcion,
|
||||
catalogMatch?.fundamento || null,
|
||||
catalogMatch?.frecuencia || inferirFrecuencia(ob.descripcionVencimiento),
|
||||
ob.descripcionVencimiento,
|
||||
catalogMatch?.categoria || 'SAT',
|
||||
]);
|
||||
count += rowCount ?? 0;
|
||||
}
|
||||
} else {
|
||||
// Fallback: use catalog-based recommendations
|
||||
const recomendadas = getRecomendaciones(rfc, regimenes, tieneNomina);
|
||||
for (const ob of recomendadas) {
|
||||
const { rowCount } = await pool.query(`
|
||||
INSERT INTO obligaciones_contribuyente (contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, es_recomendada)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, [contribuyenteId, ob.id, ob.nombre, ob.fundamento, ob.frecuencia, ob.fechaLimite, ob.categoria]);
|
||||
count += rowCount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function inferirFrecuencia(vencimiento: string): string {
|
||||
const lower = vencimiento.toLowerCase();
|
||||
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
|
||||
if (lower.includes('bimest')) return 'bimestral';
|
||||
if (lower.includes('trimest')) return 'trimestral';
|
||||
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
|
||||
return 'mensual';
|
||||
}
|
||||
|
||||
export async function completeObligacion(pool: Pool, obligacionId: string, userId: string, periodo: string): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
'UPDATE obligaciones_contribuyente SET completada = true, completada_at = now(), completada_por = $2, periodo_completado = $3 WHERE id = $1',
|
||||
[obligacionId, userId, periodo]
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function uncompleteObligacion(pool: Pool, obligacionId: string): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
'UPDATE obligaciones_contribuyente SET completada = false, completada_at = null, completada_por = null, periodo_completado = null WHERE id = $1',
|
||||
[obligacionId]
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function addObligacion(pool: Pool, contribuyenteId: string, data: {
|
||||
catalogoId?: string;
|
||||
nombre: string;
|
||||
fundamento?: string;
|
||||
frecuencia?: string;
|
||||
fechaLimite?: string;
|
||||
categoria?: string;
|
||||
}): Promise<ObligacionContribuyente> {
|
||||
const isFromCatalog = !!data.catalogoId;
|
||||
const { rows: [row] } = await pool.query(`
|
||||
INSERT INTO obligaciones_contribuyente (contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, es_custom)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, contribuyente_id AS "contribuyenteId", catalogo_id AS "catalogoId",
|
||||
nombre, fundamento, frecuencia, fecha_limite AS "fechaLimite", categoria,
|
||||
activa, es_recomendada AS "esRecomendada", es_custom AS "esCustom"
|
||||
`, [contribuyenteId, data.catalogoId || null, data.nombre, data.fundamento || null,
|
||||
data.frecuencia || null, data.fechaLimite || null, data.categoria || 'Custom',
|
||||
!isFromCatalog]);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function removeObligacion(pool: Pool, obligacionId: string): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
'UPDATE obligaciones_contribuyente SET activa = false WHERE id = $1',
|
||||
[obligacionId]
|
||||
);
|
||||
// Clean up alerts generated for this obligation (tipo format: 'ob-{obligacionId}-{periodo}')
|
||||
await pool.query(
|
||||
`DELETE FROM alertas WHERE tipo LIKE $1`,
|
||||
[`ob-${obligacionId}-%`],
|
||||
);
|
||||
// Clean up completion records
|
||||
await pool.query(
|
||||
'DELETE FROM obligacion_periodos WHERE obligacion_id = $1',
|
||||
[obligacionId],
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function restoreObligacion(pool: Pool, obligacionId: string): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
'UPDATE obligaciones_contribuyente SET activa = true WHERE id = $1',
|
||||
[obligacionId]
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns obligations for a specific period (YYYY-MM) for a contribuyente.
|
||||
* Includes:
|
||||
* - All active obligations that apply to this period (based on frequency)
|
||||
* - Completion status from obligacion_periodos table
|
||||
* - Past-due obligations from previous periods that were NOT completed
|
||||
*/
|
||||
export interface DeclaracionLink {
|
||||
id: number;
|
||||
año: number;
|
||||
mes: number;
|
||||
tipo: 'normal' | 'complementaria';
|
||||
pdfFilename: string | null;
|
||||
}
|
||||
|
||||
export async function getObligacionesPorPeriodo(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
periodo: string, // "2026-04"
|
||||
incluirAtrasados: boolean = true
|
||||
): Promise<Array<ObligacionContribuyente & { periodStatus: 'pendiente' | 'completada' | 'atrasada'; periodoAplica: string; declaracion: DeclaracionLink | null }>> {
|
||||
// Get all active obligations for this contribuyente
|
||||
const obligaciones = await getObligaciones(pool, contribuyenteId);
|
||||
const activas = obligaciones.filter(o => o.activa);
|
||||
|
||||
const [year, month] = periodo.split('-').map(Number);
|
||||
const currentPeriodo = new Date().toISOString().substring(0, 7);
|
||||
const results: Array<ObligacionContribuyente & { periodStatus: string; periodoAplica: string; declaracion: DeclaracionLink | null }> = [];
|
||||
|
||||
// Get all completion records + associated declaration info for this contribuyente
|
||||
const { rows: completions } = await pool.query<{
|
||||
obligacion_id: string;
|
||||
periodo: string;
|
||||
completada: boolean;
|
||||
declaracion_id: number | null;
|
||||
decl_año: number | null;
|
||||
decl_mes: number | null;
|
||||
decl_tipo: 'normal' | 'complementaria' | null;
|
||||
decl_pdf_filename: string | null;
|
||||
}>(`
|
||||
SELECT op.obligacion_id, op.periodo, op.completada,
|
||||
op.declaracion_id,
|
||||
dp.año AS decl_año,
|
||||
dp.mes AS decl_mes,
|
||||
dp.tipo AS decl_tipo,
|
||||
dp.pdf_filename AS decl_pdf_filename
|
||||
FROM obligacion_periodos op
|
||||
JOIN obligaciones_contribuyente oc ON oc.id = op.obligacion_id
|
||||
LEFT JOIN declaraciones_provisionales dp ON dp.id = op.declaracion_id
|
||||
WHERE oc.contribuyente_id = $1
|
||||
`, [contribuyenteId]);
|
||||
|
||||
const completionMap = new Map<string, boolean>();
|
||||
const declaracionMap = new Map<string, DeclaracionLink | null>();
|
||||
for (const c of completions) {
|
||||
const key = `${c.obligacion_id}:${c.periodo}`;
|
||||
completionMap.set(key, c.completada);
|
||||
if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) {
|
||||
declaracionMap.set(key, {
|
||||
id: c.declaracion_id,
|
||||
año: c.decl_año,
|
||||
mes: c.decl_mes,
|
||||
tipo: c.decl_tipo,
|
||||
pdfFilename: c.decl_pdf_filename,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const ob of activas) {
|
||||
// Obligations only apply from the month they were created forward
|
||||
const obStartPeriodo = ob.createdAt
|
||||
? new Date(ob.createdAt).toISOString().substring(0, 7)
|
||||
: '2000-01';
|
||||
|
||||
// Check if this obligation applies to the requested period
|
||||
if (periodo >= obStartPeriodo && appliesTo(ob.frecuencia, periodo)) {
|
||||
const key = `${ob.id}:${periodo}`;
|
||||
const isCompleted = completionMap.get(key) === true;
|
||||
results.push({
|
||||
...ob,
|
||||
periodStatus: isCompleted ? 'completada' : 'pendiente',
|
||||
periodoAplica: periodo,
|
||||
declaracion: declaracionMap.get(key) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Check past-due (previous periods not completed) — only if requested
|
||||
if (incluirAtrasados && periodo >= currentPeriodo) {
|
||||
// Look back up to 12 months for overdue items
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
let pm = month - i;
|
||||
let py = year;
|
||||
while (pm < 1) { pm += 12; py--; }
|
||||
const pastPeriodo = `${py}-${String(pm).padStart(2, '0')}`;
|
||||
|
||||
if (pastPeriodo >= currentPeriodo) continue; // only past periods
|
||||
if (pastPeriodo < obStartPeriodo) continue; // don't go before obligation was created
|
||||
if (!appliesTo(ob.frecuencia, pastPeriodo)) continue;
|
||||
|
||||
const pastKey = `${ob.id}:${pastPeriodo}`;
|
||||
const pastCompleted = completionMap.get(pastKey) === true;
|
||||
|
||||
if (!pastCompleted) {
|
||||
// Don't add duplicates
|
||||
if (!results.find(r => r.id === ob.id && r.periodoAplica === pastPeriodo)) {
|
||||
results.push({
|
||||
...ob,
|
||||
periodStatus: 'atrasada',
|
||||
periodoAplica: pastPeriodo,
|
||||
declaracion: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: atrasadas first, then by name
|
||||
results.sort((a, b) => {
|
||||
if (a.periodStatus === 'atrasada' && b.periodStatus !== 'atrasada') return -1;
|
||||
if (b.periodStatus === 'atrasada' && a.periodStatus !== 'atrasada') return 1;
|
||||
return a.nombre.localeCompare(b.nombre);
|
||||
});
|
||||
|
||||
return results as Array<ObligacionContribuyente & { periodStatus: 'pendiente' | 'completada' | 'atrasada'; periodoAplica: string; declaracion: DeclaracionLink | null }>;
|
||||
}
|
||||
|
||||
function appliesTo(frecuencia: string | null, periodo: string): boolean {
|
||||
const [, month] = periodo.split('-').map(Number);
|
||||
switch (frecuencia) {
|
||||
case 'mensual': return true;
|
||||
case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
|
||||
case 'trimestral': return [1, 4, 7, 10].includes(month);
|
||||
case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both
|
||||
case 'eventual': return false; // Don't auto-show
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark an obligation as completed for a specific period
|
||||
*/
|
||||
export async function completePeriodo(
|
||||
pool: Pool,
|
||||
obligacionId: string,
|
||||
periodo: string,
|
||||
userId: string,
|
||||
notas?: string
|
||||
): Promise<boolean> {
|
||||
await pool.query(`
|
||||
INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas)
|
||||
VALUES ($1, $2, true, now(), $3, $4)
|
||||
ON CONFLICT (obligacion_id, periodo)
|
||||
DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, notas = COALESCE($4, obligacion_periodos.notas)
|
||||
`, [obligacionId, periodo, userId, notas || null]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmark an obligation completion for a specific period
|
||||
*/
|
||||
export async function uncompletePeriodo(
|
||||
pool: Pool,
|
||||
obligacionId: string,
|
||||
periodo: string
|
||||
): Promise<boolean> {
|
||||
await pool.query(`
|
||||
DELETE FROM obligacion_periodos WHERE obligacion_id = $1 AND periodo = $2
|
||||
`, [obligacionId, periodo]);
|
||||
return true;
|
||||
}
|
||||
185
apps/api/src/services/opinion-cumplimiento.service.ts
Normal file
185
apps/api/src/services/opinion-cumplimiento.service.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { chromium } from 'playwright';
|
||||
import { writeFileSync, unlinkSync, mkdirSync, rmdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { Pool } from 'pg';
|
||||
import type { OpinionCumplimiento } from '@horux/shared';
|
||||
import { getDecryptedFiel } from './fiel.service.js';
|
||||
import { getDecryptedFielContribuyente } from './contribuyente-fiel.service.js';
|
||||
import { loginToSatOpinion } from './sat/sat-opinion-login.js';
|
||||
import { extractOpinionPdf } from './sat/sat-opinion-scraper.js';
|
||||
import { parseOpinionPdf } from './sat/sat-opinion-parser.js';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
|
||||
const PROCESS_TIMEOUT = 180_000; // 3 minutes per tenant
|
||||
|
||||
/**
|
||||
* Downloads and stores the Opinión de Cumplimiento for a tenant.
|
||||
*/
|
||||
export async function consultarOpinion(tenantId: string): Promise<OpinionCumplimiento> {
|
||||
const fiel = await getDecryptedFiel(tenantId);
|
||||
if (!fiel) {
|
||||
throw new Error('No hay FIEL configurada o está vencida');
|
||||
}
|
||||
|
||||
const tempId = randomUUID();
|
||||
const tempDir = join(tmpdir(), `horux-fiel-${tempId}`);
|
||||
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const cerPath = join(tempDir, 'cert.cer');
|
||||
const keyPath = join(tempDir, 'key.key');
|
||||
|
||||
try {
|
||||
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
||||
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout: proceso de opinión excedió 3 minutos')), PROCESS_TIMEOUT)
|
||||
);
|
||||
|
||||
const resultPromise = (async () => {
|
||||
const reportPage = await loginToSatOpinion(page, cerPath, keyPath, fiel.password);
|
||||
const pdfBuffer = await extractOpinionPdf(reportPage);
|
||||
const parsed = await parseOpinionPdf(pdfBuffer);
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
if (!tenant) throw new Error('Tenant no encontrado');
|
||||
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO opiniones_cumplimiento (rfc, razon_social, estatus, folio, cadena_original, fecha_consulta, pdf)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), $6)
|
||||
RETURNING id, rfc, razon_social as "razonSocial", estatus, folio, cadena_original as "cadenaOriginal",
|
||||
fecha_consulta as "fechaConsulta", created_at as "createdAt"
|
||||
`, [parsed.rfc, parsed.razonSocial, parsed.estatus, parsed.folio, parsed.cadenaOriginal, pdfBuffer]);
|
||||
|
||||
return rows[0] as OpinionCumplimiento;
|
||||
})();
|
||||
|
||||
return await Promise.race([resultPromise, timeoutPromise]);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
try { unlinkSync(cerPath); } catch { /* ok */ }
|
||||
try { unlinkSync(keyPath); } catch { /* ok */ }
|
||||
try { rmdirSync(tempDir); } catch { /* ok */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last N opinions for a tenant (metadata only, no PDF).
|
||||
*/
|
||||
export async function getOpiniones(pool: Pool, limit = 5, rfc?: string): Promise<OpinionCumplimiento[]> {
|
||||
const params: unknown[] = [limit];
|
||||
let rfcFilter = '';
|
||||
if (rfc) {
|
||||
rfcFilter = 'WHERE rfc = $2';
|
||||
params.push(rfc);
|
||||
}
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, rfc, razon_social as "razonSocial", estatus, folio,
|
||||
cadena_original as "cadenaOriginal",
|
||||
fecha_consulta as "fechaConsulta", created_at as "createdAt"
|
||||
FROM opiniones_cumplimiento
|
||||
${rfcFilter}
|
||||
ORDER BY fecha_consulta DESC
|
||||
LIMIT $1
|
||||
`, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PDF binary for a specific opinion.
|
||||
*/
|
||||
export async function getOpinionPdf(pool: Pool, id: number): Promise<Buffer | null> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT pdf FROM opiniones_cumplimiento WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return rows.length > 0 ? rows[0].pdf : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete opinions older than 6 months.
|
||||
*/
|
||||
export async function limpiarOpinionesAntiguas(pool: Pool): Promise<number> {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM opiniones_cumplimiento WHERE fecha_consulta < NOW() - interval '6 months'`
|
||||
);
|
||||
return rowCount ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads and stores the Opinión de Cumplimiento for a specific contribuyente
|
||||
* (despacho mode). Uses FIEL stored in the tenant BD instead of the central BD.
|
||||
*/
|
||||
export async function consultarOpinionContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
): Promise<OpinionCumplimiento> {
|
||||
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||
const fiel = await getDecryptedFielContribuyente(pool, safeId);
|
||||
if (!fiel) {
|
||||
throw new Error('No hay FIEL configurada para este contribuyente o está vencida');
|
||||
}
|
||||
|
||||
const tempId = randomUUID();
|
||||
const tempDir = join(tmpdir(), `horux-fiel-${tempId}`);
|
||||
mkdirSync(tempDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const cerPath = join(tempDir, 'cert.cer');
|
||||
const keyPath = join(tempDir, 'key.key');
|
||||
|
||||
try {
|
||||
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
|
||||
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout: proceso de opinión excedió 3 minutos')), PROCESS_TIMEOUT)
|
||||
);
|
||||
|
||||
const resultPromise = (async () => {
|
||||
const reportPage = await loginToSatOpinion(page, cerPath, keyPath, fiel.password);
|
||||
const pdfBuffer = await extractOpinionPdf(reportPage);
|
||||
const parsed = await parseOpinionPdf(pdfBuffer);
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO opiniones_cumplimiento (rfc, razon_social, estatus, folio, cadena_original, fecha_consulta, pdf)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), $6)
|
||||
RETURNING id, rfc, razon_social as "razonSocial", estatus, folio, cadena_original as "cadenaOriginal",
|
||||
fecha_consulta as "fechaConsulta", created_at as "createdAt"
|
||||
`, [parsed.rfc, parsed.razonSocial, parsed.estatus, parsed.folio, parsed.cadenaOriginal, pdfBuffer]);
|
||||
|
||||
return rows[0] as OpinionCumplimiento;
|
||||
})();
|
||||
|
||||
return await Promise.race([resultPromise, timeoutPromise]);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
try { unlinkSync(cerPath); } catch { /* ok */ }
|
||||
try { unlinkSync(keyPath); } catch { /* ok */ }
|
||||
try { rmdirSync(tempDir); } catch { /* ok */ }
|
||||
}
|
||||
}
|
||||
211
apps/api/src/services/papeleria.service.ts
Normal file
211
apps/api/src/services/papeleria.service.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export const ALLOWED_MIMES = new Set([
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // docx
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // xlsx
|
||||
]);
|
||||
|
||||
export const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
export type EstadoPapeleria = 'pendiente' | 'aprobado' | 'rechazado';
|
||||
|
||||
export interface PapeleriaItem {
|
||||
id: number;
|
||||
contribuyenteId: string;
|
||||
nombre: string;
|
||||
descripcion: string | null;
|
||||
archivoFilename: string;
|
||||
archivoMime: string;
|
||||
archivoSize: number;
|
||||
anio: number;
|
||||
mes: number;
|
||||
requiereAprobacion: boolean;
|
||||
estado: EstadoPapeleria | null;
|
||||
aprobadoPor: string | null;
|
||||
aprobadoAt: Date | null;
|
||||
comentarioRechazo: string | null;
|
||||
subidoPor: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
const SELECT = `
|
||||
id, contribuyente_id, nombre, descripcion,
|
||||
archivo_filename, archivo_mime, archivo_size,
|
||||
anio, mes,
|
||||
requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo,
|
||||
subido_por, created_at
|
||||
`;
|
||||
|
||||
const ROW = (r: any): PapeleriaItem => ({
|
||||
id: r.id,
|
||||
contribuyenteId: r.contribuyente_id,
|
||||
nombre: r.nombre,
|
||||
descripcion: r.descripcion,
|
||||
archivoFilename: r.archivo_filename,
|
||||
archivoMime: r.archivo_mime,
|
||||
archivoSize: r.archivo_size,
|
||||
anio: r.anio,
|
||||
mes: r.mes,
|
||||
requiereAprobacion: r.requiere_aprobacion,
|
||||
estado: r.estado,
|
||||
aprobadoPor: r.aprobado_por,
|
||||
aprobadoAt: r.aprobado_at,
|
||||
comentarioRechazo: r.comentario_rechazo,
|
||||
subidoPor: r.subido_por,
|
||||
createdAt: r.created_at,
|
||||
});
|
||||
|
||||
function sanitizeUuid(id: string): string {
|
||||
return id.replace(/[^a-f0-9-]/gi, '');
|
||||
}
|
||||
|
||||
export interface UploadInput {
|
||||
contribuyenteId: string;
|
||||
nombre: string;
|
||||
descripcion: string | null;
|
||||
anio: number;
|
||||
mes: number;
|
||||
requiereAprobacion: boolean;
|
||||
archivo: Buffer;
|
||||
archivoFilename: string;
|
||||
archivoMime: string;
|
||||
subidoPor: string;
|
||||
}
|
||||
|
||||
export async function uploadPapeleria(
|
||||
pool: Pool,
|
||||
input: UploadInput,
|
||||
): Promise<PapeleriaItem> {
|
||||
if (!ALLOWED_MIMES.has(input.archivoMime)) {
|
||||
throw new Error(`Formato no permitido: ${input.archivoMime}. Solo PDF, Word y Excel.`);
|
||||
}
|
||||
if (input.archivo.length > MAX_SIZE_BYTES) {
|
||||
throw new Error(`Archivo excede el máximo de 5 MB (recibido ${(input.archivo.length / 1024 / 1024).toFixed(1)} MB).`);
|
||||
}
|
||||
|
||||
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
|
||||
|
||||
const { rows: [r] } = await pool.query(
|
||||
`INSERT INTO papeleria_trabajo
|
||||
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
|
||||
anio, mes, requiere_aprobacion, estado, subido_por)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING ${SELECT}`,
|
||||
[
|
||||
sanitizeUuid(input.contribuyenteId),
|
||||
input.nombre,
|
||||
input.descripcion,
|
||||
input.archivo,
|
||||
input.archivoFilename,
|
||||
input.archivoMime,
|
||||
input.archivo.length,
|
||||
input.anio,
|
||||
input.mes,
|
||||
input.requiereAprobacion,
|
||||
estadoInicial,
|
||||
input.subidoPor,
|
||||
],
|
||||
);
|
||||
return ROW(r);
|
||||
}
|
||||
|
||||
export interface ListFilters {
|
||||
contribuyenteId: string;
|
||||
anio?: number;
|
||||
mes?: number;
|
||||
estado?: EstadoPapeleria | 'sin_aprobacion';
|
||||
}
|
||||
|
||||
export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> {
|
||||
const conds: string[] = ['contribuyente_id = $1'];
|
||||
const vals: unknown[] = [sanitizeUuid(f.contribuyenteId)];
|
||||
let i = 2;
|
||||
if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); }
|
||||
if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
|
||||
if (f.estado === 'sin_aprobacion') {
|
||||
conds.push('requiere_aprobacion = false');
|
||||
} else if (f.estado) {
|
||||
conds.push(`estado = $${i++}`); vals.push(f.estado);
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT ${SELECT} FROM papeleria_trabajo
|
||||
WHERE ${conds.join(' AND ')}
|
||||
ORDER BY anio DESC, mes DESC, created_at DESC`,
|
||||
vals,
|
||||
);
|
||||
return rows.map(ROW);
|
||||
}
|
||||
|
||||
export async function getById(pool: Pool, id: number): Promise<PapeleriaItem | null> {
|
||||
const { rows: [r] } = await pool.query(
|
||||
`SELECT ${SELECT} FROM papeleria_trabajo WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
return r ? ROW(r) : null;
|
||||
}
|
||||
|
||||
export async function downloadArchivo(
|
||||
pool: Pool,
|
||||
id: number,
|
||||
): Promise<{ archivo: Buffer; filename: string; mime: string } | null> {
|
||||
const { rows: [r] } = await pool.query(
|
||||
`SELECT archivo, archivo_filename, archivo_mime FROM papeleria_trabajo WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (!r) return null;
|
||||
return { archivo: r.archivo, filename: r.archivo_filename, mime: r.archivo_mime };
|
||||
}
|
||||
|
||||
const ROLES_APROBADOR = new Set(['owner', 'cfo', 'supervisor']);
|
||||
|
||||
export async function aprobar(
|
||||
pool: Pool,
|
||||
id: number,
|
||||
userId: string,
|
||||
userRole: string,
|
||||
): Promise<PapeleriaItem | null> {
|
||||
if (!ROLES_APROBADOR.has(userRole)) {
|
||||
throw new Error('Solo owner o supervisor pueden aprobar papelería');
|
||||
}
|
||||
const { rows: [r] } = await pool.query(
|
||||
`UPDATE papeleria_trabajo
|
||||
SET estado = 'aprobado', aprobado_por = $2, aprobado_at = NOW(),
|
||||
comentario_rechazo = NULL
|
||||
WHERE id = $1 AND requiere_aprobacion = true
|
||||
RETURNING ${SELECT}`,
|
||||
[id, userId],
|
||||
);
|
||||
return r ? ROW(r) : null;
|
||||
}
|
||||
|
||||
export async function rechazar(
|
||||
pool: Pool,
|
||||
id: number,
|
||||
userId: string,
|
||||
userRole: string,
|
||||
comentario: string | null,
|
||||
): Promise<PapeleriaItem | null> {
|
||||
if (!ROLES_APROBADOR.has(userRole)) {
|
||||
throw new Error('Solo owner o supervisor pueden rechazar papelería');
|
||||
}
|
||||
const { rows: [r] } = await pool.query(
|
||||
`UPDATE papeleria_trabajo
|
||||
SET estado = 'rechazado', aprobado_por = $2, aprobado_at = NOW(),
|
||||
comentario_rechazo = $3
|
||||
WHERE id = $1 AND requiere_aprobacion = true
|
||||
RETURNING ${SELECT}`,
|
||||
[id, userId, comentario],
|
||||
);
|
||||
return r ? ROW(r) : null;
|
||||
}
|
||||
|
||||
export async function eliminar(pool: Pool, id: number): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM papeleria_trabajo WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
422
apps/api/src/services/payment/addon.service.ts
Normal file
422
apps/api/src/services/payment/addon.service.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import { prisma } from '../../config/database.js';
|
||||
import * as mpService from './mercadopago.service.js';
|
||||
import { getTenantOwnerEmail } from '../../utils/memberships.js';
|
||||
import { computeEffectiveLimits, type PlanLimits, type AddonDelta } from '../plan-catalogo.service.js';
|
||||
import { permiteOverage } from '@horux/shared';
|
||||
import { emailService } from '../email/email.service.js';
|
||||
|
||||
export async function listActiveAddons(tenantId: string, contribuyenteId?: string | null) {
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { tenantId, status: { in: ['authorized', 'pending', 'trial'] } },
|
||||
include: {
|
||||
addons: {
|
||||
include: { planAddonCatalogo: true },
|
||||
where: {
|
||||
status: { in: ['authorized', 'pending'] },
|
||||
// Si se pide por contribuyente, solo trae los de ese contribuyente.
|
||||
// Si no, trae todos (tenant-level + todos los contribuyentes).
|
||||
...(contribuyenteId !== undefined ? { contribuyenteId } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!subscription) return { addons: [], subscription: null };
|
||||
|
||||
return {
|
||||
subscription: { id: subscription.id, plan: subscription.plan, status: subscription.status },
|
||||
addons: subscription.addons.map(a => ({
|
||||
id: a.id,
|
||||
codename: a.planAddonCatalogo.codename,
|
||||
nombre: a.planAddonCatalogo.nombre,
|
||||
precio: Number(a.amount),
|
||||
quantity: a.quantity,
|
||||
contribuyenteId: a.contribuyenteId,
|
||||
status: a.status,
|
||||
currentPeriodStart: a.currentPeriodStart?.toISOString() ?? null,
|
||||
currentPeriodEnd: a.currentPeriodEnd?.toISOString() ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function subscribeAddon(params: {
|
||||
tenantId: string;
|
||||
addonCodename: string;
|
||||
quantity?: number;
|
||||
payerEmail?: string;
|
||||
/**
|
||||
* UUID del contribuyente (entidad_id en BD tenant) cuando el add-on se ata a
|
||||
* un RFC específico. Omitido para add-ons tenant-level.
|
||||
*/
|
||||
contribuyenteId?: string | null;
|
||||
}): Promise<{ addon: any; paymentUrl: string }> {
|
||||
const { tenantId, addonCodename, quantity = 1, contribuyenteId = null } = params;
|
||||
|
||||
const addon = await prisma.planAddonCatalogo.findUnique({ where: { codename: addonCodename } });
|
||||
if (!addon || !addon.active) throw new Error('Addon no disponible');
|
||||
|
||||
const subscription = await prisma.subscription.findFirst({
|
||||
where: { tenantId, status: { in: ['authorized', 'trial'] } },
|
||||
});
|
||||
if (!subscription) throw new Error('No hay suscripción activa');
|
||||
|
||||
// Un solo addon activo por (subscription, addon, contribuyente?). Para
|
||||
// tenant-level (contribuyenteId=null) cualquier activo bloquea; para
|
||||
// per-contribuyente, solo bloquea si ya existe activo para ese mismo RFC.
|
||||
const existing = await prisma.subscriptionAddon.findFirst({
|
||||
where: {
|
||||
subscriptionId: subscription.id,
|
||||
planAddonCatalogoId: addon.id,
|
||||
contribuyenteId: contribuyenteId ?? null,
|
||||
status: { in: ['authorized', 'pending'] },
|
||||
},
|
||||
});
|
||||
if (existing) throw new Error('Ya tienes este addon activo');
|
||||
|
||||
const ownerEmail = params.payerEmail || await getTenantOwnerEmail(tenantId);
|
||||
if (!ownerEmail) throw new Error('No se pudo determinar un email para el cobro');
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { nombre: true } });
|
||||
const amount = Number(addon.precio) * quantity;
|
||||
|
||||
// Create the SubscriptionAddon record first so we have an id for external_reference
|
||||
const subscriptionAddon = await prisma.subscriptionAddon.create({
|
||||
data: {
|
||||
subscriptionId: subscription.id,
|
||||
planAddonCatalogoId: addon.id,
|
||||
contribuyenteId: contribuyenteId ?? null,
|
||||
status: 'pending',
|
||||
quantity,
|
||||
amount,
|
||||
},
|
||||
});
|
||||
|
||||
let mp: { preapprovalId: string; initPoint: string; status: string };
|
||||
try {
|
||||
mp = await mpService.createPreapproval({
|
||||
tenantId,
|
||||
reason: `Horux Despachos - ${addon.nombre}${contribuyenteId ? ` (RFC ${contribuyenteId.slice(0, 8)})` : ''} x${quantity} - ${tenant?.nombre || tenantId}`,
|
||||
amount,
|
||||
payerEmail: ownerEmail,
|
||||
frequency: addon.frecuencia === 'anual' ? 'annual' : 'monthly',
|
||||
externalReference: `addon:${subscriptionAddon.id}`,
|
||||
});
|
||||
} catch (err) {
|
||||
// If MP creation fails, clean up the pending record so user can retry
|
||||
await prisma.subscriptionAddon.delete({ where: { id: subscriptionAddon.id } });
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Update record with the mp preapproval id and final status
|
||||
const updated = await prisma.subscriptionAddon.update({
|
||||
where: { id: subscriptionAddon.id },
|
||||
data: {
|
||||
mpPreapprovalId: mp.preapprovalId,
|
||||
status: mp.status || 'pending',
|
||||
},
|
||||
});
|
||||
|
||||
return { addon: updated, paymentUrl: mp.initPoint };
|
||||
}
|
||||
|
||||
export async function cancelAddon(tenantId: string, addonId: string): Promise<void> {
|
||||
const addon = await prisma.subscriptionAddon.findUnique({
|
||||
where: { id: addonId },
|
||||
include: { subscription: { select: { tenantId: true } } },
|
||||
});
|
||||
|
||||
if (!addon || addon.subscription.tenantId !== tenantId) {
|
||||
throw new Error('Addon no encontrado');
|
||||
}
|
||||
|
||||
if (addon.mpPreapprovalId) {
|
||||
try {
|
||||
await mpService.cancelPreapproval(addon.mpPreapprovalId);
|
||||
} catch (err) {
|
||||
console.error('[Addon] Error cancelling MP preapproval:', err);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.subscriptionAddon.update({
|
||||
where: { id: addonId },
|
||||
data: { status: 'cancelled' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleAddonPayment(addonId: string, mpPaymentId: string, status: string): Promise<void> {
|
||||
const addon = await prisma.subscriptionAddon.findUnique({
|
||||
where: { id: addonId },
|
||||
include: { subscription: { select: { tenantId: true } } },
|
||||
});
|
||||
if (!addon) {
|
||||
console.error(`[Addon Webhook] SubscriptionAddon ${addonId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousStatus = addon.status;
|
||||
|
||||
if (status === 'authorized' || status === 'approved') {
|
||||
const now = new Date();
|
||||
const periodEnd = new Date(now);
|
||||
if (addon.amount) {
|
||||
periodEnd.setMonth(periodEnd.getMonth() + 1);
|
||||
}
|
||||
|
||||
await prisma.subscriptionAddon.update({
|
||||
where: { id: addonId },
|
||||
data: {
|
||||
status: 'authorized',
|
||||
currentPeriodStart: now,
|
||||
currentPeriodEnd: periodEnd,
|
||||
},
|
||||
});
|
||||
} else if (status === 'cancelled' || status === 'paused' || status === 'rejected') {
|
||||
await prisma.subscriptionAddon.update({
|
||||
where: { id: addonId },
|
||||
data: { status },
|
||||
});
|
||||
|
||||
// Aviso fail-soft al owner si el cobro de addon (overage de contribuyentes,
|
||||
// típicamente $45/mes por extra >100) fue rechazado. Solo en transición
|
||||
// real — si ya estaba en estado terminal, MP puede re-notificar y no
|
||||
// queremos spam.
|
||||
if (
|
||||
(status === 'rejected' || status === 'cancelled') &&
|
||||
previousStatus !== status &&
|
||||
addon.subscription
|
||||
) {
|
||||
const tenantId = addon.subscription.tenantId;
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { nombre: true } });
|
||||
const ownerEmail = await getTenantOwnerEmail(tenantId);
|
||||
if (tenant && ownerEmail) {
|
||||
emailService.sendPaymentFailed(ownerEmail, {
|
||||
nombre: tenant.nombre,
|
||||
amount: Number(addon.amount ?? 0),
|
||||
plan: 'Addon de contribuyentes adicionales',
|
||||
}).catch(err => console.error('[EMAIL] addon failed notification:', err));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Overage automático: Business Control y Enterprise (business_cloud) incluyen
|
||||
// 100 contribuyentes; cada uno adicional cuesta $45/mes. Se modela como un
|
||||
// único `SubscriptionAddon` con `codename = 'contribuyente_extra_business_cloud'`
|
||||
// (codename heredado por compat con suscripciones existentes; nombre display ya
|
||||
// es genérico), `contribuyenteId = null` (tenant-level) y
|
||||
// `quantity = activeCount − 100`. El cobro MP usa un preapproval propio; cuando
|
||||
// `quantity` cambia, se actualiza vía `updatePreapprovalAmount` (sin
|
||||
// re-autorización del usuario).
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
const DESPACHO_INCLUDED_RFCS = 100;
|
||||
const OVERAGE_ADDON_CODENAME = 'contribuyente_extra_business_cloud';
|
||||
|
||||
export type OverageAction = 'none' | 'created' | 'updated' | 'cancelled' | 'skipped';
|
||||
|
||||
export interface OverageAdjustResult {
|
||||
action: OverageAction;
|
||||
/** Cantidad cobrada tras el ajuste (activeCount − 3, mínimo 0). */
|
||||
overageCount: number;
|
||||
/** Sólo cuando action='created': URL de MercadoPago a presentar al usuario. */
|
||||
paymentUrl?: string;
|
||||
/** Razón si action='skipped' o 'none' (útil para logs/UI). */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajusta el add-on de overage para el tenant según el número actual de
|
||||
* contribuyentes activos. Aplica a planes Business Control y Enterprise
|
||||
* (business_cloud) — ambos incluyen 100 contribuyentes y cobran $45/mes por
|
||||
* cada adicional. Idempotente: llamar varias veces con el mismo `activeCount`
|
||||
* no tiene efecto.
|
||||
*
|
||||
* Casos:
|
||||
* - Plan no permite overage (mi_empresa, mi_empresa_plus, trial, etc.) → 'skipped'
|
||||
* - Sin suscripción activa → 'skipped' (addon requiere sub)
|
||||
* - Catálogo no seeded → 'skipped' (error de setup)
|
||||
* - overage=0 y no hay addon → 'none'
|
||||
* - overage=0 y hay addon → 'cancelled' (revoca preapproval)
|
||||
* - overage>0 y no hay addon → 'created' (crea addon + preapproval → paymentUrl)
|
||||
* - overage>0 y addon.quantity == overage → 'none' (idempotente)
|
||||
* - overage>0 y addon.quantity distinto → 'updated' (updatePreapprovalAmount)
|
||||
*/
|
||||
export async function adjustDespachoOverage(
|
||||
tenantId: string,
|
||||
activeContribuyenteCount: number,
|
||||
): Promise<OverageAdjustResult> {
|
||||
const sub = await prisma.subscription.findFirst({
|
||||
where: { tenantId, status: { in: ['authorized', 'trial', 'pending'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!sub) return { action: 'skipped', overageCount: 0, reason: 'Sin suscripción activa' };
|
||||
if (!permiteOverage(sub.plan)) {
|
||||
return { action: 'skipped', overageCount: 0, reason: `Plan ${sub.plan} no aplica overage` };
|
||||
}
|
||||
|
||||
const overage = Math.max(0, activeContribuyenteCount - DESPACHO_INCLUDED_RFCS);
|
||||
|
||||
const catalogo = await prisma.planAddonCatalogo.findUnique({
|
||||
where: { codename: OVERAGE_ADDON_CODENAME },
|
||||
});
|
||||
if (!catalogo) {
|
||||
return { action: 'skipped', overageCount: overage, reason: 'Catálogo overage no seeded' };
|
||||
}
|
||||
|
||||
const existing = await prisma.subscriptionAddon.findFirst({
|
||||
where: {
|
||||
subscriptionId: sub.id,
|
||||
planAddonCatalogoId: catalogo.id,
|
||||
contribuyenteId: null,
|
||||
status: { in: ['authorized', 'pending'] },
|
||||
},
|
||||
});
|
||||
|
||||
// Bajo o en el límite: cancela el addon si existe
|
||||
if (overage === 0) {
|
||||
if (!existing) return { action: 'none', overageCount: 0 };
|
||||
if (existing.mpPreapprovalId) {
|
||||
try {
|
||||
await mpService.cancelPreapproval(existing.mpPreapprovalId);
|
||||
} catch (err) {
|
||||
console.error('[Overage] Error cancelling MP preapproval:', err);
|
||||
}
|
||||
}
|
||||
await prisma.subscriptionAddon.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: 'cancelled' },
|
||||
});
|
||||
return { action: 'cancelled', overageCount: 0 };
|
||||
}
|
||||
|
||||
// Hay overage → crear o actualizar addon
|
||||
const price = Number(catalogo.precio);
|
||||
const newAmount = price * overage;
|
||||
|
||||
if (!existing) {
|
||||
const ownerEmail = await getTenantOwnerEmail(tenantId);
|
||||
if (!ownerEmail) {
|
||||
return { action: 'skipped', overageCount: overage, reason: 'Sin email del owner para cobro' };
|
||||
}
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { nombre: true } });
|
||||
|
||||
const addon = await prisma.subscriptionAddon.create({
|
||||
data: {
|
||||
subscriptionId: sub.id,
|
||||
planAddonCatalogoId: catalogo.id,
|
||||
contribuyenteId: null,
|
||||
status: 'pending',
|
||||
quantity: overage,
|
||||
amount: newAmount,
|
||||
},
|
||||
});
|
||||
|
||||
let mp: { preapprovalId: string; initPoint: string; status: string };
|
||||
try {
|
||||
mp = await mpService.createPreapproval({
|
||||
tenantId,
|
||||
reason: `Horux Despachos - ${catalogo.nombre} x${overage} - ${tenant?.nombre || tenantId}`,
|
||||
amount: newAmount,
|
||||
payerEmail: ownerEmail,
|
||||
frequency: 'monthly',
|
||||
externalReference: `addon:${addon.id}`,
|
||||
});
|
||||
} catch (err) {
|
||||
await prisma.subscriptionAddon.delete({ where: { id: addon.id } });
|
||||
throw err;
|
||||
}
|
||||
|
||||
await prisma.subscriptionAddon.update({
|
||||
where: { id: addon.id },
|
||||
data: { mpPreapprovalId: mp.preapprovalId, status: mp.status || 'pending' },
|
||||
});
|
||||
|
||||
return { action: 'created', overageCount: overage, paymentUrl: mp.initPoint };
|
||||
}
|
||||
|
||||
// Idempotente: si quantity ya coincide, no hay nada que hacer
|
||||
if (existing.quantity === overage) {
|
||||
return { action: 'none', overageCount: overage };
|
||||
}
|
||||
|
||||
// Actualizar quantity + amount + MP preapproval
|
||||
await prisma.subscriptionAddon.update({
|
||||
where: { id: existing.id },
|
||||
data: { quantity: overage, amount: newAmount },
|
||||
});
|
||||
if (existing.mpPreapprovalId) {
|
||||
try {
|
||||
await mpService.updatePreapprovalAmount(existing.mpPreapprovalId, newAmount);
|
||||
} catch (err) {
|
||||
console.error('[Overage] Error updating MP amount:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return { action: 'updated', overageCount: overage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuenta contribuyentes activos del tenant abriendo el pool tenant. Helper
|
||||
* para callers que viven fuera de un endpoint con `req.tenantPool` (ej.
|
||||
* `subscription.service.ts` cuando aplica un cambio de plan).
|
||||
*/
|
||||
export async function countActiveContribuyentesForTenant(tenantId: string): Promise<number> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
if (!tenant?.databaseName) return 0;
|
||||
const { tenantDb } = await import('../../config/database.js');
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const { rows: [{ cnt }] } = await pool.query<{ cnt: string }>(
|
||||
`SELECT COUNT(*)::text AS cnt FROM entidades_gestionadas
|
||||
WHERE active = true AND tipo = 'CONTRIBUYENTE'`,
|
||||
);
|
||||
return Number(cnt) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela el add-on de overage del tenant (si existe) sin importar el status
|
||||
* actual de la suscripción. Útil cuando una suscripción se cancela y queremos
|
||||
* cerrar también el preapproval mensual del overage. Idempotente.
|
||||
*/
|
||||
export async function cancelOverageAddonForTenant(tenantId: string): Promise<{ cancelled: boolean }> {
|
||||
const sub = await prisma.subscription.findFirst({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!sub) return { cancelled: false };
|
||||
|
||||
const catalogo = await prisma.planAddonCatalogo.findUnique({
|
||||
where: { codename: OVERAGE_ADDON_CODENAME },
|
||||
});
|
||||
if (!catalogo) return { cancelled: false };
|
||||
|
||||
const existing = await prisma.subscriptionAddon.findFirst({
|
||||
where: {
|
||||
subscriptionId: sub.id,
|
||||
planAddonCatalogoId: catalogo.id,
|
||||
contribuyenteId: null,
|
||||
status: { in: ['authorized', 'pending'] },
|
||||
},
|
||||
});
|
||||
if (!existing) return { cancelled: false };
|
||||
|
||||
if (existing.mpPreapprovalId) {
|
||||
try {
|
||||
await mpService.cancelPreapproval(existing.mpPreapprovalId);
|
||||
} catch (err) {
|
||||
console.error('[Overage] Error cancelling MP preapproval on tenant cancel:', err);
|
||||
}
|
||||
}
|
||||
await prisma.subscriptionAddon.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: 'cancelled' },
|
||||
});
|
||||
return { cancelled: true };
|
||||
}
|
||||
|
||||
// Re-export types used by callers that need to compose addon deltas with plan limits
|
||||
export type { PlanLimits, AddonDelta };
|
||||
export { computeEffectiveLimits };
|
||||
351
apps/api/src/services/payment/invoicing.service.ts
Normal file
351
apps/api/src/services/payment/invoicing.service.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Auto-facturación de pagos de suscripción.
|
||||
*
|
||||
* Cada vez que MercadoPago confirma un pago (webhook `payment.approved`), este
|
||||
* servicio emite automáticamente un CFDI al público en general vía Facturapi,
|
||||
* usando la organización de Horux 360 como emisor.
|
||||
*
|
||||
* Reglas:
|
||||
* - El **primer pago** aprobado de cada tenant NO se factura automáticamente —
|
||||
* el admin lo hace manualmente para verificar/capturar los datos fiscales del
|
||||
* cliente. Los pagos subsecuentes sí van auto a público en general.
|
||||
* - Trials (amount=0) no se facturan.
|
||||
* - Idempotente: si `Payment.facturapiInvoiceId` ya existe, skip.
|
||||
* - Si Facturapi falla (API down, CSD inválido), se logea el error pero NO se
|
||||
* tira el webhook — `facturapiInvoiceId` queda null y el admin puede re-emitir
|
||||
* manualmente después. Esto evita que MP reintente el webhook y que se
|
||||
* dupliquen registros de Payment.
|
||||
*
|
||||
* Emisor: Horux 360 (RFC HTS240708LJA, RESICO PM, régimen 626, sin retenciones).
|
||||
* Receptor: PUBLICO EN GENERAL (XAXX010101000, régimen 616).
|
||||
* Concepto: clave prod/serv 81112502 (Servicios de alojamiento de aplicaciones).
|
||||
*/
|
||||
import { prisma } from '../../config/database.js';
|
||||
import * as facturapiService from '../facturapi.service.js';
|
||||
import { GLOBAL_ADMIN_RFC } from '@horux/shared';
|
||||
import { auditLog } from '../../utils/audit.js';
|
||||
import { getTenantOwnerEmail } from '../../utils/memberships.js';
|
||||
|
||||
// Constantes de facturación — ajustar aquí si cambia la convención
|
||||
const CONCEPT_PRODUCT_KEY = '81112502'; // Servicios de alojamiento de aplicaciones
|
||||
const CONCEPT_UNIT_KEY = 'E48'; // Unidad de servicio
|
||||
const CONCEPT_UNIT_NAME = 'Servicio';
|
||||
// Fallback público en general — se usa cuando el tenant pagador no tiene
|
||||
// suficientes datos fiscales (sin CSF cargada, sin domicilio, etc.).
|
||||
const FALLBACK_TAX_ID = 'XAXX010101000';
|
||||
const FALLBACK_LEGAL_NAME = 'PUBLICO EN GENERAL';
|
||||
const FALLBACK_TAX_SYSTEM = '616'; // Sin obligaciones fiscales
|
||||
const FALLBACK_USE_CFDI = 'S01'; // Sin efectos fiscales
|
||||
// Default cuando facturamos con datos reales del cliente — gastos en general.
|
||||
// Fase 2 hará esto configurable por tenant.
|
||||
const DEFAULT_USE_CFDI = 'G03';
|
||||
const IVA_RATE = 0.16;
|
||||
|
||||
// Mapeo MP payment_method → SAT forma_pago. Conservador: por default TEF (03).
|
||||
const FORMA_PAGO_POR_METHOD: Record<string, string> = {
|
||||
credit_card: '04', // Tarjeta de crédito
|
||||
debit_card: '28', // Tarjeta de débito
|
||||
account_money: '03', // Transferencia (MP wallet)
|
||||
bank_transfer: '03',
|
||||
};
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
trial: 'Trial',
|
||||
custom: 'Custom',
|
||||
mi_empresa: 'Mi Empresa',
|
||||
mi_empresa_plus: 'Mi Empresa Plus',
|
||||
business_control: 'Business Control',
|
||||
business_cloud: 'Enterprise',
|
||||
};
|
||||
|
||||
/**
|
||||
* Cuenta si este tenant ya tuvo un pago aprobado antes del actual.
|
||||
* Si no hay ninguno, es el primer pago → devolvemos true (skip auto-emit).
|
||||
*/
|
||||
async function isFirstApprovedPayment(
|
||||
tenantId: string,
|
||||
excludePaymentId: string,
|
||||
): Promise<boolean> {
|
||||
const count = await prisma.payment.count({
|
||||
where: {
|
||||
tenantId,
|
||||
status: 'approved',
|
||||
id: { not: excludePaymentId },
|
||||
},
|
||||
});
|
||||
return count === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca el tenant emisor (Horux 360) con su organización Facturapi configurada.
|
||||
* Si falta, lanza error — el admin global tiene que crear la organización primero.
|
||||
*/
|
||||
async function getEmitterTenant() {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { rfc: GLOBAL_ADMIN_RFC },
|
||||
select: {
|
||||
id: true,
|
||||
nombre: true,
|
||||
rfc: true,
|
||||
codigoPostal: true,
|
||||
facturapiOrgId: true,
|
||||
},
|
||||
});
|
||||
if (!tenant) {
|
||||
throw new Error(`Tenant emisor (RFC ${GLOBAL_ADMIN_RFC}) no existe — corre pnpm bootstrap:admin-global`);
|
||||
}
|
||||
if (!tenant.facturapiOrgId) {
|
||||
throw new Error(`Tenant emisor no tiene organización Facturapi — configúrala en /configuracion`);
|
||||
}
|
||||
if (!tenant.codigoPostal) {
|
||||
throw new Error(`Tenant emisor no tiene código postal — configúralo en /configuracion/domicilio-fiscal`);
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datos fiscales del receptor para la factura. `null` si no hay datos suficientes
|
||||
* (RFC + razón social + CP + régimen) — el caller cae a público en general.
|
||||
*/
|
||||
interface CustomerData {
|
||||
legalName: string;
|
||||
taxId: string;
|
||||
taxSystem: string;
|
||||
email: string;
|
||||
zip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve los datos fiscales del receptor desde el tenant que paga.
|
||||
* Requiere CSF sincronizada (régimen) + domicilio fiscal (CP).
|
||||
*
|
||||
* Heurística cuando hay múltiples regímenes activos: usa el más antiguo
|
||||
* (primer regímen agregado al tenant). Fase 2 lo hará configurable.
|
||||
*
|
||||
* Retorna `null` si falta cualquier dato requerido — el caller debe caer
|
||||
* a público en general en ese caso.
|
||||
*/
|
||||
async function getCustomerFromTenant(payerTenantId: string): Promise<CustomerData | null> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: payerTenantId },
|
||||
select: {
|
||||
nombre: true,
|
||||
rfc: true,
|
||||
codigoPostal: true,
|
||||
factPreferencia: true,
|
||||
factRegimenPreferido: true,
|
||||
},
|
||||
});
|
||||
if (!tenant) return null;
|
||||
// Si el cliente eligió "público en general" explícitamente, respetar.
|
||||
if (tenant.factPreferencia === 'publico_general') return null;
|
||||
if (!tenant.rfc || !tenant.nombre || !tenant.codigoPostal) return null;
|
||||
|
||||
// Régimen fiscal: si el tenant configuró uno preferido, usar ese (validar
|
||||
// que sigue activo). Si no, heurística "primer activo por createdAt".
|
||||
let regimenClave: string | null = null;
|
||||
if (tenant.factRegimenPreferido) {
|
||||
const activo = await prisma.tenantRegimenActivo.findFirst({
|
||||
where: {
|
||||
tenantId: payerTenantId,
|
||||
regimen: { clave: tenant.factRegimenPreferido },
|
||||
},
|
||||
include: { regimen: true },
|
||||
});
|
||||
if (activo) regimenClave = activo.regimen.clave;
|
||||
}
|
||||
if (!regimenClave) {
|
||||
const regimenActivo = await prisma.tenantRegimenActivo.findFirst({
|
||||
where: { tenantId: payerTenantId },
|
||||
include: { regimen: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (!regimenActivo) return null;
|
||||
regimenClave = regimenActivo.regimen.clave;
|
||||
}
|
||||
|
||||
const email = await getTenantOwnerEmail(payerTenantId);
|
||||
|
||||
return {
|
||||
legalName: tenant.nombre.toUpperCase(),
|
||||
taxId: tenant.rfc.toUpperCase(),
|
||||
taxSystem: regimenClave,
|
||||
email: email || '',
|
||||
zip: tenant.codigoPostal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el payload para Facturapi. Acepta customer real (datos del cliente)
|
||||
* o fallback a público en general si `customer` es null.
|
||||
*/
|
||||
function buildInvoicePayload(params: {
|
||||
amount: number;
|
||||
description: string; // Texto del concepto — varía por kind (subscription vs timbres)
|
||||
emitterCp: string;
|
||||
paymentMethod: string | null;
|
||||
customer: CustomerData | null;
|
||||
usoCfdi: string; // Resuelto por el caller según preferencia del tenant
|
||||
}) {
|
||||
const description = params.description;
|
||||
|
||||
// Normaliza método de pago MP → código SAT. Default 03 (TEF) si no mapea.
|
||||
const normalizedMethod = params.paymentMethod?.toLowerCase().replace(/^proration-/, '') || '';
|
||||
const formaPago = FORMA_PAGO_POR_METHOD[normalizedMethod] || '03';
|
||||
|
||||
const useCustomerData = params.customer !== null;
|
||||
const customerPayload = useCustomerData
|
||||
? {
|
||||
legalName: params.customer!.legalName,
|
||||
taxId: params.customer!.taxId,
|
||||
taxSystem: params.customer!.taxSystem,
|
||||
email: params.customer!.email,
|
||||
zip: params.customer!.zip,
|
||||
}
|
||||
: {
|
||||
legalName: FALLBACK_LEGAL_NAME,
|
||||
taxId: FALLBACK_TAX_ID,
|
||||
taxSystem: FALLBACK_TAX_SYSTEM,
|
||||
email: '',
|
||||
zip: params.emitterCp,
|
||||
};
|
||||
|
||||
return {
|
||||
customer: customerPayload as any,
|
||||
items: [
|
||||
{
|
||||
description,
|
||||
productKey: CONCEPT_PRODUCT_KEY,
|
||||
unitKey: CONCEPT_UNIT_KEY,
|
||||
unitName: CONCEPT_UNIT_NAME,
|
||||
quantity: 1,
|
||||
price: params.amount, // Ya incluye IVA
|
||||
taxIncluded: true, // Facturapi desagrega subtotal + IVA 16%
|
||||
taxes: [
|
||||
{ type: 'IVA', rate: IVA_RATE, factor: 'Tasa' },
|
||||
// RESICO PM → sin retenciones
|
||||
],
|
||||
},
|
||||
],
|
||||
use: params.usoCfdi,
|
||||
paymentForm: formaPago,
|
||||
paymentMethod: 'PUE',
|
||||
currency: 'MXN',
|
||||
} as facturapiService.FacturapiInvoiceData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point. Se llama desde el webhook de MP cuando un pago se confirma.
|
||||
* Todas las validaciones son fail-soft: loggear y retornar silenciosamente.
|
||||
*/
|
||||
export async function emitInvoiceIfApplicable(paymentId: string): Promise<void> {
|
||||
try {
|
||||
const payment = await prisma.payment.findUnique({
|
||||
where: { id: paymentId },
|
||||
include: { subscription: true },
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
console.warn(`[Invoicing] Payment ${paymentId} no existe`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Gate 1: ya facturado (idempotencia)
|
||||
if (payment.facturapiInvoiceId) {
|
||||
console.log(`[Invoicing] Payment ${paymentId} ya facturado (${payment.facturapiInvoiceId}), skip`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Gate 2: status
|
||||
if (payment.status !== 'approved') {
|
||||
console.log(`[Invoicing] Payment ${paymentId} status=${payment.status}, skip (sólo approved se factura)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Gate 3: amount
|
||||
const amount = Number(payment.amount);
|
||||
if (!(amount > 0)) {
|
||||
console.log(`[Invoicing] Payment ${paymentId} amount=${amount}, skip (trial o cero)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Gate 4: primer pago del tenant → manual
|
||||
if (await isFirstApprovedPayment(payment.tenantId, payment.id)) {
|
||||
console.log(`[Invoicing] Payment ${paymentId} es el PRIMER pago aprobado del tenant ${payment.tenantId}, skip (factura manual)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Gate 5: emisor configurado
|
||||
const emitter = await getEmitterTenant();
|
||||
|
||||
// Construir payload. El concepto varía por tipo de pago:
|
||||
// - subscription: "Suscripción {plan} {freq} a Horux 360"
|
||||
// - timbres_pack: "{cantidad} timbres adicionales — Horux 360"
|
||||
let description: string;
|
||||
let auditMetadata: Record<string, any>;
|
||||
|
||||
if (payment.kind === 'timbres_pack') {
|
||||
// Recupera cantidad del paquete — vinculado 1:1 con Payment
|
||||
const paquete = await prisma.timbrePaquete.findUnique({
|
||||
where: { paymentId: payment.id },
|
||||
});
|
||||
const cantidad = paquete?.cantidad ?? 0;
|
||||
description = `${cantidad.toLocaleString('es-MX')} timbres adicionales — Horux Despachos`;
|
||||
auditMetadata = { cantidad, amount, kind: 'timbres_pack' };
|
||||
} else {
|
||||
const plan = payment.subscription?.plan || 'trial';
|
||||
const frequency = payment.subscription?.frequency || 'monthly';
|
||||
const descFrecuencia = frequency === 'annual' ? 'anual' : 'mensual';
|
||||
description = `Suscripción ${PLAN_LABELS[plan] || plan} ${descFrecuencia} a Horux Despachos`;
|
||||
auditMetadata = { amount, plan, frequency, kind: 'subscription' };
|
||||
}
|
||||
|
||||
// Resuelve customer real si el tenant pagador tiene CSF + domicilio +
|
||||
// preferencia 'mis_datos'. Si no, null → buildInvoicePayload cae a público
|
||||
// en general como fallback seguro.
|
||||
const customer = await getCustomerFromTenant(payment.tenantId);
|
||||
if (!customer) {
|
||||
console.log(`[Invoicing] Tenant ${payment.tenantId} sin datos fiscales completos o preferencia=publico_general. Facturando a Público en General.`);
|
||||
}
|
||||
|
||||
// Lee uso CFDI preferido del tenant (default G03 ya cargado en BD via default).
|
||||
const tenantPref = await prisma.tenant.findUnique({
|
||||
where: { id: payment.tenantId },
|
||||
select: { factUsoCfdi: true },
|
||||
});
|
||||
const usoCfdi = customer ? (tenantPref?.factUsoCfdi || DEFAULT_USE_CFDI) : FALLBACK_USE_CFDI;
|
||||
|
||||
const payload = buildInvoicePayload({
|
||||
amount,
|
||||
description,
|
||||
emitterCp: emitter.codigoPostal!,
|
||||
paymentMethod: payment.paymentMethod,
|
||||
customer,
|
||||
usoCfdi,
|
||||
});
|
||||
|
||||
console.log(`[Invoicing] Emitiendo factura para Payment ${paymentId} (tenant ${payment.tenantId}, $${amount}, receptor=${customer?.taxId || FALLBACK_TAX_ID})`);
|
||||
const invoice = await facturapiService.createInvoice(emitter.id, payload);
|
||||
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: { facturapiInvoiceId: invoice.id },
|
||||
});
|
||||
|
||||
auditLog({
|
||||
tenantId: payment.tenantId,
|
||||
action: 'invoice.emitted_auto',
|
||||
entityType: 'Payment',
|
||||
entityId: payment.id,
|
||||
metadata: {
|
||||
facturapiInvoiceId: invoice.id,
|
||||
...auditMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[Invoicing] Factura ${invoice.id} emitida y vinculada a Payment ${paymentId}`);
|
||||
} catch (error: any) {
|
||||
// Fail-soft: log y retorno silencioso. El admin puede re-emitir manualmente.
|
||||
console.error(`[Invoicing] Error emitiendo factura para Payment ${paymentId}:`, error.message || error);
|
||||
}
|
||||
}
|
||||
340
apps/api/src/services/payment/mercadopago.service.ts
Normal file
340
apps/api/src/services/payment/mercadopago.service.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { MercadoPagoConfig, PreApproval, Payment as MPPayment, Preference } from 'mercadopago';
|
||||
import { env } from '../../config/env.js';
|
||||
import { createHmac } from 'crypto';
|
||||
|
||||
// Selección del token según MP_USE_SANDBOX. Si se pide sandbox pero no hay
|
||||
// MP_ACCESS_TOKEN_SANDBOX seteado, cae al de producción con warning — eso permite
|
||||
// detectar config faltante sin romper el arranque.
|
||||
const useSandbox = env.MP_USE_SANDBOX && !!env.MP_ACCESS_TOKEN_SANDBOX;
|
||||
if (env.MP_USE_SANDBOX && !env.MP_ACCESS_TOKEN_SANDBOX) {
|
||||
console.warn(
|
||||
'[MP] MP_USE_SANDBOX=true pero MP_ACCESS_TOKEN_SANDBOX no está configurado. ' +
|
||||
'Cayendo al token de producción (MP_ACCESS_TOKEN). ' +
|
||||
'Configura el TEST-... token en .env para usar sandbox real.'
|
||||
);
|
||||
} else if (useSandbox) {
|
||||
console.log('[MP] Modo SANDBOX activo — usando MP_ACCESS_TOKEN_SANDBOX para todas las llamadas a MercadoPago.');
|
||||
}
|
||||
const activeToken = useSandbox ? env.MP_ACCESS_TOKEN_SANDBOX! : (env.MP_ACCESS_TOKEN || '');
|
||||
|
||||
const config = new MercadoPagoConfig({
|
||||
accessToken: activeToken,
|
||||
});
|
||||
|
||||
const preApprovalClient = new PreApproval(config);
|
||||
const paymentClient = new MPPayment(config);
|
||||
const preferenceClient = new Preference(config);
|
||||
|
||||
/**
|
||||
* Fallback público para `back_url` cuando `FRONTEND_URL` apunta a localhost.
|
||||
* MercadoPago rechaza URLs `http://localhost...` o cualquier dominio no
|
||||
* resoluble desde sus servidores con `400 Invalid value for back_url`.
|
||||
*
|
||||
* En dev (FRONTEND_URL=http://localhost:3000) sustituimos por la URL de
|
||||
* producción para que el preapproval/preference se cree exitosamente y MP
|
||||
* abra su flujo de pago. Después del pago, MP redirige al usuario a esa URL
|
||||
* de prod (no al local) — para retorno limpio al local hace falta tunnel
|
||||
* tipo ngrok. Pero el flujo de cobro funciona end-to-end en MP.
|
||||
*
|
||||
* En prod (FRONTEND_URL=https://horuxfin.com) es no-op.
|
||||
*/
|
||||
const PUBLIC_BACK_URL_FALLBACK = 'https://horuxfin.com';
|
||||
|
||||
let warnedLocalhost = false;
|
||||
function backUrlBase(): string {
|
||||
const fe = env.FRONTEND_URL;
|
||||
if (!fe || /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)/i.test(fe)) {
|
||||
if (!warnedLocalhost) {
|
||||
console.warn(
|
||||
`[MP] FRONTEND_URL=${fe} no es válida para back_url de MercadoPago. ` +
|
||||
`Usando fallback público ${PUBLIC_BACK_URL_FALLBACK}. Para retorno ` +
|
||||
`limpio al local usa ngrok y override FRONTEND_URL.`
|
||||
);
|
||||
warnedLocalhost = true;
|
||||
}
|
||||
return PUBLIC_BACK_URL_FALLBACK;
|
||||
}
|
||||
return fe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override del payer_email para entornos donde el owner del tenant tiene el
|
||||
* mismo correo vinculado al MP_ACCESS_TOKEN (vendedor) — MP rechaza con
|
||||
* "Payer and collector cannot be the same user". En ese caso seteas
|
||||
* `MP_TEST_PAYER_EMAIL` en `.env` y todas las llamadas a MP usan ese email
|
||||
* como pagador. Production: dejar sin setear → no-op.
|
||||
*/
|
||||
let warnedTestPayer = false;
|
||||
function resolvePayerEmail(callerEmail: string): string {
|
||||
if (env.MP_TEST_PAYER_EMAIL) {
|
||||
if (!warnedTestPayer) {
|
||||
console.warn(
|
||||
`[MP] Override de payer_email activo: usando ${env.MP_TEST_PAYER_EMAIL} ` +
|
||||
`(MP_TEST_PAYER_EMAIL) en lugar del email del owner del tenant. ` +
|
||||
`Quitar la variable en producción.`
|
||||
);
|
||||
warnedTestPayer = true;
|
||||
}
|
||||
return env.MP_TEST_PAYER_EMAIL;
|
||||
}
|
||||
return callerEmail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a recurring subscription (preapproval) in MercadoPago.
|
||||
* Soporta cadencia mensual (cada 1 mes) o anual (cada 12 meses).
|
||||
*/
|
||||
export async function createPreapproval(params: {
|
||||
tenantId: string;
|
||||
reason: string;
|
||||
amount: number;
|
||||
payerEmail: string;
|
||||
frequency?: 'monthly' | 'annual';
|
||||
/**
|
||||
* Fecha del primer cobro. Si no se especifica, MP cobra al día siguiente.
|
||||
* Útil para reactivaciones: el cliente ya pagó hasta `currentPeriodEnd`,
|
||||
* queremos que MP empiece a cobrar desde ese momento, no mañana.
|
||||
*/
|
||||
startDate?: Date;
|
||||
/**
|
||||
* Referencia externa para el preapproval. Si no se especifica, se usa tenantId.
|
||||
* Usar `addon:{subscriptionAddonId}` para preapprovals de addons.
|
||||
*/
|
||||
externalReference?: string;
|
||||
}) {
|
||||
if (!env.MP_ACCESS_TOKEN) {
|
||||
throw new Error(
|
||||
'MercadoPago no está configurado (falta MP_ACCESS_TOKEN en .env). ' +
|
||||
'Pide al dueño de la cuenta que agregue el token de acceso para habilitar cobros.'
|
||||
);
|
||||
}
|
||||
|
||||
const freq = params.frequency === 'annual'
|
||||
? { frequency: 12, frequency_type: 'months' as const }
|
||||
: { frequency: 1, frequency_type: 'months' as const };
|
||||
|
||||
// start_date sólo se envía si es en el futuro (MP rechaza fechas pasadas).
|
||||
const now = new Date();
|
||||
const startDateIso = params.startDate && params.startDate.getTime() > now.getTime()
|
||||
? params.startDate.toISOString()
|
||||
: undefined;
|
||||
|
||||
const response = await preApprovalClient.create({
|
||||
body: {
|
||||
reason: params.reason,
|
||||
external_reference: params.externalReference || params.tenantId,
|
||||
payer_email: resolvePayerEmail(params.payerEmail),
|
||||
auto_recurring: {
|
||||
...freq,
|
||||
transaction_amount: params.amount,
|
||||
currency_id: 'MXN',
|
||||
...(startDateIso ? { start_date: startDateIso } : {}),
|
||||
},
|
||||
back_url: `${backUrlBase()}/configuracion/suscripcion`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
preapprovalId: response.id!,
|
||||
initPoint: response.init_point!,
|
||||
status: response.status!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela un preapproval en MercadoPago. No falla si ya está cancelado o no existe.
|
||||
*/
|
||||
export async function cancelPreapproval(preapprovalId: string): Promise<void> {
|
||||
try {
|
||||
await preApprovalClient.update({
|
||||
id: preapprovalId,
|
||||
body: { status: 'cancelled' },
|
||||
});
|
||||
} catch (error: any) {
|
||||
// No tiramos el error si el preapproval ya no existe o ya está cancelado
|
||||
console.warn(`[MP] cancelPreapproval(${preapprovalId}):`, error.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza el monto recurrente de un preapproval existente (usado en upgrades:
|
||||
* después de cobrar el prorateo vía Preference, subimos el monto del preapproval
|
||||
* para que el próximo cobro recurrente sea el del plan nuevo).
|
||||
*/
|
||||
export async function updatePreapprovalAmount(
|
||||
preapprovalId: string,
|
||||
newAmount: number,
|
||||
): Promise<void> {
|
||||
if (!env.MP_ACCESS_TOKEN) {
|
||||
throw new Error('MercadoPago no está configurado (falta MP_ACCESS_TOKEN)');
|
||||
}
|
||||
await preApprovalClient.update({
|
||||
id: preapprovalId,
|
||||
body: {
|
||||
auto_recurring: {
|
||||
transaction_amount: newAmount,
|
||||
currency_id: 'MXN',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una Preference (checkout de pago único) para cobrar el prorateo de un upgrade.
|
||||
* `externalReference` se prefija con `proration:` para que el webhook distinga este
|
||||
* pago del cobro recurrente del preapproval.
|
||||
*/
|
||||
export async function createProrationPreference(params: {
|
||||
tenantId: string;
|
||||
subscriptionId: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
payerEmail: string;
|
||||
}): Promise<{ preferenceId: string; checkoutUrl: string }> {
|
||||
if (!env.MP_ACCESS_TOKEN) {
|
||||
throw new Error(
|
||||
'MercadoPago no está configurado (falta MP_ACCESS_TOKEN en .env). ' +
|
||||
'No es posible cobrar el prorateo del upgrade.'
|
||||
);
|
||||
}
|
||||
|
||||
const response = await preferenceClient.create({
|
||||
body: {
|
||||
items: [
|
||||
{
|
||||
id: `proration-${params.subscriptionId}`,
|
||||
title: params.description,
|
||||
quantity: 1,
|
||||
unit_price: params.amount,
|
||||
currency_id: 'MXN',
|
||||
},
|
||||
],
|
||||
payer: { email: resolvePayerEmail(params.payerEmail) },
|
||||
// El prefijo proration: es el marcador que el webhook usa para ramificar
|
||||
external_reference: `proration:${params.tenantId}:${params.subscriptionId}`,
|
||||
back_urls: {
|
||||
success: `${backUrlBase()}/configuracion/suscripcion?upgrade=success`,
|
||||
failure: `${backUrlBase()}/configuracion/suscripcion?upgrade=failure`,
|
||||
pending: `${backUrlBase()}/configuracion/suscripcion?upgrade=pending`,
|
||||
},
|
||||
auto_return: 'approved',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
preferenceId: response.id!,
|
||||
checkoutUrl: response.init_point!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea una Preference (checkout de pago único) para comprar un paquete de
|
||||
* timbres adicionales. external_reference = `timbres-pack:${paymentId}` para
|
||||
* que el webhook ramifique al handler correspondiente (crea TimbrePaquete +
|
||||
* marca Payment approved + emite factura).
|
||||
*/
|
||||
export async function createTimbrePackPreference(params: {
|
||||
paymentId: string; // Payment.id del record pre-creado con status=pending
|
||||
tenantId: string;
|
||||
cantidad: number;
|
||||
amount: number;
|
||||
payerEmail: string;
|
||||
}): Promise<{ preferenceId: string; checkoutUrl: string }> {
|
||||
if (!env.MP_ACCESS_TOKEN) {
|
||||
throw new Error('MercadoPago no está configurado (MP_ACCESS_TOKEN faltante).');
|
||||
}
|
||||
|
||||
const title = `${params.cantidad.toLocaleString('es-MX')} timbres adicionales — Horux 360`;
|
||||
|
||||
const response = await preferenceClient.create({
|
||||
body: {
|
||||
items: [
|
||||
{
|
||||
id: `timbres-pack-${params.paymentId}`,
|
||||
title,
|
||||
quantity: 1,
|
||||
unit_price: params.amount,
|
||||
currency_id: 'MXN',
|
||||
},
|
||||
],
|
||||
payer: { email: resolvePayerEmail(params.payerEmail) },
|
||||
external_reference: `timbres-pack:${params.paymentId}`,
|
||||
back_urls: {
|
||||
success: `${backUrlBase()}/facturacion?timbres=success`,
|
||||
failure: `${backUrlBase()}/facturacion?timbres=failure`,
|
||||
pending: `${backUrlBase()}/facturacion?timbres=pending`,
|
||||
},
|
||||
auto_return: 'approved',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
preferenceId: response.id!,
|
||||
checkoutUrl: response.init_point!,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets subscription (preapproval) status from MercadoPago
|
||||
*/
|
||||
export async function getPreapproval(preapprovalId: string) {
|
||||
const response = await preApprovalClient.get({ id: preapprovalId });
|
||||
return {
|
||||
id: response.id,
|
||||
status: response.status,
|
||||
payerEmail: response.payer_email,
|
||||
nextPaymentDate: response.next_payment_date,
|
||||
autoRecurring: response.auto_recurring,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets payment details from MercadoPago
|
||||
*/
|
||||
export async function getPaymentDetails(paymentId: string) {
|
||||
const response = await paymentClient.get({ id: paymentId });
|
||||
return {
|
||||
id: response.id,
|
||||
status: response.status,
|
||||
statusDetail: response.status_detail,
|
||||
transactionAmount: response.transaction_amount,
|
||||
currencyId: response.currency_id,
|
||||
payerEmail: response.payer?.email,
|
||||
dateApproved: response.date_approved,
|
||||
paymentMethodId: response.payment_method_id,
|
||||
externalReference: response.external_reference,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies MercadoPago webhook signature (HMAC-SHA256)
|
||||
*/
|
||||
export function verifyWebhookSignature(
|
||||
xSignature: string,
|
||||
xRequestId: string,
|
||||
dataId: string
|
||||
): boolean {
|
||||
if (!env.MP_WEBHOOK_SECRET) {
|
||||
console.error('[WEBHOOK] MP_WEBHOOK_SECRET not configured - rejecting webhook');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse x-signature header: "ts=...,v1=..."
|
||||
const parts: Record<string, string> = {};
|
||||
for (const part of xSignature.split(',')) {
|
||||
const [key, value] = part.split('=');
|
||||
parts[key.trim()] = value.trim();
|
||||
}
|
||||
|
||||
const ts = parts['ts'];
|
||||
const v1 = parts['v1'];
|
||||
if (!ts || !v1) return false;
|
||||
|
||||
// Build the manifest string
|
||||
const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`;
|
||||
const hmac = createHmac('sha256', env.MP_WEBHOOK_SECRET)
|
||||
.update(manifest)
|
||||
.digest('hex');
|
||||
|
||||
return hmac === v1;
|
||||
}
|
||||
1199
apps/api/src/services/payment/subscription.service.ts
Normal file
1199
apps/api/src/services/payment/subscription.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
210
apps/api/src/services/plan-catalogo.service.ts
Normal file
210
apps/api/src/services/plan-catalogo.service.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
export interface PlanLimits {
|
||||
maxRfcs: number;
|
||||
maxUsers: number;
|
||||
timbresIncluidosMes: number;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface AddonDelta {
|
||||
maxRfcs?: number;
|
||||
maxUsers?: number;
|
||||
timbresIncluidosMes?: number;
|
||||
features?: string[];
|
||||
}
|
||||
|
||||
export interface DespachoPlanLimits {
|
||||
plan: string;
|
||||
nombre: string;
|
||||
monthly: number | null;
|
||||
firstYear: number | null;
|
||||
renewal: number | null;
|
||||
permiteMonthly: boolean;
|
||||
maxRfcs: number;
|
||||
maxUsers: number;
|
||||
timbresIncluidosMes: number;
|
||||
dbMode: 'BYO' | 'MANAGED';
|
||||
permiteServidorBackup: boolean;
|
||||
permiteSatIncremental: boolean;
|
||||
}
|
||||
|
||||
/** Suma deltas de addons activos sobre los limits base de un plan. -1 = ilimitado se preserva. */
|
||||
export function computeEffectiveLimits(baseLimits: PlanLimits, addonDeltas: AddonDelta[]): PlanLimits {
|
||||
const result: PlanLimits = {
|
||||
maxRfcs: baseLimits.maxRfcs,
|
||||
maxUsers: baseLimits.maxUsers,
|
||||
timbresIncluidosMes: baseLimits.timbresIncluidosMes,
|
||||
features: [...baseLimits.features],
|
||||
};
|
||||
|
||||
for (const delta of addonDeltas) {
|
||||
if (delta.maxRfcs) {
|
||||
result.maxRfcs = result.maxRfcs === -1 ? -1 : result.maxRfcs + delta.maxRfcs;
|
||||
}
|
||||
if (delta.maxUsers) {
|
||||
result.maxUsers = result.maxUsers === -1 ? -1 : result.maxUsers + delta.maxUsers;
|
||||
}
|
||||
if (delta.timbresIncluidosMes) {
|
||||
result.timbresIncluidosMes += delta.timbresIncluidosMes;
|
||||
}
|
||||
if (delta.features) {
|
||||
for (const f of delta.features) {
|
||||
if (!result.features.includes(f)) result.features.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Catálogo despacho — lee de despacho_plan_prices con cache 5min
|
||||
// ============================================================================
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
let cacheData: Map<string, DespachoPlanLimits> | null = null;
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
async function loadCache(): Promise<Map<string, DespachoPlanLimits>> {
|
||||
const rows = await prisma.despachoPlanPrice.findMany();
|
||||
const map = new Map<string, DespachoPlanLimits>();
|
||||
for (const r of rows) {
|
||||
map.set(r.plan, {
|
||||
plan: r.plan,
|
||||
nombre: r.nombre,
|
||||
monthly: r.monthly !== null ? Number(r.monthly) : null,
|
||||
firstYear: r.firstYear !== null ? Number(r.firstYear) : null,
|
||||
renewal: r.renewal !== null ? Number(r.renewal) : null,
|
||||
permiteMonthly: r.permiteMonthly,
|
||||
maxRfcs: r.maxRfcs,
|
||||
maxUsers: r.maxUsers,
|
||||
timbresIncluidosMes: r.timbresIncluidosMes,
|
||||
dbMode: r.dbMode as 'BYO' | 'MANAGED',
|
||||
permiteServidorBackup: r.permiteServidorBackup,
|
||||
permiteSatIncremental: r.permiteSatIncremental,
|
||||
});
|
||||
}
|
||||
cacheData = map;
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Invalida el cache. Llamar tras editar precios/limits desde admin. */
|
||||
export function invalidateDespachoPlanCache(): void {
|
||||
cacheData = null;
|
||||
cacheExpiresAt = 0;
|
||||
}
|
||||
|
||||
/** Lee limits + precios de un plan despacho. Cache 5min. */
|
||||
export async function getDespachoPlanLimits(plan: string): Promise<DespachoPlanLimits | null> {
|
||||
if (!cacheData || Date.now() > cacheExpiresAt) await loadCache();
|
||||
return cacheData!.get(plan) ?? null;
|
||||
}
|
||||
|
||||
/** Lee todos los planes despacho. Cache 5min. */
|
||||
export async function getAllDespachoPlanLimits(): Promise<DespachoPlanLimits[]> {
|
||||
if (!cacheData || Date.now() > cacheExpiresAt) await loadCache();
|
||||
return Array.from(cacheData!.values());
|
||||
}
|
||||
|
||||
/** True si el plan acepta frecuencia mensual. Lee de BD via cache. */
|
||||
export async function permiteFrecuenciaMensualDb(plan: string): Promise<boolean> {
|
||||
const cfg = await getDespachoPlanLimits(plan);
|
||||
return cfg?.permiteMonthly ?? false;
|
||||
}
|
||||
|
||||
/** True si el plan cobra distinto en el primer año vs renovaciones. Lee de BD. */
|
||||
export async function despachoPlanTieneDualidadDb(plan: string): Promise<boolean> {
|
||||
const cfg = await getDespachoPlanLimits(plan);
|
||||
if (!cfg || cfg.firstYear === null || cfg.renewal === null) return false;
|
||||
return cfg.firstYear !== cfg.renewal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve el precio MXN para un (plan, frequency, phase) leyendo de BD.
|
||||
* Throws si el plan no existe en BD o no permite la frecuencia solicitada.
|
||||
*/
|
||||
export async function getPrecioDespachoDb(
|
||||
plan: string,
|
||||
frequency: 'monthly' | 'annual',
|
||||
phase: 'firstYear' | 'renewal' = 'renewal',
|
||||
): Promise<number> {
|
||||
const cfg = await getDespachoPlanLimits(plan);
|
||||
if (!cfg) throw new Error(`Plan ${plan} no encontrado en catálogo BD`);
|
||||
if (frequency === 'monthly') {
|
||||
if (!cfg.permiteMonthly || cfg.monthly === null) {
|
||||
throw new Error(`El plan ${plan} no permite frecuencia mensual`);
|
||||
}
|
||||
return cfg.monthly;
|
||||
}
|
||||
const price = phase === 'firstYear' ? cfg.firstYear : cfg.renewal;
|
||||
if (price === null) throw new Error(`El plan ${plan} no tiene precio anual configurado`);
|
||||
return price;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Endpoints viejos — backward compat con /plan-catalogo/* routes
|
||||
// (sin callers frontend conocidos; se mantienen por si algo externo consume)
|
||||
// ============================================================================
|
||||
|
||||
export async function listPlans(_verticalProfile?: string) {
|
||||
const all = await getAllDespachoPlanLimits();
|
||||
// Excluir trial y custom del catálogo público (admin-only)
|
||||
return all
|
||||
.filter(p => p.plan !== 'trial' && p.plan !== 'custom')
|
||||
.map(p => ({
|
||||
codename: p.plan,
|
||||
nombre: p.nombre,
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precioBase: p.firstYear ?? 0,
|
||||
frecuencia: p.permiteMonthly ? 'mensual' : 'anual',
|
||||
limits: {
|
||||
maxRfcs: p.maxRfcs,
|
||||
maxUsers: p.maxUsers,
|
||||
timbresIncluidosMes: p.timbresIncluidosMes,
|
||||
features: [], // features viven en TS (DESPACHO_PLANS); este endpoint no las expone
|
||||
} as PlanLimits,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getPlanByCodename(codename: string) {
|
||||
const p = await getDespachoPlanLimits(codename);
|
||||
if (!p) return null;
|
||||
return {
|
||||
codename: p.plan,
|
||||
nombre: p.nombre,
|
||||
verticalProfile: 'CONTABLE' as const,
|
||||
precioBase: p.firstYear ?? 0,
|
||||
frecuencia: p.permiteMonthly ? 'mensual' : 'anual',
|
||||
limits: {
|
||||
maxRfcs: p.maxRfcs,
|
||||
maxUsers: p.maxUsers,
|
||||
timbresIncluidosMes: p.timbresIncluidosMes,
|
||||
features: [],
|
||||
} as PlanLimits,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAddons(verticalProfile?: string) {
|
||||
const where: any = { active: true };
|
||||
if (verticalProfile) {
|
||||
where.OR = [
|
||||
{ verticalProfile },
|
||||
{ verticalProfile: null },
|
||||
];
|
||||
}
|
||||
const addons = await prisma.planAddonCatalogo.findMany({
|
||||
where,
|
||||
orderBy: { precio: 'asc' },
|
||||
});
|
||||
return addons.map(a => ({
|
||||
id: a.id,
|
||||
codename: a.codename,
|
||||
nombre: a.nombre,
|
||||
verticalProfile: a.verticalProfile,
|
||||
precio: Number(a.precio),
|
||||
frecuencia: a.frecuencia,
|
||||
delta: a.delta as AddonDelta,
|
||||
}));
|
||||
}
|
||||
143
apps/api/src/services/recordatorios.service.ts
Normal file
143
apps/api/src/services/recordatorios.service.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { Pool } from 'pg';
|
||||
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
|
||||
|
||||
/**
|
||||
* Obtiene recordatorios visibles para el usuario.
|
||||
* - Públicos: todos los del tenant
|
||||
* - Privados: solo los creados por el usuario
|
||||
*/
|
||||
export async function getRecordatorios(
|
||||
pool: Pool,
|
||||
userId: string,
|
||||
año: number
|
||||
): Promise<EventoFiscal[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, titulo, descripcion, fecha_limite as "fechaLimite",
|
||||
notas, completado, privado, creado_por as "creadoPor",
|
||||
created_at as "createdAt"
|
||||
FROM recordatorios
|
||||
WHERE EXTRACT(YEAR FROM fecha_limite) = $1
|
||||
AND (privado = false OR creado_por = $2)
|
||||
ORDER BY fecha_limite
|
||||
`, [año, userId]);
|
||||
|
||||
return rows.map(r => ({
|
||||
id: r.id,
|
||||
titulo: r.titulo,
|
||||
descripcion: r.descripcion || '',
|
||||
tipo: 'custom' as const,
|
||||
fechaLimite: r.fechaLimite instanceof Date
|
||||
? r.fechaLimite.toISOString().split('T')[0]
|
||||
: String(r.fechaLimite).split('T')[0],
|
||||
recurrencia: 'unica' as const,
|
||||
completado: r.completado,
|
||||
notas: r.notas,
|
||||
privado: r.privado,
|
||||
creadoPor: r.creadoPor,
|
||||
createdAt: r.createdAt?.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createRecordatorio(
|
||||
pool: Pool,
|
||||
userId: string,
|
||||
data: EventoCreate & { privado?: boolean }
|
||||
): Promise<EventoFiscal> {
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO recordatorios (titulo, descripcion, fecha_limite, notas, privado, creado_por)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite",
|
||||
notas, completado, privado, creado_por as "creadoPor",
|
||||
created_at as "createdAt"
|
||||
`, [
|
||||
data.titulo,
|
||||
data.descripcion || null,
|
||||
data.fechaLimite,
|
||||
data.notas || null,
|
||||
data.privado ?? false,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const r = rows[0];
|
||||
return {
|
||||
id: r.id,
|
||||
titulo: r.titulo,
|
||||
descripcion: r.descripcion || '',
|
||||
tipo: 'custom',
|
||||
fechaLimite: r.fechaLimite instanceof Date
|
||||
? r.fechaLimite.toISOString().split('T')[0]
|
||||
: String(r.fechaLimite).split('T')[0],
|
||||
recurrencia: 'unica',
|
||||
completado: r.completado,
|
||||
notas: r.notas,
|
||||
createdAt: r.createdAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateRecordatorio(
|
||||
pool: Pool,
|
||||
userId: string,
|
||||
id: number,
|
||||
data: EventoUpdate & { privado?: boolean }
|
||||
): Promise<EventoFiscal | null> {
|
||||
// Verify ownership or public
|
||||
const { rows: existing } = await pool.query(
|
||||
`SELECT id, creado_por FROM recordatorios WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (existing.length === 0) return null;
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.titulo !== undefined) { sets.push(`titulo = $${idx++}`); params.push(data.titulo); }
|
||||
if (data.descripcion !== undefined) { sets.push(`descripcion = $${idx++}`); params.push(data.descripcion); }
|
||||
if (data.fechaLimite !== undefined) { sets.push(`fecha_limite = $${idx++}`); params.push(data.fechaLimite); }
|
||||
if (data.completado !== undefined) { sets.push(`completado = $${idx++}`); params.push(data.completado); }
|
||||
if (data.notas !== undefined) { sets.push(`notas = $${idx++}`); params.push(data.notas); }
|
||||
if (data.privado !== undefined) { sets.push(`privado = $${idx++}`); params.push(data.privado); }
|
||||
|
||||
if (sets.length === 0) return null;
|
||||
|
||||
sets.push(`updated_at = NOW()`);
|
||||
params.push(id);
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
UPDATE recordatorios SET ${sets.join(', ')}
|
||||
WHERE id = $${idx}
|
||||
RETURNING id, titulo, descripcion, fecha_limite as "fechaLimite",
|
||||
notas, completado, privado, creado_por as "creadoPor",
|
||||
created_at as "createdAt"
|
||||
`, params);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const r = rows[0];
|
||||
return {
|
||||
id: r.id,
|
||||
titulo: r.titulo,
|
||||
descripcion: r.descripcion || '',
|
||||
tipo: 'custom',
|
||||
fechaLimite: r.fechaLimite instanceof Date
|
||||
? r.fechaLimite.toISOString().split('T')[0]
|
||||
: String(r.fechaLimite).split('T')[0],
|
||||
recurrencia: 'unica',
|
||||
completado: r.completado,
|
||||
notas: r.notas,
|
||||
createdAt: r.createdAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteRecordatorio(
|
||||
pool: Pool,
|
||||
userId: string,
|
||||
id: number
|
||||
): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM recordatorios WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
92
apps/api/src/services/regimen.service.ts
Normal file
92
apps/api/src/services/regimen.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma } from '../config/database.js';
|
||||
|
||||
export async function getAllRegimenes() {
|
||||
return prisma.regimen.findMany({
|
||||
where: { activo: true },
|
||||
orderBy: { clave: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRegimenesIgnorados(tenantId: string) {
|
||||
const rows = await prisma.tenantRegimenIgnorado.findMany({
|
||||
where: { tenantId },
|
||||
include: { regimen: true },
|
||||
orderBy: { regimen: { clave: 'asc' } },
|
||||
});
|
||||
return rows.map(r => r.regimen);
|
||||
}
|
||||
|
||||
export async function getRegimenesIgnoradosClaves(tenantId: string): Promise<string[]> {
|
||||
const rows = await prisma.tenantRegimenIgnorado.findMany({
|
||||
where: { tenantId },
|
||||
include: { regimen: { select: { clave: true } } },
|
||||
});
|
||||
return rows.map(r => r.regimen.clave);
|
||||
}
|
||||
|
||||
export async function getRegimenesActivos(tenantId: string) {
|
||||
const rows = await prisma.tenantRegimenActivo.findMany({
|
||||
where: { tenantId },
|
||||
include: { regimen: true },
|
||||
orderBy: { regimen: { clave: 'asc' } },
|
||||
});
|
||||
return rows.map(r => r.regimen);
|
||||
}
|
||||
|
||||
export async function getRegimenesActivosClaves(tenantId: string): Promise<string[]> {
|
||||
const rows = await prisma.tenantRegimenActivo.findMany({
|
||||
where: { tenantId },
|
||||
include: { regimen: { select: { clave: true } } },
|
||||
});
|
||||
return rows.map(r => r.regimen.clave);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve las claves de regímenes activos para la alerta de discrepancia.
|
||||
* Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated).
|
||||
* Si no, fallback a TenantRegimenActivo (tabla central).
|
||||
*/
|
||||
export async function getRegimenesActivosClavesEfectivos(
|
||||
tenantId: string,
|
||||
pool: Pool,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<string[]> {
|
||||
if (contribuyenteId) {
|
||||
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||
const { rows } = await pool.query(
|
||||
`SELECT regimen_fiscal FROM contribuyentes WHERE entidad_id = $1`,
|
||||
[safeId],
|
||||
);
|
||||
if (rows.length > 0 && rows[0].regimen_fiscal) {
|
||||
return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
return getRegimenesActivosClaves(tenantId);
|
||||
}
|
||||
|
||||
export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) {
|
||||
await prisma.tenantRegimenActivo.deleteMany({ where: { tenantId } });
|
||||
|
||||
if (regimenIds.length > 0) {
|
||||
await prisma.tenantRegimenActivo.createMany({
|
||||
data: regimenIds.map(regimenId => ({ tenantId, regimenId })),
|
||||
});
|
||||
}
|
||||
|
||||
return getRegimenesActivos(tenantId);
|
||||
}
|
||||
|
||||
export async function setRegimenesIgnorados(tenantId: string, regimenIds: number[]) {
|
||||
// Delete all existing and re-insert
|
||||
await prisma.tenantRegimenIgnorado.deleteMany({ where: { tenantId } });
|
||||
|
||||
if (regimenIds.length > 0) {
|
||||
await prisma.tenantRegimenIgnorado.createMany({
|
||||
data: regimenIds.map(regimenId => ({ tenantId, regimenId })),
|
||||
});
|
||||
}
|
||||
|
||||
return getRegimenesIgnorados(tenantId);
|
||||
}
|
||||
401
apps/api/src/services/reportes.service.ts
Normal file
401
apps/api/src/services/reportes.service.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import type { Pool } from 'pg';
|
||||
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
|
||||
import { calcularIngresosPorRegimen, calcularEgresosPorRegimen } from './dashboard.service.js';
|
||||
|
||||
/**
|
||||
* Resuelve condiciones `esEmisor` / `esReceptor` para un contribuyente
|
||||
* usando su RFC. Reemplaza el par `type = 'X' AND contribuyente_id = Y`.
|
||||
* Si no hay contribuyente, retorna fallback a `type`.
|
||||
*/
|
||||
async function resolveEmisorReceptor(
|
||||
pool: Pool,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<{ esEmisor: string; esReceptor: string }> {
|
||||
if (!contribuyenteId) {
|
||||
return { esEmisor: `type = 'EMITIDO'`, esReceptor: `type = 'RECIBIDO'` };
|
||||
}
|
||||
const safeId = contribuyenteId.replace(/[^a-f0-9-]/gi, '');
|
||||
if (!safeId) return { esEmisor: `type = 'EMITIDO'`, esReceptor: `type = 'RECIBIDO'` };
|
||||
const { rows } = await pool.query<{ rfc: string | null }>(
|
||||
`SELECT rfc FROM contribuyentes WHERE entidad_id = $1`,
|
||||
[safeId],
|
||||
);
|
||||
const rfc = (rows[0]?.rfc || '').replace(/[^A-Z0-9]/gi, '').toUpperCase();
|
||||
if (!rfc) {
|
||||
return {
|
||||
esEmisor: `type = 'EMITIDO' AND contribuyente_id = '${safeId}'`,
|
||||
esReceptor: `type = 'RECIBIDO' AND contribuyente_id = '${safeId}'`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
esEmisor: `UPPER(rfc_emisor) = '${rfc}'`,
|
||||
esReceptor: `UPPER(rfc_receptor) = '${rfc}'`,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeContribUuid(id?: string | null): string {
|
||||
return id ? id.replace(/[^a-f0-9-]/gi, '') : '';
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
if (value === null || value === undefined) return 0;
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'bigint') return Number(value);
|
||||
if (typeof value === 'string') return parseFloat(value) || 0;
|
||||
if (typeof value === 'object' && value !== null && 'toNumber' in value) {
|
||||
return (value as { toNumber: () => number }).toNumber();
|
||||
}
|
||||
return Number(value) || 0;
|
||||
}
|
||||
|
||||
export async function getEstadoResultados(
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string,
|
||||
tenantId: string,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<EstadoResultados> {
|
||||
// Totales usando la misma lógica del dashboard
|
||||
const ingresosData = await calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, undefined, contribuyenteId);
|
||||
const egresosData = await calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, undefined, contribuyenteId);
|
||||
|
||||
const totalIngresos = ingresosData.total;
|
||||
const totalEgresos = egresosData.total;
|
||||
const utilidadBruta = totalIngresos - totalEgresos;
|
||||
|
||||
// Desglose por régimen como conceptos
|
||||
const ingresosConceptos = ingresosData.porRegimen.map(r => ({
|
||||
concepto: `${r.regimenClave} - ${r.regimenDescripcion}`,
|
||||
monto: r.monto,
|
||||
}));
|
||||
|
||||
const egresosConceptos = egresosData.porRegimen.map(r => ({
|
||||
concepto: `${r.regimenClave} - ${r.regimenDescripcion}`,
|
||||
monto: r.monto,
|
||||
}));
|
||||
|
||||
return {
|
||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||
ingresos: ingresosConceptos,
|
||||
egresos: egresosConceptos,
|
||||
totalIngresos,
|
||||
totalEgresos,
|
||||
utilidadBruta,
|
||||
impuestos: 0,
|
||||
utilidadNeta: utilidadBruta,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFlujoEfectivo(
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<FlujoEfectivo> {
|
||||
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
||||
const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
|
||||
const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
|
||||
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
|
||||
|
||||
const { rows: entradasPUE } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
|
||||
AND ${VIGENTE} AND ${RANGO}
|
||||
GROUP BY mes ORDER BY mes
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const { rows: entradasPago } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${esEmisor} AND tipo_comprobante = 'P'
|
||||
AND ${VIGENTE} AND ${RANGO_PAGO}
|
||||
GROUP BY mes ORDER BY mes
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const { rows: entradasNC } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${esEmisor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND COALESCE(cfdi_tipo_relacion, '') <> '07'
|
||||
AND ${VIGENTE} AND ${RANGO}
|
||||
GROUP BY mes ORDER BY mes
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const { rows: salidasPUE } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PUE'
|
||||
AND ${VIGENTE} AND ${RANGO}
|
||||
GROUP BY mes ORDER BY mes
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const { rows: salidasPago } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_pago_p, 'YYYY-MM') as mes, COALESCE(SUM(monto_pago_mxn), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${esReceptor} AND tipo_comprobante = 'P'
|
||||
AND ${VIGENTE} AND ${RANGO_PAGO}
|
||||
GROUP BY mes ORDER BY mes
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const { rows: salidasNC } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, COALESCE(SUM(total_mxn), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${esReceptor} AND tipo_comprobante = 'E' AND metodo_pago = 'PUE'
|
||||
AND COALESCE(cfdi_tipo_relacion, '') <> '07'
|
||||
AND ${VIGENTE} AND ${RANGO}
|
||||
GROUP BY mes ORDER BY mes
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
// Combinar por mes
|
||||
const mesesSet = new Set<string>();
|
||||
[...entradasPUE, ...entradasPago, ...entradasNC, ...salidasPUE, ...salidasPago, ...salidasNC].forEach((r: any) => mesesSet.add(r.mes));
|
||||
const mesesOrdenados = Array.from(mesesSet).sort();
|
||||
|
||||
const get = (rows: any[], mes: string) => toNumber(rows.find((r: any) => r.mes === mes)?.total);
|
||||
|
||||
const entradas = mesesOrdenados.map(mes => ({
|
||||
concepto: mes,
|
||||
monto: get(entradasPUE, mes) + get(entradasPago, mes) - get(entradasNC, mes),
|
||||
}));
|
||||
|
||||
const salidas = mesesOrdenados.map(mes => ({
|
||||
concepto: mes,
|
||||
monto: get(salidasPUE, mes) + get(salidasPago, mes) - get(salidasNC, mes),
|
||||
}));
|
||||
|
||||
const totalEntradas = entradas.reduce((s, e) => s + e.monto, 0);
|
||||
const totalSalidas = salidas.reduce((s, e) => s + e.monto, 0);
|
||||
|
||||
return {
|
||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||
saldoInicial: 0,
|
||||
entradas,
|
||||
salidas,
|
||||
totalEntradas,
|
||||
totalSalidas,
|
||||
flujoNeto: totalEntradas - totalSalidas,
|
||||
saldoFinal: totalEntradas - totalSalidas,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula entradas/salidas de un año completo mes a mes con la lógica de flujo de efectivo.
|
||||
*/
|
||||
async function calcularFlujoPorMes(pool: Pool, año: number, contribuyenteId?: string | null): Promise<{ entradas: number[]; salidas: number[] }> {
|
||||
const VIGENTE = `status NOT IN ('Cancelado', '0')`;
|
||||
const fi = `${año}-01-01`;
|
||||
const ff = `${año}-12-31`;
|
||||
const RANGO = `fecha_emision >= $1::date AND fecha_emision < ($2::date + interval '1 day')`;
|
||||
const RANGO_PAGO = `fecha_pago_p >= $1::date AND fecha_pago_p < ($2::date + interval '1 day')`;
|
||||
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
|
||||
|
||||
const q = async (lado: 'EMITIDO' | 'RECIBIDO', tc: string, campo: string, mp?: string) => {
|
||||
const mpF = mp ? `AND metodo_pago = '${mp}'` : '';
|
||||
const fechaCol = tc === 'P' ? 'fecha_pago_p' : 'fecha_emision';
|
||||
const rango = tc === 'P' ? RANGO_PAGO : RANGO;
|
||||
const noAnticipo = tc === 'E' ? `AND COALESCE(cfdi_tipo_relacion, '') <> '07'` : '';
|
||||
const ladoCond = lado === 'EMITIDO' ? esEmisor : esReceptor;
|
||||
const { rows } = await pool.query(`
|
||||
SELECT EXTRACT(MONTH FROM ${fechaCol})::int as mes, COALESCE(SUM(${campo}), 0) as total
|
||||
FROM cfdis
|
||||
WHERE ${ladoCond} AND tipo_comprobante = '${tc}' ${mpF} ${noAnticipo} AND ${VIGENTE} AND ${rango}
|
||||
GROUP BY mes
|
||||
`, [fi, ff]);
|
||||
const map = new Map<number, number>();
|
||||
for (const r of rows) map.set(r.mes, Number(r.total));
|
||||
return map;
|
||||
};
|
||||
|
||||
const [ePUE, ePago, eNC, sPUE, sPago, sNC] = await Promise.all([
|
||||
q('EMITIDO', 'I', 'total_mxn', 'PUE'),
|
||||
q('EMITIDO', 'P', 'monto_pago_mxn'),
|
||||
q('EMITIDO', 'E', 'total_mxn', 'PUE'),
|
||||
q('RECIBIDO', 'I', 'total_mxn', 'PUE'),
|
||||
q('RECIBIDO', 'P', 'monto_pago_mxn'),
|
||||
q('RECIBIDO', 'E', 'total_mxn', 'PUE'),
|
||||
]);
|
||||
|
||||
const g = (map: Map<number, number>, m: number) => map.get(m) || 0;
|
||||
|
||||
const entradas: number[] = [];
|
||||
const salidas: number[] = [];
|
||||
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
entradas.push(g(ePUE, m) + g(ePago, m) - g(eNC, m));
|
||||
salidas.push(g(sPUE, m) + g(sPago, m) - g(sNC, m));
|
||||
}
|
||||
|
||||
return { entradas, salidas };
|
||||
}
|
||||
|
||||
export async function getComparativo(
|
||||
pool: Pool,
|
||||
año: number,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<ComparativoPeriodos> {
|
||||
const [actual, anterior] = await Promise.all([
|
||||
calcularFlujoPorMes(pool, año, contribuyenteId),
|
||||
calcularFlujoPorMes(pool, año - 1, contribuyenteId),
|
||||
]);
|
||||
|
||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||
const utilidad = actual.entradas.map((e, i) => e - actual.salidas[i]);
|
||||
|
||||
const totalActualIng = actual.entradas.reduce((a, b) => a + b, 0);
|
||||
const totalAnteriorIng = anterior.entradas.reduce((a, b) => a + b, 0);
|
||||
const totalActualEgr = actual.salidas.reduce((a, b) => a + b, 0);
|
||||
const totalAnteriorEgr = anterior.salidas.reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
periodos: meses,
|
||||
ingresos: actual.entradas,
|
||||
egresos: actual.salidas,
|
||||
utilidad,
|
||||
variacionIngresos: totalAnteriorIng > 0 ? ((totalActualIng - totalAnteriorIng) / totalAnteriorIng) * 100 : 0,
|
||||
variacionEgresos: totalAnteriorEgr > 0 ? ((totalActualEgr - totalAnteriorEgr) / totalAnteriorEgr) * 100 : 0,
|
||||
variacionUtilidad: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getConcentradoRfc(
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string,
|
||||
tipo: 'cliente' | 'proveedor',
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<ConcentradoRfc[]> {
|
||||
const { esEmisor, esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
|
||||
|
||||
if (tipo === 'cliente') {
|
||||
const { rows: data } = await pool.query(`
|
||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
|
||||
'cliente' as tipo,
|
||||
SUM(total_mxn) as "totalFacturado",
|
||||
SUM(iva_traslado_mxn) as "totalIva",
|
||||
COUNT(*)::int as "cantidadCfdis"
|
||||
FROM cfdis
|
||||
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY "totalFacturado" DESC
|
||||
`, [fechaInicio, fechaFin]);
|
||||
return data.map((d: any) => ({
|
||||
rfc: d.rfc,
|
||||
nombre: d.nombre,
|
||||
tipo: 'cliente' as const,
|
||||
totalFacturado: toNumber(d.totalFacturado),
|
||||
totalIva: toNumber(d.totalIva),
|
||||
cantidadCfdis: d.cantidadCfdis
|
||||
}));
|
||||
} else {
|
||||
const { rows: data } = await pool.query(`
|
||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
|
||||
'proveedor' as tipo,
|
||||
SUM(total_mxn) as "totalFacturado",
|
||||
SUM(iva_traslado_mxn) as "totalIva",
|
||||
COUNT(*)::int as "cantidadCfdis"
|
||||
FROM cfdis
|
||||
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND status NOT IN ('Cancelado', '0')
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY "totalFacturado" DESC
|
||||
`, [fechaInicio, fechaFin]);
|
||||
return data.map((d: any) => ({
|
||||
rfc: d.rfc,
|
||||
nombre: d.nombre,
|
||||
tipo: 'proveedor' as const,
|
||||
totalFacturado: toNumber(d.totalFacturado),
|
||||
totalIva: toNumber(d.totalIva),
|
||||
cantidadCfdis: d.cantidadCfdis
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export interface CuentasPendientes {
|
||||
cantidadCfdis: number;
|
||||
saldoTotal: number;
|
||||
detalle: { rfc: string; nombre: string; cantidad: number; saldo: number }[];
|
||||
}
|
||||
|
||||
export async function getCuentasXPagar(
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string,
|
||||
regimen?: string,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<CuentasPendientes> {
|
||||
const regimenFilter = regimen ? `AND regimen_fiscal_receptor = '${regimen}'` : '';
|
||||
const { esReceptor } = await resolveEmisorReceptor(pool, contribuyenteId);
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
rfc_emisor as rfc,
|
||||
nombre_emisor as nombre,
|
||||
COUNT(*)::int as cantidad,
|
||||
COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) as saldo
|
||||
FROM cfdis
|
||||
WHERE ${esReceptor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
|
||||
AND status NOT IN ('Cancelado', '0')
|
||||
AND fecha_emision >= $1::date
|
||||
AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
|
||||
${regimenFilter}
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY saldo DESC
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const detalle = rows.map((r: any) => ({
|
||||
rfc: r.rfc,
|
||||
nombre: r.nombre,
|
||||
cantidad: r.cantidad,
|
||||
saldo: toNumber(r.saldo),
|
||||
}));
|
||||
|
||||
return {
|
||||
cantidadCfdis: detalle.reduce((s, d) => s + d.cantidad, 0),
|
||||
saldoTotal: detalle.reduce((s, d) => s + d.saldo, 0),
|
||||
detalle,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCuentasXCobrar(
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string,
|
||||
regimen?: string,
|
||||
contribuyenteId?: string | null,
|
||||
): Promise<CuentasPendientes> {
|
||||
const regimenFilter = regimen ? `AND regimen_fiscal_emisor = '${regimen}'` : '';
|
||||
const { esEmisor } = await resolveEmisorReceptor(pool, contribuyenteId);
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
rfc_receptor as rfc,
|
||||
nombre_receptor as nombre,
|
||||
COUNT(*)::int as cantidad,
|
||||
COALESCE(SUM(COALESCE(saldo_pendiente_mxn, total_mxn)), 0) as saldo
|
||||
FROM cfdis
|
||||
WHERE ${esEmisor} AND tipo_comprobante = 'I' AND metodo_pago = 'PPD'
|
||||
AND status NOT IN ('Cancelado', '0')
|
||||
AND fecha_emision >= $1::date
|
||||
AND fecha_emision < ($2::date + interval '1 day')
|
||||
AND COALESCE(saldo_pendiente_mxn, total_mxn) > 0.01
|
||||
${regimenFilter}
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY saldo DESC
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const detalle = rows.map((r: any) => ({
|
||||
rfc: r.rfc,
|
||||
nombre: r.nombre,
|
||||
cantidad: r.cantidad,
|
||||
saldo: toNumber(r.saldo),
|
||||
}));
|
||||
|
||||
return {
|
||||
cantidadCfdis: detalle.reduce((s, d) => s + d.cantidad, 0),
|
||||
saldoTotal: detalle.reduce((s, d) => s + d.saldo, 0),
|
||||
detalle,
|
||||
};
|
||||
}
|
||||
160
apps/api/src/services/sat/sat-auth.service.ts
Normal file
160
apps/api/src/services/sat/sat-auth.service.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import type { Credential } from '@nodecfdi/credentials/node';
|
||||
|
||||
const SAT_AUTH_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc';
|
||||
|
||||
interface SatToken {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera el timestamp para la solicitud SOAP
|
||||
*/
|
||||
function createTimestamp(): { created: string; expires: string } {
|
||||
const now = new Date();
|
||||
const created = now.toISOString();
|
||||
const expires = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 minutos
|
||||
return { created, expires };
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el XML de solicitud de autenticación
|
||||
*/
|
||||
function buildAuthRequest(credential: Credential): string {
|
||||
const timestamp = createTimestamp();
|
||||
const uuid = randomUUID();
|
||||
|
||||
const certificate = credential.certificate();
|
||||
// El PEM ya contiene el certificado en base64, solo quitamos headers y newlines
|
||||
const cerB64 = certificate.pem().replace(/-----.*-----/g, '').replace(/\s/g, '');
|
||||
|
||||
// Canonicalizar y firmar
|
||||
const toDigestXml = `<u:Timestamp xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" u:Id="_0">` +
|
||||
`<u:Created>${timestamp.created}</u:Created>` +
|
||||
`<u:Expires>${timestamp.expires}</u:Expires>` +
|
||||
`</u:Timestamp>`;
|
||||
|
||||
const digestValue = createHash('sha1').update(toDigestXml).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="#_0">` +
|
||||
`<Transforms>` +
|
||||
`<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`</Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
// Firmar con la llave privada (sign retorna binary string, convertir a base64)
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
const soapEnvelope = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
|
||||
<s:Header>
|
||||
<o:Security xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1">
|
||||
<u:Timestamp u:Id="_0">
|
||||
<u:Created>${timestamp.created}</u:Created>
|
||||
<u:Expires>${timestamp.expires}</u:Expires>
|
||||
</u:Timestamp>
|
||||
<o:BinarySecurityToken u:Id="uuid-${uuid}-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${cerB64}</o:BinarySecurityToken>
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<o:SecurityTokenReference>
|
||||
<o:Reference URI="#uuid-${uuid}-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"/>
|
||||
</o:SecurityTokenReference>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</o:Security>
|
||||
</s:Header>
|
||||
<s:Body>
|
||||
<Autentica xmlns="http://DescargaMasivaTerceros.gob.mx"/>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
return soapEnvelope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el token de la respuesta SOAP
|
||||
*/
|
||||
function parseAuthResponse(responseXml: string): SatToken {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
});
|
||||
|
||||
const result = parser.parse(responseXml);
|
||||
|
||||
// Navegar la estructura de respuesta SOAP
|
||||
const envelope = result.Envelope || result['s:Envelope'];
|
||||
if (!envelope) {
|
||||
throw new Error('Respuesta SOAP inválida');
|
||||
}
|
||||
|
||||
const body = envelope.Body || envelope['s:Body'];
|
||||
if (!body) {
|
||||
throw new Error('No se encontró el cuerpo de la respuesta');
|
||||
}
|
||||
|
||||
const autenticaResponse = body.AutenticaResponse;
|
||||
if (!autenticaResponse) {
|
||||
throw new Error('No se encontró AutenticaResponse');
|
||||
}
|
||||
|
||||
const autenticaResult = autenticaResponse.AutenticaResult;
|
||||
if (!autenticaResult) {
|
||||
throw new Error('No se obtuvo token de autenticación');
|
||||
}
|
||||
|
||||
// El token es un SAML assertion en base64
|
||||
const token = autenticaResult;
|
||||
|
||||
// El token expira en 5 minutos según documentación SAT
|
||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Autentica con el SAT usando la FIEL y obtiene un token
|
||||
*/
|
||||
export async function authenticate(credential: Credential): Promise<SatToken> {
|
||||
const soapRequest = buildAuthRequest(credential);
|
||||
|
||||
try {
|
||||
const response = await fetch(SAT_AUTH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml;charset=UTF-8',
|
||||
'SOAPAction': 'http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica',
|
||||
},
|
||||
body: soapRequest,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseXml = await response.text();
|
||||
return parseAuthResponse(responseXml);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Auth Error]', error);
|
||||
throw new Error(`Error al autenticar con el SAT: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un token está vigente
|
||||
*/
|
||||
export function isTokenValid(token: SatToken): boolean {
|
||||
return new Date() < token.expiresAt;
|
||||
}
|
||||
248
apps/api/src/services/sat/sat-client.service.ts
Normal file
248
apps/api/src/services/sat/sat-client.service.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
Fiel,
|
||||
HttpsWebClient,
|
||||
FielRequestBuilder,
|
||||
Service,
|
||||
QueryParameters,
|
||||
DateTimePeriod,
|
||||
DownloadType,
|
||||
RequestType,
|
||||
DocumentStatus,
|
||||
ServiceEndpoints,
|
||||
} from '@nodecfdi/sat-ws-descarga-masiva';
|
||||
|
||||
export interface FielData {
|
||||
cerContent: string;
|
||||
keyContent: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
|
||||
*/
|
||||
export function createSatService(fielData: FielData): Service {
|
||||
// Crear FIEL usando el método estático create
|
||||
const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password);
|
||||
|
||||
// Verificar que la FIEL sea válida
|
||||
if (!fiel.isValid()) {
|
||||
throw new Error('La FIEL no es válida o está vencida');
|
||||
}
|
||||
|
||||
// Crear cliente HTTP
|
||||
const webClient = new HttpsWebClient();
|
||||
|
||||
// Crear request builder con la FIEL
|
||||
const requestBuilder = new FielRequestBuilder(fiel);
|
||||
|
||||
// Crear y retornar el servicio
|
||||
return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
success: boolean;
|
||||
requestId?: string;
|
||||
message: string;
|
||||
statusCode?: string;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
success: boolean;
|
||||
status: 'pending' | 'processing' | 'ready' | 'failed' | 'rejected';
|
||||
packageIds: string[];
|
||||
totalCfdis: number;
|
||||
message: string;
|
||||
statusCode?: string;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
success: boolean;
|
||||
packageContent: string; // Base64 encoded ZIP
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Realiza una consulta al SAT para solicitar CFDIs
|
||||
*/
|
||||
export async function querySat(
|
||||
service: Service,
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date,
|
||||
tipo: 'emitidos' | 'recibidos',
|
||||
requestType: 'metadata' | 'cfdi' = 'cfdi'
|
||||
): Promise<QueryResult> {
|
||||
try {
|
||||
const period = DateTimePeriod.createFromValues(
|
||||
formatDateForSat(fechaInicio),
|
||||
formatDateForSat(fechaFin)
|
||||
);
|
||||
|
||||
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
|
||||
const reqType = new RequestType(requestType === 'cfdi' ? 'xml' : 'metadata');
|
||||
|
||||
// XMLs: solo vigentes (active). Metadata: todos (undefined = sin filtro).
|
||||
let parameters = QueryParameters.create(period, downloadType, reqType);
|
||||
if (requestType === 'cfdi') {
|
||||
parameters = parameters.withDocumentStatus(new DocumentStatus('active'));
|
||||
}
|
||||
const result = await service.query(parameters);
|
||||
|
||||
if (!result.getStatus().isAccepted()) {
|
||||
return {
|
||||
success: false,
|
||||
message: result.getStatus().getMessage(),
|
||||
statusCode: result.getStatus().getCode().toString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
requestId: result.getRequestId(),
|
||||
message: 'Solicitud aceptada',
|
||||
statusCode: result.getStatus().getCode().toString(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Query Error]', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || 'Error al realizar consulta',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica el estado de una solicitud
|
||||
*/
|
||||
export async function verifySatRequest(
|
||||
service: Service,
|
||||
requestId: string
|
||||
): Promise<VerifyResult> {
|
||||
try {
|
||||
const result = await service.verify(requestId);
|
||||
const statusRequest = result.getStatusRequest();
|
||||
|
||||
// `codeRequest` es el código SAT específico del estado de la solicitud
|
||||
// (5000 Accepted, 5002 Exhausted, 5003 MaximumLimit, 5004 EmptyResult,
|
||||
// 5005 Duplicated) y su mensaje explica POR QUÉ el SAT rechazó. Es la
|
||||
// pieza clave para diagnosticar rejections — el `getStatus().getCode()`
|
||||
// solo devuelve el wrapper HTTP (5000 genérico "Aceptada").
|
||||
//
|
||||
// Fuente: docs phpcfdi + lib @nodecfdi/sat-ws-descarga-masiva (`CodeRequest`).
|
||||
const codeReqObj = typeof (result as any).getCodeRequest === 'function'
|
||||
? (result as any).getCodeRequest()
|
||||
: null;
|
||||
const codeRequestValue = codeReqObj ? codeReqObj.getValue() : null;
|
||||
const codeRequestMessage = codeReqObj ? codeReqObj.getMessage() : null;
|
||||
const codeRequestEntry = codeReqObj ? codeReqObj.getEntryId() : null;
|
||||
|
||||
// Debug logging
|
||||
console.log('[SAT Verify Debug]', {
|
||||
statusRequestValue: statusRequest.getValue(),
|
||||
statusRequestEntryId: statusRequest.getEntryId(),
|
||||
cfdis: result.getNumberCfdis(),
|
||||
packages: result.getPackageIds(),
|
||||
statusCode: result.getStatus().getCode(),
|
||||
statusMsg: result.getStatus().getMessage(),
|
||||
codeRequestValue,
|
||||
codeRequestEntry,
|
||||
codeRequestMessage,
|
||||
});
|
||||
|
||||
// Usar isTypeOf para determinar el estado
|
||||
let status: VerifyResult['status'];
|
||||
if (statusRequest.isTypeOf('Finished')) {
|
||||
status = 'ready';
|
||||
} else if (statusRequest.isTypeOf('InProgress')) {
|
||||
status = 'processing';
|
||||
} else if (statusRequest.isTypeOf('Accepted')) {
|
||||
status = 'pending';
|
||||
} else if (statusRequest.isTypeOf('Failure')) {
|
||||
status = 'failed';
|
||||
} else if (statusRequest.isTypeOf('Rejected')) {
|
||||
status = 'rejected';
|
||||
} else {
|
||||
// Default: check by entryId
|
||||
const entryId = statusRequest.getEntryId();
|
||||
if (entryId === 'Finished') status = 'ready';
|
||||
else if (entryId === 'InProgress') status = 'processing';
|
||||
else if (entryId === 'Accepted') status = 'pending';
|
||||
else status = 'pending';
|
||||
}
|
||||
|
||||
// Para estados terminales no-felices, construir mensaje informativo.
|
||||
// `codeRequest` (si está disponible) es la razón SAT real del rechazo.
|
||||
const statusCode = result.getStatus().getCode().toString();
|
||||
const statusMsg = result.getStatus().getMessage();
|
||||
const reqValue = statusRequest.getValue();
|
||||
const reqEntry = statusRequest.getEntryId();
|
||||
let message = statusMsg;
|
||||
if (status === 'rejected' || status === 'failed') {
|
||||
const codeReqStr = codeRequestValue
|
||||
? ` codeRequest=${codeRequestEntry}(${codeRequestValue}) — ${codeRequestMessage}`
|
||||
: '';
|
||||
message = `SAT request=${reqEntry}(${reqValue})${codeReqStr} wrapperCode=${statusCode} wrapperMsg="${statusMsg}"`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.getStatus().isAccepted(),
|
||||
status,
|
||||
packageIds: result.getPackageIds(),
|
||||
totalCfdis: result.getNumberCfdis(),
|
||||
message,
|
||||
statusCode,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Verify Error]', error.message || error);
|
||||
// Errores de la librería (ej. webError.getResponse is not a function)
|
||||
// no son fallos del SAT — devolver 'pending' para reintentar polling
|
||||
return {
|
||||
success: false,
|
||||
status: 'pending',
|
||||
packageIds: [],
|
||||
totalCfdis: 0,
|
||||
message: error.message || 'Error al verificar solicitud',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga un paquete de CFDIs
|
||||
*/
|
||||
export async function downloadSatPackage(
|
||||
service: Service,
|
||||
packageId: string
|
||||
): Promise<DownloadResult> {
|
||||
try {
|
||||
const result = await service.download(packageId);
|
||||
|
||||
if (!result.getStatus().isAccepted()) {
|
||||
return {
|
||||
success: false,
|
||||
packageContent: '',
|
||||
message: result.getStatus().getMessage(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
packageContent: result.getPackageContent(),
|
||||
message: 'Paquete descargado',
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Download Error]', error);
|
||||
return {
|
||||
success: false,
|
||||
packageContent: '',
|
||||
message: error.message || 'Error al descargar paquete',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss)
|
||||
*/
|
||||
function formatDateForSat(date: Date): string {
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
||||
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
94
apps/api/src/services/sat/sat-crypto.service.ts
Normal file
94
apps/api/src/services/sat/sat-crypto.service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { encryptAesGcm, decryptAesGcm, deriveAesKey } from '@horux/core';
|
||||
import { env } from '../../config/env.js';
|
||||
|
||||
function getKey(): Buffer {
|
||||
return deriveAesKey(env.FIEL_ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encripta datos usando AES-256-GCM con la clave derivada de FIEL_ENCRYPTION_KEY
|
||||
*/
|
||||
export function encrypt(data: Buffer): { encrypted: Buffer; iv: Buffer; tag: Buffer } {
|
||||
return encryptAesGcm(data, getKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Desencripta datos usando AES-256-GCM con la clave derivada de FIEL_ENCRYPTION_KEY
|
||||
*/
|
||||
export function decrypt(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer {
|
||||
return decryptAesGcm(encrypted, iv, tag, getKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Encripta un string y retorna los componentes
|
||||
*/
|
||||
export function encryptString(text: string): { encrypted: Buffer; iv: Buffer; tag: Buffer } {
|
||||
return encrypt(Buffer.from(text, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Desencripta a string
|
||||
*/
|
||||
export function decryptToString(encrypted: Buffer, iv: Buffer, tag: Buffer): string {
|
||||
return decrypt(encrypted, iv, tag).toString('utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encripta credenciales FIEL con IV/tag independiente por componente
|
||||
*/
|
||||
export function encryptFielCredentials(
|
||||
cerData: Buffer,
|
||||
keyData: Buffer,
|
||||
password: string
|
||||
): {
|
||||
encryptedCer: Buffer;
|
||||
encryptedKey: Buffer;
|
||||
encryptedPassword: Buffer;
|
||||
cerIv: Buffer;
|
||||
cerTag: Buffer;
|
||||
keyIv: Buffer;
|
||||
keyTag: Buffer;
|
||||
passwordIv: Buffer;
|
||||
passwordTag: Buffer;
|
||||
} {
|
||||
const cer = encrypt(cerData);
|
||||
const key = encrypt(keyData);
|
||||
const pwd = encrypt(Buffer.from(password, 'utf-8'));
|
||||
|
||||
return {
|
||||
encryptedCer: cer.encrypted,
|
||||
encryptedKey: key.encrypted,
|
||||
encryptedPassword: pwd.encrypted,
|
||||
cerIv: cer.iv,
|
||||
cerTag: cer.tag,
|
||||
keyIv: key.iv,
|
||||
keyTag: key.tag,
|
||||
passwordIv: pwd.iv,
|
||||
passwordTag: pwd.tag,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Desencripta credenciales FIEL (per-component IV/tag)
|
||||
*/
|
||||
export function decryptFielCredentials(
|
||||
encryptedCer: Buffer,
|
||||
encryptedKey: Buffer,
|
||||
encryptedPassword: Buffer,
|
||||
cerIv: Buffer,
|
||||
cerTag: Buffer,
|
||||
keyIv: Buffer,
|
||||
keyTag: Buffer,
|
||||
passwordIv: Buffer,
|
||||
passwordTag: Buffer
|
||||
): {
|
||||
cerData: Buffer;
|
||||
keyData: Buffer;
|
||||
password: string;
|
||||
} {
|
||||
const cerData = decrypt(encryptedCer, cerIv, cerTag);
|
||||
const keyData = decrypt(encryptedKey, keyIv, keyTag);
|
||||
const password = decrypt(encryptedPassword, passwordIv, passwordTag).toString('utf-8');
|
||||
|
||||
return { cerData, keyData, password };
|
||||
}
|
||||
84
apps/api/src/services/sat/sat-csf-login.ts
Normal file
84
apps/api/src/services/sat/sat-csf-login.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Browser, BrowserContext, Page } from 'playwright';
|
||||
|
||||
const PUBLIC_URL = 'https://www.sat.gob.mx/portal/public/tramites/constancia-de-situacion-fiscal';
|
||||
|
||||
export interface CsfLoginSession {
|
||||
context: BrowserContext;
|
||||
appPage: Page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates from the public CSF page → "SERVICIO" popup → FIEL login →
|
||||
* returns the post-login app page (popup that became the SPA).
|
||||
* Ver referencia_sat_portal_csf: el botón "Generar" vive en un iframe JSF
|
||||
* dentro de esta appPage, por eso la retornamos tal cual.
|
||||
*/
|
||||
export async function loginSatCsf(
|
||||
browser: Browser,
|
||||
cerPath: string,
|
||||
keyPath: string,
|
||||
password: string,
|
||||
): Promise<CsfLoginSession> {
|
||||
const context = await browser.newContext({ acceptDownloads: true });
|
||||
const publicPage = await context.newPage();
|
||||
publicPage.setDefaultTimeout(60_000);
|
||||
|
||||
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' });
|
||||
await publicPage.waitForTimeout(2000);
|
||||
|
||||
// Click acordeón "Obtén tu constancia" / "Obtener constancia"
|
||||
const obtenerLocator = publicPage.locator(
|
||||
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
|
||||
).first();
|
||||
await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 });
|
||||
await obtenerLocator.scrollIntoViewIfNeeded();
|
||||
await obtenerLocator.click();
|
||||
await publicPage.waitForTimeout(1500);
|
||||
|
||||
// Click "SERVICIO" → popup
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 60_000 });
|
||||
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
|
||||
const loginPage = await popupPromise;
|
||||
await loginPage.waitForLoadState('domcontentloaded');
|
||||
loginPage.setDefaultTimeout(60_000);
|
||||
|
||||
// Click "e.firma" (NO "e.firma portable"). El SAT a veces aterriza en la
|
||||
// pestaña de contraseña: el botón cambia a la vista FIEL. El click sintético
|
||||
// de Playwright a veces no dispara el handler — afirmamos el efecto (aparece
|
||||
// el file input) y reintentamos con dispatchEvent si hace falta.
|
||||
const efirmaBtn = loginPage
|
||||
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
|
||||
.first();
|
||||
await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
await efirmaBtn.scrollIntoViewIfNeeded();
|
||||
await efirmaBtn.click();
|
||||
|
||||
const fileInputs = loginPage.locator('input[type="file"]');
|
||||
try {
|
||||
await fileInputs.first().waitFor({ state: 'attached', timeout: 10_000 });
|
||||
} catch {
|
||||
// Retry: el click sintético no disparó el handler — forzamos dispatchEvent
|
||||
await efirmaBtn.dispatchEvent('click');
|
||||
await fileInputs.first().waitFor({ state: 'attached', timeout: 30_000 });
|
||||
}
|
||||
|
||||
// Upload .cer (primer input) y .key (segundo)
|
||||
await fileInputs.nth(0).setInputFiles(cerPath);
|
||||
await fileInputs.nth(1).setInputFiles(keyPath);
|
||||
|
||||
// Password + Enviar
|
||||
await loginPage.locator('input[type="password"]').first().fill(password);
|
||||
await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click();
|
||||
|
||||
// Esperar a que salga del dominio de login
|
||||
await loginPage.waitForURL(url => !url.toString().includes('loginda.siat.sat.gob.mx'), { timeout: 60_000 });
|
||||
await loginPage.waitForLoadState('networkidle').catch(() => undefined);
|
||||
await loginPage.waitForTimeout(2000);
|
||||
|
||||
const bodyText = await loginPage.locator('body').innerText().catch(() => '');
|
||||
if (/contrase[nñ]a\s+incorrecta|usuario.*no.*v[aá]lido|firma\s+inv[aá]lida/i.test(bodyText)) {
|
||||
throw new Error('FIEL inválida o contraseña incorrecta');
|
||||
}
|
||||
|
||||
return { context, appPage: loginPage };
|
||||
}
|
||||
246
apps/api/src/services/sat/sat-csf-parser.ts
Normal file
246
apps/api/src/services/sat/sat-csf-parser.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
|
||||
export interface Domicilio {
|
||||
codigoPostal?: string;
|
||||
tipoVialidad?: string;
|
||||
nombreVialidad?: string;
|
||||
numeroExterior?: string;
|
||||
numeroInterior?: string;
|
||||
colonia?: string;
|
||||
localidad?: string;
|
||||
municipio?: string;
|
||||
entidadFederativa?: string;
|
||||
entreCalle?: string;
|
||||
yCalle?: string;
|
||||
}
|
||||
|
||||
export interface ActividadEconomica {
|
||||
orden: number;
|
||||
descripcion: string;
|
||||
porcentaje: number;
|
||||
fechaInicio: string;
|
||||
fechaFin?: string;
|
||||
}
|
||||
|
||||
export interface RegimenCsf {
|
||||
nombre: string;
|
||||
fechaInicio: string;
|
||||
fechaFin?: string;
|
||||
}
|
||||
|
||||
export interface Obligacion {
|
||||
descripcion: string;
|
||||
descripcionVencimiento: string;
|
||||
fechaInicio: string;
|
||||
fechaFin?: string;
|
||||
}
|
||||
|
||||
export interface ConstanciaSituacionFiscal {
|
||||
rfc: string;
|
||||
curp?: string;
|
||||
idCIF: string;
|
||||
nombre?: string;
|
||||
primerApellido?: string;
|
||||
segundoApellido?: string;
|
||||
razonSocial?: string;
|
||||
nombreComercial?: string;
|
||||
fechaInicioOperaciones: string;
|
||||
estatusPadron: string;
|
||||
fechaUltimoCambioEstado?: string;
|
||||
lugarFechaEmision: string;
|
||||
domicilio: Domicilio;
|
||||
actividadesEconomicas: ActividadEconomica[];
|
||||
regimenes: RegimenCsf[];
|
||||
obligaciones: Obligacion[];
|
||||
cadenaOriginalSello: string;
|
||||
selloDigital: string;
|
||||
}
|
||||
|
||||
async function extractPdfText(pdfBuffer: Buffer): Promise<string> {
|
||||
const parser = new PDFParse({ data: pdfBuffer });
|
||||
try {
|
||||
const result = await parser.getText();
|
||||
return result.text;
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const LABELS = [
|
||||
'RFC', 'CURP', 'Nombre (s)', 'Primer Apellido', 'Segundo Apellido',
|
||||
'Denominación o Razón Social', 'Denominación/Razón Social',
|
||||
'Régimen Capital', 'Fecha inicio de operaciones', 'Estatus en el padrón',
|
||||
'Fecha de último cambio de estado', 'Nombre Comercial',
|
||||
'Código Postal', 'Tipo de Vialidad', 'Nombre de Vialidad',
|
||||
'Número Exterior', 'Número Interior', 'Nombre de la Colonia',
|
||||
'Nombre de la Localidad', 'Nombre del Municipio o Demarcación Territorial',
|
||||
'Nombre de la Entidad Federativa', 'Entre Calle', 'Y Calle',
|
||||
] as const;
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function extractLabels(text: string): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
const labelAlternation = LABELS.map(escapeRegex).join('|');
|
||||
const re = new RegExp(
|
||||
`(${labelAlternation})\\s*:\\s*([\\s\\S]*?)(?=\\s+(?:${labelAlternation})\\s*:|\\n?\\s*(?:Datos del domicilio registrado|Actividades Económicas|Regímenes|Obligaciones|Cadena Original|Sus datos personales)\\b|\\n\\s*--\\s*\\d+\\s+of\\s+\\d+|$)`,
|
||||
'g',
|
||||
);
|
||||
for (const match of text.matchAll(re)) {
|
||||
const label = match[1];
|
||||
const value = match[2].replace(/\s+/g, ' ').trim();
|
||||
if (!result.has(label)) result.set(label, value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractIdCIF(text: string): string {
|
||||
const m = text.match(/idCIF\s*:?\s*(\d+)/i);
|
||||
if (!m) throw new Error('idCIF no encontrado en PDF');
|
||||
return m[1];
|
||||
}
|
||||
|
||||
function extractLugarFechaEmision(text: string): string {
|
||||
const m = text.match(/Lugar y Fecha de Emisión\s*\n?\s*([^\n]+?)\s*(?=\n|TORC|HTS|[A-Z]{4}\d{6})/);
|
||||
if (m) return m[1].replace(/\s+/g, ' ').trim();
|
||||
const m2 = text.match(/([A-ZÁÉÍÓÚÑ ]+,\s*[A-ZÁÉÍÓÚÑ ]+\s+A\s+\d{1,2}\s+DE\s+[A-ZÁÉÍÓÚÑ]+\s+DE\s+\d{4})/i);
|
||||
if (m2) return m2[1].replace(/\s+/g, ' ').trim();
|
||||
throw new Error('Lugar y Fecha de Emisión no encontrado');
|
||||
}
|
||||
|
||||
const PAGE_NOISE_RE = /^\s*(?:--\s*\d+\s+of\s+\d+\s*--|Página\s*\[\d+\]\s*de\s*\[\d+\])\s*$/;
|
||||
|
||||
function sliceSection(text: string, header: string, nextHeaders: string[]): string {
|
||||
const start = text.indexOf(header);
|
||||
if (start === -1) return '';
|
||||
const after = start + header.length;
|
||||
let end = text.length;
|
||||
for (const h of nextHeaders) {
|
||||
const idx = text.indexOf(h, after);
|
||||
if (idx !== -1 && idx < end) end = idx;
|
||||
}
|
||||
return text.slice(after, end);
|
||||
}
|
||||
|
||||
function groupRowChunks(body: string, headerRowRegex: RegExp): string[] {
|
||||
const lines = body.split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0 && !PAGE_NOISE_RE.test(l));
|
||||
if (lines.length > 0 && headerRowRegex.test(lines[0])) lines.shift();
|
||||
const chunks: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const line of lines) {
|
||||
current.push(line);
|
||||
if (/\d{2}\/\d{2}\/\d{4}\s*$/.test(line)) {
|
||||
chunks.push(current.join(' ').replace(/\s+/g, ' ').trim());
|
||||
current = [];
|
||||
}
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function extractActividades(text: string): ActividadEconomica[] {
|
||||
const section = sliceSection(text, 'Actividades Económicas:', ['Regímenes:', 'Obligaciones:', 'Cadena Original']);
|
||||
if (!section) return [];
|
||||
const chunks = groupRowChunks(section, /^\s*Orden\s+Actividad\s+Económica\s+Porcentaje\s+Fecha\s+Inicio\s+Fecha\s+Fin\s*$/i);
|
||||
const result: ActividadEconomica[] = [];
|
||||
for (const chunk of chunks) {
|
||||
const m = chunk.match(/^(\d+)\s+(.+?)\s+(\d+)\s+(\d{2}\/\d{2}\/\d{4})(?:\s+(\d{2}\/\d{2}\/\d{4}))?$/);
|
||||
if (!m) continue;
|
||||
result.push({
|
||||
orden: Number(m[1]),
|
||||
descripcion: m[2].replace(/\s+/g, ' ').trim(),
|
||||
porcentaje: Number(m[3]),
|
||||
fechaInicio: m[4],
|
||||
fechaFin: m[5],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractRegimenes(text: string): RegimenCsf[] {
|
||||
const section = sliceSection(text, 'Regímenes:', ['Obligaciones:', 'Cadena Original']);
|
||||
if (!section) return [];
|
||||
const chunks = groupRowChunks(section, /^\s*Régimen\s+Fecha\s+Inicio\s+Fecha\s+Fin\s*$/i);
|
||||
const result: RegimenCsf[] = [];
|
||||
for (const chunk of chunks) {
|
||||
const m = chunk.match(/^(.+?)\s+(\d{2}\/\d{2}\/\d{4})(?:\s+(\d{2}\/\d{2}\/\d{4}))?$/);
|
||||
if (!m) continue;
|
||||
result.push({ nombre: m[1].replace(/\s+/g, ' ').trim(), fechaInicio: m[2], fechaFin: m[3] });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractObligaciones(text: string): Obligacion[] {
|
||||
const section = sliceSection(text, 'Obligaciones:', ['Sus datos personales', 'Cadena Original']);
|
||||
if (!section) return [];
|
||||
const chunks = groupRowChunks(section, /^\s*Descripción de la Obligación\s+Descripción Vencimiento\s+Fecha Inicio\s+Fecha Fin\s*$/i);
|
||||
const result: Obligacion[] = [];
|
||||
for (const chunk of chunks) {
|
||||
const m = chunk.match(/^(.+?)\s+((?:A\s+m[aá]s\s+tardar|Dentro\s+de|Mensualmente|Bimestralmente|Trimestralmente|Anualmente|En\s+los|Cuando\s+)[\s\S]+?)\s+(\d{2}\/\d{2}\/\d{4})(?:\s+(\d{2}\/\d{2}\/\d{4}))?$/);
|
||||
if (!m) continue;
|
||||
result.push({ descripcion: m[1].trim(), descripcionVencimiento: m[2].trim(), fechaInicio: m[3], fechaFin: m[4] });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractCadenaOriginalSello(text: string): string {
|
||||
const m = text.match(/Cadena Original Sello\s*:\s*(\|\|[\s\S]+?\|\|)\s*(?:Sello Digital|$)/);
|
||||
if (!m) throw new Error('Cadena Original Sello no encontrada');
|
||||
return m[1].replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function extractSelloDigital(text: string): string {
|
||||
const m = text.match(/Sello Digital\s*:\s*([A-Za-z0-9+/=\s]+?)(?:\n\s*\n|Página|$)/);
|
||||
if (!m) throw new Error('Sello Digital no encontrado');
|
||||
return m[1].replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
export async function parseCsfPdf(pdfBuffer: Buffer): Promise<ConstanciaSituacionFiscal> {
|
||||
const text = await extractPdfText(pdfBuffer);
|
||||
const labels = extractLabels(text);
|
||||
const idCIF = extractIdCIF(text);
|
||||
const lugarFechaEmision = extractLugarFechaEmision(text);
|
||||
|
||||
const rfc = labels.get('RFC');
|
||||
if (!rfc) throw new Error('RFC no encontrado en PDF');
|
||||
|
||||
const fechaInicioOperaciones = labels.get('Fecha inicio de operaciones');
|
||||
if (!fechaInicioOperaciones) throw new Error('Fecha inicio de operaciones no encontrada');
|
||||
|
||||
const estatusPadron = labels.get('Estatus en el padrón');
|
||||
if (!estatusPadron) throw new Error('Estatus en el padrón no encontrado');
|
||||
|
||||
return {
|
||||
rfc,
|
||||
curp: labels.get('CURP'),
|
||||
idCIF,
|
||||
nombre: labels.get('Nombre (s)'),
|
||||
primerApellido: labels.get('Primer Apellido'),
|
||||
segundoApellido: labels.get('Segundo Apellido'),
|
||||
razonSocial: labels.get('Denominación o Razón Social') ?? labels.get('Denominación/Razón Social'),
|
||||
nombreComercial: labels.get('Nombre Comercial') || undefined,
|
||||
fechaInicioOperaciones,
|
||||
estatusPadron,
|
||||
fechaUltimoCambioEstado: labels.get('Fecha de último cambio de estado'),
|
||||
lugarFechaEmision,
|
||||
domicilio: {
|
||||
codigoPostal: labels.get('Código Postal'),
|
||||
tipoVialidad: labels.get('Tipo de Vialidad'),
|
||||
nombreVialidad: labels.get('Nombre de Vialidad'),
|
||||
numeroExterior: labels.get('Número Exterior'),
|
||||
numeroInterior: labels.get('Número Interior'),
|
||||
colonia: labels.get('Nombre de la Colonia'),
|
||||
localidad: labels.get('Nombre de la Localidad'),
|
||||
municipio: labels.get('Nombre del Municipio o Demarcación Territorial'),
|
||||
entidadFederativa: labels.get('Nombre de la Entidad Federativa'),
|
||||
entreCalle: labels.get('Entre Calle'),
|
||||
yCalle: labels.get('Y Calle'),
|
||||
},
|
||||
actividadesEconomicas: extractActividades(text),
|
||||
regimenes: extractRegimenes(text),
|
||||
obligaciones: extractObligaciones(text),
|
||||
cadenaOriginalSello: extractCadenaOriginalSello(text),
|
||||
selloDigital: extractSelloDigital(text),
|
||||
};
|
||||
}
|
||||
121
apps/api/src/services/sat/sat-csf-scraper.ts
Normal file
121
apps/api/src/services/sat/sat-csf-scraper.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Page, Locator, Frame, Response } from 'playwright';
|
||||
import type { CsfLoginSession } from './sat-csf-login.js';
|
||||
|
||||
async function tryFetchPdfFromUrl(page: Page, url: string): Promise<Buffer | null> {
|
||||
if (url.startsWith('blob:') || url.startsWith('data:')) {
|
||||
const arr = await page.evaluate(async (u) => {
|
||||
const r = await fetch(u);
|
||||
const buf = await r.arrayBuffer();
|
||||
return Array.from(new Uint8Array(buf));
|
||||
}, url);
|
||||
return Buffer.from(arr);
|
||||
}
|
||||
if (url.startsWith('http')) {
|
||||
const response = await page.context().request.get(url);
|
||||
if (!response.ok()) return null;
|
||||
return Buffer.from(await response.body());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca "Generar Constancia" en cualquiera de los frames del appPage (vive
|
||||
* típicamente en un iframe JSF legacy: rfcampc.siat.sat.gob.mx/PTSC/...).
|
||||
* Intenta 3 rutas: download event, popup con viewer, response interception.
|
||||
*/
|
||||
export async function extractCsfPdf(session: CsfLoginSession): Promise<Buffer> {
|
||||
const { context, appPage } = session;
|
||||
|
||||
let interceptedPdf: Buffer | null = null;
|
||||
const responseListener = async (response: Response) => {
|
||||
const ct = response.headers()['content-type'] ?? '';
|
||||
if (ct.includes('application/pdf')) {
|
||||
try { interceptedPdf = Buffer.from(await response.body()); } catch { /* ok */ }
|
||||
}
|
||||
};
|
||||
context.on('response', responseListener);
|
||||
|
||||
const GENERAR_SELECTORS = [
|
||||
'button:has-text("Generar Constancia")',
|
||||
'button:has-text("Generar constancia")',
|
||||
'input[type="button"][value*="Generar" i]',
|
||||
'input[type="submit"][value*="Generar" i]',
|
||||
'a:has-text("Generar Constancia")',
|
||||
'a:has-text("Generar constancia")',
|
||||
].join(', ');
|
||||
|
||||
let generarLocator: Locator | null = null;
|
||||
let foundFrame: Frame | null = null;
|
||||
const deadline = Date.now() + 90_000;
|
||||
while (Date.now() < deadline) {
|
||||
for (const frame of appPage.frames()) {
|
||||
const loc = frame.locator(GENERAR_SELECTORS).first();
|
||||
const count = await loc.count().catch(() => 0);
|
||||
if (count > 0 && await loc.isVisible().catch(() => false)) {
|
||||
generarLocator = loc;
|
||||
foundFrame = frame;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (generarLocator) break;
|
||||
await appPage.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
if (!generarLocator || !foundFrame) {
|
||||
context.off('response', responseListener);
|
||||
throw new Error('Botón "Generar Constancia" no encontrado en ningún frame del portal SAT (tras 90s)');
|
||||
}
|
||||
|
||||
await generarLocator.scrollIntoViewIfNeeded();
|
||||
await appPage.waitForTimeout(500);
|
||||
|
||||
const popupPromise = context.waitForEvent('page', { timeout: 15_000 }).catch(() => null);
|
||||
const downloadPromise = appPage.waitForEvent('download', { timeout: 15_000 }).catch(() => null);
|
||||
await generarLocator.click();
|
||||
|
||||
const [popup, download] = await Promise.all([popupPromise, downloadPromise]);
|
||||
|
||||
try {
|
||||
// Path 1: download event
|
||||
if (download) {
|
||||
const stream = await download.createReadStream();
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) chunks.push(chunk as Buffer);
|
||||
const pdf = Buffer.concat(chunks);
|
||||
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) {
|
||||
throw new Error('El archivo descargado no es un PDF válido');
|
||||
}
|
||||
return pdf;
|
||||
}
|
||||
|
||||
// Path 2: viewer popup
|
||||
if (popup) {
|
||||
await popup.waitForLoadState('domcontentloaded').catch(() => undefined);
|
||||
await popup.waitForTimeout(2000);
|
||||
|
||||
let pdf = await tryFetchPdfFromUrl(popup, popup.url()).catch(() => null);
|
||||
if (!pdf) {
|
||||
const embedSrc = await popup.locator('embed[type="application/pdf"], iframe').first().getAttribute('src').catch(() => null);
|
||||
if (embedSrc) {
|
||||
const absolute = new URL(embedSrc, popup.url()).toString();
|
||||
pdf = await tryFetchPdfFromUrl(popup, absolute).catch(() => null);
|
||||
}
|
||||
}
|
||||
if (!pdf && interceptedPdf) pdf = interceptedPdf;
|
||||
if (!pdf || pdf.length === 0) throw new Error('El visor abrió pero no se pudo extraer el PDF');
|
||||
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer extraído no es un PDF válido');
|
||||
return pdf;
|
||||
}
|
||||
|
||||
// Path 3: inline response (no popup, no download)
|
||||
await appPage.waitForTimeout(3000);
|
||||
if (interceptedPdf) {
|
||||
const pdf = interceptedPdf as Buffer;
|
||||
if (!pdf.subarray(0, 5).toString().startsWith('%PDF-')) throw new Error('Buffer interceptado no es un PDF válido');
|
||||
return pdf;
|
||||
}
|
||||
throw new Error('Click en "Generar Constancia" no produjo descarga, popup ni respuesta PDF');
|
||||
} finally {
|
||||
context.off('response', responseListener);
|
||||
}
|
||||
}
|
||||
408
apps/api/src/services/sat/sat-download.service.ts
Normal file
408
apps/api/src/services/sat/sat-download.service.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import type { Credential } from '@nodecfdi/credentials/node';
|
||||
import type {
|
||||
SatDownloadRequestResponse,
|
||||
SatVerifyResponse,
|
||||
SatPackageResponse,
|
||||
CfdiSyncType
|
||||
} from '@horux/shared';
|
||||
|
||||
const SAT_SOLICITUD_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc';
|
||||
const SAT_VERIFICA_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc';
|
||||
const SAT_DESCARGA_URL = 'https://cfdidescargamasaborrar.clouda.sat.gob.mx/DescargaMasivaService.svc';
|
||||
|
||||
type TipoSolicitud = 'CFDI' | 'Metadata';
|
||||
|
||||
interface RequestDownloadParams {
|
||||
credential: Credential;
|
||||
token: string;
|
||||
rfc: string;
|
||||
fechaInicio: Date;
|
||||
fechaFin: Date;
|
||||
tipoSolicitud: TipoSolicitud;
|
||||
tipoCfdi: CfdiSyncType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea fecha a formato SAT (YYYY-MM-DDTHH:MM:SS)
|
||||
*/
|
||||
function formatSatDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 19);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el XML de solicitud de descarga
|
||||
*/
|
||||
function buildDownloadRequest(params: RequestDownloadParams): string {
|
||||
const { credential, token, rfc, fechaInicio, fechaFin, tipoSolicitud, tipoCfdi } = params;
|
||||
const uuid = randomUUID();
|
||||
|
||||
const certificate = credential.certificate();
|
||||
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
|
||||
|
||||
// Construir el elemento de solicitud
|
||||
const rfcEmisor = tipoCfdi === 'emitidos' ? rfc : undefined;
|
||||
const rfcReceptor = tipoCfdi === 'recibidos' ? rfc : undefined;
|
||||
|
||||
const solicitudContent = `<des:RfcSolicitante>${rfc}</des:RfcSolicitante>` +
|
||||
`<des:FechaInicial>${formatSatDate(fechaInicio)}</des:FechaInicial>` +
|
||||
`<des:FechaFinal>${formatSatDate(fechaFin)}</des:FechaFinal>` +
|
||||
`<des:TipoSolicitud>${tipoSolicitud}</des:TipoSolicitud>` +
|
||||
(rfcEmisor ? `<des:RfcEmisor>${rfcEmisor}</des:RfcEmisor>` : '') +
|
||||
(rfcReceptor ? `<des:RfcReceptor>${rfcReceptor}</des:RfcReceptor>` : '');
|
||||
|
||||
const solicitudToSign = `<des:SolicitaDescarga xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">${solicitudContent}</des:SolicitaDescarga>`;
|
||||
const digestValue = createHash('sha1').update(solicitudToSign).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="">` +
|
||||
`<Transforms>` +
|
||||
`<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`</Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">
|
||||
<s:Header/>
|
||||
<s:Body>
|
||||
<des:SolicitaDescarga>
|
||||
<des:solicitud RfcSolicitante="${rfc}" FechaInicial="${formatSatDate(fechaInicio)}" FechaFinal="${formatSatDate(fechaFin)}" TipoSolicitud="${tipoSolicitud}"${rfcEmisor ? ` RfcEmisor="${rfcEmisor}"` : ''}${rfcReceptor ? ` RfcReceptor="${rfcReceptor}"` : ''}>
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509IssuerSerial>
|
||||
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
|
||||
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
|
||||
</X509IssuerSerial>
|
||||
<X509Certificate>${cerB64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</des:solicitud>
|
||||
</des:SolicitaDescarga>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicita la descarga de CFDIs al SAT
|
||||
*/
|
||||
export async function requestDownload(params: RequestDownloadParams): Promise<SatDownloadRequestResponse> {
|
||||
const soapRequest = buildDownloadRequest(params);
|
||||
|
||||
try {
|
||||
const response = await fetch(SAT_SOLICITUD_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml;charset=UTF-8',
|
||||
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/ISolicitaDescargaService/SolicitaDescarga',
|
||||
'Authorization': `WRAP access_token="${params.token}"`,
|
||||
},
|
||||
body: soapRequest,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseXml = await response.text();
|
||||
return parseDownloadRequestResponse(responseXml);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Download Request Error]', error);
|
||||
throw new Error(`Error al solicitar descarga: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea la respuesta de solicitud de descarga
|
||||
*/
|
||||
function parseDownloadRequestResponse(responseXml: string): SatDownloadRequestResponse {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
attributeNamePrefix: '@_',
|
||||
});
|
||||
|
||||
const result = parser.parse(responseXml);
|
||||
const envelope = result.Envelope || result['s:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['s:Body'];
|
||||
const respuesta = body?.SolicitaDescargaResponse?.SolicitaDescargaResult;
|
||||
|
||||
if (!respuesta) {
|
||||
throw new Error('Respuesta inválida del SAT');
|
||||
}
|
||||
|
||||
return {
|
||||
idSolicitud: respuesta['@_IdSolicitud'] || '',
|
||||
codEstatus: respuesta['@_CodEstatus'] || '',
|
||||
mensaje: respuesta['@_Mensaje'] || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica el estado de una solicitud de descarga
|
||||
*/
|
||||
export async function verifyRequest(
|
||||
credential: Credential,
|
||||
token: string,
|
||||
rfc: string,
|
||||
idSolicitud: string
|
||||
): Promise<SatVerifyResponse> {
|
||||
const certificate = credential.certificate();
|
||||
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
|
||||
|
||||
const verificaContent = `<des:VerificaSolicitudDescarga xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx"><des:solicitud IdSolicitud="${idSolicitud}" RfcSolicitante="${rfc}"/></des:VerificaSolicitudDescarga>`;
|
||||
const digestValue = createHash('sha1').update(verificaContent).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="">` +
|
||||
`<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">
|
||||
<s:Header/>
|
||||
<s:Body>
|
||||
<des:VerificaSolicitudDescarga>
|
||||
<des:solicitud IdSolicitud="${idSolicitud}" RfcSolicitante="${rfc}">
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509IssuerSerial>
|
||||
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
|
||||
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
|
||||
</X509IssuerSerial>
|
||||
<X509Certificate>${cerB64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</des:solicitud>
|
||||
</des:VerificaSolicitudDescarga>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
try {
|
||||
const response = await fetch(SAT_VERIFICA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml;charset=UTF-8',
|
||||
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga',
|
||||
'Authorization': `WRAP access_token="${token}"`,
|
||||
},
|
||||
body: soapRequest,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseXml = await response.text();
|
||||
return parseVerifyResponse(responseXml);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Verify Error]', error);
|
||||
throw new Error(`Error al verificar solicitud: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea la respuesta de verificación
|
||||
*/
|
||||
function parseVerifyResponse(responseXml: string): SatVerifyResponse {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
attributeNamePrefix: '@_',
|
||||
});
|
||||
|
||||
const result = parser.parse(responseXml);
|
||||
const envelope = result.Envelope || result['s:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['s:Body'];
|
||||
const respuesta = body?.VerificaSolicitudDescargaResponse?.VerificaSolicitudDescargaResult;
|
||||
|
||||
if (!respuesta) {
|
||||
throw new Error('Respuesta de verificación inválida');
|
||||
}
|
||||
|
||||
// Extraer paquetes
|
||||
let paquetes: string[] = [];
|
||||
const paquetesNode = respuesta.IdsPaquetes;
|
||||
if (paquetesNode) {
|
||||
if (Array.isArray(paquetesNode)) {
|
||||
paquetes = paquetesNode;
|
||||
} else if (typeof paquetesNode === 'string') {
|
||||
paquetes = [paquetesNode];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
codEstatus: respuesta['@_CodEstatus'] || '',
|
||||
estadoSolicitud: parseInt(respuesta['@_EstadoSolicitud'] || '0', 10),
|
||||
codigoEstadoSolicitud: respuesta['@_CodigoEstadoSolicitud'] || '',
|
||||
numeroCfdis: parseInt(respuesta['@_NumeroCFDIs'] || '0', 10),
|
||||
mensaje: respuesta['@_Mensaje'] || '',
|
||||
paquetes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga un paquete de CFDIs
|
||||
*/
|
||||
export async function downloadPackage(
|
||||
credential: Credential,
|
||||
token: string,
|
||||
rfc: string,
|
||||
idPaquete: string
|
||||
): Promise<SatPackageResponse> {
|
||||
const certificate = credential.certificate();
|
||||
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
|
||||
|
||||
const descargaContent = `<des:PeticionDescargaMasivaTercerosEntrada xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx"><des:peticionDescarga IdPaquete="${idPaquete}" RfcSolicitante="${rfc}"/></des:PeticionDescargaMasivaTercerosEntrada>`;
|
||||
const digestValue = createHash('sha1').update(descargaContent).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="">` +
|
||||
`<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">
|
||||
<s:Header/>
|
||||
<s:Body>
|
||||
<des:PeticionDescargaMasivaTercerosEntrada>
|
||||
<des:peticionDescarga IdPaquete="${idPaquete}" RfcSolicitante="${rfc}">
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509IssuerSerial>
|
||||
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
|
||||
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
|
||||
</X509IssuerSerial>
|
||||
<X509Certificate>${cerB64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</des:peticionDescarga>
|
||||
</des:PeticionDescargaMasivaTercerosEntrada>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
try {
|
||||
const response = await fetch(SAT_DESCARGA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml;charset=UTF-8',
|
||||
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaService/Descargar',
|
||||
'Authorization': `WRAP access_token="${token}"`,
|
||||
},
|
||||
body: soapRequest,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseXml = await response.text();
|
||||
return parseDownloadResponse(responseXml);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Download Package Error]', error);
|
||||
throw new Error(`Error al descargar paquete: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea la respuesta de descarga de paquete
|
||||
*/
|
||||
function parseDownloadResponse(responseXml: string): SatPackageResponse {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
attributeNamePrefix: '@_',
|
||||
});
|
||||
|
||||
const result = parser.parse(responseXml);
|
||||
const envelope = result.Envelope || result['s:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['s:Body'];
|
||||
const respuesta = body?.RespuestaDescargaMasivaTercerosSalida?.Paquete;
|
||||
|
||||
if (!respuesta) {
|
||||
throw new Error('No se pudo obtener el paquete');
|
||||
}
|
||||
|
||||
return {
|
||||
paquete: respuesta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estados de solicitud del SAT
|
||||
*/
|
||||
export const SAT_REQUEST_STATES = {
|
||||
ACCEPTED: 1,
|
||||
IN_PROGRESS: 2,
|
||||
COMPLETED: 3,
|
||||
ERROR: 4,
|
||||
REJECTED: 5,
|
||||
EXPIRED: 6,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Verifica si la solicitud está completa
|
||||
*/
|
||||
export function isRequestComplete(estadoSolicitud: number): boolean {
|
||||
return estadoSolicitud === SAT_REQUEST_STATES.COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si la solicitud falló
|
||||
*/
|
||||
export function isRequestFailed(estadoSolicitud: number): boolean {
|
||||
return (
|
||||
estadoSolicitud === SAT_REQUEST_STATES.ERROR ||
|
||||
estadoSolicitud === SAT_REQUEST_STATES.REJECTED ||
|
||||
estadoSolicitud === SAT_REQUEST_STATES.EXPIRED
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si la solicitud está en progreso
|
||||
*/
|
||||
export function isRequestInProgress(estadoSolicitud: number): boolean {
|
||||
return (
|
||||
estadoSolicitud === SAT_REQUEST_STATES.ACCEPTED ||
|
||||
estadoSolicitud === SAT_REQUEST_STATES.IN_PROGRESS
|
||||
);
|
||||
}
|
||||
92
apps/api/src/services/sat/sat-opinion-login.ts
Normal file
92
apps/api/src/services/sat/sat-opinion-login.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { type Page } from 'playwright';
|
||||
|
||||
const TIMEOUT = 60_000;
|
||||
|
||||
export async function loginToSatOpinion(
|
||||
page: Page,
|
||||
cerPath: string,
|
||||
keyPath: string,
|
||||
password: string,
|
||||
): Promise<Page> {
|
||||
// Step 1: Navigate to SAT public page
|
||||
const publicUrl = 'https://www.sat.gob.mx/portal/public/tramites/opinion-del-cumplimiento';
|
||||
console.log('[SAT Opinion] Navigating to SAT public page...');
|
||||
await page.goto(publicUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Step 2: Click "Obtén la Opinión del cumplimiento" tab
|
||||
console.log('[SAT Opinion] Clicking "Obtén la Opinión del cumplimiento"...');
|
||||
const obtenerOpcion = page.locator('text=Obt').first();
|
||||
await obtenerOpcion.waitFor({ state: 'visible', timeout: TIMEOUT });
|
||||
await obtenerOpcion.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Step 3: Expand "De tu empresa" accordion
|
||||
console.log('[SAT Opinion] Expanding "De tu empresa" section...');
|
||||
const empresaOption = page.locator('text=De tu empresa').first();
|
||||
await empresaOption.waitFor({ state: 'visible', timeout: TIMEOUT });
|
||||
await empresaOption.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Step 4: Click "Ingresa" link — opens new tab (target=_blank)
|
||||
console.log('[SAT Opinion] Clicking "Ingresa" (opens new tab)...');
|
||||
const ingresaLink = page.locator('a:has-text("Ingresa")').first();
|
||||
await ingresaLink.waitFor({ state: 'visible', timeout: TIMEOUT });
|
||||
|
||||
const [loginPage] = await Promise.all([
|
||||
page.context().waitForEvent('page', { timeout: TIMEOUT }),
|
||||
ingresaLink.click(),
|
||||
]);
|
||||
await loginPage.waitForLoadState('domcontentloaded', { timeout: TIMEOUT });
|
||||
await loginPage.waitForTimeout(2000);
|
||||
|
||||
// Step 5: Switch to e.firma login
|
||||
console.log('[SAT Opinion] Clicking e.firma button...');
|
||||
const efirmaButton = loginPage.locator('button:has-text("e.firma"), input[value*="firma"], a:has-text("e.firma")').first();
|
||||
await efirmaButton.waitFor({ state: 'visible', timeout: TIMEOUT });
|
||||
await efirmaButton.click();
|
||||
await loginPage.waitForLoadState('domcontentloaded', { timeout: TIMEOUT });
|
||||
await loginPage.waitForTimeout(2000);
|
||||
|
||||
// Step 6: Upload .cer
|
||||
console.log('[SAT Opinion] Uploading .cer...');
|
||||
const cerInput = loginPage.locator('input[type="file"]').first();
|
||||
await cerInput.waitFor({ state: 'attached', timeout: TIMEOUT });
|
||||
await cerInput.setInputFiles(cerPath);
|
||||
await loginPage.waitForTimeout(500);
|
||||
|
||||
// Step 7: Upload .key
|
||||
console.log('[SAT Opinion] Uploading .key...');
|
||||
const keyInput = loginPage.locator('input[type="file"]').nth(1);
|
||||
await keyInput.waitFor({ state: 'attached', timeout: TIMEOUT });
|
||||
await keyInput.setInputFiles(keyPath);
|
||||
await loginPage.waitForTimeout(500);
|
||||
|
||||
// Step 8: Enter password
|
||||
console.log('[SAT Opinion] Entering password...');
|
||||
const passwordInput = loginPage.locator('input[type="password"]').first();
|
||||
await passwordInput.waitFor({ state: 'visible', timeout: TIMEOUT });
|
||||
await passwordInput.fill(password);
|
||||
|
||||
// Step 9: Submit
|
||||
console.log('[SAT Opinion] Submitting login...');
|
||||
const submitButton = loginPage.locator('button:has-text("Enviar"), input[value="Enviar"], a:has-text("Enviar"), input[type="submit"], button[type="submit"]').first();
|
||||
await submitButton.waitFor({ state: 'visible', timeout: TIMEOUT });
|
||||
await submitButton.click();
|
||||
|
||||
// Step 10: Wait for auth + redirect to report
|
||||
console.log('[SAT Opinion] Waiting for authentication...');
|
||||
await loginPage.waitForTimeout(8000);
|
||||
|
||||
const currentUrl = loginPage.url();
|
||||
if (!currentUrl.includes('reporteOpinion32DContribuyente')) {
|
||||
const baseUrl = currentUrl.replace(/#.*$/, '').replace(/\?.*$/, '');
|
||||
const reporteUrl = baseUrl + '#/reporteOpinion32DContribuyente';
|
||||
console.log(`[SAT Opinion] Navigating to report: ${reporteUrl}`);
|
||||
await loginPage.goto(reporteUrl, { waitUntil: 'domcontentloaded', timeout: TIMEOUT });
|
||||
await loginPage.waitForTimeout(5000);
|
||||
}
|
||||
|
||||
console.log('[SAT Opinion] Login completed.');
|
||||
return loginPage;
|
||||
}
|
||||
76
apps/api/src/services/sat/sat-opinion-parser.ts
Normal file
76
apps/api/src/services/sat/sat-opinion-parser.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
|
||||
export interface ParsedOpinion {
|
||||
rfc: string;
|
||||
razonSocial: string;
|
||||
estatus: string;
|
||||
folio: string;
|
||||
cadenaOriginal: string;
|
||||
fechaConsulta: string;
|
||||
}
|
||||
|
||||
export async function parseOpinionPdf(pdfBuffer: Buffer): Promise<ParsedOpinion> {
|
||||
const pdfParse = new PDFParse({ data: new Uint8Array(pdfBuffer) });
|
||||
try {
|
||||
const textResult = await pdfParse.getText();
|
||||
const text = textResult.text;
|
||||
|
||||
return {
|
||||
rfc: extractRfc(text),
|
||||
razonSocial: extractRazonSocial(text),
|
||||
estatus: extractEstatus(text),
|
||||
folio: extractFolio(text),
|
||||
cadenaOriginal: extractCadenaOriginal(text),
|
||||
fechaConsulta: extractFecha(text),
|
||||
};
|
||||
} finally {
|
||||
await pdfParse.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function extractRfc(text: string): string {
|
||||
const match = text.match(/RFC\s+Folio\s*\n\s*([A-ZÑ&]{3,4}\d{6}[A-Z\d]{3})/i);
|
||||
if (match) return match[1].trim();
|
||||
const fallback = text.match(/\b([A-ZÑ&]{3,4}\d{6}[A-Z\d]{3})\b/);
|
||||
return fallback ? fallback[1] : 'NO_ENCONTRADO';
|
||||
}
|
||||
|
||||
function extractRazonSocial(text: string): string {
|
||||
const match = text.match(/(?:Nombre|denominaci[oó]n|raz[oó]n social)\s+Sentido\s*\n\s*(.+)/i);
|
||||
if (match) {
|
||||
return match[1].trim().replace(/\s+(POSITIVO|NEGATIVO|EN SUSPENSI[OÓ]N|NO INSCRITO|INSCRITO SIN OBLIGACIONES)\s*$/i, '').trim();
|
||||
}
|
||||
return 'NO_ENCONTRADO';
|
||||
}
|
||||
|
||||
function extractEstatus(text: string): string {
|
||||
const match = text.match(/Sentido\s*\n\s*.+\s+(POSITIVO|NEGATIVO|EN SUSPENSI[OÓ]N|NO INSCRITO|INSCRITO SIN OBLIGACIONES)\s*$/im);
|
||||
if (match) {
|
||||
const raw = match[1].trim().toUpperCase();
|
||||
if (raw === 'POSITIVO') return 'Positiva';
|
||||
if (raw === 'NEGATIVO') return 'Negativa';
|
||||
if (raw.includes('SUSPENSI')) return 'En suspensión';
|
||||
if (raw.includes('NO INSCRITO')) return 'No inscrito';
|
||||
if (raw.includes('SIN OBLIGACIONES')) return 'Inscrito sin obligaciones';
|
||||
}
|
||||
if (/POSITIVO/i.test(text)) return 'Positiva';
|
||||
if (/NEGATIVO/i.test(text)) return 'Negativa';
|
||||
return 'NO_DETERMINADO';
|
||||
}
|
||||
|
||||
function extractFolio(text: string): string {
|
||||
const match = text.match(/RFC\s+Folio\s*\n\s*[A-ZÑ&]{3,4}\d{6}[A-Z\d]{3}\s+(\S+)/i);
|
||||
return match ? match[1].trim() : 'NO_ENCONTRADO';
|
||||
}
|
||||
|
||||
function extractCadenaOriginal(text: string): string {
|
||||
const match = text.match(/Cadena Original\s*\n\s*(\|\|.+\|\|)/i);
|
||||
return match ? match[1].trim() : 'NO_ENCONTRADO';
|
||||
}
|
||||
|
||||
function extractFecha(text: string): string {
|
||||
const match = text.match(/Fecha\s+y\s+hora\s+de\s+emisi[oó]n\s*\n\s*(.+)/i);
|
||||
if (match) return match[1].trim();
|
||||
const fallback = text.match(/(\d{1,2}\s+de\s+\w+\s+de\s+\d{4}\s+a\s+las\s+[\d:]+\s+horas)/i);
|
||||
return fallback ? fallback[1].trim() : 'NO_ENCONTRADO';
|
||||
}
|
||||
84
apps/api/src/services/sat/sat-opinion-scraper.ts
Normal file
84
apps/api/src/services/sat/sat-opinion-scraper.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { type Page } from 'playwright';
|
||||
|
||||
export async function extractOpinionPdf(page: Page): Promise<Buffer> {
|
||||
const TIMEOUT = 120_000;
|
||||
const POLL_INTERVAL = 3_000;
|
||||
|
||||
console.log('[SAT Opinion Scraper] Waiting for PDF to appear...');
|
||||
|
||||
let interceptedPdf: Buffer | null = null;
|
||||
page.on('response', async (response) => {
|
||||
try {
|
||||
const contentType = response.headers()['content-type'] || '';
|
||||
if (contentType.includes('application/pdf') || response.url().endsWith('.pdf')) {
|
||||
const body = await response.body();
|
||||
if (body.length > 100) {
|
||||
interceptedPdf = body;
|
||||
console.log(`[SAT Opinion Scraper] PDF intercepted via network: ${body.length} bytes`);
|
||||
}
|
||||
}
|
||||
} catch { /* response body may not be available */ }
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < TIMEOUT) {
|
||||
if (interceptedPdf) return interceptedPdf;
|
||||
|
||||
// Strategy 1: <embed> or <object> with PDF data URI
|
||||
const embedData = await page.evaluate(() => {
|
||||
for (const el of document.querySelectorAll('embed, object')) {
|
||||
const src = el.getAttribute('src') || el.getAttribute('data') || '';
|
||||
if (src.startsWith('data:application/pdf;base64,')) return src;
|
||||
}
|
||||
return null;
|
||||
}).catch(() => null);
|
||||
|
||||
if (embedData) {
|
||||
console.log('[SAT Opinion Scraper] PDF found via <embed>/<object>');
|
||||
return decodeDataUri(embedData);
|
||||
}
|
||||
|
||||
// Strategy 2: Scan full HTML for base64 PDF
|
||||
const html = await page.content().catch(() => '');
|
||||
const match = html.match(/data:application\/pdf;base64,([A-Za-z0-9+/=\s]+)/);
|
||||
if (match) {
|
||||
console.log('[SAT Opinion Scraper] PDF found via page content scan');
|
||||
return decodeDataUri(`data:application/pdf;base64,${match[1]}`);
|
||||
}
|
||||
|
||||
// Strategy 3: Check iframes
|
||||
for (const frame of page.frames()) {
|
||||
try {
|
||||
const frameUrl = frame.url();
|
||||
if (frameUrl.startsWith('data:application/pdf;base64,')) {
|
||||
console.log('[SAT Opinion Scraper] PDF found via iframe URL');
|
||||
return decodeDataUri(frameUrl);
|
||||
}
|
||||
const frameHtml = await frame.content();
|
||||
const frameMatch = frameHtml.match(/data:application\/pdf;base64,([A-Za-z0-9+/=\s]+)/);
|
||||
if (frameMatch) {
|
||||
console.log('[SAT Opinion Scraper] PDF found via iframe content');
|
||||
return decodeDataUri(`data:application/pdf;base64,${frameMatch[1]}`);
|
||||
}
|
||||
} catch { /* cross-origin frame */ }
|
||||
}
|
||||
|
||||
// Strategy 4: Page URL itself
|
||||
if (page.url().startsWith('data:application/pdf;base64,')) {
|
||||
console.log('[SAT Opinion Scraper] PDF found via page URL');
|
||||
return decodeDataUri(page.url());
|
||||
}
|
||||
|
||||
console.log(`[SAT Opinion Scraper] PDF not found, retrying in ${POLL_INTERVAL / 1000}s...`);
|
||||
await page.waitForTimeout(POLL_INTERVAL);
|
||||
}
|
||||
|
||||
throw new Error(`PDF not found after ${TIMEOUT / 1000}s`);
|
||||
}
|
||||
|
||||
function decodeDataUri(dataUri: string): Buffer {
|
||||
const prefix = 'data:application/pdf;base64,';
|
||||
const base64 = dataUri.substring(prefix.length).replace(/\s/g, '');
|
||||
return Buffer.from(base64, 'base64');
|
||||
}
|
||||
735
apps/api/src/services/sat/sat-parser.service.ts
Normal file
735
apps/api/src/services/sat/sat-parser.service.ts
Normal file
@@ -0,0 +1,735 @@
|
||||
import AdmZip from 'adm-zip';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import type { TipoCfdi, EstadoCfdi } from '@horux/shared';
|
||||
|
||||
interface CfdiParsed {
|
||||
uuid: string;
|
||||
type: TipoCfdi;
|
||||
tipoComprobante: string;
|
||||
serie: string | null;
|
||||
folio: string | null;
|
||||
status: EstadoCfdi;
|
||||
fechaEmision: Date;
|
||||
fechaCertSat: Date | null;
|
||||
rfcEmisor: string;
|
||||
nombreEmisor: string;
|
||||
rfcReceptor: string;
|
||||
nombreReceptor: string;
|
||||
subtotal: number;
|
||||
descuento: number;
|
||||
total: number;
|
||||
moneda: string;
|
||||
tipoCambio: number;
|
||||
metodoPago: string | null;
|
||||
formaPago: string | null;
|
||||
usoCfdi: string | null;
|
||||
pac: string | null;
|
||||
// Impuestos del comprobante
|
||||
ivaTraslado: number;
|
||||
isrRetencion: number;
|
||||
ivaRetencion: number;
|
||||
iepsTraslado: number;
|
||||
iepsRetencion: number;
|
||||
// Impuestos locales
|
||||
impuestosLocalesTrasladado: number;
|
||||
impuestosLocalesRetenidos: number;
|
||||
// Complemento de pagos
|
||||
montoPago: number;
|
||||
fechaPagoP: string | null;
|
||||
numParcialidad: string | null;
|
||||
uuidRelacionado: string | null;
|
||||
saldoInsoluto: string | null;
|
||||
isrRetencionPago: number;
|
||||
ivaTrasladoPago: number;
|
||||
ivaRetencionPago: number;
|
||||
iepsTrasladoPago: number;
|
||||
iepsRetencionPago: number;
|
||||
// Nómina
|
||||
fechaPago: string | null;
|
||||
fechaInicialPago: string | null;
|
||||
fechaFinalPago: string | null;
|
||||
numDiasPagados: number;
|
||||
numSeguroSocial: string | null;
|
||||
puesto: string | null;
|
||||
salarioBaseCotApor: number;
|
||||
salarioDiarioIntegrado: number;
|
||||
totalPercepciones: number;
|
||||
totalDeducciones: number;
|
||||
impRetenidosNomina: number;
|
||||
otrasDeduccionesNomina: number;
|
||||
subsidioCausado: number;
|
||||
|
||||
regimenFiscalEmisor: string | null;
|
||||
regimenFiscalReceptor: string | null;
|
||||
// CfdiRelacionados a nivel raíz del comprobante (CFDI 4.0).
|
||||
// `cfdiTipoRelacion` — clave SAT (01..07). NULL si no hay relación.
|
||||
// `cfdisRelacionados` — UUIDs pipe-separated.
|
||||
cfdiTipoRelacion: string | null;
|
||||
cfdisRelacionados: string | null;
|
||||
conceptos: ConceptoParsed[];
|
||||
xmlOriginal: string;
|
||||
}
|
||||
|
||||
interface ConceptoParsed {
|
||||
claveProdServ: string | null;
|
||||
noIdentificacion: string | null;
|
||||
descripcion: string;
|
||||
cantidad: number;
|
||||
claveUnidad: string | null;
|
||||
unidad: string | null;
|
||||
valorUnitario: number;
|
||||
importe: number;
|
||||
descuento: number;
|
||||
// Impuestos por concepto
|
||||
isrRetencion: number;
|
||||
ivaTraslado: number;
|
||||
ivaRetencion: number;
|
||||
iepsTraslado: number;
|
||||
iepsRetencion: number;
|
||||
}
|
||||
|
||||
interface ExtractedXml {
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const xmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
removeNSPrefix: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Extrae archivos XML de un paquete ZIP en base64
|
||||
*/
|
||||
export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
|
||||
const zipBuffer = Buffer.from(zipBase64, 'base64');
|
||||
const zip = new AdmZip(zipBuffer);
|
||||
const entries = zip.getEntries();
|
||||
|
||||
const xmlFiles: ExtractedXml[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.entryName.toLowerCase().endsWith('.xml')) {
|
||||
const content = entry.getData().toString('utf-8');
|
||||
xmlFiles.push({
|
||||
filename: entry.entryName,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return xmlFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea una fecha del XML/CSV del SAT preservando la hora **literal**.
|
||||
*
|
||||
* Problema: el CFDI 4.0 define `Fecha` y `FechaTimbrado` como ISO-8601 sin
|
||||
* zona horaria (hora local del contribuyente = México). Si se pasa tal cual
|
||||
* a `new Date(str)`, Node lo interpreta según la timezone de la máquina:
|
||||
* en CDMX (UTC-6), "2025-12-31T18:37:51" se convierte a UTC
|
||||
* "2026-01-01T00:37:51Z", cambiando la fecha efectiva y desalineando el
|
||||
* mes/año del CFDI. Postgres guarda ese valor UTC, y los filtros por rango
|
||||
* lo sacan del mes correcto.
|
||||
*
|
||||
* Solución: forzar 'Z' si el string no trae TZ indicator. Esto hace que
|
||||
* Node interprete el texto como UTC literal y preserve la hora tal cual.
|
||||
* El valor queda naive pero consistente: todo el sistema filtra con
|
||||
* fechas naive (sin TZ), así que el resultado es correcto.
|
||||
*/
|
||||
function parseCfdiDate(str: string | null | undefined): Date {
|
||||
if (!str) return new Date(0);
|
||||
const s = String(str).trim();
|
||||
if (!s) return new Date(0);
|
||||
const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s);
|
||||
return new Date(hasTz ? s : s + 'Z');
|
||||
}
|
||||
|
||||
function toArray(val: any): any[] {
|
||||
if (!val) return [];
|
||||
return Array.isArray(val) ? val : [val];
|
||||
}
|
||||
|
||||
function pf(val: any): number {
|
||||
return parseFloat(val || '0') || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el UUID del TimbreFiscalDigital
|
||||
*/
|
||||
function extractUuid(comprobante: any): string {
|
||||
return comprobante.Complemento?.TimbreFiscalDigital?.['@_UUID'] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae datos del timbre: fecha cert SAT y PAC
|
||||
*/
|
||||
function extractTimbreData(comprobante: any): { fechaCertSat: Date | null; pac: string | null } {
|
||||
const timbre = comprobante.Complemento?.TimbreFiscalDigital;
|
||||
if (!timbre) return { fechaCertSat: null, pac: null };
|
||||
|
||||
return {
|
||||
fechaCertSat: timbre['@_FechaTimbrado'] ? parseCfdiDate(timbre['@_FechaTimbrado']) : null,
|
||||
pac: timbre['@_RfcProvCertif'] || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae impuestos trasladados (IVA 002, IEPS 003)
|
||||
*/
|
||||
function extractTraslados(comprobante: any): { iva: number; ieps: number } {
|
||||
const traslados = toArray(comprobante.Impuestos?.Traslados?.Traslado);
|
||||
let iva = 0, ieps = 0;
|
||||
|
||||
for (const t of traslados) {
|
||||
const importe = pf(t['@_Importe']);
|
||||
if (t['@_Impuesto'] === '002') iva += importe;
|
||||
else if (t['@_Impuesto'] === '003') ieps += importe;
|
||||
}
|
||||
|
||||
return { iva, ieps };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae impuestos retenidos (ISR 001, IVA 002, IEPS 003)
|
||||
*/
|
||||
function extractRetenciones(comprobante: any): { isr: number; iva: number; ieps: number } {
|
||||
const retenciones = toArray(comprobante.Impuestos?.Retenciones?.Retencion);
|
||||
let isr = 0, iva = 0, ieps = 0;
|
||||
|
||||
for (const r of retenciones) {
|
||||
const importe = pf(r['@_Importe']);
|
||||
if (r['@_Impuesto'] === '001') isr += importe;
|
||||
else if (r['@_Impuesto'] === '002') iva += importe;
|
||||
else if (r['@_Impuesto'] === '003') ieps += importe;
|
||||
}
|
||||
|
||||
return { isr, iva, ieps };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae impuestos locales
|
||||
*/
|
||||
function extractImpuestosLocales(comprobante: any): { trasladado: number; retenido: number } {
|
||||
const complemento = comprobante.Complemento;
|
||||
if (!complemento) return { trasladado: 0, retenido: 0 };
|
||||
|
||||
const impLocales = complemento.ImpuestosLocales;
|
||||
if (!impLocales) return { trasladado: 0, retenido: 0 };
|
||||
|
||||
return {
|
||||
trasladado: pf(impLocales['@_TotaldeTraslados']),
|
||||
retenido: pf(impLocales['@_TotaldeRetenciones']),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae CfdiRelacionados a nivel raíz del Comprobante. Puede haber 1+
|
||||
* nodos `cfdi:CfdiRelacionados` (cada uno con un `TipoRelacion`), y dentro
|
||||
* de cada uno 1+ `cfdi:CfdiRelacionado` con UUID. Retorna el primer
|
||||
* TipoRelacion encontrado (lo más común) y todos los UUIDs pipe-separated.
|
||||
*/
|
||||
function extractCfdiRelacionados(comprobante: any): {
|
||||
tipoRelacion: string | null;
|
||||
uuids: string | null;
|
||||
} {
|
||||
const nodes = toArray(comprobante.CfdiRelacionados);
|
||||
if (nodes.length === 0) return { tipoRelacion: null, uuids: null };
|
||||
|
||||
let tipoRelacion: string | null = null;
|
||||
const allUuids: string[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!tipoRelacion && node['@_TipoRelacion']) {
|
||||
tipoRelacion = String(node['@_TipoRelacion']);
|
||||
}
|
||||
const rels = toArray(node.CfdiRelacionado);
|
||||
for (const r of rels) {
|
||||
if (r['@_UUID']) allUuids.push(String(r['@_UUID']));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tipoRelacion,
|
||||
uuids: allUuids.length > 0 ? allUuids.join('|') : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae datos del complemento de pagos (pago20)
|
||||
*/
|
||||
function extractPagos(comprobante: any): {
|
||||
montoPago: number;
|
||||
fechaPagoP: string | null;
|
||||
numParcialidad: string | null;
|
||||
uuidRelacionado: string | null;
|
||||
saldoInsoluto: string | null;
|
||||
isrRetencion: number;
|
||||
ivaTraslado: number;
|
||||
ivaRetencion: number;
|
||||
iepsTraslado: number;
|
||||
iepsRetencion: number;
|
||||
} {
|
||||
const result = {
|
||||
montoPago: 0, fechaPagoP: null as string | null,
|
||||
numParcialidad: null as string | null,
|
||||
uuidRelacionado: null as string | null,
|
||||
saldoInsoluto: null as string | null,
|
||||
isrRetencion: 0, ivaTraslado: 0, ivaRetencion: 0,
|
||||
iepsTraslado: 0, iepsRetencion: 0,
|
||||
};
|
||||
|
||||
const complemento = comprobante.Complemento;
|
||||
if (!complemento) return result;
|
||||
|
||||
// Try pago20:Pagos or just Pagos
|
||||
const pagosNode = complemento.Pagos;
|
||||
if (!pagosNode) return result;
|
||||
|
||||
const pagos = toArray(pagosNode.Pago);
|
||||
const fechas: string[] = [];
|
||||
const parcialidades: string[] = [];
|
||||
const uuids: string[] = [];
|
||||
const saldos: string[] = [];
|
||||
|
||||
for (const pago of pagos) {
|
||||
result.montoPago += pf(pago['@_Monto']);
|
||||
if (pago['@_FechaPago']) fechas.push(pago['@_FechaPago']);
|
||||
|
||||
// Impuestos del pago
|
||||
const retPago = toArray(pago.ImpuestosP?.RetencionesPP?.RetencionP || pago.ImpuestosP?.RetencionesPP);
|
||||
for (const r of retPago) {
|
||||
const importe = pf(r['@_ImporteP']);
|
||||
if (r['@_ImpuestoP'] === '001') result.isrRetencion += importe;
|
||||
else if (r['@_ImpuestoP'] === '002') result.ivaRetencion += importe;
|
||||
else if (r['@_ImpuestoP'] === '003') result.iepsRetencion += importe;
|
||||
}
|
||||
|
||||
const trasPago = toArray(pago.ImpuestosP?.TrasladosP?.TrasladoP || pago.ImpuestosP?.TrasladosP);
|
||||
for (const t of trasPago) {
|
||||
const importe = pf(t['@_ImporteP']);
|
||||
if (t['@_ImpuestoP'] === '002') result.ivaTraslado += importe;
|
||||
else if (t['@_ImpuestoP'] === '003') result.iepsTraslado += importe;
|
||||
}
|
||||
|
||||
// Documentos relacionados
|
||||
const doctos = toArray(pago.DoctoRelacionado);
|
||||
for (const d of doctos) {
|
||||
if (d['@_IdDocumento']) uuids.push(d['@_IdDocumento']);
|
||||
if (d['@_NumParcialidad']) parcialidades.push(d['@_NumParcialidad']);
|
||||
if (d['@_ImpSaldoInsoluto'] !== undefined) saldos.push(d['@_ImpSaldoInsoluto']);
|
||||
}
|
||||
}
|
||||
|
||||
result.fechaPagoP = fechas.length > 0 ? fechas.join('|') : null;
|
||||
result.numParcialidad = parcialidades.length > 0 ? parcialidades.join('|') : null;
|
||||
result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null;
|
||||
result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae datos del complemento de nómina (nomina12)
|
||||
*/
|
||||
function extractNomina(comprobante: any): {
|
||||
fechaPago: string | null;
|
||||
fechaInicialPago: string | null;
|
||||
fechaFinalPago: string | null;
|
||||
numDiasPagados: number;
|
||||
numSeguroSocial: string | null;
|
||||
puesto: string | null;
|
||||
salarioBaseCotApor: number;
|
||||
salarioDiarioIntegrado: number;
|
||||
totalPercepciones: number;
|
||||
totalDeducciones: number;
|
||||
impRetenidosNomina: number;
|
||||
otrasDeduccionesNomina: number;
|
||||
subsidioCausado: number;
|
||||
} {
|
||||
const result = {
|
||||
fechaPago: null as string | null,
|
||||
fechaInicialPago: null as string | null,
|
||||
fechaFinalPago: null as string | null,
|
||||
numDiasPagados: 0,
|
||||
numSeguroSocial: null as string | null,
|
||||
puesto: null as string | null,
|
||||
salarioBaseCotApor: 0,
|
||||
salarioDiarioIntegrado: 0,
|
||||
totalPercepciones: 0,
|
||||
totalDeducciones: 0,
|
||||
impRetenidosNomina: 0,
|
||||
otrasDeduccionesNomina: 0,
|
||||
subsidioCausado: 0,
|
||||
};
|
||||
|
||||
const complemento = comprobante.Complemento;
|
||||
if (!complemento) return result;
|
||||
|
||||
const nomina = complemento.Nomina;
|
||||
if (!nomina) return result;
|
||||
|
||||
result.fechaPago = nomina['@_FechaPago'] || null;
|
||||
result.fechaInicialPago = nomina['@_FechaInicialPago'] || null;
|
||||
result.fechaFinalPago = nomina['@_FechaFinalPago'] || null;
|
||||
result.numDiasPagados = pf(nomina['@_NumDiasPagados']);
|
||||
result.totalPercepciones = pf(nomina['@_TotalPercepciones']);
|
||||
result.totalDeducciones = pf(nomina['@_TotalDeducciones']);
|
||||
|
||||
// Receptor de nómina
|
||||
const receptor = nomina.Receptor;
|
||||
if (receptor) {
|
||||
result.numSeguroSocial = receptor['@_NumSeguridadSocial'] || null;
|
||||
result.puesto = receptor['@_Puesto'] || null;
|
||||
result.salarioBaseCotApor = pf(receptor['@_SalarioBaseCotApor']);
|
||||
result.salarioDiarioIntegrado = pf(receptor['@_SalarioDiarioIntegrado']);
|
||||
}
|
||||
|
||||
// Deducciones
|
||||
const deducciones = nomina.Deducciones;
|
||||
if (deducciones) {
|
||||
result.impRetenidosNomina = pf(deducciones['@_TotalImpuestosRetenidos']);
|
||||
result.otrasDeduccionesNomina = pf(deducciones['@_TotalOtrasDeducciones']);
|
||||
}
|
||||
|
||||
// Subsidio causado (OtrosPagos/OtroPago[@TipoOtroPago='002'])
|
||||
const otrosPagos = toArray(nomina.OtrosPagos?.OtroPago);
|
||||
for (const op of otrosPagos) {
|
||||
if (op['@_TipoOtroPago'] === '002') {
|
||||
result.subsidioCausado = pf(op.SubsidioAlEmpleo?.['@_SubsidioCausado']);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae los conceptos del comprobante
|
||||
*/
|
||||
function extractConceptos(comprobante: any): ConceptoParsed[] {
|
||||
const conceptosNode = comprobante.Conceptos?.Concepto;
|
||||
if (!conceptosNode) return [];
|
||||
|
||||
const conceptos = toArray(conceptosNode);
|
||||
return conceptos.map((c: any) => {
|
||||
// Impuestos por concepto
|
||||
const trasladosC = toArray(c.Impuestos?.Traslados?.Traslado);
|
||||
const retencionesC = toArray(c.Impuestos?.Retenciones?.Retencion);
|
||||
|
||||
let ivaTraslado = 0, iepsTraslado = 0;
|
||||
for (const t of trasladosC) {
|
||||
const importe = pf(t['@_Importe']);
|
||||
if (t['@_Impuesto'] === '002') ivaTraslado += importe;
|
||||
else if (t['@_Impuesto'] === '003') iepsTraslado += importe;
|
||||
}
|
||||
|
||||
let isrRetencion = 0, ivaRetencion = 0, iepsRetencion = 0;
|
||||
for (const r of retencionesC) {
|
||||
const importe = pf(r['@_Importe']);
|
||||
if (r['@_Impuesto'] === '001') isrRetencion += importe;
|
||||
else if (r['@_Impuesto'] === '002') ivaRetencion += importe;
|
||||
else if (r['@_Impuesto'] === '003') iepsRetencion += importe;
|
||||
}
|
||||
|
||||
return {
|
||||
claveProdServ: c['@_ClaveProdServ'] || null,
|
||||
noIdentificacion: c['@_NoIdentificacion'] || null,
|
||||
descripcion: c['@_Descripcion'] || '',
|
||||
cantidad: pf(c['@_Cantidad']) || 1,
|
||||
claveUnidad: c['@_ClaveUnidad'] || null,
|
||||
unidad: c['@_Unidad'] || null,
|
||||
valorUnitario: pf(c['@_ValorUnitario']),
|
||||
importe: pf(c['@_Importe']),
|
||||
descuento: pf(c['@_Descuento']),
|
||||
isrRetencion,
|
||||
ivaTraslado,
|
||||
ivaRetencion,
|
||||
iepsTraslado,
|
||||
iepsRetencion,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea un XML de CFDI y extrae los datos relevantes
|
||||
* @param downloadType - 'emitidos' o 'recibidos' para determinar el type (EMITIDO/RECIBIDO)
|
||||
*/
|
||||
export function parseXml(xmlContent: string, downloadType: 'emitidos' | 'recibidos' = 'emitidos'): CfdiParsed | null {
|
||||
try {
|
||||
const result = xmlParser.parse(xmlContent);
|
||||
const comprobante = result.Comprobante;
|
||||
|
||||
if (!comprobante) {
|
||||
console.error('[Parser] No se encontró el nodo Comprobante');
|
||||
return null;
|
||||
}
|
||||
|
||||
const emisor = comprobante.Emisor || {};
|
||||
const receptor = comprobante.Receptor || {};
|
||||
const retenciones = extractRetenciones(comprobante);
|
||||
const traslados = extractTraslados(comprobante);
|
||||
const timbreData = extractTimbreData(comprobante);
|
||||
const impLocales = extractImpuestosLocales(comprobante);
|
||||
const tipoComprobante = comprobante['@_TipoDeComprobante'] || 'I';
|
||||
|
||||
// Complemento de pagos (solo tipo P)
|
||||
const pagosData = tipoComprobante === 'P' ? extractPagos(comprobante) : {
|
||||
montoPago: 0, fechaPagoP: null, numParcialidad: null,
|
||||
uuidRelacionado: null, saldoInsoluto: null,
|
||||
isrRetencion: 0, ivaTraslado: 0, ivaRetencion: 0,
|
||||
iepsTraslado: 0, iepsRetencion: 0,
|
||||
};
|
||||
|
||||
// CfdiRelacionados a nivel raíz. CFDI 4.0 permite 1+ nodos
|
||||
// `cfdi:CfdiRelacionados` cada uno con un TipoRelacion y múltiples UUIDs.
|
||||
// Aquí capturamos el PRIMER TipoRelacion (lo más común es que haya uno
|
||||
// solo, especialmente en NC tipo E). Los UUIDs de todos los bloques se
|
||||
// concatenan con `|`.
|
||||
const relacionesData = extractCfdiRelacionados(comprobante);
|
||||
|
||||
// Complemento de nómina (solo tipo N)
|
||||
const nominaData = tipoComprobante === 'N' ? extractNomina(comprobante) : {
|
||||
fechaPago: null, fechaInicialPago: null, fechaFinalPago: null,
|
||||
numDiasPagados: 0, numSeguroSocial: null, puesto: null,
|
||||
salarioBaseCotApor: 0, salarioDiarioIntegrado: 0,
|
||||
totalPercepciones: 0, totalDeducciones: 0,
|
||||
impRetenidosNomina: 0, otrasDeduccionesNomina: 0, subsidioCausado: 0,
|
||||
};
|
||||
|
||||
const cfdi: CfdiParsed = {
|
||||
uuid: extractUuid(comprobante),
|
||||
type: downloadType === 'emitidos' ? 'EMITIDO' : 'RECIBIDO',
|
||||
tipoComprobante,
|
||||
serie: comprobante['@_Serie'] || null,
|
||||
folio: comprobante['@_Folio'] || null,
|
||||
status: 'Vigente',
|
||||
fechaEmision: parseCfdiDate(comprobante['@_Fecha']),
|
||||
fechaCertSat: timbreData.fechaCertSat,
|
||||
rfcEmisor: emisor['@_Rfc'] || '',
|
||||
nombreEmisor: emisor['@_Nombre'] || '',
|
||||
rfcReceptor: receptor['@_Rfc'] || '',
|
||||
nombreReceptor: receptor['@_Nombre'] || '',
|
||||
subtotal: pf(comprobante['@_SubTotal']),
|
||||
descuento: pf(comprobante['@_Descuento']),
|
||||
total: pf(comprobante['@_Total']),
|
||||
moneda: comprobante['@_Moneda'] || 'MXN',
|
||||
tipoCambio: pf(comprobante['@_TipoCambio']) || 1,
|
||||
metodoPago: comprobante['@_MetodoPago'] || null,
|
||||
formaPago: comprobante['@_FormaPago'] || null,
|
||||
usoCfdi: receptor['@_UsoCFDI'] || null,
|
||||
pac: timbreData.pac,
|
||||
regimenFiscalEmisor: emisor['@_RegimenFiscal'] || null,
|
||||
regimenFiscalReceptor: receptor['@_RegimenFiscalReceptor'] || receptor['@_RegimenFiscal'] || null,
|
||||
cfdiTipoRelacion: relacionesData.tipoRelacion,
|
||||
cfdisRelacionados: relacionesData.uuids,
|
||||
// Impuestos comprobante
|
||||
ivaTraslado: traslados.iva,
|
||||
isrRetencion: retenciones.isr,
|
||||
ivaRetencion: retenciones.iva,
|
||||
iepsTraslado: traslados.ieps,
|
||||
iepsRetencion: retenciones.ieps,
|
||||
// Impuestos locales
|
||||
impuestosLocalesTrasladado: impLocales.trasladado,
|
||||
impuestosLocalesRetenidos: impLocales.retenido,
|
||||
// Complemento de pagos
|
||||
montoPago: pagosData.montoPago,
|
||||
fechaPagoP: pagosData.fechaPagoP,
|
||||
numParcialidad: pagosData.numParcialidad,
|
||||
uuidRelacionado: pagosData.uuidRelacionado,
|
||||
saldoInsoluto: pagosData.saldoInsoluto,
|
||||
isrRetencionPago: pagosData.isrRetencion,
|
||||
ivaTrasladoPago: pagosData.ivaTraslado,
|
||||
ivaRetencionPago: pagosData.ivaRetencion,
|
||||
iepsTrasladoPago: pagosData.iepsTraslado,
|
||||
iepsRetencionPago: pagosData.iepsRetencion,
|
||||
// Nómina
|
||||
...nominaData,
|
||||
conceptos: extractConceptos(comprobante),
|
||||
xmlOriginal: xmlContent,
|
||||
};
|
||||
|
||||
if (!cfdi.uuid) {
|
||||
console.error('[Parser] CFDI sin UUID');
|
||||
return null;
|
||||
}
|
||||
|
||||
return cfdi;
|
||||
} catch (error) {
|
||||
console.error('[Parser Error]', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa un paquete ZIP completo y retorna los CFDIs parseados
|
||||
* @param downloadType - 'emitidos' o 'recibidos'
|
||||
*/
|
||||
export function processPackage(zipBase64: string, downloadType: 'emitidos' | 'recibidos' = 'emitidos'): CfdiParsed[] {
|
||||
const xmlFiles = extractXmlsFromZip(zipBase64);
|
||||
const cfdis: CfdiParsed[] = [];
|
||||
|
||||
for (const { content } of xmlFiles) {
|
||||
const cfdi = parseXml(content, downloadType);
|
||||
if (cfdi) {
|
||||
cfdis.push(cfdi);
|
||||
}
|
||||
}
|
||||
|
||||
return cfdis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datos parseados de un registro de metadata del SAT
|
||||
*/
|
||||
interface CfdiMetadata {
|
||||
uuid: string;
|
||||
rfcEmisor: string;
|
||||
nombreEmisor: string;
|
||||
rfcReceptor: string;
|
||||
nombreReceptor: string;
|
||||
rfcPac: string | null;
|
||||
fechaEmision: Date;
|
||||
fechaCertSat: Date | null;
|
||||
fechaCancelacion: Date | null;
|
||||
monto: number;
|
||||
tipoComprobante: string;
|
||||
status: string; // 'Vigente' | 'Cancelado'
|
||||
type: 'EMITIDO' | 'RECIBIDO';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae archivos CSV de un paquete ZIP de metadata en base64.
|
||||
* Usa AdmZip directamente para evitar problemas de archivos temporales en Windows.
|
||||
*/
|
||||
function extractCsvsFromZip(zipBase64: string): string[] {
|
||||
const zipBuffer = Buffer.from(zipBase64, 'base64');
|
||||
const zip = new AdmZip(zipBuffer);
|
||||
const entries = zip.getEntries();
|
||||
const csvContents: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const name = entry.entryName.toLowerCase();
|
||||
if (name.endsWith('.csv') || name.endsWith('.txt')) {
|
||||
csvContents.push(entry.getData().toString('utf-8'));
|
||||
}
|
||||
}
|
||||
|
||||
return csvContents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea una línea CSV respetando campos entrecomillados
|
||||
*/
|
||||
function parseCsvLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (ch === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (ch === '~' && !inQuotes) {
|
||||
fields.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
fields.push(current.trim());
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa un paquete de metadata del SAT (ZIP con CSV) y retorna los registros.
|
||||
* Usa AdmZip directo en vez de MetadataPackageReader para compatibilidad Windows.
|
||||
*/
|
||||
export function processMetadataPackage(
|
||||
zipBase64: string,
|
||||
downloadType: 'emitidos' | 'recibidos' = 'emitidos'
|
||||
): CfdiMetadata[] {
|
||||
const csvContents = extractCsvsFromZip(zipBase64);
|
||||
const results: CfdiMetadata[] = [];
|
||||
|
||||
const tipoMap: Record<string, string> = {
|
||||
'Ingreso': 'I', 'Egreso': 'E', 'Traslado': 'T', 'Nómina': 'N', 'Nomina': 'N', 'Pago': 'P',
|
||||
};
|
||||
|
||||
for (const csv of csvContents) {
|
||||
const lines = csv.split(/\r?\n/).filter(l => l.trim());
|
||||
if (lines.length < 2) continue;
|
||||
|
||||
// Header line — SAT uses ~ as delimiter
|
||||
const headers = parseCsvLine(lines[0]);
|
||||
|
||||
// Find column indices (case-insensitive)
|
||||
const idx = (name: string) => headers.findIndex(h => h.toLowerCase() === name.toLowerCase());
|
||||
const iUuid = idx('Uuid');
|
||||
const iRfcEmisor = idx('RfcEmisor');
|
||||
const iNombreEmisor = idx('NombreEmisor');
|
||||
const iRfcReceptor = idx('RfcReceptor');
|
||||
const iNombreReceptor = idx('NombreReceptor');
|
||||
const iRfcPac = idx('RfcPac');
|
||||
const iFechaEmision = idx('FechaEmision');
|
||||
const iFechaCert = idx('FechaCertificacionSat');
|
||||
const iFechaCancel = idx('FechaCancelacion');
|
||||
const iMonto = idx('Monto');
|
||||
const iEfecto = idx('EfectoComprobante');
|
||||
const iEstatus = idx('Estatus');
|
||||
// Fallback column names
|
||||
const iEstado = iEstatus >= 0 ? iEstatus : idx('Estado');
|
||||
|
||||
if (iUuid < 0) continue; // No UUID column = invalid CSV
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const fields = parseCsvLine(lines[i]);
|
||||
const uuid = (fields[iUuid] || '').trim();
|
||||
if (!uuid) continue;
|
||||
|
||||
const estatus = (fields[iEstado] || 'Vigente').trim();
|
||||
const fechaCancelStr = iFechaCancel >= 0 ? (fields[iFechaCancel] || '').trim() : '';
|
||||
const fechaEmisionStr = iFechaEmision >= 0 ? (fields[iFechaEmision] || '').trim() : '';
|
||||
const fechaCertStr = iFechaCert >= 0 ? (fields[iFechaCert] || '').trim() : '';
|
||||
const efecto = iEfecto >= 0 ? (fields[iEfecto] || 'Ingreso').trim() : 'Ingreso';
|
||||
|
||||
results.push({
|
||||
uuid: uuid.toUpperCase(),
|
||||
rfcEmisor: iRfcEmisor >= 0 ? (fields[iRfcEmisor] || '').trim() : '',
|
||||
nombreEmisor: iNombreEmisor >= 0 ? (fields[iNombreEmisor] || '').trim() : '',
|
||||
rfcReceptor: iRfcReceptor >= 0 ? (fields[iRfcReceptor] || '').trim() : '',
|
||||
nombreReceptor: iNombreReceptor >= 0 ? (fields[iNombreReceptor] || '').trim() : '',
|
||||
rfcPac: iRfcPac >= 0 ? (fields[iRfcPac] || '').trim() || null : null,
|
||||
fechaEmision: fechaEmisionStr ? parseCfdiDate(fechaEmisionStr) : new Date(),
|
||||
fechaCertSat: fechaCertStr ? parseCfdiDate(fechaCertStr) : null,
|
||||
fechaCancelacion: fechaCancelStr ? parseCfdiDate(fechaCancelStr) : null,
|
||||
monto: parseFloat(iMonto >= 0 ? fields[iMonto] || '0' : '0') || 0,
|
||||
tipoComprobante: tipoMap[efecto] || efecto.charAt(0) || 'I',
|
||||
status: estatus === '0' || estatus.toLowerCase().includes('cancel') ? 'Cancelado' : 'Vigente',
|
||||
type: downloadType === 'emitidos' ? 'EMITIDO' : 'RECIBIDO',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida que un XML sea un CFDI válido
|
||||
*/
|
||||
export function isValidCfdi(xmlContent: string): boolean {
|
||||
try {
|
||||
const result = xmlParser.parse(xmlContent);
|
||||
const comprobante = result.Comprobante;
|
||||
|
||||
if (!comprobante) return false;
|
||||
if (!comprobante.Complemento?.TimbreFiscalDigital) return false;
|
||||
if (!extractUuid(comprobante)) return false;
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type { CfdiParsed, CfdiMetadata, ConceptoParsed, ExtractedXml };
|
||||
1463
apps/api/src/services/sat/sat.service.ts
Normal file
1463
apps/api/src/services/sat/sat.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
98
apps/api/src/services/sat/sweep-stale-jobs.service.ts
Normal file
98
apps/api/src/services/sat/sweep-stale-jobs.service.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { prisma } from '../../config/database.js';
|
||||
|
||||
export interface SweepResult {
|
||||
pendingFound: number;
|
||||
runningFound: number;
|
||||
pendingMarked: number;
|
||||
runningMarked: number;
|
||||
entries: Array<{
|
||||
id: string;
|
||||
tenantId: string;
|
||||
kind: 'pending-stale' | 'running-stale';
|
||||
ageHours: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watchdog para jobs `sat_sync_jobs` stale.
|
||||
*
|
||||
* Categorías:
|
||||
* 1. `pending` con `nextRetryAt` > pendingHours atrás. El cron horario
|
||||
* `retryTimedOutJobs` normalmente los retoma, pero si no arranca
|
||||
* (dev, caída, reinicio largo) el job queda colgado y bloquea el
|
||||
* lock para nuevos syncs del mismo (tenant, contribuyente).
|
||||
*
|
||||
* 2. `running` con `startedAt` > runningHours atrás. Un sync inicial
|
||||
* típico termina en <2h; si lleva >runningHours es casi seguro
|
||||
* huérfano de un proceso que murió. La solicitud SAT ya expiró.
|
||||
*
|
||||
* Marca ambos como `failed` con `errorMessage` descriptivo. Idempotente
|
||||
* (volver a correrlo no reabre los ya-marcados-failed).
|
||||
*
|
||||
* - `apply=false` (default): dry-run, no toca BD.
|
||||
* - `pendingHours`/`runningHours`: thresholds (default 12h / 4h).
|
||||
*/
|
||||
export async function sweepStaleSatJobs(params: {
|
||||
apply: boolean;
|
||||
pendingHours?: number;
|
||||
runningHours?: number;
|
||||
} = { apply: false }): Promise<SweepResult> {
|
||||
const pendingHours = params.pendingHours ?? 12;
|
||||
const runningHours = params.runningHours ?? 4;
|
||||
const now = new Date();
|
||||
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);
|
||||
const runningCutoff = new Date(now.getTime() - runningHours * 3600 * 1000);
|
||||
|
||||
const stalePending = await prisma.satSyncJob.findMany({
|
||||
where: { status: 'pending', nextRetryAt: { lt: pendingCutoff } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const staleRunning = await prisma.satSyncJob.findMany({
|
||||
where: { status: 'running', startedAt: { lt: runningCutoff } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const result: SweepResult = {
|
||||
pendingFound: stalePending.length,
|
||||
runningFound: staleRunning.length,
|
||||
pendingMarked: 0,
|
||||
runningMarked: 0,
|
||||
entries: [],
|
||||
};
|
||||
|
||||
for (const j of stalePending) {
|
||||
const ageHours = Math.round((now.getTime() - (j.nextRetryAt ?? j.createdAt).getTime()) / 3_600_000);
|
||||
result.entries.push({ id: j.id, tenantId: j.tenantId, kind: 'pending-stale', ageHours });
|
||||
}
|
||||
for (const j of staleRunning) {
|
||||
const ageHours = Math.round((now.getTime() - (j.startedAt ?? j.createdAt).getTime()) / 3_600_000);
|
||||
result.entries.push({ id: j.id, tenantId: j.tenantId, kind: 'running-stale', ageHours });
|
||||
}
|
||||
|
||||
if (!params.apply) return result;
|
||||
|
||||
for (const j of stalePending) {
|
||||
await prisma.satSyncJob.update({
|
||||
where: { id: j.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
completedAt: now,
|
||||
errorMessage: `Abandoned by watchdog: pending with nextRetryAt ${j.nextRetryAt?.toISOString()} > ${pendingHours}h in the past. Retry cron didn't pick it up.`,
|
||||
},
|
||||
});
|
||||
result.pendingMarked++;
|
||||
}
|
||||
for (const j of staleRunning) {
|
||||
await prisma.satSyncJob.update({
|
||||
where: { id: j.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
completedAt: now,
|
||||
errorMessage: `Abandoned by watchdog: running with startedAt ${j.startedAt?.toISOString()} > ${runningHours}h (process crash / orphan). SAT request is lost; re-launch manually.`,
|
||||
},
|
||||
});
|
||||
result.runningMarked++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
467
apps/api/src/services/tareas.service.ts
Normal file
467
apps/api/src/services/tareas.service.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export type Recurrencia =
|
||||
| 'semanal' | 'quincenal' | 'mensual'
|
||||
| 'bimestral' | 'trimestral' | 'semestral' | 'anual';
|
||||
|
||||
export interface TareaCatalogo {
|
||||
id: string;
|
||||
contribuyenteId: string;
|
||||
nombre: string;
|
||||
descripcion: string | null;
|
||||
recurrencia: Recurrencia;
|
||||
diaSemana: number | null;
|
||||
diaMes: number | null;
|
||||
soloSupervisorCompleta: boolean;
|
||||
esDefault: boolean;
|
||||
active: boolean;
|
||||
orden: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface TareaPeriodo {
|
||||
id: string;
|
||||
tareaId: string;
|
||||
periodo: string;
|
||||
fechaLimite: Date;
|
||||
completada: boolean;
|
||||
completadaAt: Date | null;
|
||||
completadaPor: string | null;
|
||||
notas: string | null;
|
||||
}
|
||||
|
||||
export interface TareaConPeriodo extends TareaCatalogo {
|
||||
periodoActual: TareaPeriodo | null;
|
||||
}
|
||||
|
||||
const ROW_TO_TAREA = (r: any): TareaCatalogo => ({
|
||||
id: r.id,
|
||||
contribuyenteId: r.contribuyente_id,
|
||||
nombre: r.nombre,
|
||||
descripcion: r.descripcion,
|
||||
recurrencia: r.recurrencia,
|
||||
diaSemana: r.dia_semana,
|
||||
diaMes: r.dia_mes,
|
||||
soloSupervisorCompleta: r.solo_supervisor_completa,
|
||||
esDefault: r.es_default,
|
||||
active: r.active,
|
||||
orden: r.orden,
|
||||
createdAt: r.created_at,
|
||||
});
|
||||
|
||||
const ROW_TO_PERIODO = (r: any): TareaPeriodo => ({
|
||||
id: r.id,
|
||||
tareaId: r.tarea_id,
|
||||
periodo: r.periodo,
|
||||
fechaLimite: r.fecha_limite,
|
||||
completada: r.completada,
|
||||
completadaAt: r.completada_at,
|
||||
completadaPor: r.completada_por,
|
||||
notas: r.notas,
|
||||
});
|
||||
|
||||
function sanitizeUuid(id: string): string {
|
||||
return id.replace(/[^a-f0-9-]/gi, '');
|
||||
}
|
||||
|
||||
// ─── Catálogo CRUD ───
|
||||
|
||||
export async function listTareas(pool: Pool, contribuyenteId: string): Promise<TareaCatalogo[]> {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM tareas_catalogo
|
||||
WHERE contribuyente_id = $1 AND active = true
|
||||
ORDER BY orden, nombre`,
|
||||
[sanitizeUuid(contribuyenteId)],
|
||||
);
|
||||
return rows.map(ROW_TO_TAREA);
|
||||
}
|
||||
|
||||
export interface TareaInput {
|
||||
nombre: string;
|
||||
descripcion?: string | null;
|
||||
recurrencia: Recurrencia;
|
||||
diaSemana?: number | null;
|
||||
diaMes?: number | null;
|
||||
soloSupervisorCompleta?: boolean;
|
||||
orden?: number;
|
||||
}
|
||||
|
||||
export async function createTarea(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
data: TareaInput,
|
||||
): Promise<TareaCatalogo> {
|
||||
const { rows: [r] } = await pool.query(
|
||||
`INSERT INTO tareas_catalogo
|
||||
(contribuyente_id, nombre, descripcion, recurrencia,
|
||||
dia_semana, dia_mes, solo_supervisor_completa, orden)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[
|
||||
sanitizeUuid(contribuyenteId),
|
||||
data.nombre,
|
||||
data.descripcion ?? null,
|
||||
data.recurrencia,
|
||||
data.diaSemana ?? null,
|
||||
data.diaMes ?? null,
|
||||
data.soloSupervisorCompleta ?? false,
|
||||
data.orden ?? 0,
|
||||
],
|
||||
);
|
||||
return ROW_TO_TAREA(r);
|
||||
}
|
||||
|
||||
export async function updateTarea(
|
||||
pool: Pool,
|
||||
tareaId: string,
|
||||
data: Partial<TareaInput>,
|
||||
): Promise<TareaCatalogo | null> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let i = 1;
|
||||
if (data.nombre !== undefined) { fields.push(`nombre = $${i++}`); values.push(data.nombre); }
|
||||
if (data.descripcion !== undefined) { fields.push(`descripcion = $${i++}`); values.push(data.descripcion); }
|
||||
if (data.recurrencia !== undefined) { fields.push(`recurrencia = $${i++}`); values.push(data.recurrencia); }
|
||||
if (data.diaSemana !== undefined) { fields.push(`dia_semana = $${i++}`); values.push(data.diaSemana); }
|
||||
if (data.diaMes !== undefined) { fields.push(`dia_mes = $${i++}`); values.push(data.diaMes); }
|
||||
if (data.soloSupervisorCompleta !== undefined) { fields.push(`solo_supervisor_completa = $${i++}`); values.push(data.soloSupervisorCompleta); }
|
||||
if (data.orden !== undefined) { fields.push(`orden = $${i++}`); values.push(data.orden); }
|
||||
if (fields.length === 0) return null;
|
||||
values.push(sanitizeUuid(tareaId));
|
||||
const { rows: [r] } = await pool.query(
|
||||
`UPDATE tareas_catalogo SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`,
|
||||
values,
|
||||
);
|
||||
return r ? ROW_TO_TAREA(r) : null;
|
||||
}
|
||||
|
||||
export async function deleteTarea(pool: Pool, tareaId: string): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
`UPDATE tareas_catalogo SET active = false WHERE id = $1`,
|
||||
[sanitizeUuid(tareaId)],
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
// ─── Materialización de periodos ───
|
||||
|
||||
/** Convierte una fecha a su `periodo` según recurrencia. */
|
||||
function periodoForDate(date: Date, recurrencia: Recurrencia): string {
|
||||
const año = date.getFullYear();
|
||||
const mes = date.getMonth() + 1;
|
||||
if (recurrencia === 'semanal' || recurrencia === 'quincenal') {
|
||||
return `${año}-W${String(isoWeek(date)).padStart(2, '0')}`;
|
||||
}
|
||||
if (recurrencia === 'mensual') return `${año}-${String(mes).padStart(2, '0')}`;
|
||||
if (recurrencia === 'bimestral') return `${año}-B${Math.ceil(mes / 2)}`;
|
||||
if (recurrencia === 'trimestral') return `${año}-Q${Math.ceil(mes / 3)}`;
|
||||
if (recurrencia === 'semestral') return `${año}-S${Math.ceil(mes / 6)}`;
|
||||
return `${año}`;
|
||||
}
|
||||
|
||||
function isoWeek(date: Date): number {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula la fecha límite del próximo periodo de una tarea, basado en
|
||||
* `recurrencia`, `dia_semana`/`dia_mes` y un punto de referencia.
|
||||
*
|
||||
* Para semanal/quincenal: primera ocurrencia del dia_semana en/después de
|
||||
* `fromDate`. (Quincenal alterna con el mismo dia_semana cada 14 días.)
|
||||
* Para mensual+: dia_mes del periodo en curso de `fromDate`. Si el dia_mes
|
||||
* excede el último día del mes, se clampa al último día.
|
||||
*/
|
||||
function computeFechaLimite(
|
||||
recurrencia: Recurrencia,
|
||||
diaSemana: number | null,
|
||||
diaMes: number | null,
|
||||
fromDate: Date,
|
||||
): Date {
|
||||
if (recurrencia === 'semanal' || recurrencia === 'quincenal') {
|
||||
const target = (diaSemana ?? 5);
|
||||
const d = new Date(fromDate);
|
||||
const current = d.getDay() === 0 ? 7 : d.getDay();
|
||||
const diff = (target - current + 7) % 7;
|
||||
d.setDate(d.getDate() + diff);
|
||||
return d;
|
||||
}
|
||||
// mensual a anual: usar el mes "ancla" del periodo
|
||||
const año = fromDate.getFullYear();
|
||||
let mesAncla = fromDate.getMonth() + 1;
|
||||
if (recurrencia === 'bimestral') mesAncla = Math.ceil(mesAncla / 2) * 2;
|
||||
else if (recurrencia === 'trimestral') mesAncla = Math.ceil(mesAncla / 3) * 3;
|
||||
else if (recurrencia === 'semestral') mesAncla = Math.ceil(mesAncla / 6) * 6;
|
||||
else if (recurrencia === 'anual') mesAncla = 12;
|
||||
const lastDay = new Date(año, mesAncla, 0).getDate();
|
||||
const dia = Math.min(diaMes ?? lastDay, lastDay);
|
||||
return new Date(año, mesAncla - 1, dia);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asegura que existan los periodos vigentes (presente + futuros próximos)
|
||||
* para todas las tareas activas del contribuyente. Solo crea periodos
|
||||
* cuya `fecha_limite >= today` (no retroactivos, igual que obligaciones).
|
||||
*
|
||||
* Para cada tarea genera el periodo CURRENT (donde cae hoy) si su fecha
|
||||
* límite aún no ha pasado, o el NEXT si ya pasó.
|
||||
*/
|
||||
export async function materializarPeriodos(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
): Promise<void> {
|
||||
const tareas = await listTareas(pool, contribuyenteId);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
for (const t of tareas) {
|
||||
let fechaLimite = computeFechaLimite(t.recurrencia, t.diaSemana, t.diaMes, today);
|
||||
// Si la fecha límite calculada para "hoy" ya pasó, salta al siguiente periodo
|
||||
while (fechaLimite < today) {
|
||||
const next = new Date(fechaLimite);
|
||||
const recurrenciaIncrement: Record<Recurrencia, () => void> = {
|
||||
semanal: () => next.setDate(next.getDate() + 7),
|
||||
quincenal: () => next.setDate(next.getDate() + 14),
|
||||
mensual: () => next.setMonth(next.getMonth() + 1),
|
||||
bimestral: () => next.setMonth(next.getMonth() + 2),
|
||||
trimestral: () => next.setMonth(next.getMonth() + 3),
|
||||
semestral: () => next.setMonth(next.getMonth() + 6),
|
||||
anual: () => next.setFullYear(next.getFullYear() + 1),
|
||||
};
|
||||
recurrenciaIncrement[t.recurrencia]();
|
||||
fechaLimite = computeFechaLimite(t.recurrencia, t.diaSemana, t.diaMes, next);
|
||||
}
|
||||
|
||||
const periodo = periodoForDate(fechaLimite, t.recurrencia);
|
||||
await pool.query(
|
||||
`INSERT INTO tarea_periodos (tarea_id, periodo, fecha_limite)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (tarea_id, periodo) DO NOTHING`,
|
||||
[t.id, periodo, fechaLimite.toISOString().split('T')[0]],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee tareas activas del contribuyente con su periodo más cercano (vigente
|
||||
* o pendiente). Materializa los faltantes antes de leer.
|
||||
*/
|
||||
export async function listTareasConPeriodoActual(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
): Promise<TareaConPeriodo[]> {
|
||||
await materializarPeriodos(pool, contribuyenteId);
|
||||
const tareas = await listTareas(pool, contribuyenteId);
|
||||
if (tareas.length === 0) return [];
|
||||
|
||||
const ids = tareas.map(t => t.id);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const { rows } = await pool.query(
|
||||
`SELECT DISTINCT ON (tarea_id) *
|
||||
FROM tarea_periodos
|
||||
WHERE tarea_id = ANY($1::uuid[])
|
||||
AND (completada = false OR fecha_limite >= $2::date)
|
||||
ORDER BY tarea_id, fecha_limite ASC`,
|
||||
[ids, today],
|
||||
);
|
||||
const periodos = new Map(rows.map(r => [r.tarea_id, ROW_TO_PERIODO(r)]));
|
||||
return tareas.map(t => ({ ...t, periodoActual: periodos.get(t.id) ?? null }));
|
||||
}
|
||||
|
||||
// ─── Completar / descompletar periodo ───
|
||||
|
||||
const ROLES_SUPERVISOR = new Set(['owner', 'cfo', 'supervisor']);
|
||||
|
||||
/**
|
||||
* Marca un periodo como completado. Si la tarea tiene
|
||||
* `solo_supervisor_completa=true`, valida que el rol del usuario sea
|
||||
* owner/cfo/supervisor. Devuelve el periodo actualizado y la tarea
|
||||
* (para que el caller pueda disparar notificaciones).
|
||||
*/
|
||||
export async function completarPeriodo(
|
||||
pool: Pool,
|
||||
periodoId: string,
|
||||
userId: string,
|
||||
userRole: string,
|
||||
notas: string | null = null,
|
||||
): Promise<{ periodo: TareaPeriodo; tarea: TareaCatalogo } | null> {
|
||||
const { rows: [pRow] } = await pool.query(
|
||||
`SELECT tp.*, tc.solo_supervisor_completa
|
||||
FROM tarea_periodos tp
|
||||
JOIN tareas_catalogo tc ON tc.id = tp.tarea_id
|
||||
WHERE tp.id = $1`,
|
||||
[sanitizeUuid(periodoId)],
|
||||
);
|
||||
if (!pRow) return null;
|
||||
if (pRow.solo_supervisor_completa && !ROLES_SUPERVISOR.has(userRole)) {
|
||||
throw new Error('Solo supervisor o owner pueden marcar esta tarea como completada');
|
||||
}
|
||||
const { rows: [updated] } = await pool.query(
|
||||
`UPDATE tarea_periodos
|
||||
SET completada = true, completada_at = NOW(), completada_por = $2, notas = $3
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[sanitizeUuid(periodoId), userId, notas],
|
||||
);
|
||||
const { rows: [tareaRow] } = await pool.query(
|
||||
`SELECT * FROM tareas_catalogo WHERE id = $1`,
|
||||
[pRow.tarea_id],
|
||||
);
|
||||
return { periodo: ROW_TO_PERIODO(updated), tarea: ROW_TO_TAREA(tareaRow) };
|
||||
}
|
||||
|
||||
export async function descompletarPeriodo(
|
||||
pool: Pool,
|
||||
periodoId: string,
|
||||
): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
`UPDATE tarea_periodos
|
||||
SET completada = false, completada_at = NULL, completada_por = NULL
|
||||
WHERE id = $1`,
|
||||
[sanitizeUuid(periodoId)],
|
||||
);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
// ─── Calendario / alertas ───
|
||||
|
||||
export interface TareaEventoCalendario {
|
||||
titulo: string;
|
||||
descripcion: string;
|
||||
tipo: 'tarea';
|
||||
fechaLimite: string;
|
||||
recurrencia: string;
|
||||
completado: boolean;
|
||||
notas: string | null;
|
||||
contribuyenteId: string;
|
||||
tareaId: string;
|
||||
periodoId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lee tareas + sus periodos del año para mostrar en /calendario. Materializa
|
||||
* los faltantes antes de leer (mismo patrón que listTareasConPeriodoActual).
|
||||
* Si no hay contribuyenteId, no retorna nada (las tareas son siempre por
|
||||
* contribuyente).
|
||||
*/
|
||||
export async function getEventosTareasParaCalendario(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
año: number,
|
||||
): Promise<TareaEventoCalendario[]> {
|
||||
await materializarPeriodos(pool, contribuyenteId);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT tp.id AS periodo_id, tp.tarea_id, tp.fecha_limite, tp.completada, tp.notas,
|
||||
tc.nombre, tc.descripcion, tc.recurrencia, tc.contribuyente_id
|
||||
FROM tarea_periodos tp
|
||||
JOIN tareas_catalogo tc ON tc.id = tp.tarea_id
|
||||
WHERE tc.contribuyente_id = $1
|
||||
AND tc.active = true
|
||||
AND EXTRACT(YEAR FROM tp.fecha_limite) = $2`,
|
||||
[sanitizeUuid(contribuyenteId), año],
|
||||
);
|
||||
return rows.map(r => ({
|
||||
titulo: r.nombre,
|
||||
descripcion: r.descripcion ?? '',
|
||||
tipo: 'tarea' as const,
|
||||
fechaLimite: r.fecha_limite instanceof Date ? r.fecha_limite.toISOString().split('T')[0] : String(r.fecha_limite),
|
||||
recurrencia: r.recurrencia,
|
||||
completado: r.completada,
|
||||
notas: r.notas,
|
||||
contribuyenteId: r.contribuyente_id,
|
||||
tareaId: r.tarea_id,
|
||||
periodoId: r.periodo_id,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cuenta tareas próximas a vencer (≤3 días) para alertas auto. Solo cuenta
|
||||
* los periodos pendientes del contribuyente filtrado o de todo el tenant.
|
||||
*/
|
||||
export async function contarTareasProximasVencer(
|
||||
pool: Pool,
|
||||
contribuyenteId: string | null | undefined,
|
||||
): Promise<{ total: number; monto?: number }> {
|
||||
const safeId = contribuyenteId ? sanitizeUuid(contribuyenteId) : null;
|
||||
if (safeId) await materializarPeriodos(pool, safeId);
|
||||
const cf = safeId ? `AND tc.contribuyente_id = '${safeId}'` : '';
|
||||
const { rows: [r] } = await pool.query(
|
||||
`SELECT COUNT(*)::int AS total
|
||||
FROM tarea_periodos tp
|
||||
JOIN tareas_catalogo tc ON tc.id = tp.tarea_id
|
||||
WHERE tc.active = true
|
||||
AND tp.completada = false
|
||||
AND tp.fecha_limite >= CURRENT_DATE
|
||||
AND tp.fecha_limite <= CURRENT_DATE + INTERVAL '3 days'
|
||||
${cf}`,
|
||||
);
|
||||
return { total: r?.total || 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resuelve el auxiliar asignado a la cartera del contribuyente (si existe).
|
||||
* Busca primero en subcarteras (más específico) y luego en la top-level.
|
||||
* Retorna null si el contribuyente no está en ninguna cartera o ninguna
|
||||
* tiene auxiliar asignado.
|
||||
*/
|
||||
export async function getAuxiliarUserIdDeContribuyente(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
): Promise<string | null> {
|
||||
const { rows } = await pool.query<{ auxiliar_user_id: string | null; parent_id: string | null }>(
|
||||
`SELECT c.auxiliar_user_id, c.parent_id
|
||||
FROM cartera_entidades ce
|
||||
JOIN carteras c ON c.id = ce.cartera_id
|
||||
WHERE ce.entidad_id = $1
|
||||
ORDER BY (c.parent_id IS NOT NULL) DESC, c.created_at DESC`,
|
||||
[sanitizeUuid(contribuyenteId)],
|
||||
);
|
||||
for (const row of rows) {
|
||||
if (row.auxiliar_user_id) return row.auxiliar_user_id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Seed de tareas default ───
|
||||
|
||||
const TAREAS_DEFAULT: TareaInput[] = [
|
||||
{ nombre: 'Solicitar estados de cuenta', descripcion: 'Pedir al cliente los estados de cuenta bancarios del mes', recurrencia: 'mensual', diaMes: 5, orden: 1 },
|
||||
{ nombre: 'Realizar conciliación bancaria', descripcion: 'Conciliar los movimientos bancarios contra los CFDIs', recurrencia: 'mensual', diaMes: 10, orden: 2 },
|
||||
{ nombre: 'Realizar contabilización', descripcion: 'Registrar los CFDIs y movimientos en la contabilidad', recurrencia: 'mensual', diaMes: 14, orden: 3 },
|
||||
{ nombre: 'Revisión fiscal preliminar', descripcion: 'Revisar declaración antes de presentación (solo supervisor/owner)', recurrencia: 'mensual', diaMes: 15, soloSupervisorCompleta: true, orden: 4 },
|
||||
];
|
||||
|
||||
export async function seedTareasDefault(
|
||||
pool: Pool,
|
||||
contribuyenteId: string,
|
||||
): Promise<number> {
|
||||
const existentes = await pool.query(
|
||||
`SELECT 1 FROM tareas_catalogo WHERE contribuyente_id = $1 AND es_default = true LIMIT 1`,
|
||||
[sanitizeUuid(contribuyenteId)],
|
||||
);
|
||||
if (existentes.rowCount && existentes.rowCount > 0) return 0;
|
||||
|
||||
let count = 0;
|
||||
for (const t of TAREAS_DEFAULT) {
|
||||
await pool.query(
|
||||
`INSERT INTO tareas_catalogo
|
||||
(contribuyente_id, nombre, descripcion, recurrencia, dia_mes, solo_supervisor_completa, es_default, orden)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, true, $7)`,
|
||||
[
|
||||
sanitizeUuid(contribuyenteId),
|
||||
t.nombre,
|
||||
t.descripcion ?? null,
|
||||
t.recurrencia,
|
||||
t.diaMes ?? null,
|
||||
t.soloSupervisorCompleta ?? false,
|
||||
t.orden ?? 0,
|
||||
],
|
||||
);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
399
apps/api/src/services/tenants.service.ts
Normal file
399
apps/api/src/services/tenants.service.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { DESPACHO_PLANS, type DespachoPlan } from '@horux/shared';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import * as metabaseService from './metabase.service.js';
|
||||
import { randomBytes } from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function getAllTenants() {
|
||||
return prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: {
|
||||
id: true,
|
||||
nombre: true,
|
||||
rfc: true,
|
||||
plan: true,
|
||||
databaseName: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: { memberships: { where: { active: true } } as any }
|
||||
}
|
||||
},
|
||||
orderBy: { nombre: 'asc' }
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTenantById(id: string) {
|
||||
return prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
nombre: true,
|
||||
rfc: true,
|
||||
plan: true,
|
||||
databaseName: true,
|
||||
createdAt: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTenant(data: {
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
plan?: DespachoPlan;
|
||||
adminEmail: string;
|
||||
adminNombre: string;
|
||||
amount: number;
|
||||
/** Solo plan custom: primera fecha de pago (deadline para que el cliente
|
||||
* complete su primer cobro). Se persiste como Subscription.currentPeriodEnd. */
|
||||
firstPaymentDueAt?: string | null;
|
||||
}) {
|
||||
const plan = data.plan || 'trial';
|
||||
|
||||
// 1. Provision a dedicated database for this tenant
|
||||
const databaseName = await tenantDb.provisionDatabase(data.rfc);
|
||||
|
||||
// 1b. Register tenant database in Metabase (non-blocking, logs errors only)
|
||||
metabaseService.registerDatabase({
|
||||
nombre: data.nombre,
|
||||
dbName: databaseName,
|
||||
}).catch(err => console.error('[METABASE] Register failed:', err));
|
||||
|
||||
// 2. Create tenant record
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
nombre: data.nombre,
|
||||
rfc: data.rfc.toUpperCase(),
|
||||
plan,
|
||||
databaseName,
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Create admin user with temp password
|
||||
const tempPassword = randomBytes(4).toString('hex'); // 8-char random
|
||||
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
||||
|
||||
// Get owner role ID from roles table (rol que asignamos al dueño del tenant al crearlo)
|
||||
const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
|
||||
if (!ownerRol) throw new Error('Rol owner no encontrado en la base de datos');
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.adminEmail,
|
||||
passwordHash: hashedPassword,
|
||||
nombre: data.adminNombre,
|
||||
lastTenantId: tenant.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Crea membership owner del nuevo user en su tenant (fase 4 multi-tenant)
|
||||
await prisma.tenantMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tenantId: tenant.id,
|
||||
rolId: ownerRol.id,
|
||||
isOwner: true,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Create initial subscription. Para plan custom, si admin proveyó
|
||||
// firstPaymentDueAt, lo guardamos como currentPeriodEnd — sirve como
|
||||
// deadline visible al cliente para realizar su primer pago.
|
||||
const firstPayment = plan === 'custom' && data.firstPaymentDueAt
|
||||
? new Date(data.firstPaymentDueAt)
|
||||
: null;
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
plan,
|
||||
status: 'pending',
|
||||
amount: data.amount,
|
||||
frequency: 'monthly',
|
||||
...(firstPayment ? { currentPeriodStart: new Date(), currentPeriodEnd: firstPayment } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Send welcome email to client (non-blocking)
|
||||
emailService.sendWelcome(data.adminEmail, {
|
||||
nombre: data.adminNombre,
|
||||
email: data.adminEmail,
|
||||
tempPassword,
|
||||
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
|
||||
|
||||
// 6. Send new client notification to admin with DB credentials
|
||||
emailService.sendNewClientAdmin({
|
||||
clienteNombre: data.nombre,
|
||||
clienteRfc: data.rfc.toUpperCase(),
|
||||
adminEmail: data.adminEmail,
|
||||
adminNombre: data.adminNombre,
|
||||
tempPassword,
|
||||
databaseName,
|
||||
plan,
|
||||
}).catch(err => console.error('[EMAIL] New client admin email failed:', err));
|
||||
|
||||
return { tenant, user, tempPassword };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow "Agregar empresa" — un user existente (típicamente owner) agrega un
|
||||
* segundo RFC bajo su cuenta. A diferencia de `createTenant()`, NO crea un user
|
||||
* nuevo: el caller se vuelve owner de la empresa nueva vía TenantMembership.
|
||||
*
|
||||
* Sin trial por default — el check de trial por owner (fase 5) bloquearía
|
||||
* cualquier intento de re-activarlo. El owner contrata el plan desde la página
|
||||
* de suscripción del nuevo tenant tras esta llamada.
|
||||
*/
|
||||
export async function addTenantToOwner(data: {
|
||||
userId: string;
|
||||
nombre: string;
|
||||
rfc: string;
|
||||
plan?: DespachoPlan;
|
||||
}) {
|
||||
const plan = data.plan || 'trial';
|
||||
const rfcUpper = data.rfc.toUpperCase();
|
||||
|
||||
// Valida que el RFC no exista en el sistema
|
||||
const existingTenant = await prisma.tenant.findUnique({ where: { rfc: rfcUpper } });
|
||||
if (existingTenant) {
|
||||
throw new Error('Ya existe una empresa con ese RFC en el sistema');
|
||||
}
|
||||
|
||||
// Valida que el user exista y esté activo
|
||||
const user = await prisma.user.findUnique({ where: { id: data.userId } });
|
||||
if (!user || !user.active) throw new Error('Usuario no encontrado');
|
||||
|
||||
// 1. Provision BD dedicada
|
||||
const databaseName = await tenantDb.provisionDatabase(rfcUpper);
|
||||
|
||||
// 1b. Register tenant database in Metabase (non-blocking, logs errors only)
|
||||
metabaseService.registerDatabase({
|
||||
nombre: data.nombre,
|
||||
dbName: databaseName,
|
||||
}).catch(err => console.error('[METABASE] Register failed:', err));
|
||||
|
||||
// 2. Crea el tenant
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
nombre: data.nombre,
|
||||
rfc: rfcUpper,
|
||||
plan,
|
||||
databaseName,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Crea membership del caller como owner
|
||||
const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
|
||||
if (!ownerRol) throw new Error('Rol owner no encontrado');
|
||||
|
||||
await prisma.tenantMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tenantId: tenant.id,
|
||||
rolId: ownerRol.id,
|
||||
isOwner: true,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 4. Subscripción pending (se activa al contratar un plan)
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
plan,
|
||||
status: 'pending',
|
||||
amount: 0, // el precio real se setea al contratar
|
||||
frequency: 'monthly',
|
||||
},
|
||||
});
|
||||
|
||||
return { tenant };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista detallada de tenants del user actual con estado de suscripción. Usado
|
||||
* por la página `/mis-empresas` — incluye plan, status, currentPeriodEnd,
|
||||
* pendingPlan si aplica.
|
||||
*
|
||||
* @param onlyOwner filtra a solo memberships donde isOwner=true. Default true:
|
||||
* un user contador que trabaja en empresa ajena NO la ve aquí, pero sí sus
|
||||
* propias empresas donde es owner. El header switcher usa un endpoint distinto.
|
||||
*/
|
||||
export async function getMyTenantsDetailed(userId: string, onlyOwner = true) {
|
||||
const memberships = await prisma.tenantMembership.findMany({
|
||||
where: { userId, active: true, ...(onlyOwner ? { isOwner: true } : {}) },
|
||||
include: {
|
||||
tenant: {
|
||||
include: {
|
||||
subscriptions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
rol: { select: { nombre: true } },
|
||||
},
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
});
|
||||
|
||||
return memberships
|
||||
.filter(m => m.tenant.active)
|
||||
.map(m => {
|
||||
const sub = m.tenant.subscriptions[0] || null;
|
||||
return {
|
||||
tenantId: m.tenant.id,
|
||||
nombre: m.tenant.nombre,
|
||||
rfc: m.tenant.rfc,
|
||||
plan: m.tenant.plan,
|
||||
role: m.rol.nombre,
|
||||
isOwner: m.isOwner,
|
||||
trialEndsAt: m.tenant.trialEndsAt,
|
||||
subscription: sub ? {
|
||||
status: sub.status,
|
||||
plan: sub.plan,
|
||||
amount: sub.amount ? Number(sub.amount) : 0,
|
||||
frequency: sub.frequency,
|
||||
currentPeriodEnd: sub.currentPeriodEnd,
|
||||
pendingPlan: sub.pendingPlan,
|
||||
pendingEffectiveAt: sub.pendingEffectiveAt,
|
||||
} : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateTenant(id: string, data: {
|
||||
nombre?: string;
|
||||
rfc?: string;
|
||||
plan?: DespachoPlan;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return prisma.tenant.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.nombre && { nombre: data.nombre }),
|
||||
...(data.rfc && { rfc: data.rfc.toUpperCase() }),
|
||||
...(data.plan && { plan: data.plan }),
|
||||
...(data.active !== undefined && { active: data.active }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
nombre: true,
|
||||
rfc: true,
|
||||
plan: true,
|
||||
databaseName: true,
|
||||
active: true,
|
||||
createdAt: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDatosFiscales(id: string) {
|
||||
return prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
codigoPostal: true,
|
||||
calle: true,
|
||||
numExterior: true,
|
||||
numInterior: true,
|
||||
colonia: true,
|
||||
ciudad: true,
|
||||
municipio: true,
|
||||
estado: true,
|
||||
telefono: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateDatosFiscales(id: string, data: {
|
||||
codigoPostal?: string;
|
||||
calle?: string;
|
||||
numExterior?: string;
|
||||
numInterior?: string;
|
||||
colonia?: string;
|
||||
ciudad?: string;
|
||||
municipio?: string;
|
||||
estado?: string;
|
||||
telefono?: string;
|
||||
}) {
|
||||
return prisma.tenant.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: {
|
||||
codigoPostal: true,
|
||||
calle: true,
|
||||
numExterior: true,
|
||||
numInterior: true,
|
||||
colonia: true,
|
||||
ciudad: true,
|
||||
municipio: true,
|
||||
estado: true,
|
||||
telefono: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Preferencias de auto-facturación de pagos de suscripción.
|
||||
* Lee también los regímenes activos del tenant — útil para que la UI muestre
|
||||
* las opciones del dropdown "Régimen preferido" sin queries adicionales.
|
||||
*/
|
||||
export async function getPreferenciasFacturacion(id: string) {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
factPreferencia: true,
|
||||
factUsoCfdi: true,
|
||||
factRegimenPreferido: true,
|
||||
regimenesActivos: {
|
||||
select: { regimen: { select: { clave: true, descripcion: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!tenant) return null;
|
||||
return {
|
||||
factPreferencia: tenant.factPreferencia,
|
||||
factUsoCfdi: tenant.factUsoCfdi,
|
||||
factRegimenPreferido: tenant.factRegimenPreferido,
|
||||
regimenesActivos: tenant.regimenesActivos.map(ra => ra.regimen),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updatePreferenciasFacturacion(id: string, data: {
|
||||
factPreferencia?: 'publico_general' | 'mis_datos';
|
||||
factUsoCfdi?: string;
|
||||
factRegimenPreferido?: string | null;
|
||||
}) {
|
||||
return prisma.tenant.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: {
|
||||
factPreferencia: true,
|
||||
factUsoCfdi: true,
|
||||
factRegimenPreferido: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTenant(id: string) {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
|
||||
// Soft-delete the tenant record
|
||||
await prisma.tenant.update({
|
||||
where: { id },
|
||||
data: { active: false }
|
||||
});
|
||||
|
||||
// Soft-delete the database (rename with _deleted_ suffix)
|
||||
if (tenant) {
|
||||
await tenantDb.deprovisionDatabase(tenant.databaseName);
|
||||
tenantDb.invalidatePool(id);
|
||||
// Remove from Metabase (non-blocking)
|
||||
metabaseService.deleteDatabase(tenant.databaseName).catch(err =>
|
||||
console.error('[METABASE] Delete failed:', err)
|
||||
);
|
||||
}
|
||||
}
|
||||
251
apps/api/src/services/usuarios.service.ts
Normal file
251
apps/api/src/services/usuarios.service.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { getDespachoPlanLimits } from './plan-catalogo.service.js';
|
||||
import type { UserListItem, UserInvite, UserUpdate, Role } from '@horux/shared';
|
||||
|
||||
/**
|
||||
* Refactor F6.2 (multi-tenant): los users se listan/invitan/borran vía
|
||||
* `tenant_memberships`. `User.tenantId` y `User.rolId` se siguen poblando al
|
||||
* crear (legacy, F6.4 los borra) pero NO se leen en este servicio.
|
||||
*
|
||||
* - getUsuarios(tenantId) → memberships activos en ese tenant
|
||||
* - inviteUsuario → crea User + Membership (siempre isOwner=false aquí)
|
||||
* - updateUsuario → cambia role en la membership de ese tenant; active es global
|
||||
* - deleteUsuario → desactiva membership (soft delete por tenant). El user
|
||||
* sigue existiendo si tiene otras memberships.
|
||||
*/
|
||||
|
||||
async function getRolId(nombre: string): Promise<number> {
|
||||
const rol = await prisma.rol.findUnique({ where: { nombre } });
|
||||
if (!rol) throw new Error(`Rol '${nombre}' no encontrado`);
|
||||
return rol.id;
|
||||
}
|
||||
|
||||
/** Mapea una row de membership con user joineado al shape UserListItem. */
|
||||
function mapMembershipRow(m: any, includeTenant = false): UserListItem {
|
||||
return {
|
||||
id: m.user.id,
|
||||
email: m.user.email,
|
||||
nombre: m.user.nombre,
|
||||
role: m.rol.nombre as Role,
|
||||
active: m.user.active && m.active, // user activo Y membership activa
|
||||
lastLogin: m.user.lastLogin?.toISOString() || null,
|
||||
createdAt: m.user.createdAt.toISOString(),
|
||||
...(includeTenant && { tenantId: m.tenantId, tenantName: m.tenant.nombre }),
|
||||
};
|
||||
}
|
||||
|
||||
const MEMBERSHIP_INCLUDE = {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nombre: true,
|
||||
active: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
rol: { select: { nombre: true } },
|
||||
};
|
||||
|
||||
export async function getUsuarios(tenantId: string): Promise<UserListItem[]> {
|
||||
const memberships = await prisma.tenantMembership.findMany({
|
||||
where: { tenantId, active: true },
|
||||
include: MEMBERSHIP_INCLUDE,
|
||||
orderBy: { joinedAt: 'desc' },
|
||||
});
|
||||
|
||||
return memberships.map(m => mapMembershipRow(m));
|
||||
}
|
||||
|
||||
export async function inviteUsuario(tenantId: string, data: UserInvite): Promise<UserListItem> {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { plan: true },
|
||||
});
|
||||
|
||||
// Límite del catálogo despacho desde BD (con cache). -1 = ilimitado.
|
||||
// Si el plan no existe en BD (shouldn't happen post-seed) cae a 1 como
|
||||
// mínimo defensivo para no permitir invitaciones masivas accidentales.
|
||||
const planLimits = tenant ? await getDespachoPlanLimits(tenant.plan) : null;
|
||||
const maxUsers = planLimits?.maxUsers ?? 1;
|
||||
|
||||
// Cuenta memberships activos del tenant (no users globales) — esto es el
|
||||
// límite real ahora que los users pueden estar en varios tenants.
|
||||
const currentCount = await prisma.tenantMembership.count({
|
||||
where: { tenantId, active: true },
|
||||
});
|
||||
|
||||
if (maxUsers !== -1 && currentCount >= maxUsers) {
|
||||
throw new Error('Límite de usuarios alcanzado para este plan');
|
||||
}
|
||||
|
||||
// Si el email ya existe como user global, agregamos membership en este
|
||||
// tenant en vez de crear un user nuevo. Cubre el caso "el contador X ya
|
||||
// trabaja en otra empresa, ahora lo invitan a esta también".
|
||||
let user = await prisma.user.findUnique({ where: { email: data.email } });
|
||||
let tempPassword: string | null = null;
|
||||
|
||||
if (!user) {
|
||||
tempPassword = randomBytes(4).toString('hex');
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
passwordHash,
|
||||
nombre: data.nombre,
|
||||
lastTenantId: tenantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const rolId = await getRolId(data.role);
|
||||
|
||||
// Membership en este tenant. Si ya existía (caso edge: re-invitación tras
|
||||
// delete), reactivar. isOwner siempre false (owners se crean por register
|
||||
// o addTenantToOwner).
|
||||
await prisma.tenantMembership.upsert({
|
||||
where: { userId_tenantId: { userId: user.id, tenantId } },
|
||||
update: { rolId, isOwner: false, active: true },
|
||||
create: {
|
||||
userId: user.id,
|
||||
tenantId,
|
||||
rolId,
|
||||
isOwner: false,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Volvemos a leer la membership para devolver el shape correcto
|
||||
const membership = await prisma.tenantMembership.findUnique({
|
||||
where: { userId_tenantId: { userId: user.id, tenantId } },
|
||||
include: MEMBERSHIP_INCLUDE,
|
||||
});
|
||||
|
||||
return mapMembershipRow(membership!);
|
||||
}
|
||||
|
||||
export async function updateUsuario(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
data: UserUpdate
|
||||
): Promise<UserListItem> {
|
||||
// Verifica que la membership existe en este tenant antes de actualizar.
|
||||
const membership = await prisma.tenantMembership.findUnique({
|
||||
where: { userId_tenantId: { userId, tenantId } },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new Error('El usuario no es miembro de este tenant');
|
||||
}
|
||||
|
||||
// El cambio de role es por-tenant (afecta solo la membership)
|
||||
if (data.role) {
|
||||
const rolId = await getRolId(data.role);
|
||||
await prisma.tenantMembership.update({
|
||||
where: { userId_tenantId: { userId, tenantId } },
|
||||
data: { rolId },
|
||||
});
|
||||
}
|
||||
|
||||
// active y nombre son globales del user
|
||||
const userUpdate: any = {};
|
||||
if (data.nombre) userUpdate.nombre = data.nombre;
|
||||
if (data.active !== undefined) userUpdate.active = data.active;
|
||||
if (Object.keys(userUpdate).length > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: userUpdate });
|
||||
}
|
||||
|
||||
const refreshed = await prisma.tenantMembership.findUnique({
|
||||
where: { userId_tenantId: { userId, tenantId } },
|
||||
include: MEMBERSHIP_INCLUDE,
|
||||
});
|
||||
|
||||
return mapMembershipRow(refreshed!);
|
||||
}
|
||||
|
||||
export async function deleteUsuario(tenantId: string, userId: string): Promise<void> {
|
||||
// Soft-delete: desactiva la membership. El user sigue existiendo (puede
|
||||
// tener acceso a otros tenants). Si era su única membership activa, queda
|
||||
// sin acceso pero no se borra el record.
|
||||
const result = await prisma.tenantMembership.deleteMany({
|
||||
where: { userId, tenantId },
|
||||
});
|
||||
if (result.count === 0) {
|
||||
throw new Error('El usuario no es miembro de este tenant');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Admin global (cross-tenant)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Lista todos los memberships del sistema. Cada row es una combinación
|
||||
* (user, tenant) — un mismo user con N memberships aparece N veces. La UI
|
||||
* admin global lo presenta así para ser explícita sobre quién tiene acceso
|
||||
* dónde.
|
||||
*/
|
||||
export async function getAllUsuarios(): Promise<UserListItem[]> {
|
||||
const memberships = await prisma.tenantMembership.findMany({
|
||||
where: { active: true },
|
||||
include: {
|
||||
...MEMBERSHIP_INCLUDE,
|
||||
tenant: { select: { id: true, nombre: true } },
|
||||
},
|
||||
orderBy: [{ tenant: { nombre: 'asc' } }, { joinedAt: 'desc' }],
|
||||
});
|
||||
|
||||
return memberships.map(m => mapMembershipRow(m, true));
|
||||
}
|
||||
|
||||
export async function updateUsuarioGlobal(
|
||||
userId: string,
|
||||
data: UserUpdate & { tenantId?: string }
|
||||
): Promise<UserListItem> {
|
||||
// Si data.tenantId está presente: actualiza el role en esa membership.
|
||||
// Si data.active está: actualiza User.active globalmente.
|
||||
const targetTenantId = data.tenantId;
|
||||
|
||||
if (data.role && targetTenantId) {
|
||||
const rolId = await getRolId(data.role);
|
||||
await prisma.tenantMembership.update({
|
||||
where: { userId_tenantId: { userId, tenantId: targetTenantId } },
|
||||
data: { rolId },
|
||||
});
|
||||
}
|
||||
|
||||
const userUpdate: any = {};
|
||||
if (data.nombre) userUpdate.nombre = data.nombre;
|
||||
if (data.active !== undefined) userUpdate.active = data.active;
|
||||
if (Object.keys(userUpdate).length > 0) {
|
||||
await prisma.user.update({ where: { id: userId }, data: userUpdate });
|
||||
}
|
||||
|
||||
// Devuelve la primera membership activa del user (o la del tenant target si
|
||||
// se especificó) para mantener el shape esperado por el caller.
|
||||
const where = targetTenantId
|
||||
? { userId_tenantId: { userId, tenantId: targetTenantId } }
|
||||
: undefined;
|
||||
|
||||
const m = where
|
||||
? await prisma.tenantMembership.findUnique({
|
||||
where,
|
||||
include: { ...MEMBERSHIP_INCLUDE, tenant: { select: { id: true, nombre: true } } },
|
||||
})
|
||||
: await prisma.tenantMembership.findFirst({
|
||||
where: { userId, active: true },
|
||||
include: { ...MEMBERSHIP_INCLUDE, tenant: { select: { id: true, nombre: true } } },
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
});
|
||||
|
||||
if (!m) throw new Error('Usuario sin memberships activas');
|
||||
return mapMembershipRow(m, true);
|
||||
}
|
||||
|
||||
export async function deleteUsuarioGlobal(userId: string): Promise<void> {
|
||||
// Hard delete del user — cascade borra todas las memberships, refresh
|
||||
// tokens, password reset tokens, platform roles, etc.
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
}
|
||||
Reference in New Issue
Block a user