Compare commits

...

13 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
Horux Dev
e7dbae1ab7 feat: conceptos tab, filters, backfill, facturapi live keys, fixes
- Add Conceptos tab in CFDI page with column filters, sorting, pagination
- Add GET /cfdi/conceptos endpoint with filters and orderBy
- Backfill cfdi_conceptos from legacy XMLs (824k concepts inserted)
- Fix CFDI delete button (bypass subscription check, add alerts)
- Fix export to Excel (fetch all filtered results, limit 10k)
- Fix facturacion page concepto delete bug (immutable updates, unique ids)
- Add Facturapi live key auto-generation and caching
- Fix SAT fechaPagoP parsing
- Add metrics cache support for current year
- Increase DB pool max to 15
2026-04-29 21:03:41 +00:00
Horux Dev
066ba7deda fix: fechaPagoP pipe timestamp, admin redirects, despacho plans, CSF parsing 2026-04-28 22:24:30 +00:00
Horux Dev
3eb0f33f3b feat(timbres): 50 timbres mensuales auto para tenants plan custom 2026-04-28 15:35:05 +00:00
Horux Dev
ee9c76612e feat(facturacion): seccion CFDIs Relacionados para tipos I y E 2026-04-28 15:22:24 +00:00
Horux Dev
066c9cdb74 feat(registro): agrega Mi Empresa y Mi Empresa+ a pagina de registro 2026-04-28 14:52:42 +00:00
37 changed files with 1970 additions and 326 deletions

34
apps/api/.env.save Normal file
View File

@@ -0,0 +1,34 @@
NODE_ENV=development
PORT=4000
DATABASE_URL="postgresql://postgres:ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb@localhost:5432/horux_despachos?schema=public"
JWT_SECRET=n8tbAhp+H2wBKvGqhzq7qgCVqEztby5FzjWZM4oa9epq0CFTgSyvJN8LFLid5J1O
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
CORS_ORIGIN=http://localhost:3000
FRONTEND_URL=https://horuxfin.com
FIEL_ENCRYPTION_KEY=8E46fcpVBbnu0qxEECIE9wrE0nLugDca09YqYBytgY8DTOE0XaJgxrjuS4N6zQ3/
FIEL_STORAGE_PATH=/var/horux/fiel
MP_ACCESS_TOKEN=
MP_WEBHOOK_SECRET=
MP_NOTIFICATION_URL=
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=soporte@horuxfin.com
SMTP_PASS=fumuflfahizubnok
SMTP_FROM=Horux Despachos <soporte@horuxfin.com>
ADMIN_EMAIL=soporte@horuxfin.com
FACTURAPI_USER_KEY=sk_user_8NGmCxP6NT4Wb6QF3NBR9W8kNAvcfDbYXZYzCois5U
FACTURAPI_MODE=live
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_TUNNEL_DOMAIN=tunnel.horux.mx
CONNECTOR_ENCRYPTION_KEY=8E46fcpVBbnu0qxEECIE9wrE0nLugDca09YqYBytgY8DTOE0XaJgxrjuS4N6zQ3/
# Metabase integration
METABASE_URL=http://192.168.10.170:3000
METABASE_USERNAME=ialcarazsalazar@consultoria-as.com
METABASE_PASSWORD=Aasi940812
METABASE_PG_HOST=192.168.10.90
METABASE_PG_PORT=5432
METABASE_PG_USER=postgres
METABASE_PG_PASSWORD=ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb

View File

@@ -71,7 +71,7 @@ class TenantConnectionManager {
user: connectionOverride?.user ?? this.dbConfig.user,
password: connectionOverride?.password ?? this.dbConfig.password,
database: databaseName,
max: 3,
max: 15,
idleTimeoutMillis: 300_000,
connectionTimeoutMillis: 10_000,
};

View File

@@ -38,6 +38,7 @@ const envSchema = z.object({
// Facturapi
FACTURAPI_USER_KEY: z.string().optional(),
FACTURAPI_MODE: z.enum(['test', 'live']).optional(),
// Cloudflare Tunnel (connector BYO-DB)
CLOUDFLARE_API_TOKEN: z.string().optional(),

View File

@@ -85,6 +85,37 @@ export async function getConceptos(req: Request, res: Response, next: NextFuncti
}
}
export async function getAllConceptos(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const filters = {
fechaInicio: req.query.fechaInicio as string,
fechaFin: req.query.fechaFin as string,
tipo: req.query.tipo as any,
tipoComprobante: req.query.tipoComprobante as any,
estado: req.query.estado as any,
rfc: req.query.rfc as string,
search: req.query.search as string,
uuid: req.query.uuid as string,
claveProdServ: req.query.claveProdServ as string,
descripcion: req.query.descripcion as string,
orderBy: req.query.orderBy as 'fecha' | 'importe',
orderDir: req.query.orderDir as 'asc' | 'desc',
contribuyenteId: req.query.contribuyenteId as string,
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 20,
};
const result = await cfdiService.getAllConceptos(req.tenantPool, filters);
res.json(result);
} catch (error) {
next(error);
}
}
export async function drillDown(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
@@ -444,3 +475,21 @@ export async function deleteCfdi(req: Request, res: Response, next: NextFunction
next(error);
}
}
export async function backfillConceptos(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
if (req.user!.role !== 'owner') {
return next(new AppError(403, 'Solo el owner puede ejecutar backfill'));
}
const batchSize = parseInt(req.query.batchSize as string) || 100;
const result = await cfdiService.backfillConceptos(req.tenantPool, batchSize);
res.json(result);
} catch (error) {
next(error);
}
}

View File

@@ -10,7 +10,8 @@ const signupSchema = z.object({
regimenFiscal: z.string().optional(),
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
plan: z.enum(['trial', 'business_control', 'business_cloud']).optional().default('trial'),
plan: z.enum(['trial', 'business_control', 'business_cloud', 'mi_empresa', 'mi_empresa_plus']).optional().default('trial'),
frequency: z.enum(['monthly', 'annual']).optional().default('annual'),
}),
owner: z.object({
nombre: z.string().min(2, 'Nombre del owner requerido'),

View File

@@ -101,6 +101,37 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
const tenantId = effectiveTenantId(req);
const contribuyenteId = req.body.contribuyenteId as string | undefined;
// ── Validar CFDIs relacionados ──
// Cada UUID relacionado debe existir en la BD del tenant, estar vigente,
// y su rfc_receptor debe coincidir con el RFC del receptor de la factura
// que se está emitiendo (customer.taxId).
const relatedDocs: Array<{ relationship: string; uuids: string[] }> = req.body.relatedDocuments || [];
const customerRfc = req.body.customer?.taxId?.toUpperCase()?.trim();
if (relatedDocs.length > 0 && customerRfc) {
const allUuids = relatedDocs.flatMap(r => r.uuids).filter(u => typeof u === 'string' && u.trim() !== '');
for (const uuid of allUuids) {
const { rows } = await req.tenantPool!.query(
`SELECT rfc_receptor, status FROM cfdis WHERE LOWER(uuid) = LOWER($1) LIMIT 1`,
[uuid.trim()],
);
if (rows.length === 0) {
throw new AppError(400, `El CFDI relacionado con UUID ${uuid} no existe en el sistema.`);
}
const relacionado = rows[0];
if (relacionado.status === 'Cancelado' || relacionado.status === '0') {
throw new AppError(400, `El CFDI relacionado con UUID ${uuid} está cancelado.`);
}
const rfcReceptorRelacionado = (relacionado.rfc_receptor || '').toUpperCase().trim();
if (rfcReceptorRelacionado !== customerRfc) {
throw new AppError(
400,
`El CFDI relacionado con UUID ${uuid} no corresponde al RFC del receptor de esta factura. ` +
`RFC esperado: ${customerRfc}. RFC del receptor del CFDI relacionado: ${rfcReceptorRelacionado}.`,
);
}
}
}
// Reservar timbre — si falla emisión en Facturapi, revertimos abajo
const consumedTimbre = await facturapiService.consumeTimbre(tenantId);
@@ -182,6 +213,12 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
[parsed.rfcReceptor, parsed.nombreReceptor || null, parsed.regimenFiscalReceptor || null, req.body.customer?.zip || null],
);
// Extraer relaciones para persistencia en BD (ya declarado arriba en la validación)
const cfdiTipoRelacion = relatedDocs.length > 0 ? relatedDocs[0].relationship : null;
const cfdisRelacionados = relatedDocs.length > 0
? relatedDocs.flatMap(r => r.uuids).join('|')
: null;
await pool.query(`
INSERT INTO cfdis (
year, month, type, uuid, serie, folio, status, fecha_emision, fecha_cert_sat,
@@ -192,7 +229,8 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
iva_traslado, iva_traslado_mxn,
iva_retencion, iva_retencion_mxn,
source, facturapi_id,
contribuyente_id, xml_original
contribuyente_id, xml_original,
cfdi_tipo_relacion, cfdis_relacionados
) VALUES (
$1, $2, 'EMITIDO', $3, $4, $5, 'Vigente', $6, $7,
$8, $9, $10, $11,
@@ -202,7 +240,8 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
$23, $23,
$24, $24,
'facturapi', $25,
$26, $27
$26, $27,
$28, $29
)
`, [
year, month, parsed.uuid, parsed.serie, parsed.folio, fecha, parsed.fechaCertSat,
@@ -214,6 +253,7 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
parsed.ivaRetencion,
invoice.id,
contribuyenteId ?? null, xmlString,
cfdiTipoRelacion, cfdisRelacionados,
]);
// Enviar por email si el receptor tiene email — ruteado a la org correcta

View File

@@ -41,7 +41,7 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
try {
await requireGlobalAdmin(req);
const { nombre, rfc, plan, cfdiLimit, usersLimit, adminEmail, adminNombre, amount } = req.body;
const { nombre, rfc, plan, verticalProfile, frequency, adminEmail, adminNombre, amount } = req.body;
if (!nombre || !rfc || !adminEmail || !adminNombre) {
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
@@ -51,8 +51,8 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
nombre,
rfc,
plan,
cfdiLimit,
usersLimit,
verticalProfile,
frequency,
adminEmail,
adminNombre,
amount: amount || 0,
@@ -69,14 +69,13 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti
await requireGlobalAdmin(req);
const id = String(req.params.id);
const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body;
const { nombre, rfc, plan, verticalProfile, active } = req.body;
const tenant = await tenantsService.updateTenant(id, {
nombre,
rfc,
plan,
cfdiLimit,
usersLimit,
verticalProfile,
active,
});

View File

@@ -3,12 +3,13 @@ import { prisma } from '../config/database.js';
import { startSync, getSyncStatus, retryTimedOutJobs } 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';
import { consultarOpinion, consultarOpinionContribuyente, limpiarOpinionesAntiguas } from '../services/opinion-cumplimiento.service.js';
import { applyPendingChanges, expireTrials } from '../services/payment/subscription.service.js';
import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js';
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.service.js';
import { consultarConstancia, purgeConstanciasAntiguas } from '../services/constancia.service.js';
import { consultarConstancia, consultarConstanciaContribuyente, purgeConstanciasAntiguas } from '../services/constancia.service.js';
import { tenantDb } from '../config/database.js';
import { isDespachoTenant } from '@horux/shared';
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
@@ -57,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';
@@ -121,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);
}
@@ -169,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) {
@@ -178,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;
@@ -216,6 +294,47 @@ async function runOpinionJob(): Promise<void> {
let skipped = 0;
for (const tenant of tenants) {
const isDespacho = isDespachoTenant(tenant.rfc);
if (isDespacho) {
// Modo despacho: iterar contribuyentes con FIEL
try {
const pool = await tenantDb.getPool(tenant.id, 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
`);
if (contribuyentes.length === 0) {
skipped++;
continue;
}
for (const contrib of contribuyentes) {
try {
console.log(`[Opinion Cron] Consultando opinión para contribuyente ${contrib.rfc} (tenant ${tenant.rfc})...`);
await consultarOpinionContribuyente(pool, contrib.id);
success++;
} catch (err: any) {
console.error(`[Opinion Cron] Error para contribuyente ${contrib.rfc}:`, err.message);
failed++;
}
}
const deleted = await limpiarOpinionesAntiguas(pool);
if (deleted > 0) {
console.log(`[Opinion Cron] ${tenant.rfc}: ${deleted} opiniones antiguas eliminadas`);
}
} catch (error: any) {
console.error(`[Opinion Cron] Error despacho ${tenant.rfc}:`, error.message);
failed++;
}
continue;
}
// Modo legacy (Horux 360)
const hasFiel = await hasFielConfigured(tenant.id);
if (!hasFiel) {
skipped++;
@@ -247,7 +366,7 @@ async function runCsfJob(): Promise<void> {
const tenants = await prisma.tenant.findMany({
where: { active: true },
select: { id: true, rfc: true },
select: { id: true, rfc: true, databaseName: true },
});
let success = 0;
@@ -255,6 +374,42 @@ async function runCsfJob(): Promise<void> {
let skipped = 0;
for (const tenant of tenants) {
const isDespacho = isDespachoTenant(tenant.rfc);
if (isDespacho) {
// Modo despacho: iterar contribuyentes con FIEL
try {
const pool = await tenantDb.getPool(tenant.id, 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
`);
if (contribuyentes.length === 0) {
skipped++;
continue;
}
for (const contrib of contribuyentes) {
try {
console.log(`[CSF Cron] Consultando CSF para contribuyente ${contrib.rfc} (tenant ${tenant.rfc})...`);
await consultarConstanciaContribuyente(pool, contrib.id);
success++;
} catch (err: any) {
console.error(`[CSF Cron] Error para contribuyente ${contrib.rfc}:`, err.message);
failed++;
}
}
} catch (error: any) {
console.error(`[CSF Cron] Error despacho ${tenant.rfc}:`, error.message);
failed++;
}
continue;
}
// Modo legacy (Horux 360)
const hasFiel = await hasFielConfigured(tenant.id);
if (!hasFiel) { skipped++; continue; }
try {
@@ -390,7 +545,7 @@ export function startSatSyncJob(): void {
}
}
console.log(`[Subscription Cron] pending aplicados: ${pending.applied} (${pending.errors} errores), trials expirados: ${trials.expired}, timbres reseteados: ${timbres.reset}, declaraciones >5 años borradas: ${declsBorradas}, CSFs >5 años borradas: ${csfsBorradas}`);
console.log(`[Subscription Cron] pending aplicados: ${pending.applied} (${pending.errors} errores), trials expirados: ${trials.expired}, timbres reseteados: ${timbres.reset}, timbres custom creados: ${timbres.customCreated}, declaraciones >5 años borradas: ${declsBorradas}, CSFs >5 años borradas: ${csfsBorradas}`);
} catch (error: any) {
console.error('[Subscription Cron] Error:', error.message);
}
@@ -402,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

@@ -10,6 +10,10 @@ const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
// DELETE: permitir eliminación local sin suscripción activa (operación de limpieza)
router.delete('/:id', cfdiController.deleteCfdi);
router.use(checkPlanLimits);
router.get('/', cfdiController.getCfdis);
@@ -17,12 +21,13 @@ router.get('/resumen', cfdiController.getResumen);
router.get('/emisores', cfdiController.getEmisores);
router.get('/receptores', cfdiController.getReceptores);
router.get('/drill-down', cfdiController.drillDown);
router.get('/conceptos', cfdiController.getAllConceptos);
router.post('/backfill-conceptos', cfdiController.backfillConceptos);
router.get('/:id', cfdiController.getCfdiById);
router.get('/:id/conceptos', cfdiController.getConceptos);
router.get('/:id/xml', cfdiController.getXml);
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
// Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts
router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);
router.delete('/:id', cfdiController.deleteCfdi);
export { router as cfdiRoutes };

View File

@@ -1,7 +1,8 @@
import type { Pool } from 'pg';
import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared';
import type { Cfdi, CfdiFilters, CfdiListResponse, CfdiConceptoFilters, CfdiConceptoListResponse } from '@horux/shared';
import { markForInvalidation } from './metricas.service.js';
import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../utils/saldo.js';
import { parseXml } from './sat/sat-parser.service.js';
// Common SELECT columns mapping DB → camelCase
const CFDI_SELECT = `
@@ -208,6 +209,124 @@ export async function getConceptos(pool: Pool, cfdiId: string): Promise<any[]> {
return rows;
}
export async function getAllConceptos(pool: Pool, filters: CfdiConceptoFilters): Promise<CfdiConceptoListResponse> {
const page = filters.page || 1;
const limit = filters.limit || 20;
const offset = (page - 1) * limit;
let whereClause = 'WHERE 1=1';
const params: any[] = [];
let paramIndex = 1;
if (filters.fechaInicio) {
whereClause += ` AND c.fecha_emision >= $${paramIndex++}::date`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND c.fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
params.push(filters.fechaFin);
}
if (filters.tipoComprobante) {
whereClause += ` AND c.tipo_comprobante = $${paramIndex++}`;
params.push(filters.tipoComprobante);
}
if (filters.estado) {
whereClause += ` AND c.status = $${paramIndex++}`;
params.push(filters.estado);
}
if (filters.rfc) {
whereClause += ` AND (c.rfc_emisor ILIKE $${paramIndex} OR c.rfc_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.rfc}%`);
}
if (filters.search) {
whereClause += ` AND (cc.descripcion ILIKE $${paramIndex} OR cc.clave_prod_serv ILIKE $${paramIndex} OR c.uuid ILIKE $${paramIndex++})`;
params.push(`%${filters.search}%`);
}
if (filters.uuid) {
whereClause += ` AND c.uuid ILIKE $${paramIndex++}`;
params.push(`%${filters.uuid}%`);
}
if (filters.claveProdServ) {
whereClause += ` AND cc.clave_prod_serv ILIKE $${paramIndex++}`;
params.push(`%${filters.claveProdServ}%`);
}
if (filters.descripcion) {
whereClause += ` AND cc.descripcion ILIKE $${paramIndex++}`;
params.push(`%${filters.descripcion}%`);
}
// Contribuyente filter (same logic as getCfdis)
if (filters.contribuyenteId) {
const safeId = filters.contribuyenteId.replace(/[^a-f0-9-]/gi, '');
const { rows: contribRows } = await pool.query(
'SELECT rfc FROM contribuyentes WHERE entidad_id = $1',
[safeId],
);
const rfc = (contribRows[0]?.rfc || '').replace(/[^A-Z0-9]/gi, '').toUpperCase();
if (filters.tipo === 'EMITIDO' && rfc) {
whereClause += ` AND UPPER(c.rfc_emisor) = '${rfc}'`;
} else if (filters.tipo === 'RECIBIDO' && rfc) {
whereClause += ` AND UPPER(c.rfc_receptor) = '${rfc}'`;
} else if (rfc) {
whereClause += ` AND (c.contribuyente_id = '${safeId}' OR UPPER(c.rfc_emisor) = '${rfc}' OR UPPER(c.rfc_receptor) = '${rfc}')`;
}
} else if (filters.tipo) {
whereClause += ` AND c.type = $${paramIndex++}`;
params.push(filters.tipo);
}
// Order by
const orderBy = filters.orderBy === 'importe' ? 'cc.importe' : 'c.fecha_emision';
const orderDir = filters.orderDir === 'asc' ? 'ASC' : 'DESC';
params.push(limit, offset);
const { rows: dataWithCount } = await pool.query(`
SELECT
cc.id, cc.cfdi_id as "cfdiId",
cc.clave_prod_serv as "claveProdServ",
cc.no_identificacion as "noIdentificacion",
cc.descripcion, cc.cantidad,
cc.clave_unidad as "claveUnidad", cc.unidad,
cc.valor_unitario as "valorUnitario",
cc.valor_unitario_mxn as "valorUnitarioMxn",
cc.importe, cc.importe_mxn as "importeMxn",
cc.descuento, cc.descuento_mxn as "descuentoMxn",
cc.isr_retencion as "isrRetencion",
cc.isr_retencion_mxn as "isrRetencionMxn",
cc.iva_traslado as "ivaTraslado",
cc.iva_traslado_mxn as "ivaTrasladoMxn",
cc.iva_retencion as "ivaRetencion",
cc.iva_retencion_mxn as "ivaRetencionMxn",
c.uuid as "cfdiUuid",
c.fecha_emision as "cfdiFechaEmision",
c.rfc_emisor as "cfdiRfcEmisor",
c.nombre_emisor as "cfdiNombreEmisor",
c.rfc_receptor as "cfdiRfcReceptor",
c.nombre_receptor as "cfdiNombreReceptor",
c.tipo_comprobante as "cfdiTipoComprobante",
c.status as "cfdiStatus",
c.type as "cfdiTipo",
COUNT(*) OVER() as total_count
FROM cfdi_conceptos cc
JOIN cfdis c ON c.id = cc.cfdi_id
${whereClause}
ORDER BY ${orderBy} ${orderDir}, cc.id
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`, params);
const total = Number(dataWithCount[0]?.total_count || 0);
const data = dataWithCount.map(({ total_count, ...row }: any) => row);
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
export async function getXmlById(pool: Pool, id: string): Promise<string | null> {
const { rows } = await pool.query(`
SELECT xml_original FROM cfdis WHERE id = $1
@@ -553,7 +672,18 @@ export async function deleteCfdi(pool: Pool, id: string): Promise<void> {
`SELECT fecha_emision, contribuyente_id FROM cfdis WHERE id = $1`,
[id]
);
if (pre.length === 0) {
throw new Error('CFDI no encontrado');
}
try {
await pool.query(`DELETE FROM cfdis WHERE id = $1`, [id]);
} catch (err: any) {
if (err.code === '23503') {
throw new Error('No se puede eliminar este CFDI porque está vinculado a una conciliación u otro registro. Elimina primero las relaciones.');
}
throw err;
}
// Retroactive invalidation hook: mark cached metrics stale for prior-year CFDIs
try {
@@ -632,3 +762,74 @@ export async function getResumenCfdis(pool: Pool, año: number, mes: number, con
ivaAcreditable: Number(r?.iva_acreditable || 0),
};
}
export async function backfillConceptos(pool: Pool, batchSize: number = 100): Promise<{ processed: number; conceptosInserted: number; remaining: number }> {
// CFDIs con XML pero sin conceptos
const { rows: cfdis } = await pool.query(`
SELECT c.id, c.xml_original, c.type
FROM cfdis c
LEFT JOIN cfdi_conceptos cc ON cc.cfdi_id = c.id
WHERE c.xml_original IS NOT NULL AND cc.id IS NULL
LIMIT $1
`, [batchSize]);
let conceptosInserted = 0;
for (const row of cfdis) {
try {
const parsed = parseXml(row.xml_original, row.type === 'RECIBIDO' ? 'recibidos' : 'emitidos');
if (!parsed || !parsed.conceptos || parsed.conceptos.length === 0) continue;
const tc = parsed.tipoCambio || 1;
const m = (v: number) => v * tc;
for (const c of parsed.conceptos) {
await pool.query(`
INSERT INTO cfdi_conceptos (
cfdi_id,
clave_prod_serv, no_identificacion, descripcion, cantidad,
clave_unidad, unidad,
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
descuento, descuento_mxn,
isr_retencion, isr_retencion_mxn,
iva_traslado, iva_traslado_mxn,
iva_retencion, iva_retencion_mxn,
ieps_traslado, ieps_traslado_mxn,
ieps_retencion, ieps_retencion_mxn
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,
$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
$21,$22,$23
)
`, [
row.id,
c.claveProdServ, c.noIdentificacion, c.descripcion, c.cantidad,
c.claveUnidad, c.unidad,
c.valorUnitario, m(c.valorUnitario), c.importe, m(c.importe),
c.descuento, m(c.descuento),
c.isrRetencion, m(c.isrRetencion),
c.ivaTraslado, m(c.ivaTraslado),
c.ivaRetencion, m(c.ivaRetencion),
c.iepsTraslado, m(c.iepsTraslado),
c.iepsRetencion, m(c.iepsRetencion),
]);
conceptosInserted++;
}
} catch (err) {
console.error(`[BackfillConceptos] Error procesando CFDI ${row.id}:`, err);
}
}
const { rows: remainingRow } = await pool.query(`
SELECT COUNT(*) as count
FROM cfdis c
LEFT JOIN cfdi_conceptos cc ON cc.cfdi_id = c.id
WHERE c.xml_original IS NOT NULL AND cc.id IS NULL
`);
return {
processed: cfdis.length,
conceptosInserted,
remaining: Number(remainingRow[0]?.count || 0),
};
}

View File

@@ -299,8 +299,9 @@ export async function consultarConstanciaContribuyente(
function cleanDomField(val: string | undefined): string {
if (!val) return '';
// Remove embedded label prefixes like "Nombre de la Colonia: "
// Labels must be ordered from longest to shortest to avoid partial matches.
return val
.replace(/^.*(?:Nombre de la Colonia|Nombre del Municipio|Nombre de la Localidad|Nombre de la Entidad|Número Exterior|Número Interior|Tipo de Vialidad|Entre Calle|Y Calle|Código Postal)\s*:\s*/i, '')
.replace(/^.*(?:Nombre del Municipio o Demarcación Territorial|Nombre de la Entidad Federativa|Nombre de la Colonia|Nombre del Municipio|Nombre de la Localidad|Número Exterior|Número Interior|Tipo de Vialidad|Entre Calle|Y Calle|Código Postal)\s*:\s*/i, '')
.trim();
}

View File

@@ -8,10 +8,37 @@ function getUserClient(): Facturapi {
return new Facturapi(env.FACTURAPI_USER_KEY);
}
async function getOrgApiKey(orgId: string): Promise<string> {
async function getOrgApiKey(pool: Pool, orgId: string): Promise<string> {
const userKey = env.FACTURAPI_USER_KEY!;
let apiKey: string | undefined;
// 1. Reutilizar Live Secret Key si ya fue guardada en BD (evita PUT en cada emit)
const { rows: [orgRow] } = await pool.query(
'SELECT api_key FROM facturapi_orgs WHERE facturapi_org_id = $1 LIMIT 1',
[orgId],
);
if (orgRow?.api_key) {
return orgRow.api_key;
}
// 2. Obtener Live Secret Key vía PUT /apikeys/live (idempotente)
let apiKey: string | undefined;
try {
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${userKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (res.ok) {
const key = await res.text();
apiKey = key.replace(/"/g, '').trim();
}
} catch { /* fallback below */ }
// 3. Fallback a test key si el PUT falla (modo desarrollo/prueba)
if (!apiKey) {
try {
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, {
headers: { 'Authorization': `Bearer ${userKey}` },
@@ -21,20 +48,18 @@ async function getOrgApiKey(orgId: string): Promise<string> {
apiKey = key.replace(/"/g, '').trim();
}
} catch { /* no test key */ }
if (!apiKey) {
try {
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys`, {
headers: { 'Authorization': `Bearer ${userKey}` },
});
if (res.ok) {
const data = await res.json() as any;
if (data?.data?.length > 0) apiKey = data.data[0].api_key;
}
} catch { /* no live keys */ }
}
if (!apiKey) throw new Error('Organización Facturapi sin API key');
// 4. Guardar en BD para reutilizar en siguientes emisiones
if (apiKey.startsWith('sk_live_')) {
await pool.query(
'UPDATE facturapi_orgs SET api_key = $1 WHERE facturapi_org_id = $2',
[apiKey, orgId],
);
}
return apiKey;
}
@@ -180,7 +205,7 @@ export async function getOrgClientContribuyente(
);
if (rows.length === 0) throw new Error('Contribuyente no tiene organización Facturapi configurada');
const apiKey = await getOrgApiKey(rows[0].facturapi_org_id);
const apiKey = await getOrgApiKey(pool, rows[0].facturapi_org_id);
return new Facturapi(apiKey);
}
@@ -308,10 +333,11 @@ export async function createInvoiceContribuyente(
if (data.series) invoicePayload.series = data.series;
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
// Documentos relacionados (Ingreso / Egreso)
if (data.relatedDocuments?.length) {
invoicePayload.related_documents = data.relatedDocuments.map((r: any) => ({
relationship: r.relationship,
documents: [r.uuid],
documents: r.uuids || [r.uuid],
}));
}

View File

@@ -299,10 +299,11 @@ export async function calcularIngresosPorRegimen(
const ignorados = await getIgnorados(tenantId, _ignorados);
const descMap = await getDescMap(_descMap);
// Read-through cache: si el rango cae en años pasados con meses completos
// y hay un contribuyente seleccionado, lee de metricas_mensuales. Si hit,
// retorna de inmediato (evita ~3 queries SQL por régimen).
const cacheRange = planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId);
// Read-through cache: solo si flags son default (true) para garantizar
// que las queries filtradas no lean datos del caché completo.
const cacheRange = considerarActivos && considerarNCs
? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId)
: null;
if (cacheRange) {
const cached = await readIngresosFromCache(pool, cacheRange, ignorados, descMap);
if (cached) return cached;
@@ -474,8 +475,11 @@ export async function calcularEgresosPorRegimen(
const ignorados = await getIgnorados(tenantId, _ignorados);
const descMap = await getDescMap(_descMap);
// Read-through cache: ver nota en calcularIngresosPorRegimen.
const cacheRange = planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId);
// Read-through cache: solo si flags son default (true) para garantizar
// que las queries filtradas no lean datos del caché completo.
const cacheRange = considerarActivos && considerarNCs
? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId)
: null;
if (cacheRange) {
const cached = await readEgresosFromCache(pool, cacheRange, ignorados, descMap);
if (cached) return cached;

View File

@@ -23,7 +23,7 @@ export async function signupDespacho(data: DespachoSignupRequest) {
data: {
nombre: despacho.nombre,
rfc: tenantSlug.toUpperCase(),
plan: 'enterprise',
plan: (despacho.plan === 'trial' ? 'enterprise' : despacho.plan) as any,
databaseName: databaseName,
cfdiLimit: -1,
usersLimit: -1,
@@ -103,7 +103,7 @@ export async function signupDespacho(data: DespachoSignupRequest) {
const result2 = await subscriptionService.subscribe({
tenantId: result.tenant.id,
plan: data.despacho.plan as any,
frequency: 'annual',
frequency: data.despacho.frequency ?? 'annual',
payerEmail: owner.email,
});
paymentUrl = result2.paymentUrl;

View File

@@ -20,7 +20,7 @@ function getUserClient(): Facturapi {
async function getOrgClient(tenantId: string): Promise<Facturapi> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { facturapiOrgId: true },
select: { id: true, facturapiOrgId: true, facturapiOrgKey: true },
});
if (!tenant?.facturapiOrgId) {
@@ -31,9 +31,30 @@ async function getOrgClient(tenantId: string): Promise<Facturapi> {
const orgId = tenant.facturapiOrgId;
const userKey = env.FACTURAPI_USER_KEY!;
let apiKey: string | undefined;
// 1. Reutilizar Live Secret Key si ya fue guardada en BD (evita PUT en cada emit)
if (tenant.facturapiOrgKey) {
return new Facturapi(tenant.facturapiOrgKey);
}
// Intentar test key primero (más común en desarrollo)
// 2. Obtener Live Secret Key vía PUT /apikeys/live (idempotente)
let apiKey: string | undefined;
try {
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${userKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
if (res.ok) {
const key = await res.text();
apiKey = key.replace(/"/g, '').trim();
}
} catch { /* fallback below */ }
// 3. Fallback a test key si el PUT falla (modo desarrollo/prueba)
if (!apiKey) {
try {
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, {
headers: { 'Authorization': `Bearer ${userKey}` },
@@ -43,24 +64,20 @@ async function getOrgClient(tenantId: string): Promise<Facturapi> {
apiKey = key.replace(/"/g, '').trim();
}
} catch { /* no test key */ }
// Fallback a live key
if (!apiKey) {
try {
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys`, {
headers: { 'Authorization': `Bearer ${userKey}` },
});
if (res.ok) {
const data = await res.json() as any;
if (data?.data?.length > 0) apiKey = data.data[0].api_key;
}
} catch { /* no live keys */ }
}
if (!apiKey) {
throw new Error('Organización Facturapi sin API key');
}
// 4. Guardar en BD para reutilizar en siguientes emisiones
if (tenant && apiKey.startsWith('sk_live_')) {
await prisma.tenant.update({
where: { id: tenant.id },
data: { facturapiOrgKey: apiKey },
});
}
return new Facturapi(apiKey);
}
@@ -247,7 +264,7 @@ export interface FacturapiInvoiceData {
series?: string;
folioNumber?: number;
conditions?: string;
relatedDocuments?: Array<{ uuid: string; relationship: string }>;
relatedDocuments?: Array<{ relationship: string; uuids: string[] }>;
/**
* Régimen fiscal del emisor (override del default de la organización).
* Requerido cuando el contribuyente tiene múltiples régimenes y Facturapi
@@ -308,11 +325,11 @@ export async function createInvoice(
if (data.series) invoiceData.series = data.series;
if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
// Documentos relacionados (Egreso)
// Documentos relacionados (Ingreso / Egreso)
if (data.relatedDocuments?.length) {
invoiceData.related_documents = data.relatedDocuments.map(r => ({
relationship: r.relationship,
documents: [r.uuid],
documents: r.uuids,
}));
}
@@ -643,12 +660,18 @@ export async function refundTimbre(
* resetea `timbresUsados=0` y avanza la ventana un mes (tipo='mensual') o un año
* (tipo='anual'). Usado por cron diario. Idempotente: si no hay vencidas, no-op.
*
* Además, tenants con plan 'custom' reciben 50 timbres mensuales
* automáticamente. Si no tienen TimbreSuscripcion, se les crea; si tienen una
* vencida, se resetea; si tienen una vigente, no se toca.
*
* Los paquetes adicionales NO se tocan aquí — su vigencia es 1 año fijo desde
* la compra y el filtro `expiraEn > now` los excluye automáticamente cuando
* caducan.
*/
export async function resetExpiredMonthlyTimbres(): Promise<{ reset: number }> {
export async function resetExpiredMonthlyTimbres(): Promise<{ reset: number; customCreated: number }> {
const now = new Date();
// 1) Resetear suscripciones vencidas (comportamiento original)
const vencidas = await prisma.timbreSuscripcion.findMany({
where: { periodoFin: { lt: now } },
});
@@ -678,7 +701,60 @@ export async function resetExpiredMonthlyTimbres(): Promise<{ reset: number }> {
console.log(`[Timbres] Reset mensual tenant ${s.tenantId}: nuevo periodo ${nextInicio.toISOString().split('T')[0]}${nextFin.toISOString().split('T')[0]}`);
}
return { reset: count };
// 2) Garantizar 50 timbres mensuales para tenants con plan 'custom'
const customTenants = await prisma.tenant.findMany({
where: { plan: 'custom', active: true },
select: { id: true },
});
let customCreated = 0;
for (const t of customTenants) {
const existing = await prisma.timbreSuscripcion.findUnique({
where: { tenantId: t.id },
});
if (!existing) {
// Crear suscripción mensual de 50 timbres
const inicio = new Date();
const fin = new Date(inicio);
fin.setMonth(fin.getMonth() + 1);
fin.setDate(fin.getDate() - 1);
await prisma.timbreSuscripcion.create({
data: {
tenantId: t.id,
tipo: 'mensual',
timbresLimite: 50,
timbresUsados: 0,
periodoInicio: inicio,
periodoFin: fin,
},
});
customCreated++;
console.log(`[Timbres] Custom tenant ${t.id}: creada suscripción de 50 timbres mensuales`);
} else if (existing.periodoFin < now) {
// Vencida: resetear con nuevo periodo mensual
const inicio = new Date();
const fin = new Date(inicio);
fin.setMonth(fin.getMonth() + 1);
fin.setDate(fin.getDate() - 1);
await prisma.timbreSuscripcion.update({
where: { id: existing.id },
data: {
timbresUsados: 0,
periodoInicio: inicio,
periodoFin: fin,
},
});
count++;
customCreated++;
console.log(`[Timbres] Custom tenant ${t.id}: reseteada suscripción de 50 timbres mensuales`);
}
// Si existing.periodoFin >= now, está vigente — no se toca
}
return { reset: count, customCreated };
}
// ============================================

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

@@ -353,11 +353,12 @@ export async function getIvaMensual(
considerarActivos: boolean = true,
considerarNCs: boolean = true,
): Promise<IvaMensual[]> {
// Cache read-through: solo si año pasado, sin conciliación, con contribuyente y flags default
const currentYear = new Date().getFullYear();
// Cache read-through: sin conciliación, con contribuyente y flags default.
// El año actual YA NO se excluye: si los meses están precalculados en
// metricas_mensuales se usan; si faltan, readIvaMensualFromCache retorna
// null y caemos al path on-the-fly.
const cacheable =
process.env.METRICAS_BYPASS_CACHE !== '1' &&
año < currentYear &&
!conciliacion &&
considerarActivos &&
considerarNCs &&
@@ -607,25 +608,20 @@ async function readResumenIvaFromCache(
const resultado = trasladado - acreditable - retenido;
// Acumulado anual: su rango (year-01-01 → fechaFin) difiere del rango cacheado;
// se calcula on-the-fly contra raw cfdis. Una sola query.
// Acumulado anual: en vez de consultar raw cfdis, sumamos desde
// metricas_mensuales (enero → fechaFin). Como planCache garantiza que
// fechaFin es último día de mes, el acumulado coincide mes a mes.
const añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
const REGIMEN_TENANT = regimenTenantExpr(ctx);
const acumRow = (await pool.query(`
const acumEnd = `${añoInicio}-${String(new Date(fechaFin + 'T00:00:00').getMonth() + 1).padStart(2, '0')}-01`;
const { rows: [acumRow] } = await pool.query(`
SELECT
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
(
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
) as total
FROM cfdis
WHERE ${VIGENTE}
AND (${REGIMEN_TENANT}) = ANY($3)
AND ${acumFR}
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])).rows[0];
COALESCE(SUM(iva_trasladado_total), 0) -
COALESCE(SUM(iva_acreditable), 0) -
COALESCE(SUM(iva_retenido_cobrado), 0) as total
FROM metricas_mensuales
WHERE contribuyente_id = $1
AND make_date(anio, mes, 1) BETWEEN $2::date AND $3::date
`, [range.contribuyenteId, `${añoInicio}-01-01`, acumEnd]);
return {
trasladado,

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

@@ -322,7 +322,7 @@ function extractPagos(comprobante: any): {
}
}
result.fechaPagoP = fechas.length > 0 ? fechas.join('|') : null;
result.fechaPagoP = fechas.length > 0 ? fechas[0] : null;
result.numParcialidad = parcialidades.length > 0 ? parcialidades.join('|') : null;
result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null;
result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null;

View File

@@ -22,6 +22,7 @@ const MAX_POLL_ATTEMPTS = 45; // 45 minutos máximo (45 × 60s)
const MAX_RETRIES = 3; // Máximo de reintentos tras timeout
const RETRY_DELAY_HOURS = 6; // Horas entre reintentos
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
const SAT_DATE_SAFETY_MARGIN_DAYS = 7; // Margen de seguridad para evitar rechazo por límite de 6 años
interface SyncContext {
fielData: FielData;
@@ -666,7 +667,11 @@ async function processInitialSync(
customDateTo?: Date
): Promise<void> {
const ahora = new Date();
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
// Calcular límite histórico respetando exactamente 6 años + margen de seguridad
const maxHistorical = new Date(ahora);
maxHistorical.setFullYear(maxHistorical.getFullYear() - YEARS_TO_SYNC);
maxHistorical.setDate(maxHistorical.getDate() + SAT_DATE_SAFETY_MARGIN_DAYS);
const inicioHistorico = customDateFrom || new Date(maxHistorical.getFullYear(), maxHistorical.getMonth(), 1);
const fechaFin = customDateTo || ahora;
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
@@ -983,13 +988,19 @@ export async function startSync(
}
const now = new Date();
// Calcular dateFrom por defecto respetando el límite de 6 años del SAT
const defaultDateFrom = new Date(now);
defaultDateFrom.setFullYear(defaultDateFrom.getFullYear() - YEARS_TO_SYNC);
defaultDateFrom.setDate(defaultDateFrom.getDate() + SAT_DATE_SAFETY_MARGIN_DAYS);
defaultDateFrom.setDate(1); // Truncar al primer día del mes
const job = await prisma.satSyncJob.create({
data: {
tenantId,
contribuyenteId: contribuyenteId || null,
type,
status: 'running',
dateFrom: dateFrom || new Date(now.getFullYear() - YEARS_TO_SYNC, 0, 1),
dateFrom: dateFrom || defaultDateFrom,
dateTo: dateTo || now,
startedAt: now,
},
@@ -1009,7 +1020,7 @@ export async function startSync(
(async () => {
try {
if (type === 'initial') {
await processInitialSync(ctx, job.id, dateFrom, dateTo);
await processInitialSync(ctx, job.id, job.dateFrom, job.dateTo);
} else if (type === 'incremental') {
await processIncrementalSync(ctx, job.id);
} else if (dateFrom && dateTo) {

View File

@@ -1,5 +1,5 @@
import { prisma, tenantDb } from '../config/database.js';
import { PLANS } from '@horux/shared';
import { PLANS, DESPACHO_PLANS } from '@horux/shared';
import { emailService } from './email/email.service.js';
import * as metabaseService from './metabase.service.js';
import { randomBytes } from 'crypto';
@@ -42,18 +42,47 @@ export async function getTenantById(id: string) {
export async function createTenant(data: {
nombre: string;
rfc: string;
plan?: 'starter' | 'business' | 'enterprise';
cfdiLimit?: number;
usersLimit?: number;
plan?: string;
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
frequency?: 'monthly' | 'annual';
adminEmail: string;
adminNombre: string;
amount: number;
}) {
const plan = data.plan || 'starter';
const planConfig = PLANS[plan];
const plan = data.plan || 'trial';
const despachoPlans = ['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud', 'custom'];
const isDespacho = despachoPlans.includes(plan);
let rfc = data.rfc.toUpperCase();
let cfdiLimit: number;
let usersLimit: number;
let dbMode: 'BYO' | 'MANAGED' | undefined;
let trialEndsAt: Date | undefined;
let timbresIncluidos = 0;
if (isDespacho) {
// Normalizar RFC con prefijo DESPACHO_
if (!rfc.startsWith('DESPACHO_')) {
rfc = `DESPACHO_${rfc}`;
}
const despachoConfig = (DESPACHO_PLANS as Record<string, { maxRfcs: number; maxUsers: number; dbMode: 'BYO' | 'MANAGED'; timbresIncluidosMes: number }>)[plan];
if (!despachoConfig) throw new Error(`Plan despacho desconocido: ${plan}`);
cfdiLimit = -1;
usersLimit = -1;
dbMode = despachoConfig.dbMode;
timbresIncluidos = despachoConfig.timbresIncluidosMes;
if (plan === 'trial') {
trialEndsAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
}
} else {
const planConfig = PLANS[plan as keyof typeof PLANS];
if (!planConfig) throw new Error(`Plan desconocido: ${plan}`);
cfdiLimit = planConfig.cfdiLimit;
usersLimit = planConfig.usersLimit;
}
// 1. Provision a dedicated database for this tenant
const databaseName = await tenantDb.provisionDatabase(data.rfc);
const databaseName = await tenantDb.provisionDatabase(rfc);
// 1b. Register tenant database in Metabase (non-blocking, logs errors only)
metabaseService.registerDatabase({
@@ -65,11 +94,14 @@ export async function createTenant(data: {
const tenant = await prisma.tenant.create({
data: {
nombre: data.nombre,
rfc: data.rfc.toUpperCase(),
plan,
rfc,
plan: plan as any,
databaseName,
cfdiLimit: data.cfdiLimit || planConfig.cfdiLimit,
usersLimit: data.usersLimit || planConfig.usersLimit,
cfdiLimit,
usersLimit,
...(data.verticalProfile && { verticalProfile: data.verticalProfile as any }),
...(dbMode && { dbMode: dbMode as any }),
...(trialEndsAt && { trialEndsAt }),
}
});
@@ -101,28 +133,48 @@ export async function createTenant(data: {
},
});
// 4. Create initial subscription
// 4. Create timbre subscription for despacho plans (if applicable)
if (isDespacho && timbresIncluidos > 0) {
const inicio = new Date();
const fin = new Date(inicio);
fin.setMonth(fin.getMonth() + 1);
fin.setDate(fin.getDate() - 1);
await prisma.timbreSuscripcion.create({
data: {
tenantId: tenant.id,
tipo: 'mensual',
timbresLimite: timbresIncluidos,
timbresUsados: 0,
periodoInicio: inicio,
periodoFin: fin,
},
});
}
// 5. Create subscription (only for paid plans)
if (!isDespacho || (plan !== 'trial' && plan !== 'custom')) {
await prisma.subscription.create({
data: {
tenantId: tenant.id,
plan,
plan: plan as any,
status: 'pending',
amount: data.amount,
frequency: 'monthly',
frequency: data.frequency || 'monthly',
},
});
}
// 5. Send welcome email to client (non-blocking)
// 6. Send welcome email to client (non-blocking)
emailService.sendWelcome(data.adminEmail, {
nombre: data.adminNombre,
email: data.adminEmail,
tempPassword,
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
// 6. Send new client notification to admin with DB credentials
// 7. Send new client notification to admin with DB credentials
emailService.sendNewClientAdmin({
clienteNombre: data.nombre,
clienteRfc: data.rfc.toUpperCase(),
clienteRfc: rfc,
adminEmail: data.adminEmail,
adminNombre: data.adminNombre,
tempPassword,
@@ -265,9 +317,8 @@ export async function getMyTenantsDetailed(userId: string, onlyOwner = true) {
export async function updateTenant(id: string, data: {
nombre?: string;
rfc?: string;
plan?: 'starter' | 'business' | 'enterprise';
cfdiLimit?: number;
usersLimit?: number;
plan?: string;
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
active?: boolean;
}) {
return prisma.tenant.update({
@@ -275,9 +326,8 @@ export async function updateTenant(id: string, data: {
data: {
...(data.nombre && { nombre: data.nombre }),
...(data.rfc && { rfc: data.rfc.toUpperCase() }),
...(data.plan && { plan: data.plan }),
...(data.cfdiLimit !== undefined && { cfdiLimit: data.cfdiLimit }),
...(data.usersLimit !== undefined && { usersLimit: data.usersLimit }),
...(data.plan && { plan: data.plan as any }),
...(data.verticalProfile && { verticalProfile: data.verticalProfile as any }),
...(data.active !== undefined && { active: data.active }),
},
select: {

View File

@@ -9,9 +9,13 @@ export interface CacheRange {
* (read-through cache de Tanda B). Requisitos:
* - `contribuyenteId` presente (la tabla solo tiene datos por contribuyente)
* - `conciliacion` desactivada (la tabla guarda flujo normal, no id_conciliacion)
* - `fechaFin` antes del primer día del año actual (años cerrados)
* - `fechaInicio` es día 1 del mes; `fechaFin` es último día del mes
*
* El año actual YA NO se excluye: si los meses están precalculados en
* metricas_mensuales, se usan; si faltan, read*FromCache retorna null y el
* caller cae a on-the-fly. Esto permite cachear 2026 y años futuros sin
* modificar esta función cada año.
*
* Retorna `null` si no califica — el caller debe caer al path on-the-fly.
*/
export function planCache(
@@ -27,8 +31,8 @@ export function planCache(
const fi = new Date(fechaInicio + 'T00:00:00Z');
const ff = new Date(fechaFin + 'T00:00:00Z');
if (isNaN(fi.getTime()) || isNaN(ff.getTime())) return null;
const currentYearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
if (ff >= currentYearStart) return null;
// Ya no se excluye el año actual: si hay datos precalculados se usan,
// si no, read*FromCache retorna null y el caller cae a on-the-fly.
if (fi.getUTCDate() !== 1) return null;
const lastDay = new Date(Date.UTC(ff.getUTCFullYear(), ff.getUTCMonth() + 1, 0)).getUTCDate();
if (ff.getUTCDate() !== lastDay) return null;

View File

@@ -7,7 +7,7 @@ import Image from 'next/image';
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
import { login } from '@/lib/api/auth';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared';
export default function LoginPage() {
const router = useRouter();
@@ -30,13 +30,7 @@ export default function LoginPage() {
setUser(response.user);
const userRole = response.user?.role;
// Admin global aterriza directo en `/clientes` — su home natural es la
// gestión de tenants, no el dashboard operativo del despacho.
const platformRoles = (response.user as { platformRoles?: PlatformRole[] }).platformRoles;
const isGlobalAdmin = isGlobalAdminRfc(response.user?.tenantRfc, userRole, platformRoles);
if (isGlobalAdmin) {
router.push('/clientes');
} else if (userRole === 'cliente' || userRole === 'auxiliar' || userRole === 'supervisor') {
if (userRole === 'cliente' || userRole === 'auxiliar' || userRole === 'supervisor') {
// Clients and roles without onboarding go straight to dashboard
router.push('/dashboard');
} else {

View File

@@ -6,10 +6,11 @@ import Link from 'next/link';
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, cn } from '@horux/shared-ui';
import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/lib/api/client';
import { CheckCircle2, Server, Cloud, ArrowLeft, Clock } from 'lucide-react';
import { CheckCircle2, Server, Cloud, ArrowLeft, Clock, Zap } from 'lucide-react';
type VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
type PlanType = 'trial' | 'business_control' | 'business_cloud';
type PlanType = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
type Frequency = 'monthly' | 'annual';
export default function RegisterDespachoPage() {
const router = useRouter();
@@ -17,6 +18,8 @@ export default function RegisterDespachoPage() {
const [step, setStep] = useState(1);
const [verticalProfile, setVerticalProfile] = useState<VerticalProfile | null>(null);
const [selectedPlan, setSelectedPlan] = useState<PlanType | null>(null);
const [meFreq, setMeFreq] = useState<Frequency>('monthly');
const [mePlusFreq, setMePlusFreq] = useState<Frequency>('monthly');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState({
@@ -38,11 +41,16 @@ export default function RegisterDespachoPage() {
setLoading(true);
setError('');
try {
const frequency: Frequency | undefined =
selectedPlan === 'mi_empresa' ? meFreq :
selectedPlan === 'mi_empresa_plus' ? mePlusFreq :
undefined;
const { data } = await apiClient.post('/despachos/signup', {
despacho: {
nombre: form.despachoNombre,
verticalProfile,
plan: selectedPlan,
frequency,
},
owner: {
nombre: form.ownerNombre,
@@ -168,10 +176,10 @@ export default function RegisterDespachoPage() {
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">3</span>
</div>
<h1 className="text-3xl font-bold">Elige tu plan</h1>
<p className="text-muted-foreground mt-2">Todos los planes incluyen las mismas funcionalidades.</p>
<p className="text-muted-foreground mt-2">Mi Empresa para individuales, Business para despachos.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6">
{/* Trial Gratuito */}
<Card
className={cn(
@@ -202,6 +210,99 @@ export default function RegisterDespachoPage() {
</CardContent>
</Card>
{/* Mi Empresa */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg',
selectedPlan === 'mi_empresa' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('mi_empresa')}
>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-emerald-100 dark:bg-emerald-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
</div>
<CardTitle className="text-xl">Mi Empresa</CardTitle>
<p className="text-sm text-muted-foreground">Para una sola empresa</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex bg-muted rounded-lg p-1 text-xs font-medium">
<button
type="button"
onClick={(e) => { e.stopPropagation(); setMeFreq('monthly'); }}
className={`flex-1 py-1.5 rounded-md transition-colors ${meFreq === 'monthly' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>Mensual</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setMeFreq('annual'); }}
className={`flex-1 py-1.5 rounded-md transition-colors flex items-center justify-center gap-1 ${meFreq === 'annual' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>Anual <span className="text-emerald-600 dark:text-emerald-400 text-[10px] font-bold">17%</span></button>
</div>
<div className="text-center">
<div className="text-3xl font-bold">${meFreq === 'monthly' ? '580' : '5,800'}</div>
<p className="text-sm text-muted-foreground">{meFreq === 'monthly' ? 'por mes (IVA incluido)' : 'por año (IVA incluido)'}</p>
{meFreq === 'monthly' && <p className="text-xs text-muted-foreground mt-1">o $5,800/año (ahorras 17%)</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>1 RFC</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>3 usuarios</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 1,000,000 CFDIs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Base de datos en la nube</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>50 timbres/mes incluidos</span></div>
</div>
</CardContent>
</Card>
{/* Mi Empresa + */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg relative',
selectedPlan === 'mi_empresa_plus' && 'border-primary ring-2 ring-primary/20'
)}
onClick={() => setSelectedPlan('mi_empresa_plus')}
>
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
Más popular
</div>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-teal-100 dark:bg-teal-900 rounded-full p-3 w-fit mb-2">
<Zap className="h-6 w-6 text-teal-600 dark:text-teal-400" />
</div>
<CardTitle className="text-xl">Mi Empresa +</CardTitle>
<p className="text-sm text-muted-foreground">Con API y Lolita IA</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex bg-muted rounded-lg p-1 text-xs font-medium">
<button
type="button"
onClick={(e) => { e.stopPropagation(); setMePlusFreq('monthly'); }}
className={`flex-1 py-1.5 rounded-md transition-colors ${mePlusFreq === 'monthly' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>Mensual</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setMePlusFreq('annual'); }}
className={`flex-1 py-1.5 rounded-md transition-colors flex items-center justify-center gap-1 ${mePlusFreq === 'annual' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>Anual <span className="text-emerald-600 dark:text-emerald-400 text-[10px] font-bold">17%</span></button>
</div>
<div className="text-center">
<div className="text-3xl font-bold">${mePlusFreq === 'monthly' ? '900' : '9,000'}</div>
<p className="text-sm text-muted-foreground">{mePlusFreq === 'monthly' ? 'por mes (IVA incluido)' : 'por año (IVA incluido)'}</p>
{mePlusFreq === 'monthly' && <p className="text-xs text-muted-foreground mt-1">o $9,000/año (ahorras 17%)</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>1 RFC</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>3 usuarios</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 1,000,000 CFDIs</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Base de datos en la nube</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>50 timbres/mes incluidos</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span><strong>API REST</strong> incluida</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span><strong>Lolita IA</strong> agente fiscal</span></div>
</div>
</CardContent>
</Card>
{/* Business Control */}
<Card
className={cn(
@@ -219,21 +320,23 @@ export default function RegisterDespachoPage() {
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<div className="text-3xl font-bold">$21,000</div>
<p className="text-sm text-muted-foreground">primer año (IVA incluido)</p>
<p className="text-xs text-muted-foreground mt-1">$15,000/año a partir del 2do año</p>
<div className="text-3xl font-bold">$25,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>
</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>Base de datos en tu servidor</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>RFCs ilimitados</span></div>
<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>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Usuarios ilimitados</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 1,000,000 CFDIs por contribuyente</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Servidor local con backup</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Control total de tus datos</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Requiere Docker en tu servidor</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación, API</span></div>
</div>
</CardContent>
</Card>
{/* Business Cloud */}
{/* Enterprise (business_cloud) */}
<Card
className={cn(
'cursor-pointer transition-all hover:shadow-lg relative',
@@ -241,27 +344,27 @@ export default function RegisterDespachoPage() {
)}
onClick={() => setSelectedPlan('business_cloud')}
>
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs px-3 py-1 rounded-full">
Más popular
</div>
<CardHeader className="text-center pb-2">
<div className="mx-auto bg-purple-100 dark:bg-purple-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<CardTitle className="text-xl">Business Cloud</CardTitle>
<p className="text-sm text-muted-foreground">Nosotros lo operamos por ti</p>
<CardTitle className="text-xl">Enterprise</CardTitle>
<p className="text-sm text-muted-foreground">Despachos grandes con alto volumen</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<div className="text-3xl font-bold">$15,000</div>
<p className="text-sm text-muted-foreground">por año (fijo)</p>
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC gestionado</p>
<div className="text-3xl font-bold">$43,000</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>
</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>Base de datos en la nube (Horux)</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Sin infraestructura propia</span></div>
<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>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Usuarios ilimitados</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Backups automáticos</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Hasta 3,000,000 CFDIs por contribuyente</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Servidor local con backup</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Backups automáticos en la nube</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Dashboard, CFDI, IVA/ISR, alertas, calendario</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Reportes, conciliación, documentos, facturación, API</span></div>
<div className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Soporte prioritario</span></div>
</div>
</CardContent>

View File

@@ -4,12 +4,12 @@ import { useState, useRef, useCallback, useEffect } from 'react';
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, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
import { createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
import { cancelarFactura } from '@/lib/api/facturacion';
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
import { useCfdis, useCfdiConceptos, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-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';
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer } from 'lucide-react';
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
import * as XLSX from 'xlsx';
import { saveAs } from 'file-saver';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
@@ -262,6 +262,30 @@ export default function CfdiPage() {
const [loadingReceptor, setLoadingReceptor] = useState(false);
const [showForm, setShowForm] = useState(false);
// Tabs: CFDIs vs Conceptos
const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis');
// Conceptos filters & state
const [conceptoFilters, setConceptoFilters] = useState<CfdiConceptoFilters>({
page: 1,
limit: 20,
});
const [conceptoSearch, setConceptoSearch] = useState('');
const [conceptoColumnFilters, setConceptoColumnFilters] = useState({
fechaInicio: '',
fechaFin: '',
uuid: '',
claveProdServ: '',
descripcion: '',
});
const [conceptoOrder, setConceptoOrder] = useState<{ by: 'fecha' | 'importe'; dir: 'asc' | 'desc' }>({ by: 'fecha', dir: 'desc' });
const [openConceptoFilter, setOpenConceptoFilter] = useState<'fecha' | 'uuid' | 'clave' | 'descripcion' | null>(null);
const hasConceptoDateFilter = !!conceptoColumnFilters.fechaInicio || !!conceptoColumnFilters.fechaFin;
const hasConceptoUuidFilter = !!conceptoColumnFilters.uuid;
const hasConceptoClaveFilter = !!conceptoColumnFilters.claveProdServ;
const hasConceptoDescFilter = !!conceptoColumnFilters.descripcion;
const { data: conceptosData, isLoading: conceptosLoading } = useCfdiConceptos(conceptoFilters);
// Debounced values for autocomplete
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
const debouncedReceptor = useDebounce(columnFilters.receptor, 300);
@@ -322,6 +346,21 @@ export default function CfdiPage() {
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
// Sync shared filters between CFDIs and Conceptos tabs
useEffect(() => {
setConceptoFilters(prev => ({
...prev,
fechaInicio: columnFilters.fechaInicio || undefined,
fechaFin: columnFilters.fechaFin || undefined,
tipo: filters.tipo,
tipoComprobante: filters.tipoComprobante,
estado: filters.estado,
rfc: filters.rfc,
search: searchTerm || undefined,
page: 1,
}));
}, [filters.tipo, filters.tipoComprobante, filters.estado, filters.rfc, columnFilters.fechaInicio, columnFilters.fechaFin, searchTerm]);
// Cancelación Facturapi state
const [cancelTarget, setCancelTarget] = useState<any | null>(null);
const [cancelMotive, setCancelMotive] = useState<'01' | '02' | '03' | '04'>('02');
@@ -343,11 +382,58 @@ export default function CfdiPage() {
};
const canEdit = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'contador' || user?.role === 'auxiliar';
const canDelete = user?.role === 'owner' || user?.role === 'contador';
const handleSearch = () => {
setFilters({ ...filters, search: searchTerm, page: 1 });
};
// Conceptos column filters & sorting
const applyConceptoColumnFilters = () => {
setConceptoFilters(prev => ({
...prev,
fechaInicio: conceptoColumnFilters.fechaInicio || undefined,
fechaFin: conceptoColumnFilters.fechaFin || undefined,
uuid: conceptoColumnFilters.uuid || undefined,
claveProdServ: conceptoColumnFilters.claveProdServ || undefined,
descripcion: conceptoColumnFilters.descripcion || undefined,
orderBy: conceptoOrder.by,
orderDir: conceptoOrder.dir,
page: 1,
}));
};
const toggleConceptoOrder = (by: 'fecha' | 'importe') => {
setConceptoOrder(prev => {
const dir = prev.by === by && prev.dir === 'desc' ? 'asc' : 'desc';
return { by, dir };
});
// Apply after state update using the new values
setConceptoFilters(prev => ({
...prev,
orderBy: by,
orderDir: by === conceptoOrder.by && conceptoOrder.dir === 'desc' ? 'asc' : 'desc',
page: 1,
}));
};
const clearConceptoDateFilter = () => {
setConceptoColumnFilters(prev => ({ ...prev, fechaInicio: '', fechaFin: '' }));
setConceptoFilters(prev => ({ ...prev, fechaInicio: undefined, fechaFin: undefined, page: 1 }));
};
const clearConceptoUuidFilter = () => {
setConceptoColumnFilters(prev => ({ ...prev, uuid: '' }));
setConceptoFilters(prev => ({ ...prev, uuid: undefined, page: 1 }));
};
const clearConceptoClaveFilter = () => {
setConceptoColumnFilters(prev => ({ ...prev, claveProdServ: '' }));
setConceptoFilters(prev => ({ ...prev, claveProdServ: undefined, page: 1 }));
};
const clearConceptoDescFilter = () => {
setConceptoColumnFilters(prev => ({ ...prev, descripcion: '' }));
setConceptoFilters(prev => ({ ...prev, descripcion: undefined, page: 1 }));
};
// Export to Excel
const [exporting, setExporting] = useState(false);
@@ -356,7 +442,17 @@ export default function CfdiPage() {
setExporting(true);
try {
const exportData = data.data.map(cfdi => ({
// Traer TODOS los CFDIs que coinciden con los filtros (sin paginación)
const allFilters: CfdiFilters = { ...filters, page: 1, limit: 10000 };
const allData = await getCfdis(allFilters);
const rows = allData.data;
if (!rows.length) {
alert('No hay datos para exportar');
return;
}
const exportData = rows.map(cfdi => ({
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
'Serie': cfdi.serie || '',
@@ -366,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')
@@ -399,6 +502,98 @@ 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 {
const blob = await downloadPdf(facturapiId);
const url = window.URL.createObjectURL(new Blob([blob]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `factura-${facturapiId}.pdf`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (error: any) {
alert('Error al descargar PDF: ' + (error?.message || 'Desconocido'));
}
};
const handleDownloadXml = async (facturapiId: string | null) => {
if (!facturapiId) return;
try {
const blob = await downloadXml(facturapiId);
const url = window.URL.createObjectURL(new Blob([blob]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `factura-${facturapiId}.xml`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (error: any) {
alert('Error al descargar XML: ' + (error?.message || 'Desconocido'));
}
};
const exportSingleCfdiToExcel = (cfdi: Cfdi) => {
const row = {
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
@@ -410,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')
@@ -741,11 +943,19 @@ export default function CfdiPage() {
const handleDelete = async (id: string | number) => {
const idStr = String(id);
console.log('[DeleteCFDI] Intentando eliminar ID:', idStr, 'tipo:', typeof id);
if (!id || idStr === 'undefined' || idStr === 'null' || idStr === '0') {
alert('ID de CFDI inválido: ' + idStr);
return;
}
if (confirm('¿Eliminar este CFDI?')) {
try {
await deleteCfdi.mutateAsync(idStr);
} catch (error) {
alert('CFDI eliminado correctamente');
} catch (error: any) {
console.error('Error deleting CFDI:', error);
const msg = error?.response?.data?.message || error?.message || 'Error al eliminar el CFDI';
alert('Error: ' + msg);
}
}
};
@@ -809,6 +1019,21 @@ export default function CfdiPage() {
<>
<Header title="Gestion de CFDI" />
<main className="p-6 space-y-6">
{/* Tabs */}
<div className="flex gap-2 border-b pb-2">
<button
onClick={() => setActiveTab('cfdis')}
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${activeTab === 'cfdis' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`}
>
CFDIs
</button>
<button
onClick={() => setActiveTab('conceptos')}
className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${activeTab === 'conceptos' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'}`}
>
Conceptos
</button>
</div>
{/* Filters */}
<Card>
<CardContent className="p-4">
@@ -872,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" />
) : (
@@ -899,6 +1124,8 @@ export default function CfdiPage() {
</CardContent>
</Card>
{activeTab === 'cfdis' && (
<>
{/* Add CFDI Form */}
{showForm && canEdit && (
<Card>
@@ -1586,6 +1813,7 @@ export default function CfdiPage() {
</Popover>
</div>
</th>
<th className="pb-3 font-medium">Uso CFDI</th>
<th className="pb-3 font-medium text-right">Total</th>
<th className="pb-3 font-medium">Estado</th>
<th className="pb-3 font-medium"></th>
@@ -1625,6 +1853,9 @@ export default function CfdiPage() {
</p>
</div>
</td>
<td className="py-3">
<span className="text-xs text-muted-foreground">{cfdi.usoCfdi || '-'}</span>
</td>
<td className="py-3 text-right font-medium">
{formatCurrency(cfdi.total)}
</td>
@@ -1654,6 +1885,21 @@ export default function CfdiPage() {
)}
</Button>
</td>
{(cfdi as any).source === 'facturapi' && cfdi.facturapiId && (
<>
<td className="py-3">
<Button
variant="ghost"
size="icon"
onClick={() => handleDownloadPdf(cfdi.facturapiId)}
title="Descargar PDF (Facturapi)"
>
<FileText className="h-4 w-4" />
</Button>
</td>
{/* XML download hidden */}
</>
)}
<td className="py-3">
<Button
variant="ghost"
@@ -1678,6 +1924,7 @@ export default function CfdiPage() {
<XCircle className="h-4 w-4" />
</Button>
)}
{canDelete && (
<Button
variant="ghost"
size="icon"
@@ -1687,6 +1934,7 @@ export default function CfdiPage() {
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</td>
)}
@@ -1729,6 +1977,219 @@ export default function CfdiPage() {
)}
</CardContent>
</Card>
</>
)}
{activeTab === 'conceptos' && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
Conceptos ({conceptosData?.total || 0})
</CardTitle>
</CardHeader>
<CardContent>
{conceptosLoading ? (
<div className="space-y-3">
{[...Array(8)].map((_, i) => (
<div key={i} className="flex items-center gap-4 animate-pulse">
<div className="h-4 bg-muted rounded w-20"></div>
<div className="h-4 bg-muted rounded flex-1 max-w-[200px]"></div>
<div className="h-4 bg-muted rounded w-16"></div>
<div className="h-4 bg-muted rounded w-16"></div>
<div className="h-4 bg-muted rounded w-24"></div>
</div>
))}
</div>
) : conceptosData?.data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No se encontraron conceptos
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">
<div className="flex items-center gap-1">
<button onClick={() => toggleConceptoOrder('fecha')} className="flex items-center gap-1 hover:text-foreground transition-colors">
Fecha CFDI
{conceptoOrder.by === 'fecha' ? (
conceptoOrder.dir === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 opacity-50" />
)}
</button>
<Popover open={openConceptoFilter === 'fecha'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'fecha' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasConceptoDateFilter ? '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 fecha</h4>
<div className="space-y-2">
<div>
<Label className="text-xs">Desde</Label>
<Input type="date" className="h-8 text-sm" value={conceptoColumnFilters.fechaInicio} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, fechaInicio: e.target.value }))} />
</div>
<div>
<Label className="text-xs">Hasta</Label>
<Input type="date" className="h-8 text-sm" value={conceptoColumnFilters.fechaFin} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, fechaFin: e.target.value }))} />
</div>
</div>
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
{hasConceptoDateFilter && <Button size="sm" variant="outline" onClick={clearConceptoDateFilter}>Limpiar</Button>}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1">
UUID
<Popover open={openConceptoFilter === 'uuid'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'uuid' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasConceptoUuidFilter ? '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 placeholder="UUID..." className="h-8 text-sm" value={conceptoColumnFilters.uuid} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, uuid: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} />
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
{hasConceptoUuidFilter && <Button size="sm" variant="outline" onClick={clearConceptoUuidFilter}>Limpiar</Button>}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1">
Clave
<Popover open={openConceptoFilter === 'clave'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'clave' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasConceptoClaveFilter ? '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</h4>
<Input placeholder="Clave prod/serv..." className="h-8 text-sm" value={conceptoColumnFilters.claveProdServ} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, claveProdServ: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} />
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
{hasConceptoClaveFilter && <Button size="sm" variant="outline" onClick={clearConceptoClaveFilter}>Limpiar</Button>}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">
<div className="flex items-center gap-1">
Descripción
<Popover open={openConceptoFilter === 'descripcion'} onOpenChange={(open) => setOpenConceptoFilter(open ? 'descripcion' : null)}>
<PopoverTrigger asChild>
<button className={`p-1 rounded hover:bg-muted ${hasConceptoDescFilter ? '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 placeholder="Descripción..." className="h-8 text-sm" value={conceptoColumnFilters.descripcion} onChange={(e) => setConceptoColumnFilters(prev => ({ ...prev, descripcion: e.target.value }))} onKeyDown={(e) => e.key === 'Enter' && applyConceptoColumnFilters()} />
<div className="flex gap-2">
<Button size="sm" className="flex-1" onClick={applyConceptoColumnFilters}>Aplicar</Button>
{hasConceptoDescFilter && <Button size="sm" variant="outline" onClick={clearConceptoDescFilter}>Limpiar</Button>}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</th>
<th className="pb-3 font-medium">RFC Emisor</th>
<th className="pb-3 font-medium">RFC Receptor</th>
<th className="pb-3 font-medium">Cantidad</th>
<th className="pb-3 font-medium">Unidad</th>
<th className="pb-3 font-medium text-right">V. Unitario</th>
<th className="pb-3 font-medium text-right">
<button onClick={() => toggleConceptoOrder('importe')} className="flex items-center gap-1 hover:text-foreground transition-colors ml-auto">
Importe
{conceptoOrder.by === 'importe' ? (
conceptoOrder.dir === 'asc' ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 opacity-50" />
)}
</button>
</th>
{/* Descuento and IVA columns hidden */}
</tr>
</thead>
<tbody>
{conceptosData?.data.map((c) => (
<tr key={c.id} className="border-b hover:bg-muted/50">
<td className="py-3 text-sm">
{c.cfdiFechaEmision ? new Date(c.cfdiFechaEmision).toLocaleDateString('es-MX') : '-'}
</td>
<td className="py-3 text-xs font-mono text-muted-foreground max-w-[120px] truncate" title={c.cfdiUuid || ''}>
{c.cfdiUuid || '-'}
</td>
<td className="py-3 text-xs text-muted-foreground">{c.claveProdServ || '-'}</td>
<td className="py-3 text-sm max-w-[250px] truncate" title={c.descripcion}>{c.descripcion}</td>
<td className="py-3 text-xs text-muted-foreground">{c.cfdiRfcEmisor || '-'}</td>
<td className="py-3 text-xs text-muted-foreground">{c.cfdiRfcReceptor || '-'}</td>
<td className="py-3 text-sm">{c.cantidad}</td>
<td className="py-3 text-xs text-muted-foreground">{c.claveUnidad || c.unidad || '-'}</td>
<td className="py-3 text-sm text-right">${Number(c.valorUnitario).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
<td className="py-3 text-sm text-right">${Number(c.importe).toLocaleString('es-MX', { minimumFractionDigits: 2 })}</td>
{/* Descuento and IVA cells hidden */}
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination for conceptos */}
{conceptosData && conceptosData.totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<p className="text-sm text-muted-foreground">
Pagina {conceptosData.page} de {conceptosData.totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={conceptosData.page <= 1}
onClick={() =>
setConceptoFilters({ ...conceptoFilters, page: (conceptoFilters.page || 1) - 1 })
}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={conceptosData.page >= conceptosData.totalPages}
onClick={() =>
setConceptoFilters({ ...conceptoFilters, page: (conceptoFilters.page || 1) + 1 })
}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
</main>
<CfdiViewerModal

View File

@@ -10,22 +10,24 @@ import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useAuthStore } from '@/stores/auth-store';
import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X, DollarSign, AlertCircle, ChevronRight } from 'lucide-react';
import type { Tenant } from '@/lib/api/tenants';
import { isGlobalAdminRfc } from '@horux/shared';
import { isGlobalAdminRfc, DESPACHO_PLAN_PRICES, permiteFrecuenciaMensual } from '@horux/shared';
import { getClientesStats, getTenantUsuarios, type TenantUsuario } from '@/lib/api/admin-clientes';
const PLAN_LABELS: Record<string, string> = {
starter: 'Starter',
business: 'Business',
business_ia: 'Business + IA',
enterprise: 'Enterprise',
custom: 'Custom',
trial: 'Trial Gratuito',
mi_empresa: 'Mi Empresa',
mi_empresa_plus: 'Mi Empresa +',
business_control: 'Business Control',
business_cloud: 'Enterprise (Despacho)',
custom: 'Custom',
// Legacy labels kept for display
starter: 'Starter (legacy)',
business: 'Business (legacy)',
business_ia: 'Business + IA (legacy)',
enterprise: 'Enterprise (legacy)',
};
type PlanType = 'starter' | 'business' | 'business_ia' | 'enterprise' | 'custom';
type PlanType = 'trial' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud' | 'custom' | 'starter' | 'business' | 'business_ia' | 'enterprise';
export default function ClientesPage() {
const { user } = useAuthStore();
@@ -81,13 +83,17 @@ export default function ClientesPage() {
nombre: string;
rfc: string;
plan: PlanType;
verticalProfile: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
frequency: 'monthly' | 'annual';
adminEmail: string;
adminNombre: string;
amount: number;
}>({
nombre: '',
rfc: '',
plan: 'starter',
plan: 'trial',
verticalProfile: 'CONTABLE',
frequency: 'annual',
adminEmail: '',
adminNombre: '',
amount: 0,
@@ -116,12 +122,20 @@ export default function ClientesPage() {
try {
if (editingTenant) {
await updateTenant.mutateAsync({ id: editingTenant.id, data: formData });
await updateTenant.mutateAsync({
id: editingTenant.id,
data: {
nombre: formData.nombre,
rfc: formData.rfc,
plan: formData.plan,
verticalProfile: formData.verticalProfile,
},
});
setEditingTenant(null);
} else {
await createTenant.mutateAsync(formData);
}
setFormData({ nombre: '', rfc: '', plan: 'starter', adminEmail: '', adminNombre: '', amount: 0 });
setFormData({ nombre: '', rfc: '', plan: 'trial', verticalProfile: 'CONTABLE', frequency: 'annual', adminEmail: '', adminNombre: '', amount: 0 });
setShowForm(false);
} catch (error) {
console.error('Error:', error);
@@ -133,7 +147,9 @@ export default function ClientesPage() {
setFormData({
nombre: tenant.nombre,
rfc: tenant.rfc,
plan: tenant.plan as PlanType,
plan: (tenant.plan as PlanType) || 'trial',
verticalProfile: 'CONTABLE',
frequency: 'annual',
adminEmail: '',
adminNombre: '',
amount: 0,
@@ -154,7 +170,7 @@ export default function ClientesPage() {
const handleCancelForm = () => {
setShowForm(false);
setEditingTenant(null);
setFormData({ nombre: '', rfc: '', plan: 'starter', adminEmail: '', adminNombre: '', amount: 0 });
setFormData({ nombre: '', rfc: '', plan: 'trial', verticalProfile: 'CONTABLE', frequency: 'annual', adminEmail: '', adminNombre: '', amount: 0 });
};
const handleViewClient = (tenantId: string, tenantName: string) => {
@@ -175,15 +191,16 @@ export default function ClientesPage() {
// los planes — legacy + despacho + custom. El planColors local se mantiene
// chico con un fallback genérico para planes nuevos.
const planColors: Record<string, string> = {
starter: 'bg-muted text-muted-foreground',
business: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100',
business_ia: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100',
enterprise: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100',
trial: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100',
mi_empresa: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100',
mi_empresa_plus: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100',
business_control: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-100',
business_cloud: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100',
custom: 'bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-100',
starter: 'bg-muted text-muted-foreground',
business: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100',
business_ia: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100',
enterprise: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100',
};
return (
@@ -393,12 +410,12 @@ export default function ClientesPage() {
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
{editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'}
{editingTenant ? 'Editar Despacho' : 'Nuevo Despacho'}
</CardTitle>
<CardDescription>
{editingTenant
? 'Modifica los datos del cliente'
: 'Registra un nuevo cliente para gestionar su facturación'}
? 'Modifica los datos del despacho'
: 'Registra un nuevo despacho en Horux'}
</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
@@ -410,54 +427,112 @@ export default function ClientesPage() {
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="nombre">Nombre de la Empresa</Label>
<Label htmlFor="nombre">Nombre del Despacho</Label>
<Input
id="nombre"
value={formData.nombre}
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
placeholder="Empresa SA de CV"
placeholder="Despacho Pérez y Asociados"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="rfc">RFC</Label>
<Label htmlFor="rfc">RFC del Despacho</Label>
<Input
id="rfc"
value={formData.rfc}
onChange={(e) => setFormData({ ...formData, rfc: e.target.value.toUpperCase() })}
placeholder="XAXX010101000"
maxLength={14}
placeholder="ABC010101ABC"
maxLength={13}
required
disabled={!!editingTenant} // Can't change RFC after creation
disabled={!!editingTenant}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="plan">Plan</Label>
<Label htmlFor="verticalProfile">Perfil Profesional</Label>
<Select
value={formData.plan}
value={formData.verticalProfile}
onValueChange={(value) =>
setFormData({ ...formData, plan: value as PlanType })
setFormData({ ...formData, verticalProfile: value as 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA' })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CONTABLE">📊 Contable Fiscal, CFDI, IVA/ISR</SelectItem>
<SelectItem value="JURIDICO"> Jurídico Próximamente</SelectItem>
<SelectItem value="ARQUITECTURA">🏗 Arquitectura Próximamente</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="plan">Plan</Label>
<Select
value={formData.plan}
onValueChange={(value) => {
const plan = value as PlanType;
const isCustom = plan === 'custom';
const isTrial = plan === 'trial';
let amount = 0;
if (!isCustom && !isTrial) {
const priceInfo = (DESPACHO_PLAN_PRICES as Record<string, { monthly: number | null; firstYear: number; renewal: number }>)[plan];
amount = priceInfo?.firstYear ?? 0;
}
setFormData({ ...formData, plan, amount });
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="trial">Trial Gratuito 30 días, 3 RFCs, 20 timbres</SelectItem>
<SelectItem value="mi_empresa">Mi Empresa 1 RFC, 3 usuarios, 50 timbres/mes</SelectItem>
<SelectItem value="mi_empresa_plus">Mi Empresa + Con API y Lolita IA</SelectItem>
<SelectItem value="business_control">Business Control 100 RFCs, BYO server</SelectItem>
<SelectItem value="business_cloud">Enterprise 100 RFCs, 3M CFDIs, BYO</SelectItem>
<SelectItem value="custom">Custom Sin cobro, asignado por admin</SelectItem>
<hr className="my-1" />
<SelectItem value="starter">Starter (legacy) Sin CFDIs, 1 usuario</SelectItem>
<SelectItem value="business">Business (legacy) 50 CFDIs, 3 usuarios</SelectItem>
<SelectItem value="business_ia">Business + IA (legacy)</SelectItem>
<SelectItem value="enterprise">Enterprise (legacy) 100 CFDIs, ilimitados</SelectItem>
<SelectItem value="custom">Custom Sin cobro, sin fecha fin (despacho)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Frequency selector for plans that allow monthly */}
{formData.plan !== 'custom' && formData.plan !== 'trial' && permiteFrecuenciaMensual(formData.plan) && (
<div className="space-y-2">
<Label htmlFor="frequency">Frecuencia de Pago</Label>
<Select
value={formData.frequency}
onValueChange={(value) => {
const freq = value as 'monthly' | 'annual';
const priceInfo = (DESPACHO_PLAN_PRICES as Record<string, { monthly: number | null; firstYear: number; renewal: number }>)[formData.plan];
const amount = freq === 'monthly' ? (priceInfo?.monthly ?? 0) : (priceInfo?.firstYear ?? 0);
setFormData({ ...formData, frequency: freq, amount });
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Mensual</SelectItem>
<SelectItem value="annual">Anual (ahorra ~17%)</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Campos de admin y suscripción — solo al crear */}
{!editingTenant && (
<>
<div className="border-t pt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">Dueño del Cliente</p>
<p className="text-sm font-medium text-muted-foreground mb-3">Dueño del Despacho</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="adminNombre">Nombre del Dueño</Label>
@@ -476,16 +551,16 @@ export default function ClientesPage() {
type="email"
value={formData.adminEmail}
onChange={(e) => setFormData({ ...formData, adminEmail: e.target.value })}
placeholder="admin@empresa.com"
placeholder="admin@despacho.com"
required
/>
</div>
</div>
</div>
{formData.plan !== 'custom' && (
{formData.plan !== 'custom' && formData.plan !== 'trial' && (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="amount">Monto Mensual (MXN)</Label>
<Label htmlFor="amount">Monto (MXN)</Label>
<Input
id="amount"
type="number"
@@ -495,12 +570,17 @@ export default function ClientesPage() {
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
placeholder="0.00"
/>
<p className="text-xs text-muted-foreground">
Precio sugerido según catálogo. Puedes ajustarlo para descuentos especiales.
</p>
</div>
</div>
)}
{formData.plan === 'custom' && (
{(formData.plan === 'custom' || formData.plan === 'trial') && (
<p className="text-xs text-muted-foreground">
Plan Custom no genera cobro ni suscripción. Vigencia indefinida.
{formData.plan === 'custom'
? 'Plan Custom no genera cobro ni suscripción. Vigencia indefinida.'
: 'Trial gratuito por 30 días. No requiere tarjeta.'}
</p>
)}
</>

View File

@@ -1,7 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { KpiCard } from '@horux/shared-ui';
import { BarChart } from '@/components/charts/bar-chart';
@@ -9,7 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
import { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
import { useKpis, useIngresosEgresos, useAlertas, useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
import { useAuthStore } from '@/stores/auth-store';
import { isGlobalAdminRfc } from '@horux/shared';
import {
TrendingUp,
TrendingDown,
@@ -44,14 +43,7 @@ function shiftDatesOneYear(fechaInicio: string, fechaFin: string, delta: number)
}
export default function DashboardPage() {
const router = useRouter();
const { user } = useAuthStore();
// Admin global no opera sobre datos de despacho — su home natural es
// `/clientes` (gestión de tenants). Redirige al primer render.
const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles);
useEffect(() => {
if (isGlobalAdmin) router.replace('/clientes');
}, [isGlobalAdmin, router]);
const now = new Date();
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);

View File

@@ -15,7 +15,7 @@ import { searchClaveProdServ } from '@/lib/api/catalogos';
import { apiClient } from '@/lib/api/client';
import type { InvoiceData, InvoiceLineItem, RfcSearchResult, CfdiPpdPendiente, ConceptoPrevio } from '@/lib/api/facturacion';
import { searchRfcs, getCfdisPpd, searchConceptos } from '@/lib/api/facturacion';
import { Plus, Trash2, Send, Receipt, Search, Check, X, FileSearch, AlertTriangle } from 'lucide-react';
import { Plus, Trash2, Send, Receipt, Search, Check, X, FileSearch, AlertTriangle, Link2 } from 'lucide-react';
import { cn } from '@horux/shared-ui';
interface TaxLine {
@@ -25,7 +25,31 @@ interface TaxLine {
factor: string; // Tasa, Cuota, Exento
}
interface RelatedDocForm {
relationship: string;
uuids: string[];
}
const RELACION_OPTIONS: Record<string, { value: string; label: string }[]> = {
I: [
{ value: '04', label: '04 - Sustitución de CFDI previos' },
{ value: '05', label: '05 - Traslados de mercancias facturados previamente' },
{ value: '06', label: '06 - Factura generada por traslados previos' },
{ value: '07', label: '07 - CFDI por aplicación de anticipo' },
{ value: '08', label: '08 - Factura generada por pagos en parcialidades' },
{ value: '09', label: '09 - Factura generada por pagos diferidos' },
],
E: [
{ value: '01', label: '01 - Nota de crédito de documentos relacionados' },
{ value: '02', label: '02 - Nota de débito de documentos relacionados' },
{ value: '03', label: '03 - Devolución de mercancía sobre facturas o traslados previos' },
{ value: '04', label: '04 - Sustitución de los CFDI previos' },
{ value: '07', label: '07 - CFDI por aplicación de anticipo' },
],
};
interface ConceptoForm {
id: string;
description: string;
productKey: string;
productKeyLabel: string;
@@ -42,7 +66,7 @@ const defaultTaxes: TaxLine[] = [
];
const emptyConcepto: ConceptoForm = {
description: '', productKey: '', productKeyLabel: '',
id: '', description: '', productKey: '', productKeyLabel: '',
unitKey: 'E48', quantity: 1, price: 0, discount: 0, objetoImp: '02',
taxes: [...defaultTaxes],
};
@@ -188,7 +212,7 @@ const TIPO_CONFIG: Record<string, {
defaultFormaPago: string;
defaultMetodoPago: string;
}> = {
I: { label: 'Ingreso', needsConceptos: true, needsPaymentComplement: false, needsRelated: false, defaultUso: 'G03', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
I: { label: 'Ingreso', needsConceptos: true, needsPaymentComplement: false, needsRelated: true, defaultUso: 'G03', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
E: { label: 'Egreso (Nota de Crédito)', needsConceptos: true, needsPaymentComplement: false, needsRelated: true, defaultUso: 'G02', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
P: { label: 'Pago', needsConceptos: false, needsPaymentComplement: true, needsRelated: false, defaultUso: 'CP01', defaultFormaPago: '99', defaultMetodoPago: 'PPD' },
T: { label: 'Traslado', needsConceptos: true, needsPaymentComplement: false, needsRelated: false, defaultUso: 'S01', defaultFormaPago: '99', defaultMetodoPago: 'PUE' },
@@ -304,11 +328,10 @@ export default function FacturacionPage() {
const [condiciones, setCondiciones] = useState('');
// Conceptos
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto }]);
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto, id: crypto.randomUUID() }]);
// Documento relacionado (Egreso)
const [relatedUuid, setRelatedUuid] = useState('');
const [relatedRelationship, setRelatedRelationship] = useState('01');
// CFDIs relacionados (Ingreso / Egreso)
const [relatedDocs, setRelatedDocs] = useState<RelatedDocForm[]>([]);
// Complemento de pago
const [pagoFecha, setPagoFecha] = useState(new Date().toISOString().slice(0, 10));
@@ -343,9 +366,8 @@ export default function FacturacionPage() {
setSerie('');
setFolio('');
setCondiciones('');
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
setRelatedUuid('');
setRelatedRelationship('01');
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit, id: crypto.randomUUID() }]);
setRelatedDocs([]);
setPagoUuid('');
setPagoMonto(0);
setPagoParcialidad(1);
@@ -493,7 +515,7 @@ export default function FacturacionPage() {
setMetodoPago(c.defaultMetodoPago);
// Resetear conceptos con unidad default según tipo
const defaultUnit = tipo === 'T' ? 'H87' : 'E48';
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit, id: crypto.randomUUID() }]);
};
// Unidades de servicio que no aplican para Traslado
@@ -511,30 +533,28 @@ export default function FacturacionPage() {
};
const selectProduct = (idx: number, clave: string, descripcion: string) => {
const updated = [...conceptos];
updated[idx].productKey = clave;
updated[idx].productKeyLabel = `${clave} - ${descripcion}`;
setConceptos(updated);
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, productKey: clave, productKeyLabel: `${clave} - ${descripcion}` } : c));
setProdResults([]);
setSearchingIdx(null);
};
const updateConcepto = (idx: number, field: keyof ConceptoForm, value: any) => {
const updated = [...conceptos];
(updated[idx] as any)[field] = value;
setConceptos(prev => prev.map((c, i) => {
if (i !== idx) return c;
const updated = { ...c, [field]: value } as ConceptoForm;
// Si cambió la unidad, re-evaluar recomendación de impuestos
if (field === 'unitKey' && tipoComprobante === 'I') {
const recommended = getRecommendedTaxes(
emisorRfc, emisorRegimen, receptor.taxId, receptor.taxSystem, value
);
if (recommended) {
updated[idx].taxes = recommended;
updated.taxes = recommended;
} else {
// Si ya no aplica retención, dejar solo IVA 16%
updated[idx].taxes = [...defaultTaxes];
updated.taxes = [...defaultTaxes];
}
}
setConceptos(updated);
return updated;
}));
};
// Aplica recomendación de impuestos a un concepto si corresponde (solo tipo I)
@@ -550,12 +570,14 @@ export default function FacturacionPage() {
};
const addConcepto = () => {
const newConcepto = applyTaxRecommendation({ ...emptyConcepto });
setConceptos([...conceptos, newConcepto]);
const newConcepto = applyTaxRecommendation({ ...emptyConcepto, id: crypto.randomUUID() });
setConceptos(prev => [...prev, newConcepto]);
};
const removeConcepto = (idx: number) => {
if (conceptos.length === 1) return;
setConceptos(conceptos.filter((_, i) => i !== idx));
setConceptos(prev => {
if (prev.length === 1) return prev;
return prev.filter((_, i) => i !== idx);
});
};
// Cálculos
@@ -635,8 +657,10 @@ export default function FacturacionPage() {
}));
}
if (config.needsRelated && relatedUuid) {
data.relatedDocuments = [{ uuid: relatedUuid, relationship: relatedRelationship }];
if (config.needsRelated && relatedDocs.length > 0) {
data.relatedDocuments = relatedDocs
.filter(r => r.uuids.length > 0)
.map(r => ({ relationship: r.relationship, uuids: r.uuids.filter(u => u.trim() !== '') }));
}
if (config.needsPaymentComplement) {
@@ -685,7 +709,7 @@ export default function FacturacionPage() {
<p className="text-muted-foreground mt-4">Total</p>
<p className="text-2xl font-bold">${result.total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</p>
</div>
<Button onClick={() => { setResult(null); setConceptos([{ ...emptyConcepto }]); }}>
<Button onClick={() => { setResult(null); setConceptos([{ ...emptyConcepto, id: crypto.randomUUID() }]); }}>
Emitir otra factura
</Button>
</CardContent>
@@ -1118,36 +1142,6 @@ export default function FacturacionPage() {
</CardContent>
</Card>
{/* Documento Relacionado (Egreso) */}
{config.needsRelated && (
<Card>
<CardHeader>
<CardTitle className="text-base">Documento Relacionado</CardTitle>
<CardDescription>UUID del CFDI de ingreso al que aplica esta nota de crédito</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>UUID del CFDI relacionado</Label>
<Input value={relatedUuid} onChange={e => setRelatedUuid(e.target.value.toUpperCase())} placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" required />
</div>
<div className="space-y-2">
<Label>Tipo de Relación</Label>
<Select value={relatedRelationship} onValueChange={setRelatedRelationship}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="01">01 - Nota de crédito</SelectItem>
<SelectItem value="02">02 - Nota de débito</SelectItem>
<SelectItem value="03">03 - Devolución de mercancía</SelectItem>
<SelectItem value="04">04 - Sustitución de CFDI previo</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
)}
{/* Complemento de Pago */}
{config.needsPaymentComplement && (
<Card>
@@ -1265,7 +1259,7 @@ export default function FacturacionPage() {
</CardHeader>
<CardContent className="space-y-4">
{conceptos.map((c, idx) => (
<div key={idx} className="p-4 border rounded-lg space-y-3">
<div key={c.id} className="p-4 border rounded-lg space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Concepto {idx + 1}</span>
<div className="flex gap-1">
@@ -1357,9 +1351,7 @@ export default function FacturacionPage() {
<Select onValueChange={v => {
const opt = TAX_OPTIONS.traslado.find(o => `${o.type}-${o.rate}-${o.factor}` === v);
if (!opt) return;
const updated = [...conceptos];
updated[idx].taxes = [...updated[idx].taxes, { category: 'traslado', type: opt.type, rate: opt.rate, factor: opt.factor }];
setConceptos(updated);
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: [...c.taxes, { category: 'traslado', type: opt.type, rate: opt.rate, factor: opt.factor }] } : c));
}}>
<SelectTrigger className="h-7 text-xs w-auto gap-1">
<Plus className="h-3 w-3" />
@@ -1374,9 +1366,7 @@ export default function FacturacionPage() {
<Select onValueChange={v => {
const opt = TAX_OPTIONS.retencion.find(o => `${o.type}-${o.rate}` === v);
if (!opt) return;
const updated = [...conceptos];
updated[idx].taxes = [...updated[idx].taxes, { category: 'retencion', type: opt.type, rate: opt.rate, factor: opt.factor }];
setConceptos(updated);
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: [...c.taxes, { category: 'retencion', type: opt.type, rate: opt.rate, factor: opt.factor }] } : c));
}}>
<SelectTrigger className="h-7 text-xs w-auto gap-1">
<Plus className="h-3 w-3" />
@@ -1413,9 +1403,7 @@ export default function FacturacionPage() {
{tax.category === 'retencion' ? '-' : ''}${amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
</span>
<button type="button" onClick={() => {
const updated = [...conceptos];
updated[idx].taxes = updated[idx].taxes.filter((_, i) => i !== tIdx);
setConceptos(updated);
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: c.taxes.filter((_, ti) => ti !== tIdx) } : c));
}} className="text-muted-foreground hover:text-destructive">
<Trash2 className="h-3 w-3" />
</button>
@@ -1438,6 +1426,116 @@ export default function FacturacionPage() {
</Card>
)}
{/* CFDIs Relacionados */}
{config.needsRelated && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">CFDIs Relacionados</CardTitle>
<CardDescription>Relaciona esta factura con otros comprobantes previos</CardDescription>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setRelatedDocs([...relatedDocs, { relationship: tipoComprobante === 'E' ? '01' : '04', uuids: [''] }])}
>
<Plus className="h-4 w-4 mr-1" /> Relación
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{relatedDocs.length === 0 && (
<p className="text-sm text-muted-foreground">Sin relaciones. Agrega una si esta factura está vinculada a otros CFDI.</p>
)}
{relatedDocs.map((rel, rIdx) => (
<div key={rIdx} className="p-4 border rounded-lg space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">Relación {rIdx + 1}</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => setRelatedDocs(relatedDocs.filter((_, i) => i !== rIdx))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Tipo de Relación</Label>
<Select
value={rel.relationship}
onValueChange={v => {
const updated = [...relatedDocs];
updated[rIdx].relationship = v;
setRelatedDocs(updated);
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{RELACION_OPTIONS[tipoComprobante]?.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>UUIDs relacionados</Label>
{rel.uuids.map((uuid, uIdx) => (
<div key={uIdx} className="flex gap-2">
<Input
value={uuid}
onChange={e => {
const updated = [...relatedDocs];
updated[rIdx].uuids[uIdx] = e.target.value.toUpperCase();
setRelatedDocs(updated);
}}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
className="font-mono text-sm"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-10 w-10 text-destructive flex-shrink-0"
onClick={() => {
const updated = [...relatedDocs];
updated[rIdx].uuids = updated[rIdx].uuids.filter((_, i) => i !== uIdx);
setRelatedDocs(updated);
}}
disabled={rel.uuids.length === 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs"
onClick={() => {
const updated = [...relatedDocs];
updated[rIdx].uuids.push('');
setRelatedDocs(updated);
}}
>
<Plus className="h-3 w-3 mr-1" /> Agregar UUID
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
{/* Resumen y Emitir */}
<Card>
<CardContent className="pt-6">

View File

@@ -1,5 +1,5 @@
import { apiClient } from './client';
import type { CfdiListResponse, CfdiFilters, Cfdi } from '@horux/shared';
import type { CfdiListResponse, CfdiFilters, Cfdi, CfdiConceptoFilters, CfdiConceptoListResponse } from '@horux/shared';
export async function getCfdis(filters: CfdiFilters): Promise<CfdiListResponse> {
const params = new URLSearchParams();
@@ -102,6 +102,28 @@ export async function getCfdiConceptos(id: number | string): Promise<any[]> {
return response.data;
}
export async function getAllCfdiConceptos(filters: CfdiConceptoFilters): Promise<CfdiConceptoListResponse> {
const params = new URLSearchParams();
if (filters.fechaInicio) params.set('fechaInicio', filters.fechaInicio);
if (filters.fechaFin) params.set('fechaFin', filters.fechaFin);
if (filters.tipo) params.set('tipo', filters.tipo);
if (filters.tipoComprobante) params.set('tipoComprobante', filters.tipoComprobante);
if (filters.estado) params.set('estado', filters.estado);
if (filters.rfc) params.set('rfc', filters.rfc);
if (filters.search) params.set('search', filters.search);
if (filters.uuid) params.set('uuid', filters.uuid);
if (filters.claveProdServ) params.set('claveProdServ', filters.claveProdServ);
if (filters.descripcion) params.set('descripcion', filters.descripcion);
if (filters.orderBy) params.set('orderBy', filters.orderBy);
if (filters.orderDir) params.set('orderDir', filters.orderDir);
if (filters.contribuyenteId) params.set('contribuyenteId', filters.contribuyenteId);
if (filters.page) params.set('page', filters.page.toString());
if (filters.limit) params.set('limit', filters.limit.toString());
const response = await apiClient.get<CfdiConceptoListResponse>(`/cfdi/conceptos?${params}`);
return response.data;
}
export async function deleteCfdi(id: string): Promise<void> {
await apiClient.delete(`/cfdi/${id}`);
}

View File

@@ -69,6 +69,7 @@ export interface InvoiceData {
series?: string;
folioNumber?: number;
conditions?: string;
relatedDocuments?: Array<{ relationship: string; uuids: string[] }>;
}
export interface InvoiceResult {

View File

@@ -16,9 +16,9 @@ export interface Tenant {
export interface CreateTenantData {
nombre: string;
rfc: string;
plan?: 'starter' | 'business' | 'business_ia' | 'enterprise' | 'custom';
cfdiLimit?: number;
usersLimit?: number;
plan?: string;
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
frequency?: 'monthly' | 'annual';
adminEmail: string;
adminNombre: string;
amount?: number;
@@ -42,9 +42,8 @@ export async function createTenant(data: CreateTenantData): Promise<Tenant> {
export interface UpdateTenantData {
nombre?: string;
rfc?: string;
plan?: 'starter' | 'business' | 'business_ia' | 'enterprise' | 'custom';
cfdiLimit?: number;
usersLimit?: number;
plan?: string;
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
active?: boolean;
}

View File

@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as cfdiApi from '@/lib/api/cfdi';
import type { CfdiFilters } from '@horux/shared';
import type { CfdiFilters, CfdiConceptoFilters } from '@horux/shared';
import type { CreateCfdiData } from '@/lib/api/cfdi';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
@@ -58,6 +58,18 @@ export function useCreateManyCfdis() {
});
}
export function useCfdiConceptos(filters: CfdiConceptoFilters) {
const { selectedContribuyenteId } = useContribuyenteStore();
const filtersWithContribuyente: CfdiConceptoFilters = {
...filters,
contribuyenteId: selectedContribuyenteId || undefined,
};
return useQuery({
queryKey: ['cfdi-conceptos', filters, selectedContribuyenteId],
queryFn: () => cfdiApi.getAllCfdiConceptos(filtersWithContribuyente),
});
}
export function useDeleteCfdi() {
const queryClient = useQueryClient();

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

View File

@@ -167,6 +167,44 @@ export interface CfdiConcepto {
creadoEn: string;
}
export interface CfdiConceptoFilters {
fechaInicio?: string;
fechaFin?: string;
tipo?: TipoCfdi;
tipoComprobante?: string;
estado?: string;
rfc?: string;
search?: string;
uuid?: string;
claveProdServ?: string;
descripcion?: string;
orderBy?: 'fecha' | 'importe';
orderDir?: 'asc' | 'desc';
contribuyenteId?: string;
page?: number;
limit?: number;
}
export interface CfdiConceptoListItem extends CfdiConcepto {
cfdiUuid: string | null;
cfdiFechaEmision: string | null;
cfdiRfcEmisor: string | null;
cfdiNombreEmisor: string | null;
cfdiRfcReceptor: string | null;
cfdiNombreReceptor: string | null;
cfdiTipoComprobante: string | null;
cfdiStatus: string | null;
cfdiTipo: string | null;
}
export interface CfdiConceptoListResponse {
data: CfdiConceptoListItem[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CfdiListResponse {
data: Cfdi[];
total: number;

View File

@@ -13,7 +13,7 @@ export interface DespachoInfo {
plan: string;
}
export type DespachoSignupPlan = 'trial' | 'business_control' | 'business_cloud';
export type DespachoSignupPlan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
export interface DespachoSignupRequest {
despacho: {
@@ -23,6 +23,7 @@ export interface DespachoSignupRequest {
codigoPostal?: string;
verticalProfile: VerticalProfile;
plan?: DespachoSignupPlan;
frequency?: 'monthly' | 'annual';
};
owner: {
nombre: string;

View File

@@ -0,0 +1,143 @@
const { Pool } = require('pg');
const { XMLParser } = require('fast-xml-parser');
const DB_NAME = process.argv[2] || 'horux_roem691011ez4';
const BATCH_SIZE = parseInt(process.argv[3]) || 100;
const pool = new Pool({
host: 'localhost',
port: 5432,
user: 'postgres',
password: 'ZxHMrmnwanvLfLDdNJdRthFjWF2Lj1Rb',
database: DB_NAME,
});
const xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: true,
});
function toArray(val) {
if (!val) return [];
return Array.isArray(val) ? val : [val];
}
function pf(v) {
const n = parseFloat(v);
return isNaN(n) ? 0 : n;
}
function extractConceptos(comprobante) {
const conceptosNode = comprobante.Conceptos?.Concepto;
if (!conceptosNode) return [];
const conceptos = toArray(conceptosNode);
return conceptos.map((c) => {
const trasladosC = toArray(c.Impuestos?.Traslados?.Traslado);
const retencionesC = toArray(c.Impuestos?.Retenciones?.Retencion);
let ivaTraslado = 0, iepsTraslado = 0;
for (const t of trasladosC) {
const importe = pf(t['@_Importe']);
if (t['@_Impuesto'] === '002') ivaTraslado += importe;
else if (t['@_Impuesto'] === '003') iepsTraslado += importe;
}
let isrRetencion = 0, ivaRetencion = 0, iepsRetencion = 0;
for (const r of retencionesC) {
const importe = pf(r['@_Importe']);
if (r['@_Impuesto'] === '001') isrRetencion += importe;
else if (r['@_Impuesto'] === '002') ivaRetencion += importe;
else if (r['@_Impuesto'] === '003') iepsRetencion += importe;
}
return {
claveProdServ: c['@_ClaveProdServ'] || null,
noIdentificacion: c['@_NoIdentificacion'] || null,
descripcion: c['@_Descripcion'] || '',
cantidad: pf(c['@_Cantidad']) || 1,
claveUnidad: c['@_ClaveUnidad'] || null,
unidad: c['@_Unidad'] || null,
valorUnitario: pf(c['@_ValorUnitario']),
importe: pf(c['@_Importe']),
descuento: pf(c['@_Descuento']),
isrRetencion,
ivaTraslado,
ivaRetencion,
iepsTraslado,
iepsRetencion,
};
});
}
function parseXml(xmlContent) {
try {
const result = xmlParser.parse(xmlContent);
const comprobante = result.Comprobante;
if (!comprobante) return null;
const tc = pf(comprobante['@_TipoCambio']) || 1;
return { tipoCambio: tc, conceptos: extractConceptos(comprobante) };
} catch (e) {
console.error('Parse error:', e.message);
return null;
}
}
async function run() {
let totalProcessed = 0;
let totalConceptos = 0;
let batch = 0;
while (true) {
batch++;
const { rows: cfdis } = await pool.query(`
SELECT c.id, c.xml_original
FROM cfdis c
LEFT JOIN cfdi_conceptos cc ON cc.cfdi_id = c.id
WHERE c.xml_original IS NOT NULL AND cc.id IS NULL
LIMIT $1
`, [BATCH_SIZE]);
if (cfdis.length === 0) break;
let batchConceptos = 0;
for (const row of cfdis) {
try {
const parsed = parseXml(row.xml_original);
if (!parsed || !parsed.conceptos || parsed.conceptos.length === 0) continue;
const tc = parsed.tipoCambio || 1;
const m = (v) => v * tc;
for (const c of parsed.conceptos) {
await pool.query(`
INSERT INTO cfdi_conceptos (
cfdi_id, clave_prod_serv, no_identificacion, descripcion, cantidad,
clave_unidad, unidad, valor_unitario, valor_unitario_mxn, importe, importe_mxn,
descuento, descuento_mxn, isr_retencion, isr_retencion_mxn,
iva_traslado, iva_traslado_mxn, iva_retencion, iva_retencion_mxn,
ieps_traslado, ieps_traslado_mxn, ieps_retencion, ieps_retencion_mxn
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)
`, [
row.id, c.claveProdServ, c.noIdentificacion, c.descripcion, c.cantidad,
c.claveUnidad, c.unidad, c.valorUnitario, m(c.valorUnitario), c.importe, m(c.importe),
c.descuento, m(c.descuento), c.isrRetencion, m(c.isrRetencion),
c.ivaTraslado, m(c.ivaTraslado), c.ivaRetencion, m(c.ivaRetencion),
c.iepsTraslado, m(c.iepsTraslado), c.iepsRetencion, m(c.iepsRetencion),
]);
batchConceptos++;
}
} catch (err) {
console.error(`Error CFDI ${row.id}:`, err.message);
}
}
totalProcessed += cfdis.length;
totalConceptos += batchConceptos;
console.log(`Batch ${batch}: procesados ${cfdis.length}, conceptos ${batchConceptos}, total procesados ${totalProcessed}, total conceptos ${totalConceptos}`);
if (cfdis.length < BATCH_SIZE) break;
}
console.log(`\nBackfill completado. Total CFDIs procesados: ${totalProcessed}, total conceptos insertados: ${totalConceptos}`);
await pool.end();
}
run().catch((e) => { console.error(e); process.exit(1); });