feat: investment chart alignment, auto-renew records, fund transfers, capital planning report, and upcoming activities (v2026.3.24)
- Lock InvestmentTimeline and ProjectionChart to shared X axis range - Auto-create renewal scenario_investments records when auto_renew is true - Add fund transfer mechanism between asset accounts with journal entries - Add Capital Planning Report (5-year forecast grouped by category) - Add Upcoming Investment Activities dashboard card (maturities + planned purchases) - Bump version to 2026.3.24 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoa-ledgeriq-backend",
|
||||
"version": "2026.3.19",
|
||||
"version": "2026.3.24",
|
||||
"description": "HOA LedgerIQ - Backend API",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -58,6 +58,14 @@ export class AccountsController {
|
||||
return this.accountsService.adjustBalance(id, dto);
|
||||
}
|
||||
|
||||
@Post('transfer')
|
||||
@ApiOperation({ summary: 'Transfer funds between asset accounts' })
|
||||
transferFunds(
|
||||
@Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string },
|
||||
) {
|
||||
return this.accountsService.transferFunds(dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get account by ID' })
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -360,6 +360,62 @@ export class AccountsService {
|
||||
return journalEntry;
|
||||
}
|
||||
|
||||
async transferFunds(dto: {
|
||||
fromAccountId: string;
|
||||
toAccountId: string;
|
||||
amount: number;
|
||||
transferDate: string;
|
||||
memo?: string;
|
||||
}) {
|
||||
if (dto.amount <= 0) throw new BadRequestException('Transfer amount must be positive');
|
||||
if (dto.fromAccountId === dto.toAccountId) throw new BadRequestException('Cannot transfer to the same account');
|
||||
|
||||
const fromAccount = await this.findOne(dto.fromAccountId);
|
||||
const toAccount = await this.findOne(dto.toAccountId);
|
||||
|
||||
if (fromAccount.account_type !== 'asset') throw new BadRequestException('Source account must be an asset account');
|
||||
if (toAccount.account_type !== 'asset') throw new BadRequestException('Destination account must be an asset account');
|
||||
|
||||
// Find fiscal period
|
||||
const asOf = new Date(dto.transferDate);
|
||||
const year = asOf.getFullYear();
|
||||
const month = asOf.getMonth() + 1;
|
||||
const periods = await this.tenant.query(
|
||||
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||
[year, month],
|
||||
);
|
||||
if (!periods.length) {
|
||||
throw new BadRequestException(`No fiscal period found for ${year}-${String(month).padStart(2, '0')}`);
|
||||
}
|
||||
|
||||
const memo = dto.memo || `Transfer from ${fromAccount.name} to ${toAccount.name}`;
|
||||
|
||||
// Create journal entry: debit destination (increase), credit source (decrease)
|
||||
const jeRows = await this.tenant.query(
|
||||
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||
VALUES ($1, $2, 'transfer', $3, true, NOW(), $4)
|
||||
RETURNING *`,
|
||||
[dto.transferDate, memo, periods[0].id, '00000000-0000-0000-0000-000000000000'],
|
||||
);
|
||||
const je = jeRows[0];
|
||||
|
||||
// Credit source account (reduces asset balance)
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||
VALUES ($1, $2, 0, $3, $4)`,
|
||||
[je.id, dto.fromAccountId, dto.amount, memo],
|
||||
);
|
||||
|
||||
// Debit destination account (increases asset balance)
|
||||
await this.tenant.query(
|
||||
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||
VALUES ($1, $2, $3, 0, $4)`,
|
||||
[je.id, dto.toAccountId, dto.amount, memo],
|
||||
);
|
||||
|
||||
return je;
|
||||
}
|
||||
|
||||
async getTrialBalance(asOfDate?: string) {
|
||||
const dateFilter = asOfDate
|
||||
? `AND je.entry_date <= $1`
|
||||
|
||||
@@ -25,12 +25,15 @@ export class BoardPlanningProjectionService {
|
||||
return this.computeProjection(scenarioId);
|
||||
}
|
||||
|
||||
/** Compute full projection for a scenario. */
|
||||
/** Compute full projection for a scenario. Also auto-creates renewal records for auto_renew investments. */
|
||||
async computeProjection(scenarioId: string) {
|
||||
const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
|
||||
if (!scenarioRows.length) throw new NotFoundException('Scenario not found');
|
||||
const scenario = scenarioRows[0];
|
||||
|
||||
// Auto-create renewal investment records for auto_renew investments that have maturity dates
|
||||
await this.ensureRenewalRecords(scenarioId);
|
||||
|
||||
const investments = await this.tenant.query(
|
||||
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId],
|
||||
);
|
||||
@@ -152,6 +155,53 @@ export class BoardPlanningProjectionService {
|
||||
|
||||
// ── Private Helpers ──
|
||||
|
||||
/**
|
||||
* For each auto_renew investment with a maturity_date, ensure a corresponding
|
||||
* renewal investment record exists (starting at maturity_date, same term).
|
||||
* The renewal record has auto_renew=false so it won't create infinite chains.
|
||||
*/
|
||||
private async ensureRenewalRecords(scenarioId: string) {
|
||||
const autoRenewInvestments = await this.tenant.query(
|
||||
`SELECT * FROM scenario_investments
|
||||
WHERE scenario_id = $1 AND auto_renew = true AND maturity_date IS NOT NULL AND executed_investment_id IS NULL`,
|
||||
[scenarioId],
|
||||
);
|
||||
|
||||
for (const inv of autoRenewInvestments) {
|
||||
// Check if a renewal record already exists (linked by notes convention or same label pattern)
|
||||
const renewalLabel = `${inv.label} (Renewal)`;
|
||||
const existing = await this.tenant.query(
|
||||
`SELECT id FROM scenario_investments WHERE scenario_id = $1 AND label = $2 AND purchase_date = $3`,
|
||||
[scenarioId, renewalLabel, inv.maturity_date],
|
||||
);
|
||||
|
||||
if (existing.length > 0) continue; // Already created
|
||||
|
||||
// Compute new maturity date from original term
|
||||
let newMaturityDate: string | null = null;
|
||||
const termMonths = parseInt(inv.term_months) || 0;
|
||||
if (termMonths > 0 && inv.maturity_date) {
|
||||
const d = new Date(inv.maturity_date);
|
||||
d.setMonth(d.getMonth() + termMonths);
|
||||
newMaturityDate = d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
await this.tenant.query(
|
||||
`INSERT INTO scenario_investments
|
||||
(scenario_id, label, investment_type, fund_type, principal, interest_rate,
|
||||
term_months, institution, purchase_date, maturity_date, auto_renew, notes, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, false, $11, $12)`,
|
||||
[
|
||||
scenarioId, renewalLabel, inv.investment_type, inv.fund_type,
|
||||
inv.principal, inv.interest_rate, inv.term_months || null,
|
||||
inv.institution, inv.maturity_date, newMaturityDate,
|
||||
`Auto-created renewal of "${inv.label}". Modify as needed.`,
|
||||
(parseInt(inv.sort_order) || 0) + 1,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async getBaselineState(startYear: number, months: number) {
|
||||
// Current balances from asset accounts
|
||||
const opCashRows = await this.tenant.query(`
|
||||
@@ -403,11 +453,9 @@ export class BoardPlanningProjectionService {
|
||||
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
|
||||
else { resCashFlow += maturityTotal; resInvChange -= principal; }
|
||||
|
||||
// Auto-renew: immediately reinvest
|
||||
if (inv.auto_renew) {
|
||||
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
|
||||
else { resCashFlow -= principal; resInvChange += principal; }
|
||||
}
|
||||
// Note: auto_renew investments now create separate renewal records
|
||||
// (via ensureRenewalRecords), so the renewal purchase is handled by
|
||||
// that record's purchase_date logic above — no inline reinvest needed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,11 @@ export class ReportsController {
|
||||
return this.reportsService.getDashboardKPIs();
|
||||
}
|
||||
|
||||
@Get('upcoming-investment-activities')
|
||||
getUpcomingInvestmentActivities() {
|
||||
return this.reportsService.getUpcomingInvestmentActivities();
|
||||
}
|
||||
|
||||
@Get('cash-flow-forecast')
|
||||
getCashFlowForecast(
|
||||
@Query('startYear') startYear?: string,
|
||||
@@ -75,6 +80,13 @@ export class ReportsController {
|
||||
return this.reportsService.getCashFlowForecast(yr, mo);
|
||||
}
|
||||
|
||||
@Get('capital-planning')
|
||||
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
|
||||
return this.reportsService.getCapitalPlanningReport(
|
||||
parseInt(startYear || '') || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('quarterly')
|
||||
getQuarterlyFinancial(
|
||||
@Query('year') year?: string,
|
||||
|
||||
@@ -780,6 +780,78 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async getUpcomingInvestmentActivities() {
|
||||
const now = new Date();
|
||||
const in45Days = new Date(now);
|
||||
in45Days.setDate(in45Days.getDate() + 45);
|
||||
const in60Days = new Date(now);
|
||||
in60Days.setDate(in60Days.getDate() + 60);
|
||||
|
||||
// 1. Investments maturing within 45 days
|
||||
const maturingInvestments = await this.tenant.query(`
|
||||
SELECT id, name, institution, investment_type, fund_type, current_value, principal,
|
||||
interest_rate, maturity_date, purchase_date
|
||||
FROM investment_accounts
|
||||
WHERE is_active = true
|
||||
AND maturity_date IS NOT NULL
|
||||
AND maturity_date BETWEEN CURRENT_DATE AND $1::date
|
||||
ORDER BY maturity_date ASC
|
||||
`, [in45Days.toISOString().split('T')[0]]);
|
||||
|
||||
// Compute interest earned and days remaining for each
|
||||
const maturing = maturingInvestments.map((inv: any) => {
|
||||
const principal = parseFloat(inv.principal) || parseFloat(inv.current_value) || 0;
|
||||
const rate = parseFloat(inv.interest_rate) || 0;
|
||||
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : now;
|
||||
const maturityDate = new Date(inv.maturity_date);
|
||||
const daysHeld = Math.max((maturityDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||
const interestEarned = principal * (rate / 100) * (daysHeld / 365);
|
||||
const daysRemaining = Math.max(Math.ceil((maturityDate.getTime() - now.getTime()) / 86400000), 0);
|
||||
return {
|
||||
...inv,
|
||||
interest_earned: interestEarned.toFixed(2),
|
||||
maturity_value: (principal + interestEarned).toFixed(2),
|
||||
days_remaining: daysRemaining,
|
||||
activity_type: 'maturity',
|
||||
};
|
||||
});
|
||||
|
||||
// 2. Approved scenario investments due to execute within 60 days
|
||||
let scenarioItems: any[] = [];
|
||||
try {
|
||||
scenarioItems = await this.tenant.query(`
|
||||
SELECT si.id, si.label, si.investment_type, si.fund_type, si.principal,
|
||||
si.interest_rate, si.purchase_date, si.maturity_date, si.institution,
|
||||
bs.name as scenario_name, bs.status as scenario_status
|
||||
FROM scenario_investments si
|
||||
JOIN board_scenarios bs ON bs.id = si.scenario_id
|
||||
WHERE bs.status = 'approved'
|
||||
AND si.executed_investment_id IS NULL
|
||||
AND si.purchase_date IS NOT NULL
|
||||
AND si.purchase_date BETWEEN CURRENT_DATE AND $1::date
|
||||
ORDER BY si.purchase_date ASC
|
||||
`, [in60Days.toISOString().split('T')[0]]);
|
||||
} catch {
|
||||
// scenario tables may not exist
|
||||
}
|
||||
|
||||
const upcoming = scenarioItems.map((si: any) => {
|
||||
const purchaseDate = new Date(si.purchase_date);
|
||||
const daysUntil = Math.max(Math.ceil((purchaseDate.getTime() - now.getTime()) / 86400000), 0);
|
||||
return {
|
||||
...si,
|
||||
days_until: daysUntil,
|
||||
activity_type: 'planned_purchase',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
maturing_investments: maturing,
|
||||
upcoming_scenario_investments: upcoming,
|
||||
total_activities: maturing.length + upcoming.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future).
|
||||
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
|
||||
@@ -1264,4 +1336,120 @@ export class ReportsService {
|
||||
over_budget_items: overBudgetItems,
|
||||
};
|
||||
}
|
||||
|
||||
async getCapitalPlanningReport(startYear?: number) {
|
||||
const baseYear = startYear || new Date().getFullYear();
|
||||
const years = [baseYear, baseYear + 1, baseYear + 2, baseYear + 3, baseYear + 4];
|
||||
|
||||
// Get all active projects
|
||||
const projects = await this.tenant.query(
|
||||
`SELECT id, name, description, category, estimated_cost, target_year, target_month,
|
||||
useful_life_years, last_replacement_date, next_replacement_date, fund_source,
|
||||
status, priority, condition_rating
|
||||
FROM projects
|
||||
WHERE is_active = true
|
||||
ORDER BY category NULLS LAST, priority, name`,
|
||||
);
|
||||
|
||||
// Also try capital_projects table
|
||||
let capitalProjects: any[] = [];
|
||||
try {
|
||||
capitalProjects = await this.tenant.query(
|
||||
`SELECT id, name, description, estimated_cost, target_year, target_month,
|
||||
fund_source, status, priority, notes
|
||||
FROM capital_projects
|
||||
WHERE status NOT IN ('cancelled')
|
||||
ORDER BY priority, name`,
|
||||
);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
|
||||
// Merge and group by category
|
||||
const allProjects = [
|
||||
...projects.map((p: any) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
category: p.category || 'Uncategorized',
|
||||
estimated_cost: parseFloat(p.estimated_cost) || 0,
|
||||
target_year: parseInt(p.target_year) || null,
|
||||
useful_life_years: parseInt(p.useful_life_years) || null,
|
||||
last_replacement_date: p.last_replacement_date,
|
||||
fund_source: p.fund_source || 'reserve',
|
||||
status: p.status,
|
||||
priority: parseInt(p.priority) || 3,
|
||||
condition_rating: parseInt(p.condition_rating) || null,
|
||||
})),
|
||||
...capitalProjects
|
||||
.filter((cp: any) => !projects.some((p: any) => p.name === cp.name && p.target_year === cp.target_year))
|
||||
.map((cp: any) => ({
|
||||
id: cp.id,
|
||||
name: cp.name,
|
||||
description: cp.description,
|
||||
category: 'Capital Projects',
|
||||
estimated_cost: parseFloat(cp.estimated_cost) || 0,
|
||||
target_year: parseInt(cp.target_year) || null,
|
||||
useful_life_years: null,
|
||||
last_replacement_date: null,
|
||||
fund_source: cp.fund_source || 'reserve',
|
||||
status: cp.status,
|
||||
priority: parseInt(cp.priority) || 3,
|
||||
condition_rating: null,
|
||||
})),
|
||||
];
|
||||
|
||||
// Group by category
|
||||
const categories: Record<string, any[]> = {};
|
||||
for (const project of allProjects) {
|
||||
const cat = project.category;
|
||||
if (!categories[cat]) categories[cat] = [];
|
||||
categories[cat].push(project);
|
||||
}
|
||||
|
||||
// Build year columns for each project
|
||||
const categoryData = Object.entries(categories).map(([category, items]) => ({
|
||||
category,
|
||||
projects: items.map((p) => {
|
||||
const yearAmounts: Record<number, number> = {};
|
||||
let beyond = 0;
|
||||
if (p.target_year) {
|
||||
if (p.target_year >= years[0] && p.target_year <= years[4]) {
|
||||
yearAmounts[p.target_year] = p.estimated_cost;
|
||||
} else if (p.target_year > years[4]) {
|
||||
beyond = p.estimated_cost;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
year_amounts: yearAmounts,
|
||||
beyond,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// Compute totals per year
|
||||
const yearTotals: Record<number, number> = {};
|
||||
let beyondTotal = 0;
|
||||
for (const y of years) yearTotals[y] = 0;
|
||||
for (const cat of categoryData) {
|
||||
for (const p of cat.projects) {
|
||||
for (const y of years) {
|
||||
yearTotals[y] += p.year_amounts[y] || 0;
|
||||
}
|
||||
beyondTotal += p.beyond;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${years[4] - years[0] + 1}-YEAR CAPITAL PROJECT FORECAST`,
|
||||
start_year: years[0],
|
||||
years,
|
||||
categories: categoryData,
|
||||
year_totals: yearTotals,
|
||||
beyond_total: beyondTotal,
|
||||
grand_total: Object.values(yearTotals).reduce((a, b) => a + b, 0) + beyondTotal,
|
||||
generated_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user