Compare commits

..

23 Commits

Author SHA1 Message Date
Consultoria AS
351b14a78c security: comprehensive security audit and remediation (20 fixes)
CRITICAL fixes:
- Restrict X-View-Tenant impersonation to global admin only (was any admin)
- Add authorization to subscription endpoints (was open to any user)
- Make webhook signature verification mandatory (was skippable)
- Remove databaseName from JWT payload (resolve server-side with cache)
- Reduce body size limit from 1GB to 10MB (50MB for bulk CFDI)
- Restrict .env file permissions to 600

HIGH fixes:
- Add authorization to SAT cron endpoints (global admin only)
- Add Content-Security-Policy and Permissions-Policy headers
- Centralize isGlobalAdmin() utility with caching
- Add rate limiting on auth endpoints (express-rate-limit)
- Require authentication on logout endpoint

MEDIUM fixes:
- Replace Math.random() with crypto.randomBytes for temp passwords
- Remove console.log of temporary passwords in production
- Remove DB credentials from admin notification email
- Add escapeHtml() to email templates (prevent HTML injection)
- Add file size validation on FIEL upload (50KB max)
- Require TLS for SMTP connections
- Normalize email to lowercase before uniqueness check
- Remove hardcoded default for FIEL_ENCRYPTION_KEY

Also includes:
- Complete production deployment documentation
- API reference documentation
- Security audit report with remediation details
- Updated README with v0.5.0 changelog
- New client admin email template
- Utility scripts (create-carlos, test-emails)
- PM2 ecosystem config updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:32:04 +00:00
Consultoria AS
38626bd3e6 feat: enhance subscription page with pay button, billing period, and alerts
- "Pagar ahora" button generates MercadoPago link and opens in new tab
- Billing period card shows start/end dates and days until next payment
- Warning banners: expired (red), expiring soon (yellow), pending payment
- Improved payment history with icons and translated payment methods

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:59:09 +00:00
Consultoria AS
d22e898909 feat: add subscription UI, plan-based nav gating, and client subscription page
- Add plan field to UserInfo shared type
- Subscription API client and React Query hooks
- Client subscription page with status + payment history
- Sidebar navigation filtered by tenant plan features
- Subscription link added to navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:48:23 +00:00
Consultoria AS
3ab6d8d3e9 feat: add production deployment configs
- PM2 cluster mode (2 API workers + 1 Next.js)
- Nginx reverse proxy with SSL, rate limiting, security headers
- Automated backup script with daily/weekly rotation
- PostgreSQL production tuning script (300 connections, 4GB shared_buffers)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:44:20 +00:00
Consultoria AS
c351b5aeda feat: integrate email + subscription into tenant provisioning, FIEL notification
createTenant now: provisions DB, creates admin user with temp password,
creates initial subscription, and sends welcome email.
FIEL upload sends admin notification email.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:42:55 +00:00
Consultoria AS
b977f92141 feat: add plan enforcement middleware (subscription, CFDI limits, feature gates)
- checkPlanLimits: blocks writes when subscription inactive
- checkCfdiLimit: enforces per-plan CFDI count limits
- requireFeature: gates reportes/alertas/calendario by plan tier
- All cached with 5-min TTL, invalidated via PM2 messaging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:41:20 +00:00
Consultoria AS
69d7590834 feat: add MercadoPago payments, subscription service, and webhooks
- MercadoPago PreApproval integration for recurring subscriptions
- Subscription service with caching, manual payment, payment history
- Webhook handler with HMAC-SHA256 signature verification
- Admin endpoints for subscription management and payment links
- Email notifications on payment success/failure/cancellation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:39:00 +00:00
Consultoria AS
6fc81b1c0d feat: add email service with Nodemailer and 6 HTML templates
EmailService with mock fallback when SMTP not configured.
Templates: welcome, fiel-notification, payment-confirmed,
payment-failed, subscription-expiring, subscription-cancelled.
Uses Google Workspace SMTP (STARTTLS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:34:49 +00:00
Consultoria AS
bcabbd4959 feat: add CLI script for emergency FIEL decryption from filesystem
Decrypts .cer and .key from FIEL_STORAGE_PATH/<RFC>/ to /tmp with
30-minute auto-cleanup for security.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:33:18 +00:00
Consultoria AS
12dda005af feat: add dual filesystem storage for FIEL credentials
Save encrypted .cer, .key, and metadata to FIEL_STORAGE_PATH alongside
the existing DB storage. Each file has separate .iv and .tag sidecar files.
Filesystem failure is non-blocking (logs warning, DB remains primary).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:32:42 +00:00
Consultoria AS
d8f9f92389 refactor: remove schema-manager and tenantSchema backward compat
Delete schema-manager.ts (replaced by TenantConnectionManager).
Remove deprecated tenantSchema from Express Request interface.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:31:06 +00:00
Consultoria AS
96e1ea554c feat: add graceful shutdown and PM2 cross-worker messaging
Close all tenant DB pools on SIGTERM/SIGINT for clean restarts.
Support PM2 cluster invalidate-tenant-cache messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:30:08 +00:00
Consultoria AS
b064f15404 refactor: migrate all tenant services and controllers to pool-based queries
Replace Prisma raw queries with pg.Pool for all tenant-scoped services:
cfdi, dashboard, impuestos, alertas, calendario, reportes, export, and SAT.
Controllers now pass req.tenantPool instead of req.tenantSchema.
Fixes SQL injection in calendario.service.ts (parameterized interval).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:29:20 +00:00
Consultoria AS
7eaeefa09d feat: rewrite tenants service to use TenantConnectionManager
- Replace inline schema SQL with tenantDb.provisionDatabase
- Delete now soft-deletes DB (rename) and invalidates pool
- Use PLANS config for default limits per plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:20:37 +00:00
Consultoria AS
2b5a856845 feat: update auth service to provision databases via TenantConnectionManager
- Replace createTenantSchema with tenantDb.provisionDatabase
- JWT payload now includes databaseName (already renamed from schemaName)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:19:58 +00:00
Consultoria AS
8bfb8912c1 feat: rewrite tenant middleware for pool-based tenant resolution
- Resolve tenant DB via TenantConnectionManager instead of SET search_path
- Add tenantPool to Express Request for direct pool queries
- Keep tenantSchema as backward compat until all services are migrated
- Support admin impersonation via X-View-Tenant header

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:19:12 +00:00
Consultoria AS
d6b86dbbd3 feat: add TenantConnectionManager with dynamic pool management
- Adds pg dependency for direct PostgreSQL connections to tenant DBs
- TenantConnectionManager: singleton managing Map<tenantId, Pool>
- provisionDatabase: creates new DB with tables and indexes
- deprovisionDatabase: soft-deletes by renaming DB
- Automatic idle pool cleanup every 60s (5min threshold)
- Max 3 connections per pool (6/tenant with 2 PM2 workers)
- Graceful shutdown support for all pools

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:17:54 +00:00
Consultoria AS
f96a9c55c5 feat(saas): update schema for db-per-tenant and per-component FIEL encryption
- Rename Tenant.schemaName to databaseName across all services
- Add Subscription and Payment models to Prisma schema
- Update FielCredential to per-component IV/tag encryption columns
- Switch FIEL encryption key from JWT_SECRET to FIEL_ENCRYPTION_KEY
- Add Subscription and Payment shared types
- Update JWTPayload to use databaseName

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:15:55 +00:00
Consultoria AS
0d17fe3494 feat: add env vars for FIEL encryption, MercadoPago, SMTP, and admin email
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:08:46 +00:00
Consultoria AS
22543589c3 docs: add SaaS transformation implementation plan
28 tasks across 8 chunks:
- Chunk 1: Core infrastructure (DB-per-tenant, env, JWT, pools)
- Chunk 2: FIEL dual storage + encryption fix
- Chunk 3: Email service (Nodemailer + Gmail SMTP)
- Chunk 4: MercadoPago payments (subscriptions, webhooks)
- Chunk 5: Plan enforcement (limits, feature gates)
- Chunk 6: Tenant provisioning integration
- Chunk 7: Production deployment (PM2, Nginx, SSL, backups)
- Chunk 8: Frontend updates (subscription UI)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 23:04:48 +00:00
Consultoria AS
536a5abd33 docs: fix remaining warnings in SaaS design spec (round 2)
- Fix metadata.json shown as unencrypted in tree (now .enc)
- Fix admin bypass order in checkPlanLimits (moved before status check)
- Add PM2 cross-worker cache invalidation via process messaging
- Fix fiel_credentials "no changes" contradiction with per-component IV
- Backup all tenant DBs regardless of active status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:53:34 +00:00
Consultoria AS
3c9268ea30 docs: fix blockers and warnings in SaaS design spec
Fixes from spec review:
- BLOCKER: JWT payload migration (schemaName → databaseName)
- BLOCKER: FIEL encryption key separation from JWT_SECRET
- BLOCKER: PM2 cluster pool count (max:3 × 2 workers = 6/tenant)
- BLOCKER: Pending subscription grace period for new clients
- WARNING: Add indexes on subscriptions/payments tables
- WARNING: Fix Nginx rate limit zone definitions
- WARNING: Fix backup auth (.pgpass), retention, and schedule
- WARNING: Preserve admin X-View-Tenant impersonation
- WARNING: Encrypt metadata.json for NDA compliance
- SUGGESTION: Add health check, reduce upload limit, add rollback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:50:38 +00:00
Consultoria AS
c44e7cea34 docs: add SaaS transformation design spec
Complete design document covering:
- Database-per-tenant architecture (NDA compliance)
- FIEL dual storage (filesystem + DB, encrypted)
- MercadoPago subscription payments
- Transactional emails via Gmail SMTP
- Production deployment (Nginx, PM2, SSL, backups)
- Plan enforcement and feature gating

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 22:46:12 +00:00
78 changed files with 6964 additions and 813 deletions

167
README.md
View File

@@ -4,40 +4,80 @@ Plataforma de análisis financiero y gestión fiscal para empresas mexicanas.
## Descripción
Horux360 es una aplicación SaaS que permite a las empresas mexicanas:
Horux360 es una aplicación SaaS multi-tenant que permite a las empresas mexicanas:
- Gestionar sus CFDI (facturas electrónicas)
- Gestionar sus CFDI (facturas electrónicas) con carga masiva de XML
- Controlar IVA e ISR automáticamente
- Sincronizar CFDIs directamente con el SAT usando FIEL
- Visualizar dashboards financieros en tiempo real
- Realizar conciliación bancaria
- Recibir alertas fiscales proactivas
- Generar reportes y proyecciones financieras
- Calendario de obligaciones fiscales
## Stack Tecnológico
- **Frontend:** Next.js 14 + TypeScript + Tailwind CSS
- **Backend:** Node.js + Express + TypeScript
- **Base de datos:** PostgreSQL (multi-tenant por schema)
- **Autenticación:** JWT personalizado
- **Estado:** Zustand con persistencia
| Capa | Tecnología |
|------|-----------|
| **Frontend** | Next.js 14 + TypeScript + Tailwind CSS + shadcn/ui |
| **Backend** | Node.js + Express + TypeScript + tsx |
| **Base de datos** | PostgreSQL 16 (database-per-tenant) |
| **ORM** | Prisma (central DB) + pg (tenant DBs con raw SQL) |
| **Autenticación** | JWT (access 15min + refresh 7d) |
| **Estado** | Zustand con persistencia |
| **Proceso** | PM2 (fork mode) |
| **Proxy** | Nginx con SSL (Let's Encrypt) |
| **Email** | Nodemailer + Gmail Workspace (STARTTLS) |
| **Pagos** | MercadoPago (suscripciones) |
## Estructura del Proyecto
```
horux360/
├── apps/
│ ├── web/ # Frontend Next.js
│ ├── web/ # Frontend Next.js 14
│ │ ├── app/ # Pages (App Router)
│ │ ├── components/ # Componentes UI
│ │ ├── lib/api/ # Cliente API
│ │ └── stores/ # Zustand stores
│ └── api/ # Backend Express
│ ├── src/
│ │ ├── config/ # ENV, database connections
│ │ ├── controllers/ # Request handlers
│ │ ├── middlewares/ # Auth, tenant, rate-limit, plan-limits
│ │ ├── routes/ # Express routes
│ │ ├── services/ # Business logic
│ │ │ ├── email/ # Templates + Nodemailer
│ │ │ ├── payment/ # MercadoPago
│ │ │ └── sat/ # SAT sync + FIEL crypto
│ │ ├── utils/ # Helpers (token, password, global-admin)
│ │ └── jobs/ # SAT sync cron job
│ └── prisma/ # Schema + migrations
├── packages/
│ └── shared/ # Tipos y utilidades compartidas
│ └── shared/ # Tipos y constantes compartidas
├── deploy/
│ └── nginx/ # Configuración de Nginx
├── scripts/
│ └── backup.sh # Script de backup PostgreSQL
├── docs/
│ ├── architecture/ # Docs técnicos
│ ├── security/ # Auditorías de seguridad
│ └── plans/ # Documentación de diseño
└── docker-compose.yml
└── ecosystem.config.js # PM2 config
```
## Documentación
## Arquitectura Multi-Tenant
- [Documento de Diseño](docs/plans/2026-01-22-horux360-saas-design.md)
Cada cliente tiene su propia base de datos PostgreSQL, asegurando aislamiento completo de datos:
```
horux360 (central) ← Tenants, Users, Subscriptions, RefreshTokens
horux_<rfc_cliente_1> ← CFDIs, Alertas, Calendario, IVA del cliente 1
horux_<rfc_cliente_2> ← CFDIs, Alertas, Calendario, IVA del cliente 2
...
```
El middleware de tenant resuelve la base de datos del cliente desde el `tenantId` del JWT, usando un caché de 5 minutos.
## Planes
@@ -45,50 +85,113 @@ horux360/
|------|----------|----------|-----------------|
| Starter | 100 | 1 | Dashboard, IVA/ISR, CFDI básico |
| Business | 500 | 3 | + Reportes, Alertas, Calendario |
| Professional | 2,000 | 10 | + Conciliación, Forecasting |
| Professional | 2,000 | 10 | + Conciliación, Forecasting, SAT Sync |
| Enterprise | Ilimitado | Ilimitado | + API, Multi-empresa |
## Características Destacadas
## Seguridad
- **4 Temas visuales:** Light, Vibrant, Corporate, Dark
- **Multi-tenant:** Aislamiento de datos por empresa (schema por tenant)
- **Responsive:** Funciona en desktop y móvil
- **Tiempo real:** Dashboards actualizados al instante
- **Carga masiva de XML:** Soporte para carga de hasta 300MB de archivos XML
- **Selector de período:** Navegación por mes/año en todos los dashboards
- **Clasificación automática:** Ingresos/egresos basado en RFC del tenant
- JWT con access token (15min) y refresh token rotation (7d)
- bcrypt con 12 salt rounds para passwords
- Rate limiting en auth (10 login/15min, 3 register/hora)
- FIEL encriptada con AES-256-GCM
- CSP, HSTS, y security headers vía Nginx + Helmet
- Admin global verificado por RFC (no solo por rol)
- Webhooks de MercadoPago con verificación HMAC-SHA256
- Body limits diferenciados (10MB general, 50MB bulk CFDI)
- TLS obligatorio para SMTP
## Configuración
Ver [Auditoría de Seguridad](docs/security/2026-03-18-security-audit-remediation.md) para detalles completos.
### Variables de entorno (API)
## Documentación
| Documento | Descripción |
|-----------|-------------|
| [Diseño SaaS](docs/plans/2026-01-22-horux360-saas-design.md) | Arquitectura original y decisiones de diseño |
| [Deployment](docs/architecture/deployment.md) | Guía completa de despliegue en producción |
| [API Reference](docs/architecture/api-reference.md) | Referencia de todos los endpoints |
| [Security Audit](docs/security/2026-03-18-security-audit-remediation.md) | Auditoría de seguridad y remediaciones |
| [SAT Sync](docs/SAT-SYNC-IMPLEMENTATION.md) | Implementación de sincronización con el SAT |
## Configuración Local
### Requisitos
- Node.js 20+
- pnpm 9+
- PostgreSQL 16
### Setup
```bash
# Instalar dependencias
pnpm install
# Configurar variables de entorno
cp apps/api/.env.example apps/api/.env
cp apps/web/.env.example apps/web/.env.local
# Ejecutar migraciones
cd apps/api && pnpm prisma migrate dev
# Desarrollo
pnpm dev
```
### Variables de Entorno (API)
```env
NODE_ENV=development
PORT=4000
DATABASE_URL="postgresql://user:pass@localhost:5432/horux360"
JWT_SECRET=your-secret-key
JWT_SECRET=<min-32-chars>
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
CORS_ORIGIN=http://localhost:3000
FRONTEND_URL=http://localhost:3000
FIEL_ENCRYPTION_KEY=<min-32-chars>
FIEL_STORAGE_PATH=/var/horux/fiel
```
### Variables de entorno (Web)
### Variables de Entorno (Web)
```env
NEXT_PUBLIC_API_URL=http://localhost:4000/api
```
## Demo
## Roles
Credenciales de demo:
- **Admin:** admin@demo.com / demo123
- **Contador:** contador@demo.com / demo123
- **Visor:** visor@demo.com / demo123
| Rol | Acceso |
|-----|--------|
| **admin** | Todo dentro de su tenant + invitar usuarios |
| **contador** | CFDI, impuestos, reportes, dashboard |
| **visor** | Solo lectura |
| **admin global** | Admin del tenant CAS2408138W2 — gestión de clientes, suscripciones, SAT cron |
## Producción
- **URL:** https://horuxfin.com
- **Hosting:** Servidor dedicado
- **SSL:** Let's Encrypt (auto-renewal)
- **Process:** PM2 con auto-restart
- **Backups:** Diarios a las 01:00 AM
Ver [Guía de Deployment](docs/architecture/deployment.md) para instrucciones completas.
## Changelog
### v0.5.0 (2026-03-18)
- Auditoría de seguridad completa y remediación de 20 vulnerabilidades
- Rate limiting en endpoints de autenticación
- Content Security Policy (CSP) y headers de seguridad mejorados
- `databaseName` removido del JWT (resolución server-side)
- Restricción de impersonación a admin global únicamente
- Autorización en endpoints de suscripción y SAT cron
- Verificación obligatoria de firma en webhooks
- Body limits reducidos (10MB default, 50MB bulk)
- Passwords temporales criptográficamente seguros
- Validación de tamaño en upload de FIEL
- SMTP con TLS obligatorio
- Documentación completa de producción
### v0.4.0 (2026-01-22)
- Carga masiva de XML CFDI (hasta 300MB)
- Carga masiva de XML CFDI (hasta 50MB)
- Selector de período mes/año en dashboards
- Fix: Persistencia de sesión en refresh de página
- Fix: Clasificación ingreso/egreso basada en RFC

View File

@@ -28,8 +28,11 @@
"fast-xml-parser": "^5.3.3",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"mercadopago": "^2.12.0",
"node-cron": "^4.2.1",
"node-forge": "^1.3.3",
"nodemailer": "^8.0.2",
"pg": "^8.18.0",
"zod": "^3.23.0"
},
"devDependencies": {
@@ -41,6 +44,9 @@
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/node-forge": "^1.3.14",
"@types/nodemailer": "^7.0.11",
"@types/pg": "^8.18.0",
"express-rate-limit": "^8.3.1",
"prisma": "^5.22.0",
"tsx": "^4.19.0",
"typescript": "^5.3.0"

View File

@@ -12,9 +12,9 @@ model Tenant {
nombre String
rfc String @unique
plan Plan @default(starter)
schemaName String @unique @map("schema_name")
cfdiLimit Int @map("cfdi_limit")
usersLimit Int @map("users_limit")
databaseName String @unique @map("database_name")
cfdiLimit Int @default(100) @map("cfdi_limit")
usersLimit Int @default(1) @map("users_limit")
active Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime? @map("expires_at")
@@ -22,6 +22,8 @@ model Tenant {
users User[]
fielCredential FielCredential?
satSyncJobs SatSyncJob[]
subscriptions Subscription[]
payments Payment[]
@@map("tenants")
}
@@ -76,8 +78,12 @@ model FielCredential {
cerData Bytes @map("cer_data")
keyData Bytes @map("key_data")
keyPasswordEncrypted Bytes @map("key_password_encrypted")
encryptionIv Bytes @map("encryption_iv")
encryptionTag Bytes @map("encryption_tag")
cerIv Bytes @map("cer_iv")
cerTag Bytes @map("cer_tag")
keyIv Bytes @map("key_iv")
keyTag Bytes @map("key_tag")
passwordIv Bytes @map("password_iv")
passwordTag Bytes @map("password_tag")
serialNumber String? @map("serial_number") @db.VarChar(50)
validFrom DateTime @map("valid_from")
validUntil DateTime @map("valid_until")
@@ -90,6 +96,46 @@ model FielCredential {
@@map("fiel_credentials")
}
model Subscription {
id String @id @default(uuid())
tenantId String @map("tenant_id")
plan Plan
mpPreapprovalId String? @map("mp_preapproval_id")
status String @default("pending")
amount Decimal @db.Decimal(10, 2)
frequency String @default("monthly")
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
payments Payment[]
@@index([tenantId])
@@index([status])
@@map("subscriptions")
}
model Payment {
id String @id @default(uuid())
tenantId String @map("tenant_id")
subscriptionId String? @map("subscription_id")
mpPaymentId String? @map("mp_payment_id")
amount Decimal @db.Decimal(10, 2)
status String @default("pending")
paymentMethod String? @map("payment_method")
paidAt DateTime? @map("paid_at")
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
@@index([tenantId])
@@index([subscriptionId])
@@map("payments")
}
model SatSyncJob {
id String @id @default(uuid())
tenantId String @map("tenant_id")

View File

@@ -0,0 +1,26 @@
import { prisma } from '../src/config/database.js';
import { hashPassword } from '../src/utils/password.js';
async function main() {
const ivan = await prisma.user.findUnique({ where: { email: 'ivan@horuxfin.com' }, include: { tenant: true } });
if (!ivan) { console.error('Ivan not found'); process.exit(1); }
console.log('Tenant:', ivan.tenant.nombre, '(', ivan.tenant.id, ')');
const existing = await prisma.user.findUnique({ where: { email: 'carlos@horuxfin.com' } });
if (existing) { console.log('Carlos already exists:', existing.id); process.exit(0); }
const hash = await hashPassword('Aasi940812');
const carlos = await prisma.user.create({
data: {
tenantId: ivan.tenantId,
email: 'carlos@horuxfin.com',
passwordHash: hash,
nombre: 'Carlos Horux',
role: 'admin',
}
});
console.log('Carlos created:', carlos.id, carlos.email, carlos.role);
}
main().then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1); });

View File

@@ -0,0 +1,82 @@
/**
* CLI script to decrypt FIEL credentials from filesystem backup.
* Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>
*
* Decrypted files are written to /tmp/horux-fiel-<RFC>/ and auto-deleted after 30 minutes.
*/
import { readFile, writeFile, mkdir, rm } from 'fs/promises';
import { join } from 'path';
import { createDecipheriv, createHash } from 'crypto';
const FIEL_PATH = process.env.FIEL_STORAGE_PATH || '/var/horux/fiel';
const FIEL_KEY = process.env.FIEL_ENCRYPTION_KEY;
const rfc = process.argv[2];
if (!rfc) {
console.error('Usage: FIEL_ENCRYPTION_KEY=<key> npx tsx scripts/decrypt-fiel.ts <RFC>');
process.exit(1);
}
if (!FIEL_KEY) {
console.error('Error: FIEL_ENCRYPTION_KEY environment variable is required');
process.exit(1);
}
function deriveKey(): Buffer {
return createHash('sha256').update(FIEL_KEY!).digest();
}
function decryptBuffer(encrypted: Buffer, iv: Buffer, tag: Buffer): Buffer {
const key = deriveKey();
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
}
async function main() {
const fielDir = join(FIEL_PATH, rfc.toUpperCase());
const outputDir = `/tmp/horux-fiel-${rfc.toUpperCase()}`;
console.log(`Reading encrypted FIEL from: ${fielDir}`);
// Read encrypted certificate
const cerEnc = await readFile(join(fielDir, 'certificate.cer.enc'));
const cerIv = await readFile(join(fielDir, 'certificate.cer.iv'));
const cerTag = await readFile(join(fielDir, 'certificate.cer.tag'));
// Read encrypted private key
const keyEnc = await readFile(join(fielDir, 'private_key.key.enc'));
const keyIv = await readFile(join(fielDir, 'private_key.key.iv'));
const keyTag = await readFile(join(fielDir, 'private_key.key.tag'));
// Read and decrypt metadata
const metaEnc = await readFile(join(fielDir, 'metadata.json.enc'));
const metaIv = await readFile(join(fielDir, 'metadata.json.iv'));
const metaTag = await readFile(join(fielDir, 'metadata.json.tag'));
// Decrypt all
const cerData = decryptBuffer(cerEnc, cerIv, cerTag);
const keyData = decryptBuffer(keyEnc, keyIv, keyTag);
const metadata = JSON.parse(decryptBuffer(metaEnc, metaIv, metaTag).toString('utf-8'));
// Write decrypted files
await mkdir(outputDir, { recursive: true, mode: 0o700 });
await writeFile(join(outputDir, 'certificate.cer'), cerData, { mode: 0o600 });
await writeFile(join(outputDir, 'private_key.key'), keyData, { mode: 0o600 });
await writeFile(join(outputDir, 'metadata.json'), JSON.stringify(metadata, null, 2), { mode: 0o600 });
console.log(`\nDecrypted files written to: ${outputDir}`);
console.log('Metadata:', metadata);
console.log('\nFiles will be auto-deleted in 30 minutes.');
// Auto-delete after 30 minutes
setTimeout(async () => {
await rm(outputDir, { recursive: true, force: true });
console.log(`Cleaned up ${outputDir}`);
process.exit(0);
}, 30 * 60 * 1000);
}
main().catch((err) => {
console.error('Failed to decrypt FIEL:', err.message);
process.exit(1);
});

View File

@@ -0,0 +1,96 @@
import { emailService } from '../src/services/email/email.service.js';
const recipients = ['ivan@horuxfin.com', 'carlos@horuxfin.com'];
async function sendAllSamples() {
for (const to of recipients) {
console.log(`\n=== Enviando a ${to} ===`);
// 1. Welcome
console.log('1/6 Bienvenida...');
await emailService.sendWelcome(to, {
nombre: 'Ivan Alcaraz',
email: 'ivan@horuxfin.com',
tempPassword: 'TempPass123!',
});
// 2. FIEL notification (goes to ADMIN_EMAIL, but we override for test)
console.log('2/6 Notificación FIEL...');
// Send directly since sendFielNotification goes to admin
const { fielNotificationEmail } = await import('../src/services/email/templates/fiel-notification.js');
const { createTransport } = await import('nodemailer');
const { env } = await import('../src/config/env.js');
const transport = createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT),
secure: false,
auth: { user: env.SMTP_USER, pass: env.SMTP_PASS },
});
const fielHtml = fielNotificationEmail({
clienteNombre: 'Consultoria Alcaraz Salazar',
clienteRfc: 'CAS200101XXX',
});
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject: '[Consultoria Alcaraz Salazar] subió su FIEL (MUESTRA)',
html: fielHtml,
});
// 3. Payment confirmed
console.log('3/6 Pago confirmado...');
await emailService.sendPaymentConfirmed(to, {
nombre: 'Ivan Alcaraz',
amount: 1499,
plan: 'Enterprise',
date: '16 de marzo de 2026',
});
// 4. Payment failed
console.log('4/6 Pago fallido...');
const { paymentFailedEmail } = await import('../src/services/email/templates/payment-failed.js');
const failedHtml = paymentFailedEmail({
nombre: 'Ivan Alcaraz',
amount: 1499,
plan: 'Enterprise',
});
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject: 'Problema con tu pago - Horux360 (MUESTRA)',
html: failedHtml,
});
// 5. Subscription expiring
console.log('5/6 Suscripción por vencer...');
await emailService.sendSubscriptionExpiring(to, {
nombre: 'Ivan Alcaraz',
plan: 'Enterprise',
expiresAt: '21 de marzo de 2026',
});
// 6. Subscription cancelled
console.log('6/6 Suscripción cancelada...');
const { subscriptionCancelledEmail } = await import('../src/services/email/templates/subscription-cancelled.js');
const cancelledHtml = subscriptionCancelledEmail({
nombre: 'Ivan Alcaraz',
plan: 'Enterprise',
});
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject: 'Suscripción cancelada - Horux360 (MUESTRA)',
html: cancelledHtml,
});
console.log(`Listo: 6 correos enviados a ${to}`);
}
console.log('\n=== Todos los correos enviados ===');
process.exit(0);
}
sendAllSamples().catch((err) => {
console.error('Error:', err);
process.exit(1);
});

View File

@@ -15,6 +15,8 @@ import { usuariosRoutes } from './routes/usuarios.routes.js';
import { tenantsRoutes } from './routes/tenants.routes.js';
import fielRoutes from './routes/fiel.routes.js';
import satRoutes from './routes/sat.routes.js';
import { webhookRoutes } from './routes/webhook.routes.js';
import { subscriptionRoutes } from './routes/subscription.routes.js';
const app: Express = express();
@@ -25,9 +27,9 @@ app.use(cors({
credentials: true,
}));
// Body parsing - increased limit for bulk XML uploads (1GB)
app.use(express.json({ limit: '1gb' }));
app.use(express.urlencoded({ extended: true, limit: '1gb' }));
// Body parsing - 10MB default, bulk CFDI route has its own higher limit
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Health check
app.get('/health', (req, res) => {
@@ -47,6 +49,8 @@ app.use('/api/usuarios', usuariosRoutes);
app.use('/api/tenants', tenantsRoutes);
app.use('/api/fiel', fielRoutes);
app.use('/api/sat', satRoutes);
app.use('/api/webhooks', webhookRoutes);
app.use('/api/subscriptions', subscriptionRoutes);
// Error handling
app.use(errorMiddleware);

View File

@@ -1,4 +1,10 @@
import { PrismaClient } from '@prisma/client';
import { Pool, type PoolConfig } from 'pg';
import { env } from './env.js';
// ===========================================
// Prisma Client (central database: horux360)
// ===========================================
declare global {
var prisma: PrismaClient | undefined;
@@ -11,3 +17,303 @@ export const prisma = globalThis.prisma || new PrismaClient({
if (process.env.NODE_ENV !== 'production') {
globalThis.prisma = prisma;
}
// ===========================================
// TenantConnectionManager (per-tenant DBs)
// ===========================================
interface PoolEntry {
pool: Pool;
lastAccess: Date;
}
function parseDatabaseUrl(url: string) {
const parsed = new URL(url);
return {
host: parsed.hostname,
port: parseInt(parsed.port || '5432'),
user: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
};
}
class TenantConnectionManager {
private pools: Map<string, PoolEntry> = new Map();
private cleanupInterval: NodeJS.Timeout | null = null;
private dbConfig: { host: string; port: number; user: string; password: string };
constructor() {
this.dbConfig = parseDatabaseUrl(env.DATABASE_URL);
this.cleanupInterval = setInterval(() => this.cleanupIdlePools(), 60_000);
}
/**
* Get or create a connection pool for a tenant's database.
*/
getPool(tenantId: string, databaseName: string): Pool {
const entry = this.pools.get(tenantId);
if (entry) {
entry.lastAccess = new Date();
return entry.pool;
}
const poolConfig: PoolConfig = {
host: this.dbConfig.host,
port: this.dbConfig.port,
user: this.dbConfig.user,
password: this.dbConfig.password,
database: databaseName,
max: 3,
idleTimeoutMillis: 300_000,
connectionTimeoutMillis: 10_000,
};
const pool = new Pool(poolConfig);
pool.on('error', (err) => {
console.error(`[TenantDB] Pool error for tenant ${tenantId} (${databaseName}):`, err.message);
});
this.pools.set(tenantId, { pool, lastAccess: new Date() });
return pool;
}
/**
* Create a new database for a tenant with all required tables and indexes.
*/
async provisionDatabase(rfc: string): Promise<string> {
const databaseName = `horux_${rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
const adminPool = new Pool({
...this.dbConfig,
database: 'postgres',
max: 1,
});
try {
const exists = await adminPool.query(
`SELECT 1 FROM pg_database WHERE datname = $1`,
[databaseName]
);
if (exists.rows.length > 0) {
throw new Error(`Database ${databaseName} already exists`);
}
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
const tenantPool = new Pool({
...this.dbConfig,
database: databaseName,
max: 1,
});
try {
await this.createTables(tenantPool);
await this.createIndexes(tenantPool);
} finally {
await tenantPool.end();
}
return databaseName;
} finally {
await adminPool.end();
}
}
/**
* Soft-delete: rename database so it can be recovered.
*/
async deprovisionDatabase(databaseName: string): Promise<void> {
// Close any active pool for this tenant
for (const [tenantId, entry] of this.pools.entries()) {
// We check pool config to match the database
if ((entry.pool as any).options?.database === databaseName) {
await entry.pool.end().catch(() => {});
this.pools.delete(tenantId);
}
}
const timestamp = Date.now();
const adminPool = new Pool({
...this.dbConfig,
database: 'postgres',
max: 1,
});
try {
await adminPool.query(`
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()
`, [databaseName]);
await adminPool.query(
`ALTER DATABASE "${databaseName}" RENAME TO "${databaseName}_deleted_${timestamp}"`
);
} finally {
await adminPool.end();
}
}
/**
* Invalidate (close and remove) a specific tenant's pool.
*/
invalidatePool(tenantId: string): void {
const entry = this.pools.get(tenantId);
if (entry) {
entry.pool.end().catch(() => {});
this.pools.delete(tenantId);
}
}
/**
* Remove idle pools (not accessed in last 5 minutes).
*/
private cleanupIdlePools(): void {
const now = Date.now();
const maxIdle = 5 * 60 * 1000;
for (const [tenantId, entry] of this.pools.entries()) {
if (now - entry.lastAccess.getTime() > maxIdle) {
entry.pool.end().catch((err) =>
console.error(`[TenantDB] Error closing idle pool for ${tenantId}:`, err.message)
);
this.pools.delete(tenantId);
}
}
}
/**
* Graceful shutdown: close all pools.
*/
async shutdown(): Promise<void> {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
const closePromises = Array.from(this.pools.values()).map((entry) =>
entry.pool.end()
);
await Promise.all(closePromises);
this.pools.clear();
}
/**
* Get stats about active pools.
*/
getStats(): { activePools: number; tenantIds: string[] } {
return {
activePools: this.pools.size,
tenantIds: Array.from(this.pools.keys()),
};
}
private async createTables(pool: Pool): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS cfdis (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
tipo VARCHAR(20) NOT NULL DEFAULT 'ingreso',
serie VARCHAR(25),
folio VARCHAR(40),
fecha_emision TIMESTAMP NOT NULL,
fecha_timbrado TIMESTAMP,
rfc_emisor VARCHAR(13) NOT NULL,
nombre_emisor VARCHAR(300) NOT NULL,
rfc_receptor VARCHAR(13) NOT NULL,
nombre_receptor VARCHAR(300) NOT NULL,
subtotal DECIMAL(18,2) DEFAULT 0,
descuento DECIMAL(18,2) DEFAULT 0,
iva DECIMAL(18,2) DEFAULT 0,
isr_retenido DECIMAL(18,2) DEFAULT 0,
iva_retenido DECIMAL(18,2) DEFAULT 0,
total DECIMAL(18,2) DEFAULT 0,
moneda VARCHAR(10) DEFAULT 'MXN',
tipo_cambio DECIMAL(10,4) DEFAULT 1,
metodo_pago VARCHAR(10),
forma_pago VARCHAR(10),
uso_cfdi VARCHAR(10),
estado VARCHAR(20) DEFAULT 'vigente',
xml_url TEXT,
pdf_url TEXT,
xml_original TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
last_sat_sync TIMESTAMP,
sat_sync_job_id UUID,
source VARCHAR(20) DEFAULT 'manual'
);
CREATE TABLE IF NOT EXISTS iva_mensual (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
año INTEGER NOT NULL,
mes INTEGER NOT NULL,
iva_trasladado DECIMAL(18,2) DEFAULT 0,
iva_acreditable DECIMAL(18,2) DEFAULT 0,
iva_retenido DECIMAL(18,2) DEFAULT 0,
resultado DECIMAL(18,2) DEFAULT 0,
acumulado DECIMAL(18,2) DEFAULT 0,
estado VARCHAR(20) DEFAULT 'pendiente',
fecha_declaracion TIMESTAMP,
UNIQUE(año, mes)
);
CREATE TABLE IF NOT EXISTS isr_mensual (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
año INTEGER NOT NULL,
mes INTEGER NOT NULL,
ingresos_acumulados DECIMAL(18,2) DEFAULT 0,
deducciones DECIMAL(18,2) DEFAULT 0,
base_gravable DECIMAL(18,2) DEFAULT 0,
isr_causado DECIMAL(18,2) DEFAULT 0,
isr_retenido DECIMAL(18,2) DEFAULT 0,
isr_a_pagar DECIMAL(18,2) DEFAULT 0,
estado VARCHAR(20) DEFAULT 'pendiente',
fecha_declaracion TIMESTAMP,
UNIQUE(año, mes)
);
CREATE TABLE IF NOT EXISTS alertas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tipo VARCHAR(50) NOT NULL,
titulo VARCHAR(200) NOT NULL,
mensaje TEXT,
prioridad VARCHAR(20) DEFAULT 'media',
fecha_vencimiento TIMESTAMP,
leida BOOLEAN DEFAULT FALSE,
resuelta BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS calendario_fiscal (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
titulo VARCHAR(200) NOT NULL,
descripcion TEXT,
tipo VARCHAR(50) NOT NULL,
fecha_limite TIMESTAMP NOT NULL,
recurrencia VARCHAR(20) DEFAULT 'unica',
completado BOOLEAN DEFAULT FALSE,
notas TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
`);
}
private async createIndexes(pool: Pool): Promise<void> {
await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
await pool.query(`
CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_emision ON cfdis(fecha_emision DESC);
CREATE INDEX IF NOT EXISTS idx_cfdis_tipo ON cfdis(tipo);
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_emisor ON cfdis(rfc_emisor);
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_receptor ON cfdis(rfc_receptor);
CREATE INDEX IF NOT EXISTS idx_cfdis_estado ON cfdis(estado);
CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_emisor_trgm ON cfdis USING gin(nombre_emisor gin_trgm_ops);
CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_receptor_trgm ON cfdis USING gin(nombre_receptor gin_trgm_ops);
`);
}
}
// Singleton instance
export const tenantDb = new TenantConnectionManager();

View File

@@ -13,6 +13,28 @@ const envSchema = z.object({
JWT_EXPIRES_IN: z.string().default('15m'),
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('http://localhost:3000'),
// Frontend URL (for MercadoPago back_url, emails, etc.)
FRONTEND_URL: z.string().default('https://horuxfin.com'),
// FIEL encryption (separate from JWT to allow independent rotation)
FIEL_ENCRYPTION_KEY: z.string().min(32),
FIEL_STORAGE_PATH: z.string().default('/var/horux/fiel'),
// MercadoPago
MP_ACCESS_TOKEN: z.string().optional(),
MP_WEBHOOK_SECRET: z.string().optional(),
MP_NOTIFICATION_URL: z.string().optional(),
// SMTP (Gmail Workspace)
SMTP_HOST: z.string().default('smtp.gmail.com'),
SMTP_PORT: z.string().default('587'),
SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().default('Horux360 <noreply@horuxfin.com>'),
// Admin notification email
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
});
const parsed = envSchema.safeParse(process.env);

View File

@@ -1,10 +1,10 @@
import { Request, Response, NextFunction } from 'express';
import type { Request, Response, NextFunction } from 'express';
import * as alertasService from '../services/alertas.service.js';
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
try {
const { leida, resuelta, prioridad } = req.query;
const alertas = await alertasService.getAlertas(req.tenantSchema!, {
const alertas = await alertasService.getAlertas(req.tenantPool!, {
leida: leida === 'true' ? true : leida === 'false' ? false : undefined,
resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined,
prioridad: prioridad as string,
@@ -17,7 +17,7 @@ export async function getAlertas(req: Request, res: Response, next: NextFunction
export async function getAlerta(req: Request, res: Response, next: NextFunction) {
try {
const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(String(req.params.id)));
const alerta = await alertasService.getAlertaById(req.tenantPool!, parseInt(String(req.params.id)));
if (!alerta) {
return res.status(404).json({ message: 'Alerta no encontrada' });
}
@@ -29,7 +29,7 @@ export async function getAlerta(req: Request, res: Response, next: NextFunction)
export async function createAlerta(req: Request, res: Response, next: NextFunction) {
try {
const alerta = await alertasService.createAlerta(req.tenantSchema!, req.body);
const alerta = await alertasService.createAlerta(req.tenantPool!, req.body);
res.status(201).json(alerta);
} catch (error) {
next(error);
@@ -38,7 +38,7 @@ export async function createAlerta(req: Request, res: Response, next: NextFuncti
export async function updateAlerta(req: Request, res: Response, next: NextFunction) {
try {
const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(String(req.params.id)), req.body);
const alerta = await alertasService.updateAlerta(req.tenantPool!, parseInt(String(req.params.id)), req.body);
res.json(alerta);
} catch (error) {
next(error);
@@ -47,7 +47,7 @@ export async function updateAlerta(req: Request, res: Response, next: NextFuncti
export async function deleteAlerta(req: Request, res: Response, next: NextFunction) {
try {
await alertasService.deleteAlerta(req.tenantSchema!, parseInt(String(req.params.id)));
await alertasService.deleteAlerta(req.tenantPool!, parseInt(String(req.params.id)));
res.status(204).send();
} catch (error) {
next(error);
@@ -56,7 +56,7 @@ export async function deleteAlerta(req: Request, res: Response, next: NextFuncti
export async function getStats(req: Request, res: Response, next: NextFunction) {
try {
const stats = await alertasService.getStats(req.tenantSchema!);
const stats = await alertasService.getStats(req.tenantPool!);
res.json(stats);
} catch (error) {
next(error);
@@ -65,7 +65,7 @@ export async function getStats(req: Request, res: Response, next: NextFunction)
export async function markAllAsRead(req: Request, res: Response, next: NextFunction) {
try {
await alertasService.markAllAsRead(req.tenantSchema!);
await alertasService.markAllAsRead(req.tenantPool!);
res.json({ success: true });
} catch (error) {
next(error);

View File

@@ -1,4 +1,4 @@
import { Request, Response, NextFunction } from 'express';
import type { Request, Response, NextFunction } from 'express';
import * as calendarioService from '../services/calendario.service.js';
export async function getEventos(req: Request, res: Response, next: NextFunction) {
@@ -7,7 +7,7 @@ export async function getEventos(req: Request, res: Response, next: NextFunction
const añoNum = parseInt(año as string) || new Date().getFullYear();
const mesNum = mes ? parseInt(mes as string) : undefined;
const eventos = await calendarioService.getEventos(req.tenantSchema!, añoNum, mesNum);
const eventos = await calendarioService.getEventos(req.tenantPool!, añoNum, mesNum);
res.json(eventos);
} catch (error) {
next(error);
@@ -17,7 +17,7 @@ export async function getEventos(req: Request, res: Response, next: NextFunction
export async function getProximos(req: Request, res: Response, next: NextFunction) {
try {
const dias = parseInt(req.query.dias as string) || 30;
const eventos = await calendarioService.getProximosEventos(req.tenantSchema!, dias);
const eventos = await calendarioService.getProximosEventos(req.tenantPool!, dias);
res.json(eventos);
} catch (error) {
next(error);
@@ -26,7 +26,7 @@ export async function getProximos(req: Request, res: Response, next: NextFunctio
export async function createEvento(req: Request, res: Response, next: NextFunction) {
try {
const evento = await calendarioService.createEvento(req.tenantSchema!, req.body);
const evento = await calendarioService.createEvento(req.tenantPool!, req.body);
res.status(201).json(evento);
} catch (error) {
next(error);
@@ -35,7 +35,7 @@ export async function createEvento(req: Request, res: Response, next: NextFuncti
export async function updateEvento(req: Request, res: Response, next: NextFunction) {
try {
const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(String(req.params.id)), req.body);
const evento = await calendarioService.updateEvento(req.tenantPool!, parseInt(String(req.params.id)), req.body);
res.json(evento);
} catch (error) {
next(error);
@@ -44,7 +44,7 @@ export async function updateEvento(req: Request, res: Response, next: NextFuncti
export async function deleteEvento(req: Request, res: Response, next: NextFunction) {
try {
await calendarioService.deleteEvento(req.tenantSchema!, parseInt(String(req.params.id)));
await calendarioService.deleteEvento(req.tenantPool!, parseInt(String(req.params.id)));
res.status(204).send();
} catch (error) {
next(error);

View File

@@ -5,8 +5,8 @@ import type { CfdiFilters } from '@horux/shared';
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const filters: CfdiFilters = {
@@ -22,7 +22,7 @@ export async function getCfdis(req: Request, res: Response, next: NextFunction)
limit: parseInt(req.query.limit as string) || 20,
};
const result = await cfdiService.getCfdis(req.tenantSchema, filters);
const result = await cfdiService.getCfdis(req.tenantPool, filters);
res.json(result);
} catch (error) {
next(error);
@@ -31,11 +31,11 @@ export async function getCfdis(req: Request, res: Response, next: NextFunction)
export async function getCfdiById(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const cfdi = await cfdiService.getCfdiById(req.tenantSchema, String(req.params.id));
const cfdi = await cfdiService.getCfdiById(req.tenantPool, String(req.params.id));
if (!cfdi) {
return next(new AppError(404, 'CFDI no encontrado'));
@@ -49,11 +49,11 @@ export async function getCfdiById(req: Request, res: Response, next: NextFunctio
export async function getXml(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const xml = await cfdiService.getXmlById(req.tenantSchema, String(req.params.id));
const xml = await cfdiService.getXmlById(req.tenantPool, String(req.params.id));
if (!xml) {
return next(new AppError(404, 'XML no encontrado para este CFDI'));
@@ -69,8 +69,8 @@ export async function getXml(req: Request, res: Response, next: NextFunction) {
export async function getEmisores(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const search = (req.query.search as string) || '';
@@ -78,7 +78,7 @@ export async function getEmisores(req: Request, res: Response, next: NextFunctio
return res.json([]);
}
const emisores = await cfdiService.getEmisores(req.tenantSchema, search);
const emisores = await cfdiService.getEmisores(req.tenantPool, search);
res.json(emisores);
} catch (error) {
next(error);
@@ -87,8 +87,8 @@ export async function getEmisores(req: Request, res: Response, next: NextFunctio
export async function getReceptores(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const search = (req.query.search as string) || '';
@@ -96,7 +96,7 @@ export async function getReceptores(req: Request, res: Response, next: NextFunct
return res.json([]);
}
const receptores = await cfdiService.getReceptores(req.tenantSchema, search);
const receptores = await cfdiService.getReceptores(req.tenantPool, search);
res.json(receptores);
} catch (error) {
next(error);
@@ -105,14 +105,14 @@ export async function getReceptores(req: Request, res: Response, next: NextFunct
export async function getResumen(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const resumen = await cfdiService.getResumenCfdis(req.tenantSchema, año, mes);
const resumen = await cfdiService.getResumenCfdis(req.tenantPool, año, mes);
res.json(resumen);
} catch (error) {
next(error);
@@ -121,16 +121,15 @@ export async function getResumen(req: Request, res: Response, next: NextFunction
export async function createCfdi(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
// Only admin and contador can create CFDIs
if (!['admin', 'contador'].includes(req.user!.role)) {
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
}
const cfdi = await cfdiService.createCfdi(req.tenantSchema, req.body);
const cfdi = await cfdiService.createCfdi(req.tenantPool, req.body);
res.status(201).json(cfdi);
} catch (error: any) {
if (error.message?.includes('duplicate')) {
@@ -142,8 +141,8 @@ export async function createCfdi(req: Request, res: Response, next: NextFunction
export async function createManyCfdis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
if (!['admin', 'contador'].includes(req.user!.role)) {
@@ -160,9 +159,9 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
totalFiles: req.body.totalFiles || req.body.cfdis.length
};
console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs para schema ${req.tenantSchema}`);
console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs`);
const result = await cfdiService.createManyCfdisBatch(req.tenantSchema, req.body.cfdis);
const result = await cfdiService.createManyCfdisBatch(req.tenantPool, req.body.cfdis);
res.status(201).json({
message: `Lote ${batchInfo.batchNumber} procesado`,
@@ -171,7 +170,7 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
inserted: result.inserted,
duplicates: result.duplicates,
errors: result.errors,
errorMessages: result.errorMessages.slice(0, 5) // Limit error messages
errorMessages: result.errorMessages.slice(0, 5)
});
} catch (error: any) {
console.error('[CFDI Bulk Error]', error.message, error.stack);
@@ -181,15 +180,15 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
export async function deleteCfdi(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
if (!['admin', 'contador'].includes(req.user!.role)) {
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
}
await cfdiService.deleteCfdi(req.tenantSchema, String(req.params.id));
await cfdiService.deleteCfdi(req.tenantPool, String(req.params.id));
res.status(204).send();
} catch (error) {
next(error);

View File

@@ -4,14 +4,14 @@ import { AppError } from '../middlewares/error.middleware.js';
export async function getKpis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const kpis = await dashboardService.getKpis(req.tenantSchema, año, mes);
const kpis = await dashboardService.getKpis(req.tenantPool, año, mes);
res.json(kpis);
} catch (error) {
next(error);
@@ -20,13 +20,13 @@ export async function getKpis(req: Request, res: Response, next: NextFunction) {
export async function getIngresosEgresos(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const data = await dashboardService.getIngresosEgresos(req.tenantSchema, año);
const data = await dashboardService.getIngresosEgresos(req.tenantPool, año);
res.json(data);
} catch (error) {
next(error);
@@ -35,14 +35,14 @@ export async function getIngresosEgresos(req: Request, res: Response, next: Next
export async function getResumenFiscal(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const resumen = await dashboardService.getResumenFiscal(req.tenantSchema, año, mes);
const resumen = await dashboardService.getResumenFiscal(req.tenantPool, año, mes);
res.json(resumen);
} catch (error) {
next(error);
@@ -51,13 +51,13 @@ export async function getResumenFiscal(req: Request, res: Response, next: NextFu
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const limit = parseInt(req.query.limit as string) || 5;
const alertas = await dashboardService.getAlertas(req.tenantSchema, limit);
const alertas = await dashboardService.getAlertas(req.tenantPool, limit);
res.json(alertas);
} catch (error) {
next(error);

View File

@@ -1,10 +1,10 @@
import { Request, Response, NextFunction } from 'express';
import type { Request, Response, NextFunction } from 'express';
import * as exportService from '../services/export.service.js';
export async function exportCfdis(req: Request, res: Response, next: NextFunction) {
try {
const { tipo, estado, fechaInicio, fechaFin } = req.query;
const buffer = await exportService.exportCfdisToExcel(req.tenantSchema!, {
const buffer = await exportService.exportCfdisToExcel(req.tenantPool!, {
tipo: tipo as string,
estado: estado as string,
fechaInicio: fechaInicio as string,
@@ -27,7 +27,7 @@ export async function exportReporte(req: Request, res: Response, next: NextFunct
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
const buffer = await exportService.exportReporteToExcel(
req.tenantSchema!,
req.tenantPool!,
tipo as 'estado-resultados' | 'flujo-efectivo',
inicio,
fin

View File

@@ -16,6 +16,18 @@ export async function upload(req: Request, res: Response): Promise<void> {
return;
}
// Validate file sizes (typical .cer/.key files are under 10KB, base64 ~33% larger)
const MAX_FILE_SIZE = 50_000; // 50KB base64 ≈ ~37KB binary
if (cerFile.length > MAX_FILE_SIZE || keyFile.length > MAX_FILE_SIZE) {
res.status(400).json({ error: 'Los archivos FIEL son demasiado grandes (máx 50KB)' });
return;
}
if (password.length > 256) {
res.status(400).json({ error: 'Contraseña FIEL demasiado larga' });
return;
}
const result = await uploadFiel(tenantId, cerFile, keyFile, password);
if (!result.success) {

View File

@@ -4,12 +4,12 @@ import { AppError } from '../middlewares/error.middleware.js';
export async function getIvaMensual(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const data = await impuestosService.getIvaMensual(req.tenantSchema, año);
const data = await impuestosService.getIvaMensual(req.tenantPool, año);
res.json(data);
} catch (error) {
next(error);
@@ -18,14 +18,14 @@ export async function getIvaMensual(req: Request, res: Response, next: NextFunct
export async function getResumenIva(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const resumen = await impuestosService.getResumenIva(req.tenantSchema, año, mes);
const resumen = await impuestosService.getResumenIva(req.tenantPool, año, mes);
res.json(resumen);
} catch (error) {
next(error);
@@ -34,12 +34,12 @@ export async function getResumenIva(req: Request, res: Response, next: NextFunct
export async function getIsrMensual(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const data = await impuestosService.getIsrMensual(req.tenantSchema, año);
const data = await impuestosService.getIsrMensual(req.tenantPool, año);
res.json(data);
} catch (error) {
next(error);
@@ -48,14 +48,14 @@ export async function getIsrMensual(req: Request, res: Response, next: NextFunct
export async function getResumenIsr(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
if (!req.tenantPool) {
return next(new AppError(400, 'Tenant no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const resumen = await impuestosService.getResumenIsr(req.tenantSchema, año, mes);
const resumen = await impuestosService.getResumenIsr(req.tenantPool, año, mes);
res.json(resumen);
} catch (error) {
next(error);

View File

@@ -1,4 +1,4 @@
import { Request, Response, NextFunction } from 'express';
import type { Request, Response, NextFunction } from 'express';
import * as reportesService from '../services/reportes.service.js';
export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) {
@@ -8,8 +8,7 @@ export async function getEstadoResultados(req: Request, res: Response, next: Nex
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
console.log('[reportes] getEstadoResultados - schema:', req.tenantSchema, 'inicio:', inicio, 'fin:', fin);
const data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin);
const data = await reportesService.getEstadoResultados(req.tenantPool!, inicio, fin);
res.json(data);
} catch (error) {
console.error('[reportes] Error en getEstadoResultados:', error);
@@ -24,7 +23,7 @@ export async function getFlujoEfectivo(req: Request, res: Response, next: NextFu
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
const data = await reportesService.getFlujoEfectivo(req.tenantSchema!, inicio, fin);
const data = await reportesService.getFlujoEfectivo(req.tenantPool!, inicio, fin);
res.json(data);
} catch (error) {
next(error);
@@ -34,7 +33,7 @@ export async function getFlujoEfectivo(req: Request, res: Response, next: NextFu
export async function getComparativo(req: Request, res: Response, next: NextFunction) {
try {
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const data = await reportesService.getComparativo(req.tenantSchema!, año);
const data = await reportesService.getComparativo(req.tenantPool!, año);
res.json(data);
} catch (error) {
next(error);
@@ -49,7 +48,7 @@ export async function getConcentradoRfc(req: Request, res: Response, next: NextF
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
const tipoRfc = (tipo as 'cliente' | 'proveedor') || 'cliente';
const data = await reportesService.getConcentradoRfc(req.tenantSchema!, inicio, fin, tipoRfc);
const data = await reportesService.getConcentradoRfc(req.tenantPool!, inicio, fin, tipoRfc);
res.json(data);
} catch (error) {
next(error);

View File

@@ -7,6 +7,7 @@ import {
} from '../services/sat/sat.service.js';
import { getJobInfo, runSatSyncJobManually } from '../jobs/sat-sync.job.js';
import type { StartSyncRequest } from '@horux/shared';
import { isGlobalAdmin } from '../utils/global-admin.js';
/**
* Inicia una sincronización manual
@@ -121,10 +122,14 @@ export async function retry(req: Request, res: Response): Promise<void> {
}
/**
* Obtiene información del job programado (solo admin)
* Obtiene información del job programado (solo admin global)
*/
export async function cronInfo(req: Request, res: Response): Promise<void> {
try {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' });
return;
}
const info = getJobInfo();
res.json(info);
} catch (error: any) {
@@ -134,10 +139,14 @@ export async function cronInfo(req: Request, res: Response): Promise<void> {
}
/**
* Ejecuta el job de sincronización manualmente (solo admin)
* Ejecuta el job de sincronización manualmente (solo admin global)
*/
export async function runCron(req: Request, res: Response): Promise<void> {
try {
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' });
return;
}
// Ejecutar en background
runSatSyncJobManually().catch(err =>
console.error('[SAT Controller] Error ejecutando cron manual:', err)

View File

@@ -0,0 +1,68 @@
import type { Request, Response, NextFunction } from 'express';
import * as subscriptionService from '../services/payment/subscription.service.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
async function requireGlobalAdmin(req: Request, res: Response): Promise<boolean> {
const isAdmin = await isGlobalAdmin(req.user!.tenantId, req.user!.role);
if (!isAdmin) {
res.status(403).json({ message: 'Solo el administrador global puede gestionar suscripciones' });
}
return isAdmin;
}
export async function getSubscription(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const subscription = await subscriptionService.getActiveSubscription(tenantId);
if (!subscription) {
return res.status(404).json({ message: 'No se encontró suscripción' });
}
res.json(subscription);
} catch (error) {
next(error);
}
}
export async function generatePaymentLink(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const result = await subscriptionService.generatePaymentLink(tenantId);
res.json(result);
} catch (error) {
next(error);
}
}
export async function markAsPaid(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const { amount } = req.body;
if (!amount || amount <= 0) {
return res.status(400).json({ message: 'Monto inválido' });
}
const payment = await subscriptionService.markAsPaidManually(tenantId, amount);
res.json(payment);
} catch (error) {
next(error);
}
}
export async function getPayments(req: Request, res: Response, next: NextFunction) {
try {
if (!(await requireGlobalAdmin(req, res))) return;
const tenantId = String(req.params.tenantId);
const payments = await subscriptionService.getPaymentHistory(tenantId);
res.json(payments);
} catch (error) {
next(error);
}
}

View File

@@ -39,21 +39,24 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
throw new AppError(403, 'Solo administradores pueden crear clientes');
}
const { nombre, rfc, plan, cfdiLimit, usersLimit } = req.body;
const { nombre, rfc, plan, cfdiLimit, usersLimit, adminEmail, adminNombre, amount } = req.body;
if (!nombre || !rfc) {
throw new AppError(400, 'Nombre y RFC son requeridos');
if (!nombre || !rfc || !adminEmail || !adminNombre) {
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
}
const tenant = await tenantsService.createTenant({
const result = await tenantsService.createTenant({
nombre,
rfc,
plan,
cfdiLimit,
usersLimit,
adminEmail,
adminNombre,
amount: amount || 0,
});
res.status(201).json(tenant);
res.status(201).json(result);
} catch (error) {
next(error);
}

View File

@@ -1,20 +1,10 @@
import { Request, Response, NextFunction } from 'express';
import * as usuariosService from '../services/usuarios.service.js';
import { AppError } from '../utils/errors.js';
import { prisma } from '../config/database.js';
// RFC del tenant administrador global
const ADMIN_TENANT_RFC = 'CAS2408138W2';
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
async function isGlobalAdmin(req: Request): Promise<boolean> {
if (req.user!.role !== 'admin') return false;
const tenant = await prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { rfc: true },
});
return tenant?.rfc === ADMIN_TENANT_RFC;
return checkGlobalAdmin(req.user!.tenantId, req.user!.role);
}
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {

View File

@@ -0,0 +1,98 @@
import type { Request, Response, NextFunction } from 'express';
import * as mpService from '../services/payment/mercadopago.service.js';
import * as subscriptionService from '../services/payment/subscription.service.js';
import { prisma } from '../config/database.js';
export async function handleMercadoPagoWebhook(req: Request, res: Response, next: NextFunction) {
try {
const { type, data } = req.body;
const xSignature = req.headers['x-signature'] as string;
const xRequestId = req.headers['x-request-id'] as string;
// Verify webhook signature (mandatory)
if (!xSignature || !xRequestId || !data?.id) {
console.warn('[WEBHOOK] Missing signature headers');
return res.status(401).json({ message: 'Missing signature headers' });
}
const isValid = mpService.verifyWebhookSignature(xSignature, xRequestId, String(data.id));
if (!isValid) {
console.warn('[WEBHOOK] Invalid MercadoPago signature');
return res.status(401).json({ message: 'Invalid signature' });
}
if (type === 'payment') {
await handlePaymentNotification(String(data.id));
} else if (type === 'subscription_preapproval') {
await handlePreapprovalNotification(String(data.id));
}
// Always respond 200 to acknowledge receipt
res.status(200).json({ received: true });
} catch (error) {
console.error('[WEBHOOK] Error processing MercadoPago webhook:', error);
// Still respond 200 to prevent retries for processing errors
res.status(200).json({ received: true, error: 'processing_error' });
}
}
async function handlePaymentNotification(paymentId: string) {
const payment = await mpService.getPaymentDetails(paymentId);
if (!payment.externalReference) {
console.warn('[WEBHOOK] Payment without external_reference:', paymentId);
return;
}
const tenantId = payment.externalReference;
// Find the subscription for this tenant
const subscription = await prisma.subscription.findFirst({
where: { tenantId },
orderBy: { createdAt: 'desc' },
});
if (!subscription) {
console.warn('[WEBHOOK] No subscription found for tenant:', tenantId);
return;
}
await subscriptionService.recordPayment({
tenantId,
subscriptionId: subscription.id,
mpPaymentId: paymentId,
amount: payment.transactionAmount || 0,
status: payment.status || 'unknown',
paymentMethod: payment.paymentMethodId || 'unknown',
});
// If payment approved, ensure subscription is active
if (payment.status === 'approved') {
await prisma.subscription.update({
where: { id: subscription.id },
data: { status: 'authorized' },
});
subscriptionService.invalidateSubscriptionCache(tenantId);
}
// Broadcast cache invalidation to PM2 cluster workers
if (typeof process.send === 'function') {
process.send({ type: 'invalidate-tenant-cache', tenantId });
}
}
async function handlePreapprovalNotification(preapprovalId: string) {
const preapproval = await mpService.getPreapproval(preapprovalId);
if (preapproval.status) {
await subscriptionService.updateSubscriptionStatus(preapprovalId, preapproval.status);
}
// Broadcast cache invalidation
const subscription = await prisma.subscription.findFirst({
where: { mpPreapprovalId: preapprovalId },
});
if (subscription && typeof process.send === 'function') {
process.send({ type: 'invalidate-tenant-cache', tenantId: subscription.tenantId });
}
}

View File

@@ -1,10 +1,12 @@
import { app } from './app.js';
import { env } from './config/env.js';
import { tenantDb } from './config/database.js';
import { invalidateTenantCache } from './middlewares/plan-limits.middleware.js';
import { startSatSyncJob } from './jobs/sat-sync.job.js';
const PORT = parseInt(env.PORT, 10);
app.listen(PORT, '0.0.0.0', () => {
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`API Server running on http://0.0.0.0:${PORT}`);
console.log(`Environment: ${env.NODE_ENV}`);
@@ -13,3 +15,24 @@ app.listen(PORT, '0.0.0.0', () => {
startSatSyncJob();
}
});
// Graceful shutdown — close all tenant DB pools before exiting
const gracefulShutdown = async (signal: string) => {
console.log(`${signal} received. Shutting down gracefully...`);
server.close(() => {
console.log('HTTP server closed');
});
await tenantDb.shutdown();
process.exit(0);
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// PM2 cluster: cross-worker cache invalidation
process.on('message', (msg: any) => {
if (msg?.type === 'invalidate-tenant-cache' && msg.tenantId) {
tenantDb.invalidatePool(msg.tenantId);
invalidateTenantCache(msg.tenantId);
}
});

View File

@@ -0,0 +1,37 @@
import type { Request, Response, NextFunction } from 'express';
import { hasFeature, type Plan } from '@horux/shared';
import { prisma } from '../config/database.js';
const planCache = new Map<string, { plan: string; expires: number }>();
/**
* Middleware factory that gates routes based on tenant plan features.
* Usage: requireFeature('reportes') — blocks access if tenant's plan lacks the feature.
*/
export function requireFeature(feature: string) {
return async (req: Request, res: Response, next: NextFunction) => {
if (!req.user) return res.status(401).json({ message: 'No autenticado' });
let plan: string;
const cached = planCache.get(req.user.tenantId);
if (cached && cached.expires > Date.now()) {
plan = cached.plan;
} else {
const tenant = await prisma.tenant.findUnique({
where: { id: req.user.tenantId },
select: { plan: true },
});
if (!tenant) return res.status(404).json({ message: 'Tenant no encontrado' });
plan = tenant.plan;
planCache.set(req.user.tenantId, { plan, expires: Date.now() + 5 * 60 * 1000 });
}
if (!hasFeature(plan as Plan, feature)) {
return res.status(403).json({
message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.',
});
}
next();
};
}

View File

@@ -0,0 +1,90 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
// Simple in-memory cache with TTL
const cache = new Map<string, { data: any; expires: number }>();
async function getCached<T>(key: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
const entry = cache.get(key);
if (entry && entry.expires > Date.now()) return entry.data;
const data = await fetcher();
cache.set(key, { data, expires: Date.now() + ttlMs });
return data;
}
export function invalidateTenantCache(tenantId: string) {
for (const key of cache.keys()) {
if (key.includes(tenantId)) cache.delete(key);
}
}
/**
* Checks if tenant has an active subscription before allowing write operations
*/
export async function checkPlanLimits(req: Request, res: Response, next: NextFunction) {
if (!req.user) return next();
// Global admin impersonation bypasses subscription check
if (req.headers['x-view-tenant'] && await isGlobalAdmin(req.user.tenantId, req.user.role)) {
return next();
}
const subscription = await getCached(
`sub:${req.user.tenantId}`,
5 * 60 * 1000,
() => prisma.subscription.findFirst({
where: { tenantId: req.user!.tenantId },
orderBy: { createdAt: 'desc' },
})
);
const allowedStatuses = ['authorized', 'pending'];
if (!subscription || !allowedStatuses.includes(subscription.status)) {
// Allow GET requests even with inactive subscription (read-only access)
if (req.method !== 'GET') {
return res.status(403).json({
message: 'Suscripción inactiva. Contacta soporte para reactivar.',
});
}
}
next();
}
/**
* Checks if tenant has room for more CFDIs before allowing CFDI creation
*/
export async function checkCfdiLimit(req: Request, res: Response, next: NextFunction) {
if (!req.user || !req.tenantPool) return next();
const tenant = await getCached(
`tenant:${req.user.tenantId}`,
5 * 60 * 1000,
() => prisma.tenant.findUnique({
where: { id: req.user!.tenantId },
select: { cfdiLimit: true },
})
);
if (!tenant || tenant.cfdiLimit === -1) return next(); // unlimited
const cfdiCount = await getCached(
`cfdi-count:${req.user.tenantId}`,
5 * 60 * 1000,
async () => {
const result = await req.tenantPool!.query('SELECT COUNT(*) FROM cfdis');
return parseInt(result.rows[0].count);
}
);
const newCount = Array.isArray(req.body) ? req.body.length : 1;
if (cfdiCount + newCount > tenant.cfdiLimit) {
return res.status(403).json({
message: `Límite de CFDIs alcanzado (${cfdiCount}/${tenant.cfdiLimit}). Contacta soporte para upgrade.`,
});
}
next();
}

View File

@@ -1,48 +1,91 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { AppError } from './error.middleware.js';
import type { Pool } from 'pg';
import { prisma, tenantDb } from '../config/database.js';
import { isGlobalAdmin } from '../utils/global-admin.js';
declare global {
namespace Express {
interface Request {
tenantSchema?: string;
tenantPool?: Pool;
viewingTenantId?: string;
}
}
}
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
if (!req.user) {
return next(new AppError(401, 'No autenticado'));
}
// Cache: tenantId -> { databaseName, expires }
const tenantDbCache = new Map<string, { databaseName: string; expires: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
try {
// Check if admin is viewing a different tenant
const viewTenantId = req.headers['x-view-tenant'] as string | undefined;
let tenantId = req.user.tenantId;
// Only admins can view other tenants
if (viewTenantId && req.user.role === 'admin') {
tenantId = viewTenantId;
req.viewingTenantId = viewTenantId;
}
async function getTenantDatabaseName(tenantId: string): Promise<string | null> {
const cached = tenantDbCache.get(tenantId);
if (cached && cached.expires > Date.now()) return cached.databaseName;
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { schemaName: true, active: true },
select: { databaseName: true },
});
if (!tenant || !tenant.active) {
return next(new AppError(403, 'Tenant no encontrado o inactivo'));
if (tenant) {
tenantDbCache.set(tenantId, { databaseName: tenant.databaseName, expires: Date.now() + CACHE_TTL });
}
req.tenantSchema = tenant.schemaName;
return tenant?.databaseName ?? null;
}
// Set search_path for this request
await prisma.$executeRawUnsafe(`SET search_path TO "${tenant.schemaName}", public`);
export function invalidateTenantDbCache(tenantId: string) {
tenantDbCache.delete(tenantId);
}
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
return res.status(401).json({ message: 'No autenticado' });
}
let tenantId = req.user.tenantId;
// Admin impersonation via X-View-Tenant header (global admin only)
const viewTenantHeader = req.headers['x-view-tenant'] as string;
if (viewTenantHeader) {
const globalAdmin = await isGlobalAdmin(req.user.tenantId, req.user.role);
if (!globalAdmin) {
return res.status(403).json({ message: 'No autorizado para ver otros tenants' });
}
const viewedTenant = await prisma.tenant.findFirst({
where: {
OR: [
{ id: viewTenantHeader },
{ rfc: viewTenantHeader },
],
},
select: { id: true, databaseName: true, active: true },
});
if (!viewedTenant) {
return res.status(404).json({ message: 'Tenant no encontrado' });
}
if (!viewedTenant.active) {
return res.status(403).json({ message: 'Tenant inactivo' });
}
tenantId = viewedTenant.id;
req.viewingTenantId = viewedTenant.id;
req.tenantPool = tenantDb.getPool(tenantId, viewedTenant.databaseName);
return next();
}
// Normal flow: look up databaseName server-side (not from JWT)
const databaseName = await getTenantDatabaseName(tenantId);
if (!databaseName) {
return res.status(404).json({ message: 'Tenant no encontrado' });
}
req.tenantPool = tenantDb.getPool(tenantId, databaseName);
next();
} catch (error) {
next(new AppError(500, 'Error al configurar tenant'));
console.error('[TenantMiddleware] Error:', error);
return res.status(500).json({ message: 'Error al resolver tenant' });
}
}

View File

@@ -1,12 +1,16 @@
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
import * as alertasController from '../controllers/alertas.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(checkPlanLimits);
router.use(requireFeature('alertas'));
router.get('/', alertasController.getAlertas);
router.get('/stats', alertasController.getStats);

View File

@@ -1,13 +1,41 @@
import { Router, type IRouter } from 'express';
import rateLimit from 'express-rate-limit';
import * as authController from '../controllers/auth.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js';
const router: IRouter = Router();
router.post('/register', authController.register);
router.post('/login', authController.login);
router.post('/refresh', authController.refresh);
router.post('/logout', authController.logout);
// Rate limiting: 10 login attempts per 15 minutes per IP
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' },
standardHeaders: true,
legacyHeaders: false,
});
// Rate limiting: 3 registrations per hour per IP
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 3,
message: { message: 'Demasiados registros. Intenta de nuevo en 1 hora.' },
standardHeaders: true,
legacyHeaders: false,
});
// Rate limiting: 20 refresh attempts per 15 minutes per IP
const refreshLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
message: { message: 'Demasiadas solicitudes. Intenta de nuevo más tarde.' },
standardHeaders: true,
legacyHeaders: false,
});
router.post('/register', registerLimiter, authController.register);
router.post('/login', loginLimiter, authController.login);
router.post('/refresh', refreshLimiter, authController.refresh);
router.post('/logout', authenticate, authController.logout);
router.get('/me', authenticate, authController.me);
export { router as authRoutes };

View File

@@ -1,12 +1,16 @@
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
import * as calendarioController from '../controllers/calendario.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(checkPlanLimits);
router.use(requireFeature('calendario'));
router.get('/', calendarioController.getEventos);
router.get('/proximos', calendarioController.getProximos);

View File

@@ -1,12 +1,15 @@
import { Router, type IRouter } from 'express';
import express from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { checkPlanLimits, checkCfdiLimit } from '../middlewares/plan-limits.middleware.js';
import * as cfdiController from '../controllers/cfdi.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(checkPlanLimits);
router.get('/', cfdiController.getCfdis);
router.get('/resumen', cfdiController.getResumen);
@@ -14,8 +17,8 @@ router.get('/emisores', cfdiController.getEmisores);
router.get('/receptores', cfdiController.getReceptores);
router.get('/:id', cfdiController.getCfdiById);
router.get('/:id/xml', cfdiController.getXml);
router.post('/', cfdiController.createCfdi);
router.post('/bulk', cfdiController.createManyCfdis);
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
router.post('/bulk', express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);
router.delete('/:id', cfdiController.deleteCfdi);
export { router as cfdiRoutes };

View File

@@ -1,12 +1,14 @@
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
import * as dashboardController from '../controllers/dashboard.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(checkPlanLimits);
router.get('/kpis', dashboardController.getKpis);
router.get('/ingresos-egresos', dashboardController.getIngresosEgresos);

View File

@@ -1,12 +1,16 @@
import { Router, type IRouter } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
import * as reportesController from '../controllers/reportes.controller.js';
const router: IRouter = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.use(checkPlanLimits);
router.use(requireFeature('reportes'));
router.get('/estado-resultados', reportesController.getEstadoResultados);
router.get('/flujo-efectivo', reportesController.getFlujoEfectivo);

View File

@@ -1,6 +1,6 @@
import { Router, type IRouter } from 'express';
import * as satController from '../controllers/sat.controller.js';
import { authenticate } from '../middlewares/auth.middleware.js';
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
const router: IRouter = Router();
@@ -22,10 +22,8 @@ router.get('/sync/:id', satController.jobDetail);
// POST /api/sat/sync/:id/retry - Reintentar job fallido
router.post('/sync/:id/retry', satController.retry);
// GET /api/sat/cron - Información del job programado (admin)
router.get('/cron', satController.cronInfo);
// POST /api/sat/cron/run - Ejecutar job manualmente (admin)
router.post('/cron/run', satController.runCron);
// Admin-only cron endpoints (global admin verified in controller)
router.get('/cron', authorize('admin'), satController.cronInfo);
router.post('/cron/run', authorize('admin'), satController.runCron);
export default router;

View File

@@ -0,0 +1,17 @@
import { Router, type IRouter } from 'express';
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
import * as subscriptionController from '../controllers/subscription.controller.js';
const router: IRouter = Router();
// All endpoints require authentication + admin role
router.use(authenticate);
router.use(authorize('admin'));
// Admin subscription management (global admin verified in controller)
router.get('/:tenantId', subscriptionController.getSubscription);
router.post('/:tenantId/generate-link', subscriptionController.generatePaymentLink);
router.post('/:tenantId/mark-paid', subscriptionController.markAsPaid);
router.get('/:tenantId/payments', subscriptionController.getPayments);
export { router as subscriptionRoutes };

View File

@@ -0,0 +1,9 @@
import { Router, type IRouter } from 'express';
import { handleMercadoPagoWebhook } from '../controllers/webhook.controller.js';
const router: IRouter = Router();
// Public endpoint — no auth middleware
router.post('/mercadopago', handleMercadoPagoWebhook);
export { router as webhookRoutes };

View File

@@ -1,8 +1,8 @@
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';
export async function getAlertas(
schema: string,
pool: Pool,
filters: { leida?: boolean; resuelta?: boolean; prioridad?: string }
): Promise<AlertaFull[]> {
let whereClause = 'WHERE 1=1';
@@ -22,43 +22,43 @@ export async function getAlertas(
params.push(filters.prioridad);
}
const alertas = await prisma.$queryRawUnsafe<AlertaFull[]>(`
const { rows } = await pool.query(`
SELECT id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta, created_at as "createdAt"
FROM "${schema}".alertas
FROM alertas
${whereClause}
ORDER BY
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
created_at DESC
`, ...params);
`, params);
return alertas;
return rows;
}
export async function getAlertaById(schema: string, id: number): Promise<AlertaFull | null> {
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
export async function getAlertaById(pool: Pool, id: number): Promise<AlertaFull | null> {
const { rows } = await pool.query(`
SELECT id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta, created_at as "createdAt"
FROM "${schema}".alertas
FROM alertas
WHERE id = $1
`, id);
return alerta || null;
`, [id]);
return rows[0] || null;
}
export async function createAlerta(schema: string, data: AlertaCreate): Promise<AlertaFull> {
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
INSERT INTO "${schema}".alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
export async function createAlerta(pool: Pool, data: AlertaCreate): Promise<AlertaFull> {
const { rows } = await pool.query(`
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta, created_at as "createdAt"
`, data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null);
return alerta;
`, [data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null]);
return rows[0];
}
export async function updateAlerta(schema: string, id: number, data: AlertaUpdate): Promise<AlertaFull> {
export async function updateAlerta(pool: Pool, id: number, data: AlertaUpdate): Promise<AlertaFull> {
const sets: string[] = [];
const params: any[] = [];
let paramIndex = 1;
@@ -74,35 +74,35 @@ export async function updateAlerta(schema: string, id: number, data: AlertaUpdat
params.push(id);
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
UPDATE "${schema}".alertas
const { rows } = await pool.query(`
UPDATE alertas
SET ${sets.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta, created_at as "createdAt"
`, ...params);
`, params);
return alerta;
return rows[0];
}
export async function deleteAlerta(schema: string, id: number): Promise<void> {
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".alertas WHERE id = $1`, id);
export async function deleteAlerta(pool: Pool, id: number): Promise<void> {
await pool.query(`DELETE FROM alertas WHERE id = $1`, [id]);
}
export async function getStats(schema: string): Promise<AlertasStats> {
const [stats] = await prisma.$queryRawUnsafe<AlertasStats[]>(`
export async function getStats(pool: Pool): Promise<AlertasStats> {
const { rows: [stats] } = await pool.query(`
SELECT
COUNT(*)::int as total,
COUNT(CASE WHEN leida = false THEN 1 END)::int as "noLeidas",
COUNT(CASE WHEN prioridad = 'alta' AND resuelta = false THEN 1 END)::int as alta,
COUNT(CASE WHEN prioridad = 'media' AND resuelta = false THEN 1 END)::int as media,
COUNT(CASE WHEN prioridad = 'baja' AND resuelta = false THEN 1 END)::int as baja
FROM "${schema}".alertas
FROM alertas
`);
return stats;
}
export async function markAllAsRead(schema: string): Promise<void> {
await prisma.$queryRawUnsafe(`UPDATE "${schema}".alertas SET leida = true WHERE leida = false`);
export async function markAllAsRead(pool: Pool): Promise<void> {
await pool.query(`UPDATE alertas SET leida = true WHERE leida = false`);
}

View File

@@ -1,14 +1,13 @@
import { prisma } from '../config/database.js';
import { prisma, tenantDb } from '../config/database.js';
import { hashPassword, verifyPassword } from '../utils/password.js';
import { generateAccessToken, generateRefreshToken, verifyToken } from '../utils/token.js';
import { createTenantSchema } from '../utils/schema-manager.js';
import { AppError } from '../middlewares/error.middleware.js';
import { PLANS } from '@horux/shared';
import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared';
export async function register(data: RegisterRequest): Promise<LoginResponse> {
const existingUser = await prisma.user.findUnique({
where: { email: data.usuario.email },
where: { email: data.usuario.email.toLowerCase() },
});
if (existingUser) {
@@ -23,21 +22,20 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
throw new AppError(400, 'El RFC ya está registrado');
}
const schemaName = `tenant_${data.empresa.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
// Provision a dedicated database for this tenant
const databaseName = await tenantDb.provisionDatabase(data.empresa.rfc);
const tenant = await prisma.tenant.create({
data: {
nombre: data.empresa.nombre,
rfc: data.empresa.rfc.toUpperCase(),
plan: 'starter',
schemaName,
databaseName,
cfdiLimit: PLANS.starter.cfdiLimit,
usersLimit: PLANS.starter.usersLimit,
},
});
await createTenantSchema(schemaName);
const passwordHash = await hashPassword(data.usuario.password);
const user = await prisma.user.create({
data: {
@@ -54,7 +52,6 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
email: user.email,
role: user.role,
tenantId: tenant.id,
schemaName: tenant.schemaName,
};
const accessToken = generateAccessToken(tokenPayload);
@@ -79,6 +76,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
tenantId: tenant.id,
tenantName: tenant.nombre,
tenantRfc: tenant.rfc,
plan: tenant.plan,
},
};
}
@@ -117,7 +115,6 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
email: user.email,
role: user.role,
tenantId: user.tenantId,
schemaName: user.tenant.schemaName,
};
const accessToken = generateAccessToken(tokenPayload);
@@ -142,6 +139,7 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
tenantId: user.tenantId,
tenantName: user.tenant.nombre,
tenantRfc: user.tenant.rfc,
plan: user.tenant.plan,
},
};
}
@@ -181,7 +179,6 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
email: user.email,
role: user.role,
tenantId: user.tenantId,
schemaName: user.tenant.schemaName,
};
const accessToken = generateAccessToken(newTokenPayload);

View File

@@ -1,8 +1,8 @@
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
export async function getEventos(
schema: string,
pool: Pool,
año: number,
mes?: number
): Promise<EventoFiscal[]> {
@@ -14,49 +14,49 @@ export async function getEventos(
params.push(mes);
}
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
const { rows } = await pool.query(`
SELECT id, titulo, descripcion, tipo,
fecha_limite as "fechaLimite",
recurrencia, completado, notas,
created_at as "createdAt"
FROM "${schema}".calendario_fiscal
FROM calendario_fiscal
${whereClause}
ORDER BY fecha_limite ASC
`, ...params);
`, params);
return eventos;
return rows;
}
export async function getProximosEventos(schema: string, dias = 30): Promise<EventoFiscal[]> {
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
export async function getProximosEventos(pool: Pool, dias = 30): Promise<EventoFiscal[]> {
const { rows } = await pool.query(`
SELECT id, titulo, descripcion, tipo,
fecha_limite as "fechaLimite",
recurrencia, completado, notas,
created_at as "createdAt"
FROM "${schema}".calendario_fiscal
FROM calendario_fiscal
WHERE completado = false
AND fecha_limite BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '${dias} days'
AND fecha_limite BETWEEN CURRENT_DATE AND CURRENT_DATE + $1 * INTERVAL '1 day'
ORDER BY fecha_limite ASC
`);
`, [dias]);
return eventos;
return rows;
}
export async function createEvento(schema: string, data: EventoCreate): Promise<EventoFiscal> {
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
INSERT INTO "${schema}".calendario_fiscal
export async function createEvento(pool: Pool, data: EventoCreate): Promise<EventoFiscal> {
const { rows } = await pool.query(`
INSERT INTO calendario_fiscal
(titulo, descripcion, tipo, fecha_limite, recurrencia, notas)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, titulo, descripcion, tipo,
fecha_limite as "fechaLimite",
recurrencia, completado, notas,
created_at as "createdAt"
`, data.titulo, data.descripcion, data.tipo, data.fechaLimite, data.recurrencia, data.notas || null);
`, [data.titulo, data.descripcion, data.tipo, data.fechaLimite, data.recurrencia, data.notas || null]);
return evento;
return rows[0];
}
export async function updateEvento(schema: string, id: number, data: EventoUpdate): Promise<EventoFiscal> {
export async function updateEvento(pool: Pool, id: number, data: EventoUpdate): Promise<EventoFiscal> {
const sets: string[] = [];
const params: any[] = [];
let paramIndex = 1;
@@ -84,19 +84,19 @@ export async function updateEvento(schema: string, id: number, data: EventoUpdat
params.push(id);
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
UPDATE "${schema}".calendario_fiscal
const { rows } = await pool.query(`
UPDATE calendario_fiscal
SET ${sets.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, titulo, descripcion, tipo,
fecha_limite as "fechaLimite",
recurrencia, completado, notas,
created_at as "createdAt"
`, ...params);
`, params);
return evento;
return rows[0];
}
export async function deleteEvento(schema: string, id: number): Promise<void> {
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".calendario_fiscal WHERE id = $1`, id);
export async function deleteEvento(pool: Pool, id: number): Promise<void> {
await pool.query(`DELETE FROM calendario_fiscal WHERE id = $1`, [id]);
}

View File

@@ -1,7 +1,7 @@
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared';
export async function getCfdis(schema: string, filters: CfdiFilters): Promise<CfdiListResponse> {
export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiListResponse> {
const page = filters.page || 1;
const limit = filters.limit || 20;
const offset = (page - 1) * limit;
@@ -50,9 +50,8 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
params.push(`%${filters.search}%`);
}
// Combinar COUNT con la query principal usando window function
params.push(limit, offset);
const dataWithCount = await prisma.$queryRawUnsafe<(Cfdi & { total_count: number })[]>(`
const { rows: dataWithCount } = await pool.query(`
SELECT
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
@@ -65,14 +64,14 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
created_at as "createdAt",
COUNT(*) OVER() as total_count
FROM "${schema}".cfdis
FROM cfdis
${whereClause}
ORDER BY fecha_emision DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`, ...params);
`, params);
const total = Number(dataWithCount[0]?.total_count || 0);
const data = dataWithCount.map(({ total_count, ...cfdi }) => cfdi) as Cfdi[];
const data = dataWithCount.map(({ total_count, ...cfdi }: any) => cfdi) as Cfdi[];
return {
data,
@@ -83,8 +82,8 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
};
}
export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
export async function getCfdiById(pool: Pool, id: string): Promise<Cfdi | null> {
const { rows } = await pool.query(`
SELECT
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
@@ -97,19 +96,19 @@ export async function getCfdiById(schema: string, id: string): Promise<Cfdi | nu
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
xml_original as "xmlOriginal",
created_at as "createdAt"
FROM "${schema}".cfdis
FROM cfdis
WHERE id = $1::uuid
`, id);
`, [id]);
return result[0] || null;
return rows[0] || null;
}
export async function getXmlById(schema: string, id: string): Promise<string | null> {
const result = await prisma.$queryRawUnsafe<[{ xml_original: string | null }]>(`
SELECT xml_original FROM "${schema}".cfdis WHERE id = $1::uuid
`, id);
export async function getXmlById(pool: Pool, id: string): Promise<string | null> {
const { rows } = await pool.query(`
SELECT xml_original FROM cfdis WHERE id = $1::uuid
`, [id]);
return result[0]?.xml_original || null;
return rows[0]?.xml_original || null;
}
export interface CreateCfdiData {
@@ -139,18 +138,15 @@ export interface CreateCfdiData {
pdfUrl?: string;
}
export async function createCfdi(schema: string, data: CreateCfdiData): Promise<Cfdi> {
// Validate required fields
export async function createCfdi(pool: Pool, data: CreateCfdiData): Promise<Cfdi> {
if (!data.uuidFiscal) throw new Error('UUID Fiscal es requerido');
if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida');
if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido');
if (!data.rfcReceptor) throw new Error('RFC Receptor es requerido');
// Parse dates safely - handle YYYY-MM-DD format explicitly
let fechaEmision: Date;
let fechaTimbrado: Date;
// If date is in YYYY-MM-DD format, add time to avoid timezone issues
const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/)
? `${data.fechaEmision}T12:00:00`
: data.fechaEmision;
@@ -173,8 +169,8 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
throw new Error(`Fecha de timbrado inválida: ${data.fechaTimbrado}`);
}
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
INSERT INTO "${schema}".cfdis (
const { rows } = await pool.query(`
INSERT INTO cfdis (
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
@@ -191,7 +187,7 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
created_at as "createdAt"
`,
`, [
data.uuidFiscal,
data.tipo || 'ingreso',
data.serie || null,
@@ -216,9 +212,9 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
data.estado || 'vigente',
data.xmlUrl || null,
data.pdfUrl || null
);
]);
return result[0];
return rows[0];
}
export interface BatchInsertResult {
@@ -228,14 +224,12 @@ export interface BatchInsertResult {
errorMessages: string[];
}
// Optimized batch insert using multi-row INSERT
export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise<number> {
const result = await createManyCfdisBatch(schema, cfdis);
export async function createManyCfdis(pool: Pool, cfdis: CreateCfdiData[]): Promise<number> {
const result = await createManyCfdisBatch(pool, cfdis);
return result.inserted;
}
// New optimized batch insert with detailed results
export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
export async function createManyCfdisBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
const result: BatchInsertResult = {
inserted: 0,
duplicates: 0,
@@ -245,19 +239,17 @@ export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData
if (cfdis.length === 0) return result;
// Process in batches of 500 for optimal performance
const BATCH_SIZE = 500;
for (let batchStart = 0; batchStart < cfdis.length; batchStart += BATCH_SIZE) {
const batch = cfdis.slice(batchStart, batchStart + BATCH_SIZE);
try {
const batchResult = await insertBatch(schema, batch);
const batchResult = await insertBatch(pool, batch);
result.inserted += batchResult.inserted;
result.duplicates += batchResult.duplicates;
} catch (error: any) {
// If batch fails, try individual inserts for this batch
const individualResult = await insertIndividually(schema, batch);
const individualResult = await insertIndividually(pool, batch);
result.inserted += individualResult.inserted;
result.duplicates += individualResult.duplicates;
result.errors += individualResult.errors;
@@ -268,17 +260,14 @@ export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData
return result;
}
// Insert a batch using multi-row INSERT with ON CONFLICT
async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ inserted: number; duplicates: number }> {
async function insertBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise<{ inserted: number; duplicates: number }> {
if (cfdis.length === 0) return { inserted: 0, duplicates: 0 };
// Build the VALUES part of the query
const values: any[] = [];
const valuePlaceholders: string[] = [];
let paramIndex = 1;
for (const cfdi of cfdis) {
// Parse dates
const fechaEmision = parseDate(cfdi.fechaEmision);
const fechaTimbrado = cfdi.fechaTimbrado ? parseDate(cfdi.fechaTimbrado) : fechaEmision;
@@ -322,9 +311,8 @@ async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ i
return { inserted: 0, duplicates: 0 };
}
// Use ON CONFLICT to handle duplicates gracefully
const query = `
INSERT INTO "${schema}".cfdis (
INSERT INTO cfdis (
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
@@ -333,15 +321,12 @@ async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ i
ON CONFLICT (uuid_fiscal) DO NOTHING
`;
await prisma.$executeRawUnsafe(query, ...values);
await pool.query(query, values);
// We can't know exactly how many were inserted vs duplicates with DO NOTHING
// Return optimistic count, duplicates will be 0 (they're silently skipped)
return { inserted: valuePlaceholders.length, duplicates: 0 };
}
// Fallback: insert individually when batch fails
async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
async function insertIndividually(pool: Pool, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
const result: BatchInsertResult = {
inserted: 0,
duplicates: 0,
@@ -351,7 +336,7 @@ async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Prom
for (const cfdi of cfdis) {
try {
await createCfdi(schema, cfdi);
await createCfdi(pool, cfdi);
result.inserted++;
} catch (error: any) {
const errorMsg = error.message || 'Error desconocido';
@@ -369,11 +354,9 @@ async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Prom
return result;
}
// Helper to parse dates safely
function parseDate(dateStr: string): Date | null {
if (!dateStr) return null;
// If date is in YYYY-MM-DD format, add time to avoid timezone issues
const normalized = dateStr.match(/^\d{4}-\d{2}-\d{2}$/)
? `${dateStr}T12:00:00`
: dateStr;
@@ -382,41 +365,34 @@ function parseDate(dateStr: string): Date | null {
return isNaN(date.getTime()) ? null : date;
}
export async function deleteCfdi(schema: string, id: string): Promise<void> {
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id);
export async function deleteCfdi(pool: Pool, id: string): Promise<void> {
await pool.query(`DELETE FROM cfdis WHERE id = $1`, [id]);
}
export async function getEmisores(schema: string, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
const result = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string }[]>(`
export async function getEmisores(pool: Pool, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
const { rows } = await pool.query(`
SELECT DISTINCT rfc_emisor as rfc, nombre_emisor as nombre
FROM "${schema}".cfdis
FROM cfdis
WHERE rfc_emisor ILIKE $1 OR nombre_emisor ILIKE $1
ORDER BY nombre_emisor
LIMIT $2
`, `%${search}%`, limit);
return result;
`, [`%${search}%`, limit]);
return rows;
}
export async function getReceptores(schema: string, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
const result = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string }[]>(`
export async function getReceptores(pool: Pool, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
const { rows } = await pool.query(`
SELECT DISTINCT rfc_receptor as rfc, nombre_receptor as nombre
FROM "${schema}".cfdis
FROM cfdis
WHERE rfc_receptor ILIKE $1 OR nombre_receptor ILIKE $1
ORDER BY nombre_receptor
LIMIT $2
`, `%${search}%`, limit);
return result;
`, [`%${search}%`, limit]);
return rows;
}
export async function getResumenCfdis(schema: string, año: number, mes: number) {
const result = await prisma.$queryRawUnsafe<[{
total_ingresos: number;
total_egresos: number;
count_ingresos: number;
count_egresos: number;
iva_trasladado: number;
iva_acreditable: number;
}]>(`
export async function getResumenCfdis(pool: Pool, año: number, mes: number) {
const { rows } = await pool.query(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as total_ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as total_egresos,
@@ -424,13 +400,13 @@ export async function getResumenCfdis(schema: string, año: number, mes: number)
COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as count_egresos,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as iva_trasladado,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva_acreditable
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
`, [año, mes]);
const r = result[0];
const r = rows[0];
return {
totalIngresos: Number(r?.total_ingresos || 0),
totalEgresos: Number(r?.total_egresos || 0),

View File

@@ -1,44 +1,44 @@
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
import type { KpiData, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared';
export async function getKpis(schema: string, año: number, mes: number): Promise<KpiData> {
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
export async function getKpis(pool: Pool, año: number, mes: number): Promise<KpiData> {
const { rows: [ingresos] } = await pool.query(`
SELECT COALESCE(SUM(total), 0) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'ingreso'
AND estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
`, [año, mes]);
const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
const { rows: [egresos] } = await pool.query(`
SELECT COALESCE(SUM(total), 0) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'egreso'
AND estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
`, [año, mes]);
const [ivaData] = await prisma.$queryRawUnsafe<[{ trasladado: number; acreditable: number }]>(`
const { rows: [ivaData] } = await pool.query(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
`, [año, mes]);
const [counts] = await prisma.$queryRawUnsafe<[{ emitidos: number; recibidos: number }]>(`
const { rows: [counts] } = await pool.query(`
SELECT
COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as emitidos,
COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as recibidos
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
`, [año, mes]);
const ingresosVal = Number(ingresos?.total || 0);
const egresosVal = Number(egresos?.total || 0);
@@ -57,23 +57,23 @@ export async function getKpis(schema: string, año: number, mes: number): Promis
};
}
export async function getIngresosEgresos(schema: string, año: number): Promise<IngresosEgresosData[]> {
const data = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
export async function getIngresosEgresos(pool: Pool, año: number): Promise<IngresosEgresosData[]> {
const { rows: data } = await pool.query(`
SELECT
EXTRACT(MONTH FROM fecha_emision)::int as mes,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
GROUP BY EXTRACT(MONTH FROM fecha_emision)
ORDER BY mes
`, año);
`, [año]);
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
return meses.map((mes, index) => {
const found = data.find(d => d.mes === index + 1);
const found = data.find((d: any) => d.mes === index + 1);
return {
mes,
ingresos: Number(found?.ingresos || 0),
@@ -82,16 +82,17 @@ export async function getIngresosEgresos(schema: string, año: number): Promise<
});
}
export async function getResumenFiscal(schema: string, año: number, mes: number): Promise<ResumenFiscal> {
const [ivaResult] = await prisma.$queryRawUnsafe<[{ resultado: number; acumulado: number }]>(`
SELECT resultado, acumulado FROM "${schema}".iva_mensual
export async function getResumenFiscal(pool: Pool, año: number, mes: number): Promise<ResumenFiscal> {
const { rows: ivaRows } = await pool.query(`
SELECT resultado, acumulado FROM iva_mensual
WHERE año = $1 AND mes = $2
`, año, mes) || [{ resultado: 0, acumulado: 0 }];
`, [año, mes]);
const ivaResult = ivaRows[0] || { resultado: 0, acumulado: 0 };
const [pendientes] = await prisma.$queryRawUnsafe<[{ count: number }]>(`
SELECT COUNT(*) as count FROM "${schema}".iva_mensual
const { rows: [pendientes] } = await pool.query(`
SELECT COUNT(*) as count FROM iva_mensual
WHERE año = $1 AND estado = 'pendiente'
`, año);
`, [año]);
const resultado = Number(ivaResult?.resultado || 0);
const acumulado = Number(ivaResult?.acumulado || 0);
@@ -108,19 +109,19 @@ export async function getResumenFiscal(schema: string, año: number, mes: number
};
}
export async function getAlertas(schema: string, limit = 5): Promise<Alerta[]> {
const alertas = await prisma.$queryRawUnsafe<Alerta[]>(`
export async function getAlertas(pool: Pool, limit = 5): Promise<Alerta[]> {
const { rows } = await pool.query(`
SELECT id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta,
created_at as "createdAt"
FROM "${schema}".alertas
FROM alertas
WHERE resuelta = false
ORDER BY
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
created_at DESC
LIMIT $1
`, limit);
`, [limit]);
return alertas;
return rows;
}

View File

@@ -0,0 +1,93 @@
import { createTransport, type Transporter } from 'nodemailer';
import { env } from '../../config/env.js';
let transporter: Transporter | null = null;
function getTransporter(): Transporter {
if (!transporter) {
if (!env.SMTP_USER || !env.SMTP_PASS) {
console.warn('[EMAIL] SMTP not configured. Emails will be logged to console.');
return {
sendMail: async (opts: any) => {
console.log('[EMAIL] Would send:', { to: opts.to, subject: opts.subject });
return { messageId: 'mock' };
},
} as any;
}
transporter = createTransport({
host: env.SMTP_HOST,
port: parseInt(env.SMTP_PORT),
secure: false, // Upgrade to TLS via STARTTLS
requireTLS: true, // Reject if STARTTLS is not available
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASS,
},
});
}
return transporter;
}
async function sendEmail(to: string, subject: string, html: string) {
const transport = getTransporter();
try {
await transport.sendMail({
from: env.SMTP_FROM,
to,
subject,
html,
text: html.replace(/<[^>]*>/g, ''),
});
} catch (error) {
console.error('[EMAIL] Error sending email:', error);
// Don't throw — email failure shouldn't break the main flow
}
}
export const emailService = {
sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string }) => {
const { welcomeEmail } = await import('./templates/welcome.js');
await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data));
},
sendFielNotification: async (data: { clienteNombre: string; clienteRfc: string }) => {
const { fielNotificationEmail } = await import('./templates/fiel-notification.js');
await sendEmail(env.ADMIN_EMAIL, `[${data.clienteNombre}] subió su FIEL`, fielNotificationEmail(data));
},
sendPaymentConfirmed: async (to: string, data: { nombre: string; amount: number; plan: string; date: string }) => {
const { paymentConfirmedEmail } = await import('./templates/payment-confirmed.js');
await sendEmail(to, 'Confirmación de pago - Horux360', paymentConfirmedEmail(data));
},
sendPaymentFailed: async (to: string, data: { nombre: string; amount: number; plan: string }) => {
const { paymentFailedEmail } = await import('./templates/payment-failed.js');
await sendEmail(to, 'Problema con tu pago - Horux360', paymentFailedEmail(data));
await sendEmail(env.ADMIN_EMAIL, `Pago fallido: ${data.nombre}`, paymentFailedEmail(data));
},
sendSubscriptionExpiring: async (to: string, data: { nombre: string; plan: string; expiresAt: string }) => {
const { subscriptionExpiringEmail } = await import('./templates/subscription-expiring.js');
await sendEmail(to, 'Tu suscripción vence en 5 días', subscriptionExpiringEmail(data));
},
sendSubscriptionCancelled: async (to: string, data: { nombre: string; plan: string }) => {
const { subscriptionCancelledEmail } = await import('./templates/subscription-cancelled.js');
await sendEmail(to, 'Suscripción cancelada - Horux360', subscriptionCancelledEmail(data));
await sendEmail(env.ADMIN_EMAIL, `Suscripción cancelada: ${data.nombre}`, subscriptionCancelledEmail(data));
},
sendNewClientAdmin: async (data: {
clienteNombre: string;
clienteRfc: string;
adminEmail: string;
adminNombre: string;
tempPassword: string;
databaseName: string;
plan: string;
}) => {
const { newClientAdminEmail } = await import('./templates/new-client-admin.js');
await sendEmail(env.ADMIN_EMAIL, `Nuevo cliente: ${data.clienteNombre} (${data.clienteRfc})`, newClientAdminEmail(data));
},
};

View File

@@ -0,0 +1,35 @@
export function baseTemplate(content: string): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0;padding:0;background-color:#f4f4f5;font-family:Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f4f5;padding:32px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color:#ffffff;border-radius:8px;overflow:hidden;">
<tr>
<td style="background-color:#1e293b;padding:24px 32px;text-align:center;">
<h1 style="color:#ffffff;margin:0;font-size:24px;">Horux360</h1>
</td>
</tr>
<tr>
<td style="padding:32px;">
${content}
</td>
</tr>
<tr>
<td style="background-color:#f8fafc;padding:16px 32px;text-align:center;font-size:12px;color:#94a3b8;">
<p style="margin:0;">&copy; ${new Date().getFullYear()} Horux360 - Plataforma Fiscal Inteligente</p>
<p style="margin:4px 0 0;">Consultoria Alcaraz Salazar</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}

View File

@@ -0,0 +1,14 @@
import { baseTemplate } from './base.js';
export function fielNotificationEmail(data: { clienteNombre: string; clienteRfc: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">FIEL Subida</h2>
<p style="color:#475569;line-height:1.6;">El cliente <strong>${data.clienteNombre}</strong> ha subido su e.firma (FIEL).</p>
<div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
<p style="margin:0;color:#334155;"><strong>Empresa:</strong> ${data.clienteNombre}</p>
<p style="margin:8px 0 0;color:#334155;"><strong>RFC:</strong> ${data.clienteRfc}</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Fecha:</strong> ${new Date().toLocaleString('es-MX')}</p>
</div>
<p style="color:#475569;line-height:1.6;">Ya puedes iniciar la sincronización de CFDIs para este cliente.</p>
`);
}

View File

@@ -0,0 +1,68 @@
import { baseTemplate } from './base.js';
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export function newClientAdminEmail(data: {
clienteNombre: string;
clienteRfc: string;
adminEmail: string;
adminNombre: string;
tempPassword: string;
databaseName: string;
plan: string;
}): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Nuevo Cliente Registrado</h2>
<p style="color:#475569;line-height:1.6;margin:0 0 24px;">
Se ha dado de alta un nuevo cliente en Horux360. A continuación los detalles:
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
<tr>
<td colspan="2" style="background-color:#1e293b;color:#ffffff;padding:12px 16px;font-weight:bold;border-radius:6px 6px 0 0;">
Datos del Cliente
</td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;width:40%;">Empresa</td>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.clienteNombre)}</td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;">RFC</td>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.clienteRfc)}</td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;">Plan</td>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.plan)}</td>
</tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
<tr>
<td colspan="2" style="background-color:#3b82f6;color:#ffffff;padding:12px 16px;font-weight:bold;border-radius:6px 6px 0 0;">
Credenciales del Usuario
</td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;width:40%;">Nombre</td>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.adminNombre)}</td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;">Email</td>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;color:#1e293b;">${escapeHtml(data.adminEmail)}</td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;font-weight:bold;color:#475569;">Contraseña temporal</td>
<td style="padding:10px 16px;border-bottom:1px solid #e2e8f0;">
<code style="background-color:#f1f5f9;padding:4px 8px;border-radius:4px;font-size:14px;color:#dc2626;">${escapeHtml(data.tempPassword)}</code>
</td>
</tr>
</table>
<p style="color:#94a3b8;font-size:12px;margin:0;">
Este correo contiene información confidencial. No lo reenvíes ni lo compartas.
</p>
`);
}

View File

@@ -0,0 +1,15 @@
import { baseTemplate } from './base.js';
export function paymentConfirmedEmail(data: { nombre: string; amount: number; plan: string; date: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Pago Confirmado</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">Hemos recibido tu pago correctamente.</p>
<div style="background-color:#f0fdf4;border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid #22c55e;">
<p style="margin:0;color:#334155;"><strong>Monto:</strong> $${data.amount.toLocaleString('es-MX')} MXN</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Plan:</strong> ${data.plan}</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Fecha:</strong> ${data.date}</p>
</div>
<p style="color:#475569;line-height:1.6;">Tu suscripción está activa. Gracias por confiar en Horux360.</p>
`);
}

View File

@@ -0,0 +1,14 @@
import { baseTemplate } from './base.js';
export function paymentFailedEmail(data: { nombre: string; amount: number; plan: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Problema con tu Pago</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">No pudimos procesar tu pago. Por favor verifica tu método de pago.</p>
<div style="background-color:#fef2f2;border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid #ef4444;">
<p style="margin:0;color:#334155;"><strong>Monto pendiente:</strong> $${data.amount.toLocaleString('es-MX')} MXN</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Plan:</strong> ${data.plan}</p>
</div>
<p style="color:#475569;line-height:1.6;">Si necesitas ayuda, contacta a soporte respondiendo a este correo.</p>
`);
}

View File

@@ -0,0 +1,14 @@
import { baseTemplate } from './base.js';
export function subscriptionCancelledEmail(data: { nombre: string; plan: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Suscripción Cancelada</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">Tu suscripción al plan <strong>${data.plan}</strong> ha sido cancelada.</p>
<div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
<p style="margin:0;color:#334155;">Tu acceso continuará activo hasta el final del período actual de facturación.</p>
<p style="margin:8px 0 0;color:#334155;">Después de eso, solo tendrás acceso de lectura a tus datos.</p>
</div>
<p style="color:#475569;line-height:1.6;">Si deseas reactivar tu suscripción, contacta a soporte.</p>
`);
}

View File

@@ -0,0 +1,13 @@
import { baseTemplate } from './base.js';
export function subscriptionExpiringEmail(data: { nombre: string; plan: string; expiresAt: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Tu Suscripción Vence Pronto</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">Tu suscripción al plan <strong>${data.plan}</strong> vence el <strong>${data.expiresAt}</strong>.</p>
<div style="background-color:#fffbeb;border-radius:8px;padding:16px;margin:16px 0;border-left:4px solid #f59e0b;">
<p style="margin:0;color:#334155;">Para evitar interrupciones en el servicio, asegúrate de que tu método de pago esté actualizado.</p>
</div>
<p style="color:#475569;line-height:1.6;">Si tienes alguna pregunta sobre tu suscripción, contacta a soporte.</p>
`);
}

View File

@@ -0,0 +1,15 @@
import { baseTemplate } from './base.js';
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string }): string {
return baseTemplate(`
<h2 style="color:#1e293b;margin:0 0 16px;">Bienvenido a Horux360</h2>
<p style="color:#475569;line-height:1.6;">Hola ${data.nombre},</p>
<p style="color:#475569;line-height:1.6;">Tu cuenta ha sido creada exitosamente. Aquí tienes tus credenciales de acceso:</p>
<div style="background-color:#f1f5f9;border-radius:8px;padding:16px;margin:16px 0;">
<p style="margin:0;color:#334155;"><strong>Email:</strong> ${data.email}</p>
<p style="margin:8px 0 0;color:#334155;"><strong>Contraseña temporal:</strong> ${data.tempPassword}</p>
</div>
<p style="color:#475569;line-height:1.6;">Te recomendamos cambiar tu contraseña después de iniciar sesión.</p>
<a href="https://horux360.consultoria-as.com/login" style="display:inline-block;background-color:#2563eb;color:#ffffff;padding:12px 24px;border-radius:6px;text-decoration:none;margin-top:16px;">Iniciar sesión</a>
`);
}

View File

@@ -1,8 +1,8 @@
import ExcelJS from 'exceljs';
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
export async function exportCfdisToExcel(
schema: string,
pool: Pool,
filters: { tipo?: string; estado?: string; fechaInicio?: string; fechaFin?: string }
): Promise<Buffer> {
let whereClause = 'WHERE 1=1';
@@ -26,15 +26,15 @@ export async function exportCfdisToExcel(
params.push(filters.fechaFin);
}
const cfdis = await prisma.$queryRawUnsafe<any[]>(`
const { rows: cfdis } = await pool.query(`
SELECT uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
moneda, metodo_pago, forma_pago, uso_cfdi, estado
FROM "${schema}".cfdis
FROM cfdis
${whereClause}
ORDER BY fecha_emision DESC
`, ...params);
`, params);
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('CFDIs');
@@ -63,7 +63,7 @@ export async function exportCfdisToExcel(
};
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
cfdis.forEach(cfdi => {
cfdis.forEach((cfdi: any) => {
sheet.addRow({
...cfdi,
fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'),
@@ -78,7 +78,7 @@ export async function exportCfdisToExcel(
}
export async function exportReporteToExcel(
schema: string,
pool: Pool,
tipo: 'estado-resultados' | 'flujo-efectivo',
fechaInicio: string,
fechaFin: string
@@ -87,13 +87,13 @@ export async function exportReporteToExcel(
const sheet = workbook.addWorksheet(tipo === 'estado-resultados' ? 'Estado de Resultados' : 'Flujo de Efectivo');
if (tipo === 'estado-resultados') {
const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number }]>(`
const { rows: [totales] } = await pool.query(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
`, fechaInicio, fechaFin);
`, [fechaInicio, fechaFin]);
sheet.columns = [
{ header: 'Concepto', key: 'concepto', width: 40 },

View File

@@ -1,6 +1,10 @@
import { Credential } from '@nodecfdi/credentials/node';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { prisma } from '../config/database.js';
import { encryptFielCredentials, decryptFielCredentials } from './sat/sat-crypto.service.js';
import { env } from '../config/env.js';
import { encryptFielCredentials, encrypt, decryptFielCredentials } from './sat/sat-crypto.service.js';
import { emailService } from './email/email.service.js';
import type { FielStatus } from '@horux/shared';
/**
@@ -58,13 +62,17 @@ export async function uploadFiel(
};
}
// Encriptar credenciales (todas juntas con el mismo IV/tag)
// Encriptar credenciales (per-component IV/tag)
const {
encryptedCer,
encryptedKey,
encryptedPassword,
iv,
tag,
cerIv,
cerTag,
keyIv,
keyTag,
passwordIv,
passwordTag,
} = encryptFielCredentials(cerData, keyData, password);
// Guardar o actualizar en BD
@@ -76,8 +84,12 @@ export async function uploadFiel(
cerData: encryptedCer,
keyData: encryptedKey,
keyPasswordEncrypted: encryptedPassword,
encryptionIv: iv,
encryptionTag: tag,
cerIv,
cerTag,
keyIv,
keyTag,
passwordIv,
passwordTag,
serialNumber,
validFrom,
validUntil,
@@ -88,8 +100,12 @@ export async function uploadFiel(
cerData: encryptedCer,
keyData: encryptedKey,
keyPasswordEncrypted: encryptedPassword,
encryptionIv: iv,
encryptionTag: tag,
cerIv,
cerTag,
keyIv,
keyTag,
passwordIv,
passwordTag,
serialNumber,
validFrom,
validUntil,
@@ -98,6 +114,49 @@ export async function uploadFiel(
},
});
// Save encrypted files to filesystem (dual storage)
try {
const fielDir = join(env.FIEL_STORAGE_PATH, rfc.toUpperCase());
await mkdir(fielDir, { recursive: true, mode: 0o700 });
// Re-encrypt for filesystem (independent keys from DB)
const fsEncrypted = encryptFielCredentials(cerData, keyData, password);
await writeFile(join(fielDir, 'certificate.cer.enc'), fsEncrypted.encryptedCer, { mode: 0o600 });
await writeFile(join(fielDir, 'certificate.cer.iv'), fsEncrypted.cerIv, { mode: 0o600 });
await writeFile(join(fielDir, 'certificate.cer.tag'), fsEncrypted.cerTag, { mode: 0o600 });
await writeFile(join(fielDir, 'private_key.key.enc'), fsEncrypted.encryptedKey, { mode: 0o600 });
await writeFile(join(fielDir, 'private_key.key.iv'), fsEncrypted.keyIv, { mode: 0o600 });
await writeFile(join(fielDir, 'private_key.key.tag'), fsEncrypted.keyTag, { mode: 0o600 });
// Encrypt and store metadata
const metadata = JSON.stringify({
serial: serialNumber,
validFrom: validFrom.toISOString(),
validUntil: validUntil.toISOString(),
uploadedAt: new Date().toISOString(),
rfc: rfc.toUpperCase(),
});
const metaEncrypted = encrypt(Buffer.from(metadata, 'utf-8'));
await writeFile(join(fielDir, 'metadata.json.enc'), metaEncrypted.encrypted, { mode: 0o600 });
await writeFile(join(fielDir, 'metadata.json.iv'), metaEncrypted.iv, { mode: 0o600 });
await writeFile(join(fielDir, 'metadata.json.tag'), metaEncrypted.tag, { mode: 0o600 });
} catch (fsError) {
console.error('[FIEL] Filesystem storage failed (DB storage OK):', fsError);
}
// Notify admin that client uploaded FIEL
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { nombre: true, rfc: true },
});
if (tenant) {
emailService.sendFielNotification({
clienteNombre: tenant.nombre,
clienteRfc: tenant.rfc,
}).catch(err => console.error('[EMAIL] FIEL notification failed:', err));
}
const daysUntilExpiration = Math.ceil(
(validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
@@ -198,13 +257,17 @@ export async function getDecryptedFiel(tenantId: string): Promise<{
}
try {
// Desencriptar todas las credenciales juntas
// Desencriptar credenciales (per-component IV/tag)
const { cerData, keyData, password } = decryptFielCredentials(
Buffer.from(fiel.cerData),
Buffer.from(fiel.keyData),
Buffer.from(fiel.keyPasswordEncrypted),
Buffer.from(fiel.encryptionIv),
Buffer.from(fiel.encryptionTag)
Buffer.from(fiel.cerIv),
Buffer.from(fiel.cerTag),
Buffer.from(fiel.keyIv),
Buffer.from(fiel.keyTag),
Buffer.from(fiel.passwordIv),
Buffer.from(fiel.passwordTag)
);
return {

View File

@@ -1,8 +1,8 @@
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';
export async function getIvaMensual(schema: string, año: number): Promise<IvaMensual[]> {
const data = await prisma.$queryRawUnsafe<IvaMensual[]>(`
export async function getIvaMensual(pool: Pool, año: number): Promise<IvaMensual[]> {
const { rows: data } = await pool.query(`
SELECT
id, año, mes,
iva_trasladado as "ivaTrasladado",
@@ -10,12 +10,12 @@ export async function getIvaMensual(schema: string, año: number): Promise<IvaMe
COALESCE(iva_retenido, 0) as "ivaRetenido",
resultado, acumulado, estado,
fecha_declaracion as "fechaDeclaracion"
FROM "${schema}".iva_mensual
FROM iva_mensual
WHERE año = $1
ORDER BY mes
`, año);
`, [año]);
return data.map(row => ({
return data.map((row: any) => ({
...row,
ivaTrasladado: Number(row.ivaTrasladado),
ivaAcreditable: Number(row.ivaAcreditable),
@@ -25,19 +25,18 @@ export async function getIvaMensual(schema: string, año: number): Promise<IvaMe
}));
}
export async function getResumenIva(schema: string, año: number, mes: number): Promise<ResumenIva> {
// Get from iva_mensual if exists
const existing = await prisma.$queryRawUnsafe<any[]>(`
SELECT * FROM "${schema}".iva_mensual WHERE año = $1 AND mes = $2
`, año, mes);
export async function getResumenIva(pool: Pool, año: number, mes: number): Promise<ResumenIva> {
const { rows: existing } = await pool.query(`
SELECT * FROM iva_mensual WHERE año = $1 AND mes = $2
`, [año, mes]);
if (existing && existing.length > 0) {
const record = existing[0];
const [acumuladoResult] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
const { rows: [acumuladoResult] } = await pool.query(`
SELECT COALESCE(SUM(resultado), 0) as total
FROM "${schema}".iva_mensual
FROM iva_mensual
WHERE año = $1 AND mes <= $2
`, año, mes);
`, [año, mes]);
return {
trasladado: Number(record.iva_trasladado || 0),
@@ -48,21 +47,16 @@ export async function getResumenIva(schema: string, año: number, mes: number):
};
}
// Calculate from CFDIs if no iva_mensual record
const [calcResult] = await prisma.$queryRawUnsafe<[{
trasladado: number;
acreditable: number;
retenido: number;
}]>(`
const { rows: [calcResult] } = await pool.query(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable,
COALESCE(SUM(iva_retenido), 0) as retenido
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
`, [año, mes]);
const trasladado = Number(calcResult?.trasladado || 0);
const acreditable = Number(calcResult?.acreditable || 0);
@@ -78,10 +72,9 @@ export async function getResumenIva(schema: string, año: number, mes: number):
};
}
export async function getIsrMensual(schema: string, año: number): Promise<IsrMensual[]> {
// Check if isr_mensual table exists
export async function getIsrMensual(pool: Pool, año: number): Promise<IsrMensual[]> {
try {
const data = await prisma.$queryRawUnsafe<IsrMensual[]>(`
const { rows: data } = await pool.query(`
SELECT
id, año, mes,
ingresos_acumulados as "ingresosAcumulados",
@@ -92,12 +85,12 @@ export async function getIsrMensual(schema: string, año: number): Promise<IsrMe
isr_a_pagar as "isrAPagar",
estado,
fecha_declaracion as "fechaDeclaracion"
FROM "${schema}".isr_mensual
FROM isr_mensual
WHERE año = $1
ORDER BY mes
`, año);
`, [año]);
return data.map(row => ({
return data.map((row: any) => ({
...row,
ingresosAcumulados: Number(row.ingresosAcumulados),
deducciones: Number(row.deducciones),
@@ -107,43 +100,40 @@ export async function getIsrMensual(schema: string, año: number): Promise<IsrMe
isrAPagar: Number(row.isrAPagar),
}));
} catch {
// Table doesn't exist, return empty array
return [];
}
}
export async function getResumenIsr(schema: string, año: number, mes: number): Promise<ResumenIsr> {
// Calculate from CFDIs
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
export async function getResumenIsr(pool: Pool, año: number, mes: number): Promise<ResumenIsr> {
const { rows: [ingresos] } = await pool.query(`
SELECT COALESCE(SUM(total), 0) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) <= $2
`, año, mes);
`, [año, mes]);
const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
const { rows: [egresos] } = await pool.query(`
SELECT COALESCE(SUM(total), 0) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) <= $2
`, año, mes);
`, [año, mes]);
const [retenido] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
const { rows: [retenido] } = await pool.query(`
SELECT COALESCE(SUM(isr_retenido), 0) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) <= $2
`, año, mes);
`, [año, mes]);
const ingresosAcumulados = Number(ingresos?.total || 0);
const deducciones = Number(egresos?.total || 0);
const baseGravable = Math.max(0, ingresosAcumulados - deducciones);
// Simplified ISR calculation (actual calculation would use SAT tables)
const isrCausado = baseGravable * 0.30; // 30% simplified rate
const isrCausado = baseGravable * 0.30;
const isrRetenido = Number(retenido?.total || 0);
const isrAPagar = Math.max(0, isrCausado - isrRetenido);

View File

@@ -0,0 +1,106 @@
import { MercadoPagoConfig, PreApproval, Payment as MPPayment } from 'mercadopago';
import { env } from '../../config/env.js';
import { createHmac } from 'crypto';
const config = new MercadoPagoConfig({
accessToken: env.MP_ACCESS_TOKEN || '',
});
const preApprovalClient = new PreApproval(config);
const paymentClient = new MPPayment(config);
/**
* Creates a recurring subscription (preapproval) in MercadoPago
*/
export async function createPreapproval(params: {
tenantId: string;
reason: string;
amount: number;
payerEmail: string;
}) {
const response = await preApprovalClient.create({
body: {
reason: params.reason,
external_reference: params.tenantId,
payer_email: params.payerEmail,
auto_recurring: {
frequency: 1,
frequency_type: 'months',
transaction_amount: params.amount,
currency_id: 'MXN',
},
back_url: `${env.FRONTEND_URL}/configuracion/suscripcion`,
},
});
return {
preapprovalId: response.id!,
initPoint: response.init_point!,
status: response.status!,
};
}
/**
* Gets subscription (preapproval) status from MercadoPago
*/
export async function getPreapproval(preapprovalId: string) {
const response = await preApprovalClient.get({ id: preapprovalId });
return {
id: response.id,
status: response.status,
payerEmail: response.payer_email,
nextPaymentDate: response.next_payment_date,
autoRecurring: response.auto_recurring,
};
}
/**
* Gets payment details from MercadoPago
*/
export async function getPaymentDetails(paymentId: string) {
const response = await paymentClient.get({ id: paymentId });
return {
id: response.id,
status: response.status,
statusDetail: response.status_detail,
transactionAmount: response.transaction_amount,
currencyId: response.currency_id,
payerEmail: response.payer?.email,
dateApproved: response.date_approved,
paymentMethodId: response.payment_method_id,
externalReference: response.external_reference,
};
}
/**
* Verifies MercadoPago webhook signature (HMAC-SHA256)
*/
export function verifyWebhookSignature(
xSignature: string,
xRequestId: string,
dataId: string
): boolean {
if (!env.MP_WEBHOOK_SECRET) {
console.error('[WEBHOOK] MP_WEBHOOK_SECRET not configured - rejecting webhook');
return false;
}
// Parse x-signature header: "ts=...,v1=..."
const parts: Record<string, string> = {};
for (const part of xSignature.split(',')) {
const [key, value] = part.split('=');
parts[key.trim()] = value.trim();
}
const ts = parts['ts'];
const v1 = parts['v1'];
if (!ts || !v1) return false;
// Build the manifest string
const manifest = `id:${dataId};request-id:${xRequestId};ts:${ts};`;
const hmac = createHmac('sha256', env.MP_WEBHOOK_SECRET)
.update(manifest)
.digest('hex');
return hmac === v1;
}

View File

@@ -0,0 +1,232 @@
import { prisma } from '../../config/database.js';
import * as mpService from './mercadopago.service.js';
import { emailService } from '../email/email.service.js';
// Simple in-memory cache with TTL
const subscriptionCache = new Map<string, { data: any; expires: number }>();
export function invalidateSubscriptionCache(tenantId: string) {
subscriptionCache.delete(`sub:${tenantId}`);
}
/**
* Creates a subscription record in DB and a MercadoPago preapproval
*/
export async function createSubscription(params: {
tenantId: string;
plan: string;
amount: number;
payerEmail: string;
}) {
const tenant = await prisma.tenant.findUnique({
where: { id: params.tenantId },
});
if (!tenant) throw new Error('Tenant no encontrado');
// Create MercadoPago preapproval
const mp = await mpService.createPreapproval({
tenantId: params.tenantId,
reason: `Horux360 - Plan ${params.plan} - ${tenant.nombre}`,
amount: params.amount,
payerEmail: params.payerEmail,
});
// Create DB record
const subscription = await prisma.subscription.create({
data: {
tenantId: params.tenantId,
plan: params.plan as any,
status: mp.status || 'pending',
amount: params.amount,
frequency: 'monthly',
mpPreapprovalId: mp.preapprovalId,
},
});
invalidateSubscriptionCache(params.tenantId);
return {
subscription,
paymentUrl: mp.initPoint,
};
}
/**
* Gets active subscription for a tenant (cached 5 min)
*/
export async function getActiveSubscription(tenantId: string) {
const cached = subscriptionCache.get(`sub:${tenantId}`);
if (cached && cached.expires > Date.now()) return cached.data;
const subscription = await prisma.subscription.findFirst({
where: { tenantId },
orderBy: { createdAt: 'desc' },
});
subscriptionCache.set(`sub:${tenantId}`, {
data: subscription,
expires: Date.now() + 5 * 60 * 1000,
});
return subscription;
}
/**
* Updates subscription status from webhook notification
*/
export async function updateSubscriptionStatus(mpPreapprovalId: string, status: string) {
const subscription = await prisma.subscription.findFirst({
where: { mpPreapprovalId },
});
if (!subscription) return null;
const updated = await prisma.subscription.update({
where: { id: subscription.id },
data: { status },
});
invalidateSubscriptionCache(subscription.tenantId);
// Handle cancellation
if (status === 'cancelled') {
const tenant = await prisma.tenant.findUnique({
where: { id: subscription.tenantId },
include: { users: { where: { role: 'admin' }, take: 1 } },
});
if (tenant && tenant.users[0]) {
emailService.sendSubscriptionCancelled(tenant.users[0].email, {
nombre: tenant.nombre,
plan: subscription.plan,
}).catch(err => console.error('[EMAIL] Subscription cancelled notification failed:', err));
}
}
return updated;
}
/**
* Records a payment from MercadoPago webhook
*/
export async function recordPayment(params: {
tenantId: string;
subscriptionId: string;
mpPaymentId: string;
amount: number;
status: string;
paymentMethod: string;
}) {
const payment = await prisma.payment.create({
data: {
tenantId: params.tenantId,
subscriptionId: params.subscriptionId,
mpPaymentId: params.mpPaymentId,
amount: params.amount,
status: params.status,
paymentMethod: params.paymentMethod,
},
});
// Send email notifications based on payment status
const tenant = await prisma.tenant.findUnique({
where: { id: params.tenantId },
include: { users: { where: { role: 'admin' }, take: 1 } },
});
if (tenant && tenant.users[0]) {
const subscription = await prisma.subscription.findUnique({
where: { id: params.subscriptionId },
});
if (params.status === 'approved') {
emailService.sendPaymentConfirmed(tenant.users[0].email, {
nombre: tenant.nombre,
amount: params.amount,
plan: subscription?.plan || 'N/A',
date: new Date().toLocaleDateString('es-MX'),
}).catch(err => console.error('[EMAIL] Payment confirmed notification failed:', err));
} else if (params.status === 'rejected') {
emailService.sendPaymentFailed(tenant.users[0].email, {
nombre: tenant.nombre,
amount: params.amount,
plan: subscription?.plan || 'N/A',
}).catch(err => console.error('[EMAIL] Payment failed notification failed:', err));
}
}
return payment;
}
/**
* Manually marks a subscription as paid (for bank transfers)
*/
export async function markAsPaidManually(tenantId: string, amount: number) {
const subscription = await getActiveSubscription(tenantId);
if (!subscription) throw new Error('No hay suscripción activa');
// Update subscription status
await prisma.subscription.update({
where: { id: subscription.id },
data: { status: 'authorized' },
});
// Record the manual payment
const payment = await prisma.payment.create({
data: {
tenantId,
subscriptionId: subscription.id,
mpPaymentId: `manual-${Date.now()}`,
amount,
status: 'approved',
paymentMethod: 'bank_transfer',
},
});
invalidateSubscriptionCache(tenantId);
return payment;
}
/**
* Generates a payment link for a tenant
*/
export async function generatePaymentLink(tenantId: string) {
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
include: { users: { where: { role: 'admin' }, take: 1 } },
});
if (!tenant) throw new Error('Tenant no encontrado');
if (!tenant.users[0]) throw new Error('No admin user found');
const subscription = await getActiveSubscription(tenantId);
const plan = subscription?.plan || tenant.plan;
const amount = subscription?.amount || 0;
if (!amount) throw new Error('No se encontró monto de suscripción');
const mp = await mpService.createPreapproval({
tenantId,
reason: `Horux360 - Plan ${plan} - ${tenant.nombre}`,
amount,
payerEmail: tenant.users[0].email,
});
// Update subscription with new MP preapproval ID
if (subscription) {
await prisma.subscription.update({
where: { id: subscription.id },
data: { mpPreapprovalId: mp.preapprovalId },
});
}
return { paymentUrl: mp.initPoint };
}
/**
* Gets payment history for a tenant
*/
export async function getPaymentHistory(tenantId: string) {
return prisma.payment.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
take: 50,
});
}

View File

@@ -1,7 +1,6 @@
import { prisma } from '../config/database.js';
import type { Pool } from 'pg';
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
// Helper to convert Prisma Decimal/BigInt to number
function toNumber(value: unknown): number {
if (value === null || value === undefined) return 0;
if (typeof value === 'number') return value;
@@ -14,37 +13,37 @@ function toNumber(value: unknown): number {
}
export async function getEstadoResultados(
schema: string,
pool: Pool,
fechaInicio: string,
fechaFin: string
): Promise<EstadoResultados> {
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
const { rows: ingresos } = await pool.query(`
SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY rfc_receptor, nombre_receptor
ORDER BY total DESC LIMIT 10
`, fechaInicio, fechaFin);
`, [fechaInicio, fechaFin]);
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
const { rows: egresos } = await pool.query(`
SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY rfc_emisor, nombre_emisor
ORDER BY total DESC LIMIT 10
`, fechaInicio, fechaFin);
`, [fechaInicio, fechaFin]);
const totalesResult = await prisma.$queryRawUnsafe<{ ingresos: unknown; egresos: unknown; iva: unknown }[]>(`
const { rows: totalesResult } = await pool.query(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1::date AND $2::date
`, fechaInicio, fechaFin);
`, [fechaInicio, fechaFin]);
const totales = totalesResult[0];
const totalIngresos = toNumber(totales?.ingresos);
@@ -54,8 +53,8 @@ export async function getEstadoResultados(
return {
periodo: { inicio: fechaInicio, fin: fechaFin },
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: toNumber(i.total) })),
egresos: egresos.map(e => ({ concepto: e.nombre, monto: toNumber(e.total) })),
ingresos: ingresos.map((i: any) => ({ concepto: i.nombre, monto: toNumber(i.total) })),
egresos: egresos.map((e: any) => ({ concepto: e.nombre, monto: toNumber(e.total) })),
totalIngresos,
totalEgresos,
utilidadBruta,
@@ -65,36 +64,36 @@ export async function getEstadoResultados(
}
export async function getFlujoEfectivo(
schema: string,
pool: Pool,
fechaInicio: string,
fechaFin: string
): Promise<FlujoEfectivo> {
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
const { rows: entradas } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
ORDER BY mes
`, fechaInicio, fechaFin);
`, [fechaInicio, fechaFin]);
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
const { rows: salidas } = await pool.query(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
ORDER BY mes
`, fechaInicio, fechaFin);
`, [fechaInicio, fechaFin]);
const totalEntradas = entradas.reduce((sum, e) => sum + toNumber(e.total), 0);
const totalSalidas = salidas.reduce((sum, s) => sum + toNumber(s.total), 0);
const totalEntradas = entradas.reduce((sum: number, e: any) => sum + toNumber(e.total), 0);
const totalSalidas = salidas.reduce((sum: number, s: any) => sum + toNumber(s.total), 0);
return {
periodo: { inicio: fechaInicio, fin: fechaFin },
saldoInicial: 0,
entradas: entradas.map(e => ({ concepto: e.mes, monto: toNumber(e.total) })),
salidas: salidas.map(s => ({ concepto: s.mes, monto: toNumber(s.total) })),
entradas: entradas.map((e: any) => ({ concepto: e.mes, monto: toNumber(e.total) })),
salidas: salidas.map((s: any) => ({ concepto: s.mes, monto: toNumber(s.total) })),
totalEntradas,
totalSalidas,
flujoNeto: totalEntradas - totalSalidas,
@@ -103,36 +102,36 @@ export async function getFlujoEfectivo(
}
export async function getComparativo(
schema: string,
pool: Pool,
año: number
): Promise<ComparativoPeriodos> {
const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
const { rows: actual } = await pool.query(`
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
GROUP BY mes ORDER BY mes
`, año);
`, [año]);
const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
const { rows: anterior } = await pool.query(`
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
FROM "${schema}".cfdis
FROM cfdis
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
GROUP BY mes ORDER BY mes
`, año - 1);
`, [año - 1]);
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
const ingresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.ingresos));
const egresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.egresos));
const ingresos = meses.map((_, i) => toNumber(actual.find((a: any) => a.mes === i + 1)?.ingresos));
const egresos = meses.map((_, i) => toNumber(actual.find((a: any) => a.mes === i + 1)?.egresos));
const utilidad = ingresos.map((ing, i) => ing - egresos[i]);
const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
const totalAnteriorIng = anterior.reduce((a, b) => a + toNumber(b.ingresos), 0);
const totalAnteriorIng = anterior.reduce((a: number, b: any) => a + toNumber(b.ingresos), 0);
const totalActualEgr = egresos.reduce((a, b) => a + b, 0);
const totalAnteriorEgr = anterior.reduce((a, b) => a + toNumber(b.egresos), 0);
const totalAnteriorEgr = anterior.reduce((a: number, b: any) => a + toNumber(b.egresos), 0);
return {
periodos: meses,
@@ -146,25 +145,25 @@ export async function getComparativo(
}
export async function getConcentradoRfc(
schema: string,
pool: Pool,
fechaInicio: string,
fechaFin: string,
tipo: 'cliente' | 'proveedor'
): Promise<ConcentradoRfc[]> {
if (tipo === 'cliente') {
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
const { rows: data } = await pool.query(`
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
'cliente' as tipo,
SUM(total) as "totalFacturado",
SUM(iva) as "totalIva",
COUNT(*)::int as "cantidadCfdis"
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY rfc_receptor, nombre_receptor
ORDER BY "totalFacturado" DESC
`, fechaInicio, fechaFin);
return data.map(d => ({
`, [fechaInicio, fechaFin]);
return data.map((d: any) => ({
rfc: d.rfc,
nombre: d.nombre,
tipo: 'cliente' as const,
@@ -173,19 +172,19 @@ export async function getConcentradoRfc(
cantidadCfdis: d.cantidadCfdis
}));
} else {
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
const { rows: data } = await pool.query(`
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
'proveedor' as tipo,
SUM(total) as "totalFacturado",
SUM(iva) as "totalIva",
COUNT(*)::int as "cantidadCfdis"
FROM "${schema}".cfdis
FROM cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY rfc_emisor, nombre_emisor
ORDER BY "totalFacturado" DESC
`, fechaInicio, fechaFin);
return data.map(d => ({
`, [fechaInicio, fechaFin]);
return data.map((d: any) => ({
rfc: d.rfc,
nombre: d.nombre,
tipo: 'proveedor' as const,

View File

@@ -6,10 +6,10 @@ const IV_LENGTH = 16;
const TAG_LENGTH = 16;
/**
* Deriva una clave de 256 bits del JWT_SECRET
* Deriva una clave de 256 bits del FIEL_ENCRYPTION_KEY
*/
function deriveKey(): Buffer {
return createHash('sha256').update(env.JWT_SECRET).digest();
return createHash('sha256').update(env.FIEL_ENCRYPTION_KEY).digest();
}
/**
@@ -52,7 +52,7 @@ export function decryptToString(encrypted: Buffer, iv: Buffer, tag: Buffer): str
}
/**
* Encripta credenciales FIEL (cer, key, password)
* Encripta credenciales FIEL con IV/tag independiente por componente
*/
export function encryptFielCredentials(
cerData: Buffer,
@@ -62,61 +62,51 @@ export function encryptFielCredentials(
encryptedCer: Buffer;
encryptedKey: Buffer;
encryptedPassword: Buffer;
iv: Buffer;
tag: Buffer;
cerIv: Buffer;
cerTag: Buffer;
keyIv: Buffer;
keyTag: Buffer;
passwordIv: Buffer;
passwordTag: Buffer;
} {
// Usamos el mismo IV y tag para simplificar, concatenando los datos
const combined = Buffer.concat([
Buffer.from(cerData.length.toString().padStart(10, '0')),
cerData,
Buffer.from(keyData.length.toString().padStart(10, '0')),
keyData,
Buffer.from(password, 'utf-8'),
]);
const { encrypted, iv, tag } = encrypt(combined);
// Extraemos las partes encriptadas
const cerLength = cerData.length;
const keyLength = keyData.length;
const passwordLength = Buffer.from(password, 'utf-8').length;
const cer = encrypt(cerData);
const key = encrypt(keyData);
const pwd = encrypt(Buffer.from(password, 'utf-8'));
return {
encryptedCer: encrypted.subarray(0, 10 + cerLength),
encryptedKey: encrypted.subarray(10 + cerLength, 20 + cerLength + keyLength),
encryptedPassword: encrypted.subarray(20 + cerLength + keyLength),
iv,
tag,
encryptedCer: cer.encrypted,
encryptedKey: key.encrypted,
encryptedPassword: pwd.encrypted,
cerIv: cer.iv,
cerTag: cer.tag,
keyIv: key.iv,
keyTag: key.tag,
passwordIv: pwd.iv,
passwordTag: pwd.tag,
};
}
/**
* Desencripta credenciales FIEL
* Desencripta credenciales FIEL (per-component IV/tag)
*/
export function decryptFielCredentials(
encryptedCer: Buffer,
encryptedKey: Buffer,
encryptedPassword: Buffer,
iv: Buffer,
tag: Buffer
cerIv: Buffer,
cerTag: Buffer,
keyIv: Buffer,
keyTag: Buffer,
passwordIv: Buffer,
passwordTag: Buffer
): {
cerData: Buffer;
keyData: Buffer;
password: string;
} {
const combined = Buffer.concat([encryptedCer, encryptedKey, encryptedPassword]);
const decrypted = decrypt(combined, iv, tag);
// Parseamos las partes
const cerLengthStr = decrypted.subarray(0, 10).toString();
const cerLength = parseInt(cerLengthStr, 10);
const cerData = decrypted.subarray(10, 10 + cerLength);
const keyLengthStr = decrypted.subarray(10 + cerLength, 20 + cerLength).toString();
const keyLength = parseInt(keyLengthStr, 10);
const keyData = decrypted.subarray(20 + cerLength, 20 + cerLength + keyLength);
const password = decrypted.subarray(20 + cerLength + keyLength).toString('utf-8');
const cerData = decrypt(encryptedCer, cerIv, cerTag);
const keyData = decrypt(encryptedKey, keyIv, keyTag);
const password = decrypt(encryptedPassword, passwordIv, passwordTag).toString('utf-8');
return { cerData, keyData, password };
}

View File

@@ -1,4 +1,4 @@
import { prisma } from '../../config/database.js';
import { prisma, tenantDb } from '../../config/database.js';
import { getDecryptedFiel } from '../fiel.service.js';
import {
createSatService,
@@ -10,6 +10,7 @@ import {
import { processPackage, type CfdiParsed } from './sat-parser.service.js';
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
import type { Service } from '@nodecfdi/sat-ws-descarga-masiva';
import type { Pool } from 'pg';
const POLL_INTERVAL_MS = 30000; // 30 segundos
const MAX_POLL_ATTEMPTS = 60; // 30 minutos máximo
@@ -20,7 +21,7 @@ interface SyncContext {
service: Service;
rfc: string;
tenantId: string;
schemaName: string;
pool: Pool;
}
/**
@@ -54,7 +55,7 @@ async function updateJobProgress(
* Guarda los CFDIs en la base de datos del tenant
*/
async function saveCfdis(
schemaName: string,
pool: Pool,
cfdis: CfdiParsed[],
jobId: string
): Promise<{ inserted: number; updated: number }> {
@@ -63,16 +64,14 @@ async function saveCfdis(
for (const cfdi of cfdis) {
try {
// Usar raw query para el esquema del tenant
const existing = await prisma.$queryRawUnsafe<{ id: string }[]>(
`SELECT id FROM "${schemaName}".cfdis WHERE uuid_fiscal = $1`,
cfdi.uuidFiscal
const { rows: existing } = await pool.query(
`SELECT id FROM cfdis WHERE uuid_fiscal = $1`,
[cfdi.uuidFiscal]
);
if (existing.length > 0) {
// Actualizar CFDI existente
await prisma.$executeRawUnsafe(
`UPDATE "${schemaName}".cfdis SET
await pool.query(
`UPDATE cfdis SET
tipo = $2,
serie = $3,
folio = $4,
@@ -99,6 +98,7 @@ async function saveCfdis(
sat_sync_job_id = $24::uuid,
updated_at = NOW()
WHERE uuid_fiscal = $1`,
[
cfdi.uuidFiscal,
cfdi.tipo,
cfdi.serie,
@@ -123,12 +123,12 @@ async function saveCfdis(
cfdi.estado,
cfdi.xmlOriginal,
jobId
]
);
updated++;
} else {
// Insertar nuevo CFDI
await prisma.$executeRawUnsafe(
`INSERT INTO "${schemaName}".cfdis (
await pool.query(
`INSERT INTO cfdis (
id, uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
@@ -139,6 +139,7 @@ async function saveCfdis(
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22,
$23, 'sat', $24::uuid, NOW(), NOW()
)`,
[
cfdi.uuidFiscal,
cfdi.tipo,
cfdi.serie,
@@ -163,6 +164,7 @@ async function saveCfdis(
cfdi.estado,
cfdi.xmlOriginal,
jobId
]
);
inserted++;
}
@@ -186,11 +188,9 @@ async function processDateRange(
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
console.log(`[SAT] Procesando ${tipoCfdi} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
// 1. Solicitar descarga
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi);
if (!queryResult.success) {
// Código 5004 = No hay CFDIs en el rango
if (queryResult.statusCode === '5004') {
console.log('[SAT] No se encontraron CFDIs en el rango');
return { found: 0, downloaded: 0, inserted: 0, updated: 0 };
@@ -203,7 +203,6 @@ async function processDateRange(
await updateJobProgress(jobId, { satRequestId: requestId });
// 2. Esperar y verificar solicitud
let verifyResult;
let attempts = 0;
@@ -227,7 +226,6 @@ async function processDateRange(
throw new Error('Timeout esperando respuesta del SAT');
}
// 3. Descargar paquetes
const packageIds = verifyResult.packageIds;
await updateJobProgress(jobId, {
satPackageIds: packageIds,
@@ -249,17 +247,15 @@ async function processDateRange(
continue;
}
// 4. Procesar paquete (el contenido viene en base64)
const cfdis = processPackage(downloadResult.packageContent);
totalDownloaded += cfdis.length;
console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`);
const { inserted, updated } = await saveCfdis(ctx.schemaName, cfdis, jobId);
const { inserted, updated } = await saveCfdis(ctx.pool, cfdis, jobId);
totalInserted += inserted;
totalUpdated += updated;
// Actualizar progreso
const progress = Math.round(((i + 1) / packageIds.length) * 100);
await updateJobProgress(jobId, {
cfdisDownloaded: totalDownloaded,
@@ -287,7 +283,6 @@ async function processInitialSync(
customDateTo?: Date
): Promise<void> {
const ahora = new Date();
// Usar fechas personalizadas si se proporcionan, sino calcular desde YEARS_TO_SYNC
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
const fechaFin = customDateTo || ahora;
@@ -296,14 +291,12 @@ async function processInitialSync(
let totalInserted = 0;
let totalUpdated = 0;
// Procesar por meses para evitar límites del SAT
let currentDate = new Date(inicioHistorico);
while (currentDate < fechaFin) {
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
const rangeEnd = monthEnd > fechaFin ? fechaFin : monthEnd;
// Procesar emitidos
try {
const emitidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'emitidos');
totalFound += emitidos.found;
@@ -314,7 +307,6 @@ async function processInitialSync(
console.error(`[SAT] Error procesando emitidos ${currentDate.toISOString()}:`, error.message);
}
// Procesar recibidos
try {
const recibidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'recibidos');
totalFound += recibidos.found;
@@ -325,10 +317,8 @@ async function processInitialSync(
console.error(`[SAT] Error procesando recibidos ${currentDate.toISOString()}:`, error.message);
}
// Siguiente mes
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
// Pequeña pausa entre meses para no saturar el SAT
await new Promise(resolve => setTimeout(resolve, 5000));
}
@@ -352,7 +342,6 @@ async function processDailySync(ctx: SyncContext, jobId: string): Promise<void>
let totalInserted = 0;
let totalUpdated = 0;
// Procesar emitidos del mes
try {
const emitidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'emitidos');
totalFound += emitidos.found;
@@ -363,7 +352,6 @@ async function processDailySync(ctx: SyncContext, jobId: string): Promise<void>
console.error('[SAT] Error procesando emitidos:', error.message);
}
// Procesar recibidos del mes
try {
const recibidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'recibidos');
totalFound += recibidos.found;
@@ -391,7 +379,6 @@ export async function startSync(
dateFrom?: Date,
dateTo?: Date
): Promise<string> {
// Obtener credenciales FIEL
const decryptedFiel = await getDecryptedFiel(tenantId);
if (!decryptedFiel) {
throw new Error('No hay FIEL configurada o está vencida');
@@ -403,20 +390,17 @@ export async function startSync(
password: decryptedFiel.password,
};
// Crear servicio SAT
const service = createSatService(fielData);
// Obtener datos del tenant
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { schemaName: true },
select: { databaseName: true },
});
if (!tenant) {
throw new Error('Tenant no encontrado');
}
// Verificar que no haya sync activo
const activeSync = await prisma.satSyncJob.findFirst({
where: {
tenantId,
@@ -428,7 +412,6 @@ export async function startSync(
throw new Error('Ya hay una sincronización en curso');
}
// Crear job
const now = new Date();
const job = await prisma.satSyncJob.create({
data: {
@@ -446,7 +429,7 @@ export async function startSync(
service,
rfc: decryptedFiel.rfc,
tenantId,
schemaName: tenant.schemaName,
pool: tenantDb.getPool(tenantId, tenant.databaseName),
};
// Ejecutar sincronización en background

View File

@@ -1,4 +1,8 @@
import { prisma } from '../config/database.js';
import { prisma, tenantDb } from '../config/database.js';
import { PLANS } from '@horux/shared';
import { emailService } from './email/email.service.js';
import { randomBytes } from 'crypto';
import bcrypt from 'bcryptjs';
export async function getAllTenants() {
return prisma.tenant.findMany({
@@ -8,7 +12,7 @@ export async function getAllTenants() {
nombre: true,
rfc: true,
plan: true,
schemaName: true,
databaseName: true,
createdAt: true,
_count: {
select: { users: true }
@@ -26,7 +30,7 @@ export async function getTenantById(id: string) {
nombre: true,
rfc: true,
plan: true,
schemaName: true,
databaseName: true,
cfdiLimit: true,
usersLimit: true,
createdAt: true,
@@ -40,104 +44,72 @@ export async function createTenant(data: {
plan?: 'starter' | 'business' | 'professional' | 'enterprise';
cfdiLimit?: number;
usersLimit?: number;
adminEmail: string;
adminNombre: string;
amount: number;
}) {
const schemaName = `tenant_${data.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
const plan = data.plan || 'starter';
const planConfig = PLANS[plan];
// Create tenant record
// 1. Provision a dedicated database for this tenant
const databaseName = await tenantDb.provisionDatabase(data.rfc);
// 2. Create tenant record
const tenant = await prisma.tenant.create({
data: {
nombre: data.nombre,
rfc: data.rfc.toUpperCase(),
plan: data.plan || 'starter',
schemaName,
cfdiLimit: data.cfdiLimit || 500,
usersLimit: data.usersLimit || 3,
plan,
databaseName,
cfdiLimit: data.cfdiLimit || planConfig.cfdiLimit,
usersLimit: data.usersLimit || planConfig.usersLimit,
}
});
// Create schema and tables for the tenant
await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
// 3. Create admin user with temp password
const tempPassword = randomBytes(4).toString('hex'); // 8-char random
const hashedPassword = await bcrypt.hash(tempPassword, 10);
// Create CFDIs table
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."cfdis" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
tipo VARCHAR(20) NOT NULL,
serie VARCHAR(25),
folio VARCHAR(40),
fecha_emision TIMESTAMP NOT NULL,
fecha_timbrado TIMESTAMP NOT NULL,
rfc_emisor VARCHAR(13) NOT NULL,
nombre_emisor VARCHAR(300) NOT NULL,
rfc_receptor VARCHAR(13) NOT NULL,
nombre_receptor VARCHAR(300) NOT NULL,
subtotal DECIMAL(18,2) NOT NULL,
descuento DECIMAL(18,2) DEFAULT 0,
iva DECIMAL(18,2) DEFAULT 0,
isr_retenido DECIMAL(18,2) DEFAULT 0,
iva_retenido DECIMAL(18,2) DEFAULT 0,
total DECIMAL(18,2) NOT NULL,
moneda VARCHAR(3) DEFAULT 'MXN',
tipo_cambio DECIMAL(10,4) DEFAULT 1,
metodo_pago VARCHAR(3),
forma_pago VARCHAR(2),
uso_cfdi VARCHAR(4),
estado VARCHAR(20) DEFAULT 'vigente',
xml_url TEXT,
pdf_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
const user = await prisma.user.create({
data: {
tenantId: tenant.id,
email: data.adminEmail,
passwordHash: hashedPassword,
nombre: data.adminNombre,
role: 'admin',
},
});
// Create IVA monthly table
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" (
id SERIAL PRIMARY KEY,
año INT NOT NULL,
mes INT NOT NULL,
iva_trasladado DECIMAL(18,2) NOT NULL,
iva_acreditable DECIMAL(18,2) NOT NULL,
iva_retenido DECIMAL(18,2) DEFAULT 0,
resultado DECIMAL(18,2) NOT NULL,
acumulado DECIMAL(18,2) NOT NULL,
estado VARCHAR(20) DEFAULT 'pendiente',
fecha_declaracion TIMESTAMP,
UNIQUE(año, mes)
)
`);
// 4. Create initial subscription
await prisma.subscription.create({
data: {
tenantId: tenant.id,
plan,
status: 'pending',
amount: data.amount,
frequency: 'monthly',
},
});
// Create alerts table
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" (
id SERIAL PRIMARY KEY,
tipo VARCHAR(50) NOT NULL,
titulo VARCHAR(200) NOT NULL,
mensaje TEXT NOT NULL,
prioridad VARCHAR(20) DEFAULT 'media',
fecha_vencimiento TIMESTAMP,
leida BOOLEAN DEFAULT FALSE,
resuelta BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// 5. Send welcome email to client (non-blocking)
emailService.sendWelcome(data.adminEmail, {
nombre: data.adminNombre,
email: data.adminEmail,
tempPassword,
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
// Create calendario_fiscal table
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" (
id SERIAL PRIMARY KEY,
titulo VARCHAR(200) NOT NULL,
descripcion TEXT,
tipo VARCHAR(20) NOT NULL,
fecha_limite TIMESTAMP NOT NULL,
recurrencia VARCHAR(20) DEFAULT 'mensual',
completado BOOLEAN DEFAULT FALSE,
notas TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
// 6. Send new client notification to admin with DB credentials
emailService.sendNewClientAdmin({
clienteNombre: data.nombre,
clienteRfc: data.rfc.toUpperCase(),
adminEmail: data.adminEmail,
adminNombre: data.adminNombre,
tempPassword,
databaseName,
plan,
}).catch(err => console.error('[EMAIL] New client admin email failed:', err));
return tenant;
return { tenant, user, tempPassword };
}
export async function updateTenant(id: string, data: {
@@ -163,7 +135,7 @@ export async function updateTenant(id: string, data: {
nombre: true,
rfc: true,
plan: true,
schemaName: true,
databaseName: true,
cfdiLimit: true,
usersLimit: true,
active: true,
@@ -173,9 +145,20 @@ export async function updateTenant(id: string, data: {
}
export async function deleteTenant(id: string) {
// Soft delete - just mark as inactive
return prisma.tenant.update({
const tenant = await prisma.tenant.findUnique({
where: { id },
select: { databaseName: true },
});
// Soft-delete the tenant record
await prisma.tenant.update({
where: { id },
data: { active: false }
});
// Soft-delete the database (rename with _deleted_ suffix)
if (tenant) {
await tenantDb.deprovisionDatabase(tenant.databaseName);
tenantDb.invalidatePool(id);
}
}

View File

@@ -1,5 +1,6 @@
import { prisma } from '../config/database.js';
import bcrypt from 'bcryptjs';
import { randomBytes } from 'crypto';
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';
export async function getUsuarios(tenantId: string): Promise<UserListItem[]> {
@@ -37,8 +38,8 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
throw new Error('Límite de usuarios alcanzado para este plan');
}
// Generate temporary password
const tempPassword = Math.random().toString(36).slice(-8);
// Generate cryptographically secure temporary password
const tempPassword = randomBytes(4).toString('hex');
const passwordHash = await bcrypt.hash(tempPassword, 12);
const user = await prisma.user.create({
@@ -60,8 +61,7 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
},
});
// In production, send email with tempPassword
console.log(`Temporary password for ${data.email}: ${tempPassword}`);
// TODO: Send email with tempPassword to the invited user
return {
...user,

View File

@@ -0,0 +1,31 @@
import { prisma } from '../config/database.js';
const ADMIN_TENANT_RFC = 'CAS2408138W2';
// Cache: tenantId -> { rfc, expires }
const rfcCache = new Map<string, { rfc: string; expires: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Checks if the given user belongs to the global admin tenant (CAS2408138W2).
* Uses an in-memory cache to avoid repeated DB lookups.
*/
export async function isGlobalAdmin(tenantId: string, role: string): Promise<boolean> {
if (role !== 'admin') return false;
const cached = rfcCache.get(tenantId);
if (cached && cached.expires > Date.now()) {
return cached.rfc === ADMIN_TENANT_RFC;
}
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { rfc: true },
});
if (tenant) {
rfcCache.set(tenantId, { rfc: tenant.rfc, expires: Date.now() + CACHE_TTL });
}
return tenant?.rfc === ADMIN_TENANT_RFC;
}

View File

@@ -1,104 +0,0 @@
import { prisma } from '../config/database.js';
export async function createTenantSchema(schemaName: string): Promise<void> {
await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."cfdis" (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
tipo VARCHAR(20) NOT NULL,
serie VARCHAR(25),
folio VARCHAR(40),
fecha_emision TIMESTAMP NOT NULL,
fecha_timbrado TIMESTAMP NOT NULL,
rfc_emisor VARCHAR(13) NOT NULL,
nombre_emisor VARCHAR(300) NOT NULL,
rfc_receptor VARCHAR(13) NOT NULL,
nombre_receptor VARCHAR(300) NOT NULL,
subtotal DECIMAL(18,2) NOT NULL,
descuento DECIMAL(18,2) DEFAULT 0,
iva DECIMAL(18,2) DEFAULT 0,
isr_retenido DECIMAL(18,2) DEFAULT 0,
iva_retenido DECIMAL(18,2) DEFAULT 0,
total DECIMAL(18,2) NOT NULL,
moneda VARCHAR(3) DEFAULT 'MXN',
tipo_cambio DECIMAL(10,4) DEFAULT 1,
metodo_pago VARCHAR(3),
forma_pago VARCHAR(2),
uso_cfdi VARCHAR(4),
estado VARCHAR(20) DEFAULT 'vigente',
xml_url TEXT,
pdf_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" (
id SERIAL PRIMARY KEY,
año INTEGER NOT NULL,
mes INTEGER NOT NULL,
iva_trasladado DECIMAL(18,2) NOT NULL,
iva_acreditable DECIMAL(18,2) NOT NULL,
iva_retenido DECIMAL(18,2) DEFAULT 0,
resultado DECIMAL(18,2) NOT NULL,
acumulado DECIMAL(18,2) NOT NULL,
estado VARCHAR(20) DEFAULT 'pendiente',
fecha_declaracion TIMESTAMP,
UNIQUE(año, mes)
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."isr_mensual" (
id SERIAL PRIMARY KEY,
año INTEGER NOT NULL,
mes INTEGER NOT NULL,
ingresos_acumulados DECIMAL(18,2) NOT NULL,
deducciones DECIMAL(18,2) NOT NULL,
base_gravable DECIMAL(18,2) NOT NULL,
isr_causado DECIMAL(18,2) NOT NULL,
isr_retenido DECIMAL(18,2) NOT NULL,
isr_a_pagar DECIMAL(18,2) NOT NULL,
estado VARCHAR(20) DEFAULT 'pendiente',
fecha_declaracion TIMESTAMP,
UNIQUE(año, mes)
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" (
id SERIAL PRIMARY KEY,
tipo VARCHAR(50) NOT NULL,
titulo VARCHAR(200) NOT NULL,
mensaje TEXT NOT NULL,
prioridad VARCHAR(20) DEFAULT 'media',
fecha_vencimiento TIMESTAMP,
leida BOOLEAN DEFAULT FALSE,
resuelta BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" (
id SERIAL PRIMARY KEY,
titulo VARCHAR(200) NOT NULL,
descripcion TEXT,
tipo VARCHAR(50) NOT NULL,
fecha_limite TIMESTAMP NOT NULL,
recurrencia VARCHAR(20) DEFAULT 'unica',
completado BOOLEAN DEFAULT FALSE,
notas TEXT
)
`);
}
export async function setTenantSchema(schemaName: string): Promise<void> {
await prisma.$executeRawUnsafe(`SET search_path TO "${schemaName}"`);
}
export async function deleteTenantSchema(schemaName: string): Promise<void> {
await prisma.$executeRawUnsafe(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
}

View File

@@ -0,0 +1,306 @@
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuthStore } from '@/stores/auth-store';
import {
useSubscription,
usePaymentHistory,
useGeneratePaymentLink,
} from '@/lib/hooks/use-subscription';
import {
CreditCard,
Calendar,
CheckCircle,
AlertCircle,
Clock,
XCircle,
ExternalLink,
Loader2,
AlertTriangle,
CalendarClock,
} from 'lucide-react';
const statusConfig: Record<string, { label: string; color: string; bgColor: string; icon: typeof CheckCircle }> = {
authorized: { label: 'Activa', color: 'text-green-700', bgColor: 'bg-green-50 border-green-200', icon: CheckCircle },
pending: { label: 'Pendiente de pago', color: 'text-yellow-700', bgColor: 'bg-yellow-50 border-yellow-200', icon: Clock },
paused: { label: 'Pausada', color: 'text-orange-700', bgColor: 'bg-orange-50 border-orange-200', icon: AlertCircle },
cancelled: { label: 'Cancelada', color: 'text-red-700', bgColor: 'bg-red-50 border-red-200', icon: XCircle },
};
function getDaysUntil(dateStr: string | null): number | null {
if (!dateStr) return null;
const diff = new Date(dateStr).getTime() - Date.now();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
export default function SuscripcionPage() {
const { user } = useAuthStore();
const { data: subscription, isLoading } = useSubscription(user?.tenantId);
const { data: payments } = usePaymentHistory(user?.tenantId);
const generateLink = useGeneratePaymentLink();
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
const status = statusConfig[subscription?.status || ''] || statusConfig.pending;
const StatusIcon = status.icon;
const daysUntilEnd = getDaysUntil(subscription?.currentPeriodEnd ?? null);
const isExpired = daysUntilEnd !== null && daysUntilEnd <= 0;
const isExpiringSoon = daysUntilEnd !== null && daysUntilEnd > 0 && daysUntilEnd <= 5;
const needsPayment = subscription?.status === 'pending' || isExpired;
const handleGenerateLink = async () => {
if (!user?.tenantId) return;
try {
const result = await generateLink.mutateAsync(user.tenantId);
setPaymentUrl(result.paymentUrl);
window.open(result.paymentUrl, '_blank');
} catch {
// error handled by mutation state
}
};
return (
<>
<Header title="Suscripción" />
<main className="p-6 space-y-6">
{/* Warning banner: expired */}
{!isLoading && subscription && isExpired && (
<div className="flex items-start gap-3 rounded-lg border border-red-300 bg-red-50 p-4">
<AlertTriangle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-red-800">Tu suscripción ha vencido</p>
<p className="text-sm text-red-700 mt-1">
Tu período de facturación terminó el {formatDate(subscription.currentPeriodEnd)}.
Realiza tu pago para continuar usando todas las funciones de Horux360.
</p>
</div>
</div>
)}
{/* Warning banner: expiring soon */}
{!isLoading && subscription && isExpiringSoon && !isExpired && (
<div className="flex items-start gap-3 rounded-lg border border-yellow-300 bg-yellow-50 p-4">
<AlertTriangle className="h-5 w-5 text-yellow-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-yellow-800">Tu suscripción vence pronto</p>
<p className="text-sm text-yellow-700 mt-1">
Tu período de facturación termina en {daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''} ({formatDate(subscription.currentPeriodEnd)}).
Asegúrate de tener tu método de pago al día.
</p>
</div>
</div>
)}
{/* Warning banner: pending payment */}
{!isLoading && subscription && subscription.status === 'pending' && !isExpired && (
<div className="flex items-start gap-3 rounded-lg border border-yellow-300 bg-yellow-50 p-4">
<Clock className="h-5 w-5 text-yellow-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-yellow-800">Pago pendiente</p>
<p className="text-sm text-yellow-700 mt-1">
Tu suscripción está pendiente de pago. Haz clic en el botón de abajo para completar tu pago.
</p>
</div>
</div>
)}
{/* Subscription Status + Pay button */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CreditCard className="h-5 w-5" />
Estado de Suscripción
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="animate-pulse space-y-4">
<div className="h-4 bg-muted rounded w-1/3" />
<div className="h-4 bg-muted rounded w-1/2" />
<div className="h-4 bg-muted rounded w-1/4" />
</div>
) : subscription ? (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div>
<p className="text-sm text-muted-foreground">Plan</p>
<p className="text-lg font-semibold capitalize">{subscription.plan}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Estado</p>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium border ${status.bgColor} ${status.color}`}>
<StatusIcon className="h-4 w-4" />
{status.label}
</span>
</div>
<div>
<p className="text-sm text-muted-foreground">Monto Mensual</p>
<p className="text-lg font-semibold">
${Number(subscription.amount).toLocaleString('es-MX')} MXN
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Frecuencia</p>
<p className="text-lg font-semibold capitalize">{subscription.frequency === 'monthly' ? 'Mensual' : subscription.frequency}</p>
</div>
</div>
{/* Pay button */}
{needsPayment && Number(subscription.amount) > 0 && (
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 pt-4 border-t">
<button
onClick={handleGenerateLink}
disabled={generateLink.isPending}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{generateLink.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ExternalLink className="h-4 w-4" />
)}
{generateLink.isPending ? 'Generando link...' : 'Pagar ahora'}
</button>
{paymentUrl && (
<a
href={paymentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
Abrir link de pago nuevamente
</a>
)}
{generateLink.isError && (
<p className="text-sm text-red-600">
Error al generar el link. Intenta de nuevo o contacta soporte.
</p>
)}
</div>
)}
</div>
) : (
<p className="text-muted-foreground">No se encontró información de suscripción. Contacta a soporte.</p>
)}
</CardContent>
</Card>
{/* Next payment / Billing period */}
{subscription && (subscription.currentPeriodStart || subscription.currentPeriodEnd) && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarClock className="h-5 w-5" />
Período de Facturación
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p className="text-sm text-muted-foreground">Inicio del período</p>
<p className="font-medium">{formatDate(subscription.currentPeriodStart)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Fin del período</p>
<p className="font-medium">{formatDate(subscription.currentPeriodEnd)}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Próximo pago</p>
{daysUntilEnd !== null ? (
isExpired ? (
<p className="font-medium text-red-600">Vencido pago requerido</p>
) : (
<p className="font-medium">
En {daysUntilEnd} día{daysUntilEnd !== 1 ? 's' : ''}
<span className="text-muted-foreground"> ({formatDate(subscription.currentPeriodEnd)})</span>
</p>
)
) : (
<p className="font-medium text-muted-foreground">Sin fecha definida</p>
)}
</div>
</div>
</CardContent>
</Card>
)}
{/* Payment History */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Historial de Pagos
</CardTitle>
</CardHeader>
<CardContent>
{payments && payments.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Fecha</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Monto</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Estado</th>
<th className="text-left py-2 px-3 font-medium text-muted-foreground">Método</th>
</tr>
</thead>
<tbody>
{payments.map((payment) => (
<tr key={payment.id} className="border-b last:border-0">
<td className="py-2.5 px-3">
{new Date(payment.createdAt).toLocaleDateString('es-MX', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</td>
<td className="py-2.5 px-3 font-medium">
${Number(payment.amount).toLocaleString('es-MX')} MXN
</td>
<td className="py-2.5 px-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
payment.status === 'approved'
? 'bg-green-50 text-green-700'
: payment.status === 'rejected'
? 'bg-red-50 text-red-700'
: 'bg-yellow-50 text-yellow-700'
}`}>
{payment.status === 'approved' && <CheckCircle className="h-3 w-3" />}
{payment.status === 'rejected' && <XCircle className="h-3 w-3" />}
{payment.status !== 'approved' && payment.status !== 'rejected' && <Clock className="h-3 w-3" />}
{payment.status === 'approved' ? 'Aprobado' :
payment.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
</span>
</td>
<td className="py-2.5 px-3 text-muted-foreground capitalize">
{payment.paymentMethod === 'bank_transfer' ? 'Transferencia' :
payment.paymentMethod || '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8">
<Calendar className="h-10 w-10 text-muted-foreground/40 mx-auto mb-3" />
<p className="text-muted-foreground">No hay pagos registrados aún.</p>
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}

View File

@@ -16,23 +16,33 @@ import {
Users,
Building2,
UserCog,
CreditCard,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
import { useRouter } from 'next/navigation';
import { hasFeature, type Plan } from '@horux/shared';
const navigation = [
interface NavItem {
name: string;
href: string;
icon: typeof LayoutDashboard;
feature?: string; // Required plan feature — hidden if tenant's plan lacks it
}
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Reportes', href: '/reportes', icon: BarChart3 },
{ name: 'Calendario', href: '/calendario', icon: Calendar },
{ name: 'Alertas', href: '/alertas', icon: Bell },
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes' },
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
{ name: 'Usuarios', href: '/usuarios', icon: Users },
{ name: 'Suscripción', href: '/configuracion/suscripcion', icon: CreditCard },
{ name: 'Configuracion', href: '/configuracion', icon: Settings },
];
const adminNavigation = [
const adminNavigation: NavItem[] = [
{ name: 'Clientes', href: '/clientes', icon: Building2 },
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
];
@@ -53,9 +63,15 @@ export function Sidebar() {
}
};
// Filter navigation based on tenant plan features
const plan = (user?.plan || 'starter') as Plan;
const filteredNav = navigation.filter(
(item) => !item.feature || hasFeature(plan, item.feature)
);
const allNavigation = user?.role === 'admin'
? [...navigation.slice(0, -1), ...adminNavigation, navigation[navigation.length - 1]]
: navigation;
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
: filteredNav;
return (
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">

View File

@@ -0,0 +1,47 @@
import { apiClient } from './client';
export interface Subscription {
id: string;
tenantId: string;
plan: string;
status: string;
amount: string;
frequency: string;
mpPreapprovalId: string | null;
currentPeriodStart: string | null;
currentPeriodEnd: string | null;
createdAt: string;
updatedAt: string;
}
export interface Payment {
id: string;
tenantId: string;
subscriptionId: string | null;
mpPaymentId: string | null;
amount: string;
status: string;
paymentMethod: string | null;
paidAt: string | null;
createdAt: string;
}
export async function getSubscription(tenantId: string): Promise<Subscription> {
const response = await apiClient.get<Subscription>(`/subscriptions/${tenantId}`);
return response.data;
}
export async function generatePaymentLink(tenantId: string): Promise<{ paymentUrl: string }> {
const response = await apiClient.post<{ paymentUrl: string }>(`/subscriptions/${tenantId}/generate-link`);
return response.data;
}
export async function markAsPaid(tenantId: string, amount: number): Promise<Payment> {
const response = await apiClient.post<Payment>(`/subscriptions/${tenantId}/mark-paid`, { amount });
return response.data;
}
export async function getPaymentHistory(tenantId: string): Promise<Payment[]> {
const response = await apiClient.get<Payment[]>(`/subscriptions/${tenantId}/payments`);
return response.data;
}

View File

@@ -5,7 +5,7 @@ export interface Tenant {
nombre: string;
rfc: string;
plan: string;
schemaName: string;
databaseName: string;
createdAt: string;
_count?: {
users: number;

View File

@@ -0,0 +1,40 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as subscriptionApi from '../api/subscription';
export function useSubscription(tenantId: string | undefined) {
return useQuery({
queryKey: ['subscription', tenantId],
queryFn: () => subscriptionApi.getSubscription(tenantId!),
enabled: !!tenantId,
staleTime: 5 * 60 * 1000,
});
}
export function usePaymentHistory(tenantId: string | undefined) {
return useQuery({
queryKey: ['payments', tenantId],
queryFn: () => subscriptionApi.getPaymentHistory(tenantId!),
enabled: !!tenantId,
staleTime: 60 * 1000,
});
}
export function useGeneratePaymentLink() {
return useMutation({
mutationFn: (tenantId: string) => subscriptionApi.generatePaymentLink(tenantId),
});
}
export function useMarkAsPaid() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tenantId, amount }: { tenantId: string; amount: number }) =>
subscriptionApi.markAsPaid(tenantId, amount),
onSuccess: (_, { tenantId }) => {
queryClient.invalidateQueries({ queryKey: ['subscription', tenantId] });
queryClient.invalidateQueries({ queryKey: ['payments', tenantId] });
},
});
}

View File

@@ -0,0 +1,90 @@
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=webhook:10m rate=10r/s;
upstream horux_api {
server 127.0.0.1:4000;
}
upstream horux_web {
server 127.0.0.1:3000;
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name horuxfin.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name horuxfin.com;
# SSL (managed by Certbot)
ssl_certificate /etc/letsencrypt/live/horuxfin.com-0001/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/horuxfin.com-0001/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://horuxfin.com; frame-ancestors 'self';" always;
# Max body size (50MB for bulk CFDI uploads)
client_max_body_size 50M;
# Auth endpoints (stricter rate limiting)
location /api/auth/ {
limit_req zone=auth burst=10 nodelay;
proxy_pass http://horux_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Webhook endpoints (no auth, moderate rate limiting)
location /api/webhooks/ {
limit_req zone=webhook burst=20 nodelay;
proxy_pass http://horux_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API routes
location /api/ {
limit_req zone=api burst=50 nodelay;
proxy_pass http://horux_api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
}
# Health check (no rate limit)
location /health {
proxy_pass http://horux_api;
}
# Next.js frontend
location / {
proxy_pass http://horux_web;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

View File

@@ -0,0 +1,323 @@
# API Reference - Horux360
**Base URL:** `https://horuxfin.com/api`
---
## Autenticación
Todos los endpoints (excepto auth) requieren header:
```
Authorization: Bearer <accessToken>
```
### Rate Limits (por IP)
| Endpoint | Límite | Ventana |
|----------|--------|---------|
| `POST /auth/login` | 10 requests | 15 minutos |
| `POST /auth/register` | 3 requests | 1 hora |
| `POST /auth/refresh` | 20 requests | 15 minutos |
| General `/api/*` | 30 requests/s | burst 50 |
---
## Auth (`/api/auth`)
### `POST /auth/register`
Registra nueva empresa y usuario admin. Provisiona base de datos dedicada.
**Body:**
```json
{
"empresa": { "nombre": "Mi Empresa", "rfc": "ABC123456789" },
"usuario": { "nombre": "Juan", "email": "juan@empresa.com", "password": "min8chars" }
}
```
**Response:** `{ accessToken, refreshToken, user: UserInfo }`
### `POST /auth/login`
```json
{ "email": "usuario@empresa.com", "password": "..." }
```
**Response:** `{ accessToken, refreshToken, user: UserInfo }`
### `POST /auth/refresh`
```json
{ "refreshToken": "..." }
```
**Response:** `{ accessToken, refreshToken }`
### `POST /auth/logout` *(requiere auth)*
```json
{ "refreshToken": "..." }
```
### `GET /auth/me` *(requiere auth)*
**Response:** `UserInfo`
---
## Dashboard (`/api/dashboard`)
### `GET /dashboard/kpis?año=2026&mes=3`
KPIs principales: ingresos, egresos, utilidad, margen, IVA balance, conteo de CFDIs.
### `GET /dashboard/ingresos-egresos?año=2026`
Datos mensuales de ingresos/egresos para gráfica anual.
### `GET /dashboard/resumen-fiscal?año=2026&mes=3`
IVA por pagar, IVA a favor, ISR, declaraciones pendientes, próxima obligación.
### `GET /dashboard/alertas?limit=5`
Alertas activas no resueltas, ordenadas por prioridad.
---
## CFDI (`/api/cfdi`)
### `GET /cfdi?page=1&limit=20&tipo=ingreso&search=...`
Lista paginada de CFDIs con filtros.
### `GET /cfdi/resumen`
Resumen de conteo por tipo y estado.
### `GET /cfdi/emisores`
Lista de emisores únicos.
### `GET /cfdi/receptores`
Lista de receptores únicos.
### `GET /cfdi/:id`
Detalle de un CFDI.
### `GET /cfdi/:id/xml`
XML original del CFDI.
### `POST /cfdi`
Crear un CFDI individual. Sujeto a límite de plan.
### `POST /cfdi/bulk`
Carga masiva de CFDIs. Body limit: 50MB. Sujeto a límite de plan.
### `DELETE /cfdi/:id`
Eliminar un CFDI.
---
## Impuestos (`/api/impuestos`)
### `GET /impuestos/iva?año=2026`
Datos mensuales de IVA (trasladado, acreditable, resultado, acumulado).
---
## Alertas (`/api/alertas`)
### `GET /alertas`
### `POST /alertas`
### `PUT /alertas/:id`
### `DELETE /alertas/:id`
### `PATCH /alertas/:id/read`
### `PATCH /alertas/:id/resolve`
---
## Calendario (`/api/calendario`)
### `GET /calendario?año=2026&mes=3`
### `POST /calendario`
### `PUT /calendario/:id`
### `DELETE /calendario/:id`
---
## Reportes (`/api/reportes`)
### `GET /reportes/flujo-efectivo?año=2026`
### `GET /reportes/impuestos?año=2026`
### `GET /reportes/forecasting?año=2026`
### `GET /reportes/concentrado?año=2026`
---
## Export (`/api/export`)
### `GET /export/cfdis?format=excel&tipo=ingreso`
Exporta CFDIs a Excel o CSV.
---
## FIEL (`/api/fiel`)
### `POST /fiel/upload`
```json
{
"cerFile": "<base64>",
"keyFile": "<base64>",
"password": "..."
}
```
- Archivos max 50KB cada uno
- Password max 256 caracteres
### `GET /fiel/status`
Estado actual de la FIEL configurada.
### `DELETE /fiel`
Eliminar credenciales FIEL.
---
## SAT Sync (`/api/sat`)
### `POST /sat/sync`
Iniciar sincronización manual.
```json
{ "type": "daily", "dateFrom": "2026-01-01", "dateTo": "2026-01-31" }
```
### `GET /sat/sync/status`
Estado actual de sincronización.
### `GET /sat/sync/history?page=1&limit=10`
Historial de sincronizaciones.
### `GET /sat/sync/:id`
Detalle de un job de sincronización.
### `POST /sat/sync/:id/retry`
Reintentar un job fallido.
### `GET /sat/cron` *(admin global)*
Info del job programado.
### `POST /sat/cron/run` *(admin global)*
Ejecutar sincronización global manualmente.
---
## Usuarios (`/api/usuarios`)
### `GET /usuarios`
Usuarios del tenant actual.
### `GET /usuarios/all` *(admin global)*
Todos los usuarios de todas las empresas.
### `POST /usuarios`
Invitar usuario (genera password temporal con `crypto.randomBytes`).
```json
{ "email": "nuevo@empresa.com", "nombre": "María", "role": "contador" }
```
### `PUT /usuarios/:id`
Actualizar usuario (nombre, role, active).
### `DELETE /usuarios/:id`
### `PUT /usuarios/:id/global` *(admin global)*
Actualizar usuario de cualquier empresa.
### `DELETE /usuarios/:id/global` *(admin global)*
---
## Tenants / Clientes (`/api/tenants`) *(admin global)*
### `GET /tenants`
Lista de todos los tenants/clientes.
### `POST /tenants`
Crear nuevo tenant. Provisiona base de datos. Envía email al admin.
```json
{
"nombre": "Empresa Nueva",
"rfc": "ENE123456789",
"plan": "business",
"adminNombre": "Pedro",
"adminEmail": "pedro@nueva.com"
}
```
### `PUT /tenants/:id`
Actualizar tenant (plan, limits, active).
### `DELETE /tenants/:id`
Soft delete — renombra la base de datos a `*_deleted_*`.
---
## Suscripciones (`/api/subscriptions`) *(admin global)*
### `GET /subscriptions/:tenantId`
Suscripción activa del tenant.
### `POST /subscriptions/:tenantId/generate-link`
Generar link de pago MercadoPago.
### `POST /subscriptions/:tenantId/mark-paid`
Marcar como pagado manualmente.
```json
{ "amount": 999 }
```
### `GET /subscriptions/:tenantId/payments`
Historial de pagos.
---
## Webhooks (`/api/webhooks`)
### `POST /webhooks/mercadopago`
Webhook de MercadoPago. Requiere headers:
- `x-signature`: Firma HMAC-SHA256
- `x-request-id`: ID del request
---
## Roles y Permisos
| Rol | Descripción | Acceso |
|-----|-------------|--------|
| `admin` | Administrador del tenant | Todo dentro de su tenant + invitar usuarios |
| `contador` | Contador | CFDI, impuestos, reportes, dashboard |
| `visor` | Solo lectura | Dashboard, CFDI (solo ver), reportes |
### Admin Global
El admin del tenant con RFC `CAS2408138W2` tiene acceso adicional:
- Gestión de todos los tenants
- Suscripciones
- SAT cron
- Impersonación via `X-View-Tenant` header
- Bypass de plan limits al impersonar
---
## Tipos Compartidos (`@horux/shared`)
### UserInfo
```typescript
interface UserInfo {
id: string;
email: string;
nombre: string;
role: 'admin' | 'contador' | 'visor';
tenantId: string;
tenantName: string;
tenantRfc: string;
plan: string;
}
```
### JWTPayload
```typescript
interface JWTPayload {
userId: string;
email: string;
role: Role;
tenantId: string;
iat?: number;
exp?: number;
}
```

View File

@@ -0,0 +1,250 @@
# Guía de Despliegue en Producción - Horux360
## Infraestructura
### Servidor
- **OS:** Ubuntu 24.04 LTS
- **RAM:** 22GB
- **CPU:** 8 cores
- **Dominio:** horuxfin.com (DNS en AWS Route 53)
- **SSL:** Let's Encrypt (certificado real via DNS challenge)
- **IP Interna:** 192.168.10.212
### Stack
| Componente | Tecnología | Puerto |
|-----------|-----------|--------|
| Reverse Proxy | Nginx 1.24 | 80/443 |
| API | Node.js + Express + tsx | 4000 |
| Frontend | Next.js 14 | 3000 |
| Base de datos | PostgreSQL 16 | 5432 |
| Process Manager | PM2 | — |
---
## Arquitectura de Red
```
Internet
Nginx (443/SSL)
├── /api/* → 127.0.0.1:4000 (horux-api)
├── /api/auth/* → 127.0.0.1:4000 (rate limit: 5r/s)
├── /api/webhooks/* → 127.0.0.1:4000 (rate limit: 10r/s)
├── /health → 127.0.0.1:4000
└── /* → 127.0.0.1:3000 (horux-web)
```
---
## PM2 - Gestión de Procesos
### Configuración (`ecosystem.config.js`)
```javascript
module.exports = {
apps: [
{
name: 'horux-api',
interpreter: 'node',
script: '/root/Horux/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/cli.mjs',
args: 'src/index.ts',
cwd: '/root/Horux/apps/api',
instances: 1,
exec_mode: 'fork',
autorestart: true,
max_memory_restart: '1G',
kill_timeout: 5000,
listen_timeout: 10000,
env: { NODE_ENV: 'production', PORT: 4000 },
},
{
name: 'horux-web',
script: 'node_modules/next/dist/bin/next',
args: 'start',
cwd: '/root/Horux/apps/web',
instances: 1,
exec_mode: 'fork',
autorestart: true,
max_memory_restart: '512M',
kill_timeout: 5000,
env: { NODE_ENV: 'production', PORT: 3000 },
},
],
};
```
### Notas
- La API usa `tsx` en lugar de `tsc` compilado porque `@horux/shared` exporta TypeScript raw (ESM) que `dist/` no puede resolver.
- Next.js usa la ruta directa `node_modules/next/dist/bin/next` porque `node_modules/.bin/next` es un shell script que PM2 no puede ejecutar como script Node.js.
### Comandos Útiles
```bash
pm2 restart all # Reiniciar todo
pm2 logs horux-api # Ver logs del API
pm2 logs horux-web # Ver logs del frontend
pm2 monit # Monitor en tiempo real
pm2 save # Guardar estado actual
pm2 startup # Configurar inicio automático
```
---
## Nginx
### Archivo: `/etc/nginx/sites-available/horux360.conf`
#### Rate Limiting
| Zona | Límite | Burst | Uso |
|------|--------|-------|-----|
| `auth` | 5r/s | 10 | `/api/auth/*` |
| `webhook` | 10r/s | 20 | `/api/webhooks/*` |
| `api` | 30r/s | 50 | `/api/*` (general) |
#### Security Headers
- `Content-Security-Policy`: Restrictivo (`default-src 'self'`)
- `Strict-Transport-Security`: 1 año con includeSubDomains
- `X-Frame-Options`: SAMEORIGIN
- `X-Content-Type-Options`: nosniff
- `Permissions-Policy`: camera, microphone, geolocation deshabilitados
- `Referrer-Policy`: strict-origin-when-cross-origin
#### Body Limits
- Global: `50M` (Nginx)
- API default: `10mb` (Express)
- `/api/cfdi/bulk`: `50mb` (Express route-specific)
### Renovar SSL
```bash
certbot renew --dry-run # Verificar
certbot renew # Renovar
```
---
## PostgreSQL
### Configuración de Rendimiento (`postgresql.conf`)
| Parámetro | Valor | Descripción |
|-----------|-------|-------------|
| `max_connections` | 300 | Para multi-tenant con pools por tenant |
| `shared_buffers` | 4GB | ~18% de 22GB RAM |
| `work_mem` | 16MB | Memoria por operación de sort/hash |
| `effective_cache_size` | 16GB | ~72% de RAM |
| `maintenance_work_mem` | 512MB | Para VACUUM, CREATE INDEX |
| `wal_buffers` | 64MB | Write-ahead log buffers |
### Arquitectura Multi-Tenant
Cada cliente tiene su propia base de datos PostgreSQL:
```
horux360 ← Base central (tenants, users, subscriptions)
horux_cas2408138w2 ← Base del admin global
horux_<rfc> ← Base de cada cliente
```
### Backups
```bash
# Cron job: 0 1 * * * /root/Horux/scripts/backup.sh
# Ubicación: /var/horux/backups/
# Retención: 7 diarios + 4 semanales
```
---
## Variables de Entorno
### API (`apps/api/.env`)
```env
NODE_ENV=production
PORT=4000
DATABASE_URL="postgresql://postgres:<password>@localhost:5432/horux360?schema=public"
JWT_SECRET=<min 32 chars>
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
CORS_ORIGIN=https://horuxfin.com
FRONTEND_URL=https://horuxfin.com
FIEL_ENCRYPTION_KEY=<min 32 chars, REQUERIDO>
FIEL_STORAGE_PATH=/var/horux/fiel
# MercadoPago
MP_ACCESS_TOKEN=<token>
MP_WEBHOOK_SECRET=<secret, REQUERIDO para producción>
# SMTP (Google Workspace)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=ivan@horuxfin.com
SMTP_PASS=<app-password>
SMTP_FROM=Horux360 <ivan@horuxfin.com>
# Admin
ADMIN_EMAIL=carlos@horuxfin.com
```
### Web (`apps/web/.env.local`)
```env
NEXT_PUBLIC_API_URL=https://horuxfin.com/api
```
---
## Directorios Importantes
```
/root/Horux/ ← Código fuente
/var/horux/fiel/ ← Archivos FIEL encriptados (0700)
/var/horux/backups/ ← Backups de PostgreSQL
/etc/nginx/sites-available/ ← Config de Nginx
/etc/letsencrypt/live/ ← Certificados SSL
```
---
## Despliegue de Cambios
```bash
# 1. Pull cambios
cd /root/Horux
git pull origin main
# 2. Instalar dependencias
pnpm install
# 3. Build
pnpm build
# 4. Reiniciar servicios
pm2 restart all
# 5. Si hay cambios en nginx:
cp deploy/nginx/horux360.conf /etc/nginx/sites-available/horux360.conf
nginx -t && systemctl reload nginx
```
---
## Troubleshooting
### API no inicia
```bash
pm2 logs horux-api --lines 50 # Ver logs de error
pm2 restart horux-api # Reiniciar
```
### Puerto en uso
```bash
lsof -i :4000 # Ver quién usa el puerto
kill <PID> # Matar proceso
pm2 restart horux-api
```
### Certificado SSL expirado
```bash
certbot renew
systemctl reload nginx
```
### Base de datos lenta
```bash
sudo -u postgres psql -c "SELECT * FROM pg_stat_activity WHERE state = 'active';"
```

View File

@@ -0,0 +1,143 @@
# Auditoría de Seguridad y Remediación - Horux360
**Fecha:** 2026-03-18
**Auditor:** Claude Opus 4.6
**Alcance:** Plataforma completa (API, Frontend, Infraestructura)
---
## Resumen Ejecutivo
Se realizó una auditoría de seguridad completa de la plataforma Horux360 antes de abrirla a clientes. Se identificaron **6 vulnerabilidades críticas, 9 altas, 10 medias y 7 bajas**. Se corrigieron **20 vulnerabilidades** (todas las críticas, altas y medias de código).
## Vulnerabilidades Corregidas
### CRÍTICAS (6)
#### C1. Impersonación de Tenant sin Restricción
- **Archivo:** `tenant.middleware.ts`, `plan-limits.middleware.ts`
- **Problema:** Cualquier usuario con `role === 'admin'` (incluidos los admins de clientes) podía usar el header `X-View-Tenant` para acceder a los datos de CUALQUIER otro tenant.
- **Fix:** Se creó `utils/global-admin.ts` con función `isGlobalAdmin()` que verifica que el tenant del usuario solicitante tenga el RFC del admin global (`CAS2408138W2`). Se aplicó en `tenant.middleware.ts` y `plan-limits.middleware.ts`.
- **Impacto:** Rompía completamente el aislamiento multi-tenant.
#### C2. Endpoints de Suscripción sin Autorización (IDOR)
- **Archivo:** `subscription.routes.ts`, `subscription.controller.ts`
- **Problema:** Cualquier usuario autenticado podía llamar `POST /api/subscriptions/:tenantId/mark-paid` para marcar cualquier tenant como pagado.
- **Fix:** Se agregó `authorize('admin')` en las rutas y verificación `isGlobalAdmin()` en cada método del controlador. Doble capa de protección.
- **Impacto:** Bypass total de pagos.
#### C3. Bypass de Verificación de Webhook de MercadoPago
- **Archivo:** `webhook.controller.ts`, `mercadopago.service.ts`
- **Problema:** (1) Si faltaba el header `x-signature`, la verificación se saltaba completamente. (2) Si `MP_WEBHOOK_SECRET` no estaba configurado, la función retornaba `true` siempre.
- **Fix:** Ahora es obligatorio que los headers `x-signature`, `x-request-id` y `data.id` estén presentes; de lo contrario se rechaza con 401. Si `MP_WEBHOOK_SECRET` no está configurado, se rechaza el webhook.
- **Impacto:** Un atacante podía forjar webhooks para activar suscripciones gratis.
#### C4. `databaseName` Expuesto en JWT
- **Archivo:** `auth.service.ts`, `packages/shared/src/types/auth.ts`, `tenant.middleware.ts`
- **Problema:** El nombre interno de la base de datos PostgreSQL se incluía en el JWT (base64, visible para cualquier usuario).
- **Fix:** Se eliminó `databaseName` del payload JWT y del tipo `JWTPayload`. El tenant middleware ahora resuelve el `databaseName` server-side usando `tenantId` con caché de 5 minutos.
- **Impacto:** Fuga de información de infraestructura interna.
#### C5. Body Size Limit de 1GB
- **Archivo:** `app.ts`, `cfdi.routes.ts`, `deploy/nginx/horux360.conf`
- **Problema:** Express y Nginx aceptaban payloads de hasta 1GB, permitiendo DoS por agotamiento de memoria.
- **Fix:** Límite global reducido a `10mb`. Ruta `/api/cfdi/bulk` tiene límite específico de `50mb`. Nginx actualizado a `50M`.
- **Impacto:** Un solo request malicioso podía crashear el servidor.
#### C6. Archivo `.env` con Permisos 644
- **Archivo:** `apps/api/.env`
- **Problema:** El archivo `.env` era legible por cualquier usuario del sistema.
- **Fix:** `chmod 600` — solo legible por el propietario (root).
### ALTAS (5)
#### H1. SAT Cron Endpoints sin Autorización
- **Archivo:** `sat.routes.ts`, `sat.controller.ts`
- **Problema:** Cualquier usuario autenticado podía ejecutar el cron global de sincronización SAT.
- **Fix:** Se agregó `authorize('admin')` en rutas y `isGlobalAdmin()` en el controlador.
#### H2. Sin Content Security Policy (CSP)
- **Archivo:** `deploy/nginx/horux360.conf`
- **Problema:** Sin CSP, no había protección del navegador contra XSS.
- **Fix:** Se agregó CSP header completo. Se removió `X-XSS-Protection` (deprecado). Se agregó `Permissions-Policy`.
#### H3. Tenant CRUD con Admin Genérico
- **Archivo:** `usuarios.controller.ts`
- **Problema:** El check `isGlobalAdmin()` estaba duplicado y no centralizado.
- **Fix:** Se centralizó en `utils/global-admin.ts` con caché para evitar queries repetidos.
#### H4. Sin Rate Limiting en Auth
- **Archivo:** `auth.routes.ts`
- **Problema:** Sin límite de intentos en login/register/refresh.
- **Fix:** `express-rate-limit` instalado con: login 10/15min, register 3/hora, refresh 20/15min por IP.
#### H5. Logout Público
- **Archivo:** `auth.routes.ts`
- **Problema:** El endpoint `/auth/logout` no requería autenticación.
- **Fix:** Se agregó `authenticate` middleware.
### MEDIAS (9)
| # | Problema | Fix |
|---|---------|-----|
| M1 | Contraseñas temporales con `Math.random()` | Cambiado a `crypto.randomBytes(4).toString('hex')` |
| M2 | Contraseñas temporales logueadas a console | Removido `console.log` |
| M3 | Credenciales de BD enviadas por email | Removida sección de conexión DB del template de email |
| M4 | HTML injection en templates de email | Agregado `escapeHtml()` en todos los valores interpolados |
| M5 | Sin validación de tamaño en upload de FIEL | Límite de 50KB por archivo, 256 chars para password |
| M6 | SMTP sin requerir TLS | Agregado `requireTLS: true` en config de Nodemailer |
| M7 | Email no normalizado en registro | `toLowerCase()` aplicado antes del check de duplicados |
| M8 | FIEL_ENCRYPTION_KEY con default hardcoded | Removido `.default()`, ahora es requerido |
| M9 | Plan limits bypass con X-View-Tenant | Mismo fix que C1, verificación `isGlobalAdmin()` |
## Vulnerabilidades Pendientes (Infraestructura)
Estas requieren cambios de infraestructura que no son código:
| # | Severidad | Problema | Recomendación |
|---|-----------|---------|---------------|
| P1 | ALTA | App corre como root | Crear usuario `horux` dedicado |
| P2 | MEDIA | PostgreSQL usa superuser | Crear usuario `horux_app` con permisos mínimos |
| P3 | MEDIA | Backups sin encriptar ni offsite | Agregar GPG + sync a S3 |
| P4 | MEDIA | Sin lockout de cuenta | Agregar contador de intentos fallidos (requiere migración DB) |
| P5 | BAJA | Tokens JWT en localStorage | Migrar a HttpOnly cookies (requiere cambios frontend + API) |
| P6 | BAJA | Mismo JWT secret para access y refresh | Agregar `JWT_REFRESH_SECRET` |
## Archivos Modificados
### Nuevos
- `apps/api/src/utils/global-admin.ts` — Utilidad centralizada para verificar admin global con caché
### Modificados (Seguridad)
- `apps/api/src/middlewares/tenant.middleware.ts` — Resolución de databaseName server-side + global admin check
- `apps/api/src/middlewares/plan-limits.middleware.ts` — Global admin check para bypass
- `apps/api/src/controllers/subscription.controller.ts` — Global admin authorization
- `apps/api/src/controllers/webhook.controller.ts` — Verificación de firma obligatoria
- `apps/api/src/controllers/sat.controller.ts` — Global admin check en cron endpoints
- `apps/api/src/controllers/usuarios.controller.ts` — Uso de utilidad centralizada
- `apps/api/src/controllers/fiel.controller.ts` — Validación de tamaño de archivos
- `apps/api/src/routes/auth.routes.ts` — Rate limiting + logout autenticado
- `apps/api/src/routes/subscription.routes.ts` — authorize('admin') middleware
- `apps/api/src/routes/sat.routes.ts` — authorize('admin') en cron endpoints
- `apps/api/src/routes/cfdi.routes.ts` — Límite de 50MB específico para bulk
- `apps/api/src/services/auth.service.ts` — databaseName removido de JWT, email normalizado
- `apps/api/src/services/usuarios.service.ts` — randomBytes + sin console.log
- `apps/api/src/services/email/email.service.ts` — requireTLS
- `apps/api/src/services/email/templates/new-client-admin.ts` — Sin DB credentials, con escapeHtml
- `apps/api/src/services/payment/mercadopago.service.ts` — Rechazar si no hay secret
- `apps/api/src/config/env.ts` — FIEL_ENCRYPTION_KEY requerido
- `apps/api/src/app.ts` — Body limit 10MB
- `packages/shared/src/types/auth.ts` — databaseName removido de JWTPayload
- `deploy/nginx/horux360.conf` — CSP, Permissions-Policy, body 50M
## Prácticas Positivas Encontradas
- bcrypt con 12 salt rounds
- HTTPS con HSTS, TLS 1.2/1.3
- Helmet.js activo
- SQL parameterizado en todas las queries raw (Prisma ORM)
- FIEL encriptado con AES-256-GCM
- Refresh token rotation implementada
- Base de datos por tenant (aislamiento a nivel DB)
- PostgreSQL solo escucha en localhost
- `.env` en `.gitignore` y nunca commiteado

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,797 @@
# Horux360 SaaS Transformation — Design Spec
**Date:** 2026-03-15
**Status:** Approved
**Author:** Carlos Horux + Claude
## Overview
Transform Horux360 from an internal multi-tenant accounting tool into a production-ready SaaS platform. Client registration remains manual (sales-led). Each client gets a fully isolated PostgreSQL database. Payments via MercadoPago. Transactional emails via Gmail SMTP (@horuxfin.com). Production deployment on existing server (192.168.10.212).
**Target scale:** 10-50 clients within 6 months.
**Starting from scratch:** No data migration. Existing schemas/data will be archived. Fresh setup.
---
## Section 1: Database-Per-Tenant Architecture
### Rationale
Clients sign NDAs requiring complete data isolation. Schema-per-tenant (current approach) shares a single database. Database-per-tenant provides:
- Independent backup/restore per client
- No risk of cross-tenant data leakage
- Each DB can be moved to a different server if needed
### Structure
```
PostgreSQL Server (max_connections: 300)
├── horux360 ← Central DB (Prisma-managed)
├── horux_cas2408138w2 ← Client DB (raw SQL)
├── horux_roem691011ez4 ← Client DB
└── ...
```
### Central DB (`horux360`) — Prisma-managed tables
Existing tables (modified):
- `tenants` — add `database_name` column, remove `schema_name`
- `users` — no changes
- `refresh_tokens` — flush all existing tokens at migration cutover (invalidate all sessions)
- `fiel_credentials` — no changes
New tables:
- `subscriptions` — MercadoPago subscription tracking
- `payments` — payment history
### Prisma schema migration
The Prisma schema (`apps/api/prisma/schema.prisma`) must be updated:
- Replace `schema_name String @unique @map("schema_name")` with `database_name String @unique @map("database_name")` on the `Tenant` model
- Add `Subscription` and `Payment` models
- Run `prisma migrate dev` to generate and apply migration
- Update `Tenant` type in `packages/shared/src/types/tenant.ts`: replace `schemaName` with `databaseName`
### JWT payload migration
The current JWT payload embeds `schemaName`. This must change:
- Update `JWTPayload` in `packages/shared/src/types/auth.ts`: replace `schemaName` with `databaseName`
- Update token generation in `auth.service.ts`: read `tenant.databaseName` instead of `tenant.schemaName`
- Update `refreshTokens` function to embed `databaseName`
- At migration cutover: flush `refresh_tokens` table to invalidate all existing sessions (forces re-login)
### Client DB naming
Formula: `horux_<rfc_normalized>`
```
RFC "CAS2408138W2" → horux_cas2408138w2
RFC "TPR840604D98" → horux_tpr840604d98
```
### Client DB tables (created via raw SQL)
Each client database contains these tables (no schema prefix, direct `public` schema):
- `cfdis` — with indexes: fecha_emision DESC, tipo, rfc_emisor, rfc_receptor, pg_trgm on nombre_emisor/nombre_receptor, uuid_fiscal unique
- `iva_mensual`
- `isr_mensual`
- `alertas`
- `calendario_fiscal`
### TenantConnectionManager
```typescript
class TenantConnectionManager {
private pools: Map<string, { pool: pg.Pool; lastAccess: Date }>;
private cleanupInterval: NodeJS.Timer;
// Get or create a pool for a tenant
getPool(tenantId: string, databaseName: string): pg.Pool;
// Create a new tenant database with all tables and indexes
provisionDatabase(rfc: string): Promise<string>;
// Drop a tenant database (soft-delete: rename to horux_deleted_<rfc>_<timestamp>)
deprovisionDatabase(databaseName: string): Promise<void>;
// Cleanup idle pools (called every 60s, removes pools idle > 5min)
private cleanupIdlePools(): void;
}
```
Pool configuration per tenant:
- `max`: 3 connections (with 2 PM2 cluster instances, this means 6 connections/tenant max; at 50 tenants = 300, matching `max_connections`)
- `idleTimeoutMillis`: 300000 (5 min)
- `connectionTimeoutMillis`: 10000 (10 sec)
**Note on PM2 cluster mode:** Each PM2 worker is a separate Node.js process with its own `TenantConnectionManager` instance. With `instances: 2` and `max: 3` per pool, worst case is 50 tenants × 3 connections × 2 workers = 300 connections, which matches `max_connections = 300`. If scaling beyond 50 tenants, either increase `max_connections` or reduce pool `max` to 2.
### Tenant middleware change
Current: Sets `search_path` on a shared connection.
New: Returns a dedicated pool connected to the tenant's own database.
```typescript
// Before
req.tenantSchema = schema;
await pool.query(`SET search_path TO "${schema}", public`);
// After
req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName);
```
All tenant service functions change from using a shared pool with schema prefix to using `req.tenantPool` with direct table names.
### Admin impersonation (X-View-Tenant)
The current `X-View-Tenant` header support for admin "view-as" functionality is preserved. The new middleware resolves the `databaseName` for the viewed tenant:
```typescript
// If admin is viewing another tenant
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
const viewedTenant = await getTenantByRfc(req.headers['x-view-tenant']);
req.tenantPool = tenantConnectionManager.getPool(viewedTenant.id, viewedTenant.databaseName);
} else {
req.tenantPool = tenantConnectionManager.getPool(tenant.id, tenant.databaseName);
}
```
### Provisioning flow (new client)
1. Admin creates tenant via UI → POST `/api/tenants/`
2. Insert record in `horux360.tenants` with `database_name`
3. Execute `CREATE DATABASE horux_<rfc>`
4. Connect to new DB, create all tables + indexes
5. Create admin user in `horux360.users` linked to tenant
6. Send welcome email with temporary credentials
7. Generate MercadoPago subscription link
**Rollback on partial failure:** If any step 3-7 fails:
- Drop the created database if it exists (`DROP DATABASE IF EXISTS horux_<rfc>`)
- Delete the `tenants` row
- Delete the `users` row if created
- Return error to admin with the specific step that failed
- The entire provisioning is wrapped in a try/catch with explicit cleanup
### PostgreSQL tuning
```
max_connections = 300
shared_buffers = 4GB
work_mem = 16MB
effective_cache_size = 16GB
maintenance_work_mem = 512MB
```
### Server disk
Expand from 29 GB to 100 GB to accommodate:
- 25-50 client databases (~2-3 GB total)
- Daily backups with 7-day retention (~15 GB)
- FIEL encrypted files (<100 MB)
- Logs, builds, OS (~10 GB)
---
## Section 2: SAT Credential Storage (FIEL)
### Dual storage strategy
When a client uploads their FIEL (.cer + .key + password):
**A. Filesystem (for manual linking):**
```
/var/horux/fiel/
├── CAS2408138W2/
│ ├── certificate.cer.enc ← AES-256-GCM encrypted
│ ├── private_key.key.enc ← AES-256-GCM encrypted
│ └── metadata.json.enc ← serial, validity dates, upload date (also encrypted)
└── ROEM691011EZ4/
├── certificate.cer.enc
├── private_key.key.enc
└── metadata.json.enc
```
**B. Central DB (`fiel_credentials` table):**
- Existing structure: `cer_data`, `key_data`, `key_password_encrypted`, `encryption_iv`, `encryption_tag`
- **Schema change required:** Add per-component IV/tag columns (`cer_iv`, `cer_tag`, `key_iv`, `key_tag`, `password_iv`, `password_tag`) to support independent encryption per component. Alternatively, use a single JSON column for all encryption metadata. The existing `encryption_iv` and `encryption_tag` columns can be dropped after migration.
### Encryption
- Algorithm: AES-256-GCM
- Key: `FIEL_ENCRYPTION_KEY` environment variable (separate from other secrets)
- **Code change required:** `sat-crypto.service.ts` currently derives the key from `JWT_SECRET` via `createHash('sha256').update(env.JWT_SECRET).digest()`. This must be changed to read `FIEL_ENCRYPTION_KEY` from the env schema. The `env.ts` Zod schema must be updated to declare `FIEL_ENCRYPTION_KEY` as required.
- Each component (certificate, private key, password) is encrypted separately with its own IV and auth tag. The `fiel_credentials` table stores separate `encryption_iv` and `encryption_tag` per row. The filesystem also stores each file independently encrypted.
- **Code change required:** The current `sat-crypto.service.ts` shares a single IV/tag across all three components. Refactor to encrypt each component independently with its own IV/tag. Store per-component IV/tags in the DB (add columns: `cer_iv`, `cer_tag`, `key_iv`, `key_tag`, `password_iv`, `password_tag` — or use a JSON column).
- Password is encrypted, never stored in plaintext
### Manual decryption CLI
```bash
node scripts/decrypt-fiel.js --rfc CAS2408138W2
```
- Decrypts files to `/tmp/horux-fiel-<rfc>/`
- Files auto-delete after 30 minutes (via setTimeout or tmpwatch)
- Requires SSH access to server
### Security
- `/var/horux/fiel/` permissions: `700` (root only)
- Encrypted files are useless without `FIEL_ENCRYPTION_KEY`
- `metadata.json` is also encrypted (contains serial number + RFC which could be used to query SAT's certificate validation service, violating NDA confidentiality requirements)
### Upload flow
1. Client navigates to `/configuracion/sat`
2. Uploads `.cer` + `.key` files + enters password
3. API validates the certificate (checks it's a valid FIEL, not expired)
4. Encrypts and stores in both filesystem and database
5. Sends notification email to admin team: "Cliente X subió su FIEL"
---
## Section 3: Payment System (MercadoPago)
### Integration approach
Using MercadoPago's **Preapproval (Subscription)** API for recurring payments.
### New tables in central DB
```sql
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
plan VARCHAR(20) NOT NULL,
mp_preapproval_id VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- status: pending | authorized | paused | cancelled
amount DECIMAL(10,2) NOT NULL,
frequency VARCHAR(10) NOT NULL DEFAULT 'monthly',
-- frequency: monthly | yearly
current_period_start TIMESTAMP,
current_period_end TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_subscriptions_tenant_id ON subscriptions(tenant_id);
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
subscription_id UUID REFERENCES subscriptions(id),
mp_payment_id VARCHAR(100),
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- status: approved | pending | rejected | refunded
payment_method VARCHAR(50),
paid_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_payments_tenant_id ON payments(tenant_id);
CREATE INDEX idx_payments_subscription_id ON payments(subscription_id);
```
### Plans and pricing
Defined in `packages/shared/src/constants/plans.ts` (update existing):
| Plan | Monthly price (MXN) | CFDIs | Users | Features |
|------|---------------------|-------|-------|----------|
| starter | Configurable | 100 | 1 | dashboard, cfdi_basic, iva_isr |
| business | Configurable | 500 | 3 | + reportes, alertas, calendario |
| professional | Configurable | 2,000 | 10 | + xml_sat, conciliacion, forecasting |
| enterprise | Configurable | Unlimited | Unlimited | + api, multi_empresa |
Prices are configured from admin panel, not hardcoded.
### Subscription flow
1. Admin creates tenant and assigns plan
2. Admin clicks "Generate payment link" → API creates MercadoPago Preapproval
3. Link is sent to client via email
4. Client pays → MercadoPago sends webhook
5. System activates subscription, records payment
### Webhook endpoint
`POST /api/webhooks/mercadopago` (public, no auth)
Validates webhook signature using `x-signature` header and `x-request-id`.
Events handled:
- `payment` → query MercadoPago API for payment details → insert into `payments`, update subscription period
- `subscription_preapproval` → update subscription status (authorized, paused, cancelled)
On payment failure or subscription cancellation:
- Mark tenant `active = false`
- Client gets read-only access (can view data but not upload CFDIs, generate reports, etc.)
### Admin panel additions
- View subscription status per client (active, amount, next billing date)
- Generate payment link button
- "Mark as paid manually" button (for bank transfer payments)
- Payment history per client
### Client panel additions
- New section in `/configuracion`: "Mi suscripción"
- Shows: current plan, next billing date, payment history
- Client cannot change plan themselves (admin does it)
### Environment variables
```
MP_ACCESS_TOKEN=<mercadopago_access_token>
MP_WEBHOOK_SECRET=<webhook_signature_secret>
MP_NOTIFICATION_URL=https://horux360.consultoria-as.com/api/webhooks/mercadopago
```
---
## Section 4: Transactional Emails
### Transport
Nodemailer with Gmail SMTP (Google Workspace).
```
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=<user>@horuxfin.com
SMTP_PASS=<google_app_password>
SMTP_FROM=Horux360 <noreply@horuxfin.com>
```
Requires generating an App Password in Google Workspace admin.
### Email types
| Event | Recipient | Subject |
|-------|-----------|---------|
| Client registered | Client | Bienvenido a Horux360 |
| FIEL uploaded | Admin team | [Cliente] subió su FIEL |
| Payment received | Client | Confirmación de pago - Horux360 |
| Payment failed | Client + Admin | Problema con tu pago - Horux360 |
| Subscription expiring | Client | Tu suscripción vence en 5 días |
| Subscription cancelled | Client + Admin | Suscripción cancelada - Horux360 |
### Template approach
HTML templates as TypeScript template literal functions. No external template engine.
```typescript
// services/email/templates/welcome.ts
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string; loginUrl: string }): string {
return `<!DOCTYPE html>...`;
}
```
Each template:
- Responsive HTML email (inline CSS)
- Horux360 branding (logo, colors)
- Plain text fallback
### Email service
```typescript
class EmailService {
sendWelcome(to: string, data: WelcomeData): Promise<void>;
sendFielNotification(data: FielNotificationData): Promise<void>;
sendPaymentConfirmation(to: string, data: PaymentData): Promise<void>;
sendPaymentFailed(to: string, data: PaymentData): Promise<void>;
sendSubscriptionExpiring(to: string, data: SubscriptionData): Promise<void>;
sendSubscriptionCancelled(to: string, data: SubscriptionData): Promise<void>;
}
```
### Limits
Gmail Workspace: 500 emails/day. Expected volume for 25 clients: ~50-100 emails/month. Well within limits.
---
## Section 5: Production Deployment
### Build pipeline
**API:**
```bash
cd apps/api && pnpm build # tsc → dist/
pnpm start # node dist/index.js
```
**Web:**
```bash
cd apps/web && pnpm build # next build → .next/
pnpm start # next start (optimized server)
```
### PM2 configuration
```javascript
// ecosystem.config.js
module.exports = {
apps: [
{
name: 'horux-api',
script: 'dist/index.js',
cwd: '/root/Horux/apps/api',
instances: 2,
exec_mode: 'cluster',
env: { NODE_ENV: 'production' }
},
{
name: 'horux-web',
script: 'node_modules/.bin/next',
args: 'start',
cwd: '/root/Horux/apps/web',
instances: 1,
exec_mode: 'fork',
env: { NODE_ENV: 'production' }
}
]
};
```
Auto-restart on crash. Log rotation via `pm2-logrotate`.
### Nginx reverse proxy
```nginx
# Rate limiting zone definitions (in http block of nginx.conf)
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=webhooks:10m rate=30r/m;
server {
listen 80;
server_name horux360.consultoria-as.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name horux360.consultoria-as.com;
ssl_certificate /etc/letsencrypt/live/horux360.consultoria-as.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/horux360.consultoria-as.com/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
# Gzip
gzip on;
gzip_types text/plain application/json application/javascript text/css;
# Health check (for monitoring)
location /api/health {
proxy_pass http://127.0.0.1:4000;
}
# Rate limiting for public endpoints
location /api/auth/ {
limit_req zone=auth burst=5 nodelay;
proxy_pass http://127.0.0.1:4000;
}
location /api/webhooks/ {
limit_req zone=webhooks burst=10 nodelay;
proxy_pass http://127.0.0.1:4000;
}
# API
location /api/ {
proxy_pass http://127.0.0.1:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 200M; # Bulk XML uploads (200MB is enough for ~50k XML files)
}
# Next.js
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Health check endpoint
The existing `GET /health` endpoint returns `{ status: 'ok', timestamp }`. PM2 uses this for liveness checks. Nginx can optionally use it for upstream health monitoring.
### SSL
Let's Encrypt with certbot. Auto-renewal via cron.
```bash
certbot --nginx -d horux360.consultoria-as.com
```
### Firewall
```bash
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP (redirect to HTTPS)
ufw allow 443/tcp # HTTPS
ufw enable
```
PostgreSQL only on localhost (no external access).
### Backups
Cron job at **1:00 AM** daily (runs before SAT cron at 3:00 AM, with enough gap to complete):
**Authentication:** Create a `.pgpass` file at `/root/.pgpass` with `localhost:5432:*:postgres:<password>` and `chmod 600`. This allows `pg_dump` to authenticate without inline passwords.
```bash
#!/bin/bash
# /var/horux/scripts/backup.sh
set -euo pipefail
BACKUP_DIR=/var/horux/backups
DATE=$(date +%Y-%m-%d)
DOW=$(date +%u) # Day of week: 1=Monday, 7=Sunday
DAILY_DIR=$BACKUP_DIR/daily
WEEKLY_DIR=$BACKUP_DIR/weekly
mkdir -p $DAILY_DIR $WEEKLY_DIR
# Backup central DB
pg_dump -h localhost -U postgres horux360 | gzip > $DAILY_DIR/horux360_$DATE.sql.gz
# Backup each tenant DB
for db in $(psql -h localhost -U postgres -t -c "SELECT database_name FROM tenants WHERE database_name IS NOT NULL" horux360); do
db_trimmed=$(echo $db | xargs) # trim whitespace
pg_dump -h localhost -U postgres "$db_trimmed" | gzip > $DAILY_DIR/${db_trimmed}_${DATE}.sql.gz
done
# On Sundays, copy to weekly directory
if [ "$DOW" -eq 7 ]; then
cp $DAILY_DIR/*_${DATE}.sql.gz $WEEKLY_DIR/
fi
# Remove daily backups older than 7 days
find $DAILY_DIR -name "*.sql.gz" -mtime +7 -delete
# Remove weekly backups older than 28 days
find $WEEKLY_DIR -name "*.sql.gz" -mtime +28 -delete
# Verify backup files are not empty (catch silent pg_dump failures)
for f in $DAILY_DIR/*_${DATE}.sql.gz; do
if [ ! -s "$f" ]; then
echo "WARNING: Empty backup file: $f" >&2
fi
done
```
**Schedule separation:** Backups run at 1:00 AM, SAT cron runs at 3:00 AM. With 50 clients, backup should complete in ~15-30 minutes, leaving ample gap before SAT sync starts.
### Environment variables (production)
```
NODE_ENV=production
PORT=4000
DATABASE_URL=postgresql://postgres:<strong_password>@localhost:5432/horux360?schema=public
JWT_SECRET=<cryptographically_secure_random_64_chars>
JWT_EXPIRES_IN=24h
JWT_REFRESH_EXPIRES_IN=30d
CORS_ORIGIN=https://horux360.consultoria-as.com
FIEL_ENCRYPTION_KEY=<separate_32_byte_hex_key>
MP_ACCESS_TOKEN=<mercadopago_production_token>
MP_WEBHOOK_SECRET=<webhook_secret>
MP_NOTIFICATION_URL=https://horux360.consultoria-as.com/api/webhooks/mercadopago
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=<user>@horuxfin.com
SMTP_PASS=<google_app_password>
SMTP_FROM=Horux360 <noreply@horuxfin.com>
ADMIN_EMAIL=admin@horuxfin.com
```
### SAT cron
Already implemented. Runs at 3:00 AM when `NODE_ENV=production`. Will activate automatically with the environment change.
---
## Section 6: Plan Enforcement & Feature Gating
### Enforcement middleware
```typescript
// middleware: checkPlanLimits
async function checkPlanLimits(req, res, next) {
const tenant = await getTenantWithCache(req.user.tenantId); // cached 5 min
const subscription = await getActiveSubscription(tenant.id);
// Admin-impersonated requests bypass subscription check
// (admin needs to complete client setup regardless of payment status)
if (req.headers['x-view-tenant'] && req.user.role === 'admin') {
return next();
}
// Allowed statuses: 'authorized' (paid) or 'pending' (grace period for new clients)
const allowedStatuses = ['authorized', 'pending'];
// Check subscription status
if (!subscription || !allowedStatuses.includes(subscription.status)) {
// Allow read-only access for cancelled/paused subscriptions
if (req.method !== 'GET') {
return res.status(403).json({
message: 'Suscripción inactiva. Contacta soporte para reactivar.'
});
}
}
next();
}
```
**Grace period:** New clients start with `status: 'pending'` and have full write access (can upload FIEL, upload CFDIs, etc.). Once the subscription moves to `'cancelled'` or `'paused'` (e.g., failed payment), write access is revoked. Admin can also manually set status to `'authorized'` for clients who pay by bank transfer.
### CFDI limit check
Applied on `POST /api/cfdi/` and `POST /api/cfdi/bulk`:
```typescript
async function checkCfdiLimit(req, res, next) {
const tenant = await getTenantWithCache(req.user.tenantId);
if (tenant.cfdiLimit === -1) return next(); // unlimited
const currentCount = await getCfdiCountWithCache(req.tenantPool); // cached 5 min
const newCount = Array.isArray(req.body) ? req.body.length : 1;
if (currentCount + newCount > tenant.cfdiLimit) {
return res.status(403).json({
message: `Límite de CFDIs alcanzado (${currentCount}/${tenant.cfdiLimit}). Contacta soporte para upgrade.`
});
}
next();
}
```
### User limit check
Applied on `POST /api/usuarios/invite` (already partially exists):
```typescript
const userCount = await getUserCountForTenant(tenantId);
if (userCount >= tenant.usersLimit && tenant.usersLimit !== -1) {
return res.status(403).json({
message: `Límite de usuarios alcanzado (${userCount}/${tenant.usersLimit}).`
});
}
```
### Feature gating
Applied per route using the existing `hasFeature()` function from shared:
```typescript
function requireFeature(feature: string) {
return async (req, res, next) => {
const tenant = await getTenantWithCache(req.user.tenantId);
if (!hasFeature(tenant.plan, feature)) {
return res.status(403).json({
message: 'Tu plan no incluye esta función. Contacta soporte para upgrade.'
});
}
next();
};
}
// Usage in routes:
router.get('/reportes', authenticate, requireFeature('reportes'), reportesController);
router.get('/alertas', authenticate, requireFeature('alertas'), alertasController);
```
### Feature matrix
| Feature key | Starter | Business | Professional | Enterprise |
|-------------|---------|----------|-------------|------------|
| dashboard | Yes | Yes | Yes | Yes |
| cfdi_basic | Yes | Yes | Yes | Yes |
| iva_isr | Yes | Yes | Yes | Yes |
| reportes | No | Yes | Yes | Yes |
| alertas | No | Yes | Yes | Yes |
| calendario | No | Yes | Yes | Yes |
| xml_sat | No | No | Yes | Yes |
| conciliacion | No | No | Yes | Yes |
| forecasting | No | No | Yes | Yes |
| multi_empresa | No | No | No | Yes |
| api_externa | No | No | No | Yes |
### Frontend feature gating
The sidebar/navigation hides menu items based on plan:
```typescript
const tenant = useTenantInfo(); // new hook
const menuItems = allMenuItems.filter(item =>
!item.requiredFeature || hasFeature(tenant.plan, item.requiredFeature)
);
```
Pages also show an "upgrade" message if accessed directly via URL without the required plan.
### Caching
Plan checks and CFDI counts are cached in-memory with 5-minute TTL to avoid database queries on every request.
**Cache invalidation across PM2 workers:** Since each PM2 cluster worker has its own in-memory cache, subscription status changes (via webhook) must invalidate the cache in all workers. The webhook handler writes the status to the DB, then sends a `process.send()` message to the PM2 master which broadcasts to all workers to invalidate the specific tenant's cache entry. This ensures all workers reflect subscription changes within seconds, not minutes.
---
## Architecture Diagram
```
┌─────────────────────┐
│ Nginx (443/80) │
│ SSL + Rate Limit │
└──────────┬──────────┘
┌──────────────┼──────────────┐
│ │ │
┌─────▼─────┐ ┌────▼────┐ ┌──────▼──────┐
│ Next.js │ │ Express │ │ Webhook │
│ :3000 │ │ API x2 │ │ Handler │
│ (fork) │ │ :4000 │ │ (no auth) │
└───────────┘ │ (cluster)│ └──────┬──────┘
└────┬────┘ │
│ │
┌─────────▼──────────┐ │
│ TenantConnection │ │
│ Manager │ │
│ (pool per tenant) │ │
└─────────┬──────────┘ │
│ │
┌──────────────────┼──────┐ │
│ │ │ │
┌─────▼─────┐ ┌───────▼┐ ┌──▼──┐ │
│ horux360 │ │horux_ │ │horux│ │
│ (central) │ │client1 │ │_... │ │
│ │ └────────┘ └─────┘ │
│ tenants │ │
│ users │◄────────────────────────┘
│ subs │ (webhook updates)
│ payments │
└───────────┘
┌───────────────┐ ┌─────────────┐
│ /var/horux/ │ │ Gmail SMTP │
│ fiel/<rfc>/ │ │ @horuxfin │
│ backups/ │ └─────────────┘
└───────────────┘
┌───────────────┐
│ MercadoPago │
│ Preapproval │
│ API │
└───────────────┘
```
---
## Out of Scope
- Landing page (already exists separately)
- Self-service registration (clients are registered manually by admin)
- Automatic SAT connector (manual FIEL linking for now)
- Plan change by client (admin handles upgrades/downgrades)
- Mobile app
- Multi-region deployment

View File

@@ -2,35 +2,35 @@ module.exports = {
apps: [
{
name: 'horux-api',
interpreter: 'node',
script: '/root/Horux/node_modules/.pnpm/tsx@4.21.0/node_modules/tsx/dist/cli.mjs',
args: 'src/index.ts',
cwd: '/root/Horux/apps/api',
script: 'pnpm',
args: 'dev',
interpreter: 'none',
watch: false,
instances: 1,
exec_mode: 'fork',
autorestart: true,
restart_delay: 10000,
max_restarts: 3,
max_memory_restart: '1G',
kill_timeout: 5000,
listen_timeout: 10000,
env: {
NODE_ENV: 'development',
PORT: 4000
}
NODE_ENV: 'production',
PORT: 4000,
},
},
{
name: 'horux-web',
script: 'node_modules/next/dist/bin/next',
args: 'start',
cwd: '/root/Horux/apps/web',
script: 'pnpm',
args: 'dev',
interpreter: 'none',
watch: false,
instances: 1,
exec_mode: 'fork',
autorestart: true,
restart_delay: 10000,
max_restarts: 3,
max_memory_restart: '512M',
kill_timeout: 5000,
env: {
NODE_ENV: 'development',
PORT: 3000
}
}
]
NODE_ENV: 'production',
PORT: 3000,
},
},
],
};

View File

@@ -29,6 +29,7 @@ export interface UserInfo {
tenantId: string;
tenantName: string;
tenantRfc: string;
plan: string;
}
export interface JWTPayload {
@@ -36,7 +37,6 @@ export interface JWTPayload {
email: string;
role: Role;
tenantId: string;
schemaName: string;
iat?: number;
exp?: number;
}

View File

@@ -5,7 +5,7 @@ export interface Tenant {
nombre: string;
rfc: string;
plan: Plan;
schemaName: string;
databaseName: string;
cfdiLimit: number;
usersLimit: number;
active: boolean;
@@ -20,3 +20,29 @@ export interface TenantUsage {
usersLimit: number;
plan: Plan;
}
export interface Subscription {
id: string;
tenantId: string;
plan: Plan;
mpPreapprovalId?: string;
status: 'pending' | 'authorized' | 'paused' | 'cancelled';
amount: number;
frequency: 'monthly' | 'yearly';
currentPeriodStart?: string;
currentPeriodEnd?: string;
createdAt: string;
updatedAt: string;
}
export interface Payment {
id: string;
tenantId: string;
subscriptionId?: string;
mpPaymentId?: string;
amount: number;
status: 'approved' | 'pending' | 'rejected' | 'refunded';
paymentMethod?: string;
paidAt?: string;
createdAt: string;
}

104
pnpm-lock.yaml generated
View File

@@ -62,12 +62,21 @@ importers:
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.3
mercadopago:
specifier: ^2.12.0
version: 2.12.0
node-cron:
specifier: ^4.2.1
version: 4.2.1
node-forge:
specifier: ^1.3.3
version: 1.3.3
nodemailer:
specifier: ^8.0.2
version: 8.0.2
pg:
specifier: ^8.18.0
version: 8.18.0
zod:
specifier: ^3.23.0
version: 3.25.76
@@ -96,6 +105,15 @@ importers:
'@types/node-forge':
specifier: ^1.3.14
version: 1.3.14
'@types/nodemailer':
specifier: ^7.0.11
version: 7.0.11
'@types/pg':
specifier: ^8.18.0
version: 8.18.0
express-rate-limit:
specifier: ^8.3.1
version: 8.3.1(express@4.22.1)
prisma:
specifier: ^5.22.0
version: 5.22.0
@@ -1103,9 +1121,15 @@ packages:
'@types/node@22.19.7':
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
'@types/nodemailer@7.0.11':
resolution: {integrity: sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==}
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
'@types/pg@8.18.0':
resolution: {integrity: sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
@@ -1536,6 +1560,12 @@ packages:
resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
engines: {node: '>=8.3.0'}
express-rate-limit@8.3.1:
resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express@4.22.1:
resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==}
engines: {node: '>= 0.10.0'}
@@ -1717,6 +1747,10 @@ packages:
iobuffer@5.4.0:
resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==}
ip-address@10.1.0:
resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==}
engines: {node: '>= 12'}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
@@ -1863,6 +1897,9 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
mercadopago@2.12.0:
resolution: {integrity: sha512-9S+ZB/Fltd4BV9/U79r7U/+LrYJP844kxxvtAlVbbeVmhOE9rZt0YhPy1GXO3Yf4XyQaHwZ/SCyL2kebAicaLw==}
merge-descriptors@1.0.3:
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
@@ -1945,6 +1982,15 @@ packages:
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
engines: {node: '>=6.0.0'}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-forge@1.3.3:
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
engines: {node: '>= 6.13.0'}
@@ -1952,6 +1998,10 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
nodemailer@8.0.2:
resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==}
engines: {node: '>=6.0.0'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -2422,6 +2472,9 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
traverse@0.3.9:
resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
@@ -2537,6 +2590,10 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -2544,6 +2601,12 @@ packages:
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
@@ -3367,8 +3430,18 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/nodemailer@7.0.11':
dependencies:
'@types/node': 22.19.7
'@types/pako@2.0.4': {}
'@types/pg@8.18.0':
dependencies:
'@types/node': 22.19.7
pg-protocol: 1.11.0
pg-types: 2.2.0
'@types/prop-types@15.7.15': {}
'@types/qs@6.14.0': {}
@@ -3834,6 +3907,11 @@ snapshots:
unzipper: 0.10.14
uuid: 8.3.2
express-rate-limit@8.3.1(express@4.22.1):
dependencies:
express: 4.22.1
ip-address: 10.1.0
express@4.22.1:
dependencies:
accepts: 1.3.8
@@ -4052,6 +4130,8 @@ snapshots:
iobuffer@5.4.0: {}
ip-address@10.1.0: {}
ipaddr.js@1.9.1: {}
is-binary-path@2.1.0:
@@ -4184,6 +4264,13 @@ snapshots:
media-typer@0.3.0: {}
mercadopago@2.12.0:
dependencies:
node-fetch: 2.7.0
uuid: 9.0.1
transitivePeerDependencies:
- encoding
merge-descriptors@1.0.3: {}
merge2@1.4.1: {}
@@ -4258,10 +4345,16 @@ snapshots:
node-cron@4.2.1: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-forge@1.3.3: {}
node-releases@2.0.27: {}
nodemailer@8.0.2: {}
normalize-path@3.0.0: {}
object-assign@4.1.1: {}
@@ -4749,6 +4842,8 @@ snapshots:
toidentifier@1.0.1: {}
tr46@0.0.3: {}
traverse@0.3.9: {}
ts-interface-checker@0.1.13: {}
@@ -4850,6 +4945,8 @@ snapshots:
uuid@8.3.2: {}
uuid@9.0.1: {}
vary@1.1.2: {}
victory-vendor@36.9.2:
@@ -4869,6 +4966,13 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
wmf@1.0.2: {}
word@0.3.0: {}

71
scripts/backup.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/bin/bash
# Horux360 Database Backup Script
# Backs up all databases (central + tenant) with daily/weekly rotation
# Requires: .pgpass file at /root/.pgpass with format: localhost:5432:*:postgres:<password>
#
# Usage: Add to crontab:
# 0 1 * * * /root/Horux/scripts/backup.sh >> /var/log/horux-backup.log 2>&1
set -euo pipefail
BACKUP_DIR="/var/horux/backups"
DAILY_DIR="$BACKUP_DIR/daily"
WEEKLY_DIR="$BACKUP_DIR/weekly"
PG_USER="postgres"
PG_HOST="localhost"
DATE=$(date +%Y-%m-%d)
DAY_OF_WEEK=$(date +%u)
# Retention
DAILY_KEEP=7
WEEKLY_KEEP=4
echo "=== Horux360 Backup Started: $(date) ==="
# Create directories
mkdir -p "$DAILY_DIR" "$WEEKLY_DIR"
# Get list of all horux databases (central + tenant)
DATABASES=$(psql -h "$PG_HOST" -U "$PG_USER" -t -c \
"SELECT datname FROM pg_database WHERE datname = 'horux360' OR datname LIKE 'horux_%' AND datname NOT LIKE '%_deleted_%'" \
| tr -d ' ')
TOTAL=0
ERRORS=0
for DB in $DATABASES; do
echo "Backing up: $DB"
DUMP_FILE="$DAILY_DIR/${DB}_${DATE}.sql.gz"
if pg_dump -h "$PG_HOST" -U "$PG_USER" "$DB" | gzip > "$DUMP_FILE"; then
# Verify file is not empty
if [ -s "$DUMP_FILE" ]; then
TOTAL=$((TOTAL + 1))
echo " OK: $(du -h "$DUMP_FILE" | cut -f1)"
else
echo " WARNING: Empty backup file for $DB"
rm -f "$DUMP_FILE"
ERRORS=$((ERRORS + 1))
fi
else
echo " ERROR: Failed to backup $DB"
ERRORS=$((ERRORS + 1))
fi
done
# Weekly backup on Sundays (day 7)
if [ "$DAY_OF_WEEK" -eq 7 ]; then
echo "Creating weekly backup..."
cp "$DAILY_DIR"/*_"${DATE}".sql.gz "$WEEKLY_DIR/" 2>/dev/null || true
fi
# Clean old daily backups
echo "Cleaning daily backups older than $DAILY_KEEP days..."
find "$DAILY_DIR" -name "*.sql.gz" -mtime "+$DAILY_KEEP" -delete
# Clean old weekly backups
echo "Cleaning weekly backups older than $WEEKLY_KEEP weeks..."
find "$WEEKLY_DIR" -name "*.sql.gz" -mtime "+$((WEEKLY_KEEP * 7))" -delete
echo "=== Backup Complete: $TOTAL databases backed up, $ERRORS errors ==="
echo "=== Finished: $(date) ==="

27
scripts/tune-postgres.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# PostgreSQL Production Tuning for Horux360
# Server: 32GB RAM, 8 cores
# Target: 50 tenants, PM2 cluster ×2
#
# Run once: sudo bash scripts/tune-postgres.sh
set -euo pipefail
echo "=== PostgreSQL Production Tuning ==="
sudo -u postgres psql -c "ALTER SYSTEM SET max_connections = 300;"
sudo -u postgres psql -c "ALTER SYSTEM SET shared_buffers = '4GB';"
sudo -u postgres psql -c "ALTER SYSTEM SET work_mem = '16MB';"
sudo -u postgres psql -c "ALTER SYSTEM SET effective_cache_size = '16GB';"
sudo -u postgres psql -c "ALTER SYSTEM SET maintenance_work_mem = '512MB';"
sudo -u postgres psql -c "ALTER SYSTEM SET checkpoint_completion_target = 0.9;"
sudo -u postgres psql -c "ALTER SYSTEM SET wal_buffers = '64MB';"
sudo -u postgres psql -c "ALTER SYSTEM SET random_page_cost = 1.1;"
echo "Settings applied. Restarting PostgreSQL..."
sudo systemctl restart postgresql
echo "Verifying settings..."
sudo -u postgres psql -c "SHOW max_connections; SHOW shared_buffers; SHOW work_mem;"
echo "=== Done ==="