mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user