Transaction Categorization#
Transaction categorization in Sure operates on two independent axes:
- User-assigned category —
Transactionhas an optionalbelongs_to :categoryassociation. Any transaction can have a category set manually or via rules. - System-assigned
kind— an enum that controls whether a transaction appears in budget and income-statement analytics, regardless of its category.
Understanding both layers is necessary when building features that touch reporting, search, or the category UI.
Transaction Kinds and Budget Inclusion#
The kind enum has six values:
| Kind | Description |
|---|---|
standard | Regular transaction; included in budget analytics |
funds_movement | Internal account transfer; excluded from budget |
cc_payment | Credit-card payment; excluded from budget |
loan_payment | Payment to a Loan account; treated as an expense |
one_time | One-time expense/income; excluded from budget |
investment_contribution | Transfer to investment/crypto; treated as an expense |
Two constants drive filtering throughout the app :
TRANSFER_KINDS=%w[funds_movement cc_payment loan_payment investment_contribution]— used in search filters, rule conditions, and totals to identify all "money-between-accounts" transactions. Thetransfer?method delegates to this list.BUDGET_EXCLUDED_KINDS=%w[funds_movement one_time cc_payment]—loan_paymentandinvestment_contributionare intentionally excluded from this list because they represent real cash outflow.
Transfer-Specific Categorization Rules#
When a Transfer is created, Transfer.kind_for_account assigns the appropriate kind to each leg of the transfer based on the destination account type:
| Destination account | kind assigned |
|---|---|
loan? | loan_payment |
credit_card? or liability? | cc_payment |
investment? or crypto? | investment_contribution |
| anything else | funds_movement |
Categorization is blocked for most transfer types. Transfer#categorizable? returns true only when the destination account is a Loan. This directly gates the UI: _transaction_category.html.erb renders a full category menu only when transfer.categorizable? is true or the transaction has no transfer at all. All other transfer transactions display a static read-only badge — "Transfer" or "Payment" — instead.
When a transfer is destroyed, both legs revert to kind: "standard" .
Analytics and Income Statement Exclusion#
IncomeStatement::Totals applies two layers of exclusion when computing income and expense totals:
BUDGET_EXCLUDED_KINDS— rows wherekind IN (funds_movement, one_time, cc_payment)are dropped from the query .INTERNAL_MOVEMENT_LABELS— investment transactions with aninvestment_activity_labelofTransfer,Sweep In,Sweep Out, orExchangeare also excluded , since these represent auto cash-management within a brokerage and carry no budgeting significance.
Transactions with kind IN (investment_contribution, loan_payment) pass both filters and are always classified as expense — regardless of the raw entries.amount sign .
Trades (buy/sell orders) are fully excluded from income/expense calculations; they represent portfolio rebalancing with no net worth change .
IncomeStatement excludes pending transactions from all budget calculations; only posted transactions feed analytics .
Search totals in Transaction::Search#totals follow the same TRANSFER_KINDS split — reporting separate income_total, expense_total, transfer_inflow_total, and transfer_outflow_total columns.
Synthetic "Uncategorized" and "Other Investments" Categories#
Neither "Uncategorized" nor "Other Investments" exists as a database row. Both are in-memory Category objects built via Category.uncategorized and Category.other_investments. They are identified at runtime by uncategorized? and other_investments?, which check !persisted? plus the localized name. synthetic? is true for either.
In income statement calculations, IncomeStatement#build_period_total splits NULL-category rows into two buckets:
- Uncategorized —
category_id IS NULL AND NOT is_uncategorized_investment - Other Investments —
category_id IS NULL AND is_uncategorized_investment
is_uncategorized_investment is a computed column set by the SQL subquery in IncomeStatement::Totals .
Category.all_uncategorized_names returns the localized name in every supported locale, so search filter URL params work regardless of language.
Uncategorized Transactions in the UI#
Transactions index — shows a "Categorize N" button when @uncategorized_count > 0 , linking to the categorization wizard.
Category search filter — Transaction::Search#apply_category_filter handles "Uncategorized" as a special case: it matches rows where categories.id IS NULL AND transactions.kind NOT IN (TRANSFER_KINDS). Transfer-kind transactions are excluded from the uncategorized bucket because they intentionally have no category .
Type filter — apply_type_filter maps user-facing types (income, expense, transfer) to SQL conditions using TRANSFER_KINDS — "transfer" = kind IN TRANSFER_KINDS; "expense" / "income" = amount >= 0 / < 0 with kind NOT IN TRANSFER_KINDS.
Setting a category — Transaction#set_category! accepts a Category object or a string name, creating the category under the family if it doesn't exist.