Compare commits

..

28 Commits

Author SHA1 Message Date
Horux Dev
7df27ce66d chore: catálogo obligaciones, cierre automático, fixes SAT y facturación
- Catálogo de obligaciones fiscales expandido a 30 entradas con campo requierePago.
- Soporte de frecuencia cuatrimestral en obligaciones y declaraciones.
- Automatización de cierre de obligaciones fiscales desde Documentos › Declaraciones.
- Nuevas tablas obligacion_evidencias, obligacion_periodos estados y declaracion_obligaciones.
- Nuevo servicio obligacion-evidencias.service.ts y endpoints REST.
- Refactor de declaraciones.service.ts para vincular obligaciones y crear evidencias.
- Notificaciones por email para evidencias de obligaciones.
- Adjuntar PDFs en correo de declaración subida.
- Fix drill-down de CFDIs: carga completa al visualizar.
- Fix sincronización SAT: tipos P/N, UUID case-insensitive, no reutilizar requestId.
- Fix suscripciones pending en /configuracion/planes-despacho.
- Fix sugerencias de Clave Producto SAT: importar catálogo y robustecer autocomplete.
- Quitar toggle manual de completado en Configuración › Obligaciones fiscales › Tareas.
- Scripts de soporte para Demo Ventas y utilerías (change-user-email, resend-welcome, import-clave-prod-serv).
- Documentación de cambios en docs/CAMBIOS-2026-05-04.md.
2026-06-22 04:53:59 +00:00
Horux Dev
b217342a96 feat(notificaciones): configuración de notificaciones por rol
- Nueva tabla tenant notification_role_preferences para guardar (email_type, role, enabled).
- Migración 051 aplicada a todos los tenants.
- Backend expone endpoint /notificaciones con matriz de preferencias por rol.
- Filtrado por rol en documento_subido, weekly_update, subscription_expiring,
  alertas_nuevas y recordatorio_proximo.
- Frontend rediseñado como tabla notificación × rol con toggles inmediatos.
2026-06-17 00:04:37 +00:00
Horux Dev
8a1fbceb38 fix(notificaciones): quitar badge Próximamente de notificaciones ya existentes
- weekly_update y subscription_expiring están implementadas; se marcan como active.
- Se indica con badge 'A nivel despacho' cuando una notificación no se puede
  desactivar por contribuyente y se deshabilita el toggle.
2026-06-16 22:55:53 +00:00
Horux Dev
3f3253d41b fix(pagos): permitir pagar plan actual trial_expired y soportar planes >$10k via Preference
- Expone subscription trial_expired en /despachos/me/plan e incluye planPrice.
- Para Business Control/Enterprise (>$10k) genera pago anual único con MP Preference
  en lugar de preapproval recurrente; el webhook activa 1 año de suscripción.
- Muestra CTA de pago en UI cuando la suscripción está trial/trial_expired.
- Agrega campo mp_preference_id a subscriptions y mejora mensajes de error MP.
2026-06-16 22:37:11 +00:00
Horux Dev
63908f9e9d feat(sat): agregar cron de recuperación diaria a las 10:00 AM
- Revisa si el sync diario falló o si hay CFDIs vigentes sin xml_original.
- Si detecta facturas incompletas, lanza un sync initial con rango extendido
  (desde un mes antes de la factura incompleta más antigua hasta ayer).
- Corre secuencialmente por contribuyente para no saturar al SAT.
- Incluye soporte para tenants legacy sin contribuyentes.
2026-06-14 04:07:11 +00:00
Horux Dev
ed6cfed312 feat(dashboard): utilidad neta ajustada por notas de crédito
- La utilidad del dashboard ahora descuenta NCs emitidas de ingresos y NCs recibidas de gastos.
- El margen se calcula sobre ingresos netos.
- Solo afecta la UI del dashboard; no modifica el backend ni otros reportes.
2026-06-13 21:04:25 +00:00
Horux Dev
ab6b76fcb8 ui(dashboard): reordenar scorecards de notas de crédito
- NCs Emitidas ahora aparece después de Ingresos del Mes.
- NCs Recibidas ahora aparece después de Gastos del Mes.
2026-06-13 20:54:40 +00:00
Horux Dev
b52ff875be feat(dashboard): agregar scorecards de notas de crédito emitidas y recibidas
- Extiende KpiData con ncsEmitidas, ncsEmitidasPorRegimen, ncsRecibidas y ncsRecibidasPorRegimen.
- En getKpis se reutilizan calcularNcsEmitidasPorRegimen y calcularNcsRecibidasPorRegimen en paralelo.
- En el dashboard se agregan dos KpiCard y su desglose por régimen.
2026-06-13 20:46:57 +00:00
Horux Dev
66d68c652c Revert "feat(ui): make dashboard responsive for iPhone and mobile devices"
This reverts commit d3b326e.

The deployment caused reports of blank screens and 400 errors. Reverting to restore stable state while investigating root cause.
2026-06-13 20:16:04 +00:00
Horux Dev
d3b326e78c feat(ui): make dashboard responsive for iPhone and mobile devices
- Add Sheet primitive component for mobile drawers
- Add MobileNav with hamburger menu for dashboard layout
- Hide desktop sidebars on mobile; show mobile header
- Make dashboard header responsive with stacked layout on small screens
- Hide selector text on mobile, show icons only
- Convert fixed-width filters to responsive widths (CFDI, Clientes, Admin, Documentos, Alertas)
- Cap dialog widths to 95vw on mobile (CFDI viewer, Documentos, Reportes, Contribuyentes, Facturación)
- Make calendar grid smaller and use single-letter weekdays on mobile
- Update viewport to include viewport-fit=cover for Samsung safe areas
2026-06-13 19:55:06 +00:00
Horux Dev
b1eaf41681 fix(sat, payments, admin): multiple production fixes
- sat sweep-stale-jobs: increase initial/custom sync threshold 8h→24h to prevent watchdog killing long historical syncs
- sat-client: fix formatDateForSat same-day rejection by auto-adjusting fechaFin
- sat-sync job: check fiel_contribuyente in addition to fiel_credentials for cron eligibility
- database: extend pool idle cleanup from 5min to 12h to prevent pool closure during long syncs
- webhook controller: auto-extend currentPeriodEnd on recurring MercadoPago payments
- invoicing service: auto-send FacturAPI invoice by email after creation
- admin-clientes: fix no-renovaciones detection to include expired trials and deleted subscriptions
2026-06-10 18:11:47 +00:00
Horux Dev
bd7e499ab7 fix(csf): retry con backoff, delays entre tenants, timeouts aumentados 2026-06-01 23:43:43 +00:00
Horux Dev
44144ebf9d fix(contribuyente-selector): limpiar selección inválida de localStorage 2026-06-01 20:13:36 +00:00
Horux Dev
314a74982c fix(regimen): fallback a tenant/contribuyentes cuando un contribuyente no tiene regimen_fiscal 2026-06-01 20:07:59 +00:00
Horux Dev
76d3f00f29 debug(alertas): logging en generador y endpoint /automaticas; wrap cada alerta en try/catch 2026-06-01 19:59:57 +00:00
Horux Dev
214410d2fb fix(alertas): combinar regímenes de contribuyentes cuando no hay config a nivel tenant 2026-06-01 17:55:01 +00:00
Horux Dev
199922272f fix(sidebar): mostrar Usuarios para supervisor y auxiliar 2026-05-29 22:06:01 +00:00
Horux Dev
6e54efe5e4 feat(usuarios): supervisor puede invitar usuarios cliente
- Backend inviteUsuario: permite owner, cfo y supervisor
- Backend valida que supervisor solo pueda invitar rol cliente
- Backend addClienteAcceso: supervisor solo puede asignar contribuyentes
  que tenga visibles (getEntidadesVisibles)
- Frontend: supervisor ve botón Invitar Usuario y solo puede seleccionar
  rol Cliente en el dropdown
2026-05-29 21:32:12 +00:00
Horux Dev
5dd53cebac chore(usuarios): limpiar debug hardcodeado de supervisorNombre 2026-05-29 19:27:59 +00:00
Horux Dev
0de0df9357 fix(usuarios): mostrar nombre del supervisor en dropdown de forma robusta
- Backend: getSupervisor devuelve supervisorNombre desde Prisma
- Frontend: usa SelectTrigger con renderizado manual del label seleccionado
  en lugar de depender de SelectValue, que no siempre encontraba el texto
  del SelectItem cuando el supervisor no estaba en la lista de carteras
2026-05-29 19:03:36 +00:00
Horux Dev
20fb8ea2db debug(usuarios): agregar console.log para diagnosticar supervisorNombre 2026-05-29 18:10:33 +00:00
Horux Dev
8c9a7b73dc fix(usuarios): agregar import faltante de prisma en getSupervisor 2026-05-29 17:43:13 +00:00
Horux Dev
910c50d870 fix(usuarios): mostrar nombre del supervisor al editar auxiliar
- Backend getSupervisor ahora devuelve supervisorNombre buscando en Prisma
- Frontend usa supervisorNombre para mostrar en Select cuando el supervisor
  no está en la lista de carteras/supervisores
2026-05-29 17:24:18 +00:00
Horux Dev
2f49fdc9b7 fix(contribuyentes): agregar supervisorNombre al tipo Contribuyente 2026-05-29 17:12:58 +00:00
Horux Dev
0439a84e6d feat(contribuyentes): mostrar nombre del supervisor en card 2026-05-29 16:57:04 +00:00
Horux Dev
0815269f1b fix(papeleria): mover useMemo antes del return condicional para evitar error #310 2026-05-29 16:42:37 +00:00
Horux Dev
9b535354fb feat(papeleria): aprobación independiente por cliente
- Agrega migración 050 con columnas de aprobación de cliente
  (requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, etc.)
- Backend: endpoints /aprobar-cliente y /rechazar-cliente con validación de permisos
- Backend: list/download permiten acceso a clientes filtrando por entidades visibles
- Backend: notificación por email a clientes cuando se les solicita aprobación
- Frontend: checkbox independiente para solicitar aprobación del cliente
- Frontend: badge de estado combinado (owner + cliente)
- Frontend: botones de aprobar/rechazar para clientes en su propio flujo
2026-05-29 00:36:33 +00:00
Horux Dev
e01422e443 fix(facturacion): filtrar searchConceptos y searchRfcs por contribuyenteId
- searchConceptos: agrega AND c.contribuyente_id =  cuando se recibe contribuyenteId
- searchRfcs: restringe el catálogo global de rfcs a aquellos que aparecen en CFDIs del contribuyente (como emisor o receptor)
- Usa parametrización dinámica (3800099{params.length}) para evitar errores de índice
2026-05-28 21:44:32 +00:00
79 changed files with 4651 additions and 598 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "subscriptions" ADD COLUMN "mp_preference_id" TEXT;

View File

@@ -358,6 +358,7 @@ model Subscription {
tenantId String @map("tenant_id") tenantId String @map("tenant_id")
plan Plan plan Plan
mpPreapprovalId String? @map("mp_preapproval_id") mpPreapprovalId String? @map("mp_preapproval_id")
mpPreferenceId String? @map("mp_preference_id")
status String @default("pending") status String @default("pending")
amount Decimal @db.Decimal(10, 2) amount Decimal @db.Decimal(10, 2)
frequency String @default("monthly") frequency String @default("monthly")

View File

@@ -0,0 +1,75 @@
/**
* Script: change-user-email
*
* Cambia el correo de un usuario, resetea su contraseña a una temporal
* y reenvía el correo de bienvenida con las nuevas credenciales.
*
* Ejecución:
* cd apps/api && npx tsx scripts/change-user-email.ts
*/
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import { emailService } from '../src/services/email/email.service.js';
const prisma = new PrismaClient();
const OLD_EMAIL = 'eduardo.corona@corpcyl.com';
const NEW_EMAIL = 'miguel.corona@corpcyl.com';
function generateTempPassword(length = 12): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
let result = '';
const bytes = randomBytes(length);
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % chars.length];
}
return result + '!';
}
async function main() {
const user = await prisma.user.findUnique({ where: { email: OLD_EMAIL } });
if (!user) {
console.error(`❌ No existe un usuario con el correo ${OLD_EMAIL}`);
process.exit(1);
}
const existing = await prisma.user.findUnique({ where: { email: NEW_EMAIL } });
if (existing) {
console.error(`❌ Ya existe un usuario con el correo ${NEW_EMAIL}`);
process.exit(1);
}
const tempPassword = generateTempPassword();
const passwordHash = await bcrypt.hash(tempPassword, 12);
await prisma.user.update({
where: { id: user.id },
data: {
email: NEW_EMAIL,
passwordHash,
tokenVersion: { increment: 1 },
},
});
await emailService.sendWelcome(NEW_EMAIL, {
nombre: user.nombre,
email: NEW_EMAIL,
tempPassword,
});
console.log('✅ Correo actualizado:', OLD_EMAIL, '→', NEW_EMAIL);
console.log('✅ Contraseña temporal generada y enviada por correo');
console.log(' Nombre:', user.nombre);
console.log(' Email:', NEW_EMAIL);
console.log(' Contraseña temporal:', tempPassword);
}
main()
.catch((e) => {
console.error('\n❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,457 @@
/**
* Script: create-demo-ventas
*
* Crea una cuenta demo completa para ventas:
* - Tenant "Demo Ventas SA de CV" (plan custom, sin cobro)
* - Usuario owner: demo@horuxfin.com / Demo12345!
* - Base de datos propia con datos ficticios de contabilidad
* - Contribuyente, clientes/proveedores, CFDIs, bancos, conciliaciones,
* obligaciones fiscales y cartera.
*
* Ejecución:
* cd apps/api && npx tsx scripts/create-demo-ventas.ts
*/
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
import { tenantDb } from '../src/config/database.ts';
const prisma = new PrismaClient();
const DEMO = {
rfc: 'DEMO2501019X2',
nombre: 'Demo Ventas SA de CV',
email: 'demo@horuxfin.com',
password: 'Demo12345!',
databaseName: 'horux_demoventas',
codigoPostal: '01000',
};
const CLIENTES = [
{ rfc: 'CLI123456AB1', nombre: 'Cliente Alfa SA' },
{ rfc: 'CLI123456AB2', nombre: 'Cliente Beta SA' },
{ rfc: 'CLI123456AB3', nombre: 'Cliente Gamma SA' },
{ rfc: 'CLI123456AB4', nombre: 'Cliente Delta SA' },
{ rfc: 'CLI123456AB5', nombre: 'Cliente Epsilon SA' },
];
const PROVEEDORES = [
{ rfc: 'PRO123456AB1', nombre: 'Proveedor Materiales SA' },
{ rfc: 'PRO123456AB2', nombre: 'Proveedor Servicios SA' },
{ rfc: 'PRO123456AB3', nombre: 'Proveedor Logistica SA' },
{ rfc: 'PRO123456AB4', nombre: 'Proveedor Tecnologia SA' },
{ rfc: 'PRO123456AB5', nombre: 'Proveedor Papeleria SA' },
];
const PRODUCTOS = [
{ clave: '84111506', descripcion: 'Servicio de consultoria', unidad: 'Servicio' },
{ clave: '43232408', descripcion: 'Licencia de software', unidad: 'Licencia' },
{ clave: '81141500', descripcion: 'Soporte tecnico', unidad: 'Servicio' },
{ clave: '81121700', descripcion: 'Desarrollo web', unidad: 'Servicio' },
{ clave: '86101500', descripcion: 'Capacitacion', unidad: 'Servicio' },
{ clave: '50151500', descripcion: 'Materiales de oficina', unidad: 'Pieza' },
{ clave: '80181600', descripcion: 'Publicidad', unidad: 'Servicio' },
{ clave: '81112200', descripcion: 'Diseno grafico', unidad: 'Servicio' },
];
function parseDatabaseUrl(url: string) {
const parsed = new URL(url);
return {
host: parsed.hostname,
port: parseInt(parsed.port || '5432'),
user: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
};
}
async function main() {
console.log('🌱 Creando cuenta demo "Demo Ventas"...\n');
const ownerRole = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
if (!ownerRole) throw new Error('Rol owner no encontrado en BD central');
// ============================================================
// 1. Tenant
// ============================================================
let tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO.rfc } });
if (!tenant) {
tenant = await prisma.tenant.create({
data: {
nombre: DEMO.nombre,
rfc: DEMO.rfc,
plan: 'custom',
databaseName: DEMO.databaseName,
verticalProfile: 'CONTABLE',
dbMode: 'MANAGED',
dbSchemaVersion: 0,
codigoPostal: DEMO.codigoPostal,
active: true,
},
});
console.log('✅ Tenant creado:', tenant.nombre, `(${tenant.rfc})`);
} else {
await prisma.tenant.update({
where: { id: tenant.id },
data: { plan: 'custom', active: true, verticalProfile: 'CONTABLE' },
});
console.log('✅ Tenant actualizado:', tenant.nombre, `(${tenant.rfc})`);
}
// ============================================================
// 2. Usuario owner
// ============================================================
let user = await prisma.user.findUnique({ where: { email: DEMO.email } });
const passwordHash = await bcrypt.hash(DEMO.password, 12);
if (!user) {
user = await prisma.user.create({
data: {
email: DEMO.email,
passwordHash,
nombre: 'Usuario Demo',
lastTenantId: tenant.id,
},
});
console.log('✅ Usuario creado:', user.email);
} else {
user = await prisma.user.update({
where: { id: user.id },
data: { passwordHash, lastTenantId: tenant.id },
});
console.log('✅ Usuario actualizado:', user.email);
}
// ============================================================
// 3. Membership
// ============================================================
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
update: { rolId: ownerRole.id, isOwner: true, active: true },
create: {
userId: user.id,
tenantId: tenant.id,
rolId: ownerRole.id,
isOwner: true,
active: true,
},
});
console.log('✅ Membership owner asignada');
// ============================================================
// 4. Suscripción custom gratis/ilimitada (status authorized)
// ============================================================
const now = new Date();
const periodEnd = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
const existingSub = await prisma.subscription.findFirst({
where: { tenantId: tenant.id },
orderBy: { createdAt: 'desc' },
});
if (!existingSub) {
await prisma.subscription.create({
data: {
tenantId: tenant.id,
plan: 'custom',
status: 'authorized',
amount: 0,
frequency: 'monthly',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
},
});
} else {
await prisma.subscription.update({
where: { id: existingSub.id },
data: {
plan: 'custom',
status: 'authorized',
amount: 0,
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
pendingPlan: null,
pendingFrequency: null,
pendingEffectiveAt: null,
upgradePreferenceId: null,
upgradeTargetPlan: null,
upgradeTargetAmount: null,
},
});
}
console.log('✅ Suscripción custom activa (gratis)');
// ============================================================
// 5. Régimen fiscal activo del tenant
// ============================================================
const regimen = await prisma.regimen.findUnique({ where: { clave: '601' } });
if (regimen) {
await prisma.tenantRegimenActivo.upsert({
where: { tenantId_regimenId: { tenantId: tenant.id, regimenId: regimen.id } },
update: {},
create: { tenantId: tenant.id, regimenId: regimen.id },
});
console.log('✅ Régimen 601 activado para el tenant');
}
// ============================================================
// 6. Base de datos del tenant
// ============================================================
await tenantDb.provisionDatabase(DEMO.rfc, DEMO.databaseName);
const pool = await tenantDb.getPool(tenant.id, DEMO.databaseName);
console.log('✅ Base de datos del tenant provisionada:', DEMO.databaseName);
// ============================================================
// 7. Datos ficticios en BD del tenant
// ============================================================
await seedTenantData(pool, tenant.id, user.id);
console.log('\n🎉 Demo Ventas lista');
console.log(' Login:', DEMO.email, '/', DEMO.password);
console.log(' Tenant:', DEMO.nombre, `(${DEMO.rfc})`);
console.log(' BD:', DEMO.databaseName);
}
async function seedTenantData(pool: Pool, tenantId: string, ownerId: string) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Contribuyente principal
const { rows: [entidad] } = await client.query<{ id: string }>(`
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
VALUES ('CONTRIBUYENTE', $1, $2, $3)
ON CONFLICT DO NOTHING
RETURNING id
`, [DEMO.nombre, DEMO.rfc, ownerId]);
let contribuyenteId: string;
if (entidad) {
contribuyenteId = entidad.id;
await client.query(`
INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal)
VALUES ($1, $2, $3, $4)
ON CONFLICT (entidad_id) DO NOTHING
`, [contribuyenteId, DEMO.rfc, '601', DEMO.codigoPostal]);
} else {
const { rows: [existing] } = await client.query<{ id: string }>(`
SELECT e.id FROM entidades_gestionadas e
JOIN contribuyentes c ON c.entidad_id = e.id
WHERE e.identificador = $1
`, [DEMO.rfc]);
contribuyenteId = existing.id;
}
console.log('✅ Contribuyente principal creado:', DEMO.rfc);
// RFCs de clientes y proveedores
const rfcs = new Map<string, number>();
for (const c of [...CLIENTES, ...PROVEEDORES]) {
const { rows: [r] } = await client.query<{ id: number }>(`
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
RETURNING id
`, [c.rfc, c.nombre, c.rfc.startsWith('CLI') ? '601' : '601']);
rfcs.set(c.rfc, r.id);
}
// RFC del contribuyente principal
const { rows: [rfcPrincipal] } = await client.query<{ id: number }>(`
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
RETURNING id
`, [DEMO.rfc, DEMO.nombre, '601']);
rfcs.set(DEMO.rfc, rfcPrincipal.id);
// Bancos del contribuyente
const { rows: [banco1] } = await client.query<{ id: number }>(`
INSERT INTO bancos (banco, terminacion_cuenta, contribuyente_id)
VALUES ($1, $2, $3) RETURNING id
`, ['BBVA', '1234', contribuyenteId]);
const { rows: [banco2] } = await client.query<{ id: number }>(`
INSERT INTO bancos (banco, terminacion_cuenta, contribuyente_id)
VALUES ($1, $2, $3) RETURNING id
`, ['Santander', '5678', contribuyenteId]);
console.log('✅ Bancos creados');
// Generar CFDIs
const tipos: Array<'EMITIDO' | 'RECIBIDO'> = ['EMITIDO', 'RECIBIDO'];
const cfdiIds: number[] = [];
for (let i = 0; i < 60; i++) {
const tipo = tipos[i % 2];
const esEmitido = tipo === 'EMITIDO';
const contraparte = esEmitido
? CLIENTES[i % CLIENTES.length]
: PROVEEDORES[i % PROVEEDORES.length];
const subtotal = Math.floor(Math.random() * 40000) + 2000;
const iva = Math.round(subtotal * 0.16 * 100) / 100;
const total = Math.round((subtotal + iva) * 100) / 100;
const daysAgo = Math.floor(Math.random() * 540); // hasta ~18 meses atrás
const fecha = new Date();
fecha.setDate(fecha.getDate() - daysAgo);
fecha.setHours(10 + (i % 8), 0, 0, 0);
const year = String(fecha.getFullYear());
const month = String(fecha.getMonth() + 1).padStart(2, '0');
const fechaStr = fecha.toISOString();
const metodoPago = Math.random() > 0.3 ? 'PUE' : 'PPD';
const formasPago = ['01', '02', '03', '04'];
const formaPago = formasPago[i % formasPago.length];
const usoCfdi = esEmitido ? 'G03' : 'G01';
const rfcEmisor = esEmitido ? DEMO.rfc : contraparte.rfc;
const nombreEmisor = esEmitido ? DEMO.nombre : contraparte.nombre;
const rfcReceptor = esEmitido ? contraparte.rfc : DEMO.rfc;
const nombreReceptor = esEmitido ? contraparte.nombre : DEMO.nombre;
const { rows: [cfdi] } = await client.query<{ id: number }>(`
INSERT INTO cfdis (
year, month, type, uuid, serie, folio, status, fecha_emision,
rfc_emisor_id, rfc_emisor, nombre_emisor,
rfc_receptor_id, rfc_receptor, nombre_receptor,
subtotal, subtotal_mxn, descuento, descuento_mxn,
total, total_mxn, moneda, tipo_cambio, tipo_comprobante,
metodo_pago, forma_pago, uso_cfdi,
iva_traslado, iva_traslado_mxn,
regimen_fiscal_emisor, regimen_fiscal_receptor,
contribuyente_id, fecha_efectiva, meses_global, año_global
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10, $11,
$12, $13, $14,
$15, $16, $17, $18,
$19, $20, $21, $22, $23,
$24, $25, $26,
$27, $28,
$29, $30,
$31, $32, $33, $34
) RETURNING id
`, [
year, month, tipo, randomUUID(), 'DEMO', String(1000 + i),
'Vigente', fechaStr,
rfcs.get(rfcEmisor), rfcEmisor, nombreEmisor,
rfcs.get(rfcReceptor), rfcReceptor, nombreReceptor,
subtotal, subtotal, 0, 0,
total, total, 'MXN', 1, 'I',
metodoPago, formaPago, usoCfdi,
iva, iva,
'601', '601',
contribuyenteId, fechaStr, month, year,
]);
cfdiIds.push(cfdi.id);
// Conceptos
const numConceptos = Math.floor(Math.random() * 3) + 1;
for (let j = 0; j < numConceptos; j++) {
const prod = PRODUCTOS[(i + j) % PRODUCTOS.length];
const cantidad = Math.floor(Math.random() * 5) + 1;
const valorUnitario = Math.floor(Math.random() * 4000) + 500;
const importe = Math.round(cantidad * valorUnitario * 100) / 100;
const ivaConcepto = Math.round(importe * 0.16 * 100) / 100;
await client.query(`
INSERT INTO cfdi_conceptos (
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
iva_traslado, iva_traslado_mxn
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`, [
cfdi.id, prod.clave, prod.descripcion, cantidad, 'E48', prod.unidad,
valorUnitario, valorUnitario, importe, importe,
ivaConcepto, ivaConcepto,
]);
}
}
console.log('✅ 60 CFDIs y conceptos creados');
// Conciliaciones para algunos CFDIs PPD pagados con transferencia (forma 02/03)
const { rows: cfdisPpd } = await client.query<{ id: number; year: string; month: string }>(`
SELECT id, year, month FROM cfdis
WHERE metodo_pago = 'PPD' AND forma_pago IN ('02', '03')
ORDER BY id LIMIT 15
`);
for (const c of cfdisPpd) {
const bancoId = Math.random() > 0.5 ? banco1.id : banco2.id;
const fechaPago = new Date();
fechaPago.setDate(fechaPago.getDate() - Math.floor(Math.random() * 30));
await client.query(`
INSERT INTO conciliaciones (anio, mes, id_cfdi, fecha_de_pago, id_banco)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id_cfdi) DO NOTHING
`, [c.year, c.month, c.id, fechaPago.toISOString().split('T')[0], bancoId]);
}
console.log('✅ Conciliaciones creadas');
// Obligaciones fiscales asignadas al contribuyente
const obligaciones = [
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', categoria: 'Federal mensual' },
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', categoria: 'Federal mensual' },
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', categoria: 'Federal mensual' },
{ id: 'diot', nombre: 'DIOT', categoria: 'Informativa mensual' },
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', categoria: 'Seguridad social' },
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', categoria: 'Anual' },
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', categoria: 'Estatal' },
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', categoria: 'Estatal' },
];
for (const o of obligaciones) {
await client.query(`
INSERT INTO obligaciones_contribuyente (
contribuyente_id, catalogo_id, nombre, frecuencia, fecha_limite, categoria, activa, es_recomendada
) VALUES ($1, $2, $3, $4, $5, $6, true, true)
ON CONFLICT DO NOTHING
`, [contribuyenteId, o.id, o.nombre, 'mensual', 'Día 17 del mes siguiente', o.categoria]);
}
console.log('✅ Obligaciones fiscales asignadas');
// Cartera principal con el contribuyente
const { rows: [cartera] } = await client.query<{ id: string }>(`
INSERT INTO carteras (supervisor_user_id, nombre, descripcion)
VALUES ($1, $2, $3) RETURNING id
`, [ownerId, 'Cartera Principal', 'Clientes y prospectos de Demo Ventas']);
await client.query(`
INSERT INTO cartera_entidades (cartera_id, entidad_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, [cartera.id, contribuyenteId]);
console.log('✅ Cartera principal creada');
// Alertas y recordatorios de ejemplo
await client.query(`
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
VALUES
('obligacion', 'Declaración mensual de IVA', 'Pago de IVA correspondiente a mayo 2026', 'alta', NOW() + INTERVAL '10 days'),
('obligacion', 'Pago provisional ISR', 'Pago provisional de ISR de mayo 2026', 'alta', NOW() + INTERVAL '10 days'),
('sat', 'Sincronización SAT pendiente', 'Última sincronización hace más de 7 días', 'media', NOW() + INTERVAL '3 days')
`);
await client.query(`
INSERT INTO recordatorios (titulo, descripcion, fecha_limite, notas, completado, privado, creado_por)
VALUES
('Revisar estados de cuenta', 'Conciliar pagos de clientes', NOW() + INTERVAL '5 days', 'Prioridad alta', false, false, $1),
('Enviar facturas del mes', 'Facturación recurrente a clientes', NOW() + INTERVAL '7 days', 'Clientes Alfa y Beta', false, false, $1)
`, [ownerId]);
console.log('✅ Alertas y recordatorios de ejemplo creados');
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
main()
.catch((e) => {
console.error('\n❌ Error creando demo:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await tenantDb.shutdown();
});

View File

@@ -0,0 +1,126 @@
/**
* Script: fix-demo-carteras-asignaciones
*
* Corrige la estructura de carteras de Demo Ventas para que las asignaciones
* de obligaciones/tareas al auxiliar sean válidas:
* - La cartera principal queda solo para el supervisor.
* - Se crea una subcartera asignada al auxiliar.
* - Los contribuyentes se mueven a la subcartera del auxiliar.
* - Se mantiene la relación auxiliar → supervisor.
*
* Ejecución:
* cd apps/api && npx tsx scripts/fix-demo-carteras-asignaciones.ts
*/
import { PrismaClient } from '@prisma/client';
import { tenantDb } from '../src/config/database.ts';
const prisma = new PrismaClient();
const DEMO_RFC = 'DEMO2501019X2';
async function main() {
console.log('🔧 Corrigiendo carteras y asignaciones de Demo Ventas...\n');
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
const [supervisor, auxiliar] = await Promise.all([
prisma.user.findUnique({ where: { email: 'supervisor@horuxfin.com' } }),
prisma.user.findUnique({ where: { email: 'auxiliar@horuxfin.com' } }),
]);
if (!supervisor) throw new Error('Usuario supervisor no encontrado');
if (!auxiliar) throw new Error('Usuario auxiliar no encontrado');
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const client = await pool.connect();
try {
await client.query('BEGIN');
// Obtener cartera principal
const { rows: [carteraPrincipal] } = await client.query<{ id: string }>(`
SELECT id FROM carteras WHERE parent_id IS NULL ORDER BY created_at LIMIT 1
`);
if (!carteraPrincipal) throw new Error('No existe cartera principal');
// Crear subcartera para el auxiliar
const { rows: [subcartera] } = await client.query<{ id: string }>(`
INSERT INTO carteras (supervisor_user_id, auxiliar_user_id, nombre, descripcion, parent_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT DO NOTHING
RETURNING id
`, [supervisor.id, auxiliar.id, 'Cartera Auxiliar Demo', 'RFCs asignados al auxiliar de demo', carteraPrincipal.id]);
const subcarteraId = subcartera?.id;
if (!subcarteraId) {
// Si ya existía, recuperarla
const { rows: [existing] } = await client.query<{ id: string }>(`
SELECT id FROM carteras WHERE parent_id = $1 AND auxiliar_user_id = $2 LIMIT 1
`, [carteraPrincipal.id, auxiliar.id]);
if (!existing) throw new Error('No se pudo crear ni recuperar la subcartera del auxiliar');
// Asegurar que tenga supervisor
await client.query(`UPDATE carteras SET supervisor_user_id = $1 WHERE id = $2`, [supervisor.id, existing.id]);
}
const finalSubcarteraId = subcarteraId || (await client.query<{ id: string }>(`SELECT id FROM carteras WHERE parent_id = $1 AND auxiliar_user_id = $2 LIMIT 1`, [carteraPrincipal.id, auxiliar.id])).rows[0].id;
console.log('✅ Subcartera del auxiliar creada/recuperada');
// Mover contribuyentes de la cartera principal a la subcartera del auxiliar
const { rows: entidades } = await client.query<{ entidad_id: string }>(`
SELECT entidad_id FROM cartera_entidades WHERE cartera_id = $1
`, [carteraPrincipal.id]);
for (const e of entidades) {
await client.query(`
INSERT INTO cartera_entidades (cartera_id, entidad_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, [finalSubcarteraId, e.entidad_id]);
}
// Quitar contribuyentes de la cartera principal (ahora están en la subcartera)
await client.query(`DELETE FROM cartera_entidades WHERE cartera_id = $1`, [carteraPrincipal.id]);
// La cartera principal ya no tiene auxiliar asignado
await client.query(`UPDATE carteras SET auxiliar_user_id = NULL WHERE id = $1`, [carteraPrincipal.id]);
await client.query('COMMIT');
console.log(`${entidades.length} contribuyentes movidos a la subcartera del auxiliar`);
console.log('✅ Cartera principal limpia (sin auxiliar)');
// Asegurar relación auxiliar → supervisor
await pool.query(`
INSERT INTO auxiliar_supervisores (auxiliar_user_id, supervisor_user_id)
VALUES ($1, $2)
ON CONFLICT (auxiliar_user_id) DO UPDATE SET supervisor_user_id = EXCLUDED.supervisor_user_id
`, [auxiliar.id, supervisor.id]);
console.log('✅ Relación auxiliar → supervisor registrada');
// Validar: el auxiliar debe ser elegible para todos los contribuyentes
const { rows: elegibles } = await pool.query<{ entidad_id: string }>(`
SELECT DISTINCT ce.entidad_id
FROM carteras c
JOIN cartera_entidades ce ON ce.cartera_id = c.id
WHERE c.auxiliar_user_id = $1
`, [auxiliar.id]);
console.log(`✅ Auxiliar elegible para ${elegibles.length} contribuyentes`);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
console.log('\n🎉 Estructura de carteras corregida');
}
main()
.catch((e) => {
console.error('\n❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await tenantDb.shutdown();
});

View File

@@ -0,0 +1,56 @@
import fs from 'fs';
import readline from 'readline';
import { prisma } from '../src/config/database.js';
const BATCH_SIZE = 2000;
const CSV_PATH = process.argv[2] || '/tmp/claves_prod_serv.csv';
async function main() {
if (!fs.existsSync(CSV_PATH)) {
console.error(`Archivo no encontrado: ${CSV_PATH}`);
console.error('Uso: npx tsx scripts/import-clave-prod-serv.ts [ruta/al/csv]');
process.exit(1);
}
const existing = await prisma.catClaveProdServ.count();
console.log(`Registros existentes: ${existing}`);
if (existing > 0) {
console.log('El catálogo ya tiene datos. No se importará nada.');
process.exit(0);
}
const fileStream = fs.createReadStream(CSV_PATH, { encoding: 'utf-8' });
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
let batch: { clave: string; descripcion: string }[] = [];
let total = 0;
for await (const line of rl) {
const idx = line.indexOf(',');
if (idx === -1) continue;
const clave = line.slice(0, idx).trim();
const descripcion = line.slice(idx + 1).trim();
if (!clave || !descripcion) continue;
batch.push({ clave, descripcion });
if (batch.length >= BATCH_SIZE) {
await prisma.catClaveProdServ.createMany({ data: batch, skipDuplicates: true });
total += batch.length;
console.log(`Importados: ${total}`);
batch = [];
}
}
if (batch.length > 0) {
await prisma.catClaveProdServ.createMany({ data: batch, skipDuplicates: true });
total += batch.length;
}
console.log(`Importación completada. Total: ${total}`);
process.exit(0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,67 @@
/**
* Script: resend-welcome
*
* Genera una nueva contraseña temporal para el usuario y reenvía el correo
* de bienvenida. Útil cuando el envío anterior falló o se perdió.
*
* Ejecución:
* cd apps/api && npx tsx scripts/resend-welcome.ts
*/
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import { emailService } from '../src/services/email/email.service.js';
const prisma = new PrismaClient();
const EMAIL = 'miguel.corona@corpcyl.com';
function generateTempPassword(length = 12): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
let result = '';
const bytes = randomBytes(length);
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % chars.length];
}
return result + '!';
}
async function main() {
const user = await prisma.user.findUnique({ where: { email: EMAIL } });
if (!user) {
console.error(`❌ No existe un usuario con el correo ${EMAIL}`);
process.exit(1);
}
const tempPassword = generateTempPassword();
const passwordHash = await bcrypt.hash(tempPassword, 12);
await prisma.user.update({
where: { id: user.id },
data: {
passwordHash,
tokenVersion: { increment: 1 },
},
});
console.log('⏳ Enviando correo de bienvenida a', EMAIL, '...');
await emailService.sendWelcome(EMAIL, {
nombre: user.nombre,
email: EMAIL,
tempPassword,
});
console.log('✅ Correo de bienvenida enviado');
console.log(' Nombre:', user.nombre);
console.log(' Email:', EMAIL);
console.log(' Contraseña temporal:', tempPassword);
}
main()
.catch((e) => {
console.error('\n❌ Error enviando correo:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,112 @@
/**
* Script: reset-demo-asignaciones
*
* Deja el tenant Demo Ventas listo para que el usuario haga manualmente
* el flujo de asignación de carteras, obligaciones y tareas (útiles para tutoriales):
* - Elimina la subcartera del auxiliar.
* - Deja todos los contribuyentes en la cartera principal (sin auxiliar).
* - Elimina asignaciones de obligaciones y tareas.
* - Elimina la relación auxiliar → supervisor.
*
* Ejecución:
* cd apps/api && npx tsx scripts/reset-demo-asignaciones.ts
*/
import { PrismaClient } from '@prisma/client';
import { tenantDb } from '../src/config/database.ts';
const prisma = new PrismaClient();
const DEMO_RFC = 'DEMO2501019X2';
async function findUserIdByEmail(email: string): Promise<string | null> {
const rows = await prisma.$queryRawUnsafe<{ id: string }[]>(
`SELECT id FROM users WHERE email = $1 LIMIT 1`,
email,
);
return rows[0]?.id ?? null;
}
async function main() {
console.log('🔄 Reseteando asignaciones de Demo Ventas para tutoriales...\n');
const tenants = await prisma.$queryRawUnsafe<{ id: string; database_name: string }[]>(
`SELECT id, database_name FROM tenants WHERE rfc = $1 LIMIT 1`,
DEMO_RFC,
);
const tenant = tenants[0];
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
const supervisorId = await findUserIdByEmail('supervisor@horuxfin.com');
if (!supervisorId) throw new Error('Usuario supervisor no encontrado');
const auxiliarId = await findUserIdByEmail('auxiliar@horuxfin.com');
const pool = await tenantDb.getPool(tenant.id, tenant.database_name);
const client = await pool.connect();
try {
await client.query('BEGIN');
// Eliminar asignaciones de obligaciones y tareas
await client.query('DELETE FROM obligacion_asignaciones');
await client.query('DELETE FROM tarea_asignaciones');
console.log('✅ Asignaciones de obligaciones y tareas eliminadas');
// Obtener cartera principal
const { rows: [carteraPrincipal] } = await client.query<{ id: string }>(`
SELECT id FROM carteras WHERE parent_id IS NULL ORDER BY created_at LIMIT 1
`);
if (!carteraPrincipal) throw new Error('No existe cartera principal');
// Eliminar subcarteras (borra también cartera_entidades en cascade si hay FK)
await client.query('DELETE FROM cartera_entidades WHERE cartera_id != $1', [carteraPrincipal.id]);
await client.query('DELETE FROM carteras WHERE parent_id = $1', [carteraPrincipal.id]);
console.log('✅ Subcarteras eliminadas');
// Limpiar cartera principal: sin auxiliar, supervisor demo
await client.query(`
UPDATE carteras SET auxiliar_user_id = NULL, supervisor_user_id = $1 WHERE id = $2
`, [supervisorId, carteraPrincipal.id]);
// Agregar todos los contribuyentes a la cartera principal
const { rows: contribuyentes } = await client.query<{ entidad_id: string }>(`
SELECT entidad_id FROM contribuyentes
`);
for (const c of contribuyentes) {
await client.query(`
INSERT INTO cartera_entidades (cartera_id, entidad_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, [carteraPrincipal.id, c.entidad_id]);
}
console.log(`${contribuyentes.length} contribuyentes dejados en Cartera Principal`);
// Eliminar relación auxiliar → supervisor para que se cree en el tutorial
if (auxiliarId) {
await client.query('DELETE FROM auxiliar_supervisores WHERE auxiliar_user_id = $1', [auxiliarId]);
console.log('✅ Relación auxiliar → supervisor eliminada');
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
console.log('\n🎉 Demo Ventas listo para tutoriales');
console.log(' - Cartera Principal con 6 contribuyentes, sin auxiliar');
console.log(' - 48 obligaciones y 24 tareas sin asignar');
console.log(' - Usuarios: owner, supervisor, auxiliar, cliente');
}
main()
.catch((e) => {
console.error('\n❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await tenantDb.shutdown();
});

View File

@@ -0,0 +1,124 @@
/**
* Script: seed-demo-obligaciones-tareas
*
* Crea obligaciones fiscales y tareas recurrentes para todos los contribuyentes
* del tenant Demo Ventas. Además asigna el usuario auxiliar a las tareas y
* obligaciones, y lo vincula a la cartera principal.
*
* Ejecución:
* cd apps/api && npx tsx scripts/seed-demo-obligaciones-tareas.ts
*/
import { PrismaClient } from '@prisma/client';
import { tenantDb } from '../src/config/database.ts';
import { seedTareasDefault, materializarPeriodos } from '../src/services/tareas.service.ts';
const prisma = new PrismaClient();
const DEMO_RFC = 'DEMO2501019X2';
const OBLIGACIONES = [
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Federal mensual' },
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Federal mensual' },
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Federal mensual' },
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', categoria: 'Informativa mensual' },
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', categoria: 'Seguridad social' },
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', categoria: 'Anual' },
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', categoria: 'Estatal' },
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 10 del mes siguiente', categoria: 'Estatal' },
];
async function main() {
console.log('🌱 Sembrando obligaciones y tareas en Demo Ventas...\n');
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
const auxUser = await prisma.user.findUnique({ where: { email: 'auxiliar@horuxfin.com' } });
if (!auxUser) throw new Error('Usuario auxiliar no encontrado');
const supervisorUser = await prisma.user.findUnique({ where: { email: 'supervisor@horuxfin.com' } });
if (!supervisorUser) throw new Error('Usuario supervisor no encontrado');
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
const { rows: contribuyentes } = await pool.query<{ id: string; rfc: string }>(`
SELECT entidad_id AS id, rfc FROM contribuyentes ORDER BY rfc
`);
if (contribuyentes.length === 0) throw new Error('No hay contribuyentes en el tenant demo');
for (const c of contribuyentes) {
// Obligaciones fiscales (idempotente: evita duplicados por contribuyente + catalogo_id)
let obligacionesCreadas = 0;
for (const o of OBLIGACIONES) {
const { rows: existing } = await pool.query(
`SELECT 1 FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND catalogo_id = $2 LIMIT 1`,
[c.id, o.id],
);
if (existing.length > 0) continue;
await pool.query(`
INSERT INTO obligaciones_contribuyente (
contribuyente_id, catalogo_id, nombre, fundamento, frecuencia, fecha_limite, categoria, activa, es_recomendada
) VALUES ($1, $2, $3, $4, $5, $6, $7, true, true)
`, [c.id, o.id, o.nombre, o.fundamento, o.frecuencia, o.fechaLimite, o.categoria]);
obligacionesCreadas++;
}
console.log(`${c.rfc}: ${obligacionesCreadas} obligaciones creadas`);
// Tareas default
const tareasCreadas = await seedTareasDefault(pool, c.id);
if (tareasCreadas > 0) {
await materializarPeriodos(pool, c.id);
console.log(`${c.rfc}: ${tareasCreadas} tareas creadas y periodos materializados`);
} else {
console.log(` ${c.rfc}: tareas default ya existían`);
}
}
// Asignar auxiliar a todas las obligaciones y tareas activas
await pool.query(`
INSERT INTO obligacion_asignaciones (obligacion_id, auxiliar_user_id, asignado_por)
SELECT oc.id, $1, $2
FROM obligaciones_contribuyente oc
WHERE oc.activa = true
ON CONFLICT (obligacion_id) DO UPDATE SET auxiliar_user_id = EXCLUDED.auxiliar_user_id, asignado_por = EXCLUDED.asignado_por
`, [auxUser.id, supervisorUser.id]);
console.log('✅ Auxiliar asignado a obligaciones');
await pool.query(`
INSERT INTO tarea_asignaciones (tarea_id, auxiliar_user_id, asignado_por)
SELECT tc.id, $1, $2
FROM tareas_catalogo tc
WHERE tc.active = true
ON CONFLICT (tarea_id) DO UPDATE SET auxiliar_user_id = EXCLUDED.auxiliar_user_id, asignado_por = EXCLUDED.asignado_por
`, [auxUser.id, supervisorUser.id]);
console.log('✅ Auxiliar asignado a tareas');
// Asignar auxiliar a la cartera principal
await pool.query(`
UPDATE carteras SET auxiliar_user_id = $1
WHERE parent_id IS NULL
`, [auxUser.id]);
console.log('✅ Auxiliar asignado a la cartera principal');
// Asegurar relación auxiliar-supervisor
await pool.query(`
INSERT INTO auxiliar_supervisores (auxiliar_user_id, supervisor_user_id)
VALUES ($1, $2)
ON CONFLICT (auxiliar_user_id) DO UPDATE SET supervisor_user_id = EXCLUDED.supervisor_user_id
`, [auxUser.id, supervisorUser.id]);
console.log('✅ Relación auxiliar → supervisor registrada');
console.log('\n🎉 Obligaciones y tareas listas en Demo Ventas');
}
main()
.catch((e) => {
console.error('\n❌ Error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await tenantDb.shutdown();
});

View File

@@ -0,0 +1,337 @@
/**
* Script: update-demo-ventas
*
* Agrega al tenant Demo Ventas:
* - 5 contribuyentes adicionales
* - Usuarios supervisor, auxiliar y cliente con sus memberships
* - CFDIs de ejemplo para los nuevos contribuyentes
* - Accesos de cliente a los contribuyentes
* - Ajusta el plan custom para soportar más RFCs/usuarios
*
* Ejecución:
* cd apps/api && npx tsx scripts/update-demo-ventas.ts
*/
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
import { tenantDb } from '../src/config/database.ts';
const prisma = new PrismaClient();
const DEMO_RFC = 'DEMO2501019X2';
const DEFAULT_PASSWORD = 'Demo12345!';
const NUEVOS_CONTRIBUYENTES = [
{ rfc: 'COM2501019X1', nombre: 'Comercial del Norte SA de CV', cp: '64000' },
{ rfc: 'DIS2501019X1', nombre: 'Distribuidora del Centro SA de CV', cp: '44100' },
{ rfc: 'SIS2501019X1', nombre: 'Servicios Integrales del Sur SA de CV', cp: '86000' },
{ rfc: 'IMP2501019X1', nombre: 'Importadora del Pacifico SA de CV', cp: '82140' },
{ rfc: 'EXA2501019X1', nombre: 'Exportadora del Atlantico SA de CV', cp: '94270' },
];
const USUARIOS = [
{ email: 'supervisor@horuxfin.com', nombre: 'Supervisor Demo', rol: 'supervisor' },
{ email: 'auxiliar@horuxfin.com', nombre: 'Auxiliar Demo', rol: 'auxiliar' },
{ email: 'cliente@horuxfin.com', nombre: 'Cliente Demo', rol: 'cliente' },
];
const CLIENTES = [
{ rfc: 'CLI123456AB1', nombre: 'Cliente Alfa SA' },
{ rfc: 'CLI123456AB2', nombre: 'Cliente Beta SA' },
{ rfc: 'CLI123456AB3', nombre: 'Cliente Gamma SA' },
];
const PROVEEDORES = [
{ rfc: 'PRO123456AB1', nombre: 'Proveedor Materiales SA' },
{ rfc: 'PRO123456AB2', nombre: 'Proveedor Servicios SA' },
{ rfc: 'PRO123456AB3', nombre: 'Proveedor Logistica SA' },
];
const PRODUCTOS = [
{ clave: '84111506', descripcion: 'Servicio de consultoria', unidad: 'Servicio' },
{ clave: '43232408', descripcion: 'Licencia de software', unidad: 'Licencia' },
{ clave: '81141500', descripcion: 'Soporte tecnico', unidad: 'Servicio' },
{ clave: '81121700', descripcion: 'Desarrollo web', unidad: 'Servicio' },
{ clave: '86101500', descripcion: 'Capacitacion', unidad: 'Servicio' },
];
async function main() {
console.log('🌱 Actualizando Demo Ventas...\n');
const tenant = await prisma.tenant.findUnique({ where: { rfc: DEMO_RFC } });
if (!tenant) throw new Error(`Tenant ${DEMO_RFC} no encontrado`);
// Ajustar catálogo del plan custom para soportar la demo completa
await prisma.despachoPlanPrice.update({
where: { plan: 'custom' },
data: { maxRfcs: 10, maxUsers: 10 },
});
console.log('✅ Plan custom actualizado: maxRfcs=10, maxUsers=10');
// Crear/actualizar usuarios y memberships
const createdUsers: Record<string, { id: string; rolId: number }> = {};
for (const u of USUARIOS) {
const rol = await prisma.rol.findUnique({ where: { nombre: u.rol } });
if (!rol) throw new Error(`Rol ${u.rol} no encontrado`);
let user = await prisma.user.findUnique({ where: { email: u.email } });
const passwordHash = await bcrypt.hash(DEFAULT_PASSWORD, 12);
if (!user) {
user = await prisma.user.create({
data: { email: u.email, passwordHash, nombre: u.nombre, lastTenantId: tenant.id },
});
} else {
user = await prisma.user.update({ where: { id: user.id }, data: { passwordHash, lastTenantId: tenant.id } });
}
await prisma.tenantMembership.upsert({
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
update: { rolId: rol.id, active: true, isOwner: false },
create: { userId: user.id, tenantId: tenant.id, rolId: rol.id, active: true, isOwner: false },
});
createdUsers[u.rol] = { id: user.id, rolId: rol.id };
console.log(`✅ Usuario ${u.rol}:`, u.email);
}
const supervisorId = createdUsers.supervisor.id;
const clienteId = createdUsers.cliente.id;
// Conectar a BD del tenant
const pool = await tenantDb.getPool(tenant.id, tenant.databaseName);
// Crear contribuyentes, CFDIs y accesos
const contribuyenteIds: string[] = [];
for (const c of NUEVOS_CONTRIBUYENTES) {
const id = await crearContribuyente(pool, c, supervisorId, tenant.id);
contribuyenteIds.push(id);
console.log(`✅ Contribuyente creado: ${c.rfc}`);
await crearCfdis(pool, id, c.rfc, c.nombre);
}
// Asignar accesos de cliente a todos los contribuyentes (incluido el original)
const { rows: todasEntidades } = await pool.query<{ id: string }>(`
SELECT entidad_id AS id FROM contribuyentes
`);
for (const e of todasEntidades) {
await pool.query(`
INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, [clienteId, e.id]);
}
console.log('✅ Accesos de cliente asignados a', todasEntidades.length, 'contribuyentes');
// Agregar nuevos contribuyentes a la cartera principal
const { rows: [cartera] } = await pool.query<{ id: string }>(`
SELECT id FROM carteras ORDER BY created_at LIMIT 1
`);
if (cartera) {
for (const id of contribuyenteIds) {
await pool.query(`
INSERT INTO cartera_entidades (cartera_id, entidad_id) VALUES ($1, $2)
ON CONFLICT DO NOTHING
`, [cartera.id, id]);
}
console.log('✅ Nuevos contribuyentes agregados a cartera principal');
}
console.log('\n🎉 Demo Ventas actualizada');
console.log(' Nuevos contribuyentes:', NUEVOS_CONTRIBUYENTES.length);
console.log(' Usuos adicionales:');
for (const u of USUARIOS) {
console.log(` ${u.rol}: ${u.email} / ${DEFAULT_PASSWORD}`);
}
}
async function crearContribuyente(pool: Pool, data: { rfc: string; nombre: string; cp: string }, supervisorId: string, tenantId: string): Promise<string> {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Buscar si ya existe la entidad para este RFC
const { rows: existingEntidad } = await client.query<{ id: string }>(`
SELECT e.id FROM entidades_gestionadas e
WHERE e.identificador = $1 AND e.tipo = 'CONTRIBUYENTE'
`, [data.rfc]);
let entidadId: string;
if (existingEntidad.length > 0) {
entidadId = existingEntidad[0].id;
await client.query(`
UPDATE entidades_gestionadas
SET nombre = $1, supervisor_user_id = $2, updated_at = now()
WHERE id = $3
`, [data.nombre, supervisorId, entidadId]);
} else {
const { rows: [entidad] } = await client.query<{ id: string }>(`
INSERT INTO entidades_gestionadas (tipo, nombre, identificador, supervisor_user_id)
VALUES ('CONTRIBUYENTE', $1, $2, $3)
RETURNING id
`, [data.nombre, data.rfc, supervisorId]);
entidadId = entidad.id;
}
const { rows: existingContrib } = await client.query<{ entidad_id: string }>(`
SELECT entidad_id FROM contribuyentes WHERE entidad_id = $1
`, [entidadId]);
if (existingContrib.length > 0) {
await client.query(`
UPDATE contribuyentes
SET rfc = $1, regimen_fiscal = $2, codigo_postal = $3
WHERE entidad_id = $4
`, [data.rfc, '601', data.cp, entidadId]);
} else {
await client.query(`
INSERT INTO contribuyentes (entidad_id, rfc, regimen_fiscal, codigo_postal)
VALUES ($1, $2, $3, $4)
`, [entidadId, data.rfc, '601', data.cp]);
}
await client.query(`
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
`, [data.rfc, data.nombre, '601']);
await client.query('COMMIT');
return entidadId;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async function crearCfdis(pool: Pool, contribuyenteId: string, rfcContribuyente: string, nombreContribuyente: string) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Asegurar RFCs de clientes/proveedores
const rfcs = new Map<string, number>();
for (const c of [...CLIENTES, ...PROVEEDORES]) {
const { rows: [r] } = await client.query<{ id: number }>(`
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
RETURNING id
`, [c.rfc, c.nombre, '601']);
rfcs.set(c.rfc, r.id);
}
const { rows: [rfcPrincipal] } = await client.query<{ id: number }>(`
INSERT INTO rfcs (rfc, razon_social, regimen_fiscal)
VALUES ($1, $2, $3)
ON CONFLICT (rfc) DO UPDATE SET razon_social = EXCLUDED.razon_social
RETURNING id
`, [rfcContribuyente, nombreContribuyente, '601']);
rfcs.set(rfcContribuyente, rfcPrincipal.id);
for (let i = 0; i < 10; i++) {
const esEmitido = i < 5;
const contraparte = esEmitido
? CLIENTES[i % CLIENTES.length]
: PROVEEDORES[i % PROVEEDORES.length];
const subtotal = Math.floor(Math.random() * 30000) + 1500;
const iva = Math.round(subtotal * 0.16 * 100) / 100;
const total = Math.round((subtotal + iva) * 100) / 100;
const daysAgo = Math.floor(Math.random() * 360);
const fecha = new Date();
fecha.setDate(fecha.getDate() - daysAgo);
fecha.setHours(9 + (i % 8), 0, 0, 0);
const year = String(fecha.getFullYear());
const month = String(fecha.getMonth() + 1).padStart(2, '0');
const fechaStr = fecha.toISOString();
const metodoPago = Math.random() > 0.4 ? 'PUE' : 'PPD';
const formasPago = ['01', '02', '03'];
const formaPago = formasPago[i % formasPago.length];
const usoCfdi = esEmitido ? 'G03' : 'G01';
const tipo = esEmitido ? 'EMITIDO' : 'RECIBIDO';
const rfcEmisor = esEmitido ? rfcContribuyente : contraparte.rfc;
const nombreEmisor = esEmitido ? nombreContribuyente : contraparte.nombre;
const rfcReceptor = esEmitido ? contraparte.rfc : rfcContribuyente;
const nombreReceptor = esEmitido ? contraparte.nombre : nombreContribuyente;
const { rows: [cfdi] } = await client.query<{ id: number }>(`
INSERT INTO cfdis (
year, month, type, uuid, serie, folio, status, fecha_emision,
rfc_emisor_id, rfc_emisor, nombre_emisor,
rfc_receptor_id, rfc_receptor, nombre_receptor,
subtotal, subtotal_mxn, descuento, descuento_mxn,
total, total_mxn, moneda, tipo_cambio, tipo_comprobante,
metodo_pago, forma_pago, uso_cfdi,
iva_traslado, iva_traslado_mxn,
regimen_fiscal_emisor, regimen_fiscal_receptor,
contribuyente_id, fecha_efectiva, meses_global, año_global
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10, $11,
$12, $13, $14,
$15, $16, $17, $18,
$19, $20, $21, $22, $23,
$24, $25, $26,
$27, $28,
$29, $30,
$31, $32, $33, $34
) RETURNING id
`, [
year, month, tipo, randomUUID(), 'DEMO', String(2000 + i),
'Vigente', fechaStr,
rfcs.get(rfcEmisor), rfcEmisor, nombreEmisor,
rfcs.get(rfcReceptor), rfcReceptor, nombreReceptor,
subtotal, subtotal, 0, 0,
total, total, 'MXN', 1, 'I',
metodoPago, formaPago, usoCfdi,
iva, iva,
'601', '601',
contribuyenteId, fechaStr, month, year,
]);
const numConceptos = Math.floor(Math.random() * 2) + 1;
for (let j = 0; j < numConceptos; j++) {
const prod = PRODUCTOS[(i + j) % PRODUCTOS.length];
const cantidad = Math.floor(Math.random() * 4) + 1;
const valorUnitario = Math.floor(Math.random() * 3000) + 500;
const importe = Math.round(cantidad * valorUnitario * 100) / 100;
const ivaConcepto = Math.round(importe * 0.16 * 100) / 100;
await client.query(`
INSERT INTO cfdi_conceptos (
cfdi_id, clave_prod_serv, descripcion, cantidad, clave_unidad, unidad,
valor_unitario, valor_unitario_mxn, importe, importe_mxn,
iva_traslado, iva_traslado_mxn
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`, [
cfdi.id, prod.clave, prod.descripcion, cantidad, 'E48', prod.unidad,
valorUnitario, valorUnitario, importe, importe,
ivaConcepto, ivaConcepto,
]);
}
}
await client.query('COMMIT');
console.log(` 📄 10 CFDIs creados para ${rfcContribuyente}`);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
main()
.catch((e) => {
console.error('\n❌ Error actualizando demo:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await tenantDb.shutdown();
});

View File

@@ -187,11 +187,13 @@ class TenantConnectionManager {
} }
/** /**
* Remove idle pools (not accessed in last 5 minutes). * Remove idle pools (not accessed in last 12 hours).
* SAT syncs (initial/daily) can run for hours in background;
* a 5-minute timeout caused 'pool already ended' errors mid-sync.
*/ */
private cleanupIdlePools(): void { private cleanupIdlePools(): void {
const now = Date.now(); const now = Date.now();
const maxIdle = 5 * 60 * 1000; const maxIdle = 12 * 60 * 60 * 1000;
for (const [tenantId, entry] of this.pools.entries()) { for (const [tenantId, entry] of this.pools.entries()) {
if (now - entry.lastAccess.getTime() > maxIdle) { if (now - entry.lastAccess.getTime() > maxIdle) {

View File

@@ -2,53 +2,67 @@ export interface ObligacionFiscal {
id: string; id: string;
nombre: string; nombre: string;
fundamento: string; fundamento: string;
frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'eventual'; frecuencia: 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'anual' | 'eventual';
fechaLimite: string; fechaLimite: string;
aplica: 'PM' | 'PF' | 'ambos'; aplica: 'PM' | 'PF' | 'ambos';
regimenes: string[] | null; // null = all regimes regimenes: string[] | null; // null = all regimes
condicion: string | null; condicion: string | null;
categoria: string; categoria: string;
recomendadaPorDefecto: boolean; recomendadaPorDefecto: boolean;
/** Si true, la obligación requiere comprobante de pago para cerrarse. */
requierePago: boolean;
} }
export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [ export const OBLIGACIONES_CATALOGO: ObligacionFiscal[] = [
// === FEDERALES MENSUALES (día 17) === // === FEDERALES MENSUALES (día 17) ===
{ id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true }, { id: 'isr-provisional', nombre: 'Pago provisional de ISR', fundamento: 'Art. 14 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: true },
{ id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', recomendadaPorDefecto: true }, { id: 'iva-mensual', nombre: 'Pago mensual definitivo de IVA', fundamento: 'Art. 5-D LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: true },
{ id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false }, { id: 'actividades-vulnerables', nombre: 'Aviso de actividades vulnerables', fundamento: 'LFPIORPI', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', recomendadaPorDefecto: false }, { id: 'ret-isr-sueldos', nombre: 'Retenciones de ISR por sueldos y salarios', fundamento: 'Art. 96 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', recomendadaPorDefecto: false }, { id: 'ret-isr-asimilados', nombre: 'Retenciones de ISR por asimilados a salarios', fundamento: 'Art. 94 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Facturas emitidas tipo N', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', recomendadaPorDefecto: false }, { id: 'ret-isr-honorarios', nombre: 'Retenciones de ISR por honorarios y arrendamiento a PF', fundamento: 'Art. 106/116 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'PM que contrate PF', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', recomendadaPorDefecto: false }, { id: 'ret-iva', nombre: 'Retenciones de IVA (servicios, fletes, outsourcing)', fundamento: 'Art. 1-A LIVA', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: null, condicion: 'Según supuesto', categoria: 'Federal mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'ieps', nombre: 'Pago definitivo de IEPS', fundamento: 'Art. 5 LIEPS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Productores/importadores', categoria: 'Federal mensual', requierePago: true, recomendadaPorDefecto: false },
// === INFORMATIVAS MENSUALES === // === INFORMATIVAS MENSUALES ===
{ id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false }, { id: 'diot', nombre: 'DIOT (Declaración Informativa de Operaciones con Terceros)', fundamento: 'Art. 32 LIVA', frecuencia: 'mensual', fechaLimite: 'Último día del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false }, { id: 'cont-balanza', nombre: 'Contabilidad Electrónica - Balanza de comprobación', fundamento: 'CFF Art. 28', frecuencia: 'mensual', fechaLimite: 'Día 3 del segundo mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', recomendadaPorDefecto: false }, { id: 'cont-catalogo', nombre: 'Contabilidad Electrónica - Catálogo de cuentas', fundamento: 'CFF Art. 28', frecuencia: 'eventual', fechaLimite: 'Cuando haya modificación', aplica: 'ambos', regimenes: null, condicion: 'PF con ingresos > $4M y todas las PM, excepto RESICO', categoria: 'Informativa mensual', requierePago: false, recomendadaPorDefecto: false },
// === FEDERALES TRIMESTRALES ===
{ id: 'ieps-trimestral', nombre: 'Declaración Informativa Múltiple del IEPS', fundamento: 'LIEPS', frecuencia: 'trimestral', fechaLimite: 'Día 17 de abril, julio, octubre y enero', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Federal trimestral', requierePago: false, recomendadaPorDefecto: false },
// === RESICO PM === // === RESICO PM ===
{ id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', recomendadaPorDefecto: true }, { id: 'isr-resico-pm', nombre: 'Pago provisional ISR RESICO-PM', fundamento: 'Art. 206 LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PM', regimenes: ['626'], condicion: null, categoria: 'RESICO PM', requierePago: true, recomendadaPorDefecto: true },
// === RESICO PF === // === RESICO PF ===
{ id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', recomendadaPorDefecto: true }, { id: 'isr-resico-pf', nombre: 'Pago mensual ISR RESICO PF (1%-2.5%)', fundamento: 'Art. 113-E LISR', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'PF', regimenes: ['626'], condicion: null, categoria: 'RESICO PF', requierePago: true, recomendadaPorDefecto: true },
// === ANUALES PM === // === ANUALES PM ===
{ id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true }, { id: 'anual-isr-pm', nombre: 'Declaración Anual de ISR PM', fundamento: 'Art. 76 LISR', frecuencia: 'anual', fechaLimite: '31 de marzo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: true, recomendadaPorDefecto: true },
{ id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false }, { id: 'declaracion-transparencia', nombre: 'Declaración Informativa de transparencia', fundamento: 'LFTAIPG', frecuencia: 'anual', fechaLimite: 'Día 31 de mayo', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Federal anual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', recomendadaPorDefecto: false }, { id: 'issif', nombre: 'ISSIF (Información sobre Situación Fiscal)', fundamento: 'CFF Art. 32-H', frecuencia: 'anual', fechaLimite: 'Con la declaración anual', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: false }, { id: 'dictamen-fiscal', nombre: 'Dictamen Fiscal', fundamento: 'CFF Art. 32-A', frecuencia: 'anual', fechaLimite: '15 de mayo', aplica: 'PM', regimenes: null, condicion: 'Ingresos > $1,855M o grupos', categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
{ id: 'dim', nombre: 'DIM - Declaraciones Informativas Múltiples', fundamento: 'CFF', frecuencia: 'anual', fechaLimite: '15 de febrero', aplica: 'PM', regimenes: null, condicion: null, categoria: 'Anual', requierePago: false, recomendadaPorDefecto: false },
// === ANUALES PF === // === ANUALES PF ===
{ id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', recomendadaPorDefecto: true }, { id: 'anual-isr-pf', nombre: 'Declaración Anual PF', fundamento: 'Art. 150 LISR', frecuencia: 'anual', fechaLimite: '30 de abril', aplica: 'PF', regimenes: null, condicion: null, categoria: 'Anual', requierePago: true, recomendadaPorDefecto: true },
// === SEGURIDAD SOCIAL === // === SEGURIDAD SOCIAL ===
{ id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false }, { id: 'imss-cuotas', nombre: 'Cuotas obrero-patronales IMSS', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 17 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
{ id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false }, { id: 'sipare', nombre: 'SIPARE - Cuotas obrero-patronales', fundamento: 'LSS', frecuencia: 'mensual', fechaLimite: 'Día 15 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
{ id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false }, { id: 'infonavit', nombre: 'Aportaciones INFONAVIT + amortizaciones', fundamento: 'LINFONAVIT', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', recomendadaPorDefecto: false }, { id: 'sar-retiro', nombre: 'SAR / Retiro', fundamento: 'LSS', frecuencia: 'bimestral', fechaLimite: 'Día 17 del mes siguiente al bimestre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
{ id: 'sisub', nombre: 'Sistema de Información de Subcontratación', fundamento: 'LFT', frecuencia: 'cuatrimestral', fechaLimite: 'Día 17 de enero, mayo y septiembre', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: false, recomendadaPorDefecto: false },
{ id: 'prima-riesgo', nombre: 'Determinación Prima de Riesgo de Trabajo', fundamento: 'LSS Art. 74', frecuencia: 'anual', fechaLimite: 'Febrero', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Seguridad social', requierePago: true, recomendadaPorDefecto: false },
// === CRÉDITOS DE LOS TRABAJADORES ===
{ id: 'fonacot', nombre: 'Crédito FONACOT', fundamento: 'Ley FONACOT', frecuencia: 'mensual', fechaLimite: 'Día 5 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Créditos de los trabajadores', requierePago: true, recomendadaPorDefecto: false },
// === ESTATALES === // === ESTATALES ===
{ id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', recomendadaPorDefecto: false }, { id: 'isn', nombre: 'ISN - Impuesto Sobre Nómina', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Varía por estado (CDMX día 17)', aplica: 'ambos', regimenes: null, condicion: 'Con empleados', categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
{ id: 'isrtp', nombre: 'Impuesto sobre remuneración al trabajo', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 10 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
{ id: 'ish', nombre: 'ISH - Impuesto Sobre Hospedaje', fundamento: 'Ley estatal', frecuencia: 'mensual', fechaLimite: 'Día 15 del mes siguiente', aplica: 'ambos', regimenes: null, condicion: null, categoria: 'Estatal', requierePago: true, recomendadaPorDefecto: false },
]; ];
/** /**

View File

@@ -125,7 +125,9 @@ export async function resolverAlertaManual(req: Request, res: Response, next: Ne
export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) { export async function getAlertasAutomaticas(req: Request, res: Response, next: NextFunction) {
try { try {
const contribuyenteId = req.query.contribuyenteId as string | undefined; const contribuyenteId = req.query.contribuyenteId as string | undefined;
console.log(`[AlertasCtrl] GET /automaticas tenant=${req.user!.tenantId} contribuyente=${contribuyenteId || 'null'} user=${req.user!.userId} role=${req.user!.role}`);
const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null); const alertas = await generarAlertasAutomaticas(req.tenantPool!, req.user!.tenantId, contribuyenteId || null);
console.log(`[AlertasCtrl] GET /automaticas devuelve ${alertas.length} alertas: ${alertas.map(a => a.id).join(', ') || 'ninguna'}`);
res.json(alertas); res.json(alertas);
} catch (error) { } catch (error) {
next(error); next(error);

View File

@@ -36,6 +36,10 @@ export async function getClavesUnidad(req: Request, res: Response, next: NextFun
} catch (error) { next(error); } } catch (error) { next(error); }
} }
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) { export async function searchClaveProdServ(req: Request, res: Response, next: NextFunction) {
try { try {
const q = (req.query.q as string || '').trim(); const q = (req.query.q as string || '').trim();
@@ -44,11 +48,10 @@ export async function searchClaveProdServ(req: Request, res: Response, next: Nex
} }
// Buscar por clave o descripción // Buscar por clave o descripción
// Primero buscar por clave, luego por texto
const data = await prisma.catClaveProdServ.findMany({ const data = await prisma.catClaveProdServ.findMany({
where: { where: {
OR: [ OR: [
{ clave: { startsWith: q } }, { clave: { startsWith: q, mode: 'insensitive' } },
{ descripcion: { contains: q, mode: 'insensitive' } }, { descripcion: { contains: q, mode: 'insensitive' } },
], ],
}, },
@@ -68,8 +71,8 @@ export async function searchClaveProdServ(req: Request, res: Response, next: Nex
return res.json(fallback); return res.json(fallback);
} }
// Buscar con variantes comunes de acentos // Buscar con variantes comunes de acentos, escapando caracteres regex primero
const withAccents = normalized const withAccents = escapeRegex(normalized)
.replace(/a/gi, '[aá]').replace(/e/gi, '[eé]') .replace(/a/gi, '[aá]').replace(/e/gi, '[eé]')
.replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]') .replace(/i/gi, '[ií]').replace(/o/gi, '[oó]').replace(/u/gi, '[uú]')
.replace(/n/gi, '[nñ]'); .replace(/n/gi, '[nñ]');

View File

@@ -42,7 +42,24 @@ export async function list(req: Request, res: Response, next: NextFunction) {
try { try {
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role); const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId); const rows = await contribuyenteService.listContribuyentes(req.tenantPool!, visibleIds, req.user!.tenantId);
return res.json({ data: rows });
// Batch lookup de nombres de supervisores
const supervisorIds = [...new Set(rows.map(r => r.supervisorUserId).filter(Boolean))] as string[];
const supervisorNames: Record<string, string> = {};
if (supervisorIds.length > 0) {
const users = await prisma.user.findMany({
where: { id: { in: supervisorIds } },
select: { id: true, nombre: true },
});
for (const u of users) supervisorNames[u.id] = u.nombre;
}
return res.json({
data: rows.map(r => ({
...r,
supervisorNombre: r.supervisorUserId ? (supervisorNames[r.supervisorUserId] ?? null) : null,
})),
});
} catch (err) { return next(err); } } catch (err) { return next(err); }
} }
@@ -153,6 +170,15 @@ export async function addClienteAcceso(req: Request, res: Response, next: NextFu
const { userId } = req.body; const { userId } = req.body;
if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido')); if (!userId || typeof userId !== 'string') return next(new AppError(400, 'userId requerido'));
const entidadId = String(req.params.id); const entidadId = String(req.params.id);
// Seguridad: supervisor solo puede asignar contribuyentes que supervise
if (req.user!.role === 'supervisor') {
const visibleIds = await getEntidadesVisibles(req.tenantPool!, req.user!.userId, req.user!.role);
if (!visibleIds.includes(entidadId)) {
return next(new AppError(403, 'No tienes acceso a este contribuyente'));
}
}
await req.tenantPool!.query( await req.tenantPool!.query(
'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', 'INSERT INTO cliente_accesos (user_id, entidad_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
[userId, entidadId], [userId, entidadId],

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import { signupDespacho } from '../services/despacho.service.js'; import { signupDespacho } from '../services/despacho.service.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { prisma } from '../config/database.js'; import { prisma } from '../config/database.js';
import { getPlanPrice } from '../services/payment/subscription.service.js';
const signupSchema = z.object({ const signupSchema = z.object({
despacho: z.object({ despacho: z.object({
@@ -47,7 +48,7 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
// business_control desde una TrialInvitation), respetamos ese plan // business_control desde una TrialInvitation), respetamos ese plan
// para que el feature-gate y los límites funcionen correctamente. // para que el feature-gate y los límites funcionen correctamente.
const subscription = await prisma.subscription.findFirst({ const subscription = await prisma.subscription.findFirst({
where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } }, where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial', 'trial_expired'] } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
select: { select: {
status: true, amount: true, plan: true, status: true, amount: true, plan: true,
@@ -64,6 +65,18 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
currentPlan = String(tenant.plan); currentPlan = String(tenant.plan);
} }
// Precio de catálogo del plan actual (primer año, anual). La UI lo usa
// cuando la suscripción aún no tiene monto (trial/trial_expired) para
// mostrar el CTA de pago.
let planPrice: number | null = null;
if (currentPlan && currentPlan !== 'trial' && currentPlan !== 'custom') {
try {
planPrice = await getPlanPrice(currentPlan as any, 'annual', 'firstYear');
} catch {
planPrice = null;
}
}
// Estado de suscripción activa (si hay) — alimenta la UI con el monto // Estado de suscripción activa (si hay) — alimenta la UI con el monto
// recurrente actual, fecha de próxima renovación y si el primer pago // recurrente actual, fecha de próxima renovación y si el primer pago
// (cuando aplica dualidad firstYear) ya fue completado. // (cuando aplica dualidad firstYear) ya fue completado.
@@ -72,6 +85,7 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction)
dbMode: tenant.dbMode, dbMode: tenant.dbMode,
trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null, trialEndsAt: tenant.trialEndsAt?.toISOString() ?? null,
isTrialActive, isTrialActive,
planPrice,
subscription: subscription subscription: subscription
? { ? {
status: subscription.status, status: subscription.status,

View File

@@ -4,6 +4,7 @@ import { getOpiniones, getOpinionPdf, consultarOpinion, consultarOpinionContribu
import * as declaracionesService from '../services/declaraciones.service.js'; import * as declaracionesService from '../services/declaraciones.service.js';
import * as constanciaService from '../services/constancia.service.js'; import * as constanciaService from '../services/constancia.service.js';
import * as extrasService from '../services/documentos-extras.service.js'; import * as extrasService from '../services/documentos-extras.service.js';
import * as obligacionEvidenciasService from '../services/obligacion-evidencias.service.js';
import { notifyDocumentoSubido } from '../services/notify-upload.service.js'; import { notifyDocumentoSubido } from '../services/notify-upload.service.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
@@ -81,8 +82,9 @@ const createDeclaracionSchema = z.object({
año: z.number().int().min(2020).max(2100), año: z.number().int().min(2020).max(2100),
mes: z.number().int().min(1).max(12), mes: z.number().int().min(1).max(12),
tipo: z.enum(['normal', 'complementaria']), tipo: z.enum(['normal', 'complementaria']),
periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'semestral', 'anual']).optional(), periodicidad: z.enum(['mensual', 'bimestral', 'trimestral', 'cuatrimestral', 'semestral', 'anual']).optional(),
impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).min(1, 'Selecciona al menos un impuesto'), impuestos: z.array(z.enum(['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'])).optional(),
obligacionesIds: z.array(z.string().uuid()).optional(),
montoPago: z.number().min(0).optional(), montoPago: z.number().min(0).optional(),
pdfBase64: z.string().min(100), pdfBase64: z.string().min(100),
pdfFilename: z.string().min(1).max(255), pdfFilename: z.string().min(1).max(255),
@@ -92,6 +94,9 @@ const createDeclaracionSchema = z.object({
}).refine( }).refine(
d => !d.ligaPagoBase64 || !!d.ligaPagoFilename, d => !d.ligaPagoBase64 || !!d.ligaPagoFilename,
{ message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] }, { message: 'Si incluyes liga de pago, también debes mandar su nombre de archivo', path: ['ligaPagoFilename'] },
).refine(
d => (d.obligacionesIds && d.obligacionesIds.length > 0) || (d.impuestos && d.impuestos.length > 0),
{ message: 'Selecciona al menos una obligación fiscal o un impuesto', path: ['obligacionesIds'] },
); );
export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) { export async function listarDeclaraciones(req: Request, res: Response, next: NextFunction) {
@@ -119,6 +124,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
}); });
// Notificación fire-and-forget a owners del despacho + supervisor del RFC. // Notificación fire-and-forget a owners del despacho + supervisor del RFC.
// Incluye como adjuntos el acuse de declaración y la liga de pago (si se subió).
// No bloquea la respuesta ni falla la creación si SMTP no está configurado. // No bloquea la respuesta ni falla la creación si SMTP no está configurado.
notifyDocumentoSubido({ notifyDocumentoSubido({
pool: req.tenantPool!, pool: req.tenantPool!,
@@ -126,6 +132,7 @@ export async function crearDeclaracion(req: Request, res: Response, next: NextFu
contribuyenteId: contribuyenteId ?? null, contribuyenteId: contribuyenteId ?? null,
subidoPor: req.user!.email, subidoPor: req.user!.email,
kind: 'declaracion', kind: 'declaracion',
declaracionId: result.declaracion.id,
declaracion: { declaracion: {
periodo: `${MESES[data.mes - 1]} ${data.año}`, periodo: `${MESES[data.mes - 1]} ${data.año}`,
tipo: data.tipo, tipo: data.tipo,
@@ -334,3 +341,91 @@ export async function listarCategoriasExtras(req: Request, res: Response, next:
res.json(data); res.json(data);
} catch (error) { next(error); } } catch (error) { next(error); }
} }
// ═══════════════════════════════════════════════════════════════════════════
// Obligación evidencias — documentos que cierran obligaciones fiscales
// ═══════════════════════════════════════════════════════════════════════════
const createEvidenciaObligacionSchema = z.object({
contribuyenteId: z.string().uuid('contribuyenteId inválido'),
obligacionId: z.string().uuid('obligacionId inválido'),
periodo: z.string().regex(/^\d{4}-\d{2}$/, 'periodo debe ser YYYY-MM'),
tipoDocumento: z.enum(['declaracion', 'pago', 'acuse', 'complemento']),
pdfBase64: z.string().min(100, 'PDF requerido'),
pdfFilename: z.string().min(1).max(255),
notas: z.string().max(2000).optional(),
});
export async function listarEvidenciasObligacion(req: Request, res: Response, next: NextFunction) {
try {
const contribuyenteId = req.query.contribuyenteId as string | undefined;
if (!contribuyenteId) return next(new AppError(400, 'contribuyenteId requerido'));
const periodo = req.query.periodo as string | undefined;
const obligacionId = req.query.obligacionId as string | undefined;
const data = await obligacionEvidenciasService.listEvidencias(req.tenantPool!, contribuyenteId, {
periodo,
obligacionId,
});
res.json(data);
} catch (error) { next(error); }
}
export async function crearEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
try {
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para subir documentos' });
const data = createEvidenciaObligacionSchema.parse(req.body);
const result = await obligacionEvidenciasService.createEvidencia(req.tenantPool!, {
...data,
subidoPor: req.user!.userId,
subidoPorEmail: req.user!.email,
});
// Notificación fire-and-forget a owners + supervisor del contribuyente.
const { rows: obRows } = await req.tenantPool!.query<{ nombre: string }>(
'SELECT nombre FROM obligaciones_contribuyente WHERE id = $1',
[data.obligacionId],
);
notifyDocumentoSubido({
pool: req.tenantPool!,
tenantId: req.viewingTenantId ?? req.user!.tenantId,
contribuyenteId: data.contribuyenteId,
subidoPor: req.user!.email,
kind: 'obligacion_evidencia',
evidencia: {
obligacionNombre: obRows[0]?.nombre || 'Obligación fiscal',
periodo: data.periodo,
tipoDocumento: data.tipoDocumento,
filename: data.pdfFilename,
},
pdfBase64: data.pdfBase64,
}).catch((err: any) => console.error('[notifyDocumentoSubido obligacion_evidencia]', err?.message || err));
res.status(201).json(result);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function descargarEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
try {
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const pdf = await obligacionEvidenciasService.getEvidenciaPdf(req.tenantPool!, id);
if (!pdf) return next(new AppError(404, 'Evidencia no encontrada'));
res.setHeader('Content-Type', pdf.mime);
res.setHeader('Content-Disposition', `attachment; filename="${pdf.filename}"`);
res.send(pdf.buffer);
} catch (error) { next(error); }
}
export async function eliminarEvidenciaObligacion(req: Request, res: Response, next: NextFunction) {
try {
if (!canUpload(req)) return res.status(403).json({ message: 'No tienes permiso para eliminar documentos' });
const id = parseInt(String(req.params.id));
if (isNaN(id)) return next(new AppError(400, 'id inválido'));
const result = await obligacionEvidenciasService.deleteEvidencia(req.tenantPool!, id);
if (!result) return next(new AppError(404, 'Evidencia no encontrada'));
res.status(204).send();
} catch (error) { next(error); }
}

View File

@@ -580,7 +580,13 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
const params: any[] = []; const params: any[] = [];
if (q.length >= 2) { if (q.length >= 2) {
params.push(`%${q}%`); params.push(`%${q}%`);
whereSearch = `AND (cc.descripcion ILIKE $1 OR cc.clave_prod_serv ILIKE $1)`; whereSearch = `AND (cc.descripcion ILIKE $${params.length} OR cc.clave_prod_serv ILIKE $${params.length})`;
}
let whereContribuyente = '';
if (contribuyenteId) {
params.push(contribuyenteId);
whereContribuyente = `AND c.contribuyente_id = $${params.length}`;
} }
const { rows } = await pool.query(` const { rows } = await pool.query(`
@@ -605,6 +611,7 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun
WHERE c.status NOT IN ('Cancelado', '0') WHERE c.status NOT IN ('Cancelado', '0')
${whereType} ${whereType}
${whereSearch} ${whereSearch}
${whereContribuyente}
ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC
LIMIT 30 LIMIT 30
`, params); `, params);
@@ -708,7 +715,7 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
const q = (req.query.q as string || '').trim(); const q = (req.query.q as string || '').trim();
if (q.length < 3) return res.json([]); if (q.length < 3) return res.json([]);
const contribuyenteId = (req.query.contribuyenteId as string || '').trim(); const contribuyenteId = (req.query.contribuyenteId as string || '').replace(/[^a-f0-9-]/gi, '');
const pool = req.tenantPool!; const pool = req.tenantPool!;
// RFC del tenant despacho para excluirlo (no se factura a sí mismo) // RFC del tenant despacho para excluirlo (no se factura a sí mismo)
@@ -719,10 +726,17 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
}); });
const tenantRfc = tenant?.rfc || ''; const tenantRfc = tenant?.rfc || '';
// Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo const params: any[] = [tenantRfc, `%${q}%`];
// filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo let whereContribuyente = '';
// contrario no se podría facturar a un cliente nuevo que nunca haya if (contribuyenteId) {
// aparecido en un CFDI previo. params.push(contribuyenteId);
whereContribuyente = `AND id IN (
SELECT rfc_receptor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_receptor_id IS NOT NULL
UNION
SELECT rfc_emisor_id FROM cfdis WHERE contribuyente_id = $${params.length} AND rfc_emisor_id IS NOT NULL
)`;
}
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT id, rfc, razon_social as "razonSocial", SELECT id, rfc, razon_social as "razonSocial",
regimen_fiscal as "regimenFiscal", regimen_fiscal as "regimenFiscal",
@@ -730,9 +744,10 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction
FROM rfcs FROM rfcs
WHERE rfc != $1 WHERE rfc != $1
AND (rfc ILIKE $2 OR razon_social ILIKE $2) AND (rfc ILIKE $2 OR razon_social ILIKE $2)
${whereContribuyente}
ORDER BY razon_social ORDER BY razon_social
LIMIT 10 LIMIT 10
`, [tenantRfc, `%${q}%`]); `, params);
res.json(rows); res.json(rows);
} catch (error) { next(error); } } catch (error) { next(error); }

View File

@@ -3,29 +3,42 @@ import { z } from 'zod';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { import {
EMAIL_TYPES, EMAIL_TYPES,
getEmailPreferencesPorContribuyente, NOTIFICATION_ROLES,
setContribuyenteEmailPreferences, getRoleEmailPreferences,
setRoleEmailPreference,
type EmailType,
type NotificationRole,
} from '../services/notification-preferences.service.js'; } from '../services/notification-preferences.service.js';
export async function listPreferences(req: Request, res: Response, next: NextFunction) { export async function listPreferences(req: Request, res: Response, next: NextFunction) {
try { try {
const data = await getEmailPreferencesPorContribuyente(req.tenantPool!); const preferences = await getRoleEmailPreferences(req.tenantPool!);
res.json({ emailTypes: EMAIL_TYPES, data }); res.json({
emailTypes: EMAIL_TYPES,
roles: NOTIFICATION_ROLES,
preferences,
});
} catch (error) { } catch (error) {
next(error); next(error);
} }
} }
const updateSchema = z.object({ const updateSchema = z.object({
contribuyenteId: z.string().uuid(), emailType: z.enum([...EMAIL_TYPES] as [string, ...string[]]),
preferences: z.record(z.string(), z.boolean()), role: z.enum([...NOTIFICATION_ROLES] as [string, ...string[]]),
enabled: z.boolean(),
}); });
export async function updatePreferences(req: Request, res: Response, next: NextFunction) { export async function updatePreferences(req: Request, res: Response, next: NextFunction) {
try { try {
const { contribuyenteId, preferences } = updateSchema.parse(req.body); const { emailType, role, enabled } = updateSchema.parse(req.body);
const updated = await setContribuyenteEmailPreferences(req.tenantPool!, contribuyenteId, preferences); const preferences = await setRoleEmailPreference(
res.json({ contribuyenteId, preferences: updated }); req.tenantPool!,
emailType as EmailType,
role as NotificationRole,
enabled,
);
res.json({ preferences });
} catch (error) { } catch (error) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message)); if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error); next(error);

View File

@@ -4,15 +4,10 @@ import { AppError } from '../middlewares/error.middleware.js';
import * as papeleriaService from '../services/papeleria.service.js'; import * as papeleriaService from '../services/papeleria.service.js';
import { emailService } from '../services/email/email.service.js'; import { emailService } from '../services/email/email.service.js';
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js'; import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
import { getEntidadesVisibles } from '../utils/entidades-visibles.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { prisma } from '../config/database.js'; import { prisma } from '../config/database.js';
function rejectClienteRole(req: Request): void {
if (req.user?.role === 'cliente') {
throw new AppError(403, 'Papelería no disponible para usuarios cliente');
}
}
function effectiveTenantId(req: Request): string { function effectiveTenantId(req: Request): string {
return req.viewingTenantId || req.user!.tenantId; return req.viewingTenantId || req.user!.tenantId;
} }
@@ -24,6 +19,7 @@ const uploadSchema = z.object({
anio: z.number().int().min(2000).max(2100), anio: z.number().int().min(2000).max(2100),
mes: z.number().int().min(1).max(12), mes: z.number().int().min(1).max(12),
requiereAprobacion: z.boolean(), requiereAprobacion: z.boolean(),
requiereAprobacionCliente: z.boolean(),
archivoBase64: z.string().min(1), archivoBase64: z.string().min(1),
archivoFilename: z.string().min(1).max(255), archivoFilename: z.string().min(1).max(255),
archivoMime: z.string().min(1).max(100), archivoMime: z.string().min(1).max(100),
@@ -31,7 +27,9 @@ const uploadSchema = z.object({
export async function upload(req: Request, res: Response, next: NextFunction) { export async function upload(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req); if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden subir documentos de papelería');
}
const data = uploadSchema.parse(req.body); const data = uploadSchema.parse(req.body);
const archivo = Buffer.from(data.archivoBase64, 'base64'); const archivo = Buffer.from(data.archivoBase64, 'base64');
@@ -42,18 +40,23 @@ export async function upload(req: Request, res: Response, next: NextFunction) {
anio: data.anio, anio: data.anio,
mes: data.mes, mes: data.mes,
requiereAprobacion: data.requiereAprobacion, requiereAprobacion: data.requiereAprobacion,
requiereAprobacionCliente: data.requiereAprobacionCliente,
archivo, archivo,
archivoFilename: data.archivoFilename, archivoFilename: data.archivoFilename,
archivoMime: data.archivoMime, archivoMime: data.archivoMime,
subidoPor: req.user!.userId, subidoPor: req.user!.userId,
}); });
// Notificación a aprobadores si la papelería requiere aprobación.
if (item.requiereAprobacion) { if (item.requiereAprobacion) {
notifyAprobacionRequerida(req, item).catch(err => notifyAprobacionRequerida(req, item).catch(err =>
console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err), console.error('[papeleria.upload] notify aprobadores failed:', err?.message || err),
); );
} }
if (item.requiereAprobacionCliente) {
notifyClienteAprobacionRequerida(req, item).catch(err =>
console.error('[papeleria.upload] notify clientes failed:', err?.message || err),
);
}
res.status(201).json(item); res.status(201).json(item);
} catch (error: any) { } catch (error: any) {
@@ -74,13 +77,20 @@ const listSchema = z.object({
export async function list(req: Request, res: Response, next: NextFunction) { export async function list(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req);
const q = listSchema.parse(req.query); const q = listSchema.parse(req.query);
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
if (!entidadIds.includes(q.contribuyenteId)) {
return res.json([]);
}
const items = await papeleriaService.listPapeleria(req.tenantPool!, { const items = await papeleriaService.listPapeleria(req.tenantPool!, {
contribuyenteId: q.contribuyenteId, contribuyenteId: q.contribuyenteId,
anio: q.anio ? parseInt(q.anio, 10) : undefined, anio: q.anio ? parseInt(q.anio, 10) : undefined,
mes: q.mes ? parseInt(q.mes, 10) : undefined, mes: q.mes ? parseInt(q.mes, 10) : undefined,
estado: q.estado, estado: q.estado,
entidadIds,
userRole: req.user!.role,
}); });
res.json(items); res.json(items);
} catch (error) { } catch (error) {
@@ -91,9 +101,22 @@ export async function list(req: Request, res: Response, next: NextFunction) {
export async function download(req: Request, res: Response, next: NextFunction) { export async function download(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req);
const id = parseInt(String(req.params.id), 10); const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido')); if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const item = await papeleriaService.getById(req.tenantPool!, id);
if (!item) return next(new AppError(404, 'Documento no encontrado'));
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
if (!entidadIds.includes(item.contribuyenteId)) {
return next(new AppError(403, 'No tienes acceso a este documento'));
}
if (req.user!.role === 'cliente' && !item.requiereAprobacionCliente) {
return next(new AppError(403, 'No tienes acceso a este documento'));
}
const file = await papeleriaService.downloadArchivo(req.tenantPool!, id); const file = await papeleriaService.downloadArchivo(req.tenantPool!, id);
if (!file) return next(new AppError(404, 'Documento no encontrado')); if (!file) return next(new AppError(404, 'Documento no encontrado'));
res.setHeader('Content-Type', file.mime); res.setHeader('Content-Type', file.mime);
@@ -106,7 +129,9 @@ export async function download(req: Request, res: Response, next: NextFunction)
export async function aprobar(req: Request, res: Response, next: NextFunction) { export async function aprobar(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req); if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10); const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido')); if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const item = await papeleriaService.aprobar( const item = await papeleriaService.aprobar(
@@ -127,7 +152,9 @@ const rechazarSchema = z.object({ comentario: z.string().max(2000).nullable().op
export async function rechazar(req: Request, res: Response, next: NextFunction) { export async function rechazar(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req); if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10); const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido')); if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const { comentario } = rechazarSchema.parse(req.body); const { comentario } = rechazarSchema.parse(req.body);
@@ -146,9 +173,63 @@ export async function rechazar(req: Request, res: Response, next: NextFunction)
} }
} }
export async function aprobarCliente(req: Request, res: Response, next: NextFunction) {
try {
if (req.user?.role !== 'cliente') {
throw new AppError(403, 'Solo clientes pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
const itemCheck = await papeleriaService.getById(req.tenantPool!, id);
if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) {
return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
}
const item = await papeleriaService.aprobarCliente(req.tenantPool!, id, req.user!.userId);
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
res.json(item);
} catch (error) {
next(error);
}
}
export async function rechazarCliente(req: Request, res: Response, next: NextFunction) {
try {
if (req.user?.role !== 'cliente') {
throw new AppError(403, 'Solo clientes pueden usar este endpoint');
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const { comentario } = rechazarSchema.parse(req.body);
const entidadIds = await getEntidadesVisibles(
req.tenantPool!, req.user!.userId, req.user!.role,
);
const itemCheck = await papeleriaService.getById(req.tenantPool!, id);
if (!itemCheck || !entidadIds.includes(itemCheck.contribuyenteId) || !itemCheck.requiereAprobacionCliente) {
return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
}
const item = await papeleriaService.rechazarCliente(
req.tenantPool!, id, req.user!.userId, comentario ?? null,
);
if (!item) return next(new AppError(404, 'Documento no encontrado o no requiere tu aprobación'));
res.json(item);
} catch (error: any) {
if (error instanceof z.ZodError) return next(new AppError(400, error.errors[0].message));
next(error);
}
}
export async function eliminar(req: Request, res: Response, next: NextFunction) { export async function eliminar(req: Request, res: Response, next: NextFunction) {
try { try {
rejectClienteRole(req); if (req.user?.role === 'cliente') {
throw new AppError(403, 'Los clientes no pueden eliminar documentos');
}
const id = parseInt(String(req.params.id), 10); const id = parseInt(String(req.params.id), 10);
if (isNaN(id)) return next(new AppError(400, 'ID inválido')); if (isNaN(id)) return next(new AppError(400, 'ID inválido'));
const ok = await papeleriaService.eliminar(req.tenantPool!, id); const ok = await papeleriaService.eliminar(req.tenantPool!, id);
@@ -161,22 +242,26 @@ export async function eliminar(req: Request, res: Response, next: NextFunction)
// ─── Notificaciones ─── // ─── Notificaciones ───
async function getContribuyenteInfo(req: Request, contribuyenteId: string) {
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>(
`SELECT c.rfc, eg.nombre FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[contribuyenteId],
);
return rows[0] ?? null;
}
/** /**
* Notifica a owners y supervisores cuando una papelería requiere aprobación. * Notifica a owners y supervisores cuando una papelería requiere aprobación.
* Owners se obtienen de tenant_memberships (BD central). Supervisores se
* resuelven leyendo carteras del tenant.
*/ */
async function notifyAprobacionRequerida( async function notifyAprobacionRequerida(
req: Request, req: Request,
item: papeleriaService.PapeleriaItem, item: papeleriaService.PapeleriaItem,
): Promise<void> { ): Promise<void> {
const tenantId = effectiveTenantId(req); const tenantId = effectiveTenantId(req);
// Owners del despacho
const recipients = new Set<string>(await getTenantOwnerEmails(tenantId)); const recipients = new Set<string>(await getTenantOwnerEmails(tenantId));
// Supervisores: cualquier user con rol 'supervisor' o 'cfo' que pertenezca a este tenant.
// Buscamos vía tenant_memberships + roles.
const supervisores = await prisma.tenantMembership.findMany({ const supervisores = await prisma.tenantMembership.findMany({
where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } }, where: { tenantId, active: true, rol: { nombre: { in: ['supervisor', 'cfo'] } } },
include: { user: { select: { email: true, active: true } } }, include: { user: { select: { email: true, active: true } } },
@@ -185,23 +270,15 @@ async function notifyAprobacionRequerida(
if (m.user.active && m.user.email) recipients.add(m.user.email); if (m.user.active && m.user.email) recipients.add(m.user.email);
} }
// No notificarse a sí mismo
recipients.delete(req.user!.email); recipients.delete(req.user!.email);
if (recipients.size === 0) return; if (recipients.size === 0) return;
const tenant = await prisma.tenant.findUnique({ const tenant = await prisma.tenant.findUnique({
where: { id: tenantId }, where: { id: tenantId },
select: { nombre: true }, select: { nombre: true },
}); });
const info = await getContribuyenteInfo(req, item.contribuyenteId);
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>( if (!info) return;
`SELECT c.rfc, eg.nombre FROM contribuyentes c
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const link = `${env.FRONTEND_URL}/documentos`; const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
@@ -210,8 +287,8 @@ async function notifyAprobacionRequerida(
for (const to of recipients) { for (const to of recipients) {
try { try {
await emailService.sendPapeleriaAprobacionRequerida(to, { await emailService.sendPapeleriaAprobacionRequerida(to, {
contribuyenteRfc: rows[0].rfc, contribuyenteRfc: info.rfc,
contribuyenteNombre: rows[0].nombre, contribuyenteNombre: info.nombre,
despachoNombre: tenant?.nombre, despachoNombre: tenant?.nombre,
nombreDocumento: item.nombre, nombreDocumento: item.nombre,
descripcion: item.descripcion, descripcion: item.descripcion,
@@ -226,9 +303,7 @@ async function notifyAprobacionRequerida(
} }
/** /**
* Notifica al uploader (auxiliar) cuando un documento que él subió fue * Notifica al uploader cuando un documento fue aprobado o rechazado por owner/supervisor.
* aprobado o rechazado. Solo manda si quien aprobó/rechazó NO es el mismo
* uploader (caso edge: owner sube su propia papelería).
*/ */
async function notifyDecisionAuxiliar( async function notifyDecisionAuxiliar(
req: Request, req: Request,
@@ -238,21 +313,16 @@ async function notifyDecisionAuxiliar(
const auxiliarEmail = await getUserEmailById(item.subidoPor); const auxiliarEmail = await getUserEmailById(item.subidoPor);
if (!auxiliarEmail) return; if (!auxiliarEmail) return;
const { rows } = await req.tenantPool!.query<{ rfc: string; nombre: string }>( const info = await getContribuyenteInfo(req, item.contribuyenteId);
`SELECT c.rfc, eg.nombre FROM contribuyentes c if (!info) return;
JOIN entidades_gestionadas eg ON eg.id = c.entidad_id
WHERE c.entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const link = `${env.FRONTEND_URL}/documentos`; const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`; const periodo = `${meses[item.mes - 1]} ${item.anio}`;
await emailService.sendPapeleriaDecision(auxiliarEmail, { await emailService.sendPapeleriaDecision(auxiliarEmail, {
contribuyenteRfc: rows[0].rfc, contribuyenteRfc: info.rfc,
contribuyenteNombre: rows[0].nombre, contribuyenteNombre: info.nombre,
nombreDocumento: item.nombre, nombreDocumento: item.nombre,
estado: item.estado as 'aprobado' | 'rechazado', estado: item.estado as 'aprobado' | 'rechazado',
revisor: req.user!.email, revisor: req.user!.email,
@@ -261,3 +331,57 @@ async function notifyDecisionAuxiliar(
link, link,
}); });
} }
/**
* Notifica a los usuarios cliente asociados al contribuyente cuando un documento
* requiere su aprobación.
*/
async function notifyClienteAprobacionRequerida(
req: Request,
item: papeleriaService.PapeleriaItem,
): Promise<void> {
const tenantId = effectiveTenantId(req);
// Obtener user_ids de clientes con acceso a este contribuyente
const { rows } = await req.tenantPool!.query<{ user_id: string }>(
`SELECT user_id FROM cliente_accesos WHERE entidad_id = $1`,
[item.contribuyenteId],
);
if (rows.length === 0) return;
const userIds = rows.map(r => r.user_id);
const users = await prisma.user.findMany({
where: { id: { in: userIds }, active: true },
select: { email: true },
});
const recipients = users.map(u => u.email).filter(Boolean) as string[];
if (recipients.length === 0) return;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true },
});
const info = await getContribuyenteInfo(req, item.contribuyenteId);
if (!info) return;
const link = `${env.FRONTEND_URL}/documentos`;
const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const periodo = `${meses[item.mes - 1]} ${item.anio}`;
for (const to of recipients) {
try {
await emailService.sendPapeleriaAprobacionClienteRequerida(to, {
contribuyenteRfc: info.rfc,
contribuyenteNombre: info.nombre,
despachoNombre: tenant?.nombre,
nombreDocumento: item.nombre,
descripcion: item.descripcion,
periodo,
subidoPor: req.user!.email,
link,
});
} catch (err: any) {
console.error(`[Email] papeleria-aprobacion-cliente a ${to}:`, err?.message || err);
}
}
}

View File

@@ -184,7 +184,18 @@ export async function subscribeMe(req: Request, res: Response, next: NextFunctio
if (msg.includes('MercadoPago no está configurado')) { if (msg.includes('MercadoPago no está configurado')) {
return res.status(503).json({ message: msg }); return res.status(503).json({ message: msg });
} }
// Otros errores de MP al crear preapproval (monto inválido, email inválido, etc.) // Errores de negocio de MP (monto fuera de límites, payer igual collector, etc.)
if (msg.includes('Cannot pay an amount greater than')) {
return res.status(400).json({
message: 'El monto del plan supera el límite de cobro recurrente de MercadoPago ($10,000 MXN). Usa el pago anual único o contacta a soporte.',
});
}
if (msg.includes('Payer and collector cannot be the same user')) {
return res.status(400).json({
message: 'El correo del pagador no puede ser el mismo que el de la cuenta de MercadoPago del vendedor.',
});
}
// Otros errores de MP al crear preapproval/preference
if (msg.includes('Unauthorized access') || error?.status === 401) { if (msg.includes('Unauthorized access') || error?.status === 401) {
return res.status(503).json({ return res.status(503).json({
message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.', message: 'MercadoPago rechazó la solicitud. Verifica que MP_ACCESS_TOKEN sea válido y esté vigente.',

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import * as usuariosService from '../services/usuarios.service.js'; import * as usuariosService from '../services/usuarios.service.js';
import { AppError } from '../middlewares/error.middleware.js'; import { AppError } from '../middlewares/error.middleware.js';
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js'; import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
import { prisma } from '../config/database.js';
const inviteSchema = z.object({ const inviteSchema = z.object({
email: z.string().email('email inválido'), email: z.string().email('email inválido'),
@@ -64,11 +65,16 @@ export async function getAllUsuarios(req: Request, res: Response, next: NextFunc
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) { export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
try { try {
if (req.user!.role !== 'owner') { if (!['owner', 'cfo', 'supervisor'].includes(req.user!.role)) {
throw new AppError(403, 'Solo los dueños pueden invitar usuarios'); throw new AppError(403, 'No autorizado para invitar usuarios');
} }
const data = inviteSchema.parse(req.body); const data = inviteSchema.parse(req.body);
// Los supervisores solo pueden invitar clientes
if (req.user!.role === 'supervisor' && data.role !== 'cliente') {
throw new AppError(403, 'Los supervisores solo pueden invitar clientes');
}
// Validate: auxiliar requires a supervisor // Validate: auxiliar requires a supervisor
if (data.role === 'auxiliar' && !data.supervisorUserId) { if (data.role === 'auxiliar' && !data.supervisorUserId) {
throw new AppError(400, 'Debes asignar un supervisor al auxiliar'); throw new AppError(400, 'Debes asignar un supervisor al auxiliar');
@@ -139,7 +145,16 @@ export async function getSupervisor(req: Request, res: Response, next: NextFunct
LIMIT 1`, LIMIT 1`,
[userId], [userId],
); );
res.json({ supervisorUserId: rows[0]?.supervisor_user_id ?? null }); const supervisorUserId = rows[0]?.supervisor_user_id ?? null;
let supervisorNombre: string | null = null;
if (supervisorUserId) {
const u = await prisma.user.findUnique({
where: { id: supervisorUserId },
select: { nombre: true },
});
supervisorNombre = u?.nombre ?? null;
}
res.json({ supervisorUserId, supervisorNombre });
} catch (error) { } catch (error) {
next(error); next(error);
} }

View File

@@ -10,6 +10,21 @@ import { despachoPlanTieneDualidadDb } from '../services/plan-catalogo.service.j
import { emailService } from '../services/email/email.service.js'; import { emailService } from '../services/email/email.service.js';
import { getTenantOwnerEmail } from '../utils/memberships.js'; import { getTenantOwnerEmail } from '../utils/memberships.js';
/**
* Calcula la siguiente fecha de fin de período según la frecuencia.
* Usa el mismo algoritmo que Mercado Pago: mismo día del mes siguiente,
* ajustando al último día si el mes destino tiene menos días.
*/
function computeNextPeriodEnd(date: Date, frequency: string): Date {
const d = new Date(date);
if (frequency === 'monthly') {
d.setMonth(d.getMonth() + 1);
} else if (frequency === 'annual' || frequency === 'yearly') {
d.setFullYear(d.getFullYear() + 1);
}
return d;
}
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) { export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
try { try {
const { type, data } = req.body; const { type, data } = req.body;
@@ -159,6 +174,57 @@ async function handlePaymentNotification(paymentId: string) {
return; return;
} }
// Detecta pagos únicos de suscripción anual (planes >$10k). external_reference = `subscription:${tenantId}:${subscriptionId}`
if (payment.externalReference.startsWith('subscription:')) {
const parts = payment.externalReference.split(':');
const tenantId = parts[1];
const subscriptionId = parts[2];
if (!tenantId || !subscriptionId) {
console.warn('[WEBHOOK] external_reference de subscription malformado:', payment.externalReference);
return;
}
const paymentRecord = await subscriptionService.recordPayment({
tenantId,
subscriptionId,
mpPaymentId: paymentId,
amount: payment.transactionAmount || 0,
status: payment.status || 'unknown',
paymentMethod: payment.paymentMethodId || 'unknown',
});
if (payment.status === 'approved') {
const subscription = await prisma.subscription.findUnique({ where: { id: subscriptionId } });
if (subscription) {
const now = new Date();
const periodEnd = computeNextPeriodEnd(now, 'annual');
await prisma.$transaction([
prisma.subscription.update({
where: { id: subscription.id },
data: {
status: 'authorized',
currentPeriodStart: now,
currentPeriodEnd: periodEnd,
},
}),
prisma.tenant.update({
where: { id: tenantId },
data: { plan: subscription.plan },
}),
]);
subscriptionService.invalidateSubscriptionCache(tenantId);
console.log(`[WEBHOOK] Suscripción ${subscriptionId} activada por pago único anual hasta ${periodEnd.toISOString()}`);
}
// Auto-emisión de factura (fail-soft)
await invoicingService.emitInvoiceIfApplicable(paymentRecord.id);
}
if (typeof process.send === 'function') {
process.send({ type: 'invalidate-tenant-cache', tenantId });
}
return;
}
// Flujo normal: pago recurrente del preapproval // Flujo normal: pago recurrente del preapproval
const tenantId = payment.externalReference; const tenantId = payment.externalReference;
const subscription = await prisma.subscription.findFirst({ const subscription = await prisma.subscription.findFirst({
@@ -187,9 +253,20 @@ async function handlePaymentNotification(paymentId: string) {
// precio de renewal. Se detecta comparando el monto cobrado contra lo que // precio de renewal. Se detecta comparando el monto cobrado contra lo que
// `getPlanPrice(phase='firstYear')` devolvería para este plan. // `getPlanPrice(phase='firstYear')` devolvería para este plan.
const esPrimerPago = subscription.status === 'pending'; const esPrimerPago = subscription.status === 'pending';
const updateData: { status: string; currentPeriodEnd?: Date } = { status: 'authorized' };
// Extender currentPeriodEnd para renovaciones recurrentes.
// El primer pago ya tiene currentPeriodEnd establecido al crear la suscripción;
// solo extendemos en pagos subsecuentes para reflejar el nuevo período cobrado.
if (!esPrimerPago && subscription.currentPeriodEnd) {
const nextPeriodEnd = computeNextPeriodEnd(subscription.currentPeriodEnd, subscription.frequency);
updateData.currentPeriodEnd = nextPeriodEnd;
console.log(`[WEBHOOK] Subscription ${subscription.id} extended to ${nextPeriodEnd.toISOString()} (${subscription.frequency})`);
}
await prisma.subscription.update({ await prisma.subscription.update({
where: { id: subscription.id }, where: { id: subscription.id },
data: { status: 'authorized' }, data: updateData,
}); });
subscriptionService.invalidateSubscriptionCache(tenantId); subscriptionService.invalidateSubscriptionCache(tenantId);

View File

@@ -9,8 +9,10 @@ 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, purgeConstanciasAntiguas } from '../services/constancia.service.js';
import { tenantDb } from '../config/database.js'; import { tenantDb } from '../config/database.js';
import type { Pool } from 'pg';
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 RECOVERY_CRON_SCHEDULE = '0 10 * * *'; // 10: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
const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM const OPINION_CRON_SCHEDULE = '0 4 * * 0'; // Sundays 4:00 AM
const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual) const CSF_CRON_SCHEDULE = '0 4 1 * *'; // Día 1 de cada mes 04:00 AM (CSF mensual)
@@ -20,6 +22,38 @@ const EXPIRY_REMINDERS_CRON = '0 9 * * *'; // 9:00 AM diario — avisos p
let isRunning = false; let isRunning = false;
let isIncrementalRunning = false; let isIncrementalRunning = false;
let isRecoveryRunning = false;
/**
* Verifica si un tenant tiene FIEL a nivel tenant (legacy Horux 360)
* o a nivel contribuyente (modelo despacho).
*/
async function hasAnyFielConfigured(tenantId: string, databaseName?: string | null): Promise<boolean> {
// 1) FIEL legacy a nivel tenant
const hasLegacy = await hasFielConfigured(tenantId);
if (hasLegacy) return true;
// 2) FIEL por contribuyente (modelo despacho)
if (!databaseName) {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
databaseName = tenant?.databaseName;
}
if (!databaseName) return false;
try {
const pool = await tenantDb.getPool(tenantId, databaseName);
const { rows } = await pool.query(
`SELECT 1 FROM fiel_contribuyente WHERE is_active = true LIMIT 1`
);
return rows.length > 0;
} catch (err: any) {
console.error(`[SAT Cron] Error verificando FIEL contribuyente para tenant ${tenantId}:`, err.message);
return false;
}
}
/** /**
* Obtiene los tenants que tienen FIEL configurada y activa * Obtiene los tenants que tienen FIEL configurada y activa
@@ -27,13 +61,13 @@ let isIncrementalRunning = false;
async function getTenantsWithFiel(): Promise<string[]> { async function getTenantsWithFiel(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({ const tenants = await prisma.tenant.findMany({
where: { active: true }, where: { active: true },
select: { id: true }, select: { id: true, databaseName: true },
}); });
const tenantsWithFiel: string[] = []; const tenantsWithFiel: string[] = [];
for (const tenant of tenants) { for (const tenant of tenants) {
const hasFiel = await hasFielConfigured(tenant.id); const hasFiel = await hasAnyFielConfigured(tenant.id, tenant.databaseName);
if (hasFiel) { if (hasFiel) {
tenantsWithFiel.push(tenant.id); tenantsWithFiel.push(tenant.id);
} }
@@ -172,12 +206,12 @@ async function getTenantsConSatIncremental(): Promise<string[]> {
const tenants = await prisma.tenant.findMany({ const tenants = await prisma.tenant.findMany({
where: { active: true, plan: { in: planNames as any } }, where: { active: true, plan: { in: planNames as any } },
select: { id: true }, select: { id: true, databaseName: true },
}); });
const result: string[] = []; const result: string[] = [];
for (const tenant of tenants) { for (const tenant of tenants) {
if (await hasFielConfigured(tenant.id)) { if (await hasAnyFielConfigured(tenant.id, tenant.databaseName)) {
result.push(tenant.id); result.push(tenant.id);
} }
} }
@@ -351,12 +385,153 @@ async function runCsfJob(): Promise<void> {
console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message); console.error(`[CSF Cron] Error para ${tenant.rfc}:`, error.message);
failed++; failed++;
} }
// Delay entre tenants para no saturar al SAT y reducir bloqueos por IP
await new Promise(r => setTimeout(r, 30_000));
} }
console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`); console.log(`[CSF Cron] Completado — éxito: ${success}, fallidos: ${failed}, sin FIEL: ${skipped}`);
} }
function getYesterdayEnd(): Date {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59);
}
async function hasIncompleteCfdis(pool: Pool, contribuyenteId: string): Promise<boolean> {
const { rows } = await pool.query<{ count: string }>(`
SELECT COUNT(*)::text as count
FROM cfdis
WHERE contribuyente_id = $1
AND status = 'Vigente'
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
AND xml_original IS NULL
`, [contribuyenteId]);
return Number(rows[0]?.count || 0) > 0;
}
async function getOldestIncompleteCfdiDate(pool: Pool, contribuyenteId: string): Promise<Date | null> {
const { rows } = await pool.query<{ fecha_emision: Date | null }>(`
SELECT MIN(fecha_emision) as fecha_emision
FROM cfdis
WHERE contribuyente_id = $1
AND status = 'Vigente'
AND tipo_comprobante IN ('I', 'E', 'P', 'N')
AND xml_original IS NULL
`, [contribuyenteId]);
return rows[0]?.fecha_emision || null;
}
async function waitForRecoveryJob(jobId: string): Promise<void> {
while (true) {
const job = await prisma.satSyncJob.findUnique({ where: { id: jobId } });
if (!job || job.status === 'completed' || job.status === 'failed') {
return;
}
await new Promise(resolve => setTimeout(resolve, 60000));
}
}
async function recoverContribuyente(tenantId: string, databaseName: string, contribuyenteId: string): Promise<void> {
try {
const status = await getSyncStatus(tenantId, contribuyenteId);
if (status.hasActiveSync) {
console.log(`[SAT Recovery] ${contribuyenteId} tiene sync activo, omitiendo`);
return;
}
const pool = await tenantDb.getPool(tenantId, databaseName);
const hasIncomplete = await hasIncompleteCfdis(pool, contribuyenteId);
const lastDaily = await prisma.satSyncJob.findFirst({
where: { tenantId, contribuyenteId, type: 'daily' },
orderBy: { startedAt: 'desc' },
});
if (!hasIncomplete && lastDaily?.status !== 'failed') {
return;
}
const dateTo = getYesterdayEnd();
let dateFrom = new Date(dateTo.getFullYear() - 1, dateTo.getMonth(), dateTo.getDate());
if (hasIncomplete) {
const oldest = await getOldestIncompleteCfdiDate(pool, contribuyenteId);
if (oldest) {
dateFrom = new Date(oldest.getFullYear(), oldest.getMonth(), 1);
dateFrom.setMonth(dateFrom.getMonth() - 1);
}
}
console.log(`[SAT Recovery] Recuperando ${contribuyenteId}: ${dateFrom.toISOString()}${dateTo.toISOString()}`);
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo, contribuyenteId);
console.log(`[SAT Recovery] Job ${jobId} iniciado`);
await waitForRecoveryJob(jobId);
} catch (error: any) {
console.error(`[SAT Recovery] Error recuperando ${contribuyenteId}:`, error.message);
}
}
async function recoverTenant(tenantId: string): Promise<void> {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { databaseName: true },
});
if (!tenant?.databaseName) return;
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
const { rows } = await pool.query<{ entidad_id: string }>('SELECT entidad_id FROM contribuyentes');
const contribuyenteIds = rows.map(r => r.entidad_id);
if (contribuyenteIds.length === 0) {
const status = await getSyncStatus(tenantId);
if (status.hasActiveSync) return;
const lastDaily = await prisma.satSyncJob.findFirst({
where: { tenantId, contribuyenteId: null, type: 'daily' },
orderBy: { startedAt: 'desc' },
});
if (lastDaily?.status === 'failed') {
const dateTo = getYesterdayEnd();
const dateFrom = new Date(dateTo.getFullYear() - 1, dateTo.getMonth(), dateTo.getDate());
console.log(`[SAT Recovery] Recuperando tenant legacy ${tenantId}`);
const jobId = await startSync(tenantId, 'initial', dateFrom, dateTo);
await waitForRecoveryJob(jobId);
}
return;
}
for (const contribuyenteId of contribuyenteIds) {
await recoverContribuyente(tenantId, tenant.databaseName, contribuyenteId);
}
}
export async function runRecoverySyncJob(): Promise<void> {
if (isRecoveryRunning) {
console.log('[SAT Recovery] Ya en ejecución, omitiendo');
return;
}
isRecoveryRunning = true;
console.log('[SAT Recovery] Iniciando job de recuperación');
try {
const tenantIds = await getTenantsWithFiel();
console.log(`[SAT Recovery] ${tenantIds.length} tenants con FIEL`);
for (const tenantId of tenantIds) {
await recoverTenant(tenantId);
}
console.log('[SAT Recovery] Job de recuperación completado');
} catch (error: any) {
console.error('[SAT Recovery] Error:', error.message);
} finally {
isRecoveryRunning = false;
}
}
let scheduledTask: ReturnType<typeof cron.schedule> | null = null; let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
let retryTask: ReturnType<typeof cron.schedule> | null = null; let retryTask: ReturnType<typeof cron.schedule> | null = null;
let recoveryTask: ReturnType<typeof cron.schedule> | null = null;
let opinionTask: ReturnType<typeof cron.schedule> | null = null; let opinionTask: ReturnType<typeof cron.schedule> | null = null;
let csfTask: ReturnType<typeof cron.schedule> | null = null; let csfTask: ReturnType<typeof cron.schedule> | null = null;
let incrementalTask: ReturnType<typeof cron.schedule> | null = null; let incrementalTask: ReturnType<typeof cron.schedule> | null = null;
@@ -397,6 +572,19 @@ export function startSatSyncJob(): void {
timezone: 'America/Mexico_City', timezone: 'America/Mexico_City',
}); });
// Cron de recuperación: 10:00 AM diario. Revisa si el sync diario falló o si
// hay CFDIs vigentes sin XML, y relanza un sync `initial` con rango extendido
// para completar los XML faltantes.
recoveryTask = cron.schedule(RECOVERY_CRON_SCHEDULE, async () => {
try {
await runRecoverySyncJob();
} catch (error: any) {
console.error('[SAT Recovery Cron] Error:', error.message);
}
}, {
timezone: 'America/Mexico_City',
});
// Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale // Cron watchdog: cada 2h marca como `failed` los jobs que quedaron stale
// (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás). // (pending con nextRetryAt > 12h atrás, running con startedAt > 4h atrás).
// Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS) // Thresholds sobreescribibles vía env (STALE_PENDING_HOURS / STALE_RUNNING_HOURS)
@@ -502,6 +690,7 @@ export function startSatSyncJob(): void {
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`); console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron] Retry programado cada hora`); console.log(`[SAT Cron] Retry programado cada hora`);
console.log(`[SAT Recovery Cron] Programado para: ${RECOVERY_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`); console.log(`[Opinion Cron] Programado para: ${OPINION_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`); console.log(`[CSF Cron] Programado para: ${CSF_CRON_SCHEDULE} (America/Mexico_City)`);
console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`); console.log(`[SAT Cron Inc] Incremental Enterprise programado para: ${INCREMENTAL_CRON_SCHEDULE} (America/Mexico_City)`);
@@ -521,6 +710,10 @@ export function stopSatSyncJob(): void {
retryTask.stop(); retryTask.stop();
retryTask = null; retryTask = null;
} }
if (recoveryTask) {
recoveryTask.stop();
recoveryTask = null;
}
if (opinionTask) { if (opinionTask) {
opinionTask.stop(); opinionTask.stop();
opinionTask = null; opinionTask = null;

View File

@@ -13,6 +13,7 @@ import { tenantDb } from '../config/database.js';
import { getKpis } from '../services/dashboard.service.js'; import { getKpis } from '../services/dashboard.service.js';
import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js'; import { generarAlertasAutomaticas, getDiscrepanciasPorMes } from '../services/alertas-auto.service.js';
import { emailService } from '../services/email/email.service.js'; import { emailService } from '../services/email/email.service.js';
import { filterRecipientsByRole } from '../services/notification-preferences.service.js';
const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM const SCHEDULE = '0 8 * * 1'; // Lunes 8:00 AM
@@ -45,19 +46,27 @@ export async function sendWeeklyUpdateForTenant(tenantId: string): Promise<{ sen
return { sent: 0 }; return { sent: 0 };
} }
// Recipientes: owners activos del tenant // Pool del tenant para queries de preferencias y CFDI
const pool = await tenantDb.getPool(tenantId, tenant.databaseName);
// Recipientes: owners activos del tenant (filtrados por preferencias de rol)
const owners = await prisma.tenantMembership.findMany({ const owners = await prisma.tenantMembership.findMany({
where: { tenantId, isOwner: true, active: true }, where: { tenantId, isOwner: true, active: true },
include: { user: { select: { email: true, nombre: true, active: true } } }, include: { user: { select: { email: true, nombre: true, active: true } } },
}); });
const recipients = owners.filter(o => o.user.active); const activeOwners = owners.filter(o => o.user.active);
if (recipients.length === 0) { if (activeOwners.length === 0) {
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`); console.log(`[Weekly] Tenant ${tenant.rfc} sin owners activos, skip`);
return { sent: 0 }; return { sent: 0 };
} }
// Pool del tenant para queries de CFDI const recipientsWithRole = activeOwners.map(o => ({ email: o.user.email, role: 'owner' as const }));
const pool = await tenantDb.getPool(tenantId, tenant.databaseName); const allowedEmails = new Set(await filterRecipientsByRole(pool, 'weekly_update', recipientsWithRole));
const recipients = activeOwners.filter(o => allowedEmails.has(o.user.email));
if (recipients.length === 0) {
console.log(`[Weekly] Tenant ${tenant.rfc} sin owners con weekly_update habilitado, skip`);
return { sent: 0 };
}
const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange(); const { fechaInicio, fechaFin, periodoLabel } = currentMonthRange();

View File

@@ -0,0 +1,21 @@
-- Papelería de trabajo: aprobación independiente por cliente
ALTER TABLE papeleria_trabajo
ADD COLUMN IF NOT EXISTS requiere_aprobacion_cliente boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS estado_cliente varchar(20)
CHECK (estado_cliente IS NULL OR estado_cliente IN ('pendiente','aprobado','rechazado')),
ADD COLUMN IF NOT EXISTS aprobado_por_cliente uuid,
ADD COLUMN IF NOT EXISTS aprobado_at_cliente timestamptz,
ADD COLUMN IF NOT EXISTS comentario_rechazo_cliente text;
CREATE INDEX IF NOT EXISTS ix_papeleria_estado_cliente
ON papeleria_trabajo(estado_cliente)
WHERE estado_cliente IS NOT NULL;
CREATE INDEX IF NOT EXISTS ix_papeleria_requiere_cliente
ON papeleria_trabajo(contribuyente_id, requiere_aprobacion_cliente)
WHERE requiere_aprobacion_cliente = true;
INSERT INTO tenant_migrations (scope, version, name)
VALUES ('vertical-contable', 50, '050_papeleria_aprobacion_cliente')
ON CONFLICT (scope, version) DO NOTHING;

View File

@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS notification_role_preferences (
id SERIAL PRIMARY KEY,
email_type VARCHAR(50) NOT NULL,
role VARCHAR(20) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE (email_type, role)
);
INSERT INTO notification_role_preferences (email_type, role, enabled)
VALUES
('documento_subido','owner',true),
('documento_subido','supervisor',true),
('documento_subido','auxiliar',true),
('documento_subido','cliente',true),
('weekly_update','owner',true),
('weekly_update','supervisor',true),
('weekly_update','auxiliar',true),
('weekly_update','cliente',true),
('subscription_expiring','owner',true),
('subscription_expiring','supervisor',true),
('subscription_expiring','auxiliar',true),
('subscription_expiring','cliente',true),
('recordatorio_fiscal','owner',true),
('recordatorio_fiscal','supervisor',true),
('recordatorio_fiscal','auxiliar',true),
('recordatorio_fiscal','cliente',true),
('alertas_nuevas','owner',true),
('alertas_nuevas','supervisor',true),
('alertas_nuevas','auxiliar',true),
('alertas_nuevas','cliente',true),
('recordatorio_proximo','owner',true),
('recordatorio_proximo','supervisor',true),
('recordatorio_proximo','auxiliar',true),
('recordatorio_proximo','cliente',true)
ON CONFLICT (email_type, role) DO NOTHING;

View File

@@ -0,0 +1,7 @@
-- Extender periodicidad para soportar declaraciones cuatrimestrales (ej. SISUB)
ALTER TABLE declaraciones_provisionales
DROP CONSTRAINT IF EXISTS declaraciones_provisionales_periodicidad_check;
ALTER TABLE declaraciones_provisionales
ADD CONSTRAINT declaraciones_provisionales_periodicidad_check
CHECK (periodicidad IN ('mensual', 'bimestral', 'trimestral', 'cuatrimestral', 'semestral', 'anual'));

View File

@@ -0,0 +1,25 @@
-- Evidencias de cumplimiento para obligaciones fiscales.
-- Permite subir cualquier documento (declaración, pago, acuse, complemento)
-- vinculado a una obligación y periodo específicos.
CREATE TABLE IF NOT EXISTS obligacion_evidencias (
id serial PRIMARY KEY,
obligacion_id uuid NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
periodo varchar(7) NOT NULL, -- "2026-04"
contribuyente_id uuid NOT NULL REFERENCES contribuyentes(entidad_id) ON DELETE CASCADE,
tipo_documento varchar(30) NOT NULL CHECK (tipo_documento IN (
'declaracion', 'pago', 'acuse', 'complemento'
)),
archivo bytea NOT NULL,
archivo_filename varchar(255) NOT NULL,
archivo_mime varchar(100) DEFAULT 'application/pdf',
notas text,
subido_por uuid, -- UUID del usuario en horux360 (sin FK local)
subido_por_email varchar(255),
created_at timestamptz DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_obligacion_evidencias_obligacion_periodo
ON obligacion_evidencias (obligacion_id, periodo);
CREATE INDEX IF NOT EXISTS idx_obligacion_evidencias_contribuyente
ON obligacion_evidencias (contribuyente_id);

View File

@@ -0,0 +1,16 @@
-- Estados de declaración y pago por separado para obligaciones que requieren ambos.
ALTER TABLE obligacion_periodos
ADD COLUMN IF NOT EXISTS declaracion_presentada boolean DEFAULT false,
ADD COLUMN IF NOT EXISTS pago_presentado boolean DEFAULT false;
-- Backfill: periodos ya completados se consideran con declaración y pago presentados.
UPDATE obligacion_periodos
SET declaracion_presentada = true,
pago_presentado = true
WHERE completada = true
AND (declaracion_presentada IS NULL OR pago_presentado IS NULL);
-- Asegurar que declaracion_presentada y pago_presentado no sean NULL.
ALTER TABLE obligacion_periodos
ALTER COLUMN declaracion_presentada SET NOT NULL,
ALTER COLUMN pago_presentado SET NOT NULL;

View File

@@ -0,0 +1,12 @@
-- Relación entre declaraciones provisionales y obligaciones fiscales.
-- Permite saber exactamente qué obligaciones cierra una declaración
-- y aplicar el comprobante de pago a las mismas obligaciones.
CREATE TABLE IF NOT EXISTS declaracion_obligaciones (
declaracion_id INT NOT NULL REFERENCES declaraciones_provisionales(id) ON DELETE CASCADE,
obligacion_id UUID NOT NULL REFERENCES obligaciones_contribuyente(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (declaracion_id, obligacion_id)
);
CREATE INDEX IF NOT EXISTS idx_declaracion_obligaciones_obligacion
ON declaracion_obligaciones (obligacion_id);

View File

@@ -35,4 +35,10 @@ router.post('/extras', documentosController.crearExtra);
router.get('/extras/:id/pdf', documentosController.descargarExtraPdf); router.get('/extras/:id/pdf', documentosController.descargarExtraPdf);
router.delete('/extras/:id', documentosController.eliminarExtra); router.delete('/extras/:id', documentosController.eliminarExtra);
// Evidencias de obligaciones fiscales
router.get('/obligacion-evidencias', documentosController.listarEvidenciasObligacion);
router.post('/obligacion-evidencias', documentosController.crearEvidenciaObligacion);
router.get('/obligacion-evidencias/:id/pdf', documentosController.descargarEvidenciaObligacion);
router.delete('/obligacion-evidencias/:id', documentosController.eliminarEvidenciaObligacion);
export { router as documentosRoutes }; export { router as documentosRoutes };

View File

@@ -13,6 +13,8 @@ router.post('/', ctrl.upload);
router.get('/:id/download', ctrl.download); router.get('/:id/download', ctrl.download);
router.post('/:id/aprobar', ctrl.aprobar); router.post('/:id/aprobar', ctrl.aprobar);
router.post('/:id/rechazar', ctrl.rechazar); router.post('/:id/rechazar', ctrl.rechazar);
router.post('/:id/aprobar-cliente', ctrl.aprobarCliente);
router.post('/:id/rechazar-cliente', ctrl.rechazarCliente);
router.delete('/:id', ctrl.eliminar); router.delete('/:id', ctrl.eliminar);
export { router as papeleriaRoutes }; export { router as papeleriaRoutes };

View File

@@ -66,11 +66,13 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
paymentsCount: payments._count, paymentsCount: payments._count,
}; };
// 3) Clientes que NO renovaron: subs cuyo currentPeriodEnd cae en el rango // 3) Clientes que NO renovaron:
// y que están en status terminal (cancelled, trial_expired, paused) o sin // a) Subs cuyo currentPeriodEnd cae en el rango y están en status terminal
// payment posterior aprobado. Nota: un sub `authorized` con periodEnd // (cancelled, trial_expired, paused).
// pasado es un "se renovó automáticamente" — para detectar no-renovaciones // b) Tenants cuyo trialEndsAt ya pasó y NO tienen suscripción authorized
// miramos status efectivo + ausencia de payment en los siguientes 7 días. // (incluye trials que nunca convirtieron o cuya sub fue borrada).
// c) Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca
// fue marcada trial_expired por el cron.
const subsExpiradas = await prisma.subscription.findMany({ const subsExpiradas = await prisma.subscription.findMany({
where: { where: {
currentPeriodEnd: { gte: range.from, lte: range.to }, currentPeriodEnd: { gte: range.from, lte: range.to },
@@ -84,14 +86,99 @@ export async function getClientesStats(range: ClientesStatsRange): Promise<Clien
tenant: { select: { id: true, nombre: true, rfc: true } }, tenant: { select: { id: true, nombre: true, rfc: true } },
}, },
}); });
const noRenovaciones = subsExpiradas.map(s => ({
const noRenovacionesMap = new Map<string, ClientesStats['noRenovaciones'][number]>();
for (const s of subsExpiradas) {
noRenovacionesMap.set(s.tenantId, {
tenantId: s.tenantId, tenantId: s.tenantId,
tenantNombre: s.tenant?.nombre ?? '', tenantNombre: s.tenant?.nombre ?? '',
rfc: s.tenant?.rfc ?? '', rfc: s.tenant?.rfc ?? '',
plan: String(s.plan), plan: String(s.plan),
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '', currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
statusActual: s.status, statusActual: s.status,
})); });
}
// b + c) Trials vencidos / sin suscripción activa / subs borradas
const now = new Date();
const tenantsConSubAutorizada = new Set(
(await prisma.subscription.findMany({
where: { status: 'authorized' },
select: { tenantId: true },
})).map(s => s.tenantId)
);
const excluded = Array.from(tenantsConSubAutorizada);
// Tenants con trialEndsAt pasado y sin sub authorized
const tenantsTrialsVencidos = await prisma.tenant.findMany({
where: {
trialEndsAt: { lt: now },
id: { notIn: excluded },
},
select: { id: true, nombre: true, rfc: true, plan: true, trialEndsAt: true },
});
for (const t of tenantsTrialsVencidos) {
if (noRenovacionesMap.has(t.id)) continue;
noRenovacionesMap.set(t.id, {
tenantId: t.id,
tenantNombre: t.nombre,
rfc: t.rfc ?? '',
plan: String(t.plan ?? 'trial'),
currentPeriodEnd: t.trialEndsAt?.toISOString() ?? '',
statusActual: 'trial_expired',
});
}
// Tenants con sub trial vencida (currentPeriodEnd < ahora) que nunca fue
// marcada trial_expired por el cron, y no tienen otra sub authorized.
const subsTrialVencidas = await prisma.subscription.findMany({
where: {
status: 'trial',
currentPeriodEnd: { lt: now },
tenantId: { notIn: excluded },
},
select: {
tenantId: true,
plan: true,
currentPeriodEnd: true,
tenant: { select: { id: true, nombre: true, rfc: true } },
},
});
for (const s of subsTrialVencidas) {
if (noRenovacionesMap.has(s.tenantId)) continue;
noRenovacionesMap.set(s.tenantId, {
tenantId: s.tenantId,
tenantNombre: s.tenant?.nombre ?? '',
rfc: s.tenant?.rfc ?? '',
plan: String(s.plan),
currentPeriodEnd: s.currentPeriodEnd?.toISOString() ?? '',
statusActual: 'trial_expired',
});
}
// Tenants con plan de pago asignado manualmente (plan != 'trial') pero
// sin NINGUNA suscripción. Indica que nunca iniciaron el flujo de pago.
const tenantsConPlanPeroSinSub = await prisma.tenant.findMany({
where: {
plan: { not: 'trial' },
id: { notIn: excluded },
subscriptions: { none: {} },
},
select: { id: true, nombre: true, rfc: true, plan: true, createdAt: true },
});
for (const t of tenantsConPlanPeroSinSub) {
if (noRenovacionesMap.has(t.id)) continue;
noRenovacionesMap.set(t.id, {
tenantId: t.id,
tenantNombre: t.nombre,
rfc: t.rfc ?? '',
plan: String(t.plan),
currentPeriodEnd: t.createdAt.toISOString(),
statusActual: 'sin_suscripcion',
});
}
const noRenovaciones = Array.from(noRenovacionesMap.values());
// 4) Usuarios por cliente (memberships activos por tenant) // 4) Usuarios por cliente (memberships activos por tenant)
const memberships = await prisma.tenantMembership.findMany({ const memberships = await prisma.tenantMembership.findMany({

View File

@@ -609,30 +609,46 @@ async function alertaOpinionCumplimiento(pool: Pool, contribuyenteId?: string |
/** /**
* Genera todas las alertas automáticas para un tenant. * Genera todas las alertas automáticas para un tenant.
* Cada alerta se envuelve en try/catch para que un fallo en una no
* bloquee el resto (robustez ante timeouts o errores transitorios).
*/ */
export async function generarAlertasAutomaticas( export async function generarAlertasAutomaticas(
pool: Pool, pool: Pool,
tenantId: string, tenantId: string,
contribuyenteId?: string | null, contribuyenteId?: string | null,
): Promise<AlertaAuto[]> { ): Promise<AlertaAuto[]> {
const alertas = await Promise.all([ const generadores: { name: string; fn: () => Promise<AlertaAuto | null> }[] = [
alertaListaNegraPropia(pool, tenantId, contribuyenteId), { name: 'lista-negra-propia', fn: () => alertaListaNegraPropia(pool, tenantId, contribuyenteId) },
alertaClienteListaNegra(pool, contribuyenteId), { name: 'lista-negra-clientes', fn: () => alertaClienteListaNegra(pool, contribuyenteId) },
alertaProveedorListaNegra(pool, contribuyenteId), { name: 'lista-negra-proveedores', fn: () => alertaProveedorListaNegra(pool, contribuyenteId) },
alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId), { name: 'discrepancia-regimen', fn: () => alertaDiscrepanciaRegimen(pool, tenantId, contribuyenteId) },
alertaConcentracionClientes(pool, contribuyenteId), { name: 'concentracion-clientes', fn: () => alertaConcentracionClientes(pool, contribuyenteId) },
alertaConcentracionProveedores(pool, contribuyenteId), { name: 'concentracion-proveedores', fn: () => alertaConcentracionProveedores(pool, contribuyenteId) },
alertaRiesgoCambiario(pool, contribuyenteId), { name: 'riesgo-cambiario', fn: () => alertaRiesgoCambiario(pool, contribuyenteId) },
alertaRiesgoCancelaciones(pool, contribuyenteId), { name: 'riesgo-cancelaciones', fn: () => alertaRiesgoCancelaciones(pool, contribuyenteId) },
alertaRiesgoTransaccional(pool, contribuyenteId), { name: 'riesgo-transaccional', fn: () => alertaRiesgoTransaccional(pool, contribuyenteId) },
alertaCancelacionPeriodoAnterior(pool, contribuyenteId), { name: 'cancelacion-periodo-anterior', fn: () => alertaCancelacionPeriodoAnterior(pool, contribuyenteId) },
alertaOpinionCumplimiento(pool, contribuyenteId), { name: 'opinion-cumplimiento', fn: () => alertaOpinionCumplimiento(pool, contribuyenteId) },
alertaTipoRelacionSospechosa(pool, contribuyenteId), { name: 'tipo-relacion-sospechosa', fn: () => alertaTipoRelacionSospechosa(pool, contribuyenteId) },
alertaTareasProximasVencer(pool, contribuyenteId), { name: 'tareas-proximas-vencer', fn: () => alertaTareasProximasVencer(pool, contribuyenteId) },
alertaResicoPfLimiteIngresos(pool, contribuyenteId), { name: 'resico-pf-limite-ingresos', fn: () => alertaResicoPfLimiteIngresos(pool, contribuyenteId) },
]); ];
return alertas.filter((a): a is AlertaAuto => a !== null); const alertas: AlertaAuto[] = [];
for (const g of generadores) {
try {
const a = await g.fn();
if (a) alertas.push(a);
} catch (err: any) {
console.error(`[AlertasAuto] Fallo ${g.name} (tenant=${tenantId}, contribuyente=${contribuyenteId}):`, err.message || err);
}
}
if (alertas.length > 0) {
console.log(`[AlertasAuto] tenant=${tenantId} contribuyente=${contribuyenteId || 'null'} generadas=${alertas.map(a => a.id).join(', ')}`);
}
return alertas;
} }
/** /**

View File

@@ -119,6 +119,7 @@ function appliesToPeriod(frecuencia: string | null, periodo: string): boolean {
case 'mensual': return true; case 'mensual': return true;
case 'bimestral': return month % 2 === 1; case 'bimestral': return month % 2 === 1;
case 'trimestral': return [1, 4, 7, 10].includes(month); case 'trimestral': return [1, 4, 7, 10].includes(month);
case 'cuatrimestral': return [1, 5, 9].includes(month);
case 'anual': return month === 3 || month === 4; case 'anual': return month === 3 || month === 4;
case 'eventual': return false; case 'eventual': return false;
default: return true; default: return true;

View File

@@ -214,6 +214,7 @@ export async function generarEventosDesdeObligaciones(
if (freq === 'mensual') monthsToGenerate.push(m); if (freq === 'mensual') monthsToGenerate.push(m);
else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m); else if (freq === 'bimestral' && m % 2 === 1) monthsToGenerate.push(m);
else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m); else if (freq === 'trimestral' && [1, 4, 7, 10].includes(m)) monthsToGenerate.push(m);
else if (freq === 'cuatrimestral' && [1, 5, 9].includes(m)) monthsToGenerate.push(m);
else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m); else if (freq === 'anual' && (m === 3 || m === 4)) monthsToGenerate.push(m);
// 'eventual' and unknown: skip auto-generation // 'eventual' and unknown: skip auto-generation
} }

View File

@@ -44,6 +44,9 @@ function rowToConstancia(r: any): ConstanciaRow {
* sincroniza automáticamente domicilio + regímenes activos con lo que reporta * sincroniza automáticamente domicilio + regímenes activos con lo que reporta
* el SAT. El auto-fill NO es destructivo para datos custom del usuario: * el SAT. El auto-fill NO es destructivo para datos custom del usuario:
* solo sobreescribe campos si la CSF tiene un valor no-vacío. * solo sobreescribe campos si la CSF tiene un valor no-vacío.
*
* Incluye retry con backoff (3 intentos) para robustez ante timeouts
* transitorios del portal SAT (mantenimiento nocturno, congestión, etc.).
*/ */
export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> { export async function consultarConstancia(tenantId: string): Promise<ConstanciaRow> {
const fiel = await getDecryptedFiel(tenantId); const fiel = await getDecryptedFiel(tenantId);
@@ -55,6 +58,10 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
}); });
if (!tenant) throw new Error('Tenant no encontrado'); if (!tenant) throw new Error('Tenant no encontrado');
const MAX_RETRIES = 3;
const RETRY_DELAYS = [5_000, 15_000, 30_000]; // backoff
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
const tempId = randomUUID(); const tempId = randomUUID();
const tempDir = join(tmpdir(), `horux-csf-${tempId}`); const tempDir = join(tmpdir(), `horux-csf-${tempId}`);
mkdirSync(tempDir, { recursive: true, mode: 0o700 }); mkdirSync(tempDir, { recursive: true, mode: 0o700 });
@@ -65,9 +72,6 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 }); writeFileSync(cerPath, Buffer.from(fiel.cerContent, 'binary'), { mode: 0o600 });
writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 }); writeFileSync(keyPath, Buffer.from(fiel.keyContent, 'binary'), { mode: 0o600 });
// Headless por default. El fix de dispatchEvent en sat-csf-login cubre el
// caso donde el click sintético no dispara el handler del SAT. Si algún
// ambiente necesita ver el browser (debug), setear SAT_HEADLESS=false.
const headless = process.env.SAT_HEADLESS !== 'false'; const headless = process.env.SAT_HEADLESS !== 'false';
const browser = await chromium.launch({ const browser = await chromium.launch({
headless, headless,
@@ -76,7 +80,7 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
}); });
try { try {
const timeoutPromise = new Promise<never>((_, reject) => const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT), setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 5 minutos')), 300_000),
); );
const resultPromise = (async () => { const resultPromise = (async () => {
@@ -102,9 +106,6 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
], ],
); );
// Auto-fill domicilio del tenant + regímenes activos desde el CSF.
// Se hace después del INSERT para que si algo falla en la sincronización
// la CSF ya quedó guardada y el usuario puede verla.
await sincronizarDatosFiscales(tenantId, csf).catch(err => { await sincronizarDatosFiscales(tenantId, csf).catch(err => {
console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err); console.error(`[CSF] Error sincronizando datos fiscales para tenant ${tenantId}:`, err);
}); });
@@ -116,11 +117,19 @@ export async function consultarConstancia(tenantId: string): Promise<ConstanciaR
} finally { } finally {
await browser.close(); await browser.close();
} }
} catch (err: any) {
const willRetry = attempt < MAX_RETRIES - 1;
console.error(`[CSF] Intento ${attempt + 1}/${MAX_RETRIES} falló para tenant ${tenantId}: ${err.message}${willRetry ? ` — reintentando en ${RETRY_DELAYS[attempt]}ms...` : ''}`);
if (!willRetry) throw err;
await new Promise(r => setTimeout(r, RETRY_DELAYS[attempt]));
} finally { } finally {
try { unlinkSync(cerPath); } catch { /* ok */ } try { unlinkSync(cerPath); } catch { /* ok */ }
try { unlinkSync(keyPath); } catch { /* ok */ } try { unlinkSync(keyPath); } catch { /* ok */ }
try { rmdirSync(tempDir); } catch { /* ok */ } try { rmdirSync(tempDir); } catch { /* ok */ }
} }
}
throw new Error('No debería llegar aquí');
} }
/** /**

View File

@@ -1107,10 +1107,21 @@ export async function getKpis(
const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId); const ctx = await resolveContribuyenteContext(pool, tenantId, contribuyenteId);
const esEmisor = ctx.esEmisor; const esEmisor = ctx.esEmisor;
const esReceptor = ctx.esReceptor; const esReceptor = ctx.esReceptor;
const ingresosData = await calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); const [
const egresosData = await calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); ingresosData,
const adquisicionData = await calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId); egresosData,
const ivaData = await calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId); adquisicionData,
ivaData,
ncsEmitidasData,
ncsRecibidasData,
] = await Promise.all([
calcularIngresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
calcularEgresosPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
calcularAdquisicionesMercancias(pool, tenantId, fechaInicio, fechaFin, conciliacion, contribuyenteId),
calcularIvaBalancePorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
calcularNcsEmitidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
calcularNcsRecibidasPorRegimen(pool, tenantId, fechaInicio, fechaFin, undefined, undefined, conciliacion, contribuyenteId),
]);
// IVA a favor año actual: desde enero del año en curso // IVA a favor año actual: desde enero del año en curso
const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId); const ivaAFavorAcumulado = await calcularIvaAFavorAcumulado(pool, tenantId, fechaFin, undefined, conciliacion, contribuyenteId);
@@ -1163,6 +1174,10 @@ export async function getKpis(
cfdisEmitidosPorRegimen: emitidosPorRegimen, cfdisEmitidosPorRegimen: emitidosPorRegimen,
cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0), cfdisRecibidos: recibidosPorRegimen.reduce((s: number, r: any) => s + r.total, 0),
cfdisRecibidosPorRegimen: recibidosPorRegimen, cfdisRecibidosPorRegimen: recibidosPorRegimen,
ncsEmitidas: ncsEmitidasData.total,
ncsEmitidasPorRegimen: ncsEmitidasData.porRegimen,
ncsRecibidas: ncsRecibidasData.total,
ncsRecibidasPorRegimen: ncsRecibidasData.porRegimen,
}; };
} }

View File

@@ -1,4 +1,38 @@
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { createEvidencia } from './obligacion-evidencias.service.js';
function normalize(s: string): string {
return s
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[.,;:()]/g, '')
.trim();
}
/**
* Dadas las obligaciones seleccionadas para una declaración, infiere los
* impuestos que cubre. Se usa para mantener la resolución de alertas legacy
* (decl-*, pago-*) sin exponer el campo en la UI.
*/
function inferirImpuestosDeObligaciones(
obligaciones: Array<{ id: string; nombre: string; catalogoId?: string | null }>,
): Impuesto[] {
const set = new Set<Impuesto>();
for (const ob of obligaciones) {
const nombre = normalize(ob.nombre);
const catalogoId = normalize(ob.catalogoId || '');
if (nombre.includes('diot') || catalogoId.includes('diot')) {
set.add('DIOT');
} else if (nombre.includes('iva') || catalogoId.includes('iva')) {
set.add('IVA');
}
if (nombre.includes('isr') || catalogoId.includes('isr')) set.add('ISR');
if (nombre.includes('ieps') || catalogoId.includes('ieps')) set.add('IEPS');
if (nombre.includes('isn') || catalogoId.includes('isn')) set.add('ISN');
if (nombre.includes('ish') || catalogoId.includes('ish')) set.add('ISH');
}
return Array.from(set);
}
// Mapeo: impuesto de la declaración → reglas para matchear obligaciones del // Mapeo: impuesto de la declaración → reglas para matchear obligaciones del
// contribuyente. `include` son substrings que DEBE contener el nombre de la // contribuyente. `include` son substrings que DEBE contener el nombre de la
@@ -25,17 +59,28 @@ const IMPUESTO_A_OBLIGACION_KEYWORDS: Record<string, { include: string[]; exclud
* periodo sigue marcado completado — el usuario decidirá si re-abrirlo * periodo sigue marcado completado — el usuario decidirá si re-abrirlo
* manualmente. * manualmente.
*/ */
async function completarObligacionesPorDeclaracion( /**
* Al subir una declaración o comprobante de pago, registra una evidencia para
* cada obligación del contribuyente que corresponda al impuesto declarado.
*
* - Obligaciones informativas (`requierePago = false`) se marcan completadas al
* recibir cualquier documento de declaración/acuse.
* - Obligaciones de pago (`requierePago = true`) se marcan completadas solo al
* recibir un comprobante de pago (`tipo_documento = 'pago'`).
*/
async function registrarEvidenciasPorDeclaracion(
pool: Pool, pool: Pool,
contribuyenteId: string, contribuyenteId: string,
impuestos: string[], impuestos: string[],
periodo: string, periodo: string,
/** UUID del usuario que subió la declaración (obligacion_periodos.completada_por es uuid). */ /** UUID del usuario que subió el documento. */
completadaPor: string, subidoPor: string,
declaracionId: number, pdfBase64: string,
/** Periodicidad de la declaración. Si no se provee, se asume 'mensual'. */ pdfFilename: string,
tipoDocumento: 'declaracion' | 'pago',
/** Periodicidad de la declaración. Si no se provee, asume 'mensual'. */
periodicidad: string = 'mensual', periodicidad: string = 'mensual',
): Promise<number> { ): Promise<{ count: number; obligacionesAfectadas: string[] }> {
// Get active obligations for this contribuyente (incluye frecuencia para filtrar) // Get active obligations for this contribuyente (incluye frecuencia para filtrar)
const { rows: obligaciones } = await pool.query<{ id: string; nombre: string; frecuencia: string | null }>( const { rows: obligaciones } = await pool.query<{ id: string; nombre: string; frecuencia: string | null }>(
`SELECT id, nombre, frecuencia FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND activa = true`, `SELECT id, nombre, frecuencia FROM obligaciones_contribuyente WHERE contribuyente_id = $1 AND activa = true`,
@@ -43,6 +88,7 @@ async function completarObligacionesPorDeclaracion(
); );
let count = 0; let count = 0;
const obligacionesAfectadas: string[] = [];
for (const impuesto of impuestos) { for (const impuesto of impuestos) {
const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto]; const rules = IMPUESTO_A_OBLIGACION_KEYWORDS[impuesto];
@@ -55,33 +101,109 @@ async function completarObligacionesPorDeclaracion(
if (!matches) continue; if (!matches) continue;
// Filtro por periodicidad/frecuencia: una declaración mensual no debe // Filtro por periodicidad/frecuencia: una declaración mensual no debe
// cerrar obligaciones anuales del mismo impuesto (ej. ISR mensual no // cerrar obligaciones anuales del mismo impuesto.
// cubre "Declaración anual de ISR"). Si la obligación tiene frecuencia
// explícita y no coincide con la periodicidad de la declaración, skip.
// `eventual` obligaciones no se tocan automáticamente.
const obFrec = (ob.frecuencia || '').toLowerCase(); const obFrec = (ob.frecuencia || '').toLowerCase();
if (obFrec === 'eventual') continue; if (obFrec === 'eventual') continue;
if (obFrec && obFrec !== periodicidad.toLowerCase()) continue; if (obFrec && obFrec !== periodicidad.toLowerCase()) continue;
// Mark obligation as completed for this period, with FK a la declaración await createEvidencia(pool, {
await pool.query(` obligacionId: ob.id,
INSERT INTO obligacion_periodos (obligacion_id, periodo, completada, completada_at, completada_por, notas, declaracion_id) periodo,
VALUES ($1, $2, true, now(), $3, $4, $5) contribuyenteId,
ON CONFLICT (obligacion_id, periodo) tipoDocumento,
DO UPDATE SET completada = true, completada_at = now(), completada_por = $3, declaracion_id = $5 pdfBase64,
`, [ob.id, periodo, completadaPor, `Declaración ${impuesto} subida`, declaracionId]); pdfFilename,
notas: `${tipoDocumento === 'pago' ? 'Pago' : 'Declaración'} ${impuesto}`,
// Resolve the ob-* alert for this obligation+period subidoPor,
await pool.query( });
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
[`ob-${ob.id}-${periodo}`],
);
if (!obligacionesAfectadas.includes(ob.id)) obligacionesAfectadas.push(ob.id);
count++; count++;
} }
} }
return count; return { count, obligacionesAfectadas };
}
/**
* Cuando una declaración tiene monto $0, no se requiere comprobante de pago.
* Esta función marca `pago_presentado = true` (y `completada = true`) en los
* periodos de las obligaciones afectadas para reflejar que el pago está saldado.
*/
async function confirmarPagoPeriodoSinComprobante(
pool: Pool,
obligacionesAfectadas: string[],
periodo: string,
userId: string,
): Promise<void> {
const now = new Date();
for (const obligacionId of obligacionesAfectadas) {
await pool.query(
`INSERT INTO obligacion_periodos
(obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por)
VALUES ($1, $2, true, true, true, $3, $4)
ON CONFLICT (obligacion_id, periodo)
DO UPDATE SET
pago_presentado = true,
completada = true,
completada_at = COALESCE(obligacion_periodos.completada_at, $3),
completada_por = COALESCE(obligacion_periodos.completada_por, $4)`,
[obligacionId, periodo, now, userId],
);
// Resolver alerta ob-* si existe
await pool.query(
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
[`ob-${obligacionId}-${periodo}`],
);
}
}
/**
* Registra una evidencia por cada obligación seleccionada.
* - Obligaciones informativas se completan con `declaracion`/`acuse`/`complemento`.
* - Obligaciones de pago requieren evidencia `pago` para cerrarse.
*/
async function registrarEvidenciasPorObligaciones(
pool: Pool,
obligaciones: Array<{ id: string; nombre: string; catalogoId?: string | null }>,
contribuyenteId: string,
periodo: string,
subidoPor: string,
pdfBase64: string,
pdfFilename: string,
tipoDocumento: 'declaracion' | 'pago',
notas?: string,
): Promise<string[]> {
const afectadas: string[] = [];
for (const ob of obligaciones) {
await createEvidencia(pool, {
obligacionId: ob.id,
periodo,
contribuyenteId,
tipoDocumento,
pdfBase64,
pdfFilename,
notas: notas || `${tipoDocumento === 'pago' ? 'Comprobante de pago' : 'Declaración'}: ${ob.nombre}`,
subidoPor,
});
afectadas.push(ob.id);
}
return afectadas;
}
async function getObligacionesPorIds(
pool: Pool,
contribuyenteId: string,
obligacionesIds: string[],
): Promise<Array<{ id: string; nombre: string; catalogoId: string | null }>> {
const { rows } = await pool.query<{ id: string; nombre: string; catalogo_id: string | null }>(
`SELECT id, nombre, catalogo_id
FROM obligaciones_contribuyente
WHERE contribuyente_id = $1 AND id = ANY($2::uuid[]) AND activa = true`,
[contribuyenteId, obligacionesIds],
);
return rows.map(r => ({ id: r.id, nombre: r.nombre, catalogoId: r.catalogo_id }));
} }
/** /**
@@ -96,7 +218,7 @@ async function completarObligacionesPorDeclaracion(
export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH'; export type Impuesto = 'IVA' | 'ISR' | 'IEPS' | 'ISN' | 'DIOT' | 'OTRO' | 'ISH';
export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'semestral' | 'anual'; export type Periodicidad = 'mensual' | 'bimestral' | 'trimestral' | 'cuatrimestral' | 'semestral' | 'anual';
export interface DeclaracionRow { export interface DeclaracionRow {
id: number; id: number;
@@ -232,7 +354,10 @@ export async function createDeclaracion(
mes: number; mes: number;
tipo: 'normal' | 'complementaria'; tipo: 'normal' | 'complementaria';
periodicidad?: Periodicidad; periodicidad?: Periodicidad;
impuestos: string[]; /** Legacy: se infiere de obligacionesIds si no se envía. */
impuestos?: string[];
/** Obligaciones fiscales que cubre esta declaración. */
obligacionesIds?: string[];
montoPago?: number | null; montoPago?: number | null;
pdfBase64: string; // PDF de la declaración (base64) pdfBase64: string; // PDF de la declaración (base64)
pdfFilename: string; pdfFilename: string;
@@ -253,6 +378,16 @@ export async function createDeclaracion(
// If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed) // If monto_pago is exactly 0, auto-mark as paid (no payment receipt needed)
const pagadoAt = montoPago === 0 ? new Date() : null; const pagadoAt = montoPago === 0 ? new Date() : null;
// Resolvemos obligaciones e impuestos.
let obligacionesSeleccionadas: Array<{ id: string; nombre: string; catalogoId: string | null }> = [];
let impuestos: string[] = data.impuestos ?? [];
if (data.contribuyenteId && data.obligacionesIds && data.obligacionesIds.length > 0) {
obligacionesSeleccionadas = await getObligacionesPorIds(pool, data.contribuyenteId, data.obligacionesIds);
if (impuestos.length === 0) {
impuestos = inferirImpuestosDeObligaciones(obligacionesSeleccionadas);
}
}
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO declaraciones_provisionales `INSERT INTO declaraciones_provisionales
@@ -262,46 +397,55 @@ export async function createDeclaracion(
RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename, RETURNING id, año, mes, tipo, periodicidad, impuestos, monto_pago, pdf_filename,
pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas, pdf_liga_pago_filename, pdf_pago_filename, pagado_at, creado_por, notas,
created_at, updated_at`, created_at, updated_at`,
[data.año, data.mes, data.tipo, periodicidad, data.impuestos, montoPago, [data.año, data.mes, data.tipo, periodicidad, impuestos, montoPago,
buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null, buf, data.pdfFilename, ligaBuf, data.ligaPagoFilename ?? null,
data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null], data.notas ?? null, data.creadoPor, pagadoAt, data.contribuyenteId ?? null],
); );
const declaracion = rowToDeclaracion(rows[0]); const declaracion = rowToDeclaracion(rows[0]);
// Auto-resolver alertas. Reglas: // Guardar relación con obligaciones para que el comprobante de pago
// - tipo='normal': resuelve alertas de declaración (decl-*) del mes. // posterior se aplique a las mismas obligaciones.
// El pago se resuelve por separado al subir comprobante. if (obligacionesSeleccionadas.length > 0) {
// - tipo='complementaria': sustituye a la normal en términos de const values = obligacionesSeleccionadas.map((_, i) => `($1, $${i + 2})`).join(',');
// obligación de pago — al subirla se resuelven AMBAS (decl-* y await pool.query(
// pago-*) porque el cliente pagará usando la complementaria, `INSERT INTO declaracion_obligaciones (declaracion_id, obligacion_id) VALUES ${values}`,
// no la normal. La alerta de declaración ya estaría resuelta [declaracion.id, ...obligacionesSeleccionadas.map(o => o.id)],
// si la normal se subió antes; el resolver es idempotente. );
const prefijosDecl = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []); }
// Auto-resolver alertas legacy (decl-*, pago-*).
const prefijosDecl = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_DECL[i] || []);
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosDecl, data.año, data.mes); let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosDecl, data.año, data.mes);
if (data.tipo === 'complementaria' || montoPago === 0) { if (data.tipo === 'complementaria' || montoPago === 0) {
// complementaria: sustituye normal para pago → resolver ambas const prefijosPago = impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
// monto 0: nada que pagar → resolver alertas de pago también
const prefijosPago = data.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes); alertasResueltas += await resolverAlertasPorPeriodo(pool, prefijosPago, data.año, data.mes);
} }
// Auto-complete obligaciones del contribuyente SOLO si la declaración // Registrar evidencias de declaración en las obligaciones seleccionadas.
// también cubre el pago (complementaria sustituye a la normal para el // Fallback legacy: si no se enviaron obligaciones, se usa el keyword matching
// pago; monto=0 significa "nada que pagar"). Una declaración normal con // anterior a partir de impuestos.
// monto>0 solo presenta el acuse — la obligación de pago sigue abierta let obligacionesAfectadas: string[] = obligacionesSeleccionadas.map(o => o.id);
// y se marca completada hasta que se suba el comprobante via if (data.contribuyenteId && data.creadoPorUserId) {
// `uploadComprobantePago`. Esto mantiene las alertas `pago-*` y `ob-*`
// visibles hasta que realmente se cierre el ciclo.
const cubrePago = data.tipo === 'complementaria' || montoPago === 0;
if (data.contribuyenteId && cubrePago) {
if (!data.creadoPorUserId) {
console.warn('[createDeclaracion] Sin creadoPorUserId — no se auto-completan obligaciones del contribuyente');
} else {
const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`; const periodo = `${data.año}-${String(data.mes).padStart(2, '0')}`;
alertasResueltas += await completarObligacionesPorDeclaracion(
pool, data.contribuyenteId, data.impuestos, periodo, data.creadoPorUserId, declaracion.id, periodicidad, if (obligacionesSeleccionadas.length > 0) {
await registrarEvidenciasPorObligaciones(
pool, obligacionesSeleccionadas, data.contribuyenteId, periodo, data.creadoPorUserId,
data.pdfBase64, data.pdfFilename, 'declaracion', data.notas,
); );
} else if (impuestos.length > 0) {
const { obligacionesAfectadas: afectadas } = await registrarEvidenciasPorDeclaracion(
pool, data.contribuyenteId, impuestos, periodo, data.creadoPorUserId,
data.pdfBase64, data.pdfFilename, 'declaracion', periodicidad,
);
obligacionesAfectadas = afectadas;
}
// Si la declaración es por $0, no se requiere comprobante de pago:
// marcar el pago como presentado automáticamente.
if (montoPago === 0 && obligacionesAfectadas.length > 0) {
await confirmarPagoPeriodoSinComprobante(pool, obligacionesAfectadas, periodo, data.creadoPorUserId);
} }
} }
@@ -340,20 +484,35 @@ export async function uploadComprobantePago(
const row = rows[0]; const row = rows[0];
const declaracion = rowToDeclaracion(row); const declaracion = rowToDeclaracion(row);
// Auto-resolver alertas de pago para los impuestos del periodo // Auto-resolver alertas de pago legacy.
const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []); const prefijosPago = declaracion.impuestos.flatMap(i => IMPUESTO_A_PREFIJO_PAGO[i] || []);
let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes); let alertasResueltas = await resolverAlertasPorPeriodo(pool, prefijosPago, declaracion.año, declaracion.mes);
// Al subirse el comprobante de pago, la obligación ahora SÍ está completada // Registrar evidencias de pago en las obligaciones vinculadas a esta declaración.
// (declaración + pago). Marcar `obligacion_periodos.completada=true` y // Fallback legacy: si no hay relaciones, se usa keyword matching por impuestos.
// resolver los `ob-*` alerts. Requires contribuyenteId (guardado en la
// declaración) y userId (del caller).
if (row.contribuyente_id && data.uploadedByUserId) { if (row.contribuyente_id && data.uploadedByUserId) {
const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`; const periodo = `${declaracion.año}-${String(declaracion.mes).padStart(2, '0')}`;
const periodicidad = row.periodicidad || 'mensual';
alertasResueltas += await completarObligacionesPorDeclaracion( const { rows: relaciones } = await pool.query<{ obligacion_id: string }>(
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId, declaracion.id, periodicidad, `SELECT obligacion_id FROM declaracion_obligaciones WHERE declaracion_id = $1`,
[id],
); );
if (relaciones.length > 0) {
const obligaciones = await getObligacionesPorIds(
pool, row.contribuyente_id, relaciones.map(r => r.obligacion_id),
);
await registrarEvidenciasPorObligaciones(
pool, obligaciones, row.contribuyente_id, periodo, data.uploadedByUserId,
data.pdfBase64, data.pdfFilename, 'pago', declaracion.notas ?? undefined,
);
} else if (declaracion.impuestos.length > 0) {
const periodicidad = row.periodicidad || 'mensual';
await registrarEvidenciasPorDeclaracion(
pool, row.contribuyente_id, declaracion.impuestos, periodo, data.uploadedByUserId,
data.pdfBase64, data.pdfFilename, 'pago', periodicidad,
);
}
} }
return { declaracion, alertasResueltas }; return { declaracion, alertasResueltas };

View File

@@ -1,4 +1,4 @@
import { createEmailTransport } from '@horux/core'; import { createEmailTransport, type EmailAttachment } from '@horux/core';
import { env } from '../../config/env.js'; import { env } from '../../config/env.js';
const transport = createEmailTransport( const transport = createEmailTransport(
@@ -13,8 +13,8 @@ const transport = createEmailTransport(
: null : null
); );
async function sendEmail(to: string, subject: string, html: string) { async function sendEmail(to: string, subject: string, html: string, attachments?: EmailAttachment[]) {
await transport.send(to, subject, html); await transport.send(to, subject, html, attachments);
} }
export const emailService = { export const emailService = {
@@ -128,10 +128,14 @@ export const emailService = {
* Notifica la subida de una declaración o documento extra al despacho. * Notifica la subida de una declaración o documento extra al despacho.
* `recipients` debe venir deduplicado por el caller. El subject se * `recipients` debe venir deduplicado por el caller. El subject se
* genera a partir del kind y RFC del contribuyente. * genera a partir del kind y RFC del contribuyente.
*
* Para declaraciones, `attachments` puede contener los PDFs subidos
* (acuse + liga de pago) para enviarlos adjuntos al correo.
*/ */
sendDocumentoSubido: async ( sendDocumentoSubido: async (
recipients: string[], recipients: string[],
data: import('./templates/documento-subido.js').DocumentoSubidoData, data: import('./templates/documento-subido.js').DocumentoSubidoData,
attachments?: EmailAttachment[],
) => { ) => {
if (recipients.length === 0) return; if (recipients.length === 0) return;
const { documentoSubidoEmail } = await import('./templates/documento-subido.js'); const { documentoSubidoEmail } = await import('./templates/documento-subido.js');
@@ -143,7 +147,7 @@ export const emailService = {
// destinatario NO debe impedir enviar al siguiente. // destinatario NO debe impedir enviar al siguiente.
for (const to of recipients) { for (const to of recipients) {
try { try {
await sendEmail(to, subject, html); await sendEmail(to, subject, html, attachments);
} catch (err: any) { } catch (err: any) {
console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err); console.error(`[Email] Fallo enviando documento-subido a ${to}:`, err?.message || err);
} }
@@ -193,6 +197,19 @@ export const emailService = {
); );
}, },
/** Clientes reciben aviso cuando se sube papelería que requiere su aprobación. */
sendPapeleriaAprobacionClienteRequerida: async (
to: string,
data: import('./templates/papeleria.js').PapeleriaAprobacionClienteRequeridaData,
) => {
const { papeleriaAprobacionClienteRequeridaEmail } = await import('./templates/papeleria.js');
await sendEmail(
to,
`📋 Documento pendiente de tu aprobación — ${data.contribuyenteRfc}`,
papeleriaAprobacionClienteRequeridaEmail(data),
);
},
/** /**
* Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo * Cron 8:30 AM — alertas fiscales nuevas activadas hoy. Envía un solo
* correo por destinatario con el batch completo. Caller debe deduplicar * correo por destinatario con el batch completo. Caller debe deduplicar

View File

@@ -2,7 +2,7 @@ import { baseTemplate, heading, infoBox, primaryButton, BRAND_COLORS as C } from
export interface DocumentoSubidoData { export interface DocumentoSubidoData {
/** Kind: para el título/subject. */ /** Kind: para el título/subject. */
kind: 'declaracion' | 'extra'; kind: 'declaracion' | 'extra' | 'obligacion_evidencia';
/** Quién subió el documento (email). */ /** Quién subió el documento (email). */
subidoPor: string; subidoPor: string;
/** RFC del contribuyente. */ /** RFC del contribuyente. */
@@ -24,17 +24,30 @@ export interface DocumentoSubidoData {
descripcion?: string | null; descripcion?: string | null;
categoria?: string | null; categoria?: string | null;
}; };
/** Si es evidencia de obligación fiscal. */
evidencia?: {
obligacionNombre: string;
periodo: string;
tipoDocumento: string;
filename: string;
};
/** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */ /** URL al sistema (ej. https://despachos.horuxfin.com/documentos). */
link: string; link: string;
/** Solo para declaraciones: los adjuntos se omitieron por exceder el límite de tamaño. */
attachmentsOmitted?: boolean;
} }
export function documentoSubidoEmail(data: DocumentoSubidoData): string { export function documentoSubidoEmail(data: DocumentoSubidoData): string {
const titulo = data.kind === 'declaracion' const titulo = data.kind === 'declaracion'
? 'Nueva declaración subida' ? 'Nueva declaración subida'
: data.kind === 'obligacion_evidencia'
? 'Nueva evidencia de obligación fiscal'
: 'Nuevo documento subido'; : 'Nuevo documento subido';
const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion const contenidoEspecifico = data.kind === 'declaracion' && data.declaracion
? declaracionBlock(data.declaracion) ? declaracionBlock(data.declaracion)
: data.kind === 'obligacion_evidencia' && data.evidencia
? evidenciaBlock(data.evidencia)
: data.extra : data.extra
? extraBlock(data.extra) ? extraBlock(data.extra)
: ''; : '';
@@ -42,7 +55,7 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
return baseTemplate(` return baseTemplate(`
${heading(titulo)} ${heading(titulo)}
<p style="color:${C.textPrimary};margin:0 0 16px;"> <p style="color:${C.textPrimary};margin:0 0 16px;">
<strong>${escapeHtml(data.subidoPor)}</strong> subió un ${data.kind === 'declaracion' ? 'acuse de declaración' : 'documento'} <strong>${escapeHtml(data.subidoPor)}</strong> subió ${data.kind === 'obligacion_evidencia' ? 'una evidencia de obligación fiscal' : data.kind === 'declaracion' ? 'un acuse de declaración' : 'un documento'}
para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>. para <strong>${escapeHtml(data.contribuyenteNombre)}</strong>.
</p> </p>
${infoBox(` ${infoBox(`
@@ -57,6 +70,12 @@ export function documentoSubidoEmail(data: DocumentoSubidoData): string {
<div style="margin-top:24px;"> <div style="margin-top:24px;">
${primaryButton('Ver en el sistema', data.link)} ${primaryButton('Ver en el sistema', data.link)}
</div> </div>
${data.kind === 'declaracion' && data.attachmentsOmitted ? `
<p style="color:${C.textMuted};font-size:13px;margin-top:16px;">
Los documentos no se adjuntaron porque exceden el tamaño permitido por correo.
Puedes descargarlos desde el sistema.
</p>
` : ''}
`); `);
} }
@@ -76,6 +95,19 @@ function declaracionBlock(d: NonNullable<DocumentoSubidoData['declaracion']>): s
`; `;
} }
function evidenciaBlock(e: NonNullable<DocumentoSubidoData['evidencia']>): string {
return `
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Obligación</p>
<p style="margin:0 0 12px;color:${C.textPrimary};font-weight:600;">${escapeHtml(e.obligacionNombre)}</p>
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Periodo</p>
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.periodo)}</p>
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Tipo de documento</p>
<p style="margin:0 0 12px;color:${C.textPrimary};text-transform:capitalize;">${escapeHtml(e.tipoDocumento)}</p>
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Archivo</p>
<p style="margin:0 0 12px;color:${C.textPrimary};">${escapeHtml(e.filename)}</p>
`;
}
function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string { function extraBlock(e: NonNullable<DocumentoSubidoData['extra']>): string {
return ` return `
<p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p> <p style="margin:0 0 6px;color:${C.textMuted};font-size:13px;">Documento</p>

View File

@@ -55,3 +55,32 @@ export function papeleriaDecisionEmail(d: PapeleriaDecisionData): string {
`; `;
return baseTemplate(body); return baseTemplate(body);
} }
export interface PapeleriaAprobacionClienteRequeridaData {
contribuyenteRfc: string;
contribuyenteNombre: string;
despachoNombre?: string;
nombreDocumento: string;
descripcion: string | null;
periodo: string;
subidoPor: string;
link: string;
}
export function papeleriaAprobacionClienteRequeridaEmail(d: PapeleriaAprobacionClienteRequeridaData): string {
const body = `
${heading('Documento pendiente de tu aprobación')}
<p>${d.subidoPor} subió un documento que requiere tu aprobación como cliente:</p>
<ul>
<li><strong>Documento:</strong> ${d.nombreDocumento}</li>
<li><strong>Contribuyente:</strong> ${d.contribuyenteNombre} (${d.contribuyenteRfc})</li>
<li><strong>Periodo:</strong> ${d.periodo}</li>
${d.descripcion ? `<li><strong>Descripción:</strong> ${d.descripcion}</li>` : ''}
</ul>
${infoBox('Revisa el documento y márcalo como aprobado o rechazado desde la sección de Documentos del despacho.')}
<div style="margin-top: 24px;">
${primaryButton('Ver documento', d.link)}
</div>
`;
return baseTemplate(body);
}

View File

@@ -1,30 +1,49 @@
import type { Pool } from 'pg'; import type { Pool } from 'pg';
/** /**
* Tipos de correos informativos cuyo envío puede desactivarse por * Tipos de correos informativos cuyo envío puede desactivarse por rol.
* contribuyente. NO incluye correos transaccionales críticos * NO incluye correos transaccionales críticos (welcome, password-reset,
* (welcome, password-reset, payment-*) — esos siempre se envían. * payment-*, invitaciones) — esos siempre se envían.
* *
* Estado de implementación: * Estado de implementación:
* - documento_subido: ✅ implementado (notify-upload.service.ts) * - documento_subido: ✅ implementado (owner + supervisor del contribuyente)
* - weekly_update: ⏳ pendiente (job es tenant-wide hoy) * - weekly_update: ✅ implementado (job tenant-wide, owners)
* - subscription_expiring: ⏳ pendiente (no es per-contribuyente hoy) * - subscription_expiring: ✅ implementado (aviso a owner)
* - recordatorio_fiscal: ⏳ placeholder para futuras alertas * - recordatorio_fiscal: ⏳ placeholder para futuras alertas
* - alertas_nuevas: ✅ implementado (supervisor + auxiliares + clientes)
* - recordatorio_proximo: ✅ implementado (auxiliar/supervisor/cliente/owner)
*/ */
export const EMAIL_TYPES = [ export const EMAIL_TYPES = [
'documento_subido', 'documento_subido',
'weekly_update', 'weekly_update',
'subscription_expiring', 'subscription_expiring',
'recordatorio_fiscal', 'recordatorio_fiscal',
'alertas_nuevas',
'recordatorio_proximo',
] as const; ] as const;
export type EmailType = (typeof EMAIL_TYPES)[number]; export type EmailType = (typeof EMAIL_TYPES)[number];
/**
* Roles que pueden recibir notificaciones informativas. Se excluyen roles
* que hoy no son destinatarios de ninguna notificación (cfo, contador, visor).
*/
export const NOTIFICATION_ROLES = [
'owner',
'supervisor',
'auxiliar',
'cliente',
] as const;
export type NotificationRole = (typeof NOTIFICATION_ROLES)[number];
export type EmailPreferences = Record<EmailType, boolean>; export type EmailPreferences = Record<EmailType, boolean>;
export type RoleEmailPreferences = Record<EmailType, Record<NotificationRole, boolean>>;
/** /**
* Default: todo activado. Si el JSONB en BD viene vacío o falta una * Default legacy (por contribuyente). Se mantiene por compatibilidad con la
* key, asumimos `true` para preservar el comportamiento previo. * columna `contribuyentes.email_preferences`; la UI nueva ya no lo usa.
*/ */
function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences { function applyDefaults(raw: Partial<Record<string, unknown>>): EmailPreferences {
const out = {} as EmailPreferences; const out = {} as EmailPreferences;
@@ -38,10 +57,10 @@ function sanitizeUuid(id: string): string {
return id.replace(/[^a-f0-9-]/gi, ''); return id.replace(/[^a-f0-9-]/gi, '');
} }
/** // ═══════════════════════════════════════════════════════════════════════════
* Lee las preferencias de un contribuyente. Devuelve defaults (todo // Preferencias por contribuyente (legacy — conservado por compatibilidad)
* activado) si no hay fila o la columna está vacía. // ═══════════════════════════════════════════════════════════════════════════
*/
export async function getContribuyenteEmailPreferences( export async function getContribuyenteEmailPreferences(
pool: Pool, pool: Pool,
contribuyenteId: string, contribuyenteId: string,
@@ -55,11 +74,6 @@ export async function getContribuyenteEmailPreferences(
return applyDefaults(raw); return applyDefaults(raw);
} }
/**
* Actualiza las preferencias de un contribuyente. Solo persiste las
* keys conocidas (filtra extras maliciosos). Merge sobre la columna
* existente (no sobreescribe keys no enviadas).
*/
export async function setContribuyenteEmailPreferences( export async function setContribuyenteEmailPreferences(
pool: Pool, pool: Pool,
contribuyenteId: string, contribuyenteId: string,
@@ -81,10 +95,6 @@ export async function setContribuyenteEmailPreferences(
return getContribuyenteEmailPreferences(pool, contribuyenteId); return getContribuyenteEmailPreferences(pool, contribuyenteId);
} }
/**
* Lee preferencias para múltiples contribuyentes en una sola query.
* Útil para la UI de `/configuracion/notificaciones` que lista todos.
*/
export async function getEmailPreferencesPorContribuyente( export async function getEmailPreferencesPorContribuyente(
pool: Pool, pool: Pool,
): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> { ): Promise<Array<{ contribuyenteId: string; rfc: string; nombre: string; preferences: EmailPreferences }>> {
@@ -108,3 +118,89 @@ export async function getEmailPreferencesPorContribuyente(
preferences: applyDefaults(r.email_preferences ?? {}), preferences: applyDefaults(r.email_preferences ?? {}),
})); }));
} }
// ═══════════════════════════════════════════════════════════════════════════
// Preferencias por rol (nuevo modelo)
// ═══════════════════════════════════════════════════════════════════════════
function applyRoleDefaults(raw: Array<{ email_type: string; role: string; enabled: boolean }>): RoleEmailPreferences {
const out = {} as RoleEmailPreferences;
for (const t of EMAIL_TYPES) {
out[t] = {} as Record<NotificationRole, boolean>;
for (const r of NOTIFICATION_ROLES) {
const row = raw.find(x => x.email_type === t && x.role === r);
out[t][r] = row ? row.enabled : true;
}
}
return out;
}
/**
* Lee las preferencias de notificación por rol. Si la tabla está vacía para
* un (type, role), asume `true` para no romper el comportamiento previo.
*/
export async function getRoleEmailPreferences(pool: Pool): Promise<RoleEmailPreferences> {
const { rows } = await pool.query<{ email_type: string; role: string; enabled: boolean }>(
`SELECT email_type, role, enabled FROM notification_role_preferences`
);
return applyRoleDefaults(rows);
}
/**
* Actualiza una celda (emailType, role). Ignora valores desconocidos.
*/
export async function setRoleEmailPreference(
pool: Pool,
emailType: EmailType,
role: NotificationRole,
enabled: boolean,
): Promise<RoleEmailPreferences> {
await pool.query(
`INSERT INTO notification_role_preferences (email_type, role, enabled)
VALUES ($1, $2, $3)
ON CONFLICT (email_type, role) DO UPDATE SET enabled = EXCLUDED.enabled, updated_at = NOW()`,
[emailType, role, enabled],
);
return getRoleEmailPreferences(pool);
}
/**
* Devuelve true si el rol tiene habilitado el tipo de notificación.
* Fallback a true si no hay fila (comportamiento seguro).
*/
export async function isRoleEnabled(
pool: Pool,
emailType: EmailType,
role: NotificationRole,
): Promise<boolean> {
const { rows } = await pool.query<{ enabled: boolean }>(
`SELECT enabled FROM notification_role_preferences WHERE email_type = $1 AND role = $2`,
[emailType, role],
);
return rows[0]?.enabled ?? true;
}
interface RecipientWithRole {
email: string;
role: NotificationRole;
}
/**
* Filtra una lista de destinatarios con rol según las preferencias guardadas.
* Si no hay preferencias para un (type, role), se conserva el destinatario.
*/
export async function filterRecipientsByRole(
pool: Pool,
emailType: EmailType,
recipients: RecipientWithRole[],
): Promise<string[]> {
const prefs = await getRoleEmailPreferences(pool);
const typePrefs = prefs[emailType];
const filtered = recipients.filter(r => {
if (!typePrefs) return true;
return typePrefs[r.role] !== false;
});
return [...new Set(filtered.map(r => r.email))];
}
export type { RecipientWithRole };

View File

@@ -26,6 +26,12 @@ import { generarAlertasAutomaticas, type AlertaAuto } from './alertas-auto.servi
import { emailService } from './email/email.service.js'; import { emailService } from './email/email.service.js';
import type { AlertaItem } from './email/templates/alertas-nuevas.js'; import type { AlertaItem } from './email/templates/alertas-nuevas.js';
import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js'; import type { VentanaRecordatorio } from './email/templates/recordatorio-proximo.js';
import {
filterRecipientsByRole,
type RecipientWithRole,
type EmailType,
type NotificationRole,
} from './notification-preferences.service.js';
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000'; const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:3000';
@@ -100,39 +106,60 @@ async function getUserContacts(userIds: string[]): Promise<UserContact[]> {
/** /**
* Destinatarios de una alerta: supervisor + auxiliares + clientes del * Destinatarios de una alerta: supervisor + auxiliares + clientes del
* contribuyente. Si el owner del tenant es supervisor, ya queda incluido * contribuyente. Retorna emails con su rol para poder filtrar por
* (no se duplica). * preferencias de notificación.
*/ */
async function recipientsForAlerta( async function recipientsForAlerta(
pool: Pool, pool: Pool,
tenantId: string, tenantId: string,
contribuyenteId: string, contribuyenteId: string,
): Promise<string[]> { ): Promise<RecipientWithRole[]> {
const ids = await getUserIdsContribuyente(pool, contribuyenteId); const ids = await getUserIdsContribuyente(pool, contribuyenteId);
const userIds = new Set<string>(); const byRole = new Map<string, NotificationRole>();
if (ids.supervisor) userIds.add(ids.supervisor); if (ids.supervisor) byRole.set(ids.supervisor, 'supervisor');
ids.auxiliares.forEach(id => userIds.add(id)); ids.auxiliares.forEach(id => byRole.set(id, 'auxiliar'));
ids.clientes.forEach(id => userIds.add(id)); ids.clientes.forEach(id => byRole.set(id, 'cliente'));
const contacts = await getUserContacts([...userIds]);
return [...new Set(contacts.map(c => c.email))]; const contacts = await getUserContacts([...byRole.keys()]);
return contacts
.filter(c => byRole.has(c.userId))
.map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
}
async function getUserRole(
tenantId: string,
userId: string,
): Promise<NotificationRole | null> {
const m = await prisma.tenantMembership.findFirst({
where: { userId, tenantId, active: true },
include: { rol: { select: { nombre: true } } },
});
if (!m) return null;
const role = m.rol.nombre;
if (role === 'owner' || role === 'supervisor' || role === 'auxiliar' || role === 'cliente') {
return role;
}
return null;
} }
/** /**
* Destinatarios de un recordatorio. Los recordatorios del despacho son * Destinatarios de un recordatorio. Los recordatorios del despacho son
* tenant-level (no atados a contribuyente). Para públicos: clientes con * tenant-level (no atados a contribuyente). Retorna emails con rol para
* algún acceso + auxiliares de cualquier cartera; si no hay auxiliares, * filtrado por preferencias.
* supervisores; si owner aparece como supervisor, también recibe.
* *
* Públicos: clientes + auxiliares + supervisores + owners.
* Privados: solo el creador. * Privados: solo el creador.
*/ */
async function recipientsForRecordatorio( async function recipientsForRecordatorio(
pool: Pool, pool: Pool,
tenantId: string, tenantId: string,
recordatorio: { creadoPor: string; privado: boolean }, recordatorio: { creadoPor: string; privado: boolean },
): Promise<string[]> { ): Promise<RecipientWithRole[]> {
if (recordatorio.privado) { if (recordatorio.privado) {
const role = await getUserRole(tenantId, recordatorio.creadoPor);
if (!role) return [];
const contacts = await getUserContacts([recordatorio.creadoPor]); const contacts = await getUserContacts([recordatorio.creadoPor]);
return [...new Set(contacts.map(c => c.email))]; return contacts.map(c => ({ email: c.email, role }));
} }
// Recordatorio público: lee universos relevantes del tenant. // Recordatorio público: lee universos relevantes del tenant.
@@ -158,27 +185,19 @@ async function recipientsForRecordatorio(
), ARRAY[]::uuid[]) AS cliente_user_ids ), ARRAY[]::uuid[]) AS cliente_user_ids
`); `);
const auxiliares = r?.auxiliar_user_ids ?? []; const byRole = new Map<string, NotificationRole>();
const supervisores = r?.supervisor_user_ids ?? []; (r?.auxiliar_user_ids ?? []).forEach(id => byRole.set(id, 'auxiliar'));
const clientes = r?.cliente_user_ids ?? []; (r?.supervisor_user_ids ?? []).forEach(id => byRole.set(id, 'supervisor'));
(r?.cliente_user_ids ?? []).forEach(id => byRole.set(id, 'cliente'));
// Owners siempre se consideran owner aunque también aparezcan como supervisor.
const owners = await getOwnerUserIds(tenantId); const owners = await getOwnerUserIds(tenantId);
owners.forEach(id => byRole.set(id, 'owner'));
// Regla del owner: clientes y auxiliares siempre. Si no hay auxiliares, const contacts = await getUserContacts([...byRole.keys()]);
// agregar supervisores. Si owner es supervisor y no hay auxiliares, return contacts
// owner queda incluido vía la lista de supervisores. .filter(c => byRole.has(c.userId))
const userIds = new Set<string>(); .map(c => ({ email: c.email, role: byRole.get(c.userId)! }));
clientes.forEach(id => userIds.add(id));
auxiliares.forEach(id => userIds.add(id));
if (auxiliares.length === 0) {
supervisores.forEach(id => userIds.add(id));
// Solo si owner aparece como supervisor (intersección):
for (const ownerId of owners) {
if (supervisores.includes(ownerId)) userIds.add(ownerId);
}
}
const contacts = await getUserContacts([...userIds]);
return [...new Set(contacts.map(c => c.email))];
} }
// ──────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────
@@ -276,8 +295,10 @@ async function processAlertasContribuyente(
return { nuevas: 0, resueltas }; return { nuevas: 0, resueltas };
} }
// Envía email batched a los responsables del contribuyente. // Envía email batched a los responsables del contribuyente, filtrando por
const recipients = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId); // preferencias de rol para alertas_nuevas.
const recipientsWithRole = await recipientsForAlerta(pool, tenantId, contribuyente.entidadId);
const recipients = await filterRecipientsByRole(pool, 'alertas_nuevas', recipientsWithRole);
if (recipients.length === 0) { if (recipients.length === 0) {
console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`); console.warn(`[Notifications] Sin destinatarios para alertas de ${contribuyente.rfc} (tenant ${tenant.rfc})`);
return { nuevas: nuevas.length, resueltas }; return { nuevas: nuevas.length, resueltas };
@@ -361,10 +382,11 @@ export async function processProximosRecordatorios(
for (const r of rows) { for (const r of rows) {
try { try {
const recipients = await recipientsForRecordatorio(pool, tenantId, { const recipientsWithRole = await recipientsForRecordatorio(pool, tenantId, {
creadoPor: r.creado_por, creadoPor: r.creado_por,
privado: r.privado, privado: r.privado,
}); });
const recipients = await filterRecipientsByRole(pool, 'recordatorio_proximo', recipientsWithRole);
if (recipients.length === 0) { if (recipients.length === 0) {
console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`); console.warn(`[Notifications] Recordatorio ${r.id} (${tenant.rfc}) sin destinatarios — skip ${ventana}`);
continue; continue;

View File

@@ -3,8 +3,12 @@ import { prisma } from '../config/database.js';
import { emailService } from './email/email.service.js'; import { emailService } from './email/email.service.js';
import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js'; import { getTenantOwnerEmails, getUserEmailById } from '../utils/memberships.js';
import { env } from '../config/env.js'; import { env } from '../config/env.js';
import { getContribuyenteEmailPreferences } from './notification-preferences.service.js'; import { filterRecipientsByRole, type RecipientWithRole } from './notification-preferences.service.js';
import type { DocumentoSubidoData } from './email/templates/documento-subido.js'; import type { DocumentoSubidoData } from './email/templates/documento-subido.js';
import type { EmailAttachment } from '@horux/core';
/** Límite total de adjuntos para evitar rechazos por SMTP (20 MB). */
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
/** /**
* Notifica a los destinatarios relevantes cuando se sube una declaración * Notifica a los destinatarios relevantes cuando se sube una declaración
@@ -26,7 +30,11 @@ export async function notifyDocumentoSubido(params: {
subidoPor: string; subidoPor: string;
kind: DocumentoSubidoData['kind']; kind: DocumentoSubidoData['kind'];
declaracion?: DocumentoSubidoData['declaracion']; declaracion?: DocumentoSubidoData['declaracion'];
declaracionId?: number;
extra?: DocumentoSubidoData['extra']; extra?: DocumentoSubidoData['extra'];
evidencia?: DocumentoSubidoData['evidencia'];
/** PDF en base64 para adjuntar en notificaciones de evidencia de obligación. */
pdfBase64?: string;
}): Promise<void> { }): Promise<void> {
const { pool, tenantId, contribuyenteId, subidoPor } = params; const { pool, tenantId, contribuyenteId, subidoPor } = params;
@@ -34,10 +42,7 @@ export async function notifyDocumentoSubido(params: {
// subject informativo ni supervisor — skip. // subject informativo ni supervisor — skip.
if (!contribuyenteId) return; if (!contribuyenteId) return;
// Respeta preferencias de notificación del contribuyente. Si el user
// desactivó `documento_subido` para este contribuyente, no enviar.
const prefs = await getContribuyenteEmailPreferences(pool, contribuyenteId);
if (!prefs.documento_subido) return;
const { rows } = await pool.query<{ const { rows } = await pool.query<{
rfc: string; rfc: string;
@@ -54,14 +59,17 @@ export async function notifyDocumentoSubido(params: {
const contrib = rows[0]; const contrib = rows[0];
// 2. Recipients. Owners primero; luego supervisor si aplica. // 2. Recipients. Owners primero; luego supervisor si aplica.
const owners = await getTenantOwnerEmails(tenantId); const ownerEmails = await getTenantOwnerEmails(tenantId);
const recipients = new Set<string>(owners); const recipientsWithRole: RecipientWithRole[] = ownerEmails.map(email => ({ email, role: 'owner' }));
if (contrib.supervisor_user_id) { if (contrib.supervisor_user_id) {
const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id); const supervisorEmail = await getUserEmailById(contrib.supervisor_user_id);
if (supervisorEmail) recipients.add(supervisorEmail); if (supervisorEmail) recipientsWithRole.push({ email: supervisorEmail, role: 'supervisor' });
} }
// Filtra por preferencias de rol para documento_subido.
const recipients = new Set(await filterRecipientsByRole(pool, 'documento_subido', recipientsWithRole));
// Excluir al uploader: no notificarle su propia acción. // Excluir al uploader: no notificarle su propia acción.
recipients.delete(subidoPor.toLowerCase()); recipients.delete(subidoPor.toLowerCase());
recipients.delete(subidoPor); recipients.delete(subidoPor);
@@ -77,6 +85,23 @@ export async function notifyDocumentoSubido(params: {
// 4. Link al sistema. Usa FRONTEND_URL del env. // 4. Link al sistema. Usa FRONTEND_URL del env.
const link = `${env.FRONTEND_URL}/documentos`; const link = `${env.FRONTEND_URL}/documentos`;
// Adjuntar los PDFs cuando se trata de una declaración recién creada o de una evidencia de obligación.
let attachments: EmailAttachment[] | undefined;
let attachmentsOmitted = false;
if (params.kind === 'declaracion' && params.declaracionId) {
const built = await buildDeclaracionAttachments(pool, params.declaracionId);
attachments = built.attachments;
attachmentsOmitted = built.omitted;
} else if (params.kind === 'obligacion_evidencia' && params.pdfBase64 && params.evidencia) {
const content = Buffer.from(params.pdfBase64, 'base64');
if (content.length > MAX_ATTACHMENT_BYTES) {
attachmentsOmitted = true;
console.warn(`[notifyDocumentoSubido] Evidencia de obligación excede ${MAX_ATTACHMENT_BYTES} bytes (${content.length}). Se envía sin adjunto.`);
} else {
attachments = [{ filename: params.evidencia.filename, content }];
}
}
await emailService.sendDocumentoSubido(Array.from(recipients), { await emailService.sendDocumentoSubido(Array.from(recipients), {
kind: params.kind, kind: params.kind,
subidoPor, subidoPor,
@@ -85,6 +110,46 @@ export async function notifyDocumentoSubido(params: {
despachoNombre: tenant?.nombre, despachoNombre: tenant?.nombre,
declaracion: params.declaracion, declaracion: params.declaracion,
extra: params.extra, extra: params.extra,
evidencia: params.evidencia,
link, link,
}); attachmentsOmitted,
}, attachments);
}
async function buildDeclaracionAttachments(
pool: Pool,
declaracionId: number,
): Promise<{ attachments?: EmailAttachment[]; omitted: boolean }> {
const { rows } = await pool.query(
`SELECT pdf_declaracion, pdf_filename,
pdf_liga_pago, pdf_liga_pago_filename
FROM declaraciones_provisionales
WHERE id = $1`,
[declaracionId],
);
const row = rows[0];
if (!row) return { omitted: false };
let totalSize = 0;
const attachments: EmailAttachment[] = [];
if (row.pdf_declaracion && row.pdf_filename) {
const content = Buffer.from(row.pdf_declaracion);
totalSize += content.length;
attachments.push({ filename: row.pdf_filename, content });
}
if (row.pdf_liga_pago && row.pdf_liga_pago_filename) {
const content = Buffer.from(row.pdf_liga_pago);
totalSize += content.length;
attachments.push({ filename: row.pdf_liga_pago_filename, content });
}
if (totalSize > MAX_ATTACHMENT_BYTES) {
console.warn(`[notifyDocumentoSubido] Adjuntos de declaración ${declaracionId} exceden ${MAX_ATTACHMENT_BYTES} bytes (${totalSize}). Se envía sin adjuntos.`);
return { omitted: true };
}
return { attachments, omitted: false };
} }

View File

@@ -0,0 +1,272 @@
import type { Pool } from 'pg';
import { OBLIGACIONES_CATALOGO } from '../constants/obligaciones-fiscales.js';
export interface EvidenciaRow {
id: number;
obligacionId: string;
periodo: string;
contribuyenteId: string;
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
archivo: Buffer;
archivoFilename: string;
archivoMime: string;
notas: string | null;
subidoPor: string | null;
subidoPorEmail: string | null;
createdAt: string;
}
export interface CreateEvidenciaInput {
obligacionId: string;
periodo: string;
contribuyenteId: string;
tipoDocumento: 'declaracion' | 'pago' | 'acuse' | 'complemento';
pdfBase64: string;
pdfFilename: string;
notas?: string;
subidoPor: string; // userId UUID
subidoPorEmail?: string;
}
function rowToEvidencia(r: any): EvidenciaRow {
return {
id: r.id,
obligacionId: r.obligacion_id,
periodo: r.periodo,
contribuyenteId: r.contribuyente_id,
tipoDocumento: r.tipo_documento,
archivo: Buffer.from(r.archivo),
archivoFilename: r.archivo_filename,
archivoMime: r.archivo_mime,
notas: r.notas,
subidoPor: r.subido_por,
subidoPorEmail: r.subido_por_email,
createdAt: r.created_at.toISOString(),
};
}
async function getObligacionContribuyente(pool: Pool, obligacionId: string): Promise<{ contribuyenteId: string; catalogoId: string | null } | null> {
const { rows } = await pool.query<{ contribuyente_id: string; catalogo_id: string | null }>(
`SELECT contribuyente_id, catalogo_id FROM obligaciones_contribuyente WHERE id = $1`,
[obligacionId],
);
const row = rows[0];
if (!row) return null;
return { contribuyenteId: row.contribuyente_id, catalogoId: row.catalogo_id };
}
function requierePago(obligacion: { catalogoId: string | null }): boolean {
if (!obligacion.catalogoId) return true; // conservador: sin catálogo, requiere pago
const catalogo = OBLIGACIONES_CATALOGO.find((o) => o.id === obligacion.catalogoId);
return catalogo?.requierePago ?? true;
}
function esDocumentoDeclaracion(tipo: string): boolean {
return tipo === 'declaracion' || tipo === 'acuse' || tipo === 'complemento';
}
async function updatePeriodoStatus(
pool: Pool,
obligacionId: string,
periodo: string,
tipoDocumento: string,
reqPago: boolean,
completadaPor: string,
notas?: string,
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
const { rows } = await pool.query<{
declaracion_presentada: boolean;
pago_presentado: boolean;
completada: boolean;
}>(
`SELECT declaracion_presentada, pago_presentado, completada
FROM obligacion_periodos
WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo],
);
const existing = rows[0];
let declaracionPresentada = existing?.declaracion_presentada ?? false;
let pagoPresentado = existing?.pago_presentado ?? false;
if (esDocumentoDeclaracion(tipoDocumento)) declaracionPresentada = true;
if (tipoDocumento === 'pago') pagoPresentado = true;
const completada = !reqPago || pagoPresentado;
const now = new Date();
if (existing) {
await pool.query(
`UPDATE obligacion_periodos
SET declaracion_presentada = $3,
pago_presentado = $4,
completada = $5,
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, $6) ELSE completada_at END,
completada_por = CASE WHEN $5 THEN COALESCE(completada_por, $7) ELSE completada_por END,
notas = COALESCE($8, notas)
WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, now, completadaPor, notas ?? null],
);
} else {
await pool.query(
`INSERT INTO obligacion_periodos
(obligacion_id, periodo, declaracion_presentada, pago_presentado, completada, completada_at, completada_por, notas)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada, completada ? now : null, completada ? completadaPor : null, notas ?? null],
);
}
if (completada) {
await pool.query(
`UPDATE alertas SET resuelta = true WHERE tipo = $1 AND resuelta = false`,
[`ob-${obligacionId}-${periodo}`],
);
}
return { completada, declaracionPresentada, pagoPresentado };
}
async function recalcPeriodoStatus(
pool: Pool,
obligacionId: string,
periodo: string,
reqPago: boolean,
): Promise<void> {
const { rows } = await pool.query<{ tipo_documento: string }>(
`SELECT tipo_documento FROM obligacion_evidencias WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo],
);
const declaracionPresentada = rows.some((r) => esDocumentoDeclaracion(r.tipo_documento));
const pagoPresentado = rows.some((r) => r.tipo_documento === 'pago');
const completada = !reqPago || pagoPresentado;
await pool.query(
`UPDATE obligacion_periodos
SET declaracion_presentada = $3,
pago_presentado = $4,
completada = $5,
completada_at = CASE WHEN $5 THEN COALESCE(completada_at, NOW()) ELSE completada_at END
WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo, declaracionPresentada, pagoPresentado, completada],
);
}
export async function createEvidencia(
pool: Pool,
data: CreateEvidenciaInput,
): Promise<{ evidencia: EvidenciaRow; completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean }> {
const obligacion = await getObligacionContribuyente(pool, data.obligacionId);
if (!obligacion) throw new Error('Obligación no encontrada');
if (obligacion.contribuyenteId !== data.contribuyenteId) throw new Error('La obligación no pertenece al contribuyente');
const reqPago = requierePago(obligacion);
const archivo = Buffer.from(data.pdfBase64, 'base64');
const { rows } = await pool.query(
`INSERT INTO obligacion_evidencias
(obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime, notas, subido_por, subido_por_email)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
notas, subido_por, subido_por_email, created_at`,
[data.obligacionId, data.periodo, data.contribuyenteId, data.tipoDocumento, archivo, data.pdfFilename, 'application/pdf', data.notas ?? null, data.subidoPor, data.subidoPorEmail],
);
const status = await updatePeriodoStatus(
pool,
data.obligacionId,
data.periodo,
data.tipoDocumento,
reqPago,
data.subidoPor,
data.notas,
);
return { evidencia: rowToEvidencia(rows[0]), ...status };
}
export async function listEvidencias(
pool: Pool,
contribuyenteId: string,
filters?: { periodo?: string; obligacionId?: string },
): Promise<EvidenciaRow[]> {
const conditions: string[] = ['contribuyente_id = $1'];
const params: unknown[] = [contribuyenteId];
if (filters?.periodo) {
params.push(filters.periodo);
conditions.push(`periodo = $${params.length}`);
}
if (filters?.obligacionId) {
params.push(filters.obligacionId);
conditions.push(`obligacion_id = $${params.length}`);
}
const { rows } = await pool.query(
`SELECT id, obligacion_id, periodo, contribuyente_id, tipo_documento, archivo, archivo_filename, archivo_mime,
notas, subido_por, subido_por_email, created_at
FROM obligacion_evidencias
WHERE ${conditions.join(' AND ')}
ORDER BY created_at DESC`,
params,
);
return rows.map(rowToEvidencia);
}
export async function getEvidenciaPdf(
pool: Pool,
id: number,
): Promise<{ buffer: Buffer; filename: string; mime: string } | null> {
const { rows } = await pool.query(
`SELECT archivo, archivo_filename, archivo_mime FROM obligacion_evidencias WHERE id = $1`,
[id],
);
if (rows.length === 0 || !rows[0].archivo) return null;
return {
buffer: Buffer.from(rows[0].archivo),
filename: rows[0].archivo_filename || `evidencia-${id}.pdf`,
mime: rows[0].archivo_mime || 'application/pdf',
};
}
export async function deleteEvidencia(
pool: Pool,
id: number,
): Promise<{ obligacionId: string; periodo: string } | null> {
const { rows } = await pool.query<{ obligacion_id: string; periodo: string }>(
`DELETE FROM obligacion_evidencias WHERE id = $1 RETURNING obligacion_id, periodo`,
[id],
);
if (rows.length === 0) return null;
const { obligacion_id: obligacionId, periodo } = rows[0];
const obligacion = await getObligacionContribuyente(pool, obligacionId);
if (obligacion) {
const reqPago = requierePago(obligacion);
await recalcPeriodoStatus(pool, obligacionId, periodo, reqPago);
}
return { obligacionId, periodo };
}
export async function getPeriodoStatus(
pool: Pool,
obligacionId: string,
periodo: string,
): Promise<{ completada: boolean; declaracionPresentada: boolean; pagoPresentado: boolean } | null> {
const { rows } = await pool.query<{
completada: boolean;
declaracion_presentada: boolean;
pago_presentado: boolean;
}>(
`SELECT completada, declaracion_presentada, pago_presentado
FROM obligacion_periodos
WHERE obligacion_id = $1 AND periodo = $2`,
[obligacionId, periodo],
);
if (rows.length === 0) return null;
return {
completada: rows[0].completada,
declaracionPresentada: rows[0].declaracion_presentada,
pagoPresentado: rows[0].pago_presentado,
};
}

View File

@@ -1,6 +1,11 @@
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js'; import { OBLIGACIONES_CATALOGO, getRecomendaciones, type ObligacionFiscal } from '../constants/obligaciones-fiscales.js';
function requierePagoPorCatalogo(catalogoId: string | null): boolean {
if (!catalogoId) return true;
return OBLIGACIONES_CATALOGO.find((o) => o.id === catalogoId)?.requierePago ?? true;
}
/** /**
* Keyword-based matching: each catalog entry has discriminant keywords * Keyword-based matching: each catalog entry has discriminant keywords
* that must ALL appear in the SAT description (normalized, lowercase, no accents). * that must ALL appear in the SAT description (normalized, lowercase, no accents).
@@ -255,6 +260,7 @@ export async function initRecomendaciones(
function inferirFrecuencia(vencimiento: string): string { function inferirFrecuencia(vencimiento: string): string {
const lower = vencimiento.toLowerCase(); const lower = vencimiento.toLowerCase();
if (lower.includes('mensual') || lower.includes('mes')) return 'mensual'; if (lower.includes('mensual') || lower.includes('mes')) return 'mensual';
if (lower.includes('cuatrimest')) return 'cuatrimestral';
if (lower.includes('bimest')) return 'bimestral'; if (lower.includes('bimest')) return 'bimestral';
if (lower.includes('trimest')) return 'trimestral'; if (lower.includes('trimest')) return 'trimestral';
if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual'; if (lower.includes('anual') || lower.includes('ejercicio') || lower.includes('tres meses siguientes')) return 'anual';
@@ -351,13 +357,22 @@ export async function getObligacionesPorPeriodo(
const [year, month] = periodo.split('-').map(Number); const [year, month] = periodo.split('-').map(Number);
const currentPeriodo = new Date().toISOString().substring(0, 7); const currentPeriodo = new Date().toISOString().substring(0, 7);
const results: Array<ObligacionContribuyente & { periodStatus: string; periodoAplica: string; declaracion: DeclaracionLink | null }> = []; const results: Array<ObligacionContribuyente & {
periodStatus: string;
periodoAplica: string;
declaracion: DeclaracionLink | null;
declaracionPresentada: boolean;
pagoPresentado: boolean;
requierePago: boolean;
}> = [];
// Get all completion records + associated declaration info for this contribuyente // Get all completion records + associated declaration info for this contribuyente
const { rows: completions } = await pool.query<{ const { rows: completions } = await pool.query<{
obligacion_id: string; obligacion_id: string;
periodo: string; periodo: string;
completada: boolean; completada: boolean;
declaracion_presentada: boolean;
pago_presentado: boolean;
declaracion_id: number | null; declaracion_id: number | null;
decl_año: number | null; decl_año: number | null;
decl_mes: number | null; decl_mes: number | null;
@@ -365,6 +380,7 @@ export async function getObligacionesPorPeriodo(
decl_pdf_filename: string | null; decl_pdf_filename: string | null;
}>(` }>(`
SELECT op.obligacion_id, op.periodo, op.completada, SELECT op.obligacion_id, op.periodo, op.completada,
op.declaracion_presentada, op.pago_presentado,
op.declaracion_id, op.declaracion_id,
dp.año AS decl_año, dp.año AS decl_año,
dp.mes AS decl_mes, dp.mes AS decl_mes,
@@ -377,10 +393,14 @@ export async function getObligacionesPorPeriodo(
`, [contribuyenteId]); `, [contribuyenteId]);
const completionMap = new Map<string, boolean>(); const completionMap = new Map<string, boolean>();
const declaracionPresentadaMap = new Map<string, boolean>();
const pagoPresentadoMap = new Map<string, boolean>();
const declaracionMap = new Map<string, DeclaracionLink | null>(); const declaracionMap = new Map<string, DeclaracionLink | null>();
for (const c of completions) { for (const c of completions) {
const key = `${c.obligacion_id}:${c.periodo}`; const key = `${c.obligacion_id}:${c.periodo}`;
completionMap.set(key, c.completada); completionMap.set(key, c.completada);
declaracionPresentadaMap.set(key, c.declaracion_presentada);
pagoPresentadoMap.set(key, c.pago_presentado);
if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) { if (c.declaracion_id && c.decl_año != null && c.decl_mes != null && c.decl_tipo) {
declaracionMap.set(key, { declaracionMap.set(key, {
id: c.declaracion_id, id: c.declaracion_id,
@@ -407,6 +427,9 @@ export async function getObligacionesPorPeriodo(
periodStatus: isCompleted ? 'completada' : 'pendiente', periodStatus: isCompleted ? 'completada' : 'pendiente',
periodoAplica: periodo, periodoAplica: periodo,
declaracion: declaracionMap.get(key) ?? null, declaracion: declaracionMap.get(key) ?? null,
declaracionPresentada: declaracionPresentadaMap.get(key) === true,
pagoPresentado: pagoPresentadoMap.get(key) === true,
requierePago: requierePagoPorCatalogo(ob.catalogoId),
}); });
} }
@@ -434,6 +457,9 @@ export async function getObligacionesPorPeriodo(
periodStatus: 'atrasada', periodStatus: 'atrasada',
periodoAplica: pastPeriodo, periodoAplica: pastPeriodo,
declaracion: null, declaracion: null,
declaracionPresentada: declaracionPresentadaMap.get(pastKey) === true,
pagoPresentado: pagoPresentadoMap.get(pastKey) === true,
requierePago: requierePagoPorCatalogo(ob.catalogoId),
}); });
} }
} }
@@ -448,7 +474,14 @@ export async function getObligacionesPorPeriodo(
return a.nombre.localeCompare(b.nombre); return a.nombre.localeCompare(b.nombre);
}); });
return results as Array<ObligacionContribuyente & { periodStatus: 'pendiente' | 'completada' | 'atrasada'; periodoAplica: string; declaracion: DeclaracionLink | null }>; return results as Array<ObligacionContribuyente & {
periodStatus: 'pendiente' | 'completada' | 'atrasada';
periodoAplica: string;
declaracion: DeclaracionLink | null;
declaracionPresentada: boolean;
pagoPresentado: boolean;
requierePago: boolean;
}>;
} }
function appliesTo(frecuencia: string | null, periodo: string): boolean { function appliesTo(frecuencia: string | null, periodo: string): boolean {
@@ -457,6 +490,7 @@ function appliesTo(frecuencia: string | null, periodo: string): boolean {
case 'mensual': return true; case 'mensual': return true;
case 'bimestral': return month % 2 === 1; // Jan, Mar, May... case 'bimestral': return month % 2 === 1; // Jan, Mar, May...
case 'trimestral': return [1, 4, 7, 10].includes(month); case 'trimestral': return [1, 4, 7, 10].includes(month);
case 'cuatrimestral': return [1, 5, 9].includes(month);
case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both case 'anual': return month === 3 || month === 4; // March (PM) or April (PF) — show in both
case 'eventual': return false; // Don't auto-show case 'eventual': return false; // Don't auto-show
default: return true; default: return true;

View File

@@ -27,6 +27,11 @@ export interface PapeleriaItem {
aprobadoPor: string | null; aprobadoPor: string | null;
aprobadoAt: Date | null; aprobadoAt: Date | null;
comentarioRechazo: string | null; comentarioRechazo: string | null;
requiereAprobacionCliente: boolean;
estadoCliente: EstadoPapeleria | null;
aprobadoPorCliente: string | null;
aprobadoAtCliente: Date | null;
comentarioRechazoCliente: string | null;
subidoPor: string; subidoPor: string;
createdAt: Date; createdAt: Date;
} }
@@ -36,6 +41,7 @@ const SELECT = `
archivo_filename, archivo_mime, archivo_size, archivo_filename, archivo_mime, archivo_size,
anio, mes, anio, mes,
requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo, requiere_aprobacion, estado, aprobado_por, aprobado_at, comentario_rechazo,
requiere_aprobacion_cliente, estado_cliente, aprobado_por_cliente, aprobado_at_cliente, comentario_rechazo_cliente,
subido_por, created_at subido_por, created_at
`; `;
@@ -54,6 +60,11 @@ const ROW = (r: any): PapeleriaItem => ({
aprobadoPor: r.aprobado_por, aprobadoPor: r.aprobado_por,
aprobadoAt: r.aprobado_at, aprobadoAt: r.aprobado_at,
comentarioRechazo: r.comentario_rechazo, comentarioRechazo: r.comentario_rechazo,
requiereAprobacionCliente: r.requiere_aprobacion_cliente,
estadoCliente: r.estado_cliente,
aprobadoPorCliente: r.aprobado_por_cliente,
aprobadoAtCliente: r.aprobado_at_cliente,
comentarioRechazoCliente: r.comentario_rechazo_cliente,
subidoPor: r.subido_por, subidoPor: r.subido_por,
createdAt: r.created_at, createdAt: r.created_at,
}); });
@@ -69,6 +80,7 @@ export interface UploadInput {
anio: number; anio: number;
mes: number; mes: number;
requiereAprobacion: boolean; requiereAprobacion: boolean;
requiereAprobacionCliente: boolean;
archivo: Buffer; archivo: Buffer;
archivoFilename: string; archivoFilename: string;
archivoMime: string; archivoMime: string;
@@ -87,12 +99,13 @@ export async function uploadPapeleria(
} }
const estadoInicial = input.requiereAprobacion ? 'pendiente' : null; const estadoInicial = input.requiereAprobacion ? 'pendiente' : null;
const estadoClienteInicial = input.requiereAprobacionCliente ? 'pendiente' : null;
const { rows: [r] } = await pool.query( const { rows: [r] } = await pool.query(
`INSERT INTO papeleria_trabajo `INSERT INTO papeleria_trabajo
(contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size, (contribuyente_id, nombre, descripcion, archivo, archivo_filename, archivo_mime, archivo_size,
anio, mes, requiere_aprobacion, estado, subido_por) anio, mes, requiere_aprobacion, estado, requiere_aprobacion_cliente, estado_cliente, subido_por)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING ${SELECT}`, RETURNING ${SELECT}`,
[ [
sanitizeUuid(input.contribuyenteId), sanitizeUuid(input.contribuyenteId),
@@ -106,6 +119,8 @@ export async function uploadPapeleria(
input.mes, input.mes,
input.requiereAprobacion, input.requiereAprobacion,
estadoInicial, estadoInicial,
input.requiereAprobacionCliente,
estadoClienteInicial,
input.subidoPor, input.subidoPor,
], ],
); );
@@ -117,6 +132,8 @@ export interface ListFilters {
anio?: number; anio?: number;
mes?: number; mes?: number;
estado?: EstadoPapeleria | 'sin_aprobacion'; estado?: EstadoPapeleria | 'sin_aprobacion';
entidadIds?: string[];
userRole?: string;
} }
export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> { export async function listPapeleria(pool: Pool, f: ListFilters): Promise<PapeleriaItem[]> {
@@ -126,10 +143,17 @@ export async function listPapeleria(pool: Pool, f: ListFilters): Promise<Papeler
if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); } if (f.anio) { conds.push(`anio = $${i++}`); vals.push(f.anio); }
if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); } if (f.mes) { conds.push(`mes = $${i++}`); vals.push(f.mes); }
if (f.estado === 'sin_aprobacion') { if (f.estado === 'sin_aprobacion') {
conds.push('requiere_aprobacion = false'); conds.push('requiere_aprobacion = false AND requiere_aprobacion_cliente = false');
} else if (f.estado) { } else if (f.estado) {
conds.push(`estado = $${i++}`); vals.push(f.estado); conds.push(`estado = $${i++}`); vals.push(f.estado);
} }
if (f.entidadIds && f.entidadIds.length > 0) {
conds.push(`contribuyente_id = ANY($${i++})`);
vals.push(f.entidadIds);
}
if (f.userRole === 'cliente') {
conds.push('requiere_aprobacion_cliente = true');
}
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT ${SELECT} FROM papeleria_trabajo `SELECT ${SELECT} FROM papeleria_trabajo
WHERE ${conds.join(' AND ')} WHERE ${conds.join(' AND ')}
@@ -202,6 +226,39 @@ export async function rechazar(
return r ? ROW(r) : null; return r ? ROW(r) : null;
} }
export async function aprobarCliente(
pool: Pool,
id: number,
userId: string,
): Promise<PapeleriaItem | null> {
const { rows: [r] } = await pool.query(
`UPDATE papeleria_trabajo
SET estado_cliente = 'aprobado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
comentario_rechazo_cliente = NULL
WHERE id = $1 AND requiere_aprobacion_cliente = true
RETURNING ${SELECT}`,
[id, userId],
);
return r ? ROW(r) : null;
}
export async function rechazarCliente(
pool: Pool,
id: number,
userId: string,
comentario: string | null,
): Promise<PapeleriaItem | null> {
const { rows: [r] } = await pool.query(
`UPDATE papeleria_trabajo
SET estado_cliente = 'rechazado', aprobado_por_cliente = $2, aprobado_at_cliente = NOW(),
comentario_rechazo_cliente = $3
WHERE id = $1 AND requiere_aprobacion_cliente = true
RETURNING ${SELECT}`,
[id, userId, comentario],
);
return r ? ROW(r) : null;
}
export async function eliminar(pool: Pool, id: number): Promise<boolean> { export async function eliminar(pool: Pool, id: number): Promise<boolean> {
const { rowCount } = await pool.query( const { rowCount } = await pool.query(
`DELETE FROM papeleria_trabajo WHERE id = $1`, `DELETE FROM papeleria_trabajo WHERE id = $1`,
@@ -209,3 +266,30 @@ export async function eliminar(pool: Pool, id: number): Promise<boolean> {
); );
return (rowCount ?? 0) > 0; return (rowCount ?? 0) > 0;
} }
/**
* Calcula el estado visual combinado considerando ambas aprobaciones.
*/
export function estadoGlobal(item: PapeleriaItem): 'pendiente' | 'aprobado' | 'rechazado' | null {
const reqOwner = item.requiereAprobacion;
const reqCliente = item.requiereAprobacionCliente;
const estOwner = item.estado;
const estCliente = item.estadoCliente;
if (!reqOwner && !reqCliente) return null;
// Si cualquiera está rechazado, el documento está rechazado
if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado';
// Si ambos requieren aprobación
if (reqOwner && reqCliente) {
if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado';
return 'pendiente';
}
// Solo owner
if (reqOwner) return estOwner;
// Solo cliente
return estCliente;
}

View File

@@ -348,6 +348,17 @@ export async function emitInvoiceIfApplicable(paymentId: string): Promise<void>
data: { facturapiInvoiceId: invoice.id }, data: { facturapiInvoiceId: invoice.id },
}); });
// Enviar factura por email al cliente cuando se factura con datos reales
// (no público en general). Fail-soft: si el envío falla, no bloquea.
if (customer?.email) {
try {
await facturapiService.sendInvoiceByEmail(emitter.id, invoice.id, customer.email);
console.log(`[Invoicing] Factura ${invoice.id} enviada a ${customer.email}`);
} catch (emailErr: any) {
console.error(`[Invoicing] Error enviando factura ${invoice.id} a ${customer.email}:`, emailErr.message || emailErr);
}
}
auditLog({ auditLog({
tenantId: payment.tenantId, tenantId: payment.tenantId,
action: 'invoice.emitted_auto', action: 'invoice.emitted_auto',

View File

@@ -25,6 +25,9 @@ const preApprovalClient = new PreApproval(config);
const paymentClient = new MPPayment(config); const paymentClient = new MPPayment(config);
const preferenceClient = new Preference(config); const preferenceClient = new Preference(config);
/** Límite de la API legacy de preapproval de MercadoPago para MXN. */
export const MP_PREAPPROVAL_MAX_AMOUNT = 10000;
/** /**
* Fallback público para `back_url` cuando `FRONTEND_URL` apunta a localhost. * Fallback público para `back_url` cuando `FRONTEND_URL` apunta a localhost.
* MercadoPago rechaza URLs `http://localhost...` o cualquier dominio no * MercadoPago rechaza URLs `http://localhost...` o cualquier dominio no
@@ -227,6 +230,51 @@ export async function createProrationPreference(params: {
}; };
} }
/**
* Crea una Preference (checkout de pago único) para el pago anual de una
* suscripción. Se usa cuando el monto supera el límite de preapproval ($10k).
* external_reference = `subscription:{tenantId}:{subscriptionId}` para que el
* webhook active el período anual al aprobarse.
*/
export async function createSubscriptionPreference(params: {
tenantId: string;
subscriptionId: string;
plan: string;
amount: number;
payerEmail: string;
}): Promise<{ preferenceId: string; checkoutUrl: string }> {
if (!env.MP_ACCESS_TOKEN) {
throw new Error('MercadoPago no está configurado (falta MP_ACCESS_TOKEN en .env).');
}
const response = await preferenceClient.create({
body: {
items: [
{
id: `subscription-${params.subscriptionId}`,
title: `Horux360 - Plan ${params.plan} - Año completo`,
quantity: 1,
unit_price: params.amount,
currency_id: 'MXN',
},
],
payer: { email: resolvePayerEmail(params.payerEmail) },
external_reference: `subscription:${params.tenantId}:${params.subscriptionId}`,
back_urls: {
success: `${backUrlBase()}/configuracion/suscripcion?subscription=success`,
failure: `${backUrlBase()}/configuracion/suscripcion?subscription=failure`,
pending: `${backUrlBase()}/configuracion/suscripcion?subscription=pending`,
},
auto_return: 'approved',
},
});
return {
preferenceId: response.id!,
checkoutUrl: response.init_point!,
};
}
/** /**
* Crea una Preference (checkout de pago único) para comprar un paquete de * Crea una Preference (checkout de pago único) para comprar un paquete de
* timbres adicionales. external_reference = `timbres-pack:${paymentId}` para * timbres adicionales. external_reference = `timbres-pack:${paymentId}` para

View File

@@ -1,8 +1,9 @@
import { prisma } from '../../config/database.js'; import { prisma, tenantDb } from '../../config/database.js';
import * as mpService from './mercadopago.service.js'; import * as mpService from './mercadopago.service.js';
import { emailService } from '../email/email.service.js'; import { emailService } from '../email/email.service.js';
import { auditLog } from '../../utils/audit.js'; import { auditLog } from '../../utils/audit.js';
import { getTenantOwnerEmail } from '../../utils/memberships.js'; import { getTenantOwnerEmail, getTenantOwnerEmails } from '../../utils/memberships.js';
import { filterRecipientsByRole } from '../notification-preferences.service.js';
import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared'; import { isDespachoPaidPlan, permiteOverage, type DespachoPricePhase } from '@horux/shared';
import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js'; import { despachoPlanTieneDualidadDb, getPrecioDespachoDb } from '../plan-catalogo.service.js';
import { import {
@@ -243,25 +244,76 @@ export async function generatePaymentLink(tenantId: string) {
const ownerEmail = await getTenantOwnerEmail(tenantId); const ownerEmail = await getTenantOwnerEmail(tenantId);
if (!ownerEmail) throw new Error('No admin user found'); if (!ownerEmail) throw new Error('No admin user found');
const subscription = await getActiveSubscription(tenantId); let subscription = await getActiveSubscription(tenantId);
const plan = subscription?.plan || tenant.plan; const plan = (subscription?.plan || tenant.plan) as Plan;
const amount = subscription?.amount || 0; if (plan === 'custom' || plan === 'trial') {
throw new Error('No se puede generar link de pago para el plan actual');
}
if (!amount) throw new Error('No se encontró monto de suscripción'); const frequency = (subscription?.frequency as Frequency) || 'annual';
let amount = subscription?.amount ? Number(subscription.amount) : 0;
if (!amount) {
amount = await getPlanPrice(plan, frequency, 'firstYear');
}
// Los planes Business Control / Enterprise exceden el límite de cobro recurrente
// de MercadoPago ($10k). Para esos montos usamos una Preference de pago único
// anual; el webhook activa el período de 1 año al aprobarse.
if (amount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
if (!subscription) {
subscription = await prisma.subscription.create({
data: {
tenantId,
plan: plan as any,
status: 'pending',
amount,
frequency,
},
});
invalidateSubscriptionCache(tenantId);
}
const mp = await mpService.createSubscriptionPreference({
tenantId,
subscriptionId: subscription.id,
plan,
amount,
payerEmail: ownerEmail,
});
await prisma.subscription.update({
where: { id: subscription.id },
data: { mpPreferenceId: mp.preferenceId, status: 'pending', amount },
});
return { paymentUrl: mp.checkoutUrl };
}
const mp = await mpService.createPreapproval({ const mp = await mpService.createPreapproval({
tenantId, tenantId,
reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`, reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`,
amount, amount,
payerEmail: ownerEmail, payerEmail: ownerEmail,
frequency,
}); });
// Update subscription with new MP preapproval ID
if (subscription) { if (subscription) {
await prisma.subscription.update({ await prisma.subscription.update({
where: { id: subscription.id }, where: { id: subscription.id },
data: { mpPreapprovalId: mp.preapprovalId }, data: { mpPreapprovalId: mp.preapprovalId, status: mp.status || 'pending' },
}); });
} else {
await prisma.subscription.create({
data: {
tenantId,
plan: plan as any,
status: mp.status || 'pending',
amount,
frequency,
mpPreapprovalId: mp.preapprovalId,
},
});
invalidateSubscriptionCache(tenantId);
} }
return { paymentUrl: mp.initPoint }; return { paymentUrl: mp.initPoint };
@@ -462,6 +514,54 @@ export async function subscribe(params: {
? `${tenant.nombre} - Plan ${params.plan} - $${amount.toLocaleString('es-MX')} primer año, $${renewalAmount.toLocaleString('es-MX')} renovaciones` ? `${tenant.nombre} - Plan ${params.plan} - $${amount.toLocaleString('es-MX')} primer año, $${renewalAmount.toLocaleString('es-MX')} renovaciones`
: `Horux360 - Plan ${params.plan} (${params.frequency}) - ${tenant.nombre}`; : `Horux360 - Plan ${params.plan} (${params.frequency}) - ${tenant.nombre}`;
// Planes Business Control / Enterprise superan el límite de cobro recurrente
// de MercadoPago ($10k). Se cobra el año completo vía Preference one-off; el
// webhook activa el período anual tras el primer pago aprobado.
if (amount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
const subscription = await prisma.subscription.create({
data: {
tenantId: params.tenantId,
plan: params.plan,
status: 'pending',
amount,
frequency: params.frequency,
},
});
const mp = await mpService.createSubscriptionPreference({
tenantId: params.tenantId,
subscriptionId: subscription.id,
plan: params.plan,
amount,
payerEmail: params.payerEmail,
});
await prisma.subscription.update({
where: { id: subscription.id },
data: { mpPreferenceId: mp.preferenceId },
});
await prisma.subscription.updateMany({
where: { tenantId: params.tenantId, status: 'trial' },
data: { status: 'trial_converted' },
});
await prisma.tenant.update({
where: { id: params.tenantId },
data: { plan: params.plan },
});
invalidateSubscriptionCache(params.tenantId);
auditLog({
tenantId: params.tenantId,
action: 'subscription.created',
entityType: 'Subscription',
entityId: subscription.id,
metadata: { plan: params.plan, frequency: params.frequency, amount, paymentMethod: 'preference' },
});
return { subscription, paymentUrl: mp.checkoutUrl };
}
const mp = await mpService.createPreapproval({ const mp = await mpService.createPreapproval({
tenantId: params.tenantId, tenantId: params.tenantId,
reason, reason,
@@ -637,8 +737,14 @@ export async function applyApprovedUpgrade(subscriptionId: string): Promise<void
const newPlan = sub.upgradeTargetPlan as Plan; const newPlan = sub.upgradeTargetPlan as Plan;
const newAmount = Number(sub.upgradeTargetAmount); const newAmount = Number(sub.upgradeTargetAmount);
// Actualiza el monto del preapproval en MP (si existe) // Actualiza el monto del preapproval en MP (si existe). Si el nuevo monto
// supera el límite de cobro recurrente de MP ($10k), cancelamos el preapproval
// anterior: el plan alto se cobrará anualmente vía Preference one-off.
if (sub.mpPreapprovalId) { if (sub.mpPreapprovalId) {
if (newAmount > mpService.MP_PREAPPROVAL_MAX_AMOUNT) {
await mpService.cancelPreapproval(sub.mpPreapprovalId);
console.log(`[Upgrade] Preapproval ${sub.mpPreapprovalId} cancelado porque el nuevo monto $${newAmount} supera el límite de MP`);
} else {
try { try {
await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount); await mpService.updatePreapprovalAmount(sub.mpPreapprovalId, newAmount);
} catch (error: any) { } catch (error: any) {
@@ -646,6 +752,7 @@ export async function applyApprovedUpgrade(subscriptionId: string): Promise<void
throw error; // Re-lanza para que MP reintente el webhook throw error; // Re-lanza para que MP reintente el webhook
} }
} }
}
await prisma.$transaction([ await prisma.$transaction([
prisma.subscription.update({ prisma.subscription.update({
@@ -1085,7 +1192,7 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
{ status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } }, { status: 'trial_expired', currentPeriodEnd: { gte: oneDayAgo } },
], ],
}, },
include: { tenant: { select: { nombre: true, rfc: true } } }, include: { tenant: { select: { nombre: true, rfc: true, databaseName: true } } },
}); });
let sent = 0; let sent = 0;
@@ -1129,13 +1236,27 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
// Hay algo que avisar. // Hay algo que avisar.
try { try {
// Para suscripciones de pago, respeta preferencia 'subscription_expiring' del rol owner.
// Para trials siempre avisa al owner (no depende de preferencias de notificación informativa).
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired';
let emailsToNotify: string[] = [];
if (isTrialFlow) {
const ownerEmail = await getTenantOwnerEmail(sub.tenantId); const ownerEmail = await getTenantOwnerEmail(sub.tenantId);
if (!ownerEmail) { if (ownerEmail) emailsToNotify = [ownerEmail];
} else {
const pool = await tenantDb.getPool(sub.tenantId, sub.tenant.databaseName);
const ownerEmails = await getTenantOwnerEmails(sub.tenantId);
const recipientsWithRole = ownerEmails.map(email => ({ email, role: 'owner' as const }));
emailsToNotify = await filterRecipientsByRole(pool, 'subscription_expiring', recipientsWithRole);
}
if (emailsToNotify.length === 0) {
skipped++; skipped++;
continue; continue;
} }
const isTrialFlow = sub.status === 'trial' || sub.status === 'trial_expired'; for (const ownerEmail of emailsToNotify) {
if (isTrialFlow) { if (isTrialFlow) {
if (bucket === 0) { if (bucket === 0) {
await emailService.sendTrialExpired(ownerEmail, { await emailService.sendTrialExpired(ownerEmail, {
@@ -1157,6 +1278,7 @@ export async function sendExpiryReminders(): Promise<{ sent: number; resetOnly:
expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }), expiresAt: sub.currentPeriodEnd.toLocaleDateString('es-MX', { dateStyle: 'long' }),
}); });
} }
}
await prisma.subscription.update({ await prisma.subscription.update({
where: { id: sub.id }, where: { id: sub.id },

View File

@@ -45,7 +45,10 @@ export async function getRegimenesActivosClaves(tenantId: string): Promise<strin
/** /**
* Resuelve las claves de regímenes activos para la alerta de discrepancia. * Resuelve las claves de regímenes activos para la alerta de discrepancia.
* Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated). * Si hay contribuyenteId, lee de contribuyentes.regimen_fiscal (comma-separated).
* Si no, fallback a TenantRegimenActivo (tabla central). * Si no, combina TenantRegimenActivo (tabla central) con los regímenes de
* todos los contribuyentes activos del tenant. Esto evita que la alerta
* aparezca en el correo por-contribuyente pero desaparezca en el dashboard
* cuando no hay un contribuyente seleccionado.
*/ */
export async function getRegimenesActivosClavesEfectivos( export async function getRegimenesActivosClavesEfectivos(
tenantId: string, tenantId: string,
@@ -61,9 +64,49 @@ export async function getRegimenesActivosClavesEfectivos(
if (rows.length > 0 && rows[0].regimen_fiscal) { if (rows.length > 0 && rows[0].regimen_fiscal) {
return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean); return rows[0].regimen_fiscal.split(',').map((c: string) => c.trim()).filter(Boolean);
} }
return []; // Fallback: si el contribuyente no tiene regimen_fiscal, usamos los del tenant
// para no perder la alerta si el campo quedó vacío accidentalmente.
const tenantRegimenes = await getRegimenesActivosClaves(tenantId);
if (tenantRegimenes.length > 0) return tenantRegimenes;
const { rows: allRows } = await pool.query(
`SELECT DISTINCT regimen_fiscal FROM contribuyentes WHERE regimen_fiscal IS NOT NULL AND regimen_fiscal <> ''`,
);
const set = new Set<string>();
for (const row of allRows) {
if (row.regimen_fiscal) {
for (const clave of row.regimen_fiscal.split(',')) {
const trimmed = clave.trim();
if (trimmed) set.add(trimmed);
} }
return getRegimenesActivosClaves(tenantId); }
}
return Array.from(set);
}
const tenantRegimenes = await getRegimenesActivosClaves(tenantId);
// Fallback: si no hay regímenes configurados a nivel tenant, usamos los
// regímenes de todos los contribuyentes activos del tenant.
if (tenantRegimenes.length > 0) {
return tenantRegimenes;
}
const { rows } = await pool.query(
`SELECT DISTINCT regimen_fiscal FROM contribuyentes WHERE regimen_fiscal IS NOT NULL AND regimen_fiscal <> ''`,
);
const set = new Set<string>();
for (const row of rows) {
if (row.regimen_fiscal) {
for (const clave of row.regimen_fiscal.split(',')) {
const trimmed = clave.trim();
if (trimmed) set.add(trimmed);
}
}
}
return Array.from(set);
} }
export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) { export async function setRegimenesActivos(tenantId: string, regimenIds: number[]) {

View File

@@ -72,9 +72,17 @@ export async function querySat(
requestType: 'metadata' | 'cfdi' = 'cfdi' requestType: 'metadata' | 'cfdi' = 'cfdi'
): Promise<QueryResult> { ): Promise<QueryResult> {
try { try {
// El SAT rechaza fechaInicial >= fechaFinal. Como formatDateForSat trunca
// a medianoche, dos fechas dentro del mismo día calendario resultan iguales.
// Ajustamos fechaFin al día siguiente para evitar el error.
let adjustedFechaFin = fechaFin;
if (formatDateForSat(fechaInicio) === formatDateForSat(fechaFin)) {
adjustedFechaFin = new Date(fechaFin.getTime() + 24 * 60 * 60 * 1000);
}
const period = DateTimePeriod.createFromValues( const period = DateTimePeriod.createFromValues(
formatDateForSat(fechaInicio), formatDateForSat(fechaInicio),
formatDateForSat(fechaFin) formatDateForSat(adjustedFechaFin)
); );
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received'); const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
@@ -239,10 +247,11 @@ export async function downloadSatPackage(
} }
/** /**
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss) * Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss).
* El SAT requiere hora 00:00:00; cualquier otra hora causa
* "Fecha final invalida" / "Fecha inicial invalida".
*/ */
function formatDateForSat(date: Date): string { function formatDateForSat(date: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0'); const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} 00:00:00`;
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
} }

View File

@@ -30,20 +30,20 @@ export async function loginSatCsf(
const publicPage = await context.newPage(); const publicPage = await context.newPage();
publicPage.setDefaultTimeout(60_000); publicPage.setDefaultTimeout(60_000);
await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' }); await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle', timeout: 120_000 });
await publicPage.waitForTimeout(2000); await publicPage.waitForTimeout(3000);
// Click acordeón "Obtén tu constancia" / "Obtener constancia" // Click acordeón "Obtén tu constancia" / "Obtener constancia"
const obtenerLocator = publicPage.locator( const obtenerLocator = publicPage.locator(
'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i', 'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i',
).first(); ).first();
await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 }); await obtenerLocator.waitFor({ state: 'visible', timeout: 120_000 });
await obtenerLocator.scrollIntoViewIfNeeded(); await obtenerLocator.scrollIntoViewIfNeeded();
await obtenerLocator.click(); await obtenerLocator.click();
await publicPage.waitForTimeout(1500); await publicPage.waitForTimeout(1500);
// Click "SERVICIO" → popup // Click "SERVICIO" → popup
const popupPromise = context.waitForEvent('page', { timeout: 60_000 }); const popupPromise = context.waitForEvent('page', { timeout: 120_000 });
await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click(); await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click();
const loginPage = await popupPromise; const loginPage = await popupPromise;
await loginPage.waitForLoadState('domcontentloaded'); await loginPage.waitForLoadState('domcontentloaded');
@@ -56,7 +56,7 @@ export async function loginSatCsf(
const efirmaBtn = loginPage const efirmaBtn = loginPage
.locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]') .locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]')
.first(); .first();
await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 }); await efirmaBtn.waitFor({ state: 'visible', timeout: 60_000 });
await efirmaBtn.scrollIntoViewIfNeeded(); await efirmaBtn.scrollIntoViewIfNeeded();
await efirmaBtn.click(); await efirmaBtn.click();
@@ -82,7 +82,7 @@ export async function loginSatCsf(
return rfc !== null && rfc.value.length >= 12; return rfc !== null && rfc.value.length >= 12;
}, },
null, null,
{ timeout: 60_000 }, { timeout: 120_000 },
); );
rfcPopulated = true; rfcPopulated = true;
} catch { } catch {
@@ -121,7 +121,7 @@ export async function loginSatCsf(
// Esperar a que salga del dominio de login y aterrice en el portal SAT // Esperar a que salga del dominio de login y aterrice en el portal SAT
await loginPage.waitForURL( await loginPage.waitForURL(
url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'), url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'),
{ timeout: 60_000 }, { timeout: 120_000 },
); );
await loginPage.waitForLoadState('networkidle').catch(() => undefined); await loginPage.waitForLoadState('networkidle').catch(() => undefined);
await loginPage.waitForTimeout(2000); await loginPage.waitForTimeout(2000);

View File

@@ -299,7 +299,7 @@ async function saveCfdis(
cfdi_tipo_relacion=$88, cfdis_relacionados=$89, cfdi_tipo_relacion=$88, cfdis_relacionados=$89,
last_sat_sync=NOW(), sat_sync_job_id=$90::uuid, last_sat_sync=NOW(), sat_sync_job_id=$90::uuid,
actualizado_en=NOW() actualizado_en=NOW()
WHERE uuid = $1`, WHERE LOWER(uuid) = LOWER($1)`,
[cfdi.uuid, ...vals] [cfdi.uuid, ...vals]
); );
// Re-insert conceptos for updated CFDI // Re-insert conceptos for updated CFDI
@@ -355,7 +355,7 @@ async function saveCfdis(
[...vals, contribuyenteId] [...vals, contribuyenteId]
); );
// Get the inserted cfdi id and save conceptos // Get the inserted cfdi id and save conceptos
const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE uuid = $1`, [cfdi.uuid]); const { rows: [newRow] } = await pool.query(`SELECT id FROM cfdis WHERE LOWER(uuid) = LOWER($1)`, [cfdi.uuid]);
if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi); if (newRow) await saveConceptosWithRetry(pool, newRow.id, cfdi);
inserted++; inserted++;
} }
@@ -609,30 +609,35 @@ async function requestAndDownload(
}); });
let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {}; let existingMap = (jobRow?.satRequestIds as Record<string, string> | null) || {};
// NOTA: se desactivó la reutilización de requestIds de jobs previos porque el SAT
// limita las descargas por solicitud. Reusar un requestId de un job anterior puede
// agotar el límite y devolver "Máximo de descargas permitidas", dejando el recovery
// sin poder descargar. Cada job nuevo crea sus propias solicitudes.
//
// Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente // Si no existe en el job actual, buscar en el job más reciente del mismo tenant/contribuyente
// SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo). // SOLO si el rango de fechas es idéntico (mismo dateFrom/dateTo).
if (!existingMap[kindKey]) { // if (!existingMap[kindKey]) {
const previousJob = await prisma.satSyncJob.findFirst({ // const previousJob = await prisma.satSyncJob.findFirst({
where: { // where: {
tenantId: jobRow?.tenantId, // tenantId: jobRow?.tenantId,
contribuyenteId: jobRow?.contribuyenteId ?? null, // contribuyenteId: jobRow?.contribuyenteId ?? null,
id: { not: jobId }, // id: { not: jobId },
dateFrom: jobRow?.dateFrom, // dateFrom: jobRow?.dateFrom,
dateTo: jobRow?.dateTo, // dateTo: jobRow?.dateTo,
}, // },
orderBy: { createdAt: 'desc' }, // orderBy: { createdAt: 'desc' },
select: { satRequestIds: true }, // select: { satRequestIds: true },
}); // });
if (previousJob?.satRequestIds) { // if (previousJob?.satRequestIds) {
const prevMap = previousJob.satRequestIds as Record<string, string>; // const prevMap = previousJob.satRequestIds as Record<string, string>;
if (prevMap[kindKey]) { // if (prevMap[kindKey]) {
console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`); // console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`);
// Copiar al job actual para futuros usos // // Copiar al job actual para futuros usos
await persistSatRequestId(jobId, kindKey, prevMap[kindKey]); // await persistSatRequestId(jobId, kindKey, prevMap[kindKey]);
existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] }; // existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] };
} // }
} // }
} // }
let requestId: string | null = existingMap[kindKey] || null; let requestId: string | null = existingMap[kindKey] || null;
let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined; let verifyResult: Awaited<ReturnType<typeof verifySatRequest>> | undefined;

View File

@@ -14,10 +14,10 @@ export interface SweepResult {
} }
const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = { const DEFAULT_RUNNING_HOURS_BY_TYPE: Record<string, number> = {
initial: 8, initial: 24,
daily: 4, daily: 4,
incremental: 2, incremental: 2,
custom: 4, custom: 24,
}; };
/** /**

View File

@@ -1,51 +1,59 @@
'use client'; 'use client';
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header'; import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@horux/shared-ui';
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Bell, Loader2 } from 'lucide-react'; import { Bell, Loader2 } from 'lucide-react';
const ROLE_LABELS: Record<string, string> = {
owner: 'Owner',
supervisor: 'Supervisor',
auxiliar: 'Auxiliar',
cliente: 'Cliente',
};
const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending' }> = { const EMAIL_LABELS: Record<string, { label: string; description: string; status: 'active' | 'pending' }> = {
documento_subido: { documento_subido: {
label: 'Documento subido', label: 'Documento subido',
description: 'Notificación cuando se sube una declaración o documento extra del contribuyente.', description: 'Cuando se sube una declaración o documento extra del contribuyente.',
status: 'active', status: 'active',
}, },
weekly_update: { weekly_update: {
label: 'Reporte semanal', label: 'Reporte semanal',
description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.', description: 'Resumen de KPIs, alertas y discrepancias enviado los lunes 8:00 AM.',
status: 'pending', status: 'active',
}, },
subscription_expiring: { subscription_expiring: {
label: 'Vencimiento de suscripción', label: 'Vencimiento de suscripción',
description: 'Aviso cuando la suscripción del despacho está por vencer.', description: 'Aviso cuando la suscripción del despacho está por vencer.',
status: 'pending', status: 'active',
}, },
recordatorio_fiscal: { recordatorio_fiscal: {
label: 'Recordatorios fiscales', label: 'Recordatorios fiscales',
description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).', description: 'Avisos de obligaciones próximas a vencer (declaraciones, pagos provisionales).',
status: 'pending', status: 'pending',
}, },
alertas_nuevas: {
label: 'Alertas nuevas',
description: 'Notificación diaria cuando aparecen alertas fiscales nuevas para un contribuyente.',
status: 'active',
},
recordatorio_proximo: {
label: 'Recordatorios próximos',
description: 'Avisos de recordatorios del calendario a 3, 1 y 0 días de su fecha límite.',
status: 'active',
},
}; };
interface ContribuyentePrefs {
contribuyenteId: string;
rfc: string;
nombre: string;
preferences: Record<string, boolean>;
}
interface ListResponse { interface ListResponse {
emailTypes: string[]; emailTypes: string[];
data: ContribuyentePrefs[]; roles: string[];
preferences: Record<string, Record<string, boolean>>;
} }
export default function NotificacionesPage() { export default function NotificacionesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { selectedContribuyenteId } = useContribuyenteStore();
const { data, isLoading } = useQuery<ListResponse>({ const { data, isLoading } = useQuery<ListResponse>({
queryKey: ['notification-preferences'], queryKey: ['notification-preferences'],
@@ -55,32 +63,23 @@ export default function NotificacionesPage() {
}, },
}); });
// Aplica el filtro del selector global de contribuyente. Si hay uno
// seleccionado, solo se muestra esa fila. "Todos" muestra todos.
const visibles = useMemo(() => {
if (!data) return [];
if (!selectedContribuyenteId) return data.data;
return data.data.filter(c => c.contribuyenteId === selectedContribuyenteId);
}, [data, selectedContribuyenteId]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async ({ contribuyenteId, emailType, enabled }: { contribuyenteId: string; emailType: string; enabled: boolean }) => { mutationFn: async ({ emailType, role, enabled }: { emailType: string; role: string; enabled: boolean }) => {
await apiClient.put('/notificaciones', { await apiClient.put('/notificaciones', { emailType, role, enabled });
contribuyenteId,
preferences: { [emailType]: enabled },
});
}, },
onMutate: async ({ contribuyenteId, emailType, enabled }) => { onMutate: async ({ emailType, role, enabled }) => {
await queryClient.cancelQueries({ queryKey: ['notification-preferences'] }); await queryClient.cancelQueries({ queryKey: ['notification-preferences'] });
const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']); const previous = queryClient.getQueryData<ListResponse>(['notification-preferences']);
if (previous) { if (previous) {
queryClient.setQueryData<ListResponse>(['notification-preferences'], { queryClient.setQueryData<ListResponse>(['notification-preferences'], {
...previous, ...previous,
data: previous.data.map(c => preferences: {
c.contribuyenteId === contribuyenteId ...previous.preferences,
? { ...c, preferences: { ...c.preferences, [emailType]: enabled } } [emailType]: {
: c, ...previous.preferences[emailType],
), [role]: enabled,
},
},
}); });
} }
return { previous }; return { previous };
@@ -93,6 +92,9 @@ export default function NotificacionesPage() {
}, },
}); });
const roles = data?.roles ?? [];
const emailTypes = data?.emailTypes ?? [];
return ( return (
<> <>
<Header title="Notificaciones" /> <Header title="Notificaciones" />
@@ -101,10 +103,10 @@ export default function NotificacionesPage() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Bell className="h-4 w-4" /> <Bell className="h-4 w-4" />
Correos informativos por contribuyente Correos informativos por rol
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Por default todos los correos están activados. Desactiva los que no quieras recibir para cada cliente. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían independientemente de esta configuración. Activa o desactiva cada notificación según el rol del usuario en el despacho. Por default todos están activados. Los correos críticos (welcome, recuperación de contraseña, confirmación de pago) siempre se envían.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
@@ -114,35 +116,30 @@ export default function NotificacionesPage() {
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Cargando... Cargando...
</div> </div>
) : visibles.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
{selectedContribuyenteId
? 'El contribuyente seleccionado no tiene preferencias configuradas todavía.'
: 'No hay contribuyentes en este despacho.'}
</CardContent>
</Card>
) : ( ) : (
visibles.map(contrib => ( <Card>
<Card key={contrib.contribuyenteId}> <CardContent className="p-0 overflow-x-auto">
<CardHeader> <table className="w-full text-sm">
<CardTitle className="text-sm font-medium"> <thead>
{contrib.nombre} <tr className="border-b bg-muted/50">
</CardTitle> <th className="text-left font-medium px-4 py-3 w-1/3">Notificación</th>
<CardDescription className="font-mono text-xs">{contrib.rfc}</CardDescription> {roles.map(role => (
</CardHeader> <th key={role} className="text-center font-medium px-4 py-3 min-w-[100px]">
<CardContent> {ROLE_LABELS[role] ?? role}
<div className="space-y-3"> </th>
{(data?.emailTypes ?? []).map(type => { ))}
</tr>
</thead>
<tbody>
{emailTypes.map(type => {
const meta = EMAIL_LABELS[type]; const meta = EMAIL_LABELS[type];
if (!meta) return null; if (!meta) return null;
const checked = contrib.preferences[type] !== false;
const isPending = meta.status === 'pending'; const isPending = meta.status === 'pending';
return ( return (
<div key={type} className="flex items-start justify-between gap-4 py-2 border-b last:border-0"> <tr key={type} className="border-b last:border-0">
<div className="flex-1 min-w-0"> <td className="px-4 py-3 align-top">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{meta.label}</span> <span className="font-medium">{meta.label}</span>
{isPending && ( {isPending && (
<span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5"> <span className="text-[10px] uppercase tracking-wide bg-muted text-muted-foreground rounded px-1.5 py-0.5">
Próximamente Próximamente
@@ -150,29 +147,37 @@ export default function NotificacionesPage() {
)} )}
</div> </div>
<p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p> <p className="text-xs text-muted-foreground mt-0.5">{meta.description}</p>
</div> </td>
<label className="inline-flex items-center cursor-pointer flex-shrink-0"> {roles.map(role => {
const checked = data?.preferences?.[type]?.[role] !== false;
return (
<td key={role} className="px-4 py-3 text-center align-middle">
<label className={`inline-flex items-center ${isPending ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}>
<input <input
type="checkbox" type="checkbox"
className="sr-only peer" className="sr-only peer"
checked={checked} checked={checked}
disabled={isPending}
onChange={e => onChange={e =>
mutation.mutate({ mutation.mutate({
contribuyenteId: contrib.contribuyenteId,
emailType: type, emailType: type,
role,
enabled: e.target.checked, enabled: e.target.checked,
}) })
} }
/> />
<div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" /> <div className="relative w-10 h-6 bg-muted peer-checked:bg-primary rounded-full peer-focus:ring-2 peer-focus:ring-primary/30 transition-colors after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-transform peer-checked:after:translate-x-4" />
</label> </label>
</div> </td>
); );
})} })}
</div> </tr>
);
})}
</tbody>
</table>
</CardContent> </CardContent>
</Card> </Card>
))
)} )}
</main> </main>
</> </>

View File

@@ -189,6 +189,7 @@ export default function ObligacionesPage() {
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300', bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300', trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300', eventual: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
}; };

View File

@@ -7,6 +7,7 @@ import { apiClient } from '@/lib/api/client';
import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription'; import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription';
import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations'; import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { getSubscriptionState } from '@horux/shared';
type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom'; type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom';
type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus'; type PaidPlan = 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus';
@@ -24,6 +25,7 @@ interface PlanInfo {
dbMode: string; dbMode: string;
trialEndsAt: string | null; trialEndsAt: string | null;
isTrialActive: boolean; isTrialActive: boolean;
planPrice: number | null;
subscription: SubscriptionInfo | null; subscription: SubscriptionInfo | null;
} }
@@ -88,9 +90,14 @@ export default function PlanesDespachoPage() {
// El usuario puede cancelar si tiene una suscripción que aún corre (paid, trial, // El usuario puede cancelar si tiene una suscripción que aún corre (paid, trial,
// custom). Si ya está cancelada o expirada, no hay nada que cancelar. // custom). Si ya está cancelada o expirada, no hay nada que cancelar.
const subStatus = planInfo?.subscription?.status ?? null; const subStatus = planInfo?.subscription?.status ?? null;
const hasActiveSub = subStatus != null const subState = planInfo?.subscription ? getSubscriptionState(planInfo.subscription) : null;
&& subStatus !== 'cancelled' const hasActiveSub = subState?.isActive || subState?.isTrial || subState?.isCancelledInPeriod || false;
&& subStatus !== 'trial_expired'; // Estados en los que se puede generar un link de pago (incluye trial, vencido y pending).
const isPayableStatus = subStatus === 'trial'
|| subStatus === 'trial_expired'
|| subStatus === 'pending'
|| hasActiveSub;
const isCurrentPlanPaid = currentPlan === planInfo?.subscription?.plan && subState?.isActive === true;
/** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su /** Resuelve la frecuencia para un plan. Mi Empresa y Mi Empresa+ leen su
* propio toggle; el resto (business_*) siempre annual. */ * propio toggle; el resto (business_*) siempre annual. */
@@ -105,6 +112,15 @@ export default function PlanesDespachoPage() {
setBusy(plan); setBusy(plan);
setMessage(null); setMessage(null);
try { try {
// Si el plan actual está pendiente de pago, solo regeneramos el link de pago.
if (currentPlan === plan && subState?.isPending) {
return await handlePagarAhora();
}
// Si tiene una sub pendiente en otro plan, no permitir cambiar hasta pagar.
if (subState?.isPending) {
setMessage({ kind: 'err', text: 'Completa el pago del plan actual antes de cambiar de plan.' });
return;
}
// Sin sub activa: subscribe directo → MP (preapproval del plan completo). // Sin sub activa: subscribe directo → MP (preapproval del plan completo).
const result = await subscribeMe({ plan, frequency }); const result = await subscribeMe({ plan, frequency });
window.open(result.paymentUrl, '_blank'); window.open(result.paymentUrl, '_blank');
@@ -190,10 +206,10 @@ export default function PlanesDespachoPage() {
} }
} }
function ActiveBadge() { function CurrentPlanBadge({ pending }: { pending?: boolean }) {
return ( return (
<div className="absolute -top-3 left-1/2 -translate-x-1/2 bg-green-600 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap"> <div className={`absolute -top-3 left-1/2 -translate-x-1/2 text-white text-xs px-3 py-1 rounded-full font-medium whitespace-nowrap ${pending ? 'bg-yellow-600' : 'bg-green-600'}`}>
Plan actual {pending ? 'Plan actual — pendiente' : 'Plan actual'}
</div> </div>
); );
} }
@@ -225,9 +241,25 @@ export default function PlanesDespachoPage() {
function PlanActionButton({ plan }: { plan: PaidPlan }) { function PlanActionButton({ plan }: { plan: PaidPlan }) {
const isCurrent = currentPlan === plan; const isCurrent = currentPlan === plan;
if (isCurrent) { if (isCurrent && isCurrentPlanPaid) {
return <Button disabled className="w-full">Plan actual</Button>; return <Button disabled className="w-full">Plan actual</Button>;
} }
if (isCurrent) {
return (
<Button
className="w-full"
onClick={() => handleContratar(plan)}
disabled={busy === plan}
>
{busy === plan ? 'Procesando...' : (
<>
<ExternalLink className="h-4 w-4 mr-2" />
Pagar este plan
</>
)}
</Button>
);
}
const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar'; const label = hasActiveSub ? 'Cambiar a este plan' : 'Contratar';
return ( return (
<Button <Button
@@ -302,7 +334,7 @@ export default function PlanesDespachoPage() {
)} )}
{/* Banner de suscripción activa */} {/* Banner de suscripción activa */}
{!loading && planInfo?.subscription && hasPaidPlan && (() => { {!loading && planInfo?.subscription && hasPaidPlan && subState?.isActive && (() => {
const sub = planInfo.subscription; const sub = planInfo.subscription;
const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null; const periodEndDate = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
const fechaFormato = periodEndDate const fechaFormato = periodEndDate
@@ -329,18 +361,45 @@ export default function PlanesDespachoPage() {
); );
})()} })()}
{/* Botón "Pagar mi período actual" — visible cuando la sub corre y hay {/* Banner de suscripción pendiente */}
un monto > 0 que cobrar. Crea una MP Preference one-off por el monto {!loading && planInfo?.subscription && hasPaidPlan && subState?.isPending && (
actual (custom $10, paid plan, lo que sea). Útil para pre-pagar antes <div className="flex items-start gap-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
del cobro automático o cuando no hay preapproval recurrente activo. */} <Clock className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
{!loading && hasActiveSub && planInfo?.subscription && Number(planInfo.subscription.amount) > 0 && (() => { <div className="text-sm space-y-0.5">
<div className="font-semibold text-yellow-800 dark:text-yellow-300">
Suscripción pendiente de pago
</div>
<div className="text-yellow-700 dark:text-yellow-400">
Tu suscripción aún no está activa. Completa el pago para evitar la suspensión del servicio.
</div>
</div>
</div>
)}
{/* Banner de trial vencido */}
{!loading && subStatus === 'trial_expired' && hasPaidPlan && (
<div className="flex items-start gap-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg px-4 py-3 max-w-3xl mx-auto">
<Clock className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<span className="font-semibold text-red-800 dark:text-red-300">Tu período de prueba terminó</span>
<span className="text-red-700 dark:text-red-400"> elige un plan o paga el plan actual para recuperar el acceso.</span>
</div>
</div>
)}
{/* Botón "Pagar mi período actual" — visible cuando se puede pagar y hay
un monto definido (subscription.amount > 0 o precio de catálogo).
Crea una MP Preference one-off por el monto actual. */}
{!loading && isPayableStatus && planInfo?.subscription && (() => {
const sub = planInfo.subscription!; const sub = planInfo.subscription!;
const effectiveAmount = Number(sub.amount) > 0 ? Number(sub.amount) : (planInfo.planPrice ?? 0);
if (!effectiveAmount) return null;
const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null; const periodEnd = sub.currentPeriodEnd ? new Date(sub.currentPeriodEnd) : null;
const fechaFmt = periodEnd const fechaFmt = periodEnd
? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' }) ? periodEnd.toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' })
: null; : null;
const dias = periodEnd ? Math.max(0, Math.ceil((periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null; const dias = periodEnd ? Math.max(0, Math.ceil((periodEnd.getTime() - Date.now()) / (1000 * 60 * 60 * 24))) : null;
const montoFmt = Number(sub.amount).toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); const montoFmt = effectiveAmount.toLocaleString('es-MX', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
return ( return (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg px-5 py-4 max-w-3xl mx-auto"> <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg px-5 py-4 max-w-3xl mx-auto">
<CreditCard className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" /> <CreditCard className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
@@ -388,7 +447,7 @@ export default function PlanesDespachoPage() {
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto"> <div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 max-w-7xl mx-auto">
{/* Mi Empresa */} {/* Mi Empresa */}
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa' ? ' ring-2 ring-green-500' : ''}`}> <Card className={`relative flex flex-col${currentPlan === 'mi_empresa' ? ' ring-2 ring-green-500' : ''}`}>
{currentPlan === 'mi_empresa' && <ActiveBadge />} {currentPlan === 'mi_empresa' && <CurrentPlanBadge pending={subState?.isPending} />}
<CardHeader className="text-center pb-2"> <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"> <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" /> <Cloud className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
@@ -422,7 +481,7 @@ export default function PlanesDespachoPage() {
{/* Mi Empresa + */} {/* Mi Empresa + */}
<Card className={`relative flex flex-col${currentPlan === 'mi_empresa_plus' ? ' ring-2 ring-green-500' : ''}`}> <Card className={`relative flex flex-col${currentPlan === 'mi_empresa_plus' ? ' ring-2 ring-green-500' : ''}`}>
{currentPlan === 'mi_empresa_plus' && <ActiveBadge />} {currentPlan === 'mi_empresa_plus' && <CurrentPlanBadge pending={subState?.isPending} />}
<CardHeader className="text-center pb-2"> <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"> <div className="mx-auto bg-teal-100 dark:bg-teal-900 rounded-full p-3 w-fit mb-2">
<Cloud className="h-6 w-6 text-teal-600 dark:text-teal-400" /> <Cloud className="h-6 w-6 text-teal-600 dark:text-teal-400" />
@@ -459,7 +518,7 @@ export default function PlanesDespachoPage() {
{/* Business Control */} {/* Business Control */}
<Card className={`relative flex flex-col${currentPlan === 'business_control' ? ' ring-2 ring-green-500' : ' border-primary ring-2 ring-primary/20'}`}> <Card className={`relative flex flex-col${currentPlan === 'business_control' ? ' ring-2 ring-green-500' : ' border-primary ring-2 ring-primary/20'}`}>
{currentPlan === 'business_control' {currentPlan === 'business_control'
? <ActiveBadge /> ? <CurrentPlanBadge pending={subState?.isPending} />
: ( : (
<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"> <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 Más popular
@@ -494,7 +553,7 @@ export default function PlanesDespachoPage() {
{/* Enterprise (key interna: business_cloud) */} {/* Enterprise (key interna: business_cloud) */}
<Card className={`relative flex flex-col${currentPlan === 'business_cloud' ? ' ring-2 ring-green-500' : ''}`}> <Card className={`relative flex flex-col${currentPlan === 'business_cloud' ? ' ring-2 ring-green-500' : ''}`}>
{currentPlan === 'business_cloud' && <ActiveBadge />} {currentPlan === 'business_cloud' && <CurrentPlanBadge pending={subState?.isPending} />}
<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" />

View File

@@ -131,6 +131,7 @@ export default function ContribuyentesPage() {
<p className="font-semibold">{c.nombre}</p> <p className="font-semibold">{c.nombre}</p>
<p className="text-sm text-muted-foreground font-mono">{c.rfc}</p> <p className="text-sm text-muted-foreground font-mono">{c.rfc}</p>
{c.regimenFiscal && <p className="text-xs text-muted-foreground mt-1">Régimen: {c.regimenFiscal}</p>} {c.regimenFiscal && <p className="text-xs text-muted-foreground mt-1">Régimen: {c.regimenFiscal}</p>}
{c.supervisorNombre && <p className="text-xs text-muted-foreground mt-1">Supervisor: {c.supervisorNombre}</p>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{(user?.role === 'owner' || user?.role === 'cfo') && ( {(user?.role === 'owner' || user?.role === 'cfo') && (

View File

@@ -19,6 +19,8 @@ import {
AlertTriangle, AlertTriangle,
ShoppingCart, ShoppingCart,
CheckSquare, CheckSquare,
FileMinus,
FilePlus,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@horux/shared-ui'; import { cn } from '@horux/shared-ui';
import { FiscalDisclaimer } from '@/components/fiscal-disclaimer'; import { FiscalDisclaimer } from '@/components/fiscal-disclaimer';
@@ -118,6 +120,15 @@ export default function DashboardPage() {
? kpis?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0 ? kpis?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ivaBalance || 0; : kpis?.ivaBalance || 0;
// Notas de crédito
const ncsEmitidasDisplay = regimenSeleccionado
? kpis?.ncsEmitidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ncsEmitidas || 0;
const ncsRecibidasDisplay = regimenSeleccionado
? kpis?.ncsRecibidasPorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpis?.ncsRecibidas || 0;
const ivaAnterior = regimenSeleccionado const ivaAnterior = regimenSeleccionado
? kpisAnterior?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0 ? kpisAnterior?.ivaBalancePorRegimen?.find(r => r.regimenClave === regimenSeleccionado)?.monto || 0
: kpisAnterior?.ivaBalance || 0; : kpisAnterior?.ivaBalance || 0;
@@ -126,9 +137,15 @@ export default function DashboardPage() {
? Math.round(((ivaDisplay - ivaAnterior) / Math.abs(ivaAnterior)) * 10000) / 100 ? Math.round(((ivaDisplay - ivaAnterior) / Math.abs(ivaAnterior)) * 10000) / 100
: null; : null;
const utilidadDisplay = ingresosDisplay - egresosDisplay; // Utilidad ajustada por notas de crédito:
const margenDisplay = ingresosDisplay > 0 // Ingresos netos = Ingresos NCs emitidas
? Math.round((utilidadDisplay / ingresosDisplay) * 10000) / 100 // Egresos netos = Gastos NCs recibidas
// Utilidad neta = Ingresos netos Egresos netos
const ingresosNetosDisplay = ingresosDisplay - ncsEmitidasDisplay;
const egresosNetosDisplay = egresosDisplay - ncsRecibidasDisplay;
const utilidadDisplay = ingresosNetosDisplay - egresosNetosDisplay;
const margenDisplay = ingresosNetosDisplay > 0
? Math.round((utilidadDisplay / ingresosNetosDisplay) * 10000) / 100
: 0; : 0;
const formatCurrency = (value: number) => const formatCurrency = (value: number) =>
@@ -203,7 +220,7 @@ export default function DashboardPage() {
</div> </div>
{/* KPIs */} {/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<KpiCard <KpiCard
title={regimenSeleccionado ? `Ingresos del Mes (${regimenSeleccionado})` : 'Ingresos del Mes'} title={regimenSeleccionado ? `Ingresos del Mes (${regimenSeleccionado})` : 'Ingresos del Mes'}
value={ingresosDisplay} value={ingresosDisplay}
@@ -216,6 +233,13 @@ export default function DashboardPage() {
} }
href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })} href={drillUrl('Ingresos del Mes - CFDIs', { bucket: 'ingresos' })}
/> />
<KpiCard
title={regimenSeleccionado ? `NCs Emitidas (${regimenSeleccionado})` : 'NCs Emitidas'}
value={ncsEmitidasDisplay}
icon={<FileMinus className="h-4 w-4" />}
trend="neutral"
trendValue="Notas de crédito emitidas"
/>
<KpiCard <KpiCard
title={regimenSeleccionado ? `Gastos del Mes (${regimenSeleccionado})` : 'Gastos del Mes'} title={regimenSeleccionado ? `Gastos del Mes (${regimenSeleccionado})` : 'Gastos del Mes'}
value={egresosDisplay} value={egresosDisplay}
@@ -229,11 +253,18 @@ export default function DashboardPage() {
href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })} href={drillUrl('Gastos del Mes - CFDIs', { bucket: 'gastos' })}
/> />
<KpiCard <KpiCard
title="Utilidad" title={regimenSeleccionado ? `NCs Recibidas (${regimenSeleccionado})` : 'NCs Recibidas'}
value={ncsRecibidasDisplay}
icon={<FilePlus className="h-4 w-4" />}
trend="neutral"
trendValue="Notas de crédito recibidas"
/>
<KpiCard
title={regimenSeleccionado ? `Utilidad Neta (${regimenSeleccionado})` : 'Utilidad Neta'}
value={utilidadDisplay} value={utilidadDisplay}
icon={<Wallet className="h-4 w-4" />} icon={<Wallet className="h-4 w-4" />}
trend={utilidadDisplay > 0 ? 'up' : 'down'} trend={utilidadDisplay > 0 ? 'up' : 'down'}
trendValue={`${margenDisplay}% margen`} trendValue={`${margenDisplay}% margen · incluye NCs`}
/> />
<KpiCard <KpiCard
title={regimenSeleccionado ? `Balance IVA (${regimenSeleccionado})` : 'Balance IVA'} title={regimenSeleccionado ? `Balance IVA (${regimenSeleccionado})` : 'Balance IVA'}
@@ -252,7 +283,7 @@ export default function DashboardPage() {
{/* Desglose por régimen */} {/* Desglose por régimen */}
{!regimenSeleccionado && kpis && ( {!regimenSeleccionado && kpis && (
(kpis.ingresosPorRegimen.length > 1 || kpis.egresosPorRegimen.length > 1 || kpis.ivaBalancePorRegimen.length > 1) && ( (kpis.ingresosPorRegimen.length > 1 || kpis.egresosPorRegimen.length > 1 || kpis.ivaBalancePorRegimen.length > 1 || kpis.ncsEmitidasPorRegimen.length > 1 || kpis.ncsRecibidasPorRegimen.length > 1) && (
<div className="grid gap-4 md:grid-cols-2 3xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 3xl:grid-cols-3">
{kpis.ingresosPorRegimen.length > 1 && ( {kpis.ingresosPorRegimen.length > 1 && (
<Card> <Card>
@@ -316,6 +347,46 @@ export default function DashboardPage() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{kpis.ncsEmitidasPorRegimen.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">NCs Emitidas por Regimen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{kpis.ncsEmitidasPorRegimen.map((r) => (
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
<span className="text-sm">{r.regimenDescripcion}</span>
</div>
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{kpis.ncsRecibidasPorRegimen.length > 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">NCs Recibidas por Regimen</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{kpis.ncsRecibidasPorRegimen.map((r) => (
<div key={r.regimenClave} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-3">
<span className="text-xs font-mono font-bold bg-muted px-2 py-1 rounded">{r.regimenClave}</span>
<span className="text-sm">{r.regimenDescripcion}</span>
</div>
<span className="text-sm font-semibold">{formatCurrency(r.monto)}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div> </div>
))} ))}

View File

@@ -23,9 +23,11 @@ import {
import { PapeleriaTab } from '@/components/documentos/papeleria-tab'; import { PapeleriaTab } from '@/components/documentos/papeleria-tab';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as docsApi from '@/lib/api/documentos'; import * as docsApi from '@/lib/api/documentos';
import { getObligacionesPorPeriodo, type ObligacionPeriodo } from '@/lib/api/obligaciones';
const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']; const MESES = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH']; const IMPUESTOS: Impuesto[] = ['IVA', 'ISR', 'IEPS', 'ISN', 'DIOT', 'OTRO', 'ISH'];
const OBLIGACIONES_ROLES_UPLOAD = ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'];
const PERIODICIDADES: { value: Periodicidad; label: string }[] = [ const PERIODICIDADES: { value: Periodicidad; label: string }[] = [
{ value: 'mensual', label: 'Mensual' }, { value: 'mensual', label: 'Mensual' },
{ value: 'bimestral', label: 'Bimestral' }, { value: 'bimestral', label: 'Bimestral' },
@@ -87,7 +89,7 @@ function EstatusBadge({ estatus }: { estatus: string }) {
export default function DocumentosPage() { export default function DocumentosPage() {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const canConsultarOpinion = user?.role === 'owner' || user?.role === 'cfo'; const canConsultarOpinion = user?.role === 'owner' || user?.role === 'cfo';
const canSeePapeleria = user?.role !== 'cliente'; const canSeePapeleria = true; // Todos los roles pueden ver papelería (cliente con restricciones)
return ( return (
<> <>
@@ -504,7 +506,7 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal'); const [tipo, setTipo] = useState<'normal' | 'complementaria'>('normal');
const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual'); const [periodicidad, setPeriodicidad] = useState<Periodicidad>('mensual');
const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i); const yearsOptions = Array.from({ length: 6 }, (_, i) => currentYear - i);
const [impuestos, setImpuestos] = useState<Impuesto[]>([]); const [obligacionesIds, setObligacionesIds] = useState<string[]>([]);
const [montoPago, setMontoPago] = useState(''); const [montoPago, setMontoPago] = useState('');
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [ligaFile, setLigaFile] = useState<File | null>(null); const [ligaFile, setLigaFile] = useState<File | null>(null);
@@ -512,6 +514,15 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const periodOptions = getPeriodOptions(periodicidad); const periodOptions = getPeriodOptions(periodicidad);
const periodo = `${año}-${String(mes).padStart(2, '0')}`;
const obligacionesQ = useQuery({
queryKey: ['obligaciones-periodo-declaracion', selectedContribuyenteId, periodo],
queryFn: () => selectedContribuyenteId
? getObligacionesPorPeriodo(selectedContribuyenteId, periodo, false)
: Promise.resolve({ data: [], periodo }),
enabled: !!selectedContribuyenteId,
});
const handlePeriodicidadChange = (p: Periodicidad) => { const handlePeriodicidadChange = (p: Periodicidad) => {
setPeriodicidad(p); setPeriodicidad(p);
@@ -522,21 +533,21 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
} }
}; };
const toggleImpuesto = (i: Impuesto) => { const toggleObligacion = (id: string) => {
setImpuestos(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i]); setObligacionesIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
}; };
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setErr(null); setErr(null);
if (!file) return setErr('Selecciona el PDF de la declaración'); if (!file) return setErr('Selecciona el PDF de la declaración');
if (impuestos.length === 0) return setErr('Selecciona al menos un impuesto'); if (obligacionesIds.length === 0) return setErr('Selecciona al menos una obligación fiscal');
try { try {
const pdfBase64 = await fileToBase64(file); const pdfBase64 = await fileToBase64(file);
const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined; const ligaPagoBase64 = ligaFile ? await fileToBase64(ligaFile) : undefined;
const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined; const montoNum = montoPago.trim() !== '' ? parseFloat(montoPago) : undefined;
await create.mutateAsync({ await create.mutateAsync({
año, mes, tipo, periodicidad, impuestos, año, mes, tipo, periodicidad, obligacionesIds,
montoPago: montoNum, montoPago: montoNum,
pdfBase64, pdfFilename: file.name, pdfBase64, pdfFilename: file.name,
ligaPagoBase64, ligaPagoBase64,
@@ -606,16 +617,51 @@ function UploadDialog({ onClose }: { onClose: () => void }) {
</div> </div>
<div> <div>
<Label>Impuestos cubiertos</Label> <Label>Obligaciones fiscales cubiertas</Label>
<div className="grid grid-cols-3 gap-2 mt-1"> {!selectedContribuyenteId ? (
{IMPUESTOS.map(i => ( <p className="text-sm text-muted-foreground mt-1">Selecciona un contribuyente para ver sus obligaciones.</p>
<label key={i} className={`flex items-center gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${impuestos.includes(i) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}> ) : obligacionesQ.isLoading ? (
<input type="checkbox" checked={impuestos.includes(i)} onChange={() => toggleImpuesto(i)} className="accent-primary" /> <div className="flex items-center gap-2 text-sm text-muted-foreground mt-2">
{i} <Loader2 className="h-4 w-4 animate-spin" /> Cargando obligaciones...
</div>
) : obligacionesQ.error ? (
<p className="text-sm text-red-600 mt-1">Error al cargar obligaciones.</p>
) : obligacionesQ.data?.data.length === 0 ? (
<p className="text-sm text-muted-foreground mt-1">No hay obligaciones fiscales configuradas para este periodo.</p>
) : (
<div className="space-y-3 mt-2 max-h-60 overflow-y-auto rounded-md border p-3">
{Array.from(new Set((obligacionesQ.data?.data || []).map(o => o.categoria || 'Sin categoría'))).map((categoria) => (
<div key={categoria}>
<p className="text-xs font-semibold uppercase text-muted-foreground mb-1.5">{categoria}</p>
<div className="grid grid-cols-1 gap-2">
{(obligacionesQ.data?.data || [])
.filter(o => (o.categoria || 'Sin categoría') === categoria)
.map((o) => (
<label
key={o.id}
className={`flex items-start gap-2 px-3 py-2 rounded-md border cursor-pointer text-sm ${obligacionesIds.includes(o.id) ? 'bg-primary/10 border-primary' : 'hover:bg-muted'}`}
>
<input
type="checkbox"
checked={obligacionesIds.includes(o.id)}
onChange={() => toggleObligacion(o.id)}
className="accent-primary mt-0.5"
/>
<div className="flex-1">
<span className="font-medium">{o.nombre}</span>
<span className="text-xs text-muted-foreground ml-2 capitalize">({o.frecuencia || '—'})</span>
{o.requierePago && (
<span className="block text-[10px] text-muted-foreground">Requiere comprobante de pago</span>
)}
</div>
</label> </label>
))} ))}
</div> </div>
<p className="text-xs text-muted-foreground mt-1">Selecciona todos los impuestos que incluye esta declaración definen qué recordatorios se desactivan.</p> </div>
))}
</div>
)}
<p className="text-xs text-muted-foreground mt-1">Selecciona las obligaciones fiscales que cubre esta declaración. Al guardar se marcarán como presentadas y, si aplica, quedarán a la espera de su comprobante de pago.</p>
</div> </div>
<div> <div>

View File

@@ -11,6 +11,7 @@ import { formatCurrency, toCfdiDate } from '@/lib/utils';
import { exportToExcel } from '@/lib/export-excel'; import { exportToExcel } from '@/lib/export-excel';
import { useTableSort } from '@horux/shared-ui'; import { useTableSort } from '@horux/shared-ui';
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal'; import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
import { getCfdiById } from '@/lib/api/cfdi';
import { Eye, Download } from 'lucide-react'; import { Eye, Download } from 'lucide-react';
import type { Cfdi } from '@horux/shared'; import type { Cfdi } from '@horux/shared';
@@ -44,6 +45,7 @@ export default function DrillDownPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const titulo = searchParams.get('titulo') || 'Detalle de CFDIs'; const titulo = searchParams.get('titulo') || 'Detalle de CFDIs';
const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null); const [selectedCfdi, setSelectedCfdi] = useState<Cfdi | null>(null);
const [loadingCfdiId, setLoadingCfdiId] = useState<number | null>(null);
const { selectedContribuyenteId } = useContribuyenteStore(); const { selectedContribuyenteId } = useContribuyenteStore();
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -154,7 +156,23 @@ export default function DrillDownPage() {
<td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td> <td className="py-2 text-xs font-mono">{cfdi.regimenEmisor || '-'}</td>
<td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td> <td className="py-2 text-xs font-mono">{cfdi.regimenReceptor || '-'}</td>
<td className="py-2"> <td className="py-2">
<Button variant="ghost" size="sm" onClick={() => setSelectedCfdi(cfdi)} title="Ver factura"> <Button
variant="ghost"
size="sm"
disabled={loadingCfdiId === cfdi.id}
onClick={async () => {
setLoadingCfdiId(cfdi.id);
try {
const fullCfdi = await getCfdiById(String(cfdi.id));
setSelectedCfdi(fullCfdi);
} catch (err) {
console.error('Error cargando CFDI completo:', err);
} finally {
setLoadingCfdiId(null);
}
}}
title="Ver factura"
>
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
</td> </td>

View File

@@ -554,12 +554,26 @@ export default function FacturacionPage() {
? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave)) ? clavesUnidad?.filter(u => !SERVICE_UNITS.includes(u.clave))
: clavesUnidad; : clavesUnidad;
const prodSearchAbort = useRef<AbortController | null>(null);
const handleSearchProduct = async (q: string, idx: number) => { const handleSearchProduct = async (q: string, idx: number) => {
setProdSearch(q); setProdSearch(q);
setSearchingIdx(idx); setSearchingIdx(idx);
if (q.length < 2) { setProdResults([]); return; } setProdResults([]);
const results = await searchClaveProdServ(q); if (q.length < 2) return;
setProdResults(results);
prodSearchAbort.current?.abort();
prodSearchAbort.current = new AbortController();
try {
const results = await searchClaveProdServ(q, prodSearchAbort.current.signal);
setProdResults(results ?? []);
} catch (err: any) {
if (err.name !== 'AbortError' && err.code !== 'ERR_CANCELED') {
console.error('Error buscando clave SAT:', err);
}
setProdResults([]);
}
}; };
const selectProduct = (idx: number, clave: string, descripcion: string) => { const selectProduct = (idx: number, clave: string, descripcion: string) => {
@@ -1418,6 +1432,7 @@ export default function FacturacionPage() {
onChange={e => handleSearchProduct(e.target.value, idx)} onChange={e => handleSearchProduct(e.target.value, idx)}
onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }} onFocus={() => { setSearchingIdx(idx); setProdSearch(c.productKey); }}
placeholder="Buscar clave SAT..." placeholder="Buscar clave SAT..."
autoComplete="off"
required required
/> />
<Search className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute right-3 top-2.5 h-4 w-4 text-muted-foreground" />

View File

@@ -147,6 +147,7 @@ export default function PendientesPage() {
mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', mensual: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300', bimestral: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300', trimestral: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
cuatrimestral: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', anual: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
}; };
return f ? ( return f ? (

View File

@@ -77,10 +77,14 @@ export default function UsuariosPage() {
const deleteUsuario = useDeleteUsuario(); const deleteUsuario = useDeleteUsuario();
const isDespacho = isDespachoTenant(currentUser?.tenantRfc); const isDespacho = isDespachoTenant(currentUser?.tenantRfc);
const inviteRoles = isDespacho ? despachoInviteRoles : legacyInviteRoles; const inviteRoles = isDespacho
? (currentUser?.role === 'supervisor'
? despachoInviteRoles.filter(r => r.value === 'cliente')
: despachoInviteRoles)
: legacyInviteRoles;
const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor'; const defaultInviteRole = isDespacho ? 'auxiliar' : 'visor';
const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo'; const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo' || currentUser?.role === 'supervisor';
const [showInvite, setShowInvite] = useState(false); const [showInvite, setShowInvite] = useState(false);
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({ const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({
@@ -96,15 +100,18 @@ export default function UsuariosPage() {
const [savingAccesos, setSavingAccesos] = useState(false); const [savingAccesos, setSavingAccesos] = useState(false);
// Edit supervisor modal (para auxiliares) // Edit supervisor modal (para auxiliares)
const [editingSupervisorUser, setEditingSupervisorUser] = useState<{ id: string; nombre: string } | null>(null); const [editingSupervisorUser, setEditingSupervisorUser] = useState<{ id: string; nombre: string; supervisorNombre?: string | null } | null>(null);
const [selectedSupervisorId, setSelectedSupervisorId] = useState<string>(''); const [selectedSupervisorId, setSelectedSupervisorId] = useState<string>('');
const [savingSupervisor, setSavingSupervisor] = useState(false); const [savingSupervisor, setSavingSupervisor] = useState(false);
const [currentSupervisorNombre, setCurrentSupervisorNombre] = useState<string>('');
const openEditSupervisor = async (userId: string, nombre: string) => { const openEditSupervisor = async (userId: string, nombre: string) => {
try { try {
const res = await apiClient.get<{ supervisorUserId: string | null }>(`/usuarios/${userId}/supervisor`); const res = await apiClient.get<{ supervisorUserId: string | null; supervisorNombre: string | null }>(`/usuarios/${userId}/supervisor`);
setSelectedSupervisorId(res.data.supervisorUserId ?? ''); setSelectedSupervisorId(res.data.supervisorUserId ?? '');
setEditingSupervisorUser({ id: userId, nombre }); setCurrentSupervisorNombre(res.data.supervisorNombre ?? '');
setEditingSupervisorUser({ id: userId, nombre, supervisorNombre: res.data.supervisorNombre });
} catch { } catch {
alert('Error al cargar supervisor'); alert('Error al cargar supervisor');
} }
@@ -483,7 +490,14 @@ export default function UsuariosPage() {
<div className="space-y-2 py-2"> <div className="space-y-2 py-2">
{supervisores && supervisores.length > 0 ? ( {supervisores && supervisores.length > 0 ? (
<Select value={selectedSupervisorId || 'none'} onValueChange={(v) => setSelectedSupervisorId(v === 'none' ? '' : v)}> <Select value={selectedSupervisorId || 'none'} onValueChange={(v) => setSelectedSupervisorId(v === 'none' ? '' : v)}>
<SelectTrigger><SelectValue placeholder="Selecciona un supervisor..." /></SelectTrigger> <SelectTrigger className="w-full">
{(() => {
if (!selectedSupervisorId || selectedSupervisorId === 'none') return <span className="text-muted-foreground">Sin supervisor asignado</span>;
const s = supervisores?.find(x => x.userId === selectedSupervisorId);
if (s) return <span>{s.nombre} {s.email}</span>;
return <span>{currentSupervisorNombre || editingSupervisorUser?.supervisorNombre || selectedSupervisorId}</span>;
})()}
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">Sin supervisor asignado</SelectItem> <SelectItem value="none">Sin supervisor asignado</SelectItem>
{supervisores.map(s => ( {supervisores.map(s => (
@@ -491,6 +505,12 @@ export default function UsuariosPage() {
{s.nombre} {s.email} {s.nombre} {s.email}
</SelectItem> </SelectItem>
))} ))}
{/* Si el supervisor actual no está en la lista de carteras, mostrarlo igual */}
{selectedSupervisorId && !supervisores.some(s => s.userId === selectedSupervisorId) && (
<SelectItem value={selectedSupervisorId}>
{currentSupervisorNombre || editingSupervisorUser?.supervisorNombre || selectedSupervisorId}
</SelectItem>
)}
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (

View File

@@ -33,6 +33,16 @@ export function ContribuyenteSelector() {
} }
}, [contribuyentes, selectedContribuyenteId, setSelectedContribuyente]); }, [contribuyentes, selectedContribuyenteId, setSelectedContribuyente]);
// Clear invalid selection (e.g. stale localStorage from another tenant/session)
useEffect(() => {
if (contribuyentes && contribuyentes.length > 0 && selectedContribuyenteId) {
const exists = contribuyentes.some(c => c.id === selectedContribuyenteId);
if (!exists) {
clearSelectedContribuyente();
}
}
}, [contribuyentes, selectedContribuyenteId, clearSelectedContribuyente]);
if (isLoading || !contribuyentes || contribuyentes.length === 0) return null; if (isLoading || !contribuyentes || contribuyentes.length === 0) return null;
if (pathname && HIDDEN_PATHS.some(p => pathname === p || pathname.startsWith(`${p}/`))) return null; if (pathname && HIDDEN_PATHS.some(p => pathname === p || pathname.startsWith(`${p}/`))) return null;

View File

@@ -10,7 +10,7 @@ import {
import { apiClient } from '@/lib/api/client'; import { apiClient } from '@/lib/api/client';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { useContribuyenteStore } from '@/stores/contribuyente-store'; import { useContribuyenteStore } from '@/stores/contribuyente-store';
import { Upload, Download, Trash2, CheckCircle2, XCircle, Clock, AlertTriangle, MessageSquare } from 'lucide-react'; import { Upload, Download, Trash2, CheckCircle2, XCircle, Clock, AlertTriangle, MessageSquare, UserCheck } from 'lucide-react';
const MESES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre']; const MESES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
const ALLOWED_MIMES = [ const ALLOWED_MIMES = [
@@ -37,6 +37,9 @@ interface Papeleria {
requiereAprobacion: boolean; requiereAprobacion: boolean;
estado: 'pendiente' | 'aprobado' | 'rechazado' | null; estado: 'pendiente' | 'aprobado' | 'rechazado' | null;
comentarioRechazo: string | null; comentarioRechazo: string | null;
requiereAprobacionCliente: boolean;
estadoCliente: 'pendiente' | 'aprobado' | 'rechazado' | null;
comentarioRechazoCliente: string | null;
subidoPor: string; subidoPor: string;
createdAt: string; createdAt: string;
} }
@@ -54,28 +57,59 @@ function fileToBase64(file: File): Promise<string> {
}); });
} }
function EstadoBadge({ estado, requiereAprobacion }: { estado: string | null; requiereAprobacion: boolean }) { function estadoGlobal(item: Papeleria): 'sin_aprobacion' | 'pendiente' | 'aprobado' | 'rechazado' {
if (!requiereAprobacion) { const reqOwner = item.requiereAprobacion;
const reqCliente = item.requiereAprobacionCliente;
const estOwner = item.estado;
const estCliente = item.estadoCliente;
if (!reqOwner && !reqCliente) return 'sin_aprobacion';
if (estOwner === 'rechazado' || estCliente === 'rechazado') return 'rechazado';
if (reqOwner && reqCliente) {
if (estOwner === 'aprobado' && estCliente === 'aprobado') return 'aprobado';
return 'pendiente';
}
if (reqOwner) return estOwner ?? 'pendiente';
return estCliente ?? 'pendiente';
}
function EstadoBadge({ item }: { item: Papeleria }) {
const global = estadoGlobal(item);
if (global === 'sin_aprobacion') {
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-muted-foreground">Sin aprobación</span>; return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-muted text-muted-foreground">Sin aprobación</span>;
} }
if (estado === 'aprobado') { if (global === 'aprobado') {
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> Aprobado</span>; return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"><CheckCircle2 className="h-3 w-3" /> Aprobado</span>;
} }
if (estado === 'rechazado') { if (global === 'rechazado') {
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"><XCircle className="h-3 w-3" /> Rechazado</span>; return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"><XCircle className="h-3 w-3" /> Rechazado</span>;
} }
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><Clock className="h-3 w-3" /> Pendiente</span>;
// Pendiente — mostrar quién falta
const faltaOwner = item.requiereAprobacion && item.estado !== 'aprobado';
const faltaCliente = item.requiereAprobacionCliente && item.estadoCliente !== 'aprobado';
let label = 'Pendiente';
if (faltaOwner && faltaCliente) label = 'Pendiente (ambos)';
else if (faltaOwner) label = 'Pendiente (owner)';
else if (faltaCliente) label = 'Pendiente (cliente)';
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"><Clock className="h-3 w-3" /> {label}</span>;
} }
export function PapeleriaTab() { export function PapeleriaTab() {
const user = useAuthStore(s => s.user); const user = useAuthStore(s => s.user);
const { selectedContribuyenteId } = useContribuyenteStore(); const { selectedContribuyenteId } = useContribuyenteStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const canApprove = user?.role ? ROLES_APROBADOR.has(user.role) : false; const isCliente = user?.role === 'cliente';
const canApproveOwner = user?.role ? ROLES_APROBADOR.has(user.role) : false;
const canUpload = !isCliente;
const [showUpload, setShowUpload] = useState(false); const [showUpload, setShowUpload] = useState(false);
const [rechazoFor, setRechazoFor] = useState<Papeleria | null>(null); const [rechazoFor, setRechazoFor] = useState<Papeleria | null>(null);
const [comentarioRechazo, setComentarioRechazo] = useState(''); const [comentarioRechazo, setComentarioRechazo] = useState('');
const [rechazoClienteFor, setRechazoClienteFor] = useState<Papeleria | null>(null);
const [comentarioRechazoCliente, setComentarioRechazoCliente] = useState('');
// Filtros // Filtros
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -105,6 +139,7 @@ export function PapeleriaTab() {
const [anio, setAnio] = useState(currentYear); const [anio, setAnio] = useState(currentYear);
const [mes, setMes] = useState(new Date().getMonth() + 1); const [mes, setMes] = useState(new Date().getMonth() + 1);
const [requiereAprobacion, setRequiereAprobacion] = useState(false); const [requiereAprobacion, setRequiereAprobacion] = useState(false);
const [requiereAprobacionCliente, setRequiereAprobacionCliente] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null); const [uploadError, setUploadError] = useState<string | null>(null);
const resetUpload = () => { const resetUpload = () => {
@@ -114,6 +149,7 @@ export function PapeleriaTab() {
setAnio(currentYear); setAnio(currentYear);
setMes(new Date().getMonth() + 1); setMes(new Date().getMonth() + 1);
setRequiereAprobacion(false); setRequiereAprobacion(false);
setRequiereAprobacionCliente(false);
setUploadError(null); setUploadError(null);
}; };
@@ -130,6 +166,7 @@ export function PapeleriaTab() {
anio, anio,
mes, mes,
requiereAprobacion, requiereAprobacion,
requiereAprobacionCliente,
archivoBase64: base64, archivoBase64: base64,
archivoFilename: file.name, archivoFilename: file.name,
archivoMime: file.type, archivoMime: file.type,
@@ -172,11 +209,33 @@ export function PapeleriaTab() {
}, },
}); });
const aprobarClienteMutation = useMutation({
mutationFn: async (id: number) => apiClient.post(`/papeleria/${id}/aprobar-cliente`),
onSuccess: invalidate,
});
const rechazarClienteMutation = useMutation({
mutationFn: async ({ id, comentario }: { id: number; comentario: string | null }) =>
apiClient.post(`/papeleria/${id}/rechazar-cliente`, { comentario }),
onSuccess: () => {
setRechazoClienteFor(null);
setComentarioRechazoCliente('');
invalidate();
},
});
const eliminarMutation = useMutation({ const eliminarMutation = useMutation({
mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`), mutationFn: async (id: number) => apiClient.delete(`/papeleria/${id}`),
onSuccess: invalidate, onSuccess: invalidate,
}); });
const items = query.data ?? [];
const años = useMemo(() => {
const set = new Set<number>([currentYear]);
items.forEach(i => set.add(i.anio));
return [...set].sort((a, b) => b - a);
}, [items, currentYear]);
if (!selectedContribuyenteId) { if (!selectedContribuyenteId) {
return ( return (
<Card> <Card>
@@ -187,13 +246,6 @@ export function PapeleriaTab() {
); );
} }
const items = query.data ?? [];
const años = useMemo(() => {
const set = new Set<number>([currentYear]);
items.forEach(i => set.add(i.anio));
return [...set].sort((a, b) => b - a);
}, [items, currentYear]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Filtros + upload */} {/* Filtros + upload */}
@@ -236,9 +288,11 @@ export function PapeleriaTab() {
</select> </select>
</div> </div>
</div> </div>
{canUpload && (
<Button onClick={() => setShowUpload(true)}> <Button onClick={() => setShowUpload(true)}>
<Upload className="h-4 w-4 mr-2" /> Subir documento <Upload className="h-4 w-4 mr-2" /> Subir documento
</Button> </Button>
)}
</div> </div>
{/* Listado */} {/* Listado */}
@@ -258,7 +312,7 @@ export function PapeleriaTab() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{it.nombre}</span> <span className="text-sm font-medium">{it.nombre}</span>
<EstadoBadge estado={it.estado} requiereAprobacion={it.requiereAprobacion} /> <EstadoBadge item={it} />
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{MESES[it.mes - 1]} {it.anio} {MESES[it.mes - 1]} {it.anio}
</span> </span>
@@ -270,10 +324,31 @@ export function PapeleriaTab() {
{it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB {it.archivoFilename} · {(it.archivoSize / 1024).toFixed(0)} KB
· subido {new Date(it.createdAt).toLocaleDateString('es-MX')} · subido {new Date(it.createdAt).toLocaleDateString('es-MX')}
</p> </p>
{/* Mostrar estado detallado para no-clientes */}
{!isCliente && (it.requiereAprobacion || it.requiereAprobacionCliente) && (
<div className="flex items-center gap-2 mt-1">
{it.requiereAprobacion && (
<span className={`text-xs inline-flex items-center gap-1 ${it.estado === 'aprobado' ? 'text-green-700 dark:text-green-400' : it.estado === 'rechazado' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400'}`}>
<UserCheck className="h-3 w-3" /> Owner: {it.estado ?? '—'}
</span>
)}
{it.requiereAprobacionCliente && (
<span className={`text-xs inline-flex items-center gap-1 ${it.estadoCliente === 'aprobado' ? 'text-green-700 dark:text-green-400' : it.estadoCliente === 'rechazado' ? 'text-red-700 dark:text-red-400' : 'text-yellow-700 dark:text-yellow-400'}`}>
<UserCheck className="h-3 w-3" /> Cliente: {it.estadoCliente ?? '—'}
</span>
)}
</div>
)}
{it.estado === 'rechazado' && it.comentarioRechazo && ( {it.estado === 'rechazado' && it.comentarioRechazo && (
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400"> <p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" /> <MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span>{it.comentarioRechazo}</span> <span><strong>Owner:</strong> {it.comentarioRechazo}</span>
</p>
)}
{it.estadoCliente === 'rechazado' && it.comentarioRechazoCliente && (
<p className="text-xs mt-1 flex items-start gap-1 text-red-700 dark:text-red-400">
<MessageSquare className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span><strong>Cliente:</strong> {it.comentarioRechazoCliente}</span>
</p> </p>
)} )}
</div> </div>
@@ -281,7 +356,8 @@ export function PapeleriaTab() {
<Button variant="ghost" size="icon" onClick={() => downloadMutation.mutate(it)} title="Descargar"> <Button variant="ghost" size="icon" onClick={() => downloadMutation.mutate(it)} title="Descargar">
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
{canApprove && it.requiereAprobacion && it.estado === 'pendiente' && ( {/* Botones owner/supervisor */}
{canApproveOwner && it.requiereAprobacion && it.estado === 'pendiente' && (
<> <>
<Button <Button
variant="ghost" size="icon" variant="ghost" size="icon"
@@ -299,6 +375,26 @@ export function PapeleriaTab() {
</Button> </Button>
</> </>
)} )}
{/* Botones cliente */}
{isCliente && it.requiereAprobacionCliente && it.estadoCliente === 'pendiente' && (
<>
<Button
variant="ghost" size="icon"
onClick={() => aprobarClienteMutation.mutate(it.id)}
title="Aprobar"
>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost" size="icon"
onClick={() => setRechazoClienteFor(it)}
title="Rechazar"
>
<XCircle className="h-4 w-4 text-red-600" />
</Button>
</>
)}
{canUpload && (
<Button <Button
variant="ghost" size="icon" variant="ghost" size="icon"
onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)} onClick={() => confirm(`¿Eliminar "${it.nombre}"?`) && eliminarMutation.mutate(it.id)}
@@ -306,6 +402,7 @@ export function PapeleriaTab() {
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -375,6 +472,14 @@ export function PapeleriaTab() {
/> />
Este documento requiere aprobación de owner/supervisor Este documento requiere aprobación de owner/supervisor
</label> </label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={requiereAprobacionCliente}
onChange={e => setRequiereAprobacionCliente(e.target.checked)}
/>
Este documento requiere aprobación del cliente
</label>
{uploadError && ( {uploadError && (
<p className="text-xs text-destructive flex items-start gap-1"> <p className="text-xs text-destructive flex items-start gap-1">
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" /> <AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
@@ -394,7 +499,7 @@ export function PapeleriaTab() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Modal Rechazo */} {/* Modal Rechazo Owner */}
<Dialog open={!!rechazoFor} onOpenChange={(o) => { if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}> <Dialog open={!!rechazoFor} onOpenChange={(o) => { if (!o) { setRechazoFor(null); setComentarioRechazo(''); } }}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -426,6 +531,39 @@ export function PapeleriaTab() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Modal Rechazo Cliente */}
<Dialog open={!!rechazoClienteFor} onOpenChange={(o) => { if (!o) { setRechazoClienteFor(null); setComentarioRechazoCliente(''); } }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rechazar documento</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm">
Vas a rechazar <strong>{rechazoClienteFor?.nombre}</strong>. El comentario es opcional.
</p>
<div>
<Label>Comentario (opcional)</Label>
<Input
value={comentarioRechazoCliente}
onChange={e => setComentarioRechazoCliente(e.target.value)}
placeholder="Motivo del rechazo..."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setRechazoClienteFor(null); setComentarioRechazoCliente(''); }}>
Cancelar
</Button>
<Button
onClick={() => rechazoClienteFor && rechazarClienteMutation.mutate({ id: rechazoClienteFor.id, comentario: comentarioRechazoCliente || null })}
className={cn('bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
>
Rechazar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -59,7 +59,7 @@ const navigation: NavItem[] = [
{ name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' }, { name: 'Documentos', href: '/documentos', icon: FileCheck, feature: 'documentos' },
{ name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] }, { name: 'Carteras', href: '/carteras', icon: ClipboardList, roles: ['supervisor', 'auxiliar'] },
{ name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] }, { name: 'Contribuyentes', href: '/contribuyentes', icon: Building2, roles: ['owner', 'cfo', 'supervisor', 'contador', 'auxiliar'] },
{ name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo'] }, { name: 'Usuarios', href: '/usuarios', icon: Users, roles: ['owner', 'cfo', 'supervisor', 'auxiliar'] },
{ name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] }, { name: 'Tareas', href: '/tareas', icon: CheckSquare2, roles: ['owner', 'cfo', 'contador', 'auxiliar', 'supervisor'] },
{ name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] }, { name: 'Planes', href: '/configuracion/planes-despacho', icon: CreditCard, roles: ['owner', 'cfo'] },
{ name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] }, { name: 'Configuracion', href: '/configuracion', icon: Settings, roles: ['owner', 'cfo', 'supervisor', 'auxiliar', 'cliente'] },

View File

@@ -123,20 +123,6 @@ export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null
onSuccess: invalidate, onSuccess: invalidate,
}); });
const completarMutation = useMutation({
mutationFn: async (periodoId: string) => apiClient.post(`/tareas/periodo/${periodoId}/completar`),
onSuccess: invalidate,
onError: (err: unknown) => {
const e = err as { response?: { data?: { message?: string } } };
alert(e.response?.data?.message || 'No se pudo marcar como completada');
},
});
const descompletarMutation = useMutation({
mutationFn: async (periodoId: string) => apiClient.delete(`/tareas/periodo/${periodoId}/completar`),
onSuccess: invalidate,
});
const handleEdit = (t: Tarea) => { const handleEdit = (t: Tarea) => {
setEditingId(t.id); setEditingId(t.id);
setForm({ setForm({
@@ -206,16 +192,11 @@ export function TareasTab({ contribuyenteId }: { contribuyenteId: string | null
return ( return (
<Card key={t.id}> <Card key={t.id}>
<CardContent className="py-3 flex items-center gap-3"> <CardContent className="py-3 flex items-center gap-3">
<button <div className="flex-shrink-0" title={p?.completada ? 'Completada' : atrasada ? 'Atrasada' : 'Pendiente'}>
onClick={() => p && (p.completada ? descompletarMutation.mutate(p.id) : completarMutation.mutate(p.id))}
disabled={!p || completarMutation.isPending}
title={p?.completada ? 'Marcar pendiente' : 'Marcar completada'}
className="flex-shrink-0"
>
{p?.completada {p?.completada
? <CheckCircle2 className="h-5 w-5 text-success" /> ? <CheckCircle2 className="h-5 w-5 text-success" />
: <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />} : <Circle className={`h-5 w-5 ${atrasada ? 'text-destructive' : 'text-muted-foreground'}`} />}
</button> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className={`text-sm font-medium ${p?.completada ? 'line-through text-muted-foreground' : ''}`}> <span className={`text-sm font-medium ${p?.completada ? 'line-through text-muted-foreground' : ''}`}>

View File

@@ -20,5 +20,6 @@ export const getMetodosPago = () => apiClient.get<CatalogoItem[]>('/catalogos/me
export const getUsosCfdi = () => apiClient.get<UsoCfdiItem[]>('/catalogos/uso-cfdi').then(r => r.data); export const getUsosCfdi = () => apiClient.get<UsoCfdiItem[]>('/catalogos/uso-cfdi').then(r => r.data);
export const getMonedas = () => apiClient.get<MonedaItem[]>('/catalogos/moneda').then(r => r.data); export const getMonedas = () => apiClient.get<MonedaItem[]>('/catalogos/moneda').then(r => r.data);
export const getClavesUnidad = () => apiClient.get<CatalogoItem[]>('/catalogos/clave-unidad').then(r => r.data); export const getClavesUnidad = () => apiClient.get<CatalogoItem[]>('/catalogos/clave-unidad').then(r => r.data);
export const searchClaveProdServ = (q: string) => apiClient.get<CatalogoItem[]>(`/catalogos/clave-prod-serv?q=${encodeURIComponent(q)}`).then(r => r.data); export const searchClaveProdServ = (q: string, signal?: AbortSignal) =>
apiClient.get<CatalogoItem[]>(`/catalogos/clave-prod-serv?q=${encodeURIComponent(q)}`, { signal }).then(r => r.data);
export const getObjetosImp = () => apiClient.get<CatalogoItem[]>('/catalogos/objeto-imp').then(r => r.data); export const getObjetosImp = () => apiClient.get<CatalogoItem[]>('/catalogos/objeto-imp').then(r => r.data);

View File

@@ -6,6 +6,7 @@ export interface Contribuyente {
nombre: string; nombre: string;
identificador: string; identificador: string;
supervisorUserId: string | null; supervisorUserId: string | null;
supervisorNombre: string | null;
active: boolean; active: boolean;
createdAt: string; createdAt: string;
rfc: string; rfc: string;

View File

@@ -28,7 +28,10 @@ export interface CreateDeclaracionData {
mes: number; mes: number;
tipo: 'normal' | 'complementaria'; tipo: 'normal' | 'complementaria';
periodicidad?: Periodicidad; periodicidad?: Periodicidad;
impuestos: Impuesto[]; /** Legacy: se infiere en backend si se envían obligacionesIds. */
impuestos?: Impuesto[];
/** Obligaciones fiscales que cubre esta declaración. */
obligacionesIds?: string[];
montoPago?: number; montoPago?: number;
pdfBase64: string; pdfBase64: string;
pdfFilename: string; pdfFilename: string;

View File

@@ -0,0 +1,47 @@
import { apiClient } from './client';
export interface DeclaracionLink {
id: number;
año: number;
mes: number;
tipo: 'normal' | 'complementaria';
pdfFilename: string | null;
}
export interface ObligacionPeriodo {
id: string;
nombre: string;
frecuencia: string | null;
fechaLimite: string | null;
categoria: string | null;
activa: boolean;
esRecomendada: boolean;
completada: boolean;
completadaAt: string | null;
completadaPor: string | null;
periodoCompletado: string | null;
periodStatus: 'pendiente' | 'completada' | 'atrasada';
periodoAplica: string;
declaracion: DeclaracionLink | null;
declaracionPresentada: boolean;
pagoPresentado: boolean;
requierePago: boolean;
}
export interface ObligacionesPorPeriodoResponse {
data: ObligacionPeriodo[];
periodo: string;
}
export function getObligacionesPorPeriodo(
contribuyenteId: string,
periodo: string,
atrasados = false,
): Promise<ObligacionesPorPeriodoResponse> {
const params = new URLSearchParams();
params.set('periodo', periodo);
params.set('atrasados', String(atrasados));
return apiClient
.get<ObligacionesPorPeriodoResponse>(`/contribuyentes/${contribuyenteId}/obligaciones/periodo?${params}`)
.then((r) => r.data);
}

View File

@@ -7,7 +7,8 @@
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@horux/shared": "workspace:*", "@horux/shared": "workspace:*",

346
docs/CAMBIOS-2026-05-04.md Normal file
View File

@@ -0,0 +1,346 @@
# Resumen de cambios - 4 de mayo de 2026
---
## 1. Catálogo de obligaciones fiscales: nuevas obligaciones predefinidas
**Fecha:** 2026-05-04
Se agregaron 3 obligaciones fiscales predefinidas al catálogo maestro.
### Obligaciones agregadas
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|---|---|---|---|---|---|---|---|
| `isrtp` | Impuesto sobre remuneración al trabajo | mensual | Día 10 del mes siguiente | PM y PF | Estatal | Ninguna | No |
| `ish` | ISH - Impuesto Sobre Hospedaje | mensual | Día 15 del mes siguiente | PM y PF | Estatal | Ninguna | No |
| `sipare` | SIPARE - Cuotas obrero-patronales | mensual | Día 15 del mes siguiente | PM y PF | Seguridad social | Con empleados | No |
### Archivo modificado
| Archivo | Cambio |
|---|---|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregaron las 3 entradas al array `OBLIGACIONES_CATALOGO` |
---
## 2. Fix: Suscripciones `pending` se mostraban como activas en /configuracion/planes-despacho
**Fecha:** 2026-06-18
### Problema
En la página **Configuración Planes**, las suscripciones con estado `pending` (primer pago aún no completado) mostraban el banner verde **"Suscripción activa"** y el badge **"Plan actual"** en verde, dando la impresión de que el plan estaba pagado y vigente.
### Causa
El frontend evaluaba `subStatus === 'authorized' || subStatus === 'pending'` para mostrar el banner de activa, y consideraba `pending` como "plan actual pagado" (`isCurrentPlanPaid`).
### Solución
- Se derivó el estado real de la suscripción con `getSubscriptionState()` de `@horux/shared`.
- El banner **"Suscripción activa"** ahora solo aparece cuando la suscripción está realmente `authorized` y dentro de su período.
- Se agregó un banner amarillo **"Suscripción pendiente de pago"** para estados `pending`.
- El badge del plan actual cambia a amarillo y muestra **"Plan actual — pendiente"** cuando la suscripción está pendiente.
- El botón **"Cancelar suscripción"** ya no se muestra para suscripciones `pending`.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` | Lógica de estado de suscripción, banners y badges |
---
## 3. Fix: Botón "Pagar este plan" fallaba para suscripciones `pending`
**Fecha:** 2026-06-18
### Problema
Al hacer clic en **"Pagar este plan"** en una suscripción con estado `pending`, se mostraba el error:
**"No hay suscripción activa para cambiar"** en lugar de abrir MercadoPago.
### Causa
El flujo `handleContratar` intentaba crear una nueva suscripción (`subscribeMe`), pero el backend rechazaba porque ya existía una `pending`. El frontend entonces caía en `upgradeMe` y luego `changeMyPlan`, ambos validan que haya una suscripción `authorized` o `trial``pending` no califica, por eso el error.
### Solución
En `handleContratar`:
- Si el usuario selecciona el plan actual y la suscripción está `pending`, se llama directamente a `generatePaymentLink` para regenerar el link de pago de MercadoPago.
- Si el usuario intenta cambiar a otro plan estando `pending`, se muestra:
*"Completa el pago del plan actual antes de cambiar de plan."*
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx` | Lógica de `handleContratar` para estados `pending` |
---
## 4. Adjuntar PDFs en el correo de declaración subida
**Fecha:** 2026-05-04
### Cambio
Cuando se sube una declaración provisional (`POST /api/documentos/declaraciones`), el correo de notificación a owners y supervisor ahora incluye como adjuntos:
- El **acuse de declaración** (`pdf_declaracion`).
- La **liga de pago** (`pdf_liga_pago`), si se subió.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `packages/core/src/email/transport.ts` | `EmailTransport.send` acepta un arreglo opcional de `EmailAttachment` y lo pasa a `nodemailer.sendMail` |
| `apps/api/src/services/email/email.service.ts` | `sendEmail` y `sendDocumentoSubido` aceptan y reenvían `attachments` |
| `apps/api/src/services/notify-upload.service.ts` | Nueva función `buildDeclaracionAttachments` que lee los PDFs de `declaraciones_provisionales` y los pasa al correo |
| `apps/api/src/controllers/documentos.controller.ts` | Se pasa `declaracionId` a `notifyDocumentoSubido` para poder recuperar los PDFs |
### Notas
- Los documentos extra (`POST /api/documentos/extras`) **no** incluyen adjuntos; solo cambia el flujo de declaraciones.
- Si los adjuntos superan los 20 MB, se omiten y se deja un aviso en el cuerpo del correo para evitar rechazos por límite de SMTP.
## 5. Nueva obligación: FONACOT
**Fecha:** 2026-05-04
### Cambio
Se agregó la obligación `fonacot` al catálogo maestro de obligaciones fiscales.
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|---|---|---|---|---|---|---|---|
| `fonacot` | Crédito FONACOT | Mensual | Día 5 del mes siguiente | PM/PF | Créditos de los trabajadores | Con empleados | ❌ |
### Archivo modificado
| Archivo | Cambio |
|---|---|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `fonacot` en la sección **Créditos de los trabajadores** |
## 6. Nueva obligación: Aviso de actividades vulnerables
**Fecha:** 2026-05-04
### Cambio
Se agregó la obligación `actividades-vulnerables` al catálogo maestro.
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|---|---|---|---|---|---|---|---|
| `actividades-vulnerables` | Aviso de actividades vulnerables | Mensual | Día 17 del mes siguiente | PM/PF | Federal mensual | — | ❌ |
### Archivo modificado
| Archivo | Cambio |
|---|---|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `actividades-vulnerables` en la sección **Federales mensuales** |
## 7. Nueva obligación: Declaración Informativa de transparencia
**Fecha:** 2026-05-04
### Cambio
Se agregó la obligación `declaracion-transparencia` al catálogo maestro.
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|---|---|---|---|---|---|---|---|
| `declaracion-transparencia` | Declaración Informativa de transparencia | Anual | Día 31 de mayo | PM | Federal anual | — | ❌ |
### Archivo modificado
| Archivo | Cambio |
|---|---|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `declaracion-transparencia` en la sección **Anuales PM** |
## 8. Nueva obligación: Declaración Informativa Múltiple del IEPS (trimestral)
**Fecha:** 2026-05-04
### Cambio
Se agregó la obligación `ieps-trimestral` al catálogo maestro.
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|---|---|---|---|---|---|---|---|
| `ieps-trimestral` | Declaración Informativa Múltiple del IEPS | Trimestral | Día 17 de abril, julio, octubre y enero | PM/PF | Federal trimestral | — | ❌ |
### Archivo modificado
| Archivo | Cambio |
|---|---|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Se agregó la entrada `ieps-trimestral` en la nueva sección **Federales trimestrales** |
## 9. Nueva obligación: SISUB y soporte de frecuencia cuatrimestral
**Fecha:** 2026-05-04
### Cambio
Se agregó la obligación `sisub` al catálogo y se extendió el sistema para soportar obligaciones con frecuencia **cuatrimestral**.
| ID | Nombre | Frecuencia | Fecha límite | Aplica a | Categoría | Condición | Recomendada por defecto |
|---|---|---|---|---|---|---|---|
| `sisub` | Sistema de Información de Subcontratación | Cuatrimestral | Día 17 de enero, mayo y septiembre | PM/PF | Seguridad social | Con empleados | ❌ |
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Agregada `sisub` y `cuatrimestral` al union type de `frecuencia` |
| `apps/api/src/services/obligaciones.service.ts` | `inferirFrecuencia` y `appliesTo` soportan `cuatrimestral` |
| `apps/api/src/services/calendario-fiscal.service.ts` | Generación de eventos para meses cuatrimestrales (`1, 5, 9`) |
| `apps/api/src/services/alertas-manuales.service.ts` | `appliesToPeriod` soporta `cuatrimestral` |
| `apps/api/src/services/declaraciones.service.ts` | `Periodicidad` incluye `cuatrimestral` |
| `apps/api/src/controllers/documentos.controller.ts` | Schema de declaraciones acepta `cuatrimestral` |
| `apps/api/src/migrations/tenant/052_declaraciones_cuatrimestral.sql` | CHECK de `periodicidad` permite `cuatrimestral` |
| `apps/web/app/(dashboard)/configuracion/obligaciones/page.tsx` | Badge de frecuencia `cuatrimestral` |
| `apps/web/app/(dashboard)/pendientes/page.tsx` | Badge de frecuencia `cuatrimestral` |
## 10. Fix: sincronización SAT — tipos de CFDI, UUID case-insensitive y reutilización de requestIds
**Fecha:** 2026-05-04
### Cambios
- La verificación de CFDIs incompletos (`hasIncompleteCfdis` / `getOldestIncompleteCfdiDate`) ahora incluye los tipos de comprobante **P** (pago) y **N** (nómina), además de **I** (ingreso) y **E** (egreso).
- Al guardar/actualizar CFDIs, la comparación de `uuid` se hace con `LOWER()` para evitar duplicados por diferencias de mayúsculas/minúsculas.
- Se desactivó la reutilización de `requestId` de jobs SAT previos. Reusarlos puede agotar el límite de descargas del SAT y devolver **"Máximo de descargas permitidas"**, bloqueando el recovery.
- Se exportó `runRecoverySyncJob` para permitir su invocación manual desde scripts.
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/jobs/sat-sync.job.ts` | Incluir `P` y `N` en consultas de CFDIs incompletos; exportar `runRecoverySyncJob` |
| `apps/api/src/services/sat/sat.service.ts` | Comparación `LOWER(uuid)`; comentar reutilización de `requestId` |
---
## 11. Fix: drill-down de CFDIs carga el CFDI completo al visualizar
**Fecha:** 2026-05-04
### Problema
En la vista de drill-down, al hacer clic en el ojo para ver un CFDI se usaba únicamente el objeto resumen de la lista, que no incluye conceptos ni todos los detalles.
### Solución
Ahora se llama a `getCfdiById(id)` para obtener el CFDI completo antes de abrir el visor, y se muestra un estado de carga mientras se resuelve la petición.
### Archivo modificado
| Archivo | Cambio |
|---|---|
| `apps/web/app/(dashboard)/drill-down/page.tsx` | Carga completa del CFDI al hacer clic en "Ver factura" |
---
## 12. Scripts de soporte: Demo Ventas y operaciones
**Fecha:** 2026-05-04
Se crearon varios scripts de utilería bajo `apps/api/scripts/` para tareas de soporte y configuración de la cuenta Demo Ventas.
### Scripts principales
| Script | Propósito |
|---|---|
| `create-demo-ventas.ts` | Crea el tenant Demo Ventas, su BD, usuario owner y suscripción custom gratuita |
| `update-demo-ventas.ts` | Agrega usuarios supervisor/auxiliar/cliente y 5 contribuyentes adicionales a Demo Ventas |
| `seed-demo-obligaciones-tareas.ts` | Siembra obligaciones fiscales y tareas recurrentes para todos los contribuyentes de Demo Ventas |
| `fix-demo-carteras-asignaciones.ts` | Crea la subcartera del auxiliar y asigna contribuyentes, obligaciones y tareas de forma válida |
| `reset-demo-asignaciones.ts` | Deja Demo Ventas en estado "tutorial": elimina subcarteras, asignaciones y relación auxiliar-supervisor |
| `change-user-email.ts` | Cambia el correo de un usuario, genera contraseña temporal e invalida sesiones |
| `resend-welcome.ts` | Reenvía el correo de bienvenida a un usuario |
> Estos scripts no son parte del flujo productivo; se ejecutan manualmente vía `npx tsx`.
## 13. Automatización de cierre de obligaciones fiscales
**Fecha:** 2026-05-04
### Cambio
Se automatiza el cierre de **todas las obligaciones fiscales** desde la sección existente **Documentos Declaraciones**. Al subir una declaración o su comprobante de pago, el sistema crea automáticamente evidencias en `obligacion_evidencias` y actualiza el estado de cada obligación fiscal en `obligacion_periodos`.
### Reglas de cierre deterministas
- `requierePago = false` (informativas): se marcan completadas al subir la declaración (`declaracion`).
- `requierePago = true` (pago + declaración): la declaración marca `declaracion_presentada = true`; el periodo se cierra al subir el comprobante de pago (`pago`).
- Al subir una declaración con **monto $0**, se marca el pago como presentado automáticamente.
### Nuevas tablas y columnas
| Migración | Descripción |
|---|---|
| `053_obligacion_evidencias.sql` | Tabla genérica para evidencias de obligaciones (declaración, pago, acuse, complemento) |
| `054_obligacion_periodos_estados.sql` | Agrega `declaracion_presentada`, `pago_presentado` y `evidencia_id` a `obligacion_periodos` |
| `055_declaracion_obligaciones.sql` | Relaciona declaraciones provisionales con las obligaciones fiscales que cierran |
### Nuevos endpoints (uso interno / futuro)
| Método | Endpoint | Descripción |
|---|---|---|
| `GET` | `/api/documentos/obligacion-evidencias` | Listar evidencias por contribuyente/periodo/obligación |
| `POST` | `/api/documentos/obligacion-evidencias` | Subir nueva evidencia |
| `GET` | `/api/documentos/obligacion-evidencias/:id/pdf` | Descargar PDF de evidencia |
| `DELETE` | `/api/documentos/obligacion-evidencias/:id` | Eliminar evidencia y recalcular estado del periodo |
### Archivos creados
| Archivo | Cambio |
|---|---|
| `apps/api/src/services/obligacion-evidencias.service.ts` | Servicio para crear/listar/descargar/eliminar evidencias y actualizar `obligacion_periodos` |
| `apps/api/src/migrations/tenant/053_obligacion_evidencias.sql` | Tabla `obligacion_evidencias` |
| `apps/api/src/migrations/tenant/054_obligacion_periodos_estados.sql` | Columnas de estado en `obligacion_periodos` |
| `apps/api/src/migrations/tenant/055_declaracion_obligaciones.sql` | Relación declaración ↔ obligación |
| `apps/web/lib/api/obligaciones.ts` | Cliente API para obtener obligaciones por periodo |
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/constants/obligaciones-fiscales.ts` | Campo `requierePago` en todas las obligaciones del catálogo |
| `apps/api/src/services/declaraciones.service.ts` | Crea evidencias en las obligaciones seleccionadas; vincula declaración con obligaciones; mantiene fallback legacy por impuestos |
| `apps/api/src/services/obligaciones.service.ts` | `getObligacionesPorPeriodo` devuelve `requierePago`, `declaracionPresentada`, `pagoPresentado` |
| `apps/api/src/services/notify-upload.service.ts` | Soporte para notificaciones de `obligacion_evidencia` |
| `apps/api/src/services/email/templates/documento-subido.ts` | Template para evidencias de obligación |
| `apps/api/src/controllers/documentos.controller.ts` | Schema de declaraciones acepta `obligacionesIds` |
| `apps/api/src/routes/documentos.routes.ts` | Rutas de evidencias |
| `apps/web/lib/api/declaraciones.ts` | `CreateDeclaracionData` acepta `obligacionesIds` |
| `apps/web/app/(dashboard)/documentos/page.tsx` | Diálogo de subida reemplaza “Impuestos cubiertos” por selector de obligaciones fiscales del periodo |
## 15. Fix: quitar toggle de completado en Configuración Obligaciones fiscales Tareas
**Fecha:** 2026-06-22
### Problema
En **Configuración Obligaciones fiscales Tareas** seguía apareciendo el botón para marcar tareas como completadas/pendientes manualmente, pero el estado de las obligaciones fiscales ahora se actualiza automáticamente desde **Documentos Declaraciones**.
### Solución
- Se convirtió el icono de check/círculo en un indicador visual de estado (completada, pendiente, atrasada) sin interacción.
- Se eliminaron las mutaciones de completar/descompletar periodo del frontend.
### Archivo modificado
| Archivo | Cambio |
|---|---|
| `apps/web/components/obligaciones/tareas-tab.tsx` | Icono de estado estático; eliminados `completarMutation` y `descompletarMutation` |
## 14. Fix: sugerencias de Clave Producto SAT en facturación
**Fecha:** 2026-06-22
### Problema
En **Facturación Conceptos**, el campo **Clave Producto SAT** no mostraba sugerencias al escribir.
### Causa
La tabla `cat_clave_prod_serv` de la BD central estaba vacía; el catálogo nunca se había importado.
### Solución
- Se importó el catálogo oficial CFDI 4.0 (`c_ClaveProdServ`) desde los recursos de **phpcfdi/resources-sat-catalogs** (52,513 registros).
- Se creó el script `apps/api/scripts/import-clave-prod-serv.ts` para importaciones futuras.
- Se hizo más robusto el autocomplete del campo:
- `AbortController` para cancelar búsquedas anteriores.
- Manejo de errores y `autoComplete="off"`.
- Se sanitizó el fallback regex en el backend para evitar errores con caracteres especiales.
### Archivos creados
| Archivo | Cambio |
|---|---|
| `apps/api/scripts/import-clave-prod-serv.ts` | Importa el catálogo desde CSV a PostgreSQL |
### Archivos modificados
| Archivo | Cambio |
|---|---|
| `apps/api/src/controllers/catalogos.controller.ts` | Escapa regex en búsqueda fallback; búsqueda por clave insensible a mayúsculas |
| `apps/web/lib/api/catalogos.ts` | `searchClaveProdServ` acepta `AbortSignal` |
| `apps/web/app/(dashboard)/facturacion/page.tsx` | `handleSearchProduct` con `AbortController`, try/catch y `autoComplete="off"` |
## Deploy
```bash
cd /root/HoruxDespachosNuevo
pnpm --filter @horux/core build
pnpm --filter api build
pnpm --filter web build
npx tsx apps/api/scripts/migrate-tenants.ts
pm2 reload horux-api
pm2 reload horux-web
```
**Estado:** ✅ Exitoso

View File

@@ -8,8 +8,13 @@ export interface SmtpConfig {
from: string; from: string;
} }
export interface EmailAttachment {
filename: string;
content: Buffer;
}
export interface EmailTransport { export interface EmailTransport {
send(to: string, subject: string, html: string): Promise<void>; send(to: string, subject: string, html: string, attachments?: EmailAttachment[]): Promise<void>;
} }
export function createEmailTransport(config: SmtpConfig | null): EmailTransport { export function createEmailTransport(config: SmtpConfig | null): EmailTransport {
@@ -21,7 +26,11 @@ export function createEmailTransport(config: SmtpConfig | null): EmailTransport
console.warn('[EMAIL] SMTP not configured. Emails will be logged to console.'); console.warn('[EMAIL] SMTP not configured. Emails will be logged to console.');
return { return {
sendMail: async (opts: any) => { sendMail: async (opts: any) => {
console.log('[EMAIL] Would send:', { to: opts.to, subject: opts.subject }); console.log('[EMAIL] Would send:', {
to: opts.to,
subject: opts.subject,
attachments: opts.attachments?.map((a: any) => a.filename ?? a.path),
});
return { messageId: 'mock' }; return { messageId: 'mock' };
}, },
} as any; } as any;
@@ -42,7 +51,7 @@ export function createEmailTransport(config: SmtpConfig | null): EmailTransport
} }
return { return {
async send(to: string, subject: string, html: string) { async send(to: string, subject: string, html: string, attachments?: EmailAttachment[]) {
const transport = getTransporter(); const transport = getTransporter();
try { try {
await transport.sendMail({ await transport.sendMail({
@@ -51,7 +60,9 @@ export function createEmailTransport(config: SmtpConfig | null): EmailTransport
subject, subject,
html, html,
text: html.replace(/<[^>]*>/g, ''), text: html.replace(/<[^>]*>/g, ''),
attachments,
}); });
console.log(`[EMAIL] Sent email to ${to} with ${attachments?.length ?? 0} attachment(s)`);
} catch (error) { } catch (error) {
console.error('[EMAIL] Error sending email:', error); console.error('[EMAIL] Error sending email:', error);
} }

View File

@@ -33,6 +33,10 @@ export interface KpiData {
cfdisEmitidosPorRegimen: { regimen: string; total: number }[]; cfdisEmitidosPorRegimen: { regimen: string; total: number }[];
cfdisRecibidos: number; cfdisRecibidos: number;
cfdisRecibidosPorRegimen: { regimen: string; total: number }[]; cfdisRecibidosPorRegimen: { regimen: string; total: number }[];
ncsEmitidas: number;
ncsEmitidasPorRegimen: IngresoRegimen[];
ncsRecibidas: number;
ncsRecibidasPorRegimen: IngresoRegimen[];
} }
export interface IngresosEgresosData { export interface IngresosEgresosData {