<%= styled_form_with model: @entry,
url: transaction_path(@entry),
@@ -211,8 +238,8 @@
<%= f.fields_for :entryable do |ef| %>
-
One-time <%= @entry.amount.negative? ? "Income" : "Expense" %>
-
One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.
+
<%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %>
+
<%= t(".one_time_description") %>
<%= ef.toggle :kind, {
diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml
index 717d146b1..ae7bb1cb3 100644
--- a/config/locales/views/transactions/en.yml
+++ b/config/locales/views/transactions/en.yml
@@ -31,26 +31,47 @@ en:
balances, and cannot be undone.
delete_title: Delete transaction
details: Details
- mark_recurring: Mark as Recurring
- mark_recurring_subtitle: Track this as a recurring transaction. Amount variance is automatically calculated from past 6 months of similar transactions.
- mark_recurring_title: Recurring Transaction
- merchant_label: Merchant
- name_label: Name
- nature: Type
- none: "(none)"
- note_label: Notes
- note_placeholder: Enter a note
- overview: Overview
- settings: Settings
- tags_label: Tags
- uncategorized: "(uncategorized)"
- potential_duplicate_title: Possible duplicate detected
- potential_duplicate_description: This pending transaction may be the same as the posted transaction below. If so, merge them to avoid double-counting.
- merge_duplicate: Yes, merge them
- keep_both: No, keep both
+ exclude: Exclude
+ exclude_description: Excluded transactions will be removed from budgeting calculations and reports.
+ activity_type: Activity Type
+ activity_type_description: Type of investment activity (Buy, Sell, Dividend, etc.). Auto-detected or set manually.
+ one_time_title: One-time %{type}
+ one_time_description: One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.
+ activity_labels:
+ buy: Buy
+ sell: Sell
+ sweep_in: Sweep In
+ sweep_out: Sweep Out
+ dividend: Dividend
+ reinvestment: Reinvestment
+ interest: Interest
+ fee: Fee
+ transfer: Transfer
+ contribution: Contribution
+ withdrawal: Withdrawal
+ exchange: Exchange
+ other: Other
+ mark_recurring: Mark as Recurring
+ mark_recurring_subtitle: Track this as a recurring transaction. Amount variance is automatically calculated from past 6 months of similar transactions.
+ mark_recurring_title: Recurring Transaction
+ merchant_label: Merchant
+ name_label: Name
+ nature: Type
+ none: "(none)"
+ note_label: Notes
+ note_placeholder: Enter a note
+ overview: Overview
+ settings: Settings
+ tags_label: Tags
+ uncategorized: "(uncategorized)"
+ potential_duplicate_title: Possible duplicate detected
+ potential_duplicate_description: This pending transaction may be the same as the posted transaction below. If so, merge them to avoid double-counting.
+ merge_duplicate: Yes, merge them
+ keep_both: No, keep both
transaction:
pending: Pending
pending_tooltip: Pending transaction — may change when posted
+ activity_type_tooltip: Investment activity type
possible_duplicate: Duplicate?
potential_duplicate_tooltip: This may be a duplicate of another transaction
review_recommended: Review
diff --git a/db/migrate/20260110120000_add_investment_cashflow_support.rb b/db/migrate/20260110120000_add_investment_cashflow_support.rb
new file mode 100644
index 000000000..39765f4f1
--- /dev/null
+++ b/db/migrate/20260110120000_add_investment_cashflow_support.rb
@@ -0,0 +1,5 @@
+class AddInvestmentCashflowSupport < ActiveRecord::Migration[7.2]
+ # No-op: exclude_from_cashflow was consolidated into the existing 'excluded' toggle
+ def change
+ end
+end
diff --git a/db/migrate/20260110180000_add_investment_activity_label_to_transactions.rb b/db/migrate/20260110180000_add_investment_activity_label_to_transactions.rb
new file mode 100644
index 000000000..658900bc2
--- /dev/null
+++ b/db/migrate/20260110180000_add_investment_activity_label_to_transactions.rb
@@ -0,0 +1,8 @@
+class AddInvestmentActivityLabelToTransactions < ActiveRecord::Migration[7.2]
+ def change
+ # Label for investment activity type (Buy, Sell, Sweep In, Dividend, etc.)
+ # Provides human-readable context for why a transaction is excluded from cashflow
+ add_column :transactions, :investment_activity_label, :string
+ add_index :transactions, :investment_activity_label
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a4b314661..ad548839c 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -342,14 +342,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_12_065106) do
t.jsonb "locked_attributes", default: {}
t.string "external_id"
t.string "source"
- t.boolean "exclude_from_cashflow", default: false, null: false
t.index "lower((name)::text)", name: "index_entries_on_lower_name"
t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date"
t.index ["account_id", "source", "external_id"], name: "index_entries_on_account_source_and_external_id", unique: true, where: "((external_id IS NOT NULL) AND (source IS NOT NULL))"
t.index ["account_id"], name: "index_entries_on_account_id"
t.index ["date"], name: "index_entries_on_date"
t.index ["entryable_type"], name: "index_entries_on_entryable_type"
- t.index ["exclude_from_cashflow"], name: "index_entries_on_exclude_from_cashflow"
t.index ["import_id"], name: "index_entries_on_import_id"
end
diff --git a/test/models/income_statement_test.rb b/test/models/income_statement_test.rb
index b152917c6..e4af61d66 100644
--- a/test/models/income_statement_test.rb
+++ b/test/models/income_statement_test.rb
@@ -285,4 +285,22 @@ class IncomeStatementTest < ActiveSupport::TestCase
assert_equal 5, totals.transactions_count
assert_equal Money.new(1050, @family.currency), totals.expense_money # 900 + 150
end
+
+ test "excludes investment_contribution transactions from income statement" do
+ # Create a transfer to investment account (marked as investment_contribution)
+ investment_contribution = create_transaction(
+ account: @checking_account,
+ amount: 1000,
+ category: nil,
+ kind: "investment_contribution"
+ )
+
+ income_statement = IncomeStatement.new(@family)
+ totals = income_statement.totals(date_range: Period.last_30_days.date_range)
+
+ # investment_contribution should be excluded (it's in the exclusion list)
+ assert_equal 4, totals.transactions_count # Only original 4 transactions
+ assert_equal Money.new(1000, @family.currency), totals.income_money
+ assert_equal Money.new(900, @family.currency), totals.expense_money
+ end
end
diff --git a/test/models/rule/action_test.rb b/test/models/rule/action_test.rb
index db3933ea0..bcb8846af 100644
--- a/test/models/rule/action_test.rb
+++ b/test/models/rule/action_test.rb
@@ -100,4 +100,36 @@ class Rule::ActionTest < ActiveSupport::TestCase
assert_equal new_name, transaction.reload.entry.name
end
end
+
+ test "set_investment_activity_label" do
+ # Does not modify transactions that are locked (user edited them)
+ @txn1.lock_attr!(:investment_activity_label)
+
+ action = Rule::Action.new(
+ rule: @transaction_rule,
+ action_type: "set_investment_activity_label",
+ value: "Dividend"
+ )
+
+ action.apply(@rule_scope)
+
+ assert_nil @txn1.reload.investment_activity_label
+
+ [ @txn2, @txn3 ].each do |transaction|
+ assert_equal "Dividend", transaction.reload.investment_activity_label
+ end
+ end
+
+ test "set_investment_activity_label ignores invalid values" do
+ action = Rule::Action.new(
+ rule: @transaction_rule,
+ action_type: "set_investment_activity_label",
+ value: "InvalidLabel"
+ )
+
+ result = action.apply(@rule_scope)
+
+ assert_equal 0, result
+ assert_nil @txn1.reload.investment_activity_label
+ end
end
diff --git a/test/models/transaction_test.rb b/test/models/transaction_test.rb
index 7b5f2faeb..9064f6b97 100644
--- a/test/models/transaction_test.rb
+++ b/test/models/transaction_test.rb
@@ -18,4 +18,36 @@ class TransactionTest < ActiveSupport::TestCase
assert_not transaction.pending?
end
+
+ test "investment_contribution is a valid kind" do
+ transaction = Transaction.new(kind: "investment_contribution")
+
+ assert_equal "investment_contribution", transaction.kind
+ assert transaction.investment_contribution?
+ end
+
+ test "all transaction kinds are valid" do
+ valid_kinds = %w[standard funds_movement cc_payment loan_payment one_time investment_contribution]
+
+ valid_kinds.each do |kind|
+ transaction = Transaction.new(kind: kind)
+ assert_equal kind, transaction.kind, "#{kind} should be a valid transaction kind"
+ end
+ end
+
+ test "ACTIVITY_LABELS contains all valid labels" do
+ assert_includes Transaction::ACTIVITY_LABELS, "Buy"
+ assert_includes Transaction::ACTIVITY_LABELS, "Sell"
+ assert_includes Transaction::ACTIVITY_LABELS, "Sweep In"
+ assert_includes Transaction::ACTIVITY_LABELS, "Sweep Out"
+ assert_includes Transaction::ACTIVITY_LABELS, "Dividend"
+ assert_includes Transaction::ACTIVITY_LABELS, "Reinvestment"
+ assert_includes Transaction::ACTIVITY_LABELS, "Interest"
+ assert_includes Transaction::ACTIVITY_LABELS, "Fee"
+ assert_includes Transaction::ACTIVITY_LABELS, "Transfer"
+ assert_includes Transaction::ACTIVITY_LABELS, "Contribution"
+ assert_includes Transaction::ACTIVITY_LABELS, "Withdrawal"
+ assert_includes Transaction::ACTIVITY_LABELS, "Exchange"
+ assert_includes Transaction::ACTIVITY_LABELS, "Other"
+ end
end
diff --git a/test/models/transfer_test.rb b/test/models/transfer_test.rb
index 331638165..e47d200d2 100644
--- a/test/models/transfer_test.rb
+++ b/test/models/transfer_test.rb
@@ -104,4 +104,24 @@ class TransferTest < ActiveSupport::TestCase
Transfer.create!(inflow_transaction: inflow_entry2.transaction, outflow_transaction: outflow_entry.transaction)
end
end
+
+ test "kind_for_account returns investment_contribution for investment accounts" do
+ assert_equal "investment_contribution", Transfer.kind_for_account(accounts(:investment))
+ end
+
+ test "kind_for_account returns investment_contribution for crypto accounts" do
+ assert_equal "investment_contribution", Transfer.kind_for_account(accounts(:crypto))
+ end
+
+ test "kind_for_account returns loan_payment for loan accounts" do
+ assert_equal "loan_payment", Transfer.kind_for_account(accounts(:loan))
+ end
+
+ test "kind_for_account returns cc_payment for credit card accounts" do
+ assert_equal "cc_payment", Transfer.kind_for_account(accounts(:credit_card))
+ end
+
+ test "kind_for_account returns funds_movement for depository accounts" do
+ assert_equal "funds_movement", Transfer.kind_for_account(accounts(:depository))
+ end
end