- Refresh Recommendations now shows inline processing banner with animated progress bar while keeping existing results visible (dimmed). Auto-scrolls to AI section and shows titled notification on completion. - Investment recommendations now auto-calculate purchase and maturity dates from a configurable start date (defaults to today) in the "Add to Plan" modal, so scenarios build projections immediately. - Projection engine computes per-investment and total interest earned, ROI percentage, and total principal invested. Summary cards on the Investment Scenario detail page display these metrics prominently. - Replaced dropdown action menu with inline Edit/Execute/Remove icon buttons matching the assessment scenarios pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
384 lines
15 KiB
TypeScript
384 lines
15 KiB
TypeScript
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
import { TenantService } from '../../database/tenant.service';
|
|
|
|
@Injectable()
|
|
export class BoardPlanningService {
|
|
constructor(private tenant: TenantService) {}
|
|
|
|
// ── Scenarios ──
|
|
|
|
async listScenarios(type?: string) {
|
|
let sql = `
|
|
SELECT bs.*,
|
|
(SELECT COUNT(*) FROM scenario_investments si WHERE si.scenario_id = bs.id) as investment_count,
|
|
(SELECT COALESCE(SUM(si.principal), 0) FROM scenario_investments si WHERE si.scenario_id = bs.id) as total_principal,
|
|
(SELECT COUNT(*) FROM scenario_assessments sa WHERE sa.scenario_id = bs.id) as assessment_count
|
|
FROM board_scenarios bs
|
|
WHERE bs.status != 'archived'
|
|
`;
|
|
const params: any[] = [];
|
|
if (type) {
|
|
params.push(type);
|
|
sql += ` AND bs.scenario_type = $${params.length}`;
|
|
}
|
|
sql += ' ORDER BY bs.updated_at DESC';
|
|
return this.tenant.query(sql, params);
|
|
}
|
|
|
|
async getScenario(id: string) {
|
|
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [id]);
|
|
if (!rows.length) throw new NotFoundException('Scenario not found');
|
|
const scenario = rows[0];
|
|
|
|
const investments = await this.tenant.query(
|
|
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY sort_order, purchase_date',
|
|
[id],
|
|
);
|
|
const assessments = await this.tenant.query(
|
|
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY sort_order, effective_date',
|
|
[id],
|
|
);
|
|
|
|
return { ...scenario, investments, assessments };
|
|
}
|
|
|
|
async createScenario(dto: any, userId: string) {
|
|
const rows = await this.tenant.query(
|
|
`INSERT INTO board_scenarios (name, description, scenario_type, projection_months, created_by)
|
|
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
|
[dto.name, dto.description || null, dto.scenarioType, dto.projectionMonths || 36, userId],
|
|
);
|
|
return rows[0];
|
|
}
|
|
|
|
async updateScenario(id: string, dto: any) {
|
|
await this.getScenarioRow(id);
|
|
const rows = await this.tenant.query(
|
|
`UPDATE board_scenarios SET
|
|
name = COALESCE($2, name),
|
|
description = COALESCE($3, description),
|
|
status = COALESCE($4, status),
|
|
projection_months = COALESCE($5, projection_months),
|
|
updated_at = NOW()
|
|
WHERE id = $1 RETURNING *`,
|
|
[id, dto.name, dto.description, dto.status, dto.projectionMonths],
|
|
);
|
|
return rows[0];
|
|
}
|
|
|
|
async deleteScenario(id: string) {
|
|
await this.getScenarioRow(id);
|
|
await this.tenant.query(
|
|
`UPDATE board_scenarios SET status = 'archived', updated_at = NOW() WHERE id = $1`,
|
|
[id],
|
|
);
|
|
}
|
|
|
|
// ── Scenario Investments ──
|
|
|
|
async listInvestments(scenarioId: string) {
|
|
return this.tenant.query(
|
|
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY sort_order, purchase_date',
|
|
[scenarioId],
|
|
);
|
|
}
|
|
|
|
async addInvestment(scenarioId: string, dto: any) {
|
|
await this.getScenarioRow(scenarioId);
|
|
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, purchase_date, maturity_date,
|
|
auto_renew, notes, sort_order)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
RETURNING *`,
|
|
[
|
|
scenarioId, dto.sourceRecommendationId || null, dto.label,
|
|
dto.investmentType || null, dto.fundType,
|
|
dto.principal, dto.interestRate || null, dto.termMonths || null,
|
|
dto.institution || null, dto.purchaseDate || null, dto.maturityDate || null,
|
|
dto.autoRenew || false, dto.notes || null, dto.sortOrder || 0,
|
|
],
|
|
);
|
|
await this.invalidateProjectionCache(scenarioId);
|
|
return rows[0];
|
|
}
|
|
|
|
async addInvestmentFromRecommendation(scenarioId: string, dto: any) {
|
|
await this.getScenarioRow(scenarioId);
|
|
|
|
// Helper: compute maturity date from purchase date + term months
|
|
const computeMaturityDate = (purchaseDate: string | null, termMonths: number | null): string | null => {
|
|
if (!purchaseDate || !termMonths) return null;
|
|
const d = new Date(purchaseDate);
|
|
d.setMonth(d.getMonth() + termMonths);
|
|
return d.toISOString().split('T')[0];
|
|
};
|
|
|
|
const startDate = dto.startDate || null; // ISO date string e.g. "2026-03-16"
|
|
|
|
// 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 termMonths = comp.term_months || null;
|
|
const maturityDate = computeMaturityDate(startDate, termMonths);
|
|
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, purchase_date, maturity_date,
|
|
notes, sort_order)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
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,
|
|
termMonths, comp.bank_name || dto.bankName || null,
|
|
startDate, maturityDate,
|
|
dto.rationale || dto.notes || null,
|
|
i,
|
|
],
|
|
);
|
|
results.push(rows[0]);
|
|
}
|
|
await this.invalidateProjectionCache(scenarioId);
|
|
return results;
|
|
}
|
|
|
|
// Single investment (no components)
|
|
const termMonths = dto.termMonths || null;
|
|
const maturityDate = computeMaturityDate(startDate, termMonths);
|
|
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, purchase_date, maturity_date, notes)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
|
RETURNING *`,
|
|
[
|
|
scenarioId, dto.sourceRecommendationId || null,
|
|
dto.title || dto.label || 'AI Recommendation',
|
|
dto.investmentType || null, dto.fundType || 'reserve',
|
|
dto.suggestedAmount || 0, dto.suggestedRate || null,
|
|
termMonths, dto.bankName || null,
|
|
startDate, maturityDate,
|
|
dto.rationale || dto.notes || null,
|
|
],
|
|
);
|
|
await this.invalidateProjectionCache(scenarioId);
|
|
return rows[0];
|
|
}
|
|
|
|
async updateInvestment(id: string, dto: any) {
|
|
const inv = await this.getInvestmentRow(id);
|
|
const rows = await this.tenant.query(
|
|
`UPDATE scenario_investments SET
|
|
label = COALESCE($2, label),
|
|
investment_type = COALESCE($3, investment_type),
|
|
fund_type = COALESCE($4, fund_type),
|
|
principal = COALESCE($5, principal),
|
|
interest_rate = COALESCE($6, interest_rate),
|
|
term_months = COALESCE($7, term_months),
|
|
institution = COALESCE($8, institution),
|
|
purchase_date = COALESCE($9, purchase_date),
|
|
maturity_date = COALESCE($10, maturity_date),
|
|
auto_renew = COALESCE($11, auto_renew),
|
|
notes = COALESCE($12, notes),
|
|
sort_order = COALESCE($13, sort_order),
|
|
updated_at = NOW()
|
|
WHERE id = $1 RETURNING *`,
|
|
[
|
|
id, dto.label, dto.investmentType, dto.fundType,
|
|
dto.principal, dto.interestRate, dto.termMonths,
|
|
dto.institution, dto.purchaseDate, dto.maturityDate,
|
|
dto.autoRenew, dto.notes, dto.sortOrder,
|
|
],
|
|
);
|
|
await this.invalidateProjectionCache(inv.scenario_id);
|
|
return rows[0];
|
|
}
|
|
|
|
async removeInvestment(id: string) {
|
|
const inv = await this.getInvestmentRow(id);
|
|
await this.tenant.query('DELETE FROM scenario_investments WHERE id = $1', [id]);
|
|
await this.invalidateProjectionCache(inv.scenario_id);
|
|
}
|
|
|
|
// ── Scenario Assessments ──
|
|
|
|
async listAssessments(scenarioId: string) {
|
|
return this.tenant.query(
|
|
'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY sort_order, effective_date',
|
|
[scenarioId],
|
|
);
|
|
}
|
|
|
|
async addAssessment(scenarioId: string, dto: any) {
|
|
await this.getScenarioRow(scenarioId);
|
|
const rows = await this.tenant.query(
|
|
`INSERT INTO scenario_assessments
|
|
(scenario_id, change_type, label, target_fund, percentage_change,
|
|
flat_amount_change, special_total, special_per_unit, special_installments,
|
|
effective_date, end_date, applies_to_group_id, notes, sort_order)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
RETURNING *`,
|
|
[
|
|
scenarioId, dto.changeType, dto.label, dto.targetFund || 'operating',
|
|
dto.percentageChange || null, dto.flatAmountChange || null,
|
|
dto.specialTotal || null, dto.specialPerUnit || null,
|
|
dto.specialInstallments || 1, dto.effectiveDate,
|
|
dto.endDate || null, dto.appliesToGroupId || null,
|
|
dto.notes || null, dto.sortOrder || 0,
|
|
],
|
|
);
|
|
await this.invalidateProjectionCache(scenarioId);
|
|
return rows[0];
|
|
}
|
|
|
|
async updateAssessment(id: string, dto: any) {
|
|
const asmt = await this.getAssessmentRow(id);
|
|
const rows = await this.tenant.query(
|
|
`UPDATE scenario_assessments SET
|
|
change_type = COALESCE($2, change_type),
|
|
label = COALESCE($3, label),
|
|
target_fund = COALESCE($4, target_fund),
|
|
percentage_change = COALESCE($5, percentage_change),
|
|
flat_amount_change = COALESCE($6, flat_amount_change),
|
|
special_total = COALESCE($7, special_total),
|
|
special_per_unit = COALESCE($8, special_per_unit),
|
|
special_installments = COALESCE($9, special_installments),
|
|
effective_date = COALESCE($10, effective_date),
|
|
end_date = COALESCE($11, end_date),
|
|
applies_to_group_id = COALESCE($12, applies_to_group_id),
|
|
notes = COALESCE($13, notes),
|
|
sort_order = COALESCE($14, sort_order),
|
|
updated_at = NOW()
|
|
WHERE id = $1 RETURNING *`,
|
|
[
|
|
id, dto.changeType, dto.label, dto.targetFund,
|
|
dto.percentageChange, dto.flatAmountChange,
|
|
dto.specialTotal, dto.specialPerUnit, dto.specialInstallments,
|
|
dto.effectiveDate, dto.endDate, dto.appliesToGroupId,
|
|
dto.notes, dto.sortOrder,
|
|
],
|
|
);
|
|
await this.invalidateProjectionCache(asmt.scenario_id);
|
|
return rows[0];
|
|
}
|
|
|
|
async removeAssessment(id: string) {
|
|
const asmt = await this.getAssessmentRow(id);
|
|
await this.tenant.query('DELETE FROM scenario_assessments WHERE id = $1', [id]);
|
|
await this.invalidateProjectionCache(asmt.scenario_id);
|
|
}
|
|
|
|
// ── Execute Investment (Story 1D) ──
|
|
|
|
async executeInvestment(investmentId: string, executionDate: string, userId: string) {
|
|
const inv = await this.getInvestmentRow(investmentId);
|
|
if (inv.executed_investment_id) {
|
|
throw new BadRequestException('This investment has already been executed');
|
|
}
|
|
|
|
// 1. Create real investment_accounts record
|
|
const invRows = await this.tenant.query(
|
|
`INSERT INTO investment_accounts
|
|
(name, institution, investment_type, fund_type, principal, interest_rate,
|
|
maturity_date, purchase_date, current_value, notes, is_active)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, true)
|
|
RETURNING *`,
|
|
[
|
|
inv.label, inv.institution, inv.investment_type || 'cd',
|
|
inv.fund_type, inv.principal, inv.interest_rate || 0,
|
|
inv.maturity_date, executionDate, inv.principal,
|
|
`Executed from scenario investment. ${inv.notes || ''}`.trim(),
|
|
],
|
|
);
|
|
const realInvestment = invRows[0];
|
|
|
|
// 2. Create journal entry at the execution date
|
|
const entryDate = new Date(executionDate);
|
|
const year = entryDate.getFullYear();
|
|
const month = entryDate.getMonth() + 1;
|
|
|
|
const periods = await this.tenant.query(
|
|
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
|
[year, month],
|
|
);
|
|
if (periods.length) {
|
|
const primaryRows = await this.tenant.query(
|
|
`SELECT id, name FROM accounts WHERE is_primary = true AND fund_type = $1 AND is_active = true LIMIT 1`,
|
|
[inv.fund_type],
|
|
);
|
|
const equityAccountNumber = inv.fund_type === 'reserve' ? '3100' : '3000';
|
|
const equityRows = await this.tenant.query(
|
|
'SELECT id FROM accounts WHERE account_number = $1',
|
|
[equityAccountNumber],
|
|
);
|
|
|
|
if (primaryRows.length && equityRows.length) {
|
|
const memo = `Transfer to investment: ${inv.label}`;
|
|
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 *`,
|
|
[executionDate, memo, periods[0].id, userId],
|
|
);
|
|
const je = jeRows[0];
|
|
// Credit primary asset account (reduces cash)
|
|
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, primaryRows[0].id, inv.principal, memo],
|
|
);
|
|
// Debit equity offset account
|
|
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, equityRows[0].id, inv.principal, memo],
|
|
);
|
|
}
|
|
}
|
|
|
|
// 3. Link back to scenario investment
|
|
await this.tenant.query(
|
|
`UPDATE scenario_investments SET executed_investment_id = $1, updated_at = NOW() WHERE id = $2`,
|
|
[realInvestment.id, investmentId],
|
|
);
|
|
|
|
await this.invalidateProjectionCache(inv.scenario_id);
|
|
return realInvestment;
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
private async getScenarioRow(id: string) {
|
|
const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [id]);
|
|
if (!rows.length) throw new NotFoundException('Scenario not found');
|
|
return rows[0];
|
|
}
|
|
|
|
private async getInvestmentRow(id: string) {
|
|
const rows = await this.tenant.query('SELECT * FROM scenario_investments WHERE id = $1', [id]);
|
|
if (!rows.length) throw new NotFoundException('Scenario investment not found');
|
|
return rows[0];
|
|
}
|
|
|
|
private async getAssessmentRow(id: string) {
|
|
const rows = await this.tenant.query('SELECT * FROM scenario_assessments WHERE id = $1', [id]);
|
|
if (!rows.length) throw new NotFoundException('Scenario assessment not found');
|
|
return rows[0];
|
|
}
|
|
|
|
async invalidateProjectionCache(scenarioId: string) {
|
|
await this.tenant.query(
|
|
`UPDATE board_scenarios SET projection_cache = NULL, projection_cached_at = NULL, updated_at = NOW() WHERE id = $1`,
|
|
[scenarioId],
|
|
);
|
|
}
|
|
}
|