mirror of
https://github.com/we-promise/sure.git
synced 2026-04-20 12:34:12 +00:00
Lunchflow fix (#307)
* Fix lunch flow pre-loading and UX * Small UX fixes - Proper closing of modal on cancel - Preload on new account already * Review comments * Fix json error * Delete .claude/settings.local.json Signed-off-by: soky srm <sokysrm@gmail.com> * Lunch Flow brand (again :-) * FIX process only linked accounts * FIX disable accounts with no name * Fix string normalization --------- Signed-off-by: soky srm <sokysrm@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -11,6 +11,10 @@ class AccountsController < ApplicationController
|
|||||||
render layout: "settings"
|
render layout: "settings"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@show_lunchflow_link = family.can_connect_lunchflow?
|
||||||
|
end
|
||||||
|
|
||||||
def sync_all
|
def sync_all
|
||||||
family.sync_later
|
family.sync_later
|
||||||
redirect_to accounts_path, notice: "Syncing accounts..."
|
redirect_to accounts_path, notice: "Syncing accounts..."
|
||||||
|
|||||||
@@ -69,31 +69,6 @@ module AccountableResource
|
|||||||
@show_us_link = Current.family.can_connect_plaid_us?
|
@show_us_link = Current.family.can_connect_plaid_us?
|
||||||
@show_eu_link = Current.family.can_connect_plaid_eu?
|
@show_eu_link = Current.family.can_connect_plaid_eu?
|
||||||
@show_lunchflow_link = Current.family.can_connect_lunchflow?
|
@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
|
end
|
||||||
|
|
||||||
def accountable_type
|
def accountable_type
|
||||||
|
|||||||
@@ -9,6 +9,43 @@ class LunchflowItemsController < ApplicationController
|
|||||||
def show
|
def show
|
||||||
end
|
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
|
# Fetch available accounts from Lunchflow API and show selection UI
|
||||||
def select_accounts
|
def select_accounts
|
||||||
begin
|
begin
|
||||||
@@ -75,12 +112,20 @@ class LunchflowItemsController < ApplicationController
|
|||||||
|
|
||||||
created_accounts = []
|
created_accounts = []
|
||||||
already_linked_accounts = []
|
already_linked_accounts = []
|
||||||
|
invalid_accounts = []
|
||||||
|
|
||||||
selected_account_ids.each do |account_id|
|
selected_account_ids.each do |account_id|
|
||||||
# Find the account data from API response
|
# Find the account data from API response
|
||||||
account_data = accounts_data[:accounts].find { |acc| acc[:id].to_s == account_id.to_s }
|
account_data = accounts_data[:accounts].find { |acc| acc[:id].to_s == account_id.to_s }
|
||||||
next unless account_data
|
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
|
# Create or find lunchflow_account
|
||||||
lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by(
|
lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by(
|
||||||
account_id: account_id.to_s
|
account_id: account_id.to_s
|
||||||
@@ -117,7 +162,17 @@ class LunchflowItemsController < ApplicationController
|
|||||||
lunchflow_item.sync_later if created_accounts.any?
|
lunchflow_item.sync_later if created_accounts.any?
|
||||||
|
|
||||||
# Build appropriate flash message
|
# 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,
|
redirect_to return_to || accounts_path,
|
||||||
notice: t(".partial_success",
|
notice: t(".partial_success",
|
||||||
created_count: created_accounts.count,
|
created_count: created_accounts.count,
|
||||||
@@ -243,6 +298,12 @@ class LunchflowItemsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
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
|
# Create or find lunchflow_account
|
||||||
lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by(
|
lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by(
|
||||||
account_id: lunchflow_account_id.to_s
|
account_id: lunchflow_account_id.to_s
|
||||||
|
|||||||
89
app/javascript/controllers/lunchflow_preload_controller.js
Normal file
89
app/javascript/controllers/lunchflow_preload_controller.js
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,8 @@ class LunchflowItem < ApplicationRecord
|
|||||||
return [] if lunchflow_accounts.empty?
|
return [] if lunchflow_accounts.empty?
|
||||||
|
|
||||||
results = []
|
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
|
begin
|
||||||
result = LunchflowAccount::Processor.new(lunchflow_account).process
|
result = LunchflowAccount::Processor.new(lunchflow_account).process
|
||||||
results << { lunchflow_account_id: lunchflow_account.id, success: true, result: result }
|
results << { lunchflow_account_id: lunchflow_account.id, success: true, result: result }
|
||||||
@@ -55,7 +56,8 @@ class LunchflowItem < ApplicationRecord
|
|||||||
return [] if accounts.empty?
|
return [] if accounts.empty?
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
accounts.each do |account|
|
# Only schedule syncs for active accounts
|
||||||
|
accounts.visible.each do |account|
|
||||||
begin
|
begin
|
||||||
account.sync_later(
|
account.sync_later(
|
||||||
parent_sync: parent_sync,
|
parent_sync: parent_sync,
|
||||||
|
|||||||
@@ -24,31 +24,39 @@ class LunchflowItem::Importer
|
|||||||
# Continue with import even if snapshot storage fails
|
# Continue with import even if snapshot storage fails
|
||||||
end
|
end
|
||||||
|
|
||||||
# Step 2: Import accounts
|
# Step 2: Update only previously selected accounts (don't create new ones)
|
||||||
accounts_imported = 0
|
accounts_updated = 0
|
||||||
accounts_failed = 0
|
accounts_failed = 0
|
||||||
|
|
||||||
if accounts_data[:accounts].present?
|
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|
|
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
|
begin
|
||||||
import_account(account_data)
|
import_account(account_data)
|
||||||
accounts_imported += 1
|
accounts_updated += 1
|
||||||
rescue => e
|
rescue => e
|
||||||
accounts_failed += 1
|
accounts_failed += 1
|
||||||
account_id = account_data[:id] || "unknown"
|
Rails.logger.error "LunchflowItem::Importer - Failed to update account #{account_id}: #{e.message}"
|
||||||
Rails.logger.error "LunchflowItem::Importer - Failed to import account #{account_id}: #{e.message}"
|
# Continue updating other accounts even if one fails
|
||||||
# Continue importing other accounts even if one fails
|
|
||||||
end
|
end
|
||||||
end
|
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_imported = 0
|
||||||
transactions_failed = 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
|
begin
|
||||||
result = fetch_and_store_transactions(lunchflow_account)
|
result = fetch_and_store_transactions(lunchflow_account)
|
||||||
if result[:success]
|
if result[:success]
|
||||||
@@ -63,11 +71,11 @@ class LunchflowItem::Importer
|
|||||||
end
|
end
|
||||||
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,
|
success: accounts_failed == 0 && transactions_failed == 0,
|
||||||
accounts_imported: accounts_imported,
|
accounts_updated: accounts_updated,
|
||||||
accounts_failed: accounts_failed,
|
accounts_failed: accounts_failed,
|
||||||
transactions_imported: transactions_imported,
|
transactions_imported: transactions_imported,
|
||||||
transactions_failed: transactions_failed
|
transactions_failed: transactions_failed
|
||||||
@@ -123,16 +131,23 @@ class LunchflowItem::Importer
|
|||||||
|
|
||||||
account_id = account_data[:id]
|
account_id = account_data[:id]
|
||||||
|
|
||||||
# Validate required account_id to prevent duplicate creation
|
# Validate required account_id
|
||||||
if account_id.blank?
|
if account_id.blank?
|
||||||
Rails.logger.warn "LunchflowItem::Importer - Skipping account with missing ID"
|
Rails.logger.warn "LunchflowItem::Importer - Skipping account with missing ID"
|
||||||
raise ArgumentError, "Account ID is required"
|
raise ArgumentError, "Account ID is required"
|
||||||
end
|
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
|
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
|
begin
|
||||||
lunchflow_account.upsert_lunchflow_snapshot!(account_data)
|
lunchflow_account.upsert_lunchflow_snapshot!(account_data)
|
||||||
lunchflow_account.save!
|
lunchflow_account.save!
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class LunchflowItem::Syncer
|
|||||||
# Phase 2: Check account setup status and collect sync statistics
|
# Phase 2: Check account setup status and collect sync statistics
|
||||||
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
|
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
|
||||||
total_accounts = lunchflow_item.lunchflow_accounts.count
|
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 })
|
unlinked_accounts = lunchflow_item.lunchflow_accounts.includes(:account).where(accounts: { id: nil })
|
||||||
|
|
||||||
# Store sync statistics for display
|
# Store sync statistics for display
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ class Provider::Lunchflow
|
|||||||
|
|
||||||
handle_response(response)
|
handle_response(response)
|
||||||
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
||||||
Rails.logger.error "Lunchflow API: GET /accounts failed: #{e.class}: #{e.message}"
|
Rails.logger.error "Lunch Flow API: GET /accounts failed: #{e.class}: #{e.message}"
|
||||||
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
|
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Lunchflow API: Unexpected error during GET /accounts: #{e.class}: #{e.message}"
|
Rails.logger.error "Lunch Flow API: Unexpected error during GET /accounts: #{e.class}: #{e.message}"
|
||||||
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
|
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,10 +52,10 @@ class Provider::Lunchflow
|
|||||||
|
|
||||||
handle_response(response)
|
handle_response(response)
|
||||||
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
||||||
Rails.logger.error "Lunchflow API: GET #{path} failed: #{e.class}: #{e.message}"
|
Rails.logger.error "Lunch Flow API: GET #{path} failed: #{e.class}: #{e.message}"
|
||||||
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
|
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Lunchflow API: Unexpected error during GET #{path}: #{e.class}: #{e.message}"
|
Rails.logger.error "Lunch Flow API: Unexpected error during GET #{path}: #{e.class}: #{e.message}"
|
||||||
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
|
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -71,10 +71,10 @@ class Provider::Lunchflow
|
|||||||
|
|
||||||
handle_response(response)
|
handle_response(response)
|
||||||
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
||||||
Rails.logger.error "Lunchflow API: GET #{path} failed: #{e.class}: #{e.message}"
|
Rails.logger.error "Lunch Flow API: GET #{path} failed: #{e.class}: #{e.message}"
|
||||||
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
|
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "Lunchflow API: Unexpected error during GET #{path}: #{e.class}: #{e.message}"
|
Rails.logger.error "Lunch Flow API: Unexpected error during GET #{path}: #{e.class}: #{e.message}"
|
||||||
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
|
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ class Provider::Lunchflow
|
|||||||
when 200
|
when 200
|
||||||
JSON.parse(response.body, symbolize_names: true)
|
JSON.parse(response.body, symbolize_names: true)
|
||||||
when 400
|
when 400
|
||||||
Rails.logger.error "Lunchflow API: Bad request - #{response.body}"
|
Rails.logger.error "Lunch Flow API: Bad request - #{response.body}"
|
||||||
raise LunchflowError.new("Bad request to Lunchflow API: #{response.body}", :bad_request)
|
raise LunchflowError.new("Bad request to Lunchflow API: #{response.body}", :bad_request)
|
||||||
when 401
|
when 401
|
||||||
raise LunchflowError.new("Invalid API key", :unauthorized)
|
raise LunchflowError.new("Invalid API key", :unauthorized)
|
||||||
@@ -104,7 +104,7 @@ class Provider::Lunchflow
|
|||||||
when 429
|
when 429
|
||||||
raise LunchflowError.new("Rate limit exceeded. Please try again later.", :rate_limited)
|
raise LunchflowError.new("Rate limit exceeded. Please try again later.", :rate_limited)
|
||||||
else
|
else
|
||||||
Rails.logger.error "Lunchflow API: Unexpected response - Code: #{response.code}, Body: #{response.body}"
|
Rails.logger.error "Lunch Flow API: Unexpected response - Code: #{response.code}, Body: #{response.body}"
|
||||||
raise LunchflowError.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed)
|
raise LunchflowError.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ class Provider::LunchflowAdapter < Provider::Base
|
|||||||
# Register this adapter with the factory
|
# Register this adapter with the factory
|
||||||
Provider::Factory.register("LunchflowAccount", self)
|
Provider::Factory.register("LunchflowAccount", self)
|
||||||
|
|
||||||
# Configuration for Lunchflow
|
# Configuration for Lunch Flow
|
||||||
configure do
|
configure do
|
||||||
description <<~DESC
|
description <<~DESC
|
||||||
Setup instructions:
|
Setup instructions:
|
||||||
1. Visit [Lunchflow](https://www.lunchflow.app) to get your API key
|
1. Visit [Lunch Flow](https://www.lunchflow.app) to get your API key
|
||||||
2. Enter your API key below to enable Lunchflow bank data sync
|
2. Enter your API key below to enable Lunch Flow bank data sync
|
||||||
3. Choose the appropriate environment (production or staging)
|
3. Choose the appropriate environment (production or staging)
|
||||||
DESC
|
DESC
|
||||||
|
|
||||||
@@ -20,21 +20,21 @@ class Provider::LunchflowAdapter < Provider::Base
|
|||||||
required: true,
|
required: true,
|
||||||
secret: true,
|
secret: true,
|
||||||
env_key: "LUNCHFLOW_API_KEY",
|
env_key: "LUNCHFLOW_API_KEY",
|
||||||
description: "Your Lunchflow API key for authentication"
|
description: "Your Lunch Flow API key for authentication"
|
||||||
|
|
||||||
field :base_url,
|
field :base_url,
|
||||||
label: "Base URL",
|
label: "Base URL",
|
||||||
required: false,
|
required: false,
|
||||||
env_key: "LUNCHFLOW_BASE_URL",
|
env_key: "LUNCHFLOW_BASE_URL",
|
||||||
default: "https://lunchflow.app/api/v1",
|
default: "https://lunchflow.app/api/v1",
|
||||||
description: "Base URL for Lunchflow API"
|
description: "Base URL for Lunch Flow API"
|
||||||
end
|
end
|
||||||
|
|
||||||
def provider_name
|
def provider_name
|
||||||
"lunchflow"
|
"lunchflow"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Build a Lunchflow provider instance with configured credentials
|
# Build a Lunch Flow provider instance with configured credentials
|
||||||
# @return [Provider::Lunchflow, nil] Returns nil if API key is not configured
|
# @return [Provider::Lunchflow, nil] Returns nil if API key is not configured
|
||||||
def self.build_provider
|
def self.build_provider
|
||||||
api_key = config_value(:api_key)
|
api_key = config_value(:api_key)
|
||||||
@@ -46,7 +46,7 @@ class Provider::LunchflowAdapter < Provider::Base
|
|||||||
|
|
||||||
# Reload Lunchflow configuration when settings are updated
|
# Reload Lunchflow configuration when settings are updated
|
||||||
def self.reload_configuration
|
def self.reload_configuration
|
||||||
# Lunchflow doesn't need to configure Rails.application.config like Plaid does
|
# Lunch Flow doesn't need to configure Rails.application.config like Plaid does
|
||||||
# The configuration is read dynamically via config_value(:api_key) and config_value(:base_url)
|
# The configuration is read dynamically via config_value(:api_key) and config_value(:base_url)
|
||||||
# This method exists to be called by the settings controller after updates
|
# This method exists to be called by the settings controller after updates
|
||||||
# No action needed here since values are fetched on-demand
|
# No action needed here since values are fetched on-demand
|
||||||
@@ -65,7 +65,7 @@ class Provider::LunchflowAdapter < Provider::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def institution_domain
|
def institution_domain
|
||||||
# Lunchflow may provide institution metadata in account data
|
# Lunch Flow may provide institution metadata in account data
|
||||||
metadata = provider_account.institution_metadata
|
metadata = provider_account.institution_metadata
|
||||||
return nil unless metadata.present?
|
return nil unless metadata.present?
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ class Provider::LunchflowAdapter < Provider::Base
|
|||||||
begin
|
begin
|
||||||
domain = URI.parse(url).host&.gsub(/^www\./, "")
|
domain = URI.parse(url).host&.gsub(/^www\./, "")
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
Rails.logger.warn("Invalid institution URL for Lunchflow account #{provider_account.id}: #{url}")
|
Rails.logger.warn("Invalid institution URL for Lunch Flow account #{provider_account.id}: #{url}")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<%= render layout: "accounts/new/container", locals: { title: t(".title") } do %>
|
<%= render layout: "accounts/new/container", locals: { title: t(".title") } do %>
|
||||||
<div class="text-sm">
|
<div class="text-sm"
|
||||||
|
<% if @show_lunchflow_link %>
|
||||||
|
data-controller="lunchflow-preload"
|
||||||
|
<% end %>>
|
||||||
<% unless params[:classification] == "liability" %>
|
<% unless params[:classification] == "liability" %>
|
||||||
<%= render "account_type", accountable: Depository.new %>
|
<%= render "account_type", accountable: Depository.new %>
|
||||||
<%= render "account_type", accountable: Investment.new %>
|
<%= render "account_type", accountable: Investment.new %>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<%# locals: (path:, accountable_type:, show_us_link: true, show_eu_link: true, show_lunchflow_link: false) %>
|
<%# locals: (path:, accountable_type:, show_us_link: true, show_eu_link: true, show_lunchflow_link: false) %>
|
||||||
|
|
||||||
<%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
|
<%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
|
||||||
<div class="text-sm">
|
<div class="text-sm"
|
||||||
|
<% if show_lunchflow_link %>
|
||||||
|
data-controller="lunchflow-preload"
|
||||||
|
data-lunchflow-preload-accountable-type-value="<%= h(accountable_type) %>"
|
||||||
|
<% if params[:return_to] %>
|
||||||
|
data-lunchflow-preload-return-to-value="<%= h(params[:return_to]) %>"
|
||||||
|
<% end %>
|
||||||
|
<% end %>>
|
||||||
<%= link_to path, class: "flex items-center gap-4 w-full text-center text-primary focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-surface rounded-lg p-2" do %>
|
<%= link_to path, class: "flex items-center gap-4 w-full text-center text-primary focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-surface rounded-lg p-2" do %>
|
||||||
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
|
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
|
||||||
<%= icon("keyboard") %>
|
<%= icon("keyboard") %>
|
||||||
@@ -39,12 +46,18 @@
|
|||||||
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-primary px-2 hover:bg-surface rounded-lg p-2",
|
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-primary px-2 hover:bg-surface rounded-lg p-2",
|
||||||
data: {
|
data: {
|
||||||
turbo_frame: "modal",
|
turbo_frame: "modal",
|
||||||
turbo_action: "advance"
|
turbo_action: "advance",
|
||||||
|
lunchflow_preload_target: "link"
|
||||||
} do %>
|
} do %>
|
||||||
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
|
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
|
||||||
<%= icon("link-2") %>
|
<%= icon("link-2") %>
|
||||||
</span>
|
</span>
|
||||||
<%= t("accounts.new.method_selector.lunchflow_entry") %>
|
<span class="flex items-center gap-2">
|
||||||
|
<%= t("accounts.new.method_selector.lunchflow_entry") %>
|
||||||
|
<span data-lunchflow-preload-target="spinner" class="hidden">
|
||||||
|
<%= icon("loader-2", class: "animate-spin") %>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,28 @@
|
|||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<% @available_accounts.each do |account| %>
|
<% @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">
|
<% has_blank_name = account[:name].blank? %>
|
||||||
<%= check_box_tag "account_ids[]", account[:id], false, class: "mt-1" %>
|
<label class="flex items-start gap-3 p-3 border <%= has_blank_name ? 'border-error bg-error/5' : 'border-primary' %> rounded-lg <%= has_blank_name ? 'cursor-not-allowed opacity-60' : 'hover:bg-subtle cursor-pointer' %> transition-colors">
|
||||||
|
<%= check_box_tag "account_ids[]", account[:id], false, disabled: has_blank_name, class: "mt-1" %>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium text-sm text-primary">
|
<div class="font-medium text-sm <%= has_blank_name ? 'text-error' : 'text-primary' %>">
|
||||||
<%= account[:name] %>
|
<% if has_blank_name %>
|
||||||
|
<%= t(".no_name_placeholder") %>
|
||||||
|
<% else %>
|
||||||
|
<%= account[:name] %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-secondary mt-1">
|
<div class="text-xs text-secondary mt-1">
|
||||||
|
<% if account[:iban].present? %>
|
||||||
|
<%= account[:iban] %> •
|
||||||
|
<% end %>
|
||||||
<%= account[:institution_name] %> • <%= account[:currency] %> • <%= account[:status] %>
|
<%= account[:institution_name] %> • <%= account[:currency] %> • <%= account[:status] %>
|
||||||
</div>
|
</div>
|
||||||
|
<% if has_blank_name %>
|
||||||
|
<div class="text-xs text-error mt-1">
|
||||||
|
<%= t(".configure_name_in_lunchflow") %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -32,7 +45,7 @@
|
|||||||
<div class="flex gap-2 justify-end pt-4">
|
<div class="flex gap-2 justify-end pt-4">
|
||||||
<%= link_to t(".cancel"), @return_to || new_account_path,
|
<%= link_to t(".cancel"), @return_to || new_account_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",
|
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" } %>
|
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
|
||||||
<%= submit_tag t(".link_accounts"),
|
<%= submit_tag t(".link_accounts"),
|
||||||
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" %>
|
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>
|
</div>
|
||||||
|
|||||||
@@ -15,15 +15,28 @@
|
|||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<% @available_accounts.each do |account| %>
|
<% @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">
|
<% has_blank_name = account[:name].blank? %>
|
||||||
<%= radio_button_tag "lunchflow_account_id", account[:id], false, class: "mt-1" %>
|
<label class="flex items-start gap-3 p-3 border <%= has_blank_name ? 'border-error bg-error/5' : 'border-primary' %> rounded-lg <%= has_blank_name ? 'cursor-not-allowed opacity-60' : 'hover:bg-subtle cursor-pointer' %> transition-colors">
|
||||||
|
<%= radio_button_tag "lunchflow_account_id", account[:id], false, disabled: has_blank_name, class: "mt-1" %>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="font-medium text-sm text-primary">
|
<div class="font-medium text-sm <%= has_blank_name ? 'text-error' : 'text-primary' %>">
|
||||||
<%= account[:name] %>
|
<% if has_blank_name %>
|
||||||
|
<%= t(".no_name_placeholder") %>
|
||||||
|
<% else %>
|
||||||
|
<%= account[:name] %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-secondary mt-1">
|
<div class="text-xs text-secondary mt-1">
|
||||||
|
<% if account[:iban].present? %>
|
||||||
|
<%= account[:iban] %> •
|
||||||
|
<% end %>
|
||||||
<%= account[:institution_name] %> • <%= account[:currency] %> • <%= account[:status] %>
|
<%= account[:institution_name] %> • <%= account[:currency] %> • <%= account[:status] %>
|
||||||
</div>
|
</div>
|
||||||
|
<% if has_blank_name %>
|
||||||
|
<div class="text-xs text-error mt-1">
|
||||||
|
<%= t(".configure_name_in_lunchflow") %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -32,7 +45,7 @@
|
|||||||
<div class="flex gap-2 justify-end pt-4">
|
<div class="flex gap-2 justify-end pt-4">
|
||||||
<%= link_to t(".cancel"), @return_to || accounts_path,
|
<%= 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",
|
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" } %>
|
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
|
||||||
<%= submit_tag t(".link_account"),
|
<%= 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" %>
|
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>
|
</div>
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ en:
|
|||||||
one: "The selected account (%{names}) is already linked"
|
one: "The selected account (%{names}) is already linked"
|
||||||
other: "All %{count} selected accounts are already linked: %{names}"
|
other: "All %{count} selected accounts are already linked: %{names}"
|
||||||
api_error: "API error: %{message}"
|
api_error: "API error: %{message}"
|
||||||
|
invalid_account_names:
|
||||||
|
one: "Cannot link account with blank name"
|
||||||
|
other: "Cannot link %{count} accounts with blank names"
|
||||||
link_failed: Failed to link accounts
|
link_failed: Failed to link accounts
|
||||||
no_accounts_selected: Please select at least one account
|
no_accounts_selected: Please select at least one account
|
||||||
|
partial_invalid: "Successfully linked %{created_count} account(s), %{already_linked_count} were already linked, %{invalid_count} account(s) had invalid names"
|
||||||
partial_success: "Successfully linked %{created_count} account(s). %{already_linked_count} account(s) were already linked: %{already_linked_names}"
|
partial_success: "Successfully linked %{created_count} account(s). %{already_linked_count} account(s) were already linked: %{already_linked_names}"
|
||||||
success:
|
success:
|
||||||
one: "Successfully linked %{count} account"
|
one: "Successfully linked %{count} account"
|
||||||
@@ -34,25 +38,30 @@ en:
|
|||||||
accounts_selected: accounts selected
|
accounts_selected: accounts selected
|
||||||
api_error: "API error: %{message}"
|
api_error: "API error: %{message}"
|
||||||
cancel: Cancel
|
cancel: Cancel
|
||||||
|
configure_name_in_lunchflow: Cannot import - please configure account name in Lunchflow
|
||||||
description: Select the accounts you want to link to your Sure account.
|
description: Select the accounts you want to link to your Sure account.
|
||||||
link_accounts: Link selected accounts
|
link_accounts: Link selected accounts
|
||||||
no_accounts_found: No accounts found. Please check your API key configuration.
|
no_accounts_found: No accounts found. Please check your API key configuration.
|
||||||
no_api_key: Lunch Flow API key is not configured. Please configure it in Settings.
|
no_api_key: Lunch Flow API key is not configured. Please configure it in Settings.
|
||||||
|
no_name_placeholder: "(No name)"
|
||||||
title: Select Lunch Flow Accounts
|
title: Select Lunch Flow Accounts
|
||||||
select_existing_account:
|
select_existing_account:
|
||||||
account_already_linked: This account is already linked to a provider
|
account_already_linked: This account is already linked to a provider
|
||||||
all_accounts_already_linked: All Lunch Flow accounts are already linked
|
all_accounts_already_linked: All Lunch Flow accounts are already linked
|
||||||
api_error: "API error: %{message}"
|
api_error: "API error: %{message}"
|
||||||
cancel: Cancel
|
cancel: Cancel
|
||||||
|
configure_name_in_lunchflow: Cannot import - please configure account name in Lunchflow
|
||||||
description: Select a Lunch Flow account to link with this account. Transactions will be synced and deduplicated automatically.
|
description: Select a Lunch Flow account to link with this account. Transactions will be synced and deduplicated automatically.
|
||||||
link_account: Link account
|
link_account: Link account
|
||||||
no_account_specified: No account specified
|
no_account_specified: No account specified
|
||||||
no_accounts_found: No Lunch Flow accounts found. Please check your API key configuration.
|
no_accounts_found: No Lunch Flow accounts found. Please check your API key configuration.
|
||||||
no_api_key: Lunch Flow API key is not configured. Please configure it in Settings.
|
no_api_key: Lunch Flow API key is not configured. Please configure it in Settings.
|
||||||
|
no_name_placeholder: "(No name)"
|
||||||
title: "Link %{account_name} with Lunch Flow"
|
title: "Link %{account_name} with Lunch Flow"
|
||||||
link_existing_account:
|
link_existing_account:
|
||||||
account_already_linked: This account is already linked to a provider
|
account_already_linked: This account is already linked to a provider
|
||||||
api_error: "API error: %{message}"
|
api_error: "API error: %{message}"
|
||||||
|
invalid_account_name: Cannot link account with blank name
|
||||||
lunchflow_account_already_linked: This Lunch Flow account is already linked to another account
|
lunchflow_account_already_linked: This Lunch Flow account is already linked to another account
|
||||||
lunchflow_account_not_found: Lunch Flow account not found
|
lunchflow_account_not_found: Lunch Flow account not found
|
||||||
missing_parameters: Missing required parameters
|
missing_parameters: Missing required parameters
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
resources :lunchflow_items, only: %i[index new create show edit update destroy] do
|
resources :lunchflow_items, only: %i[index new create show edit update destroy] do
|
||||||
collection do
|
collection do
|
||||||
|
get :preload_accounts
|
||||||
get :select_accounts
|
get :select_accounts
|
||||||
post :link_accounts
|
post :link_accounts
|
||||||
get :select_existing_account
|
get :select_existing_account
|
||||||
|
|||||||
Reference in New Issue
Block a user