Add "Link to existing" option in SnapTrade Setup Accounts modal (#935)

* Add account linking functionality for SnapTrade items

- Introduced UI to link existing accounts when setting up SnapTrade items, preventing duplicate account creation.
- Updated controller to fetch linkable accounts.
- Added tests to verify proper filtering of accounts and linking behavior.

* Add `snaptrade_item_id` to account linking flow for SnapTrade items

- Updated controller to allow specifying `snaptrade_item_id` when linking accounts.
- Adjusted form and views to include `snaptrade_item_id` as a hidden field.
- Enhanced tests to validate behavior with the new parameter.
This commit is contained in:
LPW
2026-02-08 04:30:46 -05:00
committed by GitHub
parent 01c2209492
commit 24981ffd52
4 changed files with 137 additions and 29 deletions

View File

@@ -154,6 +154,14 @@ class SnaptradeItemsController < ApplicationController
@snaptrade_item.sync_later
end
# Existing unlinked, visible investment/crypto accounts that could be linked instead of creating duplicates
@linkable_accounts = Current.family.accounts
.visible
.where(accountable_type: %w[Investment Crypto])
.left_joins(:account_providers)
.where(account_providers: { id: nil })
.order(:name)
# Determine view state
@syncing = @snaptrade_item.syncing?
@waiting_for_sync = no_accounts && @syncing
@@ -369,9 +377,10 @@ class SnaptradeItemsController < ApplicationController
def link_existing_account
account_id = params[:account_id]
snaptrade_account_id = params[:snaptrade_account_id]
snaptrade_item_id = params[:snaptrade_item_id]
account = Current.family.accounts.find_by(id: account_id)
snaptrade_item = Current.family.snaptrade_items.first
snaptrade_item = Current.family.snaptrade_items.find_by(id: snaptrade_item_id)
snaptrade_account = snaptrade_item&.snaptrade_accounts&.find_by(id: snaptrade_account_id)
if account && snaptrade_account

View File

@@ -138,6 +138,7 @@
<%= t("snaptrade_items.setup_accounts.sync_start_date_help", default: "Leave blank for all available history") %>
</p>
</div>
</div>
<% end %>
</div>
@@ -160,40 +161,70 @@
</div>
<% end %>
<% if @linked_accounts.any? %>
<div class="<%= "border-t border-primary pt-6 mt-6" if @unlinked_accounts.any? %>">
<h3 class="font-medium text-primary mb-4"><%= t("snaptrade_items.setup_accounts.linked_accounts", default: "Already Linked") %></h3>
<% @linked_accounts.each do |snaptrade_account| %>
<div class="border border-success/20 bg-success/5 rounded-lg p-4 mb-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<%= icon "check-circle", class: "text-success" %>
<div>
<h4 class="font-medium text-primary"><%= snaptrade_account.name %></h4>
<p class="text-sm text-secondary">
<%= t("snaptrade_items.setup_accounts.linked_to", default: "Linked to:") %>
<%= link_to snaptrade_account.current_account.name, account_path(snaptrade_account.current_account), class: "link" %>
</p>
</div>
<% end %>
<%# Link-to-existing forms rendered OUTSIDE the create form to avoid nested <form> %>
<% if @unlinked_accounts.any? && @linkable_accounts.any? %>
<div class="border-t border-secondary pt-4 mt-2">
<p class="text-xs text-secondary mb-3">
<%= t("snaptrade_items.setup_accounts.or_link_existing", default: "Or link to an existing account instead of creating a new one:") %>
</p>
<div class="space-y-2">
<% @unlinked_accounts.each do |snaptrade_account| %>
<%= form_with url: link_existing_account_snaptrade_items_path, method: :post, data: { turbo_frame: "_top" } do |link_form| %>
<%= link_form.hidden_field :snaptrade_account_id, value: snaptrade_account.id %>
<%= link_form.hidden_field :snaptrade_item_id, value: @snaptrade_item.id %>
<p class="text-xs text-primary mb-1"><%= snaptrade_account.name %></p>
<div class="flex items-center gap-2">
<%= link_form.select :account_id,
options_for_select(@linkable_accounts.map { |a| ["#{a.name} (#{number_to_currency(a.balance, unit: Money::Currency.new(a.currency || "USD").symbol)})", a.id] }),
{ prompt: t("snaptrade_items.setup_accounts.select_account", default: "Select an account...") },
class: "bg-container border border-primary rounded px-2 py-1 text-sm text-primary flex-1 min-w-0" %>
<%= render DS::Button.new(
text: t("snaptrade_items.setup_accounts.link_button", default: "Link"),
variant: "secondary",
size: "sm",
type: "submit"
) %>
</div>
<% end %>
<% end %>
</div>
</div>
<% end %>
<% if @linked_accounts.any? %>
<div class="<%= "border-t border-primary pt-6 mt-4" if @unlinked_accounts.any? %>">
<h3 class="font-medium text-primary mb-4"><%= t("snaptrade_items.setup_accounts.linked_accounts", default: "Already Linked") %></h3>
<% @linked_accounts.each do |snaptrade_account| %>
<div class="border border-success/20 bg-success/5 rounded-lg p-4 mb-2">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<%= icon "check-circle", class: "text-success" %>
<div>
<h4 class="font-medium text-primary"><%= snaptrade_account.name %></h4>
<p class="text-sm text-secondary">
<%= t("snaptrade_items.setup_accounts.linked_to", default: "Linked to:") %>
<%= link_to snaptrade_account.current_account.name, account_path(snaptrade_account.current_account), class: "link" %>
</p>
</div>
</div>
</div>
<% end %>
</div>
<%# Show Done button when all accounts are linked (no unlinked) %>
<% if @unlinked_accounts.blank? %>
<div class="flex justify-end mt-4">
<%= render DS::Link.new(
text: t("snaptrade_items.setup_accounts.done_button", default: "Done"),
variant: "primary",
href: accounts_path,
frame: "_top"
) %>
</div>
<% end %>
<% end %>
</div>
<%# Show Done button when all accounts are linked (no unlinked) %>
<% if @unlinked_accounts.blank? %>
<div class="flex justify-end mt-4">
<%= render DS::Link.new(
text: t("snaptrade_items.setup_accounts.done_button", default: "Done"),
variant: "primary",
href: accounts_path,
frame: "_top"
) %>
</div>
<% end %>
<% end %>
<% end %>
</div>

View File

@@ -75,6 +75,9 @@ en:
cancel_button: "Cancel"
creating: "Creating Accounts..."
done_button: "Done"
or_link_existing: "Or link to an existing account instead of creating a new one:"
select_account: "Select an account..."
link_button: "Link"
linked_accounts: "Already Linked"
linked_to: "Linked to:"
snaptrade_item:

View File

@@ -38,4 +38,69 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to portal_url
end
test "setup_accounts shows linkable investment and crypto accounts in dropdown" do
get setup_accounts_snaptrade_item_url(@snaptrade_item)
assert_response :success
# Investment and crypto accounts (no provider) should appear in the link dropdown
assert_match accounts(:investment).name, response.body
assert_match accounts(:crypto).name, response.body
# Depository should NOT appear in the link dropdown (wrong type)
# The depository name may appear elsewhere on the page, so check the select options specifically
refute_match(/option.*#{accounts(:depository).name}/, response.body)
end
test "setup_accounts excludes accounts that already have a provider from dropdown" do
# Link the investment account to a snaptrade_account
AccountProvider.create!(
account: accounts(:investment),
provider: snaptrade_accounts(:fidelity_401k)
)
get setup_accounts_snaptrade_item_url(@snaptrade_item)
assert_response :success
# Investment account is now linked → should NOT appear in link dropdown options
refute_match(/option.*#{accounts(:investment).name}/, response.body)
# Crypto still unlinked → should appear
assert_match accounts(:crypto).name, response.body
end
test "link_existing_account links account to snaptrade_account" do
account = accounts(:investment)
snaptrade_account = snaptrade_accounts(:fidelity_401k)
assert_difference "AccountProvider.count", 1 do
post link_existing_account_snaptrade_items_url, params: {
account_id: account.id,
snaptrade_account_id: snaptrade_account.id,
snaptrade_item_id: @snaptrade_item.id
}
end
assert_redirected_to account_path(account)
assert_match(/Successfully linked/, flash[:notice])
snaptrade_account.reload
assert_equal account, snaptrade_account.current_account
end
test "link_existing_account handles missing account gracefully" do
snaptrade_account = snaptrade_accounts(:fidelity_401k)
assert_no_difference "AccountProvider.count" do
post link_existing_account_snaptrade_items_url, params: {
account_id: "nonexistent",
snaptrade_account_id: snaptrade_account.id,
snaptrade_item_id: @snaptrade_item.id
}
end
assert_redirected_to settings_providers_path
assert_match(/not found/i, flash[:alert])
end
end