Initial commit - erp-core

This commit is contained in:
rckrdmrd 2026-01-04 06:12:07 -06:00
commit 59f1e3badf
1271 changed files with 535514 additions and 0 deletions

22
.env.example Normal file
View 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
View 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/

31
INVENTARIO.yml Normal file
View File

@ -0,0 +1,31 @@
# Inventario generado por EPIC-008
proyecto: erp-core
fecha: "2026-01-04"
generado_por: "inventory-project.sh v1.0.0"
inventario:
docs:
total: 870
por_tipo:
markdown: 829
yaml: 26
json: 0
orchestration:
total: 32
por_tipo:
markdown: 25
yaml: 6
json: 1
problemas:
archivos_obsoletos: 0
referencias_antiguas: 0
simco_faltantes:
- _MAP.md en docs/
- PROJECT-STATUS.md
estado_simco:
herencia_simco: true
contexto_proyecto: true
map_docs: false
project_status: false

27
PROJECT-STATUS.md Normal file
View File

@ -0,0 +1,27 @@
# ESTADO DEL PROYECTO
**Proyecto:** erp-generic
**Estado:** 📋 En planificación
**Progreso:** 0%
**Última actualización:** 2025-11-24
---
## 📊 RESUMEN
- **Fase actual:** Análisis y planificación
- **Módulos completados:** 0
- **Módulos en desarrollo:** 0
- **Módulos pendientes:** 10
---
## 🎯 PRÓXIMOS PASOS
1. Completar análisis de requerimientos
2. Modelado de dominio
3. Comparación con Odoo
4. Diseño de base de datos
5. Inicio de desarrollo
---

114
README.md Normal file
View File

@ -0,0 +1,114 @@
# ERP Core - Base Genérica Reutilizable
## Descripción
ERP Core es el módulo base que proporciona el **60-70% del código compartido** para todas las verticales del ERP Suite. Contiene la funcionalidad común que será extendida por cada vertical específica.
**Estado:** En desarrollo (60%)
**Versión:** 0.1.0
## Estructura del Proyecto
```
erp-core/
├── backend/ # API REST (Node.js + Express + TypeScript)
│ ├── src/
│ │ ├── modules/ # Módulos de negocio
│ │ ├── shared/ # Código compartido
│ │ └── config/ # Configuración
│ ├── package.json
│ └── tsconfig.json
├── frontend/ # Web App (React + Vite + TypeScript)
│ ├── src/
│ │ ├── components/ # Componentes reutilizables
│ │ ├── pages/ # Páginas
│ │ ├── stores/ # Estado (Zustand)
│ │ └── services/ # Servicios API
│ ├── package.json
│ └── vite.config.ts
├── database/ # PostgreSQL
│ ├── ddl/ # Definiciones de tablas
│ ├── migrations/ # Migraciones
│ └── seeds/ # Datos iniciales
├── docs/ # Documentación del proyecto
│ ├── 00-vision-general/
│ ├── 01-fase-mvp/
│ ├── 02-modelado/
│ └── ...
└── orchestration/ # Sistema de agentes NEXUS
├── 00-guidelines/
│ └── CONTEXTO-PROYECTO.md
├── trazas/ # Historial de tareas por agente
│ ├── TRAZA-TAREAS-BACKEND.md
│ ├── TRAZA-TAREAS-FRONTEND.md
│ └── TRAZA-TAREAS-DATABASE.md
├── estados/ # Estado actual de agentes
└── PROXIMA-ACCION.md
```
## Stack Tecnológico
| Capa | Tecnología |
|------|------------|
| **Backend** | Node.js 20+, Express, TypeScript, TypeORM |
| **Frontend** | React 18, Vite, TypeScript, Tailwind CSS, Zustand |
| **Database** | PostgreSQL 15+ con RLS |
| **Auth** | JWT + bcryptjs |
## Módulos Core
| Módulo | Estado | Descripción |
|--------|--------|-------------|
| `auth` | En desarrollo | Autenticación y autorización |
| `users` | Planificado | Gestión de usuarios |
| `roles` | Planificado | Roles y permisos (RBAC) |
| `tenants` | Planificado | Multi-tenancy |
| `catalogs` | Planificado | Catálogos maestros |
| `settings` | Planificado | Configuración del sistema |
| `audit` | Planificado | Auditoría y logs |
| `reports` | Planificado | Sistema de reportes |
| `financial` | Planificado | Módulo financiero básico |
| `inventory` | Planificado | Módulo de inventario básico |
| `purchasing` | Planificado | Módulo de compras básico |
| `crm` | Planificado | CRM básico |
## Inicio Rápido
```bash
# Backend
cd backend
npm install
cp .env.example .env
npm run dev
# Frontend
cd frontend
npm install
npm run dev
```
## Documentación
- **Contexto del proyecto:** `orchestration/00-guidelines/CONTEXTO-PROYECTO.md`
- **Próxima tarea:** `orchestration/PROXIMA-ACCION.md`
- **Trazas de agentes:** `orchestration/trazas/`
- **Documentación técnica:** `docs/`
## Relación con Verticales
Las verticales (construcción, vidrio-templado, etc.) **extienden** este core:
```
erp-core (60-70%)
↓ hereda
vertical-construccion (+30-40% específico)
vertical-vidrio-templado (+30-40% específico)
...
```
---
*Proyecto parte de ERP Suite - Fábrica de Software con Agentes IA*

22
backend/.env.example Normal file
View 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
backend/.gitignore vendored Normal file
View 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
backend/Dockerfile Normal file
View 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"]

View 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

View 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

View 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
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
backend/package.json Normal file
View 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"
}
}

View 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
backend/src/app.ts Normal file
View 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;

View 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');
}

View 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
backend/src/config/redis.ts Normal file
View 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,
});
}
}

View 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 };

View 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;
}

View 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
backend/src/index.ts Normal file
View 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);
});

View 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();

View 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;

View 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();

View 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();

View 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;

View 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();

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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();

View 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();

View 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;

View 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();

View File

@ -0,0 +1,3 @@
export * from './companies.service.js';
export * from './companies.controller.js';
export { default as companiesRoutes } from './companies.routes.js';

View 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();

View 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;

View 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();

View 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();

View 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;
}

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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();

View 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();

View 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();

View 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();

View 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;

View 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';

View 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();

View 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();

View 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();

View 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

View 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();

View 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();

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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();

View 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;

View 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();

View 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';

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

Some files were not shown because too many files have changed in this diff Show More