Compare commits

...

8 Commits

Author SHA1 Message Date
Horux Dev
4e1a100b2a fix(sat-client): format dates in Mexico City timezone for SAT
formatDateForSat now uses Intl.DateTimeFormat with America/Mexico_City
timezone instead of UTC local time. Fixes 'Fecha final invalida' errors
when server runs in UTC and SAT interprets dates as Mexico local time.
2026-04-30 04:31:40 +00:00
Horux Dev
746d00bb66 fix(sat-sync): fallback to central FIEL for despacho tenants without per-contribuyente FIEL 2026-04-30 04:22:11 +00:00
Horux Dev
dd8484a800 feat(sat-sync): iterate contribuyentes for despacho tenants in daily sync
syncTenant now handles despacho mode by:
1. Querying fiel_contribuyente for active FIELs in the tenant DB
2. Running startSync per-contribuyente with contribuyenteId
3. Keeping legacy mode (tenant-wide sync) for Horux 360 tenants
2026-04-30 04:20:36 +00:00
Horux Dev
67cf2ae6fe fix(fiel): detect FIEL for despacho tenants via contribuyentes
hasFielConfigured() now checks both:
1. Legacy mode: fiel_credentials in central DB (Horux 360)
2. Despacho mode: fiel_contribuyente in tenant DB

This fixes the SAT daily sync omitting despacho tenants that have
FIEL loaded per-contribuyente but not in the central fiel_credentials table.
2026-04-30 04:17:18 +00:00
Horux Dev
8e83dd2276 feat(sat-sync): expand incremental sync to all plans except Custom and Mi empresa
- Daily sync already covers all active tenants with FIEL (no plan filter)
- Incremental sync now runs for all plans EXCEPT:
  - 'custom'
  - 'mi_empresa'
  - 'mi_empresa_plus'
- Renamed getEnterpriseTenantsWithFiel -> getIncrementalTenantsWithFiel
- Updated logs to reflect new eligibility
2026-04-30 03:02:19 +00:00
Horux Dev
86c04159b0 fix(deploy): point systemd services to correct repo and use pnpm start
- Update WorkingDirectory from /root/Horux to /root/HoruxDespachos
- Change ExecStart from pnpm dev to pnpm start (production mode)
- Prevents duplicate processes and dev-mode file watching in production
2026-04-30 02:52:43 +00:00
Horux Dev
380fd7ca9f feat(conceptos): add Excel export with extra columns
- Add exportConceptosToExcel function that fetches all filtered
  conceptos (limit 10000) and generates Excel
- Export button now works for both CFDIs and Conceptos tabs
- Extra columns in Conceptos Excel (not visible in table):
  Nombre Emisor, Nombre Receptor, Descuento, IVA Trasladado,
  IVA Retencion, ISR Retencion, Tipo Comprobante, Estatus CFDI
2026-04-30 01:20:08 +00:00
Horux Dev
740a5ac758 feat(cfdi): add hidden Excel columns
Add Subtotal MXN, Saldo Insoluto, Metodo de Pago, Forma de Pago,
ISR Retencion, IVA Retencion, and Descuento to Excel export.
These columns are not visible in the frontend table but included
in both bulk and single CFDI exports.
2026-04-30 01:15:54 +00:00
6 changed files with 222 additions and 26 deletions

View File

@@ -58,18 +58,85 @@ async function needsInitialSync(tenantId: string): Promise<boolean> {
}
/**
* Ejecuta sincronización para un tenant
* Ejecuta sincronización para un tenant.
* Para despachos, itera cada contribuyente con FIEL activa.
* Para legacy (Horux 360), sync a nivel de tenant.
*/
async function syncTenant(tenantId: string): Promise<void> {
try {
// Verificar si hay sync activo
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { rfc: true, databaseName: true },
});
if (!tenant) {
console.log(`[SAT Cron] Tenant ${tenantId} no encontrado, omitiendo`);
return;
}
const isDespacho = isDespachoTenant(tenant.rfc);
if (isDespacho) {
// Modo despacho: iterar contribuyentes con FIEL
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const { rows: contribuyentes } = await pool.query(`
SELECT c.entidad_id as id, c.rfc
FROM contribuyentes c
JOIN fiel_contribuyente f ON f.contribuyente_id = c.entidad_id
WHERE f.is_active = true AND f.valid_until > NOW()
`);
if (contribuyentes.length === 0) {
// Fallback: algunos despachos tienen FIEL a nivel de tenant (legacy) en lugar de per-contribuyente
const fielCentral = await prisma.fielCredential.findUnique({
where: { tenantId },
select: { isActive: true, validUntil: true },
});
if (fielCentral?.isActive && new Date() < fielCentral.validUntil) {
console.log(`[SAT Cron] Tenant ${tenantId} (despacho) sin FIEL per-contribuyente, usando FIEL central como fallback`);
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
return;
}
const needsInitial = await needsInitialSync(tenantId);
const syncType = needsInitial ? 'initial' : 'daily';
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId} (fallback FIEL central)`);
const jobId = await startSync(tenantId, syncType);
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId}`);
} else {
console.log(`[SAT Cron] Tenant ${tenantId} (despacho) sin contribuyentes con FIEL ni FIEL central, omitiendo`);
}
return;
}
for (const contrib of contribuyentes) {
try {
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo contribuyente ${contrib.rfc}`);
continue;
}
const needsInitial = await needsInitialSync(tenantId);
const syncType = needsInitial ? 'initial' : 'daily';
console.log(`[SAT Cron] Iniciando sync ${syncType} para contribuyente ${contrib.rfc} (tenant ${tenantId})`);
const jobId = await startSync(tenantId, syncType, undefined, undefined, contrib.id);
console.log(`[SAT Cron] Job ${jobId} iniciado para contribuyente ${contrib.rfc}`);
} catch (error: any) {
console.error(`[SAT Cron] Error sincronizando contribuyente ${contrib.rfc} (tenant ${tenantId}):`, error.message);
}
}
return;
}
// Modo legacy (Horux 360)
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) {
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
return;
}
// Determinar tipo de sync
const needsInitial = await needsInitialSync(tenantId);
const syncType = needsInitial ? 'initial' : 'daily';
@@ -122,16 +189,25 @@ async function runSyncJob(): Promise<void> {
}
/**
* Obtiene los tenants Enterprise activos con FIEL configurada.
* Planes excluidos del incremental (Custom y Mi empresa no reciben sync frecuente).
*/
async function getEnterpriseTenantsWithFiel(): Promise<string[]> {
const INCREMENTAL_EXCLUDED_PLANS = new Set(['custom', 'mi_empresa', 'mi_empresa_plus']);
/**
* Obtiene los tenants activos con FIEL configurada que son elegibles para sync incremental.
* Incluye todos los planes EXCEPTO Custom y Mi empresa / Mi empresa plus.
*/
async function getIncrementalTenantsWithFiel(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({
where: { active: true, plan: 'enterprise' },
select: { id: true },
where: { active: true },
select: { id: true, plan: true },
});
const result: string[] = [];
for (const tenant of tenants) {
if (INCREMENTAL_EXCLUDED_PLANS.has(tenant.plan || '')) {
continue;
}
if (await hasFielConfigured(tenant.id)) {
result.push(tenant.id);
}
@@ -170,7 +246,8 @@ async function incrementalSyncTenant(tenantId: string): Promise<void> {
}
/**
* Ejecuta el job incremental de 6 horas para todos los tenants Enterprise.
* Ejecuta el job incremental de 6 horas para todos los tenants elegibles.
* Elegibles = todos los planes EXCEPTO Custom y Mi empresa.
*/
async function runIncrementalSyncJob(): Promise<void> {
if (isIncrementalRunning) {
@@ -179,11 +256,11 @@ async function runIncrementalSyncJob(): Promise<void> {
}
isIncrementalRunning = true;
console.log('[SAT Cron Inc] Iniciando ciclo incremental Enterprise');
console.log('[SAT Cron Inc] Iniciando ciclo incremental');
try {
const tenantIds = await getEnterpriseTenantsWithFiel();
console.log(`[SAT Cron Inc] ${tenantIds.length} tenants Enterprise con FIEL`);
const tenantIds = await getIncrementalTenantsWithFiel();
console.log(`[SAT Cron Inc] ${tenantIds.length} tenants elegibles con FIEL`);
if (tenantIds.length === 0) return;
@@ -480,7 +557,7 @@ export function startSatSyncJob(): void {
console.log(`[SAT Cron] Retry programado cada hora`);
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)`);
console.log(`[SAT Cron Inc] Incremental programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[Subscription Cron] Lifecycle programado para: ${SUBSCRIPTION_LIFECYCLE_CRON} (America/Mexico_City)`);
console.log(`[SAT Watchdog] Programado para: ${WATCHDOG_CRON_SCHEDULE} (America/Mexico_City)`);
}

View File

@@ -1,10 +1,11 @@
import { Credential } from '@nodecfdi/credentials/node';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { prisma } from '../config/database.js';
import { prisma, tenantDb } from '../config/database.js';
import { env } from '../config/env.js';
import { encryptFielCredentials, encrypt, decryptFielCredentials } from './sat/sat-crypto.service.js';
import { emailService } from './email/email.service.js';
import { isDespachoTenant } from '@horux/shared';
import type { FielStatus } from '@horux/shared';
/**
@@ -305,9 +306,42 @@ export async function getDecryptedFiel(tenantId: string): Promise<{
}
/**
* Verifica si un tenant tiene FIEL configurada y válida
* Verifica si un tenant tiene FIEL configurada y válida.
* Para despachos, verifica si hay al menos un contribuyente con FIEL activa.
* Para legacy (Horux 360), verifica la FIEL a nivel de tenant.
*/
export async function hasFielConfigured(tenantId: string): Promise<boolean> {
// 1. Intentar FIEL a nivel de tenant (modo legacy Horux 360)
const status = await getFielStatus(tenantId);
return status.configured && !status.isExpired;
if (status.configured && !status.isExpired) {
return true;
}
// 2. Para despachos, verificar FIEL por contribuyente
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { rfc: true, databaseName: true },
});
if (!tenant?.databaseName) {
return false;
}
const isDespacho = isDespachoTenant(tenant.rfc);
if (!isDespacho) {
return false;
}
try {
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const { rows } = await pool.query(`
SELECT 1
FROM fiel_contribuyente
WHERE is_active = true AND valid_until > NOW()
LIMIT 1
`);
return rows.length > 0;
} catch (err: any) {
console.error(`[hasFielConfigured] Error consultando FIEL de contribuyentes para tenant ${tenantId}:`, err.message);
return false;
}
}

View File

@@ -239,10 +239,23 @@ export async function downloadSatPackage(
}
/**
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss)
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss) en hora de México.
* El SAT opera en zona horaria America/Mexico_City (UTC-6 estándar).
*/
function formatDateForSat(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
const pad = (n: string) => n.padStart(2, '0');
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Mexico_City',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).formatToParts(date);
const get = (type: string) => pad(parts.find(p => p.type === type)?.value || '00');
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
}

View File

@@ -5,7 +5,7 @@ import { useDebounce } from '@horux/shared-ui';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui';
import { useCfdis, useCfdiConceptos, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
import { getCfdis, createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
import { getCfdis, createManyCfdis, searchEmisores, searchReceptores, getAllCfdiConceptos, type EmisorReceptor } from '@/lib/api/cfdi';
import { cancelarFactura, downloadPdf, downloadXml } from '@/lib/api/facturacion';
import type { CfdiFilters, CfdiConceptoFilters, TipoCfdi, Cfdi } from '@horux/shared';
import type { CreateCfdiData } from '@/lib/api/cfdi';
@@ -462,9 +462,16 @@ export default function CfdiPage() {
'RFC Receptor': cfdi.rfcReceptor,
'Nombre Receptor': cfdi.nombreReceptor,
'Subtotal': cfdi.subtotal,
'Subtotal MXN': cfdi.subtotalMxn,
'IVA': cfdi.ivaTraslado,
'ISR Retención': cfdi.isrRetencion,
'IVA Retención': cfdi.ivaRetencion,
'Descuento': cfdi.descuento,
'Total': cfdi.total,
'Moneda': cfdi.moneda,
'Método de Pago': cfdi.metodoPago || '',
'Forma de Pago': cfdi.formaPago || '',
'Saldo Insoluto': cfdi.saldoInsoluto || '',
'Estatus': cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado',
'Fecha Cancelación': cfdi.fechaCancelacion
? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX')
@@ -495,6 +502,64 @@ export default function CfdiPage() {
}
};
const exportConceptosToExcel = async () => {
if (!conceptosData?.data.length) return;
setExporting(true);
try {
const allFilters: CfdiConceptoFilters = { ...conceptoFilters, page: 1, limit: 10000 };
const allData = await getAllCfdiConceptos(allFilters);
const rows = allData.data;
if (!rows.length) {
alert('No hay datos para exportar');
return;
}
const exportData = rows.map(c => ({
'Fecha CFDI': c.cfdiFechaEmision ? new Date(c.cfdiFechaEmision).toLocaleDateString('es-MX') : '',
'UUID': c.cfdiUuid || '',
'Tipo Comprobante': formatTipoComprobante(c.cfdiTipoComprobante),
'Estatus CFDI': c.cfdiStatus === 'Vigente' || c.cfdiStatus === '1' ? 'Vigente' : 'Cancelado',
'RFC Emisor': c.cfdiRfcEmisor || '',
'Nombre Emisor': c.cfdiNombreEmisor || '',
'RFC Receptor': c.cfdiRfcReceptor || '',
'Nombre Receptor': c.cfdiNombreReceptor || '',
'Clave ProdServ': c.claveProdServ || '',
'No. Identificación': c.noIdentificacion || '',
'Descripción': c.descripcion,
'Cantidad': c.cantidad,
'Unidad': c.claveUnidad || c.unidad || '',
'Valor Unitario': c.valorUnitario,
'Importe': c.importe,
'Descuento': c.descuento,
'IVA Trasladado': c.ivaTraslado,
'IVA Retención': c.ivaRetencion,
'ISR Retención': c.isrRetencion,
}));
const ws = XLSX.utils.json_to_sheet(exportData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Conceptos');
const colWidths = Object.keys(exportData[0]).map(key => ({
wch: Math.max(key.length, ...exportData.map(row => String(row[key as keyof typeof row]).length))
}));
ws['!cols'] = colWidths;
const excelBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const fileName = `conceptos_${new Date().toISOString().split('T')[0]}.xlsx`;
saveAs(blob, fileName);
} catch (error) {
console.error('Error exporting conceptos:', error);
alert('Error al exportar conceptos');
} finally {
setExporting(false);
}
};
const handleDownloadPdf = async (facturapiId: string | null) => {
if (!facturapiId) return;
try {
@@ -540,9 +605,16 @@ export default function CfdiPage() {
'RFC Receptor': cfdi.rfcReceptor,
'Nombre Receptor': cfdi.nombreReceptor,
'Subtotal': cfdi.subtotal,
'Subtotal MXN': cfdi.subtotalMxn,
'IVA': cfdi.ivaTraslado,
'ISR Retención': cfdi.isrRetencion,
'IVA Retención': cfdi.ivaRetencion,
'Descuento': cfdi.descuento,
'Total': cfdi.total,
'Moneda': cfdi.moneda,
'Método de Pago': cfdi.metodoPago || '',
'Forma de Pago': cfdi.formaPago || '',
'Saldo Insoluto': cfdi.saldoInsoluto || '',
'Estatus': cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado',
'Fecha Cancelación': cfdi.fechaCancelacion
? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX')
@@ -1025,8 +1097,8 @@ export default function CfdiPage() {
</Select>
</div>
<div className="flex gap-2">
{data && data.data.length > 0 && (
<Button variant="outline" onClick={exportToExcel} disabled={exporting}>
{((activeTab === 'cfdis' && data && data.data.length > 0) || (activeTab === 'conceptos' && conceptosData && conceptosData.data.length > 0)) && (
<Button variant="outline" onClick={activeTab === 'cfdis' ? exportToExcel : exportConceptosToExcel} disabled={exporting}>
{exporting ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (

View File

@@ -6,10 +6,10 @@ Wants=postgresql.service
[Service]
Type=simple
User=root
WorkingDirectory=/root/Horux/apps/api
WorkingDirectory=/root/HoruxDespachos/apps/api
Environment=NODE_ENV=production
Environment=PATH=/root/.local/share/pnpm:/usr/local/bin:/usr/bin:/bin
ExecStart=/root/.local/share/pnpm/pnpm dev
ExecStart=/root/.local/share/pnpm/pnpm start
Restart=always
RestartSec=10

View File

@@ -6,10 +6,10 @@ Wants=horux-api.service
[Service]
Type=simple
User=root
WorkingDirectory=/root/Horux/apps/web
WorkingDirectory=/root/HoruxDespachos/apps/web
Environment=NODE_ENV=production
Environment=PATH=/root/.local/share/pnpm:/usr/local/bin:/usr/bin:/bin
ExecStart=/root/.local/share/pnpm/pnpm dev
ExecStart=/root/.local/share/pnpm/pnpm start
Restart=always
RestartSec=10