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); // 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, principal, interest_rate, term_months, institution, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, [ scenarioId, dto.sourceRecommendationId || null, dto.title || dto.label || 'AI Recommendation', dto.investmentType || null, dto.fundType || 'reserve', dto.suggestedAmount || 0, dto.suggestedRate || null, dto.termMonths || null, dto.bankName || null, 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], ); } }