Compare commits
50 Commits
DevMarlene
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
351b14a78c | ||
|
|
38626bd3e6 | ||
|
|
d22e898909 | ||
|
|
3ab6d8d3e9 | ||
|
|
c351b5aeda | ||
|
|
b977f92141 | ||
|
|
69d7590834 | ||
|
|
6fc81b1c0d | ||
|
|
bcabbd4959 | ||
|
|
12dda005af | ||
|
|
d8f9f92389 | ||
|
|
96e1ea554c | ||
|
|
b064f15404 | ||
|
|
7eaeefa09d | ||
|
|
2b5a856845 | ||
|
|
8bfb8912c1 | ||
|
|
d6b86dbbd3 | ||
|
|
f96a9c55c5 | ||
|
|
0d17fe3494 | ||
|
|
22543589c3 | ||
|
|
536a5abd33 | ||
|
|
3c9268ea30 | ||
|
|
c44e7cea34 | ||
|
|
2994de4ce0 | ||
|
|
562e23d8bf | ||
|
|
08a7312761 | ||
|
|
0e49c0922d | ||
|
|
5c6367839f | ||
|
|
8ddb60d6c1 | ||
|
|
e132c2ba14 | ||
|
|
29ac067a82 | ||
|
|
8c3fb76406 | ||
|
|
5ff5629cd8 | ||
|
|
2bbab12627 | ||
|
|
cdb6f0c94e | ||
|
|
3beee1c174 | ||
|
|
837831ccd4 | ||
|
|
f9d2161938 | ||
|
|
427c94fb9d | ||
|
|
266e547eb5 | ||
|
|
ebd099f596 | ||
|
|
8c0bc799d3 | ||
|
|
6109294811 | ||
|
|
67f74538b8 | ||
|
|
3466ec740e | ||
|
|
3098a40356 | ||
|
|
34864742d8 | ||
|
|
1fe462764f | ||
|
|
ba012254db | ||
|
|
dcc33af523 |
167
README.md
167
README.md
@@ -4,40 +4,80 @@ Plataforma de análisis financiero y gestión fiscal para empresas mexicanas.
|
|||||||
|
|
||||||
## Descripción
|
## 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
|
- Controlar IVA e ISR automáticamente
|
||||||
|
- Sincronizar CFDIs directamente con el SAT usando FIEL
|
||||||
- Visualizar dashboards financieros en tiempo real
|
- Visualizar dashboards financieros en tiempo real
|
||||||
- Realizar conciliación bancaria
|
- Realizar conciliación bancaria
|
||||||
- Recibir alertas fiscales proactivas
|
- Recibir alertas fiscales proactivas
|
||||||
- Generar reportes y proyecciones financieras
|
- Generar reportes y proyecciones financieras
|
||||||
|
- Calendario de obligaciones fiscales
|
||||||
|
|
||||||
## Stack Tecnológico
|
## Stack Tecnológico
|
||||||
|
|
||||||
- **Frontend:** Next.js 14 + TypeScript + Tailwind CSS
|
| Capa | Tecnología |
|
||||||
- **Backend:** Node.js + Express + TypeScript
|
|------|-----------|
|
||||||
- **Base de datos:** PostgreSQL (multi-tenant por schema)
|
| **Frontend** | Next.js 14 + TypeScript + Tailwind CSS + shadcn/ui |
|
||||||
- **Autenticación:** JWT personalizado
|
| **Backend** | Node.js + Express + TypeScript + tsx |
|
||||||
- **Estado:** Zustand con persistencia
|
| **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
|
## Estructura del Proyecto
|
||||||
|
|
||||||
```
|
```
|
||||||
horux360/
|
horux360/
|
||||||
├── apps/
|
├── 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
|
│ └── 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/
|
├── 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/
|
├── docs/
|
||||||
|
│ ├── architecture/ # Docs técnicos
|
||||||
|
│ ├── security/ # Auditorías de seguridad
|
||||||
│ └── plans/ # Documentación de diseño
|
│ └── 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
|
## Planes
|
||||||
|
|
||||||
@@ -45,50 +85,113 @@ horux360/
|
|||||||
|------|----------|----------|-----------------|
|
|------|----------|----------|-----------------|
|
||||||
| Starter | 100 | 1 | Dashboard, IVA/ISR, CFDI básico |
|
| Starter | 100 | 1 | Dashboard, IVA/ISR, CFDI básico |
|
||||||
| Business | 500 | 3 | + Reportes, Alertas, Calendario |
|
| 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 |
|
| Enterprise | Ilimitado | Ilimitado | + API, Multi-empresa |
|
||||||
|
|
||||||
## Características Destacadas
|
## Seguridad
|
||||||
|
|
||||||
- **4 Temas visuales:** Light, Vibrant, Corporate, Dark
|
- JWT con access token (15min) y refresh token rotation (7d)
|
||||||
- **Multi-tenant:** Aislamiento de datos por empresa (schema por tenant)
|
- bcrypt con 12 salt rounds para passwords
|
||||||
- **Responsive:** Funciona en desktop y móvil
|
- Rate limiting en auth (10 login/15min, 3 register/hora)
|
||||||
- **Tiempo real:** Dashboards actualizados al instante
|
- FIEL encriptada con AES-256-GCM
|
||||||
- **Carga masiva de XML:** Soporte para carga de hasta 300MB de archivos XML
|
- CSP, HSTS, y security headers vía Nginx + Helmet
|
||||||
- **Selector de período:** Navegación por mes/año en todos los dashboards
|
- Admin global verificado por RFC (no solo por rol)
|
||||||
- **Clasificación automática:** Ingresos/egresos basado en RFC del tenant
|
- 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
|
```env
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
PORT=4000
|
PORT=4000
|
||||||
DATABASE_URL="postgresql://user:pass@localhost:5432/horux360"
|
DATABASE_URL="postgresql://user:pass@localhost:5432/horux360"
|
||||||
JWT_SECRET=your-secret-key
|
JWT_SECRET=<min-32-chars>
|
||||||
JWT_EXPIRES_IN=15m
|
JWT_EXPIRES_IN=15m
|
||||||
JWT_REFRESH_EXPIRES_IN=7d
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
CORS_ORIGIN=http://localhost:3000
|
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
|
```env
|
||||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api
|
NEXT_PUBLIC_API_URL=http://localhost:4000/api
|
||||||
```
|
```
|
||||||
|
|
||||||
## Demo
|
## Roles
|
||||||
|
|
||||||
Credenciales de demo:
|
| Rol | Acceso |
|
||||||
- **Admin:** admin@demo.com / demo123
|
|-----|--------|
|
||||||
- **Contador:** contador@demo.com / demo123
|
| **admin** | Todo dentro de su tenant + invitar usuarios |
|
||||||
- **Visor:** visor@demo.com / demo123
|
| **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
|
## 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)
|
### 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
|
- Selector de período mes/año en dashboards
|
||||||
- Fix: Persistencia de sesión en refresh de página
|
- Fix: Persistencia de sesión en refresh de página
|
||||||
- Fix: Clasificación ingreso/egreso basada en RFC
|
- Fix: Clasificación ingreso/egreso basada en RFC
|
||||||
|
|||||||
@@ -28,8 +28,11 @@
|
|||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mercadopago": "^2.12.0",
|
||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
|
"nodemailer": "^8.0.2",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"zod": "^3.23.0"
|
"zod": "^3.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -41,6 +44,9 @@
|
|||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/node-forge": "^1.3.14",
|
"@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",
|
"prisma": "^5.22.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ model Tenant {
|
|||||||
nombre String
|
nombre String
|
||||||
rfc String @unique
|
rfc String @unique
|
||||||
plan Plan @default(starter)
|
plan Plan @default(starter)
|
||||||
schemaName String @unique @map("schema_name")
|
databaseName String @unique @map("database_name")
|
||||||
cfdiLimit Int @map("cfdi_limit")
|
cfdiLimit Int @default(100) @map("cfdi_limit")
|
||||||
usersLimit Int @map("users_limit")
|
usersLimit Int @default(1) @map("users_limit")
|
||||||
active Boolean @default(true)
|
active Boolean @default(true)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
expiresAt DateTime? @map("expires_at")
|
expiresAt DateTime? @map("expires_at")
|
||||||
@@ -22,6 +22,8 @@ model Tenant {
|
|||||||
users User[]
|
users User[]
|
||||||
fielCredential FielCredential?
|
fielCredential FielCredential?
|
||||||
satSyncJobs SatSyncJob[]
|
satSyncJobs SatSyncJob[]
|
||||||
|
subscriptions Subscription[]
|
||||||
|
payments Payment[]
|
||||||
|
|
||||||
@@map("tenants")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
@@ -76,8 +78,12 @@ model FielCredential {
|
|||||||
cerData Bytes @map("cer_data")
|
cerData Bytes @map("cer_data")
|
||||||
keyData Bytes @map("key_data")
|
keyData Bytes @map("key_data")
|
||||||
keyPasswordEncrypted Bytes @map("key_password_encrypted")
|
keyPasswordEncrypted Bytes @map("key_password_encrypted")
|
||||||
encryptionIv Bytes @map("encryption_iv")
|
cerIv Bytes @map("cer_iv")
|
||||||
encryptionTag Bytes @map("encryption_tag")
|
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)
|
serialNumber String? @map("serial_number") @db.VarChar(50)
|
||||||
validFrom DateTime @map("valid_from")
|
validFrom DateTime @map("valid_from")
|
||||||
validUntil DateTime @map("valid_until")
|
validUntil DateTime @map("valid_until")
|
||||||
@@ -90,6 +96,46 @@ model FielCredential {
|
|||||||
@@map("fiel_credentials")
|
@@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 {
|
model SatSyncJob {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tenantId String @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
|
|||||||
26
apps/api/scripts/create-carlos.ts
Normal file
26
apps/api/scripts/create-carlos.ts
Normal 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); });
|
||||||
82
apps/api/scripts/decrypt-fiel.ts
Normal file
82
apps/api/scripts/decrypt-fiel.ts
Normal 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);
|
||||||
|
});
|
||||||
96
apps/api/scripts/test-emails.ts
Normal file
96
apps/api/scripts/test-emails.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import express from 'express';
|
import express, { type Express } from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { env, getCorsOrigins } from './config/env.js';
|
import { env, getCorsOrigins } from './config/env.js';
|
||||||
@@ -15,8 +15,10 @@ import { usuariosRoutes } from './routes/usuarios.routes.js';
|
|||||||
import { tenantsRoutes } from './routes/tenants.routes.js';
|
import { tenantsRoutes } from './routes/tenants.routes.js';
|
||||||
import fielRoutes from './routes/fiel.routes.js';
|
import fielRoutes from './routes/fiel.routes.js';
|
||||||
import satRoutes from './routes/sat.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();
|
const app: Express = express();
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
@@ -25,9 +27,9 @@ app.use(cors({
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Body parsing - increased limit for bulk XML uploads (1GB)
|
// Body parsing - 10MB default, bulk CFDI route has its own higher limit
|
||||||
app.use(express.json({ limit: '1gb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '1gb' }));
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
@@ -47,6 +49,8 @@ app.use('/api/usuarios', usuariosRoutes);
|
|||||||
app.use('/api/tenants', tenantsRoutes);
|
app.use('/api/tenants', tenantsRoutes);
|
||||||
app.use('/api/fiel', fielRoutes);
|
app.use('/api/fiel', fielRoutes);
|
||||||
app.use('/api/sat', satRoutes);
|
app.use('/api/sat', satRoutes);
|
||||||
|
app.use('/api/webhooks', webhookRoutes);
|
||||||
|
app.use('/api/subscriptions', subscriptionRoutes);
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use(errorMiddleware);
|
app.use(errorMiddleware);
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { Pool, type PoolConfig } from 'pg';
|
||||||
|
import { env } from './env.js';
|
||||||
|
|
||||||
|
// ===========================================
|
||||||
|
// Prisma Client (central database: horux360)
|
||||||
|
// ===========================================
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var prisma: PrismaClient | undefined;
|
var prisma: PrismaClient | undefined;
|
||||||
@@ -11,3 +17,303 @@ export const prisma = globalThis.prisma || new PrismaClient({
|
|||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
globalThis.prisma = prisma;
|
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();
|
||||||
|
|||||||
@@ -13,12 +13,29 @@ const envSchema = z.object({
|
|||||||
JWT_EXPIRES_IN: z.string().default('15m'),
|
JWT_EXPIRES_IN: z.string().default('15m'),
|
||||||
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
||||||
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
||||||
});
|
|
||||||
|
|
||||||
// Parse CORS origins (comma-separated) into array
|
// Frontend URL (for MercadoPago back_url, emails, etc.)
|
||||||
export function getCorsOrigins(): string[] {
|
FRONTEND_URL: z.string().default('https://horuxfin.com'),
|
||||||
return parsed.data.CORS_ORIGIN.split(',').map(origin => origin.trim());
|
|
||||||
}
|
// 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);
|
const parsed = envSchema.safeParse(process.env);
|
||||||
|
|
||||||
@@ -28,3 +45,8 @@ if (!parsed.success) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const env = parsed.data;
|
export const env = parsed.data;
|
||||||
|
|
||||||
|
// Parse CORS origins (comma-separated) into array
|
||||||
|
export function getCorsOrigins(): string[] {
|
||||||
|
return env.CORS_ORIGIN.split(',').map(origin => origin.trim());
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
import * as alertasService from '../services/alertas.service.js';
|
||||||
|
|
||||||
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
|
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const { leida, resuelta, prioridad } = req.query;
|
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,
|
leida: leida === 'true' ? true : leida === 'false' ? false : undefined,
|
||||||
resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined,
|
resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined,
|
||||||
prioridad: prioridad as string,
|
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) {
|
export async function getAlerta(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(req.params.id));
|
const alerta = await alertasService.getAlertaById(req.tenantPool!, parseInt(String(req.params.id)));
|
||||||
if (!alerta) {
|
if (!alerta) {
|
||||||
return res.status(404).json({ message: 'Alerta no encontrada' });
|
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) {
|
export async function createAlerta(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const alerta = await alertasService.createAlerta(req.tenantSchema!, req.body);
|
const alerta = await alertasService.createAlerta(req.tenantPool!, req.body);
|
||||||
res.status(201).json(alerta);
|
res.status(201).json(alerta);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function updateAlerta(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(req.params.id), req.body);
|
const alerta = await alertasService.updateAlerta(req.tenantPool!, parseInt(String(req.params.id)), req.body);
|
||||||
res.json(alerta);
|
res.json(alerta);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function deleteAlerta(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
await alertasService.deleteAlerta(req.tenantSchema!, parseInt(req.params.id));
|
await alertasService.deleteAlerta(req.tenantPool!, parseInt(String(req.params.id)));
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function getStats(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const stats = await alertasService.getStats(req.tenantSchema!);
|
const stats = await alertasService.getStats(req.tenantPool!);
|
||||||
res.json(stats);
|
res.json(stats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function markAllAsRead(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
await alertasService.markAllAsRead(req.tenantSchema!);
|
await alertasService.markAllAsRead(req.tenantPool!);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -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';
|
import * as calendarioService from '../services/calendario.service.js';
|
||||||
|
|
||||||
export async function getEventos(req: Request, res: Response, next: NextFunction) {
|
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 añoNum = parseInt(año as string) || new Date().getFullYear();
|
||||||
const mesNum = mes ? parseInt(mes as string) : undefined;
|
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);
|
res.json(eventos);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function getProximos(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const dias = parseInt(req.query.dias as string) || 30;
|
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);
|
res.json(eventos);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function createEvento(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const evento = await calendarioService.createEvento(req.tenantSchema!, req.body);
|
const evento = await calendarioService.createEvento(req.tenantPool!, req.body);
|
||||||
res.status(201).json(evento);
|
res.status(201).json(evento);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function updateEvento(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(req.params.id), req.body);
|
const evento = await calendarioService.updateEvento(req.tenantPool!, parseInt(String(req.params.id)), req.body);
|
||||||
res.json(evento);
|
res.json(evento);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function deleteEvento(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
await calendarioService.deleteEvento(req.tenantSchema!, parseInt(req.params.id));
|
await calendarioService.deleteEvento(req.tenantPool!, parseInt(String(req.params.id)));
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import type { CfdiFilters } from '@horux/shared';
|
|||||||
|
|
||||||
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
|
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters: CfdiFilters = {
|
const filters: CfdiFilters = {
|
||||||
@@ -15,12 +15,14 @@ export async function getCfdis(req: Request, res: Response, next: NextFunction)
|
|||||||
fechaInicio: req.query.fechaInicio as string,
|
fechaInicio: req.query.fechaInicio as string,
|
||||||
fechaFin: req.query.fechaFin as string,
|
fechaFin: req.query.fechaFin as string,
|
||||||
rfc: req.query.rfc as string,
|
rfc: req.query.rfc as string,
|
||||||
|
emisor: req.query.emisor as string,
|
||||||
|
receptor: req.query.receptor as string,
|
||||||
search: req.query.search as string,
|
search: req.query.search as string,
|
||||||
page: parseInt(req.query.page as string) || 1,
|
page: parseInt(req.query.page as string) || 1,
|
||||||
limit: parseInt(req.query.limit as string) || 20,
|
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);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -29,11 +31,11 @@ export async function getCfdis(req: Request, res: Response, next: NextFunction)
|
|||||||
|
|
||||||
export async function getCfdiById(req: Request, res: Response, next: NextFunction) {
|
export async function getCfdiById(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfdi = await cfdiService.getCfdiById(req.tenantSchema, req.params.id);
|
const cfdi = await cfdiService.getCfdiById(req.tenantPool, String(req.params.id));
|
||||||
|
|
||||||
if (!cfdi) {
|
if (!cfdi) {
|
||||||
return next(new AppError(404, 'CFDI no encontrado'));
|
return next(new AppError(404, 'CFDI no encontrado'));
|
||||||
@@ -45,16 +47,72 @@ export async function getCfdiById(req: Request, res: Response, next: NextFunctio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getXml(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantPool) {
|
||||||
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = await cfdiService.getXmlById(req.tenantPool, String(req.params.id));
|
||||||
|
|
||||||
|
if (!xml) {
|
||||||
|
return next(new AppError(404, 'XML no encontrado para este CFDI'));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set('Content-Type', 'application/xml');
|
||||||
|
res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`);
|
||||||
|
res.send(xml);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmisores(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantPool) {
|
||||||
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = (req.query.search as string) || '';
|
||||||
|
if (search.length < 2) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emisores = await cfdiService.getEmisores(req.tenantPool, search);
|
||||||
|
res.json(emisores);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReceptores(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantPool) {
|
||||||
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = (req.query.search as string) || '';
|
||||||
|
if (search.length < 2) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const receptores = await cfdiService.getReceptores(req.tenantPool, search);
|
||||||
|
res.json(receptores);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getResumen(req: Request, res: Response, next: NextFunction) {
|
export async function getResumen(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
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 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);
|
res.json(resumen);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -63,16 +121,15 @@ export async function getResumen(req: Request, res: Response, next: NextFunction
|
|||||||
|
|
||||||
export async function createCfdi(req: Request, res: Response, next: NextFunction) {
|
export async function createCfdi(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only admin and contador can create CFDIs
|
|
||||||
if (!['admin', 'contador'].includes(req.user!.role)) {
|
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||||
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
|
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);
|
res.status(201).json(cfdi);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message?.includes('duplicate')) {
|
if (error.message?.includes('duplicate')) {
|
||||||
@@ -84,8 +141,8 @@ export async function createCfdi(req: Request, res: Response, next: NextFunction
|
|||||||
|
|
||||||
export async function createManyCfdis(req: Request, res: Response, next: NextFunction) {
|
export async function createManyCfdis(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['admin', 'contador'].includes(req.user!.role)) {
|
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||||
@@ -102,9 +159,9 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
|
|||||||
totalFiles: req.body.totalFiles || req.body.cfdis.length
|
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({
|
res.status(201).json({
|
||||||
message: `Lote ${batchInfo.batchNumber} procesado`,
|
message: `Lote ${batchInfo.batchNumber} procesado`,
|
||||||
@@ -113,7 +170,7 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
|
|||||||
inserted: result.inserted,
|
inserted: result.inserted,
|
||||||
duplicates: result.duplicates,
|
duplicates: result.duplicates,
|
||||||
errors: result.errors,
|
errors: result.errors,
|
||||||
errorMessages: result.errorMessages.slice(0, 5) // Limit error messages
|
errorMessages: result.errorMessages.slice(0, 5)
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[CFDI Bulk Error]', error.message, error.stack);
|
console.error('[CFDI Bulk Error]', error.message, error.stack);
|
||||||
@@ -123,15 +180,15 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
|
|||||||
|
|
||||||
export async function deleteCfdi(req: Request, res: Response, next: NextFunction) {
|
export async function deleteCfdi(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['admin', 'contador'].includes(req.user!.role)) {
|
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||||
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
|
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
|
||||||
}
|
}
|
||||||
|
|
||||||
await cfdiService.deleteCfdi(req.tenantSchema, req.params.id);
|
await cfdiService.deleteCfdi(req.tenantPool, String(req.params.id));
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { AppError } from '../middlewares/error.middleware.js';
|
|||||||
|
|
||||||
export async function getKpis(req: Request, res: Response, next: NextFunction) {
|
export async function getKpis(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
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 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);
|
res.json(kpis);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function getIngresosEgresos(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
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);
|
res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function getResumenFiscal(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
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 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);
|
res.json(resumen);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = parseInt(req.query.limit as string) || 5;
|
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);
|
res.json(alertas);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -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';
|
import * as exportService from '../services/export.service.js';
|
||||||
|
|
||||||
export async function exportCfdis(req: Request, res: Response, next: NextFunction) {
|
export async function exportCfdis(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const { tipo, estado, fechaInicio, fechaFin } = req.query;
|
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,
|
tipo: tipo as string,
|
||||||
estado: estado as string,
|
estado: estado as string,
|
||||||
fechaInicio: fechaInicio 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 fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||||
|
|
||||||
const buffer = await exportService.exportReporteToExcel(
|
const buffer = await exportService.exportReporteToExcel(
|
||||||
req.tenantSchema!,
|
req.tenantPool!,
|
||||||
tipo as 'estado-resultados' | 'flujo-efectivo',
|
tipo as 'estado-resultados' | 'flujo-efectivo',
|
||||||
inicio,
|
inicio,
|
||||||
fin
|
fin
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ export async function upload(req: Request, res: Response): Promise<void> {
|
|||||||
return;
|
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);
|
const result = await uploadFiel(tenantId, cerFile, keyFile, password);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { AppError } from '../middlewares/error.middleware.js';
|
|||||||
|
|
||||||
export async function getIvaMensual(req: Request, res: Response, next: NextFunction) {
|
export async function getIvaMensual(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
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);
|
res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function getResumenIva(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
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 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);
|
res.json(resumen);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function getIsrMensual(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
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);
|
res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function getResumenIsr(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
if (!req.tenantSchema) {
|
if (!req.tenantPool) {
|
||||||
return next(new AppError(400, 'Schema no configurado'));
|
return next(new AppError(400, 'Tenant no configurado'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
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 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);
|
res.json(resumen);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -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';
|
import * as reportesService from '../services/reportes.service.js';
|
||||||
|
|
||||||
export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) {
|
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 inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
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.tenantPool!, inicio, fin);
|
||||||
const data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin);
|
|
||||||
res.json(data);
|
res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[reportes] Error en getEstadoResultados:', 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 inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
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);
|
res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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) {
|
export async function getComparativo(req: Request, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
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);
|
res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(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 fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||||
const tipoRfc = (tipo as 'cliente' | 'proveedor') || 'cliente';
|
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);
|
res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '../services/sat/sat.service.js';
|
} from '../services/sat/sat.service.js';
|
||||||
import { getJobInfo, runSatSyncJobManually } from '../jobs/sat-sync.job.js';
|
import { getJobInfo, runSatSyncJobManually } from '../jobs/sat-sync.job.js';
|
||||||
import type { StartSyncRequest } from '@horux/shared';
|
import type { StartSyncRequest } from '@horux/shared';
|
||||||
|
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inicia una sincronización manual
|
* 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> {
|
export async function cronInfo(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
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();
|
const info = getJobInfo();
|
||||||
res.json(info);
|
res.json(info);
|
||||||
} catch (error: any) {
|
} 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> {
|
export async function runCron(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
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
|
// Ejecutar en background
|
||||||
runSatSyncJobManually().catch(err =>
|
runSatSyncJobManually().catch(err =>
|
||||||
console.error('[SAT Controller] Error ejecutando cron manual:', err)
|
console.error('[SAT Controller] Error ejecutando cron manual:', err)
|
||||||
|
|||||||
68
apps/api/src/controllers/subscription.controller.ts
Normal file
68
apps/api/src/controllers/subscription.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export async function getTenant(req: Request, res: Response, next: NextFunction)
|
|||||||
throw new AppError(403, 'Solo administradores pueden ver detalles de clientes');
|
throw new AppError(403, 'Solo administradores pueden ver detalles de clientes');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenant = await tenantsService.getTenantById(req.params.id);
|
const tenant = await tenantsService.getTenantById(String(req.params.id));
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
throw new AppError(404, 'Cliente no encontrado');
|
throw new AppError(404, 'Cliente no encontrado');
|
||||||
}
|
}
|
||||||
@@ -39,21 +39,24 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
throw new AppError(403, 'Solo administradores pueden crear clientes');
|
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) {
|
if (!nombre || !rfc || !adminEmail || !adminNombre) {
|
||||||
throw new AppError(400, 'Nombre y RFC son requeridos');
|
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenant = await tenantsService.createTenant({
|
const result = await tenantsService.createTenant({
|
||||||
nombre,
|
nombre,
|
||||||
rfc,
|
rfc,
|
||||||
plan,
|
plan,
|
||||||
cfdiLimit,
|
cfdiLimit,
|
||||||
usersLimit,
|
usersLimit,
|
||||||
|
adminEmail,
|
||||||
|
adminNombre,
|
||||||
|
amount: amount || 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json(tenant);
|
res.status(201).json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -65,7 +68,7 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
throw new AppError(403, 'Solo administradores pueden editar clientes');
|
throw new AppError(403, 'Solo administradores pueden editar clientes');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.params;
|
const id = String(req.params.id);
|
||||||
const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body;
|
const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body;
|
||||||
|
|
||||||
const tenant = await tenantsService.updateTenant(id, {
|
const tenant = await tenantsService.updateTenant(id, {
|
||||||
@@ -89,7 +92,7 @@ export async function deleteTenant(req: Request, res: Response, next: NextFuncti
|
|||||||
throw new AppError(403, 'Solo administradores pueden eliminar clientes');
|
throw new AppError(403, 'Solo administradores pueden eliminar clientes');
|
||||||
}
|
}
|
||||||
|
|
||||||
await tenantsService.deleteTenant(req.params.id);
|
await tenantsService.deleteTenant(String(req.params.id));
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import * as usuariosService from '../services/usuarios.service.js';
|
import * as usuariosService from '../services/usuarios.service.js';
|
||||||
import { AppError } from '../utils/errors.js';
|
import { AppError } from '../utils/errors.js';
|
||||||
import { prisma } from '../config/database.js';
|
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
|
||||||
|
|
||||||
// RFC del tenant administrador global
|
|
||||||
const ADMIN_TENANT_RFC = 'CAS2408138W2';
|
|
||||||
|
|
||||||
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
||||||
if (req.user!.role !== 'admin') return false;
|
return checkGlobalAdmin(req.user!.tenantId, req.user!.role);
|
||||||
|
|
||||||
const tenant = await prisma.tenant.findUnique({
|
|
||||||
where: { id: req.user!.tenantId },
|
|
||||||
select: { rfc: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return tenant?.rfc === ADMIN_TENANT_RFC;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
|
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||||
|
|||||||
98
apps/api/src/controllers/webhook.controller.ts
Normal file
98
apps/api/src/controllers/webhook.controller.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { app } from './app.js';
|
import { app } from './app.js';
|
||||||
import { env } from './config/env.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';
|
import { startSatSyncJob } from './jobs/sat-sync.job.js';
|
||||||
|
|
||||||
const PORT = parseInt(env.PORT, 10);
|
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(`API Server running on http://0.0.0.0:${PORT}`);
|
||||||
console.log(`Environment: ${env.NODE_ENV}`);
|
console.log(`Environment: ${env.NODE_ENV}`);
|
||||||
|
|
||||||
@@ -13,3 +15,24 @@ app.listen(PORT, '0.0.0.0', () => {
|
|||||||
startSatSyncJob();
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
37
apps/api/src/middlewares/feature-gate.middleware.ts
Normal file
37
apps/api/src/middlewares/feature-gate.middleware.ts
Normal 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();
|
||||||
|
};
|
||||||
|
}
|
||||||
90
apps/api/src/middlewares/plan-limits.middleware.ts
Normal file
90
apps/api/src/middlewares/plan-limits.middleware.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -1,48 +1,91 @@
|
|||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { prisma } from '../config/database.js';
|
import type { Pool } from 'pg';
|
||||||
import { AppError } from './error.middleware.js';
|
import { prisma, tenantDb } from '../config/database.js';
|
||||||
|
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
interface Request {
|
interface Request {
|
||||||
tenantSchema?: string;
|
tenantPool?: Pool;
|
||||||
viewingTenantId?: string;
|
viewingTenantId?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
|
// Cache: tenantId -> { databaseName, expires }
|
||||||
if (!req.user) {
|
const tenantDbCache = new Map<string, { databaseName: string; expires: number }>();
|
||||||
return next(new AppError(401, 'No autenticado'));
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
async function getTenantDatabaseName(tenantId: string): Promise<string | null> {
|
||||||
// Check if admin is viewing a different tenant
|
const cached = tenantDbCache.get(tenantId);
|
||||||
const viewTenantId = req.headers['x-view-tenant'] as string | undefined;
|
if (cached && cached.expires > Date.now()) return cached.databaseName;
|
||||||
let tenantId = req.user.tenantId;
|
|
||||||
|
|
||||||
// Only admins can view other tenants
|
|
||||||
if (viewTenantId && req.user.role === 'admin') {
|
|
||||||
tenantId = viewTenantId;
|
|
||||||
req.viewingTenantId = viewTenantId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tenant = await prisma.tenant.findUnique({
|
const tenant = await prisma.tenant.findUnique({
|
||||||
where: { id: tenantId },
|
where: { id: tenantId },
|
||||||
select: { schemaName: true, active: true },
|
select: { databaseName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tenant || !tenant.active) {
|
if (tenant) {
|
||||||
return next(new AppError(403, 'Tenant no encontrado o inactivo'));
|
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
|
export function invalidateTenantDbCache(tenantId: string) {
|
||||||
await prisma.$executeRawUnsafe(`SET search_path TO "${tenant.schemaName}", public`);
|
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();
|
next();
|
||||||
} catch (error) {
|
} 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' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.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';
|
import * as alertasController from '../controllers/alertas.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
router.use(checkPlanLimits);
|
||||||
|
router.use(requireFeature('alertas'));
|
||||||
|
|
||||||
router.get('/', alertasController.getAlertas);
|
router.get('/', alertasController.getAlertas);
|
||||||
router.get('/stats', alertasController.getStats);
|
router.get('/stats', alertasController.getStats);
|
||||||
|
|||||||
@@ -1,13 +1,41 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
import * as authController from '../controllers/auth.controller.js';
|
import * as authController from '../controllers/auth.controller.js';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
|
|
||||||
const router = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.post('/register', authController.register);
|
// Rate limiting: 10 login attempts per 15 minutes per IP
|
||||||
router.post('/login', authController.login);
|
const loginLimiter = rateLimit({
|
||||||
router.post('/refresh', authController.refresh);
|
windowMs: 15 * 60 * 1000,
|
||||||
router.post('/logout', authController.logout);
|
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);
|
router.get('/me', authenticate, authController.me);
|
||||||
|
|
||||||
export { router as authRoutes };
|
export { router as authRoutes };
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.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';
|
import * as calendarioController from '../controllers/calendario.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
router.use(checkPlanLimits);
|
||||||
|
router.use(requireFeature('calendario'));
|
||||||
|
|
||||||
router.get('/', calendarioController.getEventos);
|
router.get('/', calendarioController.getEventos);
|
||||||
router.get('/proximos', calendarioController.getProximos);
|
router.get('/proximos', calendarioController.getProximos);
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
|
import express from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.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';
|
import * as cfdiController from '../controllers/cfdi.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
router.use(checkPlanLimits);
|
||||||
|
|
||||||
router.get('/', cfdiController.getCfdis);
|
router.get('/', cfdiController.getCfdis);
|
||||||
router.get('/resumen', cfdiController.getResumen);
|
router.get('/resumen', cfdiController.getResumen);
|
||||||
|
router.get('/emisores', cfdiController.getEmisores);
|
||||||
|
router.get('/receptores', cfdiController.getReceptores);
|
||||||
router.get('/:id', cfdiController.getCfdiById);
|
router.get('/:id', cfdiController.getCfdiById);
|
||||||
router.post('/', cfdiController.createCfdi);
|
router.get('/:id/xml', cfdiController.getXml);
|
||||||
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);
|
router.delete('/:id', cfdiController.deleteCfdi);
|
||||||
|
|
||||||
export { router as cfdiRoutes };
|
export { router as cfdiRoutes };
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.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';
|
import * as dashboardController from '../controllers/dashboard.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
router.use(checkPlanLimits);
|
||||||
|
|
||||||
router.get('/kpis', dashboardController.getKpis);
|
router.get('/kpis', dashboardController.getKpis);
|
||||||
router.get('/ingresos-egresos', dashboardController.getIngresosEgresos);
|
router.get('/ingresos-egresos', dashboardController.getIngresosEgresos);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||||
import * as exportController from '../controllers/export.controller.js';
|
import * as exportController from '../controllers/export.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import * as fielController from '../controllers/fiel.controller.js';
|
import * as fielController from '../controllers/fiel.controller.js';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
|
|
||||||
const router = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
// Todas las rutas requieren autenticación
|
// Todas las rutas requieren autenticación
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||||
import * as impuestosController from '../controllers/impuestos.controller.js';
|
import * as impuestosController from '../controllers/impuestos.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import { tenantMiddleware } from '../middlewares/tenant.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';
|
import * as reportesController from '../controllers/reportes.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(tenantMiddleware);
|
router.use(tenantMiddleware);
|
||||||
|
router.use(checkPlanLimits);
|
||||||
|
router.use(requireFeature('reportes'));
|
||||||
|
|
||||||
router.get('/estado-resultados', reportesController.getEstadoResultados);
|
router.get('/estado-resultados', reportesController.getEstadoResultados);
|
||||||
router.get('/flujo-efectivo', reportesController.getFlujoEfectivo);
|
router.get('/flujo-efectivo', reportesController.getFlujoEfectivo);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import * as satController from '../controllers/sat.controller.js';
|
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 = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
// Todas las rutas requieren autenticación
|
// Todas las rutas requieren autenticación
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
@@ -22,10 +22,8 @@ router.get('/sync/:id', satController.jobDetail);
|
|||||||
// POST /api/sat/sync/:id/retry - Reintentar job fallido
|
// POST /api/sat/sync/:id/retry - Reintentar job fallido
|
||||||
router.post('/sync/:id/retry', satController.retry);
|
router.post('/sync/:id/retry', satController.retry);
|
||||||
|
|
||||||
// GET /api/sat/cron - Información del job programado (admin)
|
// Admin-only cron endpoints (global admin verified in controller)
|
||||||
router.get('/cron', satController.cronInfo);
|
router.get('/cron', authorize('admin'), satController.cronInfo);
|
||||||
|
router.post('/cron/run', authorize('admin'), satController.runCron);
|
||||||
// POST /api/sat/cron/run - Ejecutar job manualmente (admin)
|
|
||||||
router.post('/cron/run', satController.runCron);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
17
apps/api/src/routes/subscription.routes.ts
Normal file
17
apps/api/src/routes/subscription.routes.ts
Normal 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 };
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import * as tenantsController from '../controllers/tenants.controller.js';
|
import * as tenantsController from '../controllers/tenants.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Router } from 'express';
|
import { Router, type IRouter } from 'express';
|
||||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||||
import * as usuariosController from '../controllers/usuarios.controller.js';
|
import * as usuariosController from '../controllers/usuarios.controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
|
|||||||
9
apps/api/src/routes/webhook.routes.ts
Normal file
9
apps/api/src/routes/webhook.routes.ts
Normal 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 };
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { prisma } from '../config/database.js';
|
import type { Pool } from 'pg';
|
||||||
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';
|
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';
|
||||||
|
|
||||||
export async function getAlertas(
|
export async function getAlertas(
|
||||||
schema: string,
|
pool: Pool,
|
||||||
filters: { leida?: boolean; resuelta?: boolean; prioridad?: string }
|
filters: { leida?: boolean; resuelta?: boolean; prioridad?: string }
|
||||||
): Promise<AlertaFull[]> {
|
): Promise<AlertaFull[]> {
|
||||||
let whereClause = 'WHERE 1=1';
|
let whereClause = 'WHERE 1=1';
|
||||||
@@ -22,43 +22,43 @@ export async function getAlertas(
|
|||||||
params.push(filters.prioridad);
|
params.push(filters.prioridad);
|
||||||
}
|
}
|
||||||
|
|
||||||
const alertas = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
const { rows } = await pool.query(`
|
||||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||||
fecha_vencimiento as "fechaVencimiento",
|
fecha_vencimiento as "fechaVencimiento",
|
||||||
leida, resuelta, created_at as "createdAt"
|
leida, resuelta, created_at as "createdAt"
|
||||||
FROM "${schema}".alertas
|
FROM alertas
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
||||||
created_at DESC
|
created_at DESC
|
||||||
`, ...params);
|
`, params);
|
||||||
|
|
||||||
return alertas;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAlertaById(schema: string, id: number): Promise<AlertaFull | null> {
|
export async function getAlertaById(pool: Pool, id: number): Promise<AlertaFull | null> {
|
||||||
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
const { rows } = await pool.query(`
|
||||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||||
fecha_vencimiento as "fechaVencimiento",
|
fecha_vencimiento as "fechaVencimiento",
|
||||||
leida, resuelta, created_at as "createdAt"
|
leida, resuelta, created_at as "createdAt"
|
||||||
FROM "${schema}".alertas
|
FROM alertas
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, id);
|
`, [id]);
|
||||||
return alerta || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAlerta(schema: string, data: AlertaCreate): Promise<AlertaFull> {
|
export async function createAlerta(pool: Pool, data: AlertaCreate): Promise<AlertaFull> {
|
||||||
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
const { rows } = await pool.query(`
|
||||||
INSERT INTO "${schema}".alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, tipo, titulo, mensaje, prioridad,
|
RETURNING id, tipo, titulo, mensaje, prioridad,
|
||||||
fecha_vencimiento as "fechaVencimiento",
|
fecha_vencimiento as "fechaVencimiento",
|
||||||
leida, resuelta, created_at as "createdAt"
|
leida, resuelta, created_at as "createdAt"
|
||||||
`, data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null);
|
`, [data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null]);
|
||||||
return alerta;
|
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 sets: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
@@ -74,35 +74,35 @@ export async function updateAlerta(schema: string, id: number, data: AlertaUpdat
|
|||||||
|
|
||||||
params.push(id);
|
params.push(id);
|
||||||
|
|
||||||
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
const { rows } = await pool.query(`
|
||||||
UPDATE "${schema}".alertas
|
UPDATE alertas
|
||||||
SET ${sets.join(', ')}
|
SET ${sets.join(', ')}
|
||||||
WHERE id = $${paramIndex}
|
WHERE id = $${paramIndex}
|
||||||
RETURNING id, tipo, titulo, mensaje, prioridad,
|
RETURNING id, tipo, titulo, mensaje, prioridad,
|
||||||
fecha_vencimiento as "fechaVencimiento",
|
fecha_vencimiento as "fechaVencimiento",
|
||||||
leida, resuelta, created_at as "createdAt"
|
leida, resuelta, created_at as "createdAt"
|
||||||
`, ...params);
|
`, params);
|
||||||
|
|
||||||
return alerta;
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAlerta(schema: string, id: number): Promise<void> {
|
export async function deleteAlerta(pool: Pool, id: number): Promise<void> {
|
||||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".alertas WHERE id = $1`, id);
|
await pool.query(`DELETE FROM alertas WHERE id = $1`, [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getStats(schema: string): Promise<AlertasStats> {
|
export async function getStats(pool: Pool): Promise<AlertasStats> {
|
||||||
const [stats] = await prisma.$queryRawUnsafe<AlertasStats[]>(`
|
const { rows: [stats] } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*)::int as total,
|
COUNT(*)::int as total,
|
||||||
COUNT(CASE WHEN leida = false THEN 1 END)::int as "noLeidas",
|
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 = '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 = 'media' AND resuelta = false THEN 1 END)::int as media,
|
||||||
COUNT(CASE WHEN prioridad = 'baja' AND resuelta = false THEN 1 END)::int as baja
|
COUNT(CASE WHEN prioridad = 'baja' AND resuelta = false THEN 1 END)::int as baja
|
||||||
FROM "${schema}".alertas
|
FROM alertas
|
||||||
`);
|
`);
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function markAllAsRead(schema: string): Promise<void> {
|
export async function markAllAsRead(pool: Pool): Promise<void> {
|
||||||
await prisma.$queryRawUnsafe(`UPDATE "${schema}".alertas SET leida = true WHERE leida = false`);
|
await pool.query(`UPDATE alertas SET leida = true WHERE leida = false`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { hashPassword, verifyPassword } from '../utils/password.js';
|
||||||
import { generateAccessToken, generateRefreshToken, verifyToken } from '../utils/token.js';
|
import { generateAccessToken, generateRefreshToken, verifyToken } from '../utils/token.js';
|
||||||
import { createTenantSchema } from '../utils/schema-manager.js';
|
|
||||||
import { AppError } from '../middlewares/error.middleware.js';
|
import { AppError } from '../middlewares/error.middleware.js';
|
||||||
import { PLANS } from '@horux/shared';
|
import { PLANS } from '@horux/shared';
|
||||||
import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared';
|
import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared';
|
||||||
|
|
||||||
export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser = await prisma.user.findUnique({
|
||||||
where: { email: data.usuario.email },
|
where: { email: data.usuario.email.toLowerCase() },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
@@ -23,21 +22,20 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
|||||||
throw new AppError(400, 'El RFC ya está registrado');
|
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({
|
const tenant = await prisma.tenant.create({
|
||||||
data: {
|
data: {
|
||||||
nombre: data.empresa.nombre,
|
nombre: data.empresa.nombre,
|
||||||
rfc: data.empresa.rfc.toUpperCase(),
|
rfc: data.empresa.rfc.toUpperCase(),
|
||||||
plan: 'starter',
|
plan: 'starter',
|
||||||
schemaName,
|
databaseName,
|
||||||
cfdiLimit: PLANS.starter.cfdiLimit,
|
cfdiLimit: PLANS.starter.cfdiLimit,
|
||||||
usersLimit: PLANS.starter.usersLimit,
|
usersLimit: PLANS.starter.usersLimit,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await createTenantSchema(schemaName);
|
|
||||||
|
|
||||||
const passwordHash = await hashPassword(data.usuario.password);
|
const passwordHash = await hashPassword(data.usuario.password);
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -54,7 +52,6 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
schemaName: tenant.schemaName,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const accessToken = generateAccessToken(tokenPayload);
|
const accessToken = generateAccessToken(tokenPayload);
|
||||||
@@ -79,6 +76,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
|||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
tenantName: tenant.nombre,
|
tenantName: tenant.nombre,
|
||||||
tenantRfc: tenant.rfc,
|
tenantRfc: tenant.rfc,
|
||||||
|
plan: tenant.plan,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -117,7 +115,6 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
tenantId: user.tenantId,
|
tenantId: user.tenantId,
|
||||||
schemaName: user.tenant.schemaName,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const accessToken = generateAccessToken(tokenPayload);
|
const accessToken = generateAccessToken(tokenPayload);
|
||||||
@@ -142,12 +139,15 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
|||||||
tenantId: user.tenantId,
|
tenantId: user.tenantId,
|
||||||
tenantName: user.tenant.nombre,
|
tenantName: user.tenant.nombre,
|
||||||
tenantRfc: user.tenant.rfc,
|
tenantRfc: user.tenant.rfc,
|
||||||
|
plan: user.tenant.plan,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> {
|
export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
const storedToken = await prisma.refreshToken.findUnique({
|
// Use a transaction to prevent race conditions
|
||||||
|
return await prisma.$transaction(async (tx) => {
|
||||||
|
const storedToken = await tx.refreshToken.findUnique({
|
||||||
where: { token },
|
where: { token },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,13 +156,13 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (storedToken.expiresAt < new Date()) {
|
if (storedToken.expiresAt < new Date()) {
|
||||||
await prisma.refreshToken.delete({ where: { id: storedToken.id } });
|
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
|
||||||
throw new AppError(401, 'Token expirado');
|
throw new AppError(401, 'Token expirado');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = verifyToken(token);
|
const payload = verifyToken(token);
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await tx.user.findUnique({
|
||||||
where: { id: payload.userId },
|
where: { id: payload.userId },
|
||||||
include: { tenant: true },
|
include: { tenant: true },
|
||||||
});
|
});
|
||||||
@@ -171,20 +171,20 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
|
|||||||
throw new AppError(401, 'Usuario no encontrado o desactivado');
|
throw new AppError(401, 'Usuario no encontrado o desactivado');
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.refreshToken.delete({ where: { id: storedToken.id } });
|
// Use deleteMany to avoid error if already deleted (race condition)
|
||||||
|
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
|
||||||
|
|
||||||
const newTokenPayload = {
|
const newTokenPayload = {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
tenantId: user.tenantId,
|
tenantId: user.tenantId,
|
||||||
schemaName: user.tenant.schemaName,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const accessToken = generateAccessToken(newTokenPayload);
|
const accessToken = generateAccessToken(newTokenPayload);
|
||||||
const refreshToken = generateRefreshToken(newTokenPayload);
|
const refreshToken = generateRefreshToken(newTokenPayload);
|
||||||
|
|
||||||
await prisma.refreshToken.create({
|
await tx.refreshToken.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
token: refreshToken,
|
token: refreshToken,
|
||||||
@@ -193,6 +193,7 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(token: string): Promise<void> {
|
export async function logout(token: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { prisma } from '../config/database.js';
|
import type { Pool } from 'pg';
|
||||||
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
|
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
|
||||||
|
|
||||||
export async function getEventos(
|
export async function getEventos(
|
||||||
schema: string,
|
pool: Pool,
|
||||||
año: number,
|
año: number,
|
||||||
mes?: number
|
mes?: number
|
||||||
): Promise<EventoFiscal[]> {
|
): Promise<EventoFiscal[]> {
|
||||||
@@ -14,49 +14,49 @@ export async function getEventos(
|
|||||||
params.push(mes);
|
params.push(mes);
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
const { rows } = await pool.query(`
|
||||||
SELECT id, titulo, descripcion, tipo,
|
SELECT id, titulo, descripcion, tipo,
|
||||||
fecha_limite as "fechaLimite",
|
fecha_limite as "fechaLimite",
|
||||||
recurrencia, completado, notas,
|
recurrencia, completado, notas,
|
||||||
created_at as "createdAt"
|
created_at as "createdAt"
|
||||||
FROM "${schema}".calendario_fiscal
|
FROM calendario_fiscal
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY fecha_limite ASC
|
ORDER BY fecha_limite ASC
|
||||||
`, ...params);
|
`, params);
|
||||||
|
|
||||||
return eventos;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProximosEventos(schema: string, dias = 30): Promise<EventoFiscal[]> {
|
export async function getProximosEventos(pool: Pool, dias = 30): Promise<EventoFiscal[]> {
|
||||||
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
const { rows } = await pool.query(`
|
||||||
SELECT id, titulo, descripcion, tipo,
|
SELECT id, titulo, descripcion, tipo,
|
||||||
fecha_limite as "fechaLimite",
|
fecha_limite as "fechaLimite",
|
||||||
recurrencia, completado, notas,
|
recurrencia, completado, notas,
|
||||||
created_at as "createdAt"
|
created_at as "createdAt"
|
||||||
FROM "${schema}".calendario_fiscal
|
FROM calendario_fiscal
|
||||||
WHERE completado = false
|
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
|
ORDER BY fecha_limite ASC
|
||||||
`);
|
`, [dias]);
|
||||||
|
|
||||||
return eventos;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createEvento(schema: string, data: EventoCreate): Promise<EventoFiscal> {
|
export async function createEvento(pool: Pool, data: EventoCreate): Promise<EventoFiscal> {
|
||||||
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
const { rows } = await pool.query(`
|
||||||
INSERT INTO "${schema}".calendario_fiscal
|
INSERT INTO calendario_fiscal
|
||||||
(titulo, descripcion, tipo, fecha_limite, recurrencia, notas)
|
(titulo, descripcion, tipo, fecha_limite, recurrencia, notas)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id, titulo, descripcion, tipo,
|
RETURNING id, titulo, descripcion, tipo,
|
||||||
fecha_limite as "fechaLimite",
|
fecha_limite as "fechaLimite",
|
||||||
recurrencia, completado, notas,
|
recurrencia, completado, notas,
|
||||||
created_at as "createdAt"
|
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 sets: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
@@ -84,19 +84,19 @@ export async function updateEvento(schema: string, id: number, data: EventoUpdat
|
|||||||
|
|
||||||
params.push(id);
|
params.push(id);
|
||||||
|
|
||||||
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
const { rows } = await pool.query(`
|
||||||
UPDATE "${schema}".calendario_fiscal
|
UPDATE calendario_fiscal
|
||||||
SET ${sets.join(', ')}
|
SET ${sets.join(', ')}
|
||||||
WHERE id = $${paramIndex}
|
WHERE id = $${paramIndex}
|
||||||
RETURNING id, titulo, descripcion, tipo,
|
RETURNING id, titulo, descripcion, tipo,
|
||||||
fecha_limite as "fechaLimite",
|
fecha_limite as "fechaLimite",
|
||||||
recurrencia, completado, notas,
|
recurrencia, completado, notas,
|
||||||
created_at as "createdAt"
|
created_at as "createdAt"
|
||||||
`, ...params);
|
`, params);
|
||||||
|
|
||||||
return evento;
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteEvento(schema: string, id: number): Promise<void> {
|
export async function deleteEvento(pool: Pool, id: number): Promise<void> {
|
||||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".calendario_fiscal WHERE id = $1`, id);
|
await pool.query(`DELETE FROM calendario_fiscal WHERE id = $1`, [id]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { prisma } from '../config/database.js';
|
import type { Pool } from 'pg';
|
||||||
import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared';
|
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 page = filters.page || 1;
|
||||||
const limit = filters.limit || 20;
|
const limit = filters.limit || 20;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
@@ -21,12 +21,12 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (filters.fechaInicio) {
|
if (filters.fechaInicio) {
|
||||||
whereClause += ` AND fecha_emision >= $${paramIndex++}`;
|
whereClause += ` AND fecha_emision >= $${paramIndex++}::date`;
|
||||||
params.push(filters.fechaInicio);
|
params.push(filters.fechaInicio);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filters.fechaFin) {
|
if (filters.fechaFin) {
|
||||||
whereClause += ` AND fecha_emision <= $${paramIndex++}`;
|
whereClause += ` AND fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
|
||||||
params.push(filters.fechaFin);
|
params.push(filters.fechaFin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,19 +35,23 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
|||||||
params.push(`%${filters.rfc}%`);
|
params.push(`%${filters.rfc}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.emisor) {
|
||||||
|
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex++})`;
|
||||||
|
params.push(`%${filters.emisor}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.receptor) {
|
||||||
|
whereClause += ` AND (rfc_receptor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
|
||||||
|
params.push(`%${filters.receptor}%`);
|
||||||
|
}
|
||||||
|
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
whereClause += ` AND (uuid_fiscal ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
|
whereClause += ` AND (uuid_fiscal ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
|
||||||
params.push(`%${filters.search}%`);
|
params.push(`%${filters.search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const countResult = await prisma.$queryRawUnsafe<[{ count: number }]>(`
|
|
||||||
SELECT COUNT(*) as count FROM "${schema}".cfdis ${whereClause}
|
|
||||||
`, ...params);
|
|
||||||
|
|
||||||
const total = Number(countResult[0]?.count || 0);
|
|
||||||
|
|
||||||
params.push(limit, offset);
|
params.push(limit, offset);
|
||||||
const data = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
const { rows: dataWithCount } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||||
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
||||||
@@ -58,12 +62,16 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
|||||||
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
||||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||||
created_at as "createdAt"
|
created_at as "createdAt",
|
||||||
FROM "${schema}".cfdis
|
COUNT(*) OVER() as total_count
|
||||||
|
FROM cfdis
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY fecha_emision DESC
|
ORDER BY fecha_emision DESC
|
||||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||||
`, ...params);
|
`, params);
|
||||||
|
|
||||||
|
const total = Number(dataWithCount[0]?.total_count || 0);
|
||||||
|
const data = dataWithCount.map(({ total_count, ...cfdi }: any) => cfdi) as Cfdi[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
@@ -74,8 +82,8 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
|
export async function getCfdiById(pool: Pool, id: string): Promise<Cfdi | null> {
|
||||||
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
const { rows } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||||
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
||||||
@@ -86,12 +94,21 @@ export async function getCfdiById(schema: string, id: string): Promise<Cfdi | nu
|
|||||||
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
||||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||||
|
xml_original as "xmlOriginal",
|
||||||
created_at as "createdAt"
|
created_at as "createdAt"
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE id = $1
|
WHERE id = $1::uuid
|
||||||
`, id);
|
`, [id]);
|
||||||
|
|
||||||
return result[0] || null;
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 rows[0]?.xml_original || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateCfdiData {
|
export interface CreateCfdiData {
|
||||||
@@ -121,18 +138,15 @@ export interface CreateCfdiData {
|
|||||||
pdfUrl?: string;
|
pdfUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCfdi(schema: string, data: CreateCfdiData): Promise<Cfdi> {
|
export async function createCfdi(pool: Pool, data: CreateCfdiData): Promise<Cfdi> {
|
||||||
// Validate required fields
|
|
||||||
if (!data.uuidFiscal) throw new Error('UUID Fiscal es requerido');
|
if (!data.uuidFiscal) throw new Error('UUID Fiscal es requerido');
|
||||||
if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida');
|
if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida');
|
||||||
if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido');
|
if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido');
|
||||||
if (!data.rfcReceptor) throw new Error('RFC Receptor 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 fechaEmision: Date;
|
||||||
let fechaTimbrado: 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}$/)
|
const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
? `${data.fechaEmision}T12:00:00`
|
? `${data.fechaEmision}T12:00:00`
|
||||||
: data.fechaEmision;
|
: data.fechaEmision;
|
||||||
@@ -155,8 +169,8 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
|
|||||||
throw new Error(`Fecha de timbrado inválida: ${data.fechaTimbrado}`);
|
throw new Error(`Fecha de timbrado inválida: ${data.fechaTimbrado}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
const { rows } = await pool.query(`
|
||||||
INSERT INTO "${schema}".cfdis (
|
INSERT INTO cfdis (
|
||||||
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||||
@@ -173,7 +187,7 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
|
|||||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||||
created_at as "createdAt"
|
created_at as "createdAt"
|
||||||
`,
|
`, [
|
||||||
data.uuidFiscal,
|
data.uuidFiscal,
|
||||||
data.tipo || 'ingreso',
|
data.tipo || 'ingreso',
|
||||||
data.serie || null,
|
data.serie || null,
|
||||||
@@ -198,9 +212,9 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
|
|||||||
data.estado || 'vigente',
|
data.estado || 'vigente',
|
||||||
data.xmlUrl || null,
|
data.xmlUrl || null,
|
||||||
data.pdfUrl || null
|
data.pdfUrl || null
|
||||||
);
|
]);
|
||||||
|
|
||||||
return result[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchInsertResult {
|
export interface BatchInsertResult {
|
||||||
@@ -210,14 +224,12 @@ export interface BatchInsertResult {
|
|||||||
errorMessages: string[];
|
errorMessages: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimized batch insert using multi-row INSERT
|
export async function createManyCfdis(pool: Pool, cfdis: CreateCfdiData[]): Promise<number> {
|
||||||
export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise<number> {
|
const result = await createManyCfdisBatch(pool, cfdis);
|
||||||
const result = await createManyCfdisBatch(schema, cfdis);
|
|
||||||
return result.inserted;
|
return result.inserted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// New optimized batch insert with detailed results
|
export async function createManyCfdisBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
||||||
export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
|
||||||
const result: BatchInsertResult = {
|
const result: BatchInsertResult = {
|
||||||
inserted: 0,
|
inserted: 0,
|
||||||
duplicates: 0,
|
duplicates: 0,
|
||||||
@@ -227,19 +239,17 @@ export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData
|
|||||||
|
|
||||||
if (cfdis.length === 0) return result;
|
if (cfdis.length === 0) return result;
|
||||||
|
|
||||||
// Process in batches of 500 for optimal performance
|
|
||||||
const BATCH_SIZE = 500;
|
const BATCH_SIZE = 500;
|
||||||
|
|
||||||
for (let batchStart = 0; batchStart < cfdis.length; batchStart += BATCH_SIZE) {
|
for (let batchStart = 0; batchStart < cfdis.length; batchStart += BATCH_SIZE) {
|
||||||
const batch = cfdis.slice(batchStart, batchStart + BATCH_SIZE);
|
const batch = cfdis.slice(batchStart, batchStart + BATCH_SIZE);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const batchResult = await insertBatch(schema, batch);
|
const batchResult = await insertBatch(pool, batch);
|
||||||
result.inserted += batchResult.inserted;
|
result.inserted += batchResult.inserted;
|
||||||
result.duplicates += batchResult.duplicates;
|
result.duplicates += batchResult.duplicates;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// If batch fails, try individual inserts for this batch
|
const individualResult = await insertIndividually(pool, batch);
|
||||||
const individualResult = await insertIndividually(schema, batch);
|
|
||||||
result.inserted += individualResult.inserted;
|
result.inserted += individualResult.inserted;
|
||||||
result.duplicates += individualResult.duplicates;
|
result.duplicates += individualResult.duplicates;
|
||||||
result.errors += individualResult.errors;
|
result.errors += individualResult.errors;
|
||||||
@@ -250,17 +260,14 @@ export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert a batch using multi-row INSERT with ON CONFLICT
|
async function insertBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise<{ inserted: number; duplicates: number }> {
|
||||||
async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ inserted: number; duplicates: number }> {
|
|
||||||
if (cfdis.length === 0) return { inserted: 0, duplicates: 0 };
|
if (cfdis.length === 0) return { inserted: 0, duplicates: 0 };
|
||||||
|
|
||||||
// Build the VALUES part of the query
|
|
||||||
const values: any[] = [];
|
const values: any[] = [];
|
||||||
const valuePlaceholders: string[] = [];
|
const valuePlaceholders: string[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
for (const cfdi of cfdis) {
|
for (const cfdi of cfdis) {
|
||||||
// Parse dates
|
|
||||||
const fechaEmision = parseDate(cfdi.fechaEmision);
|
const fechaEmision = parseDate(cfdi.fechaEmision);
|
||||||
const fechaTimbrado = cfdi.fechaTimbrado ? parseDate(cfdi.fechaTimbrado) : fechaEmision;
|
const fechaTimbrado = cfdi.fechaTimbrado ? parseDate(cfdi.fechaTimbrado) : fechaEmision;
|
||||||
|
|
||||||
@@ -304,9 +311,8 @@ async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ i
|
|||||||
return { inserted: 0, duplicates: 0 };
|
return { inserted: 0, duplicates: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use ON CONFLICT to handle duplicates gracefully
|
|
||||||
const query = `
|
const query = `
|
||||||
INSERT INTO "${schema}".cfdis (
|
INSERT INTO cfdis (
|
||||||
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||||
@@ -315,15 +321,12 @@ async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ i
|
|||||||
ON CONFLICT (uuid_fiscal) DO NOTHING
|
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 };
|
return { inserted: valuePlaceholders.length, duplicates: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: insert individually when batch fails
|
async function insertIndividually(pool: Pool, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
||||||
async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
|
||||||
const result: BatchInsertResult = {
|
const result: BatchInsertResult = {
|
||||||
inserted: 0,
|
inserted: 0,
|
||||||
duplicates: 0,
|
duplicates: 0,
|
||||||
@@ -333,7 +336,7 @@ async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Prom
|
|||||||
|
|
||||||
for (const cfdi of cfdis) {
|
for (const cfdi of cfdis) {
|
||||||
try {
|
try {
|
||||||
await createCfdi(schema, cfdi);
|
await createCfdi(pool, cfdi);
|
||||||
result.inserted++;
|
result.inserted++;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMsg = error.message || 'Error desconocido';
|
const errorMsg = error.message || 'Error desconocido';
|
||||||
@@ -351,11 +354,9 @@ async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Prom
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to parse dates safely
|
|
||||||
function parseDate(dateStr: string): Date | null {
|
function parseDate(dateStr: string): Date | null {
|
||||||
if (!dateStr) return 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}$/)
|
const normalized = dateStr.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
? `${dateStr}T12:00:00`
|
? `${dateStr}T12:00:00`
|
||||||
: dateStr;
|
: dateStr;
|
||||||
@@ -364,19 +365,34 @@ function parseDate(dateStr: string): Date | null {
|
|||||||
return isNaN(date.getTime()) ? null : date;
|
return isNaN(date.getTime()) ? null : date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCfdi(schema: string, id: string): Promise<void> {
|
export async function deleteCfdi(pool: Pool, id: string): Promise<void> {
|
||||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id);
|
await pool.query(`DELETE FROM cfdis WHERE id = $1`, [id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getResumenCfdis(schema: string, año: number, mes: number) {
|
export async function getEmisores(pool: Pool, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
|
||||||
const result = await prisma.$queryRawUnsafe<[{
|
const { rows } = await pool.query(`
|
||||||
total_ingresos: number;
|
SELECT DISTINCT rfc_emisor as rfc, nombre_emisor as nombre
|
||||||
total_egresos: number;
|
FROM cfdis
|
||||||
count_ingresos: number;
|
WHERE rfc_emisor ILIKE $1 OR nombre_emisor ILIKE $1
|
||||||
count_egresos: number;
|
ORDER BY nombre_emisor
|
||||||
iva_trasladado: number;
|
LIMIT $2
|
||||||
iva_acreditable: number;
|
`, [`%${search}%`, limit]);
|
||||||
}]>(`
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cfdis
|
||||||
|
WHERE rfc_receptor ILIKE $1 OR nombre_receptor ILIKE $1
|
||||||
|
ORDER BY nombre_receptor
|
||||||
|
LIMIT $2
|
||||||
|
`, [`%${search}%`, limit]);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResumenCfdis(pool: Pool, año: number, mes: number) {
|
||||||
|
const { rows } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as total_ingresos,
|
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,
|
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as total_egresos,
|
||||||
@@ -384,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,
|
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 = '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
|
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva_acreditable
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE estado = 'vigente'
|
WHERE estado = 'vigente'
|
||||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||||
`, año, mes);
|
`, [año, mes]);
|
||||||
|
|
||||||
const r = result[0];
|
const r = rows[0];
|
||||||
return {
|
return {
|
||||||
totalIngresos: Number(r?.total_ingresos || 0),
|
totalIngresos: Number(r?.total_ingresos || 0),
|
||||||
totalEgresos: Number(r?.total_egresos || 0),
|
totalEgresos: Number(r?.total_egresos || 0),
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
import { prisma } from '../config/database.js';
|
import type { Pool } from 'pg';
|
||||||
import type { KpiData, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared';
|
import type { KpiData, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared';
|
||||||
|
|
||||||
export async function getKpis(schema: string, año: number, mes: number): Promise<KpiData> {
|
export async function getKpis(pool: Pool, año: number, mes: number): Promise<KpiData> {
|
||||||
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
const { rows: [ingresos] } = await pool.query(`
|
||||||
SELECT COALESCE(SUM(total), 0) as total
|
SELECT COALESCE(SUM(total), 0) as total
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE tipo = 'ingreso'
|
WHERE tipo = 'ingreso'
|
||||||
AND estado = 'vigente'
|
AND estado = 'vigente'
|
||||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
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
|
SELECT COALESCE(SUM(total), 0) as total
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE tipo = 'egreso'
|
WHERE tipo = 'egreso'
|
||||||
AND estado = 'vigente'
|
AND estado = 'vigente'
|
||||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
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
|
SELECT
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
|
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(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE estado = 'vigente'
|
WHERE estado = 'vigente'
|
||||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
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
|
SELECT
|
||||||
COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as emitidos,
|
COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as emitidos,
|
||||||
COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as recibidos
|
COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as recibidos
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE estado = 'vigente'
|
WHERE estado = 'vigente'
|
||||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||||
`, año, mes);
|
`, [año, mes]);
|
||||||
|
|
||||||
const ingresosVal = Number(ingresos?.total || 0);
|
const ingresosVal = Number(ingresos?.total || 0);
|
||||||
const egresosVal = Number(egresos?.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[]> {
|
export async function getIngresosEgresos(pool: Pool, año: number): Promise<IngresosEgresosData[]> {
|
||||||
const data = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
|
const { rows: data } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
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 = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE estado = 'vigente'
|
WHERE estado = 'vigente'
|
||||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
GROUP BY EXTRACT(MONTH FROM fecha_emision)
|
GROUP BY EXTRACT(MONTH FROM fecha_emision)
|
||||||
ORDER BY mes
|
ORDER BY mes
|
||||||
`, año);
|
`, [año]);
|
||||||
|
|
||||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||||
|
|
||||||
return meses.map((mes, index) => {
|
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 {
|
return {
|
||||||
mes,
|
mes,
|
||||||
ingresos: Number(found?.ingresos || 0),
|
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> {
|
export async function getResumenFiscal(pool: Pool, año: number, mes: number): Promise<ResumenFiscal> {
|
||||||
const [ivaResult] = await prisma.$queryRawUnsafe<[{ resultado: number; acumulado: number }]>(`
|
const { rows: ivaRows } = await pool.query(`
|
||||||
SELECT resultado, acumulado FROM "${schema}".iva_mensual
|
SELECT resultado, acumulado FROM iva_mensual
|
||||||
WHERE año = $1 AND mes = $2
|
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 }]>(`
|
const { rows: [pendientes] } = await pool.query(`
|
||||||
SELECT COUNT(*) as count FROM "${schema}".iva_mensual
|
SELECT COUNT(*) as count FROM iva_mensual
|
||||||
WHERE año = $1 AND estado = 'pendiente'
|
WHERE año = $1 AND estado = 'pendiente'
|
||||||
`, año);
|
`, [año]);
|
||||||
|
|
||||||
const resultado = Number(ivaResult?.resultado || 0);
|
const resultado = Number(ivaResult?.resultado || 0);
|
||||||
const acumulado = Number(ivaResult?.acumulado || 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[]> {
|
export async function getAlertas(pool: Pool, limit = 5): Promise<Alerta[]> {
|
||||||
const alertas = await prisma.$queryRawUnsafe<Alerta[]>(`
|
const { rows } = await pool.query(`
|
||||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||||
fecha_vencimiento as "fechaVencimiento",
|
fecha_vencimiento as "fechaVencimiento",
|
||||||
leida, resuelta,
|
leida, resuelta,
|
||||||
created_at as "createdAt"
|
created_at as "createdAt"
|
||||||
FROM "${schema}".alertas
|
FROM alertas
|
||||||
WHERE resuelta = false
|
WHERE resuelta = false
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
||||||
created_at DESC
|
created_at DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
`, limit);
|
`, [limit]);
|
||||||
|
|
||||||
return alertas;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|||||||
93
apps/api/src/services/email/email.service.ts
Normal file
93
apps/api/src/services/email/email.service.ts
Normal 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));
|
||||||
|
},
|
||||||
|
};
|
||||||
35
apps/api/src/services/email/templates/base.ts
Normal file
35
apps/api/src/services/email/templates/base.ts
Normal 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;">© ${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>`;
|
||||||
|
}
|
||||||
14
apps/api/src/services/email/templates/fiel-notification.ts
Normal file
14
apps/api/src/services/email/templates/fiel-notification.ts
Normal 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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
68
apps/api/src/services/email/templates/new-client-admin.ts
Normal file
68
apps/api/src/services/email/templates/new-client-admin.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { baseTemplate } from './base.js';
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
15
apps/api/src/services/email/templates/payment-confirmed.ts
Normal file
15
apps/api/src/services/email/templates/payment-confirmed.ts
Normal 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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
14
apps/api/src/services/email/templates/payment-failed.ts
Normal file
14
apps/api/src/services/email/templates/payment-failed.ts
Normal 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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
15
apps/api/src/services/email/templates/welcome.ts
Normal file
15
apps/api/src/services/email/templates/welcome.ts
Normal 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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import { prisma } from '../config/database.js';
|
import type { Pool } from 'pg';
|
||||||
|
|
||||||
export async function exportCfdisToExcel(
|
export async function exportCfdisToExcel(
|
||||||
schema: string,
|
pool: Pool,
|
||||||
filters: { tipo?: string; estado?: string; fechaInicio?: string; fechaFin?: string }
|
filters: { tipo?: string; estado?: string; fechaInicio?: string; fechaFin?: string }
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
let whereClause = 'WHERE 1=1';
|
let whereClause = 'WHERE 1=1';
|
||||||
@@ -26,15 +26,15 @@ export async function exportCfdisToExcel(
|
|||||||
params.push(filters.fechaFin);
|
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,
|
SELECT uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||||
moneda, metodo_pago, forma_pago, uso_cfdi, estado
|
moneda, metodo_pago, forma_pago, uso_cfdi, estado
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
${whereClause}
|
${whereClause}
|
||||||
ORDER BY fecha_emision DESC
|
ORDER BY fecha_emision DESC
|
||||||
`, ...params);
|
`, params);
|
||||||
|
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
const sheet = workbook.addWorksheet('CFDIs');
|
const sheet = workbook.addWorksheet('CFDIs');
|
||||||
@@ -63,7 +63,7 @@ export async function exportCfdisToExcel(
|
|||||||
};
|
};
|
||||||
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||||
|
|
||||||
cfdis.forEach(cfdi => {
|
cfdis.forEach((cfdi: any) => {
|
||||||
sheet.addRow({
|
sheet.addRow({
|
||||||
...cfdi,
|
...cfdi,
|
||||||
fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'),
|
fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'),
|
||||||
@@ -78,7 +78,7 @@ export async function exportCfdisToExcel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function exportReporteToExcel(
|
export async function exportReporteToExcel(
|
||||||
schema: string,
|
pool: Pool,
|
||||||
tipo: 'estado-resultados' | 'flujo-efectivo',
|
tipo: 'estado-resultados' | 'flujo-efectivo',
|
||||||
fechaInicio: string,
|
fechaInicio: string,
|
||||||
fechaFin: string
|
fechaFin: string
|
||||||
@@ -87,13 +87,13 @@ export async function exportReporteToExcel(
|
|||||||
const sheet = workbook.addWorksheet(tipo === 'estado-resultados' ? 'Estado de Resultados' : 'Flujo de Efectivo');
|
const sheet = workbook.addWorksheet(tipo === 'estado-resultados' ? 'Estado de Resultados' : 'Flujo de Efectivo');
|
||||||
|
|
||||||
if (tipo === 'estado-resultados') {
|
if (tipo === 'estado-resultados') {
|
||||||
const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number }]>(`
|
const { rows: [totales] } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
|
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 = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
|
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
|
||||||
`, fechaInicio, fechaFin);
|
`, [fechaInicio, fechaFin]);
|
||||||
|
|
||||||
sheet.columns = [
|
sheet.columns = [
|
||||||
{ header: 'Concepto', key: 'concepto', width: 40 },
|
{ header: 'Concepto', key: 'concepto', width: 40 },
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Credential } from '@nodecfdi/credentials/node';
|
import { Credential } from '@nodecfdi/credentials/node';
|
||||||
|
import { writeFile, mkdir } from 'fs/promises';
|
||||||
|
import { join } from 'path';
|
||||||
import { prisma } from '../config/database.js';
|
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';
|
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 {
|
const {
|
||||||
encryptedCer,
|
encryptedCer,
|
||||||
encryptedKey,
|
encryptedKey,
|
||||||
encryptedPassword,
|
encryptedPassword,
|
||||||
iv,
|
cerIv,
|
||||||
tag,
|
cerTag,
|
||||||
|
keyIv,
|
||||||
|
keyTag,
|
||||||
|
passwordIv,
|
||||||
|
passwordTag,
|
||||||
} = encryptFielCredentials(cerData, keyData, password);
|
} = encryptFielCredentials(cerData, keyData, password);
|
||||||
|
|
||||||
// Guardar o actualizar en BD
|
// Guardar o actualizar en BD
|
||||||
@@ -76,8 +84,12 @@ export async function uploadFiel(
|
|||||||
cerData: encryptedCer,
|
cerData: encryptedCer,
|
||||||
keyData: encryptedKey,
|
keyData: encryptedKey,
|
||||||
keyPasswordEncrypted: encryptedPassword,
|
keyPasswordEncrypted: encryptedPassword,
|
||||||
encryptionIv: iv,
|
cerIv,
|
||||||
encryptionTag: tag,
|
cerTag,
|
||||||
|
keyIv,
|
||||||
|
keyTag,
|
||||||
|
passwordIv,
|
||||||
|
passwordTag,
|
||||||
serialNumber,
|
serialNumber,
|
||||||
validFrom,
|
validFrom,
|
||||||
validUntil,
|
validUntil,
|
||||||
@@ -88,8 +100,12 @@ export async function uploadFiel(
|
|||||||
cerData: encryptedCer,
|
cerData: encryptedCer,
|
||||||
keyData: encryptedKey,
|
keyData: encryptedKey,
|
||||||
keyPasswordEncrypted: encryptedPassword,
|
keyPasswordEncrypted: encryptedPassword,
|
||||||
encryptionIv: iv,
|
cerIv,
|
||||||
encryptionTag: tag,
|
cerTag,
|
||||||
|
keyIv,
|
||||||
|
keyTag,
|
||||||
|
passwordIv,
|
||||||
|
passwordTag,
|
||||||
serialNumber,
|
serialNumber,
|
||||||
validFrom,
|
validFrom,
|
||||||
validUntil,
|
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(
|
const daysUntilExpiration = Math.ceil(
|
||||||
(validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
(validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||||
);
|
);
|
||||||
@@ -198,13 +257,17 @@ export async function getDecryptedFiel(tenantId: string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Desencriptar todas las credenciales juntas
|
// Desencriptar credenciales (per-component IV/tag)
|
||||||
const { cerData, keyData, password } = decryptFielCredentials(
|
const { cerData, keyData, password } = decryptFielCredentials(
|
||||||
Buffer.from(fiel.cerData),
|
Buffer.from(fiel.cerData),
|
||||||
Buffer.from(fiel.keyData),
|
Buffer.from(fiel.keyData),
|
||||||
Buffer.from(fiel.keyPasswordEncrypted),
|
Buffer.from(fiel.keyPasswordEncrypted),
|
||||||
Buffer.from(fiel.encryptionIv),
|
Buffer.from(fiel.cerIv),
|
||||||
Buffer.from(fiel.encryptionTag)
|
Buffer.from(fiel.cerTag),
|
||||||
|
Buffer.from(fiel.keyIv),
|
||||||
|
Buffer.from(fiel.keyTag),
|
||||||
|
Buffer.from(fiel.passwordIv),
|
||||||
|
Buffer.from(fiel.passwordTag)
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { prisma } from '../config/database.js';
|
import type { Pool } from 'pg';
|
||||||
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';
|
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';
|
||||||
|
|
||||||
export async function getIvaMensual(schema: string, año: number): Promise<IvaMensual[]> {
|
export async function getIvaMensual(pool: Pool, año: number): Promise<IvaMensual[]> {
|
||||||
const data = await prisma.$queryRawUnsafe<IvaMensual[]>(`
|
const { rows: data } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
id, año, mes,
|
id, año, mes,
|
||||||
iva_trasladado as "ivaTrasladado",
|
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",
|
COALESCE(iva_retenido, 0) as "ivaRetenido",
|
||||||
resultado, acumulado, estado,
|
resultado, acumulado, estado,
|
||||||
fecha_declaracion as "fechaDeclaracion"
|
fecha_declaracion as "fechaDeclaracion"
|
||||||
FROM "${schema}".iva_mensual
|
FROM iva_mensual
|
||||||
WHERE año = $1
|
WHERE año = $1
|
||||||
ORDER BY mes
|
ORDER BY mes
|
||||||
`, año);
|
`, [año]);
|
||||||
|
|
||||||
return data.map(row => ({
|
return data.map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
ivaTrasladado: Number(row.ivaTrasladado),
|
ivaTrasladado: Number(row.ivaTrasladado),
|
||||||
ivaAcreditable: Number(row.ivaAcreditable),
|
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> {
|
export async function getResumenIva(pool: Pool, año: number, mes: number): Promise<ResumenIva> {
|
||||||
// Get from iva_mensual if exists
|
const { rows: existing } = await pool.query(`
|
||||||
const existing = await prisma.$queryRawUnsafe<any[]>(`
|
SELECT * FROM iva_mensual WHERE año = $1 AND mes = $2
|
||||||
SELECT * FROM "${schema}".iva_mensual WHERE año = $1 AND mes = $2
|
`, [año, mes]);
|
||||||
`, año, mes);
|
|
||||||
|
|
||||||
if (existing && existing.length > 0) {
|
if (existing && existing.length > 0) {
|
||||||
const record = existing[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
|
SELECT COALESCE(SUM(resultado), 0) as total
|
||||||
FROM "${schema}".iva_mensual
|
FROM iva_mensual
|
||||||
WHERE año = $1 AND mes <= $2
|
WHERE año = $1 AND mes <= $2
|
||||||
`, año, mes);
|
`, [año, mes]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
trasladado: Number(record.iva_trasladado || 0),
|
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 { rows: [calcResult] } = await pool.query(`
|
||||||
const [calcResult] = await prisma.$queryRawUnsafe<[{
|
|
||||||
trasladado: number;
|
|
||||||
acreditable: number;
|
|
||||||
retenido: number;
|
|
||||||
}]>(`
|
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
|
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(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable,
|
||||||
COALESCE(SUM(iva_retenido), 0) as retenido
|
COALESCE(SUM(iva_retenido), 0) as retenido
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE estado = 'vigente'
|
WHERE estado = 'vigente'
|
||||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||||
`, año, mes);
|
`, [año, mes]);
|
||||||
|
|
||||||
const trasladado = Number(calcResult?.trasladado || 0);
|
const trasladado = Number(calcResult?.trasladado || 0);
|
||||||
const acreditable = Number(calcResult?.acreditable || 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[]> {
|
export async function getIsrMensual(pool: Pool, año: number): Promise<IsrMensual[]> {
|
||||||
// Check if isr_mensual table exists
|
|
||||||
try {
|
try {
|
||||||
const data = await prisma.$queryRawUnsafe<IsrMensual[]>(`
|
const { rows: data } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
id, año, mes,
|
id, año, mes,
|
||||||
ingresos_acumulados as "ingresosAcumulados",
|
ingresos_acumulados as "ingresosAcumulados",
|
||||||
@@ -92,12 +85,12 @@ export async function getIsrMensual(schema: string, año: number): Promise<IsrMe
|
|||||||
isr_a_pagar as "isrAPagar",
|
isr_a_pagar as "isrAPagar",
|
||||||
estado,
|
estado,
|
||||||
fecha_declaracion as "fechaDeclaracion"
|
fecha_declaracion as "fechaDeclaracion"
|
||||||
FROM "${schema}".isr_mensual
|
FROM isr_mensual
|
||||||
WHERE año = $1
|
WHERE año = $1
|
||||||
ORDER BY mes
|
ORDER BY mes
|
||||||
`, año);
|
`, [año]);
|
||||||
|
|
||||||
return data.map(row => ({
|
return data.map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
ingresosAcumulados: Number(row.ingresosAcumulados),
|
ingresosAcumulados: Number(row.ingresosAcumulados),
|
||||||
deducciones: Number(row.deducciones),
|
deducciones: Number(row.deducciones),
|
||||||
@@ -107,43 +100,40 @@ export async function getIsrMensual(schema: string, año: number): Promise<IsrMe
|
|||||||
isrAPagar: Number(row.isrAPagar),
|
isrAPagar: Number(row.isrAPagar),
|
||||||
}));
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
// Table doesn't exist, return empty array
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getResumenIsr(schema: string, año: number, mes: number): Promise<ResumenIsr> {
|
export async function getResumenIsr(pool: Pool, año: number, mes: number): Promise<ResumenIsr> {
|
||||||
// Calculate from CFDIs
|
const { rows: [ingresos] } = await pool.query(`
|
||||||
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
|
||||||
SELECT COALESCE(SUM(total), 0) as total
|
SELECT COALESCE(SUM(total), 0) as total
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
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
|
SELECT COALESCE(SUM(total), 0) as total
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
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
|
SELECT COALESCE(SUM(isr_retenido), 0) as total
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE estado = 'vigente'
|
WHERE estado = 'vigente'
|
||||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
||||||
`, año, mes);
|
`, [año, mes]);
|
||||||
|
|
||||||
const ingresosAcumulados = Number(ingresos?.total || 0);
|
const ingresosAcumulados = Number(ingresos?.total || 0);
|
||||||
const deducciones = Number(egresos?.total || 0);
|
const deducciones = Number(egresos?.total || 0);
|
||||||
const baseGravable = Math.max(0, ingresosAcumulados - deducciones);
|
const baseGravable = Math.max(0, ingresosAcumulados - deducciones);
|
||||||
|
|
||||||
// Simplified ISR calculation (actual calculation would use SAT tables)
|
const isrCausado = baseGravable * 0.30;
|
||||||
const isrCausado = baseGravable * 0.30; // 30% simplified rate
|
|
||||||
const isrRetenido = Number(retenido?.total || 0);
|
const isrRetenido = Number(retenido?.total || 0);
|
||||||
const isrAPagar = Math.max(0, isrCausado - isrRetenido);
|
const isrAPagar = Math.max(0, isrCausado - isrRetenido);
|
||||||
|
|
||||||
|
|||||||
106
apps/api/src/services/payment/mercadopago.service.ts
Normal file
106
apps/api/src/services/payment/mercadopago.service.ts
Normal 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;
|
||||||
|
}
|
||||||
232
apps/api/src/services/payment/subscription.service.ts
Normal file
232
apps/api/src/services/payment/subscription.service.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { prisma } from '../config/database.js';
|
import type { Pool } from 'pg';
|
||||||
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
|
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
|
||||||
|
|
||||||
// Helper to convert Prisma Decimal/BigInt to number
|
|
||||||
function toNumber(value: unknown): number {
|
function toNumber(value: unknown): number {
|
||||||
if (value === null || value === undefined) return 0;
|
if (value === null || value === undefined) return 0;
|
||||||
if (typeof value === 'number') return value;
|
if (typeof value === 'number') return value;
|
||||||
@@ -14,37 +13,37 @@ function toNumber(value: unknown): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getEstadoResultados(
|
export async function getEstadoResultados(
|
||||||
schema: string,
|
pool: Pool,
|
||||||
fechaInicio: string,
|
fechaInicio: string,
|
||||||
fechaFin: string
|
fechaFin: string
|
||||||
): Promise<EstadoResultados> {
|
): 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
|
SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
GROUP BY rfc_receptor, nombre_receptor
|
GROUP BY rfc_receptor, nombre_receptor
|
||||||
ORDER BY total DESC LIMIT 10
|
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
|
SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
GROUP BY rfc_emisor, nombre_emisor
|
GROUP BY rfc_emisor, nombre_emisor
|
||||||
ORDER BY total DESC LIMIT 10
|
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
|
SELECT
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
|
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 = '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 = 'ingreso' THEN iva ELSE 0 END), 0) -
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
|
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
|
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
`, fechaInicio, fechaFin);
|
`, [fechaInicio, fechaFin]);
|
||||||
|
|
||||||
const totales = totalesResult[0];
|
const totales = totalesResult[0];
|
||||||
const totalIngresos = toNumber(totales?.ingresos);
|
const totalIngresos = toNumber(totales?.ingresos);
|
||||||
@@ -54,8 +53,8 @@ export async function getEstadoResultados(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||||
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: toNumber(i.total) })),
|
ingresos: ingresos.map((i: any) => ({ concepto: i.nombre, monto: toNumber(i.total) })),
|
||||||
egresos: egresos.map(e => ({ concepto: e.nombre, monto: toNumber(e.total) })),
|
egresos: egresos.map((e: any) => ({ concepto: e.nombre, monto: toNumber(e.total) })),
|
||||||
totalIngresos,
|
totalIngresos,
|
||||||
totalEgresos,
|
totalEgresos,
|
||||||
utilidadBruta,
|
utilidadBruta,
|
||||||
@@ -65,36 +64,36 @@ export async function getEstadoResultados(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getFlujoEfectivo(
|
export async function getFlujoEfectivo(
|
||||||
schema: string,
|
pool: Pool,
|
||||||
fechaInicio: string,
|
fechaInicio: string,
|
||||||
fechaFin: string
|
fechaFin: string
|
||||||
): Promise<FlujoEfectivo> {
|
): 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
|
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||||
ORDER BY mes
|
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
|
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||||
ORDER BY mes
|
ORDER BY mes
|
||||||
`, fechaInicio, fechaFin);
|
`, [fechaInicio, fechaFin]);
|
||||||
|
|
||||||
const totalEntradas = entradas.reduce((sum, e) => sum + toNumber(e.total), 0);
|
const totalEntradas = entradas.reduce((sum: number, e: any) => sum + toNumber(e.total), 0);
|
||||||
const totalSalidas = salidas.reduce((sum, s) => sum + toNumber(s.total), 0);
|
const totalSalidas = salidas.reduce((sum: number, s: any) => sum + toNumber(s.total), 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||||
saldoInicial: 0,
|
saldoInicial: 0,
|
||||||
entradas: entradas.map(e => ({ concepto: e.mes, monto: toNumber(e.total) })),
|
entradas: entradas.map((e: any) => ({ concepto: e.mes, monto: toNumber(e.total) })),
|
||||||
salidas: salidas.map(s => ({ concepto: s.mes, monto: toNumber(s.total) })),
|
salidas: salidas.map((s: any) => ({ concepto: s.mes, monto: toNumber(s.total) })),
|
||||||
totalEntradas,
|
totalEntradas,
|
||||||
totalSalidas,
|
totalSalidas,
|
||||||
flujoNeto: totalEntradas - totalSalidas,
|
flujoNeto: totalEntradas - totalSalidas,
|
||||||
@@ -103,36 +102,36 @@ export async function getFlujoEfectivo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getComparativo(
|
export async function getComparativo(
|
||||||
schema: string,
|
pool: Pool,
|
||||||
año: number
|
año: number
|
||||||
): Promise<ComparativoPeriodos> {
|
): 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,
|
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 = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
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
|
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
GROUP BY mes ORDER BY mes
|
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,
|
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 = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
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
|
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||||
GROUP BY mes ORDER BY mes
|
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 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 ingresos = meses.map((_, i) => toNumber(actual.find((a: any) => a.mes === i + 1)?.ingresos));
|
||||||
const egresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.egresos));
|
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 utilidad = ingresos.map((ing, i) => ing - egresos[i]);
|
||||||
|
|
||||||
const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
|
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 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 {
|
return {
|
||||||
periodos: meses,
|
periodos: meses,
|
||||||
@@ -146,25 +145,25 @@ export async function getComparativo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getConcentradoRfc(
|
export async function getConcentradoRfc(
|
||||||
schema: string,
|
pool: Pool,
|
||||||
fechaInicio: string,
|
fechaInicio: string,
|
||||||
fechaFin: string,
|
fechaFin: string,
|
||||||
tipo: 'cliente' | 'proveedor'
|
tipo: 'cliente' | 'proveedor'
|
||||||
): Promise<ConcentradoRfc[]> {
|
): Promise<ConcentradoRfc[]> {
|
||||||
if (tipo === 'cliente') {
|
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,
|
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
|
||||||
'cliente' as tipo,
|
'cliente' as tipo,
|
||||||
SUM(total) as "totalFacturado",
|
SUM(total) as "totalFacturado",
|
||||||
SUM(iva) as "totalIva",
|
SUM(iva) as "totalIva",
|
||||||
COUNT(*)::int as "cantidadCfdis"
|
COUNT(*)::int as "cantidadCfdis"
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
GROUP BY rfc_receptor, nombre_receptor
|
GROUP BY rfc_receptor, nombre_receptor
|
||||||
ORDER BY "totalFacturado" DESC
|
ORDER BY "totalFacturado" DESC
|
||||||
`, fechaInicio, fechaFin);
|
`, [fechaInicio, fechaFin]);
|
||||||
return data.map(d => ({
|
return data.map((d: any) => ({
|
||||||
rfc: d.rfc,
|
rfc: d.rfc,
|
||||||
nombre: d.nombre,
|
nombre: d.nombre,
|
||||||
tipo: 'cliente' as const,
|
tipo: 'cliente' as const,
|
||||||
@@ -173,19 +172,19 @@ export async function getConcentradoRfc(
|
|||||||
cantidadCfdis: d.cantidadCfdis
|
cantidadCfdis: d.cantidadCfdis
|
||||||
}));
|
}));
|
||||||
} else {
|
} 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,
|
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
|
||||||
'proveedor' as tipo,
|
'proveedor' as tipo,
|
||||||
SUM(total) as "totalFacturado",
|
SUM(total) as "totalFacturado",
|
||||||
SUM(iva) as "totalIva",
|
SUM(iva) as "totalIva",
|
||||||
COUNT(*)::int as "cantidadCfdis"
|
COUNT(*)::int as "cantidadCfdis"
|
||||||
FROM "${schema}".cfdis
|
FROM cfdis
|
||||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||||
GROUP BY rfc_emisor, nombre_emisor
|
GROUP BY rfc_emisor, nombre_emisor
|
||||||
ORDER BY "totalFacturado" DESC
|
ORDER BY "totalFacturado" DESC
|
||||||
`, fechaInicio, fechaFin);
|
`, [fechaInicio, fechaFin]);
|
||||||
return data.map(d => ({
|
return data.map((d: any) => ({
|
||||||
rfc: d.rfc,
|
rfc: d.rfc,
|
||||||
nombre: d.nombre,
|
nombre: d.nombre,
|
||||||
tipo: 'proveedor' as const,
|
tipo: 'proveedor' as const,
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ const IV_LENGTH = 16;
|
|||||||
const TAG_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 {
|
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(
|
export function encryptFielCredentials(
|
||||||
cerData: Buffer,
|
cerData: Buffer,
|
||||||
@@ -62,61 +62,51 @@ export function encryptFielCredentials(
|
|||||||
encryptedCer: Buffer;
|
encryptedCer: Buffer;
|
||||||
encryptedKey: Buffer;
|
encryptedKey: Buffer;
|
||||||
encryptedPassword: Buffer;
|
encryptedPassword: Buffer;
|
||||||
iv: Buffer;
|
cerIv: Buffer;
|
||||||
tag: Buffer;
|
cerTag: Buffer;
|
||||||
|
keyIv: Buffer;
|
||||||
|
keyTag: Buffer;
|
||||||
|
passwordIv: Buffer;
|
||||||
|
passwordTag: Buffer;
|
||||||
} {
|
} {
|
||||||
// Usamos el mismo IV y tag para simplificar, concatenando los datos
|
const cer = encrypt(cerData);
|
||||||
const combined = Buffer.concat([
|
const key = encrypt(keyData);
|
||||||
Buffer.from(cerData.length.toString().padStart(10, '0')),
|
const pwd = encrypt(Buffer.from(password, 'utf-8'));
|
||||||
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;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encryptedCer: encrypted.subarray(0, 10 + cerLength),
|
encryptedCer: cer.encrypted,
|
||||||
encryptedKey: encrypted.subarray(10 + cerLength, 20 + cerLength + keyLength),
|
encryptedKey: key.encrypted,
|
||||||
encryptedPassword: encrypted.subarray(20 + cerLength + keyLength),
|
encryptedPassword: pwd.encrypted,
|
||||||
iv,
|
cerIv: cer.iv,
|
||||||
tag,
|
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(
|
export function decryptFielCredentials(
|
||||||
encryptedCer: Buffer,
|
encryptedCer: Buffer,
|
||||||
encryptedKey: Buffer,
|
encryptedKey: Buffer,
|
||||||
encryptedPassword: Buffer,
|
encryptedPassword: Buffer,
|
||||||
iv: Buffer,
|
cerIv: Buffer,
|
||||||
tag: Buffer
|
cerTag: Buffer,
|
||||||
|
keyIv: Buffer,
|
||||||
|
keyTag: Buffer,
|
||||||
|
passwordIv: Buffer,
|
||||||
|
passwordTag: Buffer
|
||||||
): {
|
): {
|
||||||
cerData: Buffer;
|
cerData: Buffer;
|
||||||
keyData: Buffer;
|
keyData: Buffer;
|
||||||
password: string;
|
password: string;
|
||||||
} {
|
} {
|
||||||
const combined = Buffer.concat([encryptedCer, encryptedKey, encryptedPassword]);
|
const cerData = decrypt(encryptedCer, cerIv, cerTag);
|
||||||
const decrypted = decrypt(combined, iv, tag);
|
const keyData = decrypt(encryptedKey, keyIv, keyTag);
|
||||||
|
const password = decrypt(encryptedPassword, passwordIv, passwordTag).toString('utf-8');
|
||||||
// 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');
|
|
||||||
|
|
||||||
return { cerData, keyData, password };
|
return { cerData, keyData, password };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { prisma } from '../../config/database.js';
|
import { prisma, tenantDb } from '../../config/database.js';
|
||||||
import { getDecryptedFiel } from '../fiel.service.js';
|
import { getDecryptedFiel } from '../fiel.service.js';
|
||||||
import {
|
import {
|
||||||
createSatService,
|
createSatService,
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { processPackage, type CfdiParsed } from './sat-parser.service.js';
|
import { processPackage, type CfdiParsed } from './sat-parser.service.js';
|
||||||
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
|
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
|
||||||
import type { Service } from '@nodecfdi/sat-ws-descarga-masiva';
|
import type { Service } from '@nodecfdi/sat-ws-descarga-masiva';
|
||||||
|
import type { Pool } from 'pg';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 30000; // 30 segundos
|
const POLL_INTERVAL_MS = 30000; // 30 segundos
|
||||||
const MAX_POLL_ATTEMPTS = 60; // 30 minutos máximo
|
const MAX_POLL_ATTEMPTS = 60; // 30 minutos máximo
|
||||||
@@ -20,7 +21,7 @@ interface SyncContext {
|
|||||||
service: Service;
|
service: Service;
|
||||||
rfc: string;
|
rfc: string;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
schemaName: string;
|
pool: Pool;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +55,7 @@ async function updateJobProgress(
|
|||||||
* Guarda los CFDIs en la base de datos del tenant
|
* Guarda los CFDIs en la base de datos del tenant
|
||||||
*/
|
*/
|
||||||
async function saveCfdis(
|
async function saveCfdis(
|
||||||
schemaName: string,
|
pool: Pool,
|
||||||
cfdis: CfdiParsed[],
|
cfdis: CfdiParsed[],
|
||||||
jobId: string
|
jobId: string
|
||||||
): Promise<{ inserted: number; updated: number }> {
|
): Promise<{ inserted: number; updated: number }> {
|
||||||
@@ -63,16 +64,14 @@ async function saveCfdis(
|
|||||||
|
|
||||||
for (const cfdi of cfdis) {
|
for (const cfdi of cfdis) {
|
||||||
try {
|
try {
|
||||||
// Usar raw query para el esquema del tenant
|
const { rows: existing } = await pool.query(
|
||||||
const existing = await prisma.$queryRawUnsafe<{ id: string }[]>(
|
`SELECT id FROM cfdis WHERE uuid_fiscal = $1`,
|
||||||
`SELECT id FROM "${schemaName}".cfdis WHERE uuid_fiscal = $1`,
|
[cfdi.uuidFiscal]
|
||||||
cfdi.uuidFiscal
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
// Actualizar CFDI existente
|
await pool.query(
|
||||||
await prisma.$executeRawUnsafe(
|
`UPDATE cfdis SET
|
||||||
`UPDATE "${schemaName}".cfdis SET
|
|
||||||
tipo = $2,
|
tipo = $2,
|
||||||
serie = $3,
|
serie = $3,
|
||||||
folio = $4,
|
folio = $4,
|
||||||
@@ -96,9 +95,10 @@ async function saveCfdis(
|
|||||||
estado = $22,
|
estado = $22,
|
||||||
xml_original = $23,
|
xml_original = $23,
|
||||||
last_sat_sync = NOW(),
|
last_sat_sync = NOW(),
|
||||||
sat_sync_job_id = $24,
|
sat_sync_job_id = $24::uuid,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE uuid_fiscal = $1`,
|
WHERE uuid_fiscal = $1`,
|
||||||
|
[
|
||||||
cfdi.uuidFiscal,
|
cfdi.uuidFiscal,
|
||||||
cfdi.tipo,
|
cfdi.tipo,
|
||||||
cfdi.serie,
|
cfdi.serie,
|
||||||
@@ -123,12 +123,12 @@ async function saveCfdis(
|
|||||||
cfdi.estado,
|
cfdi.estado,
|
||||||
cfdi.xmlOriginal,
|
cfdi.xmlOriginal,
|
||||||
jobId
|
jobId
|
||||||
|
]
|
||||||
);
|
);
|
||||||
updated++;
|
updated++;
|
||||||
} else {
|
} else {
|
||||||
// Insertar nuevo CFDI
|
await pool.query(
|
||||||
await prisma.$executeRawUnsafe(
|
`INSERT INTO cfdis (
|
||||||
`INSERT INTO "${schemaName}".cfdis (
|
|
||||||
id, uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
id, uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||||
@@ -137,8 +137,9 @@ async function saveCfdis(
|
|||||||
) VALUES (
|
) VALUES (
|
||||||
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||||
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22,
|
$11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22,
|
||||||
$23, 'sat', $24, NOW(), NOW()
|
$23, 'sat', $24::uuid, NOW(), NOW()
|
||||||
)`,
|
)`,
|
||||||
|
[
|
||||||
cfdi.uuidFiscal,
|
cfdi.uuidFiscal,
|
||||||
cfdi.tipo,
|
cfdi.tipo,
|
||||||
cfdi.serie,
|
cfdi.serie,
|
||||||
@@ -163,6 +164,7 @@ async function saveCfdis(
|
|||||||
cfdi.estado,
|
cfdi.estado,
|
||||||
cfdi.xmlOriginal,
|
cfdi.xmlOriginal,
|
||||||
jobId
|
jobId
|
||||||
|
]
|
||||||
);
|
);
|
||||||
inserted++;
|
inserted++;
|
||||||
}
|
}
|
||||||
@@ -186,11 +188,9 @@ async function processDateRange(
|
|||||||
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
|
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
|
||||||
console.log(`[SAT] Procesando ${tipoCfdi} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
|
console.log(`[SAT] Procesando ${tipoCfdi} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
|
||||||
|
|
||||||
// 1. Solicitar descarga
|
|
||||||
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi);
|
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi);
|
||||||
|
|
||||||
if (!queryResult.success) {
|
if (!queryResult.success) {
|
||||||
// Código 5004 = No hay CFDIs en el rango
|
|
||||||
if (queryResult.statusCode === '5004') {
|
if (queryResult.statusCode === '5004') {
|
||||||
console.log('[SAT] No se encontraron CFDIs en el rango');
|
console.log('[SAT] No se encontraron CFDIs en el rango');
|
||||||
return { found: 0, downloaded: 0, inserted: 0, updated: 0 };
|
return { found: 0, downloaded: 0, inserted: 0, updated: 0 };
|
||||||
@@ -203,7 +203,6 @@ async function processDateRange(
|
|||||||
|
|
||||||
await updateJobProgress(jobId, { satRequestId: requestId });
|
await updateJobProgress(jobId, { satRequestId: requestId });
|
||||||
|
|
||||||
// 2. Esperar y verificar solicitud
|
|
||||||
let verifyResult;
|
let verifyResult;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
|
|
||||||
@@ -227,7 +226,6 @@ async function processDateRange(
|
|||||||
throw new Error('Timeout esperando respuesta del SAT');
|
throw new Error('Timeout esperando respuesta del SAT');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Descargar paquetes
|
|
||||||
const packageIds = verifyResult.packageIds;
|
const packageIds = verifyResult.packageIds;
|
||||||
await updateJobProgress(jobId, {
|
await updateJobProgress(jobId, {
|
||||||
satPackageIds: packageIds,
|
satPackageIds: packageIds,
|
||||||
@@ -249,17 +247,15 @@ async function processDateRange(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Procesar paquete (el contenido viene en base64)
|
|
||||||
const cfdis = processPackage(downloadResult.packageContent);
|
const cfdis = processPackage(downloadResult.packageContent);
|
||||||
totalDownloaded += cfdis.length;
|
totalDownloaded += cfdis.length;
|
||||||
|
|
||||||
console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`);
|
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;
|
totalInserted += inserted;
|
||||||
totalUpdated += updated;
|
totalUpdated += updated;
|
||||||
|
|
||||||
// Actualizar progreso
|
|
||||||
const progress = Math.round(((i + 1) / packageIds.length) * 100);
|
const progress = Math.round(((i + 1) / packageIds.length) * 100);
|
||||||
await updateJobProgress(jobId, {
|
await updateJobProgress(jobId, {
|
||||||
cfdisDownloaded: totalDownloaded,
|
cfdisDownloaded: totalDownloaded,
|
||||||
@@ -278,25 +274,29 @@ async function processDateRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ejecuta sincronización inicial (últimos 10 años)
|
* Ejecuta sincronización inicial o por rango personalizado
|
||||||
*/
|
*/
|
||||||
async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void> {
|
async function processInitialSync(
|
||||||
|
ctx: SyncContext,
|
||||||
|
jobId: string,
|
||||||
|
customDateFrom?: Date,
|
||||||
|
customDateTo?: Date
|
||||||
|
): Promise<void> {
|
||||||
const ahora = new Date();
|
const ahora = new Date();
|
||||||
const inicioHistorico = new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
|
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
|
||||||
|
const fechaFin = customDateTo || ahora;
|
||||||
|
|
||||||
let totalFound = 0;
|
let totalFound = 0;
|
||||||
let totalDownloaded = 0;
|
let totalDownloaded = 0;
|
||||||
let totalInserted = 0;
|
let totalInserted = 0;
|
||||||
let totalUpdated = 0;
|
let totalUpdated = 0;
|
||||||
|
|
||||||
// Procesar por meses para evitar límites del SAT
|
|
||||||
let currentDate = new Date(inicioHistorico);
|
let currentDate = new Date(inicioHistorico);
|
||||||
|
|
||||||
while (currentDate < ahora) {
|
while (currentDate < fechaFin) {
|
||||||
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
|
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
|
||||||
const rangeEnd = monthEnd > ahora ? ahora : monthEnd;
|
const rangeEnd = monthEnd > fechaFin ? fechaFin : monthEnd;
|
||||||
|
|
||||||
// Procesar emitidos
|
|
||||||
try {
|
try {
|
||||||
const emitidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'emitidos');
|
const emitidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'emitidos');
|
||||||
totalFound += emitidos.found;
|
totalFound += emitidos.found;
|
||||||
@@ -307,7 +307,6 @@ async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void
|
|||||||
console.error(`[SAT] Error procesando emitidos ${currentDate.toISOString()}:`, error.message);
|
console.error(`[SAT] Error procesando emitidos ${currentDate.toISOString()}:`, error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Procesar recibidos
|
|
||||||
try {
|
try {
|
||||||
const recibidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'recibidos');
|
const recibidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'recibidos');
|
||||||
totalFound += recibidos.found;
|
totalFound += recibidos.found;
|
||||||
@@ -318,10 +317,8 @@ async function processInitialSync(ctx: SyncContext, jobId: string): Promise<void
|
|||||||
console.error(`[SAT] Error procesando recibidos ${currentDate.toISOString()}:`, error.message);
|
console.error(`[SAT] Error procesando recibidos ${currentDate.toISOString()}:`, error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Siguiente mes
|
|
||||||
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
|
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));
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,7 +342,6 @@ async function processDailySync(ctx: SyncContext, jobId: string): Promise<void>
|
|||||||
let totalInserted = 0;
|
let totalInserted = 0;
|
||||||
let totalUpdated = 0;
|
let totalUpdated = 0;
|
||||||
|
|
||||||
// Procesar emitidos del mes
|
|
||||||
try {
|
try {
|
||||||
const emitidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'emitidos');
|
const emitidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'emitidos');
|
||||||
totalFound += emitidos.found;
|
totalFound += emitidos.found;
|
||||||
@@ -356,7 +352,6 @@ async function processDailySync(ctx: SyncContext, jobId: string): Promise<void>
|
|||||||
console.error('[SAT] Error procesando emitidos:', error.message);
|
console.error('[SAT] Error procesando emitidos:', error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Procesar recibidos del mes
|
|
||||||
try {
|
try {
|
||||||
const recibidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'recibidos');
|
const recibidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'recibidos');
|
||||||
totalFound += recibidos.found;
|
totalFound += recibidos.found;
|
||||||
@@ -384,7 +379,6 @@ export async function startSync(
|
|||||||
dateFrom?: Date,
|
dateFrom?: Date,
|
||||||
dateTo?: Date
|
dateTo?: Date
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// Obtener credenciales FIEL
|
|
||||||
const decryptedFiel = await getDecryptedFiel(tenantId);
|
const decryptedFiel = await getDecryptedFiel(tenantId);
|
||||||
if (!decryptedFiel) {
|
if (!decryptedFiel) {
|
||||||
throw new Error('No hay FIEL configurada o está vencida');
|
throw new Error('No hay FIEL configurada o está vencida');
|
||||||
@@ -396,20 +390,17 @@ export async function startSync(
|
|||||||
password: decryptedFiel.password,
|
password: decryptedFiel.password,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Crear servicio SAT
|
|
||||||
const service = createSatService(fielData);
|
const service = createSatService(fielData);
|
||||||
|
|
||||||
// Obtener datos del tenant
|
|
||||||
const tenant = await prisma.tenant.findUnique({
|
const tenant = await prisma.tenant.findUnique({
|
||||||
where: { id: tenantId },
|
where: { id: tenantId },
|
||||||
select: { schemaName: true },
|
select: { databaseName: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tenant) {
|
if (!tenant) {
|
||||||
throw new Error('Tenant no encontrado');
|
throw new Error('Tenant no encontrado');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar que no haya sync activo
|
|
||||||
const activeSync = await prisma.satSyncJob.findFirst({
|
const activeSync = await prisma.satSyncJob.findFirst({
|
||||||
where: {
|
where: {
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -421,7 +412,6 @@ export async function startSync(
|
|||||||
throw new Error('Ya hay una sincronización en curso');
|
throw new Error('Ya hay una sincronización en curso');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear job
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const job = await prisma.satSyncJob.create({
|
const job = await prisma.satSyncJob.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -439,14 +429,14 @@ export async function startSync(
|
|||||||
service,
|
service,
|
||||||
rfc: decryptedFiel.rfc,
|
rfc: decryptedFiel.rfc,
|
||||||
tenantId,
|
tenantId,
|
||||||
schemaName: tenant.schemaName,
|
pool: tenantDb.getPool(tenantId, tenant.databaseName),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ejecutar sincronización en background
|
// Ejecutar sincronización en background
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
if (type === 'initial') {
|
if (type === 'initial') {
|
||||||
await processInitialSync(ctx, job.id);
|
await processInitialSync(ctx, job.id, dateFrom, dateTo);
|
||||||
} else {
|
} else {
|
||||||
await processDailySync(ctx, job.id);
|
await processDailySync(ctx, job.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
export async function getAllTenants() {
|
||||||
return prisma.tenant.findMany({
|
return prisma.tenant.findMany({
|
||||||
@@ -8,7 +12,7 @@ export async function getAllTenants() {
|
|||||||
nombre: true,
|
nombre: true,
|
||||||
rfc: true,
|
rfc: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
schemaName: true,
|
databaseName: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { users: true }
|
select: { users: true }
|
||||||
@@ -26,7 +30,7 @@ export async function getTenantById(id: string) {
|
|||||||
nombre: true,
|
nombre: true,
|
||||||
rfc: true,
|
rfc: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
schemaName: true,
|
databaseName: true,
|
||||||
cfdiLimit: true,
|
cfdiLimit: true,
|
||||||
usersLimit: true,
|
usersLimit: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@@ -40,104 +44,72 @@ export async function createTenant(data: {
|
|||||||
plan?: 'starter' | 'business' | 'professional' | 'enterprise';
|
plan?: 'starter' | 'business' | 'professional' | 'enterprise';
|
||||||
cfdiLimit?: number;
|
cfdiLimit?: number;
|
||||||
usersLimit?: 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({
|
const tenant = await prisma.tenant.create({
|
||||||
data: {
|
data: {
|
||||||
nombre: data.nombre,
|
nombre: data.nombre,
|
||||||
rfc: data.rfc.toUpperCase(),
|
rfc: data.rfc.toUpperCase(),
|
||||||
plan: data.plan || 'starter',
|
plan,
|
||||||
schemaName,
|
databaseName,
|
||||||
cfdiLimit: data.cfdiLimit || 500,
|
cfdiLimit: data.cfdiLimit || planConfig.cfdiLimit,
|
||||||
usersLimit: data.usersLimit || 3,
|
usersLimit: data.usersLimit || planConfig.usersLimit,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create schema and tables for the tenant
|
// 3. Create admin user with temp password
|
||||||
await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
|
const tempPassword = randomBytes(4).toString('hex'); // 8-char random
|
||||||
|
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
||||||
|
|
||||||
// Create CFDIs table
|
const user = await prisma.user.create({
|
||||||
await prisma.$executeRawUnsafe(`
|
data: {
|
||||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."cfdis" (
|
tenantId: tenant.id,
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
email: data.adminEmail,
|
||||||
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
|
passwordHash: hashedPassword,
|
||||||
tipo VARCHAR(20) NOT NULL,
|
nombre: data.adminNombre,
|
||||||
serie VARCHAR(25),
|
role: 'admin',
|
||||||
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
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create IVA monthly table
|
// 4. Create initial subscription
|
||||||
await prisma.$executeRawUnsafe(`
|
await prisma.subscription.create({
|
||||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" (
|
data: {
|
||||||
id SERIAL PRIMARY KEY,
|
tenantId: tenant.id,
|
||||||
año INT NOT NULL,
|
plan,
|
||||||
mes INT NOT NULL,
|
status: 'pending',
|
||||||
iva_trasladado DECIMAL(18,2) NOT NULL,
|
amount: data.amount,
|
||||||
iva_acreditable DECIMAL(18,2) NOT NULL,
|
frequency: 'monthly',
|
||||||
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)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create alerts table
|
// 5. Send welcome email to client (non-blocking)
|
||||||
await prisma.$executeRawUnsafe(`
|
emailService.sendWelcome(data.adminEmail, {
|
||||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" (
|
nombre: data.adminNombre,
|
||||||
id SERIAL PRIMARY KEY,
|
email: data.adminEmail,
|
||||||
tipo VARCHAR(50) NOT NULL,
|
tempPassword,
|
||||||
titulo VARCHAR(200) NOT NULL,
|
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
|
||||||
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
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create calendario_fiscal table
|
// 6. Send new client notification to admin with DB credentials
|
||||||
await prisma.$executeRawUnsafe(`
|
emailService.sendNewClientAdmin({
|
||||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" (
|
clienteNombre: data.nombre,
|
||||||
id SERIAL PRIMARY KEY,
|
clienteRfc: data.rfc.toUpperCase(),
|
||||||
titulo VARCHAR(200) NOT NULL,
|
adminEmail: data.adminEmail,
|
||||||
descripcion TEXT,
|
adminNombre: data.adminNombre,
|
||||||
tipo VARCHAR(20) NOT NULL,
|
tempPassword,
|
||||||
fecha_limite TIMESTAMP NOT NULL,
|
databaseName,
|
||||||
recurrencia VARCHAR(20) DEFAULT 'mensual',
|
plan,
|
||||||
completado BOOLEAN DEFAULT FALSE,
|
}).catch(err => console.error('[EMAIL] New client admin email failed:', err));
|
||||||
notas TEXT,
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
return tenant;
|
return { tenant, user, tempPassword };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTenant(id: string, data: {
|
export async function updateTenant(id: string, data: {
|
||||||
@@ -163,7 +135,7 @@ export async function updateTenant(id: string, data: {
|
|||||||
nombre: true,
|
nombre: true,
|
||||||
rfc: true,
|
rfc: true,
|
||||||
plan: true,
|
plan: true,
|
||||||
schemaName: true,
|
databaseName: true,
|
||||||
cfdiLimit: true,
|
cfdiLimit: true,
|
||||||
usersLimit: true,
|
usersLimit: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -173,9 +145,20 @@ export async function updateTenant(id: string, data: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTenant(id: string) {
|
export async function deleteTenant(id: string) {
|
||||||
// Soft delete - just mark as inactive
|
const tenant = await prisma.tenant.findUnique({
|
||||||
return prisma.tenant.update({
|
where: { id },
|
||||||
|
select: { databaseName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Soft-delete the tenant record
|
||||||
|
await prisma.tenant.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { active: false }
|
data: { active: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Soft-delete the database (rename with _deleted_ suffix)
|
||||||
|
if (tenant) {
|
||||||
|
await tenantDb.deprovisionDatabase(tenant.databaseName);
|
||||||
|
tenantDb.invalidatePool(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from '../config/database.js';
|
import { prisma } from '../config/database.js';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';
|
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';
|
||||||
|
|
||||||
export async function getUsuarios(tenantId: string): Promise<UserListItem[]> {
|
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');
|
throw new Error('Límite de usuarios alcanzado para este plan');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate temporary password
|
// Generate cryptographically secure temporary password
|
||||||
const tempPassword = Math.random().toString(36).slice(-8);
|
const tempPassword = randomBytes(4).toString('hex');
|
||||||
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
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
|
// TODO: Send email with tempPassword to the invited user
|
||||||
console.log(`Temporary password for ${data.email}: ${tempPassword}`);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
|
|||||||
31
apps/api/src/utils/global-admin.ts
Normal file
31
apps/api/src/utils/global-admin.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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`);
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt, { type SignOptions } from 'jsonwebtoken';
|
||||||
import type { JWTPayload } from '@horux/shared';
|
import type { JWTPayload } from '@horux/shared';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
|
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
|
||||||
return jwt.sign(payload, env.JWT_SECRET, {
|
const options: SignOptions = {
|
||||||
expiresIn: env.JWT_EXPIRES_IN,
|
expiresIn: env.JWT_EXPIRES_IN as SignOptions['expiresIn'],
|
||||||
});
|
};
|
||||||
|
return jwt.sign(payload, env.JWT_SECRET, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
|
export function generateRefreshToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
|
||||||
return jwt.sign(payload, env.JWT_SECRET, {
|
const options: SignOptions = {
|
||||||
expiresIn: env.JWT_REFRESH_EXPIRES_IN,
|
expiresIn: env.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'],
|
||||||
});
|
};
|
||||||
|
return jwt.sign(payload, env.JWT_SECRET, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyToken(token: string): JWTPayload {
|
export function verifyToken(token: string): JWTPayload {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useCallback } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import { useDebounce } from '@/lib/hooks/use-debounce';
|
||||||
import { Header } from '@/components/layouts/header';
|
import { Header } from '@/components/layouts/header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -8,10 +9,15 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||||
import { createManyCfdis } from '@/lib/api/cfdi';
|
import { createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||||
import type { CfdiFilters, TipoCfdi } from '@horux/shared';
|
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||||
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
import type { CreateCfdiData } from '@/lib/api/cfdi';
|
||||||
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer } from 'lucide-react';
|
||||||
|
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { saveAs } from 'file-saver';
|
||||||
|
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||||
|
import { getCfdiById } from '@/lib/api/cfdi';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
import { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -228,7 +234,49 @@ export default function CfdiPage() {
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
});
|
});
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [columnFilters, setColumnFilters] = useState({
|
||||||
|
fechaInicio: '',
|
||||||
|
fechaFin: '',
|
||||||
|
emisor: '',
|
||||||
|
receptor: '',
|
||||||
|
});
|
||||||
|
const [openFilter, setOpenFilter] = useState<'fecha' | 'emisor' | 'receptor' | null>(null);
|
||||||
|
const [emisorSuggestions, setEmisorSuggestions] = useState<EmisorReceptor[]>([]);
|
||||||
|
const [receptorSuggestions, setReceptorSuggestions] = useState<EmisorReceptor[]>([]);
|
||||||
|
const [loadingEmisor, setLoadingEmisor] = useState(false);
|
||||||
|
const [loadingReceptor, setLoadingReceptor] = useState(false);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
|
// Debounced values for autocomplete
|
||||||
|
const debouncedEmisor = useDebounce(columnFilters.emisor, 300);
|
||||||
|
const debouncedReceptor = useDebounce(columnFilters.receptor, 300);
|
||||||
|
|
||||||
|
// Fetch emisor suggestions when debounced value changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedEmisor.length < 2) {
|
||||||
|
setEmisorSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingEmisor(true);
|
||||||
|
searchEmisores(debouncedEmisor)
|
||||||
|
.then(setEmisorSuggestions)
|
||||||
|
.catch(() => setEmisorSuggestions([]))
|
||||||
|
.finally(() => setLoadingEmisor(false));
|
||||||
|
}, [debouncedEmisor]);
|
||||||
|
|
||||||
|
// Fetch receptor suggestions when debounced value changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedReceptor.length < 2) {
|
||||||
|
setReceptorSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingReceptor(true);
|
||||||
|
searchReceptores(debouncedReceptor)
|
||||||
|
.then(setReceptorSuggestions)
|
||||||
|
.catch(() => setReceptorSuggestions([]))
|
||||||
|
.finally(() => setLoadingReceptor(false));
|
||||||
|
}, [debouncedReceptor]);
|
||||||
|
|
||||||
const [showBulkForm, setShowBulkForm] = useState(false);
|
const [showBulkForm, setShowBulkForm] = useState(false);
|
||||||
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
|
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
|
||||||
const [bulkData, setBulkData] = useState('');
|
const [bulkData, setBulkData] = useState('');
|
||||||
@@ -255,12 +303,138 @@ export default function CfdiPage() {
|
|||||||
const createCfdi = useCreateCfdi();
|
const createCfdi = useCreateCfdi();
|
||||||
const deleteCfdi = useDeleteCfdi();
|
const deleteCfdi = useDeleteCfdi();
|
||||||
|
|
||||||
|
// CFDI Viewer state
|
||||||
|
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
|
||||||
|
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleViewCfdi = async (id: string) => {
|
||||||
|
setLoadingCfdi(id);
|
||||||
|
try {
|
||||||
|
const cfdi = await getCfdiById(id);
|
||||||
|
setViewingCfdi(cfdi);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading CFDI:', error);
|
||||||
|
alert('Error al cargar el CFDI');
|
||||||
|
} finally {
|
||||||
|
setLoadingCfdi(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const canEdit = user?.role === 'admin' || user?.role === 'contador';
|
const canEdit = user?.role === 'admin' || user?.role === 'contador';
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
setFilters({ ...filters, search: searchTerm, page: 1 });
|
setFilters({ ...filters, search: searchTerm, page: 1 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export to Excel
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
const exportToExcel = async () => {
|
||||||
|
if (!data?.data.length) return;
|
||||||
|
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const exportData = data.data.map(cfdi => ({
|
||||||
|
'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'),
|
||||||
|
'Tipo': cfdi.tipo === 'ingreso' ? 'Ingreso' : 'Egreso',
|
||||||
|
'Serie': cfdi.serie || '',
|
||||||
|
'Folio': cfdi.folio || '',
|
||||||
|
'RFC Emisor': cfdi.rfcEmisor,
|
||||||
|
'Nombre Emisor': cfdi.nombreEmisor,
|
||||||
|
'RFC Receptor': cfdi.rfcReceptor,
|
||||||
|
'Nombre Receptor': cfdi.nombreReceptor,
|
||||||
|
'Subtotal': cfdi.subtotal,
|
||||||
|
'IVA': cfdi.iva,
|
||||||
|
'Total': cfdi.total,
|
||||||
|
'Moneda': cfdi.moneda,
|
||||||
|
'Estado': cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado',
|
||||||
|
'UUID': cfdi.uuidFiscal,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const ws = XLSX.utils.json_to_sheet(exportData);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, ws, 'CFDIs');
|
||||||
|
|
||||||
|
// Auto-size columns
|
||||||
|
const colWidths = Object.keys(exportData[0]).map(key => ({
|
||||||
|
wch: Math.max(key.length, ...exportData.map(row => String(row[key as keyof typeof row]).length))
|
||||||
|
}));
|
||||||
|
ws['!cols'] = colWidths;
|
||||||
|
|
||||||
|
const excelBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
|
||||||
|
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||||
|
|
||||||
|
const fileName = `cfdis_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||||
|
saveAs(blob, fileName);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting:', error);
|
||||||
|
alert('Error al exportar');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectEmisor = (emisor: EmisorReceptor) => {
|
||||||
|
setColumnFilters(prev => ({ ...prev, emisor: emisor.nombre }));
|
||||||
|
setEmisorSuggestions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectReceptor = (receptor: EmisorReceptor) => {
|
||||||
|
setColumnFilters(prev => ({ ...prev, receptor: receptor.nombre }));
|
||||||
|
setReceptorSuggestions([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyDateFilter = () => {
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
fechaInicio: columnFilters.fechaInicio || undefined,
|
||||||
|
fechaFin: columnFilters.fechaFin || undefined,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
setOpenFilter(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyEmisorFilter = () => {
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
emisor: columnFilters.emisor || undefined,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
setOpenFilter(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyReceptorFilter = () => {
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
receptor: columnFilters.receptor || undefined,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
setOpenFilter(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearDateFilter = () => {
|
||||||
|
setColumnFilters({ ...columnFilters, fechaInicio: '', fechaFin: '' });
|
||||||
|
setFilters({ ...filters, fechaInicio: undefined, fechaFin: undefined, page: 1 });
|
||||||
|
setOpenFilter(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearEmisorFilter = () => {
|
||||||
|
setColumnFilters({ ...columnFilters, emisor: '' });
|
||||||
|
setFilters({ ...filters, emisor: undefined, page: 1 });
|
||||||
|
setOpenFilter(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearReceptorFilter = () => {
|
||||||
|
setColumnFilters({ ...columnFilters, receptor: '' });
|
||||||
|
setFilters({ ...filters, receptor: undefined, page: 1 });
|
||||||
|
setOpenFilter(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasDateFilter = filters.fechaInicio || filters.fechaFin;
|
||||||
|
const hasEmisorFilter = filters.emisor;
|
||||||
|
const hasReceptorFilter = filters.receptor;
|
||||||
|
const hasActiveColumnFilters = hasDateFilter || hasEmisorFilter || hasReceptorFilter;
|
||||||
|
|
||||||
const handleFilterType = (tipo?: TipoCfdi) => {
|
const handleFilterType = (tipo?: TipoCfdi) => {
|
||||||
setFilters({ ...filters, tipo, page: 1 });
|
setFilters({ ...filters, tipo, page: 1 });
|
||||||
};
|
};
|
||||||
@@ -471,6 +645,32 @@ export default function CfdiPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcuts - Esc to close popovers and forms
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
// Close open filter popovers
|
||||||
|
if (openFilter !== null) {
|
||||||
|
setOpenFilter(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Close forms
|
||||||
|
if (showForm) {
|
||||||
|
setShowForm(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (showBulkForm) {
|
||||||
|
setShowBulkForm(false);
|
||||||
|
clearXmlFiles();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [openFilter, showForm, showBulkForm]);
|
||||||
|
|
||||||
const cancelUpload = () => {
|
const cancelUpload = () => {
|
||||||
uploadAbortRef.current = true;
|
uploadAbortRef.current = true;
|
||||||
setUploadProgress(prev => ({ ...prev, status: 'idle' }));
|
setUploadProgress(prev => ({ ...prev, status: 'idle' }));
|
||||||
@@ -558,8 +758,19 @@ export default function CfdiPage() {
|
|||||||
Egresos
|
Egresos
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{data && data.data.length > 0 && (
|
||||||
|
<Button variant="outline" onClick={exportToExcel} disabled={exporting}>
|
||||||
|
{exporting ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Exportar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
|
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
Agregar
|
Agregar
|
||||||
@@ -568,9 +779,10 @@ export default function CfdiPage() {
|
|||||||
<Upload className="h-4 w-4 mr-1" />
|
<Upload className="h-4 w-4 mr-1" />
|
||||||
Carga Masiva
|
Carga Masiva
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -1041,15 +1253,58 @@ export default function CfdiPage() {
|
|||||||
{/* Table */}
|
{/* Table */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
CFDIs ({data?.total || 0})
|
CFDIs ({data?.total || 0})
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
{hasActiveColumnFilters && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span>Filtros activos:</span>
|
||||||
|
{hasDateFilter && (
|
||||||
|
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-full flex items-center gap-1">
|
||||||
|
Fecha
|
||||||
|
<button onClick={clearDateFilter} className="hover:text-destructive">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasEmisorFilter && (
|
||||||
|
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-full flex items-center gap-1">
|
||||||
|
Emisor: {filters.emisor}
|
||||||
|
<button onClick={clearEmisorFilter} className="hover:text-destructive">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasReceptorFilter && (
|
||||||
|
<span className="px-2 py-0.5 bg-primary/10 text-primary rounded-full flex items-center gap-1">
|
||||||
|
Receptor: {filters.receptor}
|
||||||
|
<button onClick={clearReceptorFilter} className="hover:text-destructive">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="space-y-3">
|
||||||
Cargando...
|
{/* Skeleton loader */}
|
||||||
|
{[...Array(8)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-4 animate-pulse">
|
||||||
|
<div className="h-4 bg-muted rounded w-20"></div>
|
||||||
|
<div className="h-5 bg-muted rounded w-16"></div>
|
||||||
|
<div className="h-4 bg-muted rounded w-12"></div>
|
||||||
|
<div className="h-4 bg-muted rounded flex-1 max-w-[180px]"></div>
|
||||||
|
<div className="h-4 bg-muted rounded flex-1 max-w-[180px]"></div>
|
||||||
|
<div className="h-4 bg-muted rounded w-24 ml-auto"></div>
|
||||||
|
<div className="h-5 bg-muted rounded w-16"></div>
|
||||||
|
<div className="h-8 bg-muted rounded w-8"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : data?.data.length === 0 ? (
|
) : data?.data.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
@@ -1060,13 +1315,172 @@ export default function CfdiPage() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b text-left text-sm text-muted-foreground">
|
<tr className="border-b text-left text-sm text-muted-foreground">
|
||||||
<th className="pb-3 font-medium">Fecha</th>
|
<th className="pb-3 font-medium">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Fecha
|
||||||
|
<Popover open={openFilter === 'fecha'} onOpenChange={(open) => setOpenFilter(open ? 'fecha' : null)}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button className={`p-1 rounded hover:bg-muted ${hasDateFilter ? 'text-primary' : ''}`}>
|
||||||
|
<Filter className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-64" align="start">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-sm">Filtrar por fecha</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Desde</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
value={columnFilters.fechaInicio}
|
||||||
|
onChange={(e) => setColumnFilters({ ...columnFilters, fechaInicio: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Hasta</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-8 text-sm"
|
||||||
|
value={columnFilters.fechaFin}
|
||||||
|
onChange={(e) => setColumnFilters({ ...columnFilters, fechaFin: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" className="flex-1" onClick={applyDateFilter}>
|
||||||
|
Aplicar
|
||||||
|
</Button>
|
||||||
|
{hasDateFilter && (
|
||||||
|
<Button size="sm" variant="outline" onClick={clearDateFilter}>
|
||||||
|
Limpiar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
<th className="pb-3 font-medium">Tipo</th>
|
<th className="pb-3 font-medium">Tipo</th>
|
||||||
<th className="pb-3 font-medium">Folio</th>
|
<th className="pb-3 font-medium">Folio</th>
|
||||||
<th className="pb-3 font-medium">Emisor</th>
|
<th className="pb-3 font-medium">
|
||||||
<th className="pb-3 font-medium">Receptor</th>
|
<div className="flex items-center gap-1">
|
||||||
|
Emisor
|
||||||
|
<Popover open={openFilter === 'emisor'} onOpenChange={(open) => setOpenFilter(open ? 'emisor' : null)}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button className={`p-1 rounded hover:bg-muted ${hasEmisorFilter ? 'text-primary' : ''}`}>
|
||||||
|
<Filter className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72" align="start">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-sm">Filtrar por emisor</h4>
|
||||||
|
<div className="relative">
|
||||||
|
<Label className="text-xs">RFC o Nombre</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar emisor..."
|
||||||
|
className="h-8 text-sm"
|
||||||
|
value={columnFilters.emisor}
|
||||||
|
onChange={(e) => setColumnFilters(prev => ({ ...prev, emisor: e.target.value }))}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && applyEmisorFilter()}
|
||||||
|
/>
|
||||||
|
{emisorSuggestions.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
|
||||||
|
{emisorSuggestions.map((emisor, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
className="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors border-b last:border-b-0"
|
||||||
|
onClick={() => selectEmisor(emisor)}
|
||||||
|
>
|
||||||
|
<p className="font-medium truncate">{emisor.nombre}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{emisor.rfc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loadingEmisor && columnFilters.emisor.length >= 2 && emisorSuggestions.length === 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg p-2 text-center text-sm text-muted-foreground">
|
||||||
|
Buscando...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" className="flex-1" onClick={applyEmisorFilter}>
|
||||||
|
Aplicar
|
||||||
|
</Button>
|
||||||
|
{hasEmisorFilter && (
|
||||||
|
<Button size="sm" variant="outline" onClick={clearEmisorFilter}>
|
||||||
|
Limpiar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="pb-3 font-medium">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
Receptor
|
||||||
|
<Popover open={openFilter === 'receptor'} onOpenChange={(open) => setOpenFilter(open ? 'receptor' : null)}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button className={`p-1 rounded hover:bg-muted ${hasReceptorFilter ? 'text-primary' : ''}`}>
|
||||||
|
<Filter className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72" align="start">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium text-sm">Filtrar por receptor</h4>
|
||||||
|
<div className="relative">
|
||||||
|
<Label className="text-xs">RFC o Nombre</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar receptor..."
|
||||||
|
className="h-8 text-sm"
|
||||||
|
value={columnFilters.receptor}
|
||||||
|
onChange={(e) => setColumnFilters(prev => ({ ...prev, receptor: e.target.value }))}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && applyReceptorFilter()}
|
||||||
|
/>
|
||||||
|
{receptorSuggestions.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
|
||||||
|
{receptorSuggestions.map((receptor, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
className="w-full px-3 py-2 text-left text-sm hover:bg-muted transition-colors border-b last:border-b-0"
|
||||||
|
onClick={() => selectReceptor(receptor)}
|
||||||
|
>
|
||||||
|
<p className="font-medium truncate">{receptor.nombre}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{receptor.rfc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loadingReceptor && columnFilters.receptor.length >= 2 && receptorSuggestions.length === 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-900 border rounded-md shadow-lg p-2 text-center text-sm text-muted-foreground">
|
||||||
|
Buscando...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" className="flex-1" onClick={applyReceptorFilter}>
|
||||||
|
Aplicar
|
||||||
|
</Button>
|
||||||
|
{hasReceptorFilter && (
|
||||||
|
<Button size="sm" variant="outline" onClick={clearReceptorFilter}>
|
||||||
|
Limpiar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
<th className="pb-3 font-medium text-right">Total</th>
|
<th className="pb-3 font-medium text-right">Total</th>
|
||||||
<th className="pb-3 font-medium">Estado</th>
|
<th className="pb-3 font-medium">Estado</th>
|
||||||
|
<th className="pb-3 font-medium"></th>
|
||||||
{canEdit && <th className="pb-3 font-medium"></th>}
|
{canEdit && <th className="pb-3 font-medium"></th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -1122,6 +1536,21 @@ export default function CfdiPage() {
|
|||||||
{cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
|
{cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleViewCfdi(cfdi.id)}
|
||||||
|
disabled={loadingCfdi === cfdi.id}
|
||||||
|
title="Ver factura"
|
||||||
|
>
|
||||||
|
{loadingCfdi === cfdi.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<td className="py-3">
|
<td className="py-3">
|
||||||
<Button
|
<Button
|
||||||
@@ -1174,6 +1603,12 @@ export default function CfdiPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<CfdiViewerModal
|
||||||
|
cfdi={viewingCfdi}
|
||||||
|
open={viewingCfdi !== null}
|
||||||
|
onClose={() => setViewingCfdi(null)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
306
apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx
Normal file
306
apps/web/app/(dashboard)/configuracion/suscripcion/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
apps/web/components/cfdi/cfdi-invoice.tsx
Normal file
317
apps/web/components/cfdi/cfdi-invoice.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
import type { Cfdi } from '@horux/shared';
|
||||||
|
|
||||||
|
interface CfdiConcepto {
|
||||||
|
descripcion: string;
|
||||||
|
cantidad: number;
|
||||||
|
valorUnitario: number;
|
||||||
|
importe: number;
|
||||||
|
claveUnidad?: string;
|
||||||
|
claveProdServ?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CfdiInvoiceProps {
|
||||||
|
cfdi: Cfdi;
|
||||||
|
conceptos?: CfdiConcepto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) =>
|
||||||
|
new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MXN',
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) =>
|
||||||
|
new Date(dateString).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatDateTime = (dateString: string) =>
|
||||||
|
new Date(dateString).toLocaleString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tipoLabels: Record<string, string> = {
|
||||||
|
ingreso: 'Ingreso',
|
||||||
|
egreso: 'Egreso',
|
||||||
|
traslado: 'Traslado',
|
||||||
|
pago: 'Pago',
|
||||||
|
nomina: 'Nómina',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formaPagoLabels: Record<string, string> = {
|
||||||
|
'01': 'Efectivo',
|
||||||
|
'02': 'Cheque nominativo',
|
||||||
|
'03': 'Transferencia electrónica',
|
||||||
|
'04': 'Tarjeta de crédito',
|
||||||
|
'28': 'Tarjeta de débito',
|
||||||
|
'99': 'Por definir',
|
||||||
|
};
|
||||||
|
|
||||||
|
const metodoPagoLabels: Record<string, string> = {
|
||||||
|
PUE: 'Pago en una sola exhibición',
|
||||||
|
PPD: 'Pago en parcialidades o diferido',
|
||||||
|
};
|
||||||
|
|
||||||
|
const usoCfdiLabels: Record<string, string> = {
|
||||||
|
G01: 'Adquisición de mercancías',
|
||||||
|
G02: 'Devoluciones, descuentos o bonificaciones',
|
||||||
|
G03: 'Gastos en general',
|
||||||
|
I01: 'Construcciones',
|
||||||
|
I02: 'Mobilario y equipo de oficina',
|
||||||
|
I03: 'Equipo de transporte',
|
||||||
|
I04: 'Equipo de cómputo',
|
||||||
|
I05: 'Dados, troqueles, moldes',
|
||||||
|
I06: 'Comunicaciones telefónicas',
|
||||||
|
I07: 'Comunicaciones satelitales',
|
||||||
|
I08: 'Otra maquinaria y equipo',
|
||||||
|
D01: 'Honorarios médicos',
|
||||||
|
D02: 'Gastos médicos por incapacidad',
|
||||||
|
D03: 'Gastos funerales',
|
||||||
|
D04: 'Donativos',
|
||||||
|
D05: 'Intereses por créditos hipotecarios',
|
||||||
|
D06: 'Aportaciones voluntarias SAR',
|
||||||
|
D07: 'Primas por seguros de gastos médicos',
|
||||||
|
D08: 'Gastos de transportación escolar',
|
||||||
|
D09: 'Depósitos en cuentas para el ahorro',
|
||||||
|
D10: 'Pagos por servicios educativos',
|
||||||
|
P01: 'Por definir',
|
||||||
|
S01: 'Sin efectos fiscales',
|
||||||
|
CP01: 'Pagos',
|
||||||
|
CN01: 'Nómina',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
|
||||||
|
({ cfdi, conceptos }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="bg-white text-gray-800 max-w-[850px] mx-auto text-sm shadow-lg"
|
||||||
|
style={{ fontFamily: 'Segoe UI, Roboto, Arial, sans-serif' }}
|
||||||
|
>
|
||||||
|
{/* Header con gradiente */}
|
||||||
|
<div className="bg-gradient-to-r from-blue-700 to-blue-900 text-white p-6">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold opacity-90">Emisor</h2>
|
||||||
|
<p className="text-xl font-bold mt-1">{cfdi.nombreEmisor}</p>
|
||||||
|
<p className="text-blue-200 text-sm mt-1">RFC: {cfdi.rfcEmisor}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-3 mb-2">
|
||||||
|
<span
|
||||||
|
className={`px-3 py-1 text-xs font-bold rounded-full ${
|
||||||
|
cfdi.estado === 'vigente'
|
||||||
|
? 'bg-green-400 text-green-900'
|
||||||
|
: 'bg-red-400 text-red-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cfdi.estado === 'vigente' ? 'VIGENTE' : 'CANCELADO'}
|
||||||
|
</span>
|
||||||
|
<span className="px-3 py-1 text-xs font-bold rounded-full bg-white/20">
|
||||||
|
{tipoLabels[cfdi.tipo] || cfdi.tipo}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold tracking-tight">
|
||||||
|
{cfdi.serie && <span className="text-blue-300">{cfdi.serie}-</span>}
|
||||||
|
{cfdi.folio || 'S/N'}
|
||||||
|
</div>
|
||||||
|
<p className="text-blue-200 text-sm mt-1">{formatDate(cfdi.fechaEmision)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Receptor */}
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 mb-5 border-l-4 border-blue-600">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Receptor</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-800 mt-1">{cfdi.nombreReceptor}</p>
|
||||||
|
<p className="text-gray-600 text-sm">RFC: {cfdi.rfcReceptor}</p>
|
||||||
|
</div>
|
||||||
|
{cfdi.usoCfdi && (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Uso CFDI</p>
|
||||||
|
<p className="text-sm font-medium text-gray-700 mt-1">
|
||||||
|
{cfdi.usoCfdi} - {usoCfdiLabels[cfdi.usoCfdi] || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datos del Comprobante */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-5">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide">Método Pago</p>
|
||||||
|
<p className="text-sm font-semibold text-gray-800 mt-1">
|
||||||
|
{cfdi.metodoPago || '-'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || '' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide">Forma Pago</p>
|
||||||
|
<p className="text-sm font-semibold text-gray-800 mt-1">
|
||||||
|
{cfdi.formaPago || '-'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || '' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide">Moneda</p>
|
||||||
|
<p className="text-sm font-semibold text-gray-800 mt-1">{cfdi.moneda || 'MXN'}</p>
|
||||||
|
{cfdi.tipoCambio && cfdi.tipoCambio !== 1 && (
|
||||||
|
<p className="text-xs text-gray-500">TC: {cfdi.tipoCambio}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide">Versión</p>
|
||||||
|
<p className="text-sm font-semibold text-gray-800 mt-1">CFDI 4.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conceptos */}
|
||||||
|
{conceptos && conceptos.length > 0 && (
|
||||||
|
<div className="mb-5">
|
||||||
|
<h3 className="text-xs text-gray-500 uppercase tracking-wide font-medium mb-2 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-4 bg-blue-600 rounded-full"></span>
|
||||||
|
Conceptos
|
||||||
|
</h3>
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100">
|
||||||
|
<th className="text-left py-3 px-4 font-semibold text-gray-700">Descripción</th>
|
||||||
|
<th className="text-center py-3 px-3 font-semibold text-gray-700 w-20">Cant.</th>
|
||||||
|
<th className="text-right py-3 px-4 font-semibold text-gray-700 w-32">P. Unitario</th>
|
||||||
|
<th className="text-right py-3 px-4 font-semibold text-gray-700 w-32">Importe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{conceptos.map((concepto, idx) => (
|
||||||
|
<tr
|
||||||
|
key={idx}
|
||||||
|
className={`border-t border-gray-100 ${idx % 2 === 1 ? 'bg-gray-50/50' : ''}`}
|
||||||
|
>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<p className="text-gray-800">{concepto.descripcion}</p>
|
||||||
|
{concepto.claveProdServ && (
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
Clave: {concepto.claveProdServ}
|
||||||
|
{concepto.claveUnidad && ` | Unidad: ${concepto.claveUnidad}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-center py-3 px-3 text-gray-700">{concepto.cantidad}</td>
|
||||||
|
<td className="text-right py-3 px-4 text-gray-700">
|
||||||
|
{formatCurrency(concepto.valorUnitario)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right py-3 px-4 font-medium text-gray-800">
|
||||||
|
{formatCurrency(concepto.importe)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Totales */}
|
||||||
|
<div className="flex justify-end mb-5">
|
||||||
|
<div className="w-80 bg-gray-50 rounded-lg overflow-hidden">
|
||||||
|
<div className="divide-y divide-gray-200">
|
||||||
|
<div className="flex justify-between py-2.5 px-4">
|
||||||
|
<span className="text-gray-600">Subtotal</span>
|
||||||
|
<span className="font-medium">{formatCurrency(cfdi.subtotal)}</span>
|
||||||
|
</div>
|
||||||
|
{cfdi.descuento > 0 && (
|
||||||
|
<div className="flex justify-between py-2.5 px-4">
|
||||||
|
<span className="text-gray-600">Descuento</span>
|
||||||
|
<span className="font-medium text-red-600">-{formatCurrency(cfdi.descuento)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cfdi.iva > 0 && (
|
||||||
|
<div className="flex justify-between py-2.5 px-4">
|
||||||
|
<span className="text-gray-600">IVA (16%)</span>
|
||||||
|
<span className="font-medium">{formatCurrency(cfdi.iva)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cfdi.ivaRetenido > 0 && (
|
||||||
|
<div className="flex justify-between py-2.5 px-4">
|
||||||
|
<span className="text-gray-600">IVA Retenido</span>
|
||||||
|
<span className="font-medium text-red-600">-{formatCurrency(cfdi.ivaRetenido)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cfdi.isrRetenido > 0 && (
|
||||||
|
<div className="flex justify-between py-2.5 px-4">
|
||||||
|
<span className="text-gray-600">ISR Retenido</span>
|
||||||
|
<span className="font-medium text-red-600">-{formatCurrency(cfdi.isrRetenido)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-700 text-white py-3 px-4 flex justify-between items-center">
|
||||||
|
<span className="font-semibold">TOTAL</span>
|
||||||
|
<span className="text-xl font-bold">{formatCurrency(cfdi.total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timbre Fiscal Digital */}
|
||||||
|
<div className="bg-gradient-to-r from-gray-100 to-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* QR Placeholder */}
|
||||||
|
<div className="w-24 h-24 bg-white border-2 border-gray-300 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg className="w-12 h-12 text-gray-400 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h2M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[10px] text-gray-400 mt-1 block">QR</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info del Timbre */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-xs text-gray-500 uppercase tracking-wide font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
Timbre Fiscal Digital
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-gray-500">UUID: </span>
|
||||||
|
<span className="text-xs font-mono text-blue-700 font-medium">{cfdi.uuidFiscal}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-gray-500">Fecha de Timbrado: </span>
|
||||||
|
<span className="text-xs font-medium text-gray-700">{formatDateTime(cfdi.fechaTimbrado)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leyenda */}
|
||||||
|
<p className="text-[10px] text-gray-400 mt-3 text-center border-t border-gray-200 pt-2">
|
||||||
|
Este documento es una representación impresa de un CFDI • Verificable en: https://verificacfdi.facturaelectronica.sat.gob.mx
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
CfdiInvoice.displayName = 'CfdiInvoice';
|
||||||
218
apps/web/components/cfdi/cfdi-viewer-modal.tsx
Normal file
218
apps/web/components/cfdi/cfdi-viewer-modal.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
import type { Cfdi } from '@horux/shared';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CfdiInvoice } from './cfdi-invoice';
|
||||||
|
import { getCfdiXml } from '@/lib/api/cfdi';
|
||||||
|
import { Download, FileText, Loader2, Printer } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CfdiConcepto {
|
||||||
|
descripcion: string;
|
||||||
|
cantidad: number;
|
||||||
|
valorUnitario: number;
|
||||||
|
importe: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CfdiViewerModalProps {
|
||||||
|
cfdi: Cfdi | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConceptosFromXml(xmlString: string): CfdiConcepto[] {
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(xmlString, 'text/xml');
|
||||||
|
const conceptos: CfdiConcepto[] = [];
|
||||||
|
|
||||||
|
// Find all Concepto elements
|
||||||
|
const elements = doc.getElementsByTagName('*');
|
||||||
|
for (let i = 0; i < elements.length; i++) {
|
||||||
|
if (elements[i].localName === 'Concepto') {
|
||||||
|
const el = elements[i];
|
||||||
|
conceptos.push({
|
||||||
|
descripcion: el.getAttribute('Descripcion') || '',
|
||||||
|
cantidad: parseFloat(el.getAttribute('Cantidad') || '1'),
|
||||||
|
valorUnitario: parseFloat(el.getAttribute('ValorUnitario') || '0'),
|
||||||
|
importe: parseFloat(el.getAttribute('Importe') || '0'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conceptos;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
|
||||||
|
const invoiceRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [conceptos, setConceptos] = useState<CfdiConcepto[]>([]);
|
||||||
|
const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null);
|
||||||
|
const [xmlContent, setXmlContent] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cfdi?.xmlOriginal) {
|
||||||
|
setXmlContent(cfdi.xmlOriginal);
|
||||||
|
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
|
||||||
|
} else {
|
||||||
|
setXmlContent(null);
|
||||||
|
setConceptos([]);
|
||||||
|
}
|
||||||
|
}, [cfdi]);
|
||||||
|
|
||||||
|
const handleDownloadPdf = async () => {
|
||||||
|
if (!invoiceRef.current || !cfdi) return;
|
||||||
|
|
||||||
|
setDownloading('pdf');
|
||||||
|
try {
|
||||||
|
const html2pdf = (await import('html2pdf.js')).default;
|
||||||
|
|
||||||
|
const opt = {
|
||||||
|
margin: 10,
|
||||||
|
filename: `factura-${cfdi.uuidFiscal.substring(0, 8)}.pdf`,
|
||||||
|
image: { type: 'jpeg' as const, quality: 0.98 },
|
||||||
|
html2canvas: { scale: 2, useCORS: true },
|
||||||
|
jsPDF: { unit: 'mm' as const, format: 'a4' as const, orientation: 'portrait' as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
await html2pdf().set(opt).from(invoiceRef.current).save();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating PDF:', error);
|
||||||
|
alert('Error al generar el PDF');
|
||||||
|
} finally {
|
||||||
|
setDownloading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadXml = async () => {
|
||||||
|
if (!cfdi) return;
|
||||||
|
|
||||||
|
setDownloading('xml');
|
||||||
|
try {
|
||||||
|
let xml = xmlContent;
|
||||||
|
|
||||||
|
if (!xml) {
|
||||||
|
xml = await getCfdiXml(cfdi.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xml) {
|
||||||
|
alert('No hay XML disponible para este CFDI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([xml], { type: 'application/xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `cfdi-${cfdi.uuidFiscal.substring(0, 8)}.xml`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading XML:', error);
|
||||||
|
alert('Error al descargar el XML');
|
||||||
|
} finally {
|
||||||
|
setDownloading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrint = () => {
|
||||||
|
if (!invoiceRef.current) return;
|
||||||
|
|
||||||
|
// Create a print-specific stylesheet
|
||||||
|
const printStyles = document.createElement('style');
|
||||||
|
printStyles.innerHTML = `
|
||||||
|
@media print {
|
||||||
|
body * {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
#cfdi-print-area, #cfdi-print-area * {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
#cfdi-print-area {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
@page {
|
||||||
|
size: A4;
|
||||||
|
margin: 15mm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(printStyles);
|
||||||
|
|
||||||
|
// Add ID to the invoice container for print targeting
|
||||||
|
invoiceRef.current.id = 'cfdi-print-area';
|
||||||
|
|
||||||
|
// Trigger print
|
||||||
|
window.print();
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
document.head.removeChild(printStyles);
|
||||||
|
invoiceRef.current.removeAttribute('id');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cfdi) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<DialogTitle>Vista de Factura</DialogTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadPdf}
|
||||||
|
disabled={downloading !== null}
|
||||||
|
>
|
||||||
|
{downloading === 'pdf' ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
PDF
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadXml}
|
||||||
|
disabled={downloading !== null || !xmlContent}
|
||||||
|
title={!xmlContent ? 'XML no disponible' : 'Descargar XML'}
|
||||||
|
>
|
||||||
|
{downloading === 'xml' ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
XML
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePrint}
|
||||||
|
disabled={downloading !== null}
|
||||||
|
title="Imprimir factura"
|
||||||
|
>
|
||||||
|
<Printer className="h-4 w-4 mr-1" />
|
||||||
|
Imprimir
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="border rounded-lg overflow-hidden bg-gray-50 p-4">
|
||||||
|
<CfdiInvoice ref={invoiceRef} cfdi={cfdi} conceptos={conceptos} />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Sidebar } from './sidebar';
|
|
||||||
import { Header } from './header';
|
import { Header } from './header';
|
||||||
|
|
||||||
interface DashboardShellProps {
|
interface DashboardShellProps {
|
||||||
@@ -8,13 +7,12 @@ interface DashboardShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardShell({ children, title, headerContent }: DashboardShellProps) {
|
export function DashboardShell({ children, title, headerContent }: DashboardShellProps) {
|
||||||
|
// Navigation is handled by the parent layout.tsx which respects theme settings
|
||||||
|
// DashboardShell only provides Header and content wrapper
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<>
|
||||||
<Sidebar />
|
|
||||||
<div className="pl-64">
|
|
||||||
<Header title={title}>{headerContent}</Header>
|
<Header title={title}>{headerContent}</Header>
|
||||||
<main className="p-6">{children}</main>
|
<main className="p-6">{children}</main>
|
||||||
</div>
|
</>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,23 +16,33 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Building2,
|
Building2,
|
||||||
UserCog,
|
UserCog,
|
||||||
|
CreditCard,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { logout } from '@/lib/api/auth';
|
import { logout } from '@/lib/api/auth';
|
||||||
import { useRouter } from 'next/navigation';
|
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: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||||
{ name: 'CFDI', href: '/cfdi', icon: FileText },
|
{ name: 'CFDI', href: '/cfdi', icon: FileText },
|
||||||
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
||||||
{ name: 'Reportes', href: '/reportes', icon: BarChart3 },
|
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes' },
|
||||||
{ name: 'Calendario', href: '/calendario', icon: Calendar },
|
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
||||||
{ name: 'Alertas', href: '/alertas', icon: Bell },
|
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
||||||
{ name: 'Usuarios', href: '/usuarios', icon: Users },
|
{ name: 'Usuarios', href: '/usuarios', icon: Users },
|
||||||
|
{ name: 'Suscripción', href: '/configuracion/suscripcion', icon: CreditCard },
|
||||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings },
|
{ name: 'Configuracion', href: '/configuracion', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminNavigation = [
|
const adminNavigation: NavItem[] = [
|
||||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
{ 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'
|
const allNavigation = user?.role === 'admin'
|
||||||
? [...navigation.slice(0, -1), ...adminNavigation, navigation[navigation.length - 1]]
|
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
|
||||||
: navigation;
|
: filteredNav;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
|
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Onboarding persistence key.
|
* Onboarding persistence key.
|
||||||
@@ -11,6 +12,7 @@ const STORAGE_KEY = 'horux360:onboarding_seen_v1';
|
|||||||
|
|
||||||
export default function OnboardingScreen() {
|
export default function OnboardingScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { isAuthenticated, _hasHydrated } = useAuthStore();
|
||||||
const [isNewUser, setIsNewUser] = useState(true);
|
const [isNewUser, setIsNewUser] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -21,6 +23,13 @@ export default function OnboardingScreen() {
|
|||||||
router.push(path);
|
router.push(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (_hasHydrated && !isAuthenticated) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, _hasHydrated, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||||
|
|
||||||
@@ -46,6 +55,20 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
const headerStatus = useMemo(() => (isNewUser ? 'Onboarding' : 'Redirección'), [isNewUser]);
|
const headerStatus = useMemo(() => (isNewUser ? 'Onboarding' : 'Redirección'), [isNewUser]);
|
||||||
|
|
||||||
|
// Show loading while store hydrates
|
||||||
|
if (!_hasHydrated) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||||
|
<div className="animate-pulse text-slate-500">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't render if not authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen relative overflow-hidden bg-white">
|
<main className="min-h-screen relative overflow-hidden bg-white">
|
||||||
{/* Grid tech claro */}
|
{/* Grid tech claro */}
|
||||||
@@ -160,9 +183,6 @@ export default function OnboardingScreen() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 text-center text-xs text-slate-400">
|
|
||||||
Demo UI sin backend • Persistencia local: localStorage
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { getSyncStatus, startSync } from '@/lib/api/sat';
|
import { getSyncStatus, startSync } from '@/lib/api/sat';
|
||||||
import type { SatSyncStatusResponse } from '@horux/shared';
|
import type { SatSyncStatusResponse } from '@horux/shared';
|
||||||
|
|
||||||
@@ -30,6 +32,9 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [startingSync, setStartingSync] = useState(false);
|
const [startingSync, setStartingSync] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [showCustomDate, setShowCustomDate] = useState(false);
|
||||||
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
|
const [dateTo, setDateTo] = useState('');
|
||||||
|
|
||||||
const fetchStatus = async () => {
|
const fetchStatus = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -53,12 +58,21 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
|
|||||||
}
|
}
|
||||||
}, [fielConfigured]);
|
}, [fielConfigured]);
|
||||||
|
|
||||||
const handleStartSync = async (type: 'initial' | 'daily') => {
|
const handleStartSync = async (type: 'initial' | 'daily', customDates?: boolean) => {
|
||||||
setStartingSync(true);
|
setStartingSync(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await startSync({ type });
|
const params: { type: 'initial' | 'daily'; dateFrom?: string; dateTo?: string } = { type };
|
||||||
|
|
||||||
|
if (customDates && dateFrom && dateTo) {
|
||||||
|
// Convertir a formato completo con hora
|
||||||
|
params.dateFrom = `${dateFrom}T00:00:00`;
|
||||||
|
params.dateTo = `${dateTo}T23:59:59`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await startSync(params);
|
||||||
await fetchStatus();
|
await fetchStatus();
|
||||||
|
setShowCustomDate(false);
|
||||||
onSyncStarted?.();
|
onSyncStarted?.();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Error al iniciar sincronizacion');
|
setError(err.response?.data?.error || 'Error al iniciar sincronizacion');
|
||||||
@@ -162,6 +176,49 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
|
|||||||
<p className="text-sm text-red-500">{error}</p>
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Formulario de fechas personalizadas */}
|
||||||
|
{showCustomDate && (
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="dateFrom">Fecha inicio</Label>
|
||||||
|
<Input
|
||||||
|
id="dateFrom"
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
max={dateTo || undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="dateTo">Fecha fin</Label>
|
||||||
|
<Input
|
||||||
|
id="dateTo"
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
min={dateFrom || undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={startingSync || status?.hasActiveSync || !dateFrom || !dateTo}
|
||||||
|
onClick={() => handleStartSync('initial', true)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{startingSync ? 'Iniciando...' : 'Sincronizar periodo'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowCustomDate(false)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -169,18 +226,27 @@ export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
|
|||||||
onClick={() => handleStartSync('daily')}
|
onClick={() => handleStartSync('daily')}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
{startingSync ? 'Iniciando...' : 'Sincronizar ahora'}
|
{startingSync ? 'Iniciando...' : 'Sincronizar mes actual'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={startingSync || status?.hasActiveSync}
|
||||||
|
onClick={() => setShowCustomDate(!showCustomDate)}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Periodo personalizado
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!status?.lastCompletedJob && (
|
{!status?.lastCompletedJob && (
|
||||||
<Button
|
<Button
|
||||||
disabled={startingSync || status?.hasActiveSync}
|
disabled={startingSync || status?.hasActiveSync}
|
||||||
onClick={() => handleStartSync('initial')}
|
onClick={() => handleStartSync('initial')}
|
||||||
className="flex-1"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (10 anos)'}
|
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (6 anos)'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
122
apps/web/components/ui/dialog.tsx
Normal file
122
apps/web/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col space-y-1.5 text-center sm:text-left',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = 'DialogHeader';
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = 'DialogFooter';
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'text-lg font-semibold leading-none tracking-tight',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
30
apps/web/components/ui/popover.tsx
Normal file
30
apps/web/components/ui/popover.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-[9999] w-72 rounded-md border bg-white dark:bg-gray-900 p-4 text-popover-foreground shadow-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent };
|
||||||
@@ -9,6 +9,8 @@ export async function getCfdis(filters: CfdiFilters): Promise<CfdiListResponse>
|
|||||||
if (filters.fechaInicio) params.set('fechaInicio', filters.fechaInicio);
|
if (filters.fechaInicio) params.set('fechaInicio', filters.fechaInicio);
|
||||||
if (filters.fechaFin) params.set('fechaFin', filters.fechaFin);
|
if (filters.fechaFin) params.set('fechaFin', filters.fechaFin);
|
||||||
if (filters.rfc) params.set('rfc', filters.rfc);
|
if (filters.rfc) params.set('rfc', filters.rfc);
|
||||||
|
if (filters.emisor) params.set('emisor', filters.emisor);
|
||||||
|
if (filters.receptor) params.set('receptor', filters.receptor);
|
||||||
if (filters.search) params.set('search', filters.search);
|
if (filters.search) params.set('search', filters.search);
|
||||||
if (filters.page) params.set('page', filters.page.toString());
|
if (filters.page) params.set('page', filters.page.toString());
|
||||||
if (filters.limit) params.set('limit', filters.limit.toString());
|
if (filters.limit) params.set('limit', filters.limit.toString());
|
||||||
@@ -89,3 +91,27 @@ export async function createManyCfdis(
|
|||||||
export async function deleteCfdi(id: string): Promise<void> {
|
export async function deleteCfdi(id: string): Promise<void> {
|
||||||
await apiClient.delete(`/cfdi/${id}`);
|
await apiClient.delete(`/cfdi/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCfdiXml(id: string): Promise<string> {
|
||||||
|
const response = await apiClient.get<string>(`/cfdi/${id}/xml`, {
|
||||||
|
responseType: 'text'
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmisorReceptor {
|
||||||
|
rfc: string;
|
||||||
|
nombre: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchEmisores(search: string): Promise<EmisorReceptor[]> {
|
||||||
|
if (search.length < 2) return [];
|
||||||
|
const response = await apiClient.get<EmisorReceptor[]>(`/cfdi/emisores?search=${encodeURIComponent(search)}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchReceptores(search: string): Promise<EmisorReceptor[]> {
|
||||||
|
if (search.length < 2) return [];
|
||||||
|
const response = await apiClient.get<EmisorReceptor[]>(`/cfdi/receptores?search=${encodeURIComponent(search)}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|||||||
47
apps/web/lib/api/subscription.ts
Normal file
47
apps/web/lib/api/subscription.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ export interface Tenant {
|
|||||||
nombre: string;
|
nombre: string;
|
||||||
rfc: string;
|
rfc: string;
|
||||||
plan: string;
|
plan: string;
|
||||||
schemaName: string;
|
databaseName: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
_count?: {
|
_count?: {
|
||||||
users: number;
|
users: number;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export function useCfdis(filters: CfdiFilters) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['cfdis', filters],
|
queryKey: ['cfdis', filters],
|
||||||
queryFn: () => cfdiApi.getCfdis(filters),
|
queryFn: () => cfdiApi.getCfdis(filters),
|
||||||
|
staleTime: 30 * 1000, // 30 segundos
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutos en cache
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
17
apps/web/lib/hooks/use-debounce.ts
Normal file
17
apps/web/lib/hooks/use-debounce.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
40
apps/web/lib/hooks/use-subscription.ts
Normal file
40
apps/web/lib/hooks/use-subscription.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,9 +11,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@horux/shared": "workspace:*",
|
"@horux/shared": "workspace:*",
|
||||||
"@radix-ui/react-avatar": "^1.1.0",
|
"@radix-ui/react-avatar": "^1.1.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.0",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
"@radix-ui/react-dropdown-menu": "^2.1.0",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.0",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.1.0",
|
"@radix-ui/react-select": "^2.1.0",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
@@ -26,17 +27,21 @@
|
|||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
|
"html2pdf.js": "^0.14.0",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
"recharts": "^2.12.0",
|
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
|
"recharts": "^2.12.0",
|
||||||
"tailwind-merge": "^2.5.0",
|
"tailwind-merge": "^2.5.0",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.23.0",
|
"zod": "^3.23.0",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^18.3.0",
|
"@types/react": "^18.3.0",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
|||||||
90
deploy/nginx/horux360.conf
Normal file
90
deploy/nginx/horux360.conf
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
298
docs/SAT-SYNC-IMPLEMENTATION.md
Normal file
298
docs/SAT-SYNC-IMPLEMENTATION.md
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
# Implementación de Sincronización SAT
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
|
||||||
|
Sistema de sincronización automática de CFDIs con el SAT (Servicio de Administración Tributaria de México) para Horux360.
|
||||||
|
|
||||||
|
## Componentes Implementados
|
||||||
|
|
||||||
|
### 1. Backend (API)
|
||||||
|
|
||||||
|
#### Servicios
|
||||||
|
|
||||||
|
| Archivo | Descripción |
|
||||||
|
|---------|-------------|
|
||||||
|
| `src/services/fiel.service.ts` | Gestión de credenciales FIEL (e.firma) |
|
||||||
|
| `src/services/sat/sat-client.service.ts` | Cliente para el servicio web del SAT |
|
||||||
|
| `src/services/sat/sat.service.ts` | Lógica principal de sincronización |
|
||||||
|
| `src/services/sat/sat-crypto.service.ts` | Encriptación AES-256-GCM para credenciales |
|
||||||
|
| `src/services/sat/sat-parser.service.ts` | Parser de XMLs de CFDI |
|
||||||
|
|
||||||
|
#### Controladores
|
||||||
|
|
||||||
|
| Archivo | Descripción |
|
||||||
|
|---------|-------------|
|
||||||
|
| `src/controllers/fiel.controller.ts` | Endpoints para gestión de FIEL |
|
||||||
|
| `src/controllers/sat.controller.ts` | Endpoints para sincronización SAT |
|
||||||
|
|
||||||
|
#### Job Programado
|
||||||
|
|
||||||
|
| Archivo | Descripción |
|
||||||
|
|---------|-------------|
|
||||||
|
| `src/jobs/sat-sync.job.ts` | Cron job para sincronización diaria (3:00 AM) |
|
||||||
|
|
||||||
|
### 2. Frontend (Web)
|
||||||
|
|
||||||
|
#### Componentes
|
||||||
|
|
||||||
|
| Archivo | Descripción |
|
||||||
|
|---------|-------------|
|
||||||
|
| `components/sat/FielUploadModal.tsx` | Modal para subir certificado y llave FIEL |
|
||||||
|
| `components/sat/SyncStatus.tsx` | Estado de sincronización con selector de fechas |
|
||||||
|
| `components/sat/SyncHistory.tsx` | Historial de sincronizaciones |
|
||||||
|
|
||||||
|
#### Página
|
||||||
|
|
||||||
|
| Archivo | Descripción |
|
||||||
|
|---------|-------------|
|
||||||
|
| `app/(dashboard)/configuracion/sat/page.tsx` | Página de configuración SAT |
|
||||||
|
|
||||||
|
### 3. Base de Datos
|
||||||
|
|
||||||
|
#### Tabla Principal (schema public)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- sat_sync_jobs: Almacena los trabajos de sincronización
|
||||||
|
CREATE TABLE sat_sync_jobs (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
type VARCHAR(20) NOT NULL, -- 'initial' | 'daily'
|
||||||
|
status VARCHAR(20) NOT NULL, -- 'pending' | 'running' | 'completed' | 'failed'
|
||||||
|
date_from TIMESTAMP NOT NULL,
|
||||||
|
date_to TIMESTAMP NOT NULL,
|
||||||
|
cfdi_type VARCHAR(20),
|
||||||
|
sat_request_id VARCHAR(100),
|
||||||
|
sat_package_ids TEXT[],
|
||||||
|
cfdis_found INTEGER DEFAULT 0,
|
||||||
|
cfdis_downloaded INTEGER DEFAULT 0,
|
||||||
|
cfdis_inserted INTEGER DEFAULT 0,
|
||||||
|
cfdis_updated INTEGER DEFAULT 0,
|
||||||
|
progress_percent INTEGER DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
retry_count INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- fiel_credentials: Almacena las credenciales FIEL encriptadas
|
||||||
|
CREATE TABLE fiel_credentials (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID UNIQUE NOT NULL,
|
||||||
|
rfc VARCHAR(13) NOT NULL,
|
||||||
|
cer_data BYTEA NOT NULL,
|
||||||
|
key_data BYTEA NOT NULL,
|
||||||
|
key_password_encrypted BYTEA NOT NULL,
|
||||||
|
encryption_iv BYTEA NOT NULL,
|
||||||
|
encryption_tag BYTEA NOT NULL,
|
||||||
|
serial_number VARCHAR(100),
|
||||||
|
valid_from TIMESTAMP NOT NULL,
|
||||||
|
valid_until TIMESTAMP NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Columnas agregadas a tabla cfdis (por tenant)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE tenant_xxx.cfdis ADD COLUMN xml_original TEXT;
|
||||||
|
ALTER TABLE tenant_xxx.cfdis ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
ALTER TABLE tenant_xxx.cfdis ADD COLUMN last_sat_sync TIMESTAMP;
|
||||||
|
ALTER TABLE tenant_xxx.cfdis ADD COLUMN sat_sync_job_id UUID;
|
||||||
|
ALTER TABLE tenant_xxx.cfdis ADD COLUMN source VARCHAR(20) DEFAULT 'manual';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencias
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
|
||||||
|
"@nodecfdi/credentials": "^2.0.0",
|
||||||
|
"@nodecfdi/cfdi-core": "^1.0.1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flujo de Sincronización
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Usuario configura FIEL (certificado .cer + llave .key + contraseña)
|
||||||
|
↓
|
||||||
|
2. Sistema valida y encripta credenciales (AES-256-GCM)
|
||||||
|
↓
|
||||||
|
3. Usuario inicia sincronización (manual o automática 3:00 AM)
|
||||||
|
↓
|
||||||
|
4. Sistema desencripta FIEL y crea cliente SAT
|
||||||
|
↓
|
||||||
|
5. Por cada mes en el rango:
|
||||||
|
a. Solicitar CFDIs emitidos al SAT
|
||||||
|
b. Esperar respuesta (polling cada 30s)
|
||||||
|
c. Descargar paquetes ZIP
|
||||||
|
d. Extraer y parsear XMLs
|
||||||
|
e. Guardar en BD del tenant
|
||||||
|
f. Repetir para CFDIs recibidos
|
||||||
|
↓
|
||||||
|
6. Marcar job como completado
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### FIEL
|
||||||
|
|
||||||
|
| Método | Ruta | Descripción |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/fiel/status` | Estado de la FIEL configurada |
|
||||||
|
| POST | `/api/fiel/upload` | Subir nueva FIEL |
|
||||||
|
| DELETE | `/api/fiel` | Eliminar FIEL |
|
||||||
|
|
||||||
|
### Sincronización SAT
|
||||||
|
|
||||||
|
| Método | Ruta | Descripción |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| POST | `/api/sat/sync` | Iniciar sincronización |
|
||||||
|
| GET | `/api/sat/sync/status` | Estado actual |
|
||||||
|
| GET | `/api/sat/sync/history` | Historial de syncs |
|
||||||
|
| GET | `/api/sat/sync/:id` | Detalle de un job |
|
||||||
|
| POST | `/api/sat/sync/:id/retry` | Reintentar job fallido |
|
||||||
|
|
||||||
|
### Parámetros de sincronización
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface StartSyncRequest {
|
||||||
|
type?: 'initial' | 'daily'; // default: 'daily'
|
||||||
|
dateFrom?: string; // ISO date, ej: "2025-01-01T00:00:00"
|
||||||
|
dateTo?: string; // ISO date, ej: "2025-12-31T23:59:59"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuración
|
||||||
|
|
||||||
|
### Variables de entorno
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Clave para encriptar credenciales FIEL (32 bytes hex)
|
||||||
|
FIEL_ENCRYPTION_KEY=tu_clave_de_32_bytes_en_hexadecimal
|
||||||
|
|
||||||
|
# Zona horaria para el cron
|
||||||
|
TZ=America/Mexico_City
|
||||||
|
```
|
||||||
|
|
||||||
|
### Límites del SAT
|
||||||
|
|
||||||
|
- **Antigüedad máxima**: 6 años
|
||||||
|
- **Solicitudes por día**: Limitadas (se reinicia cada 24h)
|
||||||
|
- **Tamaño de paquete**: Variable
|
||||||
|
|
||||||
|
## Errores Comunes del SAT
|
||||||
|
|
||||||
|
| Código | Mensaje | Solución |
|
||||||
|
|--------|---------|----------|
|
||||||
|
| 5000 | Solicitud Aceptada | OK - esperar verificación |
|
||||||
|
| 5002 | Límite de solicitudes agotado | Esperar 24 horas |
|
||||||
|
| 5004 | No se encontraron CFDIs | Normal si no hay facturas en el rango |
|
||||||
|
| 5005 | Solicitud duplicada | Ya existe una solicitud pendiente |
|
||||||
|
| - | Información mayor a 6 años | Ajustar rango de fechas |
|
||||||
|
| - | No se permite descarga de cancelados | Facturas canceladas no disponibles |
|
||||||
|
|
||||||
|
## Seguridad
|
||||||
|
|
||||||
|
1. **Encriptación de credenciales**: AES-256-GCM con IV único
|
||||||
|
2. **Almacenamiento seguro**: Certificado, llave y contraseña encriptados
|
||||||
|
3. **Autenticación**: JWT con tenantId embebido
|
||||||
|
4. **Aislamiento**: Cada tenant tiene su propio schema en PostgreSQL
|
||||||
|
|
||||||
|
## Servicios Systemd
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API Backend
|
||||||
|
systemctl status horux-api
|
||||||
|
|
||||||
|
# Web Frontend
|
||||||
|
systemctl status horux-web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comandos Útiles
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver logs de sincronización SAT
|
||||||
|
journalctl -u horux-api -f | grep "\[SAT\]"
|
||||||
|
|
||||||
|
# Estado de jobs
|
||||||
|
psql -U postgres -d horux360 -c "SELECT * FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 5;"
|
||||||
|
|
||||||
|
# CFDIs sincronizados por tenant
|
||||||
|
psql -U postgres -d horux360 -c "SELECT COUNT(*) FROM tenant_xxx.cfdis WHERE source = 'sat';"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### 2026-01-25
|
||||||
|
|
||||||
|
- Implementación inicial de sincronización SAT
|
||||||
|
- Integración con librería @nodecfdi/sat-ws-descarga-masiva
|
||||||
|
- Soporte para fechas personalizadas en sincronización
|
||||||
|
- Corrección de cast UUID en queries SQL
|
||||||
|
- Agregadas columnas faltantes a tabla cfdis
|
||||||
|
- UI para selección de periodo personalizado
|
||||||
|
- Cambio de servicio web a modo producción (next start)
|
||||||
|
|
||||||
|
## Estado Actual (2026-01-25)
|
||||||
|
|
||||||
|
### Completado
|
||||||
|
|
||||||
|
- [x] Servicio de encriptación de credenciales FIEL
|
||||||
|
- [x] Integración con @nodecfdi/sat-ws-descarga-masiva
|
||||||
|
- [x] Parser de XMLs de CFDI
|
||||||
|
- [x] UI para subir FIEL
|
||||||
|
- [x] UI para ver estado de sincronización
|
||||||
|
- [x] UI para seleccionar periodo personalizado
|
||||||
|
- [x] Cron job para sincronización diaria (3:00 AM)
|
||||||
|
- [x] Soporte para fechas personalizadas
|
||||||
|
- [x] Corrección de cast UUID en queries
|
||||||
|
- [x] Columnas adicionales en tabla cfdis de todos los tenants
|
||||||
|
|
||||||
|
### Pendiente por probar
|
||||||
|
|
||||||
|
El SAT bloqueó las solicitudes por exceso de pruebas. **Esperar 24 horas** y luego:
|
||||||
|
|
||||||
|
1. Ir a **Configuración > SAT**
|
||||||
|
2. Clic en **"Periodo personalizado"**
|
||||||
|
3. Seleccionar: **2025-01-01** a **2025-12-31**
|
||||||
|
4. Clic en **"Sincronizar periodo"**
|
||||||
|
|
||||||
|
### Tenant de prueba
|
||||||
|
|
||||||
|
- **RFC**: CAS2408138W2
|
||||||
|
- **Schema**: `tenant_cas2408138w2`
|
||||||
|
- **Nota**: Los CFDIs "recibidos" de este tenant están cancelados (SAT no permite descargarlos)
|
||||||
|
|
||||||
|
### Comandos para verificar después de 24h
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ver estado del sync
|
||||||
|
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \
|
||||||
|
"SELECT status, cfdis_found, cfdis_downloaded, cfdis_inserted FROM sat_sync_jobs ORDER BY created_at DESC LIMIT 1;"
|
||||||
|
|
||||||
|
# Ver logs en tiempo real
|
||||||
|
journalctl -u horux-api -f | grep "\[SAT\]"
|
||||||
|
|
||||||
|
# Contar CFDIs sincronizados
|
||||||
|
PGPASSWORD=postgres psql -h localhost -U postgres -d horux360 -c \
|
||||||
|
"SELECT COUNT(*) as total FROM tenant_cas2408138w2.cfdis WHERE source = 'sat';"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problemas conocidos
|
||||||
|
|
||||||
|
1. **"Se han agotado las solicitudes de por vida"**: Límite de SAT alcanzado, esperar 24h
|
||||||
|
2. **"No se permite la descarga de xml que se encuentren cancelados"**: Normal para facturas canceladas
|
||||||
|
3. **"Información mayor a 6 años"**: SAT solo permite descargar últimos 6 años
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
|
||||||
|
- [ ] Probar sincronización completa después de 24h
|
||||||
|
- [ ] Verificar que los CFDIs se guarden correctamente
|
||||||
|
- [ ] Implementar reintentos automáticos para errores temporales
|
||||||
|
- [ ] Notificaciones por email al completar sincronización
|
||||||
|
- [ ] Dashboard con estadísticas de CFDIs por periodo
|
||||||
|
- [ ] Soporte para filtros adicionales (RFC emisor/receptor, tipo de comprobante)
|
||||||
323
docs/architecture/api-reference.md
Normal file
323
docs/architecture/api-reference.md
Normal 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;
|
||||||
|
}
|
||||||
|
```
|
||||||
250
docs/architecture/deployment.md
Normal file
250
docs/architecture/deployment.md
Normal 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';"
|
||||||
|
```
|
||||||
126
docs/plans/2026-02-17-cfdi-viewer-design.md
Normal file
126
docs/plans/2026-02-17-cfdi-viewer-design.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Diseño: Visor de CFDI
|
||||||
|
|
||||||
|
**Fecha:** 2026-02-17
|
||||||
|
**Estado:** Aprobado
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
|
||||||
|
Agregar funcionalidad para visualizar facturas CFDI en formato PDF-like, recreando la representación visual desde el XML almacenado. Incluye descarga de PDF y XML.
|
||||||
|
|
||||||
|
## Decisiones de Diseño
|
||||||
|
|
||||||
|
- **Tipo de vista:** PDF-like (representación visual similar a factura impresa)
|
||||||
|
- **Acceso:** Botón "Ver" (icono ojo) en cada fila de la tabla
|
||||||
|
- **Acciones:** Descargar PDF, Descargar XML
|
||||||
|
- **Enfoque técnico:** Componente React + html2pdf.js para generación de PDF en cliente
|
||||||
|
|
||||||
|
## Arquitectura de Componentes
|
||||||
|
|
||||||
|
```
|
||||||
|
CfdiPage (existente)
|
||||||
|
├── Tabla de CFDIs
|
||||||
|
│ └── Botón "Ver" (Eye icon) → abre modal
|
||||||
|
│
|
||||||
|
└── CfdiViewerModal (NUEVO)
|
||||||
|
├── Header: Título + Botones (PDF, XML, Cerrar)
|
||||||
|
└── CfdiInvoice (NUEVO)
|
||||||
|
├── Encabezado (Emisor + Receptor)
|
||||||
|
├── Datos del comprobante
|
||||||
|
├── Tabla de conceptos (parseados del XML)
|
||||||
|
├── Totales e impuestos
|
||||||
|
└── Timbre fiscal (UUID, fechas)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Componentes Nuevos
|
||||||
|
|
||||||
|
| Componente | Ubicación | Responsabilidad |
|
||||||
|
|------------|-----------|-----------------|
|
||||||
|
| `CfdiViewerModal` | `components/cfdi/cfdi-viewer-modal.tsx` | Modal con visor y botones de acción |
|
||||||
|
| `CfdiInvoice` | `components/cfdi/cfdi-invoice.tsx` | Renderiza la factura estilo PDF |
|
||||||
|
|
||||||
|
## Diseño Visual
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ ┌─────────────────┐ FACTURA │
|
||||||
|
│ │ [LOGO] │ Serie: A Folio: 001 │
|
||||||
|
│ │ placeholder │ Fecha: 15/Ene/2025 │
|
||||||
|
│ └─────────────────┘ │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ EMISOR │ RECEPTOR │
|
||||||
|
│ Empresa Emisora SA de CV │ Cliente SA de CV │
|
||||||
|
│ RFC: XAXX010101000 │ RFC: XAXX010101001 │
|
||||||
|
│ │ Uso CFDI: G03 │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ DATOS DEL COMPROBANTE │
|
||||||
|
│ Tipo: Ingreso Método: PUE Forma: 03 - Transferencia │
|
||||||
|
│ Moneda: MXN Tipo Cambio: 1.00 │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ CONCEPTOS │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Descripción │ Cant │ P. Unit │ Importe │ │
|
||||||
|
│ ├──────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ Servicio consultoría │ 1 │ 10,000 │ 10,000.00 │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ Subtotal: $10,000.00 │
|
||||||
|
│ IVA 16%: $1,600.00 │
|
||||||
|
│ TOTAL: $11,600.00 │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ TIMBRE FISCAL DIGITAL │
|
||||||
|
│ UUID: 12345678-1234-1234-1234-123456789012 │
|
||||||
|
│ Fecha Timbrado: 2025-01-15T12:30:45 │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flujo de Datos
|
||||||
|
|
||||||
|
1. Usuario hace clic en "Ver" (Eye icon)
|
||||||
|
2. Se abre CfdiViewerModal con el CFDI seleccionado
|
||||||
|
3. Si existe xmlOriginal:
|
||||||
|
- Parsear XML para extraer conceptos
|
||||||
|
- Mostrar factura completa
|
||||||
|
4. Si no existe XML:
|
||||||
|
- Mostrar factura con datos de BD (sin conceptos)
|
||||||
|
5. Acciones disponibles:
|
||||||
|
- Descargar PDF (html2pdf genera PDF)
|
||||||
|
- Descargar XML (si existe)
|
||||||
|
|
||||||
|
## Cambios en Backend
|
||||||
|
|
||||||
|
### Nuevo Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/cfdi/:id/xml
|
||||||
|
```
|
||||||
|
|
||||||
|
Retorna el XML original del CFDI.
|
||||||
|
|
||||||
|
### Modificar Endpoint Existente
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/cfdi/:id
|
||||||
|
```
|
||||||
|
|
||||||
|
Agregar campo `xmlOriginal` a la respuesta.
|
||||||
|
|
||||||
|
## Dependencias
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"html2pdf.js": "^0.10.1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Archivos a Crear/Modificar
|
||||||
|
|
||||||
|
### Nuevos
|
||||||
|
- `apps/web/components/cfdi/cfdi-viewer-modal.tsx`
|
||||||
|
- `apps/web/components/cfdi/cfdi-invoice.tsx`
|
||||||
|
- `apps/api/src/controllers/cfdi.controller.ts` (nuevo método getXml)
|
||||||
|
|
||||||
|
### Modificar
|
||||||
|
- `apps/web/app/(dashboard)/cfdi/page.tsx` (agregar botón Ver y modal)
|
||||||
|
- `apps/api/src/routes/cfdi.routes.ts` (agregar ruta /xml)
|
||||||
|
- `apps/api/src/services/cfdi.service.ts` (agregar método getXmlById)
|
||||||
|
- `packages/shared/src/types/cfdi.ts` (agregar xmlOriginal a Cfdi)
|
||||||
816
docs/plans/2026-02-17-cfdi-viewer-implementation.md
Normal file
816
docs/plans/2026-02-17-cfdi-viewer-implementation.md
Normal file
@@ -0,0 +1,816 @@
|
|||||||
|
# CFDI Viewer Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add PDF-like invoice visualization for CFDIs with PDF and XML download capabilities.
|
||||||
|
|
||||||
|
**Architecture:** React modal component with invoice renderer. Backend returns XML via new endpoint. html2pdf.js generates PDF client-side from rendered HTML.
|
||||||
|
|
||||||
|
**Tech Stack:** React, TypeScript, html2pdf.js, Tailwind CSS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Install html2pdf.js Dependency
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/package.json`
|
||||||
|
|
||||||
|
**Step 1: Install the dependency**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /root/Horux/apps/web && pnpm add html2pdf.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify installation**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep html2pdf apps/web/package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `"html2pdf.js": "^0.10.x"`
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/package.json apps/web/pnpm-lock.yaml
|
||||||
|
git commit -m "chore: add html2pdf.js for PDF generation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add xmlOriginal to Cfdi Type
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `packages/shared/src/types/cfdi.ts:4-31`
|
||||||
|
|
||||||
|
**Step 1: Add xmlOriginal field to Cfdi interface**
|
||||||
|
|
||||||
|
In `packages/shared/src/types/cfdi.ts`, add after line 29 (`pdfUrl: string | null;`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
xmlOriginal: string | null;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify types compile**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /root/Horux && pnpm build --filter=@horux/shared
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/shared/src/types/cfdi.ts
|
||||||
|
git commit -m "feat(types): add xmlOriginal field to Cfdi interface"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Update Backend Service to Return XML
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/api/src/services/cfdi.service.ts:77-95`
|
||||||
|
|
||||||
|
**Step 1: Update getCfdiById to include xml_original**
|
||||||
|
|
||||||
|
Replace the `getCfdiById` function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
|
||||||
|
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
||||||
|
SELECT
|
||||||
|
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||||
|
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
||||||
|
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
|
||||||
|
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
|
||||||
|
subtotal, descuento, iva, isr_retenido as "isrRetenido",
|
||||||
|
iva_retenido as "ivaRetenido", total, moneda,
|
||||||
|
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
||||||
|
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||||
|
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||||
|
xml_original as "xmlOriginal",
|
||||||
|
created_at as "createdAt"
|
||||||
|
FROM "${schema}".cfdis
|
||||||
|
WHERE id = $1
|
||||||
|
`, id);
|
||||||
|
|
||||||
|
return result[0] || null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add getXmlById function**
|
||||||
|
|
||||||
|
Add after `getCfdiById`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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
|
||||||
|
`, id);
|
||||||
|
|
||||||
|
return result[0]?.xml_original || null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify API compiles**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /root/Horux/apps/api && pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/api/src/services/cfdi.service.ts
|
||||||
|
git commit -m "feat(api): add xmlOriginal to getCfdiById and add getXmlById"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Add XML Download Endpoint
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/api/src/controllers/cfdi.controller.ts`
|
||||||
|
- Modify: `apps/api/src/routes/cfdi.routes.ts`
|
||||||
|
|
||||||
|
**Step 1: Add getXml controller function**
|
||||||
|
|
||||||
|
Add to `apps/api/src/controllers/cfdi.controller.ts` after `getCfdiById`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getXml(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
if (!req.tenantSchema) {
|
||||||
|
return next(new AppError(400, 'Schema no configurado'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = await cfdiService.getXmlById(req.tenantSchema, String(req.params.id));
|
||||||
|
|
||||||
|
if (!xml) {
|
||||||
|
return next(new AppError(404, 'XML no encontrado para este CFDI'));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set('Content-Type', 'application/xml');
|
||||||
|
res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`);
|
||||||
|
res.send(xml);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add route for XML download**
|
||||||
|
|
||||||
|
In `apps/api/src/routes/cfdi.routes.ts`, add after line 13 (`router.get('/:id', ...)`):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
router.get('/:id/xml', cfdiController.getXml);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify API compiles**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /root/Horux/apps/api && pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/api/src/controllers/cfdi.controller.ts apps/api/src/routes/cfdi.routes.ts
|
||||||
|
git commit -m "feat(api): add GET /cfdi/:id/xml endpoint"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Add API Client Function for XML Download
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/lib/api/cfdi.ts`
|
||||||
|
|
||||||
|
**Step 1: Add getCfdiXml function**
|
||||||
|
|
||||||
|
Add at the end of `apps/web/lib/api/cfdi.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function getCfdiXml(id: string): Promise<string> {
|
||||||
|
const response = await apiClient.get<string>(`/cfdi/${id}/xml`, {
|
||||||
|
responseType: 'text'
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/lib/api/cfdi.ts
|
||||||
|
git commit -m "feat(web): add getCfdiXml API function"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Create CfdiInvoice Component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/web/components/cfdi/cfdi-invoice.tsx`
|
||||||
|
|
||||||
|
**Step 1: Create the component**
|
||||||
|
|
||||||
|
Create `apps/web/components/cfdi/cfdi-invoice.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
import type { Cfdi } from '@horux/shared';
|
||||||
|
|
||||||
|
interface CfdiConcepto {
|
||||||
|
descripcion: string;
|
||||||
|
cantidad: number;
|
||||||
|
valorUnitario: number;
|
||||||
|
importe: number;
|
||||||
|
claveUnidad?: string;
|
||||||
|
claveProdServ?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CfdiInvoiceProps {
|
||||||
|
cfdi: Cfdi;
|
||||||
|
conceptos?: CfdiConcepto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) =>
|
||||||
|
new Intl.NumberFormat('es-MX', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'MXN',
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) =>
|
||||||
|
new Date(dateString).toLocaleDateString('es-MX', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tipoLabels: Record<string, string> = {
|
||||||
|
ingreso: 'Ingreso',
|
||||||
|
egreso: 'Egreso',
|
||||||
|
traslado: 'Traslado',
|
||||||
|
pago: 'Pago',
|
||||||
|
nomina: 'Nomina',
|
||||||
|
};
|
||||||
|
|
||||||
|
const formaPagoLabels: Record<string, string> = {
|
||||||
|
'01': 'Efectivo',
|
||||||
|
'02': 'Cheque nominativo',
|
||||||
|
'03': 'Transferencia electrónica',
|
||||||
|
'04': 'Tarjeta de crédito',
|
||||||
|
'28': 'Tarjeta de débito',
|
||||||
|
'99': 'Por definir',
|
||||||
|
};
|
||||||
|
|
||||||
|
const metodoPagoLabels: Record<string, string> = {
|
||||||
|
PUE: 'Pago en una sola exhibición',
|
||||||
|
PPD: 'Pago en parcialidades o diferido',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
|
||||||
|
({ cfdi, conceptos }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="bg-white text-black p-8 max-w-[800px] mx-auto text-sm"
|
||||||
|
style={{ fontFamily: 'Arial, sans-serif' }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex justify-between items-start border-b-2 border-gray-800 pb-4 mb-4">
|
||||||
|
<div className="w-32 h-20 bg-gray-200 flex items-center justify-center text-gray-500 text-xs">
|
||||||
|
[LOGO]
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">FACTURA</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{cfdi.serie && `Serie: ${cfdi.serie} `}
|
||||||
|
{cfdi.folio && `Folio: ${cfdi.folio}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600">Fecha: {formatDate(cfdi.fechaEmision)}</p>
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-1 text-xs font-semibold rounded mt-1 ${
|
||||||
|
cfdi.estado === 'vigente'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cfdi.estado === 'vigente' ? 'VIGENTE' : 'CANCELADO'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emisor / Receptor */}
|
||||||
|
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||||
|
<div className="border border-gray-300 p-4 rounded">
|
||||||
|
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
|
||||||
|
EMISOR
|
||||||
|
</h3>
|
||||||
|
<p className="font-semibold">{cfdi.nombreEmisor}</p>
|
||||||
|
<p className="text-gray-600">RFC: {cfdi.rfcEmisor}</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-gray-300 p-4 rounded">
|
||||||
|
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
|
||||||
|
RECEPTOR
|
||||||
|
</h3>
|
||||||
|
<p className="font-semibold">{cfdi.nombreReceptor}</p>
|
||||||
|
<p className="text-gray-600">RFC: {cfdi.rfcReceptor}</p>
|
||||||
|
{cfdi.usoCfdi && (
|
||||||
|
<p className="text-gray-600">Uso CFDI: {cfdi.usoCfdi}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datos del Comprobante */}
|
||||||
|
<div className="border border-gray-300 p-4 rounded mb-6">
|
||||||
|
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
|
||||||
|
DATOS DEL COMPROBANTE
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Tipo:</span>
|
||||||
|
<p className="font-medium">{tipoLabels[cfdi.tipo] || cfdi.tipo}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Método de Pago:</span>
|
||||||
|
<p className="font-medium">
|
||||||
|
{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || cfdi.metodoPago : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Forma de Pago:</span>
|
||||||
|
<p className="font-medium">
|
||||||
|
{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || cfdi.formaPago : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Moneda:</span>
|
||||||
|
<p className="font-medium">
|
||||||
|
{cfdi.moneda}
|
||||||
|
{cfdi.tipoCambio !== 1 && ` (TC: ${cfdi.tipoCambio})`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conceptos */}
|
||||||
|
{conceptos && conceptos.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
|
||||||
|
CONCEPTOS
|
||||||
|
</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100">
|
||||||
|
<th className="text-left p-2 border">Descripción</th>
|
||||||
|
<th className="text-center p-2 border w-20">Cant.</th>
|
||||||
|
<th className="text-right p-2 border w-28">P. Unit.</th>
|
||||||
|
<th className="text-right p-2 border w-28">Importe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{conceptos.map((concepto, idx) => (
|
||||||
|
<tr key={idx} className="border-b">
|
||||||
|
<td className="p-2 border">{concepto.descripcion}</td>
|
||||||
|
<td className="text-center p-2 border">{concepto.cantidad}</td>
|
||||||
|
<td className="text-right p-2 border">
|
||||||
|
{formatCurrency(concepto.valorUnitario)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right p-2 border">
|
||||||
|
{formatCurrency(concepto.importe)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Totales */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
|
<div className="w-64">
|
||||||
|
<div className="flex justify-between py-1 border-b">
|
||||||
|
<span className="text-gray-600">Subtotal:</span>
|
||||||
|
<span>{formatCurrency(cfdi.subtotal)}</span>
|
||||||
|
</div>
|
||||||
|
{cfdi.descuento > 0 && (
|
||||||
|
<div className="flex justify-between py-1 border-b">
|
||||||
|
<span className="text-gray-600">Descuento:</span>
|
||||||
|
<span className="text-red-600">-{formatCurrency(cfdi.descuento)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cfdi.iva > 0 && (
|
||||||
|
<div className="flex justify-between py-1 border-b">
|
||||||
|
<span className="text-gray-600">IVA (16%):</span>
|
||||||
|
<span>{formatCurrency(cfdi.iva)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cfdi.ivaRetenido > 0 && (
|
||||||
|
<div className="flex justify-between py-1 border-b">
|
||||||
|
<span className="text-gray-600">IVA Retenido:</span>
|
||||||
|
<span className="text-red-600">-{formatCurrency(cfdi.ivaRetenido)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cfdi.isrRetenido > 0 && (
|
||||||
|
<div className="flex justify-between py-1 border-b">
|
||||||
|
<span className="text-gray-600">ISR Retenido:</span>
|
||||||
|
<span className="text-red-600">-{formatCurrency(cfdi.isrRetenido)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between py-2 font-bold text-lg border-t-2 border-gray-800 mt-1">
|
||||||
|
<span>TOTAL:</span>
|
||||||
|
<span>{formatCurrency(cfdi.total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timbre Fiscal */}
|
||||||
|
<div className="border-t-2 border-gray-800 pt-4">
|
||||||
|
<h3 className="font-bold text-gray-700 mb-2">TIMBRE FISCAL DIGITAL</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">UUID:</p>
|
||||||
|
<p className="font-mono break-all">{cfdi.uuidFiscal}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-gray-500">Fecha de Timbrado:</p>
|
||||||
|
<p>{cfdi.fechaTimbrado}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
CfdiInvoice.displayName = 'CfdiInvoice';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/components/cfdi/cfdi-invoice.tsx
|
||||||
|
git commit -m "feat(web): add CfdiInvoice component for PDF-like rendering"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Create CfdiViewerModal Component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `apps/web/components/cfdi/cfdi-viewer-modal.tsx`
|
||||||
|
|
||||||
|
**Step 1: Create the modal component**
|
||||||
|
|
||||||
|
Create `apps/web/components/cfdi/cfdi-viewer-modal.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
import type { Cfdi } from '@horux/shared';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CfdiInvoice } from './cfdi-invoice';
|
||||||
|
import { getCfdiXml } from '@/lib/api/cfdi';
|
||||||
|
import { Download, FileText, X, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface CfdiConcepto {
|
||||||
|
descripcion: string;
|
||||||
|
cantidad: number;
|
||||||
|
valorUnitario: number;
|
||||||
|
importe: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CfdiViewerModalProps {
|
||||||
|
cfdi: Cfdi | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConceptosFromXml(xmlString: string): CfdiConcepto[] {
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(xmlString, 'text/xml');
|
||||||
|
const conceptos: CfdiConcepto[] = [];
|
||||||
|
|
||||||
|
// Find all Concepto elements
|
||||||
|
const elements = doc.getElementsByTagName('*');
|
||||||
|
for (let i = 0; i < elements.length; i++) {
|
||||||
|
if (elements[i].localName === 'Concepto') {
|
||||||
|
const el = elements[i];
|
||||||
|
conceptos.push({
|
||||||
|
descripcion: el.getAttribute('Descripcion') || '',
|
||||||
|
cantidad: parseFloat(el.getAttribute('Cantidad') || '1'),
|
||||||
|
valorUnitario: parseFloat(el.getAttribute('ValorUnitario') || '0'),
|
||||||
|
importe: parseFloat(el.getAttribute('Importe') || '0'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conceptos;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
|
||||||
|
const invoiceRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [conceptos, setConceptos] = useState<CfdiConcepto[]>([]);
|
||||||
|
const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null);
|
||||||
|
const [xmlContent, setXmlContent] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cfdi?.xmlOriginal) {
|
||||||
|
setXmlContent(cfdi.xmlOriginal);
|
||||||
|
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
|
||||||
|
} else {
|
||||||
|
setXmlContent(null);
|
||||||
|
setConceptos([]);
|
||||||
|
}
|
||||||
|
}, [cfdi]);
|
||||||
|
|
||||||
|
const handleDownloadPdf = async () => {
|
||||||
|
if (!invoiceRef.current || !cfdi) return;
|
||||||
|
|
||||||
|
setDownloading('pdf');
|
||||||
|
try {
|
||||||
|
const html2pdf = (await import('html2pdf.js')).default;
|
||||||
|
|
||||||
|
const opt = {
|
||||||
|
margin: 10,
|
||||||
|
filename: `factura-${cfdi.uuidFiscal.substring(0, 8)}.pdf`,
|
||||||
|
image: { type: 'jpeg', quality: 0.98 },
|
||||||
|
html2canvas: { scale: 2, useCORS: true },
|
||||||
|
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await html2pdf().set(opt).from(invoiceRef.current).save();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating PDF:', error);
|
||||||
|
alert('Error al generar el PDF');
|
||||||
|
} finally {
|
||||||
|
setDownloading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadXml = async () => {
|
||||||
|
if (!cfdi) return;
|
||||||
|
|
||||||
|
setDownloading('xml');
|
||||||
|
try {
|
||||||
|
let xml = xmlContent;
|
||||||
|
|
||||||
|
if (!xml) {
|
||||||
|
xml = await getCfdiXml(cfdi.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!xml) {
|
||||||
|
alert('No hay XML disponible para este CFDI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([xml], { type: 'application/xml' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `cfdi-${cfdi.uuidFiscal.substring(0, 8)}.xml`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error downloading XML:', error);
|
||||||
|
alert('Error al descargar el XML');
|
||||||
|
} finally {
|
||||||
|
setDownloading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cfdi) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<DialogTitle>Vista de Factura</DialogTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadPdf}
|
||||||
|
disabled={downloading !== null}
|
||||||
|
>
|
||||||
|
{downloading === 'pdf' ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
PDF
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownloadXml}
|
||||||
|
disabled={downloading !== null || !xmlContent}
|
||||||
|
title={!xmlContent ? 'XML no disponible' : 'Descargar XML'}
|
||||||
|
>
|
||||||
|
{downloading === 'xml' ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileText className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
XML
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="border rounded-lg overflow-hidden bg-gray-50 p-4">
|
||||||
|
<CfdiInvoice ref={invoiceRef} cfdi={cfdi} conceptos={conceptos} />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/components/cfdi/cfdi-viewer-modal.tsx
|
||||||
|
git commit -m "feat(web): add CfdiViewerModal with PDF and XML download"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Integrate Viewer into CFDI Page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/app/(dashboard)/cfdi/page.tsx`
|
||||||
|
|
||||||
|
**Step 1: Add imports at top of file**
|
||||||
|
|
||||||
|
Add after the existing imports (around line 14):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||||
|
import { getCfdiById } from '@/lib/api/cfdi';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add state for viewer modal**
|
||||||
|
|
||||||
|
Inside `CfdiPage` component, after line 255 (`const deleteCfdi = useDeleteCfdi();`), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
|
||||||
|
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleViewCfdi = async (id: string) => {
|
||||||
|
setLoadingCfdi(id);
|
||||||
|
try {
|
||||||
|
const cfdi = await getCfdiById(id);
|
||||||
|
setViewingCfdi(cfdi);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading CFDI:', error);
|
||||||
|
alert('Error al cargar el CFDI');
|
||||||
|
} finally {
|
||||||
|
setLoadingCfdi(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Add import for Cfdi type**
|
||||||
|
|
||||||
|
Update the import from `@horux/shared` at line 12 to include `Cfdi`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Add View button in table**
|
||||||
|
|
||||||
|
In the table header (around line 1070), add a new column header before the delete column:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<th className="pb-3 font-medium"></th>
|
||||||
|
```
|
||||||
|
|
||||||
|
In the table body (inside the map, around line 1125), add before the delete button:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<td className="py-3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleViewCfdi(cfdi.id)}
|
||||||
|
disabled={loadingCfdi === cfdi.id}
|
||||||
|
title="Ver factura"
|
||||||
|
>
|
||||||
|
{loadingCfdi === cfdi.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Add modal component**
|
||||||
|
|
||||||
|
At the end of the return statement, just before the closing `</>`, add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<CfdiViewerModal
|
||||||
|
cfdi={viewingCfdi}
|
||||||
|
open={viewingCfdi !== null}
|
||||||
|
onClose={() => setViewingCfdi(null)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Verify build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /root/Horux/apps/web && pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds
|
||||||
|
|
||||||
|
**Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/app/\(dashboard\)/cfdi/page.tsx
|
||||||
|
git commit -m "feat(web): integrate CFDI viewer modal into CFDI page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Build and Test
|
||||||
|
|
||||||
|
**Step 1: Build all packages**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /root/Horux && pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All packages build successfully
|
||||||
|
|
||||||
|
**Step 2: Restart services**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
systemctl restart horux-api horux-web
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Manual verification**
|
||||||
|
|
||||||
|
1. Open browser to CFDI page
|
||||||
|
2. Click Eye icon on any CFDI row
|
||||||
|
3. Verify modal opens with invoice preview
|
||||||
|
4. Click PDF button - verify PDF downloads
|
||||||
|
5. Click XML button (if XML exists) - verify XML downloads
|
||||||
|
|
||||||
|
**Step 4: Final commit with all changes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A
|
||||||
|
git status
|
||||||
|
# If any uncommitted changes:
|
||||||
|
git commit -m "feat: complete CFDI viewer implementation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Changes
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `apps/web/package.json` | Added html2pdf.js dependency |
|
||||||
|
| `packages/shared/src/types/cfdi.ts` | Added xmlOriginal field |
|
||||||
|
| `apps/api/src/services/cfdi.service.ts` | Updated getCfdiById, added getXmlById |
|
||||||
|
| `apps/api/src/controllers/cfdi.controller.ts` | Added getXml controller |
|
||||||
|
| `apps/api/src/routes/cfdi.routes.ts` | Added GET /:id/xml route |
|
||||||
|
| `apps/web/lib/api/cfdi.ts` | Added getCfdiXml function |
|
||||||
|
| `apps/web/components/cfdi/cfdi-invoice.tsx` | NEW - Invoice renderer |
|
||||||
|
| `apps/web/components/cfdi/cfdi-viewer-modal.tsx` | NEW - Modal wrapper |
|
||||||
|
| `apps/web/app/(dashboard)/cfdi/page.tsx` | Integrated viewer |
|
||||||
143
docs/security/2026-03-18-security-audit-remediation.md
Normal file
143
docs/security/2026-03-18-security-audit-remediation.md
Normal 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
|
||||||
2252
docs/superpowers/plans/2026-03-15-saas-transformation.md
Normal file
2252
docs/superpowers/plans/2026-03-15-saas-transformation.md
Normal file
File diff suppressed because it is too large
Load Diff
797
docs/superpowers/specs/2026-03-15-saas-transformation-design.md
Normal file
797
docs/superpowers/specs/2026-03-15-saas-transformation-design.md
Normal 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
|
||||||
36
ecosystem.config.js
Normal file
36
ecosystem.config.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"db:seed": "turbo run db:seed"
|
"db:seed": "turbo run db:seed"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"pg": "^8.18.0",
|
||||||
"turbo": "^2.3.0",
|
"turbo": "^2.3.0",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface UserInfo {
|
|||||||
tenantId: string;
|
tenantId: string;
|
||||||
tenantName: string;
|
tenantName: string;
|
||||||
tenantRfc: string;
|
tenantRfc: string;
|
||||||
|
plan: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JWTPayload {
|
export interface JWTPayload {
|
||||||
@@ -36,7 +37,6 @@ export interface JWTPayload {
|
|||||||
email: string;
|
email: string;
|
||||||
role: Role;
|
role: Role;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
schemaName: string;
|
|
||||||
iat?: number;
|
iat?: number;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface Cfdi {
|
|||||||
estado: EstadoCfdi;
|
estado: EstadoCfdi;
|
||||||
xmlUrl: string | null;
|
xmlUrl: string | null;
|
||||||
pdfUrl: string | null;
|
pdfUrl: string | null;
|
||||||
|
xmlOriginal: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ export interface CfdiFilters {
|
|||||||
fechaInicio?: string;
|
fechaInicio?: string;
|
||||||
fechaFin?: string;
|
fechaFin?: string;
|
||||||
rfc?: string;
|
rfc?: string;
|
||||||
|
emisor?: string;
|
||||||
|
receptor?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export interface Tenant {
|
|||||||
nombre: string;
|
nombre: string;
|
||||||
rfc: string;
|
rfc: string;
|
||||||
plan: Plan;
|
plan: Plan;
|
||||||
schemaName: string;
|
databaseName: string;
|
||||||
cfdiLimit: number;
|
cfdiLimit: number;
|
||||||
usersLimit: number;
|
usersLimit: number;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -20,3 +20,29 @@ export interface TenantUsage {
|
|||||||
usersLimit: number;
|
usersLimit: number;
|
||||||
plan: Plan;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
509
pnpm-lock.yaml
generated
509
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
pg:
|
||||||
|
specifier: ^8.18.0
|
||||||
|
version: 8.18.0
|
||||||
turbo:
|
turbo:
|
||||||
specifier: ^2.3.0
|
specifier: ^2.3.0
|
||||||
version: 2.7.5
|
version: 2.7.5
|
||||||
@@ -59,12 +62,21 @@ importers:
|
|||||||
jsonwebtoken:
|
jsonwebtoken:
|
||||||
specifier: ^9.0.2
|
specifier: ^9.0.2
|
||||||
version: 9.0.3
|
version: 9.0.3
|
||||||
|
mercadopago:
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0
|
||||||
node-cron:
|
node-cron:
|
||||||
specifier: ^4.2.1
|
specifier: ^4.2.1
|
||||||
version: 4.2.1
|
version: 4.2.1
|
||||||
node-forge:
|
node-forge:
|
||||||
specifier: ^1.3.3
|
specifier: ^1.3.3
|
||||||
version: 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:
|
zod:
|
||||||
specifier: ^3.23.0
|
specifier: ^3.23.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -93,6 +105,15 @@ importers:
|
|||||||
'@types/node-forge':
|
'@types/node-forge':
|
||||||
specifier: ^1.3.14
|
specifier: ^1.3.14
|
||||||
version: 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:
|
prisma:
|
||||||
specifier: ^5.22.0
|
specifier: ^5.22.0
|
||||||
version: 5.22.0
|
version: 5.22.0
|
||||||
@@ -112,7 +133,7 @@ importers:
|
|||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@radix-ui/react-dialog':
|
'@radix-ui/react-dialog':
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.15
|
||||||
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@radix-ui/react-dropdown-menu':
|
'@radix-ui/react-dropdown-menu':
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
@@ -120,6 +141,9 @@ importers:
|
|||||||
'@radix-ui/react-label':
|
'@radix-ui/react-label':
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 2.1.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-popover':
|
||||||
|
specifier: ^1.1.15
|
||||||
|
version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
'@radix-ui/react-select':
|
'@radix-ui/react-select':
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -156,6 +180,12 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
|
file-saver:
|
||||||
|
specifier: ^2.0.5
|
||||||
|
version: 2.0.5
|
||||||
|
html2pdf.js:
|
||||||
|
specifier: ^0.14.0
|
||||||
|
version: 0.14.0
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.460.0
|
specifier: ^0.460.0
|
||||||
version: 0.460.0(react@18.3.1)
|
version: 0.460.0(react@18.3.1)
|
||||||
@@ -177,6 +207,9 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^2.5.0
|
specifier: ^2.5.0
|
||||||
version: 2.6.0
|
version: 2.6.0
|
||||||
|
xlsx:
|
||||||
|
specifier: ^0.18.5
|
||||||
|
version: 0.18.5
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.23.0
|
specifier: ^3.23.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -184,6 +217,9 @@ importers:
|
|||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.10(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
version: 5.0.10(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/file-saver':
|
||||||
|
specifier: ^2.0.7
|
||||||
|
version: 2.0.7
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.19.7
|
version: 22.19.7
|
||||||
@@ -717,6 +753,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-popover@1.1.15':
|
||||||
|
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-popper@1.2.8':
|
'@radix-ui/react-popper@1.2.8':
|
||||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1048,6 +1097,9 @@ packages:
|
|||||||
'@types/express@5.0.6':
|
'@types/express@5.0.6':
|
||||||
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
|
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
|
||||||
|
|
||||||
|
'@types/file-saver@2.0.7':
|
||||||
|
resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
|
||||||
|
|
||||||
'@types/http-errors@2.0.5':
|
'@types/http-errors@2.0.5':
|
||||||
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
|
||||||
|
|
||||||
@@ -1069,12 +1121,24 @@ packages:
|
|||||||
'@types/node@22.19.7':
|
'@types/node@22.19.7':
|
||||||
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
|
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':
|
'@types/prop-types@15.7.15':
|
||||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||||
|
|
||||||
'@types/qs@6.14.0':
|
'@types/qs@6.14.0':
|
||||||
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
|
||||||
|
|
||||||
|
'@types/raf@3.4.3':
|
||||||
|
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
|
||||||
|
|
||||||
'@types/range-parser@1.2.7':
|
'@types/range-parser@1.2.7':
|
||||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||||
|
|
||||||
@@ -1092,6 +1156,9 @@ packages:
|
|||||||
'@types/serve-static@2.2.0':
|
'@types/serve-static@2.2.0':
|
||||||
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
'@vilic/node-forge@1.3.2-5':
|
'@vilic/node-forge@1.3.2-5':
|
||||||
resolution: {integrity: sha512-8GVr3S/nmLKL7QI7RYhVIcz3PuT/fxfkQLuh/F1CaT+/3QgI14RqiJkcKIni7h9u4ySbQGiGvm4XbNxRBJin4g==}
|
resolution: {integrity: sha512-8GVr3S/nmLKL7QI7RYhVIcz3PuT/fxfkQLuh/F1CaT+/3QgI14RqiJkcKIni7h9u4ySbQGiGvm4XbNxRBJin4g==}
|
||||||
engines: {node: '>= 6.13.0'}
|
engines: {node: '>= 6.13.0'}
|
||||||
@@ -1104,6 +1171,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
adler-32@1.3.1:
|
||||||
|
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
adm-zip@0.5.16:
|
adm-zip@0.5.16:
|
||||||
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
|
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
|
||||||
engines: {node: '>=12.0'}
|
engines: {node: '>=12.0'}
|
||||||
@@ -1156,6 +1227,10 @@ packages:
|
|||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2:
|
||||||
|
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
base64-js@1.5.1:
|
base64-js@1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
@@ -1242,6 +1317,14 @@ packages:
|
|||||||
caniuse-lite@1.0.30001765:
|
caniuse-lite@1.0.30001765:
|
||||||
resolution: {integrity: sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==}
|
resolution: {integrity: sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ==}
|
||||||
|
|
||||||
|
canvg@3.0.11:
|
||||||
|
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
cfb@1.2.2:
|
||||||
|
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
chainsaw@0.1.0:
|
chainsaw@0.1.0:
|
||||||
resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==}
|
resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==}
|
||||||
|
|
||||||
@@ -1259,6 +1342,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
codepage@1.15.0:
|
||||||
|
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
combined-stream@1.0.8:
|
combined-stream@1.0.8:
|
||||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -1289,6 +1376,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
core-js@3.48.0:
|
||||||
|
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
|
||||||
|
|
||||||
core-util-is@1.0.3:
|
core-util-is@1.0.3:
|
||||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
@@ -1305,6 +1395,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
|
resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
|
css-line-break@2.1.0:
|
||||||
|
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
|
||||||
|
|
||||||
cssesc@3.0.0:
|
cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -1398,6 +1491,9 @@ packages:
|
|||||||
dom-helpers@5.2.1:
|
dom-helpers@5.2.1:
|
||||||
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
|
||||||
|
|
||||||
|
dompurify@3.3.1:
|
||||||
|
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
|
||||||
|
|
||||||
dotenv@17.2.3:
|
dotenv@17.2.3:
|
||||||
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1464,6 +1560,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
|
resolution: {integrity: sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==}
|
||||||
engines: {node: '>=8.3.0'}
|
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:
|
express@4.22.1:
|
||||||
resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==}
|
resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==}
|
||||||
engines: {node: '>= 0.10.0'}
|
engines: {node: '>= 0.10.0'}
|
||||||
@@ -1480,6 +1582,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||||
engines: {node: '>=8.6.0'}
|
engines: {node: '>=8.6.0'}
|
||||||
|
|
||||||
|
fast-png@6.4.0:
|
||||||
|
resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==}
|
||||||
|
|
||||||
fast-xml-parser@5.3.3:
|
fast-xml-parser@5.3.3:
|
||||||
resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==}
|
resolution: {integrity: sha512-2O3dkPAAC6JavuMm8+4+pgTk+5hoAs+CjZ+sWcQLkX9+/tHRuTkQh/Oaifr8qDmZ8iEHb771Ea6G8CdwkrgvYA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1496,6 +1601,12 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fflate@0.8.2:
|
||||||
|
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||||
|
|
||||||
|
file-saver@2.0.5:
|
||||||
|
resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1521,6 +1632,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
frac@1.1.2:
|
||||||
|
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
@@ -1597,6 +1712,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
|
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
|
|
||||||
|
html2canvas@1.4.1:
|
||||||
|
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
|
||||||
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
html2pdf.js@0.14.0:
|
||||||
|
resolution: {integrity: sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ==}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -1622,6 +1744,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
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:
|
ipaddr.js@1.9.1:
|
||||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
@@ -1660,6 +1789,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||||
engines: {node: '>=12', npm: '>=6'}
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
|
||||||
|
jspdf@4.1.0:
|
||||||
|
resolution: {integrity: sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==}
|
||||||
|
|
||||||
jszip@3.10.1:
|
jszip@3.10.1:
|
||||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
@@ -1765,6 +1897,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
mercadopago@2.12.0:
|
||||||
|
resolution: {integrity: sha512-9S+ZB/Fltd4BV9/U79r7U/+LrYJP844kxxvtAlVbbeVmhOE9rZt0YhPy1GXO3Yf4XyQaHwZ/SCyL2kebAicaLw==}
|
||||||
|
|
||||||
merge-descriptors@1.0.3:
|
merge-descriptors@1.0.3:
|
||||||
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
|
resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
|
||||||
|
|
||||||
@@ -1847,6 +1982,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
|
||||||
engines: {node: '>=6.0.0'}
|
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:
|
node-forge@1.3.3:
|
||||||
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
|
resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==}
|
||||||
engines: {node: '>= 6.13.0'}
|
engines: {node: '>= 6.13.0'}
|
||||||
@@ -1854,6 +1998,10 @@ packages:
|
|||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
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:
|
normalize-path@3.0.0:
|
||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1880,6 +2028,9 @@ packages:
|
|||||||
pako@1.0.11:
|
pako@1.0.11:
|
||||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||||
|
|
||||||
|
pako@2.1.0:
|
||||||
|
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||||
|
|
||||||
parseurl@1.3.3:
|
parseurl@1.3.3:
|
||||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -1894,6 +2045,43 @@ packages:
|
|||||||
path-to-regexp@0.1.12:
|
path-to-regexp@0.1.12:
|
||||||
resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==}
|
resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==}
|
||||||
|
|
||||||
|
performance-now@2.1.0:
|
||||||
|
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
|
||||||
|
|
||||||
|
pg-cloudflare@1.3.0:
|
||||||
|
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
|
||||||
|
|
||||||
|
pg-connection-string@2.11.0:
|
||||||
|
resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==}
|
||||||
|
|
||||||
|
pg-int8@1.0.1:
|
||||||
|
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
|
||||||
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
pg-pool@3.11.0:
|
||||||
|
resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==}
|
||||||
|
peerDependencies:
|
||||||
|
pg: '>=8.0'
|
||||||
|
|
||||||
|
pg-protocol@1.11.0:
|
||||||
|
resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
pg@8.18.0:
|
||||||
|
resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==}
|
||||||
|
engines: {node: '>= 16.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
pg-native: '>=3.0.1'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
pg-native:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -1964,6 +2152,22 @@ packages:
|
|||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
postgres-array@2.0.0:
|
||||||
|
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.1:
|
||||||
|
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-date@1.0.7:
|
||||||
|
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
prisma@5.22.0:
|
prisma@5.22.0:
|
||||||
resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
|
resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==}
|
||||||
engines: {node: '>=16.13'}
|
engines: {node: '>=16.13'}
|
||||||
@@ -1989,6 +2193,9 @@ packages:
|
|||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
raf@3.4.1:
|
||||||
|
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
|
||||||
|
|
||||||
range-parser@1.2.1:
|
range-parser@1.2.1:
|
||||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -2087,6 +2294,9 @@ packages:
|
|||||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
regenerator-runtime@0.13.11:
|
||||||
|
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0:
|
resolve-pkg-maps@1.0.0:
|
||||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
@@ -2099,6 +2309,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
|
rgbcolor@1.0.1:
|
||||||
|
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
|
||||||
|
engines: {node: '>= 0.8.15'}
|
||||||
|
|
||||||
rimraf@2.7.1:
|
rimraf@2.7.1:
|
||||||
resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
|
resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
|
||||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||||
@@ -2162,6 +2376,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
split2@4.2.0:
|
||||||
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
|
ssf@0.11.2:
|
||||||
|
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
stackblur-canvas@2.7.0:
|
||||||
|
resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
|
||||||
|
engines: {node: '>=0.1.14'}
|
||||||
|
|
||||||
statuses@2.0.2:
|
statuses@2.0.2:
|
||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2201,6 +2427,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
svg-pathdata@6.0.3:
|
||||||
|
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
|
||||||
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
tailwind-merge@2.6.0:
|
tailwind-merge@2.6.0:
|
||||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||||
|
|
||||||
@@ -2213,6 +2443,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
text-segmentation@1.0.3:
|
||||||
|
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
|
||||||
|
|
||||||
thenify-all@1.6.0:
|
thenify-all@1.6.0:
|
||||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
@@ -2239,6 +2472,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
|
tr46@0.0.3:
|
||||||
|
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||||
|
|
||||||
traverse@0.3.9:
|
traverse@0.3.9:
|
||||||
resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
|
resolution: {integrity: sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==}
|
||||||
|
|
||||||
@@ -2347,10 +2583,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||||
engines: {node: '>= 0.4.0'}
|
engines: {node: '>= 0.4.0'}
|
||||||
|
|
||||||
|
utrie@1.0.2:
|
||||||
|
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
|
||||||
|
|
||||||
uuid@8.3.2:
|
uuid@8.3.2:
|
||||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
uuid@9.0.1:
|
||||||
|
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
vary@1.1.2:
|
vary@1.1.2:
|
||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -2358,12 +2601,35 @@ packages:
|
|||||||
victory-vendor@36.9.2:
|
victory-vendor@36.9.2:
|
||||||
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
|
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'}
|
||||||
|
|
||||||
|
word@0.3.0:
|
||||||
|
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
|
xlsx@0.18.5:
|
||||||
|
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
xmlchars@2.2.0:
|
xmlchars@2.2.0:
|
||||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||||
|
|
||||||
|
xtend@4.0.2:
|
||||||
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
zip-stream@4.1.1:
|
zip-stream@4.1.1:
|
||||||
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
|
resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
@@ -2787,6 +3053,29 @@ snapshots:
|
|||||||
'@types/react': 18.3.27
|
'@types/react': 18.3.27
|
||||||
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
||||||
|
|
||||||
|
'@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.3
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.27)(react@18.3.1)
|
||||||
|
'@radix-ui/react-context': 1.1.2(@types/react@18.3.27)(react@18.3.1)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.27)(react@18.3.1)
|
||||||
|
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-id': 1.1.1(@types/react@18.3.27)(react@18.3.1)
|
||||||
|
'@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
'@radix-ui/react-slot': 1.2.3(@types/react@18.3.27)(react@18.3.1)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.27)(react@18.3.1)
|
||||||
|
aria-hidden: 1.2.6
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
react-remove-scroll: 2.7.2(@types/react@18.3.27)(react@18.3.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 18.3.27
|
||||||
|
'@types/react-dom': 18.3.7(@types/react@18.3.27)
|
||||||
|
|
||||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
'@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@@ -3118,6 +3407,8 @@ snapshots:
|
|||||||
'@types/express-serve-static-core': 5.1.1
|
'@types/express-serve-static-core': 5.1.1
|
||||||
'@types/serve-static': 2.2.0
|
'@types/serve-static': 2.2.0
|
||||||
|
|
||||||
|
'@types/file-saver@2.0.7': {}
|
||||||
|
|
||||||
'@types/http-errors@2.0.5': {}
|
'@types/http-errors@2.0.5': {}
|
||||||
|
|
||||||
'@types/jsonwebtoken@9.0.10':
|
'@types/jsonwebtoken@9.0.10':
|
||||||
@@ -3139,10 +3430,25 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
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/prop-types@15.7.15': {}
|
||||||
|
|
||||||
'@types/qs@6.14.0': {}
|
'@types/qs@6.14.0': {}
|
||||||
|
|
||||||
|
'@types/raf@3.4.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@types/range-parser@1.2.7': {}
|
||||||
|
|
||||||
'@types/react-dom@18.3.7(@types/react@18.3.27)':
|
'@types/react-dom@18.3.7(@types/react@18.3.27)':
|
||||||
@@ -3163,6 +3469,9 @@ snapshots:
|
|||||||
'@types/http-errors': 2.0.5
|
'@types/http-errors': 2.0.5
|
||||||
'@types/node': 22.19.7
|
'@types/node': 22.19.7
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@vilic/node-forge@1.3.2-5': {}
|
'@vilic/node-forge@1.3.2-5': {}
|
||||||
|
|
||||||
'@xmldom/xmldom@0.9.8': {}
|
'@xmldom/xmldom@0.9.8': {}
|
||||||
@@ -3172,6 +3481,8 @@ snapshots:
|
|||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
negotiator: 0.6.3
|
negotiator: 0.6.3
|
||||||
|
|
||||||
|
adler-32@1.3.1: {}
|
||||||
|
|
||||||
adm-zip@0.5.16: {}
|
adm-zip@0.5.16: {}
|
||||||
|
|
||||||
any-promise@1.3.0: {}
|
any-promise@1.3.0: {}
|
||||||
@@ -3248,6 +3559,8 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2: {}
|
||||||
|
|
||||||
base64-js@1.5.1: {}
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.9.17: {}
|
baseline-browser-mapping@2.9.17: {}
|
||||||
@@ -3342,6 +3655,23 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001765: {}
|
caniuse-lite@1.0.30001765: {}
|
||||||
|
|
||||||
|
canvg@3.0.11:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
'@types/raf': 3.4.3
|
||||||
|
core-js: 3.48.0
|
||||||
|
raf: 3.4.1
|
||||||
|
regenerator-runtime: 0.13.11
|
||||||
|
rgbcolor: 1.0.1
|
||||||
|
stackblur-canvas: 2.7.0
|
||||||
|
svg-pathdata: 6.0.3
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
cfb@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
adler-32: 1.3.1
|
||||||
|
crc-32: 1.2.2
|
||||||
|
|
||||||
chainsaw@0.1.0:
|
chainsaw@0.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
traverse: 0.3.9
|
traverse: 0.3.9
|
||||||
@@ -3366,6 +3696,8 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
codepage@1.15.0: {}
|
||||||
|
|
||||||
combined-stream@1.0.8:
|
combined-stream@1.0.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
delayed-stream: 1.0.0
|
delayed-stream: 1.0.0
|
||||||
@@ -3391,6 +3723,9 @@ snapshots:
|
|||||||
|
|
||||||
cookie@0.7.2: {}
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
|
core-js@3.48.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
core-util-is@1.0.3: {}
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
cors@2.8.5:
|
cors@2.8.5:
|
||||||
@@ -3405,6 +3740,10 @@ snapshots:
|
|||||||
crc-32: 1.2.2
|
crc-32: 1.2.2
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
|
css-line-break@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
utrie: 1.0.2
|
||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
@@ -3474,6 +3813,10 @@ snapshots:
|
|||||||
'@babel/runtime': 7.28.6
|
'@babel/runtime': 7.28.6
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
|
dompurify@3.3.1:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
dotenv@17.2.3: {}
|
dotenv@17.2.3: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
@@ -3564,6 +3907,11 @@ snapshots:
|
|||||||
unzipper: 0.10.14
|
unzipper: 0.10.14
|
||||||
uuid: 8.3.2
|
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:
|
express@4.22.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
accepts: 1.3.8
|
accepts: 1.3.8
|
||||||
@@ -3615,6 +3963,12 @@ snapshots:
|
|||||||
merge2: 1.4.1
|
merge2: 1.4.1
|
||||||
micromatch: 4.0.8
|
micromatch: 4.0.8
|
||||||
|
|
||||||
|
fast-png@6.4.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/pako': 2.0.4
|
||||||
|
iobuffer: 5.4.0
|
||||||
|
pako: 2.1.0
|
||||||
|
|
||||||
fast-xml-parser@5.3.3:
|
fast-xml-parser@5.3.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
strnum: 2.1.2
|
strnum: 2.1.2
|
||||||
@@ -3627,6 +3981,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
fflate@0.8.2: {}
|
||||||
|
|
||||||
|
file-saver@2.0.5: {}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
@@ -3655,6 +4013,8 @@ snapshots:
|
|||||||
|
|
||||||
forwarded@0.2.0: {}
|
forwarded@0.2.0: {}
|
||||||
|
|
||||||
|
frac@1.1.2: {}
|
||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
fresh@0.5.2: {}
|
fresh@0.5.2: {}
|
||||||
@@ -3732,6 +4092,17 @@ snapshots:
|
|||||||
|
|
||||||
helmet@8.1.0: {}
|
helmet@8.1.0: {}
|
||||||
|
|
||||||
|
html2canvas@1.4.1:
|
||||||
|
dependencies:
|
||||||
|
css-line-break: 2.1.0
|
||||||
|
text-segmentation: 1.0.3
|
||||||
|
|
||||||
|
html2pdf.js@0.14.0:
|
||||||
|
dependencies:
|
||||||
|
dompurify: 3.3.1
|
||||||
|
html2canvas: 1.4.1
|
||||||
|
jspdf: 4.1.0
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
@@ -3757,6 +4128,10 @@ snapshots:
|
|||||||
|
|
||||||
internmap@2.0.3: {}
|
internmap@2.0.3: {}
|
||||||
|
|
||||||
|
iobuffer@5.4.0: {}
|
||||||
|
|
||||||
|
ip-address@10.1.0: {}
|
||||||
|
|
||||||
ipaddr.js@1.9.1: {}
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
is-binary-path@2.1.0:
|
is-binary-path@2.1.0:
|
||||||
@@ -3794,6 +4169,17 @@ snapshots:
|
|||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
|
|
||||||
|
jspdf@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.28.6
|
||||||
|
fast-png: 6.4.0
|
||||||
|
fflate: 0.8.2
|
||||||
|
optionalDependencies:
|
||||||
|
canvg: 3.0.11
|
||||||
|
core-js: 3.48.0
|
||||||
|
dompurify: 3.3.1
|
||||||
|
html2canvas: 1.4.1
|
||||||
|
|
||||||
jszip@3.10.1:
|
jszip@3.10.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
lie: 3.3.0
|
lie: 3.3.0
|
||||||
@@ -3878,6 +4264,13 @@ snapshots:
|
|||||||
|
|
||||||
media-typer@0.3.0: {}
|
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: {}
|
merge-descriptors@1.0.3: {}
|
||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
@@ -3952,10 +4345,16 @@ snapshots:
|
|||||||
|
|
||||||
node-cron@4.2.1: {}
|
node-cron@4.2.1: {}
|
||||||
|
|
||||||
|
node-fetch@2.7.0:
|
||||||
|
dependencies:
|
||||||
|
whatwg-url: 5.0.0
|
||||||
|
|
||||||
node-forge@1.3.3: {}
|
node-forge@1.3.3: {}
|
||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
|
nodemailer@8.0.2: {}
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
@@ -3974,6 +4373,8 @@ snapshots:
|
|||||||
|
|
||||||
pako@1.0.11: {}
|
pako@1.0.11: {}
|
||||||
|
|
||||||
|
pako@2.1.0: {}
|
||||||
|
|
||||||
parseurl@1.3.3: {}
|
parseurl@1.3.3: {}
|
||||||
|
|
||||||
path-is-absolute@1.0.1: {}
|
path-is-absolute@1.0.1: {}
|
||||||
@@ -3982,6 +4383,44 @@ snapshots:
|
|||||||
|
|
||||||
path-to-regexp@0.1.12: {}
|
path-to-regexp@0.1.12: {}
|
||||||
|
|
||||||
|
performance-now@2.1.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pg-cloudflare@1.3.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
pg-connection-string@2.11.0: {}
|
||||||
|
|
||||||
|
pg-int8@1.0.1: {}
|
||||||
|
|
||||||
|
pg-pool@3.11.0(pg@8.18.0):
|
||||||
|
dependencies:
|
||||||
|
pg: 8.18.0
|
||||||
|
|
||||||
|
pg-protocol@1.11.0: {}
|
||||||
|
|
||||||
|
pg-types@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
pg-int8: 1.0.1
|
||||||
|
postgres-array: 2.0.0
|
||||||
|
postgres-bytea: 1.0.1
|
||||||
|
postgres-date: 1.0.7
|
||||||
|
postgres-interval: 1.2.0
|
||||||
|
|
||||||
|
pg@8.18.0:
|
||||||
|
dependencies:
|
||||||
|
pg-connection-string: 2.11.0
|
||||||
|
pg-pool: 3.11.0(pg@8.18.0)
|
||||||
|
pg-protocol: 1.11.0
|
||||||
|
pg-types: 2.2.0
|
||||||
|
pgpass: 1.0.5
|
||||||
|
optionalDependencies:
|
||||||
|
pg-cloudflare: 1.3.0
|
||||||
|
|
||||||
|
pgpass@1.0.5:
|
||||||
|
dependencies:
|
||||||
|
split2: 4.2.0
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.1: {}
|
picomatch@2.3.1: {}
|
||||||
@@ -4036,6 +4475,16 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
postgres-array@2.0.0: {}
|
||||||
|
|
||||||
|
postgres-bytea@1.0.1: {}
|
||||||
|
|
||||||
|
postgres-date@1.0.7: {}
|
||||||
|
|
||||||
|
postgres-interval@1.2.0:
|
||||||
|
dependencies:
|
||||||
|
xtend: 4.0.2
|
||||||
|
|
||||||
prisma@5.22.0:
|
prisma@5.22.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@prisma/engines': 5.22.0
|
'@prisma/engines': 5.22.0
|
||||||
@@ -4063,6 +4512,11 @@ snapshots:
|
|||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
|
raf@3.4.1:
|
||||||
|
dependencies:
|
||||||
|
performance-now: 2.1.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
range-parser@1.2.1: {}
|
range-parser@1.2.1: {}
|
||||||
|
|
||||||
raw-body@2.5.3:
|
raw-body@2.5.3:
|
||||||
@@ -4179,6 +4633,9 @@ snapshots:
|
|||||||
tiny-invariant: 1.3.3
|
tiny-invariant: 1.3.3
|
||||||
victory-vendor: 36.9.2
|
victory-vendor: 36.9.2
|
||||||
|
|
||||||
|
regenerator-runtime@0.13.11:
|
||||||
|
optional: true
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0: {}
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
resolve@1.22.11:
|
resolve@1.22.11:
|
||||||
@@ -4189,6 +4646,9 @@ snapshots:
|
|||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
|
rgbcolor@1.0.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
rimraf@2.7.1:
|
rimraf@2.7.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
glob: 7.2.3
|
glob: 7.2.3
|
||||||
@@ -4274,6 +4734,15 @@ snapshots:
|
|||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
split2@4.2.0: {}
|
||||||
|
|
||||||
|
ssf@0.11.2:
|
||||||
|
dependencies:
|
||||||
|
frac: 1.1.2
|
||||||
|
|
||||||
|
stackblur-canvas@2.7.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
streamsearch@1.1.0: {}
|
streamsearch@1.1.0: {}
|
||||||
@@ -4305,6 +4774,9 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
svg-pathdata@6.0.3:
|
||||||
|
optional: true
|
||||||
|
|
||||||
tailwind-merge@2.6.0: {}
|
tailwind-merge@2.6.0: {}
|
||||||
|
|
||||||
tailwindcss@3.4.19(tsx@4.21.0):
|
tailwindcss@3.4.19(tsx@4.21.0):
|
||||||
@@ -4343,6 +4815,10 @@ snapshots:
|
|||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
|
text-segmentation@1.0.3:
|
||||||
|
dependencies:
|
||||||
|
utrie: 1.0.2
|
||||||
|
|
||||||
thenify-all@1.6.0:
|
thenify-all@1.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
thenify: 3.3.1
|
thenify: 3.3.1
|
||||||
@@ -4366,6 +4842,8 @@ snapshots:
|
|||||||
|
|
||||||
toidentifier@1.0.1: {}
|
toidentifier@1.0.1: {}
|
||||||
|
|
||||||
|
tr46@0.0.3: {}
|
||||||
|
|
||||||
traverse@0.3.9: {}
|
traverse@0.3.9: {}
|
||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
ts-interface-checker@0.1.13: {}
|
||||||
@@ -4461,8 +4939,14 @@ snapshots:
|
|||||||
|
|
||||||
utils-merge@1.0.1: {}
|
utils-merge@1.0.1: {}
|
||||||
|
|
||||||
|
utrie@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
base64-arraybuffer: 1.0.2
|
||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
|
uuid@9.0.1: {}
|
||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
victory-vendor@36.9.2:
|
victory-vendor@36.9.2:
|
||||||
@@ -4482,10 +4966,33 @@ snapshots:
|
|||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-timer: 3.0.1
|
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: {}
|
||||||
|
|
||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
xlsx@0.18.5:
|
||||||
|
dependencies:
|
||||||
|
adler-32: 1.3.1
|
||||||
|
cfb: 1.2.2
|
||||||
|
codepage: 1.15.0
|
||||||
|
crc-32: 1.2.2
|
||||||
|
ssf: 0.11.2
|
||||||
|
wmf: 1.0.2
|
||||||
|
word: 0.3.0
|
||||||
|
|
||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
zip-stream@4.1.1:
|
zip-stream@4.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
archiver-utils: 3.0.4
|
archiver-utils: 3.0.4
|
||||||
|
|||||||
71
scripts/backup.sh
Executable file
71
scripts/backup.sh
Executable 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) ==="
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user