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:
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user