feat: SAT sync improvements and documentation

- Add custom date range support for SAT synchronization
- Fix UUID cast in SQL queries for sat_sync_job_id
- Fix processInitialSync to respect custom dateFrom/dateTo parameters
- Add date picker UI for custom period sync
- Add comprehensive documentation for SAT sync implementation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-01-25 03:01:27 +00:00
parent 492cd62772
commit dcc33af523
3 changed files with 338 additions and 20 deletions

View File

@@ -96,7 +96,7 @@ async function saveCfdis(
estado = $22,
xml_original = $23,
last_sat_sync = NOW(),
sat_sync_job_id = $24,
sat_sync_job_id = $24::uuid,
updated_at = NOW()
WHERE uuid_fiscal = $1`,
cfdi.uuidFiscal,
@@ -137,7 +137,7 @@ async function saveCfdis(
) VALUES (
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22,
$23, 'sat', $24, NOW(), NOW()
$23, 'sat', $24::uuid, NOW(), NOW()
)`,
cfdi.uuidFiscal,
cfdi.tipo,
@@ -278,11 +278,18 @@ async function processDateRange(
}
/**
* Ejecuta sincronización inicial (últimos 10 años)
* Ejecuta sincronización inicial o por rango personalizado
*/
async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void> {
async function processInitialSync(
ctx: SyncContext,
jobId: string,
customDateFrom?: Date,
customDateTo?: Date
): Promise<void> {
const ahora = new Date();
const inicioHistorico = new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
// Usar fechas personalizadas si se proporcionan, sino calcular desde YEARS_TO_SYNC
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
const fechaFin = customDateTo || ahora;
let totalFound = 0;
let totalDownloaded = 0;
@@ -292,9 +299,9 @@ async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void
// Procesar por meses para evitar límites del SAT
let currentDate = new Date(inicioHistorico);
while (currentDate < ahora) {
while (currentDate < fechaFin) {
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
const rangeEnd = monthEnd > ahora ? ahora : monthEnd;
const rangeEnd = monthEnd > fechaFin ? fechaFin : monthEnd;
// Procesar emitidos
try {
@@ -446,7 +453,7 @@ export async function startSync(
(async () => {
try {
if (type === 'initial') {
await processInitialSync(ctx, job.id);
await processInitialSync(ctx, job.id, dateFrom, dateTo);
} else {
await processDailySync(ctx, job.id);
}

View File

@@ -3,6 +3,8 @@
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getSyncStatus, startSync } from '@/lib/api/sat';
import type { SatSyncStatusResponse } from '@horux/shared';
@@ -30,6 +32,9 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
const [loading, setLoading] = useState(true);
const [startingSync, setStartingSync] = useState(false);
const [error, setError] = useState('');
const [showCustomDate, setShowCustomDate] = useState(false);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const fetchStatus = async () => {
try {
@@ -53,12 +58,21 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
}
}, [fielConfigured]);
const handleStartSync = async (type: 'initial' | 'daily') => {
const handleStartSync = async (type: 'initial' | 'daily', customDates?: boolean) => {
setStartingSync(true);
setError('');
try {
await startSync({ type });
const params: { type: 'initial' | 'daily'; dateFrom?: string; dateTo?: string } = { type };
if (customDates && dateFrom && dateTo) {
// Convertir a formato completo con hora
params.dateFrom = `${dateFrom}T00:00:00`;
params.dateTo = `${dateTo}T23:59:59`;
}
await startSync(params);
await fetchStatus();
setShowCustomDate(false);
onSyncStarted?.();
} catch (err: any) {
setError(err.response?.data?.error || 'Error al iniciar sincronizacion');
@@ -162,6 +176,49 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
<p className="text-sm text-red-500">{error}</p>
)}
{/* Formulario de fechas personalizadas */}
{showCustomDate && (
<div className="p-4 bg-gray-50 rounded-lg space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="dateFrom">Fecha inicio</Label>
<Input
id="dateFrom"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
max={dateTo || undefined}
/>
</div>
<div>
<Label htmlFor="dateTo">Fecha fin</Label>
<Input
id="dateTo"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
min={dateFrom || undefined}
/>
</div>
</div>
<div className="flex gap-2">
<Button
disabled={startingSync || status?.hasActiveSync || !dateFrom || !dateTo}
onClick={() => handleStartSync('initial', true)}
className="flex-1"
>
{startingSync ? 'Iniciando...' : 'Sincronizar periodo'}
</Button>
<Button
variant="outline"
onClick={() => setShowCustomDate(false)}
>
Cancelar
</Button>
</div>
</div>
)}
<div className="flex gap-3">
<Button
variant="outline"
@@ -169,18 +226,27 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
onClick={() => handleStartSync('daily')}
className="flex-1"
>
{startingSync ? 'Iniciando...' : 'Sincronizar ahora'}
{startingSync ? 'Iniciando...' : 'Sincronizar mes actual'}
</Button>
<Button
variant="outline"
disabled={startingSync || status?.hasActiveSync}
onClick={() => setShowCustomDate(!showCustomDate)}
className="flex-1"
>
Periodo personalizado
</Button>
{!status?.lastCompletedJob && (
<Button
disabled={startingSync || status?.hasActiveSync}
onClick={() => handleStartSync('initial')}
className="flex-1"
>
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (10 anos)'}
</Button>
)}
</div>
{!status?.lastCompletedJob && (
<Button
disabled={startingSync || status?.hasActiveSync}
onClick={() => handleStartSync('initial')}
className="w-full"
>
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (6 anos)'}
</Button>
)}
</CardContent>
</Card>
);

View File

@@ -0,0 +1,245 @@
# Implementación de Sincronización SAT
## Resumen
Sistema de sincronización automática de CFDIs con el SAT (Servicio de Administración Tributaria de México) para Horux360.
## Componentes Implementados
### 1. Backend (API)
#### Servicios
| 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 |
#### Controladores
| Archivo | Descripción |
|---------|-------------|
| `src/controllers/fiel.controller.ts` | Endpoints para gestión de FIEL |
| `src/controllers/sat.controller.ts` | Endpoints para sincronización SAT |
#### Job Programado
| Archivo | Descripción |
|---------|-------------|
| `src/jobs/sat-sync.job.ts` | Cron job para sincronización diaria (3:00 AM) |
### 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)
```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),
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,
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()
);
```
#### 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 |
### Sincronización SAT
| 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 |
### Parámetros de sincronización
```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"
}
```
## Configuración
### Variables de entorno
```env
# Clave para encriptar credenciales FIEL (32 bytes hex)
FIEL_ENCRYPTION_KEY=tu_clave_de_32_bytes_en_hexadecimal
# Zona horaria para el cron
TZ=America/Mexico_City
```
### Límites del SAT
- **Antigüedad máxima**: 6 años
- **Solicitudes por día**: Limitadas (se reinicia cada 24h)
- **Tamaño de paquete**: Variable
## Errores Comunes del SAT
| 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 |
## Seguridad
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
## Servicios Systemd
```bash
# API Backend
systemctl status horux-api
# Web Frontend
systemctl status horux-web
```
## Comandos Útiles
```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';"
```
## 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)
## Próximos Pasos
- [ ] 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)