mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
43
app/views/lunchflow_items/select_existing_account.html.erb
Normal file
43
app/views/lunchflow_items/select_existing_account.html.erb
Normal 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 %>
|
||||
Reference in New Issue
Block a user