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:
2026-03-24 14:41:02 -04:00
parent ae856bfb2f
commit 2b331bb3ef
15 changed files with 801 additions and 21 deletions

View File

@@ -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.
}
}
}