Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
37 changes: 37 additions & 0 deletions 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
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");
}
}
}
16 changes: 8 additions & 8 deletions app/models/provider/lunchflow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ def get_accounts

handle_response(response)
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)
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)
end

Expand Down Expand Up @@ -52,10 +52,10 @@ def get_account_transactions(account_id, start_date: nil, end_date: nil)

handle_response(response)
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)
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)
end

Expand All @@ -71,10 +71,10 @@ def get_account_balance(account_id)

handle_response(response)
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)
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)
end

Expand All @@ -93,7 +93,7 @@ def handle_response(response)
when 200
JSON.parse(response.body, symbolize_names: true)
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)
Comment on lines +96 to 97
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent naming: one instance of "Lunchflow API" remains.

Line 96 was updated to "Lunch Flow API", but line 97 still contains "Lunchflow API" in the error message. This creates inconsistency with the rest of the branding updates in this PR.

Apply this diff to complete the branding update:

-        raise LunchflowError.new("Bad request to Lunchflow API: #{response.body}", :bad_request)
+        raise LunchflowError.new("Bad request to Lunch Flow API: #{response.body}", :bad_request)
🤖 Prompt for AI Agents
In app/models/provider/lunchflow.rb around lines 96 to 97, the log message was
updated to "Lunch Flow API" but the raised error message still uses "Lunchflow
API"; update the raise to use the same branding ("Lunch Flow API") so both lines
are consistent, i.e., change the error message string passed to LunchflowError
to read "Bad request to Lunch Flow API: #{response.body}".

when 401
raise LunchflowError.new("Invalid API key", :unauthorized)
Expand All @@ -104,7 +104,7 @@ def handle_response(response)
when 429
raise LunchflowError.new("Rate limit exceeded. Please try again later.", :rate_limited)
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)
end
end
Expand Down
18 changes: 9 additions & 9 deletions app/models/provider/lunchflow_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ class Provider::LunchflowAdapter < Provider::Base
# Register this adapter with the factory
Provider::Factory.register("LunchflowAccount", self)

# Configuration for Lunchflow
# Configuration for Lunch Flow
configure do
description <<~DESC
Setup instructions:
1. Visit [Lunchflow](https://www.lunchflow.app) to get your API key
2. Enter your API key below to enable Lunchflow bank data sync
1. Visit [Lunch Flow](https://www.lunchflow.app) to get your API key
2. Enter your API key below to enable Lunch Flow bank data sync
3. Choose the appropriate environment (production or staging)
DESC

Expand All @@ -20,21 +20,21 @@ class Provider::LunchflowAdapter < Provider::Base
required: true,
secret: true,
env_key: "LUNCHFLOW_API_KEY",
description: "Your Lunchflow API key for authentication"
description: "Your Lunch Flow API key for authentication"

field :base_url,
label: "Base URL",
required: false,
env_key: "LUNCHFLOW_BASE_URL",
default: "https://lunchflow.app/api/v1",
description: "Base URL for Lunchflow API"
description: "Base URL for Lunch Flow API"
end

def provider_name
"lunchflow"
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
def self.build_provider
api_key = config_value(:api_key)
Expand All @@ -46,7 +46,7 @@ def self.build_provider

# Reload Lunchflow configuration when settings are updated
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)
# This method exists to be called by the settings controller after updates
# No action needed here since values are fetched on-demand
Expand All @@ -65,7 +65,7 @@ def can_delete_holdings?
end

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
return nil unless metadata.present?

Expand All @@ -77,7 +77,7 @@ def institution_domain
begin
domain = URI.parse(url).host&.gsub(/^www\./, "")
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

Expand Down
5 changes: 4 additions & 1 deletion app/views/accounts/new.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<%= 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" %>
<%= render "account_type", accountable: Depository.new %>
<%= render "account_type", accountable: Investment.new %>
Expand Down
19 changes: 16 additions & 3 deletions app/views/accounts/new/_method_selector.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
<%# 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 %>
<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 %>
<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") %>
Expand Down Expand Up @@ -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",
data: {
turbo_frame: "modal",
turbo_action: "advance"
turbo_action: "advance",
lunchflow_preload_target: "link"
} 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)]">
<%= icon("link-2") %>
</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 %>

Expand Down
2 changes: 1 addition & 1 deletion app/views/lunchflow_items/select_accounts.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<div class="flex gap-2 justify-end pt-4">
<%= 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",
data: { turbo_frame: "_top" } %>
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
<%= 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" %>
</div>
Expand Down
2 changes: 1 addition & 1 deletion app/views/lunchflow_items/select_existing_account.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<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" } %>
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
<%= 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>
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@

resources :lunchflow_items, only: %i[index new create show edit update destroy] do
collection do
get :preload_accounts
get :select_accounts
post :link_accounts
get :select_existing_account
Expand Down