Initial commit - erp-core-backend
This commit is contained in:
commit
12fb6eeee8
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Server
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3011
|
||||||
|
API_PREFIX=/api/v1
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_NAME=erp_generic
|
||||||
|
DB_USER=erp_admin
|
||||||
|
DB_PASSWORD=erp_secret_2024
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-super-secret-jwt-key-change-in-production
|
||||||
|
JWT_EXPIRES_IN=24h
|
||||||
|
JWT_REFRESH_EXPIRES_IN=7d
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ORIGIN=http://localhost:3010,http://localhost:5173
|
||||||
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# ERP-CORE Backend - Dockerfile
|
||||||
|
# =============================================================================
|
||||||
|
# Multi-stage build for production
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Stage 1: Dependencies
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies needed for native modules
|
||||||
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
|
# Stage 2: Builder
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 3: Production
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nestjs
|
||||||
|
|
||||||
|
# Copy built application
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/package*.json ./
|
||||||
|
|
||||||
|
# Create logs directory
|
||||||
|
RUN mkdir -p /var/log/erp-core && chown -R nestjs:nodejs /var/log/erp-core
|
||||||
|
|
||||||
|
USER nestjs
|
||||||
|
|
||||||
|
EXPOSE 3011
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3011/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
78
TYPEORM_DEPENDENCIES.md
Normal file
78
TYPEORM_DEPENDENCIES.md
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# Dependencias para TypeORM + Redis
|
||||||
|
|
||||||
|
## Instrucciones de instalación
|
||||||
|
|
||||||
|
Ejecutar los siguientes comandos para agregar las dependencias necesarias:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/adrian/Documentos/workspace/projects/erp-suite/apps/erp-core/backend
|
||||||
|
|
||||||
|
# Dependencias de producción
|
||||||
|
npm install typeorm reflect-metadata ioredis
|
||||||
|
|
||||||
|
# Dependencias de desarrollo
|
||||||
|
npm install --save-dev @types/ioredis
|
||||||
|
```
|
||||||
|
|
||||||
|
## Detalle de dependencias
|
||||||
|
|
||||||
|
### Producción (dependencies)
|
||||||
|
|
||||||
|
1. **typeorm** (^0.3.x)
|
||||||
|
- ORM para TypeScript/JavaScript
|
||||||
|
- Permite trabajar con entities, repositories y query builders
|
||||||
|
- Soporta migraciones y subscribers
|
||||||
|
|
||||||
|
2. **reflect-metadata** (^0.2.x)
|
||||||
|
- Requerido por TypeORM para decoradores
|
||||||
|
- Debe importarse al inicio de la aplicación
|
||||||
|
|
||||||
|
3. **ioredis** (^5.x)
|
||||||
|
- Cliente Redis moderno para Node.js
|
||||||
|
- Usado para blacklist de tokens JWT
|
||||||
|
- Soporta clustering, pipelines y Lua scripts
|
||||||
|
|
||||||
|
### Desarrollo (devDependencies)
|
||||||
|
|
||||||
|
1. **@types/ioredis** (^5.x)
|
||||||
|
- Tipos TypeScript para ioredis
|
||||||
|
- Provee autocompletado e intellisense
|
||||||
|
|
||||||
|
## Verificación post-instalación
|
||||||
|
|
||||||
|
Después de instalar las dependencias, verificar que el proyecto compile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Y que el servidor arranque correctamente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables de entorno necesarias
|
||||||
|
|
||||||
|
Agregar al archivo `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Redis (opcional - para blacklist de tokens)
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
```
|
||||||
|
|
||||||
|
## Archivos creados
|
||||||
|
|
||||||
|
1. `/src/config/typeorm.ts` - Configuración de TypeORM DataSource
|
||||||
|
2. `/src/config/redis.ts` - Configuración de cliente Redis
|
||||||
|
3. `/src/index.ts` - Modificado para inicializar TypeORM y Redis
|
||||||
|
|
||||||
|
## Próximos pasos
|
||||||
|
|
||||||
|
1. Instalar las dependencias listadas arriba
|
||||||
|
2. Configurar variables de entorno de Redis en `.env`
|
||||||
|
3. Arrancar servidor con `npm run dev` y verificar logs
|
||||||
|
4. Comenzar a crear entities gradualmente en `src/modules/*/entities/`
|
||||||
|
5. Actualizar `typeorm.ts` para incluir las rutas de las entities creadas
|
||||||
302
TYPEORM_INTEGRATION_SUMMARY.md
Normal file
302
TYPEORM_INTEGRATION_SUMMARY.md
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
# Resumen de Integración TypeORM + Redis
|
||||||
|
|
||||||
|
## Estado de la Tarea: COMPLETADO
|
||||||
|
|
||||||
|
Integración exitosa de TypeORM y Redis al proyecto Express existente manteniendo total compatibilidad con el pool `pg` actual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archivos Creados
|
||||||
|
|
||||||
|
### 1. `/src/config/typeorm.ts`
|
||||||
|
**Propósito:** Configuración del DataSource de TypeORM
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- DataSource configurado para PostgreSQL usando las mismas variables de entorno que el pool `pg`
|
||||||
|
- Schema por defecto: `auth`
|
||||||
|
- Logging habilitado en desarrollo, solo errores en producción
|
||||||
|
- Pool de conexiones reducido (max: 10) para no competir con el pool pg (max: 20)
|
||||||
|
- Synchronize deshabilitado (se usa DDL manual)
|
||||||
|
- Funciones exportadas:
|
||||||
|
- `AppDataSource` - DataSource principal
|
||||||
|
- `initializeTypeORM()` - Inicializa la conexión
|
||||||
|
- `closeTypeORM()` - Cierra la conexión
|
||||||
|
- `isTypeORMConnected()` - Verifica estado de conexión
|
||||||
|
|
||||||
|
**Variables de entorno usadas:**
|
||||||
|
- `DB_HOST`
|
||||||
|
- `DB_PORT`
|
||||||
|
- `DB_USER`
|
||||||
|
- `DB_PASSWORD`
|
||||||
|
- `DB_NAME`
|
||||||
|
|
||||||
|
### 2. `/src/config/redis.ts`
|
||||||
|
**Propósito:** Configuración de cliente Redis para blacklist de tokens JWT
|
||||||
|
|
||||||
|
**Características:**
|
||||||
|
- Cliente ioredis con reconexión automática
|
||||||
|
- Logging completo de eventos (connect, ready, error, close, reconnecting)
|
||||||
|
- Conexión lazy (no automática)
|
||||||
|
- Redis es opcional - no detiene la aplicación si falla
|
||||||
|
- Utilidades para blacklist de tokens:
|
||||||
|
- `blacklistToken(token, expiresIn)` - Agrega token a blacklist
|
||||||
|
- `isTokenBlacklisted(token)` - Verifica si token está en blacklist
|
||||||
|
- `cleanupBlacklist()` - Limpieza manual (Redis maneja TTL automáticamente)
|
||||||
|
|
||||||
|
**Funciones exportadas:**
|
||||||
|
- `redisClient` - Cliente Redis principal
|
||||||
|
- `initializeRedis()` - Inicializa conexión
|
||||||
|
- `closeRedis()` - Cierra conexión
|
||||||
|
- `isRedisConnected()` - Verifica estado
|
||||||
|
- `blacklistToken()` - Blacklist de token
|
||||||
|
- `isTokenBlacklisted()` - Verifica blacklist
|
||||||
|
- `cleanupBlacklist()` - Limpieza manual
|
||||||
|
|
||||||
|
**Variables de entorno nuevas:**
|
||||||
|
- `REDIS_HOST` (default: localhost)
|
||||||
|
- `REDIS_PORT` (default: 6379)
|
||||||
|
- `REDIS_PASSWORD` (opcional)
|
||||||
|
|
||||||
|
### 3. `/src/index.ts` (MODIFICADO)
|
||||||
|
**Cambios realizados:**
|
||||||
|
|
||||||
|
1. **Importación de reflect-metadata** (línea 1-2):
|
||||||
|
```typescript
|
||||||
|
import 'reflect-metadata';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Importación de nuevos módulos** (líneas 7-8):
|
||||||
|
```typescript
|
||||||
|
import { initializeTypeORM, closeTypeORM } from './config/typeorm.js';
|
||||||
|
import { initializeRedis, closeRedis } from './config/redis.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Inicialización en bootstrap()** (líneas 24-32):
|
||||||
|
```typescript
|
||||||
|
// Initialize TypeORM DataSource
|
||||||
|
const typeormConnected = await initializeTypeORM();
|
||||||
|
if (!typeormConnected) {
|
||||||
|
logger.error('Failed to initialize TypeORM. Exiting...');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Redis (opcional - no detiene la app si falla)
|
||||||
|
await initializeRedis();
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Graceful shutdown actualizado** (líneas 48-51):
|
||||||
|
```typescript
|
||||||
|
// Cerrar conexiones en orden
|
||||||
|
await closeRedis();
|
||||||
|
await closeTypeORM();
|
||||||
|
await closePool();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Orden de inicialización:**
|
||||||
|
1. Pool pg (existente) - crítico
|
||||||
|
2. TypeORM DataSource - crítico
|
||||||
|
3. Redis - opcional
|
||||||
|
4. Express server
|
||||||
|
|
||||||
|
**Orden de cierre:**
|
||||||
|
1. Express server
|
||||||
|
2. Redis
|
||||||
|
3. TypeORM
|
||||||
|
4. Pool pg
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencias a Instalar
|
||||||
|
|
||||||
|
### Comando de instalación:
|
||||||
|
```bash
|
||||||
|
cd /home/adrian/Documentos/workspace/projects/erp-suite/apps/erp-core/backend
|
||||||
|
|
||||||
|
# Producción
|
||||||
|
npm install typeorm reflect-metadata ioredis
|
||||||
|
|
||||||
|
# Desarrollo
|
||||||
|
npm install --save-dev @types/ioredis
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detalle:
|
||||||
|
|
||||||
|
**Producción:**
|
||||||
|
- `typeorm` ^0.3.x - ORM principal
|
||||||
|
- `reflect-metadata` ^0.2.x - Requerido por decoradores de TypeORM
|
||||||
|
- `ioredis` ^5.x - Cliente Redis moderno
|
||||||
|
|
||||||
|
**Desarrollo:**
|
||||||
|
- `@types/ioredis` ^5.x - Tipos TypeScript para ioredis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables de Entorno
|
||||||
|
|
||||||
|
Agregar al archivo `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Redis Configuration (opcional)
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# Las variables de PostgreSQL ya existen:
|
||||||
|
# DB_HOST=localhost
|
||||||
|
# DB_PORT=5432
|
||||||
|
# DB_NAME=erp_generic
|
||||||
|
# DB_USER=erp_admin
|
||||||
|
# DB_PASSWORD=***
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Compatibilidad con Pool `pg` Existente
|
||||||
|
|
||||||
|
### Garantías de compatibilidad:
|
||||||
|
|
||||||
|
1. **NO se modificó** `/src/config/database.ts`
|
||||||
|
2. **NO se eliminó** ninguna funcionalidad del pool pg
|
||||||
|
3. **Pool pg sigue siendo la conexión principal** para queries existentes
|
||||||
|
4. **TypeORM usa su propio pool** (max: 10 conexiones) independiente del pool pg (max: 20)
|
||||||
|
5. **Ambos pools coexisten** sin conflicto de recursos
|
||||||
|
|
||||||
|
### Estrategia de migración gradual:
|
||||||
|
|
||||||
|
```
|
||||||
|
Código existente → Usa pool pg (database.ts)
|
||||||
|
Nuevo código → Puede usar TypeORM entities
|
||||||
|
No hay prisa → Migrar cuando sea conveniente
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estructura de Directorios
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── src/
|
||||||
|
│ ├── config/
|
||||||
|
│ │ ├── database.ts (EXISTENTE - pool pg)
|
||||||
|
│ │ ├── typeorm.ts (NUEVO - TypeORM DataSource)
|
||||||
|
│ │ ├── redis.ts (NUEVO - Redis client)
|
||||||
|
│ │ └── index.ts (EXISTENTE - sin cambios)
|
||||||
|
│ ├── index.ts (MODIFICADO - inicialización)
|
||||||
|
│ └── ...
|
||||||
|
├── TYPEORM_DEPENDENCIES.md (NUEVO - guía de instalación)
|
||||||
|
└── TYPEORM_INTEGRATION_SUMMARY.md (ESTE ARCHIVO)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Próximos Pasos
|
||||||
|
|
||||||
|
### 1. Instalar dependencias
|
||||||
|
```bash
|
||||||
|
npm install typeorm reflect-metadata ioredis
|
||||||
|
npm install --save-dev @types/ioredis
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configurar Redis (opcional)
|
||||||
|
Agregar variables `REDIS_*` al `.env`
|
||||||
|
|
||||||
|
### 3. Verificar compilación
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Arrancar servidor
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Verificar logs
|
||||||
|
Buscar en la consola:
|
||||||
|
- "Database connection successful" (pool pg)
|
||||||
|
- "TypeORM DataSource initialized successfully" (TypeORM)
|
||||||
|
- "Redis connection successful" o "Application will continue without Redis" (Redis)
|
||||||
|
- "Server running on port 3000"
|
||||||
|
|
||||||
|
### 6. Crear entities (cuando sea necesario)
|
||||||
|
```
|
||||||
|
src/modules/auth/entities/
|
||||||
|
├── user.entity.ts
|
||||||
|
├── role.entity.ts
|
||||||
|
└── permission.entity.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Actualizar typeorm.ts
|
||||||
|
Agregar rutas de entities al array `entities` en AppDataSource:
|
||||||
|
```typescript
|
||||||
|
entities: [
|
||||||
|
'src/modules/auth/entities/*.entity.ts'
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test de conexión TypeORM
|
||||||
|
```typescript
|
||||||
|
import { AppDataSource } from './config/typeorm.js';
|
||||||
|
|
||||||
|
// Verificar que esté inicializado
|
||||||
|
console.log(AppDataSource.isInitialized); // true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test de conexión Redis
|
||||||
|
```typescript
|
||||||
|
import { isRedisConnected, blacklistToken, isTokenBlacklisted } from './config/redis.js';
|
||||||
|
|
||||||
|
// Verificar conexión
|
||||||
|
console.log(isRedisConnected()); // true
|
||||||
|
|
||||||
|
// Test de blacklist
|
||||||
|
await blacklistToken('test-token', 3600);
|
||||||
|
const isBlacklisted = await isTokenBlacklisted('test-token'); // true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criterios de Aceptación
|
||||||
|
|
||||||
|
- [x] Archivo `src/config/typeorm.ts` creado
|
||||||
|
- [x] Archivo `src/config/redis.ts` creado
|
||||||
|
- [x] `src/index.ts` modificado para inicializar TypeORM
|
||||||
|
- [x] Compatibilidad con pool pg existente mantenida
|
||||||
|
- [x] reflect-metadata importado al inicio
|
||||||
|
- [x] Graceful shutdown actualizado
|
||||||
|
- [x] Documentación de dependencias creada
|
||||||
|
- [x] Variables de entorno documentadas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notas Importantes
|
||||||
|
|
||||||
|
1. **Redis es opcional** - Si Redis no está disponible, la aplicación arrancará normalmente pero la blacklist de tokens estará deshabilitada.
|
||||||
|
|
||||||
|
2. **TypeORM es crítico** - Si TypeORM no puede inicializar, la aplicación no arrancará. Esto es intencional para detectar problemas temprano.
|
||||||
|
|
||||||
|
3. **No usar synchronize** - Las tablas se crean manualmente con DDL, TypeORM solo las usa para queries.
|
||||||
|
|
||||||
|
4. **Schema 'auth'** - TypeORM está configurado para usar el schema 'auth' por defecto. Asegurarse de que las entities se creen en este schema.
|
||||||
|
|
||||||
|
5. **Logging** - En desarrollo, TypeORM logueará todas las queries. En producción, solo errores.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Soporte
|
||||||
|
|
||||||
|
Si hay problemas durante la instalación o arranque:
|
||||||
|
|
||||||
|
1. Verificar que todas las variables de entorno estén configuradas
|
||||||
|
2. Verificar que PostgreSQL esté corriendo y accesible
|
||||||
|
3. Verificar que Redis esté corriendo (opcional)
|
||||||
|
4. Revisar logs para mensajes de error específicos
|
||||||
|
5. Verificar que las dependencias se instalaron correctamente con `npm list typeorm reflect-metadata ioredis`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fecha de creación:** 2025-12-12
|
||||||
|
**Estado:** Listo para instalar dependencias y arrancar
|
||||||
536
TYPEORM_USAGE_EXAMPLES.md
Normal file
536
TYPEORM_USAGE_EXAMPLES.md
Normal file
@ -0,0 +1,536 @@
|
|||||||
|
# Ejemplos de Uso de TypeORM
|
||||||
|
|
||||||
|
Guía rápida para comenzar a usar TypeORM en el proyecto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Crear una Entity
|
||||||
|
|
||||||
|
### Ejemplo: User Entity
|
||||||
|
|
||||||
|
**Archivo:** `src/modules/auth/entities/user.entity.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToMany,
|
||||||
|
JoinTable,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Role } from './role.entity';
|
||||||
|
|
||||||
|
@Entity('users', { schema: 'auth' })
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ unique: true, length: 255 })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ length: 255 })
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@Column({ name: 'first_name', length: 100 })
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'last_name', length: 100 })
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@Column({ default: true })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'email_verified', default: false })
|
||||||
|
emailVerified: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@ManyToMany(() => Role, role => role.users)
|
||||||
|
@JoinTable({
|
||||||
|
name: 'user_roles',
|
||||||
|
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
|
||||||
|
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
||||||
|
})
|
||||||
|
roles: Role[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo: Role Entity
|
||||||
|
|
||||||
|
**Archivo:** `src/modules/auth/entities/role.entity.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from './user.entity';
|
||||||
|
|
||||||
|
@Entity('roles', { schema: 'auth' })
|
||||||
|
export class Role {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ unique: true, length: 50 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@ManyToMany(() => User, user => user.roles)
|
||||||
|
users: User[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Actualizar typeorm.ts
|
||||||
|
|
||||||
|
Después de crear entities, actualizar el array `entities` en `src/config/typeorm.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const AppDataSource = new DataSource({
|
||||||
|
// ... otras configuraciones ...
|
||||||
|
|
||||||
|
entities: [
|
||||||
|
'src/modules/auth/entities/*.entity.ts',
|
||||||
|
// Agregar más rutas según sea necesario
|
||||||
|
],
|
||||||
|
|
||||||
|
// ... resto de configuración ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Usar Repository en un Service
|
||||||
|
|
||||||
|
### Ejemplo: UserService
|
||||||
|
|
||||||
|
**Archivo:** `src/modules/auth/services/user.service.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
|
import { User } from '../entities/user.entity.js';
|
||||||
|
import { Role } from '../entities/role.entity.js';
|
||||||
|
|
||||||
|
export class UserService {
|
||||||
|
private userRepository: Repository<User>;
|
||||||
|
private roleRepository: Repository<Role>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.userRepository = AppDataSource.getRepository(User);
|
||||||
|
this.roleRepository = AppDataSource.getRepository(Role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear usuario
|
||||||
|
async createUser(data: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}): Promise<User> {
|
||||||
|
const user = this.userRepository.create(data);
|
||||||
|
return await this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar usuario por email (con roles)
|
||||||
|
async findByEmail(email: string): Promise<User | null> {
|
||||||
|
return await this.userRepository.findOne({
|
||||||
|
where: { email },
|
||||||
|
relations: ['roles'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar usuario por ID
|
||||||
|
async findById(id: string): Promise<User | null> {
|
||||||
|
return await this.userRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['roles'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listar todos los usuarios (con paginación)
|
||||||
|
async findAll(page: number = 1, limit: number = 10): Promise<{
|
||||||
|
users: User[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
}> {
|
||||||
|
const [users, total] = await this.userRepository.findAndCount({
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
relations: ['roles'],
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
users,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar usuario
|
||||||
|
async updateUser(id: string, data: Partial<User>): Promise<User | null> {
|
||||||
|
await this.userRepository.update(id, data);
|
||||||
|
return await this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asignar rol a usuario
|
||||||
|
async assignRole(userId: string, roleId: string): Promise<User | null> {
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id: userId },
|
||||||
|
relations: ['roles'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const role = await this.roleRepository.findOne({
|
||||||
|
where: { id: roleId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!role) return null;
|
||||||
|
|
||||||
|
if (!user.roles) user.roles = [];
|
||||||
|
user.roles.push(role);
|
||||||
|
|
||||||
|
return await this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminar usuario (soft delete)
|
||||||
|
async deleteUser(id: string): Promise<boolean> {
|
||||||
|
const result = await this.userRepository.update(id, { active: false });
|
||||||
|
return result.affected ? result.affected > 0 : false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Query Builder (para queries complejas)
|
||||||
|
|
||||||
|
### Ejemplo: Búsqueda avanzada de usuarios
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async searchUsers(filters: {
|
||||||
|
search?: string;
|
||||||
|
active?: boolean;
|
||||||
|
roleId?: string;
|
||||||
|
}): Promise<User[]> {
|
||||||
|
const query = this.userRepository
|
||||||
|
.createQueryBuilder('user')
|
||||||
|
.leftJoinAndSelect('user.roles', 'role');
|
||||||
|
|
||||||
|
if (filters.search) {
|
||||||
|
query.where(
|
||||||
|
'user.email ILIKE :search OR user.firstName ILIKE :search OR user.lastName ILIKE :search',
|
||||||
|
{ search: `%${filters.search}%` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.active !== undefined) {
|
||||||
|
query.andWhere('user.active = :active', { active: filters.active });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.roleId) {
|
||||||
|
query.andWhere('role.id = :roleId', { roleId: filters.roleId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.getMany();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Transacciones
|
||||||
|
|
||||||
|
### Ejemplo: Crear usuario con roles en una transacción
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async createUserWithRoles(
|
||||||
|
userData: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
},
|
||||||
|
roleIds: string[]
|
||||||
|
): Promise<User> {
|
||||||
|
return await AppDataSource.transaction(async (transactionalEntityManager) => {
|
||||||
|
// Crear usuario
|
||||||
|
const user = transactionalEntityManager.create(User, userData);
|
||||||
|
const savedUser = await transactionalEntityManager.save(user);
|
||||||
|
|
||||||
|
// Buscar roles
|
||||||
|
const roles = await transactionalEntityManager.findByIds(Role, roleIds);
|
||||||
|
|
||||||
|
// Asignar roles
|
||||||
|
savedUser.roles = roles;
|
||||||
|
return await transactionalEntityManager.save(savedUser);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Raw Queries (cuando sea necesario)
|
||||||
|
|
||||||
|
### Ejemplo: Query personalizada con parámetros
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async getUserStats(): Promise<{ total: number; active: number; inactive: number }> {
|
||||||
|
const result = await AppDataSource.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN active = true THEN 1 ELSE 0 END) as active,
|
||||||
|
SUM(CASE WHEN active = false THEN 1 ELSE 0 END) as inactive
|
||||||
|
FROM auth.users
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Migrar código existente gradualmente
|
||||||
|
|
||||||
|
### Antes (usando pool pg):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/modules/auth/services/user.service.ts (viejo)
|
||||||
|
import { query, queryOne } from '../../../config/database.js';
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<User | null> {
|
||||||
|
return await queryOne(
|
||||||
|
'SELECT * FROM auth.users WHERE email = $1',
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Después (usando TypeORM):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/modules/auth/services/user.service.ts (nuevo)
|
||||||
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
|
import { User } from '../entities/user.entity.js';
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<User | null> {
|
||||||
|
const userRepository = AppDataSource.getRepository(User);
|
||||||
|
return await userRepository.findOne({ where: { email } });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nota:** Ambos métodos pueden coexistir. Migrar gradualmente cuando sea conveniente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Uso en Controllers
|
||||||
|
|
||||||
|
### Ejemplo: UserController
|
||||||
|
|
||||||
|
**Archivo:** `src/modules/auth/controllers/user.controller.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { UserService } from '../services/user.service.js';
|
||||||
|
|
||||||
|
export class UserController {
|
||||||
|
private userService: UserService;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.userService = new UserService();
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/users
|
||||||
|
async getAll(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
|
||||||
|
const result = await this.userService.findAll(page, limit);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Error fetching users',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/users/:id
|
||||||
|
async getById(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = await this.userService.findById(req.params.id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'User not found',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Error fetching user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/users
|
||||||
|
async create(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const user = await this.userService.createUser(req.body);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Error creating user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Validación con Zod (integración)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const createUserSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
firstName: z.string().min(2),
|
||||||
|
lastName: z.string().min(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
async create(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Validar datos
|
||||||
|
const validatedData = createUserSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Crear usuario
|
||||||
|
const user = await this.userService.createUser(validatedData);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: user,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Validation error',
|
||||||
|
details: error.errors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Error creating user',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Custom Repository (avanzado)
|
||||||
|
|
||||||
|
### Ejemplo: UserRepository personalizado
|
||||||
|
|
||||||
|
**Archivo:** `src/modules/auth/repositories/user.repository.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
|
import { User } from '../entities/user.entity.js';
|
||||||
|
|
||||||
|
export class UserRepository extends Repository<User> {
|
||||||
|
constructor() {
|
||||||
|
super(User, AppDataSource.createEntityManager());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Método personalizado
|
||||||
|
async findActiveUsers(): Promise<User[]> {
|
||||||
|
return this.createQueryBuilder('user')
|
||||||
|
.where('user.active = :active', { active: true })
|
||||||
|
.andWhere('user.emailVerified = :verified', { verified: true })
|
||||||
|
.leftJoinAndSelect('user.roles', 'role')
|
||||||
|
.orderBy('user.createdAt', 'DESC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otro método personalizado
|
||||||
|
async findByRoleName(roleName: string): Promise<User[]> {
|
||||||
|
return this.createQueryBuilder('user')
|
||||||
|
.leftJoinAndSelect('user.roles', 'role')
|
||||||
|
.where('role.name = :roleName', { roleName })
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recursos Adicionales
|
||||||
|
|
||||||
|
- [TypeORM Documentation](https://typeorm.io/)
|
||||||
|
- [TypeORM Entity Documentation](https://typeorm.io/entities)
|
||||||
|
- [TypeORM Relations](https://typeorm.io/relations)
|
||||||
|
- [TypeORM Query Builder](https://typeorm.io/select-query-builder)
|
||||||
|
- [TypeORM Migrations](https://typeorm.io/migrations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recomendaciones
|
||||||
|
|
||||||
|
1. Comenzar con entities simples y agregar complejidad gradualmente
|
||||||
|
2. Usar Repository para queries simples
|
||||||
|
3. Usar QueryBuilder para queries complejas
|
||||||
|
4. Usar transacciones para operaciones que afectan múltiples tablas
|
||||||
|
5. Validar datos con Zod antes de guardar en base de datos
|
||||||
|
6. No usar `synchronize: true` en producción
|
||||||
|
7. Crear índices manualmente en DDL para mejor performance
|
||||||
|
8. Usar eager/lazy loading según el caso de uso
|
||||||
|
9. Documentar entities con comentarios JSDoc
|
||||||
|
10. Mantener código existente con pool pg hasta estar listo para migrar
|
||||||
8585
package-lock.json
generated
Normal file
8585
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
package.json
Normal file
59
package.json
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"name": "@erp-generic/backend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "ERP Generic Backend API",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"lint": "eslint src --ext .ts",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:coverage": "jest --coverage"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"ioredis": "^5.8.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"swagger-jsdoc": "^6.2.8",
|
||||||
|
"swagger-ui-express": "^5.0.1",
|
||||||
|
"typeorm": "^0.3.28",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"winston": "^3.11.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/compression": "^1.7.5",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/ioredis": "^4.28.10",
|
||||||
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/morgan": "^1.9.9",
|
||||||
|
"@types/node": "^20.10.4",
|
||||||
|
"@types/pg": "^8.10.9",
|
||||||
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"@types/uuid": "^9.0.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"tsx": "^4.6.2",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
134
service.descriptor.yml
Normal file
134
service.descriptor.yml
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# SERVICE DESCRIPTOR - ERP CORE API
|
||||||
|
# ==============================================================================
|
||||||
|
# API central del ERP Suite
|
||||||
|
# Mantenido por: Backend-Agent
|
||||||
|
# Actualizado: 2025-12-18
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
version: "1.0.0"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# IDENTIFICACION DEL SERVICIO
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
service:
|
||||||
|
name: "erp-core-api"
|
||||||
|
display_name: "ERP Core API"
|
||||||
|
description: "API central con funcionalidad compartida del ERP"
|
||||||
|
type: "backend"
|
||||||
|
runtime: "node"
|
||||||
|
framework: "nestjs"
|
||||||
|
owner_agent: "NEXUS-BACKEND"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# CONFIGURACION DE PUERTOS
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
ports:
|
||||||
|
internal: 3010
|
||||||
|
registry_ref: "projects.erp_suite.services.api"
|
||||||
|
protocol: "http"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# CONFIGURACION DE BASE DE DATOS
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
database:
|
||||||
|
registry_ref: "erp_core"
|
||||||
|
schemas:
|
||||||
|
- "public"
|
||||||
|
- "auth"
|
||||||
|
- "core"
|
||||||
|
role: "runtime"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# DEPENDENCIAS
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
dependencies:
|
||||||
|
services:
|
||||||
|
- name: "postgres"
|
||||||
|
type: "database"
|
||||||
|
required: true
|
||||||
|
- name: "redis"
|
||||||
|
type: "cache"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# MODULOS
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
modules:
|
||||||
|
auth:
|
||||||
|
description: "Autenticacion y sesiones"
|
||||||
|
endpoints:
|
||||||
|
- { path: "/auth/login", method: "POST" }
|
||||||
|
- { path: "/auth/register", method: "POST" }
|
||||||
|
- { path: "/auth/refresh", method: "POST" }
|
||||||
|
- { path: "/auth/logout", method: "POST" }
|
||||||
|
|
||||||
|
users:
|
||||||
|
description: "Gestion de usuarios"
|
||||||
|
endpoints:
|
||||||
|
- { path: "/users", method: "GET" }
|
||||||
|
- { path: "/users/:id", method: "GET" }
|
||||||
|
- { path: "/users", method: "POST" }
|
||||||
|
- { path: "/users/:id", method: "PUT" }
|
||||||
|
|
||||||
|
companies:
|
||||||
|
description: "Gestion de empresas"
|
||||||
|
endpoints:
|
||||||
|
- { path: "/companies", method: "GET" }
|
||||||
|
- { path: "/companies/:id", method: "GET" }
|
||||||
|
- { path: "/companies", method: "POST" }
|
||||||
|
|
||||||
|
tenants:
|
||||||
|
description: "Multi-tenancy"
|
||||||
|
endpoints:
|
||||||
|
- { path: "/tenants", method: "GET" }
|
||||||
|
- { path: "/tenants/:id", method: "GET" }
|
||||||
|
|
||||||
|
core:
|
||||||
|
description: "Catalogos base"
|
||||||
|
submodules:
|
||||||
|
- countries
|
||||||
|
- currencies
|
||||||
|
- uom
|
||||||
|
- sequences
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# DOCKER
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
docker:
|
||||||
|
image: "erp-core-api"
|
||||||
|
dockerfile: "Dockerfile"
|
||||||
|
networks:
|
||||||
|
- "erp_core_${ENV:-local}"
|
||||||
|
- "infra_shared"
|
||||||
|
labels:
|
||||||
|
traefik:
|
||||||
|
enable: true
|
||||||
|
router: "erp-core-api"
|
||||||
|
rule: "Host(`api.erp.localhost`)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# HEALTH CHECK
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
healthcheck:
|
||||||
|
endpoint: "/health"
|
||||||
|
interval: "30s"
|
||||||
|
timeout: "5s"
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# ESTADO
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
status:
|
||||||
|
phase: "development"
|
||||||
|
version: "0.1.0"
|
||||||
|
completeness: 25
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# METADATA
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
metadata:
|
||||||
|
created_at: "2025-12-18"
|
||||||
|
created_by: "Backend-Agent"
|
||||||
|
project: "erp-suite"
|
||||||
|
team: "erp-team"
|
||||||
112
src/app.ts
Normal file
112
src/app.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import express, { Application, Request, Response, NextFunction } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import compression from 'compression';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import { config } from './config/index.js';
|
||||||
|
import { logger } from './shared/utils/logger.js';
|
||||||
|
import { AppError, ApiResponse } from './shared/types/index.js';
|
||||||
|
import { setupSwagger } from './config/swagger.config.js';
|
||||||
|
import authRoutes from './modules/auth/auth.routes.js';
|
||||||
|
import apiKeysRoutes from './modules/auth/apiKeys.routes.js';
|
||||||
|
import usersRoutes from './modules/users/users.routes.js';
|
||||||
|
import { rolesRoutes, permissionsRoutes } from './modules/roles/index.js';
|
||||||
|
import { tenantsRoutes } from './modules/tenants/index.js';
|
||||||
|
import companiesRoutes from './modules/companies/companies.routes.js';
|
||||||
|
import coreRoutes from './modules/core/core.routes.js';
|
||||||
|
import partnersRoutes from './modules/partners/partners.routes.js';
|
||||||
|
import inventoryRoutes from './modules/inventory/inventory.routes.js';
|
||||||
|
import financialRoutes from './modules/financial/financial.routes.js';
|
||||||
|
import purchasesRoutes from './modules/purchases/purchases.routes.js';
|
||||||
|
import salesRoutes from './modules/sales/sales.routes.js';
|
||||||
|
import projectsRoutes from './modules/projects/projects.routes.js';
|
||||||
|
import systemRoutes from './modules/system/system.routes.js';
|
||||||
|
import crmRoutes from './modules/crm/crm.routes.js';
|
||||||
|
import hrRoutes from './modules/hr/hr.routes.js';
|
||||||
|
import reportsRoutes from './modules/reports/reports.routes.js';
|
||||||
|
|
||||||
|
const app: Application = express();
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors({
|
||||||
|
origin: config.cors.origin,
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Request parsing
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(compression());
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
const morganFormat = config.env === 'production' ? 'combined' : 'dev';
|
||||||
|
app.use(morgan(morganFormat, {
|
||||||
|
stream: { write: (message) => logger.http(message.trim()) }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Swagger documentation
|
||||||
|
const apiPrefix = config.apiPrefix;
|
||||||
|
setupSwagger(app, apiPrefix);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (_req: Request, res: Response) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use(`${apiPrefix}/auth`, authRoutes);
|
||||||
|
app.use(`${apiPrefix}/auth/api-keys`, apiKeysRoutes);
|
||||||
|
app.use(`${apiPrefix}/users`, usersRoutes);
|
||||||
|
app.use(`${apiPrefix}/roles`, rolesRoutes);
|
||||||
|
app.use(`${apiPrefix}/permissions`, permissionsRoutes);
|
||||||
|
app.use(`${apiPrefix}/tenants`, tenantsRoutes);
|
||||||
|
app.use(`${apiPrefix}/companies`, companiesRoutes);
|
||||||
|
app.use(`${apiPrefix}/core`, coreRoutes);
|
||||||
|
app.use(`${apiPrefix}/partners`, partnersRoutes);
|
||||||
|
app.use(`${apiPrefix}/inventory`, inventoryRoutes);
|
||||||
|
app.use(`${apiPrefix}/financial`, financialRoutes);
|
||||||
|
app.use(`${apiPrefix}/purchases`, purchasesRoutes);
|
||||||
|
app.use(`${apiPrefix}/sales`, salesRoutes);
|
||||||
|
app.use(`${apiPrefix}/projects`, projectsRoutes);
|
||||||
|
app.use(`${apiPrefix}/system`, systemRoutes);
|
||||||
|
app.use(`${apiPrefix}/crm`, crmRoutes);
|
||||||
|
app.use(`${apiPrefix}/hr`, hrRoutes);
|
||||||
|
app.use(`${apiPrefix}/reports`, reportsRoutes);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((_req: Request, res: Response) => {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'Endpoint no encontrado'
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
logger.error('Unhandled error', {
|
||||||
|
error: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
name: err.name
|
||||||
|
});
|
||||||
|
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: err.message,
|
||||||
|
};
|
||||||
|
return res.status(err.statusCode).json(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic error
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: config.env === 'production'
|
||||||
|
? 'Error interno del servidor'
|
||||||
|
: err.message,
|
||||||
|
};
|
||||||
|
res.status(500).json(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
69
src/config/database.ts
Normal file
69
src/config/database.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Pool, PoolConfig, PoolClient } from 'pg';
|
||||||
|
|
||||||
|
// Re-export PoolClient for use in services
|
||||||
|
export type { PoolClient };
|
||||||
|
import { config } from './index.js';
|
||||||
|
import { logger } from '../shared/utils/logger.js';
|
||||||
|
|
||||||
|
const poolConfig: PoolConfig = {
|
||||||
|
host: config.database.host,
|
||||||
|
port: config.database.port,
|
||||||
|
database: config.database.name,
|
||||||
|
user: config.database.user,
|
||||||
|
password: config.database.password,
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pool = new Pool(poolConfig);
|
||||||
|
|
||||||
|
pool.on('connect', () => {
|
||||||
|
logger.debug('New database connection established');
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('error', (err) => {
|
||||||
|
logger.error('Unexpected database error', { error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function testConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const client = await pool.connect();
|
||||||
|
const result = await client.query('SELECT NOW()');
|
||||||
|
client.release();
|
||||||
|
logger.info('Database connection successful', { timestamp: result.rows[0].now });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Database connection failed', { error: (error as Error).message });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function query<T = any>(text: string, params?: any[]): Promise<T[]> {
|
||||||
|
const start = Date.now();
|
||||||
|
const result = await pool.query(text, params);
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
logger.debug('Query executed', {
|
||||||
|
text: text.substring(0, 100),
|
||||||
|
duration: `${duration}ms`,
|
||||||
|
rows: result.rowCount
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.rows as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryOne<T = any>(text: string, params?: any[]): Promise<T | null> {
|
||||||
|
const rows = await query<T>(text, params);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClient() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePool(): Promise<void> {
|
||||||
|
await pool.end();
|
||||||
|
logger.info('Database pool closed');
|
||||||
|
}
|
||||||
35
src/config/index.ts
Normal file
35
src/config/index.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Load .env file
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
env: process.env.NODE_ENV || 'development',
|
||||||
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
|
apiPrefix: process.env.API_PREFIX || '/api/v1',
|
||||||
|
|
||||||
|
database: {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_PORT || '5432', 10),
|
||||||
|
name: process.env.DB_NAME || 'erp_generic',
|
||||||
|
user: process.env.DB_USER || 'erp_admin',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
},
|
||||||
|
|
||||||
|
jwt: {
|
||||||
|
secret: process.env.JWT_SECRET || 'change-this-secret',
|
||||||
|
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||||
|
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||||
|
},
|
||||||
|
|
||||||
|
logging: {
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
},
|
||||||
|
|
||||||
|
cors: {
|
||||||
|
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Config = typeof config;
|
||||||
178
src/config/redis.ts
Normal file
178
src/config/redis.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import Redis from 'ioredis';
|
||||||
|
import { logger } from '../shared/utils/logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuración de Redis para blacklist de tokens JWT
|
||||||
|
*/
|
||||||
|
const redisConfig = {
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||||||
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
|
|
||||||
|
// Configuración de reconexión
|
||||||
|
retryStrategy(times: number) {
|
||||||
|
const delay = Math.min(times * 50, 2000);
|
||||||
|
return delay;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Timeouts
|
||||||
|
connectTimeout: 10000,
|
||||||
|
maxRetriesPerRequest: 3,
|
||||||
|
|
||||||
|
// Logging de eventos
|
||||||
|
lazyConnect: true, // No conectar automáticamente, esperar a connect()
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cliente Redis para blacklist de tokens
|
||||||
|
*/
|
||||||
|
export const redisClient = new Redis(redisConfig);
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
redisClient.on('connect', () => {
|
||||||
|
logger.info('Redis client connecting...', {
|
||||||
|
host: redisConfig.host,
|
||||||
|
port: redisConfig.port,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on('ready', () => {
|
||||||
|
logger.info('Redis client ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on('error', (error) => {
|
||||||
|
logger.error('Redis client error', {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on('close', () => {
|
||||||
|
logger.warn('Redis connection closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
redisClient.on('reconnecting', () => {
|
||||||
|
logger.info('Redis client reconnecting...');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inicializa la conexión a Redis
|
||||||
|
* @returns Promise<boolean> - true si la conexión fue exitosa
|
||||||
|
*/
|
||||||
|
export async function initializeRedis(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await redisClient.connect();
|
||||||
|
|
||||||
|
// Test de conexión
|
||||||
|
await redisClient.ping();
|
||||||
|
|
||||||
|
logger.info('Redis connection successful', {
|
||||||
|
host: redisConfig.host,
|
||||||
|
port: redisConfig.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to connect to Redis', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
host: redisConfig.host,
|
||||||
|
port: redisConfig.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redis es opcional, no debe detener la app
|
||||||
|
logger.warn('Application will continue without Redis (token blacklist disabled)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cierra la conexión a Redis
|
||||||
|
*/
|
||||||
|
export async function closeRedis(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await redisClient.quit();
|
||||||
|
logger.info('Redis connection closed gracefully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error closing Redis connection', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forzar desconexión si quit() falla
|
||||||
|
redisClient.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si Redis está conectado
|
||||||
|
*/
|
||||||
|
export function isRedisConnected(): boolean {
|
||||||
|
return redisClient.status === 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Utilidades para Token Blacklist =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrega un token a la blacklist
|
||||||
|
* @param token - Token JWT a invalidar
|
||||||
|
* @param expiresIn - Tiempo de expiración en segundos
|
||||||
|
*/
|
||||||
|
export async function blacklistToken(token: string, expiresIn: number): Promise<void> {
|
||||||
|
if (!isRedisConnected()) {
|
||||||
|
logger.warn('Cannot blacklist token: Redis not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = `blacklist:${token}`;
|
||||||
|
await redisClient.setex(key, expiresIn, '1');
|
||||||
|
logger.debug('Token added to blacklist', { expiresIn });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error blacklisting token', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica si un token está en la blacklist
|
||||||
|
* @param token - Token JWT a verificar
|
||||||
|
* @returns Promise<boolean> - true si el token está en blacklist
|
||||||
|
*/
|
||||||
|
export async function isTokenBlacklisted(token: string): Promise<boolean> {
|
||||||
|
if (!isRedisConnected()) {
|
||||||
|
logger.warn('Cannot check blacklist: Redis not connected');
|
||||||
|
return false; // Si Redis no está disponible, permitir el acceso
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = `blacklist:${token}`;
|
||||||
|
const result = await redisClient.get(key);
|
||||||
|
return result !== null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error checking token blacklist', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
return false; // En caso de error, no bloquear el acceso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia tokens expirados de la blacklist
|
||||||
|
* Nota: Redis hace esto automáticamente con SETEX, esta función es para uso manual si es necesario
|
||||||
|
*/
|
||||||
|
export async function cleanupBlacklist(): Promise<void> {
|
||||||
|
if (!isRedisConnected()) {
|
||||||
|
logger.warn('Cannot cleanup blacklist: Redis not connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Redis maneja automáticamente la expiración con SETEX
|
||||||
|
// Esta función está disponible para limpieza manual si se necesita
|
||||||
|
logger.info('Blacklist cleanup completed (handled by Redis TTL)');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during blacklist cleanup', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/config/swagger.config.ts
Normal file
200
src/config/swagger.config.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Swagger/OpenAPI Configuration for ERP Generic Core
|
||||||
|
*/
|
||||||
|
|
||||||
|
import swaggerJSDoc from 'swagger-jsdoc';
|
||||||
|
import { Express } from 'express';
|
||||||
|
import swaggerUi from 'swagger-ui-express';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Swagger definition
|
||||||
|
const swaggerDefinition = {
|
||||||
|
openapi: '3.0.0',
|
||||||
|
info: {
|
||||||
|
title: 'ERP Generic - Core API',
|
||||||
|
version: '0.1.0',
|
||||||
|
description: `
|
||||||
|
API para el sistema ERP genérico multitenant.
|
||||||
|
|
||||||
|
## Características principales
|
||||||
|
- Autenticación JWT y gestión de sesiones
|
||||||
|
- Multi-tenant con aislamiento de datos por empresa
|
||||||
|
- Gestión financiera y contable completa
|
||||||
|
- Control de inventario y almacenes
|
||||||
|
- Módulos de compras y ventas
|
||||||
|
- CRM y gestión de partners (clientes, proveedores)
|
||||||
|
- Proyectos y recursos humanos
|
||||||
|
- Sistema de permisos granular mediante API Keys
|
||||||
|
|
||||||
|
## Autenticación
|
||||||
|
Todos los endpoints requieren autenticación mediante Bearer Token (JWT).
|
||||||
|
El token debe incluirse en el header Authorization: Bearer <token>
|
||||||
|
|
||||||
|
## Multi-tenant
|
||||||
|
El sistema identifica automáticamente la empresa (tenant) del usuario autenticado
|
||||||
|
y filtra todos los datos según el contexto de la empresa.
|
||||||
|
`,
|
||||||
|
contact: {
|
||||||
|
name: 'ERP Generic Support',
|
||||||
|
email: 'support@erpgeneric.com',
|
||||||
|
},
|
||||||
|
license: {
|
||||||
|
name: 'Proprietary',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: 'http://localhost:3003/api/v1',
|
||||||
|
description: 'Desarrollo local',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://api.erpgeneric.com/api/v1',
|
||||||
|
description: 'Producción',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: 'Auth', description: 'Autenticación y autorización (JWT)' },
|
||||||
|
{ name: 'Users', description: 'Gestión de usuarios y perfiles' },
|
||||||
|
{ name: 'Companies', description: 'Gestión de empresas (multi-tenant)' },
|
||||||
|
{ name: 'Core', description: 'Configuración central y parámetros del sistema' },
|
||||||
|
{ name: 'Partners', description: 'Gestión de partners (clientes, proveedores, contactos)' },
|
||||||
|
{ name: 'Inventory', description: 'Control de inventario, productos y almacenes' },
|
||||||
|
{ name: 'Financial', description: 'Gestión financiera, contable y movimientos' },
|
||||||
|
{ name: 'Purchases', description: 'Módulo de compras y órdenes de compra' },
|
||||||
|
{ name: 'Sales', description: 'Módulo de ventas, cotizaciones y pedidos' },
|
||||||
|
{ name: 'Projects', description: 'Gestión de proyectos y tareas' },
|
||||||
|
{ name: 'System', description: 'Configuración del sistema, logs y auditoría' },
|
||||||
|
{ name: 'CRM', description: 'CRM, oportunidades y seguimiento comercial' },
|
||||||
|
{ name: 'HR', description: 'Recursos humanos, empleados y nómina' },
|
||||||
|
{ name: 'Reports', description: 'Reportes y analíticas del sistema' },
|
||||||
|
{ name: 'Health', description: 'Health checks y monitoreo' },
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
BearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
description: 'Token JWT obtenido del endpoint de login',
|
||||||
|
},
|
||||||
|
ApiKeyAuth: {
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'header',
|
||||||
|
name: 'X-API-Key',
|
||||||
|
description: 'API Key para operaciones administrativas específicas',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
ApiResponse: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
success: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'object',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PaginatedResponse: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
success: {
|
||||||
|
type: 'boolean',
|
||||||
|
example: true,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
page: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 1,
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 20,
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 100,
|
||||||
|
},
|
||||||
|
totalPages: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
BearerAuth: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Options for swagger-jsdoc
|
||||||
|
const options: swaggerJSDoc.Options = {
|
||||||
|
definition: swaggerDefinition,
|
||||||
|
// Path to the API routes for JSDoc comments
|
||||||
|
apis: [
|
||||||
|
path.join(__dirname, '../modules/**/*.routes.ts'),
|
||||||
|
path.join(__dirname, '../modules/**/*.routes.js'),
|
||||||
|
path.join(__dirname, '../docs/openapi.yaml'),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize swagger-jsdoc
|
||||||
|
const swaggerSpec = swaggerJSDoc(options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup Swagger documentation for Express app
|
||||||
|
*/
|
||||||
|
export function setupSwagger(app: Express, prefix: string = '/api/v1') {
|
||||||
|
// Swagger UI options
|
||||||
|
const swaggerUiOptions = {
|
||||||
|
customCss: `
|
||||||
|
.swagger-ui .topbar { display: none }
|
||||||
|
.swagger-ui .info { margin: 50px 0; }
|
||||||
|
.swagger-ui .info .title { font-size: 36px; }
|
||||||
|
`,
|
||||||
|
customSiteTitle: 'ERP Generic - API Documentation',
|
||||||
|
swaggerOptions: {
|
||||||
|
persistAuthorization: true,
|
||||||
|
displayRequestDuration: true,
|
||||||
|
filter: true,
|
||||||
|
tagsSorter: 'alpha',
|
||||||
|
operationsSorter: 'alpha',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Serve Swagger UI
|
||||||
|
app.use(`${prefix}/docs`, swaggerUi.serve);
|
||||||
|
app.get(`${prefix}/docs`, swaggerUi.setup(swaggerSpec, swaggerUiOptions));
|
||||||
|
|
||||||
|
// Serve OpenAPI spec as JSON
|
||||||
|
app.get(`${prefix}/docs.json`, (req, res) => {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.send(swaggerSpec);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📚 Swagger docs available at: http://localhost:${process.env.PORT || 3003}${prefix}/docs`);
|
||||||
|
console.log(`📄 OpenAPI spec JSON at: http://localhost:${process.env.PORT || 3003}${prefix}/docs.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { swaggerSpec };
|
||||||
215
src/config/typeorm.ts
Normal file
215
src/config/typeorm.ts
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { config } from './index.js';
|
||||||
|
import { logger } from '../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// Import Auth Core Entities
|
||||||
|
import {
|
||||||
|
Tenant,
|
||||||
|
Company,
|
||||||
|
User,
|
||||||
|
Role,
|
||||||
|
Permission,
|
||||||
|
Session,
|
||||||
|
PasswordReset,
|
||||||
|
} from '../modules/auth/entities/index.js';
|
||||||
|
|
||||||
|
// Import Auth Extension Entities
|
||||||
|
import {
|
||||||
|
Group,
|
||||||
|
ApiKey,
|
||||||
|
TrustedDevice,
|
||||||
|
VerificationCode,
|
||||||
|
MfaAuditLog,
|
||||||
|
OAuthProvider,
|
||||||
|
OAuthUserLink,
|
||||||
|
OAuthState,
|
||||||
|
} from '../modules/auth/entities/index.js';
|
||||||
|
|
||||||
|
// Import Core Module Entities
|
||||||
|
import { Partner } from '../modules/partners/entities/index.js';
|
||||||
|
import {
|
||||||
|
Currency,
|
||||||
|
Country,
|
||||||
|
UomCategory,
|
||||||
|
Uom,
|
||||||
|
ProductCategory,
|
||||||
|
Sequence,
|
||||||
|
} from '../modules/core/entities/index.js';
|
||||||
|
|
||||||
|
// Import Financial Entities
|
||||||
|
import {
|
||||||
|
AccountType,
|
||||||
|
Account,
|
||||||
|
Journal,
|
||||||
|
JournalEntry,
|
||||||
|
JournalEntryLine,
|
||||||
|
Invoice,
|
||||||
|
InvoiceLine,
|
||||||
|
Payment,
|
||||||
|
Tax,
|
||||||
|
FiscalYear,
|
||||||
|
FiscalPeriod,
|
||||||
|
} from '../modules/financial/entities/index.js';
|
||||||
|
|
||||||
|
// Import Inventory Entities
|
||||||
|
import {
|
||||||
|
Product,
|
||||||
|
Warehouse,
|
||||||
|
Location,
|
||||||
|
StockQuant,
|
||||||
|
Lot,
|
||||||
|
Picking,
|
||||||
|
StockMove,
|
||||||
|
InventoryAdjustment,
|
||||||
|
InventoryAdjustmentLine,
|
||||||
|
StockValuationLayer,
|
||||||
|
} from '../modules/inventory/entities/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TypeORM DataSource configuration
|
||||||
|
*
|
||||||
|
* Configurado para coexistir con el pool pg existente.
|
||||||
|
* Permite migración gradual a entities sin romper el código actual.
|
||||||
|
*/
|
||||||
|
export const AppDataSource = new DataSource({
|
||||||
|
type: 'postgres',
|
||||||
|
host: config.database.host,
|
||||||
|
port: config.database.port,
|
||||||
|
username: config.database.user,
|
||||||
|
password: config.database.password,
|
||||||
|
database: config.database.name,
|
||||||
|
|
||||||
|
// Schema por defecto para entities de autenticación
|
||||||
|
schema: 'auth',
|
||||||
|
|
||||||
|
// Entities registradas
|
||||||
|
entities: [
|
||||||
|
// Auth Core Entities
|
||||||
|
Tenant,
|
||||||
|
Company,
|
||||||
|
User,
|
||||||
|
Role,
|
||||||
|
Permission,
|
||||||
|
Session,
|
||||||
|
PasswordReset,
|
||||||
|
// Auth Extension Entities
|
||||||
|
Group,
|
||||||
|
ApiKey,
|
||||||
|
TrustedDevice,
|
||||||
|
VerificationCode,
|
||||||
|
MfaAuditLog,
|
||||||
|
OAuthProvider,
|
||||||
|
OAuthUserLink,
|
||||||
|
OAuthState,
|
||||||
|
// Core Module Entities
|
||||||
|
Partner,
|
||||||
|
Currency,
|
||||||
|
Country,
|
||||||
|
UomCategory,
|
||||||
|
Uom,
|
||||||
|
ProductCategory,
|
||||||
|
Sequence,
|
||||||
|
// Financial Entities
|
||||||
|
AccountType,
|
||||||
|
Account,
|
||||||
|
Journal,
|
||||||
|
JournalEntry,
|
||||||
|
JournalEntryLine,
|
||||||
|
Invoice,
|
||||||
|
InvoiceLine,
|
||||||
|
Payment,
|
||||||
|
Tax,
|
||||||
|
FiscalYear,
|
||||||
|
FiscalPeriod,
|
||||||
|
// Inventory Entities
|
||||||
|
Product,
|
||||||
|
Warehouse,
|
||||||
|
Location,
|
||||||
|
StockQuant,
|
||||||
|
Lot,
|
||||||
|
Picking,
|
||||||
|
StockMove,
|
||||||
|
InventoryAdjustment,
|
||||||
|
InventoryAdjustmentLine,
|
||||||
|
StockValuationLayer,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Directorios de migraciones (para uso futuro)
|
||||||
|
migrations: [
|
||||||
|
// 'src/database/migrations/*.ts'
|
||||||
|
],
|
||||||
|
|
||||||
|
// Directorios de subscribers (para uso futuro)
|
||||||
|
subscribers: [
|
||||||
|
// 'src/database/subscribers/*.ts'
|
||||||
|
],
|
||||||
|
|
||||||
|
// NO usar synchronize en producción - usamos DDL manual
|
||||||
|
synchronize: false,
|
||||||
|
|
||||||
|
// Logging: habilitado en desarrollo, solo errores en producción
|
||||||
|
logging: config.env === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
|
|
||||||
|
// Log queries lentas (> 1000ms)
|
||||||
|
maxQueryExecutionTime: 1000,
|
||||||
|
|
||||||
|
// Pool de conexiones (configuración conservadora para no interferir con pool pg)
|
||||||
|
extra: {
|
||||||
|
max: 10, // Menor que el pool pg (20) para no competir por conexiones
|
||||||
|
min: 2,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cache de queries (opcional, se puede habilitar después)
|
||||||
|
cache: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inicializa la conexión TypeORM
|
||||||
|
* @returns Promise<boolean> - true si la conexión fue exitosa
|
||||||
|
*/
|
||||||
|
export async function initializeTypeORM(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!AppDataSource.isInitialized) {
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
logger.info('TypeORM DataSource initialized successfully', {
|
||||||
|
database: config.database.name,
|
||||||
|
schema: 'auth',
|
||||||
|
host: config.database.host,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
logger.warn('TypeORM DataSource already initialized');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to initialize TypeORM DataSource', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
stack: (error as Error).stack,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cierra la conexión TypeORM
|
||||||
|
*/
|
||||||
|
export async function closeTypeORM(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (AppDataSource.isInitialized) {
|
||||||
|
await AppDataSource.destroy();
|
||||||
|
logger.info('TypeORM DataSource closed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error closing TypeORM DataSource', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el estado de la conexión TypeORM
|
||||||
|
*/
|
||||||
|
export function isTypeORMConnected(): boolean {
|
||||||
|
return AppDataSource.isInitialized;
|
||||||
|
}
|
||||||
138
src/docs/openapi.yaml
Normal file
138
src/docs/openapi.yaml
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
info:
|
||||||
|
title: ERP Generic - Core API
|
||||||
|
description: |
|
||||||
|
API para el sistema ERP genérico multitenant.
|
||||||
|
|
||||||
|
## Características principales
|
||||||
|
- Autenticación JWT y gestión de sesiones
|
||||||
|
- Multi-tenant con aislamiento de datos
|
||||||
|
- Gestión financiera y contable
|
||||||
|
- Control de inventario y almacenes
|
||||||
|
- Compras y ventas
|
||||||
|
- CRM y gestión de partners
|
||||||
|
- Proyectos y recursos humanos
|
||||||
|
- Sistema de permisos granular (API Keys)
|
||||||
|
|
||||||
|
## Autenticación
|
||||||
|
Todos los endpoints requieren autenticación mediante Bearer Token (JWT).
|
||||||
|
Algunos endpoints administrativos pueden requerir API Key específica.
|
||||||
|
|
||||||
|
version: 0.1.0
|
||||||
|
contact:
|
||||||
|
name: ERP Generic Support
|
||||||
|
email: support@erpgeneric.com
|
||||||
|
license:
|
||||||
|
name: Proprietary
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:3003/api/v1
|
||||||
|
description: Desarrollo local
|
||||||
|
- url: https://api.erpgeneric.com/api/v1
|
||||||
|
description: Producción
|
||||||
|
|
||||||
|
tags:
|
||||||
|
- name: Auth
|
||||||
|
description: Autenticación y autorización
|
||||||
|
- name: Users
|
||||||
|
description: Gestión de usuarios
|
||||||
|
- name: Companies
|
||||||
|
description: Gestión de empresas (tenants)
|
||||||
|
- name: Core
|
||||||
|
description: Configuración central y parámetros
|
||||||
|
- name: Partners
|
||||||
|
description: Gestión de partners (clientes, proveedores, contactos)
|
||||||
|
- name: Inventory
|
||||||
|
description: Control de inventario y productos
|
||||||
|
- name: Financial
|
||||||
|
description: Gestión financiera y contable
|
||||||
|
- name: Purchases
|
||||||
|
description: Compras y órdenes de compra
|
||||||
|
- name: Sales
|
||||||
|
description: Ventas, cotizaciones y pedidos
|
||||||
|
- name: Projects
|
||||||
|
description: Gestión de proyectos y tareas
|
||||||
|
- name: System
|
||||||
|
description: Configuración del sistema y logs
|
||||||
|
- name: CRM
|
||||||
|
description: CRM y gestión de oportunidades
|
||||||
|
- name: HR
|
||||||
|
description: Recursos humanos y empleados
|
||||||
|
- name: Reports
|
||||||
|
description: Reportes y analíticas
|
||||||
|
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
BearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: Token JWT obtenido del endpoint de login
|
||||||
|
|
||||||
|
ApiKeyAuth:
|
||||||
|
type: apiKey
|
||||||
|
in: header
|
||||||
|
name: X-API-Key
|
||||||
|
description: API Key para operaciones específicas
|
||||||
|
|
||||||
|
schemas:
|
||||||
|
ApiResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
PaginatedResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
pagination:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
example: 1
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
example: 20
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
example: 100
|
||||||
|
totalPages:
|
||||||
|
type: integer
|
||||||
|
example: 5
|
||||||
|
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Health
|
||||||
|
summary: Health check del servidor
|
||||||
|
security: []
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Servidor funcionando correctamente
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
example: ok
|
||||||
|
timestamp:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
71
src/index.ts
Normal file
71
src/index.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
// Importar reflect-metadata al inicio (requerido por TypeORM)
|
||||||
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
import app from './app.js';
|
||||||
|
import { config } from './config/index.js';
|
||||||
|
import { testConnection, closePool } from './config/database.js';
|
||||||
|
import { initializeTypeORM, closeTypeORM } from './config/typeorm.js';
|
||||||
|
import { initializeRedis, closeRedis } from './config/redis.js';
|
||||||
|
import { logger } from './shared/utils/logger.js';
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
logger.info('Starting ERP Generic Backend...', {
|
||||||
|
env: config.env,
|
||||||
|
port: config.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test database connection (pool pg existente)
|
||||||
|
const dbConnected = await testConnection();
|
||||||
|
if (!dbConnected) {
|
||||||
|
logger.error('Failed to connect to database. Exiting...');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize TypeORM DataSource
|
||||||
|
const typeormConnected = await initializeTypeORM();
|
||||||
|
if (!typeormConnected) {
|
||||||
|
logger.error('Failed to initialize TypeORM. Exiting...');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Redis (opcional - no detiene la app si falla)
|
||||||
|
await initializeRedis();
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const server = app.listen(config.port, () => {
|
||||||
|
logger.info(`Server running on port ${config.port}`);
|
||||||
|
logger.info(`API available at http://localhost:${config.port}${config.apiPrefix}`);
|
||||||
|
logger.info(`Health check at http://localhost:${config.port}/health`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
logger.info(`Received ${signal}. Starting graceful shutdown...`);
|
||||||
|
|
||||||
|
server.close(async () => {
|
||||||
|
logger.info('HTTP server closed');
|
||||||
|
|
||||||
|
// Cerrar conexiones en orden
|
||||||
|
await closeRedis();
|
||||||
|
await closeTypeORM();
|
||||||
|
await closePool();
|
||||||
|
|
||||||
|
logger.info('Shutdown complete');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force shutdown after 10s
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.error('Forced shutdown after timeout');
|
||||||
|
process.exit(1);
|
||||||
|
}, 10000);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap().catch((error) => {
|
||||||
|
logger.error('Failed to start server', { error: error.message });
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
331
src/modules/auth/apiKeys.controller.ts
Normal file
331
src/modules/auth/apiKeys.controller.ts
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { apiKeysService, CreateApiKeyDto, UpdateApiKeyDto, ApiKeyFilters } from './apiKeys.service.js';
|
||||||
|
import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VALIDATION SCHEMAS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const createApiKeySchema = z.object({
|
||||||
|
name: z.string().min(1, 'Nombre requerido').max(255),
|
||||||
|
scope: z.string().max(100).optional(),
|
||||||
|
allowed_ips: z.array(z.string().ip()).optional(),
|
||||||
|
expiration_days: z.number().int().positive().max(365).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateApiKeySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
scope: z.string().max(100).nullable().optional(),
|
||||||
|
allowed_ips: z.array(z.string().ip()).nullable().optional(),
|
||||||
|
expiration_date: z.string().datetime().nullable().optional(),
|
||||||
|
is_active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const listApiKeysSchema = z.object({
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
is_active: z.enum(['true', 'false']).optional(),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONTROLLER
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ApiKeysController {
|
||||||
|
/**
|
||||||
|
* Create a new API key
|
||||||
|
* POST /api/auth/api-keys
|
||||||
|
*/
|
||||||
|
async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = createApiKeySchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateApiKeyDto = {
|
||||||
|
...validation.data,
|
||||||
|
user_id: req.user!.userId,
|
||||||
|
tenant_id: req.user!.tenantId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await apiKeysService.create(dto);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'API key creada exitosamente. Guarde la clave, no podrá verla de nuevo.',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List API keys for the current user
|
||||||
|
* GET /api/auth/api-keys
|
||||||
|
*/
|
||||||
|
async list(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = listApiKeysSchema.safeParse(req.query);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Parámetros inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: ApiKeyFilters = {
|
||||||
|
tenant_id: req.user!.tenantId,
|
||||||
|
// By default, only show user's own keys unless admin
|
||||||
|
user_id: validation.data.user_id || req.user!.userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Admins can view all keys in tenant
|
||||||
|
if (validation.data.user_id && req.user!.roles.includes('admin')) {
|
||||||
|
filters.user_id = validation.data.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.data.is_active !== undefined) {
|
||||||
|
filters.is_active = validation.data.is_active === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.data.scope) {
|
||||||
|
filters.scope = validation.data.scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeys = await apiKeysService.findAll(filters);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: apiKeys,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific API key
|
||||||
|
* GET /api/auth/api-keys/:id
|
||||||
|
*/
|
||||||
|
async getById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const apiKey = await apiKeysService.findById(id, req.user!.tenantId);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'API key no encontrada',
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership (unless admin)
|
||||||
|
if (apiKey.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'No tiene permisos para ver esta API key',
|
||||||
|
};
|
||||||
|
res.status(403).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: apiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an API key
|
||||||
|
* PATCH /api/auth/api-keys/:id
|
||||||
|
*/
|
||||||
|
async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const validation = updateApiKeySchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership first
|
||||||
|
const existing = await apiKeysService.findById(id, req.user!.tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'API key no encontrada',
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'No tiene permisos para modificar esta API key',
|
||||||
|
};
|
||||||
|
res.status(403).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateApiKeyDto = {
|
||||||
|
...validation.data,
|
||||||
|
expiration_date: validation.data.expiration_date
|
||||||
|
? new Date(validation.data.expiration_date)
|
||||||
|
: validation.data.expiration_date === null
|
||||||
|
? null
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = await apiKeysService.update(id, req.user!.tenantId, dto);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: updated,
|
||||||
|
message: 'API key actualizada',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke an API key (soft delete)
|
||||||
|
* POST /api/auth/api-keys/:id/revoke
|
||||||
|
*/
|
||||||
|
async revoke(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Check ownership first
|
||||||
|
const existing = await apiKeysService.findById(id, req.user!.tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'API key no encontrada',
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'No tiene permisos para revocar esta API key',
|
||||||
|
};
|
||||||
|
res.status(403).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiKeysService.revoke(id, req.user!.tenantId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'API key revocada exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an API key permanently
|
||||||
|
* DELETE /api/auth/api-keys/:id
|
||||||
|
*/
|
||||||
|
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Check ownership first
|
||||||
|
const existing = await apiKeysService.findById(id, req.user!.tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'API key no encontrada',
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'No tiene permisos para eliminar esta API key',
|
||||||
|
};
|
||||||
|
res.status(403).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiKeysService.delete(id, req.user!.tenantId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'API key eliminada permanentemente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate an API key (invalidates old key, creates new)
|
||||||
|
* POST /api/auth/api-keys/:id/regenerate
|
||||||
|
*/
|
||||||
|
async regenerate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Check ownership first
|
||||||
|
const existing = await apiKeysService.findById(id, req.user!.tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'API key no encontrada',
|
||||||
|
};
|
||||||
|
res.status(404).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) {
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: false,
|
||||||
|
error: 'No tiene permisos para regenerar esta API key',
|
||||||
|
};
|
||||||
|
res.status(403).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await apiKeysService.regenerate(id, req.user!.tenantId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'API key regenerada. Guarde la nueva clave, no podrá verla de nuevo.',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiKeysController = new ApiKeysController();
|
||||||
56
src/modules/auth/apiKeys.routes.ts
Normal file
56
src/modules/auth/apiKeys.routes.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { apiKeysController } from './apiKeys.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API KEY MANAGEMENT ROUTES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API key
|
||||||
|
* POST /api/auth/api-keys
|
||||||
|
*/
|
||||||
|
router.post('/', (req, res, next) => apiKeysController.create(req, res, next));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List API keys (user's own, or all for admins)
|
||||||
|
* GET /api/auth/api-keys
|
||||||
|
*/
|
||||||
|
router.get('/', (req, res, next) => apiKeysController.list(req, res, next));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific API key
|
||||||
|
* GET /api/auth/api-keys/:id
|
||||||
|
*/
|
||||||
|
router.get('/:id', (req, res, next) => apiKeysController.getById(req, res, next));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an API key
|
||||||
|
* PATCH /api/auth/api-keys/:id
|
||||||
|
*/
|
||||||
|
router.patch('/:id', (req, res, next) => apiKeysController.update(req, res, next));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke an API key (soft delete)
|
||||||
|
* POST /api/auth/api-keys/:id/revoke
|
||||||
|
*/
|
||||||
|
router.post('/:id/revoke', (req, res, next) => apiKeysController.revoke(req, res, next));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an API key permanently
|
||||||
|
* DELETE /api/auth/api-keys/:id
|
||||||
|
*/
|
||||||
|
router.delete('/:id', (req, res, next) => apiKeysController.delete(req, res, next));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate an API key
|
||||||
|
* POST /api/auth/api-keys/:id/regenerate
|
||||||
|
*/
|
||||||
|
router.post('/:id/regenerate', (req, res, next) => apiKeysController.regenerate(req, res, next));
|
||||||
|
|
||||||
|
export default router;
|
||||||
491
src/modules/auth/apiKeys.service.ts
Normal file
491
src/modules/auth/apiKeys.service.ts
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { ValidationError, NotFoundError, UnauthorizedError } from '../../shared/types/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ApiKey {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
key_index: string;
|
||||||
|
key_hash: string;
|
||||||
|
scope: string | null;
|
||||||
|
allowed_ips: string[] | null;
|
||||||
|
expiration_date: Date | null;
|
||||||
|
last_used_at: Date | null;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApiKeyDto {
|
||||||
|
user_id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
scope?: string;
|
||||||
|
allowed_ips?: string[];
|
||||||
|
expiration_days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateApiKeyDto {
|
||||||
|
name?: string;
|
||||||
|
scope?: string;
|
||||||
|
allowed_ips?: string[];
|
||||||
|
expiration_date?: Date | null;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyWithPlainKey {
|
||||||
|
apiKey: Omit<ApiKey, 'key_hash'>;
|
||||||
|
plainKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
apiKey?: ApiKey;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiKeyFilters {
|
||||||
|
user_id?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONSTANTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const API_KEY_PREFIX = 'mgn_';
|
||||||
|
const KEY_LENGTH = 32; // 32 bytes = 256 bits
|
||||||
|
const HASH_ITERATIONS = 100000;
|
||||||
|
const HASH_KEYLEN = 64;
|
||||||
|
const HASH_DIGEST = 'sha512';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class ApiKeysService {
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure API key
|
||||||
|
*/
|
||||||
|
private generatePlainKey(): string {
|
||||||
|
const randomBytes = crypto.randomBytes(KEY_LENGTH);
|
||||||
|
const key = randomBytes.toString('base64url');
|
||||||
|
return `${API_KEY_PREFIX}${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the key index (first 16 chars after prefix) for lookup
|
||||||
|
*/
|
||||||
|
private getKeyIndex(plainKey: string): string {
|
||||||
|
const keyWithoutPrefix = plainKey.replace(API_KEY_PREFIX, '');
|
||||||
|
return keyWithoutPrefix.substring(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash the API key using PBKDF2
|
||||||
|
*/
|
||||||
|
private async hashKey(plainKey: string): Promise<string> {
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
crypto.pbkdf2(
|
||||||
|
plainKey,
|
||||||
|
salt,
|
||||||
|
HASH_ITERATIONS,
|
||||||
|
HASH_KEYLEN,
|
||||||
|
HASH_DIGEST,
|
||||||
|
(err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(`${salt}:${derivedKey.toString('hex')}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a plain key against a stored hash
|
||||||
|
*/
|
||||||
|
private async verifyKey(plainKey: string, storedHash: string): Promise<boolean> {
|
||||||
|
const [salt, hash] = storedHash.split(':');
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
crypto.pbkdf2(
|
||||||
|
plainKey,
|
||||||
|
salt,
|
||||||
|
HASH_ITERATIONS,
|
||||||
|
HASH_KEYLEN,
|
||||||
|
HASH_DIGEST,
|
||||||
|
(err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(derivedKey.toString('hex') === hash);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API key
|
||||||
|
* Returns the plain key only once - it cannot be retrieved later
|
||||||
|
*/
|
||||||
|
async create(dto: CreateApiKeyDto): Promise<ApiKeyWithPlainKey> {
|
||||||
|
// Validate user exists
|
||||||
|
const user = await queryOne<{ id: string }>(
|
||||||
|
'SELECT id FROM auth.users WHERE id = $1 AND tenant_id = $2',
|
||||||
|
[dto.user_id, dto.tenant_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ValidationError('Usuario no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate name
|
||||||
|
const existing = await queryOne<{ id: string }>(
|
||||||
|
'SELECT id FROM auth.api_keys WHERE user_id = $1 AND name = $2',
|
||||||
|
[dto.user_id, dto.name]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ValidationError('Ya existe una API key con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate key
|
||||||
|
const plainKey = this.generatePlainKey();
|
||||||
|
const keyIndex = this.getKeyIndex(plainKey);
|
||||||
|
const keyHash = await this.hashKey(plainKey);
|
||||||
|
|
||||||
|
// Calculate expiration date
|
||||||
|
let expirationDate: Date | null = null;
|
||||||
|
if (dto.expiration_days) {
|
||||||
|
expirationDate = new Date();
|
||||||
|
expirationDate.setDate(expirationDate.getDate() + dto.expiration_days);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert API key
|
||||||
|
const apiKey = await queryOne<ApiKey>(
|
||||||
|
`INSERT INTO auth.api_keys (
|
||||||
|
user_id, tenant_id, name, key_index, key_hash,
|
||||||
|
scope, allowed_ips, expiration_date, is_active
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
|
||||||
|
RETURNING id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, is_active, created_at, updated_at`,
|
||||||
|
[
|
||||||
|
dto.user_id,
|
||||||
|
dto.tenant_id,
|
||||||
|
dto.name,
|
||||||
|
keyIndex,
|
||||||
|
keyHash,
|
||||||
|
dto.scope || null,
|
||||||
|
dto.allowed_ips || null,
|
||||||
|
expirationDate,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error('Error al crear API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('API key created', {
|
||||||
|
apiKeyId: apiKey.id,
|
||||||
|
userId: dto.user_id,
|
||||||
|
name: dto.name
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey,
|
||||||
|
plainKey, // Only returned once!
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all API keys for a user/tenant
|
||||||
|
*/
|
||||||
|
async findAll(filters: ApiKeyFilters): Promise<Omit<ApiKey, 'key_hash'>[]> {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (filters.user_id) {
|
||||||
|
conditions.push(`user_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.tenant_id) {
|
||||||
|
conditions.push(`tenant_id = $${paramIndex++}`);
|
||||||
|
params.push(filters.tenant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.is_active !== undefined) {
|
||||||
|
conditions.push(`is_active = $${paramIndex++}`);
|
||||||
|
params.push(filters.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.scope) {
|
||||||
|
conditions.push(`scope = $${paramIndex++}`);
|
||||||
|
params.push(filters.scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0
|
||||||
|
? `WHERE ${conditions.join(' AND ')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const apiKeys = await query<ApiKey>(
|
||||||
|
`SELECT id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, last_used_at, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM auth.api_keys
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return apiKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a specific API key by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string, tenantId: string): Promise<Omit<ApiKey, 'key_hash'> | null> {
|
||||||
|
const apiKey = await queryOne<ApiKey>(
|
||||||
|
`SELECT id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, last_used_at, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM auth.api_keys
|
||||||
|
WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an API key
|
||||||
|
*/
|
||||||
|
async update(id: string, tenantId: string, dto: UpdateApiKeyDto): Promise<Omit<ApiKey, 'key_hash'>> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundError('API key no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = ['updated_at = NOW()'];
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updates.push(`name = $${paramIndex++}`);
|
||||||
|
params.push(dto.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.scope !== undefined) {
|
||||||
|
updates.push(`scope = $${paramIndex++}`);
|
||||||
|
params.push(dto.scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.allowed_ips !== undefined) {
|
||||||
|
updates.push(`allowed_ips = $${paramIndex++}`);
|
||||||
|
params.push(dto.allowed_ips);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.expiration_date !== undefined) {
|
||||||
|
updates.push(`expiration_date = $${paramIndex++}`);
|
||||||
|
params.push(dto.expiration_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.is_active !== undefined) {
|
||||||
|
updates.push(`is_active = $${paramIndex++}`);
|
||||||
|
params.push(dto.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(id);
|
||||||
|
params.push(tenantId);
|
||||||
|
|
||||||
|
const updated = await queryOne<ApiKey>(
|
||||||
|
`UPDATE auth.api_keys
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}
|
||||||
|
RETURNING id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, last_used_at, is_active,
|
||||||
|
created_at, updated_at`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('Error al actualizar API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('API key updated', { apiKeyId: id });
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke (soft delete) an API key
|
||||||
|
*/
|
||||||
|
async revoke(id: string, tenantId: string): Promise<void> {
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE auth.api_keys
|
||||||
|
SET is_active = false, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new NotFoundError('API key no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('API key revoked', { apiKeyId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an API key permanently
|
||||||
|
*/
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const result = await query(
|
||||||
|
'DELETE FROM auth.api_keys WHERE id = $1 AND tenant_id = $2',
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('API key deleted', { apiKeyId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an API key and return the associated user info
|
||||||
|
* This is the main method used by the authentication middleware
|
||||||
|
*/
|
||||||
|
async validate(plainKey: string, clientIp?: string): Promise<ApiKeyValidationResult> {
|
||||||
|
// Check prefix
|
||||||
|
if (!plainKey.startsWith(API_KEY_PREFIX)) {
|
||||||
|
return { valid: false, error: 'Formato de API key inválido' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key index for lookup
|
||||||
|
const keyIndex = this.getKeyIndex(plainKey);
|
||||||
|
|
||||||
|
// Find API key by index
|
||||||
|
const apiKey = await queryOne<ApiKey>(
|
||||||
|
`SELECT * FROM auth.api_keys
|
||||||
|
WHERE key_index = $1 AND is_active = true`,
|
||||||
|
[keyIndex]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return { valid: false, error: 'API key no encontrada o inactiva' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify hash
|
||||||
|
const isValid = await this.verifyKey(plainKey, apiKey.key_hash);
|
||||||
|
if (!isValid) {
|
||||||
|
return { valid: false, error: 'API key inválida' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (apiKey.expiration_date && new Date(apiKey.expiration_date) < new Date()) {
|
||||||
|
return { valid: false, error: 'API key expirada' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IP whitelist
|
||||||
|
if (apiKey.allowed_ips && apiKey.allowed_ips.length > 0 && clientIp) {
|
||||||
|
if (!apiKey.allowed_ips.includes(clientIp)) {
|
||||||
|
logger.warn('API key IP not allowed', {
|
||||||
|
apiKeyId: apiKey.id,
|
||||||
|
clientIp,
|
||||||
|
allowedIps: apiKey.allowed_ips
|
||||||
|
});
|
||||||
|
return { valid: false, error: 'IP no autorizada' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user info with roles
|
||||||
|
const user = await queryOne<{
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
email: string;
|
||||||
|
role_codes: string[];
|
||||||
|
}>(
|
||||||
|
`SELECT u.id, u.tenant_id, u.email, array_agg(r.code) as role_codes
|
||||||
|
FROM auth.users u
|
||||||
|
LEFT JOIN auth.user_roles ur ON u.id = ur.user_id
|
||||||
|
LEFT JOIN auth.roles r ON ur.role_id = r.id
|
||||||
|
WHERE u.id = $1 AND u.status = 'active'
|
||||||
|
GROUP BY u.id`,
|
||||||
|
[apiKey.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { valid: false, error: 'Usuario asociado no encontrado o inactivo' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last used timestamp (async, don't wait)
|
||||||
|
query(
|
||||||
|
'UPDATE auth.api_keys SET last_used_at = NOW() WHERE id = $1',
|
||||||
|
[apiKey.id]
|
||||||
|
).catch(err => logger.error('Error updating last_used_at', { error: err }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
apiKey,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
tenant_id: user.tenant_id,
|
||||||
|
email: user.email,
|
||||||
|
roles: user.role_codes?.filter(Boolean) || [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate an API key (creates new key, invalidates old)
|
||||||
|
*/
|
||||||
|
async regenerate(id: string, tenantId: string): Promise<ApiKeyWithPlainKey> {
|
||||||
|
const existing = await queryOne<ApiKey>(
|
||||||
|
'SELECT * FROM auth.api_keys WHERE id = $1 AND tenant_id = $2',
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundError('API key no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new key
|
||||||
|
const plainKey = this.generatePlainKey();
|
||||||
|
const keyIndex = this.getKeyIndex(plainKey);
|
||||||
|
const keyHash = await this.hashKey(plainKey);
|
||||||
|
|
||||||
|
// Update with new key
|
||||||
|
const updated = await queryOne<ApiKey>(
|
||||||
|
`UPDATE auth.api_keys
|
||||||
|
SET key_index = $1, key_hash = $2, updated_at = NOW()
|
||||||
|
WHERE id = $3 AND tenant_id = $4
|
||||||
|
RETURNING id, user_id, tenant_id, name, key_index, scope,
|
||||||
|
allowed_ips, expiration_date, is_active, created_at, updated_at`,
|
||||||
|
[keyIndex, keyHash, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('Error al regenerar API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('API key regenerated', { apiKeyId: id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey: updated,
|
||||||
|
plainKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiKeysService = new ApiKeysService();
|
||||||
192
src/modules/auth/auth.controller.ts
Normal file
192
src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { authService } from './auth.service.js';
|
||||||
|
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
|
||||||
|
|
||||||
|
// Validation schemas
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Email inválido'),
|
||||||
|
password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email('Email inválido'),
|
||||||
|
password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'),
|
||||||
|
// Soporta ambos formatos: full_name (legacy) o firstName+lastName (frontend)
|
||||||
|
full_name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres').optional(),
|
||||||
|
firstName: z.string().min(2, 'Nombre debe tener al menos 2 caracteres').optional(),
|
||||||
|
lastName: z.string().min(2, 'Apellido debe tener al menos 2 caracteres').optional(),
|
||||||
|
tenant_id: z.string().uuid('Tenant ID inválido').optional(),
|
||||||
|
companyName: z.string().optional(),
|
||||||
|
}).refine(
|
||||||
|
(data) => data.full_name || (data.firstName && data.lastName),
|
||||||
|
{ message: 'Se requiere full_name o firstName y lastName', path: ['full_name'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const changePasswordSchema = z.object({
|
||||||
|
current_password: z.string().min(1, 'Contraseña actual requerida'),
|
||||||
|
new_password: z.string().min(8, 'La nueva contraseña debe tener al menos 8 caracteres'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshTokenSchema = z.object({
|
||||||
|
refresh_token: z.string().min(1, 'Refresh token requerido'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export class AuthController {
|
||||||
|
async login(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = loginSchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract request metadata for session tracking
|
||||||
|
const metadata = {
|
||||||
|
ipAddress: req.ip || req.socket.remoteAddress || 'unknown',
|
||||||
|
userAgent: req.get('User-Agent') || 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await authService.login({
|
||||||
|
...validation.data,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'Inicio de sesión exitoso',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = registerSchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await authService.register(validation.data);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
message: 'Usuario registrado exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = refreshTokenSchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract request metadata for session tracking
|
||||||
|
const metadata = {
|
||||||
|
ipAddress: req.ip || req.socket.remoteAddress || 'unknown',
|
||||||
|
userAgent: req.get('User-Agent') || 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokens = await authService.refreshToken(validation.data.refresh_token, metadata);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: { tokens },
|
||||||
|
message: 'Token renovado exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const validation = changePasswordSchema.safeParse(req.body);
|
||||||
|
if (!validation.success) {
|
||||||
|
throw new ValidationError('Datos inválidos', validation.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
await authService.changePassword(
|
||||||
|
userId,
|
||||||
|
validation.data.current_password,
|
||||||
|
validation.data.new_password
|
||||||
|
);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'Contraseña actualizada exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const profile = await authService.getProfile(userId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: profile,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
// sessionId can come from body (sent by client after login)
|
||||||
|
const sessionId = req.body?.sessionId;
|
||||||
|
if (sessionId) {
|
||||||
|
await authService.logout(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'Sesión cerrada exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async logoutAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
const sessionsRevoked = await authService.logoutAll(userId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: { sessionsRevoked },
|
||||||
|
message: 'Todas las sesiones han sido cerradas',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authController = new AuthController();
|
||||||
18
src/modules/auth/auth.routes.ts
Normal file
18
src/modules/auth/auth.routes.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { authController } from './auth.controller.js';
|
||||||
|
import { authenticate } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Public routes
|
||||||
|
router.post('/login', (req, res, next) => authController.login(req, res, next));
|
||||||
|
router.post('/register', (req, res, next) => authController.register(req, res, next));
|
||||||
|
router.post('/refresh', (req, res, next) => authController.refreshToken(req, res, next));
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
router.get('/profile', authenticate, (req, res, next) => authController.getProfile(req, res, next));
|
||||||
|
router.post('/change-password', authenticate, (req, res, next) => authController.changePassword(req, res, next));
|
||||||
|
router.post('/logout', authenticate, (req, res, next) => authController.logout(req, res, next));
|
||||||
|
router.post('/logout-all', authenticate, (req, res, next) => authController.logoutAll(req, res, next));
|
||||||
|
|
||||||
|
export default router;
|
||||||
234
src/modules/auth/auth.service.ts
Normal file
234
src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { User, UserStatus, Role } from './entities/index.js';
|
||||||
|
import { tokenService, TokenPair, RequestMetadata } from './services/token.service.js';
|
||||||
|
import { UnauthorizedError, ValidationError, NotFoundError } from '../../shared/types/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
export interface LoginDto {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
metadata?: RequestMetadata; // IP and user agent for session tracking
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterDto {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
// Soporta ambos formatos para compatibilidad frontend/backend
|
||||||
|
full_name?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
tenant_id?: string;
|
||||||
|
companyName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforma full_name a firstName/lastName para respuesta al frontend
|
||||||
|
*/
|
||||||
|
export function splitFullName(fullName: string): { firstName: string; lastName: string } {
|
||||||
|
const parts = fullName.trim().split(/\s+/);
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return { firstName: parts[0], lastName: '' };
|
||||||
|
}
|
||||||
|
const firstName = parts[0];
|
||||||
|
const lastName = parts.slice(1).join(' ');
|
||||||
|
return { firstName, lastName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforma firstName/lastName a full_name para almacenar en BD
|
||||||
|
*/
|
||||||
|
export function buildFullName(firstName?: string, lastName?: string, fullName?: string): string {
|
||||||
|
if (fullName) return fullName.trim();
|
||||||
|
return `${firstName || ''} ${lastName || ''}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
user: Omit<User, 'passwordHash'> & { firstName: string; lastName: string };
|
||||||
|
tokens: TokenPair;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthService {
|
||||||
|
private userRepository: Repository<User>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.userRepository = AppDataSource.getRepository(User);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(dto: LoginDto): Promise<LoginResponse> {
|
||||||
|
// Find user by email using TypeORM
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { email: dto.email.toLowerCase(), status: UserStatus.ACTIVE },
|
||||||
|
relations: ['roles'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedError('Credenciales inválidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash || '');
|
||||||
|
if (!isValidPassword) {
|
||||||
|
throw new UnauthorizedError('Credenciales inválidas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
user.lastLoginAt = new Date();
|
||||||
|
user.loginCount += 1;
|
||||||
|
if (dto.metadata?.ipAddress) {
|
||||||
|
user.lastLoginIp = dto.metadata.ipAddress;
|
||||||
|
}
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
// Generate token pair using TokenService
|
||||||
|
const metadata: RequestMetadata = dto.metadata || {
|
||||||
|
ipAddress: 'unknown',
|
||||||
|
userAgent: 'unknown',
|
||||||
|
};
|
||||||
|
const tokens = await tokenService.generateTokenPair(user, metadata);
|
||||||
|
|
||||||
|
// Transform fullName to firstName/lastName for frontend response
|
||||||
|
const { firstName, lastName } = splitFullName(user.fullName);
|
||||||
|
|
||||||
|
// Remove passwordHash from response and add firstName/lastName
|
||||||
|
const { passwordHash, ...userWithoutPassword } = user;
|
||||||
|
const userResponse = {
|
||||||
|
...userWithoutPassword,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('User logged in', { userId: user.id, email: user.email });
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userResponse as any,
|
||||||
|
tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(dto: RegisterDto): Promise<LoginResponse> {
|
||||||
|
// Check if email already exists using TypeORM
|
||||||
|
const existingUser = await this.userRepository.findOne({
|
||||||
|
where: { email: dto.email.toLowerCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ValidationError('El email ya está registrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform firstName/lastName to fullName for database storage
|
||||||
|
const fullName = buildFullName(dto.firstName, dto.lastName, dto.full_name);
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||||
|
|
||||||
|
// Generate tenantId if not provided (new company registration)
|
||||||
|
const tenantId = dto.tenant_id || crypto.randomUUID();
|
||||||
|
|
||||||
|
// Create user using TypeORM
|
||||||
|
const newUser = this.userRepository.create({
|
||||||
|
email: dto.email.toLowerCase(),
|
||||||
|
passwordHash,
|
||||||
|
fullName,
|
||||||
|
tenantId,
|
||||||
|
status: UserStatus.ACTIVE,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.userRepository.save(newUser);
|
||||||
|
|
||||||
|
// Load roles relation for token generation
|
||||||
|
const userWithRoles = await this.userRepository.findOne({
|
||||||
|
where: { id: newUser.id },
|
||||||
|
relations: ['roles'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userWithRoles) {
|
||||||
|
throw new Error('Error al crear usuario');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token pair using TokenService
|
||||||
|
const metadata: RequestMetadata = {
|
||||||
|
ipAddress: 'unknown',
|
||||||
|
userAgent: 'unknown',
|
||||||
|
};
|
||||||
|
const tokens = await tokenService.generateTokenPair(userWithRoles, metadata);
|
||||||
|
|
||||||
|
// Transform fullName to firstName/lastName for frontend response
|
||||||
|
const { firstName, lastName } = splitFullName(userWithRoles.fullName);
|
||||||
|
|
||||||
|
// Remove passwordHash from response and add firstName/lastName
|
||||||
|
const { passwordHash: _, ...userWithoutPassword } = userWithRoles;
|
||||||
|
const userResponse = {
|
||||||
|
...userWithoutPassword,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('User registered', { userId: userWithRoles.id, email: userWithRoles.email });
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userResponse as any,
|
||||||
|
tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(refreshToken: string, metadata: RequestMetadata): Promise<TokenPair> {
|
||||||
|
// Delegate completely to TokenService
|
||||||
|
return tokenService.refreshTokens(refreshToken, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(sessionId: string): Promise<void> {
|
||||||
|
await tokenService.revokeSession(sessionId, 'user_logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
async logoutAll(userId: string): Promise<number> {
|
||||||
|
return tokenService.revokeAllUserSessions(userId, 'logout_all');
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
|
||||||
|
// Find user using TypeORM
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Usuario no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const isValidPassword = await bcrypt.compare(currentPassword, user.passwordHash || '');
|
||||||
|
if (!isValidPassword) {
|
||||||
|
throw new UnauthorizedError('Contraseña actual incorrecta');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash new password and update user
|
||||||
|
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
user.passwordHash = newPasswordHash;
|
||||||
|
user.updatedAt = new Date();
|
||||||
|
await this.userRepository.save(user);
|
||||||
|
|
||||||
|
// Revoke all sessions after password change for security
|
||||||
|
const revokedCount = await tokenService.revokeAllUserSessions(userId, 'password_changed');
|
||||||
|
|
||||||
|
logger.info('Password changed and all sessions revoked', { userId, revokedCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProfile(userId: string): Promise<Omit<User, 'passwordHash'>> {
|
||||||
|
// Find user using TypeORM with relations
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { id: userId },
|
||||||
|
relations: ['roles', 'companies'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Usuario no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove passwordHash from response
|
||||||
|
const { passwordHash, ...userWithoutPassword } = user;
|
||||||
|
return userWithoutPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = new AuthService();
|
||||||
87
src/modules/auth/entities/api-key.entity.ts
Normal file
87
src/modules/auth/entities/api-key.entity.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
import { Tenant } from './tenant.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'api_keys' })
|
||||||
|
@Index('idx_api_keys_lookup', ['keyIndex', 'isActive'], {
|
||||||
|
where: 'is_active = TRUE',
|
||||||
|
})
|
||||||
|
@Index('idx_api_keys_expiration', ['expirationDate'], {
|
||||||
|
where: 'expiration_date IS NOT NULL',
|
||||||
|
})
|
||||||
|
@Index('idx_api_keys_user', ['userId'])
|
||||||
|
@Index('idx_api_keys_tenant', ['tenantId'])
|
||||||
|
export class ApiKey {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
// Descripción
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
// Seguridad
|
||||||
|
@Column({ type: 'varchar', length: 16, nullable: false, name: 'key_index' })
|
||||||
|
keyIndex: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false, name: 'key_hash' })
|
||||||
|
keyHash: string;
|
||||||
|
|
||||||
|
// Scope y restricciones
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
scope: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'inet', array: true, nullable: true, name: 'allowed_ips' })
|
||||||
|
allowedIps: string[] | null;
|
||||||
|
|
||||||
|
// Expiración
|
||||||
|
@Column({
|
||||||
|
type: 'timestamptz',
|
||||||
|
nullable: true,
|
||||||
|
name: 'expiration_date',
|
||||||
|
})
|
||||||
|
expirationDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' })
|
||||||
|
lastUsedAt: Date | null;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'revoked_by' })
|
||||||
|
revokedByUser: User | null;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'revoked_at' })
|
||||||
|
revokedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'revoked_by' })
|
||||||
|
revokedBy: string | null;
|
||||||
|
}
|
||||||
93
src/modules/auth/entities/company.entity.ts
Normal file
93
src/modules/auth/entities/company.entity.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from './tenant.entity.js';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'companies' })
|
||||||
|
@Index('idx_companies_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_companies_parent_company_id', ['parentCompanyId'])
|
||||||
|
@Index('idx_companies_active', ['tenantId'], { where: 'deleted_at IS NULL' })
|
||||||
|
@Index('idx_companies_tax_id', ['taxId'])
|
||||||
|
export class Company {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true, name: 'legal_name' })
|
||||||
|
legalName: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true, name: 'tax_id' })
|
||||||
|
taxId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'currency_id' })
|
||||||
|
currencyId: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'uuid',
|
||||||
|
nullable: true,
|
||||||
|
name: 'parent_company_id',
|
||||||
|
})
|
||||||
|
parentCompanyId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'partner_id' })
|
||||||
|
partnerId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
settings: Record<string, any>;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Tenant, (tenant) => tenant.companies, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@ManyToOne(() => Company, (company) => company.childCompanies, {
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'parent_company_id' })
|
||||||
|
parentCompany: Company | null;
|
||||||
|
|
||||||
|
@ManyToMany(() => Company)
|
||||||
|
childCompanies: Company[];
|
||||||
|
|
||||||
|
@ManyToMany(() => User, (user) => user.companies)
|
||||||
|
users: User[];
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
89
src/modules/auth/entities/group.entity.ts
Normal file
89
src/modules/auth/entities/group.entity.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from './tenant.entity.js';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'groups' })
|
||||||
|
@Index('idx_groups_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_groups_code', ['code'])
|
||||||
|
@Index('idx_groups_category', ['category'])
|
||||||
|
@Index('idx_groups_is_system', ['isSystem'])
|
||||||
|
export class Group {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
// Configuración
|
||||||
|
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_system' })
|
||||||
|
isSystem: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
category: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
color: string | null;
|
||||||
|
|
||||||
|
// API Keys
|
||||||
|
@Column({
|
||||||
|
type: 'integer',
|
||||||
|
default: 30,
|
||||||
|
nullable: true,
|
||||||
|
name: 'api_key_max_duration_days',
|
||||||
|
})
|
||||||
|
apiKeyMaxDurationDays: number | null;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Tenant, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'created_by' })
|
||||||
|
createdByUser: User | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'updated_by' })
|
||||||
|
updatedByUser: User | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'deleted_by' })
|
||||||
|
deletedByUser: User | null;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true })
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
15
src/modules/auth/entities/index.ts
Normal file
15
src/modules/auth/entities/index.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export { Tenant, TenantStatus } from './tenant.entity.js';
|
||||||
|
export { Company } from './company.entity.js';
|
||||||
|
export { User, UserStatus } from './user.entity.js';
|
||||||
|
export { Role } from './role.entity.js';
|
||||||
|
export { Permission, PermissionAction } from './permission.entity.js';
|
||||||
|
export { Session, SessionStatus } from './session.entity.js';
|
||||||
|
export { PasswordReset } from './password-reset.entity.js';
|
||||||
|
export { Group } from './group.entity.js';
|
||||||
|
export { ApiKey } from './api-key.entity.js';
|
||||||
|
export { TrustedDevice, TrustLevel } from './trusted-device.entity.js';
|
||||||
|
export { VerificationCode, CodeType } from './verification-code.entity.js';
|
||||||
|
export { MfaAuditLog, MfaEventType } from './mfa-audit-log.entity.js';
|
||||||
|
export { OAuthProvider } from './oauth-provider.entity.js';
|
||||||
|
export { OAuthUserLink } from './oauth-user-link.entity.js';
|
||||||
|
export { OAuthState } from './oauth-state.entity.js';
|
||||||
87
src/modules/auth/entities/mfa-audit-log.entity.ts
Normal file
87
src/modules/auth/entities/mfa-audit-log.entity.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
|
||||||
|
export enum MfaEventType {
|
||||||
|
MFA_SETUP_INITIATED = 'mfa_setup_initiated',
|
||||||
|
MFA_SETUP_COMPLETED = 'mfa_setup_completed',
|
||||||
|
MFA_DISABLED = 'mfa_disabled',
|
||||||
|
TOTP_VERIFIED = 'totp_verified',
|
||||||
|
TOTP_FAILED = 'totp_failed',
|
||||||
|
BACKUP_CODE_USED = 'backup_code_used',
|
||||||
|
BACKUP_CODES_REGENERATED = 'backup_codes_regenerated',
|
||||||
|
DEVICE_TRUSTED = 'device_trusted',
|
||||||
|
DEVICE_REVOKED = 'device_revoked',
|
||||||
|
ANOMALY_DETECTED = 'anomaly_detected',
|
||||||
|
ACCOUNT_LOCKED = 'account_locked',
|
||||||
|
ACCOUNT_UNLOCKED = 'account_unlocked',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'mfa_audit_log' })
|
||||||
|
@Index('idx_mfa_audit_user', ['userId', 'createdAt'])
|
||||||
|
@Index('idx_mfa_audit_event', ['eventType', 'createdAt'])
|
||||||
|
@Index('idx_mfa_audit_failures', ['userId', 'createdAt'], {
|
||||||
|
where: 'success = FALSE',
|
||||||
|
})
|
||||||
|
export class MfaAuditLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
// Usuario
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
// Evento
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: MfaEventType,
|
||||||
|
nullable: false,
|
||||||
|
name: 'event_type',
|
||||||
|
})
|
||||||
|
eventType: MfaEventType;
|
||||||
|
|
||||||
|
// Resultado
|
||||||
|
@Column({ type: 'boolean', nullable: false })
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 128, nullable: true, name: 'failure_reason' })
|
||||||
|
failureReason: string | null;
|
||||||
|
|
||||||
|
// Contexto
|
||||||
|
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
|
||||||
|
ipAddress: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'user_agent' })
|
||||||
|
userAgent: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 128,
|
||||||
|
nullable: true,
|
||||||
|
name: 'device_fingerprint',
|
||||||
|
})
|
||||||
|
deviceFingerprint: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
|
location: Record<string, any> | null;
|
||||||
|
|
||||||
|
// Metadata adicional
|
||||||
|
@Column({ type: 'jsonb', default: {}, nullable: true })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
191
src/modules/auth/entities/oauth-provider.entity.ts
Normal file
191
src/modules/auth/entities/oauth-provider.entity.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from './tenant.entity.js';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
import { Role } from './role.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'oauth_providers' })
|
||||||
|
@Index('idx_oauth_providers_enabled', ['isEnabled'])
|
||||||
|
@Index('idx_oauth_providers_tenant', ['tenantId'])
|
||||||
|
@Index('idx_oauth_providers_code', ['code'])
|
||||||
|
export class OAuthProvider {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'tenant_id' })
|
||||||
|
tenantId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: false, unique: true })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
// Configuración OAuth2
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false, name: 'client_id' })
|
||||||
|
clientId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true, name: 'client_secret' })
|
||||||
|
clientSecret: string | null;
|
||||||
|
|
||||||
|
// Endpoints OAuth2
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 500,
|
||||||
|
nullable: false,
|
||||||
|
name: 'authorization_endpoint',
|
||||||
|
})
|
||||||
|
authorizationEndpoint: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 500,
|
||||||
|
nullable: false,
|
||||||
|
name: 'token_endpoint',
|
||||||
|
})
|
||||||
|
tokenEndpoint: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 500,
|
||||||
|
nullable: false,
|
||||||
|
name: 'userinfo_endpoint',
|
||||||
|
})
|
||||||
|
userinfoEndpoint: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true, name: 'jwks_uri' })
|
||||||
|
jwksUri: string | null;
|
||||||
|
|
||||||
|
// Scopes y parámetros
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 500,
|
||||||
|
default: 'openid profile email',
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
scope: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 50,
|
||||||
|
default: 'code',
|
||||||
|
nullable: false,
|
||||||
|
name: 'response_type',
|
||||||
|
})
|
||||||
|
responseType: string;
|
||||||
|
|
||||||
|
// PKCE Configuration
|
||||||
|
@Column({
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
nullable: false,
|
||||||
|
name: 'pkce_enabled',
|
||||||
|
})
|
||||||
|
pkceEnabled: boolean;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 10,
|
||||||
|
default: 'S256',
|
||||||
|
nullable: true,
|
||||||
|
name: 'code_challenge_method',
|
||||||
|
})
|
||||||
|
codeChallengeMethod: string | null;
|
||||||
|
|
||||||
|
// Mapeo de claims
|
||||||
|
@Column({
|
||||||
|
type: 'jsonb',
|
||||||
|
nullable: false,
|
||||||
|
name: 'claim_mapping',
|
||||||
|
default: {
|
||||||
|
sub: 'oauth_uid',
|
||||||
|
email: 'email',
|
||||||
|
name: 'name',
|
||||||
|
picture: 'avatar_url',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
claimMapping: Record<string, any>;
|
||||||
|
|
||||||
|
// UI
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true, name: 'icon_class' })
|
||||||
|
iconClass: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true, name: 'button_text' })
|
||||||
|
buttonText: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true, name: 'button_color' })
|
||||||
|
buttonColor: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'integer',
|
||||||
|
default: 10,
|
||||||
|
nullable: false,
|
||||||
|
name: 'display_order',
|
||||||
|
})
|
||||||
|
displayOrder: number;
|
||||||
|
|
||||||
|
// Estado
|
||||||
|
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_enabled' })
|
||||||
|
isEnabled: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_visible' })
|
||||||
|
isVisible: boolean;
|
||||||
|
|
||||||
|
// Restricciones
|
||||||
|
@Column({
|
||||||
|
type: 'text',
|
||||||
|
array: true,
|
||||||
|
nullable: true,
|
||||||
|
name: 'allowed_domains',
|
||||||
|
})
|
||||||
|
allowedDomains: string[] | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
nullable: false,
|
||||||
|
name: 'auto_create_users',
|
||||||
|
})
|
||||||
|
autoCreateUsers: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'default_role_id' })
|
||||||
|
defaultRoleId: string | null;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Tenant, { onDelete: 'CASCADE', nullable: true })
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Role, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'default_role_id' })
|
||||||
|
defaultRole: Role | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'created_by' })
|
||||||
|
createdByUser: User | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'updated_by' })
|
||||||
|
updatedByUser: User | null;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
66
src/modules/auth/entities/oauth-state.entity.ts
Normal file
66
src/modules/auth/entities/oauth-state.entity.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { OAuthProvider } from './oauth-provider.entity.js';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'oauth_states' })
|
||||||
|
@Index('idx_oauth_states_state', ['state'])
|
||||||
|
@Index('idx_oauth_states_expires', ['expiresAt'])
|
||||||
|
export class OAuthState {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: false, unique: true })
|
||||||
|
state: string;
|
||||||
|
|
||||||
|
// PKCE
|
||||||
|
@Column({ type: 'varchar', length: 128, nullable: true, name: 'code_verifier' })
|
||||||
|
codeVerifier: string | null;
|
||||||
|
|
||||||
|
// Contexto
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'provider_id' })
|
||||||
|
providerId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: false, name: 'redirect_uri' })
|
||||||
|
redirectUri: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true, name: 'return_url' })
|
||||||
|
returnUrl: string | null;
|
||||||
|
|
||||||
|
// Vinculación con usuario existente (para linking)
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'link_user_id' })
|
||||||
|
linkUserId: string | null;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
|
||||||
|
ipAddress: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'user_agent' })
|
||||||
|
userAgent: string | null;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => OAuthProvider)
|
||||||
|
@JoinColumn({ name: 'provider_id' })
|
||||||
|
provider: OAuthProvider;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'link_user_id' })
|
||||||
|
linkUser: User | null;
|
||||||
|
|
||||||
|
// Tiempo de vida
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: false, name: 'expires_at' })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'used_at' })
|
||||||
|
usedAt: Date | null;
|
||||||
|
}
|
||||||
73
src/modules/auth/entities/oauth-user-link.entity.ts
Normal file
73
src/modules/auth/entities/oauth-user-link.entity.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
import { OAuthProvider } from './oauth-provider.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'oauth_user_links' })
|
||||||
|
@Index('idx_oauth_links_user', ['userId'])
|
||||||
|
@Index('idx_oauth_links_provider', ['providerId'])
|
||||||
|
@Index('idx_oauth_links_oauth_uid', ['oauthUid'])
|
||||||
|
export class OAuthUserLink {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'provider_id' })
|
||||||
|
providerId: string;
|
||||||
|
|
||||||
|
// Identificación OAuth
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false, name: 'oauth_uid' })
|
||||||
|
oauthUid: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true, name: 'oauth_email' })
|
||||||
|
oauthEmail: string | null;
|
||||||
|
|
||||||
|
// Tokens (encriptados)
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'access_token' })
|
||||||
|
accessToken: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'refresh_token' })
|
||||||
|
refreshToken: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'id_token' })
|
||||||
|
idToken: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'token_expires_at' })
|
||||||
|
tokenExpiresAt: Date | null;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ type: 'jsonb', nullable: true, name: 'raw_userinfo' })
|
||||||
|
rawUserinfo: Record<string, any> | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'last_login_at' })
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 0, nullable: false, name: 'login_count' })
|
||||||
|
loginCount: number;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => OAuthProvider, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'provider_id' })
|
||||||
|
provider: OAuthProvider;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
45
src/modules/auth/entities/password-reset.entity.ts
Normal file
45
src/modules/auth/entities/password-reset.entity.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'password_resets' })
|
||||||
|
@Index('idx_password_resets_user_id', ['userId'])
|
||||||
|
@Index('idx_password_resets_token', ['token'])
|
||||||
|
@Index('idx_password_resets_expires_at', ['expiresAt'])
|
||||||
|
export class PasswordReset {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, unique: true, nullable: false })
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: false, name: 'expires_at' })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'used_at' })
|
||||||
|
usedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
|
||||||
|
ipAddress: string | null;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => User, (user) => user.passwordResets, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
52
src/modules/auth/entities/permission.entity.ts
Normal file
52
src/modules/auth/entities/permission.entity.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Role } from './role.entity.js';
|
||||||
|
|
||||||
|
export enum PermissionAction {
|
||||||
|
CREATE = 'create',
|
||||||
|
READ = 'read',
|
||||||
|
UPDATE = 'update',
|
||||||
|
DELETE = 'delete',
|
||||||
|
APPROVE = 'approve',
|
||||||
|
CANCEL = 'cancel',
|
||||||
|
EXPORT = 'export',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'permissions' })
|
||||||
|
@Index('idx_permissions_resource', ['resource'])
|
||||||
|
@Index('idx_permissions_action', ['action'])
|
||||||
|
@Index('idx_permissions_module', ['module'])
|
||||||
|
export class Permission {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
resource: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: PermissionAction,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
action: PermissionAction;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
module: string | null;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToMany(() => Role, (role) => role.permissions)
|
||||||
|
roles: Role[];
|
||||||
|
|
||||||
|
// Sin tenant_id: permisos son globales
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
84
src/modules/auth/entities/role.entity.ts
Normal file
84
src/modules/auth/entities/role.entity.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
ManyToMany,
|
||||||
|
JoinColumn,
|
||||||
|
JoinTable,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from './tenant.entity.js';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
import { Permission } from './permission.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'roles' })
|
||||||
|
@Index('idx_roles_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_roles_code', ['code'])
|
||||||
|
@Index('idx_roles_is_system', ['isSystem'])
|
||||||
|
export class Role {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_system' })
|
||||||
|
isSystem: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
color: string | null;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Tenant, (tenant) => tenant.roles, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@ManyToMany(() => Permission, (permission) => permission.roles)
|
||||||
|
@JoinTable({
|
||||||
|
name: 'role_permissions',
|
||||||
|
schema: 'auth',
|
||||||
|
joinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
||||||
|
inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' },
|
||||||
|
})
|
||||||
|
permissions: Permission[];
|
||||||
|
|
||||||
|
@ManyToMany(() => User, (user) => user.roles)
|
||||||
|
users: User[];
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
90
src/modules/auth/entities/session.entity.ts
Normal file
90
src/modules/auth/entities/session.entity.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
|
||||||
|
export enum SessionStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
EXPIRED = 'expired',
|
||||||
|
REVOKED = 'revoked',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'sessions' })
|
||||||
|
@Index('idx_sessions_user_id', ['userId'])
|
||||||
|
@Index('idx_sessions_token', ['token'])
|
||||||
|
@Index('idx_sessions_status', ['status'])
|
||||||
|
@Index('idx_sessions_expires_at', ['expiresAt'])
|
||||||
|
export class Session {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, unique: true, nullable: false })
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 500,
|
||||||
|
unique: true,
|
||||||
|
nullable: true,
|
||||||
|
name: 'refresh_token',
|
||||||
|
})
|
||||||
|
refreshToken: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: SessionStatus,
|
||||||
|
default: SessionStatus.ACTIVE,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
status: SessionStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: false, name: 'expires_at' })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
name: 'refresh_expires_at',
|
||||||
|
})
|
||||||
|
refreshExpiresAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
|
||||||
|
ipAddress: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'user_agent' })
|
||||||
|
userAgent: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true, name: 'device_info' })
|
||||||
|
deviceInfo: Record<string, any> | null;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => User, (user) => user.sessions, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'revoked_at' })
|
||||||
|
revokedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
nullable: true,
|
||||||
|
name: 'revoked_reason',
|
||||||
|
})
|
||||||
|
revokedReason: string | null;
|
||||||
|
}
|
||||||
93
src/modules/auth/entities/tenant.entity.ts
Normal file
93
src/modules/auth/entities/tenant.entity.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Company } from './company.entity.js';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
import { Role } from './role.entity.js';
|
||||||
|
|
||||||
|
export enum TenantStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
SUSPENDED = 'suspended',
|
||||||
|
TRIAL = 'trial',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'tenants' })
|
||||||
|
@Index('idx_tenants_subdomain', ['subdomain'])
|
||||||
|
@Index('idx_tenants_status', ['status'], { where: 'deleted_at IS NULL' })
|
||||||
|
@Index('idx_tenants_created_at', ['createdAt'])
|
||||||
|
export class Tenant {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, unique: true, nullable: false })
|
||||||
|
subdomain: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 100,
|
||||||
|
unique: true,
|
||||||
|
nullable: false,
|
||||||
|
name: 'schema_name',
|
||||||
|
})
|
||||||
|
schemaName: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: TenantStatus,
|
||||||
|
default: TenantStatus.ACTIVE,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
status: TenantStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
settings: Record<string, any>;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, default: 'basic', nullable: true })
|
||||||
|
plan: string;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 10, name: 'max_users' })
|
||||||
|
maxUsers: number;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@OneToMany(() => Company, (company) => company.tenant)
|
||||||
|
companies: Company[];
|
||||||
|
|
||||||
|
@OneToMany(() => User, (user) => user.tenant)
|
||||||
|
users: User[];
|
||||||
|
|
||||||
|
@OneToMany(() => Role, (role) => role.tenant)
|
||||||
|
roles: Role[];
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
115
src/modules/auth/entities/trusted-device.entity.ts
Normal file
115
src/modules/auth/entities/trusted-device.entity.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
|
||||||
|
export enum TrustLevel {
|
||||||
|
STANDARD = 'standard',
|
||||||
|
HIGH = 'high',
|
||||||
|
TEMPORARY = 'temporary',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'trusted_devices' })
|
||||||
|
@Index('idx_trusted_devices_user', ['userId'], { where: 'is_active' })
|
||||||
|
@Index('idx_trusted_devices_fingerprint', ['deviceFingerprint'])
|
||||||
|
@Index('idx_trusted_devices_expires', ['trustExpiresAt'], {
|
||||||
|
where: 'trust_expires_at IS NOT NULL AND is_active',
|
||||||
|
})
|
||||||
|
export class TrustedDevice {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
// Relación con usuario
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
// Identificación del dispositivo
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 128,
|
||||||
|
nullable: false,
|
||||||
|
name: 'device_fingerprint',
|
||||||
|
})
|
||||||
|
deviceFingerprint: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 128, nullable: true, name: 'device_name' })
|
||||||
|
deviceName: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32, nullable: true, name: 'device_type' })
|
||||||
|
deviceType: string | null;
|
||||||
|
|
||||||
|
// Información del dispositivo
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'user_agent' })
|
||||||
|
userAgent: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true, name: 'browser_name' })
|
||||||
|
browserName: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 32,
|
||||||
|
nullable: true,
|
||||||
|
name: 'browser_version',
|
||||||
|
})
|
||||||
|
browserVersion: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: true, name: 'os_name' })
|
||||||
|
osName: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 32, nullable: true, name: 'os_version' })
|
||||||
|
osVersion: string | null;
|
||||||
|
|
||||||
|
// Ubicación del registro
|
||||||
|
@Column({ type: 'inet', nullable: false, name: 'registered_ip' })
|
||||||
|
registeredIp: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', nullable: true, name: 'registered_location' })
|
||||||
|
registeredLocation: Record<string, any> | null;
|
||||||
|
|
||||||
|
// Estado de confianza
|
||||||
|
@Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: TrustLevel,
|
||||||
|
default: TrustLevel.STANDARD,
|
||||||
|
nullable: false,
|
||||||
|
name: 'trust_level',
|
||||||
|
})
|
||||||
|
trustLevel: TrustLevel;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'trust_expires_at' })
|
||||||
|
trustExpiresAt: Date | null;
|
||||||
|
|
||||||
|
// Uso
|
||||||
|
@Column({ type: 'timestamptz', nullable: false, name: 'last_used_at' })
|
||||||
|
lastUsedAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'inet', nullable: true, name: 'last_used_ip' })
|
||||||
|
lastUsedIp: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 1, nullable: false, name: 'use_count' })
|
||||||
|
useCount: number;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'revoked_at' })
|
||||||
|
revokedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 128, nullable: true, name: 'revoked_reason' })
|
||||||
|
revokedReason: string | null;
|
||||||
|
}
|
||||||
141
src/modules/auth/entities/user.entity.ts
Normal file
141
src/modules/auth/entities/user.entity.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
ManyToMany,
|
||||||
|
JoinColumn,
|
||||||
|
JoinTable,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Tenant } from './tenant.entity.js';
|
||||||
|
import { Role } from './role.entity.js';
|
||||||
|
import { Company } from './company.entity.js';
|
||||||
|
import { Session } from './session.entity.js';
|
||||||
|
import { PasswordReset } from './password-reset.entity.js';
|
||||||
|
|
||||||
|
export enum UserStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
INACTIVE = 'inactive',
|
||||||
|
SUSPENDED = 'suspended',
|
||||||
|
PENDING_VERIFICATION = 'pending_verification',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'users' })
|
||||||
|
@Index('idx_users_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_users_email', ['email'])
|
||||||
|
@Index('idx_users_status', ['status'], { where: 'deleted_at IS NULL' })
|
||||||
|
@Index('idx_users_email_tenant', ['tenantId', 'email'])
|
||||||
|
@Index('idx_users_created_at', ['createdAt'])
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false, name: 'password_hash' })
|
||||||
|
passwordHash: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false, name: 'full_name' })
|
||||||
|
fullName: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 500, nullable: true, name: 'avatar_url' })
|
||||||
|
avatarUrl: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: UserStatus,
|
||||||
|
default: UserStatus.ACTIVE,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
status: UserStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_superuser' })
|
||||||
|
isSuperuser: boolean;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
name: 'email_verified_at',
|
||||||
|
})
|
||||||
|
emailVerifiedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'last_login_at' })
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'inet', nullable: true, name: 'last_login_ip' })
|
||||||
|
lastLoginIp: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 0, name: 'login_count' })
|
||||||
|
loginCount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, default: 'es' })
|
||||||
|
language: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, default: 'America/Mexico_City' })
|
||||||
|
timezone: string;
|
||||||
|
|
||||||
|
@Column({ type: 'jsonb', default: {} })
|
||||||
|
settings: Record<string, any>;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Tenant, (tenant) => tenant.users, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'tenant_id' })
|
||||||
|
tenant: Tenant;
|
||||||
|
|
||||||
|
@ManyToMany(() => Role, (role) => role.users)
|
||||||
|
@JoinTable({
|
||||||
|
name: 'user_roles',
|
||||||
|
schema: 'auth',
|
||||||
|
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
|
||||||
|
inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' },
|
||||||
|
})
|
||||||
|
roles: Role[];
|
||||||
|
|
||||||
|
@ManyToMany(() => Company, (company) => company.users)
|
||||||
|
@JoinTable({
|
||||||
|
name: 'user_companies',
|
||||||
|
schema: 'auth',
|
||||||
|
joinColumn: { name: 'user_id', referencedColumnName: 'id' },
|
||||||
|
inverseJoinColumn: { name: 'company_id', referencedColumnName: 'id' },
|
||||||
|
})
|
||||||
|
companies: Company[];
|
||||||
|
|
||||||
|
@OneToMany(() => Session, (session) => session.user)
|
||||||
|
sessions: Session[];
|
||||||
|
|
||||||
|
@OneToMany(() => PasswordReset, (passwordReset) => passwordReset.user)
|
||||||
|
passwordResets: PasswordReset[];
|
||||||
|
|
||||||
|
// Auditoría
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
90
src/modules/auth/entities/verification-code.entity.ts
Normal file
90
src/modules/auth/entities/verification-code.entity.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { User } from './user.entity.js';
|
||||||
|
import { Session } from './session.entity.js';
|
||||||
|
|
||||||
|
export enum CodeType {
|
||||||
|
TOTP_SETUP = 'totp_setup',
|
||||||
|
SMS = 'sms',
|
||||||
|
EMAIL = 'email',
|
||||||
|
BACKUP = 'backup',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'auth', name: 'verification_codes' })
|
||||||
|
@Index('idx_verification_codes_user', ['userId', 'codeType'], {
|
||||||
|
where: 'used_at IS NULL',
|
||||||
|
})
|
||||||
|
@Index('idx_verification_codes_expires', ['expiresAt'], {
|
||||||
|
where: 'used_at IS NULL',
|
||||||
|
})
|
||||||
|
export class VerificationCode {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'session_id' })
|
||||||
|
sessionId: string | null;
|
||||||
|
|
||||||
|
// Tipo de código
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: CodeType,
|
||||||
|
nullable: false,
|
||||||
|
name: 'code_type',
|
||||||
|
})
|
||||||
|
codeType: CodeType;
|
||||||
|
|
||||||
|
// Código (hash SHA-256)
|
||||||
|
@Column({ type: 'varchar', length: 64, nullable: false, name: 'code_hash' })
|
||||||
|
codeHash: string;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 6, nullable: false, name: 'code_length' })
|
||||||
|
codeLength: number;
|
||||||
|
|
||||||
|
// Destino (para SMS/Email)
|
||||||
|
@Column({ type: 'varchar', length: 256, nullable: true })
|
||||||
|
destination: string | null;
|
||||||
|
|
||||||
|
// Intentos
|
||||||
|
@Column({ type: 'integer', default: 0, nullable: false })
|
||||||
|
attempts: number;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', default: 5, nullable: false, name: 'max_attempts' })
|
||||||
|
maxAttempts: number;
|
||||||
|
|
||||||
|
// Validez
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: false, name: 'expires_at' })
|
||||||
|
expiresAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true, name: 'used_at' })
|
||||||
|
usedAt: Date | null;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
@Column({ type: 'inet', nullable: true, name: 'ip_address' })
|
||||||
|
ipAddress: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'user_agent' })
|
||||||
|
userAgent: string | null;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@ManyToOne(() => Session, { onDelete: 'CASCADE', nullable: true })
|
||||||
|
@JoinColumn({ name: 'session_id' })
|
||||||
|
session: Session | null;
|
||||||
|
}
|
||||||
8
src/modules/auth/index.ts
Normal file
8
src/modules/auth/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export * from './auth.service.js';
|
||||||
|
export * from './auth.controller.js';
|
||||||
|
export { default as authRoutes } from './auth.routes.js';
|
||||||
|
|
||||||
|
// API Keys
|
||||||
|
export * from './apiKeys.service.js';
|
||||||
|
export * from './apiKeys.controller.js';
|
||||||
|
export { default as apiKeysRoutes } from './apiKeys.routes.js';
|
||||||
456
src/modules/auth/services/token.service.ts
Normal file
456
src/modules/auth/services/token.service.ts
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../../config/typeorm.js';
|
||||||
|
import { config } from '../../../config/index.js';
|
||||||
|
import { User, Session, SessionStatus } from '../entities/index.js';
|
||||||
|
import { blacklistToken, isTokenBlacklisted } from '../../../config/redis.js';
|
||||||
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
import { UnauthorizedError } from '../../../shared/types/index.js';
|
||||||
|
|
||||||
|
// ===== Interfaces =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Payload structure for access and refresh tokens
|
||||||
|
*/
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string; // User ID
|
||||||
|
tid: string; // Tenant ID
|
||||||
|
email: string;
|
||||||
|
roles: string[];
|
||||||
|
jti: string; // JWT ID único
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token pair returned after authentication
|
||||||
|
*/
|
||||||
|
export interface TokenPair {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
accessTokenExpiresAt: Date;
|
||||||
|
refreshTokenExpiresAt: Date;
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request metadata for session tracking
|
||||||
|
*/
|
||||||
|
export interface RequestMetadata {
|
||||||
|
ipAddress: string;
|
||||||
|
userAgent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TokenService Class =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing JWT tokens with blacklist support via Redis
|
||||||
|
* and session tracking via TypeORM
|
||||||
|
*/
|
||||||
|
class TokenService {
|
||||||
|
private sessionRepository: Repository<Session>;
|
||||||
|
|
||||||
|
// Configuration constants
|
||||||
|
private readonly ACCESS_TOKEN_EXPIRY = '15m';
|
||||||
|
private readonly REFRESH_TOKEN_EXPIRY = '7d';
|
||||||
|
private readonly ALGORITHM = 'HS256' as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.sessionRepository = AppDataSource.getRepository(Session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new token pair (access + refresh) and creates a session
|
||||||
|
* @param user - User entity with roles loaded
|
||||||
|
* @param metadata - Request metadata (IP, user agent)
|
||||||
|
* @returns Promise<TokenPair> - Access and refresh tokens with expiration dates
|
||||||
|
*/
|
||||||
|
async generateTokenPair(user: User, metadata: RequestMetadata): Promise<TokenPair> {
|
||||||
|
try {
|
||||||
|
logger.debug('Generating token pair', { userId: user.id, tenantId: user.tenantId });
|
||||||
|
|
||||||
|
// Extract role codes from user roles
|
||||||
|
const roles = user.roles ? user.roles.map(role => role.code) : [];
|
||||||
|
|
||||||
|
// Calculate expiration dates
|
||||||
|
const accessTokenExpiresAt = this.calculateExpiration(this.ACCESS_TOKEN_EXPIRY);
|
||||||
|
const refreshTokenExpiresAt = this.calculateExpiration(this.REFRESH_TOKEN_EXPIRY);
|
||||||
|
|
||||||
|
// Generate unique JWT IDs
|
||||||
|
const accessJti = this.generateJti();
|
||||||
|
const refreshJti = this.generateJti();
|
||||||
|
|
||||||
|
// Generate access token
|
||||||
|
const accessToken = this.generateToken({
|
||||||
|
sub: user.id,
|
||||||
|
tid: user.tenantId,
|
||||||
|
email: user.email,
|
||||||
|
roles,
|
||||||
|
jti: accessJti,
|
||||||
|
}, this.ACCESS_TOKEN_EXPIRY);
|
||||||
|
|
||||||
|
// Generate refresh token
|
||||||
|
const refreshToken = this.generateToken({
|
||||||
|
sub: user.id,
|
||||||
|
tid: user.tenantId,
|
||||||
|
email: user.email,
|
||||||
|
roles,
|
||||||
|
jti: refreshJti,
|
||||||
|
}, this.REFRESH_TOKEN_EXPIRY);
|
||||||
|
|
||||||
|
// Create session record in database
|
||||||
|
const session = this.sessionRepository.create({
|
||||||
|
userId: user.id,
|
||||||
|
token: accessJti, // Store JTI instead of full token
|
||||||
|
refreshToken: refreshJti, // Store JTI instead of full token
|
||||||
|
status: SessionStatus.ACTIVE,
|
||||||
|
expiresAt: accessTokenExpiresAt,
|
||||||
|
refreshExpiresAt: refreshTokenExpiresAt,
|
||||||
|
ipAddress: metadata.ipAddress,
|
||||||
|
userAgent: metadata.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.sessionRepository.save(session);
|
||||||
|
|
||||||
|
logger.info('Token pair generated successfully', {
|
||||||
|
userId: user.id,
|
||||||
|
sessionId: session.id,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
accessTokenExpiresAt,
|
||||||
|
refreshTokenExpiresAt,
|
||||||
|
sessionId: session.id,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error generating token pair', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes an access token using a valid refresh token
|
||||||
|
* Implements token replay detection for enhanced security
|
||||||
|
* @param refreshToken - Valid refresh token
|
||||||
|
* @param metadata - Request metadata (IP, user agent)
|
||||||
|
* @returns Promise<TokenPair> - New access and refresh tokens
|
||||||
|
* @throws UnauthorizedError if token is invalid or replay detected
|
||||||
|
*/
|
||||||
|
async refreshTokens(refreshToken: string, metadata: RequestMetadata): Promise<TokenPair> {
|
||||||
|
try {
|
||||||
|
logger.debug('Refreshing tokens');
|
||||||
|
|
||||||
|
// Verify refresh token
|
||||||
|
const payload = this.verifyRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
// Find active session with this refresh token JTI
|
||||||
|
const session = await this.sessionRepository.findOne({
|
||||||
|
where: {
|
||||||
|
refreshToken: payload.jti,
|
||||||
|
status: SessionStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
relations: ['user', 'user.roles'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
logger.warn('Refresh token not found or session inactive', {
|
||||||
|
jti: payload.jti,
|
||||||
|
});
|
||||||
|
throw new UnauthorizedError('Refresh token inválido o expirado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if session has already been used (token replay detection)
|
||||||
|
if (session.revokedAt !== null) {
|
||||||
|
logger.error('TOKEN REPLAY DETECTED - Session was already used', {
|
||||||
|
sessionId: session.id,
|
||||||
|
userId: session.userId,
|
||||||
|
jti: payload.jti,
|
||||||
|
});
|
||||||
|
|
||||||
|
// SECURITY: Revoke ALL user sessions on replay detection
|
||||||
|
const revokedCount = await this.revokeAllUserSessions(
|
||||||
|
session.userId,
|
||||||
|
'Token replay detected'
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.error('All user sessions revoked due to token replay', {
|
||||||
|
userId: session.userId,
|
||||||
|
revokedCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new UnauthorizedError('Replay de token detectado. Todas las sesiones han sido revocadas por seguridad.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session hasn't expired
|
||||||
|
if (session.refreshExpiresAt && new Date() > session.refreshExpiresAt) {
|
||||||
|
logger.warn('Refresh token expired', {
|
||||||
|
sessionId: session.id,
|
||||||
|
expiredAt: session.refreshExpiresAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.revokeSession(session.id, 'Token expired');
|
||||||
|
throw new UnauthorizedError('Refresh token expirado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark current session as used (revoke it)
|
||||||
|
session.status = SessionStatus.REVOKED;
|
||||||
|
session.revokedAt = new Date();
|
||||||
|
session.revokedReason = 'Used for refresh';
|
||||||
|
await this.sessionRepository.save(session);
|
||||||
|
|
||||||
|
// Generate new token pair
|
||||||
|
const newTokenPair = await this.generateTokenPair(session.user, metadata);
|
||||||
|
|
||||||
|
logger.info('Tokens refreshed successfully', {
|
||||||
|
userId: session.userId,
|
||||||
|
oldSessionId: session.id,
|
||||||
|
newSessionId: newTokenPair.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newTokenPair;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error refreshing tokens', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes a session and blacklists its access token
|
||||||
|
* @param sessionId - Session ID to revoke
|
||||||
|
* @param reason - Reason for revocation
|
||||||
|
*/
|
||||||
|
async revokeSession(sessionId: string, reason: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.debug('Revoking session', { sessionId, reason });
|
||||||
|
|
||||||
|
const session = await this.sessionRepository.findOne({
|
||||||
|
where: { id: sessionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
logger.warn('Session not found for revocation', { sessionId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark session as revoked
|
||||||
|
session.status = SessionStatus.REVOKED;
|
||||||
|
session.revokedAt = new Date();
|
||||||
|
session.revokedReason = reason;
|
||||||
|
await this.sessionRepository.save(session);
|
||||||
|
|
||||||
|
// Blacklist the access token (JTI) in Redis
|
||||||
|
const remainingTTL = this.calculateRemainingTTL(session.expiresAt);
|
||||||
|
if (remainingTTL > 0) {
|
||||||
|
await this.blacklistAccessToken(session.token, remainingTTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Session revoked successfully', { sessionId, reason });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error revoking session', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revokes all active sessions for a user
|
||||||
|
* Used for security events like password change or token replay detection
|
||||||
|
* @param userId - User ID whose sessions to revoke
|
||||||
|
* @param reason - Reason for revocation
|
||||||
|
* @returns Promise<number> - Number of sessions revoked
|
||||||
|
*/
|
||||||
|
async revokeAllUserSessions(userId: string, reason: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
logger.debug('Revoking all user sessions', { userId, reason });
|
||||||
|
|
||||||
|
const sessions = await this.sessionRepository.find({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: SessionStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
logger.debug('No active sessions found for user', { userId });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke each session
|
||||||
|
for (const session of sessions) {
|
||||||
|
session.status = SessionStatus.REVOKED;
|
||||||
|
session.revokedAt = new Date();
|
||||||
|
session.revokedReason = reason;
|
||||||
|
|
||||||
|
// Blacklist access token
|
||||||
|
const remainingTTL = this.calculateRemainingTTL(session.expiresAt);
|
||||||
|
if (remainingTTL > 0) {
|
||||||
|
await this.blacklistAccessToken(session.token, remainingTTL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sessionRepository.save(sessions);
|
||||||
|
|
||||||
|
logger.info('All user sessions revoked', {
|
||||||
|
userId,
|
||||||
|
count: sessions.length,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
|
||||||
|
return sessions.length;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error revoking all user sessions', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an access token to the Redis blacklist
|
||||||
|
* @param jti - JWT ID to blacklist
|
||||||
|
* @param expiresIn - TTL in seconds
|
||||||
|
*/
|
||||||
|
async blacklistAccessToken(jti: string, expiresIn: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await blacklistToken(jti, expiresIn);
|
||||||
|
logger.debug('Access token blacklisted', { jti, expiresIn });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error blacklisting access token', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
jti,
|
||||||
|
});
|
||||||
|
// Don't throw - blacklist is optional (Redis might be unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an access token is blacklisted
|
||||||
|
* @param jti - JWT ID to check
|
||||||
|
* @returns Promise<boolean> - true if blacklisted
|
||||||
|
*/
|
||||||
|
async isAccessTokenBlacklisted(jti: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await isTokenBlacklisted(jti);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error checking token blacklist', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
jti,
|
||||||
|
});
|
||||||
|
// Return false on error - fail open
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Private Helper Methods =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a JWT token with the specified payload and expiry
|
||||||
|
* @param payload - Token payload (without iat/exp)
|
||||||
|
* @param expiresIn - Expiration time string (e.g., '15m', '7d')
|
||||||
|
* @returns string - Signed JWT token
|
||||||
|
*/
|
||||||
|
private generateToken(payload: Omit<JwtPayload, 'iat' | 'exp'>, expiresIn: string): string {
|
||||||
|
return jwt.sign(payload, config.jwt.secret, {
|
||||||
|
expiresIn: expiresIn as jwt.SignOptions['expiresIn'],
|
||||||
|
algorithm: this.ALGORITHM,
|
||||||
|
} as SignOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies an access token and returns its payload
|
||||||
|
* @param token - JWT access token
|
||||||
|
* @returns JwtPayload - Decoded payload
|
||||||
|
* @throws UnauthorizedError if token is invalid
|
||||||
|
*/
|
||||||
|
private verifyAccessToken(token: string): JwtPayload {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, config.jwt.secret, {
|
||||||
|
algorithms: [this.ALGORITHM],
|
||||||
|
}) as JwtPayload;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Invalid access token', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
throw new UnauthorizedError('Access token inválido o expirado');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a refresh token and returns its payload
|
||||||
|
* @param token - JWT refresh token
|
||||||
|
* @returns JwtPayload - Decoded payload
|
||||||
|
* @throws UnauthorizedError if token is invalid
|
||||||
|
*/
|
||||||
|
private verifyRefreshToken(token: string): JwtPayload {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, config.jwt.secret, {
|
||||||
|
algorithms: [this.ALGORITHM],
|
||||||
|
}) as JwtPayload;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Invalid refresh token', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
throw new UnauthorizedError('Refresh token inválido o expirado');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a unique JWT ID (JTI) using UUID v4
|
||||||
|
* @returns string - Unique identifier
|
||||||
|
*/
|
||||||
|
private generateJti(): string {
|
||||||
|
return uuidv4();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates expiration date from a time string
|
||||||
|
* @param expiresIn - Time string (e.g., '15m', '7d')
|
||||||
|
* @returns Date - Expiration date
|
||||||
|
*/
|
||||||
|
private calculateExpiration(expiresIn: string): Date {
|
||||||
|
const unit = expiresIn.slice(-1);
|
||||||
|
const value = parseInt(expiresIn.slice(0, -1), 10);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 's':
|
||||||
|
return new Date(now.getTime() + value * 1000);
|
||||||
|
case 'm':
|
||||||
|
return new Date(now.getTime() + value * 60 * 1000);
|
||||||
|
case 'h':
|
||||||
|
return new Date(now.getTime() + value * 60 * 60 * 1000);
|
||||||
|
case 'd':
|
||||||
|
return new Date(now.getTime() + value * 24 * 60 * 60 * 1000);
|
||||||
|
default:
|
||||||
|
throw new Error(`Invalid time unit: ${unit}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates remaining TTL in seconds for a given expiration date
|
||||||
|
* @param expiresAt - Expiration date
|
||||||
|
* @returns number - Remaining seconds (0 if already expired)
|
||||||
|
*/
|
||||||
|
private calculateRemainingTTL(expiresAt: Date): number {
|
||||||
|
const now = new Date();
|
||||||
|
const remainingMs = expiresAt.getTime() - now.getTime();
|
||||||
|
return Math.max(0, Math.floor(remainingMs / 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Export Singleton Instance =====
|
||||||
|
|
||||||
|
export const tokenService = new TokenService();
|
||||||
241
src/modules/companies/companies.controller.ts
Normal file
241
src/modules/companies/companies.controller.ts
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { companiesService, CreateCompanyDto, UpdateCompanyDto, CompanyFilters } from './companies.service.js';
|
||||||
|
import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js';
|
||||||
|
|
||||||
|
// Validation schemas (accept both snake_case and camelCase from frontend)
|
||||||
|
const createCompanySchema = z.object({
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(255),
|
||||||
|
legal_name: z.string().max(255).optional(),
|
||||||
|
legalName: z.string().max(255).optional(),
|
||||||
|
tax_id: z.string().max(50).optional(),
|
||||||
|
taxId: z.string().max(50).optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
currencyId: z.string().uuid().optional(),
|
||||||
|
parent_company_id: z.string().uuid().optional(),
|
||||||
|
parentCompanyId: z.string().uuid().optional(),
|
||||||
|
settings: z.record(z.any()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCompanySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
legal_name: z.string().max(255).optional().nullable(),
|
||||||
|
legalName: z.string().max(255).optional().nullable(),
|
||||||
|
tax_id: z.string().max(50).optional().nullable(),
|
||||||
|
taxId: z.string().max(50).optional().nullable(),
|
||||||
|
currency_id: z.string().uuid().optional().nullable(),
|
||||||
|
currencyId: z.string().uuid().optional().nullable(),
|
||||||
|
parent_company_id: z.string().uuid().optional().nullable(),
|
||||||
|
parentCompanyId: z.string().uuid().optional().nullable(),
|
||||||
|
settings: z.record(z.any()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
search: z.string().optional(),
|
||||||
|
parent_company_id: z.string().uuid().optional(),
|
||||||
|
parentCompanyId: z.string().uuid().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
class CompaniesController {
|
||||||
|
async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = querySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const filters: CompanyFilters = {
|
||||||
|
search: queryResult.data.search,
|
||||||
|
parentCompanyId: queryResult.data.parentCompanyId || queryResult.data.parent_company_id,
|
||||||
|
page: queryResult.data.page,
|
||||||
|
limit: queryResult.data.limit,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await companiesService.findAll(tenantId, filters);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page || 1,
|
||||||
|
limit: filters.limit || 20,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const company = await companiesService.findById(id, tenantId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: company,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createCompanySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de empresa inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseResult.data;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
// Transform to camelCase DTO
|
||||||
|
const dto: CreateCompanyDto = {
|
||||||
|
name: data.name,
|
||||||
|
legalName: data.legalName || data.legal_name,
|
||||||
|
taxId: data.taxId || data.tax_id,
|
||||||
|
currencyId: data.currencyId || data.currency_id,
|
||||||
|
parentCompanyId: data.parentCompanyId || data.parent_company_id,
|
||||||
|
settings: data.settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
const company = await companiesService.create(dto, tenantId, userId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: company,
|
||||||
|
message: 'Empresa creada exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const parseResult = updateCompanySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de empresa inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseResult.data;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
// Transform to camelCase DTO
|
||||||
|
const dto: UpdateCompanyDto = {};
|
||||||
|
if (data.name !== undefined) dto.name = data.name;
|
||||||
|
if (data.legalName !== undefined || data.legal_name !== undefined) {
|
||||||
|
dto.legalName = data.legalName ?? data.legal_name;
|
||||||
|
}
|
||||||
|
if (data.taxId !== undefined || data.tax_id !== undefined) {
|
||||||
|
dto.taxId = data.taxId ?? data.tax_id;
|
||||||
|
}
|
||||||
|
if (data.currencyId !== undefined || data.currency_id !== undefined) {
|
||||||
|
dto.currencyId = data.currencyId ?? data.currency_id;
|
||||||
|
}
|
||||||
|
if (data.parentCompanyId !== undefined || data.parent_company_id !== undefined) {
|
||||||
|
dto.parentCompanyId = data.parentCompanyId ?? data.parent_company_id;
|
||||||
|
}
|
||||||
|
if (data.settings !== undefined) dto.settings = data.settings;
|
||||||
|
|
||||||
|
const company = await companiesService.update(id, dto, tenantId, userId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: company,
|
||||||
|
message: 'Empresa actualizada exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
await companiesService.delete(id, tenantId, userId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
message: 'Empresa eliminada exitosamente',
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUsers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const users = await companiesService.getUsers(id, tenantId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: users,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubsidiaries(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const subsidiaries = await companiesService.getSubsidiaries(id, tenantId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: subsidiaries,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHierarchy(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user!.tenantId;
|
||||||
|
const hierarchy = await companiesService.getHierarchy(tenantId);
|
||||||
|
|
||||||
|
const response: ApiResponse = {
|
||||||
|
success: true,
|
||||||
|
data: hierarchy,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const companiesController = new CompaniesController();
|
||||||
50
src/modules/companies/companies.routes.ts
Normal file
50
src/modules/companies/companies.routes.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { companiesController } from './companies.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// List companies (admin, manager)
|
||||||
|
router.get('/', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.findAll(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get company hierarchy tree (must be before /:id to avoid conflict)
|
||||||
|
router.get('/hierarchy/tree', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.getHierarchy(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get company by ID
|
||||||
|
router.get('/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.findById(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create company (admin only)
|
||||||
|
router.post('/', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.create(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update company (admin only)
|
||||||
|
router.put('/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.update(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete company (admin only)
|
||||||
|
router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.delete(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get users assigned to company
|
||||||
|
router.get('/:id/users', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.getUsers(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get subsidiaries (child companies)
|
||||||
|
router.get('/:id/subsidiaries', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
companiesController.getSubsidiaries(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
472
src/modules/companies/companies.service.ts
Normal file
472
src/modules/companies/companies.service.ts
Normal file
@ -0,0 +1,472 @@
|
|||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { Company } from '../auth/entities/index.js';
|
||||||
|
import { NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ===== Interfaces =====
|
||||||
|
|
||||||
|
export interface CreateCompanyDto {
|
||||||
|
name: string;
|
||||||
|
legalName?: string;
|
||||||
|
taxId?: string;
|
||||||
|
currencyId?: string;
|
||||||
|
parentCompanyId?: string;
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCompanyDto {
|
||||||
|
name?: string;
|
||||||
|
legalName?: string | null;
|
||||||
|
taxId?: string | null;
|
||||||
|
currencyId?: string | null;
|
||||||
|
parentCompanyId?: string | null;
|
||||||
|
settings?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyFilters {
|
||||||
|
search?: string;
|
||||||
|
parentCompanyId?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompanyWithRelations extends Company {
|
||||||
|
currencyCode?: string;
|
||||||
|
parentCompanyName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CompaniesService Class =====
|
||||||
|
|
||||||
|
class CompaniesService {
|
||||||
|
private companyRepository: Repository<Company>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.companyRepository = AppDataSource.getRepository(Company);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all companies for a tenant with filters and pagination
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
tenantId: string,
|
||||||
|
filters: CompanyFilters = {}
|
||||||
|
): Promise<{ data: CompanyWithRelations[]; total: number }> {
|
||||||
|
try {
|
||||||
|
const { search, parentCompanyId, page = 1, limit = 20 } = filters;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const queryBuilder = this.companyRepository
|
||||||
|
.createQueryBuilder('company')
|
||||||
|
.leftJoin('company.parentCompany', 'parentCompany')
|
||||||
|
.addSelect(['parentCompany.name'])
|
||||||
|
.where('company.tenantId = :tenantId', { tenantId })
|
||||||
|
.andWhere('company.deletedAt IS NULL');
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (search) {
|
||||||
|
queryBuilder.andWhere(
|
||||||
|
'(company.name ILIKE :search OR company.legalName ILIKE :search OR company.taxId ILIKE :search)',
|
||||||
|
{ search: `%${search}%` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by parent company
|
||||||
|
if (parentCompanyId) {
|
||||||
|
queryBuilder.andWhere('company.parentCompanyId = :parentCompanyId', { parentCompanyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await queryBuilder.getCount();
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
const companies = await queryBuilder
|
||||||
|
.orderBy('company.name', 'ASC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(limit)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Map to include relation names
|
||||||
|
const data: CompanyWithRelations[] = companies.map(company => ({
|
||||||
|
...company,
|
||||||
|
parentCompanyName: company.parentCompany?.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.debug('Companies retrieved', { tenantId, count: data.length, total });
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error retrieving companies', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get company by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string, tenantId: string): Promise<CompanyWithRelations> {
|
||||||
|
try {
|
||||||
|
const company = await this.companyRepository
|
||||||
|
.createQueryBuilder('company')
|
||||||
|
.leftJoin('company.parentCompany', 'parentCompany')
|
||||||
|
.addSelect(['parentCompany.name'])
|
||||||
|
.where('company.id = :id', { id })
|
||||||
|
.andWhere('company.tenantId = :tenantId', { tenantId })
|
||||||
|
.andWhere('company.deletedAt IS NULL')
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!company) {
|
||||||
|
throw new NotFoundError('Empresa no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...company,
|
||||||
|
parentCompanyName: company.parentCompany?.name,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error finding company', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new company
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
dto: CreateCompanyDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<Company> {
|
||||||
|
try {
|
||||||
|
// Validate unique tax_id within tenant
|
||||||
|
if (dto.taxId) {
|
||||||
|
const existing = await this.companyRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
taxId: dto.taxId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ValidationError('Ya existe una empresa con este RFC');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate parent company exists
|
||||||
|
if (dto.parentCompanyId) {
|
||||||
|
const parent = await this.companyRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: dto.parentCompanyId,
|
||||||
|
tenantId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Empresa matriz no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create company
|
||||||
|
const company = this.companyRepository.create({
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
legalName: dto.legalName || null,
|
||||||
|
taxId: dto.taxId || null,
|
||||||
|
currencyId: dto.currencyId || null,
|
||||||
|
parentCompanyId: dto.parentCompanyId || null,
|
||||||
|
settings: dto.settings || {},
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.companyRepository.save(company);
|
||||||
|
|
||||||
|
logger.info('Company created', {
|
||||||
|
companyId: company.id,
|
||||||
|
tenantId,
|
||||||
|
name: company.name,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return company;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating company', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
dto,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a company
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateCompanyDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<Company> {
|
||||||
|
try {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Validate unique tax_id if changing
|
||||||
|
if (dto.taxId !== undefined && dto.taxId !== existing.taxId) {
|
||||||
|
if (dto.taxId) {
|
||||||
|
const duplicate = await this.companyRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
taxId: dto.taxId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicate && duplicate.id !== id) {
|
||||||
|
throw new ValidationError('Ya existe una empresa con este RFC');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate parent company (prevent self-reference and cycles)
|
||||||
|
if (dto.parentCompanyId !== undefined && dto.parentCompanyId) {
|
||||||
|
if (dto.parentCompanyId === id) {
|
||||||
|
throw new ValidationError('Una empresa no puede ser su propia matriz');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = await this.companyRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: dto.parentCompanyId,
|
||||||
|
tenantId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Empresa matriz no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for circular reference
|
||||||
|
if (await this.wouldCreateCycle(id, dto.parentCompanyId, tenantId)) {
|
||||||
|
throw new ValidationError('La asignación crearía una referencia circular');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update allowed fields
|
||||||
|
if (dto.name !== undefined) existing.name = dto.name;
|
||||||
|
if (dto.legalName !== undefined) existing.legalName = dto.legalName;
|
||||||
|
if (dto.taxId !== undefined) existing.taxId = dto.taxId;
|
||||||
|
if (dto.currencyId !== undefined) existing.currencyId = dto.currencyId;
|
||||||
|
if (dto.parentCompanyId !== undefined) existing.parentCompanyId = dto.parentCompanyId;
|
||||||
|
if (dto.settings !== undefined) {
|
||||||
|
existing.settings = { ...existing.settings, ...dto.settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.updatedBy = userId;
|
||||||
|
existing.updatedAt = new Date();
|
||||||
|
|
||||||
|
await this.companyRepository.save(existing);
|
||||||
|
|
||||||
|
logger.info('Company updated', {
|
||||||
|
companyId: id,
|
||||||
|
tenantId,
|
||||||
|
updatedBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating company', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete a company
|
||||||
|
*/
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if company has child companies
|
||||||
|
const childrenCount = await this.companyRepository.count({
|
||||||
|
where: {
|
||||||
|
parentCompanyId: id,
|
||||||
|
tenantId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (childrenCount > 0) {
|
||||||
|
throw new ForbiddenError(
|
||||||
|
'No se puede eliminar una empresa que tiene empresas subsidiarias'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await this.companyRepository.update(
|
||||||
|
{ id, tenantId },
|
||||||
|
{
|
||||||
|
deletedAt: new Date(),
|
||||||
|
deletedBy: userId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Company deleted', {
|
||||||
|
companyId: id,
|
||||||
|
tenantId,
|
||||||
|
deletedBy: userId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting company', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get users assigned to a company
|
||||||
|
*/
|
||||||
|
async getUsers(companyId: string, tenantId: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
await this.findById(companyId, tenantId);
|
||||||
|
|
||||||
|
// Using raw query for user_companies junction table
|
||||||
|
const users = await this.companyRepository.query(
|
||||||
|
`SELECT u.id, u.email, u.full_name, u.status, uc.is_default, uc.assigned_at
|
||||||
|
FROM auth.users u
|
||||||
|
INNER JOIN auth.user_companies uc ON u.id = uc.user_id
|
||||||
|
WHERE uc.company_id = $1 AND u.tenant_id = $2 AND u.deleted_at IS NULL
|
||||||
|
ORDER BY u.full_name`,
|
||||||
|
[companyId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return users;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting company users', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
companyId,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get child companies (subsidiaries)
|
||||||
|
*/
|
||||||
|
async getSubsidiaries(companyId: string, tenantId: string): Promise<Company[]> {
|
||||||
|
try {
|
||||||
|
await this.findById(companyId, tenantId);
|
||||||
|
|
||||||
|
return await this.companyRepository.find({
|
||||||
|
where: {
|
||||||
|
parentCompanyId: companyId,
|
||||||
|
tenantId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting subsidiaries', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
companyId,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full company hierarchy (tree structure)
|
||||||
|
*/
|
||||||
|
async getHierarchy(tenantId: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
// Get all companies
|
||||||
|
const companies = await this.companyRepository.find({
|
||||||
|
where: { tenantId, deletedAt: IsNull() },
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build tree structure
|
||||||
|
const companyMap = new Map<string, any>();
|
||||||
|
const roots: any[] = [];
|
||||||
|
|
||||||
|
// First pass: create map
|
||||||
|
for (const company of companies) {
|
||||||
|
companyMap.set(company.id, {
|
||||||
|
...company,
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: build tree
|
||||||
|
for (const company of companies) {
|
||||||
|
const node = companyMap.get(company.id);
|
||||||
|
if (company.parentCompanyId && companyMap.has(company.parentCompanyId)) {
|
||||||
|
companyMap.get(company.parentCompanyId).children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return roots;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting company hierarchy', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if assigning a parent would create a circular reference
|
||||||
|
*/
|
||||||
|
private async wouldCreateCycle(
|
||||||
|
companyId: string,
|
||||||
|
newParentId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
let currentId: string | null = newParentId;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
while (currentId) {
|
||||||
|
if (visited.has(currentId)) {
|
||||||
|
return true; // Found a cycle
|
||||||
|
}
|
||||||
|
if (currentId === companyId) {
|
||||||
|
return true; // Would create a cycle
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(currentId);
|
||||||
|
|
||||||
|
const parent = await this.companyRepository.findOne({
|
||||||
|
where: { id: currentId, tenantId, deletedAt: IsNull() },
|
||||||
|
select: ['parentCompanyId'],
|
||||||
|
});
|
||||||
|
|
||||||
|
currentId = parent?.parentCompanyId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Export Singleton Instance =====
|
||||||
|
|
||||||
|
export const companiesService = new CompaniesService();
|
||||||
3
src/modules/companies/index.ts
Normal file
3
src/modules/companies/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './companies.service.js';
|
||||||
|
export * from './companies.controller.js';
|
||||||
|
export { default as companiesRoutes } from './companies.routes.js';
|
||||||
257
src/modules/core/core.controller.ts
Normal file
257
src/modules/core/core.controller.ts
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './currencies.service.js';
|
||||||
|
import { countriesService } from './countries.service.js';
|
||||||
|
import { uomService, CreateUomDto, UpdateUomDto } from './uom.service.js';
|
||||||
|
import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './product-categories.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Schemas
|
||||||
|
const createCurrencySchema = z.object({
|
||||||
|
code: z.string().length(3, 'El código debe tener 3 caracteres').toUpperCase(),
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(100),
|
||||||
|
symbol: z.string().min(1).max(10),
|
||||||
|
decimal_places: z.number().int().min(0).max(6).optional(),
|
||||||
|
decimals: z.number().int().min(0).max(6).optional(), // Accept camelCase
|
||||||
|
}).refine((data) => data.decimal_places !== undefined || data.decimals !== undefined, {
|
||||||
|
message: 'decimal_places or decimals is required',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCurrencySchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
symbol: z.string().min(1).max(10).optional(),
|
||||||
|
decimal_places: z.number().int().min(0).max(6).optional(),
|
||||||
|
decimals: z.number().int().min(0).max(6).optional(), // Accept camelCase
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createUomSchema = z.object({
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(100),
|
||||||
|
code: z.string().min(1).max(20),
|
||||||
|
category_id: z.string().uuid().optional(),
|
||||||
|
categoryId: z.string().uuid().optional(), // Accept camelCase
|
||||||
|
uom_type: z.enum(['reference', 'bigger', 'smaller']).optional(),
|
||||||
|
uomType: z.enum(['reference', 'bigger', 'smaller']).optional(), // Accept camelCase
|
||||||
|
ratio: z.number().positive().default(1),
|
||||||
|
}).refine((data) => data.category_id !== undefined || data.categoryId !== undefined, {
|
||||||
|
message: 'category_id or categoryId is required',
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUomSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
ratio: z.number().positive().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createCategorySchema = z.object({
|
||||||
|
name: z.string().min(1, 'El nombre es requerido').max(100),
|
||||||
|
code: z.string().min(1).max(50),
|
||||||
|
parent_id: z.string().uuid().optional(),
|
||||||
|
parentId: z.string().uuid().optional(), // Accept camelCase
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCategorySchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
parent_id: z.string().uuid().optional().nullable(),
|
||||||
|
parentId: z.string().uuid().optional().nullable(), // Accept camelCase
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
class CoreController {
|
||||||
|
// ========== CURRENCIES ==========
|
||||||
|
async getCurrencies(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.active === 'true';
|
||||||
|
const currencies = await currenciesService.findAll(activeOnly);
|
||||||
|
res.json({ success: true, data: currencies });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const currency = await currenciesService.findById(req.params.id);
|
||||||
|
res.json({ success: true, data: currency });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createCurrencySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de moneda inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateCurrencyDto = parseResult.data;
|
||||||
|
const currency = await currenciesService.create(dto);
|
||||||
|
res.status(201).json({ success: true, data: currency, message: 'Moneda creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateCurrencySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de moneda inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateCurrencyDto = parseResult.data;
|
||||||
|
const currency = await currenciesService.update(req.params.id, dto);
|
||||||
|
res.json({ success: true, data: currency, message: 'Moneda actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== COUNTRIES ==========
|
||||||
|
async getCountries(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const countries = await countriesService.findAll();
|
||||||
|
res.json({ success: true, data: countries });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCountry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const country = await countriesService.findById(req.params.id);
|
||||||
|
res.json({ success: true, data: country });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== UOM CATEGORIES ==========
|
||||||
|
async getUomCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.active === 'true';
|
||||||
|
const categories = await uomService.findAllCategories(activeOnly);
|
||||||
|
res.json({ success: true, data: categories });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUomCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const category = await uomService.findCategoryById(req.params.id);
|
||||||
|
res.json({ success: true, data: category });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== UOM ==========
|
||||||
|
async getUoms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.active === 'true';
|
||||||
|
const categoryId = req.query.category_id as string | undefined;
|
||||||
|
const uoms = await uomService.findAll(categoryId, activeOnly);
|
||||||
|
res.json({ success: true, data: uoms });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const uom = await uomService.findById(req.params.id);
|
||||||
|
res.json({ success: true, data: uom });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createUomSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de UdM inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateUomDto = parseResult.data;
|
||||||
|
const uom = await uomService.create(dto);
|
||||||
|
res.status(201).json({ success: true, data: uom, message: 'Unidad de medida creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateUomSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de UdM inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateUomDto = parseResult.data;
|
||||||
|
const uom = await uomService.update(req.params.id, dto);
|
||||||
|
res.json({ success: true, data: uom, message: 'Unidad de medida actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PRODUCT CATEGORIES ==========
|
||||||
|
async getProductCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activeOnly = req.query.active === 'true';
|
||||||
|
const parentId = req.query.parent_id as string | undefined;
|
||||||
|
const categories = await productCategoriesService.findAll(req.tenantId!, parentId, activeOnly);
|
||||||
|
res.json({ success: true, data: categories });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const category = await productCategoriesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: category });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createCategorySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de categoría inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateProductCategoryDto = parseResult.data;
|
||||||
|
const category = await productCategoriesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: category, message: 'Categoría creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateCategorySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de categoría inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateProductCategoryDto = parseResult.data;
|
||||||
|
const category = await productCategoriesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: category, message: 'Categoría actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await productCategoriesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Categoría eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const coreController = new CoreController();
|
||||||
51
src/modules/core/core.routes.ts
Normal file
51
src/modules/core/core.routes.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { coreController } from './core.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== CURRENCIES ==========
|
||||||
|
router.get('/currencies', (req, res, next) => coreController.getCurrencies(req, res, next));
|
||||||
|
router.get('/currencies/:id', (req, res, next) => coreController.getCurrency(req, res, next));
|
||||||
|
router.post('/currencies', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.createCurrency(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/currencies/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.updateCurrency(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== COUNTRIES ==========
|
||||||
|
router.get('/countries', (req, res, next) => coreController.getCountries(req, res, next));
|
||||||
|
router.get('/countries/:id', (req, res, next) => coreController.getCountry(req, res, next));
|
||||||
|
|
||||||
|
// ========== UOM CATEGORIES ==========
|
||||||
|
router.get('/uom-categories', (req, res, next) => coreController.getUomCategories(req, res, next));
|
||||||
|
router.get('/uom-categories/:id', (req, res, next) => coreController.getUomCategory(req, res, next));
|
||||||
|
|
||||||
|
// ========== UOM ==========
|
||||||
|
router.get('/uom', (req, res, next) => coreController.getUoms(req, res, next));
|
||||||
|
router.get('/uom/:id', (req, res, next) => coreController.getUom(req, res, next));
|
||||||
|
router.post('/uom', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.createUom(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/uom/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.updateUom(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== PRODUCT CATEGORIES ==========
|
||||||
|
router.get('/product-categories', (req, res, next) => coreController.getProductCategories(req, res, next));
|
||||||
|
router.get('/product-categories/:id', (req, res, next) => coreController.getProductCategory(req, res, next));
|
||||||
|
router.post('/product-categories', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.createProductCategory(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/product-categories/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.updateProductCategory(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/product-categories/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
coreController.deleteProductCategory(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
45
src/modules/core/countries.service.ts
Normal file
45
src/modules/core/countries.service.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { Country } from './entities/country.entity.js';
|
||||||
|
import { NotFoundError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
class CountriesService {
|
||||||
|
private repository: Repository<Country>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = AppDataSource.getRepository(Country);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<Country[]> {
|
||||||
|
logger.debug('Finding all countries');
|
||||||
|
|
||||||
|
return this.repository.find({
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Country> {
|
||||||
|
logger.debug('Finding country by id', { id });
|
||||||
|
|
||||||
|
const country = await this.repository.findOne({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!country) {
|
||||||
|
throw new NotFoundError('País no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return country;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCode(code: string): Promise<Country | null> {
|
||||||
|
logger.debug('Finding country by code', { code });
|
||||||
|
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { code: code.toUpperCase() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const countriesService = new CountriesService();
|
||||||
118
src/modules/core/currencies.service.ts
Normal file
118
src/modules/core/currencies.service.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { Currency } from './entities/currency.entity.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
export interface CreateCurrencyDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
symbol: string;
|
||||||
|
decimal_places?: number;
|
||||||
|
decimals?: number; // Accept camelCase too
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCurrencyDto {
|
||||||
|
name?: string;
|
||||||
|
symbol?: string;
|
||||||
|
decimal_places?: number;
|
||||||
|
decimals?: number; // Accept camelCase too
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CurrenciesService {
|
||||||
|
private repository: Repository<Currency>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = AppDataSource.getRepository(Currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(activeOnly: boolean = false): Promise<Currency[]> {
|
||||||
|
logger.debug('Finding all currencies', { activeOnly });
|
||||||
|
|
||||||
|
const queryBuilder = this.repository
|
||||||
|
.createQueryBuilder('currency')
|
||||||
|
.orderBy('currency.code', 'ASC');
|
||||||
|
|
||||||
|
if (activeOnly) {
|
||||||
|
queryBuilder.where('currency.active = :active', { active: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryBuilder.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Currency> {
|
||||||
|
logger.debug('Finding currency by id', { id });
|
||||||
|
|
||||||
|
const currency = await this.repository.findOne({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currency) {
|
||||||
|
throw new NotFoundError('Moneda no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCode(code: string): Promise<Currency | null> {
|
||||||
|
logger.debug('Finding currency by code', { code });
|
||||||
|
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { code: code.toUpperCase() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCurrencyDto): Promise<Currency> {
|
||||||
|
logger.debug('Creating currency', { code: dto.code });
|
||||||
|
|
||||||
|
const existing = await this.findByCode(dto.code);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe una moneda con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept both snake_case and camelCase
|
||||||
|
const decimals = dto.decimal_places ?? dto.decimals ?? 2;
|
||||||
|
|
||||||
|
const currency = this.repository.create({
|
||||||
|
code: dto.code.toUpperCase(),
|
||||||
|
name: dto.name,
|
||||||
|
symbol: dto.symbol,
|
||||||
|
decimals,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.repository.save(currency);
|
||||||
|
logger.info('Currency created', { id: saved.id, code: saved.code });
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCurrencyDto): Promise<Currency> {
|
||||||
|
logger.debug('Updating currency', { id });
|
||||||
|
|
||||||
|
const currency = await this.findById(id);
|
||||||
|
|
||||||
|
// Accept both snake_case and camelCase
|
||||||
|
const decimals = dto.decimal_places ?? dto.decimals;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
currency.name = dto.name;
|
||||||
|
}
|
||||||
|
if (dto.symbol !== undefined) {
|
||||||
|
currency.symbol = dto.symbol;
|
||||||
|
}
|
||||||
|
if (decimals !== undefined) {
|
||||||
|
currency.decimals = decimals;
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
currency.active = dto.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.repository.save(currency);
|
||||||
|
logger.info('Currency updated', { id: updated.id, code: updated.code });
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const currenciesService = new CurrenciesService();
|
||||||
35
src/modules/core/entities/country.entity.ts
Normal file
35
src/modules/core/entities/country.entity.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'countries' })
|
||||||
|
@Index('idx_countries_code', ['code'], { unique: true })
|
||||||
|
export class Country {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 2, nullable: false, unique: true })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, nullable: true, name: 'phone_code' })
|
||||||
|
phoneCode: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'varchar',
|
||||||
|
length: 3,
|
||||||
|
nullable: true,
|
||||||
|
name: 'currency_code',
|
||||||
|
})
|
||||||
|
currencyCode: string | null;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
43
src/modules/core/entities/currency.entity.ts
Normal file
43
src/modules/core/entities/currency.entity.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'currencies' })
|
||||||
|
@Index('idx_currencies_code', ['code'], { unique: true })
|
||||||
|
@Index('idx_currencies_active', ['active'])
|
||||||
|
export class Currency {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 3, nullable: false, unique: true })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, nullable: false })
|
||||||
|
symbol: string;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 2, name: 'decimals' })
|
||||||
|
decimals: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 6,
|
||||||
|
nullable: true,
|
||||||
|
default: 0.01,
|
||||||
|
})
|
||||||
|
rounding: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
6
src/modules/core/entities/index.ts
Normal file
6
src/modules/core/entities/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { Currency } from './currency.entity.js';
|
||||||
|
export { Country } from './country.entity.js';
|
||||||
|
export { UomCategory } from './uom-category.entity.js';
|
||||||
|
export { Uom, UomType } from './uom.entity.js';
|
||||||
|
export { ProductCategory } from './product-category.entity.js';
|
||||||
|
export { Sequence, ResetPeriod } from './sequence.entity.js';
|
||||||
79
src/modules/core/entities/product-category.entity.ts
Normal file
79
src/modules/core/entities/product-category.entity.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'product_categories' })
|
||||||
|
@Index('idx_product_categories_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_product_categories_parent_id', ['parentId'])
|
||||||
|
@Index('idx_product_categories_code_tenant', ['tenantId', 'code'], {
|
||||||
|
unique: true,
|
||||||
|
})
|
||||||
|
@Index('idx_product_categories_active', ['tenantId', 'active'], {
|
||||||
|
where: 'deleted_at IS NULL',
|
||||||
|
})
|
||||||
|
export class ProductCategory {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
code: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'parent_id' })
|
||||||
|
parentId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true, name: 'full_path' })
|
||||||
|
fullPath: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => ProductCategory, (category) => category.children, {
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'parent_id' })
|
||||||
|
parent: ProductCategory | null;
|
||||||
|
|
||||||
|
@OneToMany(() => ProductCategory, (category) => category.parent)
|
||||||
|
children: ProductCategory[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
83
src/modules/core/entities/sequence.entity.ts
Normal file
83
src/modules/core/entities/sequence.entity.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export enum ResetPeriod {
|
||||||
|
NONE = 'none',
|
||||||
|
YEAR = 'year',
|
||||||
|
MONTH = 'month',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'sequences' })
|
||||||
|
@Index('idx_sequences_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_sequences_code_tenant', ['tenantId', 'code'], { unique: true })
|
||||||
|
@Index('idx_sequences_active', ['tenantId', 'isActive'])
|
||||||
|
export class Sequence {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'company_id' })
|
||||||
|
companyId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
prefix: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||||
|
suffix: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 1, name: 'next_number' })
|
||||||
|
nextNumber: number;
|
||||||
|
|
||||||
|
@Column({ type: 'integer', nullable: false, default: 4 })
|
||||||
|
padding: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: ResetPeriod,
|
||||||
|
nullable: true,
|
||||||
|
default: ResetPeriod.NONE,
|
||||||
|
name: 'reset_period',
|
||||||
|
})
|
||||||
|
resetPeriod: ResetPeriod | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
name: 'last_reset_date',
|
||||||
|
})
|
||||||
|
lastResetDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
30
src/modules/core/entities/uom-category.entity.ts
Normal file
30
src/modules/core/entities/uom-category.entity.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Uom } from './uom.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'uom_categories' })
|
||||||
|
@Index('idx_uom_categories_name', ['name'], { unique: true })
|
||||||
|
export class UomCategory {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false, unique: true })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@OneToMany(() => Uom, (uom) => uom.category)
|
||||||
|
uoms: Uom[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
76
src/modules/core/entities/uom.entity.ts
Normal file
76
src/modules/core/entities/uom.entity.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { UomCategory } from './uom-category.entity.js';
|
||||||
|
|
||||||
|
export enum UomType {
|
||||||
|
REFERENCE = 'reference',
|
||||||
|
BIGGER = 'bigger',
|
||||||
|
SMALLER = 'smaller',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'core', name: 'uom' })
|
||||||
|
@Index('idx_uom_category_id', ['categoryId'])
|
||||||
|
@Index('idx_uom_code', ['code'])
|
||||||
|
@Index('idx_uom_active', ['active'])
|
||||||
|
@Index('idx_uom_name_category', ['categoryId', 'name'], { unique: true })
|
||||||
|
export class Uom {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'category_id' })
|
||||||
|
categoryId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: true })
|
||||||
|
code: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: UomType,
|
||||||
|
nullable: false,
|
||||||
|
default: UomType.REFERENCE,
|
||||||
|
name: 'uom_type',
|
||||||
|
})
|
||||||
|
uomType: UomType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 6,
|
||||||
|
nullable: false,
|
||||||
|
default: 1.0,
|
||||||
|
})
|
||||||
|
factor: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'decimal',
|
||||||
|
precision: 12,
|
||||||
|
scale: 6,
|
||||||
|
nullable: true,
|
||||||
|
default: 0.01,
|
||||||
|
})
|
||||||
|
rounding: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', nullable: false, default: true })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => UomCategory, (category) => category.uoms, {
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'category_id' })
|
||||||
|
category: UomCategory;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
8
src/modules/core/index.ts
Normal file
8
src/modules/core/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export * from './currencies.service.js';
|
||||||
|
export * from './countries.service.js';
|
||||||
|
export * from './uom.service.js';
|
||||||
|
export * from './product-categories.service.js';
|
||||||
|
export * from './sequences.service.js';
|
||||||
|
export * from './entities/index.js';
|
||||||
|
export * from './core.controller.js';
|
||||||
|
export { default as coreRoutes } from './core.routes.js';
|
||||||
223
src/modules/core/product-categories.service.ts
Normal file
223
src/modules/core/product-categories.service.ts
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { ProductCategory } from './entities/product-category.entity.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
export interface CreateProductCategoryDto {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
parent_id?: string;
|
||||||
|
parentId?: string; // Accept camelCase too
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProductCategoryDto {
|
||||||
|
name?: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
parentId?: string | null; // Accept camelCase too
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProductCategoriesService {
|
||||||
|
private repository: Repository<ProductCategory>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = AppDataSource.getRepository(ProductCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(
|
||||||
|
tenantId: string,
|
||||||
|
parentId?: string,
|
||||||
|
activeOnly: boolean = false
|
||||||
|
): Promise<ProductCategory[]> {
|
||||||
|
logger.debug('Finding all product categories', {
|
||||||
|
tenantId,
|
||||||
|
parentId,
|
||||||
|
activeOnly,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryBuilder = this.repository
|
||||||
|
.createQueryBuilder('pc')
|
||||||
|
.leftJoinAndSelect('pc.parent', 'parent')
|
||||||
|
.where('pc.tenantId = :tenantId', { tenantId })
|
||||||
|
.andWhere('pc.deletedAt IS NULL');
|
||||||
|
|
||||||
|
if (parentId !== undefined) {
|
||||||
|
if (parentId === null || parentId === 'null') {
|
||||||
|
queryBuilder.andWhere('pc.parentId IS NULL');
|
||||||
|
} else {
|
||||||
|
queryBuilder.andWhere('pc.parentId = :parentId', { parentId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeOnly) {
|
||||||
|
queryBuilder.andWhere('pc.active = :active', { active: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
queryBuilder.orderBy('pc.name', 'ASC');
|
||||||
|
|
||||||
|
return queryBuilder.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<ProductCategory> {
|
||||||
|
logger.debug('Finding product category by id', { id, tenantId });
|
||||||
|
|
||||||
|
const category = await this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
relations: ['parent'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundError('Categoría de producto no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
dto: CreateProductCategoryDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<ProductCategory> {
|
||||||
|
logger.debug('Creating product category', { dto, tenantId, userId });
|
||||||
|
|
||||||
|
// Accept both snake_case and camelCase
|
||||||
|
const parentId = dto.parent_id ?? dto.parentId;
|
||||||
|
|
||||||
|
// Check unique code within tenant
|
||||||
|
const existing = await this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
code: dto.code,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe una categoría con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate parent if specified
|
||||||
|
if (parentId) {
|
||||||
|
const parent = await this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
id: parentId,
|
||||||
|
tenantId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Categoría padre no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = this.repository.create({
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
code: dto.code,
|
||||||
|
parentId: parentId || null,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.repository.save(category);
|
||||||
|
logger.info('Product category created', {
|
||||||
|
id: saved.id,
|
||||||
|
code: saved.code,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateProductCategoryDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<ProductCategory> {
|
||||||
|
logger.debug('Updating product category', { id, dto, tenantId, userId });
|
||||||
|
|
||||||
|
const category = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Accept both snake_case and camelCase
|
||||||
|
const parentId = dto.parent_id ?? dto.parentId;
|
||||||
|
|
||||||
|
// Validate parent (prevent self-reference)
|
||||||
|
if (parentId !== undefined) {
|
||||||
|
if (parentId === id) {
|
||||||
|
throw new ConflictError('Una categoría no puede ser su propio padre');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId !== null) {
|
||||||
|
const parent = await this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
id: parentId,
|
||||||
|
tenantId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Categoría padre no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
category.parentId = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
category.name = dto.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
category.active = dto.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
category.updatedBy = userId;
|
||||||
|
|
||||||
|
const updated = await this.repository.save(category);
|
||||||
|
logger.info('Product category updated', {
|
||||||
|
id: updated.id,
|
||||||
|
code: updated.code,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
logger.debug('Deleting product category', { id, tenantId });
|
||||||
|
|
||||||
|
const category = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if has children
|
||||||
|
const childrenCount = await this.repository.count({
|
||||||
|
where: {
|
||||||
|
parentId: id,
|
||||||
|
tenantId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (childrenCount > 0) {
|
||||||
|
throw new ConflictError(
|
||||||
|
'No se puede eliminar una categoría que tiene subcategorías'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: We should check for products in inventory schema
|
||||||
|
// For now, we'll just perform a hard delete as in original
|
||||||
|
// In a real scenario, you'd want to check inventory.products table
|
||||||
|
|
||||||
|
await this.repository.delete({ id, tenantId });
|
||||||
|
|
||||||
|
logger.info('Product category deleted', { id, tenantId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const productCategoriesService = new ProductCategoriesService();
|
||||||
466
src/modules/core/sequences.service.ts
Normal file
466
src/modules/core/sequences.service.ts
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { Sequence, ResetPeriod } from './entities/sequence.entity.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface CreateSequenceDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
start_number?: number;
|
||||||
|
startNumber?: number; // Accept camelCase too
|
||||||
|
padding?: number;
|
||||||
|
reset_period?: 'none' | 'year' | 'month';
|
||||||
|
resetPeriod?: 'none' | 'year' | 'month'; // Accept camelCase too
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSequenceDto {
|
||||||
|
name?: string;
|
||||||
|
prefix?: string | null;
|
||||||
|
suffix?: string | null;
|
||||||
|
padding?: number;
|
||||||
|
reset_period?: 'none' | 'year' | 'month';
|
||||||
|
resetPeriod?: 'none' | 'year' | 'month'; // Accept camelCase too
|
||||||
|
is_active?: boolean;
|
||||||
|
isActive?: boolean; // Accept camelCase too
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PREDEFINED SEQUENCE CODES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const SEQUENCE_CODES = {
|
||||||
|
// Sales
|
||||||
|
SALES_ORDER: 'SO',
|
||||||
|
QUOTATION: 'QT',
|
||||||
|
|
||||||
|
// Purchases
|
||||||
|
PURCHASE_ORDER: 'PO',
|
||||||
|
RFQ: 'RFQ',
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
PICKING_IN: 'WH/IN',
|
||||||
|
PICKING_OUT: 'WH/OUT',
|
||||||
|
PICKING_INT: 'WH/INT',
|
||||||
|
INVENTORY_ADJ: 'INV/ADJ',
|
||||||
|
|
||||||
|
// Financial
|
||||||
|
INVOICE_CUSTOMER: 'INV',
|
||||||
|
INVOICE_SUPPLIER: 'BILL',
|
||||||
|
PAYMENT: 'PAY',
|
||||||
|
JOURNAL_ENTRY: 'JE',
|
||||||
|
|
||||||
|
// CRM
|
||||||
|
LEAD: 'LEAD',
|
||||||
|
OPPORTUNITY: 'OPP',
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
PROJECT: 'PRJ',
|
||||||
|
TASK: 'TASK',
|
||||||
|
|
||||||
|
// HR
|
||||||
|
EMPLOYEE: 'EMP',
|
||||||
|
CONTRACT: 'CTR',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class SequencesService {
|
||||||
|
private repository: Repository<Sequence>;
|
||||||
|
private dataSource: DataSource;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = AppDataSource.getRepository(Sequence);
|
||||||
|
this.dataSource = AppDataSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next number in a sequence using the database function
|
||||||
|
* This is atomic and handles concurrent requests safely
|
||||||
|
*/
|
||||||
|
async getNextNumber(
|
||||||
|
sequenceCode: string,
|
||||||
|
tenantId: string,
|
||||||
|
queryRunner?: any
|
||||||
|
): Promise<string> {
|
||||||
|
logger.debug('Generating next sequence number', { sequenceCode, tenantId });
|
||||||
|
|
||||||
|
const executeQuery = queryRunner
|
||||||
|
? (sql: string, params: any[]) => queryRunner.query(sql, params)
|
||||||
|
: (sql: string, params: any[]) => this.dataSource.query(sql, params);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the database function for atomic sequence generation
|
||||||
|
const result = await executeQuery(
|
||||||
|
`SELECT core.generate_next_sequence($1, $2) as sequence_number`,
|
||||||
|
[sequenceCode, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result?.[0]?.sequence_number) {
|
||||||
|
// Sequence doesn't exist, try to create it with default settings
|
||||||
|
logger.warn('Sequence not found, creating default', {
|
||||||
|
sequenceCode,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.ensureSequenceExists(sequenceCode, tenantId, queryRunner);
|
||||||
|
|
||||||
|
// Try again
|
||||||
|
const retryResult = await executeQuery(
|
||||||
|
`SELECT core.generate_next_sequence($1, $2) as sequence_number`,
|
||||||
|
[sequenceCode, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!retryResult?.[0]?.sequence_number) {
|
||||||
|
throw new NotFoundError(`Secuencia ${sequenceCode} no encontrada`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Generated sequence number after creating default', {
|
||||||
|
sequenceCode,
|
||||||
|
number: retryResult[0].sequence_number,
|
||||||
|
});
|
||||||
|
|
||||||
|
return retryResult[0].sequence_number;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug('Generated sequence number', {
|
||||||
|
sequenceCode,
|
||||||
|
number: result[0].sequence_number,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result[0].sequence_number;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error generating sequence number', {
|
||||||
|
sequenceCode,
|
||||||
|
tenantId,
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a sequence exists, creating it with defaults if not
|
||||||
|
*/
|
||||||
|
async ensureSequenceExists(
|
||||||
|
sequenceCode: string,
|
||||||
|
tenantId: string,
|
||||||
|
queryRunner?: any
|
||||||
|
): Promise<void> {
|
||||||
|
logger.debug('Ensuring sequence exists', { sequenceCode, tenantId });
|
||||||
|
|
||||||
|
// Check if exists
|
||||||
|
const existing = await this.repository.findOne({
|
||||||
|
where: { code: sequenceCode, tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
logger.debug('Sequence already exists', { sequenceCode, tenantId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create with defaults based on code
|
||||||
|
const defaults = this.getDefaultsForCode(sequenceCode);
|
||||||
|
|
||||||
|
const sequence = this.repository.create({
|
||||||
|
tenantId,
|
||||||
|
code: sequenceCode,
|
||||||
|
name: defaults.name,
|
||||||
|
prefix: defaults.prefix,
|
||||||
|
padding: defaults.padding,
|
||||||
|
nextNumber: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.repository.save(sequence);
|
||||||
|
|
||||||
|
logger.info('Created default sequence', { sequenceCode, tenantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default settings for a sequence code
|
||||||
|
*/
|
||||||
|
private getDefaultsForCode(code: string): {
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
padding: number;
|
||||||
|
} {
|
||||||
|
const defaults: Record<
|
||||||
|
string,
|
||||||
|
{ name: string; prefix: string; padding: number }
|
||||||
|
> = {
|
||||||
|
[SEQUENCE_CODES.SALES_ORDER]: {
|
||||||
|
name: 'Órdenes de Venta',
|
||||||
|
prefix: 'SO-',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.QUOTATION]: {
|
||||||
|
name: 'Cotizaciones',
|
||||||
|
prefix: 'QT-',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.PURCHASE_ORDER]: {
|
||||||
|
name: 'Órdenes de Compra',
|
||||||
|
prefix: 'PO-',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.RFQ]: {
|
||||||
|
name: 'Solicitudes de Cotización',
|
||||||
|
prefix: 'RFQ-',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.PICKING_IN]: {
|
||||||
|
name: 'Recepciones',
|
||||||
|
prefix: 'WH/IN/',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.PICKING_OUT]: {
|
||||||
|
name: 'Entregas',
|
||||||
|
prefix: 'WH/OUT/',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.PICKING_INT]: {
|
||||||
|
name: 'Transferencias',
|
||||||
|
prefix: 'WH/INT/',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.INVENTORY_ADJ]: {
|
||||||
|
name: 'Ajustes de Inventario',
|
||||||
|
prefix: 'ADJ/',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.INVOICE_CUSTOMER]: {
|
||||||
|
name: 'Facturas de Cliente',
|
||||||
|
prefix: 'INV/',
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.INVOICE_SUPPLIER]: {
|
||||||
|
name: 'Facturas de Proveedor',
|
||||||
|
prefix: 'BILL/',
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.PAYMENT]: { name: 'Pagos', prefix: 'PAY/', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.JOURNAL_ENTRY]: {
|
||||||
|
name: 'Asientos Contables',
|
||||||
|
prefix: 'JE/',
|
||||||
|
padding: 6,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.LEAD]: { name: 'Prospectos', prefix: 'LEAD-', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.OPPORTUNITY]: {
|
||||||
|
name: 'Oportunidades',
|
||||||
|
prefix: 'OPP-',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.PROJECT]: {
|
||||||
|
name: 'Proyectos',
|
||||||
|
prefix: 'PRJ-',
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.TASK]: { name: 'Tareas', prefix: 'TASK-', padding: 5 },
|
||||||
|
[SEQUENCE_CODES.EMPLOYEE]: {
|
||||||
|
name: 'Empleados',
|
||||||
|
prefix: 'EMP-',
|
||||||
|
padding: 4,
|
||||||
|
},
|
||||||
|
[SEQUENCE_CODES.CONTRACT]: {
|
||||||
|
name: 'Contratos',
|
||||||
|
prefix: 'CTR-',
|
||||||
|
padding: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return defaults[code] || { name: code, prefix: `${code}-`, padding: 5 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all sequences for a tenant
|
||||||
|
*/
|
||||||
|
async findAll(tenantId: string): Promise<Sequence[]> {
|
||||||
|
logger.debug('Finding all sequences', { tenantId });
|
||||||
|
|
||||||
|
return this.repository.find({
|
||||||
|
where: { tenantId },
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific sequence by code
|
||||||
|
*/
|
||||||
|
async findByCode(code: string, tenantId: string): Promise<Sequence | null> {
|
||||||
|
logger.debug('Finding sequence by code', { code, tenantId });
|
||||||
|
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { code, tenantId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new sequence
|
||||||
|
*/
|
||||||
|
async create(dto: CreateSequenceDto, tenantId: string): Promise<Sequence> {
|
||||||
|
logger.debug('Creating sequence', { dto, tenantId });
|
||||||
|
|
||||||
|
// Check for existing
|
||||||
|
const existing = await this.findByCode(dto.code, tenantId);
|
||||||
|
if (existing) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Ya existe una secuencia con código ${dto.code}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept both snake_case and camelCase
|
||||||
|
const startNumber = dto.start_number ?? dto.startNumber ?? 1;
|
||||||
|
const resetPeriod = dto.reset_period ?? dto.resetPeriod ?? 'none';
|
||||||
|
|
||||||
|
const sequence = this.repository.create({
|
||||||
|
tenantId,
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name,
|
||||||
|
prefix: dto.prefix || null,
|
||||||
|
suffix: dto.suffix || null,
|
||||||
|
nextNumber: startNumber,
|
||||||
|
padding: dto.padding || 5,
|
||||||
|
resetPeriod: resetPeriod as ResetPeriod,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.repository.save(sequence);
|
||||||
|
|
||||||
|
logger.info('Sequence created', { code: dto.code, tenantId });
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a sequence
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
code: string,
|
||||||
|
dto: UpdateSequenceDto,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<Sequence> {
|
||||||
|
logger.debug('Updating sequence', { code, dto, tenantId });
|
||||||
|
|
||||||
|
const existing = await this.findByCode(code, tenantId);
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundError('Secuencia no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept both snake_case and camelCase
|
||||||
|
const resetPeriod = dto.reset_period ?? dto.resetPeriod;
|
||||||
|
const isActive = dto.is_active ?? dto.isActive;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
existing.name = dto.name;
|
||||||
|
}
|
||||||
|
if (dto.prefix !== undefined) {
|
||||||
|
existing.prefix = dto.prefix;
|
||||||
|
}
|
||||||
|
if (dto.suffix !== undefined) {
|
||||||
|
existing.suffix = dto.suffix;
|
||||||
|
}
|
||||||
|
if (dto.padding !== undefined) {
|
||||||
|
existing.padding = dto.padding;
|
||||||
|
}
|
||||||
|
if (resetPeriod !== undefined) {
|
||||||
|
existing.resetPeriod = resetPeriod as ResetPeriod;
|
||||||
|
}
|
||||||
|
if (isActive !== undefined) {
|
||||||
|
existing.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.repository.save(existing);
|
||||||
|
|
||||||
|
logger.info('Sequence updated', { code, tenantId });
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset a sequence to a specific number
|
||||||
|
*/
|
||||||
|
async reset(
|
||||||
|
code: string,
|
||||||
|
tenantId: string,
|
||||||
|
newNumber: number = 1
|
||||||
|
): Promise<Sequence> {
|
||||||
|
logger.debug('Resetting sequence', { code, tenantId, newNumber });
|
||||||
|
|
||||||
|
const sequence = await this.findByCode(code, tenantId);
|
||||||
|
if (!sequence) {
|
||||||
|
throw new NotFoundError('Secuencia no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
sequence.nextNumber = newNumber;
|
||||||
|
sequence.lastResetDate = new Date();
|
||||||
|
|
||||||
|
const updated = await this.repository.save(sequence);
|
||||||
|
|
||||||
|
logger.info('Sequence reset', { code, tenantId, newNumber });
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview what the next number would be (without incrementing)
|
||||||
|
*/
|
||||||
|
async preview(code: string, tenantId: string): Promise<string> {
|
||||||
|
logger.debug('Previewing next sequence number', { code, tenantId });
|
||||||
|
|
||||||
|
const sequence = await this.findByCode(code, tenantId);
|
||||||
|
if (!sequence) {
|
||||||
|
throw new NotFoundError('Secuencia no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddedNumber = String(sequence.nextNumber).padStart(
|
||||||
|
sequence.padding,
|
||||||
|
'0'
|
||||||
|
);
|
||||||
|
const prefix = sequence.prefix || '';
|
||||||
|
const suffix = sequence.suffix || '';
|
||||||
|
|
||||||
|
return `${prefix}${paddedNumber}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all standard sequences for a new tenant
|
||||||
|
*/
|
||||||
|
async initializeForTenant(tenantId: string): Promise<void> {
|
||||||
|
logger.debug('Initializing sequences for tenant', { tenantId });
|
||||||
|
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const [key, code] of Object.entries(SEQUENCE_CODES)) {
|
||||||
|
await this.ensureSequenceExists(code, tenantId, queryRunner);
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
|
logger.info('Initialized sequences for tenant', {
|
||||||
|
tenantId,
|
||||||
|
count: Object.keys(SEQUENCE_CODES).length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
logger.error('Error initializing sequences for tenant', {
|
||||||
|
tenantId,
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sequencesService = new SequencesService();
|
||||||
162
src/modules/core/uom.service.ts
Normal file
162
src/modules/core/uom.service.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { Uom, UomType } from './entities/uom.entity.js';
|
||||||
|
import { UomCategory } from './entities/uom-category.entity.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
export interface CreateUomDto {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
category_id?: string;
|
||||||
|
categoryId?: string; // Accept camelCase too
|
||||||
|
uom_type?: 'reference' | 'bigger' | 'smaller';
|
||||||
|
uomType?: 'reference' | 'bigger' | 'smaller'; // Accept camelCase too
|
||||||
|
ratio?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUomDto {
|
||||||
|
name?: string;
|
||||||
|
ratio?: number;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UomService {
|
||||||
|
private repository: Repository<Uom>;
|
||||||
|
private categoryRepository: Repository<UomCategory>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = AppDataSource.getRepository(Uom);
|
||||||
|
this.categoryRepository = AppDataSource.getRepository(UomCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories
|
||||||
|
async findAllCategories(activeOnly: boolean = false): Promise<UomCategory[]> {
|
||||||
|
logger.debug('Finding all UOM categories', { activeOnly });
|
||||||
|
|
||||||
|
const queryBuilder = this.categoryRepository
|
||||||
|
.createQueryBuilder('category')
|
||||||
|
.orderBy('category.name', 'ASC');
|
||||||
|
|
||||||
|
// Note: activeOnly is not supported since the table doesn't have an active field
|
||||||
|
// Keeping the parameter for backward compatibility
|
||||||
|
|
||||||
|
return queryBuilder.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCategoryById(id: string): Promise<UomCategory> {
|
||||||
|
logger.debug('Finding UOM category by id', { id });
|
||||||
|
|
||||||
|
const category = await this.categoryRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
throw new NotFoundError('Categoría de UdM no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UoM
|
||||||
|
async findAll(categoryId?: string, activeOnly: boolean = false): Promise<Uom[]> {
|
||||||
|
logger.debug('Finding all UOMs', { categoryId, activeOnly });
|
||||||
|
|
||||||
|
const queryBuilder = this.repository
|
||||||
|
.createQueryBuilder('u')
|
||||||
|
.leftJoinAndSelect('u.category', 'uc')
|
||||||
|
.orderBy('uc.name', 'ASC')
|
||||||
|
.addOrderBy('u.uomType', 'ASC')
|
||||||
|
.addOrderBy('u.name', 'ASC');
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
queryBuilder.where('u.categoryId = :categoryId', { categoryId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeOnly) {
|
||||||
|
queryBuilder.andWhere('u.active = :active', { active: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryBuilder.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<Uom> {
|
||||||
|
logger.debug('Finding UOM by id', { id });
|
||||||
|
|
||||||
|
const uom = await this.repository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['category'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uom) {
|
||||||
|
throw new NotFoundError('Unidad de medida no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return uom;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateUomDto): Promise<Uom> {
|
||||||
|
logger.debug('Creating UOM', { dto });
|
||||||
|
|
||||||
|
// Accept both snake_case and camelCase
|
||||||
|
const categoryId = dto.category_id ?? dto.categoryId;
|
||||||
|
const uomType = dto.uom_type ?? dto.uomType ?? 'reference';
|
||||||
|
const factor = dto.ratio ?? 1;
|
||||||
|
|
||||||
|
if (!categoryId) {
|
||||||
|
throw new NotFoundError('category_id es requerido');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate category exists
|
||||||
|
await this.findCategoryById(categoryId);
|
||||||
|
|
||||||
|
// Check unique code
|
||||||
|
if (dto.code) {
|
||||||
|
const existing = await this.repository.findOne({
|
||||||
|
where: { code: dto.code },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe una UdM con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uom = this.repository.create({
|
||||||
|
name: dto.name,
|
||||||
|
code: dto.code,
|
||||||
|
categoryId,
|
||||||
|
uomType: uomType as UomType,
|
||||||
|
factor,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.repository.save(uom);
|
||||||
|
logger.info('UOM created', { id: saved.id, code: saved.code });
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateUomDto): Promise<Uom> {
|
||||||
|
logger.debug('Updating UOM', { id, dto });
|
||||||
|
|
||||||
|
const uom = await this.findById(id);
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
uom.name = dto.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.ratio !== undefined) {
|
||||||
|
uom.factor = dto.ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
uom.active = dto.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.repository.save(uom);
|
||||||
|
logger.info('UOM updated', { id: updated.id, code: updated.code });
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uomService = new UomService();
|
||||||
682
src/modules/crm/crm.controller.ts
Normal file
682
src/modules/crm/crm.controller.ts
Normal file
@ -0,0 +1,682 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { leadsService, CreateLeadDto, UpdateLeadDto, LeadFilters } from './leads.service.js';
|
||||||
|
import { opportunitiesService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from './opportunities.service.js';
|
||||||
|
import { stagesService, CreateLeadStageDto, UpdateLeadStageDto, CreateOpportunityStageDto, UpdateOpportunityStageDto, CreateLostReasonDto, UpdateLostReasonDto } from './stages.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Lead schemas
|
||||||
|
const createLeadSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
ref: z.string().max(100).optional(),
|
||||||
|
contact_name: z.string().max(255).optional(),
|
||||||
|
email: z.string().email().max(255).optional(),
|
||||||
|
phone: z.string().max(50).optional(),
|
||||||
|
mobile: z.string().max(50).optional(),
|
||||||
|
website: z.string().url().max(255).optional(),
|
||||||
|
company_prospect_name: z.string().max(255).optional(),
|
||||||
|
job_position: z.string().max(100).optional(),
|
||||||
|
industry: z.string().max(100).optional(),
|
||||||
|
employee_count: z.string().max(50).optional(),
|
||||||
|
annual_revenue: z.number().min(0).optional(),
|
||||||
|
street: z.string().max(255).optional(),
|
||||||
|
city: z.string().max(100).optional(),
|
||||||
|
state: z.string().max(100).optional(),
|
||||||
|
zip: z.string().max(20).optional(),
|
||||||
|
country: z.string().max(100).optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
sales_team_id: z.string().uuid().optional(),
|
||||||
|
source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
expected_revenue: z.number().min(0).optional(),
|
||||||
|
date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLeadSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
ref: z.string().max(100).optional().nullable(),
|
||||||
|
contact_name: z.string().max(255).optional().nullable(),
|
||||||
|
email: z.string().email().max(255).optional().nullable(),
|
||||||
|
phone: z.string().max(50).optional().nullable(),
|
||||||
|
mobile: z.string().max(50).optional().nullable(),
|
||||||
|
website: z.string().url().max(255).optional().nullable(),
|
||||||
|
company_prospect_name: z.string().max(255).optional().nullable(),
|
||||||
|
job_position: z.string().max(100).optional().nullable(),
|
||||||
|
industry: z.string().max(100).optional().nullable(),
|
||||||
|
employee_count: z.string().max(50).optional().nullable(),
|
||||||
|
annual_revenue: z.number().min(0).optional().nullable(),
|
||||||
|
street: z.string().max(255).optional().nullable(),
|
||||||
|
city: z.string().max(100).optional().nullable(),
|
||||||
|
state: z.string().max(100).optional().nullable(),
|
||||||
|
zip: z.string().max(20).optional().nullable(),
|
||||||
|
country: z.string().max(100).optional().nullable(),
|
||||||
|
stage_id: z.string().uuid().optional().nullable(),
|
||||||
|
user_id: z.string().uuid().optional().nullable(),
|
||||||
|
sales_team_id: z.string().uuid().optional().nullable(),
|
||||||
|
source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional().nullable(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
expected_revenue: z.number().min(0).optional().nullable(),
|
||||||
|
date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
tags: z.array(z.string()).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const leadQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['new', 'contacted', 'qualified', 'converted', 'lost']).optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional(),
|
||||||
|
priority: z.coerce.number().int().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const lostSchema = z.object({
|
||||||
|
lost_reason_id: z.string().uuid(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveStageSchema = z.object({
|
||||||
|
stage_id: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Opportunity schemas
|
||||||
|
const createOpportunitySchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
ref: z.string().max(100).optional(),
|
||||||
|
partner_id: z.string().uuid(),
|
||||||
|
contact_name: z.string().max(255).optional(),
|
||||||
|
email: z.string().email().max(255).optional(),
|
||||||
|
phone: z.string().max(50).optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
sales_team_id: z.string().uuid().optional(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
expected_revenue: z.number().min(0).optional(),
|
||||||
|
recurring_revenue: z.number().min(0).optional(),
|
||||||
|
recurring_plan: z.string().max(50).optional(),
|
||||||
|
date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateOpportunitySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
ref: z.string().max(100).optional().nullable(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
contact_name: z.string().max(255).optional().nullable(),
|
||||||
|
email: z.string().email().max(255).optional().nullable(),
|
||||||
|
phone: z.string().max(50).optional().nullable(),
|
||||||
|
stage_id: z.string().uuid().optional().nullable(),
|
||||||
|
user_id: z.string().uuid().optional().nullable(),
|
||||||
|
sales_team_id: z.string().uuid().optional().nullable(),
|
||||||
|
priority: z.number().int().min(0).max(3).optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
expected_revenue: z.number().min(0).optional().nullable(),
|
||||||
|
recurring_revenue: z.number().min(0).optional().nullable(),
|
||||||
|
recurring_plan: z.string().max(50).optional().nullable(),
|
||||||
|
date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
tags: z.array(z.string()).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const opportunityQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['open', 'won', 'lost']).optional(),
|
||||||
|
stage_id: z.string().uuid().optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
priority: z.coerce.number().int().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stage schemas
|
||||||
|
const createStageSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
sequence: z.number().int().optional(),
|
||||||
|
is_won: z.boolean().optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
requirements: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStageSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
sequence: z.number().int().optional(),
|
||||||
|
is_won: z.boolean().optional(),
|
||||||
|
probability: z.number().min(0).max(100).optional(),
|
||||||
|
requirements: z.string().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lost reason schemas
|
||||||
|
const createLostReasonSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLostReasonSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
class CrmController {
|
||||||
|
// ========== LEADS ==========
|
||||||
|
|
||||||
|
async getLeads(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = leadQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: LeadFilters = queryResult.data;
|
||||||
|
const result = await leadsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const lead = await leadsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: lead });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLeadSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lead invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLeadDto = parseResult.data;
|
||||||
|
const lead = await leadsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: lead,
|
||||||
|
message: 'Lead creado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLeadSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de lead invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLeadDto = parseResult.data;
|
||||||
|
const lead = await leadsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lead,
|
||||||
|
message: 'Lead actualizado exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = moveStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lead = await leadsService.moveStage(req.params.id, parseResult.data.stage_id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lead,
|
||||||
|
message: 'Lead movido a nueva etapa',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async convertLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await leadsService.convert(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.lead,
|
||||||
|
opportunity_id: result.opportunity_id,
|
||||||
|
message: 'Lead convertido a oportunidad exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLeadLost(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = lostSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lead = await leadsService.markLost(
|
||||||
|
req.params.id,
|
||||||
|
parseResult.data.lost_reason_id,
|
||||||
|
parseResult.data.notes,
|
||||||
|
req.tenantId!,
|
||||||
|
req.user!.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: lead,
|
||||||
|
message: 'Lead marcado como perdido',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await leadsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Lead eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OPPORTUNITIES ==========
|
||||||
|
|
||||||
|
async getOpportunities(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = opportunityQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: OpportunityFilters = queryResult.data;
|
||||||
|
const result = await opportunitiesService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: {
|
||||||
|
total: result.total,
|
||||||
|
page: filters.page,
|
||||||
|
limit: filters.limit,
|
||||||
|
totalPages: Math.ceil(result.total / (filters.limit || 20)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const opportunity = await opportunitiesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: opportunity });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createOpportunitySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de oportunidad invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateOpportunityDto = parseResult.data;
|
||||||
|
const opportunity = await opportunitiesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateOpportunitySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de oportunidad invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateOpportunityDto = parseResult.data;
|
||||||
|
const opportunity = await opportunitiesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = moveStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opportunity = await opportunitiesService.moveStage(req.params.id, parseResult.data.stage_id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad movida a nueva etapa',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markOpportunityWon(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const opportunity = await opportunitiesService.markWon(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad marcada como ganada',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markOpportunityLost(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = lostSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opportunity = await opportunitiesService.markLost(
|
||||||
|
req.params.id,
|
||||||
|
parseResult.data.lost_reason_id,
|
||||||
|
parseResult.data.notes,
|
||||||
|
req.tenantId!,
|
||||||
|
req.user!.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: opportunity,
|
||||||
|
message: 'Oportunidad marcada como perdida',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpportunityQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await opportunitiesService.createQuotation(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: result.opportunity,
|
||||||
|
quotation_id: result.quotation_id,
|
||||||
|
message: 'Cotizacion creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await opportunitiesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Oportunidad eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPipeline(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const companyId = req.query.company_id as string | undefined;
|
||||||
|
const pipeline = await opportunitiesService.getPipeline(req.tenantId!, companyId);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: pipeline,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
|
async getLeadStages(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const stages = await stagesService.getLeadStages(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: stages });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLeadStageDto = parseResult.data;
|
||||||
|
const stage = await stagesService.createLeadStage(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: stage,
|
||||||
|
message: 'Etapa de lead creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLeadStageDto = parseResult.data;
|
||||||
|
const stage = await stagesService.updateLeadStage(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stage,
|
||||||
|
message: 'Etapa de lead actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await stagesService.deleteLeadStage(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Etapa de lead eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OPPORTUNITY STAGES ==========
|
||||||
|
|
||||||
|
async getOpportunityStages(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const stages = await stagesService.getOpportunityStages(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: stages });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateOpportunityStageDto = parseResult.data;
|
||||||
|
const stage = await stagesService.createOpportunityStage(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: stage,
|
||||||
|
message: 'Etapa de oportunidad creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateStageSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateOpportunityStageDto = parseResult.data;
|
||||||
|
const stage = await stagesService.updateOpportunityStage(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stage,
|
||||||
|
message: 'Etapa de oportunidad actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await stagesService.deleteOpportunityStage(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Etapa de oportunidad eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOST REASONS ==========
|
||||||
|
|
||||||
|
async getLostReasons(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const reasons = await stagesService.getLostReasons(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: reasons });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLostReason(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLostReasonSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de razon invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLostReasonDto = parseResult.data;
|
||||||
|
const reason = await stagesService.createLostReason(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: reason,
|
||||||
|
message: 'Razon de perdida creada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLostReason(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLostReasonSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de razon invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLostReasonDto = parseResult.data;
|
||||||
|
const reason = await stagesService.updateLostReason(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: reason,
|
||||||
|
message: 'Razon de perdida actualizada exitosamente',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLostReason(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await stagesService.deleteLostReason(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Razon de perdida eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const crmController = new CrmController();
|
||||||
126
src/modules/crm/crm.routes.ts
Normal file
126
src/modules/crm/crm.routes.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { crmController } from './crm.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== LEADS ==========
|
||||||
|
|
||||||
|
router.get('/leads', (req, res, next) => crmController.getLeads(req, res, next));
|
||||||
|
|
||||||
|
router.get('/leads/:id', (req, res, next) => crmController.getLead(req, res, next));
|
||||||
|
|
||||||
|
router.post('/leads', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createLead(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/leads/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateLead(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leads/:id/move', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.moveLeadStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leads/:id/convert', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.convertLead(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leads/:id/lost', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.markLeadLost(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/leads/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteLead(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== OPPORTUNITIES ==========
|
||||||
|
|
||||||
|
router.get('/opportunities', (req, res, next) => crmController.getOpportunities(req, res, next));
|
||||||
|
|
||||||
|
router.get('/opportunities/:id', (req, res, next) => crmController.getOpportunity(req, res, next));
|
||||||
|
|
||||||
|
router.post('/opportunities', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createOpportunity(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/opportunities/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateOpportunity(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/opportunities/:id/move', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.moveOpportunityStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/opportunities/:id/won', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.markOpportunityWon(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/opportunities/:id/lost', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.markOpportunityLost(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/opportunities/:id/quote', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createOpportunityQuotation(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/opportunities/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteOpportunity(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== PIPELINE ==========
|
||||||
|
|
||||||
|
router.get('/pipeline', (req, res, next) => crmController.getPipeline(req, res, next));
|
||||||
|
|
||||||
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
|
router.get('/lead-stages', (req, res, next) => crmController.getLeadStages(req, res, next));
|
||||||
|
|
||||||
|
router.post('/lead-stages', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createLeadStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/lead-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateLeadStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/lead-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteLeadStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== OPPORTUNITY STAGES ==========
|
||||||
|
|
||||||
|
router.get('/opportunity-stages', (req, res, next) => crmController.getOpportunityStages(req, res, next));
|
||||||
|
|
||||||
|
router.post('/opportunity-stages', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createOpportunityStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/opportunity-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateOpportunityStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/opportunity-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteOpportunityStage(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== LOST REASONS ==========
|
||||||
|
|
||||||
|
router.get('/lost-reasons', (req, res, next) => crmController.getLostReasons(req, res, next));
|
||||||
|
|
||||||
|
router.post('/lost-reasons', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.createLostReason(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/lost-reasons/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.updateLostReason(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/lost-reasons/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
crmController.deleteLostReason(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
5
src/modules/crm/index.ts
Normal file
5
src/modules/crm/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './leads.service.js';
|
||||||
|
export * from './opportunities.service.js';
|
||||||
|
export * from './stages.service.js';
|
||||||
|
export * from './crm.controller.js';
|
||||||
|
export { default as crmRoutes } from './crm.routes.js';
|
||||||
449
src/modules/crm/leads.service.ts
Normal file
449
src/modules/crm/leads.service.ts
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost';
|
||||||
|
export type LeadSource = 'website' | 'phone' | 'email' | 'referral' | 'social_media' | 'advertising' | 'event' | 'other';
|
||||||
|
|
||||||
|
export interface Lead {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
contact_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
company_prospect_name?: string;
|
||||||
|
job_position?: string;
|
||||||
|
industry?: string;
|
||||||
|
employee_count?: string;
|
||||||
|
annual_revenue?: number;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
stage_name?: string;
|
||||||
|
status: LeadStatus;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
priority: number;
|
||||||
|
probability: number;
|
||||||
|
expected_revenue?: number;
|
||||||
|
date_open?: Date;
|
||||||
|
date_closed?: Date;
|
||||||
|
date_deadline?: Date;
|
||||||
|
date_last_activity?: Date;
|
||||||
|
partner_id?: string;
|
||||||
|
opportunity_id?: string;
|
||||||
|
lost_reason_id?: string;
|
||||||
|
lost_reason_name?: string;
|
||||||
|
lost_notes?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeadDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
contact_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
website?: string;
|
||||||
|
company_prospect_name?: string;
|
||||||
|
job_position?: string;
|
||||||
|
industry?: string;
|
||||||
|
employee_count?: string;
|
||||||
|
annual_revenue?: number;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expected_revenue?: number;
|
||||||
|
date_deadline?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeadDto {
|
||||||
|
name?: string;
|
||||||
|
ref?: string | null;
|
||||||
|
contact_name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
website?: string | null;
|
||||||
|
company_prospect_name?: string | null;
|
||||||
|
job_position?: string | null;
|
||||||
|
industry?: string | null;
|
||||||
|
employee_count?: string | null;
|
||||||
|
annual_revenue?: number | null;
|
||||||
|
street?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
stage_id?: string | null;
|
||||||
|
user_id?: string | null;
|
||||||
|
sales_team_id?: string | null;
|
||||||
|
source?: LeadSource | null;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expected_revenue?: number | null;
|
||||||
|
date_deadline?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeadFilters {
|
||||||
|
company_id?: string;
|
||||||
|
status?: LeadStatus;
|
||||||
|
stage_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
priority?: number;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LeadsService {
|
||||||
|
async findAll(tenantId: string, filters: LeadFilters = {}): Promise<{ data: Lead[]; total: number }> {
|
||||||
|
const { company_id, status, stage_id, user_id, source, priority, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE l.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND l.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND l.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage_id) {
|
||||||
|
whereClause += ` AND l.stage_id = $${paramIndex++}`;
|
||||||
|
params.push(stage_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
whereClause += ` AND l.user_id = $${paramIndex++}`;
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source) {
|
||||||
|
whereClause += ` AND l.source = $${paramIndex++}`;
|
||||||
|
params.push(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priority !== undefined) {
|
||||||
|
whereClause += ` AND l.priority = $${paramIndex++}`;
|
||||||
|
params.push(priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (l.name ILIKE $${paramIndex} OR l.contact_name ILIKE $${paramIndex} OR l.email ILIKE $${paramIndex} OR l.company_name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.leads l ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Lead>(
|
||||||
|
`SELECT l.*,
|
||||||
|
c.name as company_org_name,
|
||||||
|
ls.name as stage_name,
|
||||||
|
u.email as user_email,
|
||||||
|
lr.name as lost_reason_name
|
||||||
|
FROM crm.leads l
|
||||||
|
LEFT JOIN auth.companies c ON l.company_id = c.id
|
||||||
|
LEFT JOIN crm.lead_stages ls ON l.stage_id = ls.id
|
||||||
|
LEFT JOIN auth.users u ON l.user_id = u.id
|
||||||
|
LEFT JOIN crm.lost_reasons lr ON l.lost_reason_id = lr.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY l.priority DESC, l.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Lead> {
|
||||||
|
const lead = await queryOne<Lead>(
|
||||||
|
`SELECT l.*,
|
||||||
|
c.name as company_org_name,
|
||||||
|
ls.name as stage_name,
|
||||||
|
u.email as user_email,
|
||||||
|
lr.name as lost_reason_name
|
||||||
|
FROM crm.leads l
|
||||||
|
LEFT JOIN auth.companies c ON l.company_id = c.id
|
||||||
|
LEFT JOIN crm.lead_stages ls ON l.stage_id = ls.id
|
||||||
|
LEFT JOIN auth.users u ON l.user_id = u.id
|
||||||
|
LEFT JOIN crm.lost_reasons lr ON l.lost_reason_id = lr.id
|
||||||
|
WHERE l.id = $1 AND l.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!lead) {
|
||||||
|
throw new NotFoundError('Lead no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lead;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateLeadDto, tenantId: string, userId: string): Promise<Lead> {
|
||||||
|
const lead = await queryOne<Lead>(
|
||||||
|
`INSERT INTO crm.leads (
|
||||||
|
tenant_id, company_id, name, ref, contact_name, email, phone, mobile, website,
|
||||||
|
company_name, job_position, industry, employee_count, annual_revenue,
|
||||||
|
street, city, state, zip, country, stage_id, user_id, sales_team_id, source,
|
||||||
|
priority, probability, expected_revenue, date_deadline, description, notes, tags,
|
||||||
|
date_open, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
|
||||||
|
$18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, CURRENT_TIMESTAMP, $31)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.name, dto.ref, dto.contact_name, dto.email, dto.phone,
|
||||||
|
dto.mobile, dto.website, dto.company_prospect_name, dto.job_position, dto.industry,
|
||||||
|
dto.employee_count, dto.annual_revenue, dto.street, dto.city, dto.state, dto.zip,
|
||||||
|
dto.country, dto.stage_id, dto.user_id, dto.sales_team_id, dto.source,
|
||||||
|
dto.priority || 0, dto.probability || 0, dto.expected_revenue, dto.date_deadline,
|
||||||
|
dto.description, dto.notes, dto.tags, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(lead!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateLeadDto, tenantId: string, userId: string): Promise<Lead> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status === 'converted' || existing.status === 'lost') {
|
||||||
|
throw new ValidationError('No se puede editar un lead convertido o perdido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = [
|
||||||
|
'name', 'ref', 'contact_name', 'email', 'phone', 'mobile', 'website',
|
||||||
|
'company_prospect_name', 'job_position', 'industry', 'employee_count', 'annual_revenue',
|
||||||
|
'street', 'city', 'state', 'zip', 'country', 'stage_id', 'user_id', 'sales_team_id',
|
||||||
|
'source', 'priority', 'probability', 'expected_revenue', 'date_deadline',
|
||||||
|
'description', 'notes', 'tags'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
const key = field === 'company_prospect_name' ? 'company_name' : field;
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${key} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`date_last_activity = CURRENT_TIMESTAMP`);
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.leads SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveStage(id: string, stageId: string, tenantId: string, userId: string): Promise<Lead> {
|
||||||
|
const lead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (lead.status === 'converted' || lead.status === 'lost') {
|
||||||
|
throw new ValidationError('No se puede mover un lead convertido o perdido');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.leads SET
|
||||||
|
stage_id = $1,
|
||||||
|
date_last_activity = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[stageId, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async convert(id: string, tenantId: string, userId: string): Promise<{ lead: Lead; opportunity_id: string }> {
|
||||||
|
const lead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (lead.status === 'converted') {
|
||||||
|
throw new ValidationError('El lead ya fue convertido');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lead.status === 'lost') {
|
||||||
|
throw new ValidationError('No se puede convertir un lead perdido');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Create or get partner
|
||||||
|
let partnerId = lead.partner_id;
|
||||||
|
|
||||||
|
if (!partnerId && lead.email) {
|
||||||
|
// Check if partner exists with same email
|
||||||
|
const existingPartner = await client.query(
|
||||||
|
`SELECT id FROM core.partners WHERE email = $1 AND tenant_id = $2`,
|
||||||
|
[lead.email, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingPartner.rows.length > 0) {
|
||||||
|
partnerId = existingPartner.rows[0].id;
|
||||||
|
} else {
|
||||||
|
// Create new partner
|
||||||
|
const partnerResult = await client.query(
|
||||||
|
`INSERT INTO core.partners (tenant_id, name, email, phone, mobile, is_customer, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, TRUE, $6)
|
||||||
|
RETURNING id`,
|
||||||
|
[tenantId, lead.contact_name || lead.name, lead.email, lead.phone, lead.mobile, userId]
|
||||||
|
);
|
||||||
|
partnerId = partnerResult.rows[0].id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partnerId) {
|
||||||
|
throw new ValidationError('El lead debe tener un email o partner asociado para convertirse');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get default opportunity stage
|
||||||
|
const stageResult = await client.query(
|
||||||
|
`SELECT id FROM crm.opportunity_stages WHERE tenant_id = $1 ORDER BY sequence LIMIT 1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stageId = stageResult.rows[0]?.id || null;
|
||||||
|
|
||||||
|
// Create opportunity
|
||||||
|
const opportunityResult = await client.query(
|
||||||
|
`INSERT INTO crm.opportunities (
|
||||||
|
tenant_id, company_id, name, partner_id, contact_name, email, phone,
|
||||||
|
stage_id, user_id, sales_team_id, source, priority, probability,
|
||||||
|
expected_revenue, lead_id, description, notes, tags, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
tenantId, lead.company_id, lead.name, partnerId, lead.contact_name, lead.email,
|
||||||
|
lead.phone, stageId, lead.user_id, lead.sales_team_id, lead.source, lead.priority,
|
||||||
|
lead.probability, lead.expected_revenue, id, lead.description, lead.notes, lead.tags, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const opportunityId = opportunityResult.rows[0].id;
|
||||||
|
|
||||||
|
// Update lead
|
||||||
|
await client.query(
|
||||||
|
`UPDATE crm.leads SET
|
||||||
|
status = 'converted',
|
||||||
|
partner_id = $1,
|
||||||
|
opportunity_id = $2,
|
||||||
|
date_closed = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4`,
|
||||||
|
[partnerId, opportunityId, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
const updatedLead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
return { lead: updatedLead, opportunity_id: opportunityId };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLost(id: string, lostReasonId: string, notes: string | undefined, tenantId: string, userId: string): Promise<Lead> {
|
||||||
|
const lead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (lead.status === 'converted') {
|
||||||
|
throw new ValidationError('No se puede marcar como perdido un lead convertido');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lead.status === 'lost') {
|
||||||
|
throw new ValidationError('El lead ya esta marcado como perdido');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.leads SET
|
||||||
|
status = 'lost',
|
||||||
|
lost_reason_id = $1,
|
||||||
|
lost_notes = $2,
|
||||||
|
date_closed = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4 AND tenant_id = $5`,
|
||||||
|
[lostReasonId, notes, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const lead = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (lead.opportunity_id) {
|
||||||
|
throw new ConflictError('No se puede eliminar un lead que tiene una oportunidad asociada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.leads WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const leadsService = new LeadsService();
|
||||||
503
src/modules/crm/opportunities.service.ts
Normal file
503
src/modules/crm/opportunities.service.ts
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { LeadSource } from './leads.service.js';
|
||||||
|
|
||||||
|
export type OpportunityStatus = 'open' | 'won' | 'lost';
|
||||||
|
|
||||||
|
export interface Opportunity {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
contact_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
stage_name?: string;
|
||||||
|
status: OpportunityStatus;
|
||||||
|
user_id?: string;
|
||||||
|
user_name?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
priority: number;
|
||||||
|
probability: number;
|
||||||
|
expected_revenue?: number;
|
||||||
|
recurring_revenue?: number;
|
||||||
|
recurring_plan?: string;
|
||||||
|
date_deadline?: Date;
|
||||||
|
date_closed?: Date;
|
||||||
|
date_last_activity?: Date;
|
||||||
|
lead_id?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
lost_reason_id?: string;
|
||||||
|
lost_reason_name?: string;
|
||||||
|
lost_notes?: string;
|
||||||
|
quotation_id?: string;
|
||||||
|
order_id?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOpportunityDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
partner_id: string;
|
||||||
|
contact_name?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
stage_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
sales_team_id?: string;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expected_revenue?: number;
|
||||||
|
recurring_revenue?: number;
|
||||||
|
recurring_plan?: string;
|
||||||
|
date_deadline?: string;
|
||||||
|
source?: LeadSource;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOpportunityDto {
|
||||||
|
name?: string;
|
||||||
|
ref?: string | null;
|
||||||
|
partner_id?: string;
|
||||||
|
contact_name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
stage_id?: string | null;
|
||||||
|
user_id?: string | null;
|
||||||
|
sales_team_id?: string | null;
|
||||||
|
priority?: number;
|
||||||
|
probability?: number;
|
||||||
|
expected_revenue?: number | null;
|
||||||
|
recurring_revenue?: number | null;
|
||||||
|
recurring_plan?: string | null;
|
||||||
|
date_deadline?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpportunityFilters {
|
||||||
|
company_id?: string;
|
||||||
|
status?: OpportunityStatus;
|
||||||
|
stage_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
priority?: number;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OpportunitiesService {
|
||||||
|
async findAll(tenantId: string, filters: OpportunityFilters = {}): Promise<{ data: Opportunity[]; total: number }> {
|
||||||
|
const { company_id, status, stage_id, user_id, partner_id, priority, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE o.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND o.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND o.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage_id) {
|
||||||
|
whereClause += ` AND o.stage_id = $${paramIndex++}`;
|
||||||
|
params.push(stage_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user_id) {
|
||||||
|
whereClause += ` AND o.user_id = $${paramIndex++}`;
|
||||||
|
params.push(user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND o.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priority !== undefined) {
|
||||||
|
whereClause += ` AND o.priority = $${paramIndex++}`;
|
||||||
|
params.push(priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (o.name ILIKE $${paramIndex} OR o.contact_name ILIKE $${paramIndex} OR o.email ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.opportunities o
|
||||||
|
LEFT JOIN core.partners p ON o.partner_id = p.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Opportunity>(
|
||||||
|
`SELECT o.*,
|
||||||
|
c.name as company_org_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
os.name as stage_name,
|
||||||
|
u.email as user_email,
|
||||||
|
lr.name as lost_reason_name
|
||||||
|
FROM crm.opportunities o
|
||||||
|
LEFT JOIN auth.companies c ON o.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON o.partner_id = p.id
|
||||||
|
LEFT JOIN crm.opportunity_stages os ON o.stage_id = os.id
|
||||||
|
LEFT JOIN auth.users u ON o.user_id = u.id
|
||||||
|
LEFT JOIN crm.lost_reasons lr ON o.lost_reason_id = lr.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY o.priority DESC, o.expected_revenue DESC NULLS LAST, o.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await queryOne<Opportunity>(
|
||||||
|
`SELECT o.*,
|
||||||
|
c.name as company_org_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
os.name as stage_name,
|
||||||
|
u.email as user_email,
|
||||||
|
lr.name as lost_reason_name
|
||||||
|
FROM crm.opportunities o
|
||||||
|
LEFT JOIN auth.companies c ON o.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON o.partner_id = p.id
|
||||||
|
LEFT JOIN crm.opportunity_stages os ON o.stage_id = os.id
|
||||||
|
LEFT JOIN auth.users u ON o.user_id = u.id
|
||||||
|
LEFT JOIN crm.lost_reasons lr ON o.lost_reason_id = lr.id
|
||||||
|
WHERE o.id = $1 AND o.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!opportunity) {
|
||||||
|
throw new NotFoundError('Oportunidad no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return opportunity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateOpportunityDto, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await queryOne<Opportunity>(
|
||||||
|
`INSERT INTO crm.opportunities (
|
||||||
|
tenant_id, company_id, name, ref, partner_id, contact_name, email, phone,
|
||||||
|
stage_id, user_id, sales_team_id, priority, probability, expected_revenue,
|
||||||
|
recurring_revenue, recurring_plan, date_deadline, source, description, notes, tags, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.name, dto.ref, dto.partner_id, dto.contact_name,
|
||||||
|
dto.email, dto.phone, dto.stage_id, dto.user_id, dto.sales_team_id,
|
||||||
|
dto.priority || 0, dto.probability || 0, dto.expected_revenue, dto.recurring_revenue,
|
||||||
|
dto.recurring_plan, dto.date_deadline, dto.source, dto.description, dto.notes, dto.tags, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(opportunity!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateOpportunityDto, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden editar oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = [
|
||||||
|
'name', 'ref', 'partner_id', 'contact_name', 'email', 'phone', 'stage_id',
|
||||||
|
'user_id', 'sales_team_id', 'priority', 'probability', 'expected_revenue',
|
||||||
|
'recurring_revenue', 'recurring_plan', 'date_deadline', 'description', 'notes', 'tags'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`date_last_activity = CURRENT_TIMESTAMP`);
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunities SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveStage(id: string, stageId: string, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden mover oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stage probability
|
||||||
|
const stage = await queryOne<{ probability: number; is_won: boolean }>(
|
||||||
|
`SELECT probability, is_won FROM crm.opportunity_stages WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[stageId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stage) {
|
||||||
|
throw new NotFoundError('Etapa no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunities SET
|
||||||
|
stage_id = $1,
|
||||||
|
probability = $2,
|
||||||
|
date_last_activity = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4 AND tenant_id = $5`,
|
||||||
|
[stageId, stage.probability, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markWon(id: string, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden marcar como ganadas oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunities SET
|
||||||
|
status = 'won',
|
||||||
|
probability = 100,
|
||||||
|
date_closed = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async markLost(id: string, lostReasonId: string, notes: string | undefined, tenantId: string, userId: string): Promise<Opportunity> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden marcar como perdidas oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunities SET
|
||||||
|
status = 'lost',
|
||||||
|
probability = 0,
|
||||||
|
lost_reason_id = $1,
|
||||||
|
lost_notes = $2,
|
||||||
|
date_closed = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4 AND tenant_id = $5`,
|
||||||
|
[lostReasonId, notes, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createQuotation(id: string, tenantId: string, userId: string): Promise<{ opportunity: Opportunity; quotation_id: string }> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden crear cotizaciones de oportunidades abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opportunity.quotation_id) {
|
||||||
|
throw new ValidationError('Esta oportunidad ya tiene una cotizacion asociada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Generate quotation name
|
||||||
|
const seqResult = await client.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 3) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM sales.quotations WHERE tenant_id = $1 AND name LIKE 'SO%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const nextNum = seqResult.rows[0]?.next_num || 1;
|
||||||
|
const quotationName = `SO${String(nextNum).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
// Get default currency
|
||||||
|
const currencyResult = await client.query(
|
||||||
|
`SELECT id FROM core.currencies WHERE code = 'MXN' AND tenant_id = $1 LIMIT 1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const currencyId = currencyResult.rows[0]?.id;
|
||||||
|
|
||||||
|
if (!currencyId) {
|
||||||
|
throw new ValidationError('No se encontro una moneda configurada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create quotation
|
||||||
|
const quotationResult = await client.query(
|
||||||
|
`INSERT INTO sales.quotations (
|
||||||
|
tenant_id, company_id, name, partner_id, quotation_date, validity_date,
|
||||||
|
currency_id, user_id, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, CURRENT_DATE, CURRENT_DATE + INTERVAL '30 days', $5, $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
tenantId, opportunity.company_id, quotationName, opportunity.partner_id,
|
||||||
|
currencyId, userId, opportunity.description, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const quotationId = quotationResult.rows[0].id;
|
||||||
|
|
||||||
|
// Update opportunity
|
||||||
|
await client.query(
|
||||||
|
`UPDATE crm.opportunities SET
|
||||||
|
quotation_id = $1,
|
||||||
|
date_last_activity = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3`,
|
||||||
|
[quotationId, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
const updatedOpportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
return { opportunity: updatedOpportunity, quotation_id: quotationId };
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const opportunity = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (opportunity.quotation_id || opportunity.order_id) {
|
||||||
|
throw new ValidationError('No se puede eliminar una oportunidad con cotizacion u orden asociada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lead if exists
|
||||||
|
if (opportunity.lead_id) {
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.leads SET opportunity_id = NULL WHERE id = $1`,
|
||||||
|
[opportunity.lead_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.opportunities WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pipeline view - grouped by stage
|
||||||
|
async getPipeline(tenantId: string, companyId?: string): Promise<{ stages: any[]; totals: any }> {
|
||||||
|
let whereClause = 'WHERE o.tenant_id = $1 AND o.status = $2';
|
||||||
|
const params: any[] = [tenantId, 'open'];
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
whereClause += ` AND o.company_id = $3`;
|
||||||
|
params.push(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stages = await query<{ id: string; name: string; sequence: number; probability: number }>(
|
||||||
|
`SELECT id, name, sequence, probability
|
||||||
|
FROM crm.opportunity_stages
|
||||||
|
WHERE tenant_id = $1 AND active = TRUE
|
||||||
|
ORDER BY sequence`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const opportunities = await query<any>(
|
||||||
|
`SELECT o.id, o.name, o.partner_id, p.name as partner_name,
|
||||||
|
o.stage_id, o.expected_revenue, o.probability, o.priority,
|
||||||
|
o.date_deadline, o.user_id
|
||||||
|
FROM crm.opportunities o
|
||||||
|
LEFT JOIN core.partners p ON o.partner_id = p.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY o.priority DESC, o.expected_revenue DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group opportunities by stage
|
||||||
|
const pipelineStages = stages.map(stage => ({
|
||||||
|
...stage,
|
||||||
|
opportunities: opportunities.filter((opp: any) => opp.stage_id === stage.id),
|
||||||
|
count: opportunities.filter((opp: any) => opp.stage_id === stage.id).length,
|
||||||
|
total_revenue: opportunities
|
||||||
|
.filter((opp: any) => opp.stage_id === stage.id)
|
||||||
|
.reduce((sum: number, opp: any) => sum + (parseFloat(opp.expected_revenue) || 0), 0)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add "No stage" for opportunities without stage
|
||||||
|
const noStageOpps = opportunities.filter((opp: any) => !opp.stage_id);
|
||||||
|
if (noStageOpps.length > 0) {
|
||||||
|
pipelineStages.unshift({
|
||||||
|
id: null as unknown as string,
|
||||||
|
name: 'Sin etapa',
|
||||||
|
sequence: 0,
|
||||||
|
probability: 0,
|
||||||
|
opportunities: noStageOpps,
|
||||||
|
count: noStageOpps.length,
|
||||||
|
total_revenue: noStageOpps.reduce((sum: number, opp: any) => sum + (parseFloat(opp.expected_revenue) || 0), 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = {
|
||||||
|
total_opportunities: opportunities.length,
|
||||||
|
total_expected_revenue: opportunities.reduce((sum: number, opp: any) => sum + (parseFloat(opp.expected_revenue) || 0), 0),
|
||||||
|
weighted_revenue: opportunities.reduce((sum: number, opp: any) => {
|
||||||
|
const revenue = parseFloat(opp.expected_revenue) || 0;
|
||||||
|
const probability = parseFloat(opp.probability) || 0;
|
||||||
|
return sum + (revenue * probability / 100);
|
||||||
|
}, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
return { stages: pipelineStages, totals };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const opportunitiesService = new OpportunitiesService();
|
||||||
435
src/modules/crm/stages.service.ts
Normal file
435
src/modules/crm/stages.service.ts
Normal file
@ -0,0 +1,435 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
|
export interface LeadStage {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
sequence: number;
|
||||||
|
is_won: boolean;
|
||||||
|
probability: number;
|
||||||
|
requirements?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeadStageDto {
|
||||||
|
name: string;
|
||||||
|
sequence?: number;
|
||||||
|
is_won?: boolean;
|
||||||
|
probability?: number;
|
||||||
|
requirements?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeadStageDto {
|
||||||
|
name?: string;
|
||||||
|
sequence?: number;
|
||||||
|
is_won?: boolean;
|
||||||
|
probability?: number;
|
||||||
|
requirements?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OPPORTUNITY STAGES ==========
|
||||||
|
|
||||||
|
export interface OpportunityStage {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
sequence: number;
|
||||||
|
is_won: boolean;
|
||||||
|
probability: number;
|
||||||
|
requirements?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOpportunityStageDto {
|
||||||
|
name: string;
|
||||||
|
sequence?: number;
|
||||||
|
is_won?: boolean;
|
||||||
|
probability?: number;
|
||||||
|
requirements?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOpportunityStageDto {
|
||||||
|
name?: string;
|
||||||
|
sequence?: number;
|
||||||
|
is_won?: boolean;
|
||||||
|
probability?: number;
|
||||||
|
requirements?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOST REASONS ==========
|
||||||
|
|
||||||
|
export interface LostReason {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLostReasonDto {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLostReasonDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class StagesService {
|
||||||
|
// ========== LEAD STAGES ==========
|
||||||
|
|
||||||
|
async getLeadStages(tenantId: string, includeInactive = false): Promise<LeadStage[]> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<LeadStage>(
|
||||||
|
`SELECT * FROM crm.lead_stages ${whereClause} ORDER BY sequence`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeadStageById(id: string, tenantId: string): Promise<LeadStage> {
|
||||||
|
const stage = await queryOne<LeadStage>(
|
||||||
|
`SELECT * FROM crm.lead_stages WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stage) {
|
||||||
|
throw new NotFoundError('Etapa de lead no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLeadStage(dto: CreateLeadStageDto, tenantId: string): Promise<LeadStage> {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.lead_stages WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una etapa con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = await queryOne<LeadStage>(
|
||||||
|
`INSERT INTO crm.lead_stages (tenant_id, name, sequence, is_won, probability, requirements)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.sequence || 10, dto.is_won || false, dto.probability || 0, dto.requirements]
|
||||||
|
);
|
||||||
|
|
||||||
|
return stage!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeadStage(id: string, dto: UpdateLeadStageDto, tenantId: string): Promise<LeadStage> {
|
||||||
|
await this.getLeadStageById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.lead_stages WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una etapa con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.sequence !== undefined) {
|
||||||
|
updateFields.push(`sequence = $${paramIndex++}`);
|
||||||
|
values.push(dto.sequence);
|
||||||
|
}
|
||||||
|
if (dto.is_won !== undefined) {
|
||||||
|
updateFields.push(`is_won = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_won);
|
||||||
|
}
|
||||||
|
if (dto.probability !== undefined) {
|
||||||
|
updateFields.push(`probability = $${paramIndex++}`);
|
||||||
|
values.push(dto.probability);
|
||||||
|
}
|
||||||
|
if (dto.requirements !== undefined) {
|
||||||
|
updateFields.push(`requirements = $${paramIndex++}`);
|
||||||
|
values.push(dto.requirements);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.getLeadStageById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.lead_stages SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getLeadStageById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLeadStage(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getLeadStageById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if stage is in use
|
||||||
|
const inUse = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.leads WHERE stage_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(inUse?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una etapa que tiene leads asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.lead_stages WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OPPORTUNITY STAGES ==========
|
||||||
|
|
||||||
|
async getOpportunityStages(tenantId: string, includeInactive = false): Promise<OpportunityStage[]> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<OpportunityStage>(
|
||||||
|
`SELECT * FROM crm.opportunity_stages ${whereClause} ORDER BY sequence`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpportunityStageById(id: string, tenantId: string): Promise<OpportunityStage> {
|
||||||
|
const stage = await queryOne<OpportunityStage>(
|
||||||
|
`SELECT * FROM crm.opportunity_stages WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!stage) {
|
||||||
|
throw new NotFoundError('Etapa de oportunidad no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOpportunityStage(dto: CreateOpportunityStageDto, tenantId: string): Promise<OpportunityStage> {
|
||||||
|
// Check unique name
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.opportunity_stages WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una etapa con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stage = await queryOne<OpportunityStage>(
|
||||||
|
`INSERT INTO crm.opportunity_stages (tenant_id, name, sequence, is_won, probability, requirements)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.sequence || 10, dto.is_won || false, dto.probability || 0, dto.requirements]
|
||||||
|
);
|
||||||
|
|
||||||
|
return stage!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOpportunityStage(id: string, dto: UpdateOpportunityStageDto, tenantId: string): Promise<OpportunityStage> {
|
||||||
|
await this.getOpportunityStageById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.opportunity_stages WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una etapa con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.sequence !== undefined) {
|
||||||
|
updateFields.push(`sequence = $${paramIndex++}`);
|
||||||
|
values.push(dto.sequence);
|
||||||
|
}
|
||||||
|
if (dto.is_won !== undefined) {
|
||||||
|
updateFields.push(`is_won = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_won);
|
||||||
|
}
|
||||||
|
if (dto.probability !== undefined) {
|
||||||
|
updateFields.push(`probability = $${paramIndex++}`);
|
||||||
|
values.push(dto.probability);
|
||||||
|
}
|
||||||
|
if (dto.requirements !== undefined) {
|
||||||
|
updateFields.push(`requirements = $${paramIndex++}`);
|
||||||
|
values.push(dto.requirements);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.getOpportunityStageById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.opportunity_stages SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getOpportunityStageById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteOpportunityStage(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getOpportunityStageById(id, tenantId);
|
||||||
|
|
||||||
|
const inUse = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.opportunities WHERE stage_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(inUse?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una etapa que tiene oportunidades asociadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.opportunity_stages WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LOST REASONS ==========
|
||||||
|
|
||||||
|
async getLostReasons(tenantId: string, includeInactive = false): Promise<LostReason[]> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<LostReason>(
|
||||||
|
`SELECT * FROM crm.lost_reasons ${whereClause} ORDER BY name`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLostReasonById(id: string, tenantId: string): Promise<LostReason> {
|
||||||
|
const reason = await queryOne<LostReason>(
|
||||||
|
`SELECT * FROM crm.lost_reasons WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reason) {
|
||||||
|
throw new NotFoundError('Razon de perdida no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLostReason(dto: CreateLostReasonDto, tenantId: string): Promise<LostReason> {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.lost_reasons WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una razon con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = await queryOne<LostReason>(
|
||||||
|
`INSERT INTO crm.lost_reasons (tenant_id, name, description)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.description]
|
||||||
|
);
|
||||||
|
|
||||||
|
return reason!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLostReason(id: string, dto: UpdateLostReasonDto, tenantId: string): Promise<LostReason> {
|
||||||
|
await this.getLostReasonById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM crm.lost_reasons WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe una razon con ese nombre');
|
||||||
|
}
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.getLostReasonById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE crm.lost_reasons SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getLostReasonById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLostReason(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getLostReasonById(id, tenantId);
|
||||||
|
|
||||||
|
const inUseLeads = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.leads WHERE lost_reason_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inUseOpps = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM crm.opportunities WHERE lost_reason_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(inUseLeads?.count || '0') > 0 || parseInt(inUseOpps?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una razon que esta en uso');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM crm.lost_reasons WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stagesService = new StagesService();
|
||||||
612
src/modules/financial/MIGRATION_GUIDE.md
Normal file
612
src/modules/financial/MIGRATION_GUIDE.md
Normal file
@ -0,0 +1,612 @@
|
|||||||
|
# Financial Module TypeORM Migration Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide documents the migration of the Financial module from raw SQL queries to TypeORM. The migration maintains backwards compatibility while introducing modern ORM patterns.
|
||||||
|
|
||||||
|
## Completed Tasks
|
||||||
|
|
||||||
|
### 1. Entity Creation ✅
|
||||||
|
|
||||||
|
All TypeORM entities have been created in `/src/modules/financial/entities/`:
|
||||||
|
|
||||||
|
- **account-type.entity.ts** - Chart of account types catalog
|
||||||
|
- **account.entity.ts** - Accounts with hierarchy support
|
||||||
|
- **journal.entity.ts** - Accounting journals
|
||||||
|
- **journal-entry.entity.ts** - Journal entries (header)
|
||||||
|
- **journal-entry-line.entity.ts** - Journal entry lines (detail)
|
||||||
|
- **invoice.entity.ts** - Customer and supplier invoices
|
||||||
|
- **invoice-line.entity.ts** - Invoice line items
|
||||||
|
- **payment.entity.ts** - Payment transactions
|
||||||
|
- **tax.entity.ts** - Tax configuration
|
||||||
|
- **fiscal-year.entity.ts** - Fiscal years
|
||||||
|
- **fiscal-period.entity.ts** - Fiscal periods (months/quarters)
|
||||||
|
- **index.ts** - Barrel export file
|
||||||
|
|
||||||
|
### 2. Entity Registration ✅
|
||||||
|
|
||||||
|
All financial entities have been registered in `/src/config/typeorm.ts`:
|
||||||
|
- Import statements added
|
||||||
|
- Entities added to the `entities` array in AppDataSource configuration
|
||||||
|
|
||||||
|
### 3. Service Refactoring ✅
|
||||||
|
|
||||||
|
#### accounts.service.ts - COMPLETED
|
||||||
|
|
||||||
|
The accounts service has been fully migrated to TypeORM with the following features:
|
||||||
|
|
||||||
|
**Key Changes:**
|
||||||
|
- Uses `Repository<Account>` and `Repository<AccountType>`
|
||||||
|
- Implements QueryBuilder for complex queries with joins
|
||||||
|
- Supports both snake_case (DB) and camelCase (TS) through decorators
|
||||||
|
- Maintains all original functionality including:
|
||||||
|
- Account hierarchy with cycle detection
|
||||||
|
- Soft delete with validation
|
||||||
|
- Balance calculations
|
||||||
|
- Full CRUD operations
|
||||||
|
|
||||||
|
**Pattern to Follow:**
|
||||||
|
```typescript
|
||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { Entity } from './entities/index.js';
|
||||||
|
|
||||||
|
class MyService {
|
||||||
|
private repository: Repository<Entity>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = AppDataSource.getRepository(Entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(tenantId: string, filters = {}) {
|
||||||
|
const queryBuilder = this.repository
|
||||||
|
.createQueryBuilder('alias')
|
||||||
|
.leftJoin('alias.relation', 'relation')
|
||||||
|
.addSelect(['relation.field'])
|
||||||
|
.where('alias.tenantId = :tenantId', { tenantId });
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
// Get count and results
|
||||||
|
return { data, total };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remaining Tasks
|
||||||
|
|
||||||
|
### Services to Migrate
|
||||||
|
|
||||||
|
#### 1. journals.service.ts - PRIORITY HIGH
|
||||||
|
|
||||||
|
**Current State:** Uses raw SQL queries
|
||||||
|
**Target Pattern:** Same as accounts.service.ts
|
||||||
|
|
||||||
|
**Migration Steps:**
|
||||||
|
1. Import Journal entity and Repository
|
||||||
|
2. Replace all `query()` and `queryOne()` calls with Repository methods
|
||||||
|
3. Use QueryBuilder for complex queries with joins (company, account, currency)
|
||||||
|
4. Update return types to use entity types instead of interfaces
|
||||||
|
5. Maintain validation logic for:
|
||||||
|
- Unique code per company
|
||||||
|
- Journal entry existence check before delete
|
||||||
|
6. Test endpoints thoroughly
|
||||||
|
|
||||||
|
**Key Relationships:**
|
||||||
|
- Journal → Company (ManyToOne)
|
||||||
|
- Journal → Account (default account, ManyToOne, optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. taxes.service.ts - PRIORITY HIGH
|
||||||
|
|
||||||
|
**Current State:** Uses raw SQL queries
|
||||||
|
**Special Feature:** Tax calculation logic
|
||||||
|
|
||||||
|
**Migration Steps:**
|
||||||
|
1. Import Tax entity and Repository
|
||||||
|
2. Migrate CRUD operations to Repository
|
||||||
|
3. **IMPORTANT:** Keep `calculateTaxes()` and `calculateDocumentTaxes()` logic intact
|
||||||
|
4. These calculation methods can still use raw queries if needed
|
||||||
|
5. Update filters to use QueryBuilder
|
||||||
|
|
||||||
|
**Tax Calculation Logic:**
|
||||||
|
- Located in lines 224-354 of current service
|
||||||
|
- Critical for invoice and payment processing
|
||||||
|
- DO NOT modify calculation algorithms
|
||||||
|
- Only update data access layer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. journal-entries.service.ts - PRIORITY MEDIUM
|
||||||
|
|
||||||
|
**Current State:** Uses raw SQL with transactions
|
||||||
|
**Complexity:** HIGH - Multi-table operations
|
||||||
|
|
||||||
|
**Migration Steps:**
|
||||||
|
1. Import JournalEntry, JournalEntryLine entities
|
||||||
|
2. Use TypeORM QueryRunner for transactions:
|
||||||
|
```typescript
|
||||||
|
const queryRunner = AppDataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Operations
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
} catch (error) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Double-Entry Balance Validation:**
|
||||||
|
- Keep validation logic lines 172-177
|
||||||
|
- Validate debit = credit before saving
|
||||||
|
4. Use cascade operations for lines:
|
||||||
|
- `cascade: true` is already set in entity
|
||||||
|
- Can save entry with lines in single operation
|
||||||
|
|
||||||
|
**Critical Features:**
|
||||||
|
- Transaction management (BEGIN/COMMIT/ROLLBACK)
|
||||||
|
- Balance validation (debits must equal credits)
|
||||||
|
- Status transitions (draft → posted → cancelled)
|
||||||
|
- Fiscal period validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. invoices.service.ts - PRIORITY MEDIUM
|
||||||
|
|
||||||
|
**Current State:** Uses raw SQL with complex line management
|
||||||
|
**Complexity:** HIGH - Invoice lines, tax calculations
|
||||||
|
|
||||||
|
**Migration Steps:**
|
||||||
|
1. Import Invoice, InvoiceLine entities
|
||||||
|
2. Use transactions for multi-table operations
|
||||||
|
3. **Tax Integration:**
|
||||||
|
- Line 331-340: Uses taxesService.calculateTaxes()
|
||||||
|
- Keep this integration intact
|
||||||
|
- Only migrate data access
|
||||||
|
4. **Amount Calculations:**
|
||||||
|
- updateTotals() method (lines 525-543)
|
||||||
|
- Can use QueryBuilder aggregation or raw SQL
|
||||||
|
5. **Number Generation:**
|
||||||
|
- Lines 472-478: Sequential invoice numbering
|
||||||
|
- Keep this logic, migrate to Repository
|
||||||
|
|
||||||
|
**Relationships:**
|
||||||
|
- Invoice → Company
|
||||||
|
- Invoice → Journal (optional)
|
||||||
|
- Invoice → JournalEntry (optional, for accounting integration)
|
||||||
|
- Invoice → InvoiceLine[] (one-to-many, cascade)
|
||||||
|
- InvoiceLine → Account (optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. payments.service.ts - PRIORITY MEDIUM
|
||||||
|
|
||||||
|
**Current State:** Uses raw SQL with invoice reconciliation
|
||||||
|
**Complexity:** MEDIUM-HIGH - Payment-Invoice linking
|
||||||
|
|
||||||
|
**Migration Steps:**
|
||||||
|
1. Import Payment entity
|
||||||
|
2. **Payment-Invoice Junction:**
|
||||||
|
- Table: `financial.payment_invoice`
|
||||||
|
- Not modeled as entity (junction table)
|
||||||
|
- Can use raw SQL for this or create entity
|
||||||
|
3. Use transactions for reconciliation
|
||||||
|
4. **Invoice Status Updates:**
|
||||||
|
- Lines 373-380: Updates invoice amounts
|
||||||
|
- Must coordinate with Invoice entity
|
||||||
|
|
||||||
|
**Critical Logic:**
|
||||||
|
- Reconciliation workflow (lines 314-401)
|
||||||
|
- Invoice amount updates
|
||||||
|
- Transaction rollback on errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 6. fiscalPeriods.service.ts - PRIORITY LOW
|
||||||
|
|
||||||
|
**Current State:** Uses raw SQL + database functions
|
||||||
|
**Complexity:** MEDIUM - Database function calls
|
||||||
|
|
||||||
|
**Migration Steps:**
|
||||||
|
1. Import FiscalYear, FiscalPeriod entities
|
||||||
|
2. Basic CRUD can use Repository
|
||||||
|
3. **Database Functions:**
|
||||||
|
- Line 242: `financial.close_fiscal_period()`
|
||||||
|
- Line 265: `financial.reopen_fiscal_period()`
|
||||||
|
- Keep these as raw SQL calls:
|
||||||
|
```typescript
|
||||||
|
await this.repository.query(
|
||||||
|
'SELECT * FROM financial.close_fiscal_period($1, $2)',
|
||||||
|
[periodId, userId]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
4. **Date Overlap Validation:**
|
||||||
|
- Lines 102-107, 207-212
|
||||||
|
- Use QueryBuilder with date range checks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Controller Updates
|
||||||
|
|
||||||
|
### Accept Both snake_case and camelCase
|
||||||
|
|
||||||
|
The controller currently only accepts snake_case. Update to support both:
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```typescript
|
||||||
|
const createAccountSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
code: z.string(),
|
||||||
|
// ...
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Updated:**
|
||||||
|
```typescript
|
||||||
|
const createAccountSchema = z.object({
|
||||||
|
companyId: z.string().uuid().optional(),
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
code: z.string(),
|
||||||
|
// ...
|
||||||
|
}).refine(
|
||||||
|
(data) => data.companyId || data.company_id,
|
||||||
|
{ message: "Either companyId or company_id is required" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Then normalize before service call:
|
||||||
|
const dto = {
|
||||||
|
companyId: parseResult.data.companyId || parseResult.data.company_id,
|
||||||
|
// ... rest of fields
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Simpler Approach:**
|
||||||
|
Transform incoming data before validation:
|
||||||
|
```typescript
|
||||||
|
// Add utility function
|
||||||
|
function toCamelCase(obj: any): any {
|
||||||
|
const camelObj: any = {};
|
||||||
|
for (const key in obj) {
|
||||||
|
const camelKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
camelObj[camelKey] = obj[key];
|
||||||
|
}
|
||||||
|
return camelObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use in controller
|
||||||
|
const normalizedBody = toCamelCase(req.body);
|
||||||
|
const parseResult = createAccountSchema.safeParse(normalizedBody);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Patterns
|
||||||
|
|
||||||
|
### 1. Repository Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { MyEntity } from './entities/index.js';
|
||||||
|
|
||||||
|
class MyService {
|
||||||
|
private repository: Repository<MyEntity>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repository = AppDataSource.getRepository(MyEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Simple Find Operations
|
||||||
|
|
||||||
|
**Before (Raw SQL):**
|
||||||
|
```typescript
|
||||||
|
const result = await queryOne<Entity>(
|
||||||
|
`SELECT * FROM schema.table WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (TypeORM):**
|
||||||
|
```typescript
|
||||||
|
const result = await this.repository.findOne({
|
||||||
|
where: { id, tenantId, deletedAt: IsNull() }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Complex Queries with Joins
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```typescript
|
||||||
|
const data = await query<Entity>(
|
||||||
|
`SELECT e.*, r.name as relation_name
|
||||||
|
FROM schema.entities e
|
||||||
|
LEFT JOIN schema.relations r ON e.relation_id = r.id
|
||||||
|
WHERE e.tenant_id = $1`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```typescript
|
||||||
|
const data = await this.repository
|
||||||
|
.createQueryBuilder('entity')
|
||||||
|
.leftJoin('entity.relation', 'relation')
|
||||||
|
.addSelect(['relation.name'])
|
||||||
|
.where('entity.tenantId = :tenantId', { tenantId })
|
||||||
|
.getMany();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Transactions
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```typescript
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
// operations
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```typescript
|
||||||
|
const queryRunner = AppDataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// operations using queryRunner.manager
|
||||||
|
await queryRunner.manager.save(entity);
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
} catch (error) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Soft Deletes
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```typescript
|
||||||
|
await this.repository.update(
|
||||||
|
{ id, tenantId },
|
||||||
|
{
|
||||||
|
deletedAt: new Date(),
|
||||||
|
deletedBy: userId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Pagination
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [data, total] = await this.repository.findAndCount({
|
||||||
|
where: { tenantId, deletedAt: IsNull() },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### 1. Unit Tests
|
||||||
|
|
||||||
|
For each refactored service:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
describe('AccountsService', () => {
|
||||||
|
let service: AccountsService;
|
||||||
|
let repository: Repository<Account>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
repository = AppDataSource.getRepository(Account);
|
||||||
|
service = new AccountsService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create account with valid data', async () => {
|
||||||
|
const dto = { /* ... */ };
|
||||||
|
const result = await service.create(dto, tenantId, userId);
|
||||||
|
expect(result.id).toBeDefined();
|
||||||
|
expect(result.code).toBe(dto.code);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Integration Tests
|
||||||
|
|
||||||
|
Test with actual database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
npm test src/modules/financial/__tests__/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. API Tests
|
||||||
|
|
||||||
|
Test HTTP endpoints:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test accounts endpoints
|
||||||
|
curl -X GET http://localhost:3000/api/financial/accounts?companyId=xxx
|
||||||
|
curl -X POST http://localhost:3000/api/financial/accounts -d '{"companyId":"xxx",...}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
|
||||||
|
If migration causes issues:
|
||||||
|
|
||||||
|
1. **Restore Old Services:**
|
||||||
|
```bash
|
||||||
|
cd src/modules/financial
|
||||||
|
mv accounts.service.ts accounts.service.new.ts
|
||||||
|
mv accounts.service.old.ts accounts.service.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Remove Entity Imports:**
|
||||||
|
Edit `/src/config/typeorm.ts` and remove financial entity imports
|
||||||
|
|
||||||
|
3. **Restart Application:**
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema Notes
|
||||||
|
|
||||||
|
### Schema: `financial`
|
||||||
|
|
||||||
|
All tables use the `financial` schema as specified in entities.
|
||||||
|
|
||||||
|
### Important Columns:
|
||||||
|
|
||||||
|
- **tenant_id**: Multi-tenancy isolation (UUID, NOT NULL)
|
||||||
|
- **company_id**: Company isolation (UUID, NOT NULL)
|
||||||
|
- **deleted_at**: Soft delete timestamp (NULL = active)
|
||||||
|
- **created_at**: Audit timestamp
|
||||||
|
- **created_by**: User ID who created (UUID)
|
||||||
|
- **updated_at**: Audit timestamp
|
||||||
|
- **updated_by**: User ID who updated (UUID)
|
||||||
|
|
||||||
|
### Decimal Precision:
|
||||||
|
|
||||||
|
- **Amounts**: DECIMAL(15, 2) - invoices, payments
|
||||||
|
- **Quantity**: DECIMAL(15, 4) - invoice lines
|
||||||
|
- **Tax Rate**: DECIMAL(5, 2) - tax percentage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Issues and Solutions
|
||||||
|
|
||||||
|
### Issue 1: Column Name Mismatch
|
||||||
|
|
||||||
|
**Error:** `column "companyId" does not exist`
|
||||||
|
|
||||||
|
**Solution:** Entity decorators map camelCase to snake_case:
|
||||||
|
```typescript
|
||||||
|
@Column({ name: 'company_id' })
|
||||||
|
companyId: string;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 2: Soft Deletes Not Working
|
||||||
|
|
||||||
|
**Solution:** Always include `deletedAt: IsNull()` in where clauses:
|
||||||
|
```typescript
|
||||||
|
where: { id, tenantId, deletedAt: IsNull() }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 3: Transaction Not Rolling Back
|
||||||
|
|
||||||
|
**Solution:** Always use try-catch-finally with queryRunner:
|
||||||
|
```typescript
|
||||||
|
finally {
|
||||||
|
await queryRunner.release(); // MUST release
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 4: Relations Not Loading
|
||||||
|
|
||||||
|
**Solution:** Use leftJoin or relations option:
|
||||||
|
```typescript
|
||||||
|
// Option 1: Query Builder
|
||||||
|
.leftJoin('entity.relation', 'relation')
|
||||||
|
.addSelect(['relation.field'])
|
||||||
|
|
||||||
|
// Option 2: Find options
|
||||||
|
findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['relation'],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### 1. Query Optimization
|
||||||
|
|
||||||
|
- Use `leftJoin` + `addSelect` instead of `relations` option for better control
|
||||||
|
- Add indexes on frequently queried columns (already in entities)
|
||||||
|
- Use pagination for large result sets
|
||||||
|
|
||||||
|
### 2. Connection Pooling
|
||||||
|
|
||||||
|
TypeORM pool configuration (in typeorm.ts):
|
||||||
|
```typescript
|
||||||
|
extra: {
|
||||||
|
max: 10, // Conservative to not compete with pg pool
|
||||||
|
min: 2,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Caching
|
||||||
|
|
||||||
|
Currently disabled:
|
||||||
|
```typescript
|
||||||
|
cache: false
|
||||||
|
```
|
||||||
|
|
||||||
|
Can enable later for read-heavy operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Complete service migrations** in this order:
|
||||||
|
- taxes.service.ts (High priority, simple)
|
||||||
|
- journals.service.ts (High priority, simple)
|
||||||
|
- journal-entries.service.ts (Medium, complex transactions)
|
||||||
|
- invoices.service.ts (Medium, tax integration)
|
||||||
|
- payments.service.ts (Medium, reconciliation)
|
||||||
|
- fiscalPeriods.service.ts (Low, DB functions)
|
||||||
|
|
||||||
|
2. **Update controller** to accept both snake_case and camelCase
|
||||||
|
|
||||||
|
3. **Write tests** for each migrated service
|
||||||
|
|
||||||
|
4. **Update API documentation** to reflect camelCase support
|
||||||
|
|
||||||
|
5. **Monitor performance** after deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support and Questions
|
||||||
|
|
||||||
|
For questions about this migration:
|
||||||
|
- Check existing patterns in `accounts.service.ts`
|
||||||
|
- Review TypeORM documentation: https://typeorm.io
|
||||||
|
- Check entity definitions in `/entities/` folder
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### 2024-12-14
|
||||||
|
- Created all TypeORM entities
|
||||||
|
- Registered entities in AppDataSource
|
||||||
|
- Completed accounts.service.ts migration
|
||||||
|
- Created this migration guide
|
||||||
330
src/modules/financial/accounts.service.old.ts
Normal file
330
src/modules/financial/accounts.service.old.ts
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type AccountType = 'asset' | 'liability' | 'equity' | 'income' | 'expense';
|
||||||
|
|
||||||
|
export interface AccountTypeEntity {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
account_type: AccountType;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
account_type_id: string;
|
||||||
|
account_type_name?: string;
|
||||||
|
account_type_code?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
parent_name?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
currency_code?: string;
|
||||||
|
is_reconcilable: boolean;
|
||||||
|
is_deprecated: boolean;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAccountDto {
|
||||||
|
company_id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
account_type_id: string;
|
||||||
|
parent_id?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
is_reconcilable?: boolean;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAccountDto {
|
||||||
|
name?: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
currency_id?: string | null;
|
||||||
|
is_reconcilable?: boolean;
|
||||||
|
is_deprecated?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountFilters {
|
||||||
|
company_id?: string;
|
||||||
|
account_type_id?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
is_deprecated?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountsService {
|
||||||
|
// Account Types (catalog)
|
||||||
|
async findAllAccountTypes(): Promise<AccountTypeEntity[]> {
|
||||||
|
return query<AccountTypeEntity>(
|
||||||
|
`SELECT * FROM financial.account_types ORDER BY code`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAccountTypeById(id: string): Promise<AccountTypeEntity> {
|
||||||
|
const accountType = await queryOne<AccountTypeEntity>(
|
||||||
|
`SELECT * FROM financial.account_types WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!accountType) {
|
||||||
|
throw new NotFoundError('Tipo de cuenta no encontrado');
|
||||||
|
}
|
||||||
|
return accountType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accounts
|
||||||
|
async findAll(tenantId: string, filters: AccountFilters = {}): Promise<{ data: Account[]; total: number }> {
|
||||||
|
const { company_id, account_type_id, parent_id, is_deprecated, search, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE a.tenant_id = $1 AND a.deleted_at IS NULL';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND a.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account_type_id) {
|
||||||
|
whereClause += ` AND a.account_type_id = $${paramIndex++}`;
|
||||||
|
params.push(account_type_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent_id !== undefined) {
|
||||||
|
if (parent_id === null || parent_id === 'null') {
|
||||||
|
whereClause += ' AND a.parent_id IS NULL';
|
||||||
|
} else {
|
||||||
|
whereClause += ` AND a.parent_id = $${paramIndex++}`;
|
||||||
|
params.push(parent_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_deprecated !== undefined) {
|
||||||
|
whereClause += ` AND a.is_deprecated = $${paramIndex++}`;
|
||||||
|
params.push(is_deprecated);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (a.code ILIKE $${paramIndex} OR a.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.accounts a ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Account>(
|
||||||
|
`SELECT a.*,
|
||||||
|
at.name as account_type_name,
|
||||||
|
at.code as account_type_code,
|
||||||
|
ap.name as parent_name,
|
||||||
|
cur.code as currency_code
|
||||||
|
FROM financial.accounts a
|
||||||
|
LEFT JOIN financial.account_types at ON a.account_type_id = at.id
|
||||||
|
LEFT JOIN financial.accounts ap ON a.parent_id = ap.id
|
||||||
|
LEFT JOIN core.currencies cur ON a.currency_id = cur.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a.code
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Account> {
|
||||||
|
const account = await queryOne<Account>(
|
||||||
|
`SELECT a.*,
|
||||||
|
at.name as account_type_name,
|
||||||
|
at.code as account_type_code,
|
||||||
|
ap.name as parent_name,
|
||||||
|
cur.code as currency_code
|
||||||
|
FROM financial.accounts a
|
||||||
|
LEFT JOIN financial.account_types at ON a.account_type_id = at.id
|
||||||
|
LEFT JOIN financial.accounts ap ON a.parent_id = ap.id
|
||||||
|
LEFT JOIN core.currencies cur ON a.currency_id = cur.id
|
||||||
|
WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
throw new NotFoundError('Cuenta no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateAccountDto, tenantId: string, userId: string): Promise<Account> {
|
||||||
|
// Validate unique code within company
|
||||||
|
const existing = await queryOne<Account>(
|
||||||
|
`SELECT id FROM financial.accounts WHERE company_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.company_id, dto.code]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe una cuenta con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate account type exists
|
||||||
|
await this.findAccountTypeById(dto.account_type_id);
|
||||||
|
|
||||||
|
// Validate parent account if specified
|
||||||
|
if (dto.parent_id) {
|
||||||
|
const parent = await queryOne<Account>(
|
||||||
|
`SELECT id FROM financial.accounts WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.parent_id, dto.company_id]
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Cuenta padre no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await queryOne<Account>(
|
||||||
|
`INSERT INTO financial.accounts (tenant_id, company_id, code, name, account_type_id, parent_id, currency_id, is_reconcilable, notes, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.company_id,
|
||||||
|
dto.code,
|
||||||
|
dto.name,
|
||||||
|
dto.account_type_id,
|
||||||
|
dto.parent_id,
|
||||||
|
dto.currency_id,
|
||||||
|
dto.is_reconcilable || false,
|
||||||
|
dto.notes,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return account!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateAccountDto, tenantId: string, userId: string): Promise<Account> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Validate parent (prevent self-reference)
|
||||||
|
if (dto.parent_id) {
|
||||||
|
if (dto.parent_id === id) {
|
||||||
|
throw new ConflictError('Una cuenta no puede ser su propia cuenta padre');
|
||||||
|
}
|
||||||
|
const parent = await queryOne<Account>(
|
||||||
|
`SELECT id FROM financial.accounts WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.parent_id, existing.company_id]
|
||||||
|
);
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Cuenta padre no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.parent_id !== undefined) {
|
||||||
|
updateFields.push(`parent_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.parent_id);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.is_reconcilable !== undefined) {
|
||||||
|
updateFields.push(`is_reconcilable = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_reconcilable);
|
||||||
|
}
|
||||||
|
if (dto.is_deprecated !== undefined) {
|
||||||
|
updateFields.push(`is_deprecated = $${paramIndex++}`);
|
||||||
|
values.push(dto.is_deprecated);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
const account = await queryOne<Account>(
|
||||||
|
`UPDATE financial.accounts
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return account!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if account has children
|
||||||
|
const children = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.accounts WHERE parent_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (parseInt(children?.count || '0', 10) > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una cuenta que tiene subcuentas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account has journal entry lines
|
||||||
|
const entries = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.journal_entry_lines WHERE account_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (parseInt(entries?.count || '0', 10) > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar una cuenta que tiene movimientos contables');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.accounts SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1 WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(accountId: string, tenantId: string): Promise<{ debit: number; credit: number; balance: number }> {
|
||||||
|
await this.findById(accountId, tenantId);
|
||||||
|
|
||||||
|
const result = await queryOne<{ total_debit: string; total_credit: string }>(
|
||||||
|
`SELECT COALESCE(SUM(jel.debit), 0) as total_debit,
|
||||||
|
COALESCE(SUM(jel.credit), 0) as total_credit
|
||||||
|
FROM financial.journal_entry_lines jel
|
||||||
|
INNER JOIN financial.journal_entries je ON jel.entry_id = je.id
|
||||||
|
WHERE jel.account_id = $1 AND je.status = 'posted'`,
|
||||||
|
[accountId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const debit = parseFloat(result?.total_debit || '0');
|
||||||
|
const credit = parseFloat(result?.total_credit || '0');
|
||||||
|
|
||||||
|
return {
|
||||||
|
debit,
|
||||||
|
credit,
|
||||||
|
balance: debit - credit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accountsService = new AccountsService();
|
||||||
468
src/modules/financial/accounts.service.ts
Normal file
468
src/modules/financial/accounts.service.ts
Normal file
@ -0,0 +1,468 @@
|
|||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
|
import { AppDataSource } from '../../config/typeorm.js';
|
||||||
|
import { Account, AccountType } from './entities/index.js';
|
||||||
|
import { NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ===== Interfaces =====
|
||||||
|
|
||||||
|
export interface CreateAccountDto {
|
||||||
|
companyId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
accountTypeId: string;
|
||||||
|
parentId?: string;
|
||||||
|
currencyId?: string;
|
||||||
|
isReconcilable?: boolean;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateAccountDto {
|
||||||
|
name?: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
currencyId?: string | null;
|
||||||
|
isReconcilable?: boolean;
|
||||||
|
isDeprecated?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountFilters {
|
||||||
|
companyId?: string;
|
||||||
|
accountTypeId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
isDeprecated?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountWithRelations extends Account {
|
||||||
|
accountTypeName?: string;
|
||||||
|
accountTypeCode?: string;
|
||||||
|
parentName?: string;
|
||||||
|
currencyCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== AccountsService Class =====
|
||||||
|
|
||||||
|
class AccountsService {
|
||||||
|
private accountRepository: Repository<Account>;
|
||||||
|
private accountTypeRepository: Repository<AccountType>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.accountRepository = AppDataSource.getRepository(Account);
|
||||||
|
this.accountTypeRepository = AppDataSource.getRepository(AccountType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all account types (catalog)
|
||||||
|
*/
|
||||||
|
async findAllAccountTypes(): Promise<AccountType[]> {
|
||||||
|
return this.accountTypeRepository.find({
|
||||||
|
order: { code: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account type by ID
|
||||||
|
*/
|
||||||
|
async findAccountTypeById(id: string): Promise<AccountType> {
|
||||||
|
const accountType = await this.accountTypeRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accountType) {
|
||||||
|
throw new NotFoundError('Tipo de cuenta no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all accounts with filters and pagination
|
||||||
|
*/
|
||||||
|
async findAll(
|
||||||
|
tenantId: string,
|
||||||
|
filters: AccountFilters = {}
|
||||||
|
): Promise<{ data: AccountWithRelations[]; total: number }> {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
companyId,
|
||||||
|
accountTypeId,
|
||||||
|
parentId,
|
||||||
|
isDeprecated,
|
||||||
|
search,
|
||||||
|
page = 1,
|
||||||
|
limit = 50
|
||||||
|
} = filters;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const queryBuilder = this.accountRepository
|
||||||
|
.createQueryBuilder('account')
|
||||||
|
.leftJoin('account.accountType', 'accountType')
|
||||||
|
.addSelect(['accountType.name', 'accountType.code'])
|
||||||
|
.leftJoin('account.parent', 'parent')
|
||||||
|
.addSelect(['parent.name'])
|
||||||
|
.where('account.tenantId = :tenantId', { tenantId })
|
||||||
|
.andWhere('account.deletedAt IS NULL');
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if (companyId) {
|
||||||
|
queryBuilder.andWhere('account.companyId = :companyId', { companyId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountTypeId) {
|
||||||
|
queryBuilder.andWhere('account.accountTypeId = :accountTypeId', { accountTypeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId !== undefined) {
|
||||||
|
if (parentId === null || parentId === 'null') {
|
||||||
|
queryBuilder.andWhere('account.parentId IS NULL');
|
||||||
|
} else {
|
||||||
|
queryBuilder.andWhere('account.parentId = :parentId', { parentId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDeprecated !== undefined) {
|
||||||
|
queryBuilder.andWhere('account.isDeprecated = :isDeprecated', { isDeprecated });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
queryBuilder.andWhere(
|
||||||
|
'(account.code ILIKE :search OR account.name ILIKE :search)',
|
||||||
|
{ search: `%${search}%` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await queryBuilder.getCount();
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
const accounts = await queryBuilder
|
||||||
|
.orderBy('account.code', 'ASC')
|
||||||
|
.skip(skip)
|
||||||
|
.take(limit)
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Map to include relation names
|
||||||
|
const data: AccountWithRelations[] = accounts.map(account => ({
|
||||||
|
...account,
|
||||||
|
accountTypeName: account.accountType?.name,
|
||||||
|
accountTypeCode: account.accountType?.code,
|
||||||
|
parentName: account.parent?.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.debug('Accounts retrieved', { tenantId, count: data.length, total });
|
||||||
|
|
||||||
|
return { data, total };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error retrieving accounts', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string, tenantId: string): Promise<AccountWithRelations> {
|
||||||
|
try {
|
||||||
|
const account = await this.accountRepository
|
||||||
|
.createQueryBuilder('account')
|
||||||
|
.leftJoin('account.accountType', 'accountType')
|
||||||
|
.addSelect(['accountType.name', 'accountType.code'])
|
||||||
|
.leftJoin('account.parent', 'parent')
|
||||||
|
.addSelect(['parent.name'])
|
||||||
|
.where('account.id = :id', { id })
|
||||||
|
.andWhere('account.tenantId = :tenantId', { tenantId })
|
||||||
|
.andWhere('account.deletedAt IS NULL')
|
||||||
|
.getOne();
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
throw new NotFoundError('Cuenta no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...account,
|
||||||
|
accountTypeName: account.accountType?.name,
|
||||||
|
accountTypeCode: account.accountType?.code,
|
||||||
|
parentName: account.parent?.name,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error finding account', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new account
|
||||||
|
*/
|
||||||
|
async create(
|
||||||
|
dto: CreateAccountDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<Account> {
|
||||||
|
try {
|
||||||
|
// Validate unique code within company
|
||||||
|
const existing = await this.accountRepository.findOne({
|
||||||
|
where: {
|
||||||
|
companyId: dto.companyId,
|
||||||
|
code: dto.code,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ValidationError(`Ya existe una cuenta con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate account type exists
|
||||||
|
await this.findAccountTypeById(dto.accountTypeId);
|
||||||
|
|
||||||
|
// Validate parent account if specified
|
||||||
|
if (dto.parentId) {
|
||||||
|
const parent = await this.accountRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: dto.parentId,
|
||||||
|
companyId: dto.companyId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Cuenta padre no encontrada');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create account
|
||||||
|
const account = this.accountRepository.create({
|
||||||
|
tenantId,
|
||||||
|
companyId: dto.companyId,
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name,
|
||||||
|
accountTypeId: dto.accountTypeId,
|
||||||
|
parentId: dto.parentId || null,
|
||||||
|
currencyId: dto.currencyId || null,
|
||||||
|
isReconcilable: dto.isReconcilable || false,
|
||||||
|
notes: dto.notes || null,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.accountRepository.save(account);
|
||||||
|
|
||||||
|
logger.info('Account created', {
|
||||||
|
accountId: account.id,
|
||||||
|
tenantId,
|
||||||
|
code: account.code,
|
||||||
|
createdBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return account;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating account', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
tenantId,
|
||||||
|
dto,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an account
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateAccountDto,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<Account> {
|
||||||
|
try {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Validate parent (prevent self-reference and cycles)
|
||||||
|
if (dto.parentId !== undefined && dto.parentId) {
|
||||||
|
if (dto.parentId === id) {
|
||||||
|
throw new ValidationError('Una cuenta no puede ser su propia cuenta padre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = await this.accountRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: dto.parentId,
|
||||||
|
companyId: existing.companyId,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!parent) {
|
||||||
|
throw new NotFoundError('Cuenta padre no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for circular reference
|
||||||
|
if (await this.wouldCreateCycle(id, dto.parentId, tenantId)) {
|
||||||
|
throw new ValidationError('La asignación crearía una referencia circular');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update allowed fields
|
||||||
|
if (dto.name !== undefined) existing.name = dto.name;
|
||||||
|
if (dto.parentId !== undefined) existing.parentId = dto.parentId;
|
||||||
|
if (dto.currencyId !== undefined) existing.currencyId = dto.currencyId;
|
||||||
|
if (dto.isReconcilable !== undefined) existing.isReconcilable = dto.isReconcilable;
|
||||||
|
if (dto.isDeprecated !== undefined) existing.isDeprecated = dto.isDeprecated;
|
||||||
|
if (dto.notes !== undefined) existing.notes = dto.notes;
|
||||||
|
|
||||||
|
existing.updatedBy = userId;
|
||||||
|
existing.updatedAt = new Date();
|
||||||
|
|
||||||
|
await this.accountRepository.save(existing);
|
||||||
|
|
||||||
|
logger.info('Account updated', {
|
||||||
|
accountId: id,
|
||||||
|
tenantId,
|
||||||
|
updatedBy: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating account', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete an account
|
||||||
|
*/
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if account has children
|
||||||
|
const childrenCount = await this.accountRepository.count({
|
||||||
|
where: {
|
||||||
|
parentId: id,
|
||||||
|
deletedAt: IsNull(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (childrenCount > 0) {
|
||||||
|
throw new ForbiddenError('No se puede eliminar una cuenta que tiene subcuentas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account has journal entry lines (use raw query for this check)
|
||||||
|
const entryLinesCheck = await this.accountRepository.query(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.journal_entry_lines WHERE account_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(entryLinesCheck[0]?.count || '0', 10) > 0) {
|
||||||
|
throw new ForbiddenError('No se puede eliminar una cuenta que tiene movimientos contables');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await this.accountRepository.update(
|
||||||
|
{ id, tenantId },
|
||||||
|
{
|
||||||
|
deletedAt: new Date(),
|
||||||
|
deletedBy: userId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Account deleted', {
|
||||||
|
accountId: id,
|
||||||
|
tenantId,
|
||||||
|
deletedBy: userId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting account', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
id,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account balance
|
||||||
|
*/
|
||||||
|
async getBalance(
|
||||||
|
accountId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<{ debit: number; credit: number; balance: number }> {
|
||||||
|
try {
|
||||||
|
await this.findById(accountId, tenantId);
|
||||||
|
|
||||||
|
const result = await this.accountRepository.query(
|
||||||
|
`SELECT COALESCE(SUM(jel.debit), 0) as total_debit,
|
||||||
|
COALESCE(SUM(jel.credit), 0) as total_credit
|
||||||
|
FROM financial.journal_entry_lines jel
|
||||||
|
INNER JOIN financial.journal_entries je ON jel.entry_id = je.id
|
||||||
|
WHERE jel.account_id = $1 AND je.status = 'posted'`,
|
||||||
|
[accountId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const debit = parseFloat(result[0]?.total_debit || '0');
|
||||||
|
const credit = parseFloat(result[0]?.total_credit || '0');
|
||||||
|
|
||||||
|
return {
|
||||||
|
debit,
|
||||||
|
credit,
|
||||||
|
balance: debit - credit,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting account balance', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
accountId,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if assigning a parent would create a circular reference
|
||||||
|
*/
|
||||||
|
private async wouldCreateCycle(
|
||||||
|
accountId: string,
|
||||||
|
newParentId: string,
|
||||||
|
tenantId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
let currentId: string | null = newParentId;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
while (currentId) {
|
||||||
|
if (visited.has(currentId)) {
|
||||||
|
return true; // Found a cycle
|
||||||
|
}
|
||||||
|
if (currentId === accountId) {
|
||||||
|
return true; // Would create a cycle
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(currentId);
|
||||||
|
|
||||||
|
const parent = await this.accountRepository.findOne({
|
||||||
|
where: { id: currentId, tenantId, deletedAt: IsNull() },
|
||||||
|
select: ['parentId'],
|
||||||
|
});
|
||||||
|
|
||||||
|
currentId = parent?.parentId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Export Singleton Instance =====
|
||||||
|
|
||||||
|
export const accountsService = new AccountsService();
|
||||||
38
src/modules/financial/entities/account-type.entity.ts
Normal file
38
src/modules/financial/entities/account-type.entity.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
export enum AccountTypeEnum {
|
||||||
|
ASSET = 'asset',
|
||||||
|
LIABILITY = 'liability',
|
||||||
|
EQUITY = 'equity',
|
||||||
|
INCOME = 'income',
|
||||||
|
EXPENSE = 'expense',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'financial', name: 'account_types' })
|
||||||
|
@Index('idx_account_types_code', ['code'], { unique: true })
|
||||||
|
export class AccountType {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: false, unique: true })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: AccountTypeEnum,
|
||||||
|
nullable: false,
|
||||||
|
name: 'account_type',
|
||||||
|
})
|
||||||
|
accountType: AccountTypeEnum;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
93
src/modules/financial/entities/account.entity.ts
Normal file
93
src/modules/financial/entities/account.entity.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AccountType } from './account-type.entity.js';
|
||||||
|
import { Company } from '../../auth/entities/company.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'financial', name: 'accounts' })
|
||||||
|
@Index('idx_accounts_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_accounts_company_id', ['companyId'])
|
||||||
|
@Index('idx_accounts_code', ['companyId', 'code'], { unique: true, where: 'deleted_at IS NULL' })
|
||||||
|
@Index('idx_accounts_parent_id', ['parentId'])
|
||||||
|
@Index('idx_accounts_account_type_id', ['accountTypeId'])
|
||||||
|
export class Account {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
|
||||||
|
companyId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 50, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'account_type_id' })
|
||||||
|
accountTypeId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'parent_id' })
|
||||||
|
parentId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'currency_id' })
|
||||||
|
currencyId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_reconcilable' })
|
||||||
|
isReconcilable: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false, nullable: false, name: 'is_deprecated' })
|
||||||
|
isDeprecated: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company: Company;
|
||||||
|
|
||||||
|
@ManyToOne(() => AccountType)
|
||||||
|
@JoinColumn({ name: 'account_type_id' })
|
||||||
|
accountType: AccountType;
|
||||||
|
|
||||||
|
@ManyToOne(() => Account, (account) => account.children)
|
||||||
|
@JoinColumn({ name: 'parent_id' })
|
||||||
|
parent: Account | null;
|
||||||
|
|
||||||
|
@OneToMany(() => Account, (account) => account.parent)
|
||||||
|
children: Account[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
64
src/modules/financial/entities/fiscal-period.entity.ts
Normal file
64
src/modules/financial/entities/fiscal-period.entity.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { FiscalYear, FiscalPeriodStatus } from './fiscal-year.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'financial', name: 'fiscal_periods' })
|
||||||
|
@Index('idx_fiscal_periods_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_fiscal_periods_fiscal_year_id', ['fiscalYearId'])
|
||||||
|
@Index('idx_fiscal_periods_dates', ['dateFrom', 'dateTo'])
|
||||||
|
@Index('idx_fiscal_periods_status', ['status'])
|
||||||
|
export class FiscalPeriod {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'fiscal_year_id' })
|
||||||
|
fiscalYearId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: false, name: 'date_from' })
|
||||||
|
dateFrom: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: false, name: 'date_to' })
|
||||||
|
dateTo: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: FiscalPeriodStatus,
|
||||||
|
default: FiscalPeriodStatus.OPEN,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
status: FiscalPeriodStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'closed_at' })
|
||||||
|
closedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'closed_by' })
|
||||||
|
closedBy: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => FiscalYear, (year) => year.periods)
|
||||||
|
@JoinColumn({ name: 'fiscal_year_id' })
|
||||||
|
fiscalYear: FiscalYear;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
}
|
||||||
67
src/modules/financial/entities/fiscal-year.entity.ts
Normal file
67
src/modules/financial/entities/fiscal-year.entity.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Company } from '../../auth/entities/company.entity.js';
|
||||||
|
import { FiscalPeriod } from './fiscal-period.entity.js';
|
||||||
|
|
||||||
|
export enum FiscalPeriodStatus {
|
||||||
|
OPEN = 'open',
|
||||||
|
CLOSED = 'closed',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'financial', name: 'fiscal_years' })
|
||||||
|
@Index('idx_fiscal_years_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_fiscal_years_company_id', ['companyId'])
|
||||||
|
@Index('idx_fiscal_years_dates', ['dateFrom', 'dateTo'])
|
||||||
|
export class FiscalYear {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
|
||||||
|
companyId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: false, name: 'date_from' })
|
||||||
|
dateFrom: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: false, name: 'date_to' })
|
||||||
|
dateTo: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: FiscalPeriodStatus,
|
||||||
|
default: FiscalPeriodStatus.OPEN,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
status: FiscalPeriodStatus;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company: Company;
|
||||||
|
|
||||||
|
@OneToMany(() => FiscalPeriod, (period) => period.fiscalYear)
|
||||||
|
periods: FiscalPeriod[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
}
|
||||||
22
src/modules/financial/entities/index.ts
Normal file
22
src/modules/financial/entities/index.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
// Account entities
|
||||||
|
export { AccountType, AccountTypeEnum } from './account-type.entity.js';
|
||||||
|
export { Account } from './account.entity.js';
|
||||||
|
|
||||||
|
// Journal entities
|
||||||
|
export { Journal, JournalType } from './journal.entity.js';
|
||||||
|
export { JournalEntry, EntryStatus } from './journal-entry.entity.js';
|
||||||
|
export { JournalEntryLine } from './journal-entry-line.entity.js';
|
||||||
|
|
||||||
|
// Invoice entities
|
||||||
|
export { Invoice, InvoiceType, InvoiceStatus } from './invoice.entity.js';
|
||||||
|
export { InvoiceLine } from './invoice-line.entity.js';
|
||||||
|
|
||||||
|
// Payment entities
|
||||||
|
export { Payment, PaymentType, PaymentMethod, PaymentStatus } from './payment.entity.js';
|
||||||
|
|
||||||
|
// Tax entities
|
||||||
|
export { Tax, TaxType } from './tax.entity.js';
|
||||||
|
|
||||||
|
// Fiscal period entities
|
||||||
|
export { FiscalYear, FiscalPeriodStatus } from './fiscal-year.entity.js';
|
||||||
|
export { FiscalPeriod } from './fiscal-period.entity.js';
|
||||||
79
src/modules/financial/entities/invoice-line.entity.ts
Normal file
79
src/modules/financial/entities/invoice-line.entity.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Invoice } from './invoice.entity.js';
|
||||||
|
import { Account } from './account.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'financial', name: 'invoice_lines' })
|
||||||
|
@Index('idx_invoice_lines_invoice_id', ['invoiceId'])
|
||||||
|
@Index('idx_invoice_lines_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_invoice_lines_product_id', ['productId'])
|
||||||
|
export class InvoiceLine {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'invoice_id' })
|
||||||
|
invoiceId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'product_id' })
|
||||||
|
productId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: false })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 4, nullable: false })
|
||||||
|
quantity: number;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'uom_id' })
|
||||||
|
uomId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: false, name: 'price_unit' })
|
||||||
|
priceUnit: number;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', array: true, default: '{}', name: 'tax_ids' })
|
||||||
|
taxIds: string[];
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_untaxed' })
|
||||||
|
amountUntaxed: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_tax' })
|
||||||
|
amountTax: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_total' })
|
||||||
|
amountTotal: number;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'account_id' })
|
||||||
|
accountId: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Invoice, (invoice) => invoice.lines, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'invoice_id' })
|
||||||
|
invoice: Invoice;
|
||||||
|
|
||||||
|
@ManyToOne(() => Account)
|
||||||
|
@JoinColumn({ name: 'account_id' })
|
||||||
|
account: Account | null;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
}
|
||||||
152
src/modules/financial/entities/invoice.entity.ts
Normal file
152
src/modules/financial/entities/invoice.entity.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Company } from '../../auth/entities/company.entity.js';
|
||||||
|
import { Journal } from './journal.entity.js';
|
||||||
|
import { JournalEntry } from './journal-entry.entity.js';
|
||||||
|
import { InvoiceLine } from './invoice-line.entity.js';
|
||||||
|
|
||||||
|
export enum InvoiceType {
|
||||||
|
CUSTOMER = 'customer',
|
||||||
|
SUPPLIER = 'supplier',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum InvoiceStatus {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
OPEN = 'open',
|
||||||
|
PAID = 'paid',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'financial', name: 'invoices' })
|
||||||
|
@Index('idx_invoices_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_invoices_company_id', ['companyId'])
|
||||||
|
@Index('idx_invoices_partner_id', ['partnerId'])
|
||||||
|
@Index('idx_invoices_number', ['number'])
|
||||||
|
@Index('idx_invoices_date', ['invoiceDate'])
|
||||||
|
@Index('idx_invoices_status', ['status'])
|
||||||
|
@Index('idx_invoices_type', ['invoiceType'])
|
||||||
|
export class Invoice {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
|
||||||
|
companyId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'partner_id' })
|
||||||
|
partnerId: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: InvoiceType,
|
||||||
|
nullable: false,
|
||||||
|
name: 'invoice_type',
|
||||||
|
})
|
||||||
|
invoiceType: InvoiceType;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
number: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
ref: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: false, name: 'invoice_date' })
|
||||||
|
invoiceDate: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: true, name: 'due_date' })
|
||||||
|
dueDate: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'currency_id' })
|
||||||
|
currencyId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_untaxed' })
|
||||||
|
amountUntaxed: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_tax' })
|
||||||
|
amountTax: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_total' })
|
||||||
|
amountTotal: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_paid' })
|
||||||
|
amountPaid: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_residual' })
|
||||||
|
amountResidual: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: InvoiceStatus,
|
||||||
|
default: InvoiceStatus.DRAFT,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
status: InvoiceStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'payment_term_id' })
|
||||||
|
paymentTermId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'journal_id' })
|
||||||
|
journalId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'journal_entry_id' })
|
||||||
|
journalEntryId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company: Company;
|
||||||
|
|
||||||
|
@ManyToOne(() => Journal)
|
||||||
|
@JoinColumn({ name: 'journal_id' })
|
||||||
|
journal: Journal | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => JournalEntry)
|
||||||
|
@JoinColumn({ name: 'journal_entry_id' })
|
||||||
|
journalEntry: JournalEntry | null;
|
||||||
|
|
||||||
|
@OneToMany(() => InvoiceLine, (line) => line.invoice, { cascade: true })
|
||||||
|
lines: InvoiceLine[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'validated_at' })
|
||||||
|
validatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'validated_by' })
|
||||||
|
validatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'cancelled_at' })
|
||||||
|
cancelledAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'cancelled_by' })
|
||||||
|
cancelledBy: string | null;
|
||||||
|
}
|
||||||
59
src/modules/financial/entities/journal-entry-line.entity.ts
Normal file
59
src/modules/financial/entities/journal-entry-line.entity.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { JournalEntry } from './journal-entry.entity.js';
|
||||||
|
import { Account } from './account.entity.js';
|
||||||
|
|
||||||
|
@Entity({ schema: 'financial', name: 'journal_entry_lines' })
|
||||||
|
@Index('idx_journal_entry_lines_entry_id', ['entryId'])
|
||||||
|
@Index('idx_journal_entry_lines_account_id', ['accountId'])
|
||||||
|
@Index('idx_journal_entry_lines_tenant_id', ['tenantId'])
|
||||||
|
export class JournalEntryLine {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'entry_id' })
|
||||||
|
entryId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'account_id' })
|
||||||
|
accountId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'partner_id' })
|
||||||
|
partnerId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false })
|
||||||
|
debit: number;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false })
|
||||||
|
credit: number;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
ref: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => JournalEntry, (entry) => entry.lines, {
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
@JoinColumn({ name: 'entry_id' })
|
||||||
|
entry: JournalEntry;
|
||||||
|
|
||||||
|
@ManyToOne(() => Account)
|
||||||
|
@JoinColumn({ name: 'account_id' })
|
||||||
|
account: Account;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
104
src/modules/financial/entities/journal-entry.entity.ts
Normal file
104
src/modules/financial/entities/journal-entry.entity.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Company } from '../../auth/entities/company.entity.js';
|
||||||
|
import { Journal } from './journal.entity.js';
|
||||||
|
import { JournalEntryLine } from './journal-entry-line.entity.js';
|
||||||
|
|
||||||
|
export enum EntryStatus {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
POSTED = 'posted',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'financial', name: 'journal_entries' })
|
||||||
|
@Index('idx_journal_entries_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_journal_entries_company_id', ['companyId'])
|
||||||
|
@Index('idx_journal_entries_journal_id', ['journalId'])
|
||||||
|
@Index('idx_journal_entries_date', ['date'])
|
||||||
|
@Index('idx_journal_entries_status', ['status'])
|
||||||
|
export class JournalEntry {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
|
||||||
|
companyId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'journal_id' })
|
||||||
|
journalId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
ref: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: false })
|
||||||
|
date: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: EntryStatus,
|
||||||
|
default: EntryStatus.DRAFT,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
status: EntryStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'fiscal_period_id' })
|
||||||
|
fiscalPeriodId: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company: Company;
|
||||||
|
|
||||||
|
@ManyToOne(() => Journal)
|
||||||
|
@JoinColumn({ name: 'journal_id' })
|
||||||
|
journal: Journal;
|
||||||
|
|
||||||
|
@OneToMany(() => JournalEntryLine, (line) => line.entry, { cascade: true })
|
||||||
|
lines: JournalEntryLine[];
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'posted_at' })
|
||||||
|
postedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'posted_by' })
|
||||||
|
postedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'cancelled_at' })
|
||||||
|
cancelledAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'cancelled_by' })
|
||||||
|
cancelledBy: string | null;
|
||||||
|
}
|
||||||
94
src/modules/financial/entities/journal.entity.ts
Normal file
94
src/modules/financial/entities/journal.entity.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Company } from '../../auth/entities/company.entity.js';
|
||||||
|
import { Account } from './account.entity.js';
|
||||||
|
|
||||||
|
export enum JournalType {
|
||||||
|
SALE = 'sale',
|
||||||
|
PURCHASE = 'purchase',
|
||||||
|
CASH = 'cash',
|
||||||
|
BANK = 'bank',
|
||||||
|
GENERAL = 'general',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'financial', name: 'journals' })
|
||||||
|
@Index('idx_journals_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_journals_company_id', ['companyId'])
|
||||||
|
@Index('idx_journals_code', ['companyId', 'code'], { unique: true, where: 'deleted_at IS NULL' })
|
||||||
|
@Index('idx_journals_type', ['journalType'])
|
||||||
|
export class Journal {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
|
||||||
|
companyId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: JournalType,
|
||||||
|
nullable: false,
|
||||||
|
name: 'journal_type',
|
||||||
|
})
|
||||||
|
journalType: JournalType;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'default_account_id' })
|
||||||
|
defaultAccountId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'sequence_id' })
|
||||||
|
sequenceId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'currency_id' })
|
||||||
|
currencyId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true, nullable: false })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company: Company;
|
||||||
|
|
||||||
|
@ManyToOne(() => Account)
|
||||||
|
@JoinColumn({ name: 'default_account_id' })
|
||||||
|
defaultAccount: Account | null;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'deleted_at' })
|
||||||
|
deletedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'deleted_by' })
|
||||||
|
deletedBy: string | null;
|
||||||
|
}
|
||||||
135
src/modules/financial/entities/payment.entity.ts
Normal file
135
src/modules/financial/entities/payment.entity.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Company } from '../../auth/entities/company.entity.js';
|
||||||
|
import { Journal } from './journal.entity.js';
|
||||||
|
import { JournalEntry } from './journal-entry.entity.js';
|
||||||
|
|
||||||
|
export enum PaymentType {
|
||||||
|
INBOUND = 'inbound',
|
||||||
|
OUTBOUND = 'outbound',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PaymentMethod {
|
||||||
|
CASH = 'cash',
|
||||||
|
BANK_TRANSFER = 'bank_transfer',
|
||||||
|
CHECK = 'check',
|
||||||
|
CARD = 'card',
|
||||||
|
OTHER = 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PaymentStatus {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
POSTED = 'posted',
|
||||||
|
RECONCILED = 'reconciled',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'financial', name: 'payments' })
|
||||||
|
@Index('idx_payments_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_payments_company_id', ['companyId'])
|
||||||
|
@Index('idx_payments_partner_id', ['partnerId'])
|
||||||
|
@Index('idx_payments_date', ['paymentDate'])
|
||||||
|
@Index('idx_payments_status', ['status'])
|
||||||
|
@Index('idx_payments_type', ['paymentType'])
|
||||||
|
export class Payment {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
|
||||||
|
companyId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'partner_id' })
|
||||||
|
partnerId: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: PaymentType,
|
||||||
|
nullable: false,
|
||||||
|
name: 'payment_type',
|
||||||
|
})
|
||||||
|
paymentType: PaymentType;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: PaymentMethod,
|
||||||
|
nullable: false,
|
||||||
|
name: 'payment_method',
|
||||||
|
})
|
||||||
|
paymentMethod: PaymentMethod;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 15, scale: 2, nullable: false })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'currency_id' })
|
||||||
|
currencyId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'date', nullable: false, name: 'payment_date' })
|
||||||
|
paymentDate: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||||
|
ref: string | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: PaymentStatus,
|
||||||
|
default: PaymentStatus.DRAFT,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
status: PaymentStatus;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'journal_id' })
|
||||||
|
journalId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'journal_entry_id' })
|
||||||
|
journalEntryId: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
notes: string | null;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company: Company;
|
||||||
|
|
||||||
|
@ManyToOne(() => Journal)
|
||||||
|
@JoinColumn({ name: 'journal_id' })
|
||||||
|
journal: Journal;
|
||||||
|
|
||||||
|
@ManyToOne(() => JournalEntry)
|
||||||
|
@JoinColumn({ name: 'journal_entry_id' })
|
||||||
|
journalEntry: JournalEntry | null;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamp', nullable: true, name: 'posted_at' })
|
||||||
|
postedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'posted_by' })
|
||||||
|
postedBy: string | null;
|
||||||
|
}
|
||||||
78
src/modules/financial/entities/tax.entity.ts
Normal file
78
src/modules/financial/entities/tax.entity.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
Index,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Company } from '../../auth/entities/company.entity.js';
|
||||||
|
|
||||||
|
export enum TaxType {
|
||||||
|
SALES = 'sales',
|
||||||
|
PURCHASE = 'purchase',
|
||||||
|
ALL = 'all',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'financial', name: 'taxes' })
|
||||||
|
@Index('idx_taxes_tenant_id', ['tenantId'])
|
||||||
|
@Index('idx_taxes_company_id', ['companyId'])
|
||||||
|
@Index('idx_taxes_code', ['tenantId', 'code'], { unique: true })
|
||||||
|
@Index('idx_taxes_type', ['taxType'])
|
||||||
|
export class Tax {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'tenant_id' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: false, name: 'company_id' })
|
||||||
|
companyId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: false })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, nullable: false })
|
||||||
|
code: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'enum',
|
||||||
|
enum: TaxType,
|
||||||
|
nullable: false,
|
||||||
|
name: 'tax_type',
|
||||||
|
})
|
||||||
|
taxType: TaxType;
|
||||||
|
|
||||||
|
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: false })
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false, nullable: false, name: 'included_in_price' })
|
||||||
|
includedInPrice: boolean;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: true, nullable: false })
|
||||||
|
active: boolean;
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company: Company;
|
||||||
|
|
||||||
|
// Audit fields
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'created_by' })
|
||||||
|
createdBy: string | null;
|
||||||
|
|
||||||
|
@UpdateDateColumn({
|
||||||
|
name: 'updated_at',
|
||||||
|
type: 'timestamp',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
updatedAt: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'uuid', nullable: true, name: 'updated_by' })
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
753
src/modules/financial/financial.controller.ts
Normal file
753
src/modules/financial/financial.controller.ts
Normal file
@ -0,0 +1,753 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { accountsService, CreateAccountDto, UpdateAccountDto, AccountFilters } from './accounts.service.js';
|
||||||
|
import { journalsService, CreateJournalDto, UpdateJournalDto, JournalFilters } from './journals.service.js';
|
||||||
|
import { journalEntriesService, CreateJournalEntryDto, UpdateJournalEntryDto, JournalEntryFilters } from './journal-entries.service.js';
|
||||||
|
import { invoicesService, CreateInvoiceDto, UpdateInvoiceDto, CreateInvoiceLineDto, UpdateInvoiceLineDto, InvoiceFilters } from './invoices.service.js';
|
||||||
|
import { paymentsService, CreatePaymentDto, UpdatePaymentDto, ReconcileDto, PaymentFilters } from './payments.service.js';
|
||||||
|
import { taxesService, CreateTaxDto, UpdateTaxDto, TaxFilters } from './taxes.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Schemas
|
||||||
|
const createAccountSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
code: z.string().min(1).max(50),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
account_type_id: z.string().uuid(),
|
||||||
|
parent_id: z.string().uuid().optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
is_reconcilable: z.boolean().default(false),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAccountSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
parent_id: z.string().uuid().optional().nullable(),
|
||||||
|
currency_id: z.string().uuid().optional().nullable(),
|
||||||
|
is_reconcilable: z.boolean().optional(),
|
||||||
|
is_deprecated: z.boolean().optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const accountQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
account_type_id: z.string().uuid().optional(),
|
||||||
|
parent_id: z.string().optional(),
|
||||||
|
is_deprecated: z.coerce.boolean().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createJournalSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
code: z.string().min(1).max(20),
|
||||||
|
journal_type: z.enum(['sale', 'purchase', 'cash', 'bank', 'general']),
|
||||||
|
default_account_id: z.string().uuid().optional(),
|
||||||
|
sequence_id: z.string().uuid().optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateJournalSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
default_account_id: z.string().uuid().optional().nullable(),
|
||||||
|
sequence_id: z.string().uuid().optional().nullable(),
|
||||||
|
currency_id: z.string().uuid().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const journalQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
journal_type: z.enum(['sale', 'purchase', 'cash', 'bank', 'general']).optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
const journalEntryLineSchema = z.object({
|
||||||
|
account_id: z.string().uuid(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
debit: z.number().min(0).default(0),
|
||||||
|
credit: z.number().min(0).default(0),
|
||||||
|
description: z.string().optional(),
|
||||||
|
ref: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createJournalEntrySchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
journal_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
ref: z.string().max(255).optional(),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
lines: z.array(journalEntryLineSchema).min(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateJournalEntrySchema = z.object({
|
||||||
|
ref: z.string().max(255).optional().nullable(),
|
||||||
|
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
lines: z.array(journalEntryLineSchema).min(2).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const journalEntryQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
journal_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'posted', 'cancelled']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== INVOICE SCHEMAS ==========
|
||||||
|
const createInvoiceSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
partner_id: z.string().uuid(),
|
||||||
|
invoice_type: z.enum(['customer', 'supplier']),
|
||||||
|
currency_id: z.string().uuid(),
|
||||||
|
invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
payment_term_id: z.string().uuid().optional(),
|
||||||
|
journal_id: z.string().uuid().optional(),
|
||||||
|
ref: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateInvoiceSchema = z.object({
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
payment_term_id: z.string().uuid().optional().nullable(),
|
||||||
|
journal_id: z.string().uuid().optional().nullable(),
|
||||||
|
ref: z.string().optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoiceQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
invoice_type: z.enum(['customer', 'supplier']).optional(),
|
||||||
|
status: z.enum(['draft', 'open', 'paid', 'cancelled']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createInvoiceLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional(),
|
||||||
|
description: z.string().min(1),
|
||||||
|
quantity: z.number().positive(),
|
||||||
|
uom_id: z.string().uuid().optional(),
|
||||||
|
price_unit: z.number().min(0),
|
||||||
|
tax_ids: z.array(z.string().uuid()).optional(),
|
||||||
|
account_id: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateInvoiceLineSchema = z.object({
|
||||||
|
product_id: z.string().uuid().optional().nullable(),
|
||||||
|
description: z.string().min(1).optional(),
|
||||||
|
quantity: z.number().positive().optional(),
|
||||||
|
uom_id: z.string().uuid().optional().nullable(),
|
||||||
|
price_unit: z.number().min(0).optional(),
|
||||||
|
tax_ids: z.array(z.string().uuid()).optional(),
|
||||||
|
account_id: z.string().uuid().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== PAYMENT SCHEMAS ==========
|
||||||
|
const createPaymentSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
partner_id: z.string().uuid(),
|
||||||
|
payment_type: z.enum(['inbound', 'outbound']),
|
||||||
|
payment_method: z.enum(['cash', 'bank_transfer', 'check', 'card', 'other']),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
currency_id: z.string().uuid(),
|
||||||
|
payment_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
ref: z.string().optional(),
|
||||||
|
journal_id: z.string().uuid(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePaymentSchema = z.object({
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
payment_method: z.enum(['cash', 'bank_transfer', 'check', 'card', 'other']).optional(),
|
||||||
|
amount: z.number().positive().optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
payment_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
ref: z.string().optional().nullable(),
|
||||||
|
journal_id: z.string().uuid().optional(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reconcilePaymentSchema = z.object({
|
||||||
|
invoices: z.array(z.object({
|
||||||
|
invoice_id: z.string().uuid(),
|
||||||
|
amount: z.number().positive(),
|
||||||
|
})).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
partner_id: z.string().uuid().optional(),
|
||||||
|
payment_type: z.enum(['inbound', 'outbound']).optional(),
|
||||||
|
payment_method: z.enum(['cash', 'bank_transfer', 'check', 'card', 'other']).optional(),
|
||||||
|
status: z.enum(['draft', 'posted', 'reconciled', 'cancelled']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== TAX SCHEMAS ==========
|
||||||
|
const createTaxSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
code: z.string().min(1).max(20),
|
||||||
|
tax_type: z.enum(['sales', 'purchase', 'all']),
|
||||||
|
amount: z.number().min(0).max(100),
|
||||||
|
included_in_price: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTaxSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
code: z.string().min(1).max(20).optional(),
|
||||||
|
tax_type: z.enum(['sales', 'purchase', 'all']).optional(),
|
||||||
|
amount: z.number().min(0).max(100).optional(),
|
||||||
|
included_in_price: z.boolean().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const taxQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
tax_type: z.enum(['sales', 'purchase', 'all']).optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
class FinancialController {
|
||||||
|
// ========== ACCOUNT TYPES ==========
|
||||||
|
async getAccountTypes(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const accountTypes = await accountsService.findAllAccountTypes();
|
||||||
|
res.json({ success: true, data: accountTypes });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ACCOUNTS ==========
|
||||||
|
async getAccounts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = accountQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: AccountFilters = queryResult.data;
|
||||||
|
const result = await accountsService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const account = await accountsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: account });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createAccountSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de cuenta inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateAccountDto = parseResult.data;
|
||||||
|
const account = await accountsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: account, message: 'Cuenta creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateAccountSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de cuenta inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateAccountDto = parseResult.data;
|
||||||
|
const account = await accountsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: account, message: 'Cuenta actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await accountsService.delete(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, message: 'Cuenta eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountBalance(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const balance = await accountsService.getBalance(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: balance });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== JOURNALS ==========
|
||||||
|
async getJournals(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = journalQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: JournalFilters = queryResult.data;
|
||||||
|
const result = await journalsService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJournal(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const journal = await journalsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: journal });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createJournal(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createJournalSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de diario inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateJournalDto = parseResult.data;
|
||||||
|
const journal = await journalsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: journal, message: 'Diario creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateJournal(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateJournalSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de diario inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateJournalDto = parseResult.data;
|
||||||
|
const journal = await journalsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: journal, message: 'Diario actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteJournal(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await journalsService.delete(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, message: 'Diario eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== JOURNAL ENTRIES ==========
|
||||||
|
async getJournalEntries(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = journalEntryQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: JournalEntryFilters = queryResult.data;
|
||||||
|
const result = await journalEntriesService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entry = await journalEntriesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: entry });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createJournalEntrySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de póliza inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateJournalEntryDto = parseResult.data;
|
||||||
|
const entry = await journalEntriesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: entry, message: 'Póliza creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateJournalEntrySchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de póliza inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateJournalEntryDto = parseResult.data;
|
||||||
|
const entry = await journalEntriesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: entry, message: 'Póliza actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entry = await journalEntriesService.post(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: entry, message: 'Póliza publicada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entry = await journalEntriesService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: entry, message: 'Póliza cancelada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await journalEntriesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Póliza eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== INVOICES ==========
|
||||||
|
async getInvoices(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = invoiceQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: InvoiceFilters = queryResult.data;
|
||||||
|
const result = await invoicesService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const invoice = await invoicesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: invoice });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createInvoiceSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de factura inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateInvoiceDto = parseResult.data;
|
||||||
|
const invoice = await invoicesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: invoice, message: 'Factura creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateInvoiceSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de factura inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateInvoiceDto = parseResult.data;
|
||||||
|
const invoice = await invoicesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: invoice, message: 'Factura actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const invoice = await invoicesService.validate(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: invoice, message: 'Factura validada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const invoice = await invoicesService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: invoice, message: 'Factura cancelada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await invoicesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Factura eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== INVOICE LINES ==========
|
||||||
|
async addInvoiceLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createInvoiceLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateInvoiceLineDto = parseResult.data;
|
||||||
|
const line = await invoicesService.addLine(req.params.id, dto, req.tenantId!);
|
||||||
|
res.status(201).json({ success: true, data: line, message: 'Línea agregada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateInvoiceLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateInvoiceLineSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de línea inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateInvoiceLineDto = parseResult.data;
|
||||||
|
const line = await invoicesService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!);
|
||||||
|
res.json({ success: true, data: line, message: 'Línea actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeInvoiceLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await invoicesService.removeLine(req.params.id, req.params.lineId, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Línea eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== PAYMENTS ==========
|
||||||
|
async getPayments(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = paymentQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: PaymentFilters = queryResult.data;
|
||||||
|
const result = await paymentsService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payment = await paymentsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: payment });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createPaymentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de pago inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreatePaymentDto = parseResult.data;
|
||||||
|
const payment = await paymentsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: payment, message: 'Pago creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updatePaymentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de pago inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdatePaymentDto = parseResult.data;
|
||||||
|
const payment = await paymentsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: payment, message: 'Pago actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async postPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payment = await paymentsService.post(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: payment, message: 'Pago publicado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcilePayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = reconcilePaymentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de conciliación inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: ReconcileDto = parseResult.data;
|
||||||
|
const payment = await paymentsService.reconcile(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: payment, message: 'Pago conciliado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const payment = await paymentsService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: payment, message: 'Pago cancelado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await paymentsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Pago eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== TAXES ==========
|
||||||
|
async getTaxes(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = taxQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
const filters: TaxFilters = queryResult.data;
|
||||||
|
const result = await taxesService.findAll(req.tenantId!, filters);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTax(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const tax = await taxesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: tax });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTax(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createTaxSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de impuesto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: CreateTaxDto = parseResult.data;
|
||||||
|
const tax = await taxesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.status(201).json({ success: true, data: tax, message: 'Impuesto creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTax(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateTaxSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de impuesto inválidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
const dto: UpdateTaxDto = parseResult.data;
|
||||||
|
const tax = await taxesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: tax, message: 'Impuesto actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTax(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await taxesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Impuesto eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const financialController = new FinancialController();
|
||||||
150
src/modules/financial/financial.routes.ts
Normal file
150
src/modules/financial/financial.routes.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { financialController } from './financial.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== ACCOUNT TYPES ==========
|
||||||
|
router.get('/account-types', (req, res, next) => financialController.getAccountTypes(req, res, next));
|
||||||
|
|
||||||
|
// ========== ACCOUNTS ==========
|
||||||
|
router.get('/accounts', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getAccounts(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/accounts/:id', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getAccount(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/accounts/:id/balance', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getAccountBalance(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/accounts', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.createAccount(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/accounts/:id', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updateAccount(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/accounts/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.deleteAccount(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== JOURNALS ==========
|
||||||
|
router.get('/journals', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getJournals(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/journals/:id', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getJournal(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/journals', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.createJournal(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/journals/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updateJournal(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/journals/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.deleteJournal(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== JOURNAL ENTRIES ==========
|
||||||
|
router.get('/entries', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getJournalEntries(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/entries/:id', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getJournalEntry(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/entries', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.createJournalEntry(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/entries/:id', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updateJournalEntry(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/entries/:id/post', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.postJournalEntry(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/entries/:id/cancel', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.cancelJournalEntry(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/entries/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.deleteJournalEntry(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== INVOICES ==========
|
||||||
|
router.get('/invoices', requireRoles('admin', 'accountant', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getInvoices(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/invoices/:id', requireRoles('admin', 'accountant', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/invoices', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.createInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/invoices/:id', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updateInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/invoices/:id/validate', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.validateInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/invoices/:id/cancel', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.cancelInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/invoices/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.deleteInvoice(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invoice lines
|
||||||
|
router.post('/invoices/:id/lines', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.addInvoiceLine(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/invoices/:id/lines/:lineId', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updateInvoiceLine(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/invoices/:id/lines/:lineId', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.removeInvoiceLine(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== PAYMENTS ==========
|
||||||
|
router.get('/payments', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getPayments(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/payments/:id', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getPayment(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/payments', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.createPayment(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/payments/:id', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updatePayment(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/payments/:id/post', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.postPayment(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/payments/:id/reconcile', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.reconcilePayment(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/payments/:id/cancel', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.cancelPayment(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/payments/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.deletePayment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== TAXES ==========
|
||||||
|
router.get('/taxes', requireRoles('admin', 'accountant', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getTaxes(req, res, next)
|
||||||
|
);
|
||||||
|
router.get('/taxes/:id', requireRoles('admin', 'accountant', 'manager', 'sales', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.getTax(req, res, next)
|
||||||
|
);
|
||||||
|
router.post('/taxes', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.createTax(req, res, next)
|
||||||
|
);
|
||||||
|
router.put('/taxes/:id', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.updateTax(req, res, next)
|
||||||
|
);
|
||||||
|
router.delete('/taxes/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
financialController.deleteTax(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
369
src/modules/financial/fiscalPeriods.service.ts
Normal file
369
src/modules/financial/fiscalPeriods.service.ts
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
import { logger } from '../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type FiscalPeriodStatus = 'open' | 'closed';
|
||||||
|
|
||||||
|
export interface FiscalYear {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
date_from: Date;
|
||||||
|
date_to: Date;
|
||||||
|
status: FiscalPeriodStatus;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FiscalPeriod {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
fiscal_year_id: string;
|
||||||
|
fiscal_year_name?: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
date_from: Date;
|
||||||
|
date_to: Date;
|
||||||
|
status: FiscalPeriodStatus;
|
||||||
|
closed_at: Date | null;
|
||||||
|
closed_by: string | null;
|
||||||
|
closed_by_name?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFiscalYearDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateFiscalPeriodDto {
|
||||||
|
fiscal_year_id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FiscalPeriodFilters {
|
||||||
|
company_id?: string;
|
||||||
|
fiscal_year_id?: string;
|
||||||
|
status?: FiscalPeriodStatus;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
class FiscalPeriodsService {
|
||||||
|
// ==================== FISCAL YEARS ====================
|
||||||
|
|
||||||
|
async findAllYears(tenantId: string, companyId?: string): Promise<FiscalYear[]> {
|
||||||
|
let sql = `
|
||||||
|
SELECT * FROM financial.fiscal_years
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
`;
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
|
||||||
|
if (companyId) {
|
||||||
|
sql += ` AND company_id = $2`;
|
||||||
|
params.push(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY date_from DESC`;
|
||||||
|
|
||||||
|
return query<FiscalYear>(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findYearById(id: string, tenantId: string): Promise<FiscalYear> {
|
||||||
|
const year = await queryOne<FiscalYear>(
|
||||||
|
`SELECT * FROM financial.fiscal_years WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!year) {
|
||||||
|
throw new NotFoundError('Año fiscal no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return year;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createYear(dto: CreateFiscalYearDto, tenantId: string, userId: string): Promise<FiscalYear> {
|
||||||
|
// Check for overlapping years
|
||||||
|
const overlapping = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM financial.fiscal_years
|
||||||
|
WHERE tenant_id = $1 AND company_id = $2
|
||||||
|
AND (date_from, date_to) OVERLAPS ($3::date, $4::date)`,
|
||||||
|
[tenantId, dto.company_id, dto.date_from, dto.date_to]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overlapping) {
|
||||||
|
throw new ConflictError('Ya existe un año fiscal que se superpone con estas fechas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = await queryOne<FiscalYear>(
|
||||||
|
`INSERT INTO financial.fiscal_years (
|
||||||
|
tenant_id, company_id, name, code, date_from, date_to, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.name, dto.code, dto.date_from, dto.date_to, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Fiscal year created', { yearId: year?.id, name: dto.name });
|
||||||
|
|
||||||
|
return year!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== FISCAL PERIODS ====================
|
||||||
|
|
||||||
|
async findAllPeriods(tenantId: string, filters: FiscalPeriodFilters = {}): Promise<FiscalPeriod[]> {
|
||||||
|
const conditions: string[] = ['fp.tenant_id = $1'];
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
if (filters.fiscal_year_id) {
|
||||||
|
conditions.push(`fp.fiscal_year_id = $${idx++}`);
|
||||||
|
params.push(filters.fiscal_year_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.company_id) {
|
||||||
|
conditions.push(`fy.company_id = $${idx++}`);
|
||||||
|
params.push(filters.company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status) {
|
||||||
|
conditions.push(`fp.status = $${idx++}`);
|
||||||
|
params.push(filters.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.date_from) {
|
||||||
|
conditions.push(`fp.date_from >= $${idx++}`);
|
||||||
|
params.push(filters.date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.date_to) {
|
||||||
|
conditions.push(`fp.date_to <= $${idx++}`);
|
||||||
|
params.push(filters.date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<FiscalPeriod>(
|
||||||
|
`SELECT fp.*,
|
||||||
|
fy.name as fiscal_year_name,
|
||||||
|
u.full_name as closed_by_name
|
||||||
|
FROM financial.fiscal_periods fp
|
||||||
|
JOIN financial.fiscal_years fy ON fp.fiscal_year_id = fy.id
|
||||||
|
LEFT JOIN auth.users u ON fp.closed_by = u.id
|
||||||
|
WHERE ${conditions.join(' AND ')}
|
||||||
|
ORDER BY fp.date_from DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPeriodById(id: string, tenantId: string): Promise<FiscalPeriod> {
|
||||||
|
const period = await queryOne<FiscalPeriod>(
|
||||||
|
`SELECT fp.*,
|
||||||
|
fy.name as fiscal_year_name,
|
||||||
|
u.full_name as closed_by_name
|
||||||
|
FROM financial.fiscal_periods fp
|
||||||
|
JOIN financial.fiscal_years fy ON fp.fiscal_year_id = fy.id
|
||||||
|
LEFT JOIN auth.users u ON fp.closed_by = u.id
|
||||||
|
WHERE fp.id = $1 AND fp.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!period) {
|
||||||
|
throw new NotFoundError('Período fiscal no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return period;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPeriodByDate(date: Date, companyId: string, tenantId: string): Promise<FiscalPeriod | null> {
|
||||||
|
return queryOne<FiscalPeriod>(
|
||||||
|
`SELECT fp.*
|
||||||
|
FROM financial.fiscal_periods fp
|
||||||
|
JOIN financial.fiscal_years fy ON fp.fiscal_year_id = fy.id
|
||||||
|
WHERE fp.tenant_id = $1
|
||||||
|
AND fy.company_id = $2
|
||||||
|
AND $3::date BETWEEN fp.date_from AND fp.date_to`,
|
||||||
|
[tenantId, companyId, date]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPeriod(dto: CreateFiscalPeriodDto, tenantId: string, userId: string): Promise<FiscalPeriod> {
|
||||||
|
// Verify fiscal year exists
|
||||||
|
await this.findYearById(dto.fiscal_year_id, tenantId);
|
||||||
|
|
||||||
|
// Check for overlapping periods in the same year
|
||||||
|
const overlapping = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM financial.fiscal_periods
|
||||||
|
WHERE tenant_id = $1 AND fiscal_year_id = $2
|
||||||
|
AND (date_from, date_to) OVERLAPS ($3::date, $4::date)`,
|
||||||
|
[tenantId, dto.fiscal_year_id, dto.date_from, dto.date_to]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overlapping) {
|
||||||
|
throw new ConflictError('Ya existe un período que se superpone con estas fechas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = await queryOne<FiscalPeriod>(
|
||||||
|
`INSERT INTO financial.fiscal_periods (
|
||||||
|
tenant_id, fiscal_year_id, code, name, date_from, date_to, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.fiscal_year_id, dto.code, dto.name, dto.date_from, dto.date_to, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Fiscal period created', { periodId: period?.id, name: dto.name });
|
||||||
|
|
||||||
|
return period!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PERIOD OPERATIONS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close a fiscal period
|
||||||
|
* Uses database function for validation
|
||||||
|
*/
|
||||||
|
async closePeriod(periodId: string, tenantId: string, userId: string): Promise<FiscalPeriod> {
|
||||||
|
// Verify period exists and belongs to tenant
|
||||||
|
await this.findPeriodById(periodId, tenantId);
|
||||||
|
|
||||||
|
// Use database function for atomic close with validations
|
||||||
|
const result = await queryOne<FiscalPeriod>(
|
||||||
|
`SELECT * FROM financial.close_fiscal_period($1, $2)`,
|
||||||
|
[periodId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Error al cerrar período');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Fiscal period closed', { periodId, userId });
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reopen a fiscal period (admin only)
|
||||||
|
*/
|
||||||
|
async reopenPeriod(periodId: string, tenantId: string, userId: string, reason?: string): Promise<FiscalPeriod> {
|
||||||
|
// Verify period exists and belongs to tenant
|
||||||
|
await this.findPeriodById(periodId, tenantId);
|
||||||
|
|
||||||
|
// Use database function for atomic reopen with audit
|
||||||
|
const result = await queryOne<FiscalPeriod>(
|
||||||
|
`SELECT * FROM financial.reopen_fiscal_period($1, $2, $3)`,
|
||||||
|
[periodId, userId, reason]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Error al reabrir período');
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('Fiscal period reopened', { periodId, userId, reason });
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics for a period
|
||||||
|
*/
|
||||||
|
async getPeriodStats(periodId: string, tenantId: string): Promise<{
|
||||||
|
total_entries: number;
|
||||||
|
draft_entries: number;
|
||||||
|
posted_entries: number;
|
||||||
|
total_debit: number;
|
||||||
|
total_credit: number;
|
||||||
|
}> {
|
||||||
|
const stats = await queryOne<{
|
||||||
|
total_entries: string;
|
||||||
|
draft_entries: string;
|
||||||
|
posted_entries: string;
|
||||||
|
total_debit: string;
|
||||||
|
total_credit: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) as total_entries,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'draft') as draft_entries,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'posted') as posted_entries,
|
||||||
|
COALESCE(SUM(total_debit), 0) as total_debit,
|
||||||
|
COALESCE(SUM(total_credit), 0) as total_credit
|
||||||
|
FROM financial.journal_entries
|
||||||
|
WHERE fiscal_period_id = $1 AND tenant_id = $2`,
|
||||||
|
[periodId, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total_entries: parseInt(stats?.total_entries || '0', 10),
|
||||||
|
draft_entries: parseInt(stats?.draft_entries || '0', 10),
|
||||||
|
posted_entries: parseInt(stats?.posted_entries || '0', 10),
|
||||||
|
total_debit: parseFloat(stats?.total_debit || '0'),
|
||||||
|
total_credit: parseFloat(stats?.total_credit || '0'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate monthly periods for a fiscal year
|
||||||
|
*/
|
||||||
|
async generateMonthlyPeriods(fiscalYearId: string, tenantId: string, userId: string): Promise<FiscalPeriod[]> {
|
||||||
|
const year = await this.findYearById(fiscalYearId, tenantId);
|
||||||
|
|
||||||
|
const startDate = new Date(year.date_from);
|
||||||
|
const endDate = new Date(year.date_to);
|
||||||
|
const periods: FiscalPeriod[] = [];
|
||||||
|
|
||||||
|
let currentDate = new Date(startDate);
|
||||||
|
let periodNum = 1;
|
||||||
|
|
||||||
|
while (currentDate <= endDate) {
|
||||||
|
const periodStart = new Date(currentDate);
|
||||||
|
const periodEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0);
|
||||||
|
|
||||||
|
// Don't exceed the fiscal year end
|
||||||
|
if (periodEnd > endDate) {
|
||||||
|
periodEnd.setTime(endDate.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||||
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const period = await this.createPeriod({
|
||||||
|
fiscal_year_id: fiscalYearId,
|
||||||
|
code: String(periodNum).padStart(2, '0'),
|
||||||
|
name: `${monthNames[periodStart.getMonth()]} ${periodStart.getFullYear()}`,
|
||||||
|
date_from: periodStart.toISOString().split('T')[0],
|
||||||
|
date_to: periodEnd.toISOString().split('T')[0],
|
||||||
|
}, tenantId, userId);
|
||||||
|
|
||||||
|
periods.push(period);
|
||||||
|
} catch (error) {
|
||||||
|
// Skip if period already exists (overlapping check will fail)
|
||||||
|
logger.debug('Period creation skipped', { periodNum, error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next month
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||||
|
currentDate.setDate(1);
|
||||||
|
periodNum++;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Generated monthly periods', { fiscalYearId, count: periods.length });
|
||||||
|
|
||||||
|
return periods;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fiscalPeriodsService = new FiscalPeriodsService();
|
||||||
8
src/modules/financial/index.ts
Normal file
8
src/modules/financial/index.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export * from './accounts.service.js';
|
||||||
|
export * from './journals.service.js';
|
||||||
|
export * from './journal-entries.service.js';
|
||||||
|
export * from './invoices.service.js';
|
||||||
|
export * from './payments.service.js';
|
||||||
|
export * from './taxes.service.js';
|
||||||
|
export * from './financial.controller.js';
|
||||||
|
export { default as financialRoutes } from './financial.routes.js';
|
||||||
547
src/modules/financial/invoices.service.ts
Normal file
547
src/modules/financial/invoices.service.ts
Normal file
@ -0,0 +1,547 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
import { taxesService } from './taxes.service.js';
|
||||||
|
|
||||||
|
export interface InvoiceLine {
|
||||||
|
id: string;
|
||||||
|
invoice_id: string;
|
||||||
|
product_id?: string;
|
||||||
|
product_name?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
uom_id?: string;
|
||||||
|
uom_name?: string;
|
||||||
|
price_unit: number;
|
||||||
|
tax_ids: string[];
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
account_id?: string;
|
||||||
|
account_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
invoice_type: 'customer' | 'supplier';
|
||||||
|
number?: string;
|
||||||
|
ref?: string;
|
||||||
|
invoice_date: Date;
|
||||||
|
due_date?: Date;
|
||||||
|
currency_id: string;
|
||||||
|
currency_code?: string;
|
||||||
|
amount_untaxed: number;
|
||||||
|
amount_tax: number;
|
||||||
|
amount_total: number;
|
||||||
|
amount_paid: number;
|
||||||
|
amount_residual: number;
|
||||||
|
status: 'draft' | 'open' | 'paid' | 'cancelled';
|
||||||
|
payment_term_id?: string;
|
||||||
|
journal_id?: string;
|
||||||
|
journal_entry_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
lines?: InvoiceLine[];
|
||||||
|
created_at: Date;
|
||||||
|
validated_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInvoiceDto {
|
||||||
|
company_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
invoice_type: 'customer' | 'supplier';
|
||||||
|
ref?: string;
|
||||||
|
invoice_date?: string;
|
||||||
|
due_date?: string;
|
||||||
|
currency_id: string;
|
||||||
|
payment_term_id?: string;
|
||||||
|
journal_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInvoiceDto {
|
||||||
|
partner_id?: string;
|
||||||
|
ref?: string | null;
|
||||||
|
invoice_date?: string;
|
||||||
|
due_date?: string | null;
|
||||||
|
currency_id?: string;
|
||||||
|
payment_term_id?: string | null;
|
||||||
|
journal_id?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInvoiceLineDto {
|
||||||
|
product_id?: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
uom_id?: string;
|
||||||
|
price_unit: number;
|
||||||
|
tax_ids?: string[];
|
||||||
|
account_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInvoiceLineDto {
|
||||||
|
product_id?: string | null;
|
||||||
|
description?: string;
|
||||||
|
quantity?: number;
|
||||||
|
uom_id?: string | null;
|
||||||
|
price_unit?: number;
|
||||||
|
tax_ids?: string[];
|
||||||
|
account_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvoiceFilters {
|
||||||
|
company_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
invoice_type?: string;
|
||||||
|
status?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvoicesService {
|
||||||
|
async findAll(tenantId: string, filters: InvoiceFilters = {}): Promise<{ data: Invoice[]; total: number }> {
|
||||||
|
const { company_id, partner_id, invoice_type, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE i.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND i.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND i.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice_type) {
|
||||||
|
whereClause += ` AND i.invoice_type = $${paramIndex++}`;
|
||||||
|
params.push(invoice_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND i.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND i.invoice_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND i.invoice_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (i.number ILIKE $${paramIndex} OR i.ref ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM financial.invoices i
|
||||||
|
LEFT JOIN core.partners p ON i.partner_id = p.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Invoice>(
|
||||||
|
`SELECT i.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cu.code as currency_code
|
||||||
|
FROM financial.invoices i
|
||||||
|
LEFT JOIN auth.companies c ON i.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON i.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cu ON i.currency_id = cu.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY i.invoice_date DESC, i.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Invoice> {
|
||||||
|
const invoice = await queryOne<Invoice>(
|
||||||
|
`SELECT i.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as partner_name,
|
||||||
|
cu.code as currency_code
|
||||||
|
FROM financial.invoices i
|
||||||
|
LEFT JOIN auth.companies c ON i.company_id = c.id
|
||||||
|
LEFT JOIN core.partners p ON i.partner_id = p.id
|
||||||
|
LEFT JOIN core.currencies cu ON i.currency_id = cu.id
|
||||||
|
WHERE i.id = $1 AND i.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!invoice) {
|
||||||
|
throw new NotFoundError('Factura no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
const lines = await query<InvoiceLine>(
|
||||||
|
`SELECT il.*,
|
||||||
|
pr.name as product_name,
|
||||||
|
um.name as uom_name,
|
||||||
|
a.name as account_name
|
||||||
|
FROM financial.invoice_lines il
|
||||||
|
LEFT JOIN inventory.products pr ON il.product_id = pr.id
|
||||||
|
LEFT JOIN core.uom um ON il.uom_id = um.id
|
||||||
|
LEFT JOIN financial.accounts a ON il.account_id = a.id
|
||||||
|
WHERE il.invoice_id = $1
|
||||||
|
ORDER BY il.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
invoice.lines = lines;
|
||||||
|
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateInvoiceDto, tenantId: string, userId: string): Promise<Invoice> {
|
||||||
|
const invoiceDate = dto.invoice_date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const invoice = await queryOne<Invoice>(
|
||||||
|
`INSERT INTO financial.invoices (
|
||||||
|
tenant_id, company_id, partner_id, invoice_type, ref, invoice_date,
|
||||||
|
due_date, currency_id, payment_term_id, journal_id, notes,
|
||||||
|
amount_untaxed, amount_tax, amount_total, amount_paid, amount_residual, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0, 0, 0, 0, 0, $12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.partner_id, dto.invoice_type, dto.ref,
|
||||||
|
invoiceDate, dto.due_date, dto.currency_id, dto.payment_term_id,
|
||||||
|
dto.journal_id, dto.notes, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return invoice!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateInvoiceDto, tenantId: string, userId: string): Promise<Invoice> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar facturas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.partner_id !== undefined) {
|
||||||
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.partner_id);
|
||||||
|
}
|
||||||
|
if (dto.ref !== undefined) {
|
||||||
|
updateFields.push(`ref = $${paramIndex++}`);
|
||||||
|
values.push(dto.ref);
|
||||||
|
}
|
||||||
|
if (dto.invoice_date !== undefined) {
|
||||||
|
updateFields.push(`invoice_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.invoice_date);
|
||||||
|
}
|
||||||
|
if (dto.due_date !== undefined) {
|
||||||
|
updateFields.push(`due_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.due_date);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.payment_term_id !== undefined) {
|
||||||
|
updateFields.push(`payment_term_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.payment_term_id);
|
||||||
|
}
|
||||||
|
if (dto.journal_id !== undefined) {
|
||||||
|
updateFields.push(`journal_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.journal_id);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.invoices SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar facturas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM financial.invoices WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addLine(invoiceId: string, dto: CreateInvoiceLineDto, tenantId: string): Promise<InvoiceLine> {
|
||||||
|
const invoice = await this.findById(invoiceId, tenantId);
|
||||||
|
|
||||||
|
if (invoice.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden agregar líneas a facturas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate amounts with taxes using taxesService
|
||||||
|
// Determine transaction type based on invoice type
|
||||||
|
const transactionType = invoice.invoice_type === 'customer'
|
||||||
|
? 'sales'
|
||||||
|
: 'purchase';
|
||||||
|
|
||||||
|
const taxResult = await taxesService.calculateTaxes(
|
||||||
|
{
|
||||||
|
quantity: dto.quantity,
|
||||||
|
priceUnit: dto.price_unit,
|
||||||
|
discount: 0, // Invoices don't have line discounts by default
|
||||||
|
taxIds: dto.tax_ids || [],
|
||||||
|
},
|
||||||
|
tenantId,
|
||||||
|
transactionType
|
||||||
|
);
|
||||||
|
const amountUntaxed = taxResult.amountUntaxed;
|
||||||
|
const amountTax = taxResult.amountTax;
|
||||||
|
const amountTotal = taxResult.amountTotal;
|
||||||
|
|
||||||
|
const line = await queryOne<InvoiceLine>(
|
||||||
|
`INSERT INTO financial.invoice_lines (
|
||||||
|
invoice_id, tenant_id, product_id, description, quantity, uom_id,
|
||||||
|
price_unit, tax_ids, amount_untaxed, amount_tax, amount_total, account_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
invoiceId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id,
|
||||||
|
dto.price_unit, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal, dto.account_id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update invoice totals
|
||||||
|
await this.updateTotals(invoiceId);
|
||||||
|
|
||||||
|
return line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLine(invoiceId: string, lineId: string, dto: UpdateInvoiceLineDto, tenantId: string): Promise<InvoiceLine> {
|
||||||
|
const invoice = await this.findById(invoiceId, tenantId);
|
||||||
|
|
||||||
|
if (invoice.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar líneas de facturas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLine = invoice.lines?.find(l => l.id === lineId);
|
||||||
|
if (!existingLine) {
|
||||||
|
throw new NotFoundError('Línea de factura no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const quantity = dto.quantity ?? existingLine.quantity;
|
||||||
|
const priceUnit = dto.price_unit ?? existingLine.price_unit;
|
||||||
|
|
||||||
|
if (dto.product_id !== undefined) {
|
||||||
|
updateFields.push(`product_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.product_id);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
if (dto.quantity !== undefined) {
|
||||||
|
updateFields.push(`quantity = $${paramIndex++}`);
|
||||||
|
values.push(dto.quantity);
|
||||||
|
}
|
||||||
|
if (dto.uom_id !== undefined) {
|
||||||
|
updateFields.push(`uom_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.uom_id);
|
||||||
|
}
|
||||||
|
if (dto.price_unit !== undefined) {
|
||||||
|
updateFields.push(`price_unit = $${paramIndex++}`);
|
||||||
|
values.push(dto.price_unit);
|
||||||
|
}
|
||||||
|
if (dto.tax_ids !== undefined) {
|
||||||
|
updateFields.push(`tax_ids = $${paramIndex++}`);
|
||||||
|
values.push(dto.tax_ids);
|
||||||
|
}
|
||||||
|
if (dto.account_id !== undefined) {
|
||||||
|
updateFields.push(`account_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.account_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate amounts
|
||||||
|
const amountUntaxed = quantity * priceUnit;
|
||||||
|
const amountTax = 0; // TODO: Calculate taxes
|
||||||
|
const amountTotal = amountUntaxed + amountTax;
|
||||||
|
|
||||||
|
updateFields.push(`amount_untaxed = $${paramIndex++}`);
|
||||||
|
values.push(amountUntaxed);
|
||||||
|
updateFields.push(`amount_tax = $${paramIndex++}`);
|
||||||
|
values.push(amountTax);
|
||||||
|
updateFields.push(`amount_total = $${paramIndex++}`);
|
||||||
|
values.push(amountTotal);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(lineId, invoiceId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.invoice_lines SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND invoice_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update invoice totals
|
||||||
|
await this.updateTotals(invoiceId);
|
||||||
|
|
||||||
|
const updated = await queryOne<InvoiceLine>(
|
||||||
|
`SELECT * FROM financial.invoice_lines WHERE id = $1`,
|
||||||
|
[lineId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLine(invoiceId: string, lineId: string, tenantId: string): Promise<void> {
|
||||||
|
const invoice = await this.findById(invoiceId, tenantId);
|
||||||
|
|
||||||
|
if (invoice.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar líneas de facturas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM financial.invoice_lines WHERE id = $1 AND invoice_id = $2`,
|
||||||
|
[lineId, invoiceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update invoice totals
|
||||||
|
await this.updateTotals(invoiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(id: string, tenantId: string, userId: string): Promise<Invoice> {
|
||||||
|
const invoice = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (invoice.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden validar facturas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice.lines || invoice.lines.length === 0) {
|
||||||
|
throw new ValidationError('La factura debe tener al menos una línea');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate invoice number
|
||||||
|
const prefix = invoice.invoice_type === 'customer' ? 'INV' : 'BILL';
|
||||||
|
const seqResult = await queryOne<{ next_num: number }>(
|
||||||
|
`SELECT COALESCE(MAX(CAST(SUBSTRING(number FROM 5) AS INTEGER)), 0) + 1 as next_num
|
||||||
|
FROM financial.invoices WHERE tenant_id = $1 AND number LIKE '${prefix}-%'`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
const invoiceNumber = `${prefix}-${String(seqResult?.next_num || 1).padStart(6, '0')}`;
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.invoices SET
|
||||||
|
number = $1,
|
||||||
|
status = 'open',
|
||||||
|
amount_residual = amount_total,
|
||||||
|
validated_at = CURRENT_TIMESTAMP,
|
||||||
|
validated_by = $2,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[invoiceNumber, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Invoice> {
|
||||||
|
const invoice = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (invoice.status === 'paid') {
|
||||||
|
throw new ValidationError('No se pueden cancelar facturas pagadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.status === 'cancelled') {
|
||||||
|
throw new ValidationError('La factura ya está cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.amount_paid > 0) {
|
||||||
|
throw new ValidationError('No se puede cancelar: la factura tiene pagos asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.invoices SET
|
||||||
|
status = 'cancelled',
|
||||||
|
cancelled_at = CURRENT_TIMESTAMP,
|
||||||
|
cancelled_by = $1,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateTotals(invoiceId: string): Promise<void> {
|
||||||
|
const totals = await queryOne<{ amount_untaxed: number; amount_tax: number; amount_total: number }>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(amount_untaxed), 0) as amount_untaxed,
|
||||||
|
COALESCE(SUM(amount_tax), 0) as amount_tax,
|
||||||
|
COALESCE(SUM(amount_total), 0) as amount_total
|
||||||
|
FROM financial.invoice_lines WHERE invoice_id = $1`,
|
||||||
|
[invoiceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.invoices SET
|
||||||
|
amount_untaxed = $1,
|
||||||
|
amount_tax = $2,
|
||||||
|
amount_total = $3,
|
||||||
|
amount_residual = $3 - amount_paid
|
||||||
|
WHERE id = $4`,
|
||||||
|
[totals?.amount_untaxed || 0, totals?.amount_tax || 0, totals?.amount_total || 0, invoiceId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invoicesService = new InvoicesService();
|
||||||
343
src/modules/financial/journal-entries.service.ts
Normal file
343
src/modules/financial/journal-entries.service.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type EntryStatus = 'draft' | 'posted' | 'cancelled';
|
||||||
|
|
||||||
|
export interface JournalEntryLine {
|
||||||
|
id?: string;
|
||||||
|
account_id: string;
|
||||||
|
account_name?: string;
|
||||||
|
account_code?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
partner_name?: string;
|
||||||
|
debit: number;
|
||||||
|
credit: number;
|
||||||
|
description?: string;
|
||||||
|
ref?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JournalEntry {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
journal_id: string;
|
||||||
|
journal_name?: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
date: Date;
|
||||||
|
status: EntryStatus;
|
||||||
|
notes?: string;
|
||||||
|
lines?: JournalEntryLine[];
|
||||||
|
total_debit?: number;
|
||||||
|
total_credit?: number;
|
||||||
|
created_at: Date;
|
||||||
|
posted_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateJournalEntryDto {
|
||||||
|
company_id: string;
|
||||||
|
journal_id: string;
|
||||||
|
name: string;
|
||||||
|
ref?: string;
|
||||||
|
date: string;
|
||||||
|
notes?: string;
|
||||||
|
lines: Omit<JournalEntryLine, 'id' | 'account_name' | 'account_code' | 'partner_name'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateJournalEntryDto {
|
||||||
|
ref?: string | null;
|
||||||
|
date?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
lines?: Omit<JournalEntryLine, 'id' | 'account_name' | 'account_code' | 'partner_name'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JournalEntryFilters {
|
||||||
|
company_id?: string;
|
||||||
|
journal_id?: string;
|
||||||
|
status?: EntryStatus;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class JournalEntriesService {
|
||||||
|
async findAll(tenantId: string, filters: JournalEntryFilters = {}): Promise<{ data: JournalEntry[]; total: number }> {
|
||||||
|
const { company_id, journal_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE je.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND je.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (journal_id) {
|
||||||
|
whereClause += ` AND je.journal_id = $${paramIndex++}`;
|
||||||
|
params.push(journal_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND je.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND je.date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND je.date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (je.name ILIKE $${paramIndex} OR je.ref ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.journal_entries je ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<JournalEntry>(
|
||||||
|
`SELECT je.*,
|
||||||
|
c.name as company_name,
|
||||||
|
j.name as journal_name,
|
||||||
|
(SELECT COALESCE(SUM(debit), 0) FROM financial.journal_entry_lines WHERE entry_id = je.id) as total_debit,
|
||||||
|
(SELECT COALESCE(SUM(credit), 0) FROM financial.journal_entry_lines WHERE entry_id = je.id) as total_credit
|
||||||
|
FROM financial.journal_entries je
|
||||||
|
LEFT JOIN auth.companies c ON je.company_id = c.id
|
||||||
|
LEFT JOIN financial.journals j ON je.journal_id = j.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY je.date DESC, je.name DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<JournalEntry> {
|
||||||
|
const entry = await queryOne<JournalEntry>(
|
||||||
|
`SELECT je.*,
|
||||||
|
c.name as company_name,
|
||||||
|
j.name as journal_name
|
||||||
|
FROM financial.journal_entries je
|
||||||
|
LEFT JOIN auth.companies c ON je.company_id = c.id
|
||||||
|
LEFT JOIN financial.journals j ON je.journal_id = j.id
|
||||||
|
WHERE je.id = $1 AND je.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
throw new NotFoundError('Póliza no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines
|
||||||
|
const lines = await query<JournalEntryLine>(
|
||||||
|
`SELECT jel.*,
|
||||||
|
a.name as account_name,
|
||||||
|
a.code as account_code,
|
||||||
|
p.name as partner_name
|
||||||
|
FROM financial.journal_entry_lines jel
|
||||||
|
LEFT JOIN financial.accounts a ON jel.account_id = a.id
|
||||||
|
LEFT JOIN core.partners p ON jel.partner_id = p.id
|
||||||
|
WHERE jel.entry_id = $1
|
||||||
|
ORDER BY jel.created_at`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
entry.lines = lines;
|
||||||
|
entry.total_debit = lines.reduce((sum, l) => sum + Number(l.debit), 0);
|
||||||
|
entry.total_credit = lines.reduce((sum, l) => sum + Number(l.credit), 0);
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateJournalEntryDto, tenantId: string, userId: string): Promise<JournalEntry> {
|
||||||
|
// Validate lines balance
|
||||||
|
const totalDebit = dto.lines.reduce((sum, l) => sum + l.debit, 0);
|
||||||
|
const totalCredit = dto.lines.reduce((sum, l) => sum + l.credit, 0);
|
||||||
|
|
||||||
|
if (Math.abs(totalDebit - totalCredit) > 0.01) {
|
||||||
|
throw new ValidationError('La póliza no está balanceada. Débitos y créditos deben ser iguales.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.lines.length < 2) {
|
||||||
|
throw new ValidationError('La póliza debe tener al menos 2 líneas.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Create entry
|
||||||
|
const entryResult = await client.query(
|
||||||
|
`INSERT INTO financial.journal_entries (tenant_id, company_id, journal_id, name, ref, date, notes, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.journal_id, dto.name, dto.ref, dto.date, dto.notes, userId]
|
||||||
|
);
|
||||||
|
const entry = entryResult.rows[0] as JournalEntry;
|
||||||
|
|
||||||
|
// Create lines (include tenant_id for multi-tenant security)
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO financial.journal_entry_lines (entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[entry.id, tenantId, line.account_id, line.partner_id, line.debit, line.credit, line.description, line.ref]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(entry.id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateJournalEntryDto, tenantId: string, userId: string): Promise<JournalEntry> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden modificar pólizas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Update entry header
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.ref !== undefined) {
|
||||||
|
updateFields.push(`ref = $${paramIndex++}`);
|
||||||
|
values.push(dto.ref);
|
||||||
|
}
|
||||||
|
if (dto.date !== undefined) {
|
||||||
|
updateFields.push(`date = $${paramIndex++}`);
|
||||||
|
values.push(dto.date);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
if (updateFields.length > 2) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE financial.journal_entries SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lines if provided
|
||||||
|
if (dto.lines) {
|
||||||
|
const totalDebit = dto.lines.reduce((sum, l) => sum + l.debit, 0);
|
||||||
|
const totalCredit = dto.lines.reduce((sum, l) => sum + l.credit, 0);
|
||||||
|
|
||||||
|
if (Math.abs(totalDebit - totalCredit) > 0.01) {
|
||||||
|
throw new ValidationError('La póliza no está balanceada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete existing lines
|
||||||
|
await client.query(`DELETE FROM financial.journal_entry_lines WHERE entry_id = $1`, [id]);
|
||||||
|
|
||||||
|
// Insert new lines (include tenant_id for multi-tenant security)
|
||||||
|
for (const line of dto.lines) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO financial.journal_entry_lines (entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[id, tenantId, line.account_id, line.partner_id, line.debit, line.credit, line.description, line.ref]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(id: string, tenantId: string, userId: string): Promise<JournalEntry> {
|
||||||
|
const entry = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (entry.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden publicar pólizas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate balance
|
||||||
|
if (Math.abs((entry.total_debit || 0) - (entry.total_credit || 0)) > 0.01) {
|
||||||
|
throw new ValidationError('La póliza no está balanceada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.journal_entries
|
||||||
|
SET status = 'posted', posted_at = CURRENT_TIMESTAMP, posted_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<JournalEntry> {
|
||||||
|
const entry = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (entry.status === 'cancelled') {
|
||||||
|
throw new ConflictError('La póliza ya está cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.journal_entries
|
||||||
|
SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const entry = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (entry.status !== 'draft') {
|
||||||
|
throw new ConflictError('Solo se pueden eliminar pólizas en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM financial.journal_entries WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const journalEntriesService = new JournalEntriesService();
|
||||||
216
src/modules/financial/journals.service.old.ts
Normal file
216
src/modules/financial/journals.service.old.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type JournalType = 'sale' | 'purchase' | 'cash' | 'bank' | 'general';
|
||||||
|
|
||||||
|
export interface Journal {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
journal_type: JournalType;
|
||||||
|
default_account_id?: string;
|
||||||
|
default_account_name?: string;
|
||||||
|
sequence_id?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
currency_code?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateJournalDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
journal_type: JournalType;
|
||||||
|
default_account_id?: string;
|
||||||
|
sequence_id?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateJournalDto {
|
||||||
|
name?: string;
|
||||||
|
default_account_id?: string | null;
|
||||||
|
sequence_id?: string | null;
|
||||||
|
currency_id?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JournalFilters {
|
||||||
|
company_id?: string;
|
||||||
|
journal_type?: JournalType;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class JournalsService {
|
||||||
|
async findAll(tenantId: string, filters: JournalFilters = {}): Promise<{ data: Journal[]; total: number }> {
|
||||||
|
const { company_id, journal_type, active, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE j.tenant_id = $1 AND j.deleted_at IS NULL';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND j.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (journal_type) {
|
||||||
|
whereClause += ` AND j.journal_type = $${paramIndex++}`;
|
||||||
|
params.push(journal_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND j.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.journals j ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Journal>(
|
||||||
|
`SELECT j.*,
|
||||||
|
c.name as company_name,
|
||||||
|
a.name as default_account_name,
|
||||||
|
cur.code as currency_code
|
||||||
|
FROM financial.journals j
|
||||||
|
LEFT JOIN auth.companies c ON j.company_id = c.id
|
||||||
|
LEFT JOIN financial.accounts a ON j.default_account_id = a.id
|
||||||
|
LEFT JOIN core.currencies cur ON j.currency_id = cur.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY j.code
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Journal> {
|
||||||
|
const journal = await queryOne<Journal>(
|
||||||
|
`SELECT j.*,
|
||||||
|
c.name as company_name,
|
||||||
|
a.name as default_account_name,
|
||||||
|
cur.code as currency_code
|
||||||
|
FROM financial.journals j
|
||||||
|
LEFT JOIN auth.companies c ON j.company_id = c.id
|
||||||
|
LEFT JOIN financial.accounts a ON j.default_account_id = a.id
|
||||||
|
LEFT JOIN core.currencies cur ON j.currency_id = cur.id
|
||||||
|
WHERE j.id = $1 AND j.tenant_id = $2 AND j.deleted_at IS NULL`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!journal) {
|
||||||
|
throw new NotFoundError('Diario no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return journal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateJournalDto, tenantId: string, userId: string): Promise<Journal> {
|
||||||
|
// Validate unique code within company
|
||||||
|
const existing = await queryOne<Journal>(
|
||||||
|
`SELECT id FROM financial.journals WHERE company_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.company_id, dto.code]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe un diario con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const journal = await queryOne<Journal>(
|
||||||
|
`INSERT INTO financial.journals (tenant_id, company_id, name, code, journal_type, default_account_id, sequence_id, currency_id, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.company_id,
|
||||||
|
dto.name,
|
||||||
|
dto.code,
|
||||||
|
dto.journal_type,
|
||||||
|
dto.default_account_id,
|
||||||
|
dto.sequence_id,
|
||||||
|
dto.currency_id,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return journal!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateJournalDto, tenantId: string, userId: string): Promise<Journal> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.default_account_id !== undefined) {
|
||||||
|
updateFields.push(`default_account_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.default_account_id);
|
||||||
|
}
|
||||||
|
if (dto.sequence_id !== undefined) {
|
||||||
|
updateFields.push(`sequence_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.sequence_id);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
const journal = await queryOne<Journal>(
|
||||||
|
`UPDATE financial.journals
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return journal!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if journal has entries
|
||||||
|
const entries = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.journal_entries WHERE journal_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (parseInt(entries?.count || '0', 10) > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un diario que tiene pólizas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.journals SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1 WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const journalsService = new JournalsService();
|
||||||
216
src/modules/financial/journals.service.ts
Normal file
216
src/modules/financial/journals.service.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type JournalType = 'sale' | 'purchase' | 'cash' | 'bank' | 'general';
|
||||||
|
|
||||||
|
export interface Journal {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
journal_type: JournalType;
|
||||||
|
default_account_id?: string;
|
||||||
|
default_account_name?: string;
|
||||||
|
sequence_id?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
currency_code?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateJournalDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
journal_type: JournalType;
|
||||||
|
default_account_id?: string;
|
||||||
|
sequence_id?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateJournalDto {
|
||||||
|
name?: string;
|
||||||
|
default_account_id?: string | null;
|
||||||
|
sequence_id?: string | null;
|
||||||
|
currency_id?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JournalFilters {
|
||||||
|
company_id?: string;
|
||||||
|
journal_type?: JournalType;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class JournalsService {
|
||||||
|
async findAll(tenantId: string, filters: JournalFilters = {}): Promise<{ data: Journal[]; total: number }> {
|
||||||
|
const { company_id, journal_type, active, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE j.tenant_id = $1 AND j.deleted_at IS NULL';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND j.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (journal_type) {
|
||||||
|
whereClause += ` AND j.journal_type = $${paramIndex++}`;
|
||||||
|
params.push(journal_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND j.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.journals j ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Journal>(
|
||||||
|
`SELECT j.*,
|
||||||
|
c.name as company_name,
|
||||||
|
a.name as default_account_name,
|
||||||
|
cur.code as currency_code
|
||||||
|
FROM financial.journals j
|
||||||
|
LEFT JOIN auth.companies c ON j.company_id = c.id
|
||||||
|
LEFT JOIN financial.accounts a ON j.default_account_id = a.id
|
||||||
|
LEFT JOIN core.currencies cur ON j.currency_id = cur.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY j.code
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Journal> {
|
||||||
|
const journal = await queryOne<Journal>(
|
||||||
|
`SELECT j.*,
|
||||||
|
c.name as company_name,
|
||||||
|
a.name as default_account_name,
|
||||||
|
cur.code as currency_code
|
||||||
|
FROM financial.journals j
|
||||||
|
LEFT JOIN auth.companies c ON j.company_id = c.id
|
||||||
|
LEFT JOIN financial.accounts a ON j.default_account_id = a.id
|
||||||
|
LEFT JOIN core.currencies cur ON j.currency_id = cur.id
|
||||||
|
WHERE j.id = $1 AND j.tenant_id = $2 AND j.deleted_at IS NULL`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!journal) {
|
||||||
|
throw new NotFoundError('Diario no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return journal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateJournalDto, tenantId: string, userId: string): Promise<Journal> {
|
||||||
|
// Validate unique code within company
|
||||||
|
const existing = await queryOne<Journal>(
|
||||||
|
`SELECT id FROM financial.journals WHERE company_id = $1 AND code = $2 AND deleted_at IS NULL`,
|
||||||
|
[dto.company_id, dto.code]
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError(`Ya existe un diario con código ${dto.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const journal = await queryOne<Journal>(
|
||||||
|
`INSERT INTO financial.journals (tenant_id, company_id, name, code, journal_type, default_account_id, sequence_id, currency_id, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId,
|
||||||
|
dto.company_id,
|
||||||
|
dto.name,
|
||||||
|
dto.code,
|
||||||
|
dto.journal_type,
|
||||||
|
dto.default_account_id,
|
||||||
|
dto.sequence_id,
|
||||||
|
dto.currency_id,
|
||||||
|
userId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return journal!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateJournalDto, tenantId: string, userId: string): Promise<Journal> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.default_account_id !== undefined) {
|
||||||
|
updateFields.push(`default_account_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.default_account_id);
|
||||||
|
}
|
||||||
|
if (dto.sequence_id !== undefined) {
|
||||||
|
updateFields.push(`sequence_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.sequence_id);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
const journal = await queryOne<Journal>(
|
||||||
|
`UPDATE financial.journals
|
||||||
|
SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL
|
||||||
|
RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return journal!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string, userId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if journal has entries
|
||||||
|
const entries = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.journal_entries WHERE journal_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (parseInt(entries?.count || '0', 10) > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un diario que tiene pólizas');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.journals SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1 WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const journalsService = new JournalsService();
|
||||||
456
src/modules/financial/payments.service.ts
Normal file
456
src/modules/financial/payments.service.ts
Normal file
@ -0,0 +1,456 @@
|
|||||||
|
import { query, queryOne, getClient } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface PaymentInvoice {
|
||||||
|
invoice_id: string;
|
||||||
|
invoice_number?: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Payment {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
partner_id: string;
|
||||||
|
partner_name?: string;
|
||||||
|
payment_type: 'inbound' | 'outbound';
|
||||||
|
payment_method: 'cash' | 'bank_transfer' | 'check' | 'card' | 'other';
|
||||||
|
amount: number;
|
||||||
|
currency_id: string;
|
||||||
|
currency_code?: string;
|
||||||
|
payment_date: Date;
|
||||||
|
ref?: string;
|
||||||
|
status: 'draft' | 'posted' | 'reconciled' | 'cancelled';
|
||||||
|
journal_id: string;
|
||||||
|
journal_name?: string;
|
||||||
|
journal_entry_id?: string;
|
||||||
|
notes?: string;
|
||||||
|
invoices?: PaymentInvoice[];
|
||||||
|
created_at: Date;
|
||||||
|
posted_at?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePaymentDto {
|
||||||
|
company_id: string;
|
||||||
|
partner_id: string;
|
||||||
|
payment_type: 'inbound' | 'outbound';
|
||||||
|
payment_method: 'cash' | 'bank_transfer' | 'check' | 'card' | 'other';
|
||||||
|
amount: number;
|
||||||
|
currency_id: string;
|
||||||
|
payment_date?: string;
|
||||||
|
ref?: string;
|
||||||
|
journal_id: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePaymentDto {
|
||||||
|
partner_id?: string;
|
||||||
|
payment_method?: 'cash' | 'bank_transfer' | 'check' | 'card' | 'other';
|
||||||
|
amount?: number;
|
||||||
|
currency_id?: string;
|
||||||
|
payment_date?: string;
|
||||||
|
ref?: string | null;
|
||||||
|
journal_id?: string;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReconcileDto {
|
||||||
|
invoices: { invoice_id: string; amount: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentFilters {
|
||||||
|
company_id?: string;
|
||||||
|
partner_id?: string;
|
||||||
|
payment_type?: string;
|
||||||
|
payment_method?: string;
|
||||||
|
status?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaymentsService {
|
||||||
|
async findAll(tenantId: string, filters: PaymentFilters = {}): Promise<{ data: Payment[]; total: number }> {
|
||||||
|
const { company_id, partner_id, payment_type, payment_method, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE p.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND p.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner_id) {
|
||||||
|
whereClause += ` AND p.partner_id = $${paramIndex++}`;
|
||||||
|
params.push(partner_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment_type) {
|
||||||
|
whereClause += ` AND p.payment_type = $${paramIndex++}`;
|
||||||
|
params.push(payment_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment_method) {
|
||||||
|
whereClause += ` AND p.payment_method = $${paramIndex++}`;
|
||||||
|
params.push(payment_method);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND p.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND p.payment_date >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND p.payment_date <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (p.ref ILIKE $${paramIndex} OR pr.name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM financial.payments p
|
||||||
|
LEFT JOIN core.partners pr ON p.partner_id = pr.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Payment>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
pr.name as partner_name,
|
||||||
|
cu.code as currency_code,
|
||||||
|
j.name as journal_name
|
||||||
|
FROM financial.payments p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN core.partners pr ON p.partner_id = pr.id
|
||||||
|
LEFT JOIN core.currencies cu ON p.currency_id = cu.id
|
||||||
|
LEFT JOIN financial.journals j ON p.journal_id = j.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY p.payment_date DESC, p.created_at DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Payment> {
|
||||||
|
const payment = await queryOne<Payment>(
|
||||||
|
`SELECT p.*,
|
||||||
|
c.name as company_name,
|
||||||
|
pr.name as partner_name,
|
||||||
|
cu.code as currency_code,
|
||||||
|
j.name as journal_name
|
||||||
|
FROM financial.payments p
|
||||||
|
LEFT JOIN auth.companies c ON p.company_id = c.id
|
||||||
|
LEFT JOIN core.partners pr ON p.partner_id = pr.id
|
||||||
|
LEFT JOIN core.currencies cu ON p.currency_id = cu.id
|
||||||
|
LEFT JOIN financial.journals j ON p.journal_id = j.id
|
||||||
|
WHERE p.id = $1 AND p.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!payment) {
|
||||||
|
throw new NotFoundError('Pago no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reconciled invoices
|
||||||
|
const invoices = await query<PaymentInvoice>(
|
||||||
|
`SELECT pi.invoice_id, pi.amount, i.number as invoice_number
|
||||||
|
FROM financial.payment_invoice pi
|
||||||
|
LEFT JOIN financial.invoices i ON pi.invoice_id = i.id
|
||||||
|
WHERE pi.payment_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
payment.invoices = invoices;
|
||||||
|
|
||||||
|
return payment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreatePaymentDto, tenantId: string, userId: string): Promise<Payment> {
|
||||||
|
if (dto.amount <= 0) {
|
||||||
|
throw new ValidationError('El monto debe ser mayor a 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentDate = dto.payment_date || new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const payment = await queryOne<Payment>(
|
||||||
|
`INSERT INTO financial.payments (
|
||||||
|
tenant_id, company_id, partner_id, payment_type, payment_method,
|
||||||
|
amount, currency_id, payment_date, ref, journal_id, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.partner_id, dto.payment_type, dto.payment_method,
|
||||||
|
dto.amount, dto.currency_id, paymentDate, dto.ref, dto.journal_id, dto.notes, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return payment!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdatePaymentDto, tenantId: string, userId: string): Promise<Payment> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar pagos en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.partner_id !== undefined) {
|
||||||
|
updateFields.push(`partner_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.partner_id);
|
||||||
|
}
|
||||||
|
if (dto.payment_method !== undefined) {
|
||||||
|
updateFields.push(`payment_method = $${paramIndex++}`);
|
||||||
|
values.push(dto.payment_method);
|
||||||
|
}
|
||||||
|
if (dto.amount !== undefined) {
|
||||||
|
if (dto.amount <= 0) {
|
||||||
|
throw new ValidationError('El monto debe ser mayor a 0');
|
||||||
|
}
|
||||||
|
updateFields.push(`amount = $${paramIndex++}`);
|
||||||
|
values.push(dto.amount);
|
||||||
|
}
|
||||||
|
if (dto.currency_id !== undefined) {
|
||||||
|
updateFields.push(`currency_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.currency_id);
|
||||||
|
}
|
||||||
|
if (dto.payment_date !== undefined) {
|
||||||
|
updateFields.push(`payment_date = $${paramIndex++}`);
|
||||||
|
values.push(dto.payment_date);
|
||||||
|
}
|
||||||
|
if (dto.ref !== undefined) {
|
||||||
|
updateFields.push(`ref = $${paramIndex++}`);
|
||||||
|
values.push(dto.ref);
|
||||||
|
}
|
||||||
|
if (dto.journal_id !== undefined) {
|
||||||
|
updateFields.push(`journal_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.journal_id);
|
||||||
|
}
|
||||||
|
if (dto.notes !== undefined) {
|
||||||
|
updateFields.push(`notes = $${paramIndex++}`);
|
||||||
|
values.push(dto.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.payments SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar pagos en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM financial.payments WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async post(id: string, tenantId: string, userId: string): Promise<Payment> {
|
||||||
|
const payment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (payment.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden publicar pagos en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.payments SET
|
||||||
|
status = 'posted',
|
||||||
|
posted_at = CURRENT_TIMESTAMP,
|
||||||
|
posted_by = $1,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcile(id: string, dto: ReconcileDto, tenantId: string, userId: string): Promise<Payment> {
|
||||||
|
const payment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (payment.status === 'draft') {
|
||||||
|
throw new ValidationError('Debe publicar el pago antes de conciliar');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payment.status === 'cancelled') {
|
||||||
|
throw new ValidationError('No se puede conciliar un pago cancelado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate total amount matches
|
||||||
|
const totalReconciled = dto.invoices.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
if (totalReconciled > payment.amount) {
|
||||||
|
throw new ValidationError('El monto total conciliado excede el monto del pago');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Remove existing reconciliations
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM financial.payment_invoice WHERE payment_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add new reconciliations
|
||||||
|
for (const inv of dto.invoices) {
|
||||||
|
// Validate invoice exists and belongs to same partner
|
||||||
|
const invoice = await client.query(
|
||||||
|
`SELECT id, partner_id, amount_residual, status FROM financial.invoices
|
||||||
|
WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[inv.invoice_id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invoice.rows.length === 0) {
|
||||||
|
throw new ValidationError(`Factura ${inv.invoice_id} no encontrada`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.rows[0].partner_id !== payment.partner_id) {
|
||||||
|
throw new ValidationError('La factura debe pertenecer al mismo cliente/proveedor');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.rows[0].status !== 'open') {
|
||||||
|
throw new ValidationError('Solo se pueden conciliar facturas abiertas');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inv.amount > invoice.rows[0].amount_residual) {
|
||||||
|
throw new ValidationError(`El monto excede el saldo pendiente de la factura`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO financial.payment_invoice (payment_id, invoice_id, amount)
|
||||||
|
VALUES ($1, $2, $3)`,
|
||||||
|
[id, inv.invoice_id, inv.amount]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update invoice amounts
|
||||||
|
await client.query(
|
||||||
|
`UPDATE financial.invoices SET
|
||||||
|
amount_paid = amount_paid + $1,
|
||||||
|
amount_residual = amount_residual - $1,
|
||||||
|
status = CASE WHEN amount_residual - $1 <= 0 THEN 'paid'::financial.invoice_status ELSE status END
|
||||||
|
WHERE id = $2`,
|
||||||
|
[inv.amount, inv.invoice_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update payment status
|
||||||
|
await client.query(
|
||||||
|
`UPDATE financial.payments SET
|
||||||
|
status = 'reconciled',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Payment> {
|
||||||
|
const payment = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (payment.status === 'cancelled') {
|
||||||
|
throw new ValidationError('El pago ya está cancelado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getClient();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Reverse reconciliations if any
|
||||||
|
if (payment.invoices && payment.invoices.length > 0) {
|
||||||
|
for (const inv of payment.invoices) {
|
||||||
|
await client.query(
|
||||||
|
`UPDATE financial.invoices SET
|
||||||
|
amount_paid = amount_paid - $1,
|
||||||
|
amount_residual = amount_residual + $1,
|
||||||
|
status = 'open'::financial.invoice_status
|
||||||
|
WHERE id = $2`,
|
||||||
|
[inv.amount, inv.invoice_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM financial.payment_invoice WHERE payment_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel payment
|
||||||
|
await client.query(
|
||||||
|
`UPDATE financial.payments SET
|
||||||
|
status = 'cancelled',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2`,
|
||||||
|
[userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const paymentsService = new PaymentsService();
|
||||||
382
src/modules/financial/taxes.service.old.ts
Normal file
382
src/modules/financial/taxes.service.old.ts
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Tax {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
tax_type: 'sales' | 'purchase' | 'all';
|
||||||
|
amount: number;
|
||||||
|
included_in_price: boolean;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaxDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
tax_type: 'sales' | 'purchase' | 'all';
|
||||||
|
amount: number;
|
||||||
|
included_in_price?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaxDto {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
tax_type?: 'sales' | 'purchase' | 'all';
|
||||||
|
amount?: number;
|
||||||
|
included_in_price?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxFilters {
|
||||||
|
company_id?: string;
|
||||||
|
tax_type?: string;
|
||||||
|
active?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TaxesService {
|
||||||
|
async findAll(tenantId: string, filters: TaxFilters = {}): Promise<{ data: Tax[]; total: number }> {
|
||||||
|
const { company_id, tax_type, active, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE t.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND t.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tax_type) {
|
||||||
|
whereClause += ` AND t.tax_type = $${paramIndex++}`;
|
||||||
|
params.push(tax_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND t.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (t.name ILIKE $${paramIndex} OR t.code ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.taxes t ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Tax>(
|
||||||
|
`SELECT t.*,
|
||||||
|
c.name as company_name
|
||||||
|
FROM financial.taxes t
|
||||||
|
LEFT JOIN auth.companies c ON t.company_id = c.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY t.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Tax> {
|
||||||
|
const tax = await queryOne<Tax>(
|
||||||
|
`SELECT t.*,
|
||||||
|
c.name as company_name
|
||||||
|
FROM financial.taxes t
|
||||||
|
LEFT JOIN auth.companies c ON t.company_id = c.id
|
||||||
|
WHERE t.id = $1 AND t.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tax) {
|
||||||
|
throw new NotFoundError('Impuesto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return tax;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateTaxDto, tenantId: string, userId: string): Promise<Tax> {
|
||||||
|
// Check unique code
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2`,
|
||||||
|
[tenantId, dto.code]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un impuesto con ese código');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tax = await queryOne<Tax>(
|
||||||
|
`INSERT INTO financial.taxes (
|
||||||
|
tenant_id, company_id, name, code, tax_type, amount, included_in_price, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.name, dto.code, dto.tax_type,
|
||||||
|
dto.amount, dto.included_in_price ?? false, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return tax!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateTaxDto, tenantId: string, userId: string): Promise<Tax> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.code !== undefined) {
|
||||||
|
// Check unique code
|
||||||
|
const existingCode = await queryOne(
|
||||||
|
`SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2 AND id != $3`,
|
||||||
|
[tenantId, dto.code, id]
|
||||||
|
);
|
||||||
|
if (existingCode) {
|
||||||
|
throw new ConflictError('Ya existe un impuesto con ese código');
|
||||||
|
}
|
||||||
|
updateFields.push(`code = $${paramIndex++}`);
|
||||||
|
values.push(dto.code);
|
||||||
|
}
|
||||||
|
if (dto.tax_type !== undefined) {
|
||||||
|
updateFields.push(`tax_type = $${paramIndex++}`);
|
||||||
|
values.push(dto.tax_type);
|
||||||
|
}
|
||||||
|
if (dto.amount !== undefined) {
|
||||||
|
updateFields.push(`amount = $${paramIndex++}`);
|
||||||
|
values.push(dto.amount);
|
||||||
|
}
|
||||||
|
if (dto.included_in_price !== undefined) {
|
||||||
|
updateFields.push(`included_in_price = $${paramIndex++}`);
|
||||||
|
values.push(dto.included_in_price);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.taxes SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if tax is used in any invoice lines
|
||||||
|
const usageCheck = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.invoice_lines
|
||||||
|
WHERE $1 = ANY(tax_ids)`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(usageCheck?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar: el impuesto está siendo usado en facturas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM financial.taxes WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula impuestos para una linea de documento
|
||||||
|
* Sigue la logica de Odoo para calculos de IVA
|
||||||
|
*/
|
||||||
|
async calculateTaxes(
|
||||||
|
lineData: TaxCalculationInput,
|
||||||
|
tenantId: string,
|
||||||
|
transactionType: 'sales' | 'purchase' = 'sales'
|
||||||
|
): Promise<TaxCalculationResult> {
|
||||||
|
// Validar inputs
|
||||||
|
if (lineData.quantity <= 0 || lineData.priceUnit < 0) {
|
||||||
|
return {
|
||||||
|
amountUntaxed: 0,
|
||||||
|
amountTax: 0,
|
||||||
|
amountTotal: 0,
|
||||||
|
taxBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular subtotal antes de impuestos
|
||||||
|
const subtotal = lineData.quantity * lineData.priceUnit;
|
||||||
|
const discountAmount = subtotal * (lineData.discount || 0) / 100;
|
||||||
|
const amountUntaxed = subtotal - discountAmount;
|
||||||
|
|
||||||
|
// Si no hay impuestos, retornar solo el monto sin impuestos
|
||||||
|
if (!lineData.taxIds || lineData.taxIds.length === 0) {
|
||||||
|
return {
|
||||||
|
amountUntaxed,
|
||||||
|
amountTax: 0,
|
||||||
|
amountTotal: amountUntaxed,
|
||||||
|
taxBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener impuestos de la BD
|
||||||
|
const taxResults = await query<Tax>(
|
||||||
|
`SELECT * FROM financial.taxes
|
||||||
|
WHERE id = ANY($1) AND tenant_id = $2 AND active = true
|
||||||
|
AND (tax_type = $3 OR tax_type = 'all')`,
|
||||||
|
[lineData.taxIds, tenantId, transactionType]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (taxResults.length === 0) {
|
||||||
|
return {
|
||||||
|
amountUntaxed,
|
||||||
|
amountTax: 0,
|
||||||
|
amountTotal: amountUntaxed,
|
||||||
|
taxBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular impuestos
|
||||||
|
const taxBreakdown: TaxBreakdownItem[] = [];
|
||||||
|
let totalTax = 0;
|
||||||
|
|
||||||
|
for (const tax of taxResults) {
|
||||||
|
let taxBase = amountUntaxed;
|
||||||
|
let taxAmount: number;
|
||||||
|
|
||||||
|
if (tax.included_in_price) {
|
||||||
|
// Precio incluye impuesto (IVA incluido)
|
||||||
|
// Base = Precio / (1 + tasa)
|
||||||
|
// Impuesto = Precio - Base
|
||||||
|
taxBase = amountUntaxed / (1 + tax.amount / 100);
|
||||||
|
taxAmount = amountUntaxed - taxBase;
|
||||||
|
} else {
|
||||||
|
// Precio sin impuesto (IVA añadido)
|
||||||
|
// Impuesto = Base * tasa
|
||||||
|
taxAmount = amountUntaxed * tax.amount / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
taxBreakdown.push({
|
||||||
|
taxId: tax.id,
|
||||||
|
taxName: tax.name,
|
||||||
|
taxCode: tax.code,
|
||||||
|
taxRate: tax.amount,
|
||||||
|
includedInPrice: tax.included_in_price,
|
||||||
|
base: Math.round(taxBase * 100) / 100,
|
||||||
|
taxAmount: Math.round(taxAmount * 100) / 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
totalTax += taxAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redondear a 2 decimales
|
||||||
|
const finalAmountTax = Math.round(totalTax * 100) / 100;
|
||||||
|
const finalAmountUntaxed = Math.round(amountUntaxed * 100) / 100;
|
||||||
|
const finalAmountTotal = Math.round((amountUntaxed + finalAmountTax) * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
amountUntaxed: finalAmountUntaxed,
|
||||||
|
amountTax: finalAmountTax,
|
||||||
|
amountTotal: finalAmountTotal,
|
||||||
|
taxBreakdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula impuestos para multiples lineas (ej: para totales de documento)
|
||||||
|
*/
|
||||||
|
async calculateDocumentTaxes(
|
||||||
|
lines: TaxCalculationInput[],
|
||||||
|
tenantId: string,
|
||||||
|
transactionType: 'sales' | 'purchase' = 'sales'
|
||||||
|
): Promise<TaxCalculationResult> {
|
||||||
|
let totalUntaxed = 0;
|
||||||
|
let totalTax = 0;
|
||||||
|
const allBreakdown: TaxBreakdownItem[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const result = await this.calculateTaxes(line, tenantId, transactionType);
|
||||||
|
totalUntaxed += result.amountUntaxed;
|
||||||
|
totalTax += result.amountTax;
|
||||||
|
allBreakdown.push(...result.taxBreakdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consolidar breakdown por impuesto
|
||||||
|
const consolidatedBreakdown = new Map<string, TaxBreakdownItem>();
|
||||||
|
for (const item of allBreakdown) {
|
||||||
|
const existing = consolidatedBreakdown.get(item.taxId);
|
||||||
|
if (existing) {
|
||||||
|
existing.base += item.base;
|
||||||
|
existing.taxAmount += item.taxAmount;
|
||||||
|
} else {
|
||||||
|
consolidatedBreakdown.set(item.taxId, { ...item });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
amountUntaxed: Math.round(totalUntaxed * 100) / 100,
|
||||||
|
amountTax: Math.round(totalTax * 100) / 100,
|
||||||
|
amountTotal: Math.round((totalUntaxed + totalTax) * 100) / 100,
|
||||||
|
taxBreakdown: Array.from(consolidatedBreakdown.values()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces para calculo de impuestos
|
||||||
|
export interface TaxCalculationInput {
|
||||||
|
quantity: number;
|
||||||
|
priceUnit: number;
|
||||||
|
discount: number;
|
||||||
|
taxIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxBreakdownItem {
|
||||||
|
taxId: string;
|
||||||
|
taxName: string;
|
||||||
|
taxCode: string;
|
||||||
|
taxRate: number;
|
||||||
|
includedInPrice: boolean;
|
||||||
|
base: number;
|
||||||
|
taxAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxCalculationResult {
|
||||||
|
amountUntaxed: number;
|
||||||
|
amountTax: number;
|
||||||
|
amountTotal: number;
|
||||||
|
taxBreakdown: TaxBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const taxesService = new TaxesService();
|
||||||
382
src/modules/financial/taxes.service.ts
Normal file
382
src/modules/financial/taxes.service.ts
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Tax {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
tax_type: 'sales' | 'purchase' | 'all';
|
||||||
|
amount: number;
|
||||||
|
included_in_price: boolean;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaxDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
tax_type: 'sales' | 'purchase' | 'all';
|
||||||
|
amount: number;
|
||||||
|
included_in_price?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTaxDto {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
tax_type?: 'sales' | 'purchase' | 'all';
|
||||||
|
amount?: number;
|
||||||
|
included_in_price?: boolean;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxFilters {
|
||||||
|
company_id?: string;
|
||||||
|
tax_type?: string;
|
||||||
|
active?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TaxesService {
|
||||||
|
async findAll(tenantId: string, filters: TaxFilters = {}): Promise<{ data: Tax[]; total: number }> {
|
||||||
|
const { company_id, tax_type, active, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE t.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND t.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tax_type) {
|
||||||
|
whereClause += ` AND t.tax_type = $${paramIndex++}`;
|
||||||
|
params.push(tax_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND t.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (t.name ILIKE $${paramIndex} OR t.code ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.taxes t ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Tax>(
|
||||||
|
`SELECT t.*,
|
||||||
|
c.name as company_name
|
||||||
|
FROM financial.taxes t
|
||||||
|
LEFT JOIN auth.companies c ON t.company_id = c.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY t.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Tax> {
|
||||||
|
const tax = await queryOne<Tax>(
|
||||||
|
`SELECT t.*,
|
||||||
|
c.name as company_name
|
||||||
|
FROM financial.taxes t
|
||||||
|
LEFT JOIN auth.companies c ON t.company_id = c.id
|
||||||
|
WHERE t.id = $1 AND t.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tax) {
|
||||||
|
throw new NotFoundError('Impuesto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return tax;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateTaxDto, tenantId: string, userId: string): Promise<Tax> {
|
||||||
|
// Check unique code
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2`,
|
||||||
|
[tenantId, dto.code]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un impuesto con ese código');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tax = await queryOne<Tax>(
|
||||||
|
`INSERT INTO financial.taxes (
|
||||||
|
tenant_id, company_id, name, code, tax_type, amount, included_in_price, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.name, dto.code, dto.tax_type,
|
||||||
|
dto.amount, dto.included_in_price ?? false, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return tax!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateTaxDto, tenantId: string, userId: string): Promise<Tax> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.code !== undefined) {
|
||||||
|
// Check unique code
|
||||||
|
const existingCode = await queryOne(
|
||||||
|
`SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2 AND id != $3`,
|
||||||
|
[tenantId, dto.code, id]
|
||||||
|
);
|
||||||
|
if (existingCode) {
|
||||||
|
throw new ConflictError('Ya existe un impuesto con ese código');
|
||||||
|
}
|
||||||
|
updateFields.push(`code = $${paramIndex++}`);
|
||||||
|
values.push(dto.code);
|
||||||
|
}
|
||||||
|
if (dto.tax_type !== undefined) {
|
||||||
|
updateFields.push(`tax_type = $${paramIndex++}`);
|
||||||
|
values.push(dto.tax_type);
|
||||||
|
}
|
||||||
|
if (dto.amount !== undefined) {
|
||||||
|
updateFields.push(`amount = $${paramIndex++}`);
|
||||||
|
values.push(dto.amount);
|
||||||
|
}
|
||||||
|
if (dto.included_in_price !== undefined) {
|
||||||
|
updateFields.push(`included_in_price = $${paramIndex++}`);
|
||||||
|
values.push(dto.included_in_price);
|
||||||
|
}
|
||||||
|
if (dto.active !== undefined) {
|
||||||
|
updateFields.push(`active = $${paramIndex++}`);
|
||||||
|
values.push(dto.active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE financial.taxes SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if tax is used in any invoice lines
|
||||||
|
const usageCheck = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM financial.invoice_lines
|
||||||
|
WHERE $1 = ANY(tax_ids)`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(usageCheck?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar: el impuesto está siendo usado en facturas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`DELETE FROM financial.taxes WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula impuestos para una linea de documento
|
||||||
|
* Sigue la logica de Odoo para calculos de IVA
|
||||||
|
*/
|
||||||
|
async calculateTaxes(
|
||||||
|
lineData: TaxCalculationInput,
|
||||||
|
tenantId: string,
|
||||||
|
transactionType: 'sales' | 'purchase' = 'sales'
|
||||||
|
): Promise<TaxCalculationResult> {
|
||||||
|
// Validar inputs
|
||||||
|
if (lineData.quantity <= 0 || lineData.priceUnit < 0) {
|
||||||
|
return {
|
||||||
|
amountUntaxed: 0,
|
||||||
|
amountTax: 0,
|
||||||
|
amountTotal: 0,
|
||||||
|
taxBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular subtotal antes de impuestos
|
||||||
|
const subtotal = lineData.quantity * lineData.priceUnit;
|
||||||
|
const discountAmount = subtotal * (lineData.discount || 0) / 100;
|
||||||
|
const amountUntaxed = subtotal - discountAmount;
|
||||||
|
|
||||||
|
// Si no hay impuestos, retornar solo el monto sin impuestos
|
||||||
|
if (!lineData.taxIds || lineData.taxIds.length === 0) {
|
||||||
|
return {
|
||||||
|
amountUntaxed,
|
||||||
|
amountTax: 0,
|
||||||
|
amountTotal: amountUntaxed,
|
||||||
|
taxBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener impuestos de la BD
|
||||||
|
const taxResults = await query<Tax>(
|
||||||
|
`SELECT * FROM financial.taxes
|
||||||
|
WHERE id = ANY($1) AND tenant_id = $2 AND active = true
|
||||||
|
AND (tax_type = $3 OR tax_type = 'all')`,
|
||||||
|
[lineData.taxIds, tenantId, transactionType]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (taxResults.length === 0) {
|
||||||
|
return {
|
||||||
|
amountUntaxed,
|
||||||
|
amountTax: 0,
|
||||||
|
amountTotal: amountUntaxed,
|
||||||
|
taxBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular impuestos
|
||||||
|
const taxBreakdown: TaxBreakdownItem[] = [];
|
||||||
|
let totalTax = 0;
|
||||||
|
|
||||||
|
for (const tax of taxResults) {
|
||||||
|
let taxBase = amountUntaxed;
|
||||||
|
let taxAmount: number;
|
||||||
|
|
||||||
|
if (tax.included_in_price) {
|
||||||
|
// Precio incluye impuesto (IVA incluido)
|
||||||
|
// Base = Precio / (1 + tasa)
|
||||||
|
// Impuesto = Precio - Base
|
||||||
|
taxBase = amountUntaxed / (1 + tax.amount / 100);
|
||||||
|
taxAmount = amountUntaxed - taxBase;
|
||||||
|
} else {
|
||||||
|
// Precio sin impuesto (IVA añadido)
|
||||||
|
// Impuesto = Base * tasa
|
||||||
|
taxAmount = amountUntaxed * tax.amount / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
taxBreakdown.push({
|
||||||
|
taxId: tax.id,
|
||||||
|
taxName: tax.name,
|
||||||
|
taxCode: tax.code,
|
||||||
|
taxRate: tax.amount,
|
||||||
|
includedInPrice: tax.included_in_price,
|
||||||
|
base: Math.round(taxBase * 100) / 100,
|
||||||
|
taxAmount: Math.round(taxAmount * 100) / 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
totalTax += taxAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redondear a 2 decimales
|
||||||
|
const finalAmountTax = Math.round(totalTax * 100) / 100;
|
||||||
|
const finalAmountUntaxed = Math.round(amountUntaxed * 100) / 100;
|
||||||
|
const finalAmountTotal = Math.round((amountUntaxed + finalAmountTax) * 100) / 100;
|
||||||
|
|
||||||
|
return {
|
||||||
|
amountUntaxed: finalAmountUntaxed,
|
||||||
|
amountTax: finalAmountTax,
|
||||||
|
amountTotal: finalAmountTotal,
|
||||||
|
taxBreakdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula impuestos para multiples lineas (ej: para totales de documento)
|
||||||
|
*/
|
||||||
|
async calculateDocumentTaxes(
|
||||||
|
lines: TaxCalculationInput[],
|
||||||
|
tenantId: string,
|
||||||
|
transactionType: 'sales' | 'purchase' = 'sales'
|
||||||
|
): Promise<TaxCalculationResult> {
|
||||||
|
let totalUntaxed = 0;
|
||||||
|
let totalTax = 0;
|
||||||
|
const allBreakdown: TaxBreakdownItem[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const result = await this.calculateTaxes(line, tenantId, transactionType);
|
||||||
|
totalUntaxed += result.amountUntaxed;
|
||||||
|
totalTax += result.amountTax;
|
||||||
|
allBreakdown.push(...result.taxBreakdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consolidar breakdown por impuesto
|
||||||
|
const consolidatedBreakdown = new Map<string, TaxBreakdownItem>();
|
||||||
|
for (const item of allBreakdown) {
|
||||||
|
const existing = consolidatedBreakdown.get(item.taxId);
|
||||||
|
if (existing) {
|
||||||
|
existing.base += item.base;
|
||||||
|
existing.taxAmount += item.taxAmount;
|
||||||
|
} else {
|
||||||
|
consolidatedBreakdown.set(item.taxId, { ...item });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
amountUntaxed: Math.round(totalUntaxed * 100) / 100,
|
||||||
|
amountTax: Math.round(totalTax * 100) / 100,
|
||||||
|
amountTotal: Math.round((totalUntaxed + totalTax) * 100) / 100,
|
||||||
|
taxBreakdown: Array.from(consolidatedBreakdown.values()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces para calculo de impuestos
|
||||||
|
export interface TaxCalculationInput {
|
||||||
|
quantity: number;
|
||||||
|
priceUnit: number;
|
||||||
|
discount: number;
|
||||||
|
taxIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxBreakdownItem {
|
||||||
|
taxId: string;
|
||||||
|
taxName: string;
|
||||||
|
taxCode: string;
|
||||||
|
taxRate: number;
|
||||||
|
includedInPrice: boolean;
|
||||||
|
base: number;
|
||||||
|
taxAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaxCalculationResult {
|
||||||
|
amountUntaxed: number;
|
||||||
|
amountTax: number;
|
||||||
|
amountTotal: number;
|
||||||
|
taxBreakdown: TaxBreakdownItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const taxesService = new TaxesService();
|
||||||
346
src/modules/hr/contracts.service.ts
Normal file
346
src/modules/hr/contracts.service.ts
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type ContractStatus = 'draft' | 'active' | 'expired' | 'terminated' | 'cancelled';
|
||||||
|
export type ContractType = 'permanent' | 'temporary' | 'contractor' | 'internship' | 'part_time';
|
||||||
|
|
||||||
|
export interface Contract {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
employee_id: string;
|
||||||
|
employee_name?: string;
|
||||||
|
employee_number?: string;
|
||||||
|
name: string;
|
||||||
|
reference?: string;
|
||||||
|
contract_type: ContractType;
|
||||||
|
status: ContractStatus;
|
||||||
|
job_position_id?: string;
|
||||||
|
job_position_name?: string;
|
||||||
|
department_id?: string;
|
||||||
|
department_name?: string;
|
||||||
|
date_start: Date;
|
||||||
|
date_end?: Date;
|
||||||
|
trial_date_end?: Date;
|
||||||
|
wage: number;
|
||||||
|
wage_type: string;
|
||||||
|
currency_id?: string;
|
||||||
|
currency_code?: string;
|
||||||
|
hours_per_week: number;
|
||||||
|
vacation_days: number;
|
||||||
|
christmas_bonus_days: number;
|
||||||
|
document_url?: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateContractDto {
|
||||||
|
company_id: string;
|
||||||
|
employee_id: string;
|
||||||
|
name: string;
|
||||||
|
reference?: string;
|
||||||
|
contract_type: ContractType;
|
||||||
|
job_position_id?: string;
|
||||||
|
department_id?: string;
|
||||||
|
date_start: string;
|
||||||
|
date_end?: string;
|
||||||
|
trial_date_end?: string;
|
||||||
|
wage: number;
|
||||||
|
wage_type?: string;
|
||||||
|
currency_id?: string;
|
||||||
|
hours_per_week?: number;
|
||||||
|
vacation_days?: number;
|
||||||
|
christmas_bonus_days?: number;
|
||||||
|
document_url?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateContractDto {
|
||||||
|
reference?: string | null;
|
||||||
|
job_position_id?: string | null;
|
||||||
|
department_id?: string | null;
|
||||||
|
date_end?: string | null;
|
||||||
|
trial_date_end?: string | null;
|
||||||
|
wage?: number;
|
||||||
|
wage_type?: string;
|
||||||
|
currency_id?: string | null;
|
||||||
|
hours_per_week?: number;
|
||||||
|
vacation_days?: number;
|
||||||
|
christmas_bonus_days?: number;
|
||||||
|
document_url?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractFilters {
|
||||||
|
company_id?: string;
|
||||||
|
employee_id?: string;
|
||||||
|
status?: ContractStatus;
|
||||||
|
contract_type?: ContractType;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContractsService {
|
||||||
|
async findAll(tenantId: string, filters: ContractFilters = {}): Promise<{ data: Contract[]; total: number }> {
|
||||||
|
const { company_id, employee_id, status, contract_type, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE c.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND c.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employee_id) {
|
||||||
|
whereClause += ` AND c.employee_id = $${paramIndex++}`;
|
||||||
|
params.push(employee_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND c.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contract_type) {
|
||||||
|
whereClause += ` AND c.contract_type = $${paramIndex++}`;
|
||||||
|
params.push(contract_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (c.name ILIKE $${paramIndex} OR c.reference ILIKE $${paramIndex} OR e.first_name ILIKE $${paramIndex} OR e.last_name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM hr.contracts c
|
||||||
|
LEFT JOIN hr.employees e ON c.employee_id = e.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Contract>(
|
||||||
|
`SELECT c.*,
|
||||||
|
co.name as company_name,
|
||||||
|
CONCAT(e.first_name, ' ', e.last_name) as employee_name,
|
||||||
|
e.employee_number,
|
||||||
|
j.name as job_position_name,
|
||||||
|
d.name as department_name,
|
||||||
|
cu.code as currency_code
|
||||||
|
FROM hr.contracts c
|
||||||
|
LEFT JOIN auth.companies co ON c.company_id = co.id
|
||||||
|
LEFT JOIN hr.employees e ON c.employee_id = e.id
|
||||||
|
LEFT JOIN hr.job_positions j ON c.job_position_id = j.id
|
||||||
|
LEFT JOIN hr.departments d ON c.department_id = d.id
|
||||||
|
LEFT JOIN core.currencies cu ON c.currency_id = cu.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY c.date_start DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Contract> {
|
||||||
|
const contract = await queryOne<Contract>(
|
||||||
|
`SELECT c.*,
|
||||||
|
co.name as company_name,
|
||||||
|
CONCAT(e.first_name, ' ', e.last_name) as employee_name,
|
||||||
|
e.employee_number,
|
||||||
|
j.name as job_position_name,
|
||||||
|
d.name as department_name,
|
||||||
|
cu.code as currency_code
|
||||||
|
FROM hr.contracts c
|
||||||
|
LEFT JOIN auth.companies co ON c.company_id = co.id
|
||||||
|
LEFT JOIN hr.employees e ON c.employee_id = e.id
|
||||||
|
LEFT JOIN hr.job_positions j ON c.job_position_id = j.id
|
||||||
|
LEFT JOIN hr.departments d ON c.department_id = d.id
|
||||||
|
LEFT JOIN core.currencies cu ON c.currency_id = cu.id
|
||||||
|
WHERE c.id = $1 AND c.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!contract) {
|
||||||
|
throw new NotFoundError('Contrato no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return contract;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateContractDto, tenantId: string, userId: string): Promise<Contract> {
|
||||||
|
// Check if employee has an active contract
|
||||||
|
const activeContract = await queryOne(
|
||||||
|
`SELECT id FROM hr.contracts WHERE employee_id = $1 AND status = 'active'`,
|
||||||
|
[dto.employee_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeContract) {
|
||||||
|
throw new ValidationError('El empleado ya tiene un contrato activo');
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = await queryOne<Contract>(
|
||||||
|
`INSERT INTO hr.contracts (
|
||||||
|
tenant_id, company_id, employee_id, name, reference, contract_type,
|
||||||
|
job_position_id, department_id, date_start, date_end, trial_date_end,
|
||||||
|
wage, wage_type, currency_id, hours_per_week, vacation_days, christmas_bonus_days,
|
||||||
|
document_url, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.employee_id, dto.name, dto.reference, dto.contract_type,
|
||||||
|
dto.job_position_id, dto.department_id, dto.date_start, dto.date_end, dto.trial_date_end,
|
||||||
|
dto.wage, dto.wage_type || 'monthly', dto.currency_id, dto.hours_per_week || 40,
|
||||||
|
dto.vacation_days || 6, dto.christmas_bonus_days || 15, dto.document_url, dto.notes, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(contract!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateContractDto, tenantId: string, userId: string): Promise<Contract> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar contratos en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = [
|
||||||
|
'reference', 'job_position_id', 'department_id', 'date_end', 'trial_date_end',
|
||||||
|
'wage', 'wage_type', 'currency_id', 'hours_per_week', 'vacation_days',
|
||||||
|
'christmas_bonus_days', 'document_url', 'notes'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.contracts SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(id: string, tenantId: string, userId: string): Promise<Contract> {
|
||||||
|
const contract = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (contract.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden activar contratos en estado borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if employee has another active contract
|
||||||
|
const activeContract = await queryOne(
|
||||||
|
`SELECT id FROM hr.contracts WHERE employee_id = $1 AND status = 'active' AND id != $2`,
|
||||||
|
[contract.employee_id, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeContract) {
|
||||||
|
throw new ValidationError('El empleado ya tiene otro contrato activo');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.contracts SET
|
||||||
|
status = 'active',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update employee department and position if specified
|
||||||
|
if (contract.department_id || contract.job_position_id) {
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.employees SET
|
||||||
|
department_id = COALESCE($1, department_id),
|
||||||
|
job_position_id = COALESCE($2, job_position_id),
|
||||||
|
updated_by = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $4`,
|
||||||
|
[contract.department_id, contract.job_position_id, userId, contract.employee_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(id: string, terminationDate: string, tenantId: string, userId: string): Promise<Contract> {
|
||||||
|
const contract = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (contract.status !== 'active') {
|
||||||
|
throw new ValidationError('Solo se pueden terminar contratos activos');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.contracts SET
|
||||||
|
status = 'terminated',
|
||||||
|
date_end = $1,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[terminationDate, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Contract> {
|
||||||
|
const contract = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (contract.status === 'active' || contract.status === 'terminated') {
|
||||||
|
throw new ValidationError('No se puede cancelar un contrato activo o terminado');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.contracts SET
|
||||||
|
status = 'cancelled',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const contract = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (contract.status !== 'draft' && contract.status !== 'cancelled') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar contratos en borrador o cancelados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM hr.contracts WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const contractsService = new ContractsService();
|
||||||
393
src/modules/hr/departments.service.ts
Normal file
393
src/modules/hr/departments.service.ts
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export interface Department {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
parent_name?: string;
|
||||||
|
manager_id?: string;
|
||||||
|
manager_name?: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
active: boolean;
|
||||||
|
employee_count?: number;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDepartmentDto {
|
||||||
|
company_id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
parent_id?: string;
|
||||||
|
manager_id?: string;
|
||||||
|
description?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDepartmentDto {
|
||||||
|
name?: string;
|
||||||
|
code?: string | null;
|
||||||
|
parent_id?: string | null;
|
||||||
|
manager_id?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
color?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DepartmentFilters {
|
||||||
|
company_id?: string;
|
||||||
|
active?: boolean;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job Position interfaces
|
||||||
|
export interface JobPosition {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
department_id?: string;
|
||||||
|
department_name?: string;
|
||||||
|
description?: string;
|
||||||
|
requirements?: string;
|
||||||
|
responsibilities?: string;
|
||||||
|
min_salary?: number;
|
||||||
|
max_salary?: number;
|
||||||
|
active: boolean;
|
||||||
|
employee_count?: number;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateJobPositionDto {
|
||||||
|
name: string;
|
||||||
|
department_id?: string;
|
||||||
|
description?: string;
|
||||||
|
requirements?: string;
|
||||||
|
responsibilities?: string;
|
||||||
|
min_salary?: number;
|
||||||
|
max_salary?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateJobPositionDto {
|
||||||
|
name?: string;
|
||||||
|
department_id?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
requirements?: string | null;
|
||||||
|
responsibilities?: string | null;
|
||||||
|
min_salary?: number | null;
|
||||||
|
max_salary?: number | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DepartmentsService {
|
||||||
|
// ========== DEPARTMENTS ==========
|
||||||
|
|
||||||
|
async findAll(tenantId: string, filters: DepartmentFilters = {}): Promise<{ data: Department[]; total: number }> {
|
||||||
|
const { company_id, active, search, page = 1, limit = 50 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE d.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND d.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (active !== undefined) {
|
||||||
|
whereClause += ` AND d.active = $${paramIndex++}`;
|
||||||
|
params.push(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (d.name ILIKE $${paramIndex} OR d.code ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.departments d ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Department>(
|
||||||
|
`SELECT d.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as parent_name,
|
||||||
|
CONCAT(m.first_name, ' ', m.last_name) as manager_name,
|
||||||
|
COALESCE(ec.employee_count, 0) as employee_count
|
||||||
|
FROM hr.departments d
|
||||||
|
LEFT JOIN auth.companies c ON d.company_id = c.id
|
||||||
|
LEFT JOIN hr.departments p ON d.parent_id = p.id
|
||||||
|
LEFT JOIN hr.employees m ON d.manager_id = m.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT department_id, COUNT(*) as employee_count
|
||||||
|
FROM hr.employees
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY department_id
|
||||||
|
) ec ON d.id = ec.department_id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY d.name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Department> {
|
||||||
|
const department = await queryOne<Department>(
|
||||||
|
`SELECT d.*,
|
||||||
|
c.name as company_name,
|
||||||
|
p.name as parent_name,
|
||||||
|
CONCAT(m.first_name, ' ', m.last_name) as manager_name,
|
||||||
|
COALESCE(ec.employee_count, 0) as employee_count
|
||||||
|
FROM hr.departments d
|
||||||
|
LEFT JOIN auth.companies c ON d.company_id = c.id
|
||||||
|
LEFT JOIN hr.departments p ON d.parent_id = p.id
|
||||||
|
LEFT JOIN hr.employees m ON d.manager_id = m.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT department_id, COUNT(*) as employee_count
|
||||||
|
FROM hr.employees
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY department_id
|
||||||
|
) ec ON d.id = ec.department_id
|
||||||
|
WHERE d.id = $1 AND d.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!department) {
|
||||||
|
throw new NotFoundError('Departamento no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return department;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateDepartmentDto, tenantId: string, userId: string): Promise<Department> {
|
||||||
|
// Check unique name within company
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM hr.departments WHERE name = $1 AND company_id = $2 AND tenant_id = $3`,
|
||||||
|
[dto.name, dto.company_id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un departamento con ese nombre en esta empresa');
|
||||||
|
}
|
||||||
|
|
||||||
|
const department = await queryOne<Department>(
|
||||||
|
`INSERT INTO hr.departments (tenant_id, company_id, name, code, parent_id, manager_id, description, color, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.company_id, dto.name, dto.code, dto.parent_id, dto.manager_id, dto.description, dto.color, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(department!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateDepartmentDto, tenantId: string): Promise<Department> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check unique name if changing
|
||||||
|
if (dto.name && dto.name !== existing.name) {
|
||||||
|
const nameExists = await queryOne(
|
||||||
|
`SELECT id FROM hr.departments WHERE name = $1 AND company_id = $2 AND tenant_id = $3 AND id != $4`,
|
||||||
|
[dto.name, existing.company_id, tenantId, id]
|
||||||
|
);
|
||||||
|
if (nameExists) {
|
||||||
|
throw new ConflictError('Ya existe un departamento con ese nombre en esta empresa');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = ['name', 'code', 'parent_id', 'manager_id', 'description', 'color', 'active'];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.departments SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if department has employees
|
||||||
|
const hasEmployees = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.employees WHERE department_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(hasEmployees?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un departamento con empleados asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if department has children
|
||||||
|
const hasChildren = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.departments WHERE parent_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(hasChildren?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un departamento con subdepartamentos');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM hr.departments WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== JOB POSITIONS ==========
|
||||||
|
|
||||||
|
async getJobPositions(tenantId: string, includeInactive = false): Promise<JobPosition[]> {
|
||||||
|
let whereClause = 'WHERE j.tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND j.active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<JobPosition>(
|
||||||
|
`SELECT j.*,
|
||||||
|
d.name as department_name,
|
||||||
|
COALESCE(ec.employee_count, 0) as employee_count
|
||||||
|
FROM hr.job_positions j
|
||||||
|
LEFT JOIN hr.departments d ON j.department_id = d.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT job_position_id, COUNT(*) as employee_count
|
||||||
|
FROM hr.employees
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY job_position_id
|
||||||
|
) ec ON j.id = ec.job_position_id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY j.name`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJobPositionById(id: string, tenantId: string): Promise<JobPosition> {
|
||||||
|
const position = await queryOne<JobPosition>(
|
||||||
|
`SELECT j.*,
|
||||||
|
d.name as department_name,
|
||||||
|
COALESCE(ec.employee_count, 0) as employee_count
|
||||||
|
FROM hr.job_positions j
|
||||||
|
LEFT JOIN hr.departments d ON j.department_id = d.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT job_position_id, COUNT(*) as employee_count
|
||||||
|
FROM hr.employees
|
||||||
|
WHERE status = 'active'
|
||||||
|
GROUP BY job_position_id
|
||||||
|
) ec ON j.id = ec.job_position_id
|
||||||
|
WHERE j.id = $1 AND j.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!position) {
|
||||||
|
throw new NotFoundError('Puesto no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createJobPosition(dto: CreateJobPositionDto, tenantId: string): Promise<JobPosition> {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM hr.job_positions WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un puesto con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = await queryOne<JobPosition>(
|
||||||
|
`INSERT INTO hr.job_positions (tenant_id, name, department_id, description, requirements, responsibilities, min_salary, max_salary)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[tenantId, dto.name, dto.department_id, dto.description, dto.requirements, dto.responsibilities, dto.min_salary, dto.max_salary]
|
||||||
|
);
|
||||||
|
|
||||||
|
return position!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateJobPosition(id: string, dto: UpdateJobPositionDto, tenantId: string): Promise<JobPosition> {
|
||||||
|
const existing = await this.getJobPositionById(id, tenantId);
|
||||||
|
|
||||||
|
if (dto.name && dto.name !== existing.name) {
|
||||||
|
const nameExists = await queryOne(
|
||||||
|
`SELECT id FROM hr.job_positions WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (nameExists) {
|
||||||
|
throw new ConflictError('Ya existe un puesto con ese nombre');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = ['name', 'department_id', 'description', 'requirements', 'responsibilities', 'min_salary', 'max_salary', 'active'];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.job_positions SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getJobPositionById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteJobPosition(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getJobPositionById(id, tenantId);
|
||||||
|
|
||||||
|
const hasEmployees = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.employees WHERE job_position_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(hasEmployees?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un puesto con empleados asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM hr.job_positions WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const departmentsService = new DepartmentsService();
|
||||||
402
src/modules/hr/employees.service.ts
Normal file
402
src/modules/hr/employees.service.ts
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type EmployeeStatus = 'active' | 'inactive' | 'on_leave' | 'terminated';
|
||||||
|
|
||||||
|
export interface Employee {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
employee_number: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
middle_name?: string;
|
||||||
|
full_name?: string;
|
||||||
|
user_id?: string;
|
||||||
|
birth_date?: Date;
|
||||||
|
gender?: string;
|
||||||
|
marital_status?: string;
|
||||||
|
nationality?: string;
|
||||||
|
identification_id?: string;
|
||||||
|
identification_type?: string;
|
||||||
|
social_security_number?: string;
|
||||||
|
tax_id?: string;
|
||||||
|
email?: string;
|
||||||
|
work_email?: string;
|
||||||
|
phone?: string;
|
||||||
|
work_phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
emergency_contact?: string;
|
||||||
|
emergency_phone?: string;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
department_id?: string;
|
||||||
|
department_name?: string;
|
||||||
|
job_position_id?: string;
|
||||||
|
job_position_name?: string;
|
||||||
|
manager_id?: string;
|
||||||
|
manager_name?: string;
|
||||||
|
hire_date: Date;
|
||||||
|
termination_date?: Date;
|
||||||
|
status: EmployeeStatus;
|
||||||
|
bank_name?: string;
|
||||||
|
bank_account?: string;
|
||||||
|
bank_clabe?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEmployeeDto {
|
||||||
|
company_id: string;
|
||||||
|
employee_number: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
middle_name?: string;
|
||||||
|
user_id?: string;
|
||||||
|
birth_date?: string;
|
||||||
|
gender?: string;
|
||||||
|
marital_status?: string;
|
||||||
|
nationality?: string;
|
||||||
|
identification_id?: string;
|
||||||
|
identification_type?: string;
|
||||||
|
social_security_number?: string;
|
||||||
|
tax_id?: string;
|
||||||
|
email?: string;
|
||||||
|
work_email?: string;
|
||||||
|
phone?: string;
|
||||||
|
work_phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
emergency_contact?: string;
|
||||||
|
emergency_phone?: string;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
country?: string;
|
||||||
|
department_id?: string;
|
||||||
|
job_position_id?: string;
|
||||||
|
manager_id?: string;
|
||||||
|
hire_date: string;
|
||||||
|
bank_name?: string;
|
||||||
|
bank_account?: string;
|
||||||
|
bank_clabe?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEmployeeDto {
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
middle_name?: string | null;
|
||||||
|
user_id?: string | null;
|
||||||
|
birth_date?: string | null;
|
||||||
|
gender?: string | null;
|
||||||
|
marital_status?: string | null;
|
||||||
|
nationality?: string | null;
|
||||||
|
identification_id?: string | null;
|
||||||
|
identification_type?: string | null;
|
||||||
|
social_security_number?: string | null;
|
||||||
|
tax_id?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
work_email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
work_phone?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
emergency_contact?: string | null;
|
||||||
|
emergency_phone?: string | null;
|
||||||
|
street?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
zip?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
department_id?: string | null;
|
||||||
|
job_position_id?: string | null;
|
||||||
|
manager_id?: string | null;
|
||||||
|
bank_name?: string | null;
|
||||||
|
bank_account?: string | null;
|
||||||
|
bank_clabe?: string | null;
|
||||||
|
photo_url?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployeeFilters {
|
||||||
|
company_id?: string;
|
||||||
|
department_id?: string;
|
||||||
|
status?: EmployeeStatus;
|
||||||
|
manager_id?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmployeesService {
|
||||||
|
async findAll(tenantId: string, filters: EmployeeFilters = {}): Promise<{ data: Employee[]; total: number }> {
|
||||||
|
const { company_id, department_id, status, manager_id, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE e.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND e.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (department_id) {
|
||||||
|
whereClause += ` AND e.department_id = $${paramIndex++}`;
|
||||||
|
params.push(department_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND e.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manager_id) {
|
||||||
|
whereClause += ` AND e.manager_id = $${paramIndex++}`;
|
||||||
|
params.push(manager_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (e.first_name ILIKE $${paramIndex} OR e.last_name ILIKE $${paramIndex} OR e.employee_number ILIKE $${paramIndex} OR e.email ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.employees e ${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Employee>(
|
||||||
|
`SELECT e.*,
|
||||||
|
CONCAT(e.first_name, ' ', COALESCE(e.middle_name || ' ', ''), e.last_name) as full_name,
|
||||||
|
c.name as company_name,
|
||||||
|
d.name as department_name,
|
||||||
|
j.name as job_position_name,
|
||||||
|
CONCAT(m.first_name, ' ', m.last_name) as manager_name
|
||||||
|
FROM hr.employees e
|
||||||
|
LEFT JOIN auth.companies c ON e.company_id = c.id
|
||||||
|
LEFT JOIN hr.departments d ON e.department_id = d.id
|
||||||
|
LEFT JOIN hr.job_positions j ON e.job_position_id = j.id
|
||||||
|
LEFT JOIN hr.employees m ON e.manager_id = m.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY e.last_name, e.first_name
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Employee> {
|
||||||
|
const employee = await queryOne<Employee>(
|
||||||
|
`SELECT e.*,
|
||||||
|
CONCAT(e.first_name, ' ', COALESCE(e.middle_name || ' ', ''), e.last_name) as full_name,
|
||||||
|
c.name as company_name,
|
||||||
|
d.name as department_name,
|
||||||
|
j.name as job_position_name,
|
||||||
|
CONCAT(m.first_name, ' ', m.last_name) as manager_name
|
||||||
|
FROM hr.employees e
|
||||||
|
LEFT JOIN auth.companies c ON e.company_id = c.id
|
||||||
|
LEFT JOIN hr.departments d ON e.department_id = d.id
|
||||||
|
LEFT JOIN hr.job_positions j ON e.job_position_id = j.id
|
||||||
|
LEFT JOIN hr.employees m ON e.manager_id = m.id
|
||||||
|
WHERE e.id = $1 AND e.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!employee) {
|
||||||
|
throw new NotFoundError('Empleado no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateEmployeeDto, tenantId: string, userId: string): Promise<Employee> {
|
||||||
|
// Check unique employee number
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM hr.employees WHERE employee_number = $1 AND tenant_id = $2`,
|
||||||
|
[dto.employee_number, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un empleado con ese numero');
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = await queryOne<Employee>(
|
||||||
|
`INSERT INTO hr.employees (
|
||||||
|
tenant_id, company_id, employee_number, first_name, last_name, middle_name,
|
||||||
|
user_id, birth_date, gender, marital_status, nationality, identification_id,
|
||||||
|
identification_type, social_security_number, tax_id, email, work_email,
|
||||||
|
phone, work_phone, mobile, emergency_contact, emergency_phone, street, city,
|
||||||
|
state, zip, country, department_id, job_position_id, manager_id, hire_date,
|
||||||
|
bank_name, bank_account, bank_clabe, photo_url, notes, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16,
|
||||||
|
$17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30,
|
||||||
|
$31, $32, $33, $34, $35, $36, $37)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.employee_number, dto.first_name, dto.last_name,
|
||||||
|
dto.middle_name, dto.user_id, dto.birth_date, dto.gender, dto.marital_status,
|
||||||
|
dto.nationality, dto.identification_id, dto.identification_type,
|
||||||
|
dto.social_security_number, dto.tax_id, dto.email, dto.work_email, dto.phone,
|
||||||
|
dto.work_phone, dto.mobile, dto.emergency_contact, dto.emergency_phone,
|
||||||
|
dto.street, dto.city, dto.state, dto.zip, dto.country, dto.department_id,
|
||||||
|
dto.job_position_id, dto.manager_id, dto.hire_date, dto.bank_name,
|
||||||
|
dto.bank_account, dto.bank_clabe, dto.photo_url, dto.notes, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(employee!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateEmployeeDto, tenantId: string, userId: string): Promise<Employee> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = [
|
||||||
|
'first_name', 'last_name', 'middle_name', 'user_id', 'birth_date', 'gender',
|
||||||
|
'marital_status', 'nationality', 'identification_id', 'identification_type',
|
||||||
|
'social_security_number', 'tax_id', 'email', 'work_email', 'phone', 'work_phone',
|
||||||
|
'mobile', 'emergency_contact', 'emergency_phone', 'street', 'city', 'state',
|
||||||
|
'zip', 'country', 'department_id', 'job_position_id', 'manager_id',
|
||||||
|
'bank_name', 'bank_account', 'bank_clabe', 'photo_url', 'notes'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.employees SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(id: string, terminationDate: string, tenantId: string, userId: string): Promise<Employee> {
|
||||||
|
const employee = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (employee.status === 'terminated') {
|
||||||
|
throw new ValidationError('El empleado ya esta dado de baja');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.employees SET
|
||||||
|
status = 'terminated',
|
||||||
|
termination_date = $1,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[terminationDate, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also terminate active contracts
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.contracts SET
|
||||||
|
status = 'terminated',
|
||||||
|
date_end = $1,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE employee_id = $3 AND status = 'active'`,
|
||||||
|
[terminationDate, userId, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reactivate(id: string, tenantId: string, userId: string): Promise<Employee> {
|
||||||
|
const employee = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (employee.status !== 'terminated' && employee.status !== 'inactive') {
|
||||||
|
throw new ValidationError('Solo se pueden reactivar empleados inactivos o dados de baja');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.employees SET
|
||||||
|
status = 'active',
|
||||||
|
termination_date = NULL,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const employee = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
// Check if employee has contracts
|
||||||
|
const hasContracts = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.contracts WHERE employee_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(hasContracts?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un empleado con contratos asociados');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if employee is a manager
|
||||||
|
const isManager = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.employees WHERE manager_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(isManager?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un empleado que es manager de otros');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM hr.employees WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get subordinates
|
||||||
|
async getSubordinates(id: string, tenantId: string): Promise<Employee[]> {
|
||||||
|
await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
return query<Employee>(
|
||||||
|
`SELECT e.*,
|
||||||
|
CONCAT(e.first_name, ' ', COALESCE(e.middle_name || ' ', ''), e.last_name) as full_name,
|
||||||
|
d.name as department_name,
|
||||||
|
j.name as job_position_name
|
||||||
|
FROM hr.employees e
|
||||||
|
LEFT JOIN hr.departments d ON e.department_id = d.id
|
||||||
|
LEFT JOIN hr.job_positions j ON e.job_position_id = j.id
|
||||||
|
WHERE e.manager_id = $1 AND e.tenant_id = $2
|
||||||
|
ORDER BY e.last_name, e.first_name`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const employeesService = new EmployeesService();
|
||||||
721
src/modules/hr/hr.controller.ts
Normal file
721
src/modules/hr/hr.controller.ts
Normal file
@ -0,0 +1,721 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { employeesService, CreateEmployeeDto, UpdateEmployeeDto, EmployeeFilters } from './employees.service.js';
|
||||||
|
import { departmentsService, CreateDepartmentDto, UpdateDepartmentDto, DepartmentFilters, CreateJobPositionDto, UpdateJobPositionDto } from './departments.service.js';
|
||||||
|
import { contractsService, CreateContractDto, UpdateContractDto, ContractFilters } from './contracts.service.js';
|
||||||
|
import { leavesService, CreateLeaveDto, UpdateLeaveDto, LeaveFilters, CreateLeaveTypeDto, UpdateLeaveTypeDto } from './leaves.service.js';
|
||||||
|
import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
// Employee schemas
|
||||||
|
const createEmployeeSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
employee_number: z.string().min(1).max(50),
|
||||||
|
first_name: z.string().min(1).max(100),
|
||||||
|
last_name: z.string().min(1).max(100),
|
||||||
|
middle_name: z.string().max(100).optional(),
|
||||||
|
user_id: z.string().uuid().optional(),
|
||||||
|
birth_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
gender: z.string().max(20).optional(),
|
||||||
|
marital_status: z.string().max(20).optional(),
|
||||||
|
nationality: z.string().max(100).optional(),
|
||||||
|
identification_id: z.string().max(50).optional(),
|
||||||
|
identification_type: z.string().max(50).optional(),
|
||||||
|
social_security_number: z.string().max(50).optional(),
|
||||||
|
tax_id: z.string().max(50).optional(),
|
||||||
|
email: z.string().email().max(255).optional(),
|
||||||
|
work_email: z.string().email().max(255).optional(),
|
||||||
|
phone: z.string().max(50).optional(),
|
||||||
|
work_phone: z.string().max(50).optional(),
|
||||||
|
mobile: z.string().max(50).optional(),
|
||||||
|
emergency_contact: z.string().max(255).optional(),
|
||||||
|
emergency_phone: z.string().max(50).optional(),
|
||||||
|
street: z.string().max(255).optional(),
|
||||||
|
city: z.string().max(100).optional(),
|
||||||
|
state: z.string().max(100).optional(),
|
||||||
|
zip: z.string().max(20).optional(),
|
||||||
|
country: z.string().max(100).optional(),
|
||||||
|
department_id: z.string().uuid().optional(),
|
||||||
|
job_position_id: z.string().uuid().optional(),
|
||||||
|
manager_id: z.string().uuid().optional(),
|
||||||
|
hire_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
bank_name: z.string().max(100).optional(),
|
||||||
|
bank_account: z.string().max(50).optional(),
|
||||||
|
bank_clabe: z.string().max(20).optional(),
|
||||||
|
photo_url: z.string().url().max(500).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateEmployeeSchema = createEmployeeSchema.partial().omit({ company_id: true, employee_number: true, hire_date: true });
|
||||||
|
|
||||||
|
const employeeQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
department_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['active', 'inactive', 'on_leave', 'terminated']).optional(),
|
||||||
|
manager_id: z.string().uuid().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Department schemas
|
||||||
|
const createDepartmentSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
code: z.string().max(20).optional(),
|
||||||
|
parent_id: z.string().uuid().optional(),
|
||||||
|
manager_id: z.string().uuid().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
color: z.string().max(20).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateDepartmentSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
code: z.string().max(20).optional().nullable(),
|
||||||
|
parent_id: z.string().uuid().optional().nullable(),
|
||||||
|
manager_id: z.string().uuid().optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
color: z.string().max(20).optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const departmentQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
active: z.coerce.boolean().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Job Position schemas
|
||||||
|
const createJobPositionSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
department_id: z.string().uuid().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
requirements: z.string().optional(),
|
||||||
|
responsibilities: z.string().optional(),
|
||||||
|
min_salary: z.number().min(0).optional(),
|
||||||
|
max_salary: z.number().min(0).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateJobPositionSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
department_id: z.string().uuid().optional().nullable(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
requirements: z.string().optional().nullable(),
|
||||||
|
responsibilities: z.string().optional().nullable(),
|
||||||
|
min_salary: z.number().min(0).optional().nullable(),
|
||||||
|
max_salary: z.number().min(0).optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contract schemas
|
||||||
|
const createContractSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
employee_id: z.string().uuid(),
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
reference: z.string().max(100).optional(),
|
||||||
|
contract_type: z.enum(['permanent', 'temporary', 'contractor', 'internship', 'part_time']),
|
||||||
|
job_position_id: z.string().uuid().optional(),
|
||||||
|
department_id: z.string().uuid().optional(),
|
||||||
|
date_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
date_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
trial_date_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
wage: z.number().min(0),
|
||||||
|
wage_type: z.string().max(20).optional(),
|
||||||
|
currency_id: z.string().uuid().optional(),
|
||||||
|
hours_per_week: z.number().min(0).max(168).optional(),
|
||||||
|
vacation_days: z.number().int().min(0).optional(),
|
||||||
|
christmas_bonus_days: z.number().int().min(0).optional(),
|
||||||
|
document_url: z.string().url().max(500).optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateContractSchema = z.object({
|
||||||
|
reference: z.string().max(100).optional().nullable(),
|
||||||
|
job_position_id: z.string().uuid().optional().nullable(),
|
||||||
|
department_id: z.string().uuid().optional().nullable(),
|
||||||
|
date_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
trial_date_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(),
|
||||||
|
wage: z.number().min(0).optional(),
|
||||||
|
wage_type: z.string().max(20).optional(),
|
||||||
|
currency_id: z.string().uuid().optional().nullable(),
|
||||||
|
hours_per_week: z.number().min(0).max(168).optional(),
|
||||||
|
vacation_days: z.number().int().min(0).optional(),
|
||||||
|
christmas_bonus_days: z.number().int().min(0).optional(),
|
||||||
|
document_url: z.string().url().max(500).optional().nullable(),
|
||||||
|
notes: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const contractQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
employee_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'active', 'expired', 'terminated', 'cancelled']).optional(),
|
||||||
|
contract_type: z.enum(['permanent', 'temporary', 'contractor', 'internship', 'part_time']).optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leave Type schemas
|
||||||
|
const createLeaveTypeSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100),
|
||||||
|
code: z.string().max(20).optional(),
|
||||||
|
leave_type: z.enum(['vacation', 'sick', 'personal', 'maternity', 'paternity', 'bereavement', 'unpaid', 'other']),
|
||||||
|
requires_approval: z.boolean().optional(),
|
||||||
|
max_days: z.number().int().min(1).optional(),
|
||||||
|
is_paid: z.boolean().optional(),
|
||||||
|
color: z.string().max(20).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLeaveTypeSchema = z.object({
|
||||||
|
name: z.string().min(1).max(100).optional(),
|
||||||
|
code: z.string().max(20).optional().nullable(),
|
||||||
|
requires_approval: z.boolean().optional(),
|
||||||
|
max_days: z.number().int().min(1).optional().nullable(),
|
||||||
|
is_paid: z.boolean().optional(),
|
||||||
|
color: z.string().max(20).optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leave schemas
|
||||||
|
const createLeaveSchema = z.object({
|
||||||
|
company_id: z.string().uuid(),
|
||||||
|
employee_id: z.string().uuid(),
|
||||||
|
leave_type_id: z.string().uuid(),
|
||||||
|
name: z.string().max(255).optional(),
|
||||||
|
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
description: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateLeaveSchema = z.object({
|
||||||
|
leave_type_id: z.string().uuid().optional(),
|
||||||
|
name: z.string().max(255).optional().nullable(),
|
||||||
|
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaveQuerySchema = z.object({
|
||||||
|
company_id: z.string().uuid().optional(),
|
||||||
|
employee_id: z.string().uuid().optional(),
|
||||||
|
leave_type_id: z.string().uuid().optional(),
|
||||||
|
status: z.enum(['draft', 'submitted', 'approved', 'rejected', 'cancelled']).optional(),
|
||||||
|
date_from: z.string().optional(),
|
||||||
|
date_to: z.string().optional(),
|
||||||
|
search: z.string().optional(),
|
||||||
|
page: z.coerce.number().int().positive().default(1),
|
||||||
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||||
|
});
|
||||||
|
|
||||||
|
const terminateSchema = z.object({
|
||||||
|
termination_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rejectSchema = z.object({
|
||||||
|
reason: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
class HrController {
|
||||||
|
// ========== EMPLOYEES ==========
|
||||||
|
|
||||||
|
async getEmployees(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = employeeQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: EmployeeFilters = queryResult.data;
|
||||||
|
const result = await employeesService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const employee = await employeesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: employee });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createEmployeeSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de empleado invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateEmployeeDto = parseResult.data;
|
||||||
|
const employee = await employeesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: employee, message: 'Empleado creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateEmployeeSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de empleado invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateEmployeeDto = parseResult.data;
|
||||||
|
const employee = await employeesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({ success: true, data: employee, message: 'Empleado actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminateEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = terminateSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = await employeesService.terminate(req.params.id, parseResult.data.termination_date, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: employee, message: 'Empleado dado de baja exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async reactivateEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const employee = await employeesService.reactivate(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: employee, message: 'Empleado reactivado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await employeesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Empleado eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubordinates(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const subordinates = await employeesService.getSubordinates(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: subordinates });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== DEPARTMENTS ==========
|
||||||
|
|
||||||
|
async getDepartments(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = departmentQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: DepartmentFilters = queryResult.data;
|
||||||
|
const result = await departmentsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDepartment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const department = await departmentsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: department });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDepartment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createDepartmentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de departamento invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateDepartmentDto = parseResult.data;
|
||||||
|
const department = await departmentsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: department, message: 'Departamento creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDepartment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateDepartmentSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de departamento invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateDepartmentDto = parseResult.data;
|
||||||
|
const department = await departmentsService.update(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({ success: true, data: department, message: 'Departamento actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteDepartment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await departmentsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Departamento eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== JOB POSITIONS ==========
|
||||||
|
|
||||||
|
async getJobPositions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const positions = await departmentsService.getJobPositions(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: positions });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createJobPosition(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createJobPositionSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de puesto invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateJobPositionDto = parseResult.data;
|
||||||
|
const position = await departmentsService.createJobPosition(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: position, message: 'Puesto creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateJobPosition(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateJobPositionSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de puesto invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateJobPositionDto = parseResult.data;
|
||||||
|
const position = await departmentsService.updateJobPosition(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({ success: true, data: position, message: 'Puesto actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteJobPosition(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await departmentsService.deleteJobPosition(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Puesto eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== CONTRACTS ==========
|
||||||
|
|
||||||
|
async getContracts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = contractQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: ContractFilters = queryResult.data;
|
||||||
|
const result = await contractsService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const contract = await contractsService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: contract });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createContractSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de contrato invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateContractDto = parseResult.data;
|
||||||
|
const contract = await contractsService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: contract, message: 'Contrato creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateContractSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de contrato invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateContractDto = parseResult.data;
|
||||||
|
const contract = await contractsService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({ success: true, data: contract, message: 'Contrato actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async activateContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const contract = await contractsService.activate(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: contract, message: 'Contrato activado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminateContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = terminateSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contract = await contractsService.terminate(req.params.id, parseResult.data.termination_date, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: contract, message: 'Contrato terminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const contract = await contractsService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: contract, message: 'Contrato cancelado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await contractsService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Contrato eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LEAVE TYPES ==========
|
||||||
|
|
||||||
|
async getLeaveTypes(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const includeInactive = req.query.include_inactive === 'true';
|
||||||
|
const leaveTypes = await leavesService.getLeaveTypes(req.tenantId!, includeInactive);
|
||||||
|
res.json({ success: true, data: leaveTypes });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLeaveType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLeaveTypeSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de tipo de ausencia invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLeaveTypeDto = parseResult.data;
|
||||||
|
const leaveType = await leavesService.createLeaveType(dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: leaveType, message: 'Tipo de ausencia creado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeaveType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLeaveTypeSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de tipo de ausencia invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLeaveTypeDto = parseResult.data;
|
||||||
|
const leaveType = await leavesService.updateLeaveType(req.params.id, dto, req.tenantId!);
|
||||||
|
|
||||||
|
res.json({ success: true, data: leaveType, message: 'Tipo de ausencia actualizado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLeaveType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await leavesService.deleteLeaveType(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Tipo de ausencia eliminado exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
|
async getLeaves(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const queryResult = leaveQuerySchema.safeParse(req.query);
|
||||||
|
if (!queryResult.success) {
|
||||||
|
throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: LeaveFilters = queryResult.data;
|
||||||
|
const result = await leavesService.findAll(req.tenantId!, filters);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.data,
|
||||||
|
meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const leave = await leavesService.findById(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, data: leave });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = createLeaveSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de ausencia invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: CreateLeaveDto = parseResult.data;
|
||||||
|
const leave = await leavesService.create(dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: leave, message: 'Solicitud de ausencia creada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = updateLeaveSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos de ausencia invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto: UpdateLeaveDto = parseResult.data;
|
||||||
|
const leave = await leavesService.update(req.params.id, dto, req.tenantId!, req.user!.userId);
|
||||||
|
|
||||||
|
res.json({ success: true, data: leave, message: 'Solicitud de ausencia actualizada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const leave = await leavesService.submit(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: leave, message: 'Solicitud enviada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const leave = await leavesService.approve(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: leave, message: 'Solicitud aprobada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = rejectSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
throw new ValidationError('Datos invalidos', parseResult.error.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leave = await leavesService.reject(req.params.id, parseResult.data.reason, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: leave, message: 'Solicitud rechazada' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
const leave = await leavesService.cancel(req.params.id, req.tenantId!, req.user!.userId);
|
||||||
|
res.json({ success: true, data: leave, message: 'Solicitud cancelada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
|
||||||
|
try {
|
||||||
|
await leavesService.delete(req.params.id, req.tenantId!);
|
||||||
|
res.json({ success: true, message: 'Solicitud eliminada exitosamente' });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hrController = new HrController();
|
||||||
152
src/modules/hr/hr.routes.ts
Normal file
152
src/modules/hr/hr.routes.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { hrController } from './hr.controller.js';
|
||||||
|
import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// All routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// ========== EMPLOYEES ==========
|
||||||
|
|
||||||
|
router.get('/employees', (req, res, next) => hrController.getEmployees(req, res, next));
|
||||||
|
|
||||||
|
router.get('/employees/:id', (req, res, next) => hrController.getEmployee(req, res, next));
|
||||||
|
|
||||||
|
router.get('/employees/:id/subordinates', (req, res, next) => hrController.getSubordinates(req, res, next));
|
||||||
|
|
||||||
|
router.post('/employees', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.createEmployee(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/employees/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.updateEmployee(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/employees/:id/terminate', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.terminateEmployee(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/employees/:id/reactivate', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.reactivateEmployee(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/employees/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.deleteEmployee(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== DEPARTMENTS ==========
|
||||||
|
|
||||||
|
router.get('/departments', (req, res, next) => hrController.getDepartments(req, res, next));
|
||||||
|
|
||||||
|
router.get('/departments/:id', (req, res, next) => hrController.getDepartment(req, res, next));
|
||||||
|
|
||||||
|
router.post('/departments', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.createDepartment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/departments/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.updateDepartment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/departments/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.deleteDepartment(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== JOB POSITIONS ==========
|
||||||
|
|
||||||
|
router.get('/positions', (req, res, next) => hrController.getJobPositions(req, res, next));
|
||||||
|
|
||||||
|
router.post('/positions', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.createJobPosition(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/positions/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.updateJobPosition(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/positions/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.deleteJobPosition(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== CONTRACTS ==========
|
||||||
|
|
||||||
|
router.get('/contracts', (req, res, next) => hrController.getContracts(req, res, next));
|
||||||
|
|
||||||
|
router.get('/contracts/:id', (req, res, next) => hrController.getContract(req, res, next));
|
||||||
|
|
||||||
|
router.post('/contracts', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.createContract(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/contracts/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.updateContract(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/contracts/:id/activate', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.activateContract(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/contracts/:id/terminate', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.terminateContract(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/contracts/:id/cancel', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.cancelContract(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/contracts/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.deleteContract(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== LEAVE TYPES ==========
|
||||||
|
|
||||||
|
router.get('/leave-types', (req, res, next) => hrController.getLeaveTypes(req, res, next));
|
||||||
|
|
||||||
|
router.post('/leave-types', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.createLeaveType(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/leave-types/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.updateLeaveType(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/leave-types/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.deleteLeaveType(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
|
router.get('/leaves', (req, res, next) => hrController.getLeaves(req, res, next));
|
||||||
|
|
||||||
|
router.get('/leaves/:id', (req, res, next) => hrController.getLeave(req, res, next));
|
||||||
|
|
||||||
|
router.post('/leaves', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.createLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put('/leaves/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.updateLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leaves/:id/submit', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.submitLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leaves/:id/approve', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.approveLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leaves/:id/reject', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.rejectLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post('/leaves/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.cancelLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
router.delete('/leaves/:id', requireRoles('admin', 'super_admin'), (req, res, next) =>
|
||||||
|
hrController.deleteLeave(req, res, next)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
6
src/modules/hr/index.ts
Normal file
6
src/modules/hr/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from './employees.service.js';
|
||||||
|
export * from './departments.service.js';
|
||||||
|
export * from './contracts.service.js';
|
||||||
|
export * from './leaves.service.js';
|
||||||
|
export * from './hr.controller.js';
|
||||||
|
export { default as hrRoutes } from './hr.routes.js';
|
||||||
517
src/modules/hr/leaves.service.ts
Normal file
517
src/modules/hr/leaves.service.ts
Normal file
@ -0,0 +1,517 @@
|
|||||||
|
import { query, queryOne } from '../../config/database.js';
|
||||||
|
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
|
||||||
|
|
||||||
|
export type LeaveStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'cancelled';
|
||||||
|
export type LeaveType = 'vacation' | 'sick' | 'personal' | 'maternity' | 'paternity' | 'bereavement' | 'unpaid' | 'other';
|
||||||
|
|
||||||
|
export interface LeaveTypeConfig {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
leave_type: LeaveType;
|
||||||
|
requires_approval: boolean;
|
||||||
|
max_days?: number;
|
||||||
|
is_paid: boolean;
|
||||||
|
color?: string;
|
||||||
|
active: boolean;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Leave {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
company_id: string;
|
||||||
|
company_name?: string;
|
||||||
|
employee_id: string;
|
||||||
|
employee_name?: string;
|
||||||
|
employee_number?: string;
|
||||||
|
leave_type_id: string;
|
||||||
|
leave_type_name?: string;
|
||||||
|
name?: string;
|
||||||
|
date_from: Date;
|
||||||
|
date_to: Date;
|
||||||
|
number_of_days: number;
|
||||||
|
status: LeaveStatus;
|
||||||
|
description?: string;
|
||||||
|
approved_by?: string;
|
||||||
|
approved_by_name?: string;
|
||||||
|
approved_at?: Date;
|
||||||
|
rejection_reason?: string;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeaveTypeDto {
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
leave_type: LeaveType;
|
||||||
|
requires_approval?: boolean;
|
||||||
|
max_days?: number;
|
||||||
|
is_paid?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeaveTypeDto {
|
||||||
|
name?: string;
|
||||||
|
code?: string | null;
|
||||||
|
requires_approval?: boolean;
|
||||||
|
max_days?: number | null;
|
||||||
|
is_paid?: boolean;
|
||||||
|
color?: string | null;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateLeaveDto {
|
||||||
|
company_id: string;
|
||||||
|
employee_id: string;
|
||||||
|
leave_type_id: string;
|
||||||
|
name?: string;
|
||||||
|
date_from: string;
|
||||||
|
date_to: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLeaveDto {
|
||||||
|
leave_type_id?: string;
|
||||||
|
name?: string | null;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
description?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveFilters {
|
||||||
|
company_id?: string;
|
||||||
|
employee_id?: string;
|
||||||
|
leave_type_id?: string;
|
||||||
|
status?: LeaveStatus;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LeavesService {
|
||||||
|
// ========== LEAVE TYPES ==========
|
||||||
|
|
||||||
|
async getLeaveTypes(tenantId: string, includeInactive = false): Promise<LeaveTypeConfig[]> {
|
||||||
|
let whereClause = 'WHERE tenant_id = $1';
|
||||||
|
if (!includeInactive) {
|
||||||
|
whereClause += ' AND active = TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query<LeaveTypeConfig>(
|
||||||
|
`SELECT * FROM hr.leave_types ${whereClause} ORDER BY name`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeaveTypeById(id: string, tenantId: string): Promise<LeaveTypeConfig> {
|
||||||
|
const leaveType = await queryOne<LeaveTypeConfig>(
|
||||||
|
`SELECT * FROM hr.leave_types WHERE id = $1 AND tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!leaveType) {
|
||||||
|
throw new NotFoundError('Tipo de ausencia no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
return leaveType;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLeaveType(dto: CreateLeaveTypeDto, tenantId: string): Promise<LeaveTypeConfig> {
|
||||||
|
const existing = await queryOne(
|
||||||
|
`SELECT id FROM hr.leave_types WHERE name = $1 AND tenant_id = $2`,
|
||||||
|
[dto.name, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Ya existe un tipo de ausencia con ese nombre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaveType = await queryOne<LeaveTypeConfig>(
|
||||||
|
`INSERT INTO hr.leave_types (tenant_id, name, code, leave_type, requires_approval, max_days, is_paid, color)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.name, dto.code, dto.leave_type,
|
||||||
|
dto.requires_approval ?? true, dto.max_days, dto.is_paid ?? true, dto.color
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return leaveType!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLeaveType(id: string, dto: UpdateLeaveTypeDto, tenantId: string): Promise<LeaveTypeConfig> {
|
||||||
|
const existing = await this.getLeaveTypeById(id, tenantId);
|
||||||
|
|
||||||
|
if (dto.name && dto.name !== existing.name) {
|
||||||
|
const nameExists = await queryOne(
|
||||||
|
`SELECT id FROM hr.leave_types WHERE name = $1 AND tenant_id = $2 AND id != $3`,
|
||||||
|
[dto.name, tenantId, id]
|
||||||
|
);
|
||||||
|
if (nameExists) {
|
||||||
|
throw new ConflictError('Ya existe un tipo de ausencia con ese nombre');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
const fieldsToUpdate = ['name', 'code', 'requires_approval', 'max_days', 'is_paid', 'color', 'active'];
|
||||||
|
|
||||||
|
for (const field of fieldsToUpdate) {
|
||||||
|
if ((dto as any)[field] !== undefined) {
|
||||||
|
updateFields.push(`${field} = $${paramIndex++}`);
|
||||||
|
values.push((dto as any)[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.leave_types SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getLeaveTypeById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteLeaveType(id: string, tenantId: string): Promise<void> {
|
||||||
|
await this.getLeaveTypeById(id, tenantId);
|
||||||
|
|
||||||
|
const inUse = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.leaves WHERE leave_type_id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(inUse?.count || '0') > 0) {
|
||||||
|
throw new ConflictError('No se puede eliminar un tipo de ausencia que esta en uso');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM hr.leave_types WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
|
async findAll(tenantId: string, filters: LeaveFilters = {}): Promise<{ data: Leave[]; total: number }> {
|
||||||
|
const { company_id, employee_id, leave_type_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let whereClause = 'WHERE l.tenant_id = $1';
|
||||||
|
const params: any[] = [tenantId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
if (company_id) {
|
||||||
|
whereClause += ` AND l.company_id = $${paramIndex++}`;
|
||||||
|
params.push(company_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (employee_id) {
|
||||||
|
whereClause += ` AND l.employee_id = $${paramIndex++}`;
|
||||||
|
params.push(employee_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave_type_id) {
|
||||||
|
whereClause += ` AND l.leave_type_id = $${paramIndex++}`;
|
||||||
|
params.push(leave_type_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND l.status = $${paramIndex++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_from) {
|
||||||
|
whereClause += ` AND l.date_from >= $${paramIndex++}`;
|
||||||
|
params.push(date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date_to) {
|
||||||
|
whereClause += ` AND l.date_to <= $${paramIndex++}`;
|
||||||
|
params.push(date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ` AND (l.name ILIKE $${paramIndex} OR e.first_name ILIKE $${paramIndex} OR e.last_name ILIKE $${paramIndex})`;
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countResult = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM hr.leaves l
|
||||||
|
LEFT JOIN hr.employees e ON l.employee_id = e.id
|
||||||
|
${whereClause}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
params.push(limit, offset);
|
||||||
|
const data = await query<Leave>(
|
||||||
|
`SELECT l.*,
|
||||||
|
c.name as company_name,
|
||||||
|
CONCAT(e.first_name, ' ', e.last_name) as employee_name,
|
||||||
|
e.employee_number,
|
||||||
|
lt.name as leave_type_name,
|
||||||
|
CONCAT(a.first_name, ' ', a.last_name) as approved_by_name
|
||||||
|
FROM hr.leaves l
|
||||||
|
LEFT JOIN auth.companies c ON l.company_id = c.id
|
||||||
|
LEFT JOIN hr.employees e ON l.employee_id = e.id
|
||||||
|
LEFT JOIN hr.leave_types lt ON l.leave_type_id = lt.id
|
||||||
|
LEFT JOIN hr.employees a ON l.approved_by = a.user_id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY l.date_from DESC
|
||||||
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
total: parseInt(countResult?.count || '0', 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string, tenantId: string): Promise<Leave> {
|
||||||
|
const leave = await queryOne<Leave>(
|
||||||
|
`SELECT l.*,
|
||||||
|
c.name as company_name,
|
||||||
|
CONCAT(e.first_name, ' ', e.last_name) as employee_name,
|
||||||
|
e.employee_number,
|
||||||
|
lt.name as leave_type_name,
|
||||||
|
CONCAT(a.first_name, ' ', a.last_name) as approved_by_name
|
||||||
|
FROM hr.leaves l
|
||||||
|
LEFT JOIN auth.companies c ON l.company_id = c.id
|
||||||
|
LEFT JOIN hr.employees e ON l.employee_id = e.id
|
||||||
|
LEFT JOIN hr.leave_types lt ON l.leave_type_id = lt.id
|
||||||
|
LEFT JOIN hr.employees a ON l.approved_by = a.user_id
|
||||||
|
WHERE l.id = $1 AND l.tenant_id = $2`,
|
||||||
|
[id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!leave) {
|
||||||
|
throw new NotFoundError('Solicitud de ausencia no encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
return leave;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateLeaveDto, tenantId: string, userId: string): Promise<Leave> {
|
||||||
|
// Calculate number of days
|
||||||
|
const startDate = new Date(dto.date_from);
|
||||||
|
const endDate = new Date(dto.date_to);
|
||||||
|
const diffTime = Math.abs(endDate.getTime() - startDate.getTime());
|
||||||
|
const numberOfDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
|
||||||
|
if (numberOfDays <= 0) {
|
||||||
|
throw new ValidationError('La fecha de fin debe ser igual o posterior a la fecha de inicio');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check leave type max days
|
||||||
|
const leaveType = await this.getLeaveTypeById(dto.leave_type_id, tenantId);
|
||||||
|
if (leaveType.max_days && numberOfDays > leaveType.max_days) {
|
||||||
|
throw new ValidationError(`Este tipo de ausencia tiene un maximo de ${leaveType.max_days} dias`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for overlapping leaves
|
||||||
|
const overlap = await queryOne<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) as count FROM hr.leaves
|
||||||
|
WHERE employee_id = $1 AND status IN ('submitted', 'approved')
|
||||||
|
AND ((date_from <= $2 AND date_to >= $2) OR (date_from <= $3 AND date_to >= $3)
|
||||||
|
OR (date_from >= $2 AND date_to <= $3))`,
|
||||||
|
[dto.employee_id, dto.date_from, dto.date_to]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (parseInt(overlap?.count || '0') > 0) {
|
||||||
|
throw new ValidationError('Ya existe una solicitud de ausencia para estas fechas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const leave = await queryOne<Leave>(
|
||||||
|
`INSERT INTO hr.leaves (
|
||||||
|
tenant_id, company_id, employee_id, leave_type_id, name, date_from, date_to,
|
||||||
|
number_of_days, description, created_by
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
tenantId, dto.company_id, dto.employee_id, dto.leave_type_id, dto.name,
|
||||||
|
dto.date_from, dto.date_to, numberOfDays, dto.description, userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(leave!.id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateLeaveDto, tenantId: string, userId: string): Promise<Leave> {
|
||||||
|
const existing = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (existing.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden editar solicitudes en borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
if (dto.leave_type_id !== undefined) {
|
||||||
|
updateFields.push(`leave_type_id = $${paramIndex++}`);
|
||||||
|
values.push(dto.leave_type_id);
|
||||||
|
}
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateFields.push(`name = $${paramIndex++}`);
|
||||||
|
values.push(dto.name);
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) {
|
||||||
|
updateFields.push(`description = $${paramIndex++}`);
|
||||||
|
values.push(dto.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate days if dates changed
|
||||||
|
let newDateFrom = existing.date_from;
|
||||||
|
let newDateTo = existing.date_to;
|
||||||
|
|
||||||
|
if (dto.date_from !== undefined) {
|
||||||
|
updateFields.push(`date_from = $${paramIndex++}`);
|
||||||
|
values.push(dto.date_from);
|
||||||
|
newDateFrom = new Date(dto.date_from);
|
||||||
|
}
|
||||||
|
if (dto.date_to !== undefined) {
|
||||||
|
updateFields.push(`date_to = $${paramIndex++}`);
|
||||||
|
values.push(dto.date_to);
|
||||||
|
newDateTo = new Date(dto.date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.date_from !== undefined || dto.date_to !== undefined) {
|
||||||
|
const diffTime = Math.abs(newDateTo.getTime() - newDateFrom.getTime());
|
||||||
|
const numberOfDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
updateFields.push(`number_of_days = $${paramIndex++}`);
|
||||||
|
values.push(numberOfDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push(`updated_by = $${paramIndex++}`);
|
||||||
|
values.push(userId);
|
||||||
|
|
||||||
|
values.push(id, tenantId);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.leaves SET ${updateFields.join(', ')}
|
||||||
|
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit(id: string, tenantId: string, userId: string): Promise<Leave> {
|
||||||
|
const leave = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (leave.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden enviar solicitudes en borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.leaves SET
|
||||||
|
status = 'submitted',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve(id: string, tenantId: string, userId: string): Promise<Leave> {
|
||||||
|
const leave = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (leave.status !== 'submitted') {
|
||||||
|
throw new ValidationError('Solo se pueden aprobar solicitudes enviadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.leaves SET
|
||||||
|
status = 'approved',
|
||||||
|
approved_by = $1,
|
||||||
|
approved_at = CURRENT_TIMESTAMP,
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update employee status if leave starts today or earlier
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
if (leave.date_from.toISOString().split('T')[0] <= today && leave.date_to.toISOString().split('T')[0] >= today) {
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.employees SET status = 'on_leave' WHERE id = $1`,
|
||||||
|
[leave.employee_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(id: string, reason: string, tenantId: string, userId: string): Promise<Leave> {
|
||||||
|
const leave = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (leave.status !== 'submitted') {
|
||||||
|
throw new ValidationError('Solo se pueden rechazar solicitudes enviadas');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.leaves SET
|
||||||
|
status = 'rejected',
|
||||||
|
rejection_reason = $1,
|
||||||
|
updated_by = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3 AND tenant_id = $4`,
|
||||||
|
[reason, userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, tenantId: string, userId: string): Promise<Leave> {
|
||||||
|
const leave = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (leave.status === 'cancelled') {
|
||||||
|
throw new ValidationError('La solicitud ya esta cancelada');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leave.status === 'rejected') {
|
||||||
|
throw new ValidationError('No se puede cancelar una solicitud rechazada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE hr.leaves SET
|
||||||
|
status = 'cancelled',
|
||||||
|
updated_by = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2 AND tenant_id = $3`,
|
||||||
|
[userId, id, tenantId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findById(id, tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, tenantId: string): Promise<void> {
|
||||||
|
const leave = await this.findById(id, tenantId);
|
||||||
|
|
||||||
|
if (leave.status !== 'draft') {
|
||||||
|
throw new ValidationError('Solo se pueden eliminar solicitudes en borrador');
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM hr.leaves WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const leavesService = new LeavesService();
|
||||||
177
src/modules/inventory/MIGRATION_STATUS.md
Normal file
177
src/modules/inventory/MIGRATION_STATUS.md
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
# Inventory Module TypeORM Migration Status
|
||||||
|
|
||||||
|
## Completed Tasks
|
||||||
|
|
||||||
|
### 1. Entity Creation (100% Complete)
|
||||||
|
All entity files have been successfully created in `/src/modules/inventory/entities/`:
|
||||||
|
|
||||||
|
- ✅ `product.entity.ts` - Product entity with types, tracking, and valuation methods
|
||||||
|
- ✅ `warehouse.entity.ts` - Warehouse entity with company relation
|
||||||
|
- ✅ `location.entity.ts` - Location entity with hierarchy support
|
||||||
|
- ✅ `stock-quant.entity.ts` - Stock quantities per location
|
||||||
|
- ✅ `lot.entity.ts` - Lot/batch tracking
|
||||||
|
- ✅ `picking.entity.ts` - Picking/fulfillment operations
|
||||||
|
- ✅ `stock-move.entity.ts` - Stock movement lines
|
||||||
|
- ✅ `inventory-adjustment.entity.ts` - Stock adjustments header
|
||||||
|
- ✅ `inventory-adjustment-line.entity.ts` - Stock adjustment lines
|
||||||
|
- ✅ `stock-valuation-layer.entity.ts` - FIFO/Average cost valuation
|
||||||
|
|
||||||
|
All entities include:
|
||||||
|
- Proper schema specification (`schema: 'inventory'`)
|
||||||
|
- Indexes on key fields
|
||||||
|
- Relations using TypeORM decorators
|
||||||
|
- Audit fields (created_at, created_by, updated_at, updated_by, deleted_at, deleted_by)
|
||||||
|
- Enums for type-safe status fields
|
||||||
|
|
||||||
|
### 2. Service Refactoring (Partial - 2/8 Complete)
|
||||||
|
|
||||||
|
#### ✅ Completed Services:
|
||||||
|
1. **products.service.ts** - Fully migrated to TypeORM
|
||||||
|
- Uses Repository pattern
|
||||||
|
- All CRUD operations converted
|
||||||
|
- Proper error handling and logging
|
||||||
|
- Stock validation before deletion
|
||||||
|
|
||||||
|
2. **warehouses.service.ts** - Fully migrated to TypeORM
|
||||||
|
- Company relations properly loaded
|
||||||
|
- Default warehouse handling
|
||||||
|
- Stock validation
|
||||||
|
- Location and stock retrieval
|
||||||
|
|
||||||
|
#### ⏳ Remaining Services to Migrate:
|
||||||
|
3. **locations.service.ts** - Needs TypeORM migration
|
||||||
|
- Current: Uses raw SQL queries
|
||||||
|
- Todo: Convert to Repository pattern with QueryBuilder
|
||||||
|
- Key features: Hierarchical locations, parent-child relationships
|
||||||
|
|
||||||
|
4. **lots.service.ts** - Needs TypeORM migration
|
||||||
|
- Current: Uses raw SQL queries
|
||||||
|
- Todo: Convert to Repository pattern
|
||||||
|
- Key features: Expiration tracking, stock quantity aggregation
|
||||||
|
|
||||||
|
5. **pickings.service.ts** - Needs TypeORM migration (COMPLEX)
|
||||||
|
- Current: Uses raw SQL with transactions
|
||||||
|
- Todo: Convert to TypeORM with QueryRunner for transactions
|
||||||
|
- Key features: Multi-line operations, status workflows, stock updates
|
||||||
|
|
||||||
|
6. **adjustments.service.ts** - Needs TypeORM migration (COMPLEX)
|
||||||
|
- Current: Uses raw SQL with transactions
|
||||||
|
- Todo: Convert to TypeORM with QueryRunner
|
||||||
|
- Key features: Multi-line operations, theoretical vs counted quantities
|
||||||
|
|
||||||
|
7. **valuation.service.ts** - Needs TypeORM migration (COMPLEX)
|
||||||
|
- Current: Uses raw SQL with client transactions
|
||||||
|
- Todo: Convert to TypeORM while maintaining FIFO logic
|
||||||
|
- Key features: Valuation layer management, FIFO consumption
|
||||||
|
|
||||||
|
8. **stock-quants.service.ts** - NEW SERVICE NEEDED
|
||||||
|
- Currently no dedicated service (operations are in other services)
|
||||||
|
- Should handle: Stock queries, reservations, availability checks
|
||||||
|
|
||||||
|
### 3. TypeORM Configuration
|
||||||
|
- ✅ Entities imported in `/src/config/typeorm.ts`
|
||||||
|
- ⚠️ **ACTION REQUIRED**: Add entities to the `entities` array in AppDataSource configuration
|
||||||
|
|
||||||
|
Add these lines after `FiscalPeriod,` in the entities array:
|
||||||
|
```typescript
|
||||||
|
// Inventory Entities
|
||||||
|
Product,
|
||||||
|
Warehouse,
|
||||||
|
Location,
|
||||||
|
StockQuant,
|
||||||
|
Lot,
|
||||||
|
Picking,
|
||||||
|
StockMove,
|
||||||
|
InventoryAdjustment,
|
||||||
|
InventoryAdjustmentLine,
|
||||||
|
StockValuationLayer,
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Controller Updates
|
||||||
|
- ⏳ **inventory.controller.ts** - Needs snake_case/camelCase handling
|
||||||
|
- Current: Only accepts snake_case from frontend
|
||||||
|
- Todo: Add transformers or accept both formats
|
||||||
|
- Pattern: Use class-transformer decorators or manual mapping
|
||||||
|
|
||||||
|
### 5. Index File
|
||||||
|
- ✅ Created `/src/modules/inventory/entities/index.ts` - Exports all entities
|
||||||
|
|
||||||
|
## Migration Patterns Used
|
||||||
|
|
||||||
|
### Repository Pattern
|
||||||
|
```typescript
|
||||||
|
class ProductsService {
|
||||||
|
private productRepository: Repository<Product>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.productRepository = AppDataSource.getRepository(Product);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### QueryBuilder for Complex Queries
|
||||||
|
```typescript
|
||||||
|
const products = await this.productRepository
|
||||||
|
.createQueryBuilder('product')
|
||||||
|
.where('product.tenantId = :tenantId', { tenantId })
|
||||||
|
.andWhere('product.deletedAt IS NULL')
|
||||||
|
.getMany();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relations Loading
|
||||||
|
```typescript
|
||||||
|
.leftJoinAndSelect('warehouse.company', 'company')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
// operations
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error message', { error, context });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Remaining Work
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
1. **Add entities to typeorm.ts entities array** (Manual edit required)
|
||||||
|
2. **Migrate locations.service.ts** - Simple, good next step
|
||||||
|
3. **Migrate lots.service.ts** - Simple, includes aggregations
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
4. **Create stock-quants.service.ts** - New service for stock operations
|
||||||
|
5. **Migrate pickings.service.ts** - Complex transactions
|
||||||
|
6. **Migrate adjustments.service.ts** - Complex transactions
|
||||||
|
|
||||||
|
### Lower Priority
|
||||||
|
7. **Migrate valuation.service.ts** - Most complex, FIFO logic
|
||||||
|
8. **Update controller for case handling** - Nice to have
|
||||||
|
9. **Add integration tests** - Verify TypeORM migration works correctly
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
After completing migration:
|
||||||
|
- [ ] Test product CRUD operations
|
||||||
|
- [ ] Test warehouse operations with company relations
|
||||||
|
- [ ] Test stock queries with filters
|
||||||
|
- [ ] Test multi-level location hierarchies
|
||||||
|
- [ ] Test lot expiration tracking
|
||||||
|
- [ ] Test picking workflows (draft → confirmed → done)
|
||||||
|
- [ ] Test inventory adjustments with stock updates
|
||||||
|
- [ ] Test FIFO valuation consumption
|
||||||
|
- [ ] Test transaction rollbacks on errors
|
||||||
|
- [ ] Performance test: Compare query performance vs raw SQL
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All entities use the `inventory` schema
|
||||||
|
- Soft deletes are implemented for products (deletedAt field)
|
||||||
|
- Hard deletes are used for other entities where appropriate
|
||||||
|
- Audit trails are maintained (created_by, updated_by, etc.)
|
||||||
|
- Foreign keys properly set up with @JoinColumn decorators
|
||||||
|
- Indexes added on frequently queried fields
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
None - The migration maintains API compatibility. All DTOs use camelCase internally but accept snake_case from the original queries.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user