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:
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
if (!cashAccounts.length) {
|
||||
cashAccounts = await this.tenant.query(
|
||||
`SELECT id FROM accounts WHERE account_number = '1000' 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`,
|
||||
);
|
||||
const equityByFund: Record<string, string> = {};
|
||||
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<string, { type: string; fundType: string }>(
|
||||
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<string, number> = {};
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user