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

@@ -424,6 +424,41 @@ export class TenantSchemaService {
updated_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Budget Plans
`CREATE TABLE "${s}".budget_plans (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fiscal_year INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'planning' CHECK (status IN ('planning', 'approved', 'ratified')),
base_year INTEGER NOT NULL,
inflation_rate DECIMAL(5,2) NOT NULL DEFAULT 2.50,
notes TEXT,
created_by UUID,
approved_by UUID,
approved_at TIMESTAMPTZ,
ratified_by UUID,
ratified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(fiscal_year)
)`,
// Budget Plan Lines
`CREATE TABLE "${s}".budget_plan_lines (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
budget_plan_id UUID NOT NULL REFERENCES "${s}".budget_plans(id) ON DELETE CASCADE,
account_id UUID NOT NULL REFERENCES "${s}".accounts(id),
fund_type VARCHAR(20) NOT NULL CHECK (fund_type IN ('operating', 'reserve')),
jan DECIMAL(12,2) DEFAULT 0, feb DECIMAL(12,2) DEFAULT 0,
mar DECIMAL(12,2) DEFAULT 0, apr DECIMAL(12,2) DEFAULT 0,
may DECIMAL(12,2) DEFAULT 0, jun DECIMAL(12,2) DEFAULT 0,
jul DECIMAL(12,2) DEFAULT 0, aug DECIMAL(12,2) DEFAULT 0,
sep DECIMAL(12,2) DEFAULT 0, oct DECIMAL(12,2) DEFAULT 0,
nov DECIMAL(12,2) DEFAULT 0, dec_amt DECIMAL(12,2) DEFAULT 0,
is_manually_adjusted BOOLEAN DEFAULT FALSE,
notes TEXT,
UNIQUE(budget_plan_id, account_id, fund_type)
)`,
// Indexes
`CREATE INDEX "idx_${s}_att_je" ON "${s}".attachments(journal_entry_id)`,
`CREATE INDEX "idx_${s}_je_date" ON "${s}".journal_entries(entry_date)`,
@@ -439,6 +474,9 @@ export class TenantSchemaService {
`CREATE INDEX "idx_${s}_bs_type_status" ON "${s}".board_scenarios(scenario_type, status)`,
`CREATE INDEX "idx_${s}_si_scenario" ON "${s}".scenario_investments(scenario_id)`,
`CREATE INDEX "idx_${s}_sa_scenario" ON "${s}".scenario_assessments(scenario_id)`,
`CREATE INDEX "idx_${s}_bp_year" ON "${s}".budget_plans(fiscal_year)`,
`CREATE INDEX "idx_${s}_bp_status" ON "${s}".budget_plans(status)`,
`CREATE INDEX "idx_${s}_bpl_plan" ON "${s}".budget_plan_lines(budget_plan_id)`,
];
}

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 };