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:
soky srm
2025-11-10 21:32:55 +01:00
committed by GitHub
parent eae532714b
commit c6771ebaab
15 changed files with 271 additions and 73 deletions

View File

@@ -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..."

View File

@@ -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

View File

@@ -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

View 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");
}
}
}

View File

@@ -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,

View File

@@ -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!

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 %>

View File

@@ -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 %>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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