fix: monthly actuals equity offset (Option A) + scenario activation creates accounts

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 14:36:17 -04:00
parent ba072b90f0
commit 72161f81f5
3 changed files with 236 additions and 39 deletions

View File

@@ -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) {