Compare commits
5 Commits
2ac8e4d055
...
e7dbae1ab7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7dbae1ab7 | ||
|
|
066ba7deda | ||
|
|
3eb0f33f3b | ||
|
|
ee9c76612e | ||
|
|
066c9cdb74 |
34
apps/api/.env.save
Normal file
34
apps/api/.env.save
Normal 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
|
||||||
@@ -71,7 +71,7 @@ class TenantConnectionManager {
|
|||||||
user: connectionOverride?.user ?? this.dbConfig.user,
|
user: connectionOverride?.user ?? this.dbConfig.user,
|
||||||
password: connectionOverride?.password ?? this.dbConfig.password,
|
password: connectionOverride?.password ?? this.dbConfig.password,
|
||||||
database: databaseName,
|
database: databaseName,
|
||||||
max: 3,
|
max: 15,
|
||||||
idleTimeoutMillis: 300_000,
|
idleTimeoutMillis: 300_000,
|
||||||
connectionTimeoutMillis: 10_000,
|
connectionTimeoutMillis: 10_000,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const envSchema = z.object({
|
|||||||
|
|
||||||
// Facturapi
|
// Facturapi
|
||||||
FACTURAPI_USER_KEY: z.string().optional(),
|
FACTURAPI_USER_KEY: z.string().optional(),
|
||||||
|
FACTURAPI_MODE: z.enum(['test', 'live']).optional(),
|
||||||
|
|
||||||
// Cloudflare Tunnel (connector BYO-DB)
|
// Cloudflare Tunnel (connector BYO-DB)
|
||||||
CLOUDFLARE_API_TOKEN: z.string().optional(),
|
CLOUDFLARE_API_TOKEN: z.string().optional(),
|
||||||
|
|||||||
@@ -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) {
|
export async function drillDown(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantPool) {
|
if (!req.tenantPool) {
|
||||||
@@ -444,3 +475,21 @@ export async function deleteCfdi(req: Request, res: Response, next: NextFunction
|
|||||||
next(error);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const signupSchema = z.object({
|
|||||||
regimenFiscal: z.string().optional(),
|
regimenFiscal: z.string().optional(),
|
||||||
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
|
codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(),
|
||||||
verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']),
|
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({
|
owner: z.object({
|
||||||
nombre: z.string().min(2, 'Nombre del owner requerido'),
|
nombre: z.string().min(2, 'Nombre del owner requerido'),
|
||||||
|
|||||||
@@ -101,6 +101,37 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
|
|||||||
const tenantId = effectiveTenantId(req);
|
const tenantId = effectiveTenantId(req);
|
||||||
const contribuyenteId = req.body.contribuyenteId as string | undefined;
|
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
|
// Reservar timbre — si falla emisión en Facturapi, revertimos abajo
|
||||||
const consumedTimbre = await facturapiService.consumeTimbre(tenantId);
|
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],
|
[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(`
|
await pool.query(`
|
||||||
INSERT INTO cfdis (
|
INSERT INTO cfdis (
|
||||||
year, month, type, uuid, serie, folio, status, fecha_emision, fecha_cert_sat,
|
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_traslado, iva_traslado_mxn,
|
||||||
iva_retencion, iva_retencion_mxn,
|
iva_retencion, iva_retencion_mxn,
|
||||||
source, facturapi_id,
|
source, facturapi_id,
|
||||||
contribuyente_id, xml_original
|
contribuyente_id, xml_original,
|
||||||
|
cfdi_tipo_relacion, cfdis_relacionados
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, 'EMITIDO', $3, $4, $5, 'Vigente', $6, $7,
|
$1, $2, 'EMITIDO', $3, $4, $5, 'Vigente', $6, $7,
|
||||||
$8, $9, $10, $11,
|
$8, $9, $10, $11,
|
||||||
@@ -202,7 +240,8 @@ export async function emitir(req: Request, res: Response, next: NextFunction) {
|
|||||||
$23, $23,
|
$23, $23,
|
||||||
$24, $24,
|
$24, $24,
|
||||||
'facturapi', $25,
|
'facturapi', $25,
|
||||||
$26, $27
|
$26, $27,
|
||||||
|
$28, $29
|
||||||
)
|
)
|
||||||
`, [
|
`, [
|
||||||
year, month, parsed.uuid, parsed.serie, parsed.folio, fecha, parsed.fechaCertSat,
|
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,
|
parsed.ivaRetencion,
|
||||||
invoice.id,
|
invoice.id,
|
||||||
contribuyenteId ?? null, xmlString,
|
contribuyenteId ?? null, xmlString,
|
||||||
|
cfdiTipoRelacion, cfdisRelacionados,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Enviar por email si el receptor tiene email — ruteado a la org correcta
|
// Enviar por email si el receptor tiene email — ruteado a la org correcta
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
try {
|
try {
|
||||||
await requireGlobalAdmin(req);
|
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) {
|
if (!nombre || !rfc || !adminEmail || !adminNombre) {
|
||||||
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
|
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,
|
nombre,
|
||||||
rfc,
|
rfc,
|
||||||
plan,
|
plan,
|
||||||
cfdiLimit,
|
verticalProfile,
|
||||||
usersLimit,
|
frequency,
|
||||||
adminEmail,
|
adminEmail,
|
||||||
adminNombre,
|
adminNombre,
|
||||||
amount: amount || 0,
|
amount: amount || 0,
|
||||||
@@ -69,14 +69,13 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
await requireGlobalAdmin(req);
|
await requireGlobalAdmin(req);
|
||||||
|
|
||||||
const id = String(req.params.id);
|
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, {
|
const tenant = await tenantsService.updateTenant(id, {
|
||||||
nombre,
|
nombre,
|
||||||
rfc,
|
rfc,
|
||||||
plan,
|
plan,
|
||||||
cfdiLimit,
|
verticalProfile,
|
||||||
usersLimit,
|
|
||||||
active,
|
active,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { prisma } from '../config/database.js';
|
|||||||
import { startSync, getSyncStatus, retryTimedOutJobs } from '../services/sat/sat.service.js';
|
import { startSync, getSyncStatus, retryTimedOutJobs } from '../services/sat/sat.service.js';
|
||||||
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
|
import { sweepStaleSatJobs } from '../services/sat/sweep-stale-jobs.service.js';
|
||||||
import { hasFielConfigured } from '../services/fiel.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 { applyPendingChanges, expireTrials } from '../services/payment/subscription.service.js';
|
||||||
import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js';
|
import { resetExpiredMonthlyTimbres } from '../services/facturapi.service.js';
|
||||||
import { purgeDeclaracionesAntiguas } from '../services/declaraciones.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 { tenantDb } from '../config/database.js';
|
||||||
|
import { isDespachoTenant } from '@horux/shared';
|
||||||
|
|
||||||
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
|
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
|
||||||
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
||||||
@@ -216,6 +217,47 @@ async function runOpinionJob(): Promise<void> {
|
|||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
|
||||||
for (const tenant of tenants) {
|
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);
|
const hasFiel = await hasFielConfigured(tenant.id);
|
||||||
if (!hasFiel) {
|
if (!hasFiel) {
|
||||||
skipped++;
|
skipped++;
|
||||||
@@ -247,7 +289,7 @@ async function runCsfJob(): Promise<void> {
|
|||||||
|
|
||||||
const tenants = await prisma.tenant.findMany({
|
const tenants = await prisma.tenant.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
select: { id: true, rfc: true },
|
select: { id: true, rfc: true, databaseName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
let success = 0;
|
let success = 0;
|
||||||
@@ -255,6 +297,42 @@ async function runCsfJob(): Promise<void> {
|
|||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
|
||||||
for (const tenant of tenants) {
|
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);
|
const hasFiel = await hasFielConfigured(tenant.id);
|
||||||
if (!hasFiel) { skipped++; continue; }
|
if (!hasFiel) { skipped++; continue; }
|
||||||
try {
|
try {
|
||||||
@@ -390,7 +468,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) {
|
} catch (error: any) {
|
||||||
console.error('[Subscription Cron] Error:', error.message);
|
console.error('[Subscription Cron] Error:', error.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ const router: IRouter = Router();
|
|||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
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.use(checkPlanLimits);
|
||||||
|
|
||||||
router.get('/', cfdiController.getCfdis);
|
router.get('/', cfdiController.getCfdis);
|
||||||
@@ -17,12 +21,13 @@ router.get('/resumen', cfdiController.getResumen);
|
|||||||
router.get('/emisores', cfdiController.getEmisores);
|
router.get('/emisores', cfdiController.getEmisores);
|
||||||
router.get('/receptores', cfdiController.getReceptores);
|
router.get('/receptores', cfdiController.getReceptores);
|
||||||
router.get('/drill-down', cfdiController.drillDown);
|
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', cfdiController.getCfdiById);
|
||||||
router.get('/:id/conceptos', cfdiController.getConceptos);
|
router.get('/:id/conceptos', cfdiController.getConceptos);
|
||||||
router.get('/:id/xml', cfdiController.getXml);
|
router.get('/:id/xml', cfdiController.getXml);
|
||||||
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
|
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
|
||||||
// Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts
|
// Bulk upload: 10/hora — procesa hasta 50MB, pesado en parseo + inserts
|
||||||
router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);
|
router.post('/bulk', strictLimit, express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);
|
||||||
router.delete('/:id', cfdiController.deleteCfdi);
|
|
||||||
|
|
||||||
export { router as cfdiRoutes };
|
export { router as cfdiRoutes };
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Pool } from 'pg';
|
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 { markForInvalidation } from './metricas.service.js';
|
||||||
import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../utils/saldo.js';
|
import { recomputarSaldoPendiente, uuidsAfectadosPorCfdi } from '../utils/saldo.js';
|
||||||
|
import { parseXml } from './sat/sat-parser.service.js';
|
||||||
|
|
||||||
// Common SELECT columns mapping DB → camelCase
|
// Common SELECT columns mapping DB → camelCase
|
||||||
const CFDI_SELECT = `
|
const CFDI_SELECT = `
|
||||||
@@ -208,6 +209,124 @@ export async function getConceptos(pool: Pool, cfdiId: string): Promise<any[]> {
|
|||||||
return rows;
|
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> {
|
export async function getXmlById(pool: Pool, id: string): Promise<string | null> {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT xml_original FROM cfdis WHERE id = $1
|
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`,
|
`SELECT fecha_emision, contribuyente_id FROM cfdis WHERE id = $1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
await pool.query(`DELETE 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
|
// Retroactive invalidation hook: mark cached metrics stale for prior-year CFDIs
|
||||||
try {
|
try {
|
||||||
@@ -632,3 +762,74 @@ export async function getResumenCfdis(pool: Pool, año: number, mes: number, con
|
|||||||
ivaAcreditable: Number(r?.iva_acreditable || 0),
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -299,8 +299,9 @@ export async function consultarConstanciaContribuyente(
|
|||||||
function cleanDomField(val: string | undefined): string {
|
function cleanDomField(val: string | undefined): string {
|
||||||
if (!val) return '';
|
if (!val) return '';
|
||||||
// Remove embedded label prefixes like "Nombre de la Colonia: "
|
// Remove embedded label prefixes like "Nombre de la Colonia: "
|
||||||
|
// Labels must be ordered from longest to shortest to avoid partial matches.
|
||||||
return val
|
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();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,33 +8,58 @@ function getUserClient(): Facturapi {
|
|||||||
return new Facturapi(env.FACTURAPI_USER_KEY);
|
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!;
|
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 {
|
try {
|
||||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, {
|
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, {
|
||||||
headers: { 'Authorization': `Bearer ${userKey}` },
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${userKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const key = await res.text();
|
const key = await res.text();
|
||||||
apiKey = key.replace(/"/g, '').trim();
|
apiKey = key.replace(/"/g, '').trim();
|
||||||
}
|
}
|
||||||
} catch { /* no test key */ }
|
} catch { /* fallback below */ }
|
||||||
|
|
||||||
|
// 3. Fallback a test key si el PUT falla (modo desarrollo/prueba)
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys`, {
|
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, {
|
||||||
headers: { 'Authorization': `Bearer ${userKey}` },
|
headers: { 'Authorization': `Bearer ${userKey}` },
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json() as any;
|
const key = await res.text();
|
||||||
if (data?.data?.length > 0) apiKey = data.data[0].api_key;
|
apiKey = key.replace(/"/g, '').trim();
|
||||||
}
|
}
|
||||||
} catch { /* no live keys */ }
|
} catch { /* no test key */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiKey) throw new Error('Organización Facturapi sin API key');
|
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;
|
return apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +205,7 @@ export async function getOrgClientContribuyente(
|
|||||||
);
|
);
|
||||||
if (rows.length === 0) throw new Error('Contribuyente no tiene organización Facturapi configurada');
|
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);
|
return new Facturapi(apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,10 +333,11 @@ export async function createInvoiceContribuyente(
|
|||||||
if (data.series) invoicePayload.series = data.series;
|
if (data.series) invoicePayload.series = data.series;
|
||||||
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
|
if (data.folioNumber) invoicePayload.folio_number = data.folioNumber;
|
||||||
|
|
||||||
|
// Documentos relacionados (Ingreso / Egreso)
|
||||||
if (data.relatedDocuments?.length) {
|
if (data.relatedDocuments?.length) {
|
||||||
invoicePayload.related_documents = data.relatedDocuments.map((r: any) => ({
|
invoicePayload.related_documents = data.relatedDocuments.map((r: any) => ({
|
||||||
relationship: r.relationship,
|
relationship: r.relationship,
|
||||||
documents: [r.uuid],
|
documents: r.uuids || [r.uuid],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -299,10 +299,11 @@ export async function calcularIngresosPorRegimen(
|
|||||||
const ignorados = await getIgnorados(tenantId, _ignorados);
|
const ignorados = await getIgnorados(tenantId, _ignorados);
|
||||||
const descMap = await getDescMap(_descMap);
|
const descMap = await getDescMap(_descMap);
|
||||||
|
|
||||||
// Read-through cache: si el rango cae en años pasados con meses completos
|
// Read-through cache: solo si flags son default (true) para garantizar
|
||||||
// y hay un contribuyente seleccionado, lee de metricas_mensuales. Si hit,
|
// que las queries filtradas no lean datos del caché completo.
|
||||||
// retorna de inmediato (evita ~3 queries SQL por régimen).
|
const cacheRange = considerarActivos && considerarNCs
|
||||||
const cacheRange = planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId);
|
? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId)
|
||||||
|
: null;
|
||||||
if (cacheRange) {
|
if (cacheRange) {
|
||||||
const cached = await readIngresosFromCache(pool, cacheRange, ignorados, descMap);
|
const cached = await readIngresosFromCache(pool, cacheRange, ignorados, descMap);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@@ -474,8 +475,11 @@ export async function calcularEgresosPorRegimen(
|
|||||||
const ignorados = await getIgnorados(tenantId, _ignorados);
|
const ignorados = await getIgnorados(tenantId, _ignorados);
|
||||||
const descMap = await getDescMap(_descMap);
|
const descMap = await getDescMap(_descMap);
|
||||||
|
|
||||||
// Read-through cache: ver nota en calcularIngresosPorRegimen.
|
// Read-through cache: solo si flags son default (true) para garantizar
|
||||||
const cacheRange = planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId);
|
// que las queries filtradas no lean datos del caché completo.
|
||||||
|
const cacheRange = considerarActivos && considerarNCs
|
||||||
|
? planCache(fechaInicio, fechaFin, conciliacion, contribuyenteId)
|
||||||
|
: null;
|
||||||
if (cacheRange) {
|
if (cacheRange) {
|
||||||
const cached = await readEgresosFromCache(pool, cacheRange, ignorados, descMap);
|
const cached = await readEgresosFromCache(pool, cacheRange, ignorados, descMap);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export async function signupDespacho(data: DespachoSignupRequest) {
|
|||||||
data: {
|
data: {
|
||||||
nombre: despacho.nombre,
|
nombre: despacho.nombre,
|
||||||
rfc: tenantSlug.toUpperCase(),
|
rfc: tenantSlug.toUpperCase(),
|
||||||
plan: 'enterprise',
|
plan: (despacho.plan === 'trial' ? 'enterprise' : despacho.plan) as any,
|
||||||
databaseName: databaseName,
|
databaseName: databaseName,
|
||||||
cfdiLimit: -1,
|
cfdiLimit: -1,
|
||||||
usersLimit: -1,
|
usersLimit: -1,
|
||||||
@@ -103,7 +103,7 @@ export async function signupDespacho(data: DespachoSignupRequest) {
|
|||||||
const result2 = await subscriptionService.subscribe({
|
const result2 = await subscriptionService.subscribe({
|
||||||
tenantId: result.tenant.id,
|
tenantId: result.tenant.id,
|
||||||
plan: data.despacho.plan as any,
|
plan: data.despacho.plan as any,
|
||||||
frequency: 'annual',
|
frequency: data.despacho.frequency ?? 'annual',
|
||||||
payerEmail: owner.email,
|
payerEmail: owner.email,
|
||||||
});
|
});
|
||||||
paymentUrl = result2.paymentUrl;
|
paymentUrl = result2.paymentUrl;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function getUserClient(): Facturapi {
|
|||||||
async function getOrgClient(tenantId: string): Promise<Facturapi> {
|
async function getOrgClient(tenantId: string): Promise<Facturapi> {
|
||||||
const tenant = await prisma.tenant.findUnique({
|
const tenant = await prisma.tenant.findUnique({
|
||||||
where: { id: tenantId },
|
where: { id: tenantId },
|
||||||
select: { facturapiOrgId: true },
|
select: { id: true, facturapiOrgId: true, facturapiOrgKey: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tenant?.facturapiOrgId) {
|
if (!tenant?.facturapiOrgId) {
|
||||||
@@ -31,36 +31,53 @@ async function getOrgClient(tenantId: string): Promise<Facturapi> {
|
|||||||
const orgId = tenant.facturapiOrgId;
|
const orgId = tenant.facturapiOrgId;
|
||||||
const userKey = env.FACTURAPI_USER_KEY!;
|
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 {
|
try {
|
||||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, {
|
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/live`, {
|
||||||
headers: { 'Authorization': `Bearer ${userKey}` },
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${userKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const key = await res.text();
|
const key = await res.text();
|
||||||
apiKey = key.replace(/"/g, '').trim();
|
apiKey = key.replace(/"/g, '').trim();
|
||||||
}
|
}
|
||||||
} catch { /* no test key */ }
|
} catch { /* fallback below */ }
|
||||||
|
|
||||||
// Fallback a live key
|
// 3. Fallback a test key si el PUT falla (modo desarrollo/prueba)
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys`, {
|
const res = await fetch(`https://www.facturapi.io/v2/organizations/${orgId}/apikeys/test`, {
|
||||||
headers: { 'Authorization': `Bearer ${userKey}` },
|
headers: { 'Authorization': `Bearer ${userKey}` },
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json() as any;
|
const key = await res.text();
|
||||||
if (data?.data?.length > 0) apiKey = data.data[0].api_key;
|
apiKey = key.replace(/"/g, '').trim();
|
||||||
}
|
}
|
||||||
} catch { /* no live keys */ }
|
} catch { /* no test key */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error('Organización Facturapi sin API key');
|
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);
|
return new Facturapi(apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +264,7 @@ export interface FacturapiInvoiceData {
|
|||||||
series?: string;
|
series?: string;
|
||||||
folioNumber?: number;
|
folioNumber?: number;
|
||||||
conditions?: string;
|
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).
|
* Régimen fiscal del emisor (override del default de la organización).
|
||||||
* Requerido cuando el contribuyente tiene múltiples régimenes y Facturapi
|
* 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.series) invoiceData.series = data.series;
|
||||||
if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
|
if (data.folioNumber) invoiceData.folio_number = data.folioNumber;
|
||||||
|
|
||||||
// Documentos relacionados (Egreso)
|
// Documentos relacionados (Ingreso / Egreso)
|
||||||
if (data.relatedDocuments?.length) {
|
if (data.relatedDocuments?.length) {
|
||||||
invoiceData.related_documents = data.relatedDocuments.map(r => ({
|
invoiceData.related_documents = data.relatedDocuments.map(r => ({
|
||||||
relationship: r.relationship,
|
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
|
* 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.
|
* (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
|
* 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
|
* la compra y el filtro `expiraEn > now` los excluye automáticamente cuando
|
||||||
* caducan.
|
* caducan.
|
||||||
*/
|
*/
|
||||||
export async function resetExpiredMonthlyTimbres(): Promise<{ reset: number }> {
|
export async function resetExpiredMonthlyTimbres(): Promise<{ reset: number; customCreated: number }> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
// 1) Resetear suscripciones vencidas (comportamiento original)
|
||||||
const vencidas = await prisma.timbreSuscripcion.findMany({
|
const vencidas = await prisma.timbreSuscripcion.findMany({
|
||||||
where: { periodoFin: { lt: now } },
|
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]}`);
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -353,11 +353,12 @@ export async function getIvaMensual(
|
|||||||
considerarActivos: boolean = true,
|
considerarActivos: boolean = true,
|
||||||
considerarNCs: boolean = true,
|
considerarNCs: boolean = true,
|
||||||
): Promise<IvaMensual[]> {
|
): Promise<IvaMensual[]> {
|
||||||
// Cache read-through: solo si año pasado, sin conciliación, con contribuyente y flags default
|
// Cache read-through: sin conciliación, con contribuyente y flags default.
|
||||||
const currentYear = new Date().getFullYear();
|
// 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 =
|
const cacheable =
|
||||||
process.env.METRICAS_BYPASS_CACHE !== '1' &&
|
process.env.METRICAS_BYPASS_CACHE !== '1' &&
|
||||||
año < currentYear &&
|
|
||||||
!conciliacion &&
|
!conciliacion &&
|
||||||
considerarActivos &&
|
considerarActivos &&
|
||||||
considerarNCs &&
|
considerarNCs &&
|
||||||
@@ -607,25 +608,20 @@ async function readResumenIvaFromCache(
|
|||||||
|
|
||||||
const resultado = trasladado - acreditable - retenido;
|
const resultado = trasladado - acreditable - retenido;
|
||||||
|
|
||||||
// Acumulado anual: su rango (year-01-01 → fechaFin) difiere del rango cacheado;
|
// Acumulado anual: en vez de consultar raw cfdis, sumamos desde
|
||||||
// se calcula on-the-fly contra raw cfdis. Una sola query.
|
// 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 añoInicio = new Date(fechaInicio + 'T00:00:00').getFullYear();
|
||||||
const acumFR = conciliacion ? FECHA_RANGO_CONCILIACION : FECHA_RANGO;
|
const acumEnd = `${añoInicio}-${String(new Date(fechaFin + 'T00:00:00').getMonth() + 1).padStart(2, '0')}-01`;
|
||||||
const REGIMEN_TENANT = regimenTenantExpr(ctx);
|
const { rows: [acumRow] } = await pool.query(`
|
||||||
const acumRow = (await pool.query(`
|
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(${signedCausadoTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
COALESCE(SUM(iva_trasladado_total), 0) -
|
||||||
COALESCE(SUM(${signedAcreditableTras(ctx, considerarActivos, considerarNCs)}), 0) -
|
COALESCE(SUM(iva_acreditable), 0) -
|
||||||
(
|
COALESCE(SUM(iva_retenido_cobrado), 0) as total
|
||||||
COALESCE(SUM(${signedCausadoRet(ctx, considerarActivos, considerarNCs)}), 0) -
|
FROM metricas_mensuales
|
||||||
COALESCE(SUM(${signedAcreditableRet(ctx, considerarActivos, considerarNCs)}), 0)
|
WHERE contribuyente_id = $1
|
||||||
) as total
|
AND make_date(anio, mes, 1) BETWEEN $2::date AND $3::date
|
||||||
FROM cfdis
|
`, [range.contribuyenteId, `${añoInicio}-01-01`, acumEnd]);
|
||||||
WHERE ${VIGENTE}
|
|
||||||
AND (${REGIMEN_TENANT}) = ANY($3)
|
|
||||||
AND ${acumFR}
|
|
||||||
AND (${ctx.esEmisor} OR ${ctx.esReceptor})
|
|
||||||
`, [`${añoInicio}-01-01`, fechaFin, TODOS_REGIMENES])).rows[0];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trasladado,
|
trasladado,
|
||||||
|
|||||||
@@ -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.numParcialidad = parcialidades.length > 0 ? parcialidades.join('|') : null;
|
||||||
result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null;
|
result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null;
|
||||||
result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null;
|
result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null;
|
||||||
|
|||||||
@@ -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 MAX_RETRIES = 3; // Máximo de reintentos tras timeout
|
||||||
const RETRY_DELAY_HOURS = 6; // Horas entre reintentos
|
const RETRY_DELAY_HOURS = 6; // Horas entre reintentos
|
||||||
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
|
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 {
|
interface SyncContext {
|
||||||
fielData: FielData;
|
fielData: FielData;
|
||||||
@@ -666,7 +667,11 @@ async function processInitialSync(
|
|||||||
customDateTo?: Date
|
customDateTo?: Date
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ahora = new Date();
|
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;
|
const fechaFin = customDateTo || ahora;
|
||||||
|
|
||||||
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
|
// Paso 1: Sondeo — determinar tamaño de bloque para XMLs
|
||||||
@@ -983,13 +988,19 @@ export async function startSync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
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({
|
const job = await prisma.satSyncJob.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
contribuyenteId: contribuyenteId || null,
|
contribuyenteId: contribuyenteId || null,
|
||||||
type,
|
type,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
dateFrom: dateFrom || new Date(now.getFullYear() - YEARS_TO_SYNC, 0, 1),
|
dateFrom: dateFrom || defaultDateFrom,
|
||||||
dateTo: dateTo || now,
|
dateTo: dateTo || now,
|
||||||
startedAt: now,
|
startedAt: now,
|
||||||
},
|
},
|
||||||
@@ -1009,7 +1020,7 @@ export async function startSync(
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
if (type === 'initial') {
|
if (type === 'initial') {
|
||||||
await processInitialSync(ctx, job.id, dateFrom, dateTo);
|
await processInitialSync(ctx, job.id, job.dateFrom, job.dateTo);
|
||||||
} else if (type === 'incremental') {
|
} else if (type === 'incremental') {
|
||||||
await processIncrementalSync(ctx, job.id);
|
await processIncrementalSync(ctx, job.id);
|
||||||
} else if (dateFrom && dateTo) {
|
} else if (dateFrom && dateTo) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { prisma, tenantDb } from '../config/database.js';
|
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 { emailService } from './email/email.service.js';
|
||||||
import * as metabaseService from './metabase.service.js';
|
import * as metabaseService from './metabase.service.js';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
@@ -42,18 +42,47 @@ export async function getTenantById(id: string) {
|
|||||||
export async function createTenant(data: {
|
export async function createTenant(data: {
|
||||||
nombre: string;
|
nombre: string;
|
||||||
rfc: string;
|
rfc: string;
|
||||||
plan?: 'starter' | 'business' | 'enterprise';
|
plan?: string;
|
||||||
cfdiLimit?: number;
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
usersLimit?: number;
|
frequency?: 'monthly' | 'annual';
|
||||||
adminEmail: string;
|
adminEmail: string;
|
||||||
adminNombre: string;
|
adminNombre: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
}) {
|
}) {
|
||||||
const plan = data.plan || 'starter';
|
const plan = data.plan || 'trial';
|
||||||
const planConfig = PLANS[plan];
|
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
|
// 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)
|
// 1b. Register tenant database in Metabase (non-blocking, logs errors only)
|
||||||
metabaseService.registerDatabase({
|
metabaseService.registerDatabase({
|
||||||
@@ -65,11 +94,14 @@ export async function createTenant(data: {
|
|||||||
const tenant = await prisma.tenant.create({
|
const tenant = await prisma.tenant.create({
|
||||||
data: {
|
data: {
|
||||||
nombre: data.nombre,
|
nombre: data.nombre,
|
||||||
rfc: data.rfc.toUpperCase(),
|
rfc,
|
||||||
plan,
|
plan: plan as any,
|
||||||
databaseName,
|
databaseName,
|
||||||
cfdiLimit: data.cfdiLimit || planConfig.cfdiLimit,
|
cfdiLimit,
|
||||||
usersLimit: data.usersLimit || planConfig.usersLimit,
|
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)
|
||||||
await prisma.subscription.create({
|
if (isDespacho && timbresIncluidos > 0) {
|
||||||
data: {
|
const inicio = new Date();
|
||||||
tenantId: tenant.id,
|
const fin = new Date(inicio);
|
||||||
plan,
|
fin.setMonth(fin.getMonth() + 1);
|
||||||
status: 'pending',
|
fin.setDate(fin.getDate() - 1);
|
||||||
amount: data.amount,
|
await prisma.timbreSuscripcion.create({
|
||||||
frequency: 'monthly',
|
data: {
|
||||||
},
|
tenantId: tenant.id,
|
||||||
});
|
tipo: 'mensual',
|
||||||
|
timbresLimite: timbresIncluidos,
|
||||||
|
timbresUsados: 0,
|
||||||
|
periodoInicio: inicio,
|
||||||
|
periodoFin: fin,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 5. Send welcome email to client (non-blocking)
|
// 5. Create subscription (only for paid plans)
|
||||||
|
if (!isDespacho || (plan !== 'trial' && plan !== 'custom')) {
|
||||||
|
await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
plan: plan as any,
|
||||||
|
status: 'pending',
|
||||||
|
amount: data.amount,
|
||||||
|
frequency: data.frequency || 'monthly',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Send welcome email to client (non-blocking)
|
||||||
emailService.sendWelcome(data.adminEmail, {
|
emailService.sendWelcome(data.adminEmail, {
|
||||||
nombre: data.adminNombre,
|
nombre: data.adminNombre,
|
||||||
email: data.adminEmail,
|
email: data.adminEmail,
|
||||||
tempPassword,
|
tempPassword,
|
||||||
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
|
}).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({
|
emailService.sendNewClientAdmin({
|
||||||
clienteNombre: data.nombre,
|
clienteNombre: data.nombre,
|
||||||
clienteRfc: data.rfc.toUpperCase(),
|
clienteRfc: rfc,
|
||||||
adminEmail: data.adminEmail,
|
adminEmail: data.adminEmail,
|
||||||
adminNombre: data.adminNombre,
|
adminNombre: data.adminNombre,
|
||||||
tempPassword,
|
tempPassword,
|
||||||
@@ -265,9 +317,8 @@ export async function getMyTenantsDetailed(userId: string, onlyOwner = true) {
|
|||||||
export async function updateTenant(id: string, data: {
|
export async function updateTenant(id: string, data: {
|
||||||
nombre?: string;
|
nombre?: string;
|
||||||
rfc?: string;
|
rfc?: string;
|
||||||
plan?: 'starter' | 'business' | 'enterprise';
|
plan?: string;
|
||||||
cfdiLimit?: number;
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
usersLimit?: number;
|
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return prisma.tenant.update({
|
return prisma.tenant.update({
|
||||||
@@ -275,9 +326,8 @@ export async function updateTenant(id: string, data: {
|
|||||||
data: {
|
data: {
|
||||||
...(data.nombre && { nombre: data.nombre }),
|
...(data.nombre && { nombre: data.nombre }),
|
||||||
...(data.rfc && { rfc: data.rfc.toUpperCase() }),
|
...(data.rfc && { rfc: data.rfc.toUpperCase() }),
|
||||||
...(data.plan && { plan: data.plan }),
|
...(data.plan && { plan: data.plan as any }),
|
||||||
...(data.cfdiLimit !== undefined && { cfdiLimit: data.cfdiLimit }),
|
...(data.verticalProfile && { verticalProfile: data.verticalProfile as any }),
|
||||||
...(data.usersLimit !== undefined && { usersLimit: data.usersLimit }),
|
|
||||||
...(data.active !== undefined && { active: data.active }),
|
...(data.active !== undefined && { active: data.active }),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ export interface CacheRange {
|
|||||||
* (read-through cache de Tanda B). Requisitos:
|
* (read-through cache de Tanda B). Requisitos:
|
||||||
* - `contribuyenteId` presente (la tabla solo tiene datos por contribuyente)
|
* - `contribuyenteId` presente (la tabla solo tiene datos por contribuyente)
|
||||||
* - `conciliacion` desactivada (la tabla guarda flujo normal, no id_conciliacion)
|
* - `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
|
* - `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.
|
* Retorna `null` si no califica — el caller debe caer al path on-the-fly.
|
||||||
*/
|
*/
|
||||||
export function planCache(
|
export function planCache(
|
||||||
@@ -27,8 +31,8 @@ export function planCache(
|
|||||||
const fi = new Date(fechaInicio + 'T00:00:00Z');
|
const fi = new Date(fechaInicio + 'T00:00:00Z');
|
||||||
const ff = new Date(fechaFin + 'T00:00:00Z');
|
const ff = new Date(fechaFin + 'T00:00:00Z');
|
||||||
if (isNaN(fi.getTime()) || isNaN(ff.getTime())) return null;
|
if (isNaN(fi.getTime()) || isNaN(ff.getTime())) return null;
|
||||||
const currentYearStart = new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1));
|
// Ya no se excluye el año actual: si hay datos precalculados se usan,
|
||||||
if (ff >= currentYearStart) return null;
|
// si no, read*FromCache retorna null y el caller cae a on-the-fly.
|
||||||
if (fi.getUTCDate() !== 1) return null;
|
if (fi.getUTCDate() !== 1) return null;
|
||||||
const lastDay = new Date(Date.UTC(ff.getUTCFullYear(), ff.getUTCMonth() + 1, 0)).getUTCDate();
|
const lastDay = new Date(Date.UTC(ff.getUTCFullYear(), ff.getUTCMonth() + 1, 0)).getUTCDate();
|
||||||
if (ff.getUTCDate() !== lastDay) return null;
|
if (ff.getUTCDate() !== lastDay) return null;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Image from 'next/image';
|
|||||||
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
|
import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||||
import { login } from '@/lib/api/auth';
|
import { login } from '@/lib/api/auth';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared';
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -30,13 +30,7 @@ export default function LoginPage() {
|
|||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
|
|
||||||
const userRole = response.user?.role;
|
const userRole = response.user?.role;
|
||||||
// Admin global aterriza directo en `/clientes` — su home natural es la
|
if (userRole === 'cliente' || userRole === 'auxiliar' || userRole === 'supervisor') {
|
||||||
// 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') {
|
|
||||||
// Clients and roles without onboarding go straight to dashboard
|
// Clients and roles without onboarding go straight to dashboard
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import Link from 'next/link';
|
|||||||
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, cn } from '@horux/shared-ui';
|
import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, cn } from '@horux/shared-ui';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { apiClient } from '@/lib/api/client';
|
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 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() {
|
export default function RegisterDespachoPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -17,6 +18,8 @@ export default function RegisterDespachoPage() {
|
|||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [verticalProfile, setVerticalProfile] = useState<VerticalProfile | null>(null);
|
const [verticalProfile, setVerticalProfile] = useState<VerticalProfile | null>(null);
|
||||||
const [selectedPlan, setSelectedPlan] = useState<PlanType | 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 [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
@@ -38,11 +41,16 @@ export default function RegisterDespachoPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
|
const frequency: Frequency | undefined =
|
||||||
|
selectedPlan === 'mi_empresa' ? meFreq :
|
||||||
|
selectedPlan === 'mi_empresa_plus' ? mePlusFreq :
|
||||||
|
undefined;
|
||||||
const { data } = await apiClient.post('/despachos/signup', {
|
const { data } = await apiClient.post('/despachos/signup', {
|
||||||
despacho: {
|
despacho: {
|
||||||
nombre: form.despachoNombre,
|
nombre: form.despachoNombre,
|
||||||
verticalProfile,
|
verticalProfile,
|
||||||
plan: selectedPlan,
|
plan: selectedPlan,
|
||||||
|
frequency,
|
||||||
},
|
},
|
||||||
owner: {
|
owner: {
|
||||||
nombre: form.ownerNombre,
|
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>
|
<span className="bg-primary text-primary-foreground rounded-full w-6 h-6 flex items-center justify-center font-bold">3</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold">Elige tu plan</h1>
|
<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>
|
||||||
|
|
||||||
<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 */}
|
{/* Trial Gratuito */}
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -202,6 +210,99 @@ export default function RegisterDespachoPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Business Control */}
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -219,21 +320,23 @@ export default function RegisterDespachoPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold">$21,000</div>
|
<div className="text-3xl font-bold">$25,850</div>
|
||||||
<p className="text-sm text-muted-foreground">primer año (IVA incluido)</p>
|
<p className="text-sm text-muted-foreground">por 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>
|
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-sm">
|
<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>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>RFCs ilimitados</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>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>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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Business Cloud */}
|
{/* Enterprise (business_cloud) */}
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'cursor-pointer transition-all hover:shadow-lg relative',
|
'cursor-pointer transition-all hover:shadow-lg relative',
|
||||||
@@ -241,27 +344,27 @@ export default function RegisterDespachoPage() {
|
|||||||
)}
|
)}
|
||||||
onClick={() => setSelectedPlan('business_cloud')}
|
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">
|
<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">
|
<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" />
|
<Cloud className="h-6 w-6 text-purple-600 dark:text-purple-400" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl">Business Cloud</CardTitle>
|
<CardTitle className="text-xl">Enterprise</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground">Nosotros lo operamos por ti</p>
|
<p className="text-sm text-muted-foreground">Despachos grandes con alto volumen</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-3xl font-bold">$15,000</div>
|
<div className="text-3xl font-bold">$43,000</div>
|
||||||
<p className="text-sm text-muted-foreground">por año (fijo)</p>
|
<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 gestionado</p>
|
<p className="text-xs text-muted-foreground mt-1">+ $45/mes por cada RFC adicional sobre 100</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-sm">
|
<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>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>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>Usuarios ilimitados</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 className="flex items-center gap-2"><CheckCircle2 className="h-4 w-4 text-green-500 flex-shrink-0" /><span>Soporte prioritario</span></div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useState, useRef, useCallback, useEffect } from 'react';
|
|||||||
import { useDebounce } from '@horux/shared-ui';
|
import { useDebounce } from '@horux/shared-ui';
|
||||||
import { Header } from '@/components/layouts/header';
|
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 { 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 { useCfdis, useCfdiConceptos, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||||
import { createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
|
import { getCfdis, createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||||
import { cancelarFactura } from '@/lib/api/facturacion';
|
import { cancelarFactura, downloadPdf, downloadXml } from '@/lib/api/facturacion';
|
||||||
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
import type { CfdiFilters, CfdiConceptoFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||||
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
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 * as XLSX from 'xlsx';
|
||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||||
@@ -262,6 +262,30 @@ export default function CfdiPage() {
|
|||||||
const [loadingReceptor, setLoadingReceptor] = useState(false);
|
const [loadingReceptor, setLoadingReceptor] = useState(false);
|
||||||
const [showForm, setShowForm] = 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
|
// Debounced values for autocomplete
|
||||||
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
|
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
|
||||||
const debouncedReceptor = useDebounce(columnFilters.receptor, 300);
|
const debouncedReceptor = useDebounce(columnFilters.receptor, 300);
|
||||||
@@ -322,6 +346,21 @@ export default function CfdiPage() {
|
|||||||
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
|
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
|
||||||
const [loadingCfdi, setLoadingCfdi] = useState<string | 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
|
// Cancelación Facturapi state
|
||||||
const [cancelTarget, setCancelTarget] = useState<any | null>(null);
|
const [cancelTarget, setCancelTarget] = useState<any | null>(null);
|
||||||
const [cancelMotive, setCancelMotive] = useState<'01' | '02' | '03' | '04'>('02');
|
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 canEdit = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'contador' || user?.role === 'auxiliar';
|
||||||
|
const canDelete = user?.role === 'owner' || user?.role === 'contador';
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
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
|
// Export to Excel
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
@@ -356,7 +442,17 @@ export default function CfdiPage() {
|
|||||||
|
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
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'),
|
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
||||||
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
|
'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante),
|
||||||
'Serie': cfdi.serie || '',
|
'Serie': cfdi.serie || '',
|
||||||
@@ -399,6 +495,40 @@ export default function CfdiPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 exportSingleCfdiToExcel = (cfdi: Cfdi) => {
|
||||||
const row = {
|
const row = {
|
||||||
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
||||||
@@ -741,11 +871,19 @@ export default function CfdiPage() {
|
|||||||
|
|
||||||
const handleDelete = async (id: string | number) => {
|
const handleDelete = async (id: string | number) => {
|
||||||
const idStr = String(id);
|
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?')) {
|
if (confirm('¿Eliminar este CFDI?')) {
|
||||||
try {
|
try {
|
||||||
await deleteCfdi.mutateAsync(idStr);
|
await deleteCfdi.mutateAsync(idStr);
|
||||||
} catch (error) {
|
alert('CFDI eliminado correctamente');
|
||||||
|
} catch (error: any) {
|
||||||
console.error('Error deleting CFDI:', error);
|
console.error('Error deleting CFDI:', error);
|
||||||
|
const msg = error?.response?.data?.message || error?.message || 'Error al eliminar el CFDI';
|
||||||
|
alert('Error: ' + msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -809,6 +947,21 @@ export default function CfdiPage() {
|
|||||||
<>
|
<>
|
||||||
<Header title="Gestion de CFDI" />
|
<Header title="Gestion de CFDI" />
|
||||||
<main className="p-6 space-y-6">
|
<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 */}
|
{/* Filters */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
@@ -899,6 +1052,8 @@ export default function CfdiPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{activeTab === 'cfdis' && (
|
||||||
|
<>
|
||||||
{/* Add CFDI Form */}
|
{/* Add CFDI Form */}
|
||||||
{showForm && canEdit && (
|
{showForm && canEdit && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -1586,6 +1741,7 @@ export default function CfdiPage() {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</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 text-right">Total</th>
|
||||||
<th className="pb-3 font-medium">Estado</th>
|
<th className="pb-3 font-medium">Estado</th>
|
||||||
<th className="pb-3 font-medium"></th>
|
<th className="pb-3 font-medium"></th>
|
||||||
@@ -1625,6 +1781,9 @@ export default function CfdiPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<span className="text-xs text-muted-foreground">{cfdi.usoCfdi || '-'}</span>
|
||||||
|
</td>
|
||||||
<td className="py-3 text-right font-medium">
|
<td className="py-3 text-right font-medium">
|
||||||
{formatCurrency(cfdi.total)}
|
{formatCurrency(cfdi.total)}
|
||||||
</td>
|
</td>
|
||||||
@@ -1654,6 +1813,21 @@ export default function CfdiPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</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">
|
<td className="py-3">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -1678,15 +1852,17 @@ export default function CfdiPage() {
|
|||||||
<XCircle className="h-4 w-4" />
|
<XCircle className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{canDelete && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="icon"
|
variant="ghost"
|
||||||
onClick={() => handleDelete(cfdi.id)}
|
size="icon"
|
||||||
className="text-destructive hover:text-destructive"
|
onClick={() => handleDelete(cfdi.id)}
|
||||||
title="Eliminar registro (solo local)"
|
className="text-destructive hover:text-destructive"
|
||||||
>
|
title="Eliminar registro (solo local)"
|
||||||
<Trash2 className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
@@ -1729,6 +1905,219 @@ export default function CfdiPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</main>
|
||||||
|
|
||||||
<CfdiViewerModal
|
<CfdiViewerModal
|
||||||
|
|||||||
@@ -10,22 +10,24 @@ import { useTenantViewStore } from '@/stores/tenant-view-store';
|
|||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X, DollarSign, AlertCircle, ChevronRight } from 'lucide-react';
|
import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X, DollarSign, AlertCircle, ChevronRight } from 'lucide-react';
|
||||||
import type { Tenant } from '@/lib/api/tenants';
|
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';
|
import { getClientesStats, getTenantUsuarios, type TenantUsuario } from '@/lib/api/admin-clientes';
|
||||||
|
|
||||||
const PLAN_LABELS: Record<string, string> = {
|
const PLAN_LABELS: Record<string, string> = {
|
||||||
starter: 'Starter',
|
trial: 'Trial Gratuito',
|
||||||
business: 'Business',
|
|
||||||
business_ia: 'Business + IA',
|
|
||||||
enterprise: 'Enterprise',
|
|
||||||
custom: 'Custom',
|
|
||||||
mi_empresa: 'Mi Empresa',
|
mi_empresa: 'Mi Empresa',
|
||||||
mi_empresa_plus: 'Mi Empresa +',
|
mi_empresa_plus: 'Mi Empresa +',
|
||||||
business_control: 'Business Control',
|
business_control: 'Business Control',
|
||||||
business_cloud: 'Enterprise (Despacho)',
|
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() {
|
export default function ClientesPage() {
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
@@ -81,13 +83,17 @@ export default function ClientesPage() {
|
|||||||
nombre: string;
|
nombre: string;
|
||||||
rfc: string;
|
rfc: string;
|
||||||
plan: PlanType;
|
plan: PlanType;
|
||||||
|
verticalProfile: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
|
frequency: 'monthly' | 'annual';
|
||||||
adminEmail: string;
|
adminEmail: string;
|
||||||
adminNombre: string;
|
adminNombre: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
}>({
|
}>({
|
||||||
nombre: '',
|
nombre: '',
|
||||||
rfc: '',
|
rfc: '',
|
||||||
plan: 'starter',
|
plan: 'trial',
|
||||||
|
verticalProfile: 'CONTABLE',
|
||||||
|
frequency: 'annual',
|
||||||
adminEmail: '',
|
adminEmail: '',
|
||||||
adminNombre: '',
|
adminNombre: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
@@ -116,12 +122,20 @@ export default function ClientesPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingTenant) {
|
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);
|
setEditingTenant(null);
|
||||||
} else {
|
} else {
|
||||||
await createTenant.mutateAsync(formData);
|
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);
|
setShowForm(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
@@ -133,7 +147,9 @@ export default function ClientesPage() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
nombre: tenant.nombre,
|
nombre: tenant.nombre,
|
||||||
rfc: tenant.rfc,
|
rfc: tenant.rfc,
|
||||||
plan: tenant.plan as PlanType,
|
plan: (tenant.plan as PlanType) || 'trial',
|
||||||
|
verticalProfile: 'CONTABLE',
|
||||||
|
frequency: 'annual',
|
||||||
adminEmail: '',
|
adminEmail: '',
|
||||||
adminNombre: '',
|
adminNombre: '',
|
||||||
amount: 0,
|
amount: 0,
|
||||||
@@ -154,7 +170,7 @@ export default function ClientesPage() {
|
|||||||
const handleCancelForm = () => {
|
const handleCancelForm = () => {
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingTenant(null);
|
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) => {
|
const handleViewClient = (tenantId: string, tenantName: string) => {
|
||||||
@@ -175,15 +191,16 @@ export default function ClientesPage() {
|
|||||||
// los planes — legacy + despacho + custom. El planColors local se mantiene
|
// los planes — legacy + despacho + custom. El planColors local se mantiene
|
||||||
// chico con un fallback genérico para planes nuevos.
|
// chico con un fallback genérico para planes nuevos.
|
||||||
const planColors: Record<string, string> = {
|
const planColors: Record<string, string> = {
|
||||||
starter: 'bg-muted text-muted-foreground',
|
trial: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100',
|
||||||
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',
|
|
||||||
mi_empresa: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-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',
|
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_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',
|
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',
|
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 (
|
return (
|
||||||
@@ -393,12 +410,12 @@ export default function ClientesPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
{editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'}
|
{editingTenant ? 'Editar Despacho' : 'Nuevo Despacho'}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{editingTenant
|
{editingTenant
|
||||||
? 'Modifica los datos del cliente'
|
? 'Modifica los datos del despacho'
|
||||||
: 'Registra un nuevo cliente para gestionar su facturación'}
|
: 'Registra un nuevo despacho en Horux'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
|
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
|
||||||
@@ -410,54 +427,112 @@ export default function ClientesPage() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="nombre">Nombre de la Empresa</Label>
|
<Label htmlFor="nombre">Nombre del Despacho</Label>
|
||||||
<Input
|
<Input
|
||||||
id="nombre"
|
id="nombre"
|
||||||
value={formData.nombre}
|
value={formData.nombre}
|
||||||
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, nombre: e.target.value })}
|
||||||
placeholder="Empresa SA de CV"
|
placeholder="Despacho Pérez y Asociados"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="rfc">RFC</Label>
|
<Label htmlFor="rfc">RFC del Despacho</Label>
|
||||||
<Input
|
<Input
|
||||||
id="rfc"
|
id="rfc"
|
||||||
value={formData.rfc}
|
value={formData.rfc}
|
||||||
onChange={(e) => setFormData({ ...formData, rfc: e.target.value.toUpperCase() })}
|
onChange={(e) => setFormData({ ...formData, rfc: e.target.value.toUpperCase() })}
|
||||||
placeholder="XAXX010101000"
|
placeholder="ABC010101ABC"
|
||||||
maxLength={14}
|
maxLength={13}
|
||||||
required
|
required
|
||||||
disabled={!!editingTenant} // Can't change RFC after creation
|
disabled={!!editingTenant}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Label htmlFor="plan">Plan</Label>
|
<div className="space-y-2">
|
||||||
<Select
|
<Label htmlFor="verticalProfile">Perfil Profesional</Label>
|
||||||
value={formData.plan}
|
<Select
|
||||||
onValueChange={(value) =>
|
value={formData.verticalProfile}
|
||||||
setFormData({ ...formData, plan: value as PlanType })
|
onValueChange={(value) =>
|
||||||
}
|
setFormData({ ...formData, verticalProfile: value as 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA' })
|
||||||
>
|
}
|
||||||
<SelectTrigger>
|
>
|
||||||
<SelectValue />
|
<SelectTrigger>
|
||||||
</SelectTrigger>
|
<SelectValue />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value="starter">Starter (legacy) — Sin CFDIs, 1 usuario</SelectItem>
|
<SelectContent>
|
||||||
<SelectItem value="business">Business (legacy) — 50 CFDIs, 3 usuarios</SelectItem>
|
<SelectItem value="CONTABLE">📊 Contable — Fiscal, CFDI, IVA/ISR</SelectItem>
|
||||||
<SelectItem value="business_ia">Business + IA (legacy)</SelectItem>
|
<SelectItem value="JURIDICO">⚖️ Jurídico — Próximamente</SelectItem>
|
||||||
<SelectItem value="enterprise">Enterprise (legacy) — 100 CFDIs, ilimitados</SelectItem>
|
<SelectItem value="ARQUITECTURA">🏗️ Arquitectura — Próximamente</SelectItem>
|
||||||
<SelectItem value="custom">Custom — Sin cobro, sin fecha fin (despacho)</SelectItem>
|
</SelectContent>
|
||||||
</SelectContent>
|
</Select>
|
||||||
</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>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</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 */}
|
{/* Campos de admin y suscripción — solo al crear */}
|
||||||
{!editingTenant && (
|
{!editingTenant && (
|
||||||
<>
|
<>
|
||||||
<div className="border-t pt-4">
|
<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="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="adminNombre">Nombre del Dueño</Label>
|
<Label htmlFor="adminNombre">Nombre del Dueño</Label>
|
||||||
@@ -476,16 +551,16 @@ export default function ClientesPage() {
|
|||||||
type="email"
|
type="email"
|
||||||
value={formData.adminEmail}
|
value={formData.adminEmail}
|
||||||
onChange={(e) => setFormData({ ...formData, adminEmail: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, adminEmail: e.target.value })}
|
||||||
placeholder="admin@empresa.com"
|
placeholder="admin@despacho.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{formData.plan !== 'custom' && (
|
{formData.plan !== 'custom' && formData.plan !== 'trial' && (
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="amount">Monto Mensual (MXN)</Label>
|
<Label htmlFor="amount">Monto (MXN)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="amount"
|
id="amount"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -495,12 +570,17 @@ export default function ClientesPage() {
|
|||||||
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
|
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Precio sugerido según catálogo. Puedes ajustarlo para descuentos especiales.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{formData.plan === 'custom' && (
|
{(formData.plan === 'custom' || formData.plan === 'trial') && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { Header } from '@/components/layouts/header';
|
import { Header } from '@/components/layouts/header';
|
||||||
import { KpiCard } from '@horux/shared-ui';
|
import { KpiCard } from '@horux/shared-ui';
|
||||||
import { BarChart } from '@/components/charts/bar-chart';
|
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 { PeriodSelector, RegimenSelector } from '@horux/shared-ui';
|
||||||
import { useKpis, useIngresosEgresos, useAlertas, useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
|
import { useKpis, useIngresosEgresos, useAlertas, useRegimenesDelPeriodo } from '@/lib/hooks/use-dashboard';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { isGlobalAdminRfc } from '@horux/shared';
|
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
@@ -44,14 +43,7 @@ function shiftDatesOneYear(fechaInicio: string, fechaFin: string, delta: number)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
|
||||||
const { user } = useAuthStore();
|
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 now = new Date();
|
||||||
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
|
const defaultRange = getMonthRange(now.getFullYear(), now.getMonth() + 1);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { searchClaveProdServ } from '@/lib/api/catalogos';
|
|||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import type { InvoiceData, InvoiceLineItem, RfcSearchResult, CfdiPpdPendiente, ConceptoPrevio } from '@/lib/api/facturacion';
|
import type { InvoiceData, InvoiceLineItem, RfcSearchResult, CfdiPpdPendiente, ConceptoPrevio } from '@/lib/api/facturacion';
|
||||||
import { searchRfcs, getCfdisPpd, searchConceptos } 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';
|
import { cn } from '@horux/shared-ui';
|
||||||
|
|
||||||
interface TaxLine {
|
interface TaxLine {
|
||||||
@@ -25,7 +25,31 @@ interface TaxLine {
|
|||||||
factor: string; // Tasa, Cuota, Exento
|
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 {
|
interface ConceptoForm {
|
||||||
|
id: string;
|
||||||
description: string;
|
description: string;
|
||||||
productKey: string;
|
productKey: string;
|
||||||
productKeyLabel: string;
|
productKeyLabel: string;
|
||||||
@@ -42,7 +66,7 @@ const defaultTaxes: TaxLine[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const emptyConcepto: ConceptoForm = {
|
const emptyConcepto: ConceptoForm = {
|
||||||
description: '', productKey: '', productKeyLabel: '',
|
id: '', description: '', productKey: '', productKeyLabel: '',
|
||||||
unitKey: 'E48', quantity: 1, price: 0, discount: 0, objetoImp: '02',
|
unitKey: 'E48', quantity: 1, price: 0, discount: 0, objetoImp: '02',
|
||||||
taxes: [...defaultTaxes],
|
taxes: [...defaultTaxes],
|
||||||
};
|
};
|
||||||
@@ -188,7 +212,7 @@ const TIPO_CONFIG: Record<string, {
|
|||||||
defaultFormaPago: string;
|
defaultFormaPago: string;
|
||||||
defaultMetodoPago: 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' },
|
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' },
|
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' },
|
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('');
|
const [condiciones, setCondiciones] = useState('');
|
||||||
|
|
||||||
// Conceptos
|
// Conceptos
|
||||||
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto }]);
|
const [conceptos, setConceptos] = useState<ConceptoForm[]>([{ ...emptyConcepto, id: crypto.randomUUID() }]);
|
||||||
|
|
||||||
// Documento relacionado (Egreso)
|
// CFDIs relacionados (Ingreso / Egreso)
|
||||||
const [relatedUuid, setRelatedUuid] = useState('');
|
const [relatedDocs, setRelatedDocs] = useState<RelatedDocForm[]>([]);
|
||||||
const [relatedRelationship, setRelatedRelationship] = useState('01');
|
|
||||||
|
|
||||||
// Complemento de pago
|
// Complemento de pago
|
||||||
const [pagoFecha, setPagoFecha] = useState(new Date().toISOString().slice(0, 10));
|
const [pagoFecha, setPagoFecha] = useState(new Date().toISOString().slice(0, 10));
|
||||||
@@ -343,9 +366,8 @@ export default function FacturacionPage() {
|
|||||||
setSerie('');
|
setSerie('');
|
||||||
setFolio('');
|
setFolio('');
|
||||||
setCondiciones('');
|
setCondiciones('');
|
||||||
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit }]);
|
setConceptos([{ ...emptyConcepto, unitKey: defaultUnit, id: crypto.randomUUID() }]);
|
||||||
setRelatedUuid('');
|
setRelatedDocs([]);
|
||||||
setRelatedRelationship('01');
|
|
||||||
setPagoUuid('');
|
setPagoUuid('');
|
||||||
setPagoMonto(0);
|
setPagoMonto(0);
|
||||||
setPagoParcialidad(1);
|
setPagoParcialidad(1);
|
||||||
@@ -493,7 +515,7 @@ export default function FacturacionPage() {
|
|||||||
setMetodoPago(c.defaultMetodoPago);
|
setMetodoPago(c.defaultMetodoPago);
|
||||||
// Resetear conceptos con unidad default según tipo
|
// Resetear conceptos con unidad default según tipo
|
||||||
const defaultUnit = tipo === 'T' ? 'H87' : 'E48';
|
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
|
// 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 selectProduct = (idx: number, clave: string, descripcion: string) => {
|
||||||
const updated = [...conceptos];
|
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, productKey: clave, productKeyLabel: `${clave} - ${descripcion}` } : c));
|
||||||
updated[idx].productKey = clave;
|
|
||||||
updated[idx].productKeyLabel = `${clave} - ${descripcion}`;
|
|
||||||
setConceptos(updated);
|
|
||||||
setProdResults([]);
|
setProdResults([]);
|
||||||
setSearchingIdx(null);
|
setSearchingIdx(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateConcepto = (idx: number, field: keyof ConceptoForm, value: any) => {
|
const updateConcepto = (idx: number, field: keyof ConceptoForm, value: any) => {
|
||||||
const updated = [...conceptos];
|
setConceptos(prev => prev.map((c, i) => {
|
||||||
(updated[idx] as any)[field] = value;
|
if (i !== idx) return c;
|
||||||
// Si cambió la unidad, re-evaluar recomendación de impuestos
|
const updated = { ...c, [field]: value } as ConceptoForm;
|
||||||
if (field === 'unitKey' && tipoComprobante === 'I') {
|
// Si cambió la unidad, re-evaluar recomendación de impuestos
|
||||||
const recommended = getRecommendedTaxes(
|
if (field === 'unitKey' && tipoComprobante === 'I') {
|
||||||
emisorRfc, emisorRegimen, receptor.taxId, receptor.taxSystem, value
|
const recommended = getRecommendedTaxes(
|
||||||
);
|
emisorRfc, emisorRegimen, receptor.taxId, receptor.taxSystem, value
|
||||||
if (recommended) {
|
);
|
||||||
updated[idx].taxes = recommended;
|
if (recommended) {
|
||||||
} else {
|
updated.taxes = recommended;
|
||||||
// Si ya no aplica retención, dejar solo IVA 16%
|
} else {
|
||||||
updated[idx].taxes = [...defaultTaxes];
|
updated.taxes = [...defaultTaxes];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return updated;
|
||||||
setConceptos(updated);
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Aplica recomendación de impuestos a un concepto si corresponde (solo tipo I)
|
// Aplica recomendación de impuestos a un concepto si corresponde (solo tipo I)
|
||||||
@@ -550,12 +570,14 @@ export default function FacturacionPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addConcepto = () => {
|
const addConcepto = () => {
|
||||||
const newConcepto = applyTaxRecommendation({ ...emptyConcepto });
|
const newConcepto = applyTaxRecommendation({ ...emptyConcepto, id: crypto.randomUUID() });
|
||||||
setConceptos([...conceptos, newConcepto]);
|
setConceptos(prev => [...prev, newConcepto]);
|
||||||
};
|
};
|
||||||
const removeConcepto = (idx: number) => {
|
const removeConcepto = (idx: number) => {
|
||||||
if (conceptos.length === 1) return;
|
setConceptos(prev => {
|
||||||
setConceptos(conceptos.filter((_, i) => i !== idx));
|
if (prev.length === 1) return prev;
|
||||||
|
return prev.filter((_, i) => i !== idx);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cálculos
|
// Cálculos
|
||||||
@@ -635,8 +657,10 @@ export default function FacturacionPage() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.needsRelated && relatedUuid) {
|
if (config.needsRelated && relatedDocs.length > 0) {
|
||||||
data.relatedDocuments = [{ uuid: relatedUuid, relationship: relatedRelationship }];
|
data.relatedDocuments = relatedDocs
|
||||||
|
.filter(r => r.uuids.length > 0)
|
||||||
|
.map(r => ({ relationship: r.relationship, uuids: r.uuids.filter(u => u.trim() !== '') }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.needsPaymentComplement) {
|
if (config.needsPaymentComplement) {
|
||||||
@@ -685,7 +709,7 @@ export default function FacturacionPage() {
|
|||||||
<p className="text-muted-foreground mt-4">Total</p>
|
<p className="text-muted-foreground mt-4">Total</p>
|
||||||
<p className="text-2xl font-bold">${result.total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</p>
|
<p className="text-2xl font-bold">${result.total.toLocaleString('es-MX', { minimumFractionDigits: 2 })}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => { setResult(null); setConceptos([{ ...emptyConcepto }]); }}>
|
<Button onClick={() => { setResult(null); setConceptos([{ ...emptyConcepto, id: crypto.randomUUID() }]); }}>
|
||||||
Emitir otra factura
|
Emitir otra factura
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -1118,36 +1142,6 @@ export default function FacturacionPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Complemento de Pago */}
|
||||||
{config.needsPaymentComplement && (
|
{config.needsPaymentComplement && (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -1265,7 +1259,7 @@ export default function FacturacionPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{conceptos.map((c, idx) => (
|
{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">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-muted-foreground">Concepto {idx + 1}</span>
|
<span className="text-sm font-medium text-muted-foreground">Concepto {idx + 1}</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
@@ -1357,9 +1351,7 @@ export default function FacturacionPage() {
|
|||||||
<Select onValueChange={v => {
|
<Select onValueChange={v => {
|
||||||
const opt = TAX_OPTIONS.traslado.find(o => `${o.type}-${o.rate}-${o.factor}` === v);
|
const opt = TAX_OPTIONS.traslado.find(o => `${o.type}-${o.rate}-${o.factor}` === v);
|
||||||
if (!opt) return;
|
if (!opt) return;
|
||||||
const updated = [...conceptos];
|
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: [...c.taxes, { category: 'traslado', type: opt.type, rate: opt.rate, factor: opt.factor }] } : c));
|
||||||
updated[idx].taxes = [...updated[idx].taxes, { category: 'traslado', type: opt.type, rate: opt.rate, factor: opt.factor }];
|
|
||||||
setConceptos(updated);
|
|
||||||
}}>
|
}}>
|
||||||
<SelectTrigger className="h-7 text-xs w-auto gap-1">
|
<SelectTrigger className="h-7 text-xs w-auto gap-1">
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
@@ -1374,9 +1366,7 @@ export default function FacturacionPage() {
|
|||||||
<Select onValueChange={v => {
|
<Select onValueChange={v => {
|
||||||
const opt = TAX_OPTIONS.retencion.find(o => `${o.type}-${o.rate}` === v);
|
const opt = TAX_OPTIONS.retencion.find(o => `${o.type}-${o.rate}` === v);
|
||||||
if (!opt) return;
|
if (!opt) return;
|
||||||
const updated = [...conceptos];
|
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: [...c.taxes, { category: 'retencion', type: opt.type, rate: opt.rate, factor: opt.factor }] } : c));
|
||||||
updated[idx].taxes = [...updated[idx].taxes, { category: 'retencion', type: opt.type, rate: opt.rate, factor: opt.factor }];
|
|
||||||
setConceptos(updated);
|
|
||||||
}}>
|
}}>
|
||||||
<SelectTrigger className="h-7 text-xs w-auto gap-1">
|
<SelectTrigger className="h-7 text-xs w-auto gap-1">
|
||||||
<Plus className="h-3 w-3" />
|
<Plus className="h-3 w-3" />
|
||||||
@@ -1413,9 +1403,7 @@ export default function FacturacionPage() {
|
|||||||
{tax.category === 'retencion' ? '-' : ''}${amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
{tax.category === 'retencion' ? '-' : ''}${amount.toLocaleString('es-MX', { minimumFractionDigits: 2 })}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" onClick={() => {
|
<button type="button" onClick={() => {
|
||||||
const updated = [...conceptos];
|
setConceptos(prev => prev.map((c, i) => i === idx ? { ...c, taxes: c.taxes.filter((_, ti) => ti !== tIdx) } : c));
|
||||||
updated[idx].taxes = updated[idx].taxes.filter((_, i) => i !== tIdx);
|
|
||||||
setConceptos(updated);
|
|
||||||
}} className="text-muted-foreground hover:text-destructive">
|
}} className="text-muted-foreground hover:text-destructive">
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1438,6 +1426,116 @@ export default function FacturacionPage() {
|
|||||||
</Card>
|
</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 */}
|
{/* Resumen y Emitir */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiClient } from './client';
|
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> {
|
export async function getCfdis(filters: CfdiFilters): Promise<CfdiListResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -102,6 +102,28 @@ export async function getCfdiConceptos(id: number | string): Promise<any[]> {
|
|||||||
return response.data;
|
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> {
|
export async function deleteCfdi(id: string): Promise<void> {
|
||||||
await apiClient.delete(`/cfdi/${id}`);
|
await apiClient.delete(`/cfdi/${id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export interface InvoiceData {
|
|||||||
series?: string;
|
series?: string;
|
||||||
folioNumber?: number;
|
folioNumber?: number;
|
||||||
conditions?: string;
|
conditions?: string;
|
||||||
|
relatedDocuments?: Array<{ relationship: string; uuids: string[] }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvoiceResult {
|
export interface InvoiceResult {
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ export interface Tenant {
|
|||||||
export interface CreateTenantData {
|
export interface CreateTenantData {
|
||||||
nombre: string;
|
nombre: string;
|
||||||
rfc: string;
|
rfc: string;
|
||||||
plan?: 'starter' | 'business' | 'business_ia' | 'enterprise' | 'custom';
|
plan?: string;
|
||||||
cfdiLimit?: number;
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
usersLimit?: number;
|
frequency?: 'monthly' | 'annual';
|
||||||
adminEmail: string;
|
adminEmail: string;
|
||||||
adminNombre: string;
|
adminNombre: string;
|
||||||
amount?: number;
|
amount?: number;
|
||||||
@@ -42,9 +42,8 @@ export async function createTenant(data: CreateTenantData): Promise<Tenant> {
|
|||||||
export interface UpdateTenantData {
|
export interface UpdateTenantData {
|
||||||
nombre?: string;
|
nombre?: string;
|
||||||
rfc?: string;
|
rfc?: string;
|
||||||
plan?: 'starter' | 'business' | 'business_ia' | 'enterprise' | 'custom';
|
plan?: string;
|
||||||
cfdiLimit?: number;
|
verticalProfile?: 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA';
|
||||||
usersLimit?: number;
|
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import * as cfdiApi from '@/lib/api/cfdi';
|
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 type { CreateCfdiData } from '@/lib/api/cfdi';
|
||||||
import { useContribuyenteStore } from '@/stores/contribuyente-store';
|
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() {
|
export function useDeleteCfdi() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,44 @@ export interface CfdiConcepto {
|
|||||||
creadoEn: string;
|
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 {
|
export interface CfdiListResponse {
|
||||||
data: Cfdi[];
|
data: Cfdi[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface DespachoInfo {
|
|||||||
plan: string;
|
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 {
|
export interface DespachoSignupRequest {
|
||||||
despacho: {
|
despacho: {
|
||||||
@@ -23,6 +23,7 @@ export interface DespachoSignupRequest {
|
|||||||
codigoPostal?: string;
|
codigoPostal?: string;
|
||||||
verticalProfile: VerticalProfile;
|
verticalProfile: VerticalProfile;
|
||||||
plan?: DespachoSignupPlan;
|
plan?: DespachoSignupPlan;
|
||||||
|
frequency?: 'monthly' | 'annual';
|
||||||
};
|
};
|
||||||
owner: {
|
owner: {
|
||||||
nombre: string;
|
nombre: string;
|
||||||
|
|||||||
143
scripts/backfill-conceptos.js
Normal file
143
scripts/backfill-conceptos.js
Normal 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); });
|
||||||
Reference in New Issue
Block a user