diff --git a/app/controllers/trades_controller.rb b/app/controllers/trades_controller.rb index a8f0940c7..e3a90121e 100644 --- a/app/controllers/trades_controller.rb +++ b/app/controllers/trades_controller.rb @@ -105,9 +105,16 @@ class TradesController < ApplicationController end def update_entry_params - return entry_params unless entry_params[:entryable_attributes].present? - update_params = entry_params + + # Income trades (Dividend/Interest) store amounts as negative (inflow convention). + # The form displays the absolute value, so we re-negate before saving. + if %w[Dividend Interest].include?(@entry.trade&.investment_activity_label) && update_params[:amount].present? + update_params = update_params.merge(amount: -update_params[:amount].to_d.abs) + end + + return update_params unless update_params[:entryable_attributes].present? + update_params = update_params.merge(entryable_type: "Trade") qty = update_params[:entryable_attributes][:qty] diff --git a/app/models/account.rb b/app/models/account.rb index 5de43f3c0..4a98b6f87 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -362,6 +362,13 @@ class Account < ApplicationRecord false end + def traded_standard_securities + Security.where(id: holdings.select(:security_id)) + .standard + .distinct + .order(:ticker) + end + # The balance type determines which "component" of balance is being tracked. # This is primarily used for balance related calculations and updates. # diff --git a/app/models/security.rb b/app/models/security.rb index fb26d4d3e..35b0e8bdd 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -6,6 +6,8 @@ class Security < ApplicationRecord # Data stored in config/exchanges.yml EXCHANGES = YAML.safe_load_file(Rails.root.join("config", "exchanges.yml")).freeze + KINDS = %w[standard cash].freeze + before_validation :upcase_symbols before_save :generate_logo_url_from_brandfetch, if: :should_generate_logo? @@ -14,8 +16,24 @@ class Security < ApplicationRecord validates :ticker, presence: true validates :ticker, uniqueness: { scope: :exchange_operating_mic, case_sensitive: false } + validates :kind, inclusion: { in: KINDS } scope :online, -> { where(offline: false) } + scope :standard, -> { where(kind: "standard") } + + # Lazily finds or creates a synthetic cash security for an account. + # Used as fallback when creating an interest Trade without a user-selected security. + def self.cash_for(account) + ticker = "CASH-#{account.id}".upcase + find_or_create_by!(ticker: ticker, kind: "cash") do |s| + s.name = "Cash" + s.offline = true + end + end + + def cash? + kind == "cash" + end # Returns user-friendly exchange name for a MIC code def self.exchange_name_for(mic) @@ -73,6 +91,7 @@ class Security < ApplicationRecord end def should_generate_logo? + return false if cash? url = brandfetch_icon_url return false unless url.present? diff --git a/app/models/security/health_checker.rb b/app/models/security/health_checker.rb index 92ff5f2ee..74e5a8d50 100644 --- a/app/models/security/health_checker.rb +++ b/app/models/security/health_checker.rb @@ -30,15 +30,15 @@ class Security::HealthChecker private # If a security has never had a health check, we prioritize it, regardless of batch size def never_checked_scope - Security.where(last_health_check_at: nil) + Security.standard.where(last_health_check_at: nil) end # Any securities not checked for 30 days are due # We only process the batch size, which means some "due" securities will not be checked today # This is by design, to prevent all securities from coming due at the same time def due_for_check_scope - Security.where(last_health_check_at: ..HEALTH_CHECK_INTERVAL.ago) - .order(last_health_check_at: :asc) + Security.standard.where(last_health_check_at: ..HEALTH_CHECK_INTERVAL.ago) + .order(last_health_check_at: :asc) end end diff --git a/app/models/trade.rb b/app/models/trade.rb index fd0168b5b..dbdb90f31 100644 --- a/app/models/trade.rb +++ b/app/models/trade.rb @@ -31,7 +31,7 @@ class Trade < ApplicationRecord end def unrealized_gain_loss - return nil if qty.negative? + return nil unless qty.positive? current_price = security.current_price return nil if current_price.nil? diff --git a/app/models/trade/create_form.rb b/app/models/trade/create_form.rb index 239e109d0..54c78111e 100644 --- a/app/models/trade/create_form.rb +++ b/app/models/trade/create_form.rb @@ -10,6 +10,8 @@ class Trade::CreateForm case type when "buy", "sell" create_trade + when "dividend" + create_dividend_income when "interest" create_interest_income when "deposit", "withdrawal" @@ -28,6 +30,10 @@ class Trade::CreateForm ).resolve end + def ticker_present? + ticker.present? || manual_ticker.present? + end + def create_trade signed_qty = type == "sell" ? -qty.to_d : qty.to_d signed_amount = signed_qty * price.to_d + fee.to_d @@ -55,15 +61,47 @@ class Trade::CreateForm trade_entry end - def create_interest_income - signed_amount = amount.to_d * -1 + # Dividends are always a Trade. Security is required. + def create_dividend_income + unless ticker_present? + entry = account.entries.build(entryable: Trade.new) + entry.errors.add(:base, I18n.t("trades.form.dividend_requires_security")) + return entry + end + begin + sec = security + create_income_trade(sec: sec, label: "Dividend", name: "Dividend: #{sec.ticker}") + rescue => e + Rails.logger.warn("Dividend security resolution failed: #{e.class} - #{e.message}") + entry = account.entries.build(entryable: Trade.new) + entry.errors.add(:base, I18n.t("trades.form.dividend_requires_security")) + entry + end + end + + # Interest in an investment account is always a Trade. + # Falls back to a synthetic cash security when none is selected. + def create_interest_income + sec = ticker_present? ? security : Security.cash_for(account) + name = sec.cash? ? "Interest" : "Interest: #{sec.ticker}" + create_income_trade(sec: sec, label: "Interest", name: name) + end + + def create_income_trade(sec:, label:, name:) entry = account.entries.build( - name: "Interest payment", + name: name, date: date, - amount: signed_amount, + amount: amount.to_d * -1, currency: currency, - entryable: Transaction.new + entryable: Trade.new( + qty: 0, + price: 0, + fee: 0, + currency: currency, + security: sec, + investment_activity_label: label + ) ) if entry.save diff --git a/app/views/investment_activity/_quick_edit_badge.html.erb b/app/views/investment_activity/_quick_edit_badge.html.erb index 4db357ef2..05c243894 100644 --- a/app/views/investment_activity/_quick_edit_badge.html.erb +++ b/app/views/investment_activity/_quick_edit_badge.html.erb @@ -35,6 +35,7 @@ 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 + income_trade = entryable.is_a?(Trade) && %w[Dividend Interest].include?(label) %>