From 9f11a0ba3987211c8b4b1302f2c50be1bc9490cd Mon Sep 17 00:00:00 2001 From: Horux Dev Date: Sat, 9 May 2026 21:56:42 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20facturaci=C3=B3n=20primer=20pago,=20fix?= =?UTF-8?q?es=20SAT/MP,=20autocompletado=20RFCs/conceptos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Notificación email al admin cuando llega primer pago aprobado (sin factura auto) - Endpoints GET /pagos-sin-factura y POST /emitir-factura-pago para admin global - Fix vinculación org Facturapi Horux 360 (69f23a5a242e0af47a41fa0d) - Fix webhook MP: validación defensiva de x-signature header - Fix autocompleto RFCs: eliminado filtro por contribuyenteId - Fix autocompleto conceptos: eliminado filtro por contribuyenteId - SAT fixes: anti-bot CSF scraper, request reuse, date range fix, stale job thresholds - SAT sync request reuse across jobs para evitar agotar cuota diaria - Typo fix MP_ACCESS_TOKEN en .env - Trial invitations system backend Frontend: - Nueva página /admin/facturas-pendientes con tabla y emisión manual - Métrica 'Facturas pendientes' en /clientes (clickable) - Navegación onboarding FIEL/CSD corregida - Sidebar themes sincronizados - Fix SAT portal migration scraper (NetIQ) - Trial invitation acceptance pages --- .../migration.sql | 32 ++ apps/api/prisma/schema.prisma | 23 ++ apps/api/prisma/seed.ts | 4 +- apps/api/src/app.ts | 2 + .../src/controllers/audit-log.controller.ts | 2 +- .../controllers/despacho-stats.controller.ts | 11 +- .../src/controllers/despacho.controller.ts | 34 +- .../src/controllers/facturacion.controller.ts | 171 +++++++-- apps/api/src/controllers/sat.controller.ts | 4 +- .../controllers/subscription.controller.ts | 4 +- .../api/src/controllers/tenants.controller.ts | 21 +- .../trial-invitations.controller.ts | 130 +++++++ .../src/controllers/usuarios.controller.ts | 2 +- apps/api/src/middlewares/tenant.middleware.ts | 8 +- apps/api/src/routes/despacho-stats.routes.ts | 9 +- apps/api/src/routes/facturacion.routes.ts | 4 + .../src/routes/trial-invitations.routes.ts | 22 ++ .../src/services/admin-dashboard.service.ts | 2 +- apps/api/src/services/connector.service.ts | 6 +- apps/api/src/services/constancia.service.ts | 32 +- apps/api/src/services/despacho.service.ts | 33 +- apps/api/src/services/email/email.service.ts | 23 ++ .../email/templates/primer-pago-facturar.ts | 55 +++ .../email/templates/trial-invitation.ts | 63 ++++ .../src/services/payment/invoicing.service.ts | 22 +- .../services/payment/mercadopago.service.ts | 1 + apps/api/src/services/sat/sat-csf-login.ts | 36 +- apps/api/src/services/sat/sat-csf-parser.ts | 21 +- .../src/services/sat/sat-parser.service.ts | 36 +- apps/api/src/services/sat/sat.service.ts | 76 +++- .../services/sat/sweep-stale-jobs.service.ts | 36 +- apps/api/src/services/tenants.service.ts | 39 +- .../src/services/trial-invitations.service.ts | 191 ++++++++++ apps/api/src/utils/platform-admin.ts | 7 +- apps/web/app/(auth)/login/page.tsx | 4 +- .../web/app/(auth)/register-despacho/page.tsx | 335 ++---------------- apps/web/app/(auth)/reset-password/page.tsx | 12 +- .../admin/facturas-pendientes/page.tsx | 169 +++++++++ .../admin/invitaciones-trial/page.tsx | 260 ++++++++++++++ .../app/(dashboard)/admin/usuarios/page.tsx | 6 +- apps/web/app/(dashboard)/cfdi/page.tsx | 14 +- apps/web/app/(dashboard)/clientes/page.tsx | 165 +++++---- .../app/(dashboard)/configuracion/page.tsx | 2 +- .../configuracion/planes-despacho/page.tsx | 63 +++- .../app/(dashboard)/contribuyentes/page.tsx | 2 +- .../despachos/contribuyentes/page.tsx | 52 ++- .../despachos/mis-asignados/page.tsx | 7 +- apps/web/app/(dashboard)/despachos/page.tsx | 5 +- apps/web/app/(dashboard)/onboarding/page.tsx | 12 +- apps/web/app/(dashboard)/usuarios/page.tsx | 20 +- .../web/app/invitacion/trial/[token]/page.tsx | 200 +++++++++++ apps/web/components/cfdi/cfdi-invoice.tsx | 4 +- .../web/components/cfdi/cfdi-viewer-modal.tsx | 2 +- .../components/despachos/despacho-subnav.tsx | 16 +- .../components/layouts/sidebar-compact.tsx | 44 ++- .../components/layouts/sidebar-floating.tsx | 42 ++- apps/web/components/layouts/sidebar.tsx | 3 + apps/web/components/layouts/topnav.tsx | 42 ++- apps/web/lib/api/facturacion.ts | 17 + apps/web/lib/api/tenants.ts | 10 + apps/web/lib/api/trial-invitations.ts | 70 ++++ apps/web/lib/hooks/use-pagos-sin-factura.ts | 22 ++ apps/web/lib/hooks/use-tenants.ts | 5 + docs/CAMBIOS-2026-05-09.md | 194 ++++++++++ ecosystem.config.js | 6 +- .../shared/src/constants/despacho-plans.ts | 4 +- scripts/monitoreo_sat_3am.sh | 41 +++ scripts/reprocess_bom.js | 187 ++++++++++ scripts/reprocess_duplicate_timbres.js | 164 +++++++++ scripts/test_auza_sync.js | 47 +++ 70 files changed, 2801 insertions(+), 609 deletions(-) create mode 100644 apps/api/prisma/migrations/20260507201624_add_trial_invitations/migration.sql create mode 100644 apps/api/src/controllers/trial-invitations.controller.ts create mode 100644 apps/api/src/routes/trial-invitations.routes.ts create mode 100644 apps/api/src/services/email/templates/primer-pago-facturar.ts create mode 100644 apps/api/src/services/email/templates/trial-invitation.ts create mode 100644 apps/api/src/services/trial-invitations.service.ts create mode 100644 apps/web/app/(dashboard)/admin/facturas-pendientes/page.tsx create mode 100644 apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx create mode 100644 apps/web/app/invitacion/trial/[token]/page.tsx create mode 100644 apps/web/lib/api/trial-invitations.ts create mode 100644 apps/web/lib/hooks/use-pagos-sin-factura.ts create mode 100644 docs/CAMBIOS-2026-05-09.md create mode 100644 scripts/monitoreo_sat_3am.sh create mode 100644 scripts/reprocess_bom.js create mode 100644 scripts/reprocess_duplicate_timbres.js create mode 100644 scripts/test_auza_sync.js diff --git a/apps/api/prisma/migrations/20260507201624_add_trial_invitations/migration.sql b/apps/api/prisma/migrations/20260507201624_add_trial_invitations/migration.sql new file mode 100644 index 0000000..7a9331f --- /dev/null +++ b/apps/api/prisma/migrations/20260507201624_add_trial_invitations/migration.sql @@ -0,0 +1,32 @@ +-- DropIndex +DROP INDEX "subscription_addons_subscription_id_plan_addon_catalogo_id_key"; + +-- CreateTable +CREATE TABLE "trial_invitations" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "invited_by" TEXT NOT NULL, + "plan" TEXT NOT NULL DEFAULT 'business_control', + "duration_days" INTEGER NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "token" TEXT NOT NULL, + "email_sent_to" TEXT, + "sent_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "accepted_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "trial_invitations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "trial_invitations_token_key" ON "trial_invitations"("token"); + +-- CreateIndex +CREATE INDEX "trial_invitations_tenant_id_idx" ON "trial_invitations"("tenant_id"); + +-- CreateIndex +CREATE INDEX "trial_invitations_token_idx" ON "trial_invitations"("token"); + +-- CreateIndex +CREATE INDEX "trial_invitations_status_idx" ON "trial_invitations"("status"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e199b29..c53b94f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -477,6 +477,29 @@ model TrialUsage { @@map("trial_usages") } +/// Invitaciones de trial enviadas por admin global a tenants específicos. +/// Permite activar trials configurables (ej. Business Control Prueba por 60 días) +/// con un link único que el owner del tenant puede aceptar. +model TrialInvitation { + id String @id @default(uuid()) + tenantId String @map("tenant_id") + invitedBy String @map("invited_by") + plan String @default("business_control") + durationDays Int @map("duration_days") + status String @default("pending") // pending | accepted | expired | cancelled + token String @unique + emailSentTo String? @map("email_sent_to") + sentAt DateTime @default(now()) @map("sent_at") + expiresAt DateTime @map("expires_at") + acceptedAt DateTime? @map("accepted_at") + createdAt DateTime @default(now()) @map("created_at") + + @@index([tenantId]) + @@index([token]) + @@index([status]) + @@map("trial_invitations") +} + /// Catálogo despacho — precios + limits editables por admin global. /// Las `features` siguen viviendo en TS (`DESPACHO_PLANS` en `@horux/shared`) /// porque están acopladas a UI/middleware y son contrato de código. diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 77c0b03..e8fddc7 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -162,8 +162,8 @@ async function main() { { plan: 'custom', nombre: 'Custom', monthly: null, firstYear: null, renewal: null, permiteMonthly: false, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false }, { plan: 'mi_empresa', nombre: 'Mi Empresa', monthly: 580, firstYear: 5800, renewal: 5800, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: false }, { plan: 'mi_empresa_plus', nombre: 'Mi Empresa +', monthly: 900, firstYear: 9000, renewal: 9000, permiteMonthly: true, maxRfcs: 1, maxUsers: 3, timbresIncluidosMes: 50, dbMode: 'MANAGED' as const, permiteServidorBackup: false, permiteSatIncremental: true }, - { plan: 'business_control', nombre: 'Business Control', monthly: null, firstYear: 25850, renewal: 25850, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true }, - { plan: 'business_cloud', nombre: 'Enterprise', monthly: null, firstYear: 43000, renewal: 43000, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'BYO' as const, permiteServidorBackup: true, permiteSatIncremental: true }, + { plan: 'business_control', nombre: 'Business Control', monthly: null, firstYear: 25850, renewal: 25850, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'MANAGED' as const, permiteServidorBackup: true, permiteSatIncremental: true }, + { plan: 'business_cloud', nombre: 'Enterprise', monthly: null, firstYear: 43000, renewal: 43000, permiteMonthly: false, maxRfcs: 100, maxUsers: -1, timbresIncluidosMes: 0, dbMode: 'MANAGED' as const, permiteServidorBackup: true, permiteSatIncremental: true }, ]; for (const p of DESPACHO_PLAN_CATALOGO) { await prisma.despachoPlanPrice.upsert({ diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6c5c3c9..f0ad238 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -38,6 +38,7 @@ import adminDashboardRoutes from './routes/admin-dashboard.routes.js'; import adminImpersonateRoutes from './routes/admin-impersonate.routes.js'; import adminClientesRoutes from './routes/admin-clientes.routes.js'; import adminAddonsRoutes from './routes/admin-addons.routes.js'; +import { trialInvitationRoutes } from './routes/trial-invitations.routes.js'; import despachoAuditRoutes from './routes/despacho-audit.routes.js'; import metricasRoutes from './routes/metricas.routes.js'; @@ -105,6 +106,7 @@ app.use('/api/admin/clientes', adminClientesRoutes); app.use('/api/admin/addons', adminAddonsRoutes); app.use('/api/despacho/audit-log', despachoAuditRoutes); app.use('/api/metricas', metricasRoutes); +app.use('/api/invitations/trial', trialInvitationRoutes); // Error handling app.use(errorMiddleware); diff --git a/apps/api/src/controllers/audit-log.controller.ts b/apps/api/src/controllers/audit-log.controller.ts index fbc0efc..482bdb4 100644 --- a/apps/api/src/controllers/audit-log.controller.ts +++ b/apps/api/src/controllers/audit-log.controller.ts @@ -3,7 +3,7 @@ import { prisma } from '../config/database.js'; import { isGlobalAdmin } from '../utils/global-admin.js'; async function requireGlobalAdmin(req: Request, res: Response): Promise { - const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role); + const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); if (!isAdmin) { res.status(403).json({ message: 'Solo el administrador global puede consultar el audit log' }); } diff --git a/apps/api/src/controllers/despacho-stats.controller.ts b/apps/api/src/controllers/despacho-stats.controller.ts index 24c18e2..4074fe9 100644 --- a/apps/api/src/controllers/despacho-stats.controller.ts +++ b/apps/api/src/controllers/despacho-stats.controller.ts @@ -9,10 +9,15 @@ function effectiveTenantId(req: Request): string { const ROLES_OWNER = new Set(['owner', 'cfo']); const ROLES_SUPERVISORY = new Set(['owner', 'cfo', 'supervisor']); const ROLES_ASIGNADOS = new Set(['owner', 'cfo', 'supervisor', 'auxiliar']); +const PLATFORM_SUPERSET = new Set(['platform_admin', 'platform_ti']); + +function isPlatformStaff(user: Request['user']): boolean { + return (user?.platformRoles || []).some(r => PLATFORM_SUPERSET.has(r)); +} export async function getContribuyentesStats(req: Request, res: Response, next: NextFunction) { try { - if (!ROLES_OWNER.has(req.user!.role)) { + if (!isPlatformStaff(req.user) && !ROLES_OWNER.has(req.user!.role)) { throw new AppError(403, 'Solo owner puede ver estas métricas'); } const tenantId = effectiveTenantId(req); @@ -27,7 +32,7 @@ export async function getContribuyentesStats(req: Request, res: Response, next: export async function getMisAsignados(req: Request, res: Response, next: NextFunction) { try { - if (!ROLES_ASIGNADOS.has(req.user!.role)) { + if (!isPlatformStaff(req.user) && !ROLES_ASIGNADOS.has(req.user!.role)) { throw new AppError(403, 'No tienes contribuyentes asignados'); } const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined; @@ -47,7 +52,7 @@ export async function getMisAsignados(req: Request, res: Response, next: NextFun export async function getEquipoStats(req: Request, res: Response, next: NextFunction) { try { - if (!ROLES_SUPERVISORY.has(req.user!.role)) { + if (!isPlatformStaff(req.user) && !ROLES_SUPERVISORY.has(req.user!.role)) { throw new AppError(403, 'Solo owner y supervisor pueden ver al equipo'); } const año = req.query.año ? parseInt(String(req.query.año), 10) : undefined; diff --git a/apps/api/src/controllers/despacho.controller.ts b/apps/api/src/controllers/despacho.controller.ts index 96ce909..e138fd8 100644 --- a/apps/api/src/controllers/despacho.controller.ts +++ b/apps/api/src/controllers/despacho.controller.ts @@ -10,10 +10,10 @@ const signupSchema = z.object({ regimenFiscal: z.string().optional(), codigoPostal: z.string().regex(/^\d{5}$/, 'Código postal inválido').optional(), verticalProfile: z.enum(['CONTABLE', 'JURIDICO', 'ARQUITECTURA']), - plan: z.enum(['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional().default('trial'), - // Solo aplica a mi_empresa y mi_empresa_plus (los otros pagados son - // anuales fijos). Default annual sesga el cash-flow del negocio. - frequency: z.enum(['monthly', 'annual']).optional().default('annual'), + // plan y frequency ya no se escogen en el registro — todos empiezan con trial genérico. + // Se mantienen opcionales para compatibilidad backward con clientes antiguos. + plan: z.enum(['trial', 'mi_empresa', 'mi_empresa_plus', 'business_control', 'business_cloud']).optional(), + frequency: z.enum(['monthly', 'annual']).optional(), }), owner: z.object({ nombre: z.string().min(2, 'Nombre del owner requerido'), @@ -42,16 +42,10 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction) // planes, dbMode también es MANAGED y reportar `business_cloud` daba // mapeo equivocado. tenant.plan es la fuente de verdad post-migración // 20260426073942 (que añadió mi_empresa y mi_empresa_plus al enum). - let currentPlan: string; - if (isTrialActive) { - currentPlan = 'trial'; - } else { - currentPlan = String(tenant.plan); - } - - // 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 - // (cuando aplica dualidad firstYear) ya fue completado. + // + // FIX: Si hay una subscription en trial con un plan específico (ej. + // business_control desde una TrialInvitation), respetamos ese plan + // para que el feature-gate y los límites funcionen correctamente. const subscription = await prisma.subscription.findFirst({ where: { tenantId, status: { in: ['authorized', 'pending', 'paused', 'trial'] } }, orderBy: { createdAt: 'desc' }, @@ -61,6 +55,18 @@ export async function getMyPlan(req: Request, res: Response, next: NextFunction) }, }); + let currentPlan: string; + if (subscription?.status === 'trial' && subscription.plan && subscription.plan !== 'trial') { + currentPlan = subscription.plan; + } else if (isTrialActive) { + currentPlan = 'trial'; + } else { + currentPlan = String(tenant.plan); + } + + // 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 + // (cuando aplica dualidad firstYear) ya fue completado. return res.json({ plan: currentPlan, dbMode: tenant.dbMode, diff --git a/apps/api/src/controllers/facturacion.controller.ts b/apps/api/src/controllers/facturacion.controller.ts index eef2503..f866728 100644 --- a/apps/api/src/controllers/facturacion.controller.ts +++ b/apps/api/src/controllers/facturacion.controller.ts @@ -518,8 +518,6 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun whereType = `AND (c.type = 'EMITIDO' OR (c.type = 'RECIBIDO' AND c.uso_cfdi = 'G01'))`; } - const whereContrib = contribuyenteId ? `AND c.contribuyente_id = '${contribuyenteId}'` : ''; - let whereSearch = ''; const params: any[] = []; if (q.length >= 2) { @@ -548,7 +546,6 @@ export async function searchConceptos(req: Request, res: Response, next: NextFun JOIN cfdis c ON cc.cfdi_id = c.id WHERE c.status NOT IN ('Cancelado', '0') ${whereType} - ${whereContrib} ${whereSearch} ORDER BY cc.clave_prod_serv, cc.descripcion, c.fecha_emision DESC LIMIT 30 @@ -664,40 +661,20 @@ export async function searchRfcs(req: Request, res: Response, next: NextFunction }); const tenantRfc = tenant?.rfc || ''; - // En multi-RFC con contribuyente activo, filtrar a contrapartes con las - // que ese contribuyente ha tenido CFDIs (emisor o receptor). Sin - // contribuyenteId, retornar el catálogo completo (compat con flujos - // legacy / admin global sin contribuyente seleccionado). - let rows; - if (contribuyenteId) { - ({ rows } = await pool.query(` - SELECT DISTINCT r.id, r.rfc, - r.razon_social as "razonSocial", - r.regimen_fiscal as "regimenFiscal", - r.codigo_postal as "codigoPostal" - FROM rfcs r - WHERE r.rfc != $1 - AND (r.rfc ILIKE $2 OR r.razon_social ILIKE $2) - AND EXISTS ( - SELECT 1 FROM cfdis c - WHERE c.contribuyente_id = $3 - AND (c.rfc_emisor_id = r.id OR c.rfc_receptor_id = r.id) - ) - ORDER BY r.razon_social - LIMIT 10 - `, [tenantRfc, `%${q}%`, contribuyenteId])); - } else { - ({ rows } = await pool.query(` - SELECT id, rfc, razon_social as "razonSocial", - regimen_fiscal as "regimenFiscal", - codigo_postal as "codigoPostal" - FROM rfcs - WHERE rfc != $1 - AND (rfc ILIKE $2 OR razon_social ILIKE $2) - ORDER BY razon_social - LIMIT 10 - `, [tenantRfc, `%${q}%`])); - } + // Búsqueda en el catálogo completo de RFCs. El contribuyente activo solo + // filtra CFDIs relacionados / PPD, no el autocompleto de RFCs — de lo + // contrario no se podría facturar a un cliente nuevo que nunca haya + // aparecido en un CFDI previo. + const { rows } = await pool.query(` + SELECT id, rfc, razon_social as "razonSocial", + regimen_fiscal as "regimenFiscal", + codigo_postal as "codigoPostal" + FROM rfcs + WHERE rfc != $1 + AND (rfc ILIKE $2 OR razon_social ILIKE $2) + ORDER BY razon_social + LIMIT 10 + `, [tenantRfc, `%${q}%`]); res.json(rows); } catch (error) { next(error); } @@ -787,3 +764,123 @@ export async function comprarPaquete(req: Request, res: Response, next: NextFunc next(error); } } + +// ── Admin global: pagos de suscripción sin factura ── + +export async function getPagosSinFactura(req: Request, res: Response, next: NextFunction) { + try { + if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) { + return res.status(403).json({ message: 'Solo admin global puede consultar pagos sin factura' }); + } + + const payments = await prisma.payment.findMany({ + where: { + status: 'approved', + facturapiInvoiceId: null, + kind: 'subscription', + amount: { gt: 0 }, + }, + include: { + subscription: { select: { plan: true, frequency: true } }, + tenant: { select: { nombre: true, rfc: true } }, + }, + orderBy: { paidAt: 'desc' }, + }); + + res.json(payments); + } catch (error) { next(error); } +} + +export async function emitirFacturaPago(req: Request, res: Response, next: NextFunction) { + try { + if (!(await hasPlatformRole(req.user!.userId, 'platform_admin'))) { + return res.status(403).json({ message: 'Solo admin global puede emitir facturas de pago' }); + } + + const paymentId = String(req.params.paymentId); + const payment = await prisma.payment.findUnique({ + where: { id: paymentId }, + include: { subscription: true }, + }); + + if (!payment) { + return next(new AppError(404, 'Pago no encontrado')); + } + if (payment.status !== 'approved') { + return next(new AppError(400, 'Solo pagos aprobados pueden facturarse')); + } + if (payment.facturapiInvoiceId) { + return next(new AppError(400, 'Este pago ya tiene una factura emitida')); + } + + // Reutilizar helpers del servicio de facturación + const { getEmitterTenant, getCustomerFromTenant } = await import('../services/payment/invoicing.service.js'); + + const emitter = await getEmitterTenant(); + const amount = Number(payment.amount); + const plan = (payment as any).subscription?.plan || 'custom'; + const frequency = (payment as any).subscription?.frequency || 'monthly'; + const descFrecuencia = frequency === 'annual' ? 'anual' : 'mensual'; + const description = `Suscripción ${plan} ${descFrecuencia} a Horux Despachos`; + + const customer = await getCustomerFromTenant(payment.tenantId); + if (!customer) { + return next(new AppError(400, 'El tenant no tiene datos fiscales completos. No se puede facturar.')); + } + + const tenantPref = await prisma.tenant.findUnique({ + where: { id: payment.tenantId }, + select: { factUsoCfdi: true }, + }); + const usoCfdi = customer ? (tenantPref?.factUsoCfdi || 'G03') : 'S01'; + + const formaPagoMap: Record = { + master: '04', visa: '04', amex: '04', + debmaster: '28', debvisa: '28', + account_money: '03', bank_transfer: '03', + }; + const normalizedMethod = (payment.paymentMethod || '').toLowerCase().replace(/^proration-/, ''); + const formaPago = formaPagoMap[normalizedMethod] || '03'; + + const payload = { + customer: { + legalName: customer.legalName, + taxId: customer.taxId, + taxSystem: customer.taxSystem, + email: customer.email, + zip: customer.zip, + }, + items: [ + { + description, + productKey: '81112502', + unitKey: 'E48', + unitName: 'Servicio', + quantity: 1, + price: amount, + taxIncluded: true, + taxes: [{ type: 'IVA', rate: 0.16, factor: 'Tasa' }], + }, + ], + use: usoCfdi, + paymentForm: formaPago, + paymentMethod: 'PUE', + currency: 'MXN', + }; + + const invoice = await facturapiService.createInvoice(emitter.id, payload as any); + + await prisma.payment.update({ + where: { id: payment.id }, + data: { facturapiInvoiceId: invoice.id }, + }); + + auditFromReq(req, 'invoice.emitted_manual', { + entityType: 'Payment', + entityId: payment.id, + metadata: { facturapiInvoiceId: invoice.id, amount, plan, frequency }, + }); + + res.json({ success: true, invoiceId: invoice.id, paymentId: payment.id }); + } catch (error) { next(error); } +} diff --git a/apps/api/src/controllers/sat.controller.ts b/apps/api/src/controllers/sat.controller.ts index fbb1aaa..2d30c72 100644 --- a/apps/api/src/controllers/sat.controller.ts +++ b/apps/api/src/controllers/sat.controller.ts @@ -134,7 +134,7 @@ export async function retry(req: Request, res: Response): Promise { */ export async function cronInfo(req: Request, res: Response): Promise { try { - if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) { + if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) { res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' }); return; } @@ -151,7 +151,7 @@ export async function cronInfo(req: Request, res: Response): Promise { */ export async function runCron(req: Request, res: Response): Promise { try { - if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) { + if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) { res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' }); return; } diff --git a/apps/api/src/controllers/subscription.controller.ts b/apps/api/src/controllers/subscription.controller.ts index 39d6d3a..698553d 100644 --- a/apps/api/src/controllers/subscription.controller.ts +++ b/apps/api/src/controllers/subscription.controller.ts @@ -5,7 +5,7 @@ import { isGlobalAdmin } from '../utils/global-admin.js'; import { auditFromReq } from '../utils/audit.js'; async function requireGlobalAdmin(req: Request, res: Response): Promise { - const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role); + const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); if (!isAdmin) { res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' }); } @@ -19,7 +19,7 @@ async function requireGlobalAdmin(req: Request, res: Response): Promise */ async function requireOwnTenantOrGlobalAdmin(req: Request, res: Response, targetTenantId: string): Promise { if (targetTenantId === req.user!.tenantId) return true; - const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role); + const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); if (!isAdmin) { res.status(403).json({ message: 'Solo puedes gestionar la suscripción de tu propio tenant' }); } diff --git a/apps/api/src/controllers/tenants.controller.ts b/apps/api/src/controllers/tenants.controller.ts index cf85aed..645cf9a 100644 --- a/apps/api/src/controllers/tenants.controller.ts +++ b/apps/api/src/controllers/tenants.controller.ts @@ -6,16 +6,24 @@ import { isGlobalAdmin } from '../utils/global-admin.js'; import { isOwnerSomewhere } from '../utils/memberships.js'; async function requireGlobalAdmin(req: Request): Promise { - if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) { + if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId))) { throw new AppError(403, 'Solo el administrador global puede gestionar clientes'); } } export async function getAllTenants(req: Request, res: Response, next: NextFunction) { try { - await requireGlobalAdmin(req); + const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); + if (!isAdmin) { + // Evita 403 en consola del frontend cuando componentes sin-gate hacen polling + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + return res.json([]); + } const tenants = await tenantsService.getAllTenants(); + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); res.json(tenants); } catch (error) { next(error); @@ -24,7 +32,10 @@ export async function getAllTenants(req: Request, res: Response, next: NextFunct export async function getTenant(req: Request, res: Response, next: NextFunction) { try { - await requireGlobalAdmin(req); + const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); + if (!isAdmin) { + return res.status(404).json({ message: 'Cliente no encontrado' }); + } const tenant = await tenantsService.getTenantById(String(req.params.id)); if (!tenant) { @@ -68,13 +79,15 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti await requireGlobalAdmin(req); const id = String(req.params.id); - const { nombre, rfc, plan, active } = req.body; + const { nombre, rfc, plan, active, amount, firstPaymentDueAt } = req.body; const tenant = await tenantsService.updateTenant(id, { nombre, rfc, plan, active, + amount, + firstPaymentDueAt: firstPaymentDueAt || null, }); res.json(tenant); diff --git a/apps/api/src/controllers/trial-invitations.controller.ts b/apps/api/src/controllers/trial-invitations.controller.ts new file mode 100644 index 0000000..4daa12a --- /dev/null +++ b/apps/api/src/controllers/trial-invitations.controller.ts @@ -0,0 +1,130 @@ +import type { Request, Response, NextFunction } from 'express'; +import * as trialInvitationService from '../services/trial-invitations.service.js'; +import { isGlobalAdmin } from '../utils/global-admin.js'; +import { prisma } from '../config/database.js'; + +async function requireGlobalAdmin(req: Request, res: Response): Promise { + const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); + if (!isAdmin) { + res.status(403).json({ message: 'Solo el administrador global puede gestionar invitaciones de trial' }); + } + return isAdmin; +} + +export async function createInvitation(req: Request, res: Response, next: NextFunction) { + try { + if (!(await requireGlobalAdmin(req, res))) return; + + const { tenantId, plan, durationDays } = req.body; + if (!tenantId || !durationDays || durationDays < 1 || durationDays > 365) { + return res.status(400).json({ message: 'tenantId y durationDays (1-365) son requeridos' }); + } + + const invitation = await trialInvitationService.createInvitation({ + tenantId, + invitedByUserId: req.user!.userId, + plan: plan || 'business_control', + durationDays: parseInt(durationDays, 10), + }); + + res.status(201).json(invitation); + } catch (error: any) { + if (error.message?.includes('ya tiene') || error.message?.includes('no encontrado')) { + return res.status(400).json({ message: error.message }); + } + next(error); + } +} + +export async function getAllInvitations(req: Request, res: Response, next: NextFunction) { + try { + if (!(await requireGlobalAdmin(req, res))) return; + + const { tenantId, status } = req.query; + const invitations = await trialInvitationService.getInvitations({ + tenantId: typeof tenantId === 'string' ? tenantId : undefined, + status: typeof status === 'string' ? status : undefined, + }); + + res.json(invitations); + } catch (error) { + next(error); + } +} + +export async function getMyPendingInvitation(req: Request, res: Response, next: NextFunction) { + try { + const invitation = await trialInvitationService.getPendingInvitationForTenant(req.user!.tenantId); + res.json(invitation); + } catch (error) { + next(error); + } +} + +export async function acceptInvitation(req: Request, res: Response, next: NextFunction) { + try { + const token = typeof req.params.token === 'string' ? req.params.token : ''; + if (!token) { + return res.status(400).json({ message: 'Token requerido' }); + } + + const result = await trialInvitationService.acceptInvitation(token, req.user!.userId); + res.json(result); + } catch (error: any) { + if ( + error.message?.includes('no encontrada') || + error.message?.includes('ya ') || + error.message?.includes('expirado') || + error.message?.includes('Solo el dueño') + ) { + return res.status(400).json({ message: error.message }); + } + next(error); + } +} + +export async function cancelInvitation(req: Request, res: Response, next: NextFunction) { + try { + if (!(await requireGlobalAdmin(req, res))) return; + + const id = typeof req.params.id === 'string' ? req.params.id : ''; + const result = await trialInvitationService.cancelInvitation(id); + res.json(result); + } catch (error: any) { + if (error.message?.includes('no encontrada') || error.message?.includes('Solo se pueden')) { + return res.status(400).json({ message: error.message }); + } + next(error); + } +} + +export async function getInvitationByToken(req: Request, res: Response, next: NextFunction) { + try { + const token = typeof req.params.token === 'string' ? req.params.token : ''; + const invitation = await prisma.trialInvitation.findUnique({ + where: { token }, + }); + + if (!invitation) { + return res.status(404).json({ message: 'Invitación no encontrada' }); + } + + const tenant = await prisma.tenant.findUnique({ + where: { id: invitation.tenantId }, + select: { nombre: true, rfc: true }, + }); + + // No exponer datos sensibles + res.json({ + id: invitation.id, + tenantId: invitation.tenantId, + plan: invitation.plan, + durationDays: invitation.durationDays, + status: invitation.status, + expiresAt: invitation.expiresAt, + tenant, + }); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/usuarios.controller.ts b/apps/api/src/controllers/usuarios.controller.ts index 5bd9eff..3ffb3dd 100644 --- a/apps/api/src/controllers/usuarios.controller.ts +++ b/apps/api/src/controllers/usuarios.controller.ts @@ -27,7 +27,7 @@ const updateGlobalSchema = z.object({ }); async function isGlobalAdmin(req: Request): Promise { - return checkGlobalAdmin(req.user!.tenantId, req.user!.role); + return checkGlobalAdmin(req.user!.tenantId, req.user!.role, req.user!.userId); } export async function getUsuarios(req: Request, res: Response, next: NextFunction) { diff --git a/apps/api/src/middlewares/tenant.middleware.ts b/apps/api/src/middlewares/tenant.middleware.ts index 8ed5c81..56aaef6 100644 --- a/apps/api/src/middlewares/tenant.middleware.ts +++ b/apps/api/src/middlewares/tenant.middleware.ts @@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express'; import type { Pool } from 'pg'; import { prisma, tenantDb } from '../config/database.js'; import { isGlobalAdmin } from '../utils/global-admin.js'; +import { hasAnyPlatformRole } from '../utils/platform-admin.js'; import { decryptAesGcm, deriveAesKey } from '@horux/core'; import { env } from '../config/env.js'; @@ -68,11 +69,12 @@ export async function tenantMiddleware(req: Request, res: Response, next: NextFu let tenantId = req.user.tenantId; - // Admin impersonation via X-View-Tenant header (global admin only) + // Admin impersonation via X-View-Tenant header (global admin or platform staff) const viewTenantHeader = req.headers['x-view-tenant'] as string; if (viewTenantHeader) { - const globalAdmin = await isGlobalAdmin(req.user.tenantId, req.user.role); - if (!globalAdmin) { + const isPlatformStaff = await hasAnyPlatformRole(req.user.userId, 'platform_admin', 'platform_ti'); + const globalAdmin = !isPlatformStaff && await isGlobalAdmin(req.user.tenantId, req.user.role); + if (!isPlatformStaff && !globalAdmin) { return res.status(403).json({ message: 'No autorizado para ver otros tenants' }); } diff --git a/apps/api/src/routes/despacho-stats.routes.ts b/apps/api/src/routes/despacho-stats.routes.ts index d9bc0bd..a906ae7 100644 --- a/apps/api/src/routes/despacho-stats.routes.ts +++ b/apps/api/src/routes/despacho-stats.routes.ts @@ -5,11 +5,8 @@ import * as ctrl from '../controllers/despacho-stats.controller.js'; const router: IRouter = Router(); -router.use(authenticate); -router.use(tenantMiddleware); - -router.get('/contribuyentes-stats', ctrl.getContribuyentesStats); -router.get('/mis-asignados', ctrl.getMisAsignados); -router.get('/equipo-stats', ctrl.getEquipoStats); +router.get('/contribuyentes-stats', authenticate, tenantMiddleware, ctrl.getContribuyentesStats); +router.get('/mis-asignados', authenticate, tenantMiddleware, ctrl.getMisAsignados); +router.get('/equipo-stats', authenticate, tenantMiddleware, ctrl.getEquipoStats); export { router as despachoStatsRoutes }; diff --git a/apps/api/src/routes/facturacion.routes.ts b/apps/api/src/routes/facturacion.routes.ts index e5c2b6f..8f6458e 100644 --- a/apps/api/src/routes/facturacion.routes.ts +++ b/apps/api/src/routes/facturacion.routes.ts @@ -61,4 +61,8 @@ router.get('/cfdis-ppd', facturacionController.getCfdisPpdPendientes); // CFDIs emitidos por el contribuyente al receptor (para sección "CFDIs relacionados") router.get('/cfdis-relacionables', facturacionController.getCfdisRelacionables); +// Admin global: pagos de suscripción sin factura + emisión manual +router.get('/pagos-sin-factura', facturacionController.getPagosSinFactura); +router.post('/emitir-factura-pago/:paymentId', strictLimit, facturacionController.emitirFacturaPago); + export { router as facturacionRoutes }; diff --git a/apps/api/src/routes/trial-invitations.routes.ts b/apps/api/src/routes/trial-invitations.routes.ts new file mode 100644 index 0000000..e478f25 --- /dev/null +++ b/apps/api/src/routes/trial-invitations.routes.ts @@ -0,0 +1,22 @@ +import { Router, type IRouter } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import * as trialInvitationsController from '../controllers/trial-invitations.controller.js'; + +const router: IRouter = Router(); + +// Public endpoint: anyone can view invitation details by token (no auth required) +router.get('/token/:token', trialInvitationsController.getInvitationByToken); + +// Authenticated endpoints +router.use(authenticate); + +// Global admin endpoints +router.post('/', trialInvitationsController.createInvitation); +router.get('/', trialInvitationsController.getAllInvitations); +router.post('/:id/cancel', trialInvitationsController.cancelInvitation); + +// Self-serve endpoints (autenticado, owner validation in controller) +router.get('/pending', trialInvitationsController.getMyPendingInvitation); +router.post('/:token/accept', trialInvitationsController.acceptInvitation); + +export { router as trialInvitationRoutes }; diff --git a/apps/api/src/services/admin-dashboard.service.ts b/apps/api/src/services/admin-dashboard.service.ts index 665b23d..41652d1 100644 --- a/apps/api/src/services/admin-dashboard.service.ts +++ b/apps/api/src/services/admin-dashboard.service.ts @@ -19,7 +19,7 @@ export async function getDashboardMetrics() { prisma.subscription.count({ where: { status: 'cancelled' } }), prisma.tenant.count({ where: { createdAt: { gte: thirtyDaysAgo } } }), prisma.tenant.findMany({ - where: { dbMode: 'BYO', connectorTunnelHostname: { not: null } }, + where: { connectorTunnelHostname: { not: null } }, select: { id: true, nombre: true, rfc: true, connectorLastSeen: true, connectorVersion: true }, }), prisma.payment.aggregate({ diff --git a/apps/api/src/services/connector.service.ts b/apps/api/src/services/connector.service.ts index d4db807..8ece2eb 100644 --- a/apps/api/src/services/connector.service.ts +++ b/apps/api/src/services/connector.service.ts @@ -40,7 +40,7 @@ export async function provisionConnector(tenantId: string): Promise<{ await prisma.tenant.update({ where: { id: tenantId }, data: { - dbMode: 'BYO', + // El conector es una feature de respaldo; el tenant siempre permanece MANAGED connectorTokenEnc: tokenEncoded, connectorTunnelHostname: hostname, }, @@ -92,7 +92,7 @@ export async function verifyConnectorToken(token: string): Promise((_, reject) => setTimeout(() => reject(new Error('Timeout: proceso de CSF excedió 3 minutos')), PROCESS_TIMEOUT), @@ -171,6 +175,28 @@ async function matchRegimenesToCatalogo(regimenesCsf: RegimenCsf[]): Promise = { + codigoPostal: 5, + calle: 255, + numExterior: 20, + numInterior: 20, + colonia: 255, + ciudad: 100, + municipio: 100, + estado: 100, + telefono: 20, +}; + +function truncateToLimit(key: string, value: string): string { + const limit = TENANT_FIELD_LIMITS[key]; + if (!limit || value.length <= limit) return value; + return value.slice(0, limit); +} + /** * Aplica el domicilio + regímenes activos de la CSF al tenant. Idempotente: * se puede llamar N veces, el resultado final refleja el último CSF. @@ -183,7 +209,9 @@ export async function sincronizarDatosFiscales( const fields = domicilioToTenantFields(csf.domicilio); const updates: Record = {}; for (const [k, v] of Object.entries(fields)) { - if (v && v.trim().length > 0) updates[k] = v.trim(); + if (v && v.trim().length > 0) { + updates[k] = truncateToLimit(k, v.trim()); + } } if (Object.keys(updates).length > 0) { diff --git a/apps/api/src/services/despacho.service.ts b/apps/api/src/services/despacho.service.ts index 8a59add..c31a144 100644 --- a/apps/api/src/services/despacho.service.ts +++ b/apps/api/src/services/despacho.service.ts @@ -26,7 +26,7 @@ export async function signupDespacho(data: DespachoSignupRequest) { plan: 'trial', databaseName: databaseName, verticalProfile: despacho.verticalProfile as any, - dbMode: (despacho.plan === 'business_control' ? 'BYO' : 'MANAGED') as any, + dbMode: 'MANAGED', dbSchemaVersion: 0, trialEndsAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), codigoPostal: despacho.codigoPostal, @@ -91,40 +91,9 @@ export async function signupDespacho(data: DespachoSignupRequest) { email: result.user.email, }).catch(err => console.error('[Signup] Welcome email failed:', err)); - // If paid plan, create MP checkout via subscriptionService.subscribe() - // que también crea la fila Subscription en BD (clave para que el webhook - // pueda aplicar la dualidad firstYear→renewal tras el primer cobro aprobado). - let paymentUrl: string | undefined; - if (data.despacho.plan && data.despacho.plan !== 'trial') { - try { - const subscriptionService = await import('./payment/subscription.service.js'); - const result2 = await subscriptionService.subscribe({ - tenantId: result.tenant.id, - plan: data.despacho.plan as any, - // mi_empresa(+) acepta monthly/annual; los demás solo annual - // — el subscribe valida y rechaza monthly cuando no aplica. - frequency: data.despacho.frequency || 'annual', - payerEmail: owner.email, - }); - paymentUrl = result2.paymentUrl; - } catch (err: any) { - // Rollback: delete tenant + user since payment couldn't be set up - await prisma.tenantMembership.deleteMany({ where: { tenantId: result.tenant.id } }).catch(() => {}); - await prisma.refreshToken.deleteMany({ where: { userId: result.user.id } }).catch(() => {}); - await prisma.tenant.delete({ where: { id: result.tenant.id } }).catch(() => {}); - await prisma.user.delete({ where: { id: result.user.id } }).catch(() => {}); - const msg = err?.message || ''; - if (msg.includes('MercadoPago no está configurado') || msg.includes('Unauthorized access')) { - throw new Error('No se pudo procesar el cobro. Verifica que el sistema de pagos esté configurado o selecciona el plan Trial.'); - } - throw new Error(msg || 'No se pudo procesar el cobro.'); - } - } - return { accessToken, refreshToken, - paymentUrl, user: { id: result.user.id, email: result.user.email, diff --git a/apps/api/src/services/email/email.service.ts b/apps/api/src/services/email/email.service.ts index 6ceb8ed..c4c52fb 100644 --- a/apps/api/src/services/email/email.service.ts +++ b/apps/api/src/services/email/email.service.ts @@ -68,6 +68,18 @@ export const emailService = { await sendEmail(env.ADMIN_EMAIL, `Nuevo cliente: ${data.clienteNombre} (${data.clienteRfc})`, newClientAdminEmail(data)); }, + sendPrimerPagoFacturar: async (data: { + clienteNombre: string; + clienteRfc: string; + amount: number; + plan: string; + paymentDate: string; + paymentId: string; + }) => { + const { primerPagoFacturarEmail } = await import('./templates/primer-pago-facturar.js'); + await sendEmail(env.ADMIN_EMAIL, `Factura pendiente: primer pago de ${data.clienteNombre}`, primerPagoFacturarEmail(data)); + }, + sendWeeklyUpdate: async (to: string, data: import('./templates/weekly-update.js').WeeklyUpdateData) => { const { weeklyUpdateEmail } = await import('./templates/weekly-update.js'); await sendEmail(to, `Actualización semanal — ${data.empresa}`, weeklyUpdateEmail(data)); @@ -91,6 +103,17 @@ export const emailService = { await sendEmail(to, `Prueba finalizada — ${data.despachoNombre}`, trialExpiredEmail(data)); }, + sendTrialInvitation: async (to: string, data: { + despachoNombre: string; + plan: string; + durationDays: number; + acceptUrl: string; + expiresAt: string; + }) => { + const { trialInvitationEmail } = await import('./templates/trial-invitation.js'); + await sendEmail(to, `Invitación especial — Prueba ${data.plan === 'business_control' ? 'Business Control' : data.plan}`, trialInvitationEmail(data)); + }, + /** * Notifica la subida de una declaración o documento extra al despacho. * `recipients` debe venir deduplicado por el caller. El subject se diff --git a/apps/api/src/services/email/templates/primer-pago-facturar.ts b/apps/api/src/services/email/templates/primer-pago-facturar.ts new file mode 100644 index 0000000..3267ea2 --- /dev/null +++ b/apps/api/src/services/email/templates/primer-pago-facturar.ts @@ -0,0 +1,55 @@ +import { baseTemplate, heading, BRAND_COLORS as C } from './base.js'; + +function escapeHtml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function row(label: string, value: string, isLast = false) { + const border = isLast ? '' : `border-bottom:1px solid ${C.border};`; + return ` + ${label} + ${value} + `; +} + +export function primerPagoFacturarEmail(data: { + clienteNombre: string; + clienteRfc: string; + amount: number; + plan: string; + paymentDate: string; + paymentId: string; +}): string { + const formattedAmount = new Intl.NumberFormat('es-MX', { + style: 'currency', + currency: 'MXN', + }).format(data.amount); + + return baseTemplate(` + ${heading('Primer pago aprobado — factura pendiente')} +

+ El cliente ${escapeHtml(data.clienteNombre)} realizó su primer pago exitosamente. + Como es el primer pago, la factura debe emitirse manualmente. +

+ + + ${row('Cliente', `${escapeHtml(data.clienteNombre)}`)} + ${row('RFC', `${escapeHtml(data.clienteRfc)}`)} + ${row('Plan', escapeHtml(data.plan))} + ${row('Monto', `${formattedAmount}`)} + ${row('Fecha de pago', data.paymentDate, true)} +
+ + + +
+

+ ℹ️ Nota: Los pagos subsecuentes se facturarán automáticamente. Solo el primer pago requiere emisión manual. +

+
+ `); +} diff --git a/apps/api/src/services/email/templates/trial-invitation.ts b/apps/api/src/services/email/templates/trial-invitation.ts new file mode 100644 index 0000000..0902f48 --- /dev/null +++ b/apps/api/src/services/email/templates/trial-invitation.ts @@ -0,0 +1,63 @@ +export interface TrialInvitationData { + despachoNombre: string; + plan: string; + durationDays: number; + acceptUrl: string; + expiresAt: string; +} + +export function trialInvitationEmail(data: TrialInvitationData): string { + const planDisplay = data.plan === 'business_control' ? 'Business Control' : data.plan; + return ` + + + + + + + +
+
+

🎉 Invitación especial para ${data.despachoNombre}

+
+
+

Hola,

+

Has recibido una invitación exclusiva para probar ${planDisplay} durante ${data.durationDays} días completamente gratis.

+ +
+ ¿Qué incluye? +
    +
  • Hasta 100 RFCs
  • +
  • Usuarios ilimitados
  • +
  • API de integración
  • +
  • SAT incremental
  • +
  • Todas las funciones de Business Control
  • +
+
+ +

+ Aceptar invitación +

+ +

Importante: Esta invitación expira el ${data.expiresAt}. Una vez que aceptes, tendrás ${data.durationDays} días para probar todas las funciones sin compromiso.

+ +

Al finalizar el periodo de prueba, podrás contratar el plan para continuar con el servicio.

+ +

Si tienes alguna duda, contacta a nuestro equipo de soporte.

+
+ +
+ + + `.trim(); +} diff --git a/apps/api/src/services/payment/invoicing.service.ts b/apps/api/src/services/payment/invoicing.service.ts index d0fb8bf..3dc931f 100644 --- a/apps/api/src/services/payment/invoicing.service.ts +++ b/apps/api/src/services/payment/invoicing.service.ts @@ -80,7 +80,7 @@ async function isFirstApprovedPayment( * Busca el tenant emisor (Horux 360) con su organización Facturapi configurada. * Si falta, lanza error — el admin global tiene que crear la organización primero. */ -async function getEmitterTenant() { +export async function getEmitterTenant() { const tenant = await prisma.tenant.findUnique({ where: { rfc: GLOBAL_ADMIN_RFC }, select: { @@ -125,7 +125,7 @@ interface CustomerData { * Retorna `null` si falta cualquier dato requerido — el caller debe caer * a público en general en ese caso. */ -async function getCustomerFromTenant(payerTenantId: string): Promise { +export async function getCustomerFromTenant(payerTenantId: string): Promise { const tenant = await prisma.tenant.findUnique({ where: { id: payerTenantId }, select: { @@ -179,7 +179,7 @@ async function getCustomerFromTenant(payerTenantId: string): Promise // Gate 4: primer pago del tenant → manual if (await isFirstApprovedPayment(payment.tenantId, payment.id)) { console.log(`[Invoicing] Payment ${paymentId} es el PRIMER pago aprobado del tenant ${payment.tenantId}, skip (factura manual)`); + // Notificar al admin global para que emita la factura manualmente + const tenant = await prisma.tenant.findUnique({ + where: { id: payment.tenantId }, + select: { nombre: true, rfc: true }, + }); + if (tenant) { + const { emailService } = await import('../email/email.service.js'); + emailService.sendPrimerPagoFacturar({ + clienteNombre: tenant.nombre, + clienteRfc: tenant.rfc || '', + amount, + plan: payment.subscription?.plan || 'custom', + paymentDate: payment.paidAt?.toISOString() || new Date().toISOString(), + paymentId: payment.id, + }).catch(err => console.error('[Invoicing] Error enviando notificación de primer pago:', err)); + } return; } diff --git a/apps/api/src/services/payment/mercadopago.service.ts b/apps/api/src/services/payment/mercadopago.service.ts index d1dc9b7..cff28eb 100644 --- a/apps/api/src/services/payment/mercadopago.service.ts +++ b/apps/api/src/services/payment/mercadopago.service.ts @@ -323,6 +323,7 @@ export function verifyWebhookSignature( const parts: Record = {}; for (const part of xSignature.split(',')) { const [key, value] = part.split('='); + if (!key || value === undefined) continue; parts[key.trim()] = value.trim(); } diff --git a/apps/api/src/services/sat/sat-csf-login.ts b/apps/api/src/services/sat/sat-csf-login.ts index a067877..9be08df 100644 --- a/apps/api/src/services/sat/sat-csf-login.ts +++ b/apps/api/src/services/sat/sat-csf-login.ts @@ -19,7 +19,13 @@ export async function loginSatCsf( keyPath: string, password: string, ): Promise { - const context = await browser.newContext({ acceptDownloads: true }); + const context = await browser.newContext({ + acceptDownloads: true, + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + }); + await context.addInitScript(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + }); const publicPage = await context.newPage(); publicPage.setDefaultTimeout(60_000); @@ -66,12 +72,34 @@ export async function loginSatCsf( await fileInputs.nth(0).setInputFiles(cerPath); await fileInputs.nth(1).setInputFiles(keyPath); + // Esperar a que el cert async parsing termine (RFC auto-populado por SAT). + try { + await loginPage.waitForFunction( + () => { + const rfc = document.getElementById('rfc') as HTMLInputElement | null; + return rfc !== null && rfc.value.length >= 12; + }, + null, + { timeout: 30_000 }, + ); + } catch (err) { + const html = await loginPage.content(); + const { writeFileSync, mkdirSync } = await import('node:fs'); + const debugDir = '/tmp/horux-csf-debug'; + try { mkdirSync(debugDir, { recursive: true }); } catch { /* ok */ } + writeFileSync(`${debugDir}/04c-rfc-timeout-html.html`, html); + throw err; + } + // Password + Enviar await loginPage.locator('input[type="password"]').first().fill(password); - await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click(); + await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click({ noWaitAfter: true }); - // Esperar a que salga del dominio de login - await loginPage.waitForURL(url => !url.toString().includes('loginda.siat.sat.gob.mx'), { timeout: 60_000 }); + // Esperar a que salga del dominio de login y aterrice en el portal SAT + await loginPage.waitForURL( + url => url.toString().includes('wwwmat.sat.gob.mx/operacion/'), + { timeout: 60_000 }, + ); await loginPage.waitForLoadState('networkidle').catch(() => undefined); await loginPage.waitForTimeout(2000); diff --git a/apps/api/src/services/sat/sat-csf-parser.ts b/apps/api/src/services/sat/sat-csf-parser.ts index 03a6cc6..9d0f3b2 100644 --- a/apps/api/src/services/sat/sat-csf-parser.ts +++ b/apps/api/src/services/sat/sat-csf-parser.ts @@ -85,12 +85,29 @@ function extractLabels(text: string): Map { const result = new Map(); const labelAlternation = LABELS.map(escapeRegex).join('|'); const re = new RegExp( - `(${labelAlternation})\\s*:\\s*([\\s\\S]*?)(?=\\s+(?:${labelAlternation})\\s*:|\\n?\\s*(?:Datos del domicilio registrado|Actividades Económicas|Regímenes|Obligaciones|Cadena Original|Sus datos personales)\\b|\\n\\s*--\\s*\\d+\\s+of\\s+\\d+|$)`, + `(${labelAlternation})\\s*:\\s*([\\s\\S]*?)(?=\\s*(?:${labelAlternation})\\s*:|\\n?\\s*(?:Datos del domicilio registrado|Actividades Económicas|Regímenes|Obligaciones|Cadena Original|Sus datos personales)\\b|\\n\\s*--\\s*\\d+\\s+of\\s+\\d+|$)`, 'g', ); for (const match of text.matchAll(re)) { const label = match[1]; - const value = match[2].replace(/\s+/g, ' ').trim(); + let value = match[2].replace(/\s+/g, ' ').trim(); + + // Defensa: el SAT a veces pone etiquetas consecutivas sin valor intermedio + // (ej. "Número Interior:\nNombre de la Colonia: X"). El regex lazy captura + // de más y el valor termina incluyendo el nombre de la siguiente etiqueta. + // Limpiamos cualquier prefijo de otra etiqueta del SAT que haya quedado al + // inicio del valor. + for (const otherLabel of LABELS) { + if (otherLabel === label) continue; + const prefix = otherLabel + ':'; + const lowerValue = value.toLowerCase(); + const lowerPrefix = prefix.toLowerCase(); + if (lowerValue.startsWith(lowerPrefix)) { + value = value.slice(prefix.length).trim(); + break; + } + } + if (!result.has(label)) result.set(label, value); } return result; diff --git a/apps/api/src/services/sat/sat-parser.service.ts b/apps/api/src/services/sat/sat-parser.service.ts index b7d1056..c092de4 100644 --- a/apps/api/src/services/sat/sat-parser.service.ts +++ b/apps/api/src/services/sat/sat-parser.service.ts @@ -111,7 +111,12 @@ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] { for (const entry of entries) { if (entry.entryName.toLowerCase().endsWith('.xml')) { - const content = entry.getData().toString('utf-8'); + let content = entry.getData().toString('utf-8'); + // Remover UTF-8 BOM si existe — fast-xml-parser no lo maneja y devuelve + // result.Comprobante = undefined, dejando el CFDI sin parsear. + if (content.charCodeAt(0) === 0xFEFF) { + content = content.slice(1); + } xmlFiles.push({ filename: entry.entryName, content, @@ -140,8 +145,13 @@ export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] { */ function parseCfdiDate(str: string | null | undefined): Date { if (!str) return new Date(0); - const s = String(str).trim(); + let s = String(str).trim(); if (!s) return new Date(0); + // Defensa: el SAT a veces concatena múltiples fechas con '|' (ej. en + // FechaTimbrado duplicado). Tomamos solo la primera fecha válida. + if (s.includes('|')) { + s = s.split('|')[0].trim(); + } const hasTz = /[Zz]|[+-]\d{2}:?\d{2}$/.test(s); return new Date(hasTz ? s : s + 'Z'); } @@ -155,18 +165,28 @@ function pf(val: any): number { return parseFloat(val || '0') || 0; } +/** + * Extrae el UUID del TimbreFiscalDigital + */ +function getFirstTimbre(comprobante: any): any { + const timbre = comprobante.Complemento?.TimbreFiscalDigital; + if (!timbre) return null; + return Array.isArray(timbre) ? timbre[0] : timbre; +} + /** * Extrae el UUID del TimbreFiscalDigital */ function extractUuid(comprobante: any): string { - return comprobante.Complemento?.TimbreFiscalDigital?.['@_UUID'] || ''; + const timbre = getFirstTimbre(comprobante); + return timbre?.['@_UUID'] || ''; } /** * Extrae datos del timbre: fecha cert SAT y PAC */ function extractTimbreData(comprobante: any): { fechaCertSat: Date | null; pac: string | null } { - const timbre = comprobante.Complemento?.TimbreFiscalDigital; + const timbre = getFirstTimbre(comprobante); if (!timbre) return { fechaCertSat: null, pac: null }; return { @@ -322,7 +342,7 @@ function extractPagos(comprobante: any): { } } - result.fechaPagoP = fechas.length > 0 ? fechas.join('|') : null; + result.fechaPagoP = fechas.length > 0 ? parseCfdiDate(fechas[0]).toISOString() : null; result.numParcialidad = parcialidades.length > 0 ? parcialidades.join('|') : null; result.uuidRelacionado = uuids.length > 0 ? uuids.join('|') : null; result.saldoInsoluto = saldos.length > 0 ? saldos.join('|') : null; @@ -370,9 +390,9 @@ function extractNomina(comprobante: any): { const nomina = complemento.Nomina; if (!nomina) return result; - result.fechaPago = nomina['@_FechaPago'] || null; - result.fechaInicialPago = nomina['@_FechaInicialPago'] || null; - result.fechaFinalPago = nomina['@_FechaFinalPago'] || null; + result.fechaPago = nomina['@_FechaPago'] ? parseCfdiDate(nomina['@_FechaPago']).toISOString() : null; + result.fechaInicialPago = nomina['@_FechaInicialPago'] ? parseCfdiDate(nomina['@_FechaInicialPago']).toISOString() : null; + result.fechaFinalPago = nomina['@_FechaFinalPago'] ? parseCfdiDate(nomina['@_FechaFinalPago']).toISOString() : null; result.numDiasPagados = pf(nomina['@_NumDiasPagados']); result.totalPercepciones = pf(nomina['@_TotalPercepciones']); result.totalDeducciones = pf(nomina['@_TotalDeducciones']); diff --git a/apps/api/src/services/sat/sat.service.ts b/apps/api/src/services/sat/sat.service.ts index be371f3..21afaaf 100644 --- a/apps/api/src/services/sat/sat.service.ts +++ b/apps/api/src/services/sat/sat.service.ts @@ -547,9 +547,35 @@ async function requestAndDownload( // Intentar reusar requestId previo del mismo job/kindKey (caso retry) const jobRow = await prisma.satSyncJob.findUnique({ where: { id: jobId }, - select: { satRequestIds: true }, + select: { satRequestIds: true, tenantId: true, contribuyenteId: true, dateFrom: true, dateTo: true }, }); - const existingMap = (jobRow?.satRequestIds as Record | null) || {}; + let existingMap = (jobRow?.satRequestIds as Record | null) || {}; + + // 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). + if (!existingMap[kindKey]) { + const previousJob = await prisma.satSyncJob.findFirst({ + where: { + tenantId: jobRow?.tenantId, + contribuyenteId: jobRow?.contribuyenteId ?? null, + id: { not: jobId }, + dateFrom: jobRow?.dateFrom, + dateTo: jobRow?.dateTo, + }, + orderBy: { createdAt: 'desc' }, + select: { satRequestIds: true }, + }); + if (previousJob?.satRequestIds) { + const prevMap = previousJob.satRequestIds as Record; + if (prevMap[kindKey]) { + console.log(`[SAT] Reutilizando requestId de job previo (${label}): ${prevMap[kindKey]}`); + // Copiar al job actual para futuros usos + await persistSatRequestId(jobId, kindKey, prevMap[kindKey]); + existingMap = { ...existingMap, [kindKey]: prevMap[kindKey] }; + } + } + } + let requestId: string | null = existingMap[kindKey] || null; let verifyResult: Awaited> | undefined; @@ -651,7 +677,8 @@ async function processDateRange( jobId: string, fechaInicio: Date, fechaFin: Date, - tipoCfdi: CfdiSyncType + tipoCfdi: CfdiSyncType, + skipJobUpdate = false ): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> { let totalFound = 0; let totalDownloaded = 0; @@ -678,12 +705,14 @@ async function processDateRange( console.error(`[SAT] Error en XMLs ${tipoCfdi}: ${error.message}`); } - await updateJobProgress(jobId, { - cfdisFound: totalFound, - cfdisDownloaded: totalDownloaded, - cfdisInserted: totalInserted, - cfdisUpdated: totalUpdated, - }); + if (!skipJobUpdate) { + await updateJobProgress(jobId, { + cfdisFound: totalFound, + cfdisDownloaded: totalDownloaded, + cfdisInserted: totalInserted, + cfdisUpdated: totalUpdated, + }); + } return { found: totalFound, @@ -787,7 +816,9 @@ async function processInitialSync( customDateTo?: Date ): Promise { const ahora = new Date(); - const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1); + // Exactamente 6 años atrás desde hoy (mismo día del mes), no inicio de mes. + // El SAT rechaza "mayor a 6 años" si usamos el día 1 del mes hace 6 años. + const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), ahora.getDate()); const fechaFin = customDateTo || ahora; // Paso 1: Sondeo — determinar tamaño de bloque para XMLs @@ -802,13 +833,29 @@ async function processInitialSync( let totalInserted = 0; let totalUpdated = 0; + const totalSteps = xmlChunks.length * 2 + metaChunks.length * 2; // emitidos + recibidos por cada chunk + let completedSteps = 0; + + // Helper para actualizar progreso acumulado + async function reportProgress() { + completedSteps++; + const progressPercent = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0; + await updateJobProgress(jobId, { + cfdisFound: totalFound, + cfdisDownloaded: totalDownloaded, + cfdisInserted: totalInserted, + cfdisUpdated: totalUpdated, + progressPercent, + }); + } + // Paso 2: Descargar XMLs de vigentes (bloques de 3/6 meses) for (let i = 0; i < xmlChunks.length; i++) { const { start, end } = xmlChunks[i]; console.log(`[SAT] XML bloque ${i + 1}/${xmlChunks.length}: ${start.toISOString().slice(0, 10)} → ${end.toISOString().slice(0, 10)}`); try { - const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos'); + const emitidos = await processDateRange(ctx, jobId, start, end, 'emitidos', true); totalFound += emitidos.found; totalDownloaded += emitidos.downloaded; totalInserted += emitidos.inserted; @@ -816,9 +863,10 @@ async function processInitialSync( } catch (error: any) { console.error(`[SAT] Error emitidos XML bloque ${i + 1}:`, error.message); } + await reportProgress(); try { - const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos'); + const recibidos = await processDateRange(ctx, jobId, start, end, 'recibidos', true); totalFound += recibidos.found; totalDownloaded += recibidos.downloaded; totalInserted += recibidos.inserted; @@ -826,6 +874,7 @@ async function processInitialSync( } catch (error: any) { console.error(`[SAT] Error recibidos XML bloque ${i + 1}:`, error.message); } + await reportProgress(); await new Promise(resolve => setTimeout(resolve, 5000)); } @@ -842,6 +891,7 @@ async function processInitialSync( } catch (error: any) { console.error(`[SAT] Error metadata emitidos bloque ${i + 1}:`, error.message); } + await reportProgress(); try { const { inserted, updated } = await processMetadataRange(ctx, jobId, start, end, 'recibidos'); @@ -850,6 +900,7 @@ async function processInitialSync( } catch (error: any) { console.error(`[SAT] Error metadata recibidos bloque ${i + 1}:`, error.message); } + await reportProgress(); await new Promise(resolve => setTimeout(resolve, 5000)); } @@ -859,6 +910,7 @@ async function processInitialSync( cfdisDownloaded: totalDownloaded, cfdisInserted: totalInserted, cfdisUpdated: totalUpdated, + progressPercent: 100, }); } diff --git a/apps/api/src/services/sat/sweep-stale-jobs.service.ts b/apps/api/src/services/sat/sweep-stale-jobs.service.ts index d0af285..f0e37b2 100644 --- a/apps/api/src/services/sat/sweep-stale-jobs.service.ts +++ b/apps/api/src/services/sat/sweep-stale-jobs.service.ts @@ -13,6 +13,13 @@ export interface SweepResult { }>; } +const DEFAULT_RUNNING_HOURS_BY_TYPE: Record = { + initial: 8, + daily: 4, + incremental: 2, + custom: 4, +}; + /** * Watchdog para jobs `sat_sync_jobs` stale. * @@ -22,35 +29,45 @@ export interface SweepResult { * (dev, caída, reinicio largo) el job queda colgado y bloquea el * lock para nuevos syncs del mismo (tenant, contribuyente). * - * 2. `running` con `startedAt` > runningHours atrás. Un sync inicial - * típico termina en <2h; si lleva >runningHours es casi seguro - * huérfano de un proceso que murió. La solicitud SAT ya expiró. + * 2. `running` con `startedAt` > runningHours atrás. Thresholds difieren + * por tipo: initial (8h) porque un bootstrap de 6 años puede tardar + * varias horas; daily (4h); incremental (2h) porque es ventana corta. + * Si lleva >threshold es casi seguro huérfano de un proceso que murió. * * Marca ambos como `failed` con `errorMessage` descriptivo. Idempotente * (volver a correrlo no reabre los ya-marcados-failed). * * - `apply=false` (default): dry-run, no toca BD. - * - `pendingHours`/`runningHours`: thresholds (default 12h / 4h). + * - `pendingHours`: threshold pending (default 12h). + * - `runningHours`: fallback threshold running si no se usa por-tipo (default 4h). + * - `runningHoursByType`: override por tipo de sync. */ export async function sweepStaleSatJobs(params: { apply: boolean; pendingHours?: number; runningHours?: number; + runningHoursByType?: Record; } = { apply: false }): Promise { const pendingHours = params.pendingHours ?? 12; - const runningHours = params.runningHours ?? 4; + const runningHoursByType = { ...DEFAULT_RUNNING_HOURS_BY_TYPE, ...(params.runningHoursByType || {}) }; const now = new Date(); const pendingCutoff = new Date(now.getTime() - pendingHours * 3600 * 1000); - const runningCutoff = new Date(now.getTime() - runningHours * 3600 * 1000); const stalePending = await prisma.satSyncJob.findMany({ where: { status: 'pending', nextRetryAt: { lt: pendingCutoff } }, orderBy: { createdAt: 'asc' }, }); - const staleRunning = await prisma.satSyncJob.findMany({ - where: { status: 'running', startedAt: { lt: runningCutoff } }, + + // running: evaluar por tipo usando thresholds distintos + const allRunning = await prisma.satSyncJob.findMany({ + where: { status: 'running' }, orderBy: { createdAt: 'asc' }, }); + const staleRunning = allRunning.filter(j => { + const thresholdHours = runningHoursByType[j.type] ?? params.runningHours ?? 4; + const cutoff = new Date(now.getTime() - thresholdHours * 3600 * 1000); + return (j.startedAt ?? j.createdAt) < cutoff; + }); const result: SweepResult = { pendingFound: stalePending.length, @@ -83,12 +100,13 @@ export async function sweepStaleSatJobs(params: { result.pendingMarked++; } for (const j of staleRunning) { + const thresholdHours = runningHoursByType[j.type] ?? params.runningHours ?? 4; await prisma.satSyncJob.update({ where: { id: j.id }, data: { status: 'failed', completedAt: now, - errorMessage: `Abandoned by watchdog: running with startedAt ${j.startedAt?.toISOString()} > ${runningHours}h (process crash / orphan). SAT request is lost; re-launch manually.`, + errorMessage: `Abandoned by watchdog: running ${j.type} with startedAt ${j.startedAt?.toISOString()} > ${thresholdHours}h (process crash / orphan). SAT request is lost; re-launch manually.`, }, }); result.runningMarked++; diff --git a/apps/api/src/services/tenants.service.ts b/apps/api/src/services/tenants.service.ts index 3180e11..a456138 100644 --- a/apps/api/src/services/tenants.service.ts +++ b/apps/api/src/services/tenants.service.ts @@ -17,7 +17,17 @@ export async function getAllTenants() { createdAt: true, _count: { select: { memberships: { where: { active: true } } as any } - } + }, + subscriptions: { + orderBy: { createdAt: 'desc' }, + take: 1, + select: { + id: true, + amount: true, + currentPeriodEnd: true, + status: true, + }, + }, }, orderBy: { nombre: 'asc' } }); @@ -266,8 +276,10 @@ export async function updateTenant(id: string, data: { rfc?: string; plan?: DespachoPlan; active?: boolean; + amount?: number; + firstPaymentDueAt?: string | null; }) { - return prisma.tenant.update({ + const tenant = await prisma.tenant.update({ where: { id }, data: { ...(data.nombre && { nombre: data.nombre }), @@ -285,6 +297,29 @@ export async function updateTenant(id: string, data: { createdAt: true, } }); + + // Actualizar subscription del tenant (plan custom o cualquier plan con amount) + if (data.amount !== undefined || data.firstPaymentDueAt !== undefined) { + const subscription = await prisma.subscription.findFirst({ + where: { tenantId: id }, + orderBy: { createdAt: 'desc' }, + }); + if (subscription) { + const updateData: any = {}; + if (data.amount !== undefined) { + updateData.amount = data.amount; + } + if (data.firstPaymentDueAt !== undefined) { + updateData.currentPeriodEnd = data.firstPaymentDueAt ? new Date(data.firstPaymentDueAt) : null; + } + await prisma.subscription.update({ + where: { id: subscription.id }, + data: updateData, + }); + } + } + + return tenant; } export async function getDatosFiscales(id: string) { diff --git a/apps/api/src/services/trial-invitations.service.ts b/apps/api/src/services/trial-invitations.service.ts new file mode 100644 index 0000000..db212f5 --- /dev/null +++ b/apps/api/src/services/trial-invitations.service.ts @@ -0,0 +1,191 @@ +import { prisma } from '../config/database.js'; +import { emailService } from './email/email.service.js'; +import { getTenantOwnerEmail } from '../utils/memberships.js'; +import crypto from 'crypto'; + +function generateToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +export async function createInvitation(params: { + tenantId: string; + invitedByUserId: string; + plan?: string; + durationDays: number; +}) { + const tenant = await prisma.tenant.findUnique({ + where: { id: params.tenantId }, + select: { nombre: true, rfc: true, plan: true }, + }); + if (!tenant) throw new Error('Tenant no encontrado'); + + // Verificar que no haya ya una invitación pendiente para este tenant + const existingPending = await prisma.trialInvitation.findFirst({ + where: { tenantId: params.tenantId, status: 'pending' }, + }); + if (existingPending) { + throw new Error('Este tenant ya tiene una invitación de trial pendiente'); + } + + // Verificar que el tenant no tenga ya una suscripción activa del mismo plan + const existingSub = await prisma.subscription.findFirst({ + where: { + tenantId: params.tenantId, + status: { in: ['authorized', 'pending', 'trial'] }, + plan: (params.plan || 'business_control') as any, + }, + }); + if (existingSub) { + throw new Error(`Este tenant ya tiene una suscripción activa o en trial de ${params.plan || 'business_control'}`); + } + + const token = generateToken(); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 días para aceptar + + const invitation = await prisma.trialInvitation.create({ + data: { + tenantId: params.tenantId, + invitedBy: params.invitedByUserId, + plan: params.plan || 'business_control', + durationDays: params.durationDays, + token, + expiresAt, + emailSentTo: null, + }, + }); + + // Enviar email al owner (fire-and-forget) + const ownerEmail = await getTenantOwnerEmail(params.tenantId); + if (ownerEmail) { + await prisma.trialInvitation.update({ + where: { id: invitation.id }, + data: { emailSentTo: ownerEmail }, + }); + const acceptUrl = `${process.env.FRONTEND_URL || 'https://app.horux360.com'}/invitacion/trial/${token}`; + emailService.sendTrialInvitation(ownerEmail, { + despachoNombre: tenant.nombre, + plan: invitation.plan, + durationDays: invitation.durationDays, + acceptUrl, + expiresAt: expiresAt.toLocaleDateString('es-MX'), + }).catch((err: any) => console.error('[TrialInvitation] Email failed:', err.message)); + } + + return invitation; +} + +export async function acceptInvitation(token: string, userId: string) { + const invitation = await prisma.trialInvitation.findUnique({ + where: { token }, + }); + if (!invitation) throw new Error('Invitación no encontrada'); + if (invitation.status !== 'pending') throw new Error(`Invitación ya ${invitation.status}`); + if (invitation.expiresAt < new Date()) { + await prisma.trialInvitation.update({ + where: { id: invitation.id }, + data: { status: 'expired' }, + }); + throw new Error('La invitación ha expirado'); + } + + // Verificar que el usuario sea owner del tenant + const membership = await prisma.tenantMembership.findFirst({ + where: { userId, tenantId: invitation.tenantId, isOwner: true, active: true }, + }); + if (!membership) { + throw new Error('Solo el dueño del despacho puede aceptar esta invitación'); + } + + const trialEndsAt = new Date(); + trialEndsAt.setDate(trialEndsAt.getDate() + invitation.durationDays); + const now = new Date(); + + await prisma.$transaction(async (tx) => { + // Actualizar tenant + await tx.tenant.update({ + where: { id: invitation.tenantId }, + data: { + plan: invitation.plan as any, + trialEndsAt, + }, + }); + + // Cancelar cualquier subscription trial anterior genérica + await tx.subscription.updateMany({ + where: { tenantId: invitation.tenantId, status: 'trial' }, + data: { status: 'trial_converted' }, + }); + + // Crear nueva subscription de trial con el plan específico + await tx.subscription.create({ + data: { + tenantId: invitation.tenantId, + plan: invitation.plan as any, + status: 'trial', + amount: 0, + frequency: 'annual', + currentPeriodStart: now, + currentPeriodEnd: trialEndsAt, + }, + }); + + // Marcar invitación como aceptada + await tx.trialInvitation.update({ + where: { id: invitation.id }, + data: { status: 'accepted', acceptedAt: now }, + }); + }); + + return { success: true, trialEndsAt, plan: invitation.plan, durationDays: invitation.durationDays }; +} + +export async function getInvitations(filters?: { tenantId?: string; status?: string }) { + const where: any = {}; + if (filters?.tenantId) where.tenantId = filters.tenantId; + if (filters?.status) where.status = filters.status; + + const invitations = await prisma.trialInvitation.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + + // Enrich with tenant data + const tenantIds = [...new Set(invitations.map(i => i.tenantId))]; + const tenants = await prisma.tenant.findMany({ + where: { id: { in: tenantIds } }, + select: { id: true, nombre: true, rfc: true }, + }); + const tenantMap = new Map(tenants.map(t => [t.id, t])); + + return invitations.map(inv => ({ + ...inv, + tenant: tenantMap.get(inv.tenantId) || null, + })); +} + +export async function getPendingInvitationForTenant(tenantId: string) { + const invitation = await prisma.trialInvitation.findFirst({ + where: { tenantId, status: 'pending', expiresAt: { gt: new Date() } }, + }); + if (!invitation) return null; + + const tenant = await prisma.tenant.findUnique({ + where: { id: tenantId }, + select: { nombre: true, rfc: true }, + }); + + return { ...invitation, tenant }; +} + +export async function cancelInvitation(invitationId: string) { + const invitation = await prisma.trialInvitation.findUnique({ + where: { id: invitationId }, + }); + if (!invitation) throw new Error('Invitación no encontrada'); + if (invitation.status !== 'pending') throw new Error('Solo se pueden cancelar invitaciones pendientes'); + + return prisma.trialInvitation.update({ + where: { id: invitationId }, + data: { status: 'cancelled' }, + }); +} diff --git a/apps/api/src/utils/platform-admin.ts b/apps/api/src/utils/platform-admin.ts index dfa531a..94fe636 100644 --- a/apps/api/src/utils/platform-admin.ts +++ b/apps/api/src/utils/platform-admin.ts @@ -107,7 +107,12 @@ export async function getPlatformRoles(userId: string): Promise * owner del tenant HTS240708LJA, se considera platform_admin (cubre el escenario * post-deploy pre-seed). */ -export async function isGlobalAdmin(tenantId: string, role: string): Promise { +export async function isGlobalAdmin(tenantId: string, role: string, userId?: string): Promise { + // Si se pasa userId y tiene rol de plataforma superset, permitir acceso global + if (userId && await hasAnyPlatformRole(userId, ...SUPERSET_ROLES)) { + return true; + } + // Las firmas viejas no tienen userId disponible. Lo resolvemos buscando el user // que matchea tenantId + rol 'owner'. Para evitar ese hit extra, la preferencia // es usar `hasPlatformRole(req.user.userId, 'platform_admin')` en código nuevo. diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 1bbb85d..12d4c40 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -7,7 +7,7 @@ import Image from 'next/image'; import { Button, Input, Label, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@horux/shared-ui'; import { login } from '@/lib/api/auth'; import { useAuthStore } from '@/stores/auth-store'; -import { isGlobalAdminRfc } from '@horux/shared'; +import { isGlobalAdminRfc, type PlatformRole } from '@horux/shared'; import { shouldShowOnboarding } from '@/lib/onboarding'; export default function LoginPage() { @@ -33,7 +33,7 @@ export default function LoginPage() { const userRole = response.user?.role; // Admin global aterriza directo en `/clientes` — su home natural es la // gestión de tenants, no el dashboard operativo del despacho. - const platformRoles = (response.user as { platformRoles?: string[] }).platformRoles; + const platformRoles = (response.user as { platformRoles?: PlatformRole[] }).platformRoles; const isGlobalAdmin = isGlobalAdminRfc(response.user?.tenantRfc, userRole, platformRoles); if (isGlobalAdmin) { router.push('/clientes'); diff --git a/apps/web/app/(auth)/register-despacho/page.tsx b/apps/web/app/(auth)/register-despacho/page.tsx index 8297f24..3fdeef8 100644 --- a/apps/web/app/(auth)/register-despacho/page.tsx +++ b/apps/web/app/(auth)/register-despacho/page.tsx @@ -3,23 +3,18 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, cn } from '@horux/shared-ui'; +import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui'; import { useAuthStore } from '@/stores/auth-store'; import { apiClient } from '@/lib/api/client'; -import { CheckCircle2, Server, Cloud, ArrowLeft, Clock, Building, Sparkles } from 'lucide-react'; +import { CheckCircle2, ArrowLeft } from 'lucide-react'; type VerticalProfile = 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA'; -type PlanType = 'trial' | 'mi_empresa' | 'mi_empresa_plus' | 'business_control' | 'business_cloud'; export default function RegisterDespachoPage() { const router = useRouter(); const { setUser, setTokens } = useAuthStore(); const [step, setStep] = useState(1); const [verticalProfile, setVerticalProfile] = useState(null); - const [selectedPlan, setSelectedPlan] = useState(null); - // Default 'annual' — sesgo intencional al cash-flow del negocio (10 meses - // = 17% descuento para el cliente, año completo cobrado upfront para nosotros). - const [billingFrequency, setBillingFrequency] = useState<'monthly' | 'annual'>('annual'); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [form, setForm] = useState({ @@ -37,7 +32,7 @@ export default function RegisterDespachoPage() { const handleSubmit = async () => { if (!form.acceptedTerms) { setError('Debes aceptar los términos y condiciones'); return; } - if (!verticalProfile || !selectedPlan) { setError('Completa todos los pasos'); return; } + if (!verticalProfile) { setError('Selecciona un tipo de despacho'); return; } setLoading(true); setError(''); try { @@ -45,10 +40,6 @@ export default function RegisterDespachoPage() { despacho: { nombre: form.despachoNombre, verticalProfile, - plan: selectedPlan, - // Solo mi_empresa(+) acepta monthly; el backend ignora frequency - // para los demás planes. Mandamos siempre el state para coherencia. - frequency: billingFrequency, }, owner: { nombre: form.ownerNombre, @@ -58,13 +49,7 @@ export default function RegisterDespachoPage() { }); setTokens(data.accessToken, data.refreshToken); setUser(data.user); - - // If paid plan with payment URL, redirect to MercadoPago - if (data.paymentUrl) { - window.location.href = data.paymentUrl; - } else { - router.push('/onboarding'); - } + router.push('/onboarding'); } catch (err: any) { setError(err.response?.data?.message || 'Error al registrar el despacho'); setStep(1); @@ -85,8 +70,6 @@ export default function RegisterDespachoPage() { 1 2 - - 3 Crea tu cuenta

Plataforma para despachos profesionales

@@ -118,297 +101,43 @@ export default function RegisterDespachoPage() { } // =================== STEP 2: Vertical Selection =================== - if (step === 2) { - return ( -
-
-
-
- - - 2 - - 3 -
-

¿Qué tipo de despacho eres?

-

Selecciona tu área profesional

-
-
- -
-
⚖️
-

Jurídico

-

Próximamente

-
-
-
🏗️
-

Arquitectura

-

Próximamente

-
-
- -
-
- ); - } - - // =================== STEP 3: Subscription Selection =================== return ( -
-
-
+
+
+
- - - 3 + 2
-

Elige tu plan

-

Empieza con el trial gratuito de 30 días o contrata un plan directo.

+

¿Qué tipo de despacho eres?

+

Selecciona tu área profesional

- - {/* Toggle facturación mensual / anual (afecta solo Mi Empresa y Mi Empresa+) */} -
-
- - -
-
- -
- {/* Trial Gratuito */} - setSelectedPlan('trial')} +
+
- - {error &&

{error}

} - -
- - +
+
⚖️
+

Jurídico

+

Próximamente

+
+
+
🏗️
+

Arquitectura

+

Próximamente

+
+ {error &&

{error}

} +
); diff --git a/apps/web/app/(auth)/reset-password/page.tsx b/apps/web/app/(auth)/reset-password/page.tsx index b3b783c..ee7f3ba 100644 --- a/apps/web/app/(auth)/reset-password/page.tsx +++ b/apps/web/app/(auth)/reset-password/page.tsx @@ -34,9 +34,9 @@ function ResetPasswordContent() { - - - + ); @@ -82,9 +82,9 @@ function ResetPasswordContent() { - - - + ); diff --git a/apps/web/app/(dashboard)/admin/facturas-pendientes/page.tsx b/apps/web/app/(dashboard)/admin/facturas-pendientes/page.tsx new file mode 100644 index 0000000..3039db5 --- /dev/null +++ b/apps/web/app/(dashboard)/admin/facturas-pendientes/page.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useState } from 'react'; +import { Header } from '@/components/layouts/header'; +import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui'; +import { useAuthStore } from '@/stores/auth-store'; +import { isGlobalAdminRfc } from '@horux/shared'; +import { usePagosSinFactura, useEmitirFacturaPago } from '@/lib/hooks/use-pagos-sin-factura'; +import { ShieldAlert, FileText, RefreshCw, CheckCircle, AlertCircle, Receipt } from 'lucide-react'; + +const PLAN_LABELS: Record = { + trial: 'Trial', + custom: 'Custom', + mi_empresa: 'Mi Empresa', + mi_empresa_plus: 'Mi Empresa Plus', + business_control: 'Business Control', + business_cloud: 'Enterprise', +}; + +const METHOD_LABELS: Record = { + master: 'Mastercard', + visa: 'Visa', + amex: 'Amex', + debmaster: 'Débito Mastercard', + debvisa: 'Débito Visa', + account_money: 'MercadoPago', + bank_transfer: 'Transferencia', +}; + +function formatCurrency(amount: string | number): string { + const num = typeof amount === 'string' ? parseFloat(amount) : amount; + return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(num); +} + +function formatDate(iso: string | null): string { + if (!iso) return '—'; + return new Date(iso).toLocaleString('es-MX', { dateStyle: 'short', timeStyle: 'short' }); +} + +export default function FacturasPendientesPage() { + const { user } = useAuthStore(); + const isGlobalAdmin = isGlobalAdminRfc(user?.tenantRfc, user?.role, user?.platformRoles); + const { data: payments, isLoading, error } = usePagosSinFactura(); + const emitir = useEmitirFacturaPago(); + const [emitiendoId, setEmitiendoId] = useState(null); + + if (!isGlobalAdmin) { + return ( + <> +
+
+ + + +

Acceso restringido

+

+ Solo el administrador global puede consultar pagos sin factura. +

+
+
+
+ + ); + } + + async function handleEmitir(paymentId: string) { + setEmitiendoId(paymentId); + try { + await emitir.mutateAsync(paymentId); + } finally { + setEmitiendoId(null); + } + } + + return ( + <> +
+
+ + + + + Pagos de suscripción sin factura + + + + {isLoading && ( +
+ + Cargando... +
+ )} + + {error && ( +
+ + Error al cargar los pagos +
+ )} + + {!isLoading && !error && payments && payments.length === 0 && ( +
+ +

No hay pagos pendientes de facturar

+

Todos los pagos aprobados ya tienen su factura emitida.

+
+ )} + + {!isLoading && !error && payments && payments.length > 0 && ( +
+ + + + + + + + + + + + + {payments.map((p) => ( + + + + + + + + + ))} + +
ClientePlanMontoMétodoFecha de pagoAcción
+
{p.tenant.nombre}
+
{p.tenant.rfc || '—'}
+
+ + {PLAN_LABELS[p.subscription?.plan || 'custom'] || p.subscription?.plan || 'Custom'} + + {formatCurrency(p.amount)} + {METHOD_LABELS[p.paymentMethod || ''] || p.paymentMethod || '—'} + {formatDate(p.paidAt)} + +
+
+ )} +
+
+
+ + ); +} diff --git a/apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx b/apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx new file mode 100644 index 0000000..1b4c125 --- /dev/null +++ b/apps/web/app/(dashboard)/admin/invitaciones-trial/page.tsx @@ -0,0 +1,260 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button, Input, Label, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui'; +import { getAllInvitations, createInvitation, cancelInvitation } from '@/lib/api/trial-invitations'; +import { getTenants } from '@/lib/api/tenants'; +import { Gift, X, Clock, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react'; + +interface TenantOption { + id: string; + nombre: string; + rfc: string; +} + +interface Invitation { + id: string; + tenantId: string; + plan: string; + durationDays: number; + status: string; + token: string; + sentAt: string; + expiresAt: string; + acceptedAt: string | null; + tenant: { + nombre: string; + rfc: string; + } | null; +} + +export default function InvitacionesTrialPage() { + const [tenants, setTenants] = useState([]); + const [invitations, setInvitations] = useState([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [selectedTenantId, setSelectedTenantId] = useState(''); + const [durationDays, setDurationDays] = useState('30'); + const [plan, setPlan] = useState('business_control'); + const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); + + useEffect(() => { + loadData(); + }, []); + + async function loadData() { + setLoading(true); + try { + const [tenantsData, invitationsData] = await Promise.all([ + getTenants(), + getAllInvitations(), + ]); + setTenants(tenantsData); + setInvitations(invitationsData); + } catch (err: any) { + setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cargar datos' }); + } finally { + setLoading(false); + } + } + + async function handleCreate() { + if (!selectedTenantId || !durationDays) { + setMessage({ kind: 'err', text: 'Selecciona un despacho y duración' }); + return; + } + setCreating(true); + setMessage(null); + try { + await createInvitation({ + tenantId: selectedTenantId, + plan, + durationDays: parseInt(durationDays, 10), + }); + setMessage({ kind: 'ok', text: 'Invitación enviada correctamente' }); + setSelectedTenantId(''); + setDurationDays('30'); + loadData(); + } catch (err: any) { + setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al crear invitación' }); + } finally { + setCreating(false); + } + } + + async function handleCancel(id: string) { + if (!confirm('¿Seguro que quieres cancelar esta invitación?')) return; + try { + await cancelInvitation(id); + setMessage({ kind: 'ok', text: 'Invitación cancelada' }); + loadData(); + } catch (err: any) { + setMessage({ kind: 'err', text: err?.response?.data?.message || 'Error al cancelar' }); + } + } + + function statusIcon(status: string) { + switch (status) { + case 'pending': return ; + case 'accepted': return ; + case 'expired': return ; + case 'cancelled': return ; + default: return null; + } + } + + function statusLabel(status: string) { + switch (status) { + case 'pending': return 'Pendiente'; + case 'accepted': return 'Aceptada'; + case 'expired': return 'Expirada'; + case 'cancelled': return 'Cancelada'; + default: return status; + } + } + + return ( +
+
+

+ + Invitaciones de Trial +

+

Envía invitaciones de prueba a despachos específicos

+
+ + {/* Toast de resultado */} + {message && ( +
+ {message.text} +
+ )} + + {/* Formulario de creación */} + + + Nueva invitación + + +
+
+ + +
+
+ + +
+
+ + setDurationDays(e.target.value)} + /> +
+
+ +
+
+ + {/* Tabla de invitaciones */} + + + Historial de invitaciones + + + {loading ? ( +
+ +
+ ) : invitations.length === 0 ? ( +

No hay invitaciones enviadas

+ ) : ( +
+ + + + + + + + + + + + + + {invitations.map((inv) => ( + + + + + + + + + + ))} + +
DespachoPlanDíasEstadoEnviadoExpira
+
{inv.tenant?.nombre || '—'}
+
{inv.tenant?.rfc || '—'}
+
+ {inv.plan === 'business_control' ? 'Business Control' : inv.plan === 'business_cloud' ? 'Enterprise' : inv.plan} + {inv.durationDays} + + {statusIcon(inv.status)} + {statusLabel(inv.status)} + + + {new Date(inv.sentAt).toLocaleDateString('es-MX')} + + {new Date(inv.expiresAt).toLocaleDateString('es-MX')} + + {inv.status === 'pending' && ( + + )} +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/admin/usuarios/page.tsx b/apps/web/app/(dashboard)/admin/usuarios/page.tsx index ddcba35..8b8a90f 100644 --- a/apps/web/app/(dashboard)/admin/usuarios/page.tsx +++ b/apps/web/app/(dashboard)/admin/usuarios/page.tsx @@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle, Button, Input, Label, Select, import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios'; import { getTenants, type Tenant } from '@/lib/api/tenants'; import { useAuthStore } from '@/stores/auth-store'; +import { isGlobalAdminRfc } from '@horux/shared'; import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check, UserCog, UserCheck, User, Briefcase } from 'lucide-react'; import { cn } from '@horux/shared-ui'; @@ -43,9 +44,12 @@ export default function AdminUsuariosPage() { const [filterTenant, setFilterTenant] = useState('all'); const [searchTerm, setSearchTerm] = useState(''); + const isGlobal = isGlobalAdminRfc(currentUser?.tenantRfc, currentUser?.role, currentUser?.platformRoles); + useEffect(() => { + if (!isGlobal) return; getTenants().then(setTenants).catch(console.error); - }, []); + }, [isGlobal]); const handleEdit = (usuario: any) => { setEditingUser({ diff --git a/apps/web/app/(dashboard)/cfdi/page.tsx b/apps/web/app/(dashboard)/cfdi/page.tsx index bc0c127..9ec4eea 100644 --- a/apps/web/app/(dashboard)/cfdi/page.tsx +++ b/apps/web/app/(dashboard)/cfdi/page.tsx @@ -359,7 +359,7 @@ export default function CfdiPage() { // CFDI Viewer state const [viewingCfdi, setViewingCfdi] = useState(null); - const [loadingCfdi, setLoadingCfdi] = useState(null); + const [loadingCfdi, setLoadingCfdi] = useState(null); // Cancelación Facturapi state const [cancelTarget, setCancelTarget] = useState(null); @@ -367,10 +367,10 @@ export default function CfdiPage() { const [cancelSubstitution, setCancelSubstitution] = useState(''); const [cancelling, setCancelling] = useState(false); - const handleViewCfdi = async (id: string) => { + const handleViewCfdi = async (id: number) => { setLoadingCfdi(id); try { - const cfdi = await getCfdiById(id); + const cfdi = await getCfdiById(String(id)); setViewingCfdi(cfdi); } catch (error) { console.error('Error loading CFDI:', error); @@ -882,10 +882,10 @@ export default function CfdiPage() { setUploadProgress(prev => ({ ...prev, status: 'idle' })); }; - const handleDelete = async (id: string) => { + const handleDelete = async (id: number) => { if (confirm('¿Eliminar este CFDI?')) { try { - await deleteCfdi.mutateAsync(id); + await deleteCfdi.mutateAsync(String(id)); } catch (error) { console.error('Error deleting CFDI:', error); } @@ -920,9 +920,9 @@ export default function CfdiPage() { const calculateTotal = () => { const subtotal = formData.subtotal || 0; const descuento = formData.descuento || 0; - const iva = formData.ivaTrasladoTraslado || 0; + const iva = formData.ivaTraslado || 0; const isrRetencion = formData.isrRetencion || 0; - const ivaRetencion = formData.ivaTrasladoRetencion || 0; + const ivaRetencion = formData.ivaRetencion || 0; return subtotal - descuento + iva - isrRetencion - ivaRetencion; }; diff --git a/apps/web/app/(dashboard)/clientes/page.tsx b/apps/web/app/(dashboard)/clientes/page.tsx index 67d7e42..62baa09 100644 --- a/apps/web/app/(dashboard)/clientes/page.tsx +++ b/apps/web/app/(dashboard)/clientes/page.tsx @@ -6,9 +6,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Header } from '@/components/layouts/header'; import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@horux/shared-ui'; import { useTenants, useCreateTenant, useUpdateTenant, useDeleteTenant } from '@/lib/hooks/use-tenants'; +import { usePagosSinFactura } from '@/lib/hooks/use-pagos-sin-factura'; import { useTenantViewStore } from '@/stores/tenant-view-store'; import { useAuthStore } from '@/stores/auth-store'; -import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X, DollarSign, AlertCircle, ChevronRight } from 'lucide-react'; +import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X, DollarSign, AlertCircle, ChevronRight, Receipt } from 'lucide-react'; import type { Tenant } from '@/lib/api/tenants'; import { isGlobalAdminRfc } from '@horux/shared'; import { getClientesStats, getTenantUsuarios, type TenantUsuario } from '@/lib/api/admin-clientes'; @@ -56,6 +57,7 @@ export default function ClientesPage() { queryFn: () => getClientesStats(from, to), enabled: !!user, }); + const { data: pagosSinFactura } = usePagosSinFactura(); // Map tenantId → activeUsers para lookup O(1) cuando renderizamos la lista. const usuariosPorTenant = useMemo(() => { @@ -129,14 +131,17 @@ export default function ClientesPage() { const handleEdit = (tenant: Tenant) => { setEditingTenant(tenant); + const sub = tenant.subscriptions?.[0]; setFormData({ nombre: tenant.nombre, rfc: tenant.rfc, plan: tenant.plan as PlanType, adminEmail: '', adminNombre: '', - amount: 0, - firstPaymentDueAt: '', + amount: sub ? Number(sub.amount) : 0, + firstPaymentDueAt: sub?.currentPeriodEnd + ? new Date(sub.currentPeriodEnd).toISOString().slice(0, 10) + : '', }); setShowForm(true); }; @@ -210,7 +215,7 @@ export default function ClientesPage() { {/* KPIs */} -
+
{/* Total clientes activos */} @@ -286,6 +291,24 @@ export default function ClientesPage() {
+ + {/* Facturas pendientes */} + router.push('/admin/facturas-pendientes')} + > + +
+
+ +
+
+

{pagosSinFactura?.length ?? 0}

+

Facturas pendientes

+
+
+
+
{/* Detalle de no renovaciones */} @@ -454,78 +477,78 @@ export default function ClientesPage() {
- {/* Campos de admin y suscripción — solo al crear */} + {/* Campos de admin — solo al crear */} {!editingTenant && ( - <> -
-

Dueño del Cliente

-
-
- - setFormData({ ...formData, adminNombre: e.target.value })} - placeholder="Juan Pérez" - required - /> -
-
- - setFormData({ ...formData, adminEmail: e.target.value })} - placeholder="admin@empresa.com" - required - /> -
+
+

Dueño del Cliente

+
+
+ + setFormData({ ...formData, adminNombre: e.target.value })} + placeholder="Juan Pérez" + required + /> +
+
+ + setFormData({ ...formData, adminEmail: e.target.value })} + placeholder="admin@empresa.com" + required + />
- {formData.plan !== 'trial' && ( -
-
- - setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })} - placeholder="0.00" - /> - {formData.plan === 'custom' && ( -

- Custom: monto variable. Si dejas $0, no se cobra ni se solicita tarjeta. - Si pones >$0, se generará Subscription con preapproval MercadoPago mensual. -

- )} -
- {formData.plan === 'custom' && ( -
- - setFormData({ ...formData, firstPaymentDueAt: e.target.value })} - /> -

- Deadline visible al cliente para realizar su primer pago. Opcional. -

-
- )} +
+ )} + + {/* Campos de suscripción — crear y editar (solo planes pagados / custom) */} + {formData.plan !== 'trial' && ( +
+
+ + setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })} + placeholder="0.00" + /> + {formData.plan === 'custom' && ( +

+ Custom: monto variable. Si dejas $0, no se cobra ni se solicita tarjeta. + Si pones >$0, se generará Subscription con preapproval MercadoPago mensual. +

+ )} +
+ {formData.plan === 'custom' && ( +
+ + setFormData({ ...formData, firstPaymentDueAt: e.target.value })} + /> +

+ Deadline visible al cliente para realizar su primer pago. Opcional. +

)} - {formData.plan === 'trial' && ( -

- Plan de prueba: 30 días gratis sin tarjeta. Se convierte a un plan pagado al final del periodo. -

- )} - +
+ )} + {formData.plan === 'trial' && ( +

+ Plan de prueba: 30 días gratis sin tarjeta. Se convierte a un plan pagado al final del periodo. +

)}
diff --git a/apps/web/app/(dashboard)/configuracion/page.tsx b/apps/web/app/(dashboard)/configuracion/page.tsx index 45371dd..26aaa4c 100644 --- a/apps/web/app/(dashboard)/configuracion/page.tsx +++ b/apps/web/app/(dashboard)/configuracion/page.tsx @@ -77,7 +77,7 @@ function RegimenesActivosSection() { useEffect(() => { if (activos && catalogo) { - const ids = new Set(activos.map(a => catalogo.find(c => c.clave === a.clave)?.id).filter(Boolean) as number[]); + const ids = new Set(activos.map((a: { clave: string }) => catalogo.find((c: { clave: string; id: number }) => c.clave === a.clave)?.id).filter(Boolean) as number[]); setSelected(ids); } }, [activos, catalogo]); diff --git a/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx b/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx index 8b74eb2..a8a9334 100644 --- a/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx +++ b/apps/web/app/(dashboard)/configuracion/planes-despacho/page.tsx @@ -2,9 +2,10 @@ import { useEffect, useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle, Button } from '@horux/shared-ui'; -import { CheckCircle2, Server, Cloud, Clock, ExternalLink, CreditCard } from 'lucide-react'; +import { CheckCircle2, Server, Cloud, Clock, ExternalLink, CreditCard, Gift } from 'lucide-react'; import { apiClient } from '@/lib/api/client'; import { subscribeMe, changeMyPlan, cancelMySubscription, upgradeMe, generatePaymentLink } from '@/lib/api/subscription'; +import { getPendingInvitation, acceptInvitation } from '@/lib/api/trial-invitations'; import { useAuthStore } from '@/stores/auth-store'; type Despachoplan = 'trial' | 'business_control' | 'business_cloud' | 'mi_empresa' | 'mi_empresa_plus' | 'custom'; @@ -37,7 +38,7 @@ export default function PlanesDespachoPage() { const { user } = useAuthStore(); const [planInfo, setPlanInfo] = useState(null); const [loading, setLoading] = useState(true); - const [busy, setBusy] = useState(null); + const [busy, setBusy] = useState(null); const [message, setMessage] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); // Toggle mensual/anual solo aplica a Mi Empresa y Mi Empresa+. Business // Control y Enterprise siempre se cobran anual. Default monthly para @@ -45,6 +46,12 @@ export default function PlanesDespachoPage() { // muestra como CTA secundario. const [meFreq, setMeFreq] = useState('monthly'); const [mePlusFreq, setMePlusFreq] = useState('monthly'); + const [pendingInvitation, setPendingInvitation] = useState<{ + id: string; + plan: string; + durationDays: number; + token: string; + } | null>(null); const fetchPlan = () => { apiClient.get('/despachos/me/plan') @@ -58,6 +65,20 @@ export default function PlanesDespachoPage() { .then(res => setPlanInfo(res.data)) .catch(() => setPlanInfo(null)) .finally(() => setLoading(false)); + + // Cargar invitación de trial pendiente + getPendingInvitation() + .then((inv) => { + if (inv && inv.status === 'pending') { + setPendingInvitation({ + id: inv.id, + plan: inv.plan, + durationDays: inv.durationDays, + token: inv.token, + }); + } + }) + .catch(() => {}); }, []); const currentPlan = planInfo?.plan ?? null; @@ -153,6 +174,22 @@ export default function PlanesDespachoPage() { } } + async function handleAcceptInvitation() { + if (!pendingInvitation) return; + setBusy('accept-invite'); + setMessage(null); + try { + const result = await acceptInvitation(pendingInvitation.token); + setMessage({ kind: 'ok', text: `¡Activado! Tienes ${result.durationDays} días de Business Control Prueba.` }); + setPendingInvitation(null); + fetchPlan(); + } catch (err: any) { + setMessage({ kind: 'err', text: err?.response?.data?.message || err?.message || 'Error al activar la invitación' }); + } finally { + setBusy(null); + } + } + function ActiveBadge() { return (
@@ -242,6 +279,28 @@ export default function PlanesDespachoPage() {
)} + {/* Banner de invitación de trial pendiente */} + {!loading && pendingInvitation && ( +
+ +
+
+ Invitación especial — Business Control Prueba +
+
+ Tienes una invitación para probar Business Control por {pendingInvitation.durationDays} días con todas las funciones. +
+
+ +
+ )} + {/* Banner de suscripción activa */} {!loading && planInfo?.subscription && hasPaidPlan && (() => { const sub = planInfo.subscription; diff --git a/apps/web/app/(dashboard)/contribuyentes/page.tsx b/apps/web/app/(dashboard)/contribuyentes/page.tsx index 546f5a8..b4363ee 100644 --- a/apps/web/app/(dashboard)/contribuyentes/page.tsx +++ b/apps/web/app/(dashboard)/contribuyentes/page.tsx @@ -78,7 +78,7 @@ export default function ContribuyentesPage() { }; return ( -
+

Contribuyentes

RFCs que gestiona tu despacho

- + + )} diff --git a/apps/web/app/(dashboard)/usuarios/page.tsx b/apps/web/app/(dashboard)/usuarios/page.tsx index fb0d1ca..4d916bf 100644 --- a/apps/web/app/(dashboard)/usuarios/page.tsx +++ b/apps/web/app/(dashboard)/usuarios/page.tsx @@ -14,7 +14,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from ' import Link from 'next/link'; import { cn } from '@horux/shared-ui'; import { isDespachoTenant } from '@horux/shared'; -import type { Role } from '@horux/shared'; +import type { Role, UserInvite } from '@horux/shared'; // ── Horux360 legacy roles ───────────────────────────────────────────────────── const legacyRoleLabels: Record = { @@ -83,10 +83,10 @@ export default function UsuariosPage() { const isAdmin = currentUser?.role === 'owner' || currentUser?.role === 'cfo'; const [showInvite, setShowInvite] = useState(false); - const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: Role; supervisorUserId?: string }>({ + const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: UserInvite['role']; supervisorUserId?: string }>({ email: '', nombre: '', - role: defaultInviteRole as Role, + role: defaultInviteRole as UserInvite['role'], }); const [selectedRfcIds, setSelectedRfcIds] = useState([]); @@ -183,7 +183,7 @@ export default function UsuariosPage() { ); } setShowInvite(false); - setInviteForm({ email: '', nombre: '', role: defaultInviteRole as Role, supervisorUserId: undefined }); + setInviteForm({ email: '', nombre: '', role: defaultInviteRole as UserInvite['role'], supervisorUserId: undefined }); setSelectedRfcIds([]); } catch (error: any) { alert(error.response?.data?.message || 'Error al invitar usuario'); @@ -211,11 +211,11 @@ export default function UsuariosPage() {
{isAdmin && isDespacho && ( - - - + + )} {isAdmin && (