Financial Reporting#
Sure's financial reporting pipeline centers on two components: IncomeStatement — the Ruby model that aggregates transactions by category into income and expense totals — and the Cashflow Sankey chart — a D3-based dashboard widget that visualizes those flows. Everything from budget analytics, trends reports, and financial insights draws from IncomeStatement.
Key source files:
| File | Purpose |
|---|---|
app/models/income_statement.rb | Public API: expense_totals, income_totals, net_category_totals, median_expense, median_income |
app/models/income_statement/totals.rb | Raw SQL query layer that aggregates entries by category |
app/models/income_statement/totals.rb (FamilyStats) | Interval-based median/avg stats across the whole family |
app/models/income_statement/totals.rb (CategoryStats) | Same, scoped per category |
app/controllers/pages_controller.rb | Builds @cashflow_sankey_data and @outflows_data for the dashboard |
app/views/pages/dashboard/_cashflow_sankey.html.erb | Sankey container partial; falls back to empty state |
app/views/pages/dashboard/_cashflow_sankey_chart.html.erb | Mounts the sankey-chart Stimulus controller with JSON data |
app/javascript/controllers/sankey_chart_controller.js | D3 rendering, zooming, hover tooltips, drill-down |
IncomeStatement Model#
IncomeStatement is instantiated per family (Current.family.income_statement) and optionally scoped to a single user's accounts. Its public API returns value objects rather than ActiveRecord relations:
expense_totals(period:)/income_totals(period:)— return aPeriodTotalstruct with atotalandcategory_totalsarray, one entry per category.net_category_totals(period:)— computes a per-category net (expense − income) and returns aNetCategoryTotalswith separatenet_expense_categoriesandnet_income_categorieslists. This is what drives the Sankey and outflows donut.median_expense/median_income— historical medians used by financial insights (e.g., cash-flow warning, spending anomaly generators).
Results are memoized per period within a single instance and the underlying SQL results are Rails-cached against family.entries_cache_version and accounts.maximum(:updated_at) .
Pending transactions are always excluded — family.transactions.visible.excluding_pending is the default scope.
Category Breakdown#
build_period_total maps each persisted category plus the two synthetic categories — Uncategorized and Other Investments — into a CategoryTotal with a weight (percentage of classification total). Parent categories aggregate their children's totals .
SQL Layer: IncomeStatement::Totals#
Totals#call runs a single SQL query and returns TotalsRow structs. The query routes through two paths :
combined_query_sql(default,include_trades: true): UNION ALL oftransactions_subquery_sqlandtrades_subquery_sql.transactions_only_query_sql: legacy path for backward-compatibility.
In practice, trades_subquery_sql always returns an empty result set (WHERE false) because trades represent portfolio rebalancing with no cash-flow significance .
Transaction Filtering (what gets excluded)#
Four layers of exclusion are applied inside the SQL WHERE clause :
BUDGET_EXCLUDED_KINDS—funds_movement,one_time,cc_paymenttransactions are dropped .loan_paymentandinvestment_contributionpass through and are forced toexpenseclassification .INTERNAL_MOVEMENT_LABELS— investment transactions withinvestment_activity_label IN ('Transfer', 'Sweep In', 'Sweep Out', 'Exchange')are excluded . These are brokerage cash-management events with no budgeting significance.- Tax-advantaged accounts — accounts with subtypes like 401k, IRA, HSA are excluded via
exclude_tax_advantaged_sql. See Account Reporting Controls for details. exclude_from_reportsflag — accounts with this flag set are filtered at the SQL level .
Classification Logic#
Amounts are classified as income when ae.amount < 0 (credits) and expense otherwise, with the special override that investment_contribution and loan_payment are always expense . All totals are converted to the family's currency via a join on exchange_rates .
Cashflow Sankey Chart#
The Sankey is built and rendered as follows:
1. Data assembly (PagesController#build_cashflow_sankey_data)
All three IncomeStatement totals are fetched in PagesController#dashboard and passed to build_cashflow_sankey_data. The output is a { nodes: [], links: [], currency_symbol: } hash.
The central node is "Cash Flow" . Income categories flow into it; expense categories flow out of it. A Surplus node is appended when total_income > total_expense .
Node/link construction uses net category values from net_category_totals — each category's total is expense − income, so categories that appear on both sides of the ledger are correctly netted . Subcategory netting is handled by build_net_subcategories, which groups by parent_id and assigns a net_direction per subcategory. process_net_category_nodes then places subcategories on the same side as their parent (or the opposite side if their net flips direction).
2. View layer
The _cashflow_sankey partial checks for non-empty sankey_data[:links]; if empty, it renders an empty-state with a "Add transaction" link . When data is present, it renders the chart partial and also mounts a full-screen expandable dialog at #cashflow-expanded-dialog .
The _cashflow_sankey_chart partial mounts the sankey-chart Stimulus controller and passes data, currencySymbol, startDate, and endDate as Stimulus values. The zoom-out button is hidden by default and surfaced by the controller on drill-down .
3. sankey_chart_controller.js
A ~631-line D3 Stimulus controller that handles layout calculation, gradient-filled link rendering, label overlap prevention, zoom/drill-down, and interactive hover tooltips. Clicking a category node drills into its subcategories. Node colors use the category's stored color or Category::UNCATEGORIZED_COLOR as fallback .
Dashboard layout: the cashflow_sankey section is col_span: "full", non-growing, with a 384px min-height. Users can toggle between single/full column width . The section is visible only when @accounts.any? .
Downstream Consumers#
IncomeStatement is used beyond the dashboard:
- Trends & Insights report — month-over-month income/expense/savings table and summary cards. See
app/views/reports/_trends_insights.html.erb. - Financial Insights generators —
SavingsRateChangeGenerator,CashFlowWarningGenerator, andSpendingAnomalyGeneratorall pull fromIncomeStatement#income_totals,expense_totals,median_expense, andcategory_totals. - Outflows donut chart — built by
build_outflows_donut_datafrom the samenet_category_totalsresult as the Sankey. - Reports controller —
ReportsController#build_transactions_breakdownusesAccount.included_in_reportsbut does not apply the tax-advantaged exclusion, creating a known inconsistency vs.IncomeStatement::Totals.