diff --git a/app/components/DS/buttonish.rb b/app/components/DS/buttonish.rb index b83557b17..7eeb5ee66 100644 --- a/app/components/DS/buttonish.rb +++ b/app/components/DS/buttonish.rb @@ -5,7 +5,7 @@ class DS::Buttonish < DesignSystemComponent icon_classes: "fg-inverse" }, secondary: { - container_classes: "text-primary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600", + container_classes: "text-primary bg-gray-200 theme-dark:bg-gray-700 hover:bg-gray-300 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600", icon_classes: "fg-primary" }, destructive: { diff --git a/app/controllers/lunchflow_items_controller.rb b/app/controllers/lunchflow_items_controller.rb index 98c6e0e0e..c16cd20ef 100644 --- a/app/controllers/lunchflow_items_controller.rb +++ b/app/controllers/lunchflow_items_controller.rb @@ -1,5 +1,5 @@ class LunchflowItemsController < ApplicationController - before_action :set_lunchflow_item, only: [ :show, :edit, :update, :destroy, :sync ] + before_action :set_lunchflow_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ] def index @lunchflow_items = Current.family.lunchflow_items.active.ordered @@ -475,6 +475,12 @@ class LunchflowItemsController < ApplicationController end def destroy + # Ensure we detach provider links before scheduling deletion + begin + @lunchflow_item.unlink_all!(dry_run: false) + rescue => e + Rails.logger.warn("LunchFlow unlink during destroy failed: #{e.class} - #{e.message}") + end @lunchflow_item.destroy_later redirect_to accounts_path, notice: t(".success") end @@ -490,7 +496,239 @@ class LunchflowItemsController < ApplicationController end end + # Show unlinked Lunchflow accounts for setup (similar to SimpleFIN setup_accounts) + def setup_accounts + # First, ensure we have the latest accounts from the API + @api_error = fetch_lunchflow_accounts_from_api + + # Get Lunchflow accounts that are not linked (no AccountProvider) + @lunchflow_accounts = @lunchflow_item.lunchflow_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + + # Get supported account types from the adapter + supported_types = Provider::LunchflowAdapter.supported_account_types + + # Map of account type keys to their internal values + account_type_keys = { + "depository" => "Depository", + "credit_card" => "CreditCard", + "investment" => "Investment", + "loan" => "Loan", + "other_asset" => "OtherAsset" + } + + # Build account type options using i18n, filtering to supported types + all_account_type_options = account_type_keys.filter_map do |key, type| + next unless supported_types.include?(type) + [ t(".account_types.#{key}"), type ] + end + + # Add "Skip" option at the beginning + @account_type_options = [ [ t(".account_types.skip"), "skip" ] ] + all_account_type_options + + # Helper to translate subtype options + translate_subtypes = ->(type_key, subtypes_hash) { + subtypes_hash.keys.map { |k| [ t(".subtypes.#{type_key}.#{k}"), k ] } + } + + # Subtype options for each account type (only include supported types) + all_subtype_options = { + "Depository" => { + label: t(".subtype_labels.depository"), + options: translate_subtypes.call("depository", Depository::SUBTYPES) + }, + "CreditCard" => { + label: t(".subtype_labels.credit_card"), + options: [], + message: t(".subtype_messages.credit_card") + }, + "Investment" => { + label: t(".subtype_labels.investment"), + options: translate_subtypes.call("investment", Investment::SUBTYPES) + }, + "Loan" => { + label: t(".subtype_labels.loan"), + options: translate_subtypes.call("loan", Loan::SUBTYPES) + }, + "OtherAsset" => { + label: t(".subtype_labels.other_asset").presence, + options: [], + message: t(".subtype_messages.other_asset") + } + } + + @subtype_options = all_subtype_options.slice(*supported_types) + end + + def complete_account_setup + account_types = params[:account_types] || {} + account_subtypes = params[:account_subtypes] || {} + + # Valid account types for this provider + valid_types = Provider::LunchflowAdapter.supported_account_types + + created_accounts = [] + skipped_count = 0 + + begin + ActiveRecord::Base.transaction do + account_types.each do |lunchflow_account_id, selected_type| + # Skip accounts marked as "skip" + if selected_type == "skip" || selected_type.blank? + skipped_count += 1 + next + end + + # Validate account type is supported + unless valid_types.include?(selected_type) + Rails.logger.warn("Invalid account type '#{selected_type}' submitted for LunchFlow account #{lunchflow_account_id}") + next + end + + # Find account - scoped to this item to prevent cross-item manipulation + lunchflow_account = @lunchflow_item.lunchflow_accounts.find_by(id: lunchflow_account_id) + unless lunchflow_account + Rails.logger.warn("LunchFlow account #{lunchflow_account_id} not found for item #{@lunchflow_item.id}") + next + end + + # Skip if already linked (race condition protection) + if lunchflow_account.account_provider.present? + Rails.logger.info("LunchFlow account #{lunchflow_account_id} already linked, skipping") + next + end + + selected_subtype = account_subtypes[lunchflow_account_id] + + # Default subtype for CreditCard since it only has one option + selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank? + + # Create account with user-selected type and subtype (raises on failure) + account = Account.create_and_sync( + family: Current.family, + name: lunchflow_account.name, + balance: lunchflow_account.current_balance || 0, + currency: lunchflow_account.currency || "USD", + accountable_type: selected_type, + accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {} + ) + + # Link account to lunchflow_account via account_providers join table (raises on failure) + AccountProvider.create!( + account: account, + provider: lunchflow_account + ) + + created_accounts << account + end + end + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + Rails.logger.error("LunchFlow account setup failed: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + flash[:alert] = t(".creation_failed", error: e.message) + redirect_to accounts_path, status: :see_other + return + rescue StandardError => e + Rails.logger.error("LunchFlow account setup failed unexpectedly: #{e.class} - #{e.message}") + Rails.logger.error(e.backtrace.first(10).join("\n")) + flash[:alert] = t(".creation_failed", error: "An unexpected error occurred") + redirect_to accounts_path, status: :see_other + return + end + + # Trigger a sync to process transactions + @lunchflow_item.sync_later if created_accounts.any? + + # Set appropriate flash message + if created_accounts.any? + flash[:notice] = t(".success", count: created_accounts.count) + elsif skipped_count > 0 + flash[:notice] = t(".all_skipped") + else + flash[:notice] = t(".no_accounts") + end + + if turbo_frame_request? + # Recompute data needed by Accounts#index partials + @manual_accounts = Account.uncached { + Current.family.accounts + .visible_manual + .order(:name) + .to_a + } + @lunchflow_items = Current.family.lunchflow_items.ordered + + manual_accounts_stream = if @manual_accounts.any? + turbo_stream.update( + "manual-accounts", + partial: "accounts/index/manual_accounts", + locals: { accounts: @manual_accounts } + ) + else + turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts")) + end + + render turbo_stream: [ + manual_accounts_stream, + turbo_stream.replace( + ActionView::RecordIdentifier.dom_id(@lunchflow_item), + partial: "lunchflow_items/lunchflow_item", + locals: { lunchflow_item: @lunchflow_item } + ) + ] + Array(flash_notification_stream_items) + else + redirect_to accounts_path, status: :see_other + end + end + private + + # Fetch Lunchflow accounts from the API and store them locally + # Returns nil on success, or an error message string on failure + def fetch_lunchflow_accounts_from_api + # Skip if we already have accounts cached + return nil unless @lunchflow_item.lunchflow_accounts.empty? + + # Validate API key is configured + unless @lunchflow_item.credentials_configured? + return t("lunchflow_items.setup_accounts.no_api_key") + end + + # Use the specific lunchflow_item's provider (scoped to this family's item) + lunchflow_provider = @lunchflow_item.lunchflow_provider + unless lunchflow_provider.present? + return t("lunchflow_items.setup_accounts.no_api_key") + end + + begin + accounts_data = lunchflow_provider.get_accounts + available_accounts = accounts_data[:accounts] || [] + + if available_accounts.empty? + Rails.logger.info("LunchFlow API returned no accounts for item #{@lunchflow_item.id}") + return nil + end + + available_accounts.each do |account_data| + next if account_data[:name].blank? + + lunchflow_account = @lunchflow_item.lunchflow_accounts.find_or_initialize_by( + account_id: account_data[:id].to_s + ) + lunchflow_account.upsert_lunchflow_snapshot!(account_data) + lunchflow_account.save! + end + + nil # Success + rescue Provider::Lunchflow::LunchflowError => e + Rails.logger.error("LunchFlow API error: #{e.message}") + t("lunchflow_items.setup_accounts.api_error", message: e.message) + rescue StandardError => e + Rails.logger.error("Unexpected error fetching LunchFlow accounts: #{e.class}: #{e.message}") + t("lunchflow_items.setup_accounts.api_error", message: e.message) + end + end def set_lunchflow_item @lunchflow_item = Current.family.lunchflow_items.find(params[:id]) end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 19b9116c2..0ba123eef 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -126,8 +126,8 @@ class Settings::ProvidersController < ApplicationController config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? end - # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist - @simplefin_items = Current.family.simplefin_items.ordered.select(:id) - @lunchflow_items = Current.family.lunchflow_items.ordered.select(:id) + # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials + @simplefin_items = Current.family.simplefin_items.where.not(access_url: [ nil, "" ]).ordered.select(:id) + @lunchflow_items = Current.family.lunchflow_items.where.not(api_key: [ nil, "" ]).ordered.select(:id) end end diff --git a/app/controllers/simplefin_items_controller.rb b/app/controllers/simplefin_items_controller.rb index 4d78b1c5a..dd5c5eea4 100644 --- a/app/controllers/simplefin_items_controller.rb +++ b/app/controllers/simplefin_items_controller.rb @@ -158,6 +158,7 @@ class SimplefinItemsController < ApplicationController def setup_accounts @simplefin_accounts = @simplefin_item.simplefin_accounts.includes(:account).where(accounts: { id: nil }) @account_type_options = [ + [ "Skip this account", "skip" ], [ "Checking or Savings Account", "Depository" ], [ "Credit Card", "CreditCard" ], [ "Investment Account", "Investment" ], @@ -223,8 +224,38 @@ class SimplefinItemsController < ApplicationController @simplefin_item.update!(sync_start_date: params[:sync_start_date]) end + # Valid account types for this provider (plus OtherAsset which SimpleFIN UI allows) + valid_types = Provider::SimplefinAdapter.supported_account_types + [ "OtherAsset" ] + + created_accounts = [] + skipped_count = 0 + account_types.each do |simplefin_account_id, selected_type| - simplefin_account = @simplefin_item.simplefin_accounts.find(simplefin_account_id) + # Skip accounts marked as "skip" + if selected_type == "skip" || selected_type.blank? + skipped_count += 1 + next + end + + # Validate account type is supported + unless valid_types.include?(selected_type) + Rails.logger.warn("Invalid account type '#{selected_type}' submitted for SimpleFIN account #{simplefin_account_id}") + next + end + + # Find account - scoped to this item to prevent cross-item manipulation + simplefin_account = @simplefin_item.simplefin_accounts.find_by(id: simplefin_account_id) + unless simplefin_account + Rails.logger.warn("SimpleFIN account #{simplefin_account_id} not found for item #{@simplefin_item.id}") + next + end + + # Skip if already linked (race condition protection) + if simplefin_account.account.present? + Rails.logger.info("SimpleFIN account #{simplefin_account_id} already linked, skipping") + next + end + selected_subtype = account_subtypes[simplefin_account_id] # Default subtype for CreditCard since it only has one option @@ -237,15 +268,23 @@ class SimplefinItemsController < ApplicationController selected_subtype ) simplefin_account.update!(account: account) + created_accounts << account end # Clear pending status and mark as complete @simplefin_item.update!(pending_account_setup: false) # Trigger a sync to process the imported SimpleFin data (transactions and holdings) - @simplefin_item.sync_later + @simplefin_item.sync_later if created_accounts.any? - flash[:notice] = t(".success") + # Set appropriate flash message + if created_accounts.any? + flash[:notice] = t(".success", count: created_accounts.count) + elsif skipped_count > 0 + flash[:notice] = t(".all_skipped") + else + flash[:notice] = t(".no_accounts") + end if turbo_frame_request? # Recompute data needed by Accounts#index partials @manual_accounts = Account.uncached { @@ -276,7 +315,7 @@ class SimplefinItemsController < ApplicationController ) ] + Array(flash_notification_stream_items) else - redirect_to accounts_path, notice: t(".success"), status: :see_other + redirect_to accounts_path, status: :see_other end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index b3c3a7505..cdce24be4 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -44,9 +44,9 @@ module SettingsHelper } end - def settings_section(title:, subtitle: nil, &block) + def settings_section(title:, subtitle: nil, collapsible: false, open: true, &block) content = capture(&block) - render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content } + render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open } end def settings_nav_footer diff --git a/app/models/lunchflow_account/processor.rb b/app/models/lunchflow_account/processor.rb index 793426474..4431080b7 100644 --- a/app/models/lunchflow_account/processor.rb +++ b/app/models/lunchflow_account/processor.rb @@ -39,10 +39,15 @@ class LunchflowAccount::Processor account = lunchflow_account.current_account balance = lunchflow_account.current_balance || 0 - # For liability accounts (credit cards and loans), ensure positive balances - # LunchFlow may return negative values for liabilities, but Sure expects positive + # LunchFlow balance convention matches our app convention: + # - Positive balance = debt (you owe money) + # - Negative balance = credit balance (bank owes you, e.g., overpayment) + # No sign conversion needed - pass through as-is (same as Plaid) + # + # Exception: CreditCard and Loan accounts return inverted signs + # Provider returns negative for positive balance, so we negate it if account.accountable_type == "CreditCard" || account.accountable_type == "Loan" - balance = balance.abs + balance = -balance end # Normalize currency with fallback chain: parsed lunchflow currency -> existing account currency -> USD diff --git a/app/models/lunchflow_item.rb b/app/models/lunchflow_item.rb index 584605473..a2af0fa25 100644 --- a/app/models/lunchflow_item.rb +++ b/app/models/lunchflow_item.rb @@ -1,5 +1,5 @@ class LunchflowItem < ApplicationRecord - include Syncable, Provided + include Syncable, Provided, Unlinking enum :status, { good: "good", requires_update: "requires_update" }, default: :good @@ -104,39 +104,32 @@ class LunchflowItem < ApplicationRecord end def sync_status_summary - latest = latest_sync - return nil unless latest + # Use centralized count helper methods for consistency + total_accounts = total_accounts_count + linked_count = linked_accounts_count + unlinked_count = unlinked_accounts_count - # If sync has statistics, use them - if latest.sync_stats.present? - stats = latest.sync_stats - total = stats["total_accounts"] || 0 - linked = stats["linked_accounts"] || 0 - unlinked = stats["unlinked_accounts"] || 0 - - if total == 0 - "No accounts found" - elsif unlinked == 0 - "#{linked} #{'account'.pluralize(linked)} synced" - else - "#{linked} synced, #{unlinked} need setup" - end + if total_accounts == 0 + "No accounts found" + elsif unlinked_count == 0 + "#{linked_count} #{'account'.pluralize(linked_count)} synced" else - # Fallback to current account counts - total_accounts = lunchflow_accounts.count - linked_count = accounts.count - unlinked_count = total_accounts - linked_count - - if total_accounts == 0 - "No accounts found" - elsif unlinked_count == 0 - "#{linked_count} #{'account'.pluralize(linked_count)} synced" - else - "#{linked_count} synced, #{unlinked_count} need setup" - end + "#{linked_count} synced, #{unlinked_count} need setup" end end + def linked_accounts_count + lunchflow_accounts.joins(:account_provider).count + end + + def unlinked_accounts_count + lunchflow_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count + end + + def total_accounts_count + lunchflow_accounts.count + end + def institution_display_name # Try to get institution name from stored metadata institution_name.presence || institution_domain.presence || name diff --git a/app/models/lunchflow_item/unlinking.rb b/app/models/lunchflow_item/unlinking.rb new file mode 100644 index 000000000..60deae774 --- /dev/null +++ b/app/models/lunchflow_item/unlinking.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module LunchflowItem::Unlinking + # Concern that encapsulates unlinking logic for a Lunchflow item. + # Mirrors the SimplefinItem::Unlinking behavior. + extend ActiveSupport::Concern + + # Idempotently remove all connections between this Lunchflow item and local accounts. + # - Detaches any AccountProvider links for each LunchflowAccount + # - Detaches Holdings that point at the AccountProvider links + # Returns a per-account result payload for observability + def unlink_all!(dry_run: false) + results = [] + + lunchflow_accounts.find_each do |lfa| + links = AccountProvider.where(provider_type: "LunchflowAccount", provider_id: lfa.id).to_a + link_ids = links.map(&:id) + result = { + lfa_id: lfa.id, + name: lfa.name, + provider_link_ids: link_ids + } + results << result + + next if dry_run + + begin + ActiveRecord::Base.transaction do + # Detach holdings for any provider links found + if link_ids.any? + Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) + end + + # Destroy all provider links + links.each do |ap| + ap.destroy! + end + end + rescue => e + Rails.logger.warn( + "LunchflowItem Unlinker: failed to fully unlink LFA ##{lfa.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}" + ) + # Record error for observability; continue with other accounts + result[:error] = e.message + end + end + + results + end +end diff --git a/app/models/provider/configurable.rb b/app/models/provider/configurable.rb index 104ed107a..eaf0fb2a2 100644 --- a/app/models/provider/configurable.rb +++ b/app/models/provider/configurable.rb @@ -113,6 +113,7 @@ module Provider::Configurable @provider_key = provider_key @fields = [] @provider_description = nil + @configured_check = nil end # Set the provider-level description (markdown supported) @@ -121,6 +122,14 @@ module Provider::Configurable @provider_description = text end + # Define a custom check for whether this provider is configured + # @param block [Proc] A block that returns true if the provider is configured + # Example: + # configured_check { get_value(:client_id).present? && get_value(:secret).present? } + def configured_check(&block) + @configured_check = block + end + # Define a configuration field # @param name [Symbol] The field name # @param label [String] Human-readable label @@ -150,9 +159,21 @@ module Provider::Configurable field.value end - # Check if all required fields are present + # Check if provider is properly configured + # Uses custom configured_check if defined, otherwise checks required fields def configured? - fields.select(&:required).all? { |f| f.value.present? } + if @configured_check + instance_eval(&@configured_check) + else + required_fields = fields.select(&:required) + if required_fields.any? + required_fields.all? { |f| f.value.present? } + else + # If no required fields, provider is not considered configured + # unless it defines a custom configured_check + false + end + end end # Get all field values as a hash diff --git a/app/models/provider/plaid_adapter.rb b/app/models/provider/plaid_adapter.rb index d42e2ef93..ac4fd8187 100644 --- a/app/models/provider/plaid_adapter.rb +++ b/app/models/provider/plaid_adapter.rb @@ -106,6 +106,9 @@ class Provider::PlaidAdapter < Provider::Base env_key: "PLAID_ENV", default: "sandbox", description: "Plaid environment: sandbox, development, or production" + + # Plaid requires both client_id and secret to be configured + configured_check { get_value(:client_id).present? && get_value(:secret).present? } end def provider_name diff --git a/app/models/provider/plaid_eu_adapter.rb b/app/models/provider/plaid_eu_adapter.rb index 9d2273721..497bb13c4 100644 --- a/app/models/provider/plaid_eu_adapter.rb +++ b/app/models/provider/plaid_eu_adapter.rb @@ -45,6 +45,9 @@ class Provider::PlaidEuAdapter env_key: "PLAID_EU_ENV", default: "sandbox", description: "Plaid environment: sandbox, development, or production" + + # Plaid EU requires both client_id and secret to be configured + configured_check { get_value(:client_id).present? && get_value(:secret).present? } end # Thread-safe lazy loading of Plaid EU configuration diff --git a/app/models/simplefin_account/processor.rb b/app/models/simplefin_account/processor.rb index 8429b17af..c59020f8d 100644 --- a/app/models/simplefin_account/processor.rb +++ b/app/models/simplefin_account/processor.rb @@ -41,11 +41,10 @@ class SimplefinAccount::Processor account = simplefin_account.current_account balance = simplefin_account.current_balance || simplefin_account.available_balance || 0 - # SimpleFin returns negative balances for credit cards (liabilities) - # But Maybe expects positive balances for liabilities - if account.accountable_type == "CreditCard" || account.accountable_type == "Loan" - balance = balance.abs - end + # SimpleFIN balance convention matches our app convention: + # - Positive balance = debt (you owe money) + # - Negative balance = credit balance (bank owes you, e.g., overpayment) + # No sign conversion needed - pass through as-is (same as Plaid) # Calculate cash balance correctly for investment accounts cash_balance = if account.accountable_type == "Investment" diff --git a/app/models/sync.rb b/app/models/sync.rb index 3e2abfe6e..d1ba07a26 100644 --- a/app/models/sync.rb +++ b/app/models/sync.rb @@ -67,6 +67,24 @@ class Sync < ApplicationRecord return end + # Guard: syncable may have been deleted while job was queued + unless syncable.present? + Rails.logger.warn("Sync #{id} - syncable #{syncable_type}##{syncable_id} no longer exists. Marking as failed.") + start! if may_start? + fail! + update(error: "Syncable record was deleted") + return + end + + # Guard: syncable may be scheduled for deletion + if syncable.respond_to?(:scheduled_for_deletion?) && syncable.scheduled_for_deletion? + Rails.logger.warn("Sync #{id} - syncable #{syncable_type}##{syncable_id} is scheduled for deletion. Skipping sync.") + start! if may_start? + fail! + update(error: "Syncable record is scheduled for deletion") + return + end + start! begin diff --git a/app/views/accounts/index/_manual_accounts.html.erb b/app/views/accounts/index/_manual_accounts.html.erb index 0dd8f66dd..97c08ac07 100644 --- a/app/views/accounts/index/_manual_accounts.html.erb +++ b/app/views/accounts/index/_manual_accounts.html.erb @@ -1,7 +1,7 @@ <%# locals: (accounts:) %>
- + <%= icon("chevron-right", class: "group-open:transform group-open:rotate-90") %>
diff --git a/app/views/lunchflow_items/_lunchflow_item.html.erb b/app/views/lunchflow_items/_lunchflow_item.html.erb index be34da70b..fefb2f910 100644 --- a/app/views/lunchflow_items/_lunchflow_item.html.erb +++ b/app/views/lunchflow_items/_lunchflow_item.html.erb @@ -2,7 +2,7 @@ <%= tag.div id: dom_id(lunchflow_item) do %>
- +
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> @@ -23,6 +23,11 @@

<%= t(".deletion_in_progress") %>

<% end %>
+ <% if lunchflow_item.accounts.any? %> +

+ <%= lunchflow_item.institution_summary %> +

+ <% end %> <% if lunchflow_item.syncing? %>
<%= icon "loader", size: "sm", class: "animate-spin" %> @@ -35,7 +40,15 @@
<% else %>

- <%= lunchflow_item.last_synced_at ? t(".status", timestamp: time_ago_in_words(lunchflow_item.last_synced_at)) : t(".status_never") %> + <% if lunchflow_item.last_synced_at %> + <% if lunchflow_item.sync_status_summary %> + <%= t(".status_with_summary", timestamp: time_ago_in_words(lunchflow_item.last_synced_at), summary: lunchflow_item.sync_status_summary) %> + <% else %> + <%= t(".status", timestamp: time_ago_in_words(lunchflow_item.last_synced_at)) %> + <% end %> + <% else %> + <%= t(".status_never") %> + <% end %>

<% end %>
@@ -67,10 +80,36 @@
<% if lunchflow_item.accounts.any? %> <%= render "accounts/index/account_groups", accounts: lunchflow_item.accounts %> - <% else %> + <% end %> + + <%# Use model methods for consistent counts %> + <% unlinked_count = lunchflow_item.unlinked_accounts_count %> + <% linked_count = lunchflow_item.linked_accounts_count %> + <% total_count = lunchflow_item.total_accounts_count %> + + <% if unlinked_count > 0 %> +
+

<%= t(".setup_needed") %>

+

<%= t(".setup_description", linked: linked_count, total: total_count) %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_lunchflow_item_path(lunchflow_item), + frame: :modal + ) %> +
+ <% elsif lunchflow_item.accounts.empty? && total_count == 0 %>

<%= t(".no_accounts_title") %>

<%= t(".no_accounts_description") %>

+ <%= render DS::Link.new( + text: t(".setup_action"), + icon: "settings", + variant: "primary", + href: setup_accounts_lunchflow_item_path(lunchflow_item), + frame: :modal + ) %>
<% end %>
diff --git a/app/views/lunchflow_items/_subtype_select.html.erb b/app/views/lunchflow_items/_subtype_select.html.erb new file mode 100644 index 000000000..5d99dbcb8 --- /dev/null +++ b/app/views/lunchflow_items/_subtype_select.html.erb @@ -0,0 +1,23 @@ + diff --git a/app/views/lunchflow_items/setup_accounts.html.erb b/app/views/lunchflow_items/setup_accounts.html.erb new file mode 100644 index 000000000..63886fbe2 --- /dev/null +++ b/app/views/lunchflow_items/setup_accounts.html.erb @@ -0,0 +1,111 @@ +<% content_for :title, "Set Up Lunch Flow Accounts" %> + +<%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) do %> +
+ <%= icon "building-2", class: "text-primary" %> + <%= t(".subtitle") %> +
+ <% end %> + + <% dialog.with_body do %> + <%= form_with url: complete_account_setup_lunchflow_item_path(@lunchflow_item), + method: :post, + local: true, + data: { + controller: "loading-button", + action: "submit->loading-button#showLoading", + loading_button_loading_text_value: t(".creating_accounts"), + turbo_frame: "_top" + }, + class: "space-y-6" do |form| %> + +
+ <% if @api_error.present? %> +
+ <%= icon "alert-circle", size: "lg", class: "text-destructive" %> +

<%= t(".fetch_failed") %>

+

<%= @api_error %>

+
+ <% elsif @lunchflow_accounts.empty? %> +
+ <%= icon "check-circle", size: "lg", class: "text-success" %> +

<%= t(".no_accounts_to_setup") %>

+

<%= t(".all_accounts_linked") %>

+
+ <% else %> +
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %> +
+

+ <%= t(".choose_account_type") %> +

+
    + <% @account_type_options.reject { |_, type| type == "skip" }.each do |label, type| %> +
  • <%= label %>
  • + <% end %> +
+
+
+
+ + <% @lunchflow_accounts.each do |lunchflow_account| %> +
+
+
+

+ <%= lunchflow_account.name %> + <% if lunchflow_account.institution_metadata.present? && lunchflow_account.institution_metadata['name'].present? %> + • <%= lunchflow_account.institution_metadata["name"] %> + <% end %> +

+

+ <%= t(".balance") %>: <%= number_to_currency(lunchflow_account.current_balance || 0, unit: lunchflow_account.currency) %> +

+
+
+ +
+
+ <%= label_tag "account_types[#{lunchflow_account.id}]", t(".account_type_label"), + class: "block text-sm font-medium text-primary mb-2" %> + <%= select_tag "account_types[#{lunchflow_account.id}]", + options_for_select(@account_type_options, "skip"), + { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full", + data: { + action: "change->account-type-selector#updateSubtype" + } } %> +
+ + +
+ <% @subtype_options.each do |account_type, subtype_config| %> + <%= render "lunchflow_items/subtype_select", account_type: account_type, subtype_config: subtype_config, lunchflow_account: lunchflow_account %> + <% end %> +
+
+
+ <% end %> + <% end %> +
+ +
+ <%= render DS::Button.new( + text: t(".create_accounts"), + variant: "primary", + icon: "plus", + type: "submit", + class: "flex-1", + disabled: @api_error.present? || @lunchflow_accounts.empty?, + data: { loading_button_target: "button" } + ) %> + <%= render DS::Link.new( + text: t(".cancel"), + variant: "secondary", + href: accounts_path + ) %> +
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb index 9a7397a77..738f7facf 100644 --- a/app/views/plaid_items/_plaid_item.html.erb +++ b/app/views/plaid_items/_plaid_item.html.erb @@ -2,7 +2,7 @@ <%= tag.div id: dom_id(plaid_item) do %>
- +
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> diff --git a/app/views/settings/_section.html.erb b/app/views/settings/_section.html.erb index 5a8c9633a..0c9a4f586 100644 --- a/app/views/settings/_section.html.erb +++ b/app/views/settings/_section.html.erb @@ -1,12 +1,31 @@ -<%# locals: (title:, subtitle: nil, content:) %> -
-
-

<%= title %>

- <% if subtitle.present? %> -

<%= subtitle %>

- <% end %> -
-
- <%= content %> -
-
+<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true) %> +<% if collapsible %> +
class="group bg-container shadow-border-xs rounded-xl p-4"> + +
+ <%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %> +
+

<%= title %>

+ <% if subtitle.present? %> +

<%= subtitle %>

+ <% end %> +
+
+
+
+ <%= content %> +
+
+<% else %> +
+
+

<%= title %>

+ <% if subtitle.present? %> +

<%= subtitle %>

+ <% end %> +
+
+ <%= content %> +
+
+<% end %> diff --git a/app/views/settings/providers/_lunchflow_panel.html.erb b/app/views/settings/providers/_lunchflow_panel.html.erb index c9380ba24..eb7199668 100644 --- a/app/views/settings/providers/_lunchflow_panel.html.erb +++ b/app/views/settings/providers/_lunchflow_panel.html.erb @@ -52,11 +52,14 @@
<% end %> - <% items = local_assigns[:lunchflow_items] || @lunchflow_items || Current.family.lunchflow_items.where.not(api_key: nil) %> - <% if items&.any? %> -
+ <% items = local_assigns[:lunchflow_items] || @lunchflow_items || Current.family.lunchflow_items.where.not(api_key: [nil, ""]) %> +
+ <% if items&.any? %>

Configured and ready to use. Visit the Accounts tab to manage and set up accounts.

-
- <% end %> + <% else %> +
+

Not configured

+ <% end %> +
diff --git a/app/views/settings/providers/_provider_form.html.erb b/app/views/settings/providers/_provider_form.html.erb index 1ffa40282..cb2384a9f 100644 --- a/app/views/settings/providers/_provider_form.html.erb +++ b/app/views/settings/providers/_provider_form.html.erb @@ -74,10 +74,13 @@ <% end %> <%# Show configuration status %> - <% if configuration.configured? %> -
+
+ <% if configuration.configured? %>

Configured and ready to use

-
- <% end %> + <% else %> +
+

Not configured

+ <% end %> +
diff --git a/app/views/settings/providers/_simplefin_panel.html.erb b/app/views/settings/providers/_simplefin_panel.html.erb index 089b9a249..db36085d8 100644 --- a/app/views/settings/providers/_simplefin_panel.html.erb +++ b/app/views/settings/providers/_simplefin_panel.html.erb @@ -36,12 +36,13 @@ <% end %> - <% if @simplefin_items&.any? %> -
+
+ <% if @simplefin_items&.any? %>

Configured and ready to use. Visit the Accounts tab to manage and set up accounts.

-
- <% else %> -
No SimpleFIN connections yet.
- <% end %> + <% else %> +
+

Not configured

+ <% end %> +
diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index c04bed239..ccd19cb6b 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -1,6 +1,6 @@ <%= content_for :page_title, "Bank Sync Providers" %> -
+

Configure credentials for third-party bank sync providers. Settings configured here will override environment variables. @@ -9,18 +9,18 @@ <% @provider_configurations.each do |config| %> <% next if config.provider_key.to_s.casecmp("simplefin").zero? %> - <%= settings_section title: config.provider_key.titleize do %> + <%= settings_section title: config.provider_key.titleize, collapsible: true, open: false do %> <%= render "settings/providers/provider_form", configuration: config %> <% end %> <% end %> - <%= settings_section title: "Lunch Flow" do %> + <%= settings_section title: "Lunch Flow", collapsible: true, open: false do %> <%= render "settings/providers/lunchflow_panel" %> <% end %> - <%= settings_section title: "SimpleFIN" do %> + <%= settings_section title: "SimpleFIN", collapsible: true, open: false do %> <%= render "settings/providers/simplefin_panel" %> diff --git a/app/views/simplefin_items/_simplefin_item.html.erb b/app/views/simplefin_items/_simplefin_item.html.erb index 0a20f8950..f8c916f61 100644 --- a/app/views/simplefin_items/_simplefin_item.html.erb +++ b/app/views/simplefin_items/_simplefin_item.html.erb @@ -2,7 +2,7 @@ <%= tag.div id: dom_id(simplefin_item) do %>

- +
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> diff --git a/app/views/simplefin_items/setup_accounts.html.erb b/app/views/simplefin_items/setup_accounts.html.erb index 1570bb471..a18dbaff1 100644 --- a/app/views/simplefin_items/setup_accounts.html.erb +++ b/app/views/simplefin_items/setup_accounts.html.erb @@ -79,7 +79,7 @@ <%= label_tag "account_types[#{simplefin_account.id}]", "Account Type:", class: "block text-sm font-medium text-primary mb-2" %> <% inferred = @inferred_map[simplefin_account.id] || {} %> - <% selected_type = inferred[:confidence] == :high ? inferred[:type] : "" %> + <% selected_type = inferred[:confidence] == :high ? inferred[:type] : "skip" %> <%= select_tag "account_types[#{simplefin_account.id}]", options_for_select(@account_type_options, selected_type), { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full", diff --git a/config/locales/views/lunchflow_items/en.yml b/config/locales/views/lunchflow_items/en.yml index 68b34df7f..df7534395 100644 --- a/config/locales/views/lunchflow_items/en.yml +++ b/config/locales/views/lunchflow_items/en.yml @@ -26,14 +26,21 @@ en: one: "Successfully linked %{count} account" other: "Successfully linked %{count} accounts" lunchflow_item: + accounts_need_setup: Accounts need setup delete: Delete connection deletion_in_progress: deletion in progress... error: Error no_accounts_description: This connection has no linked accounts yet. no_accounts_title: No accounts + setup_action: Set Up New Accounts + setup_description: "%{linked} of %{total} accounts linked. Choose account types for your newly imported Lunch Flow accounts." + setup_needed: New accounts ready to set up status: "Synced %{timestamp} ago" status_never: Never synced + status_with_summary: "Last synced %{timestamp} ago • %{summary}" syncing: Syncing... + total: Total + unlinked: Unlinked select_accounts: accounts_selected: accounts selected api_error: "API error: %{message}" @@ -66,6 +73,70 @@ en: lunchflow_account_not_found: Lunch Flow account not found missing_parameters: Missing required parameters success: "Successfully linked %{account_name} with Lunch Flow" + setup_accounts: + account_type_label: "Account Type:" + all_accounts_linked: "All your Lunch Flow accounts have already been set up." + api_error: "API error: %{message}" + fetch_failed: "Failed to Fetch Accounts" + no_accounts_to_setup: "No Accounts to Set Up" + no_api_key: "Lunch Flow API key is not configured. Please check your connection settings." + account_types: + skip: Skip this account + depository: Checking or Savings Account + credit_card: Credit Card + investment: Investment Account + loan: Loan or Mortgage + other_asset: Other Asset + subtype_labels: + depository: "Account Subtype:" + credit_card: "" + investment: "Investment Type:" + loan: "Loan Type:" + other_asset: "" + subtype_messages: + credit_card: "Credit cards will be automatically set up as credit card accounts." + other_asset: "No additional options needed for Other Assets." + subtypes: + depository: + checking: Checking + savings: Savings + hsa: Health Savings Account + cd: Certificate of Deposit + money_market: Money Market + investment: + brokerage: Brokerage + pension: Pension + retirement: Retirement + "401k": "401(k)" + roth_401k: "Roth 401(k)" + "403b": "403(b)" + tsp: Thrift Savings Plan + "529_plan": "529 Plan" + hsa: Health Savings Account + mutual_fund: Mutual Fund + ira: Traditional IRA + roth_ira: Roth IRA + angel: Angel + loan: + mortgage: Mortgage + student: Student Loan + auto: Auto Loan + other: Other Loan + balance: Balance + cancel: Cancel + choose_account_type: "Choose the correct account type for each Lunch Flow account:" + create_accounts: Create Accounts + creating_accounts: Creating Accounts... + historical_data_range: "Historical Data Range:" + subtitle: Choose the correct account types for your imported accounts + sync_start_date_help: Select how far back you want to sync transaction history. Maximum 3 years of history available. + sync_start_date_label: "Start syncing transactions from:" + title: Set Up Your Lunch Flow Accounts + complete_account_setup: + all_skipped: "All accounts were skipped. No accounts were created." + creation_failed: "Failed to create accounts: %{error}" + no_accounts: "No accounts to set up." + success: "Successfully created %{count} account(s)." sync: success: Sync started update: diff --git a/config/locales/views/simplefin_items/en.yml b/config/locales/views/simplefin_items/en.yml index 7181520dc..e07501b12 100644 --- a/config/locales/views/simplefin_items/en.yml +++ b/config/locales/views/simplefin_items/en.yml @@ -31,7 +31,9 @@ en: placeholder: "Paste your SimpleFin setup token here..." help_text: "The token should be a long string starting with letters and numbers" complete_account_setup: - success: SimpleFin accounts have been set up successfully! Your transactions and holdings are being imported in the background. + all_skipped: "All accounts were skipped. No accounts were created." + no_accounts: "No accounts to set up." + success: "Successfully created %{count} SimpleFIN account(s)! Your transactions and holdings are being imported in the background." simplefin_item: add_new: Add new connection confirm_accept: Delete connection diff --git a/config/routes.rb b/config/routes.rb index b07d53b3b..8c82dc606 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -319,6 +319,8 @@ Rails.application.routes.draw do member do post :sync + get :setup_accounts + post :complete_account_setup end end