From 72161f81f516f384c2e4e8cfbace1282d802eaa6 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Thu, 21 May 2026 14:36:17 -0400 Subject: [PATCH] fix: monthly actuals equity offset (Option A) + scenario activation creates accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Monthly Actuals — Option A: - Replace operating cash account offset with per-fund equity account clearing - Equity accounts 3000/3100 now absorb the net P&L from actuals entries - Cash account is never touched by monthly actuals, eliminating the balance discrepancy that required manual cash adjustments - Per-fund routing: operating income/expense clears to 3000, reserve to 3100 - Falls back gracefully if only one equity account exists Scenario Activation (Issue 4): - updateScenario now accepts userId and triggers materialisation when status transitions to 'active' - Each pending scenario investment is created as a real investment_accounts record dated to its purchase_date (future dates are supported) - Journal entries are posted at the purchase_date using the fund's primary cash account and equity offset (matching manual account creation) - Rollover detection: if an existing active investment matures within 7 days of the new investment's purchase_date and shares the same fund_type, the system creates a maturity JE (proceeds → cash) and a reinvestment JE (cash → new CD) rather than a fresh cash deduction, then retires the source investment - Per-investment failures are logged but do not abort the rest of the batch Co-Authored-By: Claude Sonnet 4.6 --- .../board-planning.controller.ts | 4 +- .../board-planning/board-planning.service.ts | 190 +++++++++++++++++- .../monthly-actuals.service.ts | 81 ++++---- 3 files changed, 236 insertions(+), 39 deletions(-) diff --git a/backend/src/modules/board-planning/board-planning.controller.ts b/backend/src/modules/board-planning/board-planning.controller.ts index 7692db9..9d792d0 100644 --- a/backend/src/modules/board-planning/board-planning.controller.ts +++ b/backend/src/modules/board-planning/board-planning.controller.ts @@ -43,8 +43,8 @@ export class BoardPlanningController { @Put('scenarios/:id') @RequireCapability('planning.scenarios.edit') - updateScenario(@Param('id') id: string, @Body() dto: any) { - return this.service.updateScenario(id, dto); + updateScenario(@Param('id') id: string, @Body() dto: any, @Req() req: any) { + return this.service.updateScenario(id, dto, req.user.sub); } @Delete('scenarios/:id') diff --git a/backend/src/modules/board-planning/board-planning.service.ts b/backend/src/modules/board-planning/board-planning.service.ts index efb65b1..eafdc38 100644 --- a/backend/src/modules/board-planning/board-planning.service.ts +++ b/backend/src/modules/board-planning/board-planning.service.ts @@ -51,8 +51,8 @@ export class BoardPlanningService { return rows[0]; } - async updateScenario(id: string, dto: any) { - await this.getScenarioRow(id); + async updateScenario(id: string, dto: any, userId?: string) { + const existing = await this.getScenarioRow(id); const rows = await this.tenant.query( `UPDATE board_scenarios SET name = COALESCE($2, name), @@ -63,7 +63,191 @@ export class BoardPlanningService { WHERE id = $1 RETURNING *`, [id, dto.name, dto.description, dto.status, dto.projectionMonths], ); - return rows[0]; + const updated = rows[0]; + + // When a scenario first transitions to 'active', materialise all pending + // investments as real investment_accounts records, dated to their purchase_date. + if (dto.status === 'active' && existing.status !== 'active' && userId) { + await this.activateScenarioInvestments(id, userId); + } + + await this.invalidateProjectionCache(id); + return updated; + } + + private async activateScenarioInvestments(scenarioId: string, userId: string) { + const investments = await this.tenant.query( + `SELECT * FROM scenario_investments + WHERE scenario_id = $1 AND executed_investment_id IS NULL + ORDER BY sort_order, purchase_date`, + [scenarioId], + ); + + for (const inv of investments) { + try { + await this.materialiseScenarioInvestment(inv, userId); + } catch (err: any) { + // Log failure per-investment but don't abort the rest + console.error(`[scenario activation] Failed to execute investment ${inv.id} (${inv.label}):`, err?.message); + } + } + } + + private async materialiseScenarioInvestment(inv: any, userId: string) { + const purchaseDate: string = inv.purchase_date + ? (inv.purchase_date instanceof Date + ? inv.purchase_date.toISOString().split('T')[0] + : String(inv.purchase_date).split('T')[0]) + : new Date().toISOString().split('T')[0]; + + // Detect a CD rollover: an existing active investment in the same fund that + // matures within 7 days of this investment's purchase date. + const rolloverRows = await this.tenant.query( + `SELECT id, name, current_value, principal, interest_rate, maturity_date + FROM investment_accounts + WHERE fund_type = $1 + AND is_active = true + AND maturity_date IS NOT NULL + AND ABS(maturity_date - $2::date) <= 7 + ORDER BY ABS(maturity_date - $2::date) + LIMIT 1`, + [inv.fund_type, purchaseDate], + ); + const isRollover = rolloverRows.length > 0; + const rolloverSource = isRollover ? rolloverRows[0] : null; + + // 1. Create the real investment_accounts record (purchase_date = scenario date) + 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, + purchaseDate, + inv.principal, + `Activated from scenario. ${inv.notes || ''}`.trim(), + ], + ); + const realInvestment = invRows[0]; + + // 2. Journal entries — only if a fiscal period exists for the purchase month + const d = new Date(purchaseDate); + const yr = d.getFullYear(); + const mo = d.getMonth() + 1; + const periods = await this.tenant.query( + 'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2', + [yr, mo], + ); + + if (periods.length) { + const equityAcctNum = inv.fund_type === 'reserve' ? '3100' : '3000'; + const equityRows = await this.tenant.query( + 'SELECT id FROM accounts WHERE account_number = $1 AND is_active = true LIMIT 1', + [equityAcctNum], + ); + const primaryRows = await this.tenant.query( + `SELECT id FROM accounts + WHERE is_primary = true AND fund_type = $1 AND account_type = 'asset' AND is_active = true + LIMIT 1`, + [inv.fund_type], + ); + + if (equityRows.length && primaryRows.length) { + const primaryId = primaryRows[0].id; + const equityId = equityRows[0].id; + const periodId = periods[0].id; + + if (isRollover && rolloverSource) { + // ── Rollover path ── + // Step A: return maturing CD proceeds to primary cash + const srcValue = parseFloat(rolloverSource.current_value) || parseFloat(rolloverSource.principal) || 0; + const maturityMemo = `Maturity: ${rolloverSource.name} → rollover to ${inv.label}`; + + const matJERows = 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 id`, + [purchaseDate, maturityMemo, periodId, userId], + ); + const matJEId = matJERows[0].id; + // Debit primary cash (proceeds arrive) + await this.tenant.query( + `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) + VALUES ($1, $2, $3, 0, $4)`, + [matJEId, primaryId, srcValue, maturityMemo], + ); + // Credit equity (reverses the original investment transfer) + await this.tenant.query( + `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) + VALUES ($1, $2, 0, $3, $4)`, + [matJEId, equityId, srcValue, maturityMemo], + ); + + // Step B: deploy proceeds into the new CD + const reinvestMemo = `Rollover investment: ${inv.label}`; + const newJERows = 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 id`, + [purchaseDate, reinvestMemo, periodId, userId], + ); + const newJEId = newJERows[0].id; + // Credit primary cash (funds deployed) + await this.tenant.query( + `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) + VALUES ($1, $2, 0, $3, $4)`, + [newJEId, primaryId, inv.principal, reinvestMemo], + ); + // Debit equity (fund balance reduced by deployed amount) + await this.tenant.query( + `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) + VALUES ($1, $2, $3, 0, $4)`, + [newJEId, equityId, inv.principal, reinvestMemo], + ); + + // Retire the source investment + await this.tenant.query( + 'UPDATE investment_accounts SET is_active = false, updated_at = NOW() WHERE id = $1', + [rolloverSource.id], + ); + } else { + // ── Fresh purchase path ── + const memo = `Scenario 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 id`, + [purchaseDate, memo, periodId, userId], + ); + const jeId = jeRows[0].id; + // Credit primary cash (funds leave operating/reserve account) + await this.tenant.query( + `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) + VALUES ($1, $2, 0, $3, $4)`, + [jeId, primaryId, inv.principal, memo], + ); + // Debit equity (marks the transfer out of the fund balance) + await this.tenant.query( + `INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo) + VALUES ($1, $2, $3, 0, $4)`, + [jeId, equityId, inv.principal, memo], + ); + } + } + } + + // 3. Link scenario investment back to the real account + await this.tenant.query( + 'UPDATE scenario_investments SET executed_investment_id = $1, updated_at = NOW() WHERE id = $2', + [realInvestment.id, inv.id], + ); + + return realInvestment; } async deleteScenario(id: string) { diff --git a/backend/src/modules/monthly-actuals/monthly-actuals.service.ts b/backend/src/modules/monthly-actuals/monthly-actuals.service.ts index 1892178..0142a96 100644 --- a/backend/src/modules/monthly-actuals/monthly-actuals.service.ts +++ b/backend/src/modules/monthly-actuals/monthly-actuals.service.ts @@ -92,21 +92,28 @@ export class MonthlyActualsService { await this.journalEntriesService.void(entry.id, userId, `Replaced by updated monthly actuals for ${monthLabel} ${year}`); } - // 2. Find primary operating cash account (offset account for double-entry) - let cashAccounts = await this.tenant.query( - `SELECT id FROM accounts WHERE is_primary = true AND fund_type = 'operating' AND account_type = 'asset' LIMIT 1`, + // 2. Find equity accounts per fund type to use as the double-entry clearing offset. + // Using equity instead of cash means monthly actuals never move the cash balance — + // cash is tracked exclusively via real transaction journal entries. + // Equity normal balance is credit; a debit position here represents recognized income + // exceeding expenses (the P&L surplus cleared to fund balance). + const equityAccountRows = await this.tenant.query( + `SELECT id, fund_type FROM accounts + WHERE account_type = 'equity' AND is_active = true + ORDER BY + CASE WHEN account_number IN ('3000','3100') THEN 0 ELSE 1 END, + account_number`, ); - if (!cashAccounts.length) { - cashAccounts = await this.tenant.query( - `SELECT id FROM accounts WHERE account_number = '1000' AND account_type = 'asset' LIMIT 1`, - ); + const equityByFund: Record = {}; + for (const row of equityAccountRows) { + if (!equityByFund[row.fund_type]) equityByFund[row.fund_type] = row.id; } - if (!cashAccounts.length) { + const fallbackEquityId = equityByFund['operating'] || equityByFund['reserve'] || null; + if (!fallbackEquityId) { throw new BadRequestException( - 'No primary cash account found. Please set a primary operating account on the Accounts page.', + 'No equity account found. Please ensure equity accounts (3000/3100) are set up in your Chart of Accounts.', ); } - const cashAccountId = cashAccounts[0].id; // 3. Filter to lines with actual amounts const filteredLines = lines.filter((l) => l.amount !== 0); @@ -114,53 +121,59 @@ export class MonthlyActualsService { return { message: 'No actuals to save', journal_entry_id: null }; } - // 4. Look up account types for each line + // 4. Look up account types AND fund types for each line const accountIds = filteredLines.map((l) => l.accountId); const accountRows = await this.tenant.query( - `SELECT id, account_type FROM accounts WHERE id = ANY($1)`, + `SELECT id, account_type, fund_type FROM accounts WHERE id = ANY($1)`, [accountIds], ); - const accountTypeMap = new Map(accountRows.map((a: any) => [a.id, a.account_type])); + const accountInfoMap = new Map( + accountRows.map((a: any) => [a.id, { type: a.account_type as string, fundType: (a.fund_type || 'operating') as string }]), + ); - // 5. Build journal entry lines + // 5. Build journal entry lines; track net equity offset per fund const jeLines: any[] = []; - let totalCashDebit = 0; - let totalCashCredit = 0; + // equityNetByFund: positive → net debit on equity (income > expense for fund) + // negative → net credit on equity (expense > income for fund) + const equityNetByFund: Record = {}; for (const line of filteredLines) { - const acctType = accountTypeMap.get(line.accountId); - if (!acctType) continue; + const acctInfo = accountInfoMap.get(line.accountId); + if (!acctInfo) continue; const abs = Math.abs(line.amount); + const fund = acctInfo.fundType; - if (acctType === 'expense') { + if (acctInfo.type === 'expense') { if (line.amount > 0) { - // Normal expense: debit expense, credit cash jeLines.push({ accountId: line.accountId, debit: abs, credit: 0, memo: `${monthLabel} actual` }); - totalCashCredit += abs; + equityNetByFund[fund] = (equityNetByFund[fund] || 0) + abs; // equity to be credited } else { - // Negative expense (refund/correction): credit expense, debit cash jeLines.push({ accountId: line.accountId, debit: 0, credit: abs, memo: `${monthLabel} actual (correction)` }); - totalCashDebit += abs; + equityNetByFund[fund] = (equityNetByFund[fund] || 0) - abs; } - } else if (acctType === 'income') { + } else if (acctInfo.type === 'income') { if (line.amount > 0) { - // Normal income: credit income, debit cash jeLines.push({ accountId: line.accountId, debit: 0, credit: abs, memo: `${monthLabel} actual` }); - totalCashDebit += abs; + equityNetByFund[fund] = (equityNetByFund[fund] || 0) - abs; // equity to be debited } else { - // Negative income (correction): debit income, credit cash jeLines.push({ accountId: line.accountId, debit: abs, credit: 0, memo: `${monthLabel} actual (correction)` }); - totalCashCredit += abs; + equityNetByFund[fund] = (equityNetByFund[fund] || 0) + abs; } } } - // 6. Add offsetting cash line(s) to balance the entry - const netCash = totalCashDebit - totalCashCredit; - if (netCash > 0) { - jeLines.push({ accountId: cashAccountId, debit: netCash, credit: 0, memo: `${monthLabel} actuals offset` }); - } else if (netCash < 0) { - jeLines.push({ accountId: cashAccountId, debit: 0, credit: Math.abs(netCash), memo: `${monthLabel} actuals offset` }); + // 6. Add one equity clearing line per fund to balance the entry + for (const [fund, net] of Object.entries(equityNetByFund)) { + if (net === 0) continue; + const equityId = equityByFund[fund] || fallbackEquityId; + // net > 0 means expenses exceed income for this fund → credit equity (equity absorbs expense) + // net < 0 means income exceeds expenses → debit equity (income clears through equity) + jeLines.push({ + accountId: equityId, + debit: net < 0 ? Math.abs(net) : 0, + credit: net > 0 ? net : 0, + memo: `${monthLabel} actuals clearing`, + }); } // 7. Set entry_date to last day of the month