Compare commits
18 Commits
0bded50e57
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5636427be | ||
|
|
ad6eec51ef | ||
|
|
a008659eda | ||
|
|
b43c9f334a | ||
|
|
099f34c903 | ||
|
|
3de0014e80 | ||
|
|
b39bbcdd0a | ||
|
|
5489c84e6b | ||
|
|
24d35333df | ||
|
|
3f31e25ae7 | ||
|
|
284c7620a9 | ||
|
|
dfc0183c12 | ||
|
|
b5701b603c | ||
|
|
dbdb6f3e5c | ||
|
|
8a61fcadfa | ||
|
|
ad72436c25 | ||
|
|
1b202bc542 | ||
|
|
57c4908e68 |
@@ -91,3 +91,16 @@ METABASE_PG_PASSWORD=
|
||||
|
||||
# ----- SAT Playwright headless toggle (debug temporal) ----------------------
|
||||
# SAT_HEADLESS=false # solo dev — muestra browser para debug de scrapers
|
||||
|
||||
# ----- Proxies SAT (opcional) ------------------------------------------------
|
||||
# Lista de proxies HTTP/HTTPS para descargas masivas del SAT. Formato:
|
||||
# http://user:pass@host:port,http://user:pass@host:port,...
|
||||
# Si se deja vacío, el sistema usa la IP pública del servidor (comportamiento actual).
|
||||
SAT_PROXY_LIST=
|
||||
# Estrategia de rotación: round-robin | random (default: round-robin)
|
||||
SAT_PROXY_STRATEGY=round-robin
|
||||
# Si es true, cuando todos los proxies fallan se intenta con la IP directa del servidor.
|
||||
SAT_PROXY_FALLBACK_DIRECT=true
|
||||
# Máximo de contribuyentes sincronizados en paralelo por el scheduler (default: 10).
|
||||
# Aprovecha el pool de proxies; cada RFC suele usar una IP distinta en round-robin.
|
||||
SAT_CONCURRENT_CONTRIBUYENTES=10
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"facturapi": "^4.14.2",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"helmet": "^8.0.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mercadopago": "^2.12.0",
|
||||
"node-cron": "^4.2.1",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Agrega proxy_used a sat_sync_jobs para diagnosticar bloqueos por IP
|
||||
ALTER TABLE "sat_sync_jobs" ADD COLUMN IF NOT EXISTS "proxy_used" VARCHAR(255);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Tabla de errores SAT por proxy para reportes diarios
|
||||
CREATE TABLE IF NOT EXISTS "sat_proxy_errors" (
|
||||
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"job_id" TEXT NOT NULL REFERENCES "sat_sync_jobs"("id") ON DELETE CASCADE,
|
||||
"proxy_used" VARCHAR(255),
|
||||
"error_code" VARCHAR(50),
|
||||
"stage" VARCHAR(255),
|
||||
"message" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "sat_proxy_errors_created_at_idx" ON "sat_proxy_errors"("created_at");
|
||||
CREATE INDEX IF NOT EXISTS "sat_proxy_errors_proxy_used_created_at_idx" ON "sat_proxy_errors"("proxy_used", "created_at");
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Deshabilita SAT incremental para business_control; solo daily + retry programado.
|
||||
UPDATE "despacho_plan_prices"
|
||||
SET "permite_sat_incremental" = false
|
||||
WHERE "plan" = 'business_control';
|
||||
@@ -677,8 +677,12 @@ model SatSyncJob {
|
||||
// usuario (botón UI). Cambia la política de retry: 2 intentos vs 3 del
|
||||
// bootstrap puro. Daily/incremental ignoran este campo.
|
||||
isCustomRange Boolean @default(false) @map("is_custom_range")
|
||||
// Proxy usado en el último request SAT que falló (host:port). Ayuda a diagnosticar
|
||||
// bloqueos por IP y a generar reportes diarios de errores por proxy.
|
||||
proxyUsed String? @map("proxy_used") @db.VarChar(255)
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
proxyErrors SatProxyError[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([status])
|
||||
@@ -686,6 +690,24 @@ model SatSyncJob {
|
||||
@@map("sat_sync_jobs")
|
||||
}
|
||||
|
||||
// Errores de bloqueo/devolución del SAT por proxy.
|
||||
// Permite reportes diarios de cuántos 404/500X/etc. ocurrieron en cada IP.
|
||||
model SatProxyError {
|
||||
id String @id @default(uuid())
|
||||
jobId String @map("job_id")
|
||||
proxyUsed String? @map("proxy_used") @db.VarChar(255)
|
||||
errorCode String? @map("error_code") @db.VarChar(50)
|
||||
stage String? @map("stage") @db.VarChar(255)
|
||||
message String? @map("message")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
job SatSyncJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([proxyUsed, createdAt])
|
||||
@@map("sat_proxy_errors")
|
||||
}
|
||||
|
||||
enum SatSyncType {
|
||||
initial
|
||||
daily
|
||||
|
||||
20
apps/api/scripts/test-proxy-rotation.ts
Normal file
20
apps/api/scripts/test-proxy-rotation.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'dotenv/config';
|
||||
import { ProxyManager } from '../src/services/sat/proxy.service.js';
|
||||
|
||||
const manager = new ProxyManager(
|
||||
process.env.SAT_PROXY_LIST || '',
|
||||
(process.env.SAT_PROXY_STRATEGY as any) || 'round-robin',
|
||||
);
|
||||
|
||||
console.log(`Total de proxies: ${manager.getProxyCount()}`);
|
||||
console.log(`Estrategia: ${process.env.SAT_PROXY_STRATEGY || 'round-robin'}`);
|
||||
console.log('Próximos 10 proxies seleccionados:');
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const proxy = manager.getNextProxy();
|
||||
if (!proxy) {
|
||||
console.log(` ${i + 1}. (sin proxy configurado)`);
|
||||
} else {
|
||||
console.log(` ${i + 1}. ${proxy.host}:${proxy.port}`);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ const createRecordatorioSchema = z.object({
|
||||
fechaLimite: z.string().min(8), // ISO date o yyyy-mm-dd
|
||||
notas: z.string().max(2000).optional(),
|
||||
privado: z.boolean().optional(),
|
||||
recurrencia: z.enum(['unica', 'mensual', 'bimestral', 'trimestral', 'anual']).default('unica'),
|
||||
fechaFin: z.string().min(8).optional(),
|
||||
});
|
||||
|
||||
const updateRecordatorioSchema = z.object({
|
||||
@@ -107,7 +109,7 @@ export async function createRecordatorio(req: Request, res: Response, next: Next
|
||||
const evento = await recordatoriosService.createRecordatorio(
|
||||
req.tenantPool!,
|
||||
req.user!.userId,
|
||||
{ ...data, tipo: 'custom', recurrencia: 'unica' }
|
||||
{ ...data, tipo: 'custom' }
|
||||
);
|
||||
|
||||
res.status(201).json(evento);
|
||||
|
||||
@@ -260,7 +260,11 @@ export async function removeAuxiliar(req: Request, res: Response, next: NextFunc
|
||||
// Supervisores available (for dropdown)
|
||||
export async function getSupervisores(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const supervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId);
|
||||
const allSupervisores = await carteraService.getSupervisores(req.tenantPool!, req.user!.tenantId);
|
||||
// Un supervisor solo se ve a si mismo en el dropdown (no puede asignar a otro supervisor)
|
||||
const supervisores = isSupervisor(req)
|
||||
? allSupervisores.filter(s => s.userId === req.user!.userId)
|
||||
: allSupervisores;
|
||||
return res.json({ data: supervisores });
|
||||
} catch (err) { return next(err); }
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ export async function listConceptos(req: Request, res: Response, next: NextFunct
|
||||
uuidLike?: string;
|
||||
claveProdServ?: string;
|
||||
descripcionConcepto?: string;
|
||||
noIdentificacion?: string;
|
||||
orderBy?: 'fecha' | 'importe';
|
||||
orderDir?: 'asc' | 'desc';
|
||||
} = {
|
||||
@@ -146,6 +147,7 @@ export async function listConceptos(req: Request, res: Response, next: NextFunct
|
||||
uuidLike: req.query.uuidLike as string,
|
||||
claveProdServ: req.query.claveProdServ as string,
|
||||
descripcionConcepto: req.query.descripcionConcepto as string,
|
||||
noIdentificacion: req.query.noIdentificacion as string,
|
||||
orderBy: req.query.orderBy as 'fecha' | 'importe',
|
||||
orderDir: req.query.orderDir as 'asc' | 'desc',
|
||||
};
|
||||
|
||||
@@ -38,10 +38,14 @@ const createSchema = z.object({
|
||||
|
||||
const updateSchema = createSchema.partial();
|
||||
|
||||
function effectiveTenantId(req: Request): string {
|
||||
return req.viewingTenantId || req.user!.tenantId;
|
||||
}
|
||||
|
||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
|
||||
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId);
|
||||
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, effectiveTenantId(req));
|
||||
|
||||
// Batch lookup de nombres de supervisores
|
||||
const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[];
|
||||
@@ -65,7 +69,7 @@ export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
export async function getById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), req.user!.tenantId);
|
||||
const row = await contribuyenteService.getContribuyenteById(req.tenantPool!, String(req.params.id), effectiveTenantId(req));
|
||||
if (!row) return next(new AppError(404, 'Contribuyente no encontrado'));
|
||||
return res.json(row);
|
||||
} catch (err) { return next(err); }
|
||||
|
||||
@@ -70,15 +70,25 @@ export async function inviteUsuario(req: Request, res: Response, next: NextFunct
|
||||
}
|
||||
const data = inviteSchema.parse(req.body);
|
||||
|
||||
// Los supervisores solo pueden invitar clientes
|
||||
if (req.user!.role === 'supervisor' && data.role !== 'cliente') {
|
||||
throw new AppError(403, 'Los supervisores solo pueden invitar clientes');
|
||||
// Los supervisores solo pueden invitar clientes y auxiliares
|
||||
if (req.user!.role === 'supervisor' && !['cliente', 'auxiliar'].includes(data.role)) {
|
||||
throw new AppError(403, 'Los supervisores solo pueden invitar clientes y auxiliares');
|
||||
}
|
||||
|
||||
// Validate: auxiliar requires a supervisor
|
||||
if (data.role === 'auxiliar' && !data.supervisorUserId) {
|
||||
// Un supervisor que invita un auxiliar se asigna a sí mismo por defecto
|
||||
if (req.user!.role === 'supervisor') {
|
||||
data.supervisorUserId = req.user!.userId;
|
||||
} else {
|
||||
throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
|
||||
}
|
||||
}
|
||||
|
||||
// Un supervisor solo puede asignar auxiliares a sí mismo
|
||||
if (req.user!.role === 'supervisor' && data.role === 'auxiliar' && data.supervisorUserId !== req.user!.userId) {
|
||||
throw new AppError(403, 'Solo puedes asignar auxiliares a tu propia supervisión');
|
||||
}
|
||||
|
||||
const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, data);
|
||||
|
||||
|
||||
@@ -253,12 +253,19 @@ async function handlePaymentNotification(paymentId: string) {
|
||||
// precio de renewal. Se detecta comparando el monto cobrado contra lo que
|
||||
// `getPlanPrice(phase='firstYear')` devolvería para este plan.
|
||||
const esPrimerPago = subscription.status === 'pending';
|
||||
const updateData: { status: string; currentPeriodEnd?: Date } = { status: 'authorized' };
|
||||
const updateData: { status: string; currentPeriodStart?: Date; currentPeriodEnd?: Date } = { status: 'authorized' };
|
||||
|
||||
if (esPrimerPago) {
|
||||
// El primer pago aprobado define el inicio del período activo.
|
||||
// Algunos flujos (cambio de plan, creación manual) dejan currentPeriodEnd
|
||||
// en null, así que lo establecemos aquí para evitar que la suscripción
|
||||
// aparezca vencida aunque esté authorized.
|
||||
const periodStart = payment.dateApproved ? new Date(payment.dateApproved) : new Date();
|
||||
updateData.currentPeriodStart = periodStart;
|
||||
updateData.currentPeriodEnd = computeNextPeriodEnd(periodStart, subscription.frequency);
|
||||
console.log(`[WEBHOOK] Subscription ${subscription.id} primer pago aprobado: período ${updateData.currentPeriodStart.toISOString()} → ${updateData.currentPeriodEnd.toISOString()} (${subscription.frequency})`);
|
||||
} else if (subscription.currentPeriodEnd) {
|
||||
// Extender currentPeriodEnd para renovaciones recurrentes.
|
||||
// El primer pago ya tiene currentPeriodEnd establecido al crear la suscripción;
|
||||
// solo extendemos en pagos subsecuentes para reflejar el nuevo período cobrado.
|
||||
if (!esPrimerPago && subscription.currentPeriodEnd) {
|
||||
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
|
||||
updateData.currentPeriodEnd = nextPeriodEnd;
|
||||
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);
|
||||
|
||||
@@ -7,6 +7,8 @@ import { startWeeklyUpdateJob } from './jobs/weekly-update.job.js';
|
||||
import { startMetricasInvalidationsJob } from './jobs/metricas-invalidations.job.js';
|
||||
import { startNotificationsJob } from './jobs/notifications.job.js';
|
||||
import { startSatSyncMonitorJob } from './jobs/sat-sync-monitor.job.js';
|
||||
import { startSatProxyReportJob } from './jobs/sat-proxy-report.job.js';
|
||||
import { startRecordatoriosPeriodicosJob } from './jobs/recordatorios-periodicos.job.js';
|
||||
|
||||
const PORT = parseInt(env.PORT, 10);
|
||||
|
||||
@@ -25,6 +27,8 @@ const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
startSatSyncJob();
|
||||
startMetricasInvalidationsJob();
|
||||
startSatSyncMonitorJob();
|
||||
startSatProxyReportJob();
|
||||
startRecordatoriosPeriodicosJob();
|
||||
if (sendRealEmails) {
|
||||
startWeeklyUpdateJob();
|
||||
startNotificationsJob();
|
||||
|
||||
75
apps/api/src/jobs/recordatorios-periodicos.job.ts
Normal file
75
apps/api/src/jobs/recordatorios-periodicos.job.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Extensión periódica de recordatorios recurrentes.
|
||||
*
|
||||
* Cada vez que corre, itera los tenants activos y para cada serie periódica
|
||||
* activa genera nuevas instancias futuras hasta mantener un horizonte mínimo
|
||||
* (default 24 meses).
|
||||
*
|
||||
* Programado diariamente a las 6:00 AM (America/Mexico_City) porque es una
|
||||
* tarea liviana y nos asegura que siempre haya instancias disponibles.
|
||||
*/
|
||||
import cron from 'node-cron';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { extenderSeriesActivas } from '../services/recordatorios.service.js';
|
||||
|
||||
const SCHEDULE = '0 6 * * *'; // 06:00 AM diario
|
||||
|
||||
let task: ReturnType<typeof cron.schedule> | null = null;
|
||||
|
||||
export async function runRecordatoriosPeriodicosJob(): Promise<{
|
||||
tenants: number;
|
||||
series: number;
|
||||
instancias: number;
|
||||
}> {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true, rfc: true, databaseName: true },
|
||||
});
|
||||
|
||||
let seriesTotal = 0;
|
||||
let instanciasTotal = 0;
|
||||
|
||||
for (const tenant of tenants) {
|
||||
if (!tenant.databaseName) continue;
|
||||
try {
|
||||
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
|
||||
const result = await extenderSeriesActivas(pool);
|
||||
if (result.series > 0) {
|
||||
console.log(`[Recordatorios Periodicos] ${tenant.rfc}: ${result.instancias} instancias en ${result.series} series`);
|
||||
}
|
||||
seriesTotal += result.series;
|
||||
instanciasTotal += result.instancias;
|
||||
} catch (err: any) {
|
||||
console.error(`[Recordatorios Periodicos] Error en ${tenant.rfc}:`, err.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
return { tenants: tenants.length, series: seriesTotal, instancias: instanciasTotal };
|
||||
}
|
||||
|
||||
export function startRecordatoriosPeriodicosJob(): void {
|
||||
if (task) {
|
||||
console.warn('[Recordatorios Periodicos Cron] Ya iniciado');
|
||||
return;
|
||||
}
|
||||
task = cron.schedule(SCHEDULE, async () => {
|
||||
try {
|
||||
const result = await runRecordatoriosPeriodicosJob();
|
||||
if (result.series > 0) {
|
||||
console.log(`[Recordatorios Periodicos Cron] ${result.tenants} tenants — ${result.instancias} instancias en ${result.series} series`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[Recordatorios Periodicos Cron] Error general:', err.message || err);
|
||||
}
|
||||
}, {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
console.log(`[Recordatorios Periodicos Cron] Programado: ${SCHEDULE} (06:00 AM diario America/Mexico_City)`);
|
||||
}
|
||||
|
||||
export function stopRecordatoriosPeriodicosJob(): void {
|
||||
if (task) {
|
||||
task.stop();
|
||||
task = null;
|
||||
}
|
||||
}
|
||||
93
apps/api/src/jobs/sat-proxy-report.job.ts
Normal file
93
apps/api/src/jobs/sat-proxy-report.job.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import cron from 'node-cron';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { emailService } from '../services/email/email.service.js';
|
||||
import type { SatProxyReportData } from '../services/email/templates/sat-proxy-report.js';
|
||||
|
||||
const PROXY_REPORT_CRON_SCHEDULE = '0 8 * * *'; // 8:00 AM CDMX diario
|
||||
|
||||
let reportTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
|
||||
function cdmxDateString(d: Date): string {
|
||||
return d.toLocaleDateString('es-MX', { timeZone: 'America/Mexico_City' });
|
||||
}
|
||||
|
||||
function hoursAgo(hours: number): Date {
|
||||
return new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export async function runSatProxyReport(): Promise<void> {
|
||||
console.log('[SAT Proxy Report] Generando reporte diario de errores por proxy');
|
||||
|
||||
try {
|
||||
const cutoff = hoursAgo(24);
|
||||
const rows = await prisma.$queryRaw<Array<{ proxy: string | null; errorCode: string | null; count: bigint }>>`
|
||||
SELECT proxy_used AS proxy, error_code AS "errorCode", COUNT(*) AS count
|
||||
FROM sat_proxy_errors
|
||||
WHERE created_at >= ${cutoff}
|
||||
GROUP BY proxy_used, error_code
|
||||
ORDER BY count DESC, proxy_used ASC, error_code ASC
|
||||
`;
|
||||
|
||||
const byProxy = rows.map(r => ({
|
||||
proxy: r.proxy || 'IP directa del servidor',
|
||||
errorCode: r.errorCode || '—',
|
||||
count: Number(r.count),
|
||||
}));
|
||||
|
||||
const totalErrors = byProxy.reduce((sum, r) => sum + r.count, 0);
|
||||
|
||||
const now = new Date();
|
||||
const data: SatProxyReportData = {
|
||||
generatedAt: now.toLocaleString('es-MX', { timeZone: 'America/Mexico_City' }),
|
||||
recipient: env.ADMIN_EMAIL,
|
||||
dateFrom: cdmxDateString(hoursAgo(24)),
|
||||
dateTo: cdmxDateString(now),
|
||||
totalErrors,
|
||||
byProxy,
|
||||
};
|
||||
|
||||
const recipient = env.ADMIN_EMAIL;
|
||||
if (!recipient) {
|
||||
console.warn('[SAT Proxy Report] ADMIN_EMAIL no configurado, no se envía reporte');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SAT Proxy Report] Enviando reporte a ${recipient}: ${totalErrors} errores en ${byProxy.length} grupos`);
|
||||
await emailService.sendSatProxyReport(recipient, data);
|
||||
console.log('[SAT Proxy Report] Reporte enviado');
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Proxy Report] Error generando reporte:', error.message || error);
|
||||
}
|
||||
}
|
||||
|
||||
export function startSatProxyReportJob(): void {
|
||||
if (reportTask) {
|
||||
console.log('[SAT Proxy Report] Job ya está programado');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cron.validate(PROXY_REPORT_CRON_SCHEDULE)) {
|
||||
console.error('[SAT Proxy Report] Expresión cron inválida:', PROXY_REPORT_CRON_SCHEDULE);
|
||||
return;
|
||||
}
|
||||
|
||||
reportTask = cron.schedule(PROXY_REPORT_CRON_SCHEDULE, async () => {
|
||||
try {
|
||||
await runSatProxyReport();
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Proxy Report Cron] Error:', error.message || error);
|
||||
}
|
||||
}, {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
|
||||
console.log(`[SAT Proxy Report] Programado: ${PROXY_REPORT_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
}
|
||||
|
||||
export function stopSatProxyReportJob(): void {
|
||||
if (reportTask) {
|
||||
reportTask.stop();
|
||||
reportTask = null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import cron from 'node-cron';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { startSync, getSyncStatus, retryTimedOutJobs } from '../services/sat/sat.service.js';
|
||||
import { startSync, getSyncStatus, retryTimedOutJobs, continuePendingDailyRequests } from '../services/sat/sat.service.js';
|
||||
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
|
||||
import { hasFielConfigured } from '../services/fiel.service.js';
|
||||
import { consultarOpinion, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
|
||||
@@ -11,18 +11,22 @@ import { consultarConstancia, purgeConstanciasAntiguas } from '../services/const
|
||||
import { tenantDb } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
|
||||
const SYNC_CRON_SCHEDULE = '0 6-10 * * *'; // 6:00–10:00 AM CDMX — ~20% de tenants por hora (5 grupos); el SAT cierra el servicio en la noche
|
||||
const RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10:00 AM todos los días
|
||||
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
||||
const RETRY_9AM_CRON_SCHEDULE = '0 9 * * *'; // 9:00 AM todos los días
|
||||
const RETRY_4PM_CRON_SCHEDULE = '0 16 * * *'; // 4:00 PM todos los días
|
||||
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas (legacy, se mantiene por compatibilidad)
|
||||
const CONCURRENT_CONTRIBUYENTES = Number(process.env.SAT_CONCURRENT_CONTRIBUYENTES || '10'); // Máximo de contribuyentes en paralelo
|
||||
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
|
||||
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
|
||||
const INCREMENTAL_CRON_SCHEDULE = '0 11,15,19 * * *'; // 11:00, 15:00 y 19:00; fuera de ese rango el daily (03:00) cubre
|
||||
const INCREMENTAL_CRON_SCHEDULE = '0 11,15,19 * * *'; // 11:00, 15:00 y 19:00; fuera de ese rango el daily (6-10 AM) cubre
|
||||
const SUBSCRIPTION_LIFECYCLE_CRON = '30 2 * * *'; // 2:30 AM diario — aplica pending changes + expira trials
|
||||
const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos pre-vencimiento (7d/3d/1d/0d)
|
||||
|
||||
let isRunning = false;
|
||||
let isIncrementalRunning = false;
|
||||
let isRecoveryRunning = false;
|
||||
let isDailyRetryRunning = false;
|
||||
|
||||
/**
|
||||
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
|
||||
@@ -46,7 +50,7 @@ async function hasAnyFielConfigured(tenantId: string, databaseName?: string | nu
|
||||
try {
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true LIMIT 1`
|
||||
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true AND valid_until > NOW() LIMIT 1`
|
||||
);
|
||||
return rows.length > 0;
|
||||
} catch (err: any) {
|
||||
@@ -95,7 +99,135 @@ async function needsInitialSync(tenantId: string, contribuyenteId?: string): Pro
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta sincronización para un tenant y sus contribuyentes
|
||||
* Devuelve los entidad_id de contribuyentes con FIEL vigente.
|
||||
* Si el tenant tiene FIEL legacy vigente a nivel tenant, devuelve todos
|
||||
* (startSync hace fallback por RFC). `total` permite distinguir "tenant sin
|
||||
* contribuyentes" (path legacy) de "ninguno con FIEL vigente" (se omite).
|
||||
*/
|
||||
async function getContribuyentesParaSync(
|
||||
tenantId: string,
|
||||
databaseName: string,
|
||||
logPrefix: string
|
||||
): Promise<{ ids: string[]; total: number }> {
|
||||
const pool = await tenantDb.getPool(tenantId, databaseName);
|
||||
const { rows: allRows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
||||
const allIds: string[] = allRows.map((r: any) => r.entidad_id);
|
||||
if (allIds.length === 0) return { ids: [], total: 0 };
|
||||
|
||||
const hasLegacyFiel = await hasFielConfigured(tenantId);
|
||||
if (hasLegacyFiel) return { ids: allIds, total: allIds.length };
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT c.entidad_id FROM contribuyentes c
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM fiel_contribuyente f
|
||||
WHERE f.contribuyente_id = c.entidad_id
|
||||
AND f.is_active = true AND f.valid_until > NOW()
|
||||
)`
|
||||
);
|
||||
const ids: string[] = rows.map((r: any) => r.entidad_id);
|
||||
const skipped = allIds.length - ids.length;
|
||||
if (skipped > 0) {
|
||||
console.log(`${logPrefix} Tenant ${tenantId}: ${skipped} contribuyente(s) sin FIEL vigente, omitidos`);
|
||||
}
|
||||
return { ids, total: allIds.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unidad mínima de sincronización: un tenant (legacy) o un contribuyente.
|
||||
*/
|
||||
interface SyncUnit {
|
||||
tenantId: string;
|
||||
contribuyenteId?: string;
|
||||
syncType: 'initial' | 'daily' | 'incremental';
|
||||
}
|
||||
|
||||
/**
|
||||
* Recolecta todas las unidades de sync para un conjunto de tenants.
|
||||
* - Modo incremental: solo incluye contribuyentes/tenants con initial completado.
|
||||
* - Modo daily/initial: determina initial vs daily por contribuyente.
|
||||
*/
|
||||
async function getSyncUnits(
|
||||
tenantIds: string[],
|
||||
options: { incremental?: boolean; logPrefix?: string } = {}
|
||||
): Promise<SyncUnit[]> {
|
||||
const { incremental = false, logPrefix = '[SAT Cron]' } = options;
|
||||
const units: SyncUnit[] = [];
|
||||
|
||||
for (const tenantId of tenantIds) {
|
||||
try {
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
|
||||
let contribuyenteIds: string[] = [];
|
||||
if (tenant?.databaseName) {
|
||||
const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, logPrefix);
|
||||
if (total > 0 && ids.length === 0) {
|
||||
console.log(`${logPrefix} Tenant ${tenantId}: ningún contribuyente con FIEL vigente, se omite`);
|
||||
continue;
|
||||
}
|
||||
contribuyenteIds = ids;
|
||||
}
|
||||
|
||||
// Tenant legacy sin contribuyentes (Horux 360)
|
||||
if (contribuyenteIds.length === 0) {
|
||||
if (incremental) {
|
||||
const hasInitial = await prisma.satSyncJob.findFirst({
|
||||
where: { tenantId, contribuyenteId: null, type: 'initial', status: 'completed' },
|
||||
});
|
||||
if (!hasInitial) continue;
|
||||
units.push({ tenantId, syncType: 'incremental' });
|
||||
} else {
|
||||
const needsInitial = await needsInitialSync(tenantId);
|
||||
units.push({ tenantId, syncType: needsInitial ? 'initial' : 'daily' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Contribuyentes del tenant
|
||||
for (const contribuyenteId of contribuyenteIds) {
|
||||
if (incremental) {
|
||||
const hasInitial = await prisma.satSyncJob.findFirst({
|
||||
where: { tenantId, contribuyenteId, type: 'initial', status: 'completed' },
|
||||
});
|
||||
if (!hasInitial) continue;
|
||||
units.push({ tenantId, contribuyenteId, syncType: 'incremental' });
|
||||
} else {
|
||||
const needsInitial = await needsInitialSync(tenantId, contribuyenteId);
|
||||
units.push({ tenantId, contribuyenteId, syncType: needsInitial ? 'initial' : 'daily' });
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`${logPrefix} Error recolectando unidades para tenant ${tenantId}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return units;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta sync para una unidad (tenant o contribuyente), respetando locks.
|
||||
*/
|
||||
async function syncUnit(unit: SyncUnit, logPrefix: string): Promise<void> {
|
||||
try {
|
||||
const status = await getSyncStatus(unit.tenantId, unit.contribuyenteId);
|
||||
if (status.hasActiveSync) {
|
||||
console.log(`${logPrefix} ${unit.tenantId}${unit.contribuyenteId ? ` contribuyente ${unit.contribuyenteId}` : ''} ya tiene sync activo, omitiendo`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${logPrefix} Iniciando sync ${unit.syncType} para ${unit.tenantId}${unit.contribuyenteId ? ` contribuyente ${unit.contribuyenteId}` : ''}`);
|
||||
const jobId = await startSync(unit.tenantId, unit.syncType, undefined, undefined, unit.contribuyenteId);
|
||||
console.log(`${logPrefix} Job ${jobId} iniciado`);
|
||||
} catch (error: any) {
|
||||
console.error(`${logPrefix} Error sincronizando ${unit.tenantId}${unit.contribuyenteId ? ` contribuyente ${unit.contribuyenteId}` : ''}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta sincronización para un tenant y sus contribuyentes (modo secuencial legacy)
|
||||
*/
|
||||
async function syncTenant(tenantId: string): Promise<void> {
|
||||
try {
|
||||
@@ -107,9 +239,12 @@ async function syncTenant(tenantId: string): Promise<void> {
|
||||
|
||||
let contribuyenteIds: string[] = [];
|
||||
if (tenant?.databaseName) {
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
||||
contribuyenteIds = rows.map((r: any) => r.entidad_id);
|
||||
const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, '[SAT Cron]');
|
||||
if (total > 0 && ids.length === 0) {
|
||||
console.log(`[SAT Cron] Tenant ${tenantId}: ningún contribuyente con FIEL vigente, se omite`);
|
||||
return;
|
||||
}
|
||||
contribuyenteIds = ids;
|
||||
}
|
||||
|
||||
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy Horux 360)
|
||||
@@ -153,6 +288,27 @@ async function syncTenant(tenantId: string): Promise<void> {
|
||||
/**
|
||||
* Ejecuta el job de sincronización para todos los tenants
|
||||
*/
|
||||
const DAILY_GROUPS = 5; // ventanas 6,7,8,9,10 AM
|
||||
const DAILY_WINDOW_START = 6; // primera ventana CDMX
|
||||
|
||||
/** Hash estable del tenantId → grupo 0..DAILY_GROUPS-1 (reparte ~20% por ventana) */
|
||||
function tenantGroup(tenantId: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < tenantId.length; i++) h = (h * 31 + tenantId.charCodeAt(i)) >>> 0;
|
||||
return h % DAILY_GROUPS;
|
||||
}
|
||||
|
||||
/** Hora actual en zona America/Mexico_City (0-23) */
|
||||
function cdmxHour(): number {
|
||||
return Number(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Mexico_City',
|
||||
hour: 'numeric',
|
||||
hour12: false,
|
||||
}).format(new Date())
|
||||
);
|
||||
}
|
||||
|
||||
async function runSyncJob(): Promise<void> {
|
||||
if (isRunning) {
|
||||
console.log('[SAT Cron] Job ya en ejecución, omitiendo');
|
||||
@@ -171,13 +327,36 @@ async function runSyncJob(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Procesar en lotes para no saturar
|
||||
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) {
|
||||
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS);
|
||||
await Promise.all(batch.map(syncTenant));
|
||||
const hour = cdmxHour();
|
||||
const groupIndex = hour - DAILY_WINDOW_START; // 6→0 … 10→4
|
||||
if (groupIndex < 0 || groupIndex >= DAILY_GROUPS) {
|
||||
console.log(`[SAT Cron] Hora CDMX ${hour} fuera de ventana 6-10 AM, omitiendo`);
|
||||
return;
|
||||
}
|
||||
const groupTenants = tenantIds.filter(id => tenantGroup(id) === groupIndex);
|
||||
console.log(`[SAT Cron] Ventana ${hour}:00 CDMX — grupo ${groupIndex + 1}/${DAILY_GROUPS}: ${groupTenants.length}/${tenantIds.length} tenants`);
|
||||
|
||||
if (groupTenants.length === 0) {
|
||||
console.log('[SAT Cron] No hay tenants en este grupo');
|
||||
return;
|
||||
}
|
||||
|
||||
// Recolectar unidades de sync (contribuyentes o tenants legacy)
|
||||
const units = await getSyncUnits(groupTenants, { logPrefix: '[SAT Cron]' });
|
||||
console.log(`[SAT Cron] Ventana ${hour}:00 CDMX — ${units.length} unidades de sync listas (max ${CONCURRENT_CONTRIBUYENTES} paralelas)`);
|
||||
|
||||
if (units.length === 0) {
|
||||
console.log('[SAT Cron] No hay unidades de sync en este grupo');
|
||||
return;
|
||||
}
|
||||
|
||||
// Procesar en lotes de contribuyentes para aprovechar los proxies
|
||||
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
|
||||
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
|
||||
await Promise.all(batch.map(unit => syncUnit(unit, '[SAT Cron]')));
|
||||
|
||||
// Pequeña pausa entre lotes
|
||||
if (i + CONCURRENT_SYNCS < tenantIds.length) {
|
||||
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
@@ -194,7 +373,8 @@ async function runSyncJob(): Promise<void> {
|
||||
* Obtiene los tenants activos cuyo plan habilita SAT incremental (3 syncs/día
|
||||
* adicionales al daily). El flag vive en `despacho_plan_prices.permite_sat_incremental`,
|
||||
* editable por admin global desde `/configuracion/precios-suscripcion`.
|
||||
* Default backfill: mi_empresa_plus, business_control, business_cloud.
|
||||
* Planes con incremental: mi_empresa_plus, business_cloud.
|
||||
* business_control usa solo daily + retry programado.
|
||||
*/
|
||||
async function getTenantsConSatIncremental(): Promise<string[]> {
|
||||
const planesIncrementales = await prisma.despachoPlanPrice.findMany({
|
||||
@@ -232,9 +412,12 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
|
||||
|
||||
let contribuyenteIds: string[] = [];
|
||||
if (tenant?.databaseName) {
|
||||
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
|
||||
const { rows } = await pool.query('SELECT entidad_id FROM contribuyentes');
|
||||
contribuyenteIds = rows.map((r: any) => r.entidad_id);
|
||||
const { ids, total } = await getContribuyentesParaSync(tenantId, tenant.databaseName, '[SAT Cron Inc]');
|
||||
if (total > 0 && ids.length === 0) {
|
||||
console.log(`[SAT Cron Inc] Tenant ${tenantId}: ningún contribuyente con FIEL vigente, se omite`);
|
||||
return;
|
||||
}
|
||||
contribuyenteIds = ids;
|
||||
}
|
||||
|
||||
// Si no hay contribuyentes, sincronizar a nivel tenant (legacy)
|
||||
@@ -306,11 +489,19 @@ async function runIncrementalSyncJob(): Promise<void> {
|
||||
|
||||
if (tenantIds.length === 0) return;
|
||||
|
||||
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) {
|
||||
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS);
|
||||
await Promise.all(batch.map(incrementalSyncTenant));
|
||||
const units = await getSyncUnits(tenantIds, { incremental: true, logPrefix: '[SAT Cron Inc]' });
|
||||
console.log(`[SAT Cron Inc] ${units.length} unidades de sync listas (max ${CONCURRENT_CONTRIBUYENTES} paralelas)`);
|
||||
|
||||
if (i + CONCURRENT_SYNCS < tenantIds.length) {
|
||||
if (units.length === 0) {
|
||||
console.log('[SAT Cron Inc] No hay unidades de sync');
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < units.length; i += CONCURRENT_CONTRIBUYENTES) {
|
||||
const batch = units.slice(i, i + CONCURRENT_CONTRIBUYENTES);
|
||||
await Promise.all(batch.map(unit => syncUnit(unit, '[SAT Cron Inc]')));
|
||||
|
||||
if (i + CONCURRENT_CONTRIBUYENTES < units.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
@@ -529,9 +720,30 @@ export async function runRecoverySyncJob(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function runDailyRetryJob(): Promise<void> {
|
||||
if (isDailyRetryRunning) {
|
||||
console.log('[SAT Daily Retry] Ya en ejecución, omitiendo');
|
||||
return;
|
||||
}
|
||||
|
||||
isDailyRetryRunning = true;
|
||||
console.log('[SAT Daily Retry] Iniciando retry programado de daily syncs');
|
||||
|
||||
try {
|
||||
await continuePendingDailyRequests();
|
||||
console.log('[SAT Daily Retry] Retry programado completado');
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Daily Retry] Error:', error.message);
|
||||
} finally {
|
||||
isDailyRetryRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let retryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let retry9amTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let retry4pmTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let opinionTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let csfTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
@@ -585,6 +797,28 @@ export function startSatSyncJob(): void {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
|
||||
// Retomar jobs diarios que quedaron pending por timeout de polling.
|
||||
// 9:00 AM y 4:00 PM CDMX, complemento a los retries automáticos de 6h/12h.
|
||||
retry9amTask = cron.schedule(RETRY_9AM_CRON_SCHEDULE, async () => {
|
||||
try {
|
||||
await runDailyRetryJob();
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Daily Retry 9AM] Error:', error.message);
|
||||
}
|
||||
}, {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
|
||||
retry4pmTask = cron.schedule(RETRY_4PM_CRON_SCHEDULE, async () => {
|
||||
try {
|
||||
await runDailyRetryJob();
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Daily Retry 4PM] Error:', error.message);
|
||||
}
|
||||
}, {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
|
||||
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
|
||||
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
|
||||
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
|
||||
@@ -691,6 +925,7 @@ export function startSatSyncJob(): void {
|
||||
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[SAT Cron] Retry programado cada hora`);
|
||||
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[SAT Daily Retry] Programado para: ${RETRY_9AM_CRON_SCHEDULE} y ${RETRY_4PM_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
@@ -714,6 +949,14 @@ export function stopSatSyncJob(): void {
|
||||
recoveryTask.stop();
|
||||
recoveryTask = null;
|
||||
}
|
||||
if (retry9amTask) {
|
||||
retry9amTask.stop();
|
||||
retry9amTask = null;
|
||||
}
|
||||
if (retry4pmTask) {
|
||||
retry4pmTask.stop();
|
||||
retry4pmTask = null;
|
||||
}
|
||||
if (opinionTask) {
|
||||
opinionTask.stop();
|
||||
opinionTask = null;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Recordatorios periódicos: maestro + instancias materializadas en la misma tabla
|
||||
|
||||
ALTER TABLE recordatorios
|
||||
ADD COLUMN IF NOT EXISTS recurrencia VARCHAR(20) DEFAULT 'unica' NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS serie_id INTEGER REFERENCES recordatorios(id) ON DELETE CASCADE,
|
||||
ADD COLUMN IF NOT EXISTS activo BOOLEAN DEFAULT true NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS fecha_inicio DATE,
|
||||
ADD COLUMN IF NOT EXISTS fecha_fin DATE;
|
||||
|
||||
-- Backfill seguro para filas existentes (el default ya cubre recurrencia/activo, pero dejamos explícito)
|
||||
UPDATE recordatorios
|
||||
SET recurrencia = 'unica',
|
||||
activo = true
|
||||
WHERE recurrencia IS NULL
|
||||
OR activo IS NULL;
|
||||
|
||||
-- Índices para consultas de serie y regeneración
|
||||
CREATE INDEX IF NOT EXISTS recordatorios_serie_id_idx ON recordatorios(serie_id);
|
||||
CREATE INDEX IF NOT EXISTS recordatorios_recurrencia_activo_idx ON recordatorios(recurrencia, activo);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Fix: la constraint unique de declaraciones normales solo consideraba
|
||||
-- (año, mes, contribuyente_id). Esto impedía subir una declaración normal de
|
||||
-- ISRTP si ya existía una normal de ISN para el mismo mes y contribuyente.
|
||||
-- Ahora la unicidad se valida por (año, mes, contribuyente_id, impuestos),
|
||||
-- permitiendo una declaración normal distinta por cada conjunto de impuestos.
|
||||
|
||||
DROP INDEX IF EXISTS uniq_declaracion_normal_mes_contrib;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uniq_declaracion_normal_mes_contrib_impuestos
|
||||
ON declaraciones_provisionales(año, mes, contribuyente_id, impuestos)
|
||||
WHERE tipo = 'normal';
|
||||
|
||||
INSERT INTO tenant_migrations (scope, version, name)
|
||||
VALUES ('vertical-contable', 57, '057_declaraciones_unique_por_impuestos')
|
||||
ON CONFLICT (scope, version) DO NOTHING;
|
||||
@@ -6,10 +6,10 @@ import { strictLimit } from '../middlewares/rate-limit.middleware.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Rate limiting: 10 login attempts per 15 minutes per IP
|
||||
// Rate limiting: 25 login attempts per 15 minutes per IP
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
max: 25,
|
||||
message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
|
||||
@@ -10,7 +10,7 @@ router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
// Static routes first
|
||||
router.get('/supervisores', authorize('owner'), ctrl.getSupervisores);
|
||||
router.get('/supervisores', authorize('owner', 'supervisor'), ctrl.getSupervisores);
|
||||
|
||||
// Asignaciones de obligaciones/tareas a auxiliares (antes de /:id para evitar match dinámico)
|
||||
router.get('/asignaciones', authorize('owner', 'supervisor'), asignacionesCtrl.listPorSupervisor);
|
||||
|
||||
@@ -323,7 +323,7 @@ export async function logout(token: string): Promise<void> {
|
||||
// Password reset
|
||||
// ============================================================================
|
||||
|
||||
const PASSWORD_RESET_EXPIRY_MS = 60 * 60 * 1000; // 1 hora
|
||||
const PASSWORD_RESET_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 horas
|
||||
|
||||
/**
|
||||
* Solicita recuperación de contraseña. No revela si el email existe (anti-enumeration).
|
||||
|
||||
@@ -102,12 +102,12 @@ export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiLi
|
||||
}
|
||||
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
|
||||
whereClause += ` AND fecha_emision::date >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
whereClause += ` AND fecha_emision::date <= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
|
||||
@@ -214,11 +214,11 @@ export async function getConceptosList(
|
||||
params.push(filters.estado);
|
||||
}
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
|
||||
whereClause += ` AND c.fecha_emision::date >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND COALESCE(c.fecha_efectiva, c.fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
whereClause += ` AND c.fecha_emision::date <= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
if (filters.rfc) {
|
||||
@@ -385,11 +385,11 @@ export async function getCfdiXmlsForZip(
|
||||
params.push(filters.estado);
|
||||
}
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') >= $${paramIndex++}::date`;
|
||||
whereClause += ` AND fecha_emision::date >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
whereClause += ` AND fecha_emision::date <= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
if (filters.rfc) {
|
||||
|
||||
@@ -50,6 +50,11 @@ export const emailService = {
|
||||
await sendEmail(to, `🚨 Alerta SAT: ${total} anomalía${total === 1 ? '' : 's'} detectada${total === 1 ? '' : 's'}`, satSyncAlertEmail(data));
|
||||
},
|
||||
|
||||
sendSatProxyReport: async (to: string, data: import('./templates/sat-proxy-report.js').SatProxyReportData) => {
|
||||
const { satProxyReportEmail } = await import('./templates/sat-proxy-report.js');
|
||||
await sendEmail(to, `📊 Reporte diario SAT: ${data.totalErrors} errores por proxy`, satProxyReportEmail(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));
|
||||
|
||||
63
apps/api/src/services/email/templates/sat-proxy-report.ts
Normal file
63
apps/api/src/services/email/templates/sat-proxy-report.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { baseTemplate, heading, infoBox, BRAND_COLORS as C } from './base.js';
|
||||
|
||||
export interface ProxyErrorRow {
|
||||
proxy: string;
|
||||
errorCode: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SatProxyReportData {
|
||||
generatedAt: string;
|
||||
recipient: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
totalErrors: number;
|
||||
byProxy: ProxyErrorRow[];
|
||||
}
|
||||
|
||||
function tableHeader(cells: string[]): string {
|
||||
return `<tr>
|
||||
${cells.map(c => `<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};">${c}</th>`).join('')}
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function tableRow(cells: string[]): string {
|
||||
return `<tr>
|
||||
${cells.map(c => `<td style="padding:10px 12px;border-bottom:1px solid ${C.border};color:${C.textPrimary};font-size:13px;vertical-align:top;">${c}</td>`).join('')}
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
export function satProxyReportEmail(data: SatProxyReportData): string {
|
||||
const rowsHtml = data.byProxy.length > 0
|
||||
? data.byProxy.map(r => tableRow([
|
||||
r.proxy ? `<code style="font-size:12px;">${r.proxy}</code>` : '<span style="color:#dc2626;">IP directa del servidor</span>',
|
||||
r.errorCode || '—',
|
||||
`<strong>${r.count}</strong>`,
|
||||
])).join('')
|
||||
: tableRow(['—', '—', '<span style="color:#16a34a;">Sin errores de bloqueo</span>']);
|
||||
|
||||
return baseTemplate(`
|
||||
${heading('📊 Reporte diario de errores SAT por proxy')}
|
||||
<p style="color:${C.textPrimary};margin:0 0 16px;">
|
||||
Resumen de errores de bloqueo/devolución del SAT en las últimas 24 horas,
|
||||
agrupados por proxy/IP usada.
|
||||
</p>
|
||||
${infoBox(`
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td style="padding:6px 0;color:${C.textMuted};">Período</td><td style="padding:6px 0;color:${C.textPrimary};font-weight:600;text-align:right;">${data.dateFrom} → ${data.dateTo}</td></tr>
|
||||
<tr><td style="padding:6px 0;color:${C.textMuted};">Errores totales</td><td style="padding:6px 0;color:${data.totalErrors > 0 ? '#dc2626' : '#16a34a'};font-weight:600;text-align:right;">${data.totalErrors}</td></tr>
|
||||
</table>
|
||||
`)}
|
||||
|
||||
<h3 style="font-family:'Inter', sans-serif;font-weight:600;color:${C.textPrimary};margin:28px 0 12px;font-size:16px;">Errores por proxy (${data.byProxy.length})</h3>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<thead>${tableHeader(['Proxy / IP', 'Código', 'Cantidad'])}</thead>
|
||||
<tbody>${rowsHtml}</tbody>
|
||||
</table>
|
||||
|
||||
<p style="color:${C.textMuted};margin:24px 0 0;font-size:12px;">
|
||||
Reporte generado el ${data.generatedAt} para ${data.recipient}.<br/>
|
||||
Configura la lista de proxies con SAT_PROXY_LIST y la concurrencia con SAT_CONCURRENT_CONTRIBUYENTES.
|
||||
</p>
|
||||
`);
|
||||
}
|
||||
@@ -378,6 +378,7 @@ export async function processProximosRecordatorios(
|
||||
WHERE completado = false
|
||||
AND fecha_limite = (CURRENT_DATE + ${dias})::date
|
||||
AND ${col} IS NULL
|
||||
AND (serie_id IS NOT NULL OR recurrencia = 'unica')
|
||||
`);
|
||||
|
||||
for (const r of rows) {
|
||||
|
||||
@@ -1,77 +1,221 @@
|
||||
import type { Pool } from 'pg';
|
||||
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
|
||||
|
||||
export type RecurrenciaRecordatorio = 'unica' | 'mensual' | 'bimestral' | 'trimestral' | 'anual';
|
||||
|
||||
const RECURRENCIA_DELTA_MESES: Record<RecurrenciaRecordatorio, number> = {
|
||||
unica: 0,
|
||||
mensual: 1,
|
||||
bimestral: 2,
|
||||
trimestral: 3,
|
||||
anual: 12,
|
||||
};
|
||||
|
||||
interface RecordatorioRow {
|
||||
id: number;
|
||||
titulo: string;
|
||||
descripcion: string | null;
|
||||
fecha_limite: Date;
|
||||
notas: string | null;
|
||||
completado: boolean;
|
||||
privado: boolean;
|
||||
creado_por: string;
|
||||
created_at: Date;
|
||||
recurrencia: RecurrenciaRecordatorio;
|
||||
serie_id: number | null;
|
||||
activo: boolean;
|
||||
fecha_inicio: Date | null;
|
||||
fecha_fin: Date | null;
|
||||
}
|
||||
|
||||
function toISODate(d: Date): string {
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function startOfDay(d: Date): Date {
|
||||
const r = new Date(d);
|
||||
r.setHours(0, 0, 0, 0);
|
||||
return r;
|
||||
}
|
||||
|
||||
function addMonthsLocal(date: Date, months: number): Date {
|
||||
const result = new Date(date);
|
||||
const day = result.getDate();
|
||||
result.setMonth(result.getMonth() + months);
|
||||
// Si el mes resultante no tiene el mismo día, volver al último día del mes anterior
|
||||
if (result.getDate() !== day) {
|
||||
result.setDate(0);
|
||||
}
|
||||
return startOfDay(result);
|
||||
}
|
||||
|
||||
function calcularSiguienteFecha(fecha: Date, recurrencia: RecurrenciaRecordatorio): Date {
|
||||
return addMonthsLocal(fecha, RECURRENCIA_DELTA_MESES[recurrencia]);
|
||||
}
|
||||
|
||||
function generarFechasInstancias(
|
||||
fechaInicio: Date,
|
||||
recurrencia: RecurrenciaRecordatorio,
|
||||
fechaFin: Date | null | undefined,
|
||||
maxMesesHorizonte = 24,
|
||||
): Date[] {
|
||||
const delta = RECURRENCIA_DELTA_MESES[recurrencia];
|
||||
if (delta === 0) return [];
|
||||
|
||||
const inicio = startOfDay(fechaInicio);
|
||||
const limiteHorizonte = addMonthsLocal(new Date(), maxMesesHorizonte);
|
||||
const limite = fechaFin ? startOfDay(fechaFin) : limiteHorizonte;
|
||||
|
||||
const fechas: Date[] = [];
|
||||
let current = inicio;
|
||||
while (current <= limite) {
|
||||
fechas.push(new Date(current));
|
||||
current = calcularSiguienteFecha(current, recurrencia);
|
||||
}
|
||||
return fechas;
|
||||
}
|
||||
|
||||
function rowToEventoFiscal(r: RecordatorioRow): EventoFiscal {
|
||||
return {
|
||||
id: r.id,
|
||||
titulo: r.titulo,
|
||||
descripcion: r.descripcion || '',
|
||||
tipo: 'custom' as const,
|
||||
fechaLimite: toISODate(r.fecha_limite),
|
||||
recurrencia: r.recurrencia,
|
||||
completado: r.completado,
|
||||
notas: r.notas,
|
||||
privado: r.privado,
|
||||
creadoPor: r.creado_por,
|
||||
createdAt: r.created_at?.toISOString(),
|
||||
// metadata extra para el frontend
|
||||
serieId: r.serie_id ?? undefined,
|
||||
esPeriodico: r.recurrencia !== 'unica',
|
||||
} as EventoFiscal;
|
||||
}
|
||||
|
||||
async function findById(pool: Pool, id: number): Promise<RecordatorioRow | null> {
|
||||
const { rows } = await pool.query<RecordatorioRow>(
|
||||
`SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||
FROM recordatorios WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function getMaestroDesdeId(pool: Pool, id: number): Promise<RecordatorioRow | null> {
|
||||
const row = await findById(pool, id);
|
||||
if (!row) return null;
|
||||
if (row.serie_id === null) return row;
|
||||
return findById(pool, row.serie_id);
|
||||
}
|
||||
|
||||
async function generarInstancias(
|
||||
pool: Pool,
|
||||
maestro: RecordatorioRow,
|
||||
maxMesesHorizonte = 24,
|
||||
): Promise<number> {
|
||||
if (!maestro.fecha_inicio) return 0;
|
||||
if (maestro.recurrencia === 'unica') return 0;
|
||||
|
||||
const fechas = generarFechasInstancias(
|
||||
maestro.fecha_inicio,
|
||||
maestro.recurrencia,
|
||||
maestro.fecha_fin ?? undefined,
|
||||
maxMesesHorizonte,
|
||||
);
|
||||
|
||||
if (fechas.length === 0) return 0;
|
||||
|
||||
let insertadas = 0;
|
||||
for (const fecha of fechas) {
|
||||
const { rowCount } = await pool.query(
|
||||
`INSERT INTO recordatorios (
|
||||
titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||
creado_por, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, false, $5, $6, 'unica', $7, true, $8, $9)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[
|
||||
maestro.titulo,
|
||||
maestro.descripcion,
|
||||
toISODate(fecha),
|
||||
maestro.notas,
|
||||
maestro.privado,
|
||||
maestro.creado_por,
|
||||
maestro.id,
|
||||
toISODate(maestro.fecha_inicio),
|
||||
maestro.fecha_fin ? toISODate(maestro.fecha_fin) : null,
|
||||
]
|
||||
);
|
||||
insertadas += rowCount ?? 0;
|
||||
}
|
||||
return insertadas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene recordatorios visibles para el usuario.
|
||||
* - Públicos: todos los del tenant
|
||||
* - Privados: solo los creados por el usuario
|
||||
* Excluye los maestros periódicos (serie_id IS NULL AND recurrencia != 'unica')
|
||||
* para no duplicar eventos en el calendario.
|
||||
*/
|
||||
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"
|
||||
const { rows } = await pool.query<RecordatorioRow>(`
|
||||
SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||
FROM recordatorios
|
||||
WHERE EXTRACT(YEAR FROM fecha_limite) = $1
|
||||
AND activo = true
|
||||
AND (privado = false OR creado_por = $2)
|
||||
AND NOT (serie_id IS NULL AND recurrencia <> 'unica')
|
||||
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(),
|
||||
}));
|
||||
return rows.map(rowToEventoFiscal);
|
||||
}
|
||||
|
||||
export async function createRecordatorio(
|
||||
pool: Pool,
|
||||
userId: string,
|
||||
data: EventoCreate & { privado?: boolean }
|
||||
data: EventoCreate & { privado?: boolean; fechaFin?: string | null }
|
||||
): 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"
|
||||
const recurrencia = (data.recurrencia as RecurrenciaRecordatorio) || 'unica';
|
||||
const fechaFin = data.fechaFin ? new Date(data.fechaFin + 'T00:00:00') : null;
|
||||
const fechaLimite = new Date(data.fechaLimite + 'T00:00:00');
|
||||
|
||||
const { rows } = await pool.query<RecordatorioRow>(`
|
||||
INSERT INTO recordatorios (
|
||||
titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||
creado_por, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, false, $5, $6, $7, NULL, true, $8, $9)
|
||||
RETURNING id, titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||
`, [
|
||||
data.titulo,
|
||||
data.descripcion || null,
|
||||
data.fechaLimite,
|
||||
toISODate(fechaLimite),
|
||||
data.notas || null,
|
||||
data.privado ?? false,
|
||||
userId,
|
||||
recurrencia,
|
||||
toISODate(fechaLimite),
|
||||
fechaFin ? toISODate(fechaFin) : 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(),
|
||||
};
|
||||
const maestro = rows[0];
|
||||
|
||||
if (recurrencia !== 'unica') {
|
||||
await generarInstancias(pool, maestro);
|
||||
}
|
||||
|
||||
return rowToEventoFiscal(maestro);
|
||||
}
|
||||
|
||||
export async function updateRecordatorio(
|
||||
@@ -80,14 +224,32 @@ export async function updateRecordatorio(
|
||||
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]
|
||||
);
|
||||
const row = await findById(pool, id);
|
||||
if (!row) return null;
|
||||
|
||||
if (existing.length === 0) return null;
|
||||
// Completar una instancia individual de una serie periódica
|
||||
const soloCompletado =
|
||||
data.completado !== undefined &&
|
||||
data.titulo === undefined &&
|
||||
data.descripcion === undefined &&
|
||||
data.fechaLimite === undefined &&
|
||||
data.notas === undefined &&
|
||||
data.privado === undefined;
|
||||
|
||||
if (soloCompletado && row.serie_id !== null) {
|
||||
await pool.query(`UPDATE recordatorios SET completado = $1, updated_at = NOW() WHERE id = $2`, [
|
||||
data.completado,
|
||||
id,
|
||||
]);
|
||||
const updated = await findById(pool, id);
|
||||
return updated ? rowToEventoFiscal(updated) : null;
|
||||
}
|
||||
|
||||
const maestro = row.serie_id === null ? row : await findById(pool, row.serie_id);
|
||||
if (!maestro) return null;
|
||||
|
||||
// Recordatorio único: editar directamente
|
||||
if (maestro.recurrencia === 'unica') {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
@@ -95,39 +257,90 @@ export async function updateRecordatorio(
|
||||
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 (data.completado !== undefined) { sets.push(`completado = $${idx++}`); params.push(data.completado); }
|
||||
|
||||
if (sets.length === 0) return null;
|
||||
|
||||
if (sets.length === 0) return rowToEventoFiscal(maestro);
|
||||
sets.push(`updated_at = NOW()`);
|
||||
params.push(id);
|
||||
params.push(maestro.id);
|
||||
|
||||
const { rows } = await pool.query(`
|
||||
const { rows } = await pool.query<RecordatorioRow>(`
|
||||
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"
|
||||
RETURNING id, titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||
`, params);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
return rows[0] ? rowToEventoFiscal(rows[0]) : 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(),
|
||||
};
|
||||
// Serie periódica: editar maestro y propagar a instancias futuras no completadas
|
||||
const updatesMaestro: string[] = [];
|
||||
const paramsMaestro: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.titulo !== undefined) { updatesMaestro.push(`titulo = $${idx++}`); paramsMaestro.push(data.titulo); }
|
||||
if (data.descripcion !== undefined) { updatesMaestro.push(`descripcion = $${idx++}`); paramsMaestro.push(data.descripcion); }
|
||||
if (data.notas !== undefined) { updatesMaestro.push(`notas = $${idx++}`); paramsMaestro.push(data.notas); }
|
||||
if (data.privado !== undefined) { updatesMaestro.push(`privado = $${idx++}`); paramsMaestro.push(data.privado); }
|
||||
|
||||
const nuevaFechaInicio = data.fechaLimite ? new Date(data.fechaLimite + 'T00:00:00') : null;
|
||||
if (nuevaFechaInicio) {
|
||||
updatesMaestro.push(`fecha_limite = $${idx++}`);
|
||||
paramsMaestro.push(toISODate(nuevaFechaInicio));
|
||||
updatesMaestro.push(`fecha_inicio = $${idx++}`);
|
||||
paramsMaestro.push(toISODate(nuevaFechaInicio));
|
||||
}
|
||||
|
||||
if (updatesMaestro.length > 0) {
|
||||
updatesMaestro.push(`updated_at = NOW()`);
|
||||
paramsMaestro.push(maestro.id);
|
||||
await pool.query(
|
||||
`UPDATE recordatorios SET ${updatesMaestro.join(', ')} WHERE id = $${idx}`,
|
||||
paramsMaestro
|
||||
);
|
||||
}
|
||||
|
||||
// Propagar campos de contenido a instancias futuras no completadas
|
||||
const updatesInstancias: string[] = [];
|
||||
const paramsInstancias: any[] = [];
|
||||
let iIdx = 1;
|
||||
|
||||
if (data.titulo !== undefined) { updatesInstancias.push(`titulo = $${iIdx++}`); paramsInstancias.push(data.titulo); }
|
||||
if (data.descripcion !== undefined) { updatesInstancias.push(`descripcion = $${iIdx++}`); paramsInstancias.push(data.descripcion); }
|
||||
if (data.notas !== undefined) { updatesInstancias.push(`notas = $${iIdx++}`); paramsInstancias.push(data.notas); }
|
||||
if (data.privado !== undefined) { updatesInstancias.push(`privado = $${iIdx++}`); paramsInstancias.push(data.privado); }
|
||||
|
||||
if (updatesInstancias.length > 0) {
|
||||
paramsInstancias.push(maestro.id);
|
||||
await pool.query(
|
||||
`UPDATE recordatorios
|
||||
SET ${updatesInstancias.join(', ')}
|
||||
WHERE serie_id = $${iIdx}
|
||||
AND fecha_limite >= CURRENT_DATE
|
||||
AND completado = false`,
|
||||
paramsInstancias
|
||||
);
|
||||
}
|
||||
|
||||
// Si cambió la fecha de inicio, regenerar instancias futuras
|
||||
if (nuevaFechaInicio) {
|
||||
await pool.query(
|
||||
`DELETE FROM recordatorios
|
||||
WHERE serie_id = $1
|
||||
AND fecha_limite >= CURRENT_DATE
|
||||
AND completado = false`,
|
||||
[maestro.id]
|
||||
);
|
||||
const maestroActualizado = await findById(pool, maestro.id);
|
||||
if (maestroActualizado) {
|
||||
await generarInstancias(pool, maestroActualizado);
|
||||
}
|
||||
}
|
||||
|
||||
const maestroFinal = await findById(pool, maestro.id);
|
||||
return maestroFinal ? rowToEventoFiscal(maestroFinal) : null;
|
||||
}
|
||||
|
||||
export async function deleteRecordatorio(
|
||||
@@ -135,9 +348,70 @@ export async function deleteRecordatorio(
|
||||
userId: string,
|
||||
id: number
|
||||
): Promise<boolean> {
|
||||
const { rowCount } = await pool.query(
|
||||
`DELETE FROM recordatorios WHERE id = $1`,
|
||||
[id]
|
||||
const row = await findById(pool, id);
|
||||
if (!row) return false;
|
||||
|
||||
// Serie periódica: cancelar (desactivar maestro y borrar instancias futuras no completadas)
|
||||
if (row.recurrencia !== 'unica' || row.serie_id !== null) {
|
||||
const maestro = row.serie_id === null ? row : await findById(pool, row.serie_id);
|
||||
if (!maestro) return false;
|
||||
|
||||
await pool.query(`UPDATE recordatorios SET activo = false, updated_at = NOW() WHERE id = $1`, [maestro.id]);
|
||||
await pool.query(
|
||||
`DELETE FROM recordatorios
|
||||
WHERE serie_id = $1
|
||||
AND fecha_limite >= CURRENT_DATE
|
||||
AND completado = false`,
|
||||
[maestro.id]
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Único: borrar directamente
|
||||
const { rowCount } = await pool.query(`DELETE FROM recordatorios WHERE id = $1`, [id]);
|
||||
return (rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extiende las series activas para mantener un horizonte futuro de instancias.
|
||||
* Útil para llamar desde un cron periódico.
|
||||
*/
|
||||
export async function extenderSeriesActivas(
|
||||
pool: Pool,
|
||||
mesesHorizonte = 24,
|
||||
): Promise<{ series: number; instancias: number }> {
|
||||
const { rows: maestros } = await pool.query<RecordatorioRow>(`
|
||||
SELECT id, titulo, descripcion, fecha_limite, notas, completado, privado,
|
||||
creado_por, created_at, recurrencia, serie_id, activo, fecha_inicio, fecha_fin
|
||||
FROM recordatorios
|
||||
WHERE serie_id IS NULL
|
||||
AND recurrencia <> 'unica'
|
||||
AND activo = true
|
||||
`);
|
||||
|
||||
let seriesProcesadas = 0;
|
||||
let instanciasCreadas = 0;
|
||||
|
||||
const horizonte = addMonthsLocal(new Date(), mesesHorizonte);
|
||||
|
||||
for (const maestro of maestros) {
|
||||
const { rows: ultimas } = await pool.query<{ fecha_limite: Date }>(`
|
||||
SELECT fecha_limite
|
||||
FROM recordatorios
|
||||
WHERE serie_id = $1
|
||||
ORDER BY fecha_limite DESC
|
||||
LIMIT 1
|
||||
`, [maestro.id]);
|
||||
|
||||
const ultima = ultimas[0]?.fecha_limite;
|
||||
if (!ultima || startOfDay(ultima) < addMonthsLocal(new Date(), mesesHorizonte - 6)) {
|
||||
const creadas = await generarInstancias(pool, maestro, mesesHorizonte);
|
||||
if (creadas > 0) {
|
||||
seriesProcesadas++;
|
||||
instanciasCreadas += creadas;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { series: seriesProcesadas, instancias: instanciasCreadas };
|
||||
}
|
||||
|
||||
100
apps/api/src/services/sat/proxy.service.ts
Normal file
100
apps/api/src/services/sat/proxy.service.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
|
||||
export interface ProxyConfig {
|
||||
url: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export type ProxyStrategy = 'round-robin' | 'random';
|
||||
|
||||
function parseProxyList(raw: string): ProxyConfig[] {
|
||||
if (!raw.trim()) return [];
|
||||
|
||||
const configs: ProxyConfig[] = [];
|
||||
const items = raw.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const url = new URL(item);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
console.warn(`[ProxyManager] Protocolo no soportado, se omite: ${item}`);
|
||||
continue;
|
||||
}
|
||||
configs.push({
|
||||
url: item,
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80),
|
||||
username: url.username || undefined,
|
||||
password: url.password || undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[ProxyManager] URL de proxy inválida, se omite: ${item}`);
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
export class ProxyManager {
|
||||
private proxies: ProxyConfig[];
|
||||
private strategy: ProxyStrategy;
|
||||
private currentIndex = 0;
|
||||
|
||||
constructor(
|
||||
proxyList = process.env.SAT_PROXY_LIST || '',
|
||||
strategy: ProxyStrategy = (process.env.SAT_PROXY_STRATEGY as ProxyStrategy) || 'round-robin',
|
||||
) {
|
||||
this.proxies = parseProxyList(proxyList);
|
||||
this.strategy = ['round-robin', 'random'].includes(strategy) ? strategy : 'round-robin';
|
||||
|
||||
if (this.proxies.length > 0) {
|
||||
console.log(`[ProxyManager] ${this.proxies.length} proxy(s) configurados (estrategia: ${this.strategy})`);
|
||||
} else {
|
||||
console.log('[ProxyManager] No hay proxies configurados; se usará la IP directa del servidor');
|
||||
}
|
||||
}
|
||||
|
||||
hasProxies(): boolean {
|
||||
return this.proxies.length > 0;
|
||||
}
|
||||
|
||||
getProxyCount(): number {
|
||||
return this.proxies.length;
|
||||
}
|
||||
|
||||
getNextProxy(): ProxyConfig | null {
|
||||
if (this.proxies.length === 0) return null;
|
||||
|
||||
if (this.strategy === 'random') {
|
||||
return this.proxies[Math.floor(Math.random() * this.proxies.length)];
|
||||
}
|
||||
|
||||
const proxy = this.proxies[this.currentIndex];
|
||||
this.currentIndex = (this.currentIndex + 1) % this.proxies.length;
|
||||
return proxy;
|
||||
}
|
||||
|
||||
createAgent(proxy: ProxyConfig): HttpsProxyAgent<string> {
|
||||
return new HttpsProxyAgent(proxy.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un agente con el siguiente proxy del pool.
|
||||
* Útil cuando se quiere una nueva conexión por solicitud.
|
||||
*/
|
||||
createNextAgent(): HttpsProxyAgent<string> | null {
|
||||
const proxy = this.getNextProxy();
|
||||
if (!proxy) return null;
|
||||
console.log(`[ProxyManager] Usando proxy: ${proxy.host}:${proxy.port}`);
|
||||
return this.createAgent(proxy);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Instancia global del proxy manager.
|
||||
* Lee SAT_PROXY_LIST y SAT_PROXY_STRATEGY del entorno.
|
||||
*/
|
||||
export const proxyManager = new ProxyManager();
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DocumentStatus,
|
||||
ServiceEndpoints,
|
||||
} from '@nodecfdi/sat-ws-descarga-masiva';
|
||||
import { proxyManager } from './proxy.service.js';
|
||||
|
||||
export interface FielData {
|
||||
cerContent: string;
|
||||
@@ -17,10 +18,33 @@ export interface FielData {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ProxyInfo {
|
||||
host: string;
|
||||
port: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout explícito para el cliente HTTP del SAT (ms).
|
||||
*
|
||||
* IMPORTANTE: la librería @nodecfdi/sat-ws-descarga-masiva@2.0.0 tiene un bug
|
||||
* en HttpsWebClient: si no se pasa un timeout explícito y ocurre un timeout
|
||||
* de red, rechaza con un `Error` nativo en vez de `WebClientException`.
|
||||
* Eso rompe el manejo de errores posterior y produce
|
||||
* `webError.getResponse is not a function`.
|
||||
*
|
||||
* Al pasar un timeout explícito, `_timeout` queda definido y la librería
|
||||
* envuelve el timeout como `WebClientException`, permitiendo reintentos sanos.
|
||||
*
|
||||
* El endpoint de verificación del SAT suele tardar >30s en responder; 5 minutos
|
||||
* da margen sin dejar la conexión colgada indefinidamente.
|
||||
*/
|
||||
const SAT_WEB_CLIENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutos
|
||||
|
||||
/**
|
||||
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
|
||||
*/
|
||||
export function createSatService(fielData: FielData): Service {
|
||||
export function createSatService(fielData: FielData): { service: Service; proxyInfo: ProxyInfo | null } {
|
||||
// Crear FIEL usando el método estático create
|
||||
const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password);
|
||||
|
||||
@@ -29,14 +53,33 @@ export function createSatService(fielData: FielData): Service {
|
||||
throw new Error('La FIEL no es válida o está vencida');
|
||||
}
|
||||
|
||||
// Crear cliente HTTP
|
||||
const webClient = new HttpsWebClient();
|
||||
// Crear cliente HTTP con timeout explícito para evitar el bug de la librería
|
||||
// cuando ocurre un timeout de red. Si hay proxies configurados, se usa uno
|
||||
// del pool para reducir el riesgo de bloqueo por IP del SAT.
|
||||
const proxy = proxyManager.getNextProxy();
|
||||
const proxyAgent = proxy ? proxyManager.createAgent(proxy) : null;
|
||||
if (proxyAgent) {
|
||||
console.log('[SAT] Usando proxy para la conexión con el SAT');
|
||||
} else {
|
||||
console.log('[SAT] Sin proxy configurado; usando IP directa del servidor');
|
||||
}
|
||||
|
||||
const webClient = new (HttpsWebClient as any)(
|
||||
undefined,
|
||||
undefined,
|
||||
SAT_WEB_CLIENT_TIMEOUT_MS,
|
||||
proxyAgent,
|
||||
);
|
||||
|
||||
// Crear request builder con la FIEL
|
||||
const requestBuilder = new FielRequestBuilder(fiel);
|
||||
|
||||
// Crear y retornar el servicio
|
||||
return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
|
||||
const service = new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
|
||||
return {
|
||||
service,
|
||||
proxyInfo: proxy ? { host: proxy.host, port: proxy.port, url: proxy.url } : null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
@@ -73,10 +116,13 @@ export async function querySat(
|
||||
): Promise<QueryResult> {
|
||||
try {
|
||||
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
|
||||
// a medianoche, dos fechas dentro del mismo día calendario resultan iguales.
|
||||
// Ajustamos fechaFin al día siguiente para evitar el error.
|
||||
// a medianoche en zona horaria de México, dos fechas dentro del mismo día
|
||||
// calendario mexicano resultan iguales. Ajustamos fechaFin al día siguiente
|
||||
// en hora México para evitar el error.
|
||||
let adjustedFechaFin = fechaFin;
|
||||
if (formatDateForSat(fechaInicio) === formatDateForSat(fechaFin)) {
|
||||
if (isSameMexicoDay(fechaInicio, fechaFin)) {
|
||||
// Sumar 24h en ms es suficiente porque formatDateForSat solo usa la fecha
|
||||
// calendaria de México, no la hora.
|
||||
adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
@@ -110,7 +156,30 @@ export async function querySat(
|
||||
statusCode: result.getStatus().getCode().toString(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Query Error]', error);
|
||||
// Errores tipo "EmptyResult (5004)" o "Se han agotado las solicitudes de por vida"
|
||||
// a veces vienen como excepción en vez de resultado aceptado. Los traducimos para
|
||||
// que el llamador los trate como "sin datos / no hay nada más que hacer" en lugar
|
||||
// de error fatal.
|
||||
const raw = error?.message || String(error);
|
||||
const emptyMatch = raw.match(/EmptyResult\s*\(?\s*(5004)\s*\)?/i) || raw.includes('5004');
|
||||
if (emptyMatch) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No se encontró la información',
|
||||
statusCode: '5004',
|
||||
};
|
||||
}
|
||||
|
||||
const exhaustedMatch = raw.includes('Se han agotado las solicitudes de por vida');
|
||||
if (exhaustedMatch) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Se han agotado las solicitudes de por vida para este rango',
|
||||
statusCode: 'exhausted',
|
||||
};
|
||||
}
|
||||
|
||||
console.error('[SAT Query Error]', error?.message, error?.stack || error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || 'Error al realizar consulta',
|
||||
@@ -174,6 +243,7 @@ export async function verifySatRequest(
|
||||
if (entryId === 'Finished') status = 'ready';
|
||||
else if (entryId === 'InProgress') status = 'processing';
|
||||
else if (entryId === 'Accepted') status = 'pending';
|
||||
else if (entryId === 'Unknown' && result.getStatus().getCode().toString() === '404') status = 'failed';
|
||||
else status = 'pending';
|
||||
}
|
||||
|
||||
@@ -183,6 +253,38 @@ export async function verifySatRequest(
|
||||
const statusMsg = result.getStatus().getMessage();
|
||||
const reqValue = statusRequest.getValue();
|
||||
const reqEntry = statusRequest.getEntryId();
|
||||
|
||||
// EmptyResult (5004) o Exhausted (5002, "solicitudes de por vida"): el SAT
|
||||
// aceptó la solicitud pero no generó paquetes (rango sin info) o ya agotamos
|
||||
// las solicitudes de ese rango. Tratarlos como "ready" con 0 paquetes para
|
||||
// NO fallar la etapa ni quemar reintentos — es un resultado benigno.
|
||||
// Se comparan value/entry/mensaje de forma defensiva porque getValue() puede
|
||||
// venir como number o string según la versión de la librería.
|
||||
const codeValueStr = codeRequestValue != null ? String(codeRequestValue) : '';
|
||||
const codeEntryStr = codeRequestEntry != null ? String(codeRequestEntry) : '';
|
||||
const codeMsgStr = codeRequestMessage != null ? String(codeRequestMessage) : '';
|
||||
const isEmptyResult =
|
||||
codeValueStr === '5004' ||
|
||||
codeEntryStr === '5004' ||
|
||||
/EmptyResult/i.test(codeEntryStr) ||
|
||||
/\b5004\b/.test(codeMsgStr);
|
||||
const isExhausted =
|
||||
codeValueStr === '5002' ||
|
||||
/Exhausted/i.test(codeEntryStr) ||
|
||||
/solicitudes de por vida/i.test(codeMsgStr);
|
||||
if (isEmptyResult || isExhausted) {
|
||||
return {
|
||||
success: true,
|
||||
status: 'ready',
|
||||
packageIds: [],
|
||||
totalCfdis: 0,
|
||||
message: isExhausted
|
||||
? 'Se han agotado las solicitudes de por vida para este rango'
|
||||
: 'No se encontró información para el rango solicitado',
|
||||
statusCode,
|
||||
};
|
||||
}
|
||||
|
||||
let message = statusMsg;
|
||||
if (status === 'rejected' || status === 'failed') {
|
||||
const codeReqStr = codeRequestValue
|
||||
@@ -200,7 +302,7 @@ export async function verifySatRequest(
|
||||
statusCode,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Verify Error]', error.message || error);
|
||||
console.error('[SAT Verify Error]', error?.message, error?.stack || error);
|
||||
// Errores de la librería (ej. webError.getResponse is not a function)
|
||||
// no son fallos del SAT — devolver 'pending' para reintentar polling
|
||||
return {
|
||||
@@ -250,8 +352,34 @@ export async function downloadSatPackage(
|
||||
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss).
|
||||
* El SAT requiere hora 00:00:00; cualquier otra hora causa
|
||||
* "Fecha final invalida" / "Fecha inicial invalida".
|
||||
*
|
||||
* IMPORTANTE: las fechas deben interpretarse en la zona horaria de México
|
||||
* (America/Mexico_City) porque el SAT opera en esa zona. El servidor corre
|
||||
* en UTC, así que usamos Intl.DateTimeFormat para obtener los componentes
|
||||
* locales a México.
|
||||
*/
|
||||
function formatDateForSat(date: Date): string {
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
|
||||
const fmt = new Intl.DateTimeFormat('es-MX', {
|
||||
timeZone: 'America/Mexico_City',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
const parts = fmt.formatToParts(date);
|
||||
const get = (type: string) => parts.find(p => p.type === type)?.value || '00';
|
||||
return `${get('year')}-${get('month')}-${get('day')} 00:00:00`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve true si dos fechas (interpretadas en zona horaria de México)
|
||||
* caen en el mismo día calendario.
|
||||
*/
|
||||
function isSameMexicoDay(a: Date, b: Date): boolean {
|
||||
const fmt = new Intl.DateTimeFormat('es-MX', {
|
||||
timeZone: 'America/Mexico_City',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
return fmt.format(a) === fmt.format(b);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
verifySatRequest,
|
||||
downloadSatPackage,
|
||||
type FielData,
|
||||
type ProxyInfo,
|
||||
} from './sat-client.service.js';
|
||||
import { processPackage, processMetadataPackage, extractXmlsFromZip, type CfdiParsed, type CfdiMetadata } from './sat-parser.service.js';
|
||||
import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../../utils/saldo.js';
|
||||
@@ -17,10 +18,24 @@ import type { Pool } from 'pg';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const POLL_INTERVAL_MS = 60000; // 60 segundos
|
||||
const MAX_POLL_ATTEMPTS = 500; // ~8 horas máximo para syncs iniciales grandes
|
||||
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutos entre verificaciones
|
||||
const MAX_POLL_ATTEMPTS = 9; // 9 intentos máximo por solicitud (~45 min total)
|
||||
const DAILY_MAX_POLL_ATTEMPTS = 9; // igual para daily: 9 intentos × 5 min
|
||||
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
|
||||
|
||||
/**
|
||||
* Fecha final segura para consultas al SAT.
|
||||
*
|
||||
* El SAT rechaza fechas futuras e incluso "hoy" en algunos horarios/condiciones,
|
||||
* devolviendo "Fecha final invalida". Usamos el día anterior a medio día UTC,
|
||||
* que al interpretarse en America/Mexico_City siempre cae en "ayer" y evita
|
||||
* tanto fechas futuras como problemas de cambio de día por zona horaria.
|
||||
*/
|
||||
function getYesterdayEnd(): Date {
|
||||
const now = new Date();
|
||||
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1, 12, 0, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Política de retry por tipo de sync.
|
||||
* - `retryAtHours[i]` = horas DESDE startedAt para el retry i+1.
|
||||
@@ -49,6 +64,12 @@ const RETRY_POLICIES: Record<'daily' | 'custom' | 'initial' | 'incremental', {
|
||||
incremental: { maxRetries: 0, retryAtHours: [] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Límite total de intentos para jobs diarios. Incluye el intento original más
|
||||
* retries automáticos (6h/12h) y los retries fijos de 9 AM / 4 PM CDMX.
|
||||
*/
|
||||
const MAX_DAILY_RETRY_ATTEMPTS = 5;
|
||||
|
||||
function getRetryPolicy(job: { type: SatSyncType; isCustomRange: boolean }) {
|
||||
if (job.type === 'initial' && job.isCustomRange) return RETRY_POLICIES.custom;
|
||||
return RETRY_POLICIES[job.type];
|
||||
@@ -71,6 +92,7 @@ function computeNextRetryAt(
|
||||
interface SyncContext {
|
||||
fielData: FielData;
|
||||
service: Service;
|
||||
proxyInfo: ProxyInfo | null;
|
||||
rfc: string;
|
||||
tenantId: string;
|
||||
databaseName: string;
|
||||
@@ -78,6 +100,33 @@ interface SyncContext {
|
||||
getPool: () => Promise<Pool>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra un error de bloqueo/devolución del SAT junto con el proxy usado.
|
||||
* Permite reportes diarios de errores por IP.
|
||||
*/
|
||||
async function recordSatProxyError(
|
||||
jobId: string,
|
||||
ctx: SyncContext,
|
||||
errorCode: string | null,
|
||||
stage: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const proxyUsed = ctx.proxyInfo ? `${ctx.proxyInfo.host}:${ctx.proxyInfo.port}` : null;
|
||||
await prisma.satProxyError.create({
|
||||
data: {
|
||||
jobId,
|
||||
proxyUsed,
|
||||
errorCode,
|
||||
stage,
|
||||
message,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[SAT] Error guardando sat_proxy_error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza el progreso de un job
|
||||
*/
|
||||
@@ -97,6 +146,7 @@ async function updateJobProgress(
|
||||
completedAt: Date;
|
||||
retryCount: number;
|
||||
nextRetryAt: Date;
|
||||
proxyUsed: string | null;
|
||||
}>
|
||||
): Promise<void> {
|
||||
await prisma.satSyncJob.update({
|
||||
@@ -598,6 +648,7 @@ async function requestAndDownload(
|
||||
fechaFin: Date,
|
||||
tipoCfdi: CfdiSyncType,
|
||||
requestType: 'cfdi' | 'metadata',
|
||||
isDaily = false,
|
||||
): Promise<{ packageContents: string[]; totalCfdis: number }> {
|
||||
const label = `${tipoCfdi}/${requestType}`;
|
||||
const kindKey = makeRequestKindKey(fechaInicio, fechaFin, tipoCfdi, requestType);
|
||||
@@ -650,6 +701,10 @@ async function requestAndDownload(
|
||||
|
||||
// Estados terminales inválidos → descartar y crear nuevo
|
||||
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
|
||||
if (isAgotadas(verifyResult.message)) {
|
||||
console.log(`[SAT] Solicitud reusada agotada de por vida (${label}); se cancela y se omite, no se recrea.`);
|
||||
return { packageContents: [], totalCfdis: 0 };
|
||||
}
|
||||
console.log(`[SAT] Request reusado en estado ${verifyResult.status}, creando nuevo`);
|
||||
requestId = null;
|
||||
verifyResult = undefined;
|
||||
@@ -670,10 +725,22 @@ async function requestAndDownload(
|
||||
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi, requestType);
|
||||
|
||||
if (!queryResult.success) {
|
||||
if (queryResult.statusCode === '5004') {
|
||||
console.log(`[SAT] No se encontraron CFDIs (${label})`);
|
||||
if (queryResult.statusCode === '5004' || queryResult.statusCode === 'exhausted' || isAgotadas(queryResult.message)) {
|
||||
console.log(`[SAT] Sin CFDIs, quota agotada o solicitudes agotadas (${label}): ${queryResult.message}`);
|
||||
return { packageContents: [], totalCfdis: 0 };
|
||||
}
|
||||
if (/error no controlado/i.test(queryResult.message || '')) {
|
||||
await recordSatProxyError(jobId, ctx, queryResult.statusCode || '404', stageIdForTimeout(label), queryResult.message || 'Error no controlado');
|
||||
if (isDaily) {
|
||||
// En daily no detenemos el job por un 404 transitorio del SAT; se
|
||||
// registra como no fatal para diagnóstico y se continúa.
|
||||
console.warn(`[SAT] Rechazo 404 del SAT en daily (${label}): ${queryResult.message} — se registra y continúa`);
|
||||
throw new Error(`SAT 404 en daily (${label}): ${queryResult.message}`);
|
||||
}
|
||||
console.warn(`[SAT] Rechazo transitorio del SAT (${label}): ${queryResult.message} — se reintentará`);
|
||||
throw new SatTransientError(stageIdForTimeout(label), queryResult.message);
|
||||
}
|
||||
await recordSatProxyError(jobId, ctx, queryResult.statusCode || null, stageIdForTimeout(label), queryResult.message || 'Error SAT');
|
||||
throw new Error(`Error SAT (${label}): ${queryResult.message}`);
|
||||
}
|
||||
|
||||
@@ -685,23 +752,29 @@ async function requestAndDownload(
|
||||
|
||||
// Polling — si el reuse ya devolvió `ready`, salta el loop directamente.
|
||||
if (!verifyResult || verifyResult.status !== 'ready') {
|
||||
const maxAttempts = isDaily ? DAILY_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS;
|
||||
let attempts = 0;
|
||||
while (attempts < MAX_POLL_ATTEMPTS) {
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
attempts++;
|
||||
|
||||
verifyResult = await verifySatRequest(ctx.service, requestId);
|
||||
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts})`);
|
||||
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts})`);
|
||||
|
||||
if (verifyResult.status === 'ready') break;
|
||||
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
|
||||
if (isAgotadas(verifyResult.message)) {
|
||||
console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite este rango.`);
|
||||
return { packageContents: [], totalCfdis: 0 };
|
||||
}
|
||||
await recordSatProxyError(jobId, ctx, verifyResult.status, stageIdForTimeout(label), verifyResult.message || `Solicitud ${verifyResult.status}`);
|
||||
throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!verifyResult || verifyResult.status !== 'ready') {
|
||||
throw new Error(`Timeout esperando respuesta del SAT (${label})`);
|
||||
throw new SatSyncTimeoutError(stageIdForTimeout(label), `Timeout esperando respuesta del SAT (${label})`);
|
||||
}
|
||||
|
||||
const packageContents: string[] = [];
|
||||
@@ -741,17 +814,20 @@ async function processDateRange(
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date,
|
||||
tipoCfdi: CfdiSyncType,
|
||||
skipJobUpdate = false
|
||||
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
|
||||
skipJobUpdate = false,
|
||||
throwOnError = false,
|
||||
isDaily = false
|
||||
): Promise<{ found: number; downloaded: number; inserted: number; updated: number; errors: { message: string }[] }> {
|
||||
let totalFound = 0;
|
||||
let totalDownloaded = 0;
|
||||
let totalInserted = 0;
|
||||
let totalUpdated = 0;
|
||||
const errors: { message: string }[] = [];
|
||||
|
||||
// Solo XMLs de vigentes (datos completos)
|
||||
try {
|
||||
const { packageContents, totalCfdis } = await requestAndDownload(
|
||||
ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'cfdi'
|
||||
ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'cfdi', isDaily
|
||||
);
|
||||
totalFound += totalCfdis;
|
||||
|
||||
@@ -766,6 +842,8 @@ async function processDateRange(
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`);
|
||||
if (throwOnError) throw error;
|
||||
errors.push({ message: error.message || `Error desconocido en XMLs ${tipoCfdi}` });
|
||||
}
|
||||
|
||||
if (!skipJobUpdate) {
|
||||
@@ -782,6 +860,7 @@ async function processDateRange(
|
||||
downloaded: totalDownloaded,
|
||||
inserted: totalInserted,
|
||||
updated: totalUpdated,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -794,14 +873,17 @@ async function processMetadataRange(
|
||||
jobId: string,
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date,
|
||||
tipoCfdi: CfdiSyncType
|
||||
): Promise<{ inserted: number; updated: number }> {
|
||||
tipoCfdi: CfdiSyncType,
|
||||
throwOnError = false,
|
||||
isDaily = false
|
||||
): Promise<{ inserted: number; updated: number; errors: { message: string }[] }> {
|
||||
let totalInserted = 0;
|
||||
let totalUpdated = 0;
|
||||
const errors: { message: string }[] = [];
|
||||
|
||||
try {
|
||||
const { packageContents } = await requestAndDownload(
|
||||
ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'metadata'
|
||||
ctx, jobId, fechaInicio, fechaFin, tipoCfdi, 'metadata', isDaily
|
||||
);
|
||||
|
||||
for (const content of packageContents) {
|
||||
@@ -814,9 +896,11 @@ async function processMetadataRange(
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error en metadata ${tipoCfdi}: ${error.message}`);
|
||||
if (throwOnError) throw error;
|
||||
errors.push({ message: error.message || `Error desconocido en metadata ${tipoCfdi}` });
|
||||
}
|
||||
|
||||
return { inserted: totalInserted, updated: totalUpdated };
|
||||
return { inserted: totalInserted, updated: totalUpdated, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -904,11 +988,13 @@ async function processInitialSync(
|
||||
customDateFrom?: Date,
|
||||
customDateTo?: Date
|
||||
): Promise<void> {
|
||||
const ahora = new Date();
|
||||
// Exactamente 6 años atrás desde hoy (mismo día del mes), no inicio de mes.
|
||||
// El SAT rechaza fechas futuras; por defecto usamos ayer como fecha final.
|
||||
// Si el usuario pasó un rango explícito lo respetamos (aunque podría fallar
|
||||
// si pone "hoy").
|
||||
const fechaFin = customDateTo || getYesterdayEnd();
|
||||
// Exactamente 6 años atrás desde la fecha final (mismo día del mes), no inicio de mes.
|
||||
// El SAT rechaza "mayor a 6 años" si usamos el día 1 del mes hace 6 años.
|
||||
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), ahora.getDate());
|
||||
const fechaFin = customDateTo || ahora;
|
||||
const inicioHistorico = customDateFrom || new Date(fechaFin.getFullYear() - YEARS_TO_SYNC, fechaFin.getMonth(), fechaFin.getDate());
|
||||
|
||||
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
|
||||
const chunkMonths = await determineChunkMonths(ctx, jobId, inicioHistorico, fechaFin);
|
||||
@@ -1098,7 +1184,9 @@ async function processCustomRangeSync(
|
||||
const INCREMENTAL_WINDOW_HOURS = 8;
|
||||
|
||||
async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<void> {
|
||||
const ahora = new Date();
|
||||
// Retrocedemos 2h respecto a ahora para evitar que el SAT vea una fecha final
|
||||
// futura / demasiado reciente (rechazo "Fecha final invalida").
|
||||
const ahora = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
const desde = new Date(ahora.getTime() - INCREMENTAL_WINDOW_HOURS * 60 * 60 * 1000);
|
||||
|
||||
let totalFound = 0;
|
||||
@@ -1109,25 +1197,17 @@ async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<
|
||||
console.log(`[SAT] Incremental: ${desde.toISOString()} → ${ahora.toISOString()} (${INCREMENTAL_WINDOW_HOURS}h)`);
|
||||
|
||||
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
||||
try {
|
||||
const result = await processDateRange(ctx, jobId, desde, ahora, tipo);
|
||||
totalFound += result.found;
|
||||
totalDownloaded += result.downloaded;
|
||||
totalInserted += result.inserted;
|
||||
totalUpdated += result.updated;
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error incremental XMLs ${tipo}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
||||
try {
|
||||
const { inserted, updated } = await processMetadataRange(ctx, jobId, desde, ahora, tipo);
|
||||
totalInserted += inserted;
|
||||
totalUpdated += updated;
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error incremental metadata ${tipo}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
await updateJobProgress(jobId, {
|
||||
@@ -1138,51 +1218,310 @@ async function processIncrementalSync(ctx: SyncContext, jobId: string): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
async function processDailySync(ctx: SyncContext, jobId: string): Promise<void> {
|
||||
const ahora = new Date();
|
||||
/**
|
||||
* Error usado para señalar que una etapa del sync diario excedió el tiempo
|
||||
* de espera al SAT. Lleva el identificador de la etapa para poder retomar
|
||||
* desde el mismo punto en los retries programados.
|
||||
*/
|
||||
class SatSyncTimeoutError extends Error {
|
||||
constructor(
|
||||
public readonly stageId: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SatSyncTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechazo transitorio del SAT (p. ej. "Error no controlado", típico de
|
||||
* throttling cuando se lanzan muchas solicitudes en ráfaga). A diferencia de
|
||||
* "solicitudes agotadas", SÍ vale la pena reintentarlo: se comporta como un
|
||||
* timeout — se estaciona la etapa y los retries de 9 AM / 4 PM la retoman.
|
||||
*/
|
||||
class SatTransientError extends Error {
|
||||
constructor(
|
||||
public readonly stageId: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SatTransientError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* El daily terminó sus etapas XML y parte de las de metadata, pero uno o más
|
||||
* chunks de metadata aún no tienen paquetes listos. No es un fallo del SAT:
|
||||
* el requestId ya quedó persistido y los retries de 9 AM / 4 PM lo retoman.
|
||||
* Se trata como "pending" (no consume el job entero) en vez de abortar.
|
||||
*/
|
||||
class SatMetadataPendingError extends Error {
|
||||
constructor(
|
||||
public readonly stageId: string,
|
||||
public readonly pendingStages: string[],
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SatMetadataPendingError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta la respuesta del SAT "Se han agotado las solicitudes de por vida":
|
||||
* se agotaron las solicitudes máximas para ese rango de fecha. No es
|
||||
* transitorio — no vale la pena seguir intentando ni recrear la solicitud;
|
||||
* hay que cancelarla y omitir ese rango.
|
||||
*/
|
||||
function isAgotadas(message?: string | null): boolean {
|
||||
return !!message && message.toLowerCase().includes('agotad');
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un stageId a partir de la etiqueta de una solicitud SAT.
|
||||
* Usado cuando requestAndDownload detecta timeout y no conoce el stage exacto.
|
||||
*/
|
||||
function stageIdForTimeout(label: string): string {
|
||||
// label tiene forma "tipoCfdi/requestType" (ej. "emitidos/cfdi" o "recibidos/metadata")
|
||||
const [tipo, requestType] = label.split('/');
|
||||
if (requestType === 'cfdi') return `xml-${tipo}-7d`;
|
||||
return `metadata-${tipo}-chunk`;
|
||||
}
|
||||
|
||||
function isSundayInCDMX(now: Date = new Date()): boolean {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/Mexico_City',
|
||||
weekday: 'short',
|
||||
}).format(now) === 'Sun';
|
||||
}
|
||||
|
||||
async function processDailySync(
|
||||
ctx: SyncContext,
|
||||
jobId: string,
|
||||
resumeFromStage?: string
|
||||
): Promise<void> {
|
||||
// Usamos ayer como fecha final para evitar el rechazo "Fecha final invalida"
|
||||
// del SAT cuando se consulta con la fecha actual.
|
||||
const ahora = getYesterdayEnd();
|
||||
const inicioAño = new Date(ahora.getFullYear(), 0, 1);
|
||||
const hace7Dias = new Date(ahora.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
const ejecutarMetadataHistorica = isSundayInCDMX();
|
||||
|
||||
let totalFound = 0;
|
||||
let totalDownloaded = 0;
|
||||
let totalInserted = 0;
|
||||
let totalUpdated = 0;
|
||||
|
||||
// Paso 1: XMLs de los últimos 7 días (CFDIs nuevos)
|
||||
console.log(`[SAT] Daily: XMLs desde ${hace7Dias.toISOString().slice(0, 10)} → ${ahora.toISOString().slice(0, 10)}`);
|
||||
interface DailyStage {
|
||||
id: string;
|
||||
label: string;
|
||||
isMetadata: boolean;
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
||||
try {
|
||||
const result = await processDateRange(ctx, jobId, hace7Dias, ahora, tipo);
|
||||
const makeStage = (
|
||||
id: string,
|
||||
label: string,
|
||||
isMetadata: boolean,
|
||||
runImpl: (stageId: string) => Promise<void>,
|
||||
): DailyStage => ({ id, label, isMetadata, run: () => runImpl(id) });
|
||||
|
||||
const stages: DailyStage[] = [
|
||||
makeStage('xml-emitidos-7d', 'XMLs emitidos últimos 7 días', false, async (id) => {
|
||||
const result = await processDateRange(ctx, jobId, hace7Dias, ahora, 'emitidos', true, false, true);
|
||||
totalFound += result.found;
|
||||
totalDownloaded += result.downloaded;
|
||||
totalInserted += result.inserted;
|
||||
totalUpdated += result.updated;
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error XMLs ${tipo} (7 días):`, error.message);
|
||||
if (result.errors.length > 0) {
|
||||
for (const e of result.errors) nonFatalErrors.push({ stage: id, message: e.message });
|
||||
}
|
||||
}),
|
||||
makeStage('xml-recibidos-7d', 'XMLs recibidos últimos 7 días', false, async (id) => {
|
||||
const result = await processDateRange(ctx, jobId, hace7Dias, ahora, 'recibidos', true, false, true);
|
||||
totalFound += result.found;
|
||||
totalDownloaded += result.downloaded;
|
||||
totalInserted += result.inserted;
|
||||
totalUpdated += result.updated;
|
||||
if (result.errors.length > 0) {
|
||||
for (const e of result.errors) nonFatalErrors.push({ stage: id, message: e.message });
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
// Paso 2: Metadata del ciclo fiscal actual (enero → hoy)
|
||||
// Captura cancelaciones y cambios de status del año completo
|
||||
console.log(`[SAT] Daily: Metadata desde ${inicioAño.toISOString().slice(0, 10)} → ${ahora.toISOString().slice(0, 10)}`);
|
||||
|
||||
for (const tipo of ['emitidos', 'recibidos'] as const) {
|
||||
try {
|
||||
const { inserted, updated } = await processMetadataRange(ctx, jobId, inicioAño, ahora, tipo);
|
||||
// La metadata histórica (desde inicio de año) consume muchas solicitudes al SAT
|
||||
// y no cambia día a día. Solo la ejecutamos los domingos para reducir carga.
|
||||
if (ejecutarMetadataHistorica) {
|
||||
const metaChunks = generateChunks(inicioAño, ahora, 3);
|
||||
// Recorrer de más nuevo a más viejo: si un bloque antiguo da timeout, los
|
||||
// recientes (más relevantes) ya quedaron procesados y no bloquean el avance.
|
||||
for (let i = metaChunks.length - 1; i >= 0; i--) {
|
||||
const { start, end } = metaChunks[i];
|
||||
const chunkLabel = `${start.toISOString().slice(0, 10)}_${end.toISOString().slice(0, 10)}`;
|
||||
stages.push(makeStage(
|
||||
`metadata-emitidos-${chunkLabel}`,
|
||||
`Metadata emitidos ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`,
|
||||
true,
|
||||
async (id) => {
|
||||
const { inserted, updated, errors } = await processMetadataRange(ctx, jobId, start, end, 'emitidos', false, true);
|
||||
totalInserted += inserted;
|
||||
totalUpdated += updated;
|
||||
if (errors.length > 0) {
|
||||
for (const e of errors) nonFatalErrors.push({ stage: id, message: e.message });
|
||||
}
|
||||
},
|
||||
));
|
||||
stages.push(makeStage(
|
||||
`metadata-recibidos-${chunkLabel}`,
|
||||
`Metadata recibidos ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`,
|
||||
true,
|
||||
async (id) => {
|
||||
const { inserted, updated, errors } = await processMetadataRange(ctx, jobId, start, end, 'recibidos', false, true);
|
||||
totalInserted += inserted;
|
||||
totalUpdated += updated;
|
||||
if (errors.length > 0) {
|
||||
for (const e of errors) nonFatalErrors.push({ stage: id, message: e.message });
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const totalStages = stages.length;
|
||||
let activeStageIndex = 0;
|
||||
if (resumeFromStage) {
|
||||
const idx = stages.findIndex(s => s.id === resumeFromStage);
|
||||
if (idx >= 0) {
|
||||
activeStageIndex = idx;
|
||||
console.log(`[SAT Daily] Retomando desde etapa ${resumeFromStage} (${idx + 1}/${totalStages})`);
|
||||
} else {
|
||||
console.log(`[SAT Daily] No se encontró etapa ${resumeFromStage}, iniciando desde el principio`);
|
||||
activeStageIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const pendingStages: string[] = [];
|
||||
const nonFatalErrors: { stage: string; message: string }[] = [];
|
||||
|
||||
for (let i = activeStageIndex; i < totalStages; i++) {
|
||||
const stage = stages[i];
|
||||
console.log(`[SAT Daily] Etapa ${i + 1}/${totalStages}: ${stage.label}`);
|
||||
try {
|
||||
await stage.run();
|
||||
const progressPercent = totalStages > 0 ? Math.round(((i + 1) / totalStages) * 100) : 0;
|
||||
await updateJobProgress(jobId, {
|
||||
cfdisFound: totalFound,
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
progressPercent,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error metadata ${tipo} (ciclo fiscal):`, error.message);
|
||||
const retryableErr = error instanceof SatSyncTimeoutError || error instanceof SatTransientError
|
||||
? error
|
||||
: (error.message?.includes('Timeout') ? new SatSyncTimeoutError(stage.id, error.message) : null);
|
||||
|
||||
// Errores transitorios (timeout, metadata aún no lista) se estacionan para
|
||||
// reintento; no se pierden requestIds ya creados.
|
||||
if (retryableErr && stage.isMetadata) {
|
||||
console.warn(`[SAT Daily] Etapa ${stage.id} sin paquetes listos; se estaciona y se continúa con las demás.`);
|
||||
pendingStages.push(stage.id);
|
||||
continue;
|
||||
}
|
||||
if (retryableErr) {
|
||||
throw retryableErr;
|
||||
}
|
||||
|
||||
// Errores no transitorios (404 Error no controlado, etc.) no abortan el
|
||||
// daily. Se registran para diagnóstico y se continúa con las demás etapas.
|
||||
console.error(`[SAT Daily] Etapa ${stage.id} falló (no transitorio): ${error.message}`);
|
||||
nonFatalErrors.push({ stage: stage.id, message: error.message });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = nonFatalErrors.length > 0
|
||||
? JSON.stringify({ completedWithWarnings: true, errors: nonFatalErrors })
|
||||
: undefined;
|
||||
|
||||
await updateJobProgress(jobId, {
|
||||
cfdisFound: totalFound,
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
progressPercent: 100,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
if (pendingStages.length > 0) {
|
||||
throw new SatMetadataPendingError(
|
||||
pendingStages[0],
|
||||
pendingStages,
|
||||
`Metadata no lista en ${pendingStages.length} chunk(s): ${pendingStages.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica y descarga un requestId existente, procesando los paquetes resultantes.
|
||||
* Similar a processMetadataRange pero recibiendo el requestId explícito.
|
||||
*/
|
||||
async function requestAndDownloadWithId(
|
||||
ctx: SyncContext,
|
||||
jobId: string,
|
||||
requestId: string,
|
||||
tipoCfdi: 'emitidos' | 'recibidos',
|
||||
requestType: 'metadata',
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date,
|
||||
isDaily = false,
|
||||
): Promise<{ inserted: number; updated: number }> {
|
||||
let totalInserted = 0;
|
||||
let totalUpdated = 0;
|
||||
const label = `${tipoCfdi}/${requestType} ${fechaInicio.toISOString().slice(0, 10)} → ${fechaFin.toISOString().slice(0, 10)}`;
|
||||
|
||||
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;
|
||||
let attempts = 0;
|
||||
const maxAttempts = isDaily ? DAILY_MAX_POLL_ATTEMPTS : MAX_POLL_ATTEMPTS;
|
||||
while (attempts < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
attempts++;
|
||||
|
||||
verifyResult = await verifySatRequest(ctx.service, requestId);
|
||||
console.log(`[SAT] Estado ${label}: ${verifyResult.status} (intento ${attempts}/${maxAttempts})`);
|
||||
|
||||
if (verifyResult.status === 'ready') break;
|
||||
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
|
||||
if (isAgotadas(verifyResult.message)) {
|
||||
console.log(`[SAT] Solicitudes agotadas de por vida (${label}); se cancela y se omite.`);
|
||||
return { inserted: 0, updated: 0 };
|
||||
}
|
||||
throw new Error(`Solicitud fallida (${label}): ${verifyResult.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!verifyResult || verifyResult.status !== 'ready') {
|
||||
throw new SatSyncTimeoutError(stageIdForTimeout(label), `Timeout esperando respuesta del SAT (${label})`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < verifyResult.packageIds.length; i++) {
|
||||
const packageId = verifyResult.packageIds[i];
|
||||
console.log(`[SAT] Descargando paquete ${label} ${i + 1}/${verifyResult.packageIds.length}: ${packageId}`);
|
||||
|
||||
const downloadResult = await downloadSatPackage(ctx.service, packageId);
|
||||
if (!downloadResult.success) {
|
||||
console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const items = processMetadataPackage(downloadResult.packageContent, tipoCfdi);
|
||||
console.log(`[SAT] Procesando ${items.length} registros de metadata ${tipoCfdi}`);
|
||||
|
||||
const { inserted, updated } = await saveMetadata(await ctx.getPool(), items, jobId, ctx.contribuyenteId);
|
||||
totalInserted += inserted;
|
||||
totalUpdated += updated;
|
||||
}
|
||||
|
||||
return { inserted: totalInserted, updated: totalUpdated };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1217,7 +1556,7 @@ export async function startSync(
|
||||
password: decryptedFiel.password,
|
||||
};
|
||||
|
||||
const service = createSatService(fielData);
|
||||
const { service, proxyInfo } = createSatService(fielData);
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
@@ -1258,12 +1597,14 @@ export async function startSync(
|
||||
dateTo: dateTo || now,
|
||||
startedAt: now,
|
||||
isCustomRange,
|
||||
proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null,
|
||||
},
|
||||
});
|
||||
|
||||
const ctx: SyncContext = {
|
||||
fielData,
|
||||
service,
|
||||
proxyInfo,
|
||||
rfc: decryptedFiel.rfc,
|
||||
tenantId,
|
||||
databaseName: tenant.databaseName,
|
||||
@@ -1294,25 +1635,50 @@ export async function startSync(
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error en sincronización ${job.id}:`, error);
|
||||
|
||||
const isTimeout = error.message?.includes('Timeout');
|
||||
const isMetadataPending = error instanceof SatMetadataPendingError;
|
||||
const isSatTimeout = error instanceof SatSyncTimeoutError;
|
||||
const isTransient = error instanceof SatTransientError;
|
||||
const isTimeout = isSatTimeout || isMetadataPending || isTransient || error.message?.includes('Timeout');
|
||||
const currentRetries = job.retryCount || 0;
|
||||
const policy = getRetryPolicy(job);
|
||||
const nextRetryNumber = currentRetries + 1;
|
||||
const nextRetry = isTimeout && nextRetryNumber <= policy.maxRetries
|
||||
? computeNextRetryAt(job.startedAt!, nextRetryNumber, policy)
|
||||
// Para daily permitimos hasta MAX_DAILY_RETRY_ATTEMPTS intentos totales.
|
||||
// Los primeros usan la política automática (6h/12h); los restantes los
|
||||
// recogen los crons fijos de 9 AM / 4 PM CDMX.
|
||||
const maxAttempts = job.type === 'daily' ? MAX_DAILY_RETRY_ATTEMPTS : policy.maxRetries;
|
||||
const hasAttemptsLeft = isTimeout && nextRetryNumber <= maxAttempts;
|
||||
// B: anclar la política a createdAt (inmutable) en vez de startedAt,
|
||||
// porque startedAt se resetea en cada retry para que el watchdog mida
|
||||
// el intento actual y no mate retries legítimos en vuelo.
|
||||
const nextRetry = hasAttemptsLeft
|
||||
? (computeNextRetryAt(job.createdAt, nextRetryNumber, policy) ?? null)
|
||||
: null;
|
||||
|
||||
if (nextRetry) {
|
||||
// Para timeouts/rechazos transitorios del daily, persistimos la etapa para retomar en retries programados.
|
||||
const progressErrorMessage = isMetadataPending
|
||||
? JSON.stringify({ stage: error.stageId, pendingStages: error.pendingStages, message: error.message })
|
||||
: isSatTimeout || isTransient
|
||||
? JSON.stringify({ stage: error.stageId, message: error.message })
|
||||
: undefined;
|
||||
|
||||
if (hasAttemptsLeft) {
|
||||
const retryLabel = nextRetry
|
||||
? nextRetry.toLocaleString('es-MX')
|
||||
: 'próxima ventana 9 AM / 4 PM CDMX';
|
||||
await updateJobProgress(job.id, {
|
||||
status: 'pending',
|
||||
errorMessage: `Timeout (intento ${nextRetryNumber}/${policy.maxRetries}). Reintento programado para ${nextRetry.toLocaleString('es-MX')}.`,
|
||||
errorMessage: progressErrorMessage ?? `Timeout (intento ${nextRetryNumber}/${maxAttempts}). Reintento programado para ${retryLabel}.`,
|
||||
retryCount: nextRetryNumber,
|
||||
nextRetryAt: nextRetry,
|
||||
nextRetryAt: nextRetry ?? null as any,
|
||||
});
|
||||
console.log(`[SAT] Job ${job.id} programado para reintento ${nextRetryNumber}/${policy.maxRetries} a las ${nextRetry.toLocaleString('es-MX')}`);
|
||||
console.log(`[SAT] Job ${job.id} programado para reintento ${nextRetryNumber}/${maxAttempts} (${retryLabel})`);
|
||||
} else {
|
||||
// Sin reintentos restantes, error no-timeout, o policy con maxRetries=0 (incremental)
|
||||
const finalMsg = isTimeout
|
||||
const finalMsg = isMetadataPending
|
||||
? progressErrorMessage
|
||||
: isSatTimeout
|
||||
? progressErrorMessage
|
||||
: isTimeout
|
||||
? policy.maxRetries === 0
|
||||
? 'Timeout en sync incremental — sin reintentos por política. Próximo cron incremental cubrirá el gap.'
|
||||
: 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.'
|
||||
@@ -1390,7 +1756,7 @@ export async function retryTimedOutJobs(): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const service = createSatService({
|
||||
const { service, proxyInfo } = createSatService({
|
||||
cerContent: decryptedFiel.cerContent,
|
||||
keyContent: decryptedFiel.keyContent,
|
||||
password: decryptedFiel.password,
|
||||
@@ -1403,6 +1769,7 @@ export async function retryTimedOutJobs(): Promise<void> {
|
||||
password: decryptedFiel.password,
|
||||
},
|
||||
service,
|
||||
proxyInfo,
|
||||
rfc: decryptedFiel.rfc,
|
||||
tenantId: job.tenantId,
|
||||
databaseName: job.tenant.databaseName,
|
||||
@@ -1410,7 +1777,23 @@ export async function retryTimedOutJobs(): Promise<void> {
|
||||
getPool: () => tenantDb.getPool(job.tenantId, job.tenant.databaseName),
|
||||
};
|
||||
|
||||
await updateJobProgress(job.id, { status: 'running', errorMessage: null as any });
|
||||
// B: resetear startedAt al inicio de ESTE intento para que el watchdog
|
||||
// mida el intento actual (y no mate un retry legítimo por el startedAt
|
||||
// original del job). La política de retries se ancla a createdAt.
|
||||
await updateJobProgress(job.id, { status: 'running', errorMessage: null as any, startedAt: new Date(), proxyUsed: proxyInfo ? `${proxyInfo.host}:${proxyInfo.port}` : null });
|
||||
|
||||
// Para jobs daily, intentamos retomar desde la última etapa completada.
|
||||
let resumeFromStage: string | undefined;
|
||||
if (job.type === 'daily' && job.errorMessage) {
|
||||
try {
|
||||
const parsed = JSON.parse(job.errorMessage);
|
||||
if (typeof parsed.stage === 'string') {
|
||||
resumeFromStage = parsed.stage;
|
||||
}
|
||||
} catch {
|
||||
// errorMessage no es JSON, ignorar
|
||||
}
|
||||
}
|
||||
|
||||
// Re-ejecutar según tipo original
|
||||
try {
|
||||
@@ -1419,7 +1802,7 @@ export async function retryTimedOutJobs(): Promise<void> {
|
||||
} else if (job.type === 'incremental') {
|
||||
await processIncrementalSync(ctx, job.id);
|
||||
} else {
|
||||
await processDailySync(ctx, job.id);
|
||||
await processDailySync(ctx, job.id, resumeFromStage);
|
||||
}
|
||||
|
||||
await updateJobProgress(job.id, {
|
||||
@@ -1432,24 +1815,44 @@ export async function retryTimedOutJobs(): Promise<void> {
|
||||
} catch (retryError: any) {
|
||||
console.error(`[SAT Retry] Job ${job.id} falló de nuevo:`, retryError.message);
|
||||
|
||||
const isTimeout = retryError.message?.includes('Timeout');
|
||||
const isMetadataPending = retryError instanceof SatMetadataPendingError;
|
||||
const isSatTimeout = retryError instanceof SatSyncTimeoutError;
|
||||
const isTransient = retryError instanceof SatTransientError;
|
||||
const isTimeout = isSatTimeout || isMetadataPending || isTransient || retryError.message?.includes('Timeout');
|
||||
const policy = getRetryPolicy(job);
|
||||
const nextRetryNumber = job.retryCount + 1;
|
||||
const nextRetry = isTimeout && nextRetryNumber <= policy.maxRetries
|
||||
? computeNextRetryAt(job.startedAt!, nextRetryNumber, policy)
|
||||
// Para daily permitimos hasta MAX_DAILY_RETRY_ATTEMPTS intentos totales.
|
||||
const maxAttempts = job.type === 'daily' ? MAX_DAILY_RETRY_ATTEMPTS : policy.maxRetries;
|
||||
const hasAttemptsLeft = isTimeout && nextRetryNumber <= maxAttempts;
|
||||
// B: política anclada a createdAt (startedAt se resetea por intento).
|
||||
const nextRetry = hasAttemptsLeft
|
||||
? (computeNextRetryAt(job.createdAt, nextRetryNumber, policy) ?? null)
|
||||
: null;
|
||||
|
||||
if (nextRetry) {
|
||||
const progressErrorMessage = isMetadataPending
|
||||
? JSON.stringify({ stage: retryError.stageId, pendingStages: retryError.pendingStages, message: retryError.message })
|
||||
: isSatTimeout || isTransient
|
||||
? JSON.stringify({ stage: retryError.stageId, message: retryError.message })
|
||||
: undefined;
|
||||
|
||||
if (hasAttemptsLeft) {
|
||||
const retryLabel = nextRetry
|
||||
? nextRetry.toLocaleString('es-MX')
|
||||
: 'próxima ventana 9 AM / 4 PM CDMX';
|
||||
await updateJobProgress(job.id, {
|
||||
status: 'pending',
|
||||
errorMessage: `Timeout (intento ${nextRetryNumber}/${policy.maxRetries}). Reintento programado para ${nextRetry.toLocaleString('es-MX')}.`,
|
||||
errorMessage: progressErrorMessage ?? `Timeout (intento ${nextRetryNumber}/${maxAttempts}). Reintento programado para ${retryLabel}.`,
|
||||
retryCount: nextRetryNumber,
|
||||
nextRetryAt: nextRetry,
|
||||
nextRetryAt: nextRetry ?? null as any,
|
||||
});
|
||||
} else {
|
||||
await updateJobProgress(job.id, {
|
||||
status: 'failed',
|
||||
errorMessage: isTimeout
|
||||
errorMessage: isMetadataPending
|
||||
? progressErrorMessage
|
||||
: isSatTimeout
|
||||
? progressErrorMessage
|
||||
: isTimeout
|
||||
? 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.'
|
||||
: retryError.message,
|
||||
completedAt: new Date(),
|
||||
@@ -1467,6 +1870,154 @@ export async function retryTimedOutJobs(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retoma jobs diarios que quedaron pending por timeout de polling, sin depender
|
||||
* de nextRetryAt. Usado por los cron fijos de 9:00 AM y 4:00 PM CDMX.
|
||||
* Hasta MAX_DAILY_RETRY_ATTEMPTS intentos en total.
|
||||
*/
|
||||
export async function continuePendingDailyRequests(): Promise<void> {
|
||||
const pendingJobs = await prisma.satSyncJob.findMany({
|
||||
where: {
|
||||
status: 'pending',
|
||||
type: 'daily',
|
||||
retryCount: { lt: MAX_DAILY_RETRY_ATTEMPTS },
|
||||
},
|
||||
include: { tenant: { select: { id: true, databaseName: true, rfc: true } } },
|
||||
});
|
||||
|
||||
if (pendingJobs.length === 0) {
|
||||
console.log('[SAT Daily Retry] No hay jobs diarios pendientes');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[SAT Daily Retry] ${pendingJobs.length} job(s) diarios pendientes`);
|
||||
|
||||
for (const job of pendingJobs) {
|
||||
try {
|
||||
const activeSync = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId: job.tenantId,
|
||||
contribuyenteId: job.contribuyenteId ?? null,
|
||||
status: 'running',
|
||||
},
|
||||
});
|
||||
|
||||
if (activeSync) {
|
||||
console.log(`[SAT Daily Retry] (${job.tenant.rfc}, contrib=${job.contribuyenteId || 'tenant-wide'}) tiene sync activo, posponiendo`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[SAT Daily Retry] Reintentando job ${job.id} (${job.tenant.rfc}), intento ${(job.retryCount || 0) + 1}/${MAX_DAILY_RETRY_ATTEMPTS}`);
|
||||
|
||||
let decryptedFiel = null;
|
||||
if (job.contribuyenteId) {
|
||||
const pool = await tenantDb.getPool(job.tenantId, job.tenant.databaseName);
|
||||
decryptedFiel = await getDecryptedFielContribuyente(pool, job.contribuyenteId);
|
||||
}
|
||||
if (!decryptedFiel) {
|
||||
decryptedFiel = await getDecryptedFiel(job.tenantId);
|
||||
}
|
||||
if (!decryptedFiel) {
|
||||
await updateJobProgress(job.id, {
|
||||
status: 'failed',
|
||||
errorMessage: 'FIEL no disponible para reintento',
|
||||
completedAt: new Date(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const { service, proxyInfo } = createSatService({
|
||||
cerContent: decryptedFiel.cerContent,
|
||||
keyContent: decryptedFiel.keyContent,
|
||||
password: decryptedFiel.password,
|
||||
});
|
||||
|
||||
const ctx: SyncContext = {
|
||||
fielData: {
|
||||
cerContent: decryptedFiel.cerContent,
|
||||
keyContent: decryptedFiel.keyContent,
|
||||
password: decryptedFiel.password,
|
||||
},
|
||||
service,
|
||||
proxyInfo,
|
||||
rfc: decryptedFiel.rfc,
|
||||
tenantId: job.tenantId,
|
||||
databaseName: job.tenant.databaseName,
|
||||
contribuyenteId: job.contribuyenteId ?? null,
|
||||
getPool: () => tenantDb.getPool(job.tenantId, job.tenant.databaseName),
|
||||
};
|
||||
|
||||
let resumeFromStage: string | undefined;
|
||||
if (job.errorMessage) {
|
||||
try {
|
||||
const parsed = JSON.parse(job.errorMessage);
|
||||
if (typeof parsed.stage === 'string') {
|
||||
resumeFromStage = parsed.stage;
|
||||
}
|
||||
} catch {
|
||||
// no es JSON, ignorar
|
||||
}
|
||||
}
|
||||
|
||||
// B: resetear startedAt al inicio de este intento (ver retryTimedOutJobs).
|
||||
await updateJobProgress(job.id, { status: 'running', errorMessage: null as any, startedAt: new Date() });
|
||||
|
||||
try {
|
||||
await processDailySync(ctx, job.id, resumeFromStage);
|
||||
await updateJobProgress(job.id, {
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
progressPercent: 100,
|
||||
errorMessage: null as any,
|
||||
});
|
||||
console.log(`[SAT Daily Retry] Job ${job.id} completado`);
|
||||
} catch (retryError: any) {
|
||||
console.error(`[SAT Daily Retry] Job ${job.id} falló:`, retryError.message);
|
||||
|
||||
const isMetadataPending = retryError instanceof SatMetadataPendingError;
|
||||
const isSatTimeout = retryError instanceof SatSyncTimeoutError;
|
||||
const isTransient = retryError instanceof SatTransientError;
|
||||
const isTimeout = isSatTimeout || isMetadataPending || isTransient || retryError.message?.includes('Timeout');
|
||||
const nextRetryCount = (job.retryCount || 0) + 1;
|
||||
const progressErrorMessage = isMetadataPending
|
||||
? JSON.stringify({ stage: retryError.stageId, pendingStages: retryError.pendingStages, message: retryError.message })
|
||||
: isSatTimeout || isTransient
|
||||
? JSON.stringify({ stage: retryError.stageId, message: retryError.message })
|
||||
: undefined;
|
||||
|
||||
if (isTimeout && nextRetryCount < MAX_DAILY_RETRY_ATTEMPTS) {
|
||||
await updateJobProgress(job.id, {
|
||||
status: 'pending',
|
||||
errorMessage: progressErrorMessage,
|
||||
retryCount: nextRetryCount,
|
||||
nextRetryAt: null as any,
|
||||
});
|
||||
console.log(`[SAT Daily Retry] Job ${job.id} quedó pending para siguiente ventana (intento ${nextRetryCount}/${MAX_DAILY_RETRY_ATTEMPTS})`);
|
||||
} else {
|
||||
await updateJobProgress(job.id, {
|
||||
status: 'failed',
|
||||
errorMessage: isMetadataPending
|
||||
? progressErrorMessage
|
||||
: isSatTimeout
|
||||
? progressErrorMessage
|
||||
: isTimeout
|
||||
? 'Fallo conexión SAT, vuelve a intentar con un rango de fechas menor.'
|
||||
: retryError.message,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT Daily Retry] Error procesando job ${job.id}:`, error.message);
|
||||
await updateJobProgress(job.id, {
|
||||
status: 'failed',
|
||||
errorMessage: error.message,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el estado actual de sincronización de un tenant
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface SweepResult {
|
||||
|
||||
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
||||
initial: 24,
|
||||
daily: 4,
|
||||
daily: 8,
|
||||
incremental: 2,
|
||||
custom: 24,
|
||||
};
|
||||
@@ -38,8 +38,8 @@ const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
|
||||
* (volver a correrlo no reabre los ya-marcados-failed).
|
||||
*
|
||||
* - `apply=false` (default): dry-run, no toca BD.
|
||||
* - `pendingHours`: threshold pending (default 12h).
|
||||
* - `runningHours`: fallback threshold running si no se usa por-tipo (default 4h).
|
||||
* - `pendingHours`: threshold pending (default 24h).
|
||||
* - `runningHours`: fallback threshold running si no se usa por-tipo (default 8h).
|
||||
* - `runningHoursByType`: override por tipo de sync.
|
||||
*/
|
||||
export async function sweepStaleSatJobs(params: {
|
||||
@@ -48,7 +48,7 @@ export async function sweepStaleSatJobs(params: {
|
||||
runningHours?: number;
|
||||
runningHoursByType?: Record<string, number>;
|
||||
} = { apply: false }): Promise<SweepResult> {
|
||||
const pendingHours = params.pendingHours ?? 12;
|
||||
const pendingHours = params.pendingHours ?? 24;
|
||||
const runningHoursByType = { ...DEFAULT_RUNNING_HOURS_BY_TYPE, ...(params.runningHoursByType || {}) };
|
||||
const now = new Date();
|
||||
const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label } from '@horux/shared-ui';
|
||||
import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui';
|
||||
import { useEventos, useCreateEvento, useUpdateEvento, useDeleteEvento } from '@/lib/hooks/use-calendario';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import {
|
||||
@@ -42,6 +42,8 @@ interface RecordatorioForm {
|
||||
fechaLimite: string;
|
||||
notas: string;
|
||||
privado: boolean;
|
||||
recurrencia: 'unica' | 'mensual' | 'bimestral' | 'trimestral' | 'anual';
|
||||
fechaFin: string;
|
||||
}
|
||||
|
||||
const emptyForm: RecordatorioForm = {
|
||||
@@ -50,6 +52,8 @@ const emptyForm: RecordatorioForm = {
|
||||
fechaLimite: '',
|
||||
notas: '',
|
||||
privado: false,
|
||||
recurrencia: 'unica',
|
||||
fechaFin: '',
|
||||
};
|
||||
|
||||
export default function CalendarioPage() {
|
||||
@@ -100,6 +104,8 @@ export default function CalendarioPage() {
|
||||
fechaLimite: evento.fechaLimite,
|
||||
notas: evento.notas || '',
|
||||
privado: (evento as any).privado ?? false,
|
||||
recurrencia: (evento.recurrencia as RecordatorioForm['recurrencia']) || 'unica',
|
||||
fechaFin: '', // La fecha fin no se edita desde el calendario; se mantiene la original
|
||||
});
|
||||
setShowForm(true);
|
||||
};
|
||||
@@ -113,15 +119,19 @@ export default function CalendarioPage() {
|
||||
data: { titulo: form.titulo, descripcion: form.descripcion, fechaLimite: form.fechaLimite, notas: form.notas, privado: form.privado } as any,
|
||||
});
|
||||
} else {
|
||||
await createEvento.mutateAsync({
|
||||
const payload: any = {
|
||||
titulo: form.titulo,
|
||||
descripcion: form.descripcion,
|
||||
tipo: 'custom',
|
||||
fechaLimite: form.fechaLimite,
|
||||
recurrencia: 'unica',
|
||||
recurrencia: form.recurrencia,
|
||||
notas: form.notas,
|
||||
privado: form.privado,
|
||||
} as any);
|
||||
};
|
||||
if (form.recurrencia !== 'unica' && form.fechaFin) {
|
||||
payload.fechaFin = form.fechaFin;
|
||||
}
|
||||
await createEvento.mutateAsync(payload);
|
||||
}
|
||||
setShowForm(false);
|
||||
setForm(emptyForm);
|
||||
@@ -131,10 +141,15 @@ export default function CalendarioPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('¿Eliminar este recordatorio?')) return;
|
||||
const handleDelete = async (evento: EventoFiscal) => {
|
||||
if (!evento.id) return;
|
||||
const esPeriodico = evento.recurrencia && evento.recurrencia !== 'unica';
|
||||
const mensaje = esPeriodico
|
||||
? 'Esto cancelará todas las ocurrencias futuras de esta serie. ¿Continuar?'
|
||||
: '¿Eliminar este recordatorio?';
|
||||
if (!confirm(mensaje)) return;
|
||||
try {
|
||||
await deleteEvento.mutateAsync(id);
|
||||
await deleteEvento.mutateAsync(evento.id);
|
||||
} catch {
|
||||
alert('Error al eliminar');
|
||||
}
|
||||
@@ -206,6 +221,44 @@ export default function CalendarioPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!editingId && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recurrencia">Recurrencia</Label>
|
||||
<Select
|
||||
value={form.recurrencia}
|
||||
onValueChange={(v) => setForm({ ...form, recurrencia: v as RecordatorioForm['recurrencia'] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unica">Única</SelectItem>
|
||||
<SelectItem value="mensual">Mensual</SelectItem>
|
||||
<SelectItem value="bimestral">Bimestral</SelectItem>
|
||||
<SelectItem value="trimestral">Trimestral</SelectItem>
|
||||
<SelectItem value="anual">Anual</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{form.recurrencia !== 'unica' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fechaFin">Fecha fin (opcional)</Label>
|
||||
<Input
|
||||
id="fechaFin"
|
||||
type="date"
|
||||
value={form.fechaFin}
|
||||
onChange={e => setForm({ ...form, fechaFin: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{editingId && form.recurrencia !== 'unica' && (
|
||||
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
Este es un recordatorio periódico. Los cambios se aplicarán a todas las ocurrencias futuras.
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="descripcion">Descripción (opcional)</Label>
|
||||
<Input
|
||||
@@ -417,7 +470,7 @@ export default function CalendarioPage() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon" className="h-7 w-7 text-destructive"
|
||||
onClick={() => evento.id && handleDelete(evento.id)}
|
||||
onClick={() => handleDelete(evento)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -322,41 +322,65 @@ export default function CfdiPage() {
|
||||
const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis');
|
||||
// Filtros locales de la pestaña Conceptos (no compartidos con CFDIs).
|
||||
// Popovers en headers UUID, Clave, Descripción + ordenamiento por importe.
|
||||
const [conceptosFilters, setConceptosFilters] = useState<{
|
||||
// Valores que el usuario escribe en los inputs de los popovers
|
||||
const [conceptosDraftFilters, setConceptosDraftFilters] = useState<{
|
||||
uuidLike: string;
|
||||
claveProdServ: string;
|
||||
descripcionConcepto: string;
|
||||
noIdentificacion: string;
|
||||
}>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '', noIdentificacion: '' });
|
||||
// Filtros realmente aplicados a la query (solo cambian al dar Aplicar o Limpiar)
|
||||
const [appliedConceptosFilters, setAppliedConceptosFilters] = useState<{
|
||||
uuidLike: string;
|
||||
claveProdServ: string;
|
||||
descripcionConcepto: string;
|
||||
noIdentificacion: string;
|
||||
}>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '', noIdentificacion: '' });
|
||||
const [conceptosSort, setConceptosSort] = useState<{
|
||||
orderBy?: 'fecha' | 'importe';
|
||||
orderDir?: 'asc' | 'desc';
|
||||
}>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '', noIdentificacion: '' });
|
||||
}>({});
|
||||
const [conceptosOpenFilter, setConceptosOpenFilter] = useState<'uuid' | 'clave' | 'descripcion' | 'noIdentificacion' | null>(null);
|
||||
|
||||
const conceptosQuery = useQuery({
|
||||
queryKey: ['cfdi-conceptos', filters, selectedContribuyenteId, conceptosFilters],
|
||||
queryKey: ['cfdi-conceptos', filters, selectedContribuyenteId, appliedConceptosFilters, conceptosSort],
|
||||
queryFn: () => getConceptosList({
|
||||
...filters,
|
||||
contribuyenteId: selectedContribuyenteId || undefined,
|
||||
uuidLike: conceptosFilters.uuidLike || undefined,
|
||||
claveProdServ: conceptosFilters.claveProdServ || undefined,
|
||||
descripcionConcepto: conceptosFilters.descripcionConcepto || undefined,
|
||||
noIdentificacion: conceptosFilters.noIdentificacion || undefined,
|
||||
orderBy: conceptosFilters.orderBy,
|
||||
orderDir: conceptosFilters.orderDir,
|
||||
uuidLike: appliedConceptosFilters.uuidLike || undefined,
|
||||
claveProdServ: appliedConceptosFilters.claveProdServ || undefined,
|
||||
descripcionConcepto: appliedConceptosFilters.descripcionConcepto || undefined,
|
||||
noIdentificacion: appliedConceptosFilters.noIdentificacion || undefined,
|
||||
orderBy: conceptosSort.orderBy,
|
||||
orderDir: conceptosSort.orderDir,
|
||||
}),
|
||||
enabled: activeTab === 'conceptos',
|
||||
});
|
||||
|
||||
const toggleImporteSort = () => {
|
||||
setConceptosFilters(prev => {
|
||||
// null → asc → desc → null (o ciclo simple asc ↔ desc si prefieres)
|
||||
setConceptosSort(prev => {
|
||||
const isImporte = prev.orderBy === 'importe';
|
||||
if (!isImporte) return { ...prev, orderBy: 'importe', orderDir: 'desc' };
|
||||
if (prev.orderDir === 'desc') return { ...prev, orderBy: 'importe', orderDir: 'asc' };
|
||||
return { ...prev, orderBy: undefined, orderDir: undefined };
|
||||
if (!isImporte) return { orderBy: 'importe', orderDir: 'desc' };
|
||||
if (prev.orderDir === 'desc') return { orderBy: 'importe', orderDir: 'asc' };
|
||||
return {};
|
||||
});
|
||||
setFilters(f => ({ ...f, page: 1 }));
|
||||
};
|
||||
|
||||
const applyConceptosFilters = () => {
|
||||
setAppliedConceptosFilters({ ...conceptosDraftFilters });
|
||||
setFilters(f => ({ ...f, page: 1 }));
|
||||
setConceptosOpenFilter(null);
|
||||
};
|
||||
|
||||
const clearConceptosFilter = (field: keyof typeof conceptosDraftFilters) => {
|
||||
const newDraft = { ...conceptosDraftFilters, [field]: '' };
|
||||
setConceptosDraftFilters(newDraft);
|
||||
setAppliedConceptosFilters(newDraft);
|
||||
setFilters(f => ({ ...f, page: 1 }));
|
||||
setConceptosOpenFilter(null);
|
||||
};
|
||||
|
||||
const createCfdi = useCreateCfdi();
|
||||
const deleteCfdi = useDeleteCfdi();
|
||||
|
||||
@@ -480,12 +504,12 @@ export default function CfdiPage() {
|
||||
const fullResponse = await getConceptosList({
|
||||
...filters,
|
||||
contribuyenteId: selectedContribuyenteId || undefined,
|
||||
uuidLike: conceptosFilters.uuidLike || undefined,
|
||||
claveProdServ: conceptosFilters.claveProdServ || undefined,
|
||||
descripcionConcepto: conceptosFilters.descripcionConcepto || undefined,
|
||||
noIdentificacion: conceptosFilters.noIdentificacion || undefined,
|
||||
orderBy: conceptosFilters.orderBy,
|
||||
orderDir: conceptosFilters.orderDir,
|
||||
uuidLike: appliedConceptosFilters.uuidLike || undefined,
|
||||
claveProdServ: appliedConceptosFilters.claveProdServ || undefined,
|
||||
descripcionConcepto: appliedConceptosFilters.descripcionConcepto || undefined,
|
||||
noIdentificacion: appliedConceptosFilters.noIdentificacion || undefined,
|
||||
orderBy: conceptosSort.orderBy,
|
||||
orderDir: conceptosSort.orderDir,
|
||||
page: 1,
|
||||
limit: EXPORT_MAX,
|
||||
});
|
||||
@@ -1598,17 +1622,17 @@ export default function CfdiPage() {
|
||||
UUID
|
||||
<Popover open={conceptosOpenFilter === 'uuid'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'uuid' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.uuidLike ? 'text-primary' : ''}`}>
|
||||
<button className={`p-1 rounded hover:bg-muted ${appliedConceptosFilters.uuidLike ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por UUID</h4>
|
||||
<Input className="h-8 text-sm font-mono" placeholder="Fragmento del UUID..." value={conceptosFilters.uuidLike} onChange={(e) => setConceptosFilters({ ...conceptosFilters, uuidLike: e.target.value })} />
|
||||
<Input className="h-8 text-sm font-mono" placeholder="Fragmento del UUID..." value={conceptosDraftFilters.uuidLike} onChange={(e) => setConceptosDraftFilters({ ...conceptosDraftFilters, uuidLike: e.target.value })} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button>
|
||||
{conceptosFilters.uuidLike && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, uuidLike: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>}
|
||||
<Button size="sm" className="flex-1" onClick={applyConceptosFilters}>Aplicar</Button>
|
||||
{appliedConceptosFilters.uuidLike && <Button size="sm" variant="outline" onClick={() => clearConceptosFilter('uuidLike')}>Limpiar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -1620,17 +1644,17 @@ export default function CfdiPage() {
|
||||
Clave
|
||||
<Popover open={conceptosOpenFilter === 'clave'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'clave' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.claveProdServ ? 'text-primary' : ''}`}>
|
||||
<button className={`p-1 rounded hover:bg-muted ${appliedConceptosFilters.claveProdServ ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por Clave SAT</h4>
|
||||
<Input className="h-8 text-sm font-mono" placeholder="Ej: 81112502" value={conceptosFilters.claveProdServ} onChange={(e) => setConceptosFilters({ ...conceptosFilters, claveProdServ: e.target.value })} />
|
||||
<Input className="h-8 text-sm font-mono" placeholder="Ej: 81112502" value={conceptosDraftFilters.claveProdServ} onChange={(e) => setConceptosDraftFilters({ ...conceptosDraftFilters, claveProdServ: e.target.value })} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button>
|
||||
{conceptosFilters.claveProdServ && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, claveProdServ: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>}
|
||||
<Button size="sm" className="flex-1" onClick={applyConceptosFilters}>Aplicar</Button>
|
||||
{appliedConceptosFilters.claveProdServ && <Button size="sm" variant="outline" onClick={() => clearConceptosFilter('claveProdServ')}>Limpiar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -1642,17 +1666,17 @@ export default function CfdiPage() {
|
||||
Descripción
|
||||
<Popover open={conceptosOpenFilter === 'descripcion'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'descripcion' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.descripcionConcepto ? 'text-primary' : ''}`}>
|
||||
<button className={`p-1 rounded hover:bg-muted ${appliedConceptosFilters.descripcionConcepto ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por descripción</h4>
|
||||
<Input className="h-8 text-sm" placeholder="Texto contenido en la descripción..." value={conceptosFilters.descripcionConcepto} onChange={(e) => setConceptosFilters({ ...conceptosFilters, descripcionConcepto: e.target.value })} />
|
||||
<Input className="h-8 text-sm" placeholder="Texto contenido en la descripción..." value={conceptosDraftFilters.descripcionConcepto} onChange={(e) => setConceptosDraftFilters({ ...conceptosDraftFilters, descripcionConcepto: e.target.value })} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button>
|
||||
{conceptosFilters.descripcionConcepto && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, descripcionConcepto: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>}
|
||||
<Button size="sm" className="flex-1" onClick={applyConceptosFilters}>Aplicar</Button>
|
||||
{appliedConceptosFilters.descripcionConcepto && <Button size="sm" variant="outline" onClick={() => clearConceptosFilter('descripcionConcepto')}>Limpiar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -1664,17 +1688,17 @@ export default function CfdiPage() {
|
||||
No. Identificación
|
||||
<Popover open={conceptosOpenFilter === 'noIdentificacion'} onOpenChange={(open) => setConceptosOpenFilter(open ? 'noIdentificacion' : null)}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className={`p-1 rounded hover:bg-muted ${conceptosFilters.noIdentificacion ? 'text-primary' : ''}`}>
|
||||
<button className={`p-1 rounded hover:bg-muted ${appliedConceptosFilters.noIdentificacion ? 'text-primary' : ''}`}>
|
||||
<Filter className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="start">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-sm">Filtrar por No. Identificación</h4>
|
||||
<Input className="h-8 text-sm font-mono" placeholder="Ej: PROD-001" value={conceptosFilters.noIdentificacion} onChange={(e) => setConceptosFilters({ ...conceptosFilters, noIdentificacion: e.target.value })} />
|
||||
<Input className="h-8 text-sm font-mono" placeholder="Ej: PROD-001" value={conceptosDraftFilters.noIdentificacion} onChange={(e) => setConceptosDraftFilters({ ...conceptosDraftFilters, noIdentificacion: e.target.value })} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1" onClick={() => { setFilters({ ...filters, page: 1 }); setConceptosOpenFilter(null); }}>Aplicar</Button>
|
||||
{conceptosFilters.noIdentificacion && <Button size="sm" variant="outline" onClick={() => { setConceptosFilters({ ...conceptosFilters, noIdentificacion: '' }); setFilters({ ...filters, page: 1 }); }}>Limpiar</Button>}
|
||||
<Button size="sm" className="flex-1" onClick={applyConceptosFilters}>Aplicar</Button>
|
||||
{appliedConceptosFilters.noIdentificacion && <Button size="sm" variant="outline" onClick={() => clearConceptosFilter('noIdentificacion')}>Limpiar</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -1694,8 +1718,8 @@ export default function CfdiPage() {
|
||||
title="Ordenar por importe"
|
||||
>
|
||||
Importe
|
||||
{conceptosFilters.orderBy === 'importe' ? (
|
||||
<span className="text-primary">{conceptosFilters.orderDir === 'asc' ? '▲' : '▼'}</span>
|
||||
{conceptosSort.orderBy === 'importe' ? (
|
||||
<span className="text-primary">{conceptosSort.orderDir === 'asc' ? '▲' : '▼'}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">⇅</span>
|
||||
)}
|
||||
|
||||
@@ -161,6 +161,7 @@ export default function CsdConfigPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [creatingOrg, setCreatingOrg] = useState(false);
|
||||
const [cerFile, setCerFile] = useState<string>('');
|
||||
const [keyFile, setKeyFile] = useState<string>('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -178,16 +179,28 @@ export default function CsdConfigPage() {
|
||||
};
|
||||
|
||||
const handleCreateOrg = async () => {
|
||||
if (creatingOrg) return;
|
||||
setCreatingOrg(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const cfg = { timeout: 60000 };
|
||||
if (selectedContribuyenteId) {
|
||||
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`);
|
||||
await apiClient.post(`/contribuyentes/${selectedContribuyenteId}/facturapi/org`, undefined, cfg);
|
||||
} else {
|
||||
await apiClient.post('/facturacion/org');
|
||||
await apiClient.post('/facturacion/org', undefined, cfg);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['facturapi-org-contrib'] });
|
||||
setMessage({ type: 'success', text: 'Organización creada en Facturapi' });
|
||||
} catch (err: any) {
|
||||
setMessage({ type: 'error', text: err.response?.data?.message || 'Error al crear organización' });
|
||||
const isTimeout = err?.code === 'ECONNABORTED';
|
||||
setMessage({
|
||||
type: 'error',
|
||||
text: isTimeout
|
||||
? 'La creación está tardando más de lo esperado. Refresca la página en unos segundos; si no aparece, intenta de nuevo.'
|
||||
: (err.response?.data?.message || 'Error al crear organización'),
|
||||
});
|
||||
} finally {
|
||||
setCreatingOrg(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -268,7 +281,9 @@ export default function CsdConfigPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No hay organización configurada para este tenant.
|
||||
</p>
|
||||
<Button onClick={handleCreateOrg}>Crear Organización</Button>
|
||||
<Button onClick={handleCreateOrg} disabled={creatingOrg}>
|
||||
{creatingOrg ? 'Creando… (puede tardar unos segundos)' : 'Crear Organización'}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
|
||||
@@ -534,9 +534,9 @@ export default function PlanesDespachoPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col flex-1 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold">$25,850</div>
|
||||
<div className="text-3xl font-bold">$30,850</div>
|
||||
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">+ $25/mes por cada RFC adicional sobre 100</p>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
|
||||
@@ -563,9 +563,9 @@ export default function PlanesDespachoPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col flex-1 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold">$43,000</div>
|
||||
<div className="text-3xl font-bold">$68,850</div>
|
||||
<p className="text-sm text-muted-foreground">por año (IVA incluido)</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">+ $60/mes por cada RFC adicional sobre 100</p>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 100 RFCs</span></div>
|
||||
|
||||
@@ -305,6 +305,7 @@ export default function PreciosSuscripcionPage() {
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
<strong>SAT Inc</strong> habilita 3 syncs SAT extra al día (11:00, 15:00, 19:00) además del daily de las 03:00.
|
||||
Disponible en <strong>Business Cloud</strong> y <strong>Mi Empresa Plus</strong>; no incluido en <strong>Business Control</strong>.
|
||||
Ventana de 8h por sync, deduplicado por UUID. Latencia típica de un CFDI ~1-2h en horario laboral
|
||||
vs ~24h con solo el daily.
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, CardContent } from '@horux/shared-ui';
|
||||
@@ -10,7 +11,7 @@ import { apiClient } from '@/lib/api/client';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
||||
import { usePeriodoStore, añoMesFromFechaInicio } from '@/stores/periodo-store';
|
||||
import { Building2, Clock, AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { Building2, Clock, AlertTriangle, CheckCircle2, Loader2, Search, FolderOpen, ChevronDown } from 'lucide-react';
|
||||
|
||||
interface Asignado {
|
||||
contribuyenteId: string;
|
||||
@@ -29,6 +30,9 @@ const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar', 'cont
|
||||
const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']);
|
||||
|
||||
export default function MisAsignadosPage() {
|
||||
const [filtroCliente, setFiltroCliente] = useState('');
|
||||
const [filtroCartera, setFiltroCartera] = useState('');
|
||||
|
||||
const role = useAuthStore(s => s.user?.role);
|
||||
const platformRoles = useAuthStore(s => s.user?.platformRoles);
|
||||
const isPlatformStaff = platformRoles?.some(r => PLATFORM_SUPERSET.has(r)) ?? false;
|
||||
@@ -64,6 +68,19 @@ export default function MisAsignadosPage() {
|
||||
|
||||
const items = data ?? [];
|
||||
|
||||
const carterasUnicas = Array.from(new Set(items.map(it => it.carteraNombre || 'Sin cartera'))).sort((a, b) =>
|
||||
a.localeCompare(b, 'es', { sensitivity: 'base' })
|
||||
);
|
||||
|
||||
const itemsFiltrados = items.filter((it) => {
|
||||
const coincideCliente = [it.nombre, it.rfc].some(v =>
|
||||
v.toLowerCase().includes(filtroCliente.trim().toLowerCase())
|
||||
);
|
||||
const coincideCartera =
|
||||
filtroCartera === '' || (filtroCartera === '__sin_cartera__' ? !it.carteraNombre : it.carteraNombre === filtroCartera);
|
||||
return coincideCliente && coincideCartera;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Despacho — Mis asignados"><PeriodoSelector /></Header>
|
||||
@@ -84,6 +101,39 @@ export default function MisAsignadosPage() {
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col sm:flex-row gap-3 p-4 border-b bg-muted/30">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={filtroCliente}
|
||||
onChange={(e) => setFiltroCliente(e.target.value)}
|
||||
placeholder="Buscar por cliente o RFC..."
|
||||
className="w-full rounded-md border border-input bg-background pl-9 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative sm:w-64">
|
||||
<FolderOpen className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<select
|
||||
value={filtroCartera}
|
||||
onChange={(e) => setFiltroCartera(e.target.value)}
|
||||
className="w-full appearance-none rounded-md border border-input bg-background pl-9 pr-8 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="">Todas las carteras</option>
|
||||
<option value="__sin_cartera__">Sin cartera</option>
|
||||
{carterasUnicas.filter(c => c !== 'Sin cartera').map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{itemsFiltrados.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
No hay resultados para los filtros seleccionados.
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/50">
|
||||
<tr>
|
||||
@@ -102,7 +152,7 @@ export default function MisAsignadosPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(it => {
|
||||
{itemsFiltrados.map(it => {
|
||||
const total =
|
||||
it.obligacionesPendientes + it.obligacionesAtrasadas + it.obligacionesCompletadas +
|
||||
it.tareasPendientes + it.tareasAtrasadas + it.tareasCompletadas;
|
||||
@@ -193,6 +243,7 @@ export default function MisAsignadosPage() {
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { addClienteAcceso } from '@/lib/api/contribuyentes';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound } from 'lucide-react';
|
||||
import { Users, UserPlus, Trash2, Shield, Eye, Calculator, UserCheck, UserCog, Building2, FolderOpen, KeyRound, Pencil } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@horux/shared-ui';
|
||||
import Link from 'next/link';
|
||||
import { cn } from '@horux/shared-ui';
|
||||
@@ -79,7 +79,7 @@ export default function UsuariosPage() {
|
||||
const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
|
||||
const inviteRoles = isDespacho
|
||||
? (currentUser?.role === 'supervisor'
|
||||
? despachoInviteRoles.filter(r => r.value === 'cliente')
|
||||
? despachoInviteRoles.filter(r => r.value === 'cliente' || r.value === 'auxiliar')
|
||||
: despachoInviteRoles)
|
||||
: legacyInviteRoles;
|
||||
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
|
||||
@@ -106,6 +106,13 @@ export default function UsuariosPage() {
|
||||
|
||||
const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>('');
|
||||
|
||||
// Edit user modal (owner only)
|
||||
const [editingUser, setEditingUser] = useState<{ id: string; nombre: string; role: Role; email: string } | null>(null);
|
||||
const [editForm, setEditForm] = useState<{ nombre: string; role: Role }>({ nombre: '', role: 'auxiliar' });
|
||||
const [savingUser, setSavingUser] = useState(false);
|
||||
|
||||
const isOwner = currentUser?.role === 'owner';
|
||||
|
||||
const openEditSupervisor = async (userId: string, nombre: string) => {
|
||||
try {
|
||||
const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`);
|
||||
@@ -132,6 +139,24 @@ export default function UsuariosPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openEditUser = (usuario: { id: string; nombre: string; role: Role; email: string }) => {
|
||||
setEditingUser(usuario);
|
||||
setEditForm({ nombre: usuario.nombre, role: usuario.role });
|
||||
};
|
||||
|
||||
const handleSaveUser = async () => {
|
||||
if (!editingUser) return;
|
||||
setSavingUser(true);
|
||||
try {
|
||||
await updateUsuario.mutateAsync({ id: editingUser.id, data: editForm });
|
||||
setEditingUser(null);
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.message || 'Error al guardar usuario');
|
||||
} finally {
|
||||
setSavingUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditAccesos = async (userId: string, nombre: string) => {
|
||||
try {
|
||||
const res = await apiClient.get<{ data: string[] }>(`/usuarios/${userId}/accesos`);
|
||||
@@ -270,7 +295,16 @@ export default function UsuariosPage() {
|
||||
<Label htmlFor="role">Rol</Label>
|
||||
<Select
|
||||
value={inviteForm.role}
|
||||
onValueChange={(v) => { setInviteForm({ ...inviteForm, role: v as UserInvite['role'], supervisorUserId: undefined }); if (v !== 'cliente') setSelectedRfcIds([]); }}
|
||||
onValueChange={(v) => {
|
||||
const isAuxiliar = v === 'auxiliar';
|
||||
const isSupervisor = currentUser?.role === 'supervisor';
|
||||
setInviteForm({
|
||||
...inviteForm,
|
||||
role: v as UserInvite['role'],
|
||||
supervisorUserId: isAuxiliar && isSupervisor ? currentUser?.id : undefined,
|
||||
});
|
||||
if (v !== 'cliente') setSelectedRfcIds([]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -393,6 +427,18 @@ export default function UsuariosPage() {
|
||||
<RoleIcon className="h-4 w-4" />
|
||||
<span className="text-sm">{roleInfo.label}</span>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEditUser(usuario)}
|
||||
title="Editar nombre y rol"
|
||||
>
|
||||
<Pencil className="h-4 w-4 mr-1" /> Editar
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && !isCurrentUser && (
|
||||
<div className="flex gap-1">
|
||||
{isDespacho && usuario.role === 'cliente' && (
|
||||
@@ -526,6 +572,67 @@ export default function UsuariosPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Edit User Modal */}
|
||||
{editingUser && (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) setEditingUser(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar usuario</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-email">Email</Label>
|
||||
<Input id="edit-email" value={editingUser.email} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-nombre">Nombre</Label>
|
||||
<Input
|
||||
id="edit-nombre"
|
||||
value={editForm.nombre}
|
||||
onChange={e => setEditForm({ ...editForm, nombre: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-role">Rol</Label>
|
||||
{editingUser.id === currentUser?.id ? (
|
||||
<div className="text-sm border rounded-md px-3 py-2 bg-muted text-muted-foreground">
|
||||
{getRoleInfo(editForm.role, isDespacho).label}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={editForm.role}
|
||||
onValueChange={(v) => setEditForm({ ...editForm, role: v as Role })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(isDespacho
|
||||
? (['owner', 'supervisor', 'auxiliar', 'cliente'] as Role[])
|
||||
: (['owner', 'cfo', 'contador', 'visor', 'auxiliar'] as Role[])
|
||||
).map((r) => (
|
||||
<SelectItem key={r} value={r}>
|
||||
{getRoleInfo(r, isDespacho).label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{editingUser.id === currentUser?.id && (
|
||||
<p className="text-xs text-muted-foreground">No puedes cambiar tu propio rol.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditingUser(null)}>Cancelar</Button>
|
||||
<Button onClick={handleSaveUser} disabled={savingUser}>
|
||||
{savingUser ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ export function ContribuyenteSelector() {
|
||||
|
||||
const selected = contribuyentes.find((c) => c.id === selectedContribuyenteId);
|
||||
|
||||
// Orden alfabético por nombre (locale español, sin distinguir acentos/mayúsculas)
|
||||
const contribuyentesOrdenados = [...contribuyentes].sort((a, b) =>
|
||||
a.nombre.localeCompare(b.nombre, 'es', { sensitivity: 'base', numeric: true })
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="contribuyente-selector relative">
|
||||
<button
|
||||
@@ -91,7 +96,7 @@ export function ContribuyenteSelector() {
|
||||
)}
|
||||
|
||||
{/* Lista de contribuyentes */}
|
||||
{contribuyentes.map((c) => (
|
||||
{contribuyentesOrdenados.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => { setSelectedContribuyente(c.id, c.rfc, c.nombre); setOpen(false); }}
|
||||
|
||||
@@ -657,6 +657,7 @@ Se habilitó el pago a **hasta 12 meses** en el checkout de MercadoPago para:
|
||||
|---|---|
|
||||
| `apps/api/src/services/payment/addon.service.ts` | Precio de overage por plan ($25 / $60) |
|
||||
| `apps/api/src/services/payment/mercadopago.service.ts` | `payment_methods.installments: 12` en preferences anuales y prorrateos |
|
||||
| `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` | Tarjetas de precios mostradas en UI: Business Control $30,850 y Enterprise $68,850; overage $25/$60 |
|
||||
| `apps/api/prisma/seed` (implícito) | Valores actualizados en `despacho_plan_prices` vía SQL |
|
||||
|
||||
### Deploy
|
||||
@@ -674,6 +675,207 @@ pm2 reload horux-web
|
||||
|
||||
---
|
||||
|
||||
## 30. Supervisor puede invitar Clientes y Auxiliares
|
||||
|
||||
**Fecha:** 2026-06-25
|
||||
|
||||
### Cambio
|
||||
Antes los supervisores solo podían invitar usuarios con rol **Cliente**. Ahora también pueden invitar **Auxiliares**, siempre asignados a su propia supervisión.
|
||||
|
||||
### Backend
|
||||
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/controllers/usuarios.controller.ts` | Permite a `supervisor` invitar `cliente` y `auxiliar`; al invitar un auxiliar se asigna a sí mismo por defecto y no permite asignar a otro supervisor |
|
||||
| `apps/api/src/routes/cartera.routes.ts` | `/carteras/supervisores` ahora acepta `owner` y `supervisor` |
|
||||
| `apps/api/src/controllers/cartera.controller.ts` | `getSupervisores` filtra para que un supervisor solo se vea a sí mismo en el dropdown |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/web/app/(dashboard)/usuarios/page.tsx` | El dropdown de roles para supervisor incluye **Auxiliar**; al seleccionar auxiliar se preselecciona al supervisor logueado como responsable |
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
cd /root/HoruxDespachosNuevo
|
||||
pnpm --filter api build
|
||||
pnpm --filter web build
|
||||
pm2 reload horux-api
|
||||
pm2 reload horux-web
|
||||
```
|
||||
|
||||
**Estado:** ✅ Exitoso
|
||||
|
||||
---
|
||||
|
||||
## 31. Owner puede editar nombre y rol de usuarios
|
||||
|
||||
**Fecha:** 2026-06-25
|
||||
|
||||
### Cambio
|
||||
En la página **Configuración › Usuarios**, el **owner** ahora puede editar el **nombre** y el **rol** de cualquier usuario del tenant desde un modal.
|
||||
|
||||
### Detalles
|
||||
- Se agregó el botón **Editar** en cada fila de usuario (solo visible para `owner`).
|
||||
- El modal permite cambiar:
|
||||
- **Nombre**
|
||||
- **Rol** (los roles disponibles dependen de si es tenant despacho o legacy)
|
||||
- Un owner **no puede cambiar su propio rol** (solo su nombre).
|
||||
- El backend ya tenía el endpoint `PATCH /usuarios/:id`; se ajustó la UI para exponerlo.
|
||||
|
||||
### Archivos modificados
|
||||
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/web/app/(dashboard)/usuarios/page.tsx` | Botón Editar, modal de edición, validación de rol propio |
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
cd /root/HoruxDespachosNuevo
|
||||
pnpm --filter web build
|
||||
pm2 reload horux-web
|
||||
```
|
||||
|
||||
**Estado:** ✅ Exitoso
|
||||
|
||||
---
|
||||
|
||||
## 32. Recordatorios periódicos en calendario
|
||||
|
||||
**Fecha:** 2026-06-25
|
||||
|
||||
### Cambio
|
||||
Los recordatorios custom del calendario ahora pueden ser **periódicos**: mensual, bimestral, trimestral o anual. Se mantienen activos indefinidamente hasta que el usuario cancele la serie.
|
||||
|
||||
### Modelo
|
||||
Se reutiliza la tabla `recordatorios` con metadatos de serie:
|
||||
- **Maestro**: `serie_id IS NULL` y `recurrencia <> 'unica'`.
|
||||
- **Instancias**: filas con `serie_id = maestro.id`, una por cada ocurrencia generada.
|
||||
|
||||
Esto permite completar una instancia sin afectar las demás, y el cron de emails de recordatorios próximos sigue funcionando sin cambios mayores.
|
||||
|
||||
### Funcionalidad
|
||||
- Al crear un recordatorio periódico se generan instancias para un horizonte de 24 meses.
|
||||
- Al **editar** una instancia se actualizan el maestro y **todas las ocurrencias futuras no completadas**.
|
||||
- Al **eliminar** una instancia periódica se **cancela toda la serie**: se desactiva el maestro y se borran las instancias futuras no completadas.
|
||||
- Un cron diario a las 6:00 AM extiende automáticamente las series activas para mantener el horizonte futuro.
|
||||
|
||||
### Frecuencias soportadas
|
||||
- `mensual`
|
||||
- `bimestral`
|
||||
- `trimestral`
|
||||
- `anual`
|
||||
|
||||
### Archivos modificados
|
||||
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/migrations/tenant/056_recordatorios_periodicos.sql` | Nuevas columnas `recurrencia`, `serie_id`, `activo`, `fecha_inicio`, `fecha_fin` e índices |
|
||||
| `apps/api/src/services/recordatorios.service.ts` | Lógica de creación, edición, eliminación y regeneración de series periódicas |
|
||||
| `apps/api/src/services/notifications.service.ts` | Excluir maestros del envío de recordatorios próximos |
|
||||
| `apps/api/src/controllers/calendario.controller.ts` | Schemas aceptan `recurrencia` y `fechaFin` |
|
||||
| `apps/api/src/jobs/recordatorios-periodicos.job.ts` | Nuevo cron de extensión de series |
|
||||
| `apps/api/src/index.ts` | Registro del nuevo cron |
|
||||
| `apps/web/app/(dashboard)/calendario/page.tsx` | Selector de recurrencia, fecha fin, indicador de serie y confirmación al cancelar |
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
cd /root/HoruxDespachosNuevo
|
||||
pnpm --filter api build
|
||||
pnpm --filter web build
|
||||
pnpm --filter @horux/api db:migrate-tenants
|
||||
pm2 reload horux-api
|
||||
pm2 reload horux-web
|
||||
```
|
||||
|
||||
**Estado:** ✅ Exitoso
|
||||
|
||||
---
|
||||
|
||||
## 33. Filtros de Conceptos con Aplicar/Limpiar
|
||||
|
||||
**Fecha:** 2026-06-30
|
||||
|
||||
### Cambio
|
||||
En **CFDIs › Conceptos**, los filtros de encabezado de tabla (UUID, Clave SAT, Descripción, No. Identificación) ahora se aplican únicamente al hacer clic en **Aplicar**, en lugar de disparar la búsqueda en cada cambio de teclado.
|
||||
|
||||
### Antes
|
||||
Los inputs usaban `onChange` para actualizar `conceptosFilters`, lo que provocaba que `useQuery` re-lanzara la petición por cada letra escrita.
|
||||
|
||||
### Ahora
|
||||
- Se separó el estado en dos:
|
||||
- `conceptosDraftFilters`: valores que el usuario escribe en los popovers.
|
||||
- `appliedConceptosFilters`: valores realmente aplicados a la query.
|
||||
- **Aplicar**: copia el draft al estado aplicado, resetea paginación y cierra el popover.
|
||||
- **Limpiar**: borra ese campo, aplica el cambio y resetea paginación.
|
||||
- El ordenamiento por importe sigue funcionando con un clic en el encabezado.
|
||||
|
||||
### Archivos modificados
|
||||
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/web/app/(dashboard)/cfdi/page.tsx` | Separación de draft/applied filters y nuevos handlers `applyConceptosFilters` / `clearConceptosFilter` |
|
||||
| `apps/api/src/controllers/cfdi.controller.ts` | El endpoint `/cfdi/conceptos` ahora recibe y pasa `noIdentificacion` al servicio |
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
cd /root/HoruxDespachosNuevo
|
||||
pnpm --filter api build
|
||||
pnpm --filter web build
|
||||
pm2 reload horux-api
|
||||
pm2 reload horux-web
|
||||
```
|
||||
|
||||
**Estado:** ✅ Exitoso
|
||||
|
||||
---
|
||||
|
||||
## 34. Filtro de fechas en CFDIs usa fecha de emisión
|
||||
|
||||
**Fecha:** 2026-06-30
|
||||
|
||||
### Cambio
|
||||
El filtro de fechas en el listado y export de **CFDIs** ahora filtra por **`fecha_emision`** en lugar de `fecha_efectiva`.
|
||||
|
||||
### Antes
|
||||
El where clause usaba:
|
||||
```sql
|
||||
COALESCE(fecha_efectiva, fecha_emision - interval '1 hour') BETWEEN fechaInicio AND fechaFin
|
||||
```
|
||||
Esto provocaba que facturas emitidas en meses anteriores pero pagadas/ejercidas en el rango seleccionado aparecieran en el resultado (por ejemplo, facturas de febrero con `fecha_efectiva` en junio).
|
||||
|
||||
### Ahora
|
||||
El filtro usa directamente:
|
||||
```sql
|
||||
fecha_emision::date BETWEEN fechaInicio AND fechaFin
|
||||
```
|
||||
El export Excel hereda el mismo filtro, así que tabla y Excel son consistentes.
|
||||
|
||||
### Archivos modificados
|
||||
|
||||
| Archivo | Cambio |
|
||||
|---|---|
|
||||
| `apps/api/src/services/cfdi.service.ts` | Filtros de fecha en `getCfdis`, `getConceptosList` y `downloadXmlsZip` usan `fecha_emision::date` |
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
cd /root/HoruxDespachosNuevo
|
||||
pnpm --filter api build
|
||||
pnpm --filter web build
|
||||
pm2 reload horux-api
|
||||
pm2 reload horux-web
|
||||
```
|
||||
|
||||
**Estado:** ✅ Exitoso
|
||||
|
||||
---
|
||||
|
||||
## Deploy histórico
|
||||
|
||||
### Preparación
|
||||
|
||||
@@ -1,298 +1,312 @@
|
||||
# Implementación de Sincronización SAT
|
||||
# Sincronización SAT — Implementación y Operación
|
||||
|
||||
## Resumen
|
||||
Documentación viva del sistema de sincronización de CFDIs con el SAT para Horux Despachos / Horux 360.
|
||||
|
||||
Sistema de sincronización automática de CFDIs con el SAT (Servicio de Administración Tributaria de México) para Horux360.
|
||||
## 1. Resumen
|
||||
|
||||
## Componentes Implementados
|
||||
El sistema descarga periódicamente XMLs y metadata de CFDIs emitidos y recibidos desde el servicio web de descarga masiva del SAT (`@nodecfdi/sat-ws-descarga-masiva`), usando la FIEL de cada contribuyente o del tenant (modo legacy Horux 360).
|
||||
|
||||
### 1. Backend (API)
|
||||
Los datos se almacenan en la base de datos del tenant correspondiente.
|
||||
|
||||
#### Servicios
|
||||
## 2. Arquitectura
|
||||
|
||||
| Archivo | Descripción |
|
||||
|---------|-------------|
|
||||
| `src/services/fiel.service.ts` | Gestión de credenciales FIEL (e.firma) |
|
||||
| `src/services/sat/sat-client.service.ts` | Cliente para el servicio web del SAT |
|
||||
| `src/services/sat/sat.service.ts` | Lógica principal de sincronización |
|
||||
| `src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM para credenciales |
|
||||
| `src/services/sat/sat-parser.service.ts` | Parser de XMLs de CFDI |
|
||||
### Backend
|
||||
|
||||
#### Controladores
|
||||
| Archivo | Responsabilidad |
|
||||
|---------|-----------------|
|
||||
| `apps/api/src/services/sat/sat.service.ts` | Lógica principal de sincronización, políticas de reintento, polling |
|
||||
| `apps/api/src/services/sat/sat-client.service.ts` | Cliente del SAT, `query`, `verify`, `download` |
|
||||
| `apps/api/src/services/sat/sat-parser.service.ts` | Parseo de XMLs y metadata |
|
||||
| `apps/api/src/services/sat/proxy.service.ts` | Pool rotativo de proxies SAT |
|
||||
| `apps/api/src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM de credenciales FIEL |
|
||||
| `apps/api/src/services/fiel.service.ts` | FIEL a nivel tenant (legacy) |
|
||||
| `apps/api/src/services/contribuyente-fiel.service.ts` | FIEL por contribuyente (modelo despacho) |
|
||||
| `apps/api/src/controllers/sat.controller.ts` | Endpoints HTTP |
|
||||
| `apps/api/src/jobs/sat-sync.job.ts` | Crons de sincronización |
|
||||
| `apps/api/src/jobs/sat-sync-monitor.job.ts` | Watchdog de jobs atorados/fallidos |
|
||||
|
||||
| Archivo | Descripción |
|
||||
|---------|-------------|
|
||||
| `src/controllers/fiel.controller.ts` | Endpoints para gestión de FIEL |
|
||||
| `src/controllers/sat.controller.ts` | Endpoints para sincronización SAT |
|
||||
### Frontend
|
||||
|
||||
#### Job Programado
|
||||
| Archivo | Responsabilidad |
|
||||
|---------|-----------------|
|
||||
| `apps/web/components/sat/FielUploadModal.tsx` | Subir FIEL |
|
||||
| `apps/web/components/sat/SyncStatus.tsx` | Estado y selector de fechas |
|
||||
| `apps/web/components/sat/SyncHistory.tsx` | Historial de sincronizaciones |
|
||||
| `apps/web/app/(dashboard)/configuracion/sat/page.tsx` | Página de configuración SAT |
|
||||
|
||||
| Archivo | Descripción |
|
||||
|---------|-------------|
|
||||
| `src/jobs/sat-sync.job.ts` | Cron job para sincronización diaria (3:00 AM) |
|
||||
## 3. Modelo de datos
|
||||
|
||||
### 2. Frontend (Web)
|
||||
|
||||
#### Componentes
|
||||
|
||||
| Archivo | Descripción |
|
||||
|---------|-------------|
|
||||
| `components/sat/FielUploadModal.tsx` | Modal para subir certificado y llave FIEL |
|
||||
| `components/sat/SyncStatus.tsx` | Estado de sincronización con selector de fechas |
|
||||
| `components/sat/SyncHistory.tsx` | Historial de sincronizaciones |
|
||||
|
||||
#### Página
|
||||
|
||||
| Archivo | Descripción |
|
||||
|---------|-------------|
|
||||
| `app/(dashboard)/configuracion/sat/page.tsx` | Página de configuración SAT |
|
||||
|
||||
### 3. Base de Datos
|
||||
|
||||
#### Tabla Principal (schema public)
|
||||
### Tabla global `public.sat_sync_jobs`
|
||||
|
||||
```sql
|
||||
-- sat_sync_jobs: Almacena los trabajos de sincronización
|
||||
CREATE TABLE sat_sync_jobs (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
type VARCHAR(20) NOT NULL, -- 'initial' | 'daily'
|
||||
status VARCHAR(20) NOT NULL, -- 'pending' | 'running' | 'completed' | 'failed'
|
||||
date_from TIMESTAMP NOT NULL,
|
||||
date_to TIMESTAMP NOT NULL,
|
||||
cfdi_type VARCHAR(20),
|
||||
sat_request_id VARCHAR(100),
|
||||
id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id),
|
||||
contribuyente_id TEXT, -- NULL = modo legacy Horux 360
|
||||
type "SatSyncType" NOT NULL, -- 'initial' | 'daily' | 'incremental'
|
||||
status "SatSyncStatus" NOT NULL, -- 'pending' | 'running' | 'completed' | 'failed'
|
||||
date_from DATE NOT NULL,
|
||||
date_to DATE NOT NULL,
|
||||
cfdi_type "CfdiSyncType", -- 'emitidos' | 'recibidos' (no siempre usado)
|
||||
sat_request_id VARCHAR(50), -- legacy, preferir sat_request_ids
|
||||
sat_package_ids TEXT[],
|
||||
cfdis_found INTEGER DEFAULT 0,
|
||||
cfdis_downloaded INTEGER DEFAULT 0,
|
||||
cfdis_inserted INTEGER DEFAULT 0,
|
||||
cfdis_updated INTEGER DEFAULT 0,
|
||||
progress_percent INTEGER DEFAULT 0,
|
||||
cfdis_found INTEGER NOT NULL DEFAULT 0,
|
||||
cfdis_downloaded INTEGER NOT NULL DEFAULT 0,
|
||||
cfdis_inserted INTEGER NOT NULL DEFAULT 0,
|
||||
cfdis_updated INTEGER NOT NULL DEFAULT 0,
|
||||
progress_percent INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
started_at TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
retry_count INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
-- fiel_credentials: Almacena las credenciales FIEL encriptadas
|
||||
CREATE TABLE fiel_credentials (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id UUID UNIQUE NOT NULL,
|
||||
rfc VARCHAR(13) NOT NULL,
|
||||
cer_data BYTEA NOT NULL,
|
||||
key_data BYTEA NOT NULL,
|
||||
key_password_encrypted BYTEA NOT NULL,
|
||||
encryption_iv BYTEA NOT NULL,
|
||||
encryption_tag BYTEA NOT NULL,
|
||||
serial_number VARCHAR(100),
|
||||
valid_from TIMESTAMP NOT NULL,
|
||||
valid_until TIMESTAMP NOT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
started_at TIMESTAMP(3),
|
||||
completed_at TIMESTAMP(3),
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_retry_at TIMESTAMP(3),
|
||||
is_custom_range BOOLEAN NOT NULL DEFAULT false,
|
||||
sat_request_ids JSONB NOT NULL DEFAULT '{}'
|
||||
);
|
||||
```
|
||||
|
||||
#### Columnas agregadas a tabla cfdis (por tenant)
|
||||
|
||||
```sql
|
||||
ALTER TABLE tenant_xxx.cfdis ADD COLUMN xml_original TEXT;
|
||||
ALTER TABLE tenant_xxx.cfdis ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||
ALTER TABLE tenant_xxx.cfdis ADD COLUMN last_sat_sync TIMESTAMP;
|
||||
ALTER TABLE tenant_xxx.cfdis ADD COLUMN sat_sync_job_id UUID;
|
||||
ALTER TABLE tenant_xxx.cfdis ADD COLUMN source VARCHAR(20) DEFAULT 'manual';
|
||||
```
|
||||
|
||||
## Dependencias
|
||||
|
||||
```json
|
||||
{
|
||||
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
|
||||
"@nodecfdi/credentials": "^2.0.0",
|
||||
"@nodecfdi/cfdi-core": "^1.0.1"
|
||||
}
|
||||
```
|
||||
|
||||
## Flujo de Sincronización
|
||||
|
||||
```
|
||||
1. Usuario configura FIEL (certificado .cer + llave .key + contraseña)
|
||||
↓
|
||||
2. Sistema valida y encripta credenciales (AES-256-GCM)
|
||||
↓
|
||||
3. Usuario inicia sincronización (manual o automática 3:00 AM)
|
||||
↓
|
||||
4. Sistema desencripta FIEL y crea cliente SAT
|
||||
↓
|
||||
5. Por cada mes en el rango:
|
||||
a. Solicitar CFDIs emitidos al SAT
|
||||
b. Esperar respuesta (polling cada 30s)
|
||||
c. Descargar paquetes ZIP
|
||||
d. Extraer y parsear XMLs
|
||||
e. Guardar en BD del tenant
|
||||
f. Repetir para CFDIs recibidos
|
||||
↓
|
||||
6. Marcar job como completado
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### FIEL
|
||||
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/fiel/status` | Estado de la FIEL configurada |
|
||||
| POST | `/api/fiel/upload` | Subir nueva FIEL |
|
||||
| DELETE | `/api/fiel` | Eliminar FIEL |
|
||||
- **Legacy:** `public.fiel_credentials` (una por tenant).
|
||||
- **Por contribuyente:** `fiel_contribuyente` dentro de la base del tenant.
|
||||
|
||||
### Sincronización SAT
|
||||
El sistema intenta primero la FIEL del contribuyente; si no existe, cae a la FIEL del tenant.
|
||||
|
||||
| Método | Ruta | Descripción |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/sat/sync` | Iniciar sincronización |
|
||||
| GET | `/api/sat/sync/status` | Estado actual |
|
||||
| GET | `/api/sat/sync/history` | Historial de syncs |
|
||||
| GET | `/api/sat/sync/:id` | Detalle de un job |
|
||||
| POST | `/api/sat/sync/:id/retry` | Reintentar job fallido |
|
||||
## 4. Tipos de sincronización
|
||||
|
||||
### Parámetros de sincronización
|
||||
| Tipo | Descripción | Cuándo corre |
|
||||
|------|-------------|--------------|
|
||||
| `initial` | Primer sync de un contribuyente/tenant. Descarga XMLs + metadata en bloques. | Manual o cuando no hay un `initial` completado |
|
||||
| `daily` | Sync diaria de los últimos 7 días de XMLs + metadata histórica solo los domingos | Cron 6–10 AM CDMX y retries 9 AM / 4 PM |
|
||||
| `incremental` | Ventana de las últimas 8 horas | Cron 11 AM, 3 PM, 7 PM CDMX (Enterprise) |
|
||||
| Custom range | `daily` con `dateFrom`/`dateTo` explícitos, llamado por el UI | Manual |
|
||||
|
||||
```typescript
|
||||
interface StartSyncRequest {
|
||||
type?: 'initial' | 'daily'; // default: 'daily'
|
||||
dateFrom?: string; // ISO date, ej: "2025-01-01T00:00:00"
|
||||
dateTo?: string; // ISO date, ej: "2025-12-31T23:59:59"
|
||||
}
|
||||
```
|
||||
## 5. Cronograma de jobs
|
||||
|
||||
## Configuración
|
||||
Definidos en `apps/api/src/jobs/sat-sync.job.ts`:
|
||||
|
||||
### Variables de entorno
|
||||
| Job | Expresión | Horario CDMX | Propósito |
|
||||
|-----|-----------|--------------|-----------|
|
||||
| SAT Cron | `0 6-10 * * *` | 6:00–10:00 AM | Daily sync, ~20% de tenants por hora, hasta `SAT_CONCURRENT_CONTRIBUYENTES` paralelos |
|
||||
| Recovery Cron | `0 10 * * *` | 10:00 AM | Recuperar jobs `running` atorados |
|
||||
| Daily Retry | `0 9 * * *` y `0 16 * * *` | 9:00 AM y 4:00 PM | Reintentar daily fallidos |
|
||||
| Incremental Enterprise | `0 11,15,19 * * *` | 11 AM, 3 PM, 7 PM | Sync incremental |
|
||||
| SAT Watchdog | `0 */2 * * *` | Cada 2 horas | Marcar jobs `running` sin heartbeat como failed |
|
||||
| SAT Monitor | `0 */2 * * *` | Cada 2 horas | Alertar por email de jobs fallidos |
|
||||
| SAT Proxy Report | `0 8 * * *` | 8:00 AM | Reporte diario de errores SAT por proxy |
|
||||
|
||||
```env
|
||||
# Clave para encriptar credenciales FIEL (32 bytes hex)
|
||||
FIEL_ENCRYPTION_KEY=tu_clave_de_32_bytes_en_hexadecimal
|
||||
## 6. Flujo de sincronización
|
||||
|
||||
# Zona horaria para el cron
|
||||
TZ=America/Mexico_City
|
||||
```
|
||||
### `processDailySync`
|
||||
|
||||
### Límites del SAT
|
||||
1. Fecha final = ayer a medio día UTC (`getYesterdayEnd()`).
|
||||
2. Ejecuta XMLs emitidos y recibidos de los últimos 7 días.
|
||||
3. **Metadata histórica (desde inicio de año) solo si es domingo en CDMX.**
|
||||
4. Errores 404 se registran pero **no abortan** el daily.
|
||||
5. Errores transitorios (timeout) se reintentan según política.
|
||||
|
||||
- **Antigüedad máxima**: 6 años
|
||||
- **Solicitudes por día**: Limitadas (se reinicia cada 24h)
|
||||
- **Tamaño de paquete**: Variable
|
||||
### `processInitialSync` / custom range
|
||||
|
||||
## Errores Comunes del SAT
|
||||
1. Divide el rango en bloques de 3 o 6 meses para XMLs (según volumen estimado).
|
||||
2. Descarga XMLs emitidos y recibidos por bloque.
|
||||
3. Descarga metadata del rango completo.
|
||||
4. Pausa de 5 segundos entre bloques.
|
||||
|
||||
| Código | Mensaje | Solución |
|
||||
|--------|---------|----------|
|
||||
| 5000 | Solicitud Aceptada | OK - esperar verificación |
|
||||
| 5002 | Límite de solicitudes agotado | Esperar 24 horas |
|
||||
| 5004 | No se encontraron CFDIs | Normal si no hay facturas en el rango |
|
||||
| 5005 | Solicitud duplicada | Ya existe una solicitud pendiente |
|
||||
| - | Información mayor a 6 años | Ajustar rango de fechas |
|
||||
| - | No se permite descarga de cancelados | Facturas canceladas no disponibles |
|
||||
### `processIncrementalSync`
|
||||
|
||||
## Seguridad
|
||||
1. Ventana de 8 horas: `ahora - 10h` a `ahora - 2h`.
|
||||
2. Descarga XMLs + metadata de emitidos y recibidos.
|
||||
|
||||
1. **Encriptación de credenciales**: AES-256-GCM con IV único
|
||||
2. **Almacenamiento seguro**: Certificado, llave y contraseña encriptados
|
||||
3. **Autenticación**: JWT con tenantId embebido
|
||||
4. **Aislamiento**: Cada tenant tiene su propio schema en PostgreSQL
|
||||
## 7. Proxies SAT (rotación por IP)
|
||||
|
||||
## Servicios Systemd
|
||||
Para mitigar el bloqueo `404 Error no controlado` causado por cuota de solicitudes desde una sola IP pública, el sistema soporta un pool de proxies HTTP/HTTPS rotativos.
|
||||
|
||||
### Configuración
|
||||
|
||||
Variables en `apps/api/.env`:
|
||||
|
||||
```bash
|
||||
# API Backend
|
||||
systemctl status horux-api
|
||||
# Lista de proxies separados por coma. Soporta autenticación básica.
|
||||
SAT_PROXY_LIST=http://user:pass@host1:port,http://user:pass@host2:port
|
||||
|
||||
# Web Frontend
|
||||
systemctl status horux-web
|
||||
# Estrategia de rotación: round-robin | random (default: round-robin)
|
||||
SAT_PROXY_STRATEGY=round-robin
|
||||
|
||||
# Si true, cuando todos los proxies fallan se intenta con la IP directa del servidor.
|
||||
SAT_PROXY_FALLBACK_DIRECT=true
|
||||
|
||||
# Máximo de contribuyentes sincronizados en paralelo por el scheduler (default: 10).
|
||||
SAT_CONCURRENT_CONTRIBUYENTES=10
|
||||
```
|
||||
|
||||
## Comandos Útiles
|
||||
### Componentes
|
||||
|
||||
- **`ProxyManager`** (`apps/api/src/services/sat/proxy.service.ts`): parsea `SAT_PROXY_LIST`, rota proxies y crea `HttpsProxyAgent`.
|
||||
- **`sat-client.service.ts`**: usa el agente del proxy en cada petición SOAP al SAT.
|
||||
|
||||
### Reporte diario de errores por proxy
|
||||
|
||||
Cada error de bloqueo/devolución del SAT se guarda en `public.sat_proxy_errors` con el proxy usado.
|
||||
|
||||
Un cron a las **8:00 AM CDMX** envía un email a `ADMIN_EMAIL` con:
|
||||
|
||||
- Total de errores en las últimas 24 horas.
|
||||
- Tabla agrupada por `proxy` + `error_code`.
|
||||
|
||||
Archivos:
|
||||
|
||||
- `apps/api/src/jobs/sat-proxy-report.job.ts` — cron y consulta.
|
||||
- `apps/api/src/services/email/templates/sat-proxy-report.ts` — template del email.
|
||||
- `apps/api/src/services/email/email.service.ts` — `sendSatProxyReport`.
|
||||
|
||||
### Comportamiento
|
||||
|
||||
- Cada llamada a `getNextProxy()` devuelve el siguiente proxy del pool (round-robin).
|
||||
- Cuando el scheduler lanza 10 contribuyentes en paralelo, cada uno tiende a usar una IP distinta.
|
||||
- Si un proxy devuelve error de conexión, el cliente SAT puede caer a la IP directa según `SAT_PROXY_FALLBACK_DIRECT`.
|
||||
|
||||
### Recomendaciones operativas
|
||||
|
||||
- Tamaño mínimo del pool: **1 proxy por cada 5 RFCs** que se sincronicen en paralelo.
|
||||
- Con `SAT_CONCURRENT_CONTRIBUYENTES=10`, un pool de 10 proxies da una IP por RFC en el peor caso.
|
||||
- Monitorear logs por `[ProxyManager] Usando proxy: ...` y por 404 persistentes en una misma IP.
|
||||
|
||||
## 8. Polling y límites
|
||||
|
||||
Después de crear una solicitud (`query`) al SAT, el sistema verifica el estado periódicamente (`verify`).
|
||||
|
||||
Constantes en `sat.service.ts`:
|
||||
|
||||
```ts
|
||||
const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutos entre verificaciones
|
||||
const MAX_POLL_ATTEMPTS = 9; // 9 intentos máximo por solicitud
|
||||
const DAILY_MAX_POLL_ATTEMPTS = 9; // igual para daily
|
||||
const YEARS_TO_SYNC = 6;
|
||||
```
|
||||
|
||||
Esto da un máximo de **~45 minutos por solicitud** (9 × 5 min).
|
||||
|
||||
Cada solicitud al SAT tiene su propio polling; los intentos no se comparten entre solicitudes.
|
||||
|
||||
## 9. Políticas de reintentos
|
||||
|
||||
```ts
|
||||
const RETRY_POLICIES = {
|
||||
daily: { maxRetries: 2, retryAtHours: [6, 12] },
|
||||
custom: { maxRetries: 2, retryAtHours: [6, 12] },
|
||||
initial: { maxRetries: 3, retryAtHours: [6, 12, 24] },
|
||||
incremental: { maxRetries: 0, retryAtHours: [] },
|
||||
};
|
||||
|
||||
const MAX_DAILY_RETRY_ATTEMPTS = 5; // original + 2 automáticos + 2 crons fijos
|
||||
```
|
||||
|
||||
Los reintentos automáticos se programan desde `createdAt` del job. Los reintentos por cron fijo (9 AM / 4 PM) se manejan en `continuePendingDailyRequests`.
|
||||
|
||||
## 10. Manejo de errores
|
||||
|
||||
### Errores no fatales (se registran, no abortan en daily)
|
||||
|
||||
- `404 Error no controlado` en daily se guarda en `errorMessage` como `completedWithWarnings`.
|
||||
- En syncs custom/initial los 404 se capturan por bloque pero el job puede continuar.
|
||||
|
||||
### Errores transitorios (reintentan)
|
||||
|
||||
- Timeout del polling (`SatSyncTimeoutError`).
|
||||
- `SatTransientError` (ej. rechazo transitorio del SAT).
|
||||
- Metadata aún no lista (`SatMetadataPendingError`).
|
||||
|
||||
### Errores fatales (job falla)
|
||||
|
||||
- FIEL inválida o vencida.
|
||||
- Errores que no son transitorios y no están en la lista de no fatales.
|
||||
|
||||
## 11. Errores comunes del SAT
|
||||
|
||||
| Código/Mensaje | Significado | Acción |
|
||||
|----------------|-------------|--------|
|
||||
| `5000` / "Solicitud Aceptada" | OK, hay que esperar | Polling normal |
|
||||
| `5002` / "Solicitudes agotadas de por vida" | Cuota de solicitudes agotada | Omitir rango, esperar 24h |
|
||||
| `5004` / "No se encontró la información" | Sin CFDIs en el rango | Continuar |
|
||||
| `5005` / Duplicada | Solicitud duplicada | Reusar requestId existente |
|
||||
| `404` / "Error no controlado" | Bloqueo/cuota del SAT | Registrar y continuar en daily; reintentar en otros syncs |
|
||||
| "Fecha final invalida" | Fecha futura o mal formada | Usar `getYesterdayEnd()` |
|
||||
| "El certificado no es válido" | FIEL rechazada por el SAT | Revisar vigencia/contraseña de FIEL |
|
||||
|
||||
## 12. Monitoreo y comandos útiles
|
||||
|
||||
### Estado del API
|
||||
|
||||
```bash
|
||||
# Ver logs de sincronización SAT
|
||||
journalctl -u horux-api -f | grep "\[SAT\]"
|
||||
|
||||
# Estado de jobs
|
||||
psql -U postgres -d horux360 -c "SELECT * FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 5;"
|
||||
|
||||
# CFDIs sincronizados por tenant
|
||||
psql -U postgres -d horux360 -c "SELECT COUNT(*) FROM tenant_xxx.cfdis WHERE source = 'sat';"
|
||||
pm2 status horux-api
|
||||
pm2 logs horux-api --lines 50 --nostream
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-01-25
|
||||
|
||||
- Implementación inicial de sincronización SAT
|
||||
- Integración con librería @nodecfdi/sat-ws-descarga-masiva
|
||||
- Soporte para fechas personalizadas en sincronización
|
||||
- Corrección de cast UUID en queries SQL
|
||||
- Agregadas columnas faltantes a tabla cfdis
|
||||
- UI para selección de periodo personalizado
|
||||
- Cambio de servicio web a modo producción (next start)
|
||||
|
||||
## Estado Actual (2026-01-25)
|
||||
|
||||
### Completado
|
||||
|
||||
- [x] Servicio de encriptación de credenciales FIEL
|
||||
- [x] Integración con @nodecfdi/sat-ws-descarga-masiva
|
||||
- [x] Parser de XMLs de CFDI
|
||||
- [x] UI para subir FIEL
|
||||
- [x] UI para ver estado de sincronización
|
||||
- [x] UI para seleccionar periodo personalizado
|
||||
- [x] Cron job para sincronización diaria (3:00 AM)
|
||||
- [x] Soporte para fechas personalizadas
|
||||
- [x] Corrección de cast UUID en queries
|
||||
- [x] Columnas adicionales en tabla cfdis de todos los tenants
|
||||
|
||||
### Pendiente por probar
|
||||
|
||||
El SAT bloqueó las solicitudes por exceso de pruebas. **Esperar 24 horas** y luego:
|
||||
|
||||
1. Ir a **Configuración > SAT**
|
||||
2. Clic en **"Periodo personalizado"**
|
||||
3. Seleccionar: **2025-01-01** a **2025-12-31**
|
||||
4. Clic en **"Sincronizar periodo"**
|
||||
|
||||
### Tenant de prueba
|
||||
|
||||
- **RFC**: HTS240708LJA
|
||||
- **Schema**: `tenant_cas2408138w2`
|
||||
- **Nota**: Los CFDIs "recibidos" de este tenant están cancelados (SAT no permite descargarlos)
|
||||
|
||||
### Comandos para verificar después de 24h
|
||||
### Jobs recientes
|
||||
|
||||
```bash
|
||||
# Ver estado del sync
|
||||
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \
|
||||
"SELECT status, cfdis_found, cfdis_downloaded, cfdis_inserted FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 1;"
|
||||
|
||||
# Ver logs en tiempo real
|
||||
journalctl -u horux-api -f | grep "\[SAT\]"
|
||||
|
||||
# Contar CFDIs sincronizados
|
||||
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \
|
||||
"SELECT COUNT(*) as total FROM tenant_cas2408138w2.cfdis WHERE source = 'sat';"
|
||||
export DATABASE_URL="postgresql://postgres:PASSWORD@localhost:5432/horux360"
|
||||
psql "$DATABASE_URL" -c "
|
||||
SELECT id, type, status, contribuyente_id,
|
||||
cfdis_found, cfdis_downloaded, cfdis_inserted, cfdis_updated,
|
||||
error_message, created_at, completed_at
|
||||
FROM sat_sync_jobs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 20;
|
||||
"
|
||||
```
|
||||
|
||||
### Problemas conocidos
|
||||
### Jobs fallidos o atorados
|
||||
|
||||
1. **"Se han agotado las solicitudes de por vida"**: Límite de SAT alcanzado, esperar 24h
|
||||
2. **"No se permite la descarga de xml que se encuentren cancelados"**: Normal para facturas canceladas
|
||||
3. **"Información mayor a 6 años"**: SAT solo permite descargar últimos 6 años
|
||||
```bash
|
||||
psql "$DATABASE_URL" -c "
|
||||
SELECT id, type, status, tenant_id, contribuyente_id, error_message, created_at, started_at
|
||||
FROM sat_sync_jobs
|
||||
WHERE status IN ('failed', 'running')
|
||||
ORDER BY created_at DESC;
|
||||
"
|
||||
```
|
||||
|
||||
## Próximos Pasos
|
||||
### Contribuyentes de un tenant
|
||||
|
||||
- [ ] Probar sincronización completa después de 24h
|
||||
- [ ] Verificar que los CFDIs se guarden correctamente
|
||||
- [ ] Implementar reintentos automáticos para errores temporales
|
||||
- [ ] Notificaciones por email al completar sincronización
|
||||
- [ ] Dashboard con estadísticas de CFDIs por periodo
|
||||
- [ ] Soporte para filtros adicionales (RFC emisor/receptor, tipo de comprobante)
|
||||
```bash
|
||||
# Reemplazar horux_<tenant_db> por la base del tenant
|
||||
psql "$DATABASE_URL" -c "
|
||||
SELECT c.entidad_id, c.rfc, e.nombre
|
||||
FROM contribuyentes c
|
||||
JOIN entidades_gestionadas e ON c.entidad_id = e.id;
|
||||
"
|
||||
```
|
||||
|
||||
## 13. Changelog reciente
|
||||
|
||||
### 2026-08-03
|
||||
|
||||
- **Proxies SAT rotativos**: integración de `ProxyManager` y pool de proxies HTTP/HTTPS para evitar bloqueo por IP del SAT.
|
||||
- **Concurrencia por contribuyente**: el scheduler daily e incremental procesa hasta `SAT_CONCURRENT_CONTRIBUYENTES=10` RFCs en paralelo, sin importar a cuántos tenants pertenezcan.
|
||||
- **Reporte diario de errores por proxy**: cron a las 8 AM CDMX que envía a `ADMIN_EMAIL` un resumen de errores SAT agrupados por proxy/IP.
|
||||
- Tabla `public.sat_proxy_errors` para trazabilidad de bloqueos por IP.
|
||||
- Variables de entorno: `SAT_PROXY_LIST`, `SAT_PROXY_STRATEGY`, `SAT_PROXY_FALLBACK_DIRECT`, `SAT_CONCURRENT_CONTRIBUYENTES`.
|
||||
|
||||
### 2026-07-31
|
||||
|
||||
- Metadata histórica en daily solo se ejecuta los domingos.
|
||||
- Errores 404 en daily no abortan el proceso; se registran en `error_message`.
|
||||
- Polling reducido a **9 intentos máximos cada 5 minutos** por solicitud.
|
||||
- Daily retry fijo a las 9:00 AM y 4:00 PM CDMX.
|
||||
- Incremental Enterprise a las 11:00 AM, 3:00 PM y 7:00 PM CDMX.
|
||||
|
||||
## 14. Problemas conocidos
|
||||
|
||||
1. **Bloqueo `404 Error no controlado` del SAT**: Aparece cuando se hacen muchas consultas desde la misma IP. Mitigación: proxies rotativos (`SAT_PROXY_LIST`) + concurrencia controlada por contribuyente + metadata histórica solo domingos.
|
||||
2. **FIEL inválida**: Algunos tenants/contribuyentes tienen FIEL rechazada por el SAT. Requiere revisar/renovar FIEL.
|
||||
3. **Jobs `initial` atorados en `running`**: Pueden quedar si el proceso se reinicia; el recovery cron y el watchdog los limpian.
|
||||
|
||||
## 15. Próximos pasos
|
||||
|
||||
- [x] Implementar proxies rotativos para evitar bloqueo por IP del SAT.
|
||||
- [x] Reporte diario de errores por proxy.
|
||||
- [ ] Monitorear tasa de éxito tras proxies + concurrencia por contribuyente.
|
||||
- [ ] Revisar/renovar FIELs inválidas reportadas por el monitor.
|
||||
- [ ] Evaluar ampliar ventana horaria del daily (6–10 AM) si el volumen de RFCs supera el throughput con 10 paralelos.
|
||||
|
||||
@@ -22,5 +22,10 @@
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@nodecfdi/sat-ws-descarga-masiva@2.0.0": "patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
43
patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch
Normal file
43
patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch
Normal file
@@ -0,0 +1,43 @@
|
||||
diff --git a/build/index.js b/build/index.js
|
||||
index bf7a6aafce966c4ab44ba3abb240578cc68779d6..df01102262bbe6d544a5a1b4453977be8a826d0d 100644
|
||||
--- a/build/index.js
|
||||
+++ b/build/index.js
|
||||
@@ -266,7 +266,13 @@ var ServiceConsumer = class _ServiceConsumer {
|
||||
} catch (error) {
|
||||
const webError = error;
|
||||
exception = webError;
|
||||
- response = webError.getResponse();
|
||||
+ if (typeof webError.getResponse !== "function") {
|
||||
+ console.error("[SAT Library] Error no-WebClientException capturado en ServiceConsumer.execute:", error);
|
||||
+ const fallbackResponse = new CResponse(0, String(error && error.message ? error.message : error), {});
|
||||
+ response = fallbackResponse;
|
||||
+ } else {
|
||||
+ response = webError.getResponse();
|
||||
+ }
|
||||
}
|
||||
this.checkErrors(request, response, exception);
|
||||
return response.getBody();
|
||||
@@ -2660,10 +2666,12 @@ var HttpsWebClient = class {
|
||||
_fireRequestClosure;
|
||||
_fireResponseClosure;
|
||||
_timeout;
|
||||
- constructor(onFireRequest, onFireResponse, timeout) {
|
||||
+ _agent;
|
||||
+ constructor(onFireRequest, onFireResponse, timeout, agent = void 0) {
|
||||
this._fireRequestClosure = onFireRequest;
|
||||
this._fireResponseClosure = onFireResponse;
|
||||
this._timeout = timeout;
|
||||
+ this._agent = agent;
|
||||
}
|
||||
fireRequest(request) {
|
||||
if (this._fireRequestClosure) {
|
||||
@@ -2679,7 +2687,8 @@ var HttpsWebClient = class {
|
||||
const options = {
|
||||
method: request.getMethod(),
|
||||
headers: request.getHeaders(),
|
||||
- timeout: this._timeout ?? request.getTimeout() ?? void 0
|
||||
+ timeout: this._timeout ?? request.getTimeout() ?? void 0,
|
||||
+ agent: this._agent
|
||||
};
|
||||
return new Promise((resolve, reject) => {
|
||||
let clientRequest;
|
||||
42
pnpm-lock.yaml
generated
42
pnpm-lock.yaml
generated
@@ -4,6 +4,11 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
patchedDependencies:
|
||||
'@nodecfdi/sat-ws-descarga-masiva@2.0.0':
|
||||
hash: i4ncoh7xgprkdron5l2ech4ifm
|
||||
path: patches/@nodecfdi__sat-ws-descarga-masiva@2.0.0.patch
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -34,7 +39,7 @@ importers:
|
||||
version: 3.2.0(luxon@3.7.2)
|
||||
'@nodecfdi/sat-ws-descarga-masiva':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)
|
||||
version: 2.0.0(patch_hash=i4ncoh7xgprkdron5l2ech4ifm)(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)
|
||||
'@prisma/client':
|
||||
specifier: ^5.22.0
|
||||
version: 5.22.0(prisma@5.22.0)
|
||||
@@ -65,6 +70,9 @@ importers:
|
||||
helmet:
|
||||
specifier: ^8.0.0
|
||||
version: 8.1.0
|
||||
https-proxy-agent:
|
||||
specifier: ^7.0.6
|
||||
version: 7.0.6
|
||||
jsonwebtoken:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.3
|
||||
@@ -1373,6 +1381,10 @@ packages:
|
||||
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
|
||||
engines: {node: '>=12.0'}
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
any-promise@1.3.0:
|
||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||
|
||||
@@ -1658,6 +1670,15 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
@@ -1926,6 +1947,10 @@ packages:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3108,7 +3133,7 @@ snapshots:
|
||||
dependencies:
|
||||
luxon: 3.7.2
|
||||
|
||||
'@nodecfdi/sat-ws-descarga-masiva@2.0.0(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)':
|
||||
'@nodecfdi/sat-ws-descarga-masiva@2.0.0(patch_hash=i4ncoh7xgprkdron5l2ech4ifm)(@nodecfdi/cfdi-core@1.0.1)(luxon@3.7.2)':
|
||||
dependencies:
|
||||
'@nodecfdi/cfdi-core': 1.0.1
|
||||
'@nodecfdi/credentials': 3.2.0(luxon@3.7.2)
|
||||
@@ -3762,6 +3787,8 @@ snapshots:
|
||||
|
||||
adm-zip@0.5.16: {}
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
@@ -4071,6 +4098,10 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
@@ -4393,6 +4424,13 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
Reference in New Issue
Block a user