- Fix compound inflation: use Math.pow(1 + rate/100, yearsGap) instead of flat rate so multi-year gaps (e.g., 2026→2029) compound annually - Budget Planner: add CSV import flow + Download Template button; show proper empty state when no base budget exists with Create/Import options - Budget Manager: remove CSV import, Download Template, and Save buttons; redirect users to Budget Planner when no budget exists for selected year - Fix getAvailableYears to return null latestBudgetYear when no budgets exist and include current year in year selector for fresh tenants Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
408 lines
16 KiB
TypeScript
408 lines
16 KiB
TypeScript
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<string, string[]> = {
|
|
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<string> {
|
|
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 };
|
|
}
|
|
}
|