Lunch flow improvements (#268)

- Add support to link existing account with lunch-flow
The account will be promoted to a lunch flow connection now
( TBD if we want to allow un-linking? )
- Add support for proper de-dup at provider import level. This will handle de-dups for Lunch Flow, Plaid and SimpleFIN
- Fix plaid account removal on invalid credentials
This commit is contained in:
soky srm
2025-10-31 13:29:44 +01:00
committed by GitHub
parent da114b5b3d
commit 106fcd06e4
11 changed files with 561 additions and 5 deletions

View File

@@ -138,6 +138,139 @@ class LunchflowItemsController < ApplicationController
redirect_to new_account_path, alert: t(".api_error", message: e.message)
end
# Fetch available Lunchflow accounts to link with an existing account
def select_existing_account
account_id = params[:account_id]
unless account_id.present?
redirect_to accounts_path, alert: t(".no_account_specified")
return
end
@account = Current.family.accounts.find(account_id)
# Check if account is already linked
if @account.account_providers.exists?
redirect_to accounts_path, alert: t(".account_already_linked")
return
end
begin
cache_key = "lunchflow_accounts_#{Current.family.id}"
# Try to get cached accounts first
@available_accounts = Rails.cache.read(cache_key)
# If not cached, fetch from API
if @available_accounts.nil?
lunchflow_provider = Provider::LunchflowAdapter.build_provider
unless lunchflow_provider.present?
redirect_to accounts_path, alert: t(".no_api_key")
return
end
accounts_data = lunchflow_provider.get_accounts
@available_accounts = accounts_data[:accounts] || []
# Cache the accounts for 5 minutes
Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes)
end
if @available_accounts.empty?
redirect_to accounts_path, alert: t(".no_accounts_found")
return
end
# Filter out already linked accounts
lunchflow_item = Current.family.lunchflow_items.first
if lunchflow_item
linked_account_ids = lunchflow_item.lunchflow_accounts.joins(:account_provider).pluck(:account_id)
@available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) }
end
if @available_accounts.empty?
redirect_to accounts_path, alert: t(".all_accounts_already_linked")
return
end
@return_to = safe_return_to_path
render layout: false
rescue Provider::Lunchflow::LunchflowError => e
redirect_to accounts_path, alert: t(".api_error", message: e.message)
end
end
# Link a selected Lunchflow account to an existing account
def link_existing_account
account_id = params[:account_id]
lunchflow_account_id = params[:lunchflow_account_id]
return_to = safe_return_to_path
unless account_id.present? && lunchflow_account_id.present?
redirect_to accounts_path, alert: t(".missing_parameters")
return
end
@account = Current.family.accounts.find(account_id)
# Check if account is already linked
if @account.account_providers.exists?
redirect_to accounts_path, alert: t(".account_already_linked")
return
end
# Create or find lunchflow_item for this family
lunchflow_item = Current.family.lunchflow_items.first_or_create!(
name: "Lunchflow Connection"
)
# Fetch account details from API
lunchflow_provider = Provider::LunchflowAdapter.build_provider
unless lunchflow_provider.present?
redirect_to accounts_path, alert: t(".no_api_key")
return
end
accounts_data = lunchflow_provider.get_accounts
# Find the selected Lunchflow account data
account_data = accounts_data[:accounts].find { |acc| acc[:id].to_s == lunchflow_account_id.to_s }
unless account_data
redirect_to accounts_path, alert: t(".lunchflow_account_not_found")
return
end
# Create or find lunchflow_account
lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by(
account_id: lunchflow_account_id.to_s
)
lunchflow_account.upsert_lunchflow_snapshot!(account_data)
lunchflow_account.save!
# Check if this lunchflow_account is already linked to another account
if lunchflow_account.account_provider.present?
redirect_to accounts_path, alert: t(".lunchflow_account_already_linked")
return
end
# Link account to lunchflow_account via account_providers join table
AccountProvider.create!(
account: @account,
provider: lunchflow_account
)
# Trigger sync to fetch transactions
lunchflow_item.sync_later
redirect_to return_to || accounts_path,
notice: t(".success", account_name: @account.name)
rescue Provider::Lunchflow::LunchflowError => e
redirect_to accounts_path, alert: t(".api_error", message: e.message)
end
def new
@lunchflow_item = Current.family.lunchflow_items.build
end

View File

@@ -27,6 +27,19 @@ class Account::ProviderImportAdapter
e.entryable = Transaction.new
end
# If this is a new entry, check for potential duplicates from manual/CSV imports
# This handles the case where a user manually created or CSV imported a transaction
# before linking their account to a provider
if entry.new_record?
duplicate = find_duplicate_transaction(date: date, amount: amount, currency: currency)
if duplicate
# "Claim" the duplicate by updating its external_id and source
# This prevents future duplicate checks from matching it again
entry = duplicate
entry.assign_attributes(external_id: external_id, source: source)
end
end
# Validate entryable type matches to prevent external_id collisions
if entry.persisted? && !entry.entryable.is_a?(Transaction)
raise ArgumentError, "Entry with external_id '#{external_id}' already exists with different entryable type: #{entry.entryable_type}"
@@ -252,4 +265,34 @@ class Account::ProviderImportAdapter
Rails.logger.error("Failed to update #{account.accountable_type} attributes from #{source}: #{e.message}")
false
end
private
# Finds a potential duplicate transaction from manual entry or CSV import
# Matches on date, amount, and currency
# Only matches transactions without external_id (manual/CSV imported)
#
# @param date [Date, String] Transaction date
# @param amount [BigDecimal, Numeric] Transaction amount
# @param currency [String] Currency code
# @return [Entry, nil] The duplicate entry or nil if not found
def find_duplicate_transaction(date:, amount:, currency:)
# Convert date to Date object if it's a string
date = Date.parse(date.to_s) unless date.is_a?(Date)
# Look for entries on the same account with:
# 1. Same date
# 2. Same amount (exact match)
# 3. Same currency
# 4. No external_id (manual/CSV imported transactions)
# 5. Entry type is Transaction (not Trade or Valuation)
account.entries
.where(entryable_type: "Transaction")
.where(date: date)
.where(amount: amount)
.where(currency: currency)
.where(external_id: nil)
.order(created_at: :asc)
.first
end
end

View File

@@ -104,12 +104,19 @@ class PlaidItem < ApplicationRecord
plaid_provider.remove_item(access_token)
rescue Plaid::ApiError => e
json_response = JSON.parse(e.response_body)
error_code = json_response["error_code"]
# If the item is not found, that means it was already deleted by the user on their
# Plaid portal OR by Plaid support. Either way, we're not being billed, so continue
# with the deletion of our internal record.
unless json_response["error_code"] == "ITEM_NOT_FOUND"
raise e
# Continue with deletion if:
# - ITEM_NOT_FOUND: Item was already deleted by the user on their Plaid portal OR by Plaid support
# - INVALID_API_KEYS: API credentials are invalid/missing, so we can't communicate with Plaid anyway
# - Other credential errors: We're deleting our record, so no need to fail if we can't reach Plaid
ignorable_errors = %w[ITEM_NOT_FOUND INVALID_API_KEYS INVALID_CLIENT_ID INVALID_SECRET]
unless ignorable_errors.include?(error_code)
# Log the error but don't prevent deletion - we're removing the item from our database
# If we can't tell Plaid, we'll at least stop using it on our end
Rails.logger.warn("Failed to remove Plaid item: #{error_code} - #{json_response['error_message']}")
Sentry.capture_exception(e) if defined?(Sentry)
end
end

View File

@@ -32,6 +32,15 @@
<%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %>
<%= 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),
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 %>
<%= icon("link", size: "sm") %>
<% end %>
<% end %>
<% end %>
</div>
<div class="flex items-center gap-8">

View File

@@ -0,0 +1,43 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title", account_name: @account.name)) %>
<% dialog.with_body do %>
<div class="space-y-4">
<p class="text-sm text-secondary">
<%= t(".description") %>
</p>
<form action="<%= link_existing_account_lunchflow_items_path %>" method="post" class="space-y-4" data-turbo-frame="_top">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<%= hidden_field_tag :account_id, @account.id %>
<%= hidden_field_tag :return_to, @return_to %>
<div class="space-y-2">
<% @available_accounts.each do |account| %>
<label class="flex items-start gap-3 p-3 border border-primary rounded-lg hover:bg-subtle cursor-pointer transition-colors">
<%= radio_button_tag "lunchflow_account_id", account[:id], false, class: "mt-1" %>
<div class="flex-1">
<div class="font-medium text-sm text-primary">
<%= account[:name] %>
</div>
<div class="text-xs text-secondary mt-1">
<%= account[:institution_name] %> • <%= account[:currency] %> • <%= account[:status] %>
</div>
</div>
</label>
<% end %>
</div>
<div class="flex gap-2 justify-end pt-4">
<%= link_to t(".cancel"), @return_to || accounts_path,
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
data: { turbo_frame: "_top" } %>
<%= submit_tag t(".link_account"),
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %>
</div>
</form>
</div>
<% end %>
<% end %>
<% end %>