370 lines
11 KiB
TypeScript
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();
|