Files
HOA_Financial_Platform/backend/src/modules/investment-planning/investment-planning.service.ts
olsch01 25663fc79e Add AI debug logging and switch from fetch to https module
- Add toggle-able debug logging (AI_DEBUG env var) that logs prompts,
  request metadata, raw responses, parsed output, and full error chains
- Replace Node.js native fetch() with https module for Docker Alpine
  compatibility (fixes "fetch failed" error with large payloads)
- Reduce max_tokens from 16384 to 4096 (qwen3.5 doesn't need thinking
  token budget)
- Strip <think> blocks from model responses
- Add AI_DEBUG to docker-compose.yml and .env.example

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:50:39 -05:00

607 lines
23 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TenantService } from '../../database/tenant.service';
import { DataSource } from 'typeorm';
// ── Interfaces ──
export interface AccountBalance {
id: string;
account_number: string;
name: string;
account_type: string;
fund_type: string;
interest_rate: string | null;
balance: string;
}
export interface InvestmentAccount {
id: string;
name: string;
institution: string;
investment_type: string;
fund_type: string;
principal: string;
interest_rate: string;
maturity_date: string | null;
purchase_date: string | null;
current_value: string;
}
export interface CdRate {
bank_name: string;
apy: string;
min_deposit: string | null;
term: string;
term_months: number | null;
fetched_at: string;
}
export interface Recommendation {
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
priority: 'high' | 'medium' | 'low';
title: string;
summary: string;
details: string;
fund_type: 'operating' | 'reserve' | 'both';
suggested_amount?: number;
suggested_term?: string;
suggested_rate?: number;
bank_name?: string;
rationale: string;
}
export interface AIResponse {
recommendations: Recommendation[];
overall_assessment: string;
risk_notes: string[];
}
@Injectable()
export class InvestmentPlanningService {
private readonly logger = new Logger(InvestmentPlanningService.name);
private debugEnabled: boolean;
constructor(
private tenant: TenantService,
private configService: ConfigService,
private dataSource: DataSource,
) {
// Toggle with AI_DEBUG=true in .env for detailed prompt/response logging
this.debugEnabled = this.configService.get<string>('AI_DEBUG') === 'true';
if (this.debugEnabled) {
this.logger.warn('AI DEBUG MODE ENABLED — prompts and responses will be logged');
}
}
private debug(label: string, data: any) {
if (!this.debugEnabled) return;
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
// Truncate very long output to keep logs manageable
const truncated = text.length > 5000 ? text.slice(0, 5000) + `\n... [truncated, ${text.length} total chars]` : text;
this.logger.log(`[AI_DEBUG] ${label}:\n${truncated}`);
}
// ── Public API Methods ──
/**
* Build a comprehensive financial snapshot for the investment planning page.
* All financial data is tenant-scoped via TenantService.
*/
async getFinancialSnapshot() {
const [
accountBalances,
investmentAccounts,
budgets,
projects,
cashFlowContext,
] = await Promise.all([
this.getAccountBalances(),
this.getInvestmentAccounts(),
this.getBudgets(),
this.getProjects(),
this.getCashFlowContext(),
]);
// Compute summary totals
const operatingCash = accountBalances
.filter((a) => a.fund_type === 'operating' && a.account_type === 'asset')
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
const reserveCash = accountBalances
.filter((a) => a.fund_type === 'reserve' && a.account_type === 'asset')
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
const operatingInvestments = investmentAccounts
.filter((i) => i.fund_type === 'operating')
.reduce((sum, i) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
const reserveInvestments = investmentAccounts
.filter((i) => i.fund_type === 'reserve')
.reduce((sum, i) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
return {
summary: {
operating_cash: operatingCash,
reserve_cash: reserveCash,
operating_investments: operatingInvestments,
reserve_investments: reserveInvestments,
total_operating: operatingCash + operatingInvestments,
total_reserve: reserveCash + reserveInvestments,
total_all: operatingCash + reserveCash + operatingInvestments + reserveInvestments,
},
account_balances: accountBalances,
investment_accounts: investmentAccounts,
budgets,
projects,
cash_flow_context: cashFlowContext,
};
}
/**
* Fetch latest CD rates from the shared schema (cross-tenant market data).
* Uses DataSource directly since this queries the shared schema, not tenant.
*/
async getCdRates(): Promise<CdRate[]> {
const queryRunner = this.dataSource.createQueryRunner();
try {
await queryRunner.connect();
const rates = await queryRunner.query(
`SELECT bank_name, apy, min_deposit, term, term_months, fetched_at
FROM shared.cd_rates
ORDER BY apy DESC
LIMIT 25`,
);
return rates;
} finally {
await queryRunner.release();
}
}
/**
* Orchestrate the AI recommendation flow:
* 1. Gather all financial data (tenant-scoped)
* 2. Fetch CD rates (shared schema)
* 3. Build the prompt with all context
* 4. Call the AI API
* 5. Parse and return structured recommendations
*/
async getAIRecommendations(): Promise<AIResponse> {
this.debug('getAIRecommendations', 'Starting AI recommendation flow');
const [snapshot, cdRates] = await Promise.all([
this.getFinancialSnapshot(),
this.getCdRates(),
]);
this.debug('snapshot_summary', {
operating_cash: snapshot.summary.operating_cash,
reserve_cash: snapshot.summary.reserve_cash,
total_all: snapshot.summary.total_all,
investment_accounts: snapshot.investment_accounts.length,
budgets: snapshot.budgets.length,
projects: snapshot.projects.length,
cd_rates: cdRates.length,
});
const messages = this.buildPromptMessages(snapshot, cdRates);
const aiResponse = await this.callAI(messages);
this.debug('final_response', {
recommendation_count: aiResponse.recommendations.length,
has_assessment: !!aiResponse.overall_assessment,
risk_notes_count: aiResponse.risk_notes?.length || 0,
});
return aiResponse;
}
// ── Private: Tenant-Scoped Data Queries ──
private async getAccountBalances(): Promise<AccountBalance[]> {
return this.tenant.query(`
SELECT
a.id, a.account_number, a.name, a.account_type, a.fund_type,
a.interest_rate,
CASE
WHEN a.account_type IN ('asset', 'expense')
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
END as balance
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
WHERE a.is_active = true
AND a.account_type IN ('asset', 'liability', 'equity')
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type, a.interest_rate
ORDER BY a.account_number
`);
}
private async getInvestmentAccounts(): Promise<InvestmentAccount[]> {
return this.tenant.query(`
SELECT
id, name, institution, investment_type, fund_type,
principal, interest_rate, maturity_date, purchase_date, current_value
FROM investment_accounts
WHERE is_active = true
ORDER BY maturity_date NULLS LAST
`);
}
private async getBudgets() {
const year = new Date().getFullYear();
return this.tenant.query(
`SELECT
b.fund_type, a.account_type, a.name, a.account_number,
(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) as annual_total
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1
ORDER BY a.account_type, a.account_number`,
[year],
);
}
private async getProjects() {
return this.tenant.query(`
SELECT
name, estimated_cost, target_year, target_month, fund_source,
status, priority, current_fund_balance, funded_percentage
FROM projects
WHERE is_active = true
AND status IN ('planned', 'approved', 'in_progress')
ORDER BY target_year, target_month NULLS LAST, priority
`);
}
private async getCashFlowContext() {
const year = new Date().getFullYear();
// Current operating cash position
const opCashResult = await this.tenant.query(`
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
GROUP BY a.id
) sub
`);
// Current reserve cash position
const resCashResult = await this.tenant.query(`
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
GROUP BY a.id
) sub
`);
// Annual budget summary by fund_type and account_type
const budgetSummary = await this.tenant.query(
`SELECT
b.fund_type, a.account_type,
SUM(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) as annual_total
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1
GROUP BY b.fund_type, a.account_type`,
[year],
);
// Assessment income (monthly recurring revenue)
const assessmentIncome = await this.tenant.query(`
SELECT
COALESCE(SUM(ag.regular_assessment * (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active')), 0) as monthly_assessment_income
FROM assessment_groups ag
WHERE ag.is_active = true
`);
return {
current_operating_cash: parseFloat(opCashResult[0]?.total || '0'),
current_reserve_cash: parseFloat(resCashResult[0]?.total || '0'),
budget_summary: budgetSummary,
monthly_assessment_income: parseFloat(assessmentIncome[0]?.monthly_assessment_income || '0'),
};
}
// ── Private: AI Prompt Construction ──
private buildPromptMessages(snapshot: any, cdRates: CdRate[]) {
const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot;
const today = new Date().toISOString().split('T')[0];
const systemPrompt = `You are a financial advisor specializing in HOA (Homeowners Association) reserve fund management and conservative investment strategy. You provide fiduciary-grade investment recommendations.
CRITICAL RULES:
1. HOAs are legally required to maintain adequate reserves. NEVER recommend depleting reserve funds below safe levels.
2. HOA investments must be conservative ONLY: CDs, money market accounts, treasury bills, and high-yield savings. NO stocks, bonds, mutual funds, or speculative instruments.
3. Liquidity is paramount: always ensure enough cash to cover at least 3 months of operating expenses AND any capital project expenses due within the next 12 months.
4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access.
5. Operating funds should remain highly liquid (money market or high-yield savings only).
6. Respect the separation between operating funds and reserve funds. Never suggest commingling.
7. Base your recommendations ONLY on the available CD rates and instruments provided. Do not reference rates or banks not in the provided data.
RESPONSE FORMAT:
Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema:
{
"recommendations": [
{
"type": "cd_ladder" | "new_investment" | "reallocation" | "maturity_action" | "liquidity_warning" | "general",
"priority": "high" | "medium" | "low",
"title": "Short action title (under 60 chars)",
"summary": "One sentence summary of the recommendation",
"details": "Detailed explanation with specific dollar amounts and timeframes",
"fund_type": "operating" | "reserve" | "both",
"suggested_amount": 50000.00,
"suggested_term": "12 months",
"suggested_rate": 4.50,
"bank_name": "Bank name from CD rates (if applicable)",
"rationale": "Financial reasoning for why this makes sense"
}
],
"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: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible.`;
// Build the data context for the user prompt
const investmentsList = investment_accounts.length === 0
? 'No current investments.'
: investment_accounts.map((i: any) =>
`- ${i.name} | Type: ${i.investment_type} | Fund: ${i.fund_type} | Principal: $${parseFloat(i.principal).toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
).join('\n');
const budgetLines = budgets.length === 0
? 'No budget data available.'
: budgets.map((b: any) =>
`- ${b.name} (${b.account_number}) | ${b.account_type}/${b.fund_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr`,
).join('\n');
const projectLines = projects.length === 0
? 'No upcoming capital projects.'
: projects.map((p: any) =>
`- ${p.name} | Cost: $${parseFloat(p.estimated_cost).toFixed(2)} | Target: ${p.target_year || '?'}/${p.target_month || '?'} | Fund: ${p.fund_source} | Status: ${p.status} | Funded: ${parseFloat(p.funded_percentage || '0').toFixed(1)}%`,
).join('\n');
const budgetSummaryLines = (cash_flow_context.budget_summary || []).length === 0
? 'No budget summary available.'
: cash_flow_context.budget_summary.map((b: any) =>
`- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total) / 12).toFixed(2)}/mo)`,
).join('\n');
const cdRateLines = cdRates.length === 0
? 'No CD rate data available. Rate fetcher may not have been run yet.'
: cdRates.map((r: CdRate) =>
`- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}% | Term: ${r.term} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`,
).join('\n');
const userPrompt = `Analyze this HOA's financial position and provide investment recommendations.
TODAY'S DATE: ${today}
=== CURRENT CASH POSITIONS ===
Operating Cash (bank accounts): $${summary.operating_cash.toFixed(2)}
Reserve Cash (bank accounts): $${summary.reserve_cash.toFixed(2)}
Operating Investments: $${summary.operating_investments.toFixed(2)}
Reserve Investments: $${summary.reserve_investments.toFixed(2)}
Total Operating Fund: $${summary.total_operating.toFixed(2)}
Total Reserve Fund: $${summary.total_reserve.toFixed(2)}
Grand Total: $${summary.total_all.toFixed(2)}
=== CURRENT INVESTMENTS ===
${investmentsList}
=== ANNUAL BUDGET (${new Date().getFullYear()}) ===
${budgetLines}
=== BUDGET SUMMARY (Annual Totals by Category) ===
${budgetSummaryLines}
=== MONTHLY ASSESSMENT INCOME ===
Recurring monthly assessment income: $${cash_flow_context.monthly_assessment_income.toFixed(2)}/month
=== UPCOMING CAPITAL PROJECTS ===
${projectLines}
=== AVAILABLE CD RATES (Market Data) ===
${cdRateLines}
Based on this complete financial picture, provide your investment recommendations. Consider:
1. Is there excess cash that could earn better returns in CDs?
2. Are any current investments maturing soon that need reinvestment planning?
3. Is the liquidity position adequate for upcoming expenses and projects?
4. Would a CD ladder strategy improve the yield while maintaining access to funds?
5. Are operating and reserve funds properly separated in the investment strategy?`;
return [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
];
}
// ── Private: AI API Call ──
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {
const apiUrl = this.configService.get<string>('AI_API_URL') || 'https://integrate.api.nvidia.com/v1';
const apiKey = this.configService.get<string>('AI_API_KEY');
const model = this.configService.get<string>('AI_MODEL') || 'qwen/qwen3.5-397b-a17b';
if (!apiKey) {
this.logger.error('AI_API_KEY not configured');
return {
recommendations: [],
overall_assessment: 'AI recommendations are not available. The AI_API_KEY has not been configured in the environment.',
risk_notes: ['Configure AI_API_KEY in .env to enable investment recommendations.'],
};
}
const requestBody = {
model,
messages,
temperature: 0.3,
max_tokens: 4096,
};
const bodyString = JSON.stringify(requestBody);
this.debug('prompt_system', messages[0]?.content);
this.debug('prompt_user', messages[1]?.content);
this.debug('request_meta', {
url: `${apiUrl}/chat/completions`,
model,
temperature: 0.3,
max_tokens: 4096,
body_length_bytes: Buffer.byteLength(bodyString, 'utf-8'),
message_count: messages.length,
});
try {
this.logger.log(`Calling AI API: ${apiUrl} with model ${model} (body: ${Buffer.byteLength(bodyString, 'utf-8')} bytes)`);
const startTime = Date.now();
// Use Node.js https module instead of native fetch for better
// compatibility in Docker Alpine environments
const { URL } = await import('url');
const https = await import('https');
const aiResult = await new Promise<any>((resolve, reject) => {
const url = new URL(`${apiUrl}/chat/completions`);
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
},
timeout: 180000, // 3 minute timeout
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
resolve({ status: res.statusCode, body: data });
});
});
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request timed out after 180s`));
});
req.write(bodyString);
req.end();
});
const elapsed = Date.now() - startTime;
this.logger.log(`AI API responded in ${elapsed}ms with status ${aiResult.status}`);
this.debug('response_timing', { elapsed_ms: elapsed, status: aiResult.status });
if (aiResult.status >= 400) {
this.logger.error(`AI API error ${aiResult.status}: ${aiResult.body}`);
this.debug('response_error_body', aiResult.body);
throw new Error(`AI API returned ${aiResult.status}: ${aiResult.body}`);
}
const data = JSON.parse(aiResult.body);
const msg = data.choices?.[0]?.message;
// Thinking models (kimi-k2.5) may return content in 'content' or
// spend all tokens on 'reasoning_content' with content=null
const content = msg?.content || null;
this.logger.log(`AI response: content=${content ? content.length + ' chars' : 'null'}, reasoning=${msg?.reasoning_content ? 'yes' : 'no'}, finish=${data.choices?.[0]?.finish_reason}`);
this.debug('response_raw_content', content);
this.debug('response_usage', data.usage);
if (msg?.reasoning_content) {
this.debug('response_reasoning', msg.reasoning_content);
}
if (!content) {
throw new Error('AI model returned empty content — it may have exhausted tokens on reasoning. Try a non-thinking model or increase max_tokens.');
}
// Parse the JSON response — handle potential markdown code fences
let cleaned = content.trim();
if (cleaned.startsWith('```')) {
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
}
// Handle thinking model wrapper: strip <think>...</think> blocks
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
const parsed = JSON.parse(cleaned) as AIResponse;
// Validate the response structure
if (!parsed.recommendations || !Array.isArray(parsed.recommendations)) {
this.debug('invalid_response_structure', parsed);
throw new Error('Invalid AI response: missing recommendations array');
}
this.logger.log(`AI returned ${parsed.recommendations.length} recommendations`);
this.debug('parsed_recommendations', parsed.recommendations.map((r) => ({
type: r.type,
priority: r.priority,
title: r.title,
fund_type: r.fund_type,
suggested_amount: r.suggested_amount,
})));
return parsed;
} catch (error: any) {
// Log the full error chain for debugging
this.logger.error(`AI recommendation failed: ${error.message}`);
if (error.cause) {
this.logger.error(` → cause: ${error.cause?.message || error.cause}`);
this.debug('error_cause', {
message: error.cause?.message,
code: error.cause?.code,
errno: error.cause?.errno,
syscall: error.cause?.syscall,
hostname: error.cause?.hostname,
stack: error.cause?.stack,
});
}
this.debug('error_full', {
message: error.message,
name: error.name,
code: error.code,
stack: error.stack,
});
// For JSON parse errors, return what we can
if (error instanceof SyntaxError) {
return {
recommendations: [],
overall_assessment: 'The AI service returned an invalid response format. Please try again.',
risk_notes: [`Response parsing error: ${error.message}`],
};
}
// For network/timeout errors, return a graceful fallback
return {
recommendations: [],
overall_assessment: 'Unable to generate AI recommendations at this time. Please try again later.',
risk_notes: [`AI service error: ${error.message}`],
};
}
}
}