From 7ba5c414b111fb0ad18d44fa41ce85911067f6a7 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Mon, 16 Mar 2026 15:59:56 -0400 Subject: [PATCH] fix: handle multi-component investment recommendations (CD ladders) When adding a multi-stage investment strategy (e.g. CD ladder) from AI recommendations to a board planning scenario, all component investments are now created as separate rows instead of collapsing into a single investment. The AI prompt now instructs inclusion of a components array, the backend loops through components to create individual scenario investments, and the frontend passes and displays component details. Co-Authored-By: Claude Opus 4.6 --- .../board-planning/board-planning.service.ts | 31 +++++++++++++++++++ .../investment-planning.service.ts | 27 +++++++++++++++- .../InvestmentPlanningPage.tsx | 22 +++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/backend/src/modules/board-planning/board-planning.service.ts b/backend/src/modules/board-planning/board-planning.service.ts index 61ed74e..a0c5c19 100644 --- a/backend/src/modules/board-planning/board-planning.service.ts +++ b/backend/src/modules/board-planning/board-planning.service.ts @@ -106,6 +106,37 @@ export class BoardPlanningService { async addInvestmentFromRecommendation(scenarioId: string, dto: any) { await this.getScenarioRow(scenarioId); + + // If the recommendation has components (e.g. CD ladder with multiple CDs), create one row per component + const components = dto.components as any[] | undefined; + if (components && Array.isArray(components) && components.length > 0) { + const results: any[] = []; + for (let i = 0; i < components.length; i++) { + const comp = components[i]; + const rows = await this.tenant.query( + `INSERT INTO scenario_investments + (scenario_id, source_recommendation_id, label, investment_type, fund_type, + principal, interest_rate, term_months, institution, notes, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + scenarioId, dto.sourceRecommendationId || null, + comp.label || `${dto.title || 'AI Recommendation'} - Part ${i + 1}`, + comp.investment_type || dto.investmentType || null, + dto.fundType || 'reserve', + comp.amount || 0, comp.rate || null, + comp.term_months || null, comp.bank_name || dto.bankName || null, + dto.rationale || dto.notes || null, + i, + ], + ); + results.push(rows[0]); + } + await this.invalidateProjectionCache(scenarioId); + return results; + } + + // Single investment (no components) const rows = await this.tenant.query( `INSERT INTO scenario_investments (scenario_id, source_recommendation_id, label, investment_type, fund_type, diff --git a/backend/src/modules/investment-planning/investment-planning.service.ts b/backend/src/modules/investment-planning/investment-planning.service.ts index 245c630..dfe6bd6 100644 --- a/backend/src/modules/investment-planning/investment-planning.service.ts +++ b/backend/src/modules/investment-planning/investment-planning.service.ts @@ -38,6 +38,15 @@ export interface MarketRate { fetched_at: string; } +export interface RecommendationComponent { + label: string; + amount: number; + term_months: number; + rate: number; + bank_name?: string; + investment_type?: string; +} + export interface Recommendation { type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general'; priority: 'high' | 'medium' | 'low'; @@ -50,6 +59,7 @@ export interface Recommendation { suggested_rate?: number; bank_name?: string; rationale: string; + components?: RecommendationComponent[]; } export interface AIResponse { @@ -904,13 +914,28 @@ Respond with ONLY valid JSON (no markdown, no code fences) matching this exact s "suggested_term": "12 months", "suggested_rate": 4.50, "bank_name": "Bank name from market rates (if applicable)", - "rationale": "Financial reasoning for why this makes sense" + "rationale": "Financial reasoning for why this makes sense", + "components": [ + { + "label": "Component label (e.g. '6-Month CD at Marcus')", + "amount": 6600.00, + "term_months": 6, + "rate": 4.05, + "bank_name": "Marcus", + "investment_type": "cd" + } + ] } ], "overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities", "risk_notes": ["Array of risk items or concerns to flag for the board"] } +IMPORTANT ABOUT COMPONENTS: +- For cd_ladder recommendations, you MUST include a "components" array with each individual CD as a separate component. Each component should have its own label, amount, term_months, rate, and bank_name. The suggested_amount should be the total of all component amounts. +- For other multi-part strategies (e.g. splitting funds across multiple accounts), also include a "components" array. +- For simple single-investment recommendations, omit the "components" field entirely. + IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`; // Build the data context for the user prompt diff --git a/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx b/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx index 551eb97..b651876 100644 --- a/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx +++ b/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx @@ -85,6 +85,15 @@ interface MarketRatesResponse { high_yield_savings: MarketRate[]; } +interface RecommendationComponent { + label: string; + amount: number; + term_months: number; + rate: number; + bank_name?: string; + investment_type?: string; +} + interface Recommendation { type: string; priority: 'high' | 'medium' | 'low'; @@ -97,6 +106,7 @@ interface Recommendation { suggested_rate?: number; bank_name?: string; rationale: string; + components?: RecommendationComponent[]; } interface AIResponse { @@ -392,6 +402,7 @@ export function InvestmentPlanningPage() { termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null, bankName: rec.bank_name, rationale: rec.rationale, + components: rec.components || undefined, }); return scenarioId; }, @@ -421,6 +432,7 @@ export function InvestmentPlanningPage() { termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null, bankName: rec.bank_name, rationale: rec.rationale, + components: rec.components || undefined, }); return scenario.id; }, @@ -865,6 +877,16 @@ export function InvestmentPlanningPage() { {selectedRec.suggested_amount != null && ( Amount: {fmt(selectedRec.suggested_amount)} )} + {selectedRec.components && selectedRec.components.length > 0 && ( + + {selectedRec.components.length} investments will be created: + {selectedRec.components.map((c, i) => ( + + {c.label}: {fmt(c.amount)} @ {c.rate}% ({c.term_months} mo) + + ))} + + )} )}