diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 1524b25d0..b71318b62 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -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.search(@q).reverse_chronological + entries = @account.entries.where(excluded: false).search(@q).reverse_chronological @pagy, @entries = pagy(entries, limit: params[:per_page] || "10") diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 91593f801..9a076701d 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -123,6 +123,9 @@ class ReportsController < ApplicationController # Investment metrics @investment_metrics = build_investment_metrics + # Investment flows (contributions/withdrawals) + @investment_flows = InvestmentFlowStatement.new(Current.family).period_totals(period: @period) + # Flags for view rendering @has_accounts = Current.family.accounts.any? end @@ -161,6 +164,14 @@ class ReportsController < ApplicationController visible: @investment_metrics[:has_investments], collapsible: true }, + { + key: "investment_flows", + title: "reports.investment_flows.title", + partial: "reports/investment_flows", + locals: { investment_flows: @investment_flows }, + visible: @investment_metrics[:has_investments] && (@investment_flows.contributions.amount > 0 || @investment_flows.withdrawals.amount > 0), + collapsible: true + }, { key: "transactions_breakdown", title: "reports.transactions_breakdown.title", @@ -345,14 +356,6 @@ class ReportsController < ApplicationController # Apply filters transactions = apply_transaction_filters(transactions) - # Get trades in the period (matching income_statement logic) - trades = Trade - .joins(:entry) - .joins(entry: :account) - .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] }) - .where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range }) - .includes(entry: :account, category: []) - # Get sort parameters sort_by = params[:sort_by] || "amount" sort_direction = params[:sort_direction] || "desc" @@ -378,24 +381,6 @@ class ReportsController < ApplicationController grouped_data[key][:total] += converted_amount end - # Process trades - trades.each do |trade| - entry = trade.entry - is_expense = entry.amount > 0 - type = is_expense ? "expense" : "income" - # Use "Other Investments" for trades without category - category_name = trade.category&.name || Category.other_investments_name - category_color = trade.category&.color || Category::OTHER_INVESTMENTS_COLOR - - # Convert to family currency - converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount - - key = [ category_name, type, category_color ] - grouped_data[key] ||= { total: 0, count: 0 } - grouped_data[key][:count] += 1 - grouped_data[key][:total] += converted_amount - end - # Convert to array result = grouped_data.map do |key, data| { @@ -562,14 +547,6 @@ class ReportsController < ApplicationController transactions = apply_transaction_filters(transactions) - # Get trades in the period (matching income_statement logic) - trades = Trade - .joins(:entry) - .joins(entry: :account) - .where(accounts: { family_id: Current.family.id, status: [ "draft", "active" ] }) - .where(entries: { entryable_type: "Trade", excluded: false, date: @period.date_range }) - .includes(entry: :account, category: []) - # Group by category, type, and month breakdown = {} family_currency = Current.family.currency @@ -592,25 +569,6 @@ class ReportsController < ApplicationController breakdown[key][:total] += converted_amount end - # Process trades - trades.each do |trade| - entry = trade.entry - is_expense = entry.amount > 0 - type = is_expense ? "expense" : "income" - # Use "Other Investments" for trades without category - category_name = trade.category&.name || Category.other_investments_name - month_key = entry.date.beginning_of_month - - # Convert to family currency - converted_amount = Money.new(entry.amount.abs, entry.currency).exchange_to(family_currency, fallback_rate: 1).amount - - key = [ category_name, type ] - breakdown[key] ||= { category: category_name, type: type, months: {}, total: 0 } - breakdown[key][:months][month_key] ||= 0 - breakdown[key][:months][month_key] += converted_amount - breakdown[key][:total] += converted_amount - end - # Convert to array and sort by type and total (descending) result = breakdown.map do |key, data| { diff --git a/app/controllers/trades_controller.rb b/app/controllers/trades_controller.rb index 46cd1f39a..f131c47ef 100644 --- a/app/controllers/trades_controller.rb +++ b/app/controllers/trades_controller.rb @@ -55,7 +55,7 @@ class TradesController < ApplicationController def entry_params params.require(:entry).permit( :name, :date, :amount, :currency, :excluded, :notes, :nature, - entryable_attributes: [ :id, :qty, :price, :category_id ] + entryable_attributes: [ :id, :qty, :price, :investment_activity_label ] ) end @@ -73,11 +73,27 @@ class TradesController < ApplicationController qty = update_params[:entryable_attributes][:qty] price = update_params[:entryable_attributes][:price] + nature = update_params[:nature] if qty.present? && price.present? - qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d + is_sell = nature == "inflow" + qty = is_sell ? -qty.to_d.abs : qty.to_d.abs update_params[:entryable_attributes][:qty] = qty update_params[:amount] = qty * price.to_d + + # Sync investment_activity_label with Buy/Sell type if not explicitly set to something else + # Check both the submitted param and the existing record's label + current_label = update_params[:entryable_attributes][:investment_activity_label].presence || + @entry.trade&.investment_activity_label + if current_label.blank? || current_label == "Buy" || current_label == "Sell" + update_params[:entryable_attributes][:investment_activity_label] = is_sell ? "Sell" : "Buy" + end + + # Update entry name to reflect Buy/Sell change + ticker = @entry.trade&.security&.ticker + if ticker.present? + update_params[:name] = Trade.build_name(is_sell ? "sell" : "buy", qty.abs, ticker) + end end update_params.except(:nature) diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 58220bd99..8c8ef6a21 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -149,6 +149,87 @@ class TransactionsController < ApplicationController redirect_back_or_to transactions_path end + def convert_to_trade + @transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) + @entry = @transaction.entry + + unless @entry.account.investment? + flash[:alert] = t("transactions.convert_to_trade.errors.not_investment_account") + redirect_back_or_to transactions_path + return + end + + render :convert_to_trade + end + + def create_trade_from_transaction + @transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) + @entry = @transaction.entry + + # Pre-transaction validations + unless @entry.account.investment? + flash[:alert] = t("transactions.convert_to_trade.errors.not_investment_account") + redirect_back_or_to transactions_path + return + end + + if @entry.excluded? + flash[:alert] = t("transactions.convert_to_trade.errors.already_converted") + redirect_back_or_to transactions_path + return + end + + # Resolve security before transaction + security = resolve_security_for_conversion + return if performed? # Early exit if redirect already happened + + # Validate and calculate qty/price before transaction + qty, price = calculate_qty_and_price + return if performed? # Early exit if redirect already happened + + activity_label = params[:investment_activity_label].presence + # Infer sell from amount sign: negative amount = money coming in = sell + is_sell = activity_label == "Sell" || (activity_label.blank? && @entry.amount < 0) + + ActiveRecord::Base.transaction do + # For trades: positive qty = buy (money out), negative qty = sell (money in) + signed_qty = is_sell ? -qty : qty + trade_amount = qty * price + # Sells bring money in (negative amount), Buys take money out (positive amount) + signed_amount = is_sell ? -trade_amount : trade_amount + + # Default activity label if not provided + activity_label ||= is_sell ? "Sell" : "Buy" + + # Create trade entry + @entry.account.entries.create!( + name: params[:trade_name] || Trade.build_name(is_sell ? "sell" : "buy", qty, security.ticker), + date: @entry.date, + amount: signed_amount, + currency: @entry.currency, + entryable: Trade.new( + security: security, + qty: signed_qty, + price: price, + currency: @entry.currency, + investment_activity_label: activity_label + ) + ) + + # Mark original transaction as excluded (soft delete) + @entry.update!(excluded: true) + end + + flash[:notice] = t("transactions.convert_to_trade.success") + redirect_to account_path(@entry.account), status: :see_other + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + flash[:alert] = t("transactions.convert_to_trade.errors.conversion_failed", error: e.message) + redirect_back_or_to transactions_path, status: :see_other + rescue StandardError => e + flash[:alert] = t("transactions.convert_to_trade.errors.unexpected_error", error: e.message) + redirect_back_or_to transactions_path, status: :see_other + end + def mark_as_recurring transaction = Current.family.transactions.includes(entry: :account).find(params[:id]) @@ -280,4 +361,64 @@ class TransactionsController < ApplicationController def preferences_params params.require(:preferences).permit(collapsed_sections: {}) end + + # Helper methods for convert_to_trade + + def resolve_security_for_conversion + if params[:security_id] == "__custom__" + ticker = params[:custom_ticker].presence + unless ticker.present? + flash[:alert] = t("transactions.convert_to_trade.errors.enter_ticker") + redirect_back_or_to transactions_path + return nil + end + + Security::Resolver.new( + ticker.strip, + exchange_operating_mic: params[:exchange_operating_mic].presence + ).resolve + elsif params[:security_id].present? + found = Security.find_by(id: params[:security_id]) + unless found + flash[:alert] = t("transactions.convert_to_trade.errors.security_not_found") + redirect_back_or_to transactions_path + return nil + end + found + elsif params[:ticker].present? + Security::Resolver.new( + params[:ticker].strip, + exchange_operating_mic: params[:exchange_operating_mic].presence + ).resolve + end.tap do |security| + if security.nil? && !performed? + flash[:alert] = t("transactions.convert_to_trade.errors.select_security") + redirect_back_or_to transactions_path + end + end + end + + def calculate_qty_and_price + amount = @entry.amount.abs + qty = params[:qty].present? ? params[:qty].to_d.abs : nil + price = params[:price].present? ? params[:price].to_d : nil + + if qty.nil? && price.nil? + flash[:alert] = t("transactions.convert_to_trade.errors.enter_qty_or_price") + redirect_back_or_to transactions_path, status: :see_other + return [ nil, nil ] + elsif qty.nil? && price.present? && price > 0 + qty = (amount / price).round(6) + elsif price.nil? && qty.present? && qty > 0 + price = (amount / qty).round(4) + end + + if qty.nil? || qty <= 0 || price.nil? || price <= 0 + flash[:alert] = t("transactions.convert_to_trade.errors.invalid_qty_or_price") + redirect_back_or_to transactions_path, status: :see_other + return [ nil, nil ] + end + + [ qty, price ] + end end diff --git a/app/javascript/controllers/activity_label_quick_edit_controller.js b/app/javascript/controllers/activity_label_quick_edit_controller.js new file mode 100644 index 000000000..ee10fdf2c --- /dev/null +++ b/app/javascript/controllers/activity_label_quick_edit_controller.js @@ -0,0 +1,108 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["dropdown", "badge"] + static values = { + url: String, + entryableId: String, + currentLabel: String, + entryableType: String, + convertUrl: String + } + + connect() { + // Close dropdown when clicking outside + this.boundCloseOnClickOutside = this.closeOnClickOutside.bind(this) + document.addEventListener("click", this.boundCloseOnClickOutside) + } + + disconnect() { + document.removeEventListener("click", this.boundCloseOnClickOutside) + } + + toggle(event) { + event.preventDefault() + event.stopPropagation() + + if (this.hasDropdownTarget) { + this.dropdownTarget.classList.toggle("hidden") + } + } + + closeOnClickOutside(event) { + if (!this.element.contains(event.target)) { + this.close() + } + } + + close() { + if (this.hasDropdownTarget) { + this.dropdownTarget.classList.add("hidden") + } + } + + async select(event) { + event.preventDefault() + event.stopPropagation() + + const label = event.currentTarget.dataset.label + + // Don't update if it's the same label + if (label === this.currentLabelValue) { + this.close() + return + } + + // For Transactions: Buy/Sell should prompt to convert to trade + if (this.entryableTypeValue === "Transaction" && (label === "Buy" || label === "Sell") && this.hasConvertUrlValue) { + this.close() + // Navigate to convert-to-trade modal in a Turbo frame, passing the selected label + const url = new URL(this.convertUrlValue, window.location.origin) + url.searchParams.set("activity_label", label) + Turbo.visit(url.toString(), { frame: "modal" }) + return + } + + // For other labels (Dividend, Interest, Fee, etc.) or for Trades, just save the label + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content + if (!csrfToken) { + console.error("CSRF token not found") + return + } + + try { + const response = await fetch(this.urlValue, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": csrfToken, + "Accept": "text/vnd.turbo-stream.html" + }, + body: JSON.stringify({ + entry: { + entryable_attributes: { + id: this.entryableIdValue, + investment_activity_label: label + } + } + }) + }) + + if (response.ok) { + const contentType = response.headers.get("content-type") + if (contentType?.includes("text/vnd.turbo-stream.html")) { + // Let Turbo handle the stream response + const html = await response.text() + Turbo.renderStreamMessage(html) + } + // Update local state and badge + this.currentLabelValue = label + this.close() + } else { + console.error("Failed to update activity label:", response.status) + } + } catch (error) { + console.error("Error updating activity label:", error) + } + } +} diff --git a/app/javascript/controllers/convert_to_trade_controller.js b/app/javascript/controllers/convert_to_trade_controller.js new file mode 100644 index 000000000..c0e71663b --- /dev/null +++ b/app/javascript/controllers/convert_to_trade_controller.js @@ -0,0 +1,21 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["customWrapper", "customField", "tickerSelect"] + + toggleCustomTicker(event) { + const value = event.target.value + + if (value === "__custom__") { + // Show custom ticker field + this.customWrapperTarget.classList.remove("hidden") + this.customFieldTarget.required = true + this.customFieldTarget.focus() + } else { + // Hide custom ticker field + this.customWrapperTarget.classList.add("hidden") + this.customFieldTarget.required = false + this.customFieldTarget.value = "" + } + } +} diff --git a/app/models/account/provider_import_adapter.rb b/app/models/account/provider_import_adapter.rb index bae56389e..932edf57f 100644 --- a/app/models/account/provider_import_adapter.rb +++ b/app/models/account/provider_import_adapter.rb @@ -142,10 +142,28 @@ class Account::ProviderImportAdapter 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) + # Auto-detect investment activity labels for investment accounts + detected_label = investment_activity_label + if account.investment? && detected_label.nil? && entry.entryable.is_a?(Transaction) + detected_label = detect_activity_label(name, amount) + end + + # Auto-set kind for internal movements and contributions + auto_kind = nil + if Transaction::INTERNAL_MOVEMENT_LABELS.include?(detected_label) + auto_kind = "funds_movement" + elsif detected_label == "Contribution" + auto_kind = "investment_contribution" + end + + # Set investment activity label and kind if detected + if entry.entryable.is_a?(Transaction) + if detected_label.present? && entry.transaction.investment_activity_label.blank? + entry.transaction.assign_attributes(investment_activity_label: detected_label) + end + + if auto_kind.present? + entry.transaction.assign_attributes(kind: auto_kind) end end @@ -484,8 +502,9 @@ class Account::ProviderImportAdapter # @param name [String, nil] Optional custom name for the trade # @param external_id [String, nil] Provider's unique ID (optional, for deduplication) # @param source [String] Provider name + # @param activity_label [String, nil] Investment activity label (e.g., "Buy", "Sell", "Reinvestment") # @return [Entry] The created entry with trade - def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:) + def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:, activity_label: nil) raise ArgumentError, "security is required" if security.nil? raise ArgumentError, "source is required" if source.blank? @@ -522,7 +541,8 @@ class Account::ProviderImportAdapter security: security, qty: quantity, price: price, - currency: currency + currency: currency, + investment_activity_label: activity_label || (quantity > 0 ? "Buy" : "Sell") ) entry.assign_attributes( @@ -790,6 +810,36 @@ class Account::ProviderImportAdapter ) end + # Auto-detects investment activity label from transaction name and amount + # Only detects extremely obvious cases to maintain high accuracy + # Users can always manually adjust the label afterward + # + # @param name [String] Transaction name/description + # @param amount [BigDecimal, Numeric] Transaction amount (positive or negative) + # @return [String, nil] Detected activity label or nil if no pattern matches + def detect_activity_label(name, amount) + return nil if name.blank? + + name_lower = name.downcase.strip + + # Only detect the most obvious patterns - be conservative to avoid false positives + # Users can manually adjust labels for edge cases + case name_lower + when /^dividend\b/, /\bdividend payment\b/, /\bqualified dividend\b/, /\bordinary dividend\b/ + "Dividend" + when /^interest\b/, /\binterest income\b/, /\binterest payment\b/ + "Interest" + when /^fee\b/, /\bmanagement fee\b/, /\badvisory fee\b/, /\btransaction fee\b/ + "Fee" + when /\bemployer match\b/, /\bemployer contribution\b/ + "Contribution" + when /\b401[k\(]/, /\bira contribution\b/, /\broth contribution\b/ + "Contribution" + else + nil # Let user categorize manually - default to nil for safety + end + end + # Determines why an entry should be skipped during sync. # Returns nil if entry should NOT be skipped. # diff --git a/app/models/category.rb b/app/models/category.rb index f668ac1fc..b6586492e 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,6 +1,5 @@ class Category < ApplicationRecord has_many :transactions, dependent: :nullify, class_name: "Transaction" - has_many :trades, dependent: :nullify has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" belongs_to :family diff --git a/app/models/income_statement/totals.rb b/app/models/income_statement/totals.rb index 758ae6be3..ffcb468b1 100644 --- a/app/models/income_statement/totals.rb +++ b/app/models/income_statement/totals.rb @@ -96,6 +96,10 @@ class IncomeStatement::Totals er.to_currency = :target_currency ) WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution') + AND ( + at.investment_activity_label IS NULL + OR at.investment_activity_label NOT IN ('Transfer', 'Sweep In', 'Sweep Out', 'Exchange') + ) AND ae.excluded = false AND a.family_id = :family_id AND a.status IN ('draft', 'active') @@ -104,30 +108,14 @@ class IncomeStatement::Totals end def trades_subquery_sql - # Get trades for the same family and date range as transactions - # Trades without categories appear as "Uncategorized Investments" (separate from regular uncategorized) + # Trades are completely excluded from income/expense budgets + # Rationale: Trades represent portfolio rebalancing, not cash flow + # Example: Selling $10k AAPL to buy MSFT = no net worth change, not an expense + # Contributions/withdrawals are tracked separately as Transactions with activity labels <<~SQL - SELECT - c.id as category_id, - c.parent_id as parent_category_id, - CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification, - ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total, - COUNT(ae.id) as entry_count, - CASE WHEN t.category_id IS NULL THEN true ELSE false END as is_uncategorized_investment - FROM trades t - JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Trade' - JOIN accounts a ON a.id = ae.account_id - LEFT JOIN categories c ON c.id = t.category_id - LEFT JOIN exchange_rates er ON ( - er.date = ae.date AND - er.from_currency = ae.currency AND - er.to_currency = :target_currency - ) - 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 - 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 + SELECT NULL as category_id, NULL as parent_category_id, NULL as classification, + NULL as total, NULL as entry_count, NULL as is_uncategorized_investment + WHERE false SQL end diff --git a/app/models/investment_flow_statement.rb b/app/models/investment_flow_statement.rb new file mode 100644 index 000000000..d17a71266 --- /dev/null +++ b/app/models/investment_flow_statement.rb @@ -0,0 +1,30 @@ +class InvestmentFlowStatement + include Monetizable + + attr_reader :family + + def initialize(family) + @family = family + end + + # Get contribution/withdrawal totals for a period + def period_totals(period: Period.current_month) + transactions = family.transactions + .visible + .excluding_pending + .where(entries: { date: period.date_range }) + .where(kind: %w[standard investment_contribution]) + .where(investment_activity_label: %w[Contribution Withdrawal]) + + contributions = transactions.where(investment_activity_label: "Contribution").sum("entries.amount").abs + withdrawals = transactions.where(investment_activity_label: "Withdrawal").sum("entries.amount").abs + + PeriodTotals.new( + contributions: Money.new(contributions, family.currency), + withdrawals: Money.new(withdrawals, family.currency), + net_flow: Money.new(contributions - withdrawals, family.currency) + ) + end + + PeriodTotals = Data.define(:contributions, :withdrawals, :net_flow) +end diff --git a/app/models/plaid_account/investments/transactions_processor.rb b/app/models/plaid_account/investments/transactions_processor.rb index d90657ce6..5cabccb2c 100644 --- a/app/models/plaid_account/investments/transactions_processor.rb +++ b/app/models/plaid_account/investments/transactions_processor.rb @@ -72,7 +72,8 @@ class PlaidAccount::Investments::TransactionsProcessor currency: transaction["iso_currency_code"], date: transaction["date"], name: transaction["name"], - source: "plaid" + source: "plaid", + activity_label: label_from_plaid_type(transaction) ) end diff --git a/app/models/trade.rb b/app/models/trade.rb index b9233f9db..0692fae96 100644 --- a/app/models/trade.rb +++ b/app/models/trade.rb @@ -4,10 +4,13 @@ class Trade < ApplicationRecord monetize :price belongs_to :security - belongs_to :category, optional: true + + # Use the same activity labels as Transaction + ACTIVITY_LABELS = Transaction::ACTIVITY_LABELS.dup.freeze validates :qty, presence: true validates :price, :currency, presence: true + validates :investment_activity_label, inclusion: { in: ACTIVITY_LABELS }, allow_nil: true # Trade types for categorization def buy? @@ -35,4 +38,10 @@ class Trade < ApplicationRecord Trend.new(current: current_value, previous: cost_basis) end + + # Trades are always excluded from expense budgets + # They represent portfolio management, not living expenses + def excluded_from_budget? + true + end end diff --git a/app/models/trade/create_form.rb b/app/models/trade/create_form.rb index a6973df72..d822131c5 100644 --- a/app/models/trade/create_form.rb +++ b/app/models/trade/create_form.rb @@ -42,7 +42,7 @@ class Trade::CreateForm price: price, currency: currency, security: security, - category: investment_category_for(type) + investment_activity_label: type.capitalize # "buy" → "Buy", "sell" → "Sell" ) ) @@ -54,14 +54,6 @@ class Trade::CreateForm trade_entry end - def investment_category_for(trade_type) - # Buy trades are categorized as "Savings & Investments" (expense) - # Sell trades are left uncategorized for now - return nil unless trade_type == "buy" - - account.family.categories.find_by(name: "Savings & Investments") - end - def create_interest_income signed_amount = amount.to_d * -1 diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index ed0f17bcd..e8372bed4 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -21,7 +21,7 @@ class TradeImport < Import qty: row.qty, currency: row.currency.presence || mapped_account.currency, price: row.price, - category: investment_category_for(row.qty, mapped_account.family), + investment_activity_label: investment_activity_label_for(row.qty), entry: Entry.new( account: mapped_account, date: row.date_iso, @@ -78,12 +78,11 @@ class TradeImport < Import end private - def investment_category_for(qty, family) - # Buy trades (positive qty) are categorized as "Savings & Investments" - # Sell trades are left uncategorized - users will be prompted to categorize - return nil unless qty.to_d.positive? - - family.categories.find_by(name: "Savings & Investments") + def investment_activity_label_for(qty) + # Set activity label based on quantity signage + # Buy trades have positive qty, Sell trades have negative qty + return nil if qty.blank? || qty.to_d.zero? + qty.to_d.positive? ? "Buy" : "Sell" end def find_or_create_security(ticker: nil, exchange_operating_mic: nil) diff --git a/app/models/transaction.rb b/app/models/transaction.rb index e1a86afb5..4850923b2 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -26,6 +26,9 @@ class Transaction < ApplicationRecord "Interest", "Fee", "Transfer", "Contribution", "Withdrawal", "Exchange", "Other" ].freeze + # Internal movement labels that should be excluded from budget (auto cash management) + INTERNAL_MOVEMENT_LABELS = [ "Transfer", "Sweep In", "Sweep Out", "Exchange" ].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, -> { diff --git a/app/views/investment_activity/_badge.html.erb b/app/views/investment_activity/_badge.html.erb new file mode 100644 index 000000000..549d5b33f --- /dev/null +++ b/app/views/investment_activity/_badge.html.erb @@ -0,0 +1,33 @@ +<%# locals: (label:) %> +<%# Simple non-interactive badge for displaying activity labels %> +<% + # Color mapping for different investment activity labels + color = case label + when "Buy" + "rgb(59 130 246)" # blue + when "Sell" + "rgb(239 68 68)" # red + when "Dividend", "Interest" + "rgb(34 197 94)" # green + when "Contribution" + "rgb(168 85 247)" # purple + when "Withdrawal" + "rgb(249 115 22)" # orange + when "Fee" + "rgb(107 114 128)" # gray + when "Transfer", "Sweep In", "Sweep Out", "Exchange" + "rgb(107 114 128)" # gray + when "Reinvestment" + "rgb(59 130 246)" # blue + else + "rgb(107 114 128)" # gray for "Other" + end +%> + + + <%= label %> + diff --git a/app/views/investment_activity/_quick_edit_badge.html.erb b/app/views/investment_activity/_quick_edit_badge.html.erb new file mode 100644 index 000000000..4db357ef2 --- /dev/null +++ b/app/views/investment_activity/_quick_edit_badge.html.erb @@ -0,0 +1,95 @@ +<%# locals: (entry:, entryable:) %> +<% + label = entryable.investment_activity_label + has_label = label.present? + + # Build the correct URL based on entryable type + update_url = entryable.is_a?(Transaction) ? transaction_path(entry) : trade_path(entry) + + # Color mapping for different investment activity labels using design system tokens + color = if has_label + case label + when "Buy" + "var(--color-blue-500)" + when "Sell" + "var(--color-red-500)" + when "Dividend", "Interest" + "var(--color-green-500)" + when "Contribution" + "var(--color-violet-500)" + when "Withdrawal" + "var(--color-orange-500)" + when "Fee" + "var(--color-gray-500)" + when "Transfer", "Sweep In", "Sweep Out", "Exchange" + "var(--color-gray-500)" + when "Reinvestment" + "var(--color-blue-500)" + else + "var(--color-gray-500)" # for "Other" + end + else + "var(--color-gray-400)" # slightly lighter for empty state + end + + activity_labels = entryable.is_a?(Trade) ? Trade::ACTIVITY_LABELS : Transaction::ACTIVITY_LABELS + entryable_type = entryable.is_a?(Trade) ? "Trade" : "Transaction" + convert_url = entryable.is_a?(Transaction) ? convert_to_trade_transaction_path(entryable) : nil +%> + +
<%= t(".period_activity", period: period.label) %>
-<%= t(".contributions") %>
+<%= format_money(totals.contributions) %>
+<%= t(".withdrawals") %>
+<%= format_money(totals.withdrawals) %>
+<%= t(".trades") %>
+<%= totals.trades_count %>
++ Track money flowing into and out of your investment accounts through contributions and withdrawals. +
+ +Money added to investments
+Money withdrawn from investments
+Total net change
+<%= t(".description") %>
+ <% end %> + + <% dialog.with_body do %> + <%= form_with url: create_trade_from_transaction_transaction_path(@transaction), method: :post, class: "space-y-4", data: { controller: "convert-to-trade", turbo_frame: "_top" } do |f| %> + +<%= t(".security_hint") %>
+ <% else %> + <%= f.text_field :ticker, + placeholder: t(".ticker_placeholder"), + required: true, + class: "form-field__input border border-secondary rounded-lg px-3 py-2 w-full text-primary bg-container", + pattern: "[A-Za-z0-9.:-]{1,20}", + title: t(".ticker_hint") %> +<%= t(".ticker_hint") %>
+ <% end %> +<%= t(".quantity_hint") %>
+<%= t(".price_hint", currency: @entry.currency) %>
++ <%= icon "info", size: "xs", class: "inline-block mr-1" %> + <%= t(".qty_or_price_hint", amount: format_money(@entry.amount_money.abs)) %> +
+ +<%= t(".trade_type_hint") %>
+<%= t(".exchange_hint") %>
+Convert this transaction into a security trade (buy/sell) by providing ticker, shares, and price.
+