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 { 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(sql, params); } async findYearById(id: string, tenantId: string): Promise { const year = await queryOne( `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 { // 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( `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 { 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( `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 { const period = await queryOne( `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 { return queryOne( `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 { // 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( `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 { // Verify period exists and belongs to tenant await this.findPeriodById(periodId, tenantId); // Use database function for atomic close with validations const result = await queryOne( `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 { // Verify period exists and belongs to tenant await this.findPeriodById(periodId, tenantId); // Use database function for atomic reopen with audit const result = await queryOne( `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 { 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();