Compare commits
68 Commits
a64aa11548
...
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 | ||
|
|
07fc9a8fe3 | ||
|
|
dcc33af523 | ||
|
|
492cd62772 | ||
|
|
008f586b54 | ||
|
|
38466a2b23 | ||
|
|
98d704a549 | ||
|
|
c52548a2bb | ||
|
|
121fe731d0 | ||
|
|
02ccfb41a0 | ||
|
|
75a9819c1e | ||
|
|
2dd22ec152 | ||
|
|
69efb585d3 | ||
|
|
2655a51a99 | ||
|
|
31c66f2823 | ||
|
|
e50e7100f1 | ||
|
|
0a65c60570 | ||
|
|
473912bfd7 | ||
|
|
09684f77b9 | ||
|
|
56e6e27ab3 |
167
README.md
167
README.md
@@ -4,40 +4,80 @@ Plataforma de análisis financiero y gestión fiscal para empresas mexicanas.
|
||||
|
||||
## Descripción
|
||||
|
||||
Horux360 es una aplicación SaaS que permite a las empresas mexicanas:
|
||||
Horux360 es una aplicación SaaS multi-tenant que permite a las empresas mexicanas:
|
||||
|
||||
- Gestionar sus CFDI (facturas electrónicas)
|
||||
- Gestionar sus CFDI (facturas electrónicas) con carga masiva de XML
|
||||
- Controlar IVA e ISR automáticamente
|
||||
- Sincronizar CFDIs directamente con el SAT usando FIEL
|
||||
- Visualizar dashboards financieros en tiempo real
|
||||
- Realizar conciliación bancaria
|
||||
- Recibir alertas fiscales proactivas
|
||||
- Generar reportes y proyecciones financieras
|
||||
- Calendario de obligaciones fiscales
|
||||
|
||||
## Stack Tecnológico
|
||||
|
||||
- **Frontend:** Next.js 14 + TypeScript + Tailwind CSS
|
||||
- **Backend:** Node.js + Express + TypeScript
|
||||
- **Base de datos:** PostgreSQL (multi-tenant por schema)
|
||||
- **Autenticación:** JWT personalizado
|
||||
- **Estado:** Zustand con persistencia
|
||||
| Capa | Tecnología |
|
||||
|------|-----------|
|
||||
| **Frontend** | Next.js 14 + TypeScript + Tailwind CSS + shadcn/ui |
|
||||
| **Backend** | Node.js + Express + TypeScript + tsx |
|
||||
| **Base de datos** | PostgreSQL 16 (database-per-tenant) |
|
||||
| **ORM** | Prisma (central DB) + pg (tenant DBs con raw SQL) |
|
||||
| **Autenticación** | JWT (access 15min + refresh 7d) |
|
||||
| **Estado** | Zustand con persistencia |
|
||||
| **Proceso** | PM2 (fork mode) |
|
||||
| **Proxy** | Nginx con SSL (Let's Encrypt) |
|
||||
| **Email** | Nodemailer + Gmail Workspace (STARTTLS) |
|
||||
| **Pagos** | MercadoPago (suscripciones) |
|
||||
|
||||
## Estructura del Proyecto
|
||||
|
||||
```
|
||||
horux360/
|
||||
├── apps/
|
||||
│ ├── web/ # Frontend Next.js
|
||||
│ ├── web/ # Frontend Next.js 14
|
||||
│ │ ├── app/ # Pages (App Router)
|
||||
│ │ ├── components/ # Componentes UI
|
||||
│ │ ├── lib/api/ # Cliente API
|
||||
│ │ └── stores/ # Zustand stores
|
||||
│ └── api/ # Backend Express
|
||||
│ ├── src/
|
||||
│ │ ├── config/ # ENV, database connections
|
||||
│ │ ├── controllers/ # Request handlers
|
||||
│ │ ├── middlewares/ # Auth, tenant, rate-limit, plan-limits
|
||||
│ │ ├── routes/ # Express routes
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ │ ├── email/ # Templates + Nodemailer
|
||||
│ │ │ ├── payment/ # MercadoPago
|
||||
│ │ │ └── sat/ # SAT sync + FIEL crypto
|
||||
│ │ ├── utils/ # Helpers (token, password, global-admin)
|
||||
│ │ └── jobs/ # SAT sync cron job
|
||||
│ └── prisma/ # Schema + migrations
|
||||
├── packages/
|
||||
│ └── shared/ # Tipos y utilidades compartidas
|
||||
│ └── shared/ # Tipos y constantes compartidas
|
||||
├── deploy/
|
||||
│ └── nginx/ # Configuración de Nginx
|
||||
├── scripts/
|
||||
│ └── backup.sh # Script de backup PostgreSQL
|
||||
├── docs/
|
||||
│ ├── architecture/ # Docs técnicos
|
||||
│ ├── security/ # Auditorías de seguridad
|
||||
│ └── plans/ # Documentación de diseño
|
||||
└── docker-compose.yml
|
||||
└── ecosystem.config.js # PM2 config
|
||||
```
|
||||
|
||||
## Documentación
|
||||
## Arquitectura Multi-Tenant
|
||||
|
||||
- [Documento de Diseño](docs/plans/2026-01-22-horux360-saas-design.md)
|
||||
Cada cliente tiene su propia base de datos PostgreSQL, asegurando aislamiento completo de datos:
|
||||
|
||||
```
|
||||
horux360 (central) ← Tenants, Users, Subscriptions, RefreshTokens
|
||||
horux_<rfc_cliente_1> ← CFDIs, Alertas, Calendario, IVA del cliente 1
|
||||
horux_<rfc_cliente_2> ← CFDIs, Alertas, Calendario, IVA del cliente 2
|
||||
...
|
||||
```
|
||||
|
||||
El middleware de tenant resuelve la base de datos del cliente desde el `tenantId` del JWT, usando un caché de 5 minutos.
|
||||
|
||||
## Planes
|
||||
|
||||
@@ -45,50 +85,113 @@ horux360/
|
||||
|------|----------|----------|-----------------|
|
||||
| Starter | 100 | 1 | Dashboard, IVA/ISR, CFDI básico |
|
||||
| Business | 500 | 3 | + Reportes, Alertas, Calendario |
|
||||
| Professional | 2,000 | 10 | + Conciliación, Forecasting |
|
||||
| Professional | 2,000 | 10 | + Conciliación, Forecasting, SAT Sync |
|
||||
| Enterprise | Ilimitado | Ilimitado | + API, Multi-empresa |
|
||||
|
||||
## Características Destacadas
|
||||
## Seguridad
|
||||
|
||||
- **4 Temas visuales:** Light, Vibrant, Corporate, Dark
|
||||
- **Multi-tenant:** Aislamiento de datos por empresa (schema por tenant)
|
||||
- **Responsive:** Funciona en desktop y móvil
|
||||
- **Tiempo real:** Dashboards actualizados al instante
|
||||
- **Carga masiva de XML:** Soporte para carga de hasta 300MB de archivos XML
|
||||
- **Selector de período:** Navegación por mes/año en todos los dashboards
|
||||
- **Clasificación automática:** Ingresos/egresos basado en RFC del tenant
|
||||
- JWT con access token (15min) y refresh token rotation (7d)
|
||||
- bcrypt con 12 salt rounds para passwords
|
||||
- Rate limiting en auth (10 login/15min, 3 register/hora)
|
||||
- FIEL encriptada con AES-256-GCM
|
||||
- CSP, HSTS, y security headers vía Nginx + Helmet
|
||||
- Admin global verificado por RFC (no solo por rol)
|
||||
- Webhooks de MercadoPago con verificación HMAC-SHA256
|
||||
- Body limits diferenciados (10MB general, 50MB bulk CFDI)
|
||||
- TLS obligatorio para SMTP
|
||||
|
||||
## Configuración
|
||||
Ver [Auditoría de Seguridad](docs/security/2026-03-18-security-audit-remediation.md) para detalles completos.
|
||||
|
||||
### Variables de entorno (API)
|
||||
## Documentación
|
||||
|
||||
| Documento | Descripción |
|
||||
|-----------|-------------|
|
||||
| [Diseño SaaS](docs/plans/2026-01-22-horux360-saas-design.md) | Arquitectura original y decisiones de diseño |
|
||||
| [Deployment](docs/architecture/deployment.md) | Guía completa de despliegue en producción |
|
||||
| [API Reference](docs/architecture/api-reference.md) | Referencia de todos los endpoints |
|
||||
| [Security Audit](docs/security/2026-03-18-security-audit-remediation.md) | Auditoría de seguridad y remediaciones |
|
||||
| [SAT Sync](docs/SAT-SYNC-IMPLEMENTATION.md) | Implementación de sincronización con el SAT |
|
||||
|
||||
## Configuración Local
|
||||
|
||||
### Requisitos
|
||||
- Node.js 20+
|
||||
- pnpm 9+
|
||||
- PostgreSQL 16
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Instalar dependencias
|
||||
pnpm install
|
||||
|
||||
# Configurar variables de entorno
|
||||
cp apps/api/.env.example apps/api/.env
|
||||
cp apps/web/.env.example apps/web/.env.local
|
||||
|
||||
# Ejecutar migraciones
|
||||
cd apps/api && pnpm prisma migrate dev
|
||||
|
||||
# Desarrollo
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Variables de Entorno (API)
|
||||
|
||||
```env
|
||||
NODE_ENV=development
|
||||
PORT=4000
|
||||
DATABASE_URL="postgresql://user:pass@localhost:5432/horux360"
|
||||
JWT_SECRET=your-secret-key
|
||||
JWT_SECRET=<min-32-chars>
|
||||
JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
FRONTEND_URL=http://localhost:3000
|
||||
FIEL_ENCRYPTION_KEY=<min-32-chars>
|
||||
FIEL_STORAGE_PATH=/var/horux/fiel
|
||||
```
|
||||
|
||||
### Variables de entorno (Web)
|
||||
|
||||
### Variables de Entorno (Web)
|
||||
```env
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000/api
|
||||
```
|
||||
|
||||
## Demo
|
||||
## Roles
|
||||
|
||||
Credenciales de demo:
|
||||
- **Admin:** admin@demo.com / demo123
|
||||
- **Contador:** contador@demo.com / demo123
|
||||
- **Visor:** visor@demo.com / demo123
|
||||
| Rol | Acceso |
|
||||
|-----|--------|
|
||||
| **admin** | Todo dentro de su tenant + invitar usuarios |
|
||||
| **contador** | CFDI, impuestos, reportes, dashboard |
|
||||
| **visor** | Solo lectura |
|
||||
| **admin global** | Admin del tenant CAS2408138W2 — gestión de clientes, suscripciones, SAT cron |
|
||||
|
||||
## Producción
|
||||
|
||||
- **URL:** https://horuxfin.com
|
||||
- **Hosting:** Servidor dedicado
|
||||
- **SSL:** Let's Encrypt (auto-renewal)
|
||||
- **Process:** PM2 con auto-restart
|
||||
- **Backups:** Diarios a las 01:00 AM
|
||||
|
||||
Ver [Guía de Deployment](docs/architecture/deployment.md) para instrucciones completas.
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.5.0 (2026-03-18)
|
||||
- Auditoría de seguridad completa y remediación de 20 vulnerabilidades
|
||||
- Rate limiting en endpoints de autenticación
|
||||
- Content Security Policy (CSP) y headers de seguridad mejorados
|
||||
- `databaseName` removido del JWT (resolución server-side)
|
||||
- Restricción de impersonación a admin global únicamente
|
||||
- Autorización en endpoints de suscripción y SAT cron
|
||||
- Verificación obligatoria de firma en webhooks
|
||||
- Body limits reducidos (10MB default, 50MB bulk)
|
||||
- Passwords temporales criptográficamente seguros
|
||||
- Validación de tamaño en upload de FIEL
|
||||
- SMTP con TLS obligatorio
|
||||
- Documentación completa de producción
|
||||
|
||||
### v0.4.0 (2026-01-22)
|
||||
- Carga masiva de XML CFDI (hasta 300MB)
|
||||
- Carga masiva de XML CFDI (hasta 50MB)
|
||||
- Selector de período mes/año en dashboards
|
||||
- Fix: Persistencia de sesión en refresh de página
|
||||
- Fix: Clasificación ingreso/egreso basada en RFC
|
||||
|
||||
@@ -15,25 +15,38 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@horux/shared": "workspace:*",
|
||||
"@nodecfdi/cfdi-core": "^1.0.1",
|
||||
"@nodecfdi/credentials": "^3.2.0",
|
||||
"@nodecfdi/sat-ws-descarga-masiva": "^2.0.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.21.0",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mercadopago": "^2.12.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-forge": "^1.3.3",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pg": "^8.18.0",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/node-forge": "^1.3.14",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/pg": "^8.18.0",
|
||||
"express-rate-limit": "^8.3.1",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.3.0"
|
||||
|
||||
@@ -12,9 +12,9 @@ model Tenant {
|
||||
nombre String
|
||||
rfc String @unique
|
||||
plan Plan @default(starter)
|
||||
schemaName String @unique @map("schema_name")
|
||||
cfdiLimit Int @map("cfdi_limit")
|
||||
usersLimit Int @map("users_limit")
|
||||
databaseName String @unique @map("database_name")
|
||||
cfdiLimit Int @default(100) @map("cfdi_limit")
|
||||
usersLimit Int @default(1) @map("users_limit")
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
@@ -22,6 +22,8 @@ model Tenant {
|
||||
users User[]
|
||||
fielCredential FielCredential?
|
||||
satSyncJobs SatSyncJob[]
|
||||
subscriptions Subscription[]
|
||||
payments Payment[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
@@ -76,8 +78,12 @@ model FielCredential {
|
||||
cerData Bytes @map("cer_data")
|
||||
keyData Bytes @map("key_data")
|
||||
keyPasswordEncrypted Bytes @map("key_password_encrypted")
|
||||
encryptionIv Bytes @map("encryption_iv")
|
||||
encryptionTag Bytes @map("encryption_tag")
|
||||
cerIv Bytes @map("cer_iv")
|
||||
cerTag Bytes @map("cer_tag")
|
||||
keyIv Bytes @map("key_iv")
|
||||
keyTag Bytes @map("key_tag")
|
||||
passwordIv Bytes @map("password_iv")
|
||||
passwordTag Bytes @map("password_tag")
|
||||
serialNumber String? @map("serial_number") @db.VarChar(50)
|
||||
validFrom DateTime @map("valid_from")
|
||||
validUntil DateTime @map("valid_until")
|
||||
@@ -90,6 +96,46 @@ model FielCredential {
|
||||
@@map("fiel_credentials")
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
plan Plan
|
||||
mpPreapprovalId String? @map("mp_preapproval_id")
|
||||
status String @default("pending")
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
frequency String @default("monthly")
|
||||
currentPeriodStart DateTime? @map("current_period_start")
|
||||
currentPeriodEnd DateTime? @map("current_period_end")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
payments Payment[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([status])
|
||||
@@map("subscriptions")
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
subscriptionId String? @map("subscription_id")
|
||||
mpPaymentId String? @map("mp_payment_id")
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
status String @default("pending")
|
||||
paymentMethod String? @map("payment_method")
|
||||
paidAt DateTime? @map("paid_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
subscription Subscription? @relation(fields: [subscriptionId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([subscriptionId])
|
||||
@@map("payments")
|
||||
}
|
||||
|
||||
model SatSyncJob {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @map("tenant_id")
|
||||
|
||||
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 helmet from 'helmet';
|
||||
import { env, getCorsOrigins } from './config/env.js';
|
||||
@@ -13,8 +13,12 @@ import { calendarioRoutes } from './routes/calendario.routes.js';
|
||||
import { reportesRoutes } from './routes/reportes.routes.js';
|
||||
import { usuariosRoutes } from './routes/usuarios.routes.js';
|
||||
import { tenantsRoutes } from './routes/tenants.routes.js';
|
||||
import fielRoutes from './routes/fiel.routes.js';
|
||||
import satRoutes from './routes/sat.routes.js';
|
||||
import { webhookRoutes } from './routes/webhook.routes.js';
|
||||
import { subscriptionRoutes } from './routes/subscription.routes.js';
|
||||
|
||||
const app = express();
|
||||
const app: Express = express();
|
||||
|
||||
// Security
|
||||
app.use(helmet());
|
||||
@@ -23,9 +27,9 @@ app.use(cors({
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Body parsing - increased limit for bulk XML uploads (1GB)
|
||||
app.use(express.json({ limit: '1gb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '1gb' }));
|
||||
// Body parsing - 10MB default, bulk CFDI route has its own higher limit
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
@@ -43,6 +47,10 @@ app.use('/api/calendario', calendarioRoutes);
|
||||
app.use('/api/reportes', reportesRoutes);
|
||||
app.use('/api/usuarios', usuariosRoutes);
|
||||
app.use('/api/tenants', tenantsRoutes);
|
||||
app.use('/api/fiel', fielRoutes);
|
||||
app.use('/api/sat', satRoutes);
|
||||
app.use('/api/webhooks', webhookRoutes);
|
||||
app.use('/api/subscriptions', subscriptionRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorMiddleware);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Pool, type PoolConfig } from 'pg';
|
||||
import { env } from './env.js';
|
||||
|
||||
// ===========================================
|
||||
// Prisma Client (central database: horux360)
|
||||
// ===========================================
|
||||
|
||||
declare global {
|
||||
var prisma: PrismaClient | undefined;
|
||||
@@ -11,3 +17,303 @@ export const prisma = globalThis.prisma || new PrismaClient({
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalThis.prisma = prisma;
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// TenantConnectionManager (per-tenant DBs)
|
||||
// ===========================================
|
||||
|
||||
interface PoolEntry {
|
||||
pool: Pool;
|
||||
lastAccess: Date;
|
||||
}
|
||||
|
||||
function parseDatabaseUrl(url: string) {
|
||||
const parsed = new URL(url);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parseInt(parsed.port || '5432'),
|
||||
user: decodeURIComponent(parsed.username),
|
||||
password: decodeURIComponent(parsed.password),
|
||||
};
|
||||
}
|
||||
|
||||
class TenantConnectionManager {
|
||||
private pools: Map<string, PoolEntry> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
private dbConfig: { host: string; port: number; user: string; password: string };
|
||||
|
||||
constructor() {
|
||||
this.dbConfig = parseDatabaseUrl(env.DATABASE_URL);
|
||||
this.cleanupInterval = setInterval(() => this.cleanupIdlePools(), 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a connection pool for a tenant's database.
|
||||
*/
|
||||
getPool(tenantId: string, databaseName: string): Pool {
|
||||
const entry = this.pools.get(tenantId);
|
||||
if (entry) {
|
||||
entry.lastAccess = new Date();
|
||||
return entry.pool;
|
||||
}
|
||||
|
||||
const poolConfig: PoolConfig = {
|
||||
host: this.dbConfig.host,
|
||||
port: this.dbConfig.port,
|
||||
user: this.dbConfig.user,
|
||||
password: this.dbConfig.password,
|
||||
database: databaseName,
|
||||
max: 3,
|
||||
idleTimeoutMillis: 300_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
};
|
||||
|
||||
const pool = new Pool(poolConfig);
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error(`[TenantDB] Pool error for tenant ${tenantId} (${databaseName}):`, err.message);
|
||||
});
|
||||
|
||||
this.pools.set(tenantId, { pool, lastAccess: new Date() });
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new database for a tenant with all required tables and indexes.
|
||||
*/
|
||||
async provisionDatabase(rfc: string): Promise<string> {
|
||||
const databaseName = `horux_${rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
|
||||
const adminPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: 'postgres',
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
const exists = await adminPool.query(
|
||||
`SELECT 1 FROM pg_database WHERE datname = $1`,
|
||||
[databaseName]
|
||||
);
|
||||
|
||||
if (exists.rows.length > 0) {
|
||||
throw new Error(`Database ${databaseName} already exists`);
|
||||
}
|
||||
|
||||
await adminPool.query(`CREATE DATABASE "${databaseName}"`);
|
||||
|
||||
const tenantPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: databaseName,
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.createTables(tenantPool);
|
||||
await this.createIndexes(tenantPool);
|
||||
} finally {
|
||||
await tenantPool.end();
|
||||
}
|
||||
|
||||
return databaseName;
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete: rename database so it can be recovered.
|
||||
*/
|
||||
async deprovisionDatabase(databaseName: string): Promise<void> {
|
||||
// Close any active pool for this tenant
|
||||
for (const [tenantId, entry] of this.pools.entries()) {
|
||||
// We check pool config to match the database
|
||||
if ((entry.pool as any).options?.database === databaseName) {
|
||||
await entry.pool.end().catch(() => {});
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const adminPool = new Pool({
|
||||
...this.dbConfig,
|
||||
database: 'postgres',
|
||||
max: 1,
|
||||
});
|
||||
|
||||
try {
|
||||
await adminPool.query(`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname = $1 AND pid <> pg_backend_pid()
|
||||
`, [databaseName]);
|
||||
|
||||
await adminPool.query(
|
||||
`ALTER DATABASE "${databaseName}" RENAME TO "${databaseName}_deleted_${timestamp}"`
|
||||
);
|
||||
} finally {
|
||||
await adminPool.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate (close and remove) a specific tenant's pool.
|
||||
*/
|
||||
invalidatePool(tenantId: string): void {
|
||||
const entry = this.pools.get(tenantId);
|
||||
if (entry) {
|
||||
entry.pool.end().catch(() => {});
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove idle pools (not accessed in last 5 minutes).
|
||||
*/
|
||||
private cleanupIdlePools(): void {
|
||||
const now = Date.now();
|
||||
const maxIdle = 5 * 60 * 1000;
|
||||
|
||||
for (const [tenantId, entry] of this.pools.entries()) {
|
||||
if (now - entry.lastAccess.getTime() > maxIdle) {
|
||||
entry.pool.end().catch((err) =>
|
||||
console.error(`[TenantDB] Error closing idle pool for ${tenantId}:`, err.message)
|
||||
);
|
||||
this.pools.delete(tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Graceful shutdown: close all pools.
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
|
||||
const closePromises = Array.from(this.pools.values()).map((entry) =>
|
||||
entry.pool.end()
|
||||
);
|
||||
await Promise.all(closePromises);
|
||||
this.pools.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats about active pools.
|
||||
*/
|
||||
getStats(): { activePools: number; tenantIds: string[] } {
|
||||
return {
|
||||
activePools: this.pools.size,
|
||||
tenantIds: Array.from(this.pools.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
private async createTables(pool: Pool): Promise<void> {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS cfdis (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
|
||||
tipo VARCHAR(20) NOT NULL DEFAULT 'ingreso',
|
||||
serie VARCHAR(25),
|
||||
folio VARCHAR(40),
|
||||
fecha_emision TIMESTAMP NOT NULL,
|
||||
fecha_timbrado TIMESTAMP,
|
||||
rfc_emisor VARCHAR(13) NOT NULL,
|
||||
nombre_emisor VARCHAR(300) NOT NULL,
|
||||
rfc_receptor VARCHAR(13) NOT NULL,
|
||||
nombre_receptor VARCHAR(300) NOT NULL,
|
||||
subtotal DECIMAL(18,2) DEFAULT 0,
|
||||
descuento DECIMAL(18,2) DEFAULT 0,
|
||||
iva DECIMAL(18,2) DEFAULT 0,
|
||||
isr_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
iva_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
total DECIMAL(18,2) DEFAULT 0,
|
||||
moneda VARCHAR(10) DEFAULT 'MXN',
|
||||
tipo_cambio DECIMAL(10,4) DEFAULT 1,
|
||||
metodo_pago VARCHAR(10),
|
||||
forma_pago VARCHAR(10),
|
||||
uso_cfdi VARCHAR(10),
|
||||
estado VARCHAR(20) DEFAULT 'vigente',
|
||||
xml_url TEXT,
|
||||
pdf_url TEXT,
|
||||
xml_original TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
last_sat_sync TIMESTAMP,
|
||||
sat_sync_job_id UUID,
|
||||
source VARCHAR(20) DEFAULT 'manual'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS iva_mensual (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
año INTEGER NOT NULL,
|
||||
mes INTEGER NOT NULL,
|
||||
iva_trasladado DECIMAL(18,2) DEFAULT 0,
|
||||
iva_acreditable DECIMAL(18,2) DEFAULT 0,
|
||||
iva_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
resultado DECIMAL(18,2) DEFAULT 0,
|
||||
acumulado DECIMAL(18,2) DEFAULT 0,
|
||||
estado VARCHAR(20) DEFAULT 'pendiente',
|
||||
fecha_declaracion TIMESTAMP,
|
||||
UNIQUE(año, mes)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS isr_mensual (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
año INTEGER NOT NULL,
|
||||
mes INTEGER NOT NULL,
|
||||
ingresos_acumulados DECIMAL(18,2) DEFAULT 0,
|
||||
deducciones DECIMAL(18,2) DEFAULT 0,
|
||||
base_gravable DECIMAL(18,2) DEFAULT 0,
|
||||
isr_causado DECIMAL(18,2) DEFAULT 0,
|
||||
isr_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
isr_a_pagar DECIMAL(18,2) DEFAULT 0,
|
||||
estado VARCHAR(20) DEFAULT 'pendiente',
|
||||
fecha_declaracion TIMESTAMP,
|
||||
UNIQUE(año, mes)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alertas (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tipo VARCHAR(50) NOT NULL,
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
mensaje TEXT,
|
||||
prioridad VARCHAR(20) DEFAULT 'media',
|
||||
fecha_vencimiento TIMESTAMP,
|
||||
leida BOOLEAN DEFAULT FALSE,
|
||||
resuelta BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendario_fiscal (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
descripcion TEXT,
|
||||
tipo VARCHAR(50) NOT NULL,
|
||||
fecha_limite TIMESTAMP NOT NULL,
|
||||
recurrencia VARCHAR(20) DEFAULT 'unica',
|
||||
completado BOOLEAN DEFAULT FALSE,
|
||||
notas TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
private async createIndexes(pool: Pool): Promise<void> {
|
||||
await pool.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_fecha_emision ON cfdis(fecha_emision DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_tipo ON cfdis(tipo);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_emisor ON cfdis(rfc_emisor);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_rfc_receptor ON cfdis(rfc_receptor);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_estado ON cfdis(estado);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_emisor_trgm ON cfdis USING gin(nombre_emisor gin_trgm_ops);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfdis_nombre_receptor_trgm ON cfdis USING gin(nombre_receptor gin_trgm_ops);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const tenantDb = new TenantConnectionManager();
|
||||
|
||||
@@ -13,12 +13,29 @@ const envSchema = z.object({
|
||||
JWT_EXPIRES_IN: z.string().default('15m'),
|
||||
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
||||
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
||||
});
|
||||
|
||||
// Parse CORS origins (comma-separated) into array
|
||||
export function getCorsOrigins(): string[] {
|
||||
return parsed.data.CORS_ORIGIN.split(',').map(origin => origin.trim());
|
||||
}
|
||||
// Frontend URL (for MercadoPago back_url, emails, etc.)
|
||||
FRONTEND_URL: z.string().default('https://horuxfin.com'),
|
||||
|
||||
// FIEL encryption (separate from JWT to allow independent rotation)
|
||||
FIEL_ENCRYPTION_KEY: z.string().min(32),
|
||||
FIEL_STORAGE_PATH: z.string().default('/var/horux/fiel'),
|
||||
|
||||
// MercadoPago
|
||||
MP_ACCESS_TOKEN: z.string().optional(),
|
||||
MP_WEBHOOK_SECRET: z.string().optional(),
|
||||
MP_NOTIFICATION_URL: z.string().optional(),
|
||||
|
||||
// SMTP (Gmail Workspace)
|
||||
SMTP_HOST: z.string().default('smtp.gmail.com'),
|
||||
SMTP_PORT: z.string().default('587'),
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASS: z.string().optional(),
|
||||
SMTP_FROM: z.string().default('Horux360 <noreply@horuxfin.com>'),
|
||||
|
||||
// Admin notification email
|
||||
ADMIN_EMAIL: z.string().default('carlos@horuxfin.com'),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
|
||||
@@ -28,3 +45,8 @@ if (!parsed.success) {
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { leida, resuelta, prioridad } = req.query;
|
||||
const alertas = await alertasService.getAlertas(req.tenantSchema!, {
|
||||
const alertas = await alertasService.getAlertas(req.tenantPool!, {
|
||||
leida: leida === 'true' ? true : leida === 'false' ? false : undefined,
|
||||
resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined,
|
||||
prioridad: prioridad as string,
|
||||
@@ -17,7 +17,7 @@ export async function getAlertas(req: Request, res: Response, next: NextFunction
|
||||
|
||||
export async function getAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(req.params.id));
|
||||
const alerta = await alertasService.getAlertaById(req.tenantPool!, parseInt(String(req.params.id)));
|
||||
if (!alerta) {
|
||||
return res.status(404).json({ message: 'Alerta no encontrada' });
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export async function getAlerta(req: Request, res: Response, next: NextFunction)
|
||||
|
||||
export async function createAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const alerta = await alertasService.createAlerta(req.tenantSchema!, req.body);
|
||||
const alerta = await alertasService.createAlerta(req.tenantPool!, req.body);
|
||||
res.status(201).json(alerta);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -38,7 +38,7 @@ export async function createAlerta(req: Request, res: Response, next: NextFuncti
|
||||
|
||||
export async function updateAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(req.params.id), req.body);
|
||||
const alerta = await alertasService.updateAlerta(req.tenantPool!, parseInt(String(req.params.id)), req.body);
|
||||
res.json(alerta);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -47,7 +47,7 @@ export async function updateAlerta(req: Request, res: Response, next: NextFuncti
|
||||
|
||||
export async function deleteAlerta(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await alertasService.deleteAlerta(req.tenantSchema!, parseInt(req.params.id));
|
||||
await alertasService.deleteAlerta(req.tenantPool!, parseInt(String(req.params.id)));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -56,7 +56,7 @@ export async function deleteAlerta(req: Request, res: Response, next: NextFuncti
|
||||
|
||||
export async function getStats(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const stats = await alertasService.getStats(req.tenantSchema!);
|
||||
const stats = await alertasService.getStats(req.tenantPool!);
|
||||
res.json(stats);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -65,7 +65,7 @@ export async function getStats(req: Request, res: Response, next: NextFunction)
|
||||
|
||||
export async function markAllAsRead(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await alertasService.markAllAsRead(req.tenantSchema!);
|
||||
await alertasService.markAllAsRead(req.tenantPool!);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as calendarioService from '../services/calendario.service.js';
|
||||
|
||||
export async function getEventos(req: Request, res: Response, next: NextFunction) {
|
||||
@@ -7,7 +7,7 @@ export async function getEventos(req: Request, res: Response, next: NextFunction
|
||||
const añoNum = parseInt(año as string) || new Date().getFullYear();
|
||||
const mesNum = mes ? parseInt(mes as string) : undefined;
|
||||
|
||||
const eventos = await calendarioService.getEventos(req.tenantSchema!, añoNum, mesNum);
|
||||
const eventos = await calendarioService.getEventos(req.tenantPool!, añoNum, mesNum);
|
||||
res.json(eventos);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -17,7 +17,7 @@ export async function getEventos(req: Request, res: Response, next: NextFunction
|
||||
export async function getProximos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const dias = parseInt(req.query.dias as string) || 30;
|
||||
const eventos = await calendarioService.getProximosEventos(req.tenantSchema!, dias);
|
||||
const eventos = await calendarioService.getProximosEventos(req.tenantPool!, dias);
|
||||
res.json(eventos);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -26,7 +26,7 @@ export async function getProximos(req: Request, res: Response, next: NextFunctio
|
||||
|
||||
export async function createEvento(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const evento = await calendarioService.createEvento(req.tenantSchema!, req.body);
|
||||
const evento = await calendarioService.createEvento(req.tenantPool!, req.body);
|
||||
res.status(201).json(evento);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -35,7 +35,7 @@ export async function createEvento(req: Request, res: Response, next: NextFuncti
|
||||
|
||||
export async function updateEvento(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(req.params.id), req.body);
|
||||
const evento = await calendarioService.updateEvento(req.tenantPool!, parseInt(String(req.params.id)), req.body);
|
||||
res.json(evento);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -44,7 +44,7 @@ export async function updateEvento(req: Request, res: Response, next: NextFuncti
|
||||
|
||||
export async function deleteEvento(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
await calendarioService.deleteEvento(req.tenantSchema!, parseInt(req.params.id));
|
||||
await calendarioService.deleteEvento(req.tenantPool!, parseInt(String(req.params.id)));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { CfdiFilters } from '@horux/shared';
|
||||
|
||||
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const filters: CfdiFilters = {
|
||||
@@ -15,12 +15,14 @@ export async function getCfdis(req: Request, res: Response, next: NextFunction)
|
||||
fechaInicio: req.query.fechaInicio as string,
|
||||
fechaFin: req.query.fechaFin 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,
|
||||
page: parseInt(req.query.page as string) || 1,
|
||||
limit: parseInt(req.query.limit as string) || 20,
|
||||
};
|
||||
|
||||
const result = await cfdiService.getCfdis(req.tenantSchema, filters);
|
||||
const result = await cfdiService.getCfdis(req.tenantPool, filters);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -29,11 +31,11 @@ export async function getCfdis(req: Request, res: Response, next: NextFunction)
|
||||
|
||||
export async function getCfdiById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const cfdi = await cfdiService.getCfdiById(req.tenantSchema, req.params.id);
|
||||
const cfdi = await cfdiService.getCfdiById(req.tenantPool, String(req.params.id));
|
||||
|
||||
if (!cfdi) {
|
||||
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) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||
|
||||
const resumen = await cfdiService.getResumenCfdis(req.tenantSchema, año, mes);
|
||||
const resumen = await cfdiService.getResumenCfdis(req.tenantPool, año, mes);
|
||||
res.json(resumen);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -63,16 +121,15 @@ export async function getResumen(req: Request, res: Response, next: NextFunction
|
||||
|
||||
export async function createCfdi(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
// Only admin and contador can create CFDIs
|
||||
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
|
||||
}
|
||||
|
||||
const cfdi = await cfdiService.createCfdi(req.tenantSchema, req.body);
|
||||
const cfdi = await cfdiService.createCfdi(req.tenantPool, req.body);
|
||||
res.status(201).json(cfdi);
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes('duplicate')) {
|
||||
@@ -84,8 +141,8 @@ export async function createCfdi(req: Request, res: Response, next: NextFunction
|
||||
|
||||
export async function createManyCfdis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||
@@ -102,9 +159,9 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
|
||||
totalFiles: req.body.totalFiles || req.body.cfdis.length
|
||||
};
|
||||
|
||||
console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs para schema ${req.tenantSchema}`);
|
||||
console.log(`[CFDI Bulk] Lote ${batchInfo.batchNumber}/${batchInfo.totalBatches} - ${req.body.cfdis.length} CFDIs`);
|
||||
|
||||
const result = await cfdiService.createManyCfdisBatch(req.tenantSchema, req.body.cfdis);
|
||||
const result = await cfdiService.createManyCfdisBatch(req.tenantPool, req.body.cfdis);
|
||||
|
||||
res.status(201).json({
|
||||
message: `Lote ${batchInfo.batchNumber} procesado`,
|
||||
@@ -113,7 +170,7 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
|
||||
inserted: result.inserted,
|
||||
duplicates: result.duplicates,
|
||||
errors: result.errors,
|
||||
errorMessages: result.errorMessages.slice(0, 5) // Limit error messages
|
||||
errorMessages: result.errorMessages.slice(0, 5)
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[CFDI Bulk Error]', error.message, error.stack);
|
||||
@@ -123,15 +180,15 @@ export async function createManyCfdis(req: Request, res: Response, next: NextFun
|
||||
|
||||
export async function deleteCfdi(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
if (!['admin', 'contador'].includes(req.user!.role)) {
|
||||
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
|
||||
}
|
||||
|
||||
await cfdiService.deleteCfdi(req.tenantSchema, req.params.id);
|
||||
await cfdiService.deleteCfdi(req.tenantPool, String(req.params.id));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -4,14 +4,14 @@ import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
export async function getKpis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||
|
||||
const kpis = await dashboardService.getKpis(req.tenantSchema, año, mes);
|
||||
const kpis = await dashboardService.getKpis(req.tenantPool, año, mes);
|
||||
res.json(kpis);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -20,13 +20,13 @@ export async function getKpis(req: Request, res: Response, next: NextFunction) {
|
||||
|
||||
export async function getIngresosEgresos(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
|
||||
const data = await dashboardService.getIngresosEgresos(req.tenantSchema, año);
|
||||
const data = await dashboardService.getIngresosEgresos(req.tenantPool, año);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -35,14 +35,14 @@ export async function getIngresosEgresos(req: Request, res: Response, next: Next
|
||||
|
||||
export async function getResumenFiscal(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||
|
||||
const resumen = await dashboardService.getResumenFiscal(req.tenantSchema, año, mes);
|
||||
const resumen = await dashboardService.getResumenFiscal(req.tenantPool, año, mes);
|
||||
res.json(resumen);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -51,13 +51,13 @@ export async function getResumenFiscal(req: Request, res: Response, next: NextFu
|
||||
|
||||
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const limit = parseInt(req.query.limit as string) || 5;
|
||||
|
||||
const alertas = await dashboardService.getAlertas(req.tenantSchema, limit);
|
||||
const alertas = await dashboardService.getAlertas(req.tenantPool, limit);
|
||||
res.json(alertas);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as exportService from '../services/export.service.js';
|
||||
|
||||
export async function exportCfdis(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { tipo, estado, fechaInicio, fechaFin } = req.query;
|
||||
const buffer = await exportService.exportCfdisToExcel(req.tenantSchema!, {
|
||||
const buffer = await exportService.exportCfdisToExcel(req.tenantPool!, {
|
||||
tipo: tipo as string,
|
||||
estado: estado as string,
|
||||
fechaInicio: fechaInicio as string,
|
||||
@@ -27,7 +27,7 @@ export async function exportReporte(req: Request, res: Response, next: NextFunct
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
|
||||
const buffer = await exportService.exportReporteToExcel(
|
||||
req.tenantSchema!,
|
||||
req.tenantPool!,
|
||||
tipo as 'estado-resultados' | 'flujo-efectivo',
|
||||
inicio,
|
||||
fin
|
||||
|
||||
80
apps/api/src/controllers/fiel.controller.ts
Normal file
80
apps/api/src/controllers/fiel.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { uploadFiel, getFielStatus, deleteFiel } from '../services/fiel.service.js';
|
||||
import type { FielUploadRequest } from '@horux/shared';
|
||||
|
||||
/**
|
||||
* Sube y configura las credenciales FIEL
|
||||
*/
|
||||
export async function upload(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.user!.tenantId;
|
||||
|
||||
const { cerFile, keyFile, password } = req.body as FielUploadRequest;
|
||||
|
||||
if (!cerFile || !keyFile || !password) {
|
||||
res.status(400).json({ error: 'cerFile, keyFile y password son requeridos' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file sizes (typical .cer/.key files are under 10KB, base64 ~33% larger)
|
||||
const MAX_FILE_SIZE = 50_000; // 50KB base64 ≈ ~37KB binary
|
||||
if (cerFile.length > MAX_FILE_SIZE || keyFile.length > MAX_FILE_SIZE) {
|
||||
res.status(400).json({ error: 'Los archivos FIEL son demasiado grandes (máx 50KB)' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length > 256) {
|
||||
res.status(400).json({ error: 'Contraseña FIEL demasiado larga' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await uploadFiel(tenantId, cerFile, keyFile, password);
|
||||
|
||||
if (!result.success) {
|
||||
res.status(400).json({ error: result.message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: result.message,
|
||||
status: result.status,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[FIEL Controller] Error en upload:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el estado de la FIEL configurada
|
||||
*/
|
||||
export async function status(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.user!.tenantId;
|
||||
const fielStatus = await getFielStatus(tenantId);
|
||||
res.json(fielStatus);
|
||||
} catch (error: any) {
|
||||
console.error('[FIEL Controller] Error en status:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina las credenciales FIEL
|
||||
*/
|
||||
export async function remove(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.user!.tenantId;
|
||||
const deleted = await deleteFiel(tenantId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: 'No hay FIEL configurada' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ message: 'FIEL eliminada correctamente' });
|
||||
} catch (error: any) {
|
||||
console.error('[FIEL Controller] Error en remove:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import { AppError } from '../middlewares/error.middleware.js';
|
||||
|
||||
export async function getIvaMensual(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const data = await impuestosService.getIvaMensual(req.tenantSchema, año);
|
||||
const data = await impuestosService.getIvaMensual(req.tenantPool, año);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -18,14 +18,14 @@ export async function getIvaMensual(req: Request, res: Response, next: NextFunct
|
||||
|
||||
export async function getResumenIva(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||
|
||||
const resumen = await impuestosService.getResumenIva(req.tenantSchema, año, mes);
|
||||
const resumen = await impuestosService.getResumenIva(req.tenantPool, año, mes);
|
||||
res.json(resumen);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -34,12 +34,12 @@ export async function getResumenIva(req: Request, res: Response, next: NextFunct
|
||||
|
||||
export async function getIsrMensual(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const data = await impuestosService.getIsrMensual(req.tenantSchema, año);
|
||||
const data = await impuestosService.getIsrMensual(req.tenantPool, año);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -48,14 +48,14 @@ export async function getIsrMensual(req: Request, res: Response, next: NextFunct
|
||||
|
||||
export async function getResumenIsr(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
if (!req.tenantPool) {
|
||||
return next(new AppError(400, 'Tenant no configurado'));
|
||||
}
|
||||
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
|
||||
|
||||
const resumen = await impuestosService.getResumenIsr(req.tenantSchema, año, mes);
|
||||
const resumen = await impuestosService.getResumenIsr(req.tenantPool, año, mes);
|
||||
res.json(resumen);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import * as reportesService from '../services/reportes.service.js';
|
||||
|
||||
export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) {
|
||||
@@ -8,8 +8,7 @@ export async function getEstadoResultados(req: Request, res: Response, next: Nex
|
||||
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
|
||||
console.log('[reportes] getEstadoResultados - schema:', req.tenantSchema, 'inicio:', inicio, 'fin:', fin);
|
||||
const data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin);
|
||||
const data = await reportesService.getEstadoResultados(req.tenantPool!, inicio, fin);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('[reportes] Error en getEstadoResultados:', error);
|
||||
@@ -24,7 +23,7 @@ export async function getFlujoEfectivo(req: Request, res: Response, next: NextFu
|
||||
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
|
||||
const data = await reportesService.getFlujoEfectivo(req.tenantSchema!, inicio, fin);
|
||||
const data = await reportesService.getFlujoEfectivo(req.tenantPool!, inicio, fin);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -34,7 +33,7 @@ export async function getFlujoEfectivo(req: Request, res: Response, next: NextFu
|
||||
export async function getComparativo(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const año = parseInt(req.query.año as string) || new Date().getFullYear();
|
||||
const data = await reportesService.getComparativo(req.tenantSchema!, año);
|
||||
const data = await reportesService.getComparativo(req.tenantPool!, año);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -49,7 +48,7 @@ export async function getConcentradoRfc(req: Request, res: Response, next: NextF
|
||||
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
|
||||
const tipoRfc = (tipo as 'cliente' | 'proveedor') || 'cliente';
|
||||
|
||||
const data = await reportesService.getConcentradoRfc(req.tenantSchema!, inicio, fin, tipoRfc);
|
||||
const data = await reportesService.getConcentradoRfc(req.tenantPool!, inicio, fin, tipoRfc);
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
160
apps/api/src/controllers/sat.controller.ts
Normal file
160
apps/api/src/controllers/sat.controller.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import {
|
||||
startSync,
|
||||
getSyncStatus,
|
||||
getSyncHistory,
|
||||
retryJob,
|
||||
} from '../services/sat/sat.service.js';
|
||||
import { getJobInfo, runSatSyncJobManually } from '../jobs/sat-sync.job.js';
|
||||
import type { StartSyncRequest } from '@horux/shared';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
|
||||
/**
|
||||
* Inicia una sincronización manual
|
||||
*/
|
||||
export async function start(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.user!.tenantId;
|
||||
const { type, dateFrom, dateTo } = req.body as StartSyncRequest;
|
||||
|
||||
const jobId = await startSync(
|
||||
tenantId,
|
||||
type || 'daily',
|
||||
dateFrom ? new Date(dateFrom) : undefined,
|
||||
dateTo ? new Date(dateTo) : undefined
|
||||
);
|
||||
|
||||
res.json({
|
||||
jobId,
|
||||
message: 'Sincronización iniciada',
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en start:', error);
|
||||
|
||||
if (error.message.includes('FIEL') || error.message.includes('sincronización en curso')) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el estado actual de sincronización
|
||||
*/
|
||||
export async function status(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.user!.tenantId;
|
||||
const syncStatus = await getSyncStatus(tenantId);
|
||||
res.json(syncStatus);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en status:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el historial de sincronizaciones
|
||||
*/
|
||||
export async function history(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.user!.tenantId;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 10;
|
||||
|
||||
const result = await getSyncHistory(tenantId, page, limit);
|
||||
res.json({
|
||||
...result,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en history:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene detalle de un job específico
|
||||
*/
|
||||
export async function jobDetail(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const tenantId = req.user!.tenantId;
|
||||
const { id } = req.params;
|
||||
const { jobs } = await getSyncHistory(tenantId, 1, 100);
|
||||
const job = jobs.find(j => j.id === id);
|
||||
|
||||
if (!job) {
|
||||
res.status(404).json({ error: 'Job no encontrado' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(job);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en jobDetail:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reintenta un job fallido
|
||||
*/
|
||||
export async function retry(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const newJobId = await retryJob(id);
|
||||
|
||||
res.json({
|
||||
jobId: newJobId,
|
||||
message: 'Job reintentado',
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en retry:', error);
|
||||
|
||||
if (error.message.includes('no encontrado') || error.message.includes('Solo se pueden')) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene información del job programado (solo admin global)
|
||||
*/
|
||||
export async function cronInfo(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
|
||||
res.status(403).json({ error: 'Solo el administrador global puede ver info del cron' });
|
||||
return;
|
||||
}
|
||||
const info = getJobInfo();
|
||||
res.json(info);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en cronInfo:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta el job de sincronización manualmente (solo admin global)
|
||||
*/
|
||||
export async function runCron(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req.user!.tenantId, req.user!.role))) {
|
||||
res.status(403).json({ error: 'Solo el administrador global puede ejecutar el cron' });
|
||||
return;
|
||||
}
|
||||
// Ejecutar en background
|
||||
runSatSyncJobManually().catch(err =>
|
||||
console.error('[SAT Controller] Error ejecutando cron manual:', err)
|
||||
);
|
||||
|
||||
res.json({ message: 'Job de sincronización iniciado' });
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Controller] Error en runCron:', error);
|
||||
res.status(500).json({ error: 'Error interno del servidor' });
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
const tenant = await tenantsService.getTenantById(req.params.id);
|
||||
const tenant = await tenantsService.getTenantById(String(req.params.id));
|
||||
if (!tenant) {
|
||||
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');
|
||||
}
|
||||
|
||||
const { nombre, rfc, plan, cfdiLimit, usersLimit } = req.body;
|
||||
const { nombre, rfc, plan, cfdiLimit, usersLimit, adminEmail, adminNombre, amount } = req.body;
|
||||
|
||||
if (!nombre || !rfc) {
|
||||
throw new AppError(400, 'Nombre y RFC son requeridos');
|
||||
if (!nombre || !rfc || !adminEmail || !adminNombre) {
|
||||
throw new AppError(400, 'Nombre, RFC, adminEmail y adminNombre son requeridos');
|
||||
}
|
||||
|
||||
const tenant = await tenantsService.createTenant({
|
||||
const result = await tenantsService.createTenant({
|
||||
nombre,
|
||||
rfc,
|
||||
plan,
|
||||
cfdiLimit,
|
||||
usersLimit,
|
||||
adminEmail,
|
||||
adminNombre,
|
||||
amount: amount || 0,
|
||||
});
|
||||
|
||||
res.status(201).json(tenant);
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -65,7 +68,7 @@ export async function updateTenant(req: Request, res: Response, next: NextFuncti
|
||||
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 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');
|
||||
}
|
||||
|
||||
await tenantsService.deleteTenant(req.params.id);
|
||||
await tenantsService.deleteTenant(String(req.params.id));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import * as usuariosService from '../services/usuarios.service.js';
|
||||
import { AppError } from '../utils/errors.js';
|
||||
import { isGlobalAdmin as checkGlobalAdmin } from '../utils/global-admin.js';
|
||||
|
||||
async function isGlobalAdmin(req: Request): Promise<boolean> {
|
||||
return checkGlobalAdmin(req.user!.tenantId, req.user!.role);
|
||||
}
|
||||
|
||||
export async function getUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
@@ -11,6 +16,21 @@ export async function getUsuarios(req: Request, res: Response, next: NextFunctio
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todos los usuarios de todas las empresas (solo admin global)
|
||||
*/
|
||||
export async function getAllUsuarios(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede ver todos los usuarios');
|
||||
}
|
||||
const usuarios = await usuariosService.getAllUsuarios();
|
||||
res.json(usuarios);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function inviteUsuario(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (req.user!.role !== 'admin') {
|
||||
@@ -28,7 +48,8 @@ export async function updateUsuario(req: Request, res: Response, next: NextFunct
|
||||
if (req.user!.role !== 'admin') {
|
||||
throw new AppError(403, 'Solo administradores pueden modificar usuarios');
|
||||
}
|
||||
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, req.params.id, req.body);
|
||||
const userId = req.params.id as string;
|
||||
const usuario = await usuariosService.updateUsuario(req.user!.tenantId, userId, req.body);
|
||||
res.json(usuario);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -40,10 +61,49 @@ export async function deleteUsuario(req: Request, res: Response, next: NextFunct
|
||||
if (req.user!.role !== 'admin') {
|
||||
throw new AppError(403, 'Solo administradores pueden eliminar usuarios');
|
||||
}
|
||||
if (req.params.id === req.user!.id) {
|
||||
const userId = req.params.id as string;
|
||||
if (userId === req.user!.userId) {
|
||||
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
|
||||
}
|
||||
await usuariosService.deleteUsuario(req.user!.tenantId, req.params.id);
|
||||
await usuariosService.deleteUsuario(req.user!.tenantId, userId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un usuario globalmente (puede cambiar de empresa)
|
||||
*/
|
||||
export async function updateUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede modificar usuarios globalmente');
|
||||
}
|
||||
const userId = req.params.id as string;
|
||||
if (userId === req.user!.userId && req.body.tenantId) {
|
||||
throw new AppError(400, 'No puedes cambiar tu propia empresa');
|
||||
}
|
||||
const usuario = await usuariosService.updateUsuarioGlobal(userId, req.body);
|
||||
res.json(usuario);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un usuario globalmente
|
||||
*/
|
||||
export async function deleteUsuarioGlobal(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!(await isGlobalAdmin(req))) {
|
||||
throw new AppError(403, 'Solo el administrador global puede eliminar usuarios globalmente');
|
||||
}
|
||||
const userId = req.params.id as string;
|
||||
if (userId === req.user!.userId) {
|
||||
throw new AppError(400, 'No puedes eliminar tu propia cuenta');
|
||||
}
|
||||
await usuariosService.deleteUsuarioGlobal(userId);
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
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,9 +1,38 @@
|
||||
import { app } from './app.js';
|
||||
import { env } from './config/env.js';
|
||||
import { tenantDb } from './config/database.js';
|
||||
import { invalidateTenantCache } from './middlewares/plan-limits.middleware.js';
|
||||
import { startSatSyncJob } from './jobs/sat-sync.job.js';
|
||||
|
||||
const PORT = parseInt(env.PORT, 10);
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 API Server running on http://0.0.0.0:${PORT}`);
|
||||
console.log(`📊 Environment: ${env.NODE_ENV}`);
|
||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`API Server running on http://0.0.0.0:${PORT}`);
|
||||
console.log(`Environment: ${env.NODE_ENV}`);
|
||||
|
||||
// Iniciar job de sincronización SAT
|
||||
if (env.NODE_ENV === 'production') {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
162
apps/api/src/jobs/sat-sync.job.ts
Normal file
162
apps/api/src/jobs/sat-sync.job.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import cron from 'node-cron';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { startSync, getSyncStatus } from '../services/sat/sat.service.js';
|
||||
import { hasFielConfigured } from '../services/fiel.service.js';
|
||||
|
||||
const SYNC_CRON_SCHEDULE = '0 3 * * *'; // 3:00 AM todos los días
|
||||
const CONCURRENT_SYNCS = 3; // Máximo de sincronizaciones simultáneas
|
||||
|
||||
let isRunning = false;
|
||||
|
||||
/**
|
||||
* Obtiene los tenants que tienen FIEL configurada y activa
|
||||
*/
|
||||
async function getTenantsWithFiel(): Promise<string[]> {
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { active: true },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const tenantsWithFiel: string[] = [];
|
||||
|
||||
for (const tenant of tenants) {
|
||||
const hasFiel = await hasFielConfigured(tenant.id);
|
||||
if (hasFiel) {
|
||||
tenantsWithFiel.push(tenant.id);
|
||||
}
|
||||
}
|
||||
|
||||
return tenantsWithFiel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un tenant necesita sincronización inicial
|
||||
*/
|
||||
async function needsInitialSync(tenantId: string): Promise<boolean> {
|
||||
const completedSync = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
type: 'initial',
|
||||
status: 'completed',
|
||||
},
|
||||
});
|
||||
|
||||
return !completedSync;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta sincronización para un tenant
|
||||
*/
|
||||
async function syncTenant(tenantId: string): Promise<void> {
|
||||
try {
|
||||
// Verificar si hay sync activo
|
||||
const status = await getSyncStatus(tenantId);
|
||||
if (status.hasActiveSync) {
|
||||
console.log(`[SAT Cron] Tenant ${tenantId} ya tiene sync activo, omitiendo`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determinar tipo de sync
|
||||
const needsInitial = await needsInitialSync(tenantId);
|
||||
const syncType = needsInitial ? 'initial' : 'daily';
|
||||
|
||||
console.log(`[SAT Cron] Iniciando sync ${syncType} para tenant ${tenantId}`);
|
||||
const jobId = await startSync(tenantId, syncType);
|
||||
console.log(`[SAT Cron] Job ${jobId} iniciado para tenant ${tenantId}`);
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT Cron] Error sincronizando tenant ${tenantId}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta el job de sincronización para todos los tenants
|
||||
*/
|
||||
async function runSyncJob(): Promise<void> {
|
||||
if (isRunning) {
|
||||
console.log('[SAT Cron] Job ya en ejecución, omitiendo');
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
console.log('[SAT Cron] Iniciando job de sincronización diaria');
|
||||
|
||||
try {
|
||||
const tenantIds = await getTenantsWithFiel();
|
||||
console.log(`[SAT Cron] ${tenantIds.length} tenants con FIEL configurada`);
|
||||
|
||||
if (tenantIds.length === 0) {
|
||||
console.log('[SAT Cron] No hay tenants para sincronizar');
|
||||
return;
|
||||
}
|
||||
|
||||
// Procesar en lotes para no saturar
|
||||
for (let i = 0; i < tenantIds.length; i += CONCURRENT_SYNCS) {
|
||||
const batch = tenantIds.slice(i, i + CONCURRENT_SYNCS);
|
||||
await Promise.all(batch.map(syncTenant));
|
||||
|
||||
// Pequeña pausa entre lotes
|
||||
if (i + CONCURRENT_SYNCS < tenantIds.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SAT Cron] Job de sincronización completado');
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Cron] Error en job:', error.message);
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
let scheduledTask: ReturnType<typeof cron.schedule> | null = null;
|
||||
|
||||
/**
|
||||
* Inicia el job programado
|
||||
*/
|
||||
export function startSatSyncJob(): void {
|
||||
if (scheduledTask) {
|
||||
console.log('[SAT Cron] Job ya está programado');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar expresión cron
|
||||
if (!cron.validate(SYNC_CRON_SCHEDULE)) {
|
||||
console.error('[SAT Cron] Expresión cron inválida:', SYNC_CRON_SCHEDULE);
|
||||
return;
|
||||
}
|
||||
|
||||
scheduledTask = cron.schedule(SYNC_CRON_SCHEDULE, runSyncJob, {
|
||||
timezone: 'America/Mexico_City',
|
||||
});
|
||||
|
||||
console.log(`[SAT Cron] Job programado para: ${SYNC_CRON_SCHEDULE} (America/Mexico_City)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detiene el job programado
|
||||
*/
|
||||
export function stopSatSyncJob(): void {
|
||||
if (scheduledTask) {
|
||||
scheduledTask.stop();
|
||||
scheduledTask = null;
|
||||
console.log('[SAT Cron] Job detenido');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta el job manualmente (para testing o ejecución forzada)
|
||||
*/
|
||||
export async function runSatSyncJobManually(): Promise<void> {
|
||||
await runSyncJob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene información del próximo job programado
|
||||
*/
|
||||
export function getJobInfo(): { scheduled: boolean; expression: string; timezone: string } {
|
||||
return {
|
||||
scheduled: scheduledTask !== null,
|
||||
expression: SYNC_CRON_SCHEDULE,
|
||||
timezone: 'America/Mexico_City',
|
||||
};
|
||||
}
|
||||
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 { prisma } from '../config/database.js';
|
||||
import { AppError } from './error.middleware.js';
|
||||
import type { Pool } from 'pg';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { isGlobalAdmin } from '../utils/global-admin.js';
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
tenantSchema?: string;
|
||||
tenantPool?: Pool;
|
||||
viewingTenantId?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.user) {
|
||||
return next(new AppError(401, 'No autenticado'));
|
||||
}
|
||||
// Cache: tenantId -> { databaseName, expires }
|
||||
const tenantDbCache = new Map<string, { databaseName: string; expires: number }>();
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
try {
|
||||
// Check if admin is viewing a different tenant
|
||||
const viewTenantId = req.headers['x-view-tenant'] as string | undefined;
|
||||
let tenantId = req.user.tenantId;
|
||||
|
||||
// Only admins can view other tenants
|
||||
if (viewTenantId && req.user.role === 'admin') {
|
||||
tenantId = viewTenantId;
|
||||
req.viewingTenantId = viewTenantId;
|
||||
}
|
||||
async function getTenantDatabaseName(tenantId: string): Promise<string | null> {
|
||||
const cached = tenantDbCache.get(tenantId);
|
||||
if (cached && cached.expires > Date.now()) return cached.databaseName;
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { schemaName: true, active: true },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
|
||||
if (!tenant || !tenant.active) {
|
||||
return next(new AppError(403, 'Tenant no encontrado o inactivo'));
|
||||
if (tenant) {
|
||||
tenantDbCache.set(tenantId, { databaseName: tenant.databaseName, expires: Date.now() + CACHE_TTL });
|
||||
}
|
||||
|
||||
req.tenantSchema = tenant.schemaName;
|
||||
return tenant?.databaseName ?? null;
|
||||
}
|
||||
|
||||
// Set search_path for this request
|
||||
await prisma.$executeRawUnsafe(`SET search_path TO "${tenant.schemaName}", public`);
|
||||
export function invalidateTenantDbCache(tenantId: string) {
|
||||
tenantDbCache.delete(tenantId);
|
||||
}
|
||||
|
||||
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ message: 'No autenticado' });
|
||||
}
|
||||
|
||||
let tenantId = req.user.tenantId;
|
||||
|
||||
// Admin impersonation via X-View-Tenant header (global admin only)
|
||||
const viewTenantHeader = req.headers['x-view-tenant'] as string;
|
||||
if (viewTenantHeader) {
|
||||
const globalAdmin = await isGlobalAdmin(req.user.tenantId, req.user.role);
|
||||
if (!globalAdmin) {
|
||||
return res.status(403).json({ message: 'No autorizado para ver otros tenants' });
|
||||
}
|
||||
|
||||
const viewedTenant = await prisma.tenant.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: viewTenantHeader },
|
||||
{ rfc: viewTenantHeader },
|
||||
],
|
||||
},
|
||||
select: { id: true, databaseName: true, active: true },
|
||||
});
|
||||
|
||||
if (!viewedTenant) {
|
||||
return res.status(404).json({ message: 'Tenant no encontrado' });
|
||||
}
|
||||
|
||||
if (!viewedTenant.active) {
|
||||
return res.status(403).json({ message: 'Tenant inactivo' });
|
||||
}
|
||||
|
||||
tenantId = viewedTenant.id;
|
||||
req.viewingTenantId = viewedTenant.id;
|
||||
req.tenantPool = tenantDb.getPool(tenantId, viewedTenant.databaseName);
|
||||
return next();
|
||||
}
|
||||
|
||||
// Normal flow: look up databaseName server-side (not from JWT)
|
||||
const databaseName = await getTenantDatabaseName(tenantId);
|
||||
if (!databaseName) {
|
||||
return res.status(404).json({ message: 'Tenant no encontrado' });
|
||||
}
|
||||
|
||||
req.tenantPool = tenantDb.getPool(tenantId, databaseName);
|
||||
next();
|
||||
} catch (error) {
|
||||
next(new AppError(500, 'Error al configurar tenant'));
|
||||
console.error('[TenantMiddleware] Error:', error);
|
||||
return res.status(500).json({ message: 'Error al resolver tenant' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
|
||||
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
|
||||
import * as alertasController from '../controllers/alertas.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
router.use(checkPlanLimits);
|
||||
router.use(requireFeature('alertas'));
|
||||
|
||||
router.get('/', alertasController.getAlertas);
|
||||
router.get('/stats', alertasController.getStats);
|
||||
|
||||
@@ -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 { authenticate } from '../middlewares/auth.middleware.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.post('/register', authController.register);
|
||||
router.post('/login', authController.login);
|
||||
router.post('/refresh', authController.refresh);
|
||||
router.post('/logout', authController.logout);
|
||||
// Rate limiting: 10 login attempts per 15 minutes per IP
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
message: { message: 'Demasiados intentos de login. Intenta de nuevo en 15 minutos.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Rate limiting: 3 registrations per hour per IP
|
||||
const registerLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 3,
|
||||
message: { message: 'Demasiados registros. Intenta de nuevo en 1 hora.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Rate limiting: 20 refresh attempts per 15 minutes per IP
|
||||
const refreshLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 20,
|
||||
message: { message: 'Demasiadas solicitudes. Intenta de nuevo más tarde.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
router.post('/register', registerLimiter, authController.register);
|
||||
router.post('/login', loginLimiter, authController.login);
|
||||
router.post('/refresh', refreshLimiter, authController.refresh);
|
||||
router.post('/logout', authenticate, authController.logout);
|
||||
router.get('/me', authenticate, authController.me);
|
||||
|
||||
export { router as authRoutes };
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
|
||||
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
|
||||
import * as calendarioController from '../controllers/calendario.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
router.use(checkPlanLimits);
|
||||
router.use(requireFeature('calendario'));
|
||||
|
||||
router.get('/', calendarioController.getEventos);
|
||||
router.get('/proximos', calendarioController.getProximos);
|
||||
|
||||
@@ -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 { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import { checkPlanLimits, checkCfdiLimit } from '../middlewares/plan-limits.middleware.js';
|
||||
import * as cfdiController from '../controllers/cfdi.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
router.use(checkPlanLimits);
|
||||
|
||||
router.get('/', cfdiController.getCfdis);
|
||||
router.get('/resumen', cfdiController.getResumen);
|
||||
router.get('/emisores', cfdiController.getEmisores);
|
||||
router.get('/receptores', cfdiController.getReceptores);
|
||||
router.get('/:id', cfdiController.getCfdiById);
|
||||
router.post('/', cfdiController.createCfdi);
|
||||
router.post('/bulk', cfdiController.createManyCfdis);
|
||||
router.get('/:id/xml', cfdiController.getXml);
|
||||
router.post('/', checkCfdiLimit, cfdiController.createCfdi);
|
||||
router.post('/bulk', express.json({ limit: '50mb' }), checkCfdiLimit, cfdiController.createManyCfdis);
|
||||
router.delete('/:id', cfdiController.deleteCfdi);
|
||||
|
||||
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 { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
|
||||
import * as dashboardController from '../controllers/dashboard.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
router.use(checkPlanLimits);
|
||||
|
||||
router.get('/kpis', dashboardController.getKpis);
|
||||
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 { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as exportController from '../controllers/export.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
|
||||
19
apps/api/src/routes/fiel.routes.ts
Normal file
19
apps/api/src/routes/fiel.routes.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import * as fielController from '../controllers/fiel.controller.js';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Todas las rutas requieren autenticación
|
||||
router.use(authenticate);
|
||||
|
||||
// POST /api/fiel/upload - Subir credenciales FIEL
|
||||
router.post('/upload', fielController.upload);
|
||||
|
||||
// GET /api/fiel/status - Obtener estado de la FIEL
|
||||
router.get('/status', fielController.status);
|
||||
|
||||
// DELETE /api/fiel - Eliminar credenciales FIEL
|
||||
router.delete('/', fielController.remove);
|
||||
|
||||
export default router;
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import * as impuestosController from '../controllers/impuestos.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
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 { tenantMiddleware } from '../middlewares/tenant.middleware.js';
|
||||
import { checkPlanLimits } from '../middlewares/plan-limits.middleware.js';
|
||||
import { requireFeature } from '../middlewares/feature-gate.middleware.js';
|
||||
import * as reportesController from '../controllers/reportes.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.use(tenantMiddleware);
|
||||
router.use(checkPlanLimits);
|
||||
router.use(requireFeature('reportes'));
|
||||
|
||||
router.get('/estado-resultados', reportesController.getEstadoResultados);
|
||||
router.get('/flujo-efectivo', reportesController.getFlujoEfectivo);
|
||||
|
||||
29
apps/api/src/routes/sat.routes.ts
Normal file
29
apps/api/src/routes/sat.routes.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Router, type IRouter } from 'express';
|
||||
import * as satController from '../controllers/sat.controller.js';
|
||||
import { authenticate, authorize } from '../middlewares/auth.middleware.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Todas las rutas requieren autenticación
|
||||
router.use(authenticate);
|
||||
|
||||
// POST /api/sat/sync - Iniciar sincronización manual
|
||||
router.post('/sync', satController.start);
|
||||
|
||||
// GET /api/sat/sync/status - Estado actual de sincronización
|
||||
router.get('/sync/status', satController.status);
|
||||
|
||||
// GET /api/sat/sync/history - Historial de sincronizaciones
|
||||
router.get('/sync/history', satController.history);
|
||||
|
||||
// GET /api/sat/sync/:id - Detalle de un job
|
||||
router.get('/sync/:id', satController.jobDetail);
|
||||
|
||||
// POST /api/sat/sync/:id/retry - Reintentar job fallido
|
||||
router.post('/sync/:id/retry', satController.retry);
|
||||
|
||||
// Admin-only cron endpoints (global admin verified in controller)
|
||||
router.get('/cron', authorize('admin'), satController.cronInfo);
|
||||
router.post('/cron/run', authorize('admin'), satController.runCron);
|
||||
|
||||
export default router;
|
||||
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 * as tenantsController from '../controllers/tenants.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, type IRouter } from 'express';
|
||||
import { authenticate } from '../middlewares/auth.middleware.js';
|
||||
import * as usuariosController from '../controllers/usuarios.controller.js';
|
||||
|
||||
const router = Router();
|
||||
const router: IRouter = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
// Rutas por tenant
|
||||
router.get('/', usuariosController.getUsuarios);
|
||||
router.post('/invite', usuariosController.inviteUsuario);
|
||||
router.patch('/:id', usuariosController.updateUsuario);
|
||||
router.delete('/:id', usuariosController.deleteUsuario);
|
||||
|
||||
// Rutas globales (solo admin global)
|
||||
router.get('/global/all', usuariosController.getAllUsuarios);
|
||||
router.patch('/global/:id', usuariosController.updateUsuarioGlobal);
|
||||
router.delete('/global/:id', usuariosController.deleteUsuarioGlobal);
|
||||
|
||||
export { router as usuariosRoutes };
|
||||
|
||||
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';
|
||||
|
||||
export async function getAlertas(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
filters: { leida?: boolean; resuelta?: boolean; prioridad?: string }
|
||||
): Promise<AlertaFull[]> {
|
||||
let whereClause = 'WHERE 1=1';
|
||||
@@ -22,43 +22,43 @@ export async function getAlertas(
|
||||
params.push(filters.prioridad);
|
||||
}
|
||||
|
||||
const alertas = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
FROM "${schema}".alertas
|
||||
FROM alertas
|
||||
${whereClause}
|
||||
ORDER BY
|
||||
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
||||
created_at DESC
|
||||
`, ...params);
|
||||
`, params);
|
||||
|
||||
return alertas;
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getAlertaById(schema: string, id: number): Promise<AlertaFull | null> {
|
||||
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||
export async function getAlertaById(pool: Pool, id: number): Promise<AlertaFull | null> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
FROM "${schema}".alertas
|
||||
FROM alertas
|
||||
WHERE id = $1
|
||||
`, id);
|
||||
return alerta || null;
|
||||
`, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function createAlerta(schema: string, data: AlertaCreate): Promise<AlertaFull> {
|
||||
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||
INSERT INTO "${schema}".alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||
export async function createAlerta(pool: Pool, data: AlertaCreate): Promise<AlertaFull> {
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
`, data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null);
|
||||
return alerta;
|
||||
`, [data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null]);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function updateAlerta(schema: string, id: number, data: AlertaUpdate): Promise<AlertaFull> {
|
||||
export async function updateAlerta(pool: Pool, id: number, data: AlertaUpdate): Promise<AlertaFull> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
@@ -74,35 +74,35 @@ export async function updateAlerta(schema: string, id: number, data: AlertaUpdat
|
||||
|
||||
params.push(id);
|
||||
|
||||
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
|
||||
UPDATE "${schema}".alertas
|
||||
const { rows } = await pool.query(`
|
||||
UPDATE alertas
|
||||
SET ${sets.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta, created_at as "createdAt"
|
||||
`, ...params);
|
||||
`, params);
|
||||
|
||||
return alerta;
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function deleteAlerta(schema: string, id: number): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".alertas WHERE id = $1`, id);
|
||||
export async function deleteAlerta(pool: Pool, id: number): Promise<void> {
|
||||
await pool.query(`DELETE FROM alertas WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
export async function getStats(schema: string): Promise<AlertasStats> {
|
||||
const [stats] = await prisma.$queryRawUnsafe<AlertasStats[]>(`
|
||||
export async function getStats(pool: Pool): Promise<AlertasStats> {
|
||||
const { rows: [stats] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*)::int as total,
|
||||
COUNT(CASE WHEN leida = false THEN 1 END)::int as "noLeidas",
|
||||
COUNT(CASE WHEN prioridad = 'alta' AND resuelta = false THEN 1 END)::int as alta,
|
||||
COUNT(CASE WHEN prioridad = 'media' AND resuelta = false THEN 1 END)::int as media,
|
||||
COUNT(CASE WHEN prioridad = 'baja' AND resuelta = false THEN 1 END)::int as baja
|
||||
FROM "${schema}".alertas
|
||||
FROM alertas
|
||||
`);
|
||||
return stats;
|
||||
}
|
||||
|
||||
export async function markAllAsRead(schema: string): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`UPDATE "${schema}".alertas SET leida = true WHERE leida = false`);
|
||||
export async function markAllAsRead(pool: Pool): Promise<void> {
|
||||
await pool.query(`UPDATE alertas SET leida = true WHERE leida = false`);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { hashPassword, verifyPassword } from '../utils/password.js';
|
||||
import { generateAccessToken, generateRefreshToken, verifyToken } from '../utils/token.js';
|
||||
import { createTenantSchema } from '../utils/schema-manager.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { PLANS } from '@horux/shared';
|
||||
import type { LoginRequest, RegisterRequest, LoginResponse } from '@horux/shared';
|
||||
|
||||
export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: data.usuario.email },
|
||||
where: { email: data.usuario.email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
@@ -23,21 +22,20 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
throw new AppError(400, 'El RFC ya está registrado');
|
||||
}
|
||||
|
||||
const schemaName = `tenant_${data.empresa.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
// Provision a dedicated database for this tenant
|
||||
const databaseName = await tenantDb.provisionDatabase(data.empresa.rfc);
|
||||
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
nombre: data.empresa.nombre,
|
||||
rfc: data.empresa.rfc.toUpperCase(),
|
||||
plan: 'starter',
|
||||
schemaName,
|
||||
databaseName,
|
||||
cfdiLimit: PLANS.starter.cfdiLimit,
|
||||
usersLimit: PLANS.starter.usersLimit,
|
||||
},
|
||||
});
|
||||
|
||||
await createTenantSchema(schemaName);
|
||||
|
||||
const passwordHash = await hashPassword(data.usuario.password);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
@@ -54,7 +52,6 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: tenant.id,
|
||||
schemaName: tenant.schemaName,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
@@ -79,6 +76,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.nombre,
|
||||
tenantRfc: tenant.rfc,
|
||||
plan: tenant.plan,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -117,7 +115,6 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
schemaName: user.tenant.schemaName,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
@@ -142,12 +139,15 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||
tenantId: user.tenantId,
|
||||
tenantName: user.tenant.nombre,
|
||||
tenantRfc: user.tenant.rfc,
|
||||
plan: user.tenant.plan,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -156,13 +156,13 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const payload = verifyToken(token);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
const user = await tx.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
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');
|
||||
}
|
||||
|
||||
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 = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
tenantId: user.tenantId,
|
||||
schemaName: user.tenant.schemaName,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(newTokenPayload);
|
||||
const refreshToken = generateRefreshToken(newTokenPayload);
|
||||
|
||||
await prisma.refreshToken.create({
|
||||
await tx.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: refreshToken,
|
||||
@@ -193,6 +193,7 @@ export async function refreshTokens(token: string): Promise<{ accessToken: strin
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
});
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
export async function getEventos(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
año: number,
|
||||
mes?: number
|
||||
): Promise<EventoFiscal[]> {
|
||||
@@ -14,49 +14,49 @@ export async function getEventos(
|
||||
params.push(mes);
|
||||
}
|
||||
|
||||
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, titulo, descripcion, tipo,
|
||||
fecha_limite as "fechaLimite",
|
||||
recurrencia, completado, notas,
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".calendario_fiscal
|
||||
FROM calendario_fiscal
|
||||
${whereClause}
|
||||
ORDER BY fecha_limite ASC
|
||||
`, ...params);
|
||||
`, params);
|
||||
|
||||
return eventos;
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getProximosEventos(schema: string, dias = 30): Promise<EventoFiscal[]> {
|
||||
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||
export async function getProximosEventos(pool: Pool, dias = 30): Promise<EventoFiscal[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, titulo, descripcion, tipo,
|
||||
fecha_limite as "fechaLimite",
|
||||
recurrencia, completado, notas,
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".calendario_fiscal
|
||||
FROM calendario_fiscal
|
||||
WHERE completado = false
|
||||
AND fecha_limite BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '${dias} days'
|
||||
AND fecha_limite BETWEEN CURRENT_DATE AND CURRENT_DATE + $1 * INTERVAL '1 day'
|
||||
ORDER BY fecha_limite ASC
|
||||
`);
|
||||
`, [dias]);
|
||||
|
||||
return eventos;
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function createEvento(schema: string, data: EventoCreate): Promise<EventoFiscal> {
|
||||
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||
INSERT INTO "${schema}".calendario_fiscal
|
||||
export async function createEvento(pool: Pool, data: EventoCreate): Promise<EventoFiscal> {
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO calendario_fiscal
|
||||
(titulo, descripcion, tipo, fecha_limite, recurrencia, notas)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, titulo, descripcion, tipo,
|
||||
fecha_limite as "fechaLimite",
|
||||
recurrencia, completado, notas,
|
||||
created_at as "createdAt"
|
||||
`, data.titulo, data.descripcion, data.tipo, data.fechaLimite, data.recurrencia, data.notas || null);
|
||||
`, [data.titulo, data.descripcion, data.tipo, data.fechaLimite, data.recurrencia, data.notas || null]);
|
||||
|
||||
return evento;
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function updateEvento(schema: string, id: number, data: EventoUpdate): Promise<EventoFiscal> {
|
||||
export async function updateEvento(pool: Pool, id: number, data: EventoUpdate): Promise<EventoFiscal> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
@@ -84,19 +84,19 @@ export async function updateEvento(schema: string, id: number, data: EventoUpdat
|
||||
|
||||
params.push(id);
|
||||
|
||||
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
|
||||
UPDATE "${schema}".calendario_fiscal
|
||||
const { rows } = await pool.query(`
|
||||
UPDATE calendario_fiscal
|
||||
SET ${sets.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING id, titulo, descripcion, tipo,
|
||||
fecha_limite as "fechaLimite",
|
||||
recurrencia, completado, notas,
|
||||
created_at as "createdAt"
|
||||
`, ...params);
|
||||
`, params);
|
||||
|
||||
return evento;
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function deleteEvento(schema: string, id: number): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".calendario_fiscal WHERE id = $1`, id);
|
||||
export async function deleteEvento(pool: Pool, id: number): Promise<void> {
|
||||
await pool.query(`DELETE FROM calendario_fiscal WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared';
|
||||
|
||||
export async function getCfdis(schema: string, filters: CfdiFilters): Promise<CfdiListResponse> {
|
||||
export async function getCfdis(pool: Pool, filters: CfdiFilters): Promise<CfdiListResponse> {
|
||||
const page = filters.page || 1;
|
||||
const limit = filters.limit || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
@@ -21,12 +21,12 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
||||
}
|
||||
|
||||
if (filters.fechaInicio) {
|
||||
whereClause += ` AND fecha_emision >= $${paramIndex++}`;
|
||||
whereClause += ` AND fecha_emision >= $${paramIndex++}::date`;
|
||||
params.push(filters.fechaInicio);
|
||||
}
|
||||
|
||||
if (filters.fechaFin) {
|
||||
whereClause += ` AND fecha_emision <= $${paramIndex++}`;
|
||||
whereClause += ` AND fecha_emision <= ($${paramIndex++}::date + interval '1 day')`;
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
|
||||
@@ -35,19 +35,23 @@ export async function getCfdis(schema: string, filters: CfdiFilters): Promise<Cf
|
||||
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) {
|
||||
whereClause += ` AND (uuid_fiscal ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
|
||||
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);
|
||||
const data = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
||||
const { rows: dataWithCount } = await pool.query(`
|
||||
SELECT
|
||||
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||
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",
|
||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".cfdis
|
||||
created_at as "createdAt",
|
||||
COUNT(*) OVER() as total_count
|
||||
FROM cfdis
|
||||
${whereClause}
|
||||
ORDER BY fecha_emision DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`, ...params);
|
||||
`, params);
|
||||
|
||||
const total = Number(dataWithCount[0]?.total_count || 0);
|
||||
const data = dataWithCount.map(({ total_count, ...cfdi }: any) => cfdi) as Cfdi[];
|
||||
|
||||
return {
|
||||
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> {
|
||||
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
||||
export async function getCfdiById(pool: Pool, id: string): Promise<Cfdi | null> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
||||
@@ -86,12 +94,21 @@ export async function getCfdiById(schema: string, id: string): Promise<Cfdi | nu
|
||||
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);
|
||||
FROM cfdis
|
||||
WHERE id = $1::uuid
|
||||
`, [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 {
|
||||
@@ -121,18 +138,15 @@ export interface CreateCfdiData {
|
||||
pdfUrl?: string;
|
||||
}
|
||||
|
||||
export async function createCfdi(schema: string, data: CreateCfdiData): Promise<Cfdi> {
|
||||
// Validate required fields
|
||||
export async function createCfdi(pool: Pool, data: CreateCfdiData): Promise<Cfdi> {
|
||||
if (!data.uuidFiscal) throw new Error('UUID Fiscal es requerido');
|
||||
if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida');
|
||||
if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido');
|
||||
if (!data.rfcReceptor) throw new Error('RFC Receptor es requerido');
|
||||
|
||||
// Parse dates safely - handle YYYY-MM-DD format explicitly
|
||||
let fechaEmision: Date;
|
||||
let fechaTimbrado: Date;
|
||||
|
||||
// If date is in YYYY-MM-DD format, add time to avoid timezone issues
|
||||
const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||
? `${data.fechaEmision}T12:00:00`
|
||||
: data.fechaEmision;
|
||||
@@ -155,8 +169,8 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
|
||||
throw new Error(`Fecha de timbrado inválida: ${data.fechaTimbrado}`);
|
||||
}
|
||||
|
||||
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
||||
INSERT INTO "${schema}".cfdis (
|
||||
const { rows } = await pool.query(`
|
||||
INSERT INTO cfdis (
|
||||
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||
@@ -173,7 +187,7 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
|
||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||
created_at as "createdAt"
|
||||
`,
|
||||
`, [
|
||||
data.uuidFiscal,
|
||||
data.tipo || 'ingreso',
|
||||
data.serie || null,
|
||||
@@ -198,9 +212,9 @@ export async function createCfdi(schema: string, data: CreateCfdiData): Promise<
|
||||
data.estado || 'vigente',
|
||||
data.xmlUrl || null,
|
||||
data.pdfUrl || null
|
||||
);
|
||||
]);
|
||||
|
||||
return result[0];
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export interface BatchInsertResult {
|
||||
@@ -210,14 +224,12 @@ export interface BatchInsertResult {
|
||||
errorMessages: string[];
|
||||
}
|
||||
|
||||
// Optimized batch insert using multi-row INSERT
|
||||
export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise<number> {
|
||||
const result = await createManyCfdisBatch(schema, cfdis);
|
||||
export async function createManyCfdis(pool: Pool, cfdis: CreateCfdiData[]): Promise<number> {
|
||||
const result = await createManyCfdisBatch(pool, cfdis);
|
||||
return result.inserted;
|
||||
}
|
||||
|
||||
// New optimized batch insert with detailed results
|
||||
export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
||||
export async function createManyCfdisBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
||||
const result: BatchInsertResult = {
|
||||
inserted: 0,
|
||||
duplicates: 0,
|
||||
@@ -227,19 +239,17 @@ export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData
|
||||
|
||||
if (cfdis.length === 0) return result;
|
||||
|
||||
// Process in batches of 500 for optimal performance
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
for (let batchStart = 0; batchStart < cfdis.length; batchStart += BATCH_SIZE) {
|
||||
const batch = cfdis.slice(batchStart, batchStart + BATCH_SIZE);
|
||||
|
||||
try {
|
||||
const batchResult = await insertBatch(schema, batch);
|
||||
const batchResult = await insertBatch(pool, batch);
|
||||
result.inserted += batchResult.inserted;
|
||||
result.duplicates += batchResult.duplicates;
|
||||
} catch (error: any) {
|
||||
// If batch fails, try individual inserts for this batch
|
||||
const individualResult = await insertIndividually(schema, batch);
|
||||
const individualResult = await insertIndividually(pool, batch);
|
||||
result.inserted += individualResult.inserted;
|
||||
result.duplicates += individualResult.duplicates;
|
||||
result.errors += individualResult.errors;
|
||||
@@ -250,17 +260,14 @@ export async function createManyCfdisBatch(schema: string, cfdis: CreateCfdiData
|
||||
return result;
|
||||
}
|
||||
|
||||
// Insert a batch using multi-row INSERT with ON CONFLICT
|
||||
async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ inserted: number; duplicates: number }> {
|
||||
async function insertBatch(pool: Pool, cfdis: CreateCfdiData[]): Promise<{ inserted: number; duplicates: number }> {
|
||||
if (cfdis.length === 0) return { inserted: 0, duplicates: 0 };
|
||||
|
||||
// Build the VALUES part of the query
|
||||
const values: any[] = [];
|
||||
const valuePlaceholders: string[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const cfdi of cfdis) {
|
||||
// Parse dates
|
||||
const fechaEmision = parseDate(cfdi.fechaEmision);
|
||||
const fechaTimbrado = cfdi.fechaTimbrado ? parseDate(cfdi.fechaTimbrado) : fechaEmision;
|
||||
|
||||
@@ -304,9 +311,8 @@ async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ i
|
||||
return { inserted: 0, duplicates: 0 };
|
||||
}
|
||||
|
||||
// Use ON CONFLICT to handle duplicates gracefully
|
||||
const query = `
|
||||
INSERT INTO "${schema}".cfdis (
|
||||
INSERT INTO cfdis (
|
||||
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||
@@ -315,15 +321,12 @@ async function insertBatch(schema: string, cfdis: CreateCfdiData[]): Promise<{ i
|
||||
ON CONFLICT (uuid_fiscal) DO NOTHING
|
||||
`;
|
||||
|
||||
await prisma.$executeRawUnsafe(query, ...values);
|
||||
await pool.query(query, values);
|
||||
|
||||
// We can't know exactly how many were inserted vs duplicates with DO NOTHING
|
||||
// Return optimistic count, duplicates will be 0 (they're silently skipped)
|
||||
return { inserted: valuePlaceholders.length, duplicates: 0 };
|
||||
}
|
||||
|
||||
// Fallback: insert individually when batch fails
|
||||
async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
||||
async function insertIndividually(pool: Pool, cfdis: CreateCfdiData[]): Promise<BatchInsertResult> {
|
||||
const result: BatchInsertResult = {
|
||||
inserted: 0,
|
||||
duplicates: 0,
|
||||
@@ -333,7 +336,7 @@ async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Prom
|
||||
|
||||
for (const cfdi of cfdis) {
|
||||
try {
|
||||
await createCfdi(schema, cfdi);
|
||||
await createCfdi(pool, cfdi);
|
||||
result.inserted++;
|
||||
} catch (error: any) {
|
||||
const errorMsg = error.message || 'Error desconocido';
|
||||
@@ -351,11 +354,9 @@ async function insertIndividually(schema: string, cfdis: CreateCfdiData[]): Prom
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper to parse dates safely
|
||||
function parseDate(dateStr: string): Date | null {
|
||||
if (!dateStr) return null;
|
||||
|
||||
// If date is in YYYY-MM-DD format, add time to avoid timezone issues
|
||||
const normalized = dateStr.match(/^\d{4}-\d{2}-\d{2}$/)
|
||||
? `${dateStr}T12:00:00`
|
||||
: dateStr;
|
||||
@@ -364,19 +365,34 @@ function parseDate(dateStr: string): Date | null {
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
export async function deleteCfdi(schema: string, id: string): Promise<void> {
|
||||
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id);
|
||||
export async function deleteCfdi(pool: Pool, id: string): Promise<void> {
|
||||
await pool.query(`DELETE FROM cfdis WHERE id = $1`, [id]);
|
||||
}
|
||||
|
||||
export async function getResumenCfdis(schema: string, año: number, mes: number) {
|
||||
const result = await prisma.$queryRawUnsafe<[{
|
||||
total_ingresos: number;
|
||||
total_egresos: number;
|
||||
count_ingresos: number;
|
||||
count_egresos: number;
|
||||
iva_trasladado: number;
|
||||
iva_acreditable: number;
|
||||
}]>(`
|
||||
export async function getEmisores(pool: Pool, search: string, limit: number = 10): Promise<{ rfc: string; nombre: string }[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT DISTINCT rfc_emisor as rfc, nombre_emisor as nombre
|
||||
FROM cfdis
|
||||
WHERE rfc_emisor ILIKE $1 OR nombre_emisor ILIKE $1
|
||||
ORDER BY nombre_emisor
|
||||
LIMIT $2
|
||||
`, [`%${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
|
||||
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,
|
||||
@@ -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,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as iva_trasladado,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva_acreditable
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const r = result[0];
|
||||
const r = rows[0];
|
||||
return {
|
||||
totalIngresos: Number(r?.total_ingresos || 0),
|
||||
totalEgresos: Number(r?.total_egresos || 0),
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
import type { KpiData, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared';
|
||||
|
||||
export async function getKpis(schema: string, año: number, mes: number): Promise<KpiData> {
|
||||
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||
export async function getKpis(pool: Pool, año: number, mes: number): Promise<KpiData> {
|
||||
const { rows: [ingresos] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(total), 0) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'ingreso'
|
||||
AND estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||
const { rows: [egresos] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(total), 0) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'egreso'
|
||||
AND estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const [ivaData] = await prisma.$queryRawUnsafe<[{ trasladado: number; acreditable: number }]>(`
|
||||
const { rows: [ivaData] } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const [counts] = await prisma.$queryRawUnsafe<[{ emitidos: number; recibidos: number }]>(`
|
||||
const { rows: [counts] } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as emitidos,
|
||||
COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as recibidos
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const ingresosVal = Number(ingresos?.total || 0);
|
||||
const egresosVal = Number(egresos?.total || 0);
|
||||
@@ -57,23 +57,23 @@ export async function getKpis(schema: string, año: number, mes: number): Promis
|
||||
};
|
||||
}
|
||||
|
||||
export async function getIngresosEgresos(schema: string, año: number): Promise<IngresosEgresosData[]> {
|
||||
const data = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
|
||||
export async function getIngresosEgresos(pool: Pool, año: number): Promise<IngresosEgresosData[]> {
|
||||
const { rows: data } = await pool.query(`
|
||||
SELECT
|
||||
EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
GROUP BY EXTRACT(MONTH FROM fecha_emision)
|
||||
ORDER BY mes
|
||||
`, año);
|
||||
`, [año]);
|
||||
|
||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||
|
||||
return meses.map((mes, index) => {
|
||||
const found = data.find(d => d.mes === index + 1);
|
||||
const found = data.find((d: any) => d.mes === index + 1);
|
||||
return {
|
||||
mes,
|
||||
ingresos: Number(found?.ingresos || 0),
|
||||
@@ -82,16 +82,17 @@ export async function getIngresosEgresos(schema: string, año: number): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
export async function getResumenFiscal(schema: string, año: number, mes: number): Promise<ResumenFiscal> {
|
||||
const [ivaResult] = await prisma.$queryRawUnsafe<[{ resultado: number; acumulado: number }]>(`
|
||||
SELECT resultado, acumulado FROM "${schema}".iva_mensual
|
||||
export async function getResumenFiscal(pool: Pool, año: number, mes: number): Promise<ResumenFiscal> {
|
||||
const { rows: ivaRows } = await pool.query(`
|
||||
SELECT resultado, acumulado FROM iva_mensual
|
||||
WHERE año = $1 AND mes = $2
|
||||
`, año, mes) || [{ resultado: 0, acumulado: 0 }];
|
||||
`, [año, mes]);
|
||||
const ivaResult = ivaRows[0] || { resultado: 0, acumulado: 0 };
|
||||
|
||||
const [pendientes] = await prisma.$queryRawUnsafe<[{ count: number }]>(`
|
||||
SELECT COUNT(*) as count FROM "${schema}".iva_mensual
|
||||
const { rows: [pendientes] } = await pool.query(`
|
||||
SELECT COUNT(*) as count FROM iva_mensual
|
||||
WHERE año = $1 AND estado = 'pendiente'
|
||||
`, año);
|
||||
`, [año]);
|
||||
|
||||
const resultado = Number(ivaResult?.resultado || 0);
|
||||
const acumulado = Number(ivaResult?.acumulado || 0);
|
||||
@@ -108,19 +109,19 @@ export async function getResumenFiscal(schema: string, año: number, mes: number
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAlertas(schema: string, limit = 5): Promise<Alerta[]> {
|
||||
const alertas = await prisma.$queryRawUnsafe<Alerta[]>(`
|
||||
export async function getAlertas(pool: Pool, limit = 5): Promise<Alerta[]> {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT id, tipo, titulo, mensaje, prioridad,
|
||||
fecha_vencimiento as "fechaVencimiento",
|
||||
leida, resuelta,
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".alertas
|
||||
FROM alertas
|
||||
WHERE resuelta = false
|
||||
ORDER BY
|
||||
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
|
||||
created_at DESC
|
||||
LIMIT $1
|
||||
`, limit);
|
||||
`, [limit]);
|
||||
|
||||
return alertas;
|
||||
return rows;
|
||||
}
|
||||
|
||||
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 { prisma } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export async function exportCfdisToExcel(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
filters: { tipo?: string; estado?: string; fechaInicio?: string; fechaFin?: string }
|
||||
): Promise<Buffer> {
|
||||
let whereClause = 'WHERE 1=1';
|
||||
@@ -26,15 +26,15 @@ export async function exportCfdisToExcel(
|
||||
params.push(filters.fechaFin);
|
||||
}
|
||||
|
||||
const cfdis = await prisma.$queryRawUnsafe<any[]>(`
|
||||
const { rows: cfdis } = await pool.query(`
|
||||
SELECT uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||
moneda, metodo_pago, forma_pago, uso_cfdi, estado
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
${whereClause}
|
||||
ORDER BY fecha_emision DESC
|
||||
`, ...params);
|
||||
`, params);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('CFDIs');
|
||||
@@ -63,7 +63,7 @@ export async function exportCfdisToExcel(
|
||||
};
|
||||
sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
|
||||
cfdis.forEach(cfdi => {
|
||||
cfdis.forEach((cfdi: any) => {
|
||||
sheet.addRow({
|
||||
...cfdi,
|
||||
fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'),
|
||||
@@ -78,7 +78,7 @@ export async function exportCfdisToExcel(
|
||||
}
|
||||
|
||||
export async function exportReporteToExcel(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
tipo: 'estado-resultados' | 'flujo-efectivo',
|
||||
fechaInicio: string,
|
||||
fechaFin: string
|
||||
@@ -87,13 +87,13 @@ export async function exportReporteToExcel(
|
||||
const sheet = workbook.addWorksheet(tipo === 'estado-resultados' ? 'Estado de Resultados' : 'Flujo de Efectivo');
|
||||
|
||||
if (tipo === 'estado-resultados') {
|
||||
const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number }]>(`
|
||||
const { rows: [totales] } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
|
||||
`, fechaInicio, fechaFin);
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
sheet.columns = [
|
||||
{ header: 'Concepto', key: 'concepto', width: 40 },
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Credential } from '@nodecfdi/credentials/node';
|
||||
import { writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { prisma } from '../config/database.js';
|
||||
import { encrypt, decrypt } 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';
|
||||
|
||||
/**
|
||||
@@ -44,8 +48,11 @@ export async function uploadFiel(
|
||||
const certificate = credential.certificate();
|
||||
const rfc = certificate.rfc();
|
||||
const serialNumber = certificate.serialNumber().bytes();
|
||||
const validFrom = certificate.validFromDateTime();
|
||||
const validUntil = certificate.validToDateTime();
|
||||
// validFromDateTime() y validToDateTime() retornan strings ISO o objetos DateTime
|
||||
const validFromRaw = certificate.validFromDateTime();
|
||||
const validUntilRaw = certificate.validToDateTime();
|
||||
const validFrom = new Date(String(validFromRaw));
|
||||
const validUntil = new Date(String(validUntilRaw));
|
||||
|
||||
// Verificar que no esté vencida
|
||||
if (new Date() > validUntil) {
|
||||
@@ -55,10 +62,18 @@ export async function uploadFiel(
|
||||
};
|
||||
}
|
||||
|
||||
// Encriptar credenciales
|
||||
const { encrypted: encryptedCer, iv, tag } = encrypt(cerData);
|
||||
const { encrypted: encryptedKey } = encrypt(keyData);
|
||||
const { encrypted: encryptedPassword } = encrypt(Buffer.from(password, 'utf-8'));
|
||||
// Encriptar credenciales (per-component IV/tag)
|
||||
const {
|
||||
encryptedCer,
|
||||
encryptedKey,
|
||||
encryptedPassword,
|
||||
cerIv,
|
||||
cerTag,
|
||||
keyIv,
|
||||
keyTag,
|
||||
passwordIv,
|
||||
passwordTag,
|
||||
} = encryptFielCredentials(cerData, keyData, password);
|
||||
|
||||
// Guardar o actualizar en BD
|
||||
await prisma.fielCredential.upsert({
|
||||
@@ -69,8 +84,12 @@ export async function uploadFiel(
|
||||
cerData: encryptedCer,
|
||||
keyData: encryptedKey,
|
||||
keyPasswordEncrypted: encryptedPassword,
|
||||
encryptionIv: iv,
|
||||
encryptionTag: tag,
|
||||
cerIv,
|
||||
cerTag,
|
||||
keyIv,
|
||||
keyTag,
|
||||
passwordIv,
|
||||
passwordTag,
|
||||
serialNumber,
|
||||
validFrom,
|
||||
validUntil,
|
||||
@@ -81,8 +100,12 @@ export async function uploadFiel(
|
||||
cerData: encryptedCer,
|
||||
keyData: encryptedKey,
|
||||
keyPasswordEncrypted: encryptedPassword,
|
||||
encryptionIv: iv,
|
||||
encryptionTag: tag,
|
||||
cerIv,
|
||||
cerTag,
|
||||
keyIv,
|
||||
keyTag,
|
||||
passwordIv,
|
||||
passwordTag,
|
||||
serialNumber,
|
||||
validFrom,
|
||||
validUntil,
|
||||
@@ -91,6 +114,49 @@ export async function uploadFiel(
|
||||
},
|
||||
});
|
||||
|
||||
// Save encrypted files to filesystem (dual storage)
|
||||
try {
|
||||
const fielDir = join(env.FIEL_STORAGE_PATH, rfc.toUpperCase());
|
||||
await mkdir(fielDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Re-encrypt for filesystem (independent keys from DB)
|
||||
const fsEncrypted = encryptFielCredentials(cerData, keyData, password);
|
||||
|
||||
await writeFile(join(fielDir, 'certificate.cer.enc'), fsEncrypted.encryptedCer, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'certificate.cer.iv'), fsEncrypted.cerIv, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'certificate.cer.tag'), fsEncrypted.cerTag, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'private_key.key.enc'), fsEncrypted.encryptedKey, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'private_key.key.iv'), fsEncrypted.keyIv, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'private_key.key.tag'), fsEncrypted.keyTag, { mode: 0o600 });
|
||||
|
||||
// Encrypt and store metadata
|
||||
const metadata = JSON.stringify({
|
||||
serial: serialNumber,
|
||||
validFrom: validFrom.toISOString(),
|
||||
validUntil: validUntil.toISOString(),
|
||||
uploadedAt: new Date().toISOString(),
|
||||
rfc: rfc.toUpperCase(),
|
||||
});
|
||||
const metaEncrypted = encrypt(Buffer.from(metadata, 'utf-8'));
|
||||
await writeFile(join(fielDir, 'metadata.json.enc'), metaEncrypted.encrypted, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'metadata.json.iv'), metaEncrypted.iv, { mode: 0o600 });
|
||||
await writeFile(join(fielDir, 'metadata.json.tag'), metaEncrypted.tag, { mode: 0o600 });
|
||||
} catch (fsError) {
|
||||
console.error('[FIEL] Filesystem storage failed (DB storage OK):', fsError);
|
||||
}
|
||||
|
||||
// Notify admin that client uploaded FIEL
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { nombre: true, rfc: true },
|
||||
});
|
||||
if (tenant) {
|
||||
emailService.sendFielNotification({
|
||||
clienteNombre: tenant.nombre,
|
||||
clienteRfc: tenant.rfc,
|
||||
}).catch(err => console.error('[EMAIL] FIEL notification failed:', err));
|
||||
}
|
||||
|
||||
const daysUntilExpiration = Math.ceil(
|
||||
(validUntil.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
@@ -172,7 +238,9 @@ export async function deleteFiel(tenantId: string): Promise<boolean> {
|
||||
* Solo debe usarse internamente por el servicio de SAT
|
||||
*/
|
||||
export async function getDecryptedFiel(tenantId: string): Promise<{
|
||||
credential: Credential;
|
||||
cerContent: string;
|
||||
keyContent: string;
|
||||
password: string;
|
||||
rfc: string;
|
||||
} | null> {
|
||||
const fiel = await prisma.fielCredential.findUnique({
|
||||
@@ -189,32 +257,23 @@ export async function getDecryptedFiel(tenantId: string): Promise<{
|
||||
}
|
||||
|
||||
try {
|
||||
// Desencriptar
|
||||
const cerData = decrypt(
|
||||
// Desencriptar credenciales (per-component IV/tag)
|
||||
const { cerData, keyData, password } = decryptFielCredentials(
|
||||
Buffer.from(fiel.cerData),
|
||||
Buffer.from(fiel.encryptionIv),
|
||||
Buffer.from(fiel.encryptionTag)
|
||||
);
|
||||
const keyData = decrypt(
|
||||
Buffer.from(fiel.keyData),
|
||||
Buffer.from(fiel.encryptionIv),
|
||||
Buffer.from(fiel.encryptionTag)
|
||||
);
|
||||
const password = decrypt(
|
||||
Buffer.from(fiel.keyPasswordEncrypted),
|
||||
Buffer.from(fiel.encryptionIv),
|
||||
Buffer.from(fiel.encryptionTag)
|
||||
).toString('utf-8');
|
||||
|
||||
// Crear credencial
|
||||
const credential = Credential.create(
|
||||
cerData.toString('binary'),
|
||||
keyData.toString('binary'),
|
||||
password
|
||||
Buffer.from(fiel.cerIv),
|
||||
Buffer.from(fiel.cerTag),
|
||||
Buffer.from(fiel.keyIv),
|
||||
Buffer.from(fiel.keyTag),
|
||||
Buffer.from(fiel.passwordIv),
|
||||
Buffer.from(fiel.passwordTag)
|
||||
);
|
||||
|
||||
return {
|
||||
credential,
|
||||
cerContent: cerData.toString('binary'),
|
||||
keyContent: keyData.toString('binary'),
|
||||
password,
|
||||
rfc: fiel.rfc,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import type { Pool } from 'pg';
|
||||
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';
|
||||
|
||||
export async function getIvaMensual(schema: string, año: number): Promise<IvaMensual[]> {
|
||||
const data = await prisma.$queryRawUnsafe<IvaMensual[]>(`
|
||||
export async function getIvaMensual(pool: Pool, año: number): Promise<IvaMensual[]> {
|
||||
const { rows: data } = await pool.query(`
|
||||
SELECT
|
||||
id, año, mes,
|
||||
iva_trasladado as "ivaTrasladado",
|
||||
@@ -10,12 +10,12 @@ export async function getIvaMensual(schema: string, año: number): Promise<IvaMe
|
||||
COALESCE(iva_retenido, 0) as "ivaRetenido",
|
||||
resultado, acumulado, estado,
|
||||
fecha_declaracion as "fechaDeclaracion"
|
||||
FROM "${schema}".iva_mensual
|
||||
FROM iva_mensual
|
||||
WHERE año = $1
|
||||
ORDER BY mes
|
||||
`, año);
|
||||
`, [año]);
|
||||
|
||||
return data.map(row => ({
|
||||
return data.map((row: any) => ({
|
||||
...row,
|
||||
ivaTrasladado: Number(row.ivaTrasladado),
|
||||
ivaAcreditable: Number(row.ivaAcreditable),
|
||||
@@ -25,19 +25,18 @@ export async function getIvaMensual(schema: string, año: number): Promise<IvaMe
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getResumenIva(schema: string, año: number, mes: number): Promise<ResumenIva> {
|
||||
// Get from iva_mensual if exists
|
||||
const existing = await prisma.$queryRawUnsafe<any[]>(`
|
||||
SELECT * FROM "${schema}".iva_mensual WHERE año = $1 AND mes = $2
|
||||
`, año, mes);
|
||||
export async function getResumenIva(pool: Pool, año: number, mes: number): Promise<ResumenIva> {
|
||||
const { rows: existing } = await pool.query(`
|
||||
SELECT * FROM iva_mensual WHERE año = $1 AND mes = $2
|
||||
`, [año, mes]);
|
||||
|
||||
if (existing && existing.length > 0) {
|
||||
const record = existing[0];
|
||||
const [acumuladoResult] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||
const { rows: [acumuladoResult] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(resultado), 0) as total
|
||||
FROM "${schema}".iva_mensual
|
||||
FROM iva_mensual
|
||||
WHERE año = $1 AND mes <= $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
return {
|
||||
trasladado: Number(record.iva_trasladado || 0),
|
||||
@@ -48,21 +47,16 @@ export async function getResumenIva(schema: string, año: number, mes: number):
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate from CFDIs if no iva_mensual record
|
||||
const [calcResult] = await prisma.$queryRawUnsafe<[{
|
||||
trasladado: number;
|
||||
acreditable: number;
|
||||
retenido: number;
|
||||
}]>(`
|
||||
const { rows: [calcResult] } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable,
|
||||
COALESCE(SUM(iva_retenido), 0) as retenido
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) = $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const trasladado = Number(calcResult?.trasladado || 0);
|
||||
const acreditable = Number(calcResult?.acreditable || 0);
|
||||
@@ -78,10 +72,9 @@ export async function getResumenIva(schema: string, año: number, mes: number):
|
||||
};
|
||||
}
|
||||
|
||||
export async function getIsrMensual(schema: string, año: number): Promise<IsrMensual[]> {
|
||||
// Check if isr_mensual table exists
|
||||
export async function getIsrMensual(pool: Pool, año: number): Promise<IsrMensual[]> {
|
||||
try {
|
||||
const data = await prisma.$queryRawUnsafe<IsrMensual[]>(`
|
||||
const { rows: data } = await pool.query(`
|
||||
SELECT
|
||||
id, año, mes,
|
||||
ingresos_acumulados as "ingresosAcumulados",
|
||||
@@ -92,12 +85,12 @@ export async function getIsrMensual(schema: string, año: number): Promise<IsrMe
|
||||
isr_a_pagar as "isrAPagar",
|
||||
estado,
|
||||
fecha_declaracion as "fechaDeclaracion"
|
||||
FROM "${schema}".isr_mensual
|
||||
FROM isr_mensual
|
||||
WHERE año = $1
|
||||
ORDER BY mes
|
||||
`, año);
|
||||
`, [año]);
|
||||
|
||||
return data.map(row => ({
|
||||
return data.map((row: any) => ({
|
||||
...row,
|
||||
ingresosAcumulados: Number(row.ingresosAcumulados),
|
||||
deducciones: Number(row.deducciones),
|
||||
@@ -107,43 +100,40 @@ export async function getIsrMensual(schema: string, año: number): Promise<IsrMe
|
||||
isrAPagar: Number(row.isrAPagar),
|
||||
}));
|
||||
} catch {
|
||||
// Table doesn't exist, return empty array
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getResumenIsr(schema: string, año: number, mes: number): Promise<ResumenIsr> {
|
||||
// Calculate from CFDIs
|
||||
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||
export async function getResumenIsr(pool: Pool, año: number, mes: number): Promise<ResumenIsr> {
|
||||
const { rows: [ingresos] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(total), 0) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||
const { rows: [egresos] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(total), 0) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const [retenido] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
|
||||
const { rows: [retenido] } = await pool.query(`
|
||||
SELECT COALESCE(SUM(isr_retenido), 0) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente'
|
||||
AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
AND EXTRACT(MONTH FROM fecha_emision) <= $2
|
||||
`, año, mes);
|
||||
`, [año, mes]);
|
||||
|
||||
const ingresosAcumulados = Number(ingresos?.total || 0);
|
||||
const deducciones = Number(egresos?.total || 0);
|
||||
const baseGravable = Math.max(0, ingresosAcumulados - deducciones);
|
||||
|
||||
// Simplified ISR calculation (actual calculation would use SAT tables)
|
||||
const isrCausado = baseGravable * 0.30; // 30% simplified rate
|
||||
const isrCausado = baseGravable * 0.30;
|
||||
const isrRetenido = Number(retenido?.total || 0);
|
||||
const isrAPagar = Math.max(0, isrCausado - isrRetenido);
|
||||
|
||||
|
||||
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';
|
||||
|
||||
// Helper to convert Prisma Decimal/BigInt to number
|
||||
function toNumber(value: unknown): number {
|
||||
if (value === null || value === undefined) return 0;
|
||||
if (typeof value === 'number') return value;
|
||||
@@ -14,37 +13,37 @@ function toNumber(value: unknown): number {
|
||||
}
|
||||
|
||||
export async function getEstadoResultados(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string
|
||||
): Promise<EstadoResultados> {
|
||||
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
|
||||
const { rows: ingresos } = await pool.query(`
|
||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY total DESC LIMIT 10
|
||||
`, fechaInicio, fechaFin);
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
|
||||
const { rows: egresos } = await pool.query(`
|
||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY total DESC LIMIT 10
|
||||
`, fechaInicio, fechaFin);
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const totalesResult = await prisma.$queryRawUnsafe<{ ingresos: unknown; egresos: unknown; iva: unknown }[]>(`
|
||||
const { rows: totalesResult } = await pool.query(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) -
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
`, fechaInicio, fechaFin);
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const totales = totalesResult[0];
|
||||
const totalIngresos = toNumber(totales?.ingresos);
|
||||
@@ -54,8 +53,8 @@ export async function getEstadoResultados(
|
||||
|
||||
return {
|
||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: toNumber(i.total) })),
|
||||
egresos: egresos.map(e => ({ concepto: e.nombre, monto: toNumber(e.total) })),
|
||||
ingresos: ingresos.map((i: any) => ({ concepto: i.nombre, monto: toNumber(i.total) })),
|
||||
egresos: egresos.map((e: any) => ({ concepto: e.nombre, monto: toNumber(e.total) })),
|
||||
totalIngresos,
|
||||
totalEgresos,
|
||||
utilidadBruta,
|
||||
@@ -65,36 +64,36 @@ export async function getEstadoResultados(
|
||||
}
|
||||
|
||||
export async function getFlujoEfectivo(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string
|
||||
): Promise<FlujoEfectivo> {
|
||||
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
|
||||
const { rows: entradas } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||
ORDER BY mes
|
||||
`, fechaInicio, fechaFin);
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
|
||||
const { rows: salidas } = await pool.query(`
|
||||
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
|
||||
ORDER BY mes
|
||||
`, fechaInicio, fechaFin);
|
||||
`, [fechaInicio, fechaFin]);
|
||||
|
||||
const totalEntradas = entradas.reduce((sum, e) => sum + toNumber(e.total), 0);
|
||||
const totalSalidas = salidas.reduce((sum, s) => sum + toNumber(s.total), 0);
|
||||
const totalEntradas = entradas.reduce((sum: number, e: any) => sum + toNumber(e.total), 0);
|
||||
const totalSalidas = salidas.reduce((sum: number, s: any) => sum + toNumber(s.total), 0);
|
||||
|
||||
return {
|
||||
periodo: { inicio: fechaInicio, fin: fechaFin },
|
||||
saldoInicial: 0,
|
||||
entradas: entradas.map(e => ({ concepto: e.mes, monto: toNumber(e.total) })),
|
||||
salidas: salidas.map(s => ({ concepto: s.mes, monto: toNumber(s.total) })),
|
||||
entradas: entradas.map((e: any) => ({ concepto: e.mes, monto: toNumber(e.total) })),
|
||||
salidas: salidas.map((s: any) => ({ concepto: s.mes, monto: toNumber(s.total) })),
|
||||
totalEntradas,
|
||||
totalSalidas,
|
||||
flujoNeto: totalEntradas - totalSalidas,
|
||||
@@ -103,36 +102,36 @@ export async function getFlujoEfectivo(
|
||||
}
|
||||
|
||||
export async function getComparativo(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
año: number
|
||||
): Promise<ComparativoPeriodos> {
|
||||
const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
|
||||
const { rows: actual } = await pool.query(`
|
||||
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
GROUP BY mes ORDER BY mes
|
||||
`, año);
|
||||
`, [año]);
|
||||
|
||||
const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
|
||||
const { rows: anterior } = await pool.query(`
|
||||
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
|
||||
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
|
||||
GROUP BY mes ORDER BY mes
|
||||
`, año - 1);
|
||||
`, [año - 1]);
|
||||
|
||||
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
|
||||
const ingresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.ingresos));
|
||||
const egresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.egresos));
|
||||
const ingresos = meses.map((_, i) => toNumber(actual.find((a: any) => a.mes === i + 1)?.ingresos));
|
||||
const egresos = meses.map((_, i) => toNumber(actual.find((a: any) => a.mes === i + 1)?.egresos));
|
||||
const utilidad = ingresos.map((ing, i) => ing - egresos[i]);
|
||||
|
||||
const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
|
||||
const totalAnteriorIng = anterior.reduce((a, b) => a + toNumber(b.ingresos), 0);
|
||||
const totalAnteriorIng = anterior.reduce((a: number, b: any) => a + toNumber(b.ingresos), 0);
|
||||
const totalActualEgr = egresos.reduce((a, b) => a + b, 0);
|
||||
const totalAnteriorEgr = anterior.reduce((a, b) => a + toNumber(b.egresos), 0);
|
||||
const totalAnteriorEgr = anterior.reduce((a: number, b: any) => a + toNumber(b.egresos), 0);
|
||||
|
||||
return {
|
||||
periodos: meses,
|
||||
@@ -146,25 +145,25 @@ export async function getComparativo(
|
||||
}
|
||||
|
||||
export async function getConcentradoRfc(
|
||||
schema: string,
|
||||
pool: Pool,
|
||||
fechaInicio: string,
|
||||
fechaFin: string,
|
||||
tipo: 'cliente' | 'proveedor'
|
||||
): Promise<ConcentradoRfc[]> {
|
||||
if (tipo === 'cliente') {
|
||||
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
|
||||
const { rows: data } = await pool.query(`
|
||||
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
|
||||
'cliente' as tipo,
|
||||
SUM(total) as "totalFacturado",
|
||||
SUM(iva) as "totalIva",
|
||||
COUNT(*)::int as "cantidadCfdis"
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'ingreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_receptor, nombre_receptor
|
||||
ORDER BY "totalFacturado" DESC
|
||||
`, fechaInicio, fechaFin);
|
||||
return data.map(d => ({
|
||||
`, [fechaInicio, fechaFin]);
|
||||
return data.map((d: any) => ({
|
||||
rfc: d.rfc,
|
||||
nombre: d.nombre,
|
||||
tipo: 'cliente' as const,
|
||||
@@ -173,19 +172,19 @@ export async function getConcentradoRfc(
|
||||
cantidadCfdis: d.cantidadCfdis
|
||||
}));
|
||||
} else {
|
||||
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
|
||||
const { rows: data } = await pool.query(`
|
||||
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
|
||||
'proveedor' as tipo,
|
||||
SUM(total) as "totalFacturado",
|
||||
SUM(iva) as "totalIva",
|
||||
COUNT(*)::int as "cantidadCfdis"
|
||||
FROM "${schema}".cfdis
|
||||
FROM cfdis
|
||||
WHERE tipo = 'egreso' AND estado = 'vigente'
|
||||
AND fecha_emision BETWEEN $1::date AND $2::date
|
||||
GROUP BY rfc_emisor, nombre_emisor
|
||||
ORDER BY "totalFacturado" DESC
|
||||
`, fechaInicio, fechaFin);
|
||||
return data.map(d => ({
|
||||
`, [fechaInicio, fechaFin]);
|
||||
return data.map((d: any) => ({
|
||||
rfc: d.rfc,
|
||||
nombre: d.nombre,
|
||||
tipo: 'proveedor' as const,
|
||||
|
||||
160
apps/api/src/services/sat/sat-auth.service.ts
Normal file
160
apps/api/src/services/sat/sat-auth.service.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import type { Credential } from '@nodecfdi/credentials/node';
|
||||
|
||||
const SAT_AUTH_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc';
|
||||
|
||||
interface SatToken {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera el timestamp para la solicitud SOAP
|
||||
*/
|
||||
function createTimestamp(): { created: string; expires: string } {
|
||||
const now = new Date();
|
||||
const created = now.toISOString();
|
||||
const expires = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 minutos
|
||||
return { created, expires };
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el XML de solicitud de autenticación
|
||||
*/
|
||||
function buildAuthRequest(credential: Credential): string {
|
||||
const timestamp = createTimestamp();
|
||||
const uuid = randomUUID();
|
||||
|
||||
const certificate = credential.certificate();
|
||||
// El PEM ya contiene el certificado en base64, solo quitamos headers y newlines
|
||||
const cerB64 = certificate.pem().replace(/-----.*-----/g, '').replace(/\s/g, '');
|
||||
|
||||
// Canonicalizar y firmar
|
||||
const toDigestXml = `<u:Timestamp xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" u:Id="_0">` +
|
||||
`<u:Created>${timestamp.created}</u:Created>` +
|
||||
`<u:Expires>${timestamp.expires}</u:Expires>` +
|
||||
`</u:Timestamp>`;
|
||||
|
||||
const digestValue = createHash('sha1').update(toDigestXml).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="#_0">` +
|
||||
`<Transforms>` +
|
||||
`<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`</Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
// Firmar con la llave privada (sign retorna binary string, convertir a base64)
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
const soapEnvelope = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
|
||||
<s:Header>
|
||||
<o:Security xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" s:mustUnderstand="1">
|
||||
<u:Timestamp u:Id="_0">
|
||||
<u:Created>${timestamp.created}</u:Created>
|
||||
<u:Expires>${timestamp.expires}</u:Expires>
|
||||
</u:Timestamp>
|
||||
<o:BinarySecurityToken u:Id="uuid-${uuid}-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">${cerB64}</o:BinarySecurityToken>
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<o:SecurityTokenReference>
|
||||
<o:Reference URI="#uuid-${uuid}-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"/>
|
||||
</o:SecurityTokenReference>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</o:Security>
|
||||
</s:Header>
|
||||
<s:Body>
|
||||
<Autentica xmlns="http://DescargaMasivaTerceros.gob.mx"/>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
return soapEnvelope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el token de la respuesta SOAP
|
||||
*/
|
||||
function parseAuthResponse(responseXml: string): SatToken {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
});
|
||||
|
||||
const result = parser.parse(responseXml);
|
||||
|
||||
// Navegar la estructura de respuesta SOAP
|
||||
const envelope = result.Envelope || result['s:Envelope'];
|
||||
if (!envelope) {
|
||||
throw new Error('Respuesta SOAP inválida');
|
||||
}
|
||||
|
||||
const body = envelope.Body || envelope['s:Body'];
|
||||
if (!body) {
|
||||
throw new Error('No se encontró el cuerpo de la respuesta');
|
||||
}
|
||||
|
||||
const autenticaResponse = body.AutenticaResponse;
|
||||
if (!autenticaResponse) {
|
||||
throw new Error('No se encontró AutenticaResponse');
|
||||
}
|
||||
|
||||
const autenticaResult = autenticaResponse.AutenticaResult;
|
||||
if (!autenticaResult) {
|
||||
throw new Error('No se obtuvo token de autenticación');
|
||||
}
|
||||
|
||||
// El token es un SAML assertion en base64
|
||||
const token = autenticaResult;
|
||||
|
||||
// El token expira en 5 minutos según documentación SAT
|
||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Autentica con el SAT usando la FIEL y obtiene un token
|
||||
*/
|
||||
export async function authenticate(credential: Credential): Promise<SatToken> {
|
||||
const soapRequest = buildAuthRequest(credential);
|
||||
|
||||
try {
|
||||
const response = await fetch(SAT_AUTH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml;charset=UTF-8',
|
||||
'SOAPAction': 'http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica',
|
||||
},
|
||||
body: soapRequest,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseXml = await response.text();
|
||||
return parseAuthResponse(responseXml);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Auth Error]', error);
|
||||
throw new Error(`Error al autenticar con el SAT: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si un token está vigente
|
||||
*/
|
||||
export function isTokenValid(token: SatToken): boolean {
|
||||
return new Date() < token.expiresAt;
|
||||
}
|
||||
210
apps/api/src/services/sat/sat-client.service.ts
Normal file
210
apps/api/src/services/sat/sat-client.service.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
Fiel,
|
||||
HttpsWebClient,
|
||||
FielRequestBuilder,
|
||||
Service,
|
||||
QueryParameters,
|
||||
DateTimePeriod,
|
||||
DownloadType,
|
||||
RequestType,
|
||||
ServiceEndpoints,
|
||||
} from '@nodecfdi/sat-ws-descarga-masiva';
|
||||
|
||||
export interface FielData {
|
||||
cerContent: string;
|
||||
keyContent: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea el servicio de descarga masiva del SAT usando los datos de la FIEL
|
||||
*/
|
||||
export function createSatService(fielData: FielData): Service {
|
||||
// Crear FIEL usando el método estático create
|
||||
const fiel = Fiel.create(fielData.cerContent, fielData.keyContent, fielData.password);
|
||||
|
||||
// Verificar que la FIEL sea válida
|
||||
if (!fiel.isValid()) {
|
||||
throw new Error('La FIEL no es válida o está vencida');
|
||||
}
|
||||
|
||||
// Crear cliente HTTP
|
||||
const webClient = new HttpsWebClient();
|
||||
|
||||
// Crear request builder con la FIEL
|
||||
const requestBuilder = new FielRequestBuilder(fiel);
|
||||
|
||||
// Crear y retornar el servicio
|
||||
return new Service(requestBuilder, webClient, undefined, ServiceEndpoints.cfdi());
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
success: boolean;
|
||||
requestId?: string;
|
||||
message: string;
|
||||
statusCode?: string;
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
success: boolean;
|
||||
status: 'pending' | 'processing' | 'ready' | 'failed' | 'rejected';
|
||||
packageIds: string[];
|
||||
totalCfdis: number;
|
||||
message: string;
|
||||
statusCode?: string;
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
success: boolean;
|
||||
packageContent: string; // Base64 encoded ZIP
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Realiza una consulta al SAT para solicitar CFDIs
|
||||
*/
|
||||
export async function querySat(
|
||||
service: Service,
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date,
|
||||
tipo: 'emitidos' | 'recibidos',
|
||||
requestType: 'metadata' | 'cfdi' = 'cfdi'
|
||||
): Promise<QueryResult> {
|
||||
try {
|
||||
const period = DateTimePeriod.createFromValues(
|
||||
formatDateForSat(fechaInicio),
|
||||
formatDateForSat(fechaFin)
|
||||
);
|
||||
|
||||
const downloadType = new DownloadType(tipo === 'emitidos' ? 'issued' : 'received');
|
||||
const reqType = new RequestType(requestType === 'cfdi' ? 'xml' : 'metadata');
|
||||
|
||||
const parameters = QueryParameters.create(period, downloadType, reqType);
|
||||
const result = await service.query(parameters);
|
||||
|
||||
if (!result.getStatus().isAccepted()) {
|
||||
return {
|
||||
success: false,
|
||||
message: result.getStatus().getMessage(),
|
||||
statusCode: result.getStatus().getCode().toString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
requestId: result.getRequestId(),
|
||||
message: 'Solicitud aceptada',
|
||||
statusCode: result.getStatus().getCode().toString(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Query Error]', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || 'Error al realizar consulta',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica el estado de una solicitud
|
||||
*/
|
||||
export async function verifySatRequest(
|
||||
service: Service,
|
||||
requestId: string
|
||||
): Promise<VerifyResult> {
|
||||
try {
|
||||
const result = await service.verify(requestId);
|
||||
const statusRequest = result.getStatusRequest();
|
||||
|
||||
// Debug logging
|
||||
console.log('[SAT Verify Debug]', {
|
||||
statusRequestValue: statusRequest.getValue(),
|
||||
statusRequestEntryId: statusRequest.getEntryId(),
|
||||
cfdis: result.getNumberCfdis(),
|
||||
packages: result.getPackageIds(),
|
||||
statusCode: result.getStatus().getCode(),
|
||||
statusMsg: result.getStatus().getMessage(),
|
||||
});
|
||||
|
||||
// Usar isTypeOf para determinar el estado
|
||||
let status: VerifyResult['status'];
|
||||
if (statusRequest.isTypeOf('Finished')) {
|
||||
status = 'ready';
|
||||
} else if (statusRequest.isTypeOf('InProgress')) {
|
||||
status = 'processing';
|
||||
} else if (statusRequest.isTypeOf('Accepted')) {
|
||||
status = 'pending';
|
||||
} else if (statusRequest.isTypeOf('Failure')) {
|
||||
status = 'failed';
|
||||
} else if (statusRequest.isTypeOf('Rejected')) {
|
||||
status = 'rejected';
|
||||
} else {
|
||||
// Default: check by entryId
|
||||
const entryId = statusRequest.getEntryId();
|
||||
if (entryId === 'Finished') status = 'ready';
|
||||
else if (entryId === 'InProgress') status = 'processing';
|
||||
else if (entryId === 'Accepted') status = 'pending';
|
||||
else status = 'pending';
|
||||
}
|
||||
|
||||
return {
|
||||
success: result.getStatus().isAccepted(),
|
||||
status,
|
||||
packageIds: result.getPackageIds(),
|
||||
totalCfdis: result.getNumberCfdis(),
|
||||
message: result.getStatus().getMessage(),
|
||||
statusCode: result.getStatus().getCode().toString(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Verify Error]', error);
|
||||
return {
|
||||
success: false,
|
||||
status: 'failed',
|
||||
packageIds: [],
|
||||
totalCfdis: 0,
|
||||
message: error.message || 'Error al verificar solicitud',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga un paquete de CFDIs
|
||||
*/
|
||||
export async function downloadSatPackage(
|
||||
service: Service,
|
||||
packageId: string
|
||||
): Promise<DownloadResult> {
|
||||
try {
|
||||
const result = await service.download(packageId);
|
||||
|
||||
if (!result.getStatus().isAccepted()) {
|
||||
return {
|
||||
success: false,
|
||||
packageContent: '',
|
||||
message: result.getStatus().getMessage(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
packageContent: result.getPackageContent(),
|
||||
message: 'Paquete descargado',
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Download Error]', error);
|
||||
return {
|
||||
success: false,
|
||||
packageContent: '',
|
||||
message: error.message || 'Error al descargar paquete',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea una fecha para el SAT (YYYY-MM-DD HH:mm:ss)
|
||||
*/
|
||||
function formatDateForSat(date: Date): string {
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
||||
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
@@ -6,10 +6,10 @@ const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Deriva una clave de 256 bits del JWT_SECRET
|
||||
* Deriva una clave de 256 bits del FIEL_ENCRYPTION_KEY
|
||||
*/
|
||||
function deriveKey(): Buffer {
|
||||
return createHash('sha256').update(env.JWT_SECRET).digest();
|
||||
return createHash('sha256').update(env.FIEL_ENCRYPTION_KEY).digest();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +52,7 @@ export function decryptToString(encrypted: Buffer, iv: Buffer, tag: Buffer): str
|
||||
}
|
||||
|
||||
/**
|
||||
* Encripta credenciales FIEL (cer, key, password)
|
||||
* Encripta credenciales FIEL con IV/tag independiente por componente
|
||||
*/
|
||||
export function encryptFielCredentials(
|
||||
cerData: Buffer,
|
||||
@@ -62,61 +62,51 @@ export function encryptFielCredentials(
|
||||
encryptedCer: Buffer;
|
||||
encryptedKey: Buffer;
|
||||
encryptedPassword: Buffer;
|
||||
iv: Buffer;
|
||||
tag: Buffer;
|
||||
cerIv: Buffer;
|
||||
cerTag: Buffer;
|
||||
keyIv: Buffer;
|
||||
keyTag: Buffer;
|
||||
passwordIv: Buffer;
|
||||
passwordTag: Buffer;
|
||||
} {
|
||||
// Usamos el mismo IV y tag para simplificar, concatenando los datos
|
||||
const combined = Buffer.concat([
|
||||
Buffer.from(cerData.length.toString().padStart(10, '0')),
|
||||
cerData,
|
||||
Buffer.from(keyData.length.toString().padStart(10, '0')),
|
||||
keyData,
|
||||
Buffer.from(password, 'utf-8'),
|
||||
]);
|
||||
|
||||
const { encrypted, iv, tag } = encrypt(combined);
|
||||
|
||||
// Extraemos las partes encriptadas
|
||||
const cerLength = cerData.length;
|
||||
const keyLength = keyData.length;
|
||||
const passwordLength = Buffer.from(password, 'utf-8').length;
|
||||
const cer = encrypt(cerData);
|
||||
const key = encrypt(keyData);
|
||||
const pwd = encrypt(Buffer.from(password, 'utf-8'));
|
||||
|
||||
return {
|
||||
encryptedCer: encrypted.subarray(0, 10 + cerLength),
|
||||
encryptedKey: encrypted.subarray(10 + cerLength, 20 + cerLength + keyLength),
|
||||
encryptedPassword: encrypted.subarray(20 + cerLength + keyLength),
|
||||
iv,
|
||||
tag,
|
||||
encryptedCer: cer.encrypted,
|
||||
encryptedKey: key.encrypted,
|
||||
encryptedPassword: pwd.encrypted,
|
||||
cerIv: cer.iv,
|
||||
cerTag: cer.tag,
|
||||
keyIv: key.iv,
|
||||
keyTag: key.tag,
|
||||
passwordIv: pwd.iv,
|
||||
passwordTag: pwd.tag,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Desencripta credenciales FIEL
|
||||
* Desencripta credenciales FIEL (per-component IV/tag)
|
||||
*/
|
||||
export function decryptFielCredentials(
|
||||
encryptedCer: Buffer,
|
||||
encryptedKey: Buffer,
|
||||
encryptedPassword: Buffer,
|
||||
iv: Buffer,
|
||||
tag: Buffer
|
||||
cerIv: Buffer,
|
||||
cerTag: Buffer,
|
||||
keyIv: Buffer,
|
||||
keyTag: Buffer,
|
||||
passwordIv: Buffer,
|
||||
passwordTag: Buffer
|
||||
): {
|
||||
cerData: Buffer;
|
||||
keyData: Buffer;
|
||||
password: string;
|
||||
} {
|
||||
const combined = Buffer.concat([encryptedCer, encryptedKey, encryptedPassword]);
|
||||
const decrypted = decrypt(combined, iv, tag);
|
||||
|
||||
// Parseamos las partes
|
||||
const cerLengthStr = decrypted.subarray(0, 10).toString();
|
||||
const cerLength = parseInt(cerLengthStr, 10);
|
||||
const cerData = decrypted.subarray(10, 10 + cerLength);
|
||||
|
||||
const keyLengthStr = decrypted.subarray(10 + cerLength, 20 + cerLength).toString();
|
||||
const keyLength = parseInt(keyLengthStr, 10);
|
||||
const keyData = decrypted.subarray(20 + cerLength, 20 + cerLength + keyLength);
|
||||
|
||||
const password = decrypted.subarray(20 + cerLength + keyLength).toString('utf-8');
|
||||
const cerData = decrypt(encryptedCer, cerIv, cerTag);
|
||||
const keyData = decrypt(encryptedKey, keyIv, keyTag);
|
||||
const password = decrypt(encryptedPassword, passwordIv, passwordTag).toString('utf-8');
|
||||
|
||||
return { cerData, keyData, password };
|
||||
}
|
||||
|
||||
408
apps/api/src/services/sat/sat-download.service.ts
Normal file
408
apps/api/src/services/sat/sat-download.service.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import type { Credential } from '@nodecfdi/credentials/node';
|
||||
import type {
|
||||
SatDownloadRequestResponse,
|
||||
SatVerifyResponse,
|
||||
SatPackageResponse,
|
||||
CfdiSyncType
|
||||
} from '@horux/shared';
|
||||
|
||||
const SAT_SOLICITUD_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc';
|
||||
const SAT_VERIFICA_URL = 'https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc';
|
||||
const SAT_DESCARGA_URL = 'https://cfdidescargamasaborrar.clouda.sat.gob.mx/DescargaMasivaService.svc';
|
||||
|
||||
type TipoSolicitud = 'CFDI' | 'Metadata';
|
||||
|
||||
interface RequestDownloadParams {
|
||||
credential: Credential;
|
||||
token: string;
|
||||
rfc: string;
|
||||
fechaInicio: Date;
|
||||
fechaFin: Date;
|
||||
tipoSolicitud: TipoSolicitud;
|
||||
tipoCfdi: CfdiSyncType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatea fecha a formato SAT (YYYY-MM-DDTHH:MM:SS)
|
||||
*/
|
||||
function formatSatDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 19);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el XML de solicitud de descarga
|
||||
*/
|
||||
function buildDownloadRequest(params: RequestDownloadParams): string {
|
||||
const { credential, token, rfc, fechaInicio, fechaFin, tipoSolicitud, tipoCfdi } = params;
|
||||
const uuid = randomUUID();
|
||||
|
||||
const certificate = credential.certificate();
|
||||
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
|
||||
|
||||
// Construir el elemento de solicitud
|
||||
const rfcEmisor = tipoCfdi === 'emitidos' ? rfc : undefined;
|
||||
const rfcReceptor = tipoCfdi === 'recibidos' ? rfc : undefined;
|
||||
|
||||
const solicitudContent = `<des:RfcSolicitante>${rfc}</des:RfcSolicitante>` +
|
||||
`<des:FechaInicial>${formatSatDate(fechaInicio)}</des:FechaInicial>` +
|
||||
`<des:FechaFinal>${formatSatDate(fechaFin)}</des:FechaFinal>` +
|
||||
`<des:TipoSolicitud>${tipoSolicitud}</des:TipoSolicitud>` +
|
||||
(rfcEmisor ? `<des:RfcEmisor>${rfcEmisor}</des:RfcEmisor>` : '') +
|
||||
(rfcReceptor ? `<des:RfcReceptor>${rfcReceptor}</des:RfcReceptor>` : '');
|
||||
|
||||
const solicitudToSign = `<des:SolicitaDescarga xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">${solicitudContent}</des:SolicitaDescarga>`;
|
||||
const digestValue = createHash('sha1').update(solicitudToSign).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="">` +
|
||||
`<Transforms>` +
|
||||
`<Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`</Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx" xmlns:xd="http://www.w3.org/2000/09/xmldsig#">
|
||||
<s:Header/>
|
||||
<s:Body>
|
||||
<des:SolicitaDescarga>
|
||||
<des:solicitud RfcSolicitante="${rfc}" FechaInicial="${formatSatDate(fechaInicio)}" FechaFinal="${formatSatDate(fechaFin)}" TipoSolicitud="${tipoSolicitud}"${rfcEmisor ? ` RfcEmisor="${rfcEmisor}"` : ''}${rfcReceptor ? ` RfcReceptor="${rfcReceptor}"` : ''}>
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509IssuerSerial>
|
||||
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
|
||||
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
|
||||
</X509IssuerSerial>
|
||||
<X509Certificate>${cerB64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</des:solicitud>
|
||||
</des:SolicitaDescarga>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicita la descarga de CFDIs al SAT
|
||||
*/
|
||||
export async function requestDownload(params: RequestDownloadParams): Promise<SatDownloadRequestResponse> {
|
||||
const soapRequest = buildDownloadRequest(params);
|
||||
|
||||
try {
|
||||
const response = await fetch(SAT_SOLICITUD_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml;charset=UTF-8',
|
||||
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/ISolicitaDescargaService/SolicitaDescarga',
|
||||
'Authorization': `WRAP access_token="${params.token}"`,
|
||||
},
|
||||
body: soapRequest,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseXml = await response.text();
|
||||
return parseDownloadRequestResponse(responseXml);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Download Request Error]', error);
|
||||
throw new Error(`Error al solicitar descarga: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea la respuesta de solicitud de descarga
|
||||
*/
|
||||
function parseDownloadRequestResponse(responseXml: string): SatDownloadRequestResponse {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
attributeNamePrefix: '@_',
|
||||
});
|
||||
|
||||
const result = parser.parse(responseXml);
|
||||
const envelope = result.Envelope || result['s:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['s:Body'];
|
||||
const respuesta = body?.SolicitaDescargaResponse?.SolicitaDescargaResult;
|
||||
|
||||
if (!respuesta) {
|
||||
throw new Error('Respuesta inválida del SAT');
|
||||
}
|
||||
|
||||
return {
|
||||
idSolicitud: respuesta['@_IdSolicitud'] || '',
|
||||
codEstatus: respuesta['@_CodEstatus'] || '',
|
||||
mensaje: respuesta['@_Mensaje'] || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica el estado de una solicitud de descarga
|
||||
*/
|
||||
export async function verifyRequest(
|
||||
credential: Credential,
|
||||
token: string,
|
||||
rfc: string,
|
||||
idSolicitud: string
|
||||
): Promise<SatVerifyResponse> {
|
||||
const certificate = credential.certificate();
|
||||
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
|
||||
|
||||
const verificaContent = `<des:VerificaSolicitudDescarga xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx"><des:solicitud IdSolicitud="${idSolicitud}" RfcSolicitante="${rfc}"/></des:VerificaSolicitudDescarga>`;
|
||||
const digestValue = createHash('sha1').update(verificaContent).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="">` +
|
||||
`<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">
|
||||
<s:Header/>
|
||||
<s:Body>
|
||||
<des:VerificaSolicitudDescarga>
|
||||
<des:solicitud IdSolicitud="${idSolicitud}" RfcSolicitante="${rfc}">
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509IssuerSerial>
|
||||
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
|
||||
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
|
||||
</X509IssuerSerial>
|
||||
<X509Certificate>${cerB64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</des:solicitud>
|
||||
</des:VerificaSolicitudDescarga>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
try {
|
||||
const response = await fetch(SAT_VERIFICA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml;charset=UTF-8',
|
||||
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga',
|
||||
'Authorization': `WRAP access_token="${token}"`,
|
||||
},
|
||||
body: soapRequest,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseXml = await response.text();
|
||||
return parseVerifyResponse(responseXml);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Verify Error]', error);
|
||||
throw new Error(`Error al verificar solicitud: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea la respuesta de verificación
|
||||
*/
|
||||
function parseVerifyResponse(responseXml: string): SatVerifyResponse {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
attributeNamePrefix: '@_',
|
||||
});
|
||||
|
||||
const result = parser.parse(responseXml);
|
||||
const envelope = result.Envelope || result['s:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['s:Body'];
|
||||
const respuesta = body?.VerificaSolicitudDescargaResponse?.VerificaSolicitudDescargaResult;
|
||||
|
||||
if (!respuesta) {
|
||||
throw new Error('Respuesta de verificación inválida');
|
||||
}
|
||||
|
||||
// Extraer paquetes
|
||||
let paquetes: string[] = [];
|
||||
const paquetesNode = respuesta.IdsPaquetes;
|
||||
if (paquetesNode) {
|
||||
if (Array.isArray(paquetesNode)) {
|
||||
paquetes = paquetesNode;
|
||||
} else if (typeof paquetesNode === 'string') {
|
||||
paquetes = [paquetesNode];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
codEstatus: respuesta['@_CodEstatus'] || '',
|
||||
estadoSolicitud: parseInt(respuesta['@_EstadoSolicitud'] || '0', 10),
|
||||
codigoEstadoSolicitud: respuesta['@_CodigoEstadoSolicitud'] || '',
|
||||
numeroCfdis: parseInt(respuesta['@_NumeroCFDIs'] || '0', 10),
|
||||
mensaje: respuesta['@_Mensaje'] || '',
|
||||
paquetes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga un paquete de CFDIs
|
||||
*/
|
||||
export async function downloadPackage(
|
||||
credential: Credential,
|
||||
token: string,
|
||||
rfc: string,
|
||||
idPaquete: string
|
||||
): Promise<SatPackageResponse> {
|
||||
const certificate = credential.certificate();
|
||||
const cerB64 = Buffer.from(certificate.pem().replace(/-----.*-----/g, '').replace(/\n/g, '')).toString('base64');
|
||||
|
||||
const descargaContent = `<des:PeticionDescargaMasivaTercerosEntrada xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx"><des:peticionDescarga IdPaquete="${idPaquete}" RfcSolicitante="${rfc}"/></des:PeticionDescargaMasivaTercerosEntrada>`;
|
||||
const digestValue = createHash('sha1').update(descargaContent).digest('base64');
|
||||
|
||||
const signedInfoXml = `<SignedInfo xmlns="http://www.w3.org/2000/09/xmldsig#">` +
|
||||
`<CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>` +
|
||||
`<SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1"/>` +
|
||||
`<Reference URI="">` +
|
||||
`<Transforms><Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></Transforms>` +
|
||||
`<DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"/>` +
|
||||
`<DigestValue>${digestValue}</DigestValue>` +
|
||||
`</Reference>` +
|
||||
`</SignedInfo>`;
|
||||
|
||||
const signatureBinary = credential.sign(signedInfoXml, 'sha1');
|
||||
const signatureValue = Buffer.from(signatureBinary, 'binary').toString('base64');
|
||||
|
||||
const soapRequest = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:des="http://DescargaMasivaTerceros.sat.gob.mx">
|
||||
<s:Header/>
|
||||
<s:Body>
|
||||
<des:PeticionDescargaMasivaTercerosEntrada>
|
||||
<des:peticionDescarga IdPaquete="${idPaquete}" RfcSolicitante="${rfc}">
|
||||
<Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
|
||||
${signedInfoXml}
|
||||
<SignatureValue>${signatureValue}</SignatureValue>
|
||||
<KeyInfo>
|
||||
<X509Data>
|
||||
<X509IssuerSerial>
|
||||
<X509IssuerName>${certificate.issuerAsRfc4514()}</X509IssuerName>
|
||||
<X509SerialNumber>${certificate.serialNumber().bytes()}</X509SerialNumber>
|
||||
</X509IssuerSerial>
|
||||
<X509Certificate>${cerB64}</X509Certificate>
|
||||
</X509Data>
|
||||
</KeyInfo>
|
||||
</Signature>
|
||||
</des:peticionDescarga>
|
||||
</des:PeticionDescargaMasivaTercerosEntrada>
|
||||
</s:Body>
|
||||
</s:Envelope>`;
|
||||
|
||||
try {
|
||||
const response = await fetch(SAT_DESCARGA_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml;charset=UTF-8',
|
||||
'SOAPAction': 'http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaService/Descargar',
|
||||
'Authorization': `WRAP access_token="${token}"`,
|
||||
},
|
||||
body: soapRequest,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Error HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const responseXml = await response.text();
|
||||
return parseDownloadResponse(responseXml);
|
||||
} catch (error: any) {
|
||||
console.error('[SAT Download Package Error]', error);
|
||||
throw new Error(`Error al descargar paquete: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea la respuesta de descarga de paquete
|
||||
*/
|
||||
function parseDownloadResponse(responseXml: string): SatPackageResponse {
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true,
|
||||
attributeNamePrefix: '@_',
|
||||
});
|
||||
|
||||
const result = parser.parse(responseXml);
|
||||
const envelope = result.Envelope || result['s:Envelope'];
|
||||
const body = envelope?.Body || envelope?.['s:Body'];
|
||||
const respuesta = body?.RespuestaDescargaMasivaTercerosSalida?.Paquete;
|
||||
|
||||
if (!respuesta) {
|
||||
throw new Error('No se pudo obtener el paquete');
|
||||
}
|
||||
|
||||
return {
|
||||
paquete: respuesta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Estados de solicitud del SAT
|
||||
*/
|
||||
export const SAT_REQUEST_STATES = {
|
||||
ACCEPTED: 1,
|
||||
IN_PROGRESS: 2,
|
||||
COMPLETED: 3,
|
||||
ERROR: 4,
|
||||
REJECTED: 5,
|
||||
EXPIRED: 6,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Verifica si la solicitud está completa
|
||||
*/
|
||||
export function isRequestComplete(estadoSolicitud: number): boolean {
|
||||
return estadoSolicitud === SAT_REQUEST_STATES.COMPLETED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si la solicitud falló
|
||||
*/
|
||||
export function isRequestFailed(estadoSolicitud: number): boolean {
|
||||
return (
|
||||
estadoSolicitud === SAT_REQUEST_STATES.ERROR ||
|
||||
estadoSolicitud === SAT_REQUEST_STATES.REJECTED ||
|
||||
estadoSolicitud === SAT_REQUEST_STATES.EXPIRED
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si la solicitud está en progreso
|
||||
*/
|
||||
export function isRequestInProgress(estadoSolicitud: number): boolean {
|
||||
return (
|
||||
estadoSolicitud === SAT_REQUEST_STATES.ACCEPTED ||
|
||||
estadoSolicitud === SAT_REQUEST_STATES.IN_PROGRESS
|
||||
);
|
||||
}
|
||||
244
apps/api/src/services/sat/sat-parser.service.ts
Normal file
244
apps/api/src/services/sat/sat-parser.service.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import AdmZip from 'adm-zip';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import type { TipoCfdi, EstadoCfdi } from '@horux/shared';
|
||||
|
||||
interface CfdiParsed {
|
||||
uuidFiscal: string;
|
||||
tipo: TipoCfdi;
|
||||
serie: string | null;
|
||||
folio: string | null;
|
||||
fechaEmision: Date;
|
||||
fechaTimbrado: Date;
|
||||
rfcEmisor: string;
|
||||
nombreEmisor: string;
|
||||
rfcReceptor: string;
|
||||
nombreReceptor: string;
|
||||
subtotal: number;
|
||||
descuento: number;
|
||||
iva: number;
|
||||
isrRetenido: number;
|
||||
ivaRetenido: number;
|
||||
total: number;
|
||||
moneda: string;
|
||||
tipoCambio: number;
|
||||
metodoPago: string | null;
|
||||
formaPago: string | null;
|
||||
usoCfdi: string | null;
|
||||
estado: EstadoCfdi;
|
||||
xmlOriginal: string;
|
||||
}
|
||||
|
||||
interface ExtractedXml {
|
||||
filename: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const xmlParser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
removeNSPrefix: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Extrae archivos XML de un paquete ZIP en base64
|
||||
*/
|
||||
export function extractXmlsFromZip(zipBase64: string): ExtractedXml[] {
|
||||
const zipBuffer = Buffer.from(zipBase64, 'base64');
|
||||
const zip = new AdmZip(zipBuffer);
|
||||
const entries = zip.getEntries();
|
||||
|
||||
const xmlFiles: ExtractedXml[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.entryName.toLowerCase().endsWith('.xml')) {
|
||||
const content = entry.getData().toString('utf-8');
|
||||
xmlFiles.push({
|
||||
filename: entry.entryName,
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return xmlFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapea el tipo de comprobante SAT a nuestro tipo
|
||||
*/
|
||||
function mapTipoCfdi(tipoComprobante: string): TipoCfdi {
|
||||
const mapping: Record<string, TipoCfdi> = {
|
||||
'I': 'ingreso',
|
||||
'E': 'egreso',
|
||||
'T': 'traslado',
|
||||
'P': 'pago',
|
||||
'N': 'nomina',
|
||||
};
|
||||
return mapping[tipoComprobante] || 'ingreso';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae el UUID del TimbreFiscalDigital
|
||||
*/
|
||||
function extractUuid(comprobante: any): string {
|
||||
const complemento = comprobante.Complemento;
|
||||
if (!complemento) return '';
|
||||
|
||||
const timbre = complemento.TimbreFiscalDigital;
|
||||
if (!timbre) return '';
|
||||
|
||||
return timbre['@_UUID'] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae la fecha de timbrado
|
||||
*/
|
||||
function extractFechaTimbrado(comprobante: any): Date {
|
||||
const complemento = comprobante.Complemento;
|
||||
if (!complemento) return new Date();
|
||||
|
||||
const timbre = complemento.TimbreFiscalDigital;
|
||||
if (!timbre) return new Date();
|
||||
|
||||
return new Date(timbre['@_FechaTimbrado']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae los impuestos trasladados (IVA)
|
||||
*/
|
||||
function extractIva(comprobante: any): number {
|
||||
const impuestos = comprobante.Impuestos;
|
||||
if (!impuestos) return 0;
|
||||
|
||||
const traslados = impuestos.Traslados?.Traslado;
|
||||
if (!traslados) return 0;
|
||||
|
||||
const trasladoArray = Array.isArray(traslados) ? traslados : [traslados];
|
||||
|
||||
let totalIva = 0;
|
||||
for (const traslado of trasladoArray) {
|
||||
if (traslado['@_Impuesto'] === '002') { // 002 = IVA
|
||||
totalIva += parseFloat(traslado['@_Importe'] || '0');
|
||||
}
|
||||
}
|
||||
|
||||
return totalIva;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae los impuestos retenidos
|
||||
*/
|
||||
function extractRetenciones(comprobante: any): { isr: number; iva: number } {
|
||||
const impuestos = comprobante.Impuestos;
|
||||
if (!impuestos) return { isr: 0, iva: 0 };
|
||||
|
||||
const retenciones = impuestos.Retenciones?.Retencion;
|
||||
if (!retenciones) return { isr: 0, iva: 0 };
|
||||
|
||||
const retencionArray = Array.isArray(retenciones) ? retenciones : [retenciones];
|
||||
|
||||
let isr = 0;
|
||||
let iva = 0;
|
||||
|
||||
for (const retencion of retencionArray) {
|
||||
const importe = parseFloat(retencion['@_Importe'] || '0');
|
||||
if (retencion['@_Impuesto'] === '001') { // 001 = ISR
|
||||
isr += importe;
|
||||
} else if (retencion['@_Impuesto'] === '002') { // 002 = IVA
|
||||
iva += importe;
|
||||
}
|
||||
}
|
||||
|
||||
return { isr, iva };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsea un XML de CFDI y extrae los datos relevantes
|
||||
*/
|
||||
export function parseXml(xmlContent: string): CfdiParsed | null {
|
||||
try {
|
||||
const result = xmlParser.parse(xmlContent);
|
||||
const comprobante = result.Comprobante;
|
||||
|
||||
if (!comprobante) {
|
||||
console.error('[Parser] No se encontró el nodo Comprobante');
|
||||
return null;
|
||||
}
|
||||
|
||||
const emisor = comprobante.Emisor || {};
|
||||
const receptor = comprobante.Receptor || {};
|
||||
const retenciones = extractRetenciones(comprobante);
|
||||
|
||||
const cfdi: CfdiParsed = {
|
||||
uuidFiscal: extractUuid(comprobante),
|
||||
tipo: mapTipoCfdi(comprobante['@_TipoDeComprobante']),
|
||||
serie: comprobante['@_Serie'] || null,
|
||||
folio: comprobante['@_Folio'] || null,
|
||||
fechaEmision: new Date(comprobante['@_Fecha']),
|
||||
fechaTimbrado: extractFechaTimbrado(comprobante),
|
||||
rfcEmisor: emisor['@_Rfc'] || '',
|
||||
nombreEmisor: emisor['@_Nombre'] || '',
|
||||
rfcReceptor: receptor['@_Rfc'] || '',
|
||||
nombreReceptor: receptor['@_Nombre'] || '',
|
||||
subtotal: parseFloat(comprobante['@_SubTotal'] || '0'),
|
||||
descuento: parseFloat(comprobante['@_Descuento'] || '0'),
|
||||
iva: extractIva(comprobante),
|
||||
isrRetenido: retenciones.isr,
|
||||
ivaRetenido: retenciones.iva,
|
||||
total: parseFloat(comprobante['@_Total'] || '0'),
|
||||
moneda: comprobante['@_Moneda'] || 'MXN',
|
||||
tipoCambio: parseFloat(comprobante['@_TipoCambio'] || '1'),
|
||||
metodoPago: comprobante['@_MetodoPago'] || null,
|
||||
formaPago: comprobante['@_FormaPago'] || null,
|
||||
usoCfdi: receptor['@_UsoCFDI'] || null,
|
||||
estado: 'vigente',
|
||||
xmlOriginal: xmlContent,
|
||||
};
|
||||
|
||||
if (!cfdi.uuidFiscal) {
|
||||
console.error('[Parser] CFDI sin UUID');
|
||||
return null;
|
||||
}
|
||||
|
||||
return cfdi;
|
||||
} catch (error) {
|
||||
console.error('[Parser Error]', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa un paquete ZIP completo y retorna los CFDIs parseados
|
||||
*/
|
||||
export function processPackage(zipBase64: string): CfdiParsed[] {
|
||||
const xmlFiles = extractXmlsFromZip(zipBase64);
|
||||
const cfdis: CfdiParsed[] = [];
|
||||
|
||||
for (const { content } of xmlFiles) {
|
||||
const cfdi = parseXml(content);
|
||||
if (cfdi) {
|
||||
cfdis.push(cfdi);
|
||||
}
|
||||
}
|
||||
|
||||
return cfdis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida que un XML sea un CFDI válido
|
||||
*/
|
||||
export function isValidCfdi(xmlContent: string): boolean {
|
||||
try {
|
||||
const result = xmlParser.parse(xmlContent);
|
||||
const comprobante = result.Comprobante;
|
||||
|
||||
if (!comprobante) return false;
|
||||
if (!comprobante.Complemento?.TimbreFiscalDigital) return false;
|
||||
if (!extractUuid(comprobante)) return false;
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type { CfdiParsed, ExtractedXml };
|
||||
590
apps/api/src/services/sat/sat.service.ts
Normal file
590
apps/api/src/services/sat/sat.service.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
import { prisma, tenantDb } from '../../config/database.js';
|
||||
import { getDecryptedFiel } from '../fiel.service.js';
|
||||
import {
|
||||
createSatService,
|
||||
querySat,
|
||||
verifySatRequest,
|
||||
downloadSatPackage,
|
||||
type FielData,
|
||||
} from './sat-client.service.js';
|
||||
import { processPackage, type CfdiParsed } from './sat-parser.service.js';
|
||||
import type { SatSyncJob, CfdiSyncType, SatSyncType } from '@horux/shared';
|
||||
import type { Service } from '@nodecfdi/sat-ws-descarga-masiva';
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
const POLL_INTERVAL_MS = 30000; // 30 segundos
|
||||
const MAX_POLL_ATTEMPTS = 60; // 30 minutos máximo
|
||||
const YEARS_TO_SYNC = 6; // SAT solo permite descargar últimos 6 años
|
||||
|
||||
interface SyncContext {
|
||||
fielData: FielData;
|
||||
service: Service;
|
||||
rfc: string;
|
||||
tenantId: string;
|
||||
pool: Pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza el progreso de un job
|
||||
*/
|
||||
async function updateJobProgress(
|
||||
jobId: string,
|
||||
updates: Partial<{
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
satRequestId: string;
|
||||
satPackageIds: string[];
|
||||
cfdisFound: number;
|
||||
cfdisDownloaded: number;
|
||||
cfdisInserted: number;
|
||||
cfdisUpdated: number;
|
||||
progressPercent: number;
|
||||
errorMessage: string;
|
||||
startedAt: Date;
|
||||
completedAt: Date;
|
||||
retryCount: number;
|
||||
nextRetryAt: Date;
|
||||
}>
|
||||
): Promise<void> {
|
||||
await prisma.satSyncJob.update({
|
||||
where: { id: jobId },
|
||||
data: updates,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarda los CFDIs en la base de datos del tenant
|
||||
*/
|
||||
async function saveCfdis(
|
||||
pool: Pool,
|
||||
cfdis: CfdiParsed[],
|
||||
jobId: string
|
||||
): Promise<{ inserted: number; updated: number }> {
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (const cfdi of cfdis) {
|
||||
try {
|
||||
const { rows: existing } = await pool.query(
|
||||
`SELECT id FROM cfdis WHERE uuid_fiscal = $1`,
|
||||
[cfdi.uuidFiscal]
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await pool.query(
|
||||
`UPDATE cfdis SET
|
||||
tipo = $2,
|
||||
serie = $3,
|
||||
folio = $4,
|
||||
fecha_emision = $5,
|
||||
fecha_timbrado = $6,
|
||||
rfc_emisor = $7,
|
||||
nombre_emisor = $8,
|
||||
rfc_receptor = $9,
|
||||
nombre_receptor = $10,
|
||||
subtotal = $11,
|
||||
descuento = $12,
|
||||
iva = $13,
|
||||
isr_retenido = $14,
|
||||
iva_retenido = $15,
|
||||
total = $16,
|
||||
moneda = $17,
|
||||
tipo_cambio = $18,
|
||||
metodo_pago = $19,
|
||||
forma_pago = $20,
|
||||
uso_cfdi = $21,
|
||||
estado = $22,
|
||||
xml_original = $23,
|
||||
last_sat_sync = NOW(),
|
||||
sat_sync_job_id = $24::uuid,
|
||||
updated_at = NOW()
|
||||
WHERE uuid_fiscal = $1`,
|
||||
[
|
||||
cfdi.uuidFiscal,
|
||||
cfdi.tipo,
|
||||
cfdi.serie,
|
||||
cfdi.folio,
|
||||
cfdi.fechaEmision,
|
||||
cfdi.fechaTimbrado,
|
||||
cfdi.rfcEmisor,
|
||||
cfdi.nombreEmisor,
|
||||
cfdi.rfcReceptor,
|
||||
cfdi.nombreReceptor,
|
||||
cfdi.subtotal,
|
||||
cfdi.descuento,
|
||||
cfdi.iva,
|
||||
cfdi.isrRetenido,
|
||||
cfdi.ivaRetenido,
|
||||
cfdi.total,
|
||||
cfdi.moneda,
|
||||
cfdi.tipoCambio,
|
||||
cfdi.metodoPago,
|
||||
cfdi.formaPago,
|
||||
cfdi.usoCfdi,
|
||||
cfdi.estado,
|
||||
cfdi.xmlOriginal,
|
||||
jobId
|
||||
]
|
||||
);
|
||||
updated++;
|
||||
} else {
|
||||
await pool.query(
|
||||
`INSERT INTO cfdis (
|
||||
id, uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
|
||||
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
|
||||
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
|
||||
moneda, tipo_cambio, metodo_pago, forma_pago, uso_cfdi, estado,
|
||||
xml_original, source, sat_sync_job_id, last_sat_sync, created_at
|
||||
) VALUES (
|
||||
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,
|
||||
$23, 'sat', $24::uuid, NOW(), NOW()
|
||||
)`,
|
||||
[
|
||||
cfdi.uuidFiscal,
|
||||
cfdi.tipo,
|
||||
cfdi.serie,
|
||||
cfdi.folio,
|
||||
cfdi.fechaEmision,
|
||||
cfdi.fechaTimbrado,
|
||||
cfdi.rfcEmisor,
|
||||
cfdi.nombreEmisor,
|
||||
cfdi.rfcReceptor,
|
||||
cfdi.nombreReceptor,
|
||||
cfdi.subtotal,
|
||||
cfdi.descuento,
|
||||
cfdi.iva,
|
||||
cfdi.isrRetenido,
|
||||
cfdi.ivaRetenido,
|
||||
cfdi.total,
|
||||
cfdi.moneda,
|
||||
cfdi.tipoCambio,
|
||||
cfdi.metodoPago,
|
||||
cfdi.formaPago,
|
||||
cfdi.usoCfdi,
|
||||
cfdi.estado,
|
||||
cfdi.xmlOriginal,
|
||||
jobId
|
||||
]
|
||||
);
|
||||
inserted++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SAT] Error guardando CFDI ${cfdi.uuidFiscal}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return { inserted, updated };
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa una solicitud de descarga para un rango de fechas
|
||||
*/
|
||||
async function processDateRange(
|
||||
ctx: SyncContext,
|
||||
jobId: string,
|
||||
fechaInicio: Date,
|
||||
fechaFin: Date,
|
||||
tipoCfdi: CfdiSyncType
|
||||
): Promise<{ found: number; downloaded: number; inserted: number; updated: number }> {
|
||||
console.log(`[SAT] Procesando ${tipoCfdi} desde ${fechaInicio.toISOString()} hasta ${fechaFin.toISOString()}`);
|
||||
|
||||
const queryResult = await querySat(ctx.service, fechaInicio, fechaFin, tipoCfdi);
|
||||
|
||||
if (!queryResult.success) {
|
||||
if (queryResult.statusCode === '5004') {
|
||||
console.log('[SAT] No se encontraron CFDIs en el rango');
|
||||
return { found: 0, downloaded: 0, inserted: 0, updated: 0 };
|
||||
}
|
||||
throw new Error(`Error SAT: ${queryResult.message}`);
|
||||
}
|
||||
|
||||
const requestId = queryResult.requestId!;
|
||||
console.log(`[SAT] Solicitud creada: ${requestId}`);
|
||||
|
||||
await updateJobProgress(jobId, { satRequestId: requestId });
|
||||
|
||||
let verifyResult;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < MAX_POLL_ATTEMPTS) {
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
attempts++;
|
||||
|
||||
verifyResult = await verifySatRequest(ctx.service, requestId);
|
||||
console.log(`[SAT] Estado solicitud: ${verifyResult.status} (intento ${attempts})`);
|
||||
|
||||
if (verifyResult.status === 'ready') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (verifyResult.status === 'failed' || verifyResult.status === 'rejected') {
|
||||
throw new Error(`Solicitud fallida: ${verifyResult.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!verifyResult || verifyResult.status !== 'ready') {
|
||||
throw new Error('Timeout esperando respuesta del SAT');
|
||||
}
|
||||
|
||||
const packageIds = verifyResult.packageIds;
|
||||
await updateJobProgress(jobId, {
|
||||
satPackageIds: packageIds,
|
||||
cfdisFound: verifyResult.totalCfdis,
|
||||
});
|
||||
|
||||
let totalInserted = 0;
|
||||
let totalUpdated = 0;
|
||||
let totalDownloaded = 0;
|
||||
|
||||
for (let i = 0; i < packageIds.length; i++) {
|
||||
const packageId = packageIds[i];
|
||||
console.log(`[SAT] Descargando paquete ${i + 1}/${packageIds.length}: ${packageId}`);
|
||||
|
||||
const downloadResult = await downloadSatPackage(ctx.service, packageId);
|
||||
|
||||
if (!downloadResult.success) {
|
||||
console.error(`[SAT] Error descargando paquete ${packageId}: ${downloadResult.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cfdis = processPackage(downloadResult.packageContent);
|
||||
totalDownloaded += cfdis.length;
|
||||
|
||||
console.log(`[SAT] Procesando ${cfdis.length} CFDIs del paquete`);
|
||||
|
||||
const { inserted, updated } = await saveCfdis(ctx.pool, cfdis, jobId);
|
||||
totalInserted += inserted;
|
||||
totalUpdated += updated;
|
||||
|
||||
const progress = Math.round(((i + 1) / packageIds.length) * 100);
|
||||
await updateJobProgress(jobId, {
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
progressPercent: progress,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
found: verifyResult.totalCfdis,
|
||||
downloaded: totalDownloaded,
|
||||
inserted: totalInserted,
|
||||
updated: totalUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta sincronización inicial o por rango personalizado
|
||||
*/
|
||||
async function processInitialSync(
|
||||
ctx: SyncContext,
|
||||
jobId: string,
|
||||
customDateFrom?: Date,
|
||||
customDateTo?: Date
|
||||
): Promise<void> {
|
||||
const ahora = new Date();
|
||||
const inicioHistorico = customDateFrom || new Date(ahora.getFullYear() - YEARS_TO_SYNC, ahora.getMonth(), 1);
|
||||
const fechaFin = customDateTo || ahora;
|
||||
|
||||
let totalFound = 0;
|
||||
let totalDownloaded = 0;
|
||||
let totalInserted = 0;
|
||||
let totalUpdated = 0;
|
||||
|
||||
let currentDate = new Date(inicioHistorico);
|
||||
|
||||
while (currentDate < fechaFin) {
|
||||
const monthEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0, 23, 59, 59);
|
||||
const rangeEnd = monthEnd > fechaFin ? fechaFin : monthEnd;
|
||||
|
||||
try {
|
||||
const emitidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'emitidos');
|
||||
totalFound += emitidos.found;
|
||||
totalDownloaded += emitidos.downloaded;
|
||||
totalInserted += emitidos.inserted;
|
||||
totalUpdated += emitidos.updated;
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error procesando emitidos ${currentDate.toISOString()}:`, error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const recibidos = await processDateRange(ctx, jobId, currentDate, rangeEnd, 'recibidos');
|
||||
totalFound += recibidos.found;
|
||||
totalDownloaded += recibidos.downloaded;
|
||||
totalInserted += recibidos.inserted;
|
||||
totalUpdated += recibidos.updated;
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error procesando recibidos ${currentDate.toISOString()}:`, error.message);
|
||||
}
|
||||
|
||||
currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
|
||||
await updateJobProgress(jobId, {
|
||||
cfdisFound: totalFound,
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta sincronización diaria (mes actual)
|
||||
*/
|
||||
async function processDailySync(ctx: SyncContext, jobId: string): Promise<void> {
|
||||
const ahora = new Date();
|
||||
const inicioMes = new Date(ahora.getFullYear(), ahora.getMonth(), 1);
|
||||
|
||||
let totalFound = 0;
|
||||
let totalDownloaded = 0;
|
||||
let totalInserted = 0;
|
||||
let totalUpdated = 0;
|
||||
|
||||
try {
|
||||
const emitidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'emitidos');
|
||||
totalFound += emitidos.found;
|
||||
totalDownloaded += emitidos.downloaded;
|
||||
totalInserted += emitidos.inserted;
|
||||
totalUpdated += emitidos.updated;
|
||||
} catch (error: any) {
|
||||
console.error('[SAT] Error procesando emitidos:', error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
const recibidos = await processDateRange(ctx, jobId, inicioMes, ahora, 'recibidos');
|
||||
totalFound += recibidos.found;
|
||||
totalDownloaded += recibidos.downloaded;
|
||||
totalInserted += recibidos.inserted;
|
||||
totalUpdated += recibidos.updated;
|
||||
} catch (error: any) {
|
||||
console.error('[SAT] Error procesando recibidos:', error.message);
|
||||
}
|
||||
|
||||
await updateJobProgress(jobId, {
|
||||
cfdisFound: totalFound,
|
||||
cfdisDownloaded: totalDownloaded,
|
||||
cfdisInserted: totalInserted,
|
||||
cfdisUpdated: totalUpdated,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicia la sincronización con el SAT
|
||||
*/
|
||||
export async function startSync(
|
||||
tenantId: string,
|
||||
type: SatSyncType = 'daily',
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date
|
||||
): Promise<string> {
|
||||
const decryptedFiel = await getDecryptedFiel(tenantId);
|
||||
if (!decryptedFiel) {
|
||||
throw new Error('No hay FIEL configurada o está vencida');
|
||||
}
|
||||
|
||||
const fielData: FielData = {
|
||||
cerContent: decryptedFiel.cerContent,
|
||||
keyContent: decryptedFiel.keyContent,
|
||||
password: decryptedFiel.password,
|
||||
};
|
||||
|
||||
const service = createSatService(fielData);
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id: tenantId },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
|
||||
if (!tenant) {
|
||||
throw new Error('Tenant no encontrado');
|
||||
}
|
||||
|
||||
const activeSync = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
status: { in: ['pending', 'running'] },
|
||||
},
|
||||
});
|
||||
|
||||
if (activeSync) {
|
||||
throw new Error('Ya hay una sincronización en curso');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const job = await prisma.satSyncJob.create({
|
||||
data: {
|
||||
tenantId,
|
||||
type,
|
||||
status: 'running',
|
||||
dateFrom: dateFrom || new Date(now.getFullYear() - YEARS_TO_SYNC, 0, 1),
|
||||
dateTo: dateTo || now,
|
||||
startedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
const ctx: SyncContext = {
|
||||
fielData,
|
||||
service,
|
||||
rfc: decryptedFiel.rfc,
|
||||
tenantId,
|
||||
pool: tenantDb.getPool(tenantId, tenant.databaseName),
|
||||
};
|
||||
|
||||
// Ejecutar sincronización en background
|
||||
(async () => {
|
||||
try {
|
||||
if (type === 'initial') {
|
||||
await processInitialSync(ctx, job.id, dateFrom, dateTo);
|
||||
} else {
|
||||
await processDailySync(ctx, job.id);
|
||||
}
|
||||
|
||||
await updateJobProgress(job.id, {
|
||||
status: 'completed',
|
||||
completedAt: new Date(),
|
||||
progressPercent: 100,
|
||||
});
|
||||
|
||||
console.log(`[SAT] Sincronización ${job.id} completada`);
|
||||
} catch (error: any) {
|
||||
console.error(`[SAT] Error en sincronización ${job.id}:`, error);
|
||||
await updateJobProgress(job.id, {
|
||||
status: 'failed',
|
||||
errorMessage: error.message,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return job.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el estado actual de sincronización de un tenant
|
||||
*/
|
||||
export async function getSyncStatus(tenantId: string): Promise<{
|
||||
hasActiveSync: boolean;
|
||||
currentJob?: SatSyncJob;
|
||||
lastCompletedJob?: SatSyncJob;
|
||||
totalCfdisSynced: number;
|
||||
}> {
|
||||
const activeJob = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
status: { in: ['pending', 'running'] },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const lastCompleted = await prisma.satSyncJob.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
status: 'completed',
|
||||
},
|
||||
orderBy: { completedAt: 'desc' },
|
||||
});
|
||||
|
||||
const totals = await prisma.satSyncJob.aggregate({
|
||||
where: {
|
||||
tenantId,
|
||||
status: 'completed',
|
||||
},
|
||||
_sum: {
|
||||
cfdisInserted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const mapJob = (job: any): SatSyncJob => ({
|
||||
id: job.id,
|
||||
tenantId: job.tenantId,
|
||||
type: job.type,
|
||||
status: job.status,
|
||||
dateFrom: job.dateFrom.toISOString(),
|
||||
dateTo: job.dateTo.toISOString(),
|
||||
cfdiType: job.cfdiType ?? undefined,
|
||||
satRequestId: job.satRequestId ?? undefined,
|
||||
satPackageIds: job.satPackageIds,
|
||||
cfdisFound: job.cfdisFound,
|
||||
cfdisDownloaded: job.cfdisDownloaded,
|
||||
cfdisInserted: job.cfdisInserted,
|
||||
cfdisUpdated: job.cfdisUpdated,
|
||||
progressPercent: job.progressPercent,
|
||||
errorMessage: job.errorMessage ?? undefined,
|
||||
startedAt: job.startedAt?.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString(),
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
retryCount: job.retryCount,
|
||||
});
|
||||
|
||||
return {
|
||||
hasActiveSync: !!activeJob,
|
||||
currentJob: activeJob ? mapJob(activeJob) : undefined,
|
||||
lastCompletedJob: lastCompleted ? mapJob(lastCompleted) : undefined,
|
||||
totalCfdisSynced: totals._sum.cfdisInserted || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el historial de sincronizaciones
|
||||
*/
|
||||
export async function getSyncHistory(
|
||||
tenantId: string,
|
||||
page: number = 1,
|
||||
limit: number = 10
|
||||
): Promise<{ jobs: SatSyncJob[]; total: number }> {
|
||||
const [jobs, total] = await Promise.all([
|
||||
prisma.satSyncJob.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.satSyncJob.count({ where: { tenantId } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
jobs: jobs.map(job => ({
|
||||
id: job.id,
|
||||
tenantId: job.tenantId,
|
||||
type: job.type,
|
||||
status: job.status,
|
||||
dateFrom: job.dateFrom.toISOString(),
|
||||
dateTo: job.dateTo.toISOString(),
|
||||
cfdiType: job.cfdiType ?? undefined,
|
||||
satRequestId: job.satRequestId ?? undefined,
|
||||
satPackageIds: job.satPackageIds,
|
||||
cfdisFound: job.cfdisFound,
|
||||
cfdisDownloaded: job.cfdisDownloaded,
|
||||
cfdisInserted: job.cfdisInserted,
|
||||
cfdisUpdated: job.cfdisUpdated,
|
||||
progressPercent: job.progressPercent,
|
||||
errorMessage: job.errorMessage ?? undefined,
|
||||
startedAt: job.startedAt?.toISOString(),
|
||||
completedAt: job.completedAt?.toISOString(),
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
retryCount: job.retryCount,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reintenta un job fallido
|
||||
*/
|
||||
export async function retryJob(jobId: string): Promise<string> {
|
||||
const job = await prisma.satSyncJob.findUnique({
|
||||
where: { id: jobId },
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
throw new Error('Job no encontrado');
|
||||
}
|
||||
|
||||
if (job.status !== 'failed') {
|
||||
throw new Error('Solo se pueden reintentar jobs fallidos');
|
||||
}
|
||||
|
||||
return startSync(job.tenantId, job.type, job.dateFrom, job.dateTo);
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { PLANS } from '@horux/shared';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import { randomBytes } from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function getAllTenants() {
|
||||
return prisma.tenant.findMany({
|
||||
@@ -8,7 +12,7 @@ export async function getAllTenants() {
|
||||
nombre: true,
|
||||
rfc: true,
|
||||
plan: true,
|
||||
schemaName: true,
|
||||
databaseName: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: { users: true }
|
||||
@@ -26,7 +30,7 @@ export async function getTenantById(id: string) {
|
||||
nombre: true,
|
||||
rfc: true,
|
||||
plan: true,
|
||||
schemaName: true,
|
||||
databaseName: true,
|
||||
cfdiLimit: true,
|
||||
usersLimit: true,
|
||||
createdAt: true,
|
||||
@@ -40,104 +44,72 @@ export async function createTenant(data: {
|
||||
plan?: 'starter' | 'business' | 'professional' | 'enterprise';
|
||||
cfdiLimit?: number;
|
||||
usersLimit?: number;
|
||||
adminEmail: string;
|
||||
adminNombre: string;
|
||||
amount: number;
|
||||
}) {
|
||||
const schemaName = `tenant_${data.rfc.toLowerCase().replace(/[^a-z0-9]/g, '')}`;
|
||||
const plan = data.plan || 'starter';
|
||||
const planConfig = PLANS[plan];
|
||||
|
||||
// Create tenant record
|
||||
// 1. Provision a dedicated database for this tenant
|
||||
const databaseName = await tenantDb.provisionDatabase(data.rfc);
|
||||
|
||||
// 2. Create tenant record
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
nombre: data.nombre,
|
||||
rfc: data.rfc.toUpperCase(),
|
||||
plan: data.plan || 'starter',
|
||||
schemaName,
|
||||
cfdiLimit: data.cfdiLimit || 500,
|
||||
usersLimit: data.usersLimit || 3,
|
||||
plan,
|
||||
databaseName,
|
||||
cfdiLimit: data.cfdiLimit || planConfig.cfdiLimit,
|
||||
usersLimit: data.usersLimit || planConfig.usersLimit,
|
||||
}
|
||||
});
|
||||
|
||||
// Create schema and tables for the tenant
|
||||
await prisma.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
|
||||
// 3. Create admin user with temp password
|
||||
const tempPassword = randomBytes(4).toString('hex'); // 8-char random
|
||||
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
||||
|
||||
// Create CFDIs table
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."cfdis" (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
uuid_fiscal VARCHAR(36) UNIQUE NOT NULL,
|
||||
tipo VARCHAR(20) NOT NULL,
|
||||
serie VARCHAR(25),
|
||||
folio VARCHAR(40),
|
||||
fecha_emision TIMESTAMP NOT NULL,
|
||||
fecha_timbrado TIMESTAMP NOT NULL,
|
||||
rfc_emisor VARCHAR(13) NOT NULL,
|
||||
nombre_emisor VARCHAR(300) NOT NULL,
|
||||
rfc_receptor VARCHAR(13) NOT NULL,
|
||||
nombre_receptor VARCHAR(300) NOT NULL,
|
||||
subtotal DECIMAL(18,2) NOT NULL,
|
||||
descuento DECIMAL(18,2) DEFAULT 0,
|
||||
iva DECIMAL(18,2) DEFAULT 0,
|
||||
isr_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
iva_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
total DECIMAL(18,2) NOT NULL,
|
||||
moneda VARCHAR(3) DEFAULT 'MXN',
|
||||
tipo_cambio DECIMAL(10,4) DEFAULT 1,
|
||||
metodo_pago VARCHAR(3),
|
||||
forma_pago VARCHAR(2),
|
||||
uso_cfdi VARCHAR(4),
|
||||
estado VARCHAR(20) DEFAULT 'vigente',
|
||||
xml_url TEXT,
|
||||
pdf_url TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
email: data.adminEmail,
|
||||
passwordHash: hashedPassword,
|
||||
nombre: data.adminNombre,
|
||||
role: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
// Create IVA monthly table
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."iva_mensual" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
año INT NOT NULL,
|
||||
mes INT NOT NULL,
|
||||
iva_trasladado DECIMAL(18,2) NOT NULL,
|
||||
iva_acreditable DECIMAL(18,2) NOT NULL,
|
||||
iva_retenido DECIMAL(18,2) DEFAULT 0,
|
||||
resultado DECIMAL(18,2) NOT NULL,
|
||||
acumulado DECIMAL(18,2) NOT NULL,
|
||||
estado VARCHAR(20) DEFAULT 'pendiente',
|
||||
fecha_declaracion TIMESTAMP,
|
||||
UNIQUE(año, mes)
|
||||
)
|
||||
`);
|
||||
// 4. Create initial subscription
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
plan,
|
||||
status: 'pending',
|
||||
amount: data.amount,
|
||||
frequency: 'monthly',
|
||||
},
|
||||
});
|
||||
|
||||
// Create alerts table
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."alertas" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tipo VARCHAR(50) NOT NULL,
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
mensaje TEXT NOT NULL,
|
||||
prioridad VARCHAR(20) DEFAULT 'media',
|
||||
fecha_vencimiento TIMESTAMP,
|
||||
leida BOOLEAN DEFAULT FALSE,
|
||||
resuelta BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
// 5. Send welcome email to client (non-blocking)
|
||||
emailService.sendWelcome(data.adminEmail, {
|
||||
nombre: data.adminNombre,
|
||||
email: data.adminEmail,
|
||||
tempPassword,
|
||||
}).catch(err => console.error('[EMAIL] Welcome email failed:', err));
|
||||
|
||||
// Create calendario_fiscal table
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" (
|
||||
id SERIAL PRIMARY KEY,
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
descripcion TEXT,
|
||||
tipo VARCHAR(20) NOT NULL,
|
||||
fecha_limite TIMESTAMP NOT NULL,
|
||||
recurrencia VARCHAR(20) DEFAULT 'mensual',
|
||||
completado BOOLEAN DEFAULT FALSE,
|
||||
notas TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
// 6. Send new client notification to admin with DB credentials
|
||||
emailService.sendNewClientAdmin({
|
||||
clienteNombre: data.nombre,
|
||||
clienteRfc: data.rfc.toUpperCase(),
|
||||
adminEmail: data.adminEmail,
|
||||
adminNombre: data.adminNombre,
|
||||
tempPassword,
|
||||
databaseName,
|
||||
plan,
|
||||
}).catch(err => console.error('[EMAIL] New client admin email failed:', err));
|
||||
|
||||
return tenant;
|
||||
return { tenant, user, tempPassword };
|
||||
}
|
||||
|
||||
export async function updateTenant(id: string, data: {
|
||||
@@ -163,7 +135,7 @@ export async function updateTenant(id: string, data: {
|
||||
nombre: true,
|
||||
rfc: true,
|
||||
plan: true,
|
||||
schemaName: true,
|
||||
databaseName: true,
|
||||
cfdiLimit: true,
|
||||
usersLimit: true,
|
||||
active: true,
|
||||
@@ -173,9 +145,20 @@ export async function updateTenant(id: string, data: {
|
||||
}
|
||||
|
||||
export async function deleteTenant(id: string) {
|
||||
// Soft delete - just mark as inactive
|
||||
return prisma.tenant.update({
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
select: { databaseName: true },
|
||||
});
|
||||
|
||||
// Soft-delete the tenant record
|
||||
await prisma.tenant.update({
|
||||
where: { id },
|
||||
data: { active: false }
|
||||
});
|
||||
|
||||
// Soft-delete the database (rename with _deleted_ suffix)
|
||||
if (tenant) {
|
||||
await tenantDb.deprovisionDatabase(tenant.databaseName);
|
||||
tenantDb.invalidatePool(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '../config/database.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';
|
||||
|
||||
export async function getUsuarios(tenantId: string): Promise<UserListItem[]> {
|
||||
@@ -37,8 +38,8 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
|
||||
throw new Error('Límite de usuarios alcanzado para este plan');
|
||||
}
|
||||
|
||||
// Generate temporary password
|
||||
const tempPassword = Math.random().toString(36).slice(-8);
|
||||
// Generate cryptographically secure temporary password
|
||||
const tempPassword = randomBytes(4).toString('hex');
|
||||
const passwordHash = await bcrypt.hash(tempPassword, 12);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
@@ -60,8 +61,7 @@ export async function inviteUsuario(tenantId: string, data: UserInvite): Promise
|
||||
},
|
||||
});
|
||||
|
||||
// In production, send email with tempPassword
|
||||
console.log(`Temporary password for ${data.email}: ${tempPassword}`);
|
||||
// TODO: Send email with tempPassword to the invited user
|
||||
|
||||
return {
|
||||
...user,
|
||||
@@ -105,3 +105,93 @@ export async function deleteUsuario(tenantId: string, userId: string): Promise<v
|
||||
where: { id: userId, tenantId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene todos los usuarios de todas las empresas (solo admin global)
|
||||
*/
|
||||
export async function getAllUsuarios(): Promise<UserListItem[]> {
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nombre: true,
|
||||
role: true,
|
||||
active: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
tenantId: true,
|
||||
tenant: {
|
||||
select: {
|
||||
nombre: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ tenant: { nombre: 'asc' } }, { createdAt: 'desc' }],
|
||||
});
|
||||
|
||||
return users.map(u => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
nombre: u.nombre,
|
||||
role: u.role,
|
||||
active: u.active,
|
||||
lastLogin: u.lastLogin?.toISOString() || null,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
tenantId: u.tenantId,
|
||||
tenantName: u.tenant.nombre,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Actualiza un usuario globalmente (puede cambiar de tenant)
|
||||
*/
|
||||
export async function updateUsuarioGlobal(
|
||||
userId: string,
|
||||
data: UserUpdate & { tenantId?: string }
|
||||
): Promise<UserListItem> {
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(data.nombre && { nombre: data.nombre }),
|
||||
...(data.role && { role: data.role }),
|
||||
...(data.active !== undefined && { active: data.active }),
|
||||
...(data.tenantId && { tenantId: data.tenantId }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
nombre: true,
|
||||
role: true,
|
||||
active: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
tenantId: true,
|
||||
tenant: {
|
||||
select: {
|
||||
nombre: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nombre: user.nombre,
|
||||
role: user.role,
|
||||
active: user.active,
|
||||
lastLogin: user.lastLogin?.toISOString() || null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
tenantId: user.tenantId,
|
||||
tenantName: user.tenant.nombre,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina un usuario globalmente
|
||||
*/
|
||||
export async function deleteUsuarioGlobal(userId: string): Promise<void> {
|
||||
await prisma.user.delete({
|
||||
where: { id: userId },
|
||||
});
|
||||
}
|
||||
|
||||
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 { env } from '../config/env.js';
|
||||
|
||||
export function generateAccessToken(payload: Omit<JWTPayload, 'iat' | 'exp'>): string {
|
||||
return jwt.sign(payload, env.JWT_SECRET, {
|
||||
expiresIn: env.JWT_EXPIRES_IN,
|
||||
});
|
||||
const options: SignOptions = {
|
||||
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 {
|
||||
return jwt.sign(payload, env.JWT_SECRET, {
|
||||
expiresIn: env.JWT_REFRESH_EXPIRES_IN,
|
||||
});
|
||||
const options: SignOptions = {
|
||||
expiresIn: env.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'],
|
||||
};
|
||||
return jwt.sign(payload, env.JWT_SECRET, options);
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JWTPayload {
|
||||
|
||||
@@ -30,7 +30,11 @@ export default function LoginPage() {
|
||||
const response = await login({ email, password });
|
||||
setTokens(response.accessToken, response.refreshToken);
|
||||
setUser(response.user);
|
||||
router.push('/dashboard');
|
||||
|
||||
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
|
||||
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||
|
||||
router.push(seen ? '/dashboard' : '/onboarding');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.message || 'Error al iniciar sesión');
|
||||
} finally {
|
||||
|
||||
305
apps/web/app/(dashboard)/admin/usuarios/page.tsx
Normal file
305
apps/web/app/(dashboard)/admin/usuarios/page.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { DashboardShell } from '@/components/layouts/dashboard-shell';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useAllUsuarios, useUpdateUsuarioGlobal, useDeleteUsuarioGlobal } from '@/lib/hooks/use-usuarios';
|
||||
import { getTenants, type Tenant } from '@/lib/api/tenants';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { Users, Pencil, Trash2, Shield, Eye, Calculator, Building2, X, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const roleLabels = {
|
||||
admin: { label: 'Administrador', icon: Shield, color: 'text-primary' },
|
||||
contador: { label: 'Contador', icon: Calculator, color: 'text-green-600' },
|
||||
visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' },
|
||||
};
|
||||
|
||||
interface EditingUser {
|
||||
id: string;
|
||||
nombre: string;
|
||||
role: 'admin' | 'contador' | 'visor';
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
export default function AdminUsuariosPage() {
|
||||
const { user: currentUser } = useAuthStore();
|
||||
const { data: usuarios, isLoading, error } = useAllUsuarios();
|
||||
const updateUsuario = useUpdateUsuarioGlobal();
|
||||
const deleteUsuario = useDeleteUsuarioGlobal();
|
||||
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [editingUser, setEditingUser] = useState<EditingUser | null>(null);
|
||||
const [filterTenant, setFilterTenant] = useState<string>('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getTenants().then(setTenants).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handleEdit = (usuario: any) => {
|
||||
setEditingUser({
|
||||
id: usuario.id,
|
||||
nombre: usuario.nombre,
|
||||
role: usuario.role,
|
||||
tenantId: usuario.tenantId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingUser) return;
|
||||
try {
|
||||
await updateUsuario.mutateAsync({
|
||||
id: editingUser.id,
|
||||
data: {
|
||||
nombre: editingUser.nombre,
|
||||
role: editingUser.role,
|
||||
tenantId: editingUser.tenantId,
|
||||
},
|
||||
});
|
||||
setEditingUser(null);
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Error al actualizar usuario');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Estas seguro de eliminar este usuario?')) return;
|
||||
try {
|
||||
await deleteUsuario.mutateAsync(id);
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.error || 'Error al eliminar usuario');
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsuarios = usuarios?.filter(u => {
|
||||
const matchesTenant = filterTenant === 'all' || u.tenantId === filterTenant;
|
||||
const matchesSearch = !searchTerm ||
|
||||
u.nombre.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
return matchesTenant && matchesSearch;
|
||||
});
|
||||
|
||||
// Agrupar por empresa
|
||||
const groupedByTenant = filteredUsuarios?.reduce((acc, u) => {
|
||||
const key = u.tenantId || 'sin-empresa';
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
tenantName: u.tenantName || 'Sin empresa',
|
||||
users: [],
|
||||
};
|
||||
}
|
||||
acc[key].users.push(u);
|
||||
return acc;
|
||||
}, {} as Record<string, { tenantName: string; users: typeof filteredUsuarios }>);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<DashboardShell title="Administracion de Usuarios">
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center">
|
||||
<p className="text-destructive">
|
||||
No tienes permisos para ver esta pagina o ocurrio un error.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell title="Administracion de Usuarios">
|
||||
<div className="space-y-4">
|
||||
{/* Filtros */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
placeholder="Buscar por nombre o email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[250px]">
|
||||
<Select value={filterTenant} onValueChange={setFilterTenant}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Filtrar por empresa" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas las empresas</SelectItem>
|
||||
{tenants.map(t => (
|
||||
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
<span className="font-medium">{filteredUsuarios?.length || 0} usuarios</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="text-sm">{Object.keys(groupedByTenant || {}).length} empresas</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users by tenant */}
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground">
|
||||
Cargando usuarios...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
Object.entries(groupedByTenant || {}).map(([tenantId, { tenantName, users }]) => (
|
||||
<Card key={tenantId}>
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4" />
|
||||
{tenantName}
|
||||
<span className="text-muted-foreground font-normal text-sm">
|
||||
({users?.length} usuarios)
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y">
|
||||
{users?.map(usuario => {
|
||||
const roleInfo = roleLabels[usuario.role];
|
||||
const RoleIcon = roleInfo.icon;
|
||||
const isCurrentUser = usuario.id === currentUser?.id;
|
||||
const isEditing = editingUser?.id === usuario.id;
|
||||
|
||||
return (
|
||||
<div key={usuario.id} className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className={cn(
|
||||
'w-10 h-10 rounded-full flex items-center justify-center',
|
||||
'bg-primary/10 text-primary font-medium'
|
||||
)}>
|
||||
{usuario.nombre.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={editingUser.nombre}
|
||||
onChange={(e) => setEditingUser({ ...editingUser, nombre: e.target.value })}
|
||||
className="h-8"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={editingUser.role}
|
||||
onValueChange={(v) => setEditingUser({ ...editingUser, role: v as any })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">Administrador</SelectItem>
|
||||
<SelectItem value="contador">Contador</SelectItem>
|
||||
<SelectItem value="visor">Visor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={editingUser.tenantId}
|
||||
onValueChange={(v) => setEditingUser({ ...editingUser, tenantId: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tenants.map(t => (
|
||||
<SelectItem key={t.id} value={t.id}>{t.nombre}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{usuario.nombre}</span>
|
||||
{isCurrentUser && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded">Tu</span>
|
||||
)}
|
||||
{!usuario.active && (
|
||||
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded">Inactivo</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{usuario.email}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{!isEditing && (
|
||||
<div className={cn('flex items-center gap-1', roleInfo.color)}>
|
||||
<RoleIcon className="h-4 w-4" />
|
||||
<span className="text-sm">{roleInfo.label}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isCurrentUser && (
|
||||
<div className="flex gap-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSave}
|
||||
disabled={updateUsuario.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setEditingUser(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEdit(usuario)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(usuario.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -8,10 +9,15 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
|
||||
import { createManyCfdis } from '@/lib/api/cfdi';
|
||||
import type { CfdiFilters, TipoCfdi } from '@horux/shared';
|
||||
import { createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } from '@/lib/api/cfdi';
|
||||
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||
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 { useTenantViewStore } from '@/stores/tenant-view-store';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
@@ -228,7 +234,49 @@ export default function CfdiPage() {
|
||||
limit: 20,
|
||||
});
|
||||
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);
|
||||
|
||||
// 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 [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
|
||||
const [bulkData, setBulkData] = useState('');
|
||||
@@ -255,12 +303,138 @@ export default function CfdiPage() {
|
||||
const createCfdi = useCreateCfdi();
|
||||
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 handleSearch = () => {
|
||||
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) => {
|
||||
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 = () => {
|
||||
uploadAbortRef.current = true;
|
||||
setUploadProgress(prev => ({ ...prev, status: 'idle' }));
|
||||
@@ -558,8 +758,19 @@ export default function CfdiPage() {
|
||||
Egresos
|
||||
</Button>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<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); }}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Agregar
|
||||
@@ -568,9 +779,10 @@ export default function CfdiPage() {
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
Carga Masiva
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1041,15 +1253,58 @@ export default function CfdiPage() {
|
||||
{/* Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4" />
|
||||
CFDIs ({data?.total || 0})
|
||||
</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>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Cargando...
|
||||
<div className="space-y-3">
|
||||
{/* 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>
|
||||
) : data?.data.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
@@ -1060,13 +1315,172 @@ export default function CfdiPage() {
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<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">Folio</th>
|
||||
<th className="pb-3 font-medium">Emisor</th>
|
||||
<th className="pb-3 font-medium">Receptor</th>
|
||||
<th className="pb-3 font-medium">
|
||||
<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">Estado</th>
|
||||
<th className="pb-3 font-medium"></th>
|
||||
{canEdit && <th className="pb-3 font-medium"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1122,6 +1536,21 @@ export default function CfdiPage() {
|
||||
{cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
|
||||
</span>
|
||||
</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 && (
|
||||
<td className="py-3">
|
||||
<Button
|
||||
@@ -1174,6 +1603,12 @@ export default function CfdiPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
|
||||
<CfdiViewerModal
|
||||
cfdi={viewingCfdi}
|
||||
open={viewingCfdi !== null}
|
||||
onClose={() => setViewingCfdi(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { themes, type ThemeName } from '@/themes';
|
||||
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles } from 'lucide-react';
|
||||
import { Check, Palette, User, Building, Sidebar, PanelTop, Minimize2, Sparkles, RefreshCw } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const themeOptions: { name: ThemeName; label: string; description: string; layoutDesc: string; layoutIcon: typeof Sidebar }[] = [
|
||||
{
|
||||
@@ -90,6 +91,26 @@ export default function ConfiguracionPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SAT Configuration */}
|
||||
<Link href="/configuracion/sat">
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Sincronizacion SAT
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configura tu FIEL y la sincronizacion automatica de CFDIs con el SAT
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Descarga automaticamente tus facturas emitidas y recibidas directamente del portal del SAT.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
{/* Theme Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
156
apps/web/app/(dashboard)/configuracion/sat/page.tsx
Normal file
156
apps/web/app/(dashboard)/configuracion/sat/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FielUploadModal } from '@/components/sat/FielUploadModal';
|
||||
import { SyncStatus } from '@/components/sat/SyncStatus';
|
||||
import { SyncHistory } from '@/components/sat/SyncHistory';
|
||||
import { getFielStatus, deleteFiel } from '@/lib/api/fiel';
|
||||
import type { FielStatus } from '@horux/shared';
|
||||
|
||||
export default function SatConfigPage() {
|
||||
const [fielStatus, setFielStatus] = useState<FielStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fetchFielStatus = async () => {
|
||||
try {
|
||||
const status = await getFielStatus();
|
||||
setFielStatus(status);
|
||||
} catch (err) {
|
||||
console.error('Error fetching FIEL status:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFielStatus();
|
||||
}, []);
|
||||
|
||||
const handleUploadSuccess = (status: FielStatus) => {
|
||||
setFielStatus(status);
|
||||
setShowUploadModal(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Estas seguro de eliminar la FIEL? Se detendran las sincronizaciones automaticas.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteFiel();
|
||||
setFielStatus({ configured: false });
|
||||
} catch (err) {
|
||||
console.error('Error deleting FIEL:', err);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Configuracion SAT</h1>
|
||||
<p>Cargando...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Configuracion SAT</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gestiona tu FIEL y la sincronizacion automatica de CFDIs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Estado de la FIEL */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>FIEL (e.firma)</CardTitle>
|
||||
<CardDescription>
|
||||
Tu firma electronica para autenticarte con el SAT
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{fielStatus?.configured ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">RFC</p>
|
||||
<p className="font-medium">{fielStatus.rfc}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">No. Serie</p>
|
||||
<p className="font-medium text-xs">{fielStatus.serialNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Vigente hasta</p>
|
||||
<p className="font-medium">
|
||||
{fielStatus.validUntil ? new Date(fielStatus.validUntil).toLocaleDateString('es-MX') : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Estado</p>
|
||||
<p className={`font-medium ${fielStatus.isExpired ? 'text-red-500' : 'text-green-500'}`}>
|
||||
{fielStatus.isExpired ? 'Vencida' : `Valida (${fielStatus.daysUntilExpiration} dias)`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
>
|
||||
Actualizar FIEL
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? 'Eliminando...' : 'Eliminar FIEL'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
No tienes una FIEL configurada. Sube tu certificado y llave privada para habilitar
|
||||
la sincronizacion automatica de CFDIs con el SAT.
|
||||
</p>
|
||||
<Button onClick={() => setShowUploadModal(true)}>
|
||||
Configurar FIEL
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Estado de Sincronizacion */}
|
||||
<SyncStatus
|
||||
fielConfigured={fielStatus?.configured || false}
|
||||
onSyncStarted={fetchFielStatus}
|
||||
/>
|
||||
|
||||
{/* Historial */}
|
||||
<SyncHistory fielConfigured={fielStatus?.configured || false} />
|
||||
|
||||
{/* Modal de carga */}
|
||||
{showUploadModal && (
|
||||
<FielUploadModal
|
||||
onSuccess={handleUploadSuccess}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
apps/web/app/onboarding/page.tsx
Normal file
5
apps/web/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import OnboardingScreen from "../../components/onboarding/OnboardingScreen";
|
||||
|
||||
export default function Page() {
|
||||
return <OnboardingScreen />;
|
||||
}
|
||||
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';
|
||||
|
||||
interface DashboardShellProps {
|
||||
@@ -8,13 +7,12 @@ interface 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 (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Sidebar />
|
||||
<div className="pl-64">
|
||||
<>
|
||||
<Header title={title}>{headerContent}</Header>
|
||||
<main className="p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,24 +15,36 @@ import {
|
||||
Bell,
|
||||
Users,
|
||||
Building2,
|
||||
UserCog,
|
||||
CreditCard,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { logout } from '@/lib/api/auth';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { hasFeature, type Plan } from '@horux/shared';
|
||||
|
||||
const navigation = [
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: typeof LayoutDashboard;
|
||||
feature?: string; // Required plan feature — hidden if tenant's plan lacks it
|
||||
}
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'CFDI', href: '/cfdi', icon: FileText },
|
||||
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
|
||||
{ name: 'Reportes', href: '/reportes', icon: BarChart3 },
|
||||
{ name: 'Calendario', href: '/calendario', icon: Calendar },
|
||||
{ name: 'Alertas', href: '/alertas', icon: Bell },
|
||||
{ name: 'Reportes', href: '/reportes', icon: BarChart3, feature: 'reportes' },
|
||||
{ name: 'Calendario', href: '/calendario', icon: Calendar, feature: 'calendario' },
|
||||
{ name: 'Alertas', href: '/alertas', icon: Bell, feature: 'alertas' },
|
||||
{ name: 'Usuarios', href: '/usuarios', icon: Users },
|
||||
{ name: 'Suscripción', href: '/configuracion/suscripcion', icon: CreditCard },
|
||||
{ name: 'Configuracion', href: '/configuracion', icon: Settings },
|
||||
];
|
||||
|
||||
const adminNavigation = [
|
||||
const adminNavigation: NavItem[] = [
|
||||
{ name: 'Clientes', href: '/clientes', icon: Building2 },
|
||||
{ name: 'Admin Usuarios', href: '/admin/usuarios', icon: UserCog },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
@@ -51,9 +63,15 @@ export function Sidebar() {
|
||||
}
|
||||
};
|
||||
|
||||
// Filter navigation based on tenant plan features
|
||||
const plan = (user?.plan || 'starter') as Plan;
|
||||
const filteredNav = navigation.filter(
|
||||
(item) => !item.feature || hasFeature(plan, item.feature)
|
||||
);
|
||||
|
||||
const allNavigation = user?.role === 'admin'
|
||||
? [...navigation.slice(0, -1), ...adminNavigation, navigation[navigation.length - 1]]
|
||||
: navigation;
|
||||
? [...filteredNav.slice(0, -1), ...adminNavigation, filteredNav[filteredNav.length - 1]]
|
||||
: filteredNav;
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
|
||||
|
||||
190
apps/web/components/onboarding/OnboardingScreen.tsx
Normal file
190
apps/web/components/onboarding/OnboardingScreen.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
/**
|
||||
* Onboarding persistence key.
|
||||
* If you later want this to come from env/config, move it to apps/web/config/onboarding.ts
|
||||
*/
|
||||
const STORAGE_KEY = 'horux360:onboarding_seen_v1';
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
const { isAuthenticated, _hasHydrated } = useAuthStore();
|
||||
const [isNewUser, setIsNewUser] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const safePush = (path: string) => {
|
||||
// Avoid multiple navigations if user clicks quickly.
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (_hasHydrated && !isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, _hasHydrated, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const seen = typeof window !== 'undefined' && localStorage.getItem(STORAGE_KEY) === '1';
|
||||
|
||||
// If the user has already seen onboarding, go to dashboard automatically.
|
||||
if (seen) {
|
||||
setIsNewUser(false);
|
||||
setLoading(true);
|
||||
const t = setTimeout(() => router.push('/dashboard'), 900);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleContinue = () => {
|
||||
if (typeof window !== 'undefined') localStorage.setItem(STORAGE_KEY, '1');
|
||||
setLoading(true);
|
||||
setTimeout(() => router.push('/dashboard'), 700);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (typeof window !== 'undefined') localStorage.removeItem(STORAGE_KEY);
|
||||
location.reload();
|
||||
};
|
||||
|
||||
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 (
|
||||
<main className="min-h-screen relative overflow-hidden bg-white">
|
||||
{/* Grid tech claro */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.05]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(to right, rgba(15,23,42,.2) 1px, transparent 1px), linear-gradient(to bottom, rgba(15,23,42,.2) 1px, transparent 1px)',
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Glow global azul (sutil) */}
|
||||
<div className="absolute -top-24 left-1/2 h-72 w-[42rem] -translate-x-1/2 rounded-full bg-blue-500/20 blur-3xl" />
|
||||
|
||||
<div className="relative z-10 flex min-h-screen items-center justify-center p-6">
|
||||
<div className="w-full max-w-4xl">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-blue-500" />
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<p className="text-sm font-semibold text-slate-800">Horux360</p>
|
||||
<p className="text-xs text-slate-500">Pantalla de inicio</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-slate-500">{headerStatus}</span>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 md:p-8">
|
||||
{isNewUser ? (
|
||||
<div className="grid gap-8 md:grid-cols-2 md:items-center">
|
||||
{/* Left */}
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-semibold text-slate-900">
|
||||
Bienvenido a Horux360
|
||||
</h1>
|
||||
<p className="mt-2 text-sm md:text-base text-slate-600 max-w-md">
|
||||
Revisa este breve video para conocer el flujo. Después podrás continuar.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 disabled:opacity-60 disabled:cursor-not-allowed text-white px-6 py-3 rounded-xl font-semibold shadow-md hover:bg-blue-700 hover:shadow-lg transition-all"
|
||||
>
|
||||
{loading ? 'Cargando…' : 'Continuar'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => safePush('/login')}
|
||||
disabled={loading}
|
||||
className="px-5 py-3 rounded-xl font-medium text-slate-700 border border-slate-300 hover:bg-slate-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
Ver más
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-xs text-slate-500">
|
||||
Usuario nuevo: muestra video • Usuario recurrente: redirección automática
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right (video) - elegante sin glow */}
|
||||
<div className="relative">
|
||||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="h-1 w-full rounded-t-2xl bg-gradient-to-r from-blue-600/80 via-blue-500/40 to-transparent" />
|
||||
<div className="p-3">
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 overflow-hidden">
|
||||
<video src="/video-intro.mp4" controls className="w-full rounded-xl" />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-blue-500" />
|
||||
Video introductorio
|
||||
</span>
|
||||
<span>v1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-center">
|
||||
<div className="h-12 w-12 rounded-2xl bg-blue-600/10 border border-blue-500/30 flex items-center justify-center">
|
||||
<div className="h-3 w-3 rounded-full bg-blue-500 animate-pulse" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-lg font-semibold text-slate-800">
|
||||
Redirigiendo al dashboard…
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-slate-600">Usuario recurrente detectado.</p>
|
||||
|
||||
<div className="mt-6 w-full max-w-sm h-2 rounded-full bg-slate-200 overflow-hidden border border-slate-300">
|
||||
<div className="h-full w-2/3 bg-blue-600/80 animate-pulse" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="mt-6 text-xs text-slate-500 hover:text-slate-700 underline underline-offset-4"
|
||||
>
|
||||
Ver video otra vez (reset demo)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
138
apps/web/components/sat/FielUploadModal.tsx
Normal file
138
apps/web/components/sat/FielUploadModal.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { uploadFiel } from '@/lib/api/fiel';
|
||||
import type { FielStatus } from '@horux/shared';
|
||||
|
||||
interface FielUploadModalProps {
|
||||
onSuccess: (status: FielStatus) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function FielUploadModal({ onSuccess, onClose }: FielUploadModalProps) {
|
||||
const [cerFile, setCerFile] = useState<File | null>(null);
|
||||
const [keyFile, setKeyFile] = useState<File | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
// Remove data URL prefix (e.g., "data:application/x-x509-ca-cert;base64,")
|
||||
const base64 = result.split(',')[1];
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!cerFile || !keyFile || !password) {
|
||||
setError('Todos los campos son requeridos');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const cerBase64 = await fileToBase64(cerFile);
|
||||
const keyBase64 = await fileToBase64(keyFile);
|
||||
|
||||
const result = await uploadFiel({
|
||||
cerFile: cerBase64,
|
||||
keyFile: keyBase64,
|
||||
password,
|
||||
});
|
||||
|
||||
if (result.status) {
|
||||
onSuccess(result.status);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Error al subir la FIEL');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cerFile, keyFile, password, onSuccess]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<Card className="w-full max-w-md mx-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Configurar FIEL (e.firma)</CardTitle>
|
||||
<CardDescription>
|
||||
Sube tu certificado y llave privada para sincronizar CFDIs con el SAT
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cer">Certificado (.cer)</Label>
|
||||
<Input
|
||||
id="cer"
|
||||
type="file"
|
||||
accept=".cer"
|
||||
onChange={(e) => setCerFile(e.target.files?.[0] || null)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="key">Llave Privada (.key)</Label>
|
||||
<Input
|
||||
id="key"
|
||||
type="file"
|
||||
accept=".key"
|
||||
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Contrasena de la llave</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Ingresa la contrasena de tu FIEL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? 'Subiendo...' : 'Configurar FIEL'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
apps/web/components/sat/SyncHistory.tsx
Normal file
182
apps/web/components/sat/SyncHistory.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getSyncHistory, retrySync } from '@/lib/api/sat';
|
||||
import type { SatSyncJob } from '@horux/shared';
|
||||
|
||||
interface SyncHistoryProps {
|
||||
fielConfigured: boolean;
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pendiente',
|
||||
running: 'En progreso',
|
||||
completed: 'Completado',
|
||||
failed: 'Fallido',
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
running: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
initial: 'Inicial',
|
||||
daily: 'Diaria',
|
||||
};
|
||||
|
||||
export function SyncHistory({ fielConfigured }: SyncHistoryProps) {
|
||||
const [jobs, setJobs] = useState<SatSyncJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const limit = 10;
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const data = await getSyncHistory(page, limit);
|
||||
setJobs(data.jobs);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
console.error('Error fetching sync history:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fielConfigured) {
|
||||
fetchHistory();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fielConfigured, page]);
|
||||
|
||||
const handleRetry = async (jobId: string) => {
|
||||
try {
|
||||
await retrySync(jobId);
|
||||
fetchHistory();
|
||||
} catch (err) {
|
||||
console.error('Error retrying job:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (!fielConfigured) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Historial de Sincronizaciones</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">Cargando historial...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (jobs.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Historial de Sincronizaciones</CardTitle>
|
||||
<CardDescription>
|
||||
Registro de todas las sincronizaciones con el SAT
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">No hay sincronizaciones registradas.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Historial de Sincronizaciones</CardTitle>
|
||||
<CardDescription>
|
||||
Registro de todas las sincronizaciones con el SAT
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{jobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${statusColors[job.status]}`}>
|
||||
{statusLabels[job.status]}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{typeLabels[job.type]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{job.startedAt ? new Date(job.startedAt).toLocaleString('es-MX') : 'No iniciado'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{job.cfdisInserted} nuevos, {job.cfdisUpdated} actualizados
|
||||
</p>
|
||||
{job.errorMessage && (
|
||||
<p className="text-xs text-red-500 mt-1">{job.errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
{job.status === 'failed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleRetry(job.id)}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
)}
|
||||
{job.status === 'running' && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{job.progressPercent}%</p>
|
||||
<p className="text-xs text-muted-foreground">{job.cfdisDownloaded} descargados</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="py-2 px-3 text-sm">
|
||||
Pagina {page} de {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page === totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
253
apps/web/components/sat/SyncStatus.tsx
Normal file
253
apps/web/components/sat/SyncStatus.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
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 type { SatSyncStatusResponse } from '@horux/shared';
|
||||
|
||||
interface SyncStatusProps {
|
||||
fielConfigured: boolean;
|
||||
onSyncStarted?: () => void;
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: 'Pendiente',
|
||||
running: 'En progreso',
|
||||
completed: 'Completado',
|
||||
failed: 'Fallido',
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
running: 'bg-blue-100 text-blue-800',
|
||||
completed: 'bg-green-100 text-green-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
};
|
||||
|
||||
export function SyncStatus({ fielConfigured, onSyncStarted }: SyncStatusProps) {
|
||||
const [status, setStatus] = useState<SatSyncStatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [startingSync, setStartingSync] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showCustomDate, setShowCustomDate] = useState(false);
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const data = await getSyncStatus();
|
||||
setStatus(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching sync status:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (fielConfigured) {
|
||||
fetchStatus();
|
||||
// Actualizar cada 30 segundos si hay sync activo
|
||||
const interval = setInterval(fetchStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fielConfigured]);
|
||||
|
||||
const handleStartSync = async (type: 'initial' | 'daily', customDates?: boolean) => {
|
||||
setStartingSync(true);
|
||||
setError('');
|
||||
try {
|
||||
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();
|
||||
setShowCustomDate(false);
|
||||
onSyncStarted?.();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Error al iniciar sincronizacion');
|
||||
} finally {
|
||||
setStartingSync(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!fielConfigured) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sincronizacion SAT</CardTitle>
|
||||
<CardDescription>
|
||||
Configura tu FIEL para habilitar la sincronizacion automatica
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
La sincronizacion con el SAT requiere una FIEL valida configurada.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sincronizacion SAT</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">Cargando estado...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sincronizacion SAT</CardTitle>
|
||||
<CardDescription>
|
||||
Estado de la sincronizacion automatica de CFDIs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{status?.hasActiveSync && status.currentJob && (
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 rounded text-sm ${statusColors[status.currentJob.status]}`}>
|
||||
{statusLabels[status.currentJob.status]}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{status.currentJob.type === 'initial' ? 'Sincronizacion inicial' : 'Sincronizacion diaria'}
|
||||
</span>
|
||||
</div>
|
||||
{status.currentJob.status === 'running' && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${status.currentJob.progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm mt-2">
|
||||
{status.currentJob.cfdisDownloaded} CFDIs descargados
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.lastCompletedJob && !status.hasActiveSync && (
|
||||
<div className="p-4 bg-green-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 rounded text-sm ${statusColors.completed}`}>
|
||||
Ultima sincronizacion exitosa
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{new Date(status.lastCompletedJob.completedAt!).toLocaleString('es-MX')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{status.lastCompletedJob.cfdisInserted} CFDIs nuevos, {status.lastCompletedJob.cfdisUpdated} actualizados
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-center">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-2xl font-bold">{status?.totalCfdisSynced || 0}</p>
|
||||
<p className="text-sm text-muted-foreground">CFDIs sincronizados</p>
|
||||
</div>
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<p className="text-2xl font-bold">3:00 AM</p>
|
||||
<p className="text-sm text-muted-foreground">Sincronizacion diaria</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<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">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={startingSync || status?.hasActiveSync}
|
||||
onClick={() => handleStartSync('daily')}
|
||||
className="flex-1"
|
||||
>
|
||||
{startingSync ? 'Iniciando...' : 'Sincronizar mes actual'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={startingSync || status?.hasActiveSync}
|
||||
onClick={() => setShowCustomDate(!showCustomDate)}
|
||||
className="flex-1"
|
||||
>
|
||||
Periodo personalizado
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!status?.lastCompletedJob && (
|
||||
<Button
|
||||
disabled={startingSync || status?.hasActiveSync}
|
||||
onClick={() => handleStartSync('initial')}
|
||||
className="w-full"
|
||||
>
|
||||
{startingSync ? 'Iniciando...' : 'Sincronizacion inicial (6 anos)'}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</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.fechaFin) params.set('fechaFin', filters.fechaFin);
|
||||
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.page) params.set('page', filters.page.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> {
|
||||
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;
|
||||
}
|
||||
|
||||
16
apps/web/lib/api/fiel.ts
Normal file
16
apps/web/lib/api/fiel.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { apiClient } from './client';
|
||||
import type { FielStatus, FielUploadRequest } from '@horux/shared';
|
||||
|
||||
export async function uploadFiel(data: FielUploadRequest): Promise<{ message: string; status: FielStatus }> {
|
||||
const response = await apiClient.post('/fiel/upload', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getFielStatus(): Promise<FielStatus> {
|
||||
const response = await apiClient.get<FielStatus>('/fiel/status');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteFiel(): Promise<void> {
|
||||
await apiClient.delete('/fiel');
|
||||
}
|
||||
45
apps/web/lib/api/sat.ts
Normal file
45
apps/web/lib/api/sat.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
SatSyncJob,
|
||||
SatSyncStatusResponse,
|
||||
SatSyncHistoryResponse,
|
||||
StartSyncRequest,
|
||||
StartSyncResponse,
|
||||
} from '@horux/shared';
|
||||
|
||||
export async function startSync(data?: StartSyncRequest): Promise<StartSyncResponse> {
|
||||
const response = await apiClient.post<StartSyncResponse>('/sat/sync', data || {});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getSyncStatus(): Promise<SatSyncStatusResponse> {
|
||||
const response = await apiClient.get<SatSyncStatusResponse>('/sat/sync/status');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getSyncHistory(page: number = 1, limit: number = 10): Promise<SatSyncHistoryResponse> {
|
||||
const response = await apiClient.get<SatSyncHistoryResponse>('/sat/sync/history', {
|
||||
params: { page, limit },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getSyncJob(id: string): Promise<SatSyncJob> {
|
||||
const response = await apiClient.get<SatSyncJob>(`/sat/sync/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function retrySync(id: string): Promise<StartSyncResponse> {
|
||||
const response = await apiClient.post<StartSyncResponse>(`/sat/sync/${id}/retry`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getCronInfo(): Promise<{ scheduled: boolean; expression: string; timezone: string }> {
|
||||
const response = await apiClient.get('/sat/cron');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function runCron(): Promise<{ message: string }> {
|
||||
const response = await apiClient.post('/sat/cron/run');
|
||||
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;
|
||||
rfc: string;
|
||||
plan: string;
|
||||
schemaName: string;
|
||||
databaseName: string;
|
||||
createdAt: string;
|
||||
_count?: {
|
||||
users: number;
|
||||
|
||||
@@ -19,3 +19,18 @@ export async function updateUsuario(id: string, data: UserUpdate): Promise<UserL
|
||||
export async function deleteUsuario(id: string): Promise<void> {
|
||||
await apiClient.delete(`/usuarios/${id}`);
|
||||
}
|
||||
|
||||
// Funciones globales (admin global)
|
||||
export async function getAllUsuarios(): Promise<UserListItem[]> {
|
||||
const response = await apiClient.get<UserListItem[]>('/usuarios/global/all');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateUsuarioGlobal(id: string, data: UserUpdate): Promise<UserListItem> {
|
||||
const response = await apiClient.patch<UserListItem>(`/usuarios/global/${id}`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteUsuarioGlobal(id: string): Promise<void> {
|
||||
await apiClient.delete(`/usuarios/global/${id}`);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export function useCfdis(filters: CfdiFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['cfdis', 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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -38,3 +38,31 @@ export function useDeleteUsuario() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Hooks globales (admin global)
|
||||
export function useAllUsuarios() {
|
||||
return useQuery({
|
||||
queryKey: ['usuarios', 'global'],
|
||||
queryFn: usuariosApi.getAllUsuarios,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateUsuarioGlobal() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UserUpdate }) => usuariosApi.updateUsuarioGlobal(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteUsuarioGlobal() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => usuariosApi.deleteUsuarioGlobal(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['usuarios'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
"dependencies": {
|
||||
"@horux/shared": "workspace:*",
|
||||
"@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-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
@@ -26,17 +27,21 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"recharts": "^2.12.0",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"recharts": "^2.12.0",
|
||||
"tailwind-merge": "^2.5.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.23.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user