- 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>
472 lines
12 KiB
TypeScript
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();
|