erp-core-backend-v2/src/modules/financial/accounts.service.ts
Adrian Flores Cortes 2d2a562274 [TASK-028] security: Add tenant_id validation to uniqueness checks
- warehouses.service.ts: Add code uniqueness check with tenantId
- products.service.ts: Add SKU/barcode uniqueness checks with tenantId
- accounts.service.ts: Add tenantId to code and parent validation

Fixes 5 RLS gaps in backend services for multi-tenant isolation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 22:22:59 -06:00

472 lines
12 KiB
TypeScript

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