+ <% end %>
+
<% if @holding.cost_basis_locked? %>
diff --git a/config/locales/views/holdings/en.yml b/config/locales/views/holdings/en.yml
index 0e6d588d4..a14efe9b6 100644
--- a/config/locales/views/holdings/en.yml
+++ b/config/locales/views/holdings/en.yml
@@ -10,6 +10,13 @@ en:
error: Invalid cost basis value.
unlock_cost_basis:
success: Cost basis unlocked. It may be updated on next sync.
+ remap_security:
+ success: Security updated successfully.
+ security_not_found: Could not find the selected security.
+ reset_security:
+ success: Security reset to provider value.
+ errors:
+ security_collision: "Cannot remap: you already have a holding for %{ticker} on %{date}."
cost_basis_sources:
manual: User set
calculated: From trades
@@ -33,7 +40,7 @@ en:
average_cost: Average cost
holdings: Holdings
name: Name
- new_holding: New transaction
+ new_holding: New activity
no_holdings: No holdings to show.
return: Total return
weight: Weight
@@ -48,10 +55,24 @@ en:
delete_subtitle: This will delete the holding and all your associated trades
on this account. This action cannot be undone.
delete_title: Delete holding
+ edit_security: Edit security
history: History
+ no_trade_history: No trade history available for this holding.
overview: Overview
portfolio_weight_label: Portfolio Weight
settings: Settings
+ security_label: Security
+ originally: "was %{ticker}"
+ search_security: Search security
+ search_security_placeholder: Search by ticker or name
+ cancel: Cancel
+ remap_security: Save
+ no_security_provider: Security provider not configured. Cannot search for securities.
+ security_remapped_label: Security remapped
+ provider_sent: "Provider sent: %{ticker}"
+ reset_to_provider: Reset to provider
+ reset_confirm_title: Reset security to provider?
+ reset_confirm_body: "This will change the security from %{current} back to %{original} and move all associated trades."
ticker_label: Ticker
trade_history_entry: "%{qty} shares of %{security} at %{price}"
total_return_label: Total Return
diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml
index 6f4a78ba1..e7849fd41 100644
--- a/config/locales/views/transactions/en.yml
+++ b/config/locales/views/transactions/en.yml
@@ -137,6 +137,7 @@ en:
cancel: Cancel
submit: Convert to Trade
success: Transaction converted to trade
+ conversion_note: "Converted from transaction: %{original_name} (%{original_date})"
errors:
not_investment_account: Only transactions in investment accounts can be converted to trades
already_converted: This transaction has already been converted or excluded
diff --git a/config/routes.rb b/config/routes.rb
index 69e1f4fc1..96ad6a28d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -227,6 +227,8 @@ Rails.application.routes.draw do
resources :holdings, only: %i[index new show update destroy] do
member do
post :unlock_cost_basis
+ patch :remap_security
+ post :reset_security
end
end
resources :trades, only: %i[show new create update destroy]
diff --git a/db/migrate/20260119000001_add_provider_security_tracking_to_holdings.rb b/db/migrate/20260119000001_add_provider_security_tracking_to_holdings.rb
new file mode 100644
index 000000000..17a63395c
--- /dev/null
+++ b/db/migrate/20260119000001_add_provider_security_tracking_to_holdings.rb
@@ -0,0 +1,6 @@
+class AddProviderSecurityTrackingToHoldings < ActiveRecord::Migration[7.2]
+ def change
+ add_reference :holdings, :provider_security, type: :uuid, null: true, foreign_key: { to_table: :securities }
+ add_column :holdings, :security_locked, :boolean, default: false, null: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 4a1957462..a2f020b1d 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -551,10 +551,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
t.uuid "account_provider_id"
t.string "cost_basis_source"
t.boolean "cost_basis_locked", default: false, null: false
+ t.uuid "provider_security_id"
+ t.boolean "security_locked", default: false, null: false
t.index ["account_id", "external_id"], name: "idx_holdings_on_account_id_external_id_unique", unique: true, where: "(external_id IS NOT NULL)"
t.index ["account_id", "security_id", "date", "currency"], name: "idx_on_account_id_security_id_date_currency_5323e39f8b", unique: true
t.index ["account_id"], name: "index_holdings_on_account_id"
t.index ["account_provider_id"], name: "index_holdings_on_account_provider_id"
+ t.index ["provider_security_id"], name: "index_holdings_on_provider_security_id"
t.index ["security_id"], name: "index_holdings_on_security_id"
end
@@ -1449,6 +1452,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_22_160000) do
add_foreign_key "holdings", "account_providers"
add_foreign_key "holdings", "accounts", on_delete: :cascade
add_foreign_key "holdings", "securities"
+ add_foreign_key "holdings", "securities", column: "provider_security_id"
add_foreign_key "impersonation_session_logs", "impersonation_sessions"
add_foreign_key "impersonation_sessions", "users", column: "impersonated_id"
add_foreign_key "impersonation_sessions", "users", column: "impersonator_id"
diff --git a/test/models/holding_test.rb b/test/models/holding_test.rb
index 1a384bdd1..7f6985767 100644
--- a/test/models/holding_test.rb
+++ b/test/models/holding_test.rb
@@ -245,6 +245,164 @@ class HoldingTest < ActiveSupport::TestCase
assert_not @amzn.cost_basis_replaceable_by?("manual")
end
+ # Security remapping tests
+
+ test "security_replaceable_by_provider? returns false when locked" do
+ @amzn.update!(security_locked: true)
+ assert_not @amzn.security_replaceable_by_provider?
+ end
+
+ test "security_replaceable_by_provider? returns true when not locked" do
+ @amzn.update!(security_locked: false)
+ assert @amzn.security_replaceable_by_provider?
+ end
+
+ test "security_remapped? returns true when provider_security differs from security" do
+ other_security = create_security("GOOG", prices: [ { date: Date.current, price: 100.00 } ])
+ @amzn.update!(provider_security: other_security)
+ assert @amzn.security_remapped?
+ end
+
+ test "security_remapped? returns false when provider_security is nil" do
+ assert_nil @amzn.provider_security_id
+ assert_not @amzn.security_remapped?
+ end
+
+ test "security_remapped? returns false when provider_security equals security" do
+ @amzn.update!(provider_security: @amzn.security)
+ assert_not @amzn.security_remapped?
+ end
+
+ test "remap_security! changes holding security and locks it" do
+ old_security = @amzn.security
+ new_security = create_security("GOOG", prices: [ { date: Date.current, price: 100.00 } ])
+
+ @amzn.remap_security!(new_security)
+
+ assert_equal new_security, @amzn.security
+ assert @amzn.security_locked?
+ assert_equal old_security, @amzn.provider_security
+ end
+
+ test "remap_security! updates all holdings for the same security" do
+ old_security = @amzn.security
+ new_security = create_security("GOOG", prices: [ { date: Date.current, price: 100.00 } ])
+
+ # There are 2 AMZN holdings (from load_holdings) - yesterday and today
+ amzn_holdings_count = @account.holdings.where(security: old_security).count
+ assert_equal 2, amzn_holdings_count
+
+ @amzn.remap_security!(new_security)
+
+ # All holdings should now be for the new security
+ assert_equal 0, @account.holdings.where(security: old_security).count
+ assert_equal 2, @account.holdings.where(security: new_security).count
+
+ # All should be locked with provider_security set
+ @account.holdings.where(security: new_security).each do |h|
+ assert h.security_locked?
+ assert_equal old_security, h.provider_security
+ end
+ end
+
+ test "remap_security! moves trades to new security" do
+ old_security = @amzn.security
+ new_security = create_security("GOOG", prices: [ { date: Date.current, price: 100.00 } ])
+
+ # Create a trade for the old security
+ create_trade(old_security, account: @account, qty: 5, price: 100.00, date: Date.current)
+ assert_equal 1, @account.trades.where(security: old_security).count
+
+ @amzn.remap_security!(new_security)
+
+ # Trade should have moved to the new security
+ assert_equal 0, @account.trades.where(security: old_security).count
+ assert_equal 1, @account.trades.where(security: new_security).count
+ end
+
+ test "remap_security! does nothing when security is same" do
+ current_security = @amzn.security
+
+ @amzn.remap_security!(current_security)
+
+ assert_equal current_security, @amzn.security
+ assert_not @amzn.security_locked?
+ assert_nil @amzn.provider_security_id
+ end
+
+ test "remap_security! merges holdings on collision by combining qty and amount" do
+ new_security = create_security("GOOG", prices: [ { date: Date.current, price: 100.00 } ])
+
+ # Create an existing holding for the new security on the same date
+ existing_goog = @account.holdings.create!(
+ date: @amzn.date,
+ security: new_security,
+ qty: 5,
+ price: 100,
+ amount: 500,
+ currency: "USD"
+ )
+
+ amzn_security = @amzn.security
+ amzn_qty = @amzn.qty
+ amzn_amount = @amzn.amount
+ initial_count = @account.holdings.count
+
+ # Remap should merge by combining qty and amount
+ @amzn.remap_security!(new_security)
+
+ # The AMZN holding on collision date should be deleted, merged into GOOG
+ assert_equal initial_count - 1, @account.holdings.count
+
+ # The existing GOOG holding should have merged values
+ existing_goog.reload
+ assert_equal 5 + amzn_qty, existing_goog.qty
+ assert_equal 500 + amzn_amount, existing_goog.amount
+
+ # Merged holding should be locked to prevent provider overwrites
+ assert existing_goog.security_locked, "Merged holding should be locked"
+
+ # No holdings should remain for the old AMZN security
+ assert_equal 0, @account.holdings.where(security: amzn_security).count
+ end
+
+ test "reset_security_to_provider! restores original security" do
+ old_security = @amzn.security
+ new_security = create_security("GOOG", prices: [ { date: Date.current, price: 100.00 } ])
+
+ @amzn.remap_security!(new_security)
+ assert_equal new_security, @amzn.security
+ assert @amzn.security_locked?
+
+ @amzn.reset_security_to_provider!
+
+ assert_equal old_security, @amzn.security
+ assert_not @amzn.security_locked?
+ assert_nil @amzn.provider_security_id
+ end
+
+ test "reset_security_to_provider! moves trades back" do
+ old_security = @amzn.security
+ new_security = create_security("GOOG", prices: [ { date: Date.current, price: 100.00 } ])
+
+ create_trade(old_security, account: @account, qty: 5, price: 100.00, date: Date.current)
+
+ @amzn.remap_security!(new_security)
+ assert_equal 1, @account.trades.where(security: new_security).count
+
+ @amzn.reset_security_to_provider!
+ assert_equal 0, @account.trades.where(security: new_security).count
+ assert_equal 1, @account.trades.where(security: old_security).count
+ end
+
+ test "reset_security_to_provider! does nothing if not remapped" do
+ old_security = @amzn.security
+ @amzn.reset_security_to_provider!
+
+ assert_equal old_security, @amzn.security
+ assert_nil @amzn.provider_security_id
+ end
+
private
def load_holdings
diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb
index 7d0183c3a..8065beab7 100644
--- a/test/system/trades_test.rb
+++ b/test/system/trades_test.rb
@@ -58,7 +58,7 @@ class TradesTest < ApplicationSystemTestCase
private
def open_new_trade_modal
- click_on "New transaction"
+ click_on "New activity"
end
def within_trades(&block)