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; } export interface UpdateCompanyDto { name?: string; legalName?: string | null; taxId?: string | null; currencyId?: string | null; parentCompanyId?: string | null; settings?: Record; } 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; 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 { 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 { 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 { 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 { 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 { 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 { 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 { try { // Get all companies const companies = await this.companyRepository.find({ where: { tenantId, deletedAt: IsNull() }, order: { name: 'ASC' }, }); // Build tree structure const companyMap = new Map(); 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 { let currentId: string | null = newParentId; const visited = new Set(); 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();