import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { TenantService } from '../../database/tenant.service'; const monthCols = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt']; @Injectable() export class BudgetPlanningService { constructor(private tenant: TenantService) {} // ── Plans CRUD ── async listPlans() { return this.tenant.query( `SELECT bp.*, (SELECT COUNT(*) FROM budget_plan_lines bpl WHERE bpl.budget_plan_id = bp.id) as line_count FROM budget_plans bp ORDER BY bp.fiscal_year`, ); } async getPlan(fiscalYear: number) { const plans = await this.tenant.query( 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], ); if (!plans.length) return null; const plan = plans[0]; const lines = await this.tenant.query( `SELECT bpl.*, a.account_number, a.name as account_name, a.account_type, a.fund_type as account_fund_type FROM budget_plan_lines bpl JOIN accounts a ON a.id = bpl.account_id WHERE bpl.budget_plan_id = $1 ORDER BY a.account_number`, [plan.id], ); return { ...plan, lines }; } async getAvailableYears() { // Find the latest year that has official budgets const result = await this.tenant.query( 'SELECT MAX(fiscal_year) as max_year FROM budgets', ); const rawMaxYear = result[0]?.max_year; const latestBudgetYear = rawMaxYear || null; // null means no budgets exist at all const baseYear = rawMaxYear || new Date().getFullYear(); // Also find years that already have plans const existingPlans = await this.tenant.query( 'SELECT fiscal_year, status FROM budget_plans ORDER BY fiscal_year', ); const planYears = existingPlans.map((p: any) => ({ year: p.fiscal_year, status: p.status, })); // Return next 5 years (or current year + 4 if no budgets exist) const years = []; const startOffset = rawMaxYear ? 1 : 0; // include current year if no budgets exist for (let i = startOffset; i <= startOffset + 4; i++) { const yr = baseYear + i; const existing = planYears.find((p: any) => p.year === yr); years.push({ year: yr, hasPlan: !!existing, status: existing?.status || null, }); } return { latestBudgetYear, years, existingPlans: planYears }; } async createPlan(fiscalYear: number, baseYear: number, inflationRate: number, userId: string) { // Check no existing plan for this year const existing = await this.tenant.query( 'SELECT id FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], ); if (existing.length) { throw new BadRequestException(`A budget plan already exists for ${fiscalYear}`); } // Create the plan const rows = await this.tenant.query( `INSERT INTO budget_plans (fiscal_year, base_year, inflation_rate, created_by) VALUES ($1, $2, $3, $4) RETURNING *`, [fiscalYear, baseYear, inflationRate, userId], ); const plan = rows[0]; // Generate inflated lines from base year await this.generateLines(plan.id, baseYear, inflationRate, fiscalYear); return this.getPlan(fiscalYear); } async generateLines(planId: string, baseYear: number, inflationRate: number, fiscalYear: number) { // Delete existing non-manually-adjusted lines (or all if fresh) await this.tenant.query( 'DELETE FROM budget_plan_lines WHERE budget_plan_id = $1 AND is_manually_adjusted = false', [planId], ); // Try official budgets first, then fall back to budget_plan_lines for base year let baseLines = await this.tenant.query( `SELECT b.account_id, b.fund_type, ${monthCols.join(', ')} FROM budgets b WHERE b.fiscal_year = $1`, [baseYear], ); if (!baseLines.length) { // Fall back to budget_plan_lines for base year (for chained plans) baseLines = await this.tenant.query( `SELECT bpl.account_id, bpl.fund_type, ${monthCols.join(', ')} FROM budget_plan_lines bpl JOIN budget_plans bp ON bp.id = bpl.budget_plan_id WHERE bp.fiscal_year = $1`, [baseYear], ); } if (!baseLines.length) return; // Compound inflation: (1 + rate/100)^yearsGap const yearsGap = Math.max(1, fiscalYear - baseYear); const multiplier = Math.pow(1 + inflationRate / 100, yearsGap); // Get existing manually-adjusted lines to avoid duplicates const manualLines = await this.tenant.query( `SELECT account_id, fund_type FROM budget_plan_lines WHERE budget_plan_id = $1 AND is_manually_adjusted = true`, [planId], ); const manualKeys = new Set(manualLines.map((l: any) => `${l.account_id}-${l.fund_type}`)); for (const line of baseLines) { const key = `${line.account_id}-${line.fund_type}`; if (manualKeys.has(key)) continue; // Don't overwrite manual edits const inflated = monthCols.map((m) => { const val = parseFloat(line[m]) || 0; return Math.round(val * multiplier * 100) / 100; }); await this.tenant.query( `INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ON CONFLICT (budget_plan_id, account_id, fund_type) DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9, jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15, is_manually_adjusted=false`, [planId, line.account_id, line.fund_type, ...inflated], ); } } async updateLines(planId: string, lines: any[]) { for (const line of lines) { const monthValues = monthCols.map((m) => { const key = m === 'dec_amt' ? 'dec' : m; return line[key] ?? line[m] ?? 0; }); await this.tenant.query( `INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, is_manually_adjusted) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, true) ON CONFLICT (budget_plan_id, account_id, fund_type) DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9, jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15, is_manually_adjusted=true`, [planId, line.accountId, line.fundType, ...monthValues], ); } return { updated: lines.length }; } async updateInflation(fiscalYear: number, inflationRate: number) { const plans = await this.tenant.query( 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], ); if (!plans.length) throw new NotFoundException('Budget plan not found'); const plan = plans[0]; if (plan.status === 'ratified') { throw new BadRequestException('Cannot modify inflation on a ratified budget'); } await this.tenant.query( 'UPDATE budget_plans SET inflation_rate = $1, updated_at = NOW() WHERE fiscal_year = $2', [inflationRate, fiscalYear], ); // Re-generate only non-manually-adjusted lines await this.generateLines(plan.id, plan.base_year, inflationRate, fiscalYear); return this.getPlan(fiscalYear); } async advanceStatus(fiscalYear: number, newStatus: string, userId: string) { const plans = await this.tenant.query( 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], ); if (!plans.length) throw new NotFoundException('Budget plan not found'); const plan = plans[0]; const validTransitions: Record = { planning: ['approved'], approved: ['planning', 'ratified'], ratified: ['approved'], }; if (!validTransitions[plan.status]?.includes(newStatus)) { throw new BadRequestException(`Cannot transition from ${plan.status} to ${newStatus}`); } // If reverting from ratified, remove official budget if (plan.status === 'ratified' && newStatus === 'approved') { await this.tenant.query('DELETE FROM budgets WHERE fiscal_year = $1', [fiscalYear]); } const updates: string[] = ['status = $1', 'updated_at = NOW()']; const params: any[] = [newStatus]; if (newStatus === 'approved') { updates.push(`approved_by = $${params.length + 1}`, `approved_at = NOW()`); params.push(userId); } else if (newStatus === 'ratified') { updates.push(`ratified_by = $${params.length + 1}`, `ratified_at = NOW()`); params.push(userId); } params.push(fiscalYear); await this.tenant.query( `UPDATE budget_plans SET ${updates.join(', ')} WHERE fiscal_year = $${params.length}`, params, ); // If ratifying, copy to official budgets if (newStatus === 'ratified') { await this.ratifyToOfficial(plan.id, fiscalYear); } return this.getPlan(fiscalYear); } private async ratifyToOfficial(planId: string, fiscalYear: number) { // Clear existing official budgets for this year await this.tenant.query('DELETE FROM budgets WHERE fiscal_year = $1', [fiscalYear]); // Copy plan lines to official budgets await this.tenant.query( `INSERT INTO budgets (fiscal_year, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, notes) SELECT $1, bpl.account_id, bpl.fund_type, bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun, bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt, bpl.notes FROM budget_plan_lines bpl WHERE bpl.budget_plan_id = $2`, [fiscalYear, planId], ); } async importLines(fiscalYear: number, lines: any[], userId: string) { // Ensure plan exists (create if needed) let plans = await this.tenant.query( 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], ); if (!plans.length) { await this.tenant.query( `INSERT INTO budget_plans (fiscal_year, base_year, inflation_rate, created_by) VALUES ($1, $1, 0, $2) RETURNING *`, [fiscalYear, userId], ); plans = await this.tenant.query( 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], ); } const plan = plans[0]; const errors: string[] = []; const created: string[] = []; let imported = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const accountNumber = String(line.accountNumber || line.account_number || '').trim(); const accountName = String(line.accountName || line.account_name || '').trim(); if (!accountNumber) { errors.push(`Row ${i + 1}: missing account_number`); continue; } let accounts = await this.tenant.query( `SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`, [accountNumber], ); // Auto-create account if not found if ((!accounts || accounts.length === 0) && accountName) { const accountType = this.inferAccountType(accountNumber, accountName); const fundType = this.inferFundType(accountNumber, accountName); await this.tenant.query( `INSERT INTO accounts (account_number, name, account_type, fund_type, is_system) VALUES ($1, $2, $3, $4, false)`, [accountNumber, accountName, accountType, fundType], ); accounts = await this.tenant.query( `SELECT id, fund_type, account_type FROM accounts WHERE account_number = $1 AND is_active = true`, [accountNumber], ); created.push(`${accountNumber} - ${accountName} (${accountType}/${fundType})`); } if (!accounts || accounts.length === 0) { errors.push(`Row ${i + 1}: account "${accountNumber}" not found`); continue; } const account = accounts[0]; const fundType = line.fund_type || account.fund_type || 'operating'; const monthValues = monthCols.map((m) => { const key = m === 'dec_amt' ? 'dec' : m; return this.parseCurrency(line[key] ?? line[m] ?? 0); }); await this.tenant.query( `INSERT INTO budget_plan_lines (budget_plan_id, account_id, fund_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt, is_manually_adjusted) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, true) ON CONFLICT (budget_plan_id, account_id, fund_type) DO UPDATE SET jan=$4, feb=$5, mar=$6, apr=$7, may=$8, jun=$9, jul=$10, aug=$11, sep=$12, oct=$13, nov=$14, dec_amt=$15, is_manually_adjusted=true`, [plan.id, account.id, fundType, ...monthValues], ); imported++; } return { imported, errors, created, plan: await this.getPlan(fiscalYear) }; } async getTemplate(fiscalYear: number): Promise { const rows = await this.tenant.query( `SELECT a.account_number, a.name as account_name, COALESCE(b.jan, 0) as jan, COALESCE(b.feb, 0) as feb, COALESCE(b.mar, 0) as mar, COALESCE(b.apr, 0) as apr, COALESCE(b.may, 0) as may, COALESCE(b.jun, 0) as jun, COALESCE(b.jul, 0) as jul, COALESCE(b.aug, 0) as aug, COALESCE(b.sep, 0) as sep, COALESCE(b.oct, 0) as oct, COALESCE(b.nov, 0) as nov, COALESCE(b.dec_amt, 0) as dec FROM accounts a LEFT JOIN budgets b ON b.account_id = a.id AND b.fiscal_year = $1 WHERE a.is_active = true AND a.account_type IN ('income', 'expense') ORDER BY a.account_number`, [fiscalYear], ); const header = 'account_number,account_name,jan,feb,mar,apr,may,jun,jul,aug,sep,oct,nov,dec'; const csvLines = rows.map((r: any) => { const name = String(r.account_name).includes(',') ? `"${r.account_name}"` : r.account_name; return [r.account_number, name, r.jan, r.feb, r.mar, r.apr, r.may, r.jun, r.jul, r.aug, r.sep, r.oct, r.nov, r.dec].join(','); }); return [header, ...csvLines].join('\n'); } private parseCurrency(val: string | number | undefined | null): number { if (val === undefined || val === null) return 0; if (typeof val === 'number') return val; let s = String(val).trim(); if (!s || s === '-' || s === '$-' || s === '$ -') return 0; const isNegative = s.includes('(') && s.includes(')'); s = s.replace(/[$,\s()]/g, ''); if (!s || s === '-') return 0; const num = parseFloat(s); if (isNaN(num)) return 0; return isNegative ? -num : num; } private inferAccountType(accountNumber: string, accountName: string): string { const prefix = parseInt(accountNumber.split('-')[0].trim(), 10); if (isNaN(prefix)) return 'expense'; const nameUpper = (accountName || '').toUpperCase(); if (prefix >= 3000 && prefix < 4000) return 'income'; if (nameUpper.includes('INCOME') || nameUpper.includes('REVENUE') || nameUpper.includes('ASSESSMENT')) return 'income'; return 'expense'; } private inferFundType(accountNumber: string, accountName: string): string { const prefix = parseInt(accountNumber.split('-')[0].trim(), 10); const nameUpper = (accountName || '').toUpperCase(); if (nameUpper.includes('RESERVE')) return 'reserve'; if (prefix >= 7000 && prefix < 8000) return 'reserve'; return 'operating'; } async deletePlan(fiscalYear: number) { const plans = await this.tenant.query( 'SELECT * FROM budget_plans WHERE fiscal_year = $1', [fiscalYear], ); if (!plans.length) throw new NotFoundException('Budget plan not found'); if (plans[0].status !== 'planning') { throw new BadRequestException('Can only delete plans in planning status'); } await this.tenant.query('DELETE FROM budget_plans WHERE fiscal_year = $1', [fiscalYear]); return { deleted: true }; } }