erp-core/backend/src/modules/financial/fiscalPeriods.service.ts

370 lines
11 KiB
TypeScript

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