mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
Merge pull request #608 from luckyPipewrench/investment-activity
Investment activity labels and classification
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
]
|
||||
|
||||
|
||||
@@ -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, -> {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") %>">
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user