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) %>
> - - diff --git a/app/views/trades/_form.html.erb b/app/views/trades/_form.html.erb index 2ea361b6e..81930c77a 100644 --- a/app/views/trades/_form.html.erb +++ b/app/views/trades/_form.html.erb @@ -10,11 +10,12 @@
<%= form.select :type, [ - ["Buy", "buy"], - ["Sell", "sell"], - ["Deposit", "deposit"], - ["Withdrawal", "withdrawal"], - ["Interest", "interest"] + [t(".type_buy"), "buy"], + [t(".type_sell"), "sell"], + [t(".type_deposit"), "deposit"], + [t(".type_withdrawal"), "withdrawal"], + [t(".type_dividend"), "dividend"], + [t(".type_interest"), "interest"] ], { label: t(".type"), selected: type }, { data: { @@ -34,10 +35,19 @@ required: true %>
<% else %> - <%= form.text_field :manual_ticker, label: "Ticker symbol", placeholder: "AAPL", required: true %> + <%= form.text_field :manual_ticker, label: t(".holding"), placeholder: t(".ticker_placeholder"), required: true %> <% end %> <% end %> + <% if %w[dividend interest].include?(type) %> + <% account_securities = account ? account.traded_standard_securities : [] %> + <% security_options = account_securities.map { |s| [ s.name.presence || s.ticker, s.exchange_operating_mic.present? ? "#{s.ticker}|#{s.exchange_operating_mic}" : s.ticker ] } %> + <% select_options = { label: type == "dividend" ? t(".holding") : t(".holding_optional") } %> + <% select_options[:include_blank] = true if type == "interest" %> + <% select_options[:required] = true if type == "dividend" %> + <%= form.select :ticker, security_options, select_options %> + <% end %> + <%= form.date_field :date, label: true, value: model.date || Date.current, required: true %> <% unless %w[buy sell].include?(type) %> diff --git a/app/views/trades/_header.html.erb b/app/views/trades/_header.html.erb index a9f1e12bb..0f0f6ae94 100644 --- a/app/views/trades/_header.html.erb +++ b/app/views/trades/_header.html.erb @@ -2,14 +2,20 @@
<%= tag.header class: "mb-4 space-y-1" do %> + <% label = entry.trade.investment_activity_label %> + <% income_trade = %w[Dividend Interest].include?(label) %> - <%= entry.amount.positive? ? t(".buy") : t(".sell") %> + <% if income_trade %> + <%= t(".#{label.downcase}") %> + <% else %> + <%= entry.amount.positive? ? t(".buy") : t(".sell") %> + <% end %>

- <%= format_money entry.amount_money %> + <%= format_money(income_trade ? entry.amount_money.abs : entry.amount_money) %> @@ -30,15 +36,16 @@ <% end %> <% trade = entry.trade %> - + + <% unless trade.security.cash? %>
<%= render DS::Disclosure.new(title: t(".overview"), open: true) do %>
-
-
-
<%= t(".symbol_label") %>
-
<%= trade.security.ticker %>
-
+
+
+
<%= t(".symbol_label") %>
+
<%= trade.security.ticker %>
+
<% if trade.qty.positive? %>
@@ -52,7 +59,7 @@
<% end %> - <% if trade.security.current_price.present? %> + <% if trade.qty.positive? && trade.security.current_price.present? %>
<%= t(".current_market_price_label") %>
<%= format_money trade.security.current_price %>
@@ -71,4 +78,5 @@
<% end %>
+ <% end %>
diff --git a/app/views/trades/show.html.erb b/app/views/trades/show.html.erb index b056b06ce..7e1c222c7 100644 --- a/app/views/trades/show.html.erb +++ b/app/views/trades/show.html.erb @@ -19,37 +19,48 @@ max: Date.current, disabled: @entry.linked?, "data-auto-submit-form-target": "auto" %> -
- <%= f.select :nature, - [[t(".buy"), "outflow"], [t(".sell"), "inflow"]], - { container_class: "w-1/3", label: t(".type_label"), selected: @entry.amount.positive? ? "outflow" : "inflow" }, - { data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %> - <%= f.fields_for :entryable do |ef| %> - <%= ef.number_field :qty, - label: t(".quantity_label"), - step: "any", - value: trade.qty.abs, - "data-auto-submit-form-target": "auto", - disabled: @entry.linked? %> - <% end %> -
- <%= f.fields_for :entryable do |ef| %> - <%= ef.money_field :price, - label: t(".cost_per_share_label"), - disable_currency: true, + <% if trade.qty.zero? %> + <%= f.money_field :amount, + label: t(".amount_label"), + value: @entry.amount_money.abs, auto_submit: true, min: 0, step: "any", disabled: @entry.linked? %> <% end %> - <%= f.fields_for :entryable do |ef| %> - <%= ef.money_field :fee, - label: t(".fee_label"), - disable_currency: true, - auto_submit: true, - min: 0, - step: "any", - disabled: @entry.linked? %> + <% unless trade.qty.zero? %> +
+ <%= f.select :nature, + [[t(".buy"), "outflow"], [t(".sell"), "inflow"]], + { container_class: "w-1/3", label: t(".type_label"), selected: @entry.amount.positive? ? "outflow" : "inflow" }, + { data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %> + <%= f.fields_for :entryable do |ef| %> + <%= ef.number_field :qty, + label: t(".quantity_label"), + step: "any", + value: trade.qty.abs, + "data-auto-submit-form-target": "auto", + disabled: @entry.linked? %> + <% end %> +
+ <%= f.fields_for :entryable do |ef| %> + <%= ef.money_field :price, + label: t(".cost_per_share_label"), + disable_currency: true, + auto_submit: true, + min: 0, + step: "any", + disabled: @entry.linked? %> + <% end %> + <%= f.fields_for :entryable do |ef| %> + <%= ef.money_field :fee, + label: t(".fee_label"), + disable_currency: true, + auto_submit: true, + min: 0, + step: "any", + disabled: @entry.linked? %> + <% end %> <% end %> <% end %>

diff --git a/config/locales/views/trades/de.yml b/config/locales/views/trades/de.yml index 8fadd71af..912d5bd9a 100644 --- a/config/locales/views/trades/de.yml +++ b/config/locales/views/trades/de.yml @@ -6,24 +6,35 @@ de: account_prompt: Konto suchen amount: Betrag holding: Tickersymbol + holding_optional: Tickersymbol (optional) price: Preis pro Anteil qty: Menge submit: Transaktion hinzufügen ticker_placeholder: AAPL type: Typ + type_buy: Kaufen + type_sell: Verkaufen + type_deposit: Einzahlung + type_withdrawal: Auszahlung + type_dividend: Dividende + type_interest: Zinsen + dividend_requires_security: Für Dividenden ist ein Wertpapier erforderlich header: buy: Kaufen + sell: Verkaufen + dividend: Dividende + interest: Zinsen current_market_price_label: Aktueller Marktpreis overview: Übersicht purchase_price_label: Kaufpreis purchase_qty_label: Kaufmenge - sell: Verkaufen symbol_label: Symbol total_return_label: Nicht realisierter Gewinn/Verlust new: title: Neue Transaktion show: additional: Zusätzlich + amount_label: Betrag buy: Kaufen category_label: Kategorie cost_per_share_label: Kosten pro Anteil diff --git a/config/locales/views/trades/en.yml b/config/locales/views/trades/en.yml index 9659b44f8..b2257c26f 100644 --- a/config/locales/views/trades/en.yml +++ b/config/locales/views/trades/en.yml @@ -7,24 +7,35 @@ en: amount: Amount fee: Transaction fee holding: Ticker symbol + holding_optional: Ticker symbol (optional) price: Price per share qty: Quantity submit: Add transaction ticker_placeholder: AAPL type: Type + type_buy: Buy + type_sell: Sell + type_deposit: Deposit + type_withdrawal: Withdrawal + type_dividend: Dividend + type_interest: Interest + dividend_requires_security: Security is required for dividends header: buy: Buy + sell: Sell + dividend: Dividend + interest: Interest current_market_price_label: Current Market Price overview: Overview purchase_price_label: Purchase Price purchase_qty_label: Purchase Quantity - sell: Sell symbol_label: Symbol total_return_label: Unrealized gain/loss new: title: New transaction show: additional: Additional + amount_label: Amount buy: Buy category_label: Category cost_per_share_label: Cost per Share diff --git a/config/locales/views/trades/es.yml b/config/locales/views/trades/es.yml index f3e64aee0..58c86d756 100644 --- a/config/locales/views/trades/es.yml +++ b/config/locales/views/trades/es.yml @@ -6,24 +6,35 @@ es: account_prompt: Buscar cuenta amount: Importe holding: Símbolo del ticker + holding_optional: Símbolo del ticker (opcional) price: Precio por acción qty: Cantidad submit: Añadir transacción ticker_placeholder: AAPL type: Tipo + type_buy: Comprar + type_sell: Vender + type_deposit: Depósito + type_withdrawal: Retiro + type_dividend: Dividendo + type_interest: Interés + dividend_requires_security: Se requiere un valor para los dividendos header: buy: Comprar + sell: Vender + dividend: Dividendo + interest: Interés current_market_price_label: Precio de mercado actual overview: Resumen purchase_price_label: Precio de compra purchase_qty_label: Cantidad comprada - sell: Vender symbol_label: Símbolo total_return_label: Ganancia/pérdida no realizada new: title: Nueva transacción show: additional: Adicional + amount_label: Importe buy: Compra category_label: Categoría cost_per_share_label: Costo por acción diff --git a/config/locales/views/trades/fr.yml b/config/locales/views/trades/fr.yml index 7332d4eb6..e94a04678 100644 --- a/config/locales/views/trades/fr.yml +++ b/config/locales/views/trades/fr.yml @@ -6,24 +6,35 @@ fr: account_prompt: Rechercher un compte amount: Montant holding: Symbole boursier + holding_optional: Symbole boursier (facultatif) price: Prix par action qty: Quantité submit: Ajouter la transaction ticker_placeholder: AAPL type: Type + type_buy: Acheter + type_sell: Vendre + type_deposit: Dépôt + type_withdrawal: Retrait + type_dividend: Dividende + type_interest: Intérêts + dividend_requires_security: Un titre est requis pour les dividendes header: buy: Acheter + sell: Vendre + dividend: Dividende + interest: Intérêts current_market_price_label: Prix du marché actuel overview: Aperçu purchase_price_label: Prix d'achat purchase_qty_label: Quantité achetée - sell: Vendre symbol_label: Symbole total_return_label: Gain/perte non réalisé(e) new: title: Nouvelle transaction show: additional: Détails supplémentaires + amount_label: Montant cost_per_share_label: Coût par action date_label: Date delete: Supprimer diff --git a/db/migrate/20260328120000_add_kind_to_securities.rb b/db/migrate/20260328120000_add_kind_to_securities.rb new file mode 100644 index 000000000..3a31ff62a --- /dev/null +++ b/db/migrate/20260328120000_add_kind_to_securities.rb @@ -0,0 +1,7 @@ +class AddKindToSecurities < ActiveRecord::Migration[7.2] + def change + add_column :securities, :kind, :string, null: false, default: "standard" + add_index :securities, :kind + add_check_constraint :securities, "kind IN ('standard', 'cash')", name: "chk_securities_kind" + end +end diff --git a/db/schema.rb b/db/schema.rb index 7350b7845..74d060072 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_03_26_112218) do +ActiveRecord::Schema[7.2].define(version: 2026_03_28_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -1175,9 +1175,12 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_26_112218) do t.integer "failed_fetch_count", default: 0, null: false t.datetime "last_health_check_at" t.string "website_url" + t.string "kind", default: "standard", null: false t.index "upper((ticker)::text), COALESCE(upper((exchange_operating_mic)::text), ''::text)", name: "index_securities_on_ticker_and_exchange_operating_mic_unique", unique: true t.index ["country_code"], name: "index_securities_on_country_code" t.index ["exchange_operating_mic"], name: "index_securities_on_exchange_operating_mic" + t.index ["kind"], name: "index_securities_on_kind" + t.check_constraint "kind = ANY (ARRAY['standard'::text, 'cash'::text])", name: "chk_securities_kind" end create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/test/controllers/trades_controller_test.rb b/test/controllers/trades_controller_test.rb index ea6127f8a..e93c63246 100644 --- a/test/controllers/trades_controller_test.rb +++ b/test/controllers/trades_controller_test.rb @@ -93,8 +93,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to @entry.account end - test "creates interest entry" do - assert_difference [ "Entry.count", "Transaction.count" ], 1 do + test "creates interest entry as trade with synthetic cash security when no ticker given" do + assert_difference [ "Entry.count", "Trade.count" ], 1 do post trades_url(account_id: @entry.account_id), params: { model: { type: "interest", @@ -108,9 +108,73 @@ class TradesControllerTest < ActionDispatch::IntegrationTest created_entry = Entry.order(created_at: :desc).first assert created_entry.amount.negative? + assert created_entry.trade? + assert created_entry.trade.security.cash? + assert_equal "Interest", created_entry.name assert_redirected_to @entry.account end + test "creates interest entry as trade with security when ticker given" do + assert_difference [ "Entry.count", "Trade.count" ], 1 do + post trades_url(account_id: @entry.account_id), params: { + model: { + type: "interest", + date: Date.current, + amount: 10, + currency: "USD", + ticker: "AAPL|XNAS" + } + } + end + + created_entry = Entry.order(created_at: :desc).first + + assert created_entry.amount.negative? + assert created_entry.trade? + assert_equal "AAPL", created_entry.trade.security.ticker + assert_equal "Interest: AAPL", created_entry.name + assert_redirected_to @entry.account + end + + test "creates dividend entry as trade with required security" do + assert_difference [ "Entry.count", "Trade.count" ], 1 do + post trades_url(account_id: @entry.account_id), params: { + model: { + type: "dividend", + date: Date.current, + amount: 25, + currency: "USD", + ticker: "AAPL|XNAS" + } + } + end + + created_entry = Entry.order(created_at: :desc).first + + assert created_entry.amount.negative? + assert created_entry.trade? + assert_equal 0, created_entry.trade.qty + assert_equal "AAPL", created_entry.trade.security.ticker + assert_equal "Dividend: AAPL", created_entry.name + assert_equal "Dividend", created_entry.trade.investment_activity_label + assert_redirected_to @entry.account + end + + test "creating dividend without security returns error" do + assert_no_difference [ "Entry.count", "Trade.count" ] do + post trades_url(account_id: @entry.account_id), params: { + model: { + type: "dividend", + date: Date.current, + amount: 25, + currency: "USD" + } + } + end + + assert_response :unprocessable_entity + end + test "creates trade buy entry with fee" do assert_difference [ "Entry.count", "Trade.count" ], 1 do post trades_url(account_id: @entry.account_id), params: { diff --git a/test/models/security_test.rb b/test/models/security_test.rb index 0e6ef3c8f..ee18a14fd 100644 --- a/test/models/security_test.rb +++ b/test/models/security_test.rb @@ -38,4 +38,34 @@ class SecurityTest < ActiveSupport::TestCase assert_not duplicate.valid? assert_equal [ "has already been taken" ], duplicate.errors[:ticker] end + + test "cash_for lazily creates a per-account synthetic cash security" do + account = accounts(:investment) + + cash = Security.cash_for(account) + + assert cash.persisted? + assert cash.cash? + assert cash.offline? + assert_equal "Cash", cash.name + assert_includes cash.ticker, account.id.upcase + end + + test "cash_for returns the same security on repeated calls" do + account = accounts(:investment) + + first = Security.cash_for(account) + second = Security.cash_for(account) + + assert_equal first.id, second.id + end + + test "standard scope excludes cash securities" do + account = accounts(:investment) + Security.cash_for(account) + + standard_tickers = Security.standard.pluck(:ticker) + + assert_not_includes standard_tickers, "CASH-#{account.id.upcase}" + end end