Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions app/controllers/accounts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ def index
render layout: "settings"
end

def new
@show_lunchflow_link = family.can_connect_lunchflow?
end

def sync_all
family.sync_later
redirect_to accounts_path, notice: "Syncing accounts..."
Expand Down
25 changes: 0 additions & 25 deletions app/controllers/concerns/accountable_resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,31 +69,6 @@ def set_link_options
@show_us_link = Current.family.can_connect_plaid_us?
@show_eu_link = Current.family.can_connect_plaid_eu?
@show_lunchflow_link = Current.family.can_connect_lunchflow?

# Preload Lunchflow accounts if available and cache them
if @show_lunchflow_link
cache_key = "lunchflow_accounts_#{Current.family.id}"

@lunchflow_accounts = Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
begin
lunchflow_provider = Provider::LunchflowAdapter.build_provider

if lunchflow_provider.present?
accounts_data = lunchflow_provider.get_accounts
accounts_data[:accounts] || []
else
[]
end
rescue Provider::Lunchflow::LunchflowError => e
Rails.logger.error("Failed to preload Lunchflow accounts: #{e.message}")
[]
rescue StandardError => e
Rails.logger.error("Unexpected error preloading Lunchflow accounts: #{e.class}: #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
[]
end
end
end
end

def accountable_type
Expand Down
63 changes: 62 additions & 1 deletion app/controllers/lunchflow_items_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,43 @@ def index
def show
end

# Preload Lunchflow accounts in background (async, non-blocking)
def preload_accounts
begin
cache_key = "lunchflow_accounts_#{Current.family.id}"

# Check if already cached
cached_accounts = Rails.cache.read(cache_key)

if cached_accounts.present?
render json: { success: true, has_accounts: cached_accounts.any?, cached: true }
return
end

# Fetch from API
lunchflow_provider = Provider::LunchflowAdapter.build_provider

unless lunchflow_provider.present?
render json: { success: false, error: "no_api_key", has_accounts: false }
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)

render json: { success: true, has_accounts: available_accounts.any?, cached: false }
rescue Provider::Lunchflow::LunchflowError => e
Rails.logger.error("Lunchflow preload error: #{e.message}")
render json: { success: false, error: e.message, has_accounts: false }
rescue StandardError => e
Rails.logger.error("Unexpected error preloading Lunchflow accounts: #{e.class}: #{e.message}")
render json: { success: false, error: "unexpected_error", has_accounts: false }
end
end

# Fetch available accounts from Lunchflow API and show selection UI
def select_accounts
begin
Expand Down Expand Up @@ -75,12 +112,20 @@ def link_accounts

created_accounts = []
already_linked_accounts = []
invalid_accounts = []

selected_account_ids.each do |account_id|
# Find the account data from API response
account_data = accounts_data[:accounts].find { |acc| acc[:id].to_s == account_id.to_s }
next unless account_data

# Validate account name is not blank (required by Account model)
if account_data[:name].blank?
invalid_accounts << account_id
Rails.logger.warn "LunchflowItemsController - Skipping account #{account_id} with blank name"
next
end

# Create or find lunchflow_account
lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by(
account_id: account_id.to_s
Expand Down Expand Up @@ -117,7 +162,17 @@ def link_accounts
lunchflow_item.sync_later if created_accounts.any?

# Build appropriate flash message
if created_accounts.any? && already_linked_accounts.any?
if invalid_accounts.any? && created_accounts.empty? && already_linked_accounts.empty?
# All selected accounts were invalid (blank names)
redirect_to new_account_path, alert: t(".invalid_account_names", count: invalid_accounts.count)
elsif invalid_accounts.any? && (created_accounts.any? || already_linked_accounts.any?)
# Some accounts were created/already linked, but some had invalid names
redirect_to return_to || accounts_path,
alert: t(".partial_invalid",
created_count: created_accounts.count,
already_linked_count: already_linked_accounts.count,
invalid_count: invalid_accounts.count)
elsif created_accounts.any? && already_linked_accounts.any?
redirect_to return_to || accounts_path,
notice: t(".partial_success",
created_count: created_accounts.count,
Expand Down Expand Up @@ -243,6 +298,12 @@ def link_existing_account
return
end

# Validate account name is not blank (required by Account model)
if account_data[:name].blank?
redirect_to accounts_path, alert: t(".invalid_account_name")
return
end

# Create or find lunchflow_account
lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by(
account_id: lunchflow_account_id.to_s
Expand Down
89 changes: 89 additions & 0 deletions app/javascript/controllers/lunchflow_preload_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Controller } from "@hotwired/stimulus";

// Connects to data-controller="lunchflow-preload"
export default class extends Controller {
static targets = ["link", "spinner"];
static values = {
accountableType: String,
returnTo: String,
};

connect() {
this.preloadAccounts();
}

async preloadAccounts() {
try {
// Show loading state if we have a link target (on method selector page)
if (this.hasLinkTarget) {
this.showLoading();
}

// Fetch accounts in background to populate cache
const url = new URL(
"/lunchflow_items/preload_accounts",
window.location.origin
);
if (this.hasAccountableTypeValue) {
url.searchParams.append("accountable_type", this.accountableTypeValue);
}
if (this.hasReturnToValue) {
url.searchParams.append("return_to", this.returnToValue);
}

const csrfToken = document.querySelector('[name="csrf-token"]');
const headers = {
Accept: "application/json",
};
if (csrfToken) {
headers["X-CSRF-Token"] = csrfToken.content;
}

const response = await fetch(url, { headers });

if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}

const data = await response.json();

if (data.success && data.has_accounts) {
// Accounts loaded successfully, enable the link
if (this.hasLinkTarget) {
this.hideLoading();
}
} else if (!data.has_accounts) {
// No accounts available, hide the link entirely
if (this.hasLinkTarget) {
this.linkTarget.style.display = "none";
}
} else {
// Error occurred
if (this.hasLinkTarget) {
this.hideLoading();
}
console.error("Failed to preload Lunchflow accounts:", data.error);
}
} catch (error) {
// On error, still enable the link so user can try
if (this.hasLinkTarget) {
this.hideLoading();
}
console.error("Error preloading Lunchflow accounts:", error);
}
}

showLoading() {
this.linkTarget.classList.add("pointer-events-none", "opacity-50");
if (this.hasSpinnerTarget) {
this.spinnerTarget.classList.remove("hidden");
}
}

hideLoading() {
this.linkTarget.classList.remove("pointer-events-none", "opacity-50");
if (this.hasSpinnerTarget) {
this.spinnerTarget.classList.add("hidden");
}
}
}
6 changes: 4 additions & 2 deletions app/models/lunchflow_item.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ def process_accounts
return [] if lunchflow_accounts.empty?

results = []
lunchflow_accounts.joins(:account).each do |lunchflow_account|
# Only process accounts that are linked and have active status
lunchflow_accounts.joins(:account).merge(Account.visible).each do |lunchflow_account|
begin
result = LunchflowAccount::Processor.new(lunchflow_account).process
results << { lunchflow_account_id: lunchflow_account.id, success: true, result: result }
Expand All @@ -55,7 +56,8 @@ def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_
return [] if accounts.empty?

results = []
accounts.each do |account|
# Only schedule syncs for active accounts
accounts.visible.each do |account|
begin
account.sync_later(
parent_sync: parent_sync,
Expand Down
41 changes: 28 additions & 13 deletions app/models/lunchflow_item/importer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,31 +24,39 @@ def import
# Continue with import even if snapshot storage fails
end

# Step 2: Import accounts
accounts_imported = 0
# Step 2: Update only previously selected accounts (don't create new ones)
accounts_updated = 0
accounts_failed = 0

if accounts_data[:accounts].present?
# Get all existing lunchflow account IDs for this item (normalize to strings for comparison)
existing_account_ids = lunchflow_item.lunchflow_accounts.pluck(:account_id).map(&:to_s)

accounts_data[:accounts].each do |account_data|
account_id = account_data[:id]&.to_s
next unless account_id.present?

# Only update if this account was previously selected (exists in our DB)
next unless existing_account_ids.include?(account_id)

begin
import_account(account_data)
accounts_imported += 1
accounts_updated += 1
rescue => e
accounts_failed += 1
account_id = account_data[:id] || "unknown"
Rails.logger.error "LunchflowItem::Importer - Failed to import account #{account_id}: #{e.message}"
# Continue importing other accounts even if one fails
Rails.logger.error "LunchflowItem::Importer - Failed to update account #{account_id}: #{e.message}"
# Continue updating other accounts even if one fails
end
end
end

Rails.logger.info "LunchflowItem::Importer - Imported #{accounts_imported} accounts (#{accounts_failed} failed)"
Rails.logger.info "LunchflowItem::Importer - Updated #{accounts_updated} accounts (#{accounts_failed} failed)"

# Step 3: Fetch transactions for each account
# Step 3: Fetch transactions only for linked accounts with active status
transactions_imported = 0
transactions_failed = 0

lunchflow_item.lunchflow_accounts.each do |lunchflow_account|
lunchflow_item.lunchflow_accounts.joins(:account).merge(Account.visible).each do |lunchflow_account|
begin
result = fetch_and_store_transactions(lunchflow_account)
if result[:success]
Expand All @@ -63,11 +71,11 @@ def import
end
end

Rails.logger.info "LunchflowItem::Importer - Completed import for item #{lunchflow_item.id}: #{accounts_imported} accounts, #{transactions_imported} transactions"
Rails.logger.info "LunchflowItem::Importer - Completed import for item #{lunchflow_item.id}: #{accounts_updated} accounts updated, #{transactions_imported} transactions"

{
success: accounts_failed == 0 && transactions_failed == 0,
accounts_imported: accounts_imported,
accounts_updated: accounts_updated,
accounts_failed: accounts_failed,
transactions_imported: transactions_imported,
transactions_failed: transactions_failed
Expand Down Expand Up @@ -123,16 +131,23 @@ def import_account(account_data)

account_id = account_data[:id]

# Validate required account_id to prevent duplicate creation
# Validate required account_id
if account_id.blank?
Rails.logger.warn "LunchflowItem::Importer - Skipping account with missing ID"
raise ArgumentError, "Account ID is required"
end

lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by(
# Only find existing accounts, don't create new ones during sync
lunchflow_account = lunchflow_item.lunchflow_accounts.find_by(
account_id: account_id.to_s
)

# Skip if account wasn't previously selected
unless lunchflow_account
Rails.logger.debug "LunchflowItem::Importer - Skipping unselected account #{account_id}"
return
end

begin
lunchflow_account.upsert_lunchflow_snapshot!(account_data)
lunchflow_account.save!
Expand Down
2 changes: 1 addition & 1 deletion app/models/lunchflow_item/syncer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def perform_sync(sync)
# Phase 2: Check account setup status and collect sync statistics
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
total_accounts = lunchflow_item.lunchflow_accounts.count
linked_accounts = lunchflow_item.lunchflow_accounts.joins(:account)
linked_accounts = lunchflow_item.lunchflow_accounts.joins(:account).merge(Account.visible)
unlinked_accounts = lunchflow_item.lunchflow_accounts.includes(:account).where(accounts: { id: nil })

# Store sync statistics for display
Expand Down
Loading