Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion app/controllers/accounts_controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
class AccountsController < ApplicationController
before_action :set_account, only: %i[sync sparkline toggle_active show destroy]
before_action :set_account, only: %i[sync sparkline toggle_active show destroy unlink confirm_unlink select_provider]
include Periodable

def index
Expand Down Expand Up @@ -78,6 +78,79 @@ def destroy
end
end

def confirm_unlink
unless @account.linked?
redirect_to account_path(@account), alert: "Account is not linked to a provider"
end
end

def unlink
if @account.linked?
# Remove new system links (account_providers join table)
@account.account_providers.destroy_all

# Remove legacy system links (foreign keys)
@account.update!(plaid_account_id: nil, simplefin_account_id: nil)

redirect_to accounts_path, notice: "Account unlinked successfully. It is now a manual account."
else
redirect_to account_path(@account), alert: "Account is not linked to a provider"
end
end

def select_provider
if @account.linked?
redirect_to account_path(@account), alert: "Account is already linked to a provider"
return
end

@available_providers = []

# Check SimpleFIN
if family.can_connect_simplefin?
@available_providers << {
name: "SimpleFIN",
key: "simplefin",
description: "Connect to your bank via SimpleFIN",
path: select_existing_account_simplefin_items_path(account_id: @account.id)
}
end

# Check Plaid US
if family.can_connect_plaid_us?
@available_providers << {
name: "Plaid",
key: "plaid_us",
description: "Connect to your US bank via Plaid",
path: select_existing_account_plaid_items_path(account_id: @account.id, region: "us")
}
end

# Check Plaid EU
if family.can_connect_plaid_eu?
@available_providers << {
name: "Plaid (EU)",
key: "plaid_eu",
description: "Connect to your EU bank via Plaid",
path: select_existing_account_plaid_items_path(account_id: @account.id, region: "eu")
}
end

# Check Lunch Flow
if family.can_connect_lunchflow?
@available_providers << {
name: "Lunch Flow",
key: "lunchflow",
description: "Connect to your bank via Lunch Flow",
path: select_existing_account_lunchflow_items_path(account_id: @account.id)
}
end

if @available_providers.empty?
redirect_to account_path(@account), alert: "No providers are currently configured"
end
end

private
def family
Current.family
Expand Down
6 changes: 3 additions & 3 deletions app/controllers/lunchflow_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def link_accounts

# Create or find lunchflow_item for this family
lunchflow_item = Current.family.lunchflow_items.first_or_create!(
name: "Lunchflow Connection"
name: "Lunch Flow Connection"
)

# Fetch account details from API
Expand Down Expand Up @@ -279,7 +279,7 @@ def link_existing_account

# Create or find lunchflow_item for this family
lunchflow_item = Current.family.lunchflow_items.first_or_create!(
name: "Lunchflow Connection"
name: "Lunch Flow Connection"
)

# Fetch account details from API
Expand Down Expand Up @@ -338,7 +338,7 @@ def new

def create
@lunchflow_item = Current.family.lunchflow_items.build(lunchflow_params)
@lunchflow_item.name = "Lunchflow Connection"
@lunchflow_item.name = "Lunch Flow Connection"

if @lunchflow_item.save
# Trigger initial sync to fetch accounts
Expand Down
42 changes: 42 additions & 0 deletions app/controllers/plaid_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,48 @@ def sync
end
end

def select_existing_account
@account = Current.family.accounts.find(params[:account_id])
@region = params[:region] || "us"

# Get all Plaid accounts from this family's Plaid items for the specified region
# that are not yet linked to any account
@available_plaid_accounts = Current.family.plaid_items
.where(plaid_region: @region)
.includes(:plaid_accounts)
.flat_map(&:plaid_accounts)
.select { |pa| pa.account_provider.nil? && pa.account.nil? } # Not linked via new or legacy system

if @available_plaid_accounts.empty?
redirect_to account_path(@account), alert: "No available Plaid accounts to link. Please connect a new Plaid account first."
end
end

def link_existing_account
@account = Current.family.accounts.find(params[:account_id])
plaid_account = PlaidAccount.find(params[:plaid_account_id])

# Verify the Plaid account belongs to this family's Plaid items
unless Current.family.plaid_items.include?(plaid_account.plaid_item)
redirect_to account_path(@account), alert: "Invalid Plaid account selected"
return
end

# Verify the Plaid account is not already linked
if plaid_account.account_provider.present? || plaid_account.account.present?
redirect_to account_path(@account), alert: "This Plaid account is already linked"
return
end

# Create the link via AccountProvider
AccountProvider.create!(
account: @account,
provider: plaid_account
)

redirect_to accounts_path, notice: "Account successfully linked to Plaid"
end

private
def set_plaid_item
@plaid_item = Current.family.plaid_items.find(params[:id])
Expand Down
40 changes: 40 additions & 0 deletions app/controllers/simplefin_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,46 @@ def complete_account_setup
redirect_to accounts_path, notice: t(".success")
end

def select_existing_account
@account = Current.family.accounts.find(params[:account_id])

# Get all SimpleFIN accounts from this family's SimpleFIN items
# that are not yet linked to any account
@available_simplefin_accounts = Current.family.simplefin_items
.includes(:simplefin_accounts)
.flat_map(&:simplefin_accounts)
.select { |sa| sa.account_provider.nil? && sa.account.nil? } # Not linked via new or legacy system

if @available_simplefin_accounts.empty?
redirect_to account_path(@account), alert: "No available SimpleFIN accounts to link. Please connect a new SimpleFIN account first."
end
end

def link_existing_account
@account = Current.family.accounts.find(params[:account_id])
simplefin_account = SimplefinAccount.find(params[:simplefin_account_id])

# Verify the SimpleFIN account belongs to this family's SimpleFIN items
unless Current.family.simplefin_items.include?(simplefin_account.simplefin_item)
redirect_to account_path(@account), alert: "Invalid SimpleFIN account selected"
return
end

# Verify the SimpleFIN account is not already linked
if simplefin_account.account_provider.present? || simplefin_account.account.present?
redirect_to account_path(@account), alert: "This SimpleFIN account is already linked"
return
end

# Create the link via AccountProvider
AccountProvider.create!(
account: @account,
provider: simplefin_account
)

redirect_to accounts_path, notice: "Account successfully linked to SimpleFIN"
end

private

def set_simplefin_item
Expand Down
6 changes: 5 additions & 1 deletion app/models/account.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ class Account < ApplicationRecord
scope :assets, -> { where(classification: "asset") }
scope :liabilities, -> { where(classification: "liability") }
scope :alphabetically, -> { order(:name) }
scope :manual, -> { left_joins(:account_providers).where(account_providers: { id: nil }) }
scope :manual, -> {
left_joins(:account_providers)
.where(account_providers: { id: nil })
.where(plaid_account_id: nil, simplefin_account_id: nil)
}

has_one_attached :logo

Expand Down
11 changes: 9 additions & 2 deletions app/models/account/linkable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ module Account::Linkable

# A "linked" account gets transaction and balance data from a third party like Plaid or SimpleFin
def linked?
account_providers.any?
account_providers.any? || plaid_account.present? || simplefin_account.present?
end

# An "offline" or "unlinked" account is one where the user tracks values and
Expand Down Expand Up @@ -43,7 +43,14 @@ def provider_for(provider_type)

# Convenience method to get the provider name
def provider_name
provider&.provider_name
# Try new system first
return provider&.provider_name if provider.present?

# Fall back to legacy system
return "plaid" if plaid_account.present?
return "simplefin" if simplefin_account.present?

nil
end

# Check if account is linked to a specific provider
Expand Down
12 changes: 11 additions & 1 deletion app/models/plaid_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,22 @@ class PlaidItem < ApplicationRecord
has_one_attached :logo

has_many :plaid_accounts, dependent: :destroy
has_many :accounts, through: :plaid_accounts
has_many :legacy_accounts, through: :plaid_accounts, source: :account

scope :active, -> { where(scheduled_for_deletion: false) }
scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) }

# Get accounts from both new and legacy systems
def accounts
# Preload associations to avoid N+1 queries
plaid_accounts
.includes(:account, account_provider: :account)
.map(&:current_account)
.compact
.uniq
end

def get_update_link_token(webhooks_url:, redirect_url:)
family.get_link_token(
webhooks_url: webhooks_url,
Expand Down
12 changes: 11 additions & 1 deletion app/models/simplefin_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,22 @@ class SimplefinItem < ApplicationRecord
has_one_attached :logo

has_many :simplefin_accounts, dependent: :destroy
has_many :accounts, through: :simplefin_accounts
has_many :legacy_accounts, through: :simplefin_accounts, source: :account

scope :active, -> { where(scheduled_for_deletion: false) }
scope :ordered, -> { order(created_at: :desc) }
scope :needs_update, -> { where(status: :requires_update) }

# Get accounts from both new and legacy systems
def accounts
# Preload associations to avoid N+1 queries
simplefin_accounts
.includes(:account, account_provider: :account)
.map(&:current_account)
.compact
.uniq
end

def destroy_later
update!(scheduled_for_deletion: true)
DestroyJob.perform_later(self)
Expand Down
13 changes: 10 additions & 3 deletions app/views/accounts/_account.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,20 @@
<%= icon("pencil-line", size: "sm") %>
<% end %>

<% if !account.account_providers.exists? && (account.accountable_type == "Depository" || account.accountable_type == "CreditCard") %>
<%= link_to select_existing_account_lunchflow_items_path(account_id: account.id, return_to: return_to),
<% if !account.linked? && (account.accountable_type == "Depository" || account.accountable_type == "CreditCard") %>
<%= link_to select_provider_account_path(account),
data: { turbo_frame: :modal },
class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1",
title: t("accounts.account.link_lunchflow") do %>
title: t("accounts.account.link_provider") do %>
<%= icon("link", size: "sm") %>
<% end %>
<% elsif account.linked? %>
<%= link_to confirm_unlink_account_path(account),
data: { turbo_frame: :modal },
class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1",
title: t("accounts.account.unlink_provider") do %>
<%= icon("unlink", size: "sm") %>
<% end %>
<% end %>
<% end %>
</div>
Expand Down
31 changes: 31 additions & 0 deletions app/views/accounts/confirm_unlink.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t("accounts.confirm_unlink.title")) %>

<% dialog.with_body do %>
<p class="text-secondary text-sm mb-4">
<%= t("accounts.confirm_unlink.description_html", account_name: @account.name, provider_name: @account.provider_name) %>
</p>

<div class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex items-start gap-2">
<%= icon "alert-triangle", class: "w-4 h-4 text-yellow-600 mt-0.5 flex-shrink-0" %>
<div class="text-xs">
<p class="font-medium text-yellow-900 mb-1"><%= t("accounts.confirm_unlink.warning_title") %></p>
<ul class="text-yellow-700 list-disc list-inside space-y-1">
<li><%= t("accounts.confirm_unlink.warning_no_sync") %></li>
<li><%= t("accounts.confirm_unlink.warning_manual_updates") %></li>
<li><%= t("accounts.confirm_unlink.warning_transactions_kept") %></li>
<li><%= t("accounts.confirm_unlink.warning_can_delete") %></li>
</ul>
</div>
</div>
</div>

<%= render DS::Button.new(
text: t("accounts.confirm_unlink.confirm_button"),
href: unlink_account_path(@account),
method: :delete,
full_width: true,
data: { turbo_frame: "_top" }) %>
<% end %>
<% end %>
23 changes: 23 additions & 0 deletions app/views/accounts/select_provider.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t("accounts.select_provider.title")) %>

<% dialog.with_body do %>
<p class="text-secondary text-sm mb-4">
<%= t("accounts.select_provider.description", account_name: @account.name) %>
</p>

<div class="space-y-2">
<% @available_providers.each do |provider| %>
<%= link_to provider[:path], data: { turbo_frame: :modal }, class: "block p-4 border border-primary rounded-lg hover:bg-container-hover transition-colors" do %>
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-primary"><%= provider[:name] %></p>
<p class="text-sm text-secondary"><%= provider[:description] %></p>
</div>
<%= icon("chevron-right", size: "sm", class: "text-secondary") %>
</div>
<% end %>
<% end %>
</div>
<% end %>
<% end %>
Loading