Merge pull request #608 from luckyPipewrench/investment-activity

Investment activity labels and classification
This commit is contained in:
soky srm
2026-01-13 10:13:31 +01:00
committed by GitHub
22 changed files with 284 additions and 39 deletions

View File

@@ -36,7 +36,7 @@ class AccountsController < ApplicationController
@chart_view = params[:chart_view] || "balance"
@tab = params[:tab]
@q = params.fetch(:q, {}).permit(:search, status: [])
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological
entries = @account.entries.search(@q).reverse_chronological
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")

View File

@@ -218,7 +218,7 @@ class TransactionsController < ApplicationController
def entry_params
entry_params = params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, { tag_ids: [] } ]
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, :investment_activity_label, { tag_ids: [] } ]
)
nature = entry_params.delete(:nature)

View File

@@ -18,8 +18,9 @@ class Account::ProviderImportAdapter
# @param notes [String, nil] Optional transaction notes/memo
# @param pending_transaction_id [String, nil] Plaid's linking ID for pending→posted reconciliation
# @param extra [Hash, nil] Optional provider-specific metadata to merge into transaction.extra
# @param investment_activity_label [String, nil] Optional activity type label (e.g., "Buy", "Dividend")
# @return [Entry] The created or updated entry
def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: nil, pending_transaction_id: nil, extra: nil)
def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: nil, pending_transaction_id: nil, extra: nil, investment_activity_label: nil)
raise ArgumentError, "external_id is required" if external_id.blank?
raise ArgumentError, "source is required" if source.blank?
@@ -114,7 +115,16 @@ class Account::ProviderImportAdapter
entry.transaction.extra = existing.deep_merge(incoming)
entry.transaction.save!
end
# Set investment activity label if provided and not already set
if investment_activity_label.present? && entry.entryable.is_a?(Transaction)
if entry.transaction.investment_activity_label.blank?
entry.transaction.assign_attributes(investment_activity_label: investment_activity_label)
end
end
entry.save!
entry.transaction.save! if entry.transaction.changed?
# AFTER save: For NEW posted transactions, check for fuzzy matches to SUGGEST (not auto-claim)
# This handles tip adjustments where auto-matching is too risky

View File

@@ -47,7 +47,7 @@ class IncomeStatement::CategoryStats
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
AND ae.excluded = false
AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true

View File

@@ -44,7 +44,7 @@ class IncomeStatement::FamilyStats
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
AND ae.excluded = false
AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true

View File

@@ -69,9 +69,9 @@ class IncomeStatement::Totals
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
AND ae.excluded = false
AND a.family_id = :family_id
AND a.family_id = :family_id
AND a.status IN ('draft', 'active')
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
SQL
@@ -95,9 +95,9 @@ class IncomeStatement::Totals
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
AND ae.excluded = false
AND a.family_id = :family_id
AND a.family_id = :family_id
AND a.status IN ('draft', 'active')
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
SQL
@@ -126,7 +126,7 @@ class IncomeStatement::Totals
WHERE a.family_id = :family_id
AND a.status IN ('draft', 'active')
AND ae.excluded = false
AND ae.date BETWEEN :start_date AND :end_date
AND ae.date BETWEEN :start_date AND :end_date
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END, CASE WHEN t.category_id IS NULL THEN true ELSE false END
SQL
end

View File

@@ -1,6 +1,24 @@
class PlaidAccount::Investments::TransactionsProcessor
SecurityNotFoundError = Class.new(StandardError)
# Map Plaid investment transaction types to activity labels
# All values must be valid Transaction::ACTIVITY_LABELS
PLAID_TYPE_TO_LABEL = {
"buy" => "Buy",
"sell" => "Sell",
"cancel" => "Other",
"cash" => "Other",
"fee" => "Fee",
"transfer" => "Transfer",
"dividend" => "Dividend",
"interest" => "Interest",
"contribution" => "Contribution",
"withdrawal" => "Withdrawal",
"dividend reinvestment" => "Reinvestment",
"spin off" => "Other",
"split" => "Other"
}.freeze
def initialize(plaid_account, security_resolver:)
@plaid_account = plaid_account
@security_resolver = security_resolver
@@ -68,10 +86,16 @@ class PlaidAccount::Investments::TransactionsProcessor
currency: transaction["iso_currency_code"],
date: transaction["date"],
name: transaction["name"],
source: "plaid"
source: "plaid",
investment_activity_label: label_from_plaid_type(transaction)
)
end
def label_from_plaid_type(transaction)
plaid_type = transaction["type"]&.downcase
PLAID_TYPE_TO_LABEL[plaid_type] || "Other"
end
def transactions
plaid_account.raw_investments_payload["transactions"] || []
end

View File

@@ -0,0 +1,31 @@
class Rule::ActionExecutor::SetInvestmentActivityLabel < Rule::ActionExecutor
def label
"Set investment activity label"
end
def type
"select"
end
def options
Transaction::ACTIVITY_LABELS.map { |l| [ l, l ] }
end
def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil)
return 0 unless Transaction::ACTIVITY_LABELS.include?(value)
scope = transaction_scope
unless ignore_attribute_locks
scope = scope.enrichable(:investment_activity_label)
end
count_modified_resources(scope) do |txn|
txn.enrich_attribute(
:investment_activity_label,
value,
source: "rule"
)
end
end
end

View File

@@ -20,6 +20,7 @@ class Rule::Registry::TransactionResource < Rule::Registry
Rule::ActionExecutor::SetTransactionTags.new(rule),
Rule::ActionExecutor::SetTransactionMerchant.new(rule),
Rule::ActionExecutor::SetTransactionName.new(rule),
Rule::ActionExecutor::SetInvestmentActivityLabel.new(rule),
Rule::ActionExecutor::ExcludeTransaction.new(rule)
]

View File

@@ -16,9 +16,16 @@ class Transaction < ApplicationRecord
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
one_time: "one_time" # A one-time expense/income, excluded from budget analytics
one_time: "one_time", # A one-time expense/income, excluded from budget analytics
investment_contribution: "investment_contribution" # Transfer to investment/crypto account, excluded from budget analytics
}
# All valid investment activity labels (for UI dropdown)
ACTIVITY_LABELS = [
"Buy", "Sell", "Sweep In", "Sweep Out", "Dividend", "Reinvestment",
"Interest", "Fee", "Transfer", "Contribution", "Withdrawal", "Exchange", "Other"
].freeze
# Pending transaction scopes - filter based on provider pending flags in extra JSONB
# Works with any provider that stores pending status in extra["provider_name"]["pending"]
scope :pending, -> {

View File

@@ -49,8 +49,8 @@ class Transaction::Search
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
result = transactions_scope
.select(
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
"COUNT(entries.id) as transactions_count"
)
.joins(
@@ -100,14 +100,14 @@ class Transaction::Search
if parent_category_ids.empty?
query = query.left_joins(:category).where(
"categories.name IN (?) OR (
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution'))
)",
categories
)
else
query = query.left_joins(:category).where(
"categories.name IN (?) OR categories.parent_id IN (?) OR (
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution'))
)",
categories, parent_category_ids
)

View File

@@ -16,6 +16,10 @@ class Transfer < ApplicationRecord
def kind_for_account(account)
if account.loan?
"loan_payment"
elsif account.credit_card?
"cc_payment"
elsif account.investment? || account.crypto?
"investment_contribution"
elsif account.liability?
"cc_payment"
else

View File

@@ -78,6 +78,13 @@
</span>
<% end %>
<%# Investment activity label badge %>
<% if transaction.investment_activity_label.present? %>
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-alpha-black-50 text-secondary" title="<%= t("transactions.transaction.activity_type_tooltip") %>">
<%= t("transactions.activity_labels.#{transaction.investment_activity_label.parameterize(separator: '_')}") %>
</span>
<% end %>
<%# Pending indicator %>
<% if transaction.pending? %>
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary text-secondary" title="<%= t("transactions.transaction.pending_tooltip") %>">

View File

@@ -194,8 +194,8 @@
data: { controller: "auto-submit-form" } do |f| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary">Exclude</h4>
<p class="text-secondary">Excluded transactions will be removed from budgeting calculations and reports.</p>
<h4 class="text-primary"><%= t(".exclude") %></h4>
<p class="text-secondary"><%= t(".exclude_description") %></p>
</div>
<%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
@@ -203,6 +203,33 @@
<% end %>
</div>
<% if @entry.account.investment? || @entry.account.crypto? %>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.fields_for :entryable do |ef| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary"><%= t(".activity_type") %></h4>
<p class="text-secondary"><%= t(".activity_type_description") %></p>
</div>
<%= ef.select :investment_activity_label,
options_for_select(
[["—", nil]] + Transaction::ACTIVITY_LABELS.map { |l| [t("transactions.activity_labels.#{l.parameterize(separator: '_')}"), l] },
@entry.entryable.investment_activity_label
),
{ label: false },
{ class: "form-field__input border border-secondary rounded-lg px-3 py-1.5 max-w-40 text-sm",
data: { auto_submit_form_target: "auto" } } %>
</div>
<% end %>
<% end %>
</div>
<% end %>
<div class="pb-4">
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
@@ -211,8 +238,8 @@
<%= f.fields_for :entryable do |ef| %>
<div class="flex cursor-pointer items-center gap-4 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-primary">One-time <%= @entry.amount.negative? ? "Income" : "Expense" %></h4>
<p class="text-secondary">One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.</p>
<h4 class="text-primary"><%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %></h4>
<p class="text-secondary"><%= t(".one_time_description") %></p>
</div>
<%= ef.toggle :kind, {

View File

@@ -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

View File

@@ -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

View File

@@ -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

2
db/schema.rb generated
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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