QoL tweaks: Cash Flow cards, auto-primary accounts, investment projections, Sankey filters

- Dashboard: Remove tenant name/role subtitle
- Cash Flow: Replace Operating/Reserve net cards with inflow vs outflow
  breakdown showing In/Out amounts and signed net; replace Ending Cash
  card with AI Financial Health status from saved recommendation
- Accounts: Auto-set first asset account per fund_type as primary on creation
- Investments: Add 5th summary card for projected annual interest earnings
- Sankey: Add Actuals/Budget/Forecast data source toggle and
  All Funds/Operating/Reserve fund filter SegmentedControls with
  backend support for budget-based and forecast (actuals+budget) queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:22:37 -05:00
parent f1e66966f3
commit 07347a644f
7 changed files with 250 additions and 50 deletions

View File

@@ -142,7 +142,21 @@ export class AccountsService {
}
}
return account;
// Auto-set as primary if this is the first asset account for this fund_type
if (dto.accountType === 'asset') {
const existingPrimary = await this.tenant.query(
'SELECT id FROM accounts WHERE fund_type = $1 AND is_primary = true AND id != $2',
[dto.fundType, accountId],
);
if (!existingPrimary.length) {
await this.tenant.query(
'UPDATE accounts SET is_primary = true WHERE id = $1',
[accountId],
);
}
}
return this.findOne(accountId);
}
async update(id: string, dto: UpdateAccountDto) {

View File

@@ -24,8 +24,16 @@ export class ReportsController {
}
@Get('cash-flow-sankey')
getCashFlowSankey(@Query('year') year?: string) {
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear());
getCashFlowSankey(
@Query('year') year?: string,
@Query('source') source?: string,
@Query('fundType') fundType?: string,
) {
return this.reportsService.getCashFlowSankey(
parseInt(year || '') || new Date().getFullYear(),
source || 'actuals',
fundType || 'all',
);
}
@Get('cash-flow')

View File

@@ -83,33 +83,151 @@ export class ReportsService {
};
}
async getCashFlowSankey(year: number) {
// Get income accounts with amounts
const income = await this.tenant.query(`
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true
GROUP BY a.id, a.name
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
ORDER BY amount DESC
`, [year]);
async getCashFlowSankey(year: number, source = 'actuals', fundType = 'all') {
let income: any[];
let expenses: any[];
const expenses = await this.tenant.query(`
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'expense' AND a.is_active = true
GROUP BY a.id, a.name, a.fund_type
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
ORDER BY amount DESC
`, [year]);
const fundCondition = fundType !== 'all' ? ` AND a.fund_type = $2` : '';
const fundParams = fundType !== 'all' ? [year, fundType] : [year];
const monthSum = `COALESCE(b.jan,0)+COALESCE(b.feb,0)+COALESCE(b.mar,0)+COALESCE(b.apr,0)+COALESCE(b.may,0)+COALESCE(b.jun,0)+COALESCE(b.jul,0)+COALESCE(b.aug,0)+COALESCE(b.sep,0)+COALESCE(b.oct,0)+COALESCE(b.nov,0)+COALESCE(b.dec_amt,0)`;
if (source === 'budget') {
income = await this.tenant.query(`
SELECT a.name, SUM(${monthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name
HAVING SUM(${monthSum}) > 0
ORDER BY SUM(${monthSum}) DESC
`, fundParams);
expenses = await this.tenant.query(`
SELECT a.name, a.fund_type, SUM(${monthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name, a.fund_type
HAVING SUM(${monthSum}) > 0
ORDER BY SUM(${monthSum}) DESC
`, fundParams);
} else if (source === 'forecast') {
// Combine actuals (Jan to current date) + budget (remaining months)
const now = new Date();
const currentMonth = now.getMonth(); // 0-indexed
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
const remainingMonths = monthNames.slice(currentMonth + 1);
const actualsFundCond = fundType !== 'all' ? ' AND a.fund_type = $2' : '';
const actualsParams: any[] = fundType !== 'all' ? [`${year}-01-01`, fundType] : [`${year}-01-01`];
const actualsIncome = await this.tenant.query(`
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date >= $1 AND je.entry_date <= CURRENT_DATE
WHERE a.account_type = 'income' AND a.is_active = true${actualsFundCond}
GROUP BY a.id, a.name
`, actualsParams);
const actualsExpenses = await this.tenant.query(`
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date >= $1 AND je.entry_date <= CURRENT_DATE
WHERE a.account_type = 'expense' AND a.is_active = true${actualsFundCond}
GROUP BY a.id, a.name, a.fund_type
`, actualsParams);
// Budget for remaining months
let budgetIncome: any[] = [];
let budgetExpenses: any[] = [];
if (remainingMonths.length > 0) {
const budgetMonthSum = remainingMonths.map(m => `COALESCE(b.${m},0)`).join('+');
budgetIncome = await this.tenant.query(`
SELECT a.name, SUM(${budgetMonthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'income' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name
`, fundParams);
budgetExpenses = await this.tenant.query(`
SELECT a.name, a.fund_type, SUM(${budgetMonthSum}) as amount
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND a.account_type = 'expense' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name, a.fund_type
`, fundParams);
}
// Merge actuals + budget by account name
const incomeMap = new Map<string, number>();
for (const a of actualsIncome) {
const amt = parseFloat(a.amount) || 0;
if (amt > 0) incomeMap.set(a.name, (incomeMap.get(a.name) || 0) + amt);
}
for (const b of budgetIncome) {
const amt = parseFloat(b.amount) || 0;
if (amt > 0) incomeMap.set(b.name, (incomeMap.get(b.name) || 0) + amt);
}
income = Array.from(incomeMap.entries())
.map(([name, amount]) => ({ name, amount: String(amount) }))
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
const expenseMap = new Map<string, { amount: number; fund_type: string }>();
for (const a of actualsExpenses) {
const amt = parseFloat(a.amount) || 0;
if (amt > 0) {
const existing = expenseMap.get(a.name);
expenseMap.set(a.name, { amount: (existing?.amount || 0) + amt, fund_type: a.fund_type });
}
}
for (const b of budgetExpenses) {
const amt = parseFloat(b.amount) || 0;
if (amt > 0) {
const existing = expenseMap.get(b.name);
expenseMap.set(b.name, { amount: (existing?.amount || 0) + amt, fund_type: b.fund_type });
}
}
expenses = Array.from(expenseMap.entries())
.map(([name, { amount, fund_type }]) => ({ name, amount: String(amount), fund_type }))
.sort((a, b) => parseFloat(b.amount) - parseFloat(a.amount));
} else {
// Actuals: query journal entries for the year
income = await this.tenant.query(`
SELECT a.name, COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'income' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name
HAVING COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) > 0
ORDER BY amount DESC
`, fundParams);
expenses = await this.tenant.query(`
SELECT a.name, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND EXTRACT(YEAR FROM je.entry_date) = $1
WHERE a.account_type = 'expense' AND a.is_active = true${fundCondition}
GROUP BY a.id, a.name, a.fund_type
HAVING COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) > 0
ORDER BY amount DESC
`, fundParams);
}
if (!income.length && !expenses.length) {
return { nodes: [], links: [], total_income: 0, total_expenses: 0, net_cash_flow: 0 };