Transaction Exclusion#
Overview#
"Exclusion" in Sure is a soft-delete for analytics: setting entries.excluded = true removes an entry from reports, budget totals, and the default transaction list, while keeping the row in the database. The flag is a first-class boolean on Entry and is part of the protected_from_sync? guard that prevents provider sync from overwriting user intent .
Both regular transactions and investment trades expose the same excluded boolean through their respective controllers and view toggles.
Data Model#
The excluded flag lives directly on the entries table row. Supporting predicates and validations in Entry:
protected_from_sync?— returnstrueifexcluded?,user_modified?, orimport_locked?. An excluded entry is never overwritten by sync.protection_reason— returns:excludedas the highest-priority reason; used by the UI to decide which protection badge to render.cannot_unexclude_split_parent— a model validation that prevents togglingexcludedfromtrue→falsewhile child entries exist (i.e., the entry is a split parent). This is the primary constraint distinguishing exclusion on a normal transaction from exclusion on a split parent.
Excluded entries continue to appear in the transaction list (they are not hidden by default) but are rendered with dimmed styling (see Visual Rendering below).
Toggle Controls#
Transactions#
The "Exclude from Analytics" setting appears in the Settings section of the transaction detail drawer (transactions/show.html.erb). It is rendered only when:
- The user has
can_edit_entry?permission, and - The entry is not a split parent (
@entry.split_parent?must be false)
The toggle is a standard f.toggle :excluded auto-submit form field that fires a PATCH to transaction_path(@entry) . The excluded param flows through entry_params in TransactionsController, which permits :excluded as a top-level entry attribute.
Trades#
The trade detail drawer (trades/show.html.erb) exposes the same f.toggle :excluded auto-submit form in its own Settings section — with no split-parent guard since trades cannot be split. The toggle is always visible to users with edit permission.
The excluded param is permitted in TradesController#entry_params.
Visual Rendering of Excluded Entries#
Transaction list row#
Excluded transactions are rendered with dimmed styling: the name/merchant block gets opacity-50 text-secondary and the amount cell gets opacity-50 . The protection lock icon (shown on hover for user_modified entries) is not shown when entry.excluded? is true .
Trade list row#
Excluded trades apply text-secondary bg-surface-inset to the entire row container . As with transactions, the protection lock icon is hidden when entry.excluded? .
Filtering#
The Transaction::Search#transactions_scope base query applies Entry.excluding_split_parents but does not filter by excluded — meaning excluded transactions appear in the default list alongside active ones (dimmed). The status filter only surfaces confirmed / pending ; there is no dedicated "excluded" or "hidden" filter option in the UI. The apply_status_filter method delegates to Entry.pending / Entry.excluding_pending scopes .
Backend Logic & Business Rules#
Split parent constraint#
When an entry is split, Entry#split! sets excluded: true on the parent atomically with creating child entries. The cannot_unexclude_split_parent validation then permanently blocks any attempt to set excluded: false on that entry while children exist. The only way to restore a split parent is via Entry#unsplit!, which destroys all children and then sets excluded: false on the parent.
Automatic exclusion#
Two automated exclusion paths exist in the sync pipeline:
- Stale pending transactions —
Entry.auto_exclude_stale_pendingbulk-setsexcluded: trueon pending entries older than 8 days that have no matching posted record (called after each sync via SimpleFIN). - Pending duplicate reconciliation —
Entry.reconcile_pending_duplicatessetsexcluded: trueon a pending entry when exactly one matching posted entry is found .
Trade-to-transaction conversion#
When a transaction is converted to a trade, the original transaction entry is soft-deleted via @entry.update!(excluded: true) within the same database transaction that creates the new trade entry. This is intentional — the raw transaction is preserved for audit purposes but removed from analytics.
Convert-to-trade guard#
The "Convert to Trade" action in the transaction drawer is hidden when the entry is already excluded (!@entry.excluded? check) , preventing a double-conversion.
Analytics exclusion#
- The
excluding_split_parentsscope (aNOT EXISTSsubquery) removes split parents from the transaction list and fromTransaction::Searchtotals. - The Rule Engine's
TransactionResourceusesfamily.transactions.visibleand explicitly excludes split parents, so rules never fire on split parents. - The
uncategorized_transactionsscope filtersexcluded: false, so excluded entries are invisible to the uncategorized-count badge.
Known Issue: Excluded Investment Transactions Cannot Be Restored#
Trades (investment entries) have an exclude toggle in their detail drawer, but as of issue #2612, once a transaction inside an investment account is excluded (e.g., a buy that was excluded rather than converted to a trade), there is no in-UI toggle to restore it — the entry disappears from the transaction list and the only recovery path is a direct database edit or controller call. The balance tooltip may still reflect the excluded entry, causing a visible inconsistency.
This is distinct from the split-parent restriction; it affects plain investment transactions where the UI toggle simply isn't surfaced after exclusion.
Key Files#
| File | Purpose |
|---|---|
app/models/entry.rb | excluded flag, protected_from_sync?, split constraints, auto-exclusion class methods |
app/controllers/transactions_controller.rb | Permits excluded param; handles trade-to-transaction conversion soft-delete |
app/controllers/trades_controller.rb | Permits excluded param for trade entries |
app/views/transactions/show.html.erb | Exclusion toggle in transaction drawer; split-parent guard |
app/views/trades/show.html.erb | Exclusion toggle in trade drawer |
app/views/transactions/_transaction.html.erb | Row-level dimming for excluded transactions |
app/views/trades/_trade.html.erb | Row-level dimming for excluded trades |
app/models/transaction/search.rb | Search/filter scope; status filter does not surface excluded |
app/views/transactions/searches/filters/_status_filter.html.erb | Status filter UI (confirmed/pending only) |