Initial commit - Horux Despachos NL

This commit is contained in:
2026-05-03 16:47:53 -06:00
commit b00b677c54
647 changed files with 133843 additions and 0 deletions

View 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(' ') : '';
}

View 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;
}

View 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,
}));
}

View 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;
}

View 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}`,
}));
}

View 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]
);
}

View 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`);
}

View 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,
},
};
}

View 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]);
}

View 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;
}

View 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,
}));
}

View 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),
};
}

View 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]);
}
}
}

View 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,
};
}

View 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 */ }
}
}

View 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}`,
);
}
}

View 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;
}
}

View 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;
}

File diff suppressed because it is too large Load Diff

View 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` };
}

View 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,
};
}

View 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,
}],
},
};
}

View 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);
}

View 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);
}
}
},
};

View 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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[ch]!);
}

View 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>
&nbsp;·&nbsp;
<a href="https://horuxfin.com" style="color:${BRAND.primary};text-decoration:none;font-weight:500;">horuxfin.com</a>
</p>
<p style="margin:0;">&copy; ${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;

View 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>
`);
}

View 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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[ch]!);
}

View 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>
`);
}

View File

@@ -0,0 +1,58 @@
import { baseTemplate, heading, BRAND_COLORS as C } from './base.js';
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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>
`);
}

View 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);
}

View 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>
`);
}

View 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>
`);
}

View 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>
`);
}

View File

@@ -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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[ch]!);
}

View File

@@ -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')}
`);
}

View File

@@ -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>
`);
}

View 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);
}

View 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>
`);
}

View 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>
`);
}

View 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')}
`);
}

View 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);
}

View 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 };
}

View 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;
}

File diff suppressed because it is too large Load Diff

View 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);
}
}

View 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,
};
}

View 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]
);
}

View 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 ?? {}),
}));
}

View 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 };
}

View 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,
});
}

View 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;
}

View 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 */ }
}
}

View 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;
}

View 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 };

View 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);
}
}

View 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;
}

File diff suppressed because it is too large Load Diff

View 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,
}));
}

View 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;
}

View 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);
}

View 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,
};
}

View 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;
}

View 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())}`;
}

View 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 };
}

View 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 };
}

View 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),
};
}

View 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);
}
}

View 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
);
}

View 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;
}

View 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';
}

View 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');
}

View 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 };

File diff suppressed because it is too large Load Diff

View 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;
}

View 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;
}

View 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)
);
}
}

View 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 } });
}