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; private accountTypeRepository: Repository; constructor() { this.accountRepository = AppDataSource.getRepository(Account); this.accountTypeRepository = AppDataSource.getRepository(AccountType); } /** * Get all account types (catalog) */ async findAllAccountTypes(): Promise { return this.accountTypeRepository.find({ order: { code: 'ASC' }, }); } /** * Get account type by ID */ async findAccountTypeById(id: string): Promise { 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 { 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 { try { // Validate unique code within company and tenant (RLS compliance) const existing = await this.accountRepository.findOne({ where: { tenantId, 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 (RLS compliance) if (dto.parentId) { const parent = await this.accountRepository.findOne({ where: { id: dto.parentId, tenantId, 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 { 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, tenantId, 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 { 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 { let currentId: string | null = newParentId; const visited = new Set(); 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();