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, {