Add security remapping for holdings with sync protection (#692)

* Add security remapping support to holdings

- Introduced `provider_security` tracking for holdings with schema updates.
- Implemented security remap/reset workflows in `Holding` model and UI.
- Updated routes, controllers, and tests to support new functionality.
- Enhanced client-side interaction with Stimulus controller for remapping.

# Conflicts:
#	app/components/UI/account/activity_feed.html.erb
#	db/schema.rb

* Refactor "New transaction" to "New activity" across UI and tests

- Updated localized strings, button labels, and ARIA attributes.
- Improved error handling in holdings' current price display.
- Scoped fallback queries in `provider_import_adapter` to prevent overwrites.
- Added safeguard for offline securities in price fetching logic.

* Update security remapping to merge holdings on collision by deleting duplicates

- Removed error handling for collisions in `remap_security!`.
- Added logic to merge holdings by deleting duplicates on conflicting dates.
- Modified associated test to validate merging behavior.

* Update security remapping to merge holdings on collision by combining qty and amount

- Modified `remap_security!` to merge holdings by summing `qty` and `amount` on conflicting dates.
- Adjusted logic to calculate `price` for merged holdings.
- Updated test to validate new merge behavior.

* Improve DOM handling in Turbo redirect action & enhance holdings merge logic

- Updated Turbo's custom `redirect` action to use the "replace" option for cleaner DOM updates without clearing the cache.
- Enhanced holdings merge logic to calculate weighted average cost basis during security remapping, ensuring more accurate cost_basis updates.

* Track provider_security_id during security updates to support reset workflows

* Fix provider tracking: guard nil ticker lookups and preserve merge attrs

- Guard fallback 1b lookup when security.ticker is blank to avoid matching NULL tickers
- Preserve external_id, provider_security_id, account_provider_id during collision merge

* Fix schema.rb version after merge (includes tax_treatment migration)

* fix: Rename migration to run after schema version

The migration 20260117000001 was skipped in CI because it had a timestamp
earlier than the schema version (2026_01_17_200000). CI loads schema.rb
directly and only runs migrations with versions after the schema version.

Renamed to 20260119000001 so it runs correctly.

* Update schema: remove Coinbase tables, add new fields and indexes

* Update schema: add back `tax_treatment` field with default value "taxable"

* Improve Turbo redirect action: use "replace" to avoid form submission in history

* Lock merged holdings to prevent provider overwrites and fix activity feed template indentation

* Refactor holdings transfer logic: enforce currency checks during collisions and enhance merge handling

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
LPW
2026-01-23 06:54:55 -05:00
committed by GitHub
parent e0fb585bda
commit c504ba9b99
17 changed files with 522 additions and 53 deletions

View File

@@ -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

View File

@@ -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)