Compare commits
1 Commits
ba012254db
...
DevMarlene
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07fc9a8fe3 |
@@ -96,7 +96,7 @@ async function saveCfdis(
|
|||||||
estado = $22,
|
estado = $22,
|
||||||
xml_original = $23,
|
xml_original = $23,
|
||||||
last_sat_sync = NOW(),
|
last_sat_sync = NOW(),
|
||||||
sat_sync_job_id = $24::uuid,
|
sat_sync_job_id = $24,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE uuid_fiscal = $1`,
|
WHERE uuid_fiscal = $1`,
|
||||||
cfdi.uuidFiscal,
|
cfdi.uuidFiscal,
|
||||||
@@ -137,7 +137,7 @@ async function saveCfdis(
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
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,
|
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22,
|
||||||
$23, 'sat', $24::uuid, NOW(), NOW()
|
$23, 'sat', $24, NOW(), NOW()
|
||||||
)`,
|
)`,
|
||||||
cfdi.uuidFiscal,
|
cfdi.uuidFiscal,
|
||||||
cfdi.tipo,
|
cfdi.tipo,
|
||||||
@@ -278,18 +278,11 @@ async function processDateRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ejecuta sincronización inicial o por rango personalizado
|
* Ejecuta sincronización inicial (últimos 10 años)
|
||||||
*/
|
*/
|
||||||
async function processInitialSync(
|
async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void> {
|
||||||
ctx: SyncContext,
|
|
||||||
jobId: string,
|
|
||||||
customDateFrom?: Date,
|
|
||||||
customDateTo?: Date
|
|
||||||
): Promise<void> {
|
|
||||||
const ahora = new Date();
|
const ahora = new Date();
|
||||||
// Usar fechas personalizadas si se proporcionan, sino calcular desde YEARS_TO_SYNC
|
const inicioHistorico = new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
|
||||||
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
|
|
||||||
const fechaFin = customDateTo || ahora;
|
|
||||||
|
|
||||||
let totalFound = 0;
|
let totalFound = 0;
|
||||||
let totalDownloaded = 0;
|
let totalDownloaded = 0;
|
||||||
@@ -299,9 +292,9 @@ async function processInitialSync(
|
|||||||
// Procesar por meses para evitar límites del SAT
|
// Procesar por meses para evitar límites del SAT
|
||||||
let currentDate = new Date(inicioHistorico);
|
let currentDate = new Date(inicioHistorico);
|
||||||
|
|
||||||
while (currentDate < fechaFin) {
|
while (currentDate < ahora) {
|
||||||
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
|
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
|
||||||
const rangeEnd = monthEnd > fechaFin ? fechaFin : monthEnd;
|
const rangeEnd = monthEnd > ahora ? ahora : monthEnd;
|
||||||
|
|
||||||
// Procesar emitidos
|
// Procesar emitidos
|
||||||
try {
|
try {
|
||||||
@@ -453,7 +446,7 @@ export async function startSync(
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
if (type === 'initial') {
|
if (type === 'initial') {
|
||||||
await processInitialSync(ctx, job.id, dateFrom, dateTo);
|
await processInitialSync(ctx, job.id);
|
||||||
} else {
|
} else {
|
||||||
await processDailySync(ctx, job.id);
|
await processDailySync(ctx, job.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export default function LoginPage() {
|
|||||||
const response = await login({ email, password });
|
const response = await login({ email, password });
|
||||||
setTokens(response.accessToken, response.refreshToken);
|
setTokens(response.accessToken, response.refreshToken);
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
router.push('/dashboard');
|
|
||||||
|
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
|
||||||
|
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||||
|
|
||||||
|
router.push(seen ? '/dashboard' : '/onboarding');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.message || 'Error al iniciar sesión');
|
setError(err.response?.data?.message || 'Error al iniciar sesión');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
5
apps/web/app/onboarding/page.tsx
Normal file
5
apps/web/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import OnboardingScreen from "../../components/onboarding/OnboardingScreen";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <OnboardingScreen />;
|
||||||
|
}
|
||||||
170
apps/web/components/onboarding/OnboardingScreen.tsx
Normal file
170
apps/web/components/onboarding/OnboardingScreen.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding persistence key.
|
||||||
|
* If you later want this to come from env/config, move it to apps/web/config/onboarding.ts
|
||||||
|
*/
|
||||||
|
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
|
||||||
|
|
||||||
|
export default function OnboardingScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isNewUser, setIsNewUser] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const safePush = (path: string) => {
|
||||||
|
// Avoid multiple navigations if user clicks quickly.
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
router.push(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||||
|
|
||||||
|
// If the user has already seen onboarding, go to dashboard automatically.
|
||||||
|
if (seen) {
|
||||||
|
setIsNewUser(false);
|
||||||
|
setLoading(true);
|
||||||
|
const t = setTimeout(() => router.push('/dashboard'), 900);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const handleContinue = () => {
|
||||||
|
if (typeof window !== 'undefined') localStorage.setItem(STORAGE_KEY, '1');
|
||||||
|
setLoading(true);
|
||||||
|
setTimeout(() => router.push('/dashboard'), 700);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (typeof window !== 'undefined') localStorage.removeItem(STORAGE_KEY);
|
||||||
|
location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerStatus = useMemo(() => (isNewUser ? 'Onboarding' : 'Redirección'), [isNewUser]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen relative overflow-hidden bg-white">
|
||||||
|
{/* Grid tech claro */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.05]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(to right, rgba(15,23,42,.2) 1px, transparent 1px), linear-gradient(to bottom, rgba(15,23,42,.2) 1px, transparent 1px)',
|
||||||
|
backgroundSize: '48px 48px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Glow global azul (sutil) */}
|
||||||
|
<div className="absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-blue-500/20 blur-3xl" />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex min-h-screen items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-4xl">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white shadow-xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-9 w-9 rounded-xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
|
||||||
|
<div className="h-2.5 w-2.5 rounded-full bg-blue-500" />
|
||||||
|
</div>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<p className="text-sm font-semibold text-slate-800">Horux360</p>
|
||||||
|
<p className="text-xs text-slate-500">Pantalla de inicio</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-xs text-slate-500">{headerStatus}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
{isNewUser ? (
|
||||||
|
<div className="grid gap-8 md:grid-cols-2 md:items-center">
|
||||||
|
{/* Left */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl md:text-3xl font-semibold text-slate-900">
|
||||||
|
Bienvenido a Horux360
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm md:text-base text-slate-600 max-w-md">
|
||||||
|
Revisa este breve video para conocer el flujo. Después podrás continuar.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleContinue}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 disabled:opacity-60 disabled:cursor-not-allowed text-white px-6 py-3 rounded-xl font-semibold shadow-md hover:bg-blue-700 hover:shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
{loading ? 'Cargando…' : 'Continuar'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => safePush('/login')}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-5 py-3 rounded-xl font-medium text-slate-700 border border-slate-300 hover:bg-slate-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Ver más
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-xs text-slate-500">
|
||||||
|
Usuario nuevo: muestra video • Usuario recurrente: redirección automática
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right (video) - elegante sin glow */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="h-1 w-full rounded-t-2xl bg-gradient-to-r from-blue-600/80 via-blue-500/40 to-transparent" />
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50 overflow-hidden">
|
||||||
|
<video src="/video-intro.mp4" controls className="w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-blue-500" />
|
||||||
|
Video introductorio
|
||||||
|
</span>
|
||||||
|
<span>v1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-center">
|
||||||
|
<div className="h-12 w-12 rounded-2xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
|
||||||
|
<div className="h-3 w-3 rounded-full bg-blue-500 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-5 text-lg font-semibold text-slate-800">
|
||||||
|
Redirigiendo al dashboard…
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">Usuario recurrente detectado.</p>
|
||||||
|
|
||||||
|
<div className="mt-6 w-full max-w-sm h-2 rounded-full bg-slate-200 overflow-hidden border border-slate-300">
|
||||||
|
<div className="h-full w-2/3 bg-blue-600/80 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="mt-6 text-xs text-slate-500 hover:text-slate-700 underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Ver video otra vez (reset demo)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-xs text-slate-400">
|
||||||
|
Demo UI sin backend • Persistencia local: localStorage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { getSyncStatus, startSync } from '@/lib/api/sat';
|
||||||
import type { SatSyncStatusResponse } from '@horux/shared';
|
import type { SatSyncStatusResponse } from '@horux/shared';
|
||||||
|
|
||||||
@@ -32,9 +30,6 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [startingSync, setStartingSync] = useState(false);
|
const [startingSync, setStartingSync] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [showCustomDate, setShowCustomDate] = useState(false);
|
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
|
||||||
const [dateTo, setDateTo] = useState('');
|
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
const fetchStatus = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -58,21 +53,12 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
|
|||||||
}
|
}
|
||||||
}, [fielConfigured]);
|
}, [fielConfigured]);
|
||||||
|
|
||||||
const handleStartSync = async (type: 'initial' | 'daily', customDates?: boolean) => {
|
const handleStartSync = async (type: 'initial' | 'daily') => {
|
||||||
setStartingSync(true);
|
setStartingSync(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const params: { type: 'initial' | 'daily'; dateFrom?: string; dateTo?: string } = { type };
|
await startSync({ 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();
|
await fetchStatus();
|
||||||
setShowCustomDate(false);
|
|
||||||
onSyncStarted?.();
|
onSyncStarted?.();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Error al iniciar sincronizacion');
|
setError(err.response?.data?.error || 'Error al iniciar sincronizacion');
|
||||||
@@ -176,49 +162,6 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
|
|||||||
<p className="text-sm text-red-500">{error}</p>
|
<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">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -226,27 +169,18 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
|
|||||||
onClick={() => handleStartSync('daily')}
|
onClick={() => handleStartSync('daily')}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
{startingSync ? 'Iniciando...' : 'Sincronizar mes actual'}
|
{startingSync ? 'Iniciando...' : 'Sincronizar ahora'}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
disabled={startingSync || status?.hasActiveSync}
|
|
||||||
onClick={() => setShowCustomDate(!showCustomDate)}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
Periodo personalizado
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{!status?.lastCompletedJob && (
|
||||||
|
<Button
|
||||||
|
disabled={startingSync || status?.hasActiveSync}
|
||||||
|
onClick={() => handleStartSync('initial')}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (10 anos)'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!status?.lastCompletedJob && (
|
|
||||||
<Button
|
|
||||||
disabled={startingSync || status?.hasActiveSync}
|
|
||||||
onClick={() => handleStartSync('initial')}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (6 anos)'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,298 +0,0 @@
|
|||||||
# 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)
|
|
||||||
|
|
||||||
## 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**: CAS2408138W2
|
|
||||||
- **Schema**: `tenant_cas2408138w2`
|
|
||||||
- **Nota**: Los CFDIs "recibidos" de este tenant están cancelados (SAT no permite descargarlos)
|
|
||||||
|
|
||||||
### Comandos para verificar después de 24h
|
|
||||||
|
|
||||||
```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';"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Problemas conocidos
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
## Próximos Pasos
|
|
||||||
|
|
||||||
- [ ] 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)
|
|
||||||
Reference in New Issue
Block a user