feat: add Future Year Budget Planning with inflation-adjusted projections

Adds budget planning capability under Board Planning, allowing HOA boards
to model future year budgets with configurable per-year inflation rates.

Backend:
- New budget_plans + budget_plan_lines tables (migration 014)
- BudgetPlanningService: CRUD, inflation generation (per-month preservation),
  status workflow (planning → approved → ratified), ratify-to-official copy
- 8 new API endpoints on board-planning controller
- Projection engine (both board-planning and reports) now falls back to
  planned budgets via UNION ALL query when no official budget exists
- Extended year range from 3 to dynamic based on projection months

Frontend:
- BudgetPlanningPage with monthly grid table (mirrors BudgetsPage pattern)
- Year selector, inflation rate control, status progression buttons
- Inline editing with save, confirmation modals for status changes
- Manual edit tracking with visual indicator
- Summary cards for income/expense totals

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 10:24:18 -04:00
parent c8d77aaa48
commit e6fe2314de
10 changed files with 1052 additions and 17 deletions

View File

@@ -202,13 +202,36 @@ export class BoardPlanningProjectionService {
`SELECT frequency, regular_assessment, special_assessment, unit_count FROM assessment_groups WHERE is_active = true`,
);
// Budgets
// Budgets (official + planned budget fallback)
const budgetsByYearMonth: Record<string, any> = {};
for (const yr of [startYear, startYear + 1, startYear + 2]) {
const budgetRows = await this.tenant.query(
`SELECT b.fund_type, a.account_type, b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1`, [yr],
);
const endYear = startYear + Math.ceil(months / 12) + 1;
for (let yr = startYear; yr <= endYear; yr++) {
let budgetRows: any[];
try {
budgetRows = await this.tenant.query(
`SELECT fund_type, account_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt FROM (
SELECT b.account_id, b.fund_type, a.account_type,
b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt,
1 as source_priority
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1
UNION ALL
SELECT bpl.account_id, bpl.fund_type, a.account_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,
2 as source_priority
FROM budget_plan_lines bpl
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
JOIN accounts a ON a.id = bpl.account_id
WHERE bp.fiscal_year = $1
) combined
ORDER BY account_id, fund_type, source_priority`, [yr],
);
} catch {
// budget_plan_lines may not exist yet - fall back to official only
budgetRows = await this.tenant.query(
`SELECT b.fund_type, a.account_type, b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1`, [yr],
);
}
for (let m = 0; m < 12; m++) {
const key = `${yr}-${m + 1}`;
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };

View File

@@ -4,6 +4,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { BoardPlanningService } from './board-planning.service';
import { BoardPlanningProjectionService } from './board-planning-projection.service';
import { BudgetPlanningService } from './budget-planning.service';
@ApiTags('board-planning')
@Controller('board-planning')
@@ -13,6 +14,7 @@ export class BoardPlanningController {
constructor(
private service: BoardPlanningService,
private projection: BoardPlanningProjectionService,
private budgetPlanning: BudgetPlanningService,
) {}
// ── Scenarios ──
@@ -127,4 +129,49 @@ export class BoardPlanningController {
) {
return this.service.executeInvestment(id, dto.executionDate, req.user.sub);
}
// ── Budget Planning ──
@Get('budget-plans')
@AllowViewer()
listBudgetPlans() {
return this.budgetPlanning.listPlans();
}
@Get('budget-plans/available-years')
@AllowViewer()
getAvailableYears() {
return this.budgetPlanning.getAvailableYears();
}
@Get('budget-plans/:year')
@AllowViewer()
getBudgetPlan(@Param('year') year: string) {
return this.budgetPlanning.getPlan(parseInt(year, 10));
}
@Post('budget-plans')
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
}
@Put('budget-plans/:year/lines')
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
}
@Put('budget-plans/:year/inflation')
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
}
@Put('budget-plans/:year/status')
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
}
@Delete('budget-plans/:year')
deleteBudgetPlan(@Param('year') year: string) {
return this.budgetPlanning.deletePlan(parseInt(year, 10));
}
}

View File

@@ -2,10 +2,11 @@ import { Module } from '@nestjs/common';
import { BoardPlanningController } from './board-planning.controller';
import { BoardPlanningService } from './board-planning.service';
import { BoardPlanningProjectionService } from './board-planning-projection.service';
import { BudgetPlanningService } from './budget-planning.service';
@Module({
controllers: [BoardPlanningController],
providers: [BoardPlanningService, BoardPlanningProjectionService],
exports: [BoardPlanningService],
providers: [BoardPlanningService, BoardPlanningProjectionService, BudgetPlanningService],
exports: [BoardPlanningService, BudgetPlanningService],
})
export class BoardPlanningModule {}

View File

@@ -0,0 +1,269 @@
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.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 latestBudgetYear = result[0]?.max_year || 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 after latest budget, marking which have plans
const years = [];
for (let i = 1; i <= 5; i++) {
const yr = latestBudgetYear + 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);
return this.getPlan(fiscalYear);
}
async generateLines(planId: string, baseYear: number, inflationRate: 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;
const multiplier = 1 + inflationRate / 100;
// 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);
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 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 };
}
}

View File

@@ -864,15 +864,37 @@ export class ReportsService {
// We need budgets for startYear and startYear+1 to cover 24 months
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
for (const yr of [startYear, startYear + 1, startYear + 2]) {
const budgetRows = await this.tenant.query(
`SELECT b.fund_type, a.account_type,
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1`, [yr],
);
const endYear = startYear + Math.ceil(months / 12) + 1;
for (let yr = startYear; yr <= endYear; yr++) {
let budgetRows: any[];
try {
budgetRows = await this.tenant.query(
`SELECT fund_type, account_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt FROM (
SELECT b.account_id, b.fund_type, a.account_type,
b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt,
1 as source_priority
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1
UNION ALL
SELECT bpl.account_id, bpl.fund_type, a.account_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,
2 as source_priority
FROM budget_plan_lines bpl
JOIN budget_plans bp ON bp.id = bpl.budget_plan_id
JOIN accounts a ON a.id = bpl.account_id
WHERE bp.fiscal_year = $1
) combined
ORDER BY account_id, fund_type, source_priority`, [yr],
);
} catch {
budgetRows = await this.tenant.query(
`SELECT b.fund_type, a.account_type,
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1`, [yr],
);
}
for (let m = 0; m < 12; m++) {
const key = `${yr}-${m + 1}`;
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };