mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 11:04:14 +00:00
Improvements (#379)
* Improvements - Fix button visibility in reports on light theme - Unify logic for provider syncs - Add default option is to skip accounts linking ( no op default ) * Stability fixes and UX improvements * FIX add unlinking when deleting lunch flow connection as well * Wrap updates in transaction * Some more improvements * FIX proper provider setup check * Make provider section collapsible * Fix balance calculation * Restore focus ring * Use browser default focus * Fix lunch flow balance for credit cards
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
50
app/models/lunchflow_item/unlinking.rb
Normal file
50
app/models/lunchflow_item/unlinking.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%# locals: (accounts:) %>
|
||||
|
||||
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<summary class="flex items-center gap-2 focus-visible:outline-hidden">
|
||||
<summary class="flex items-center gap-2">
|
||||
<%= icon("chevron-right", class: "group-open:transform group-open:rotate-90") %>
|
||||
|
||||
<div class="flex items-center justify-center h-8 w-8 rounded-full bg-black/5">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<%= tag.div id: dom_id(lunchflow_item) do %>
|
||||
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<summary class="flex items-center justify-between gap-2 focus-visible:outline-hidden">
|
||||
<summary class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if lunchflow_item.accounts.any? %>
|
||||
<p class="text-xs text-secondary">
|
||||
<%= lunchflow_item.institution_summary %>
|
||||
</p>
|
||||
<% end %>
|
||||
<% if lunchflow_item.syncing? %>
|
||||
<div class="text-secondary flex items-center gap-1">
|
||||
<%= icon "loader", size: "sm", class: "animate-spin" %>
|
||||
@@ -35,7 +40,15 @@
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-secondary">
|
||||
<%= 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 %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -67,10 +80,36 @@
|
||||
<div class="space-y-4 mt-4">
|
||||
<% 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 %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".setup_description", linked: linked_count, total: total_count) %></p>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".setup_action"),
|
||||
icon: "settings",
|
||||
variant: "primary",
|
||||
href: setup_accounts_lunchflow_item_path(lunchflow_item),
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
<% elsif lunchflow_item.accounts.empty? && total_count == 0 %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_title") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".no_accounts_description") %></p>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".setup_action"),
|
||||
icon: "settings",
|
||||
variant: "primary",
|
||||
href: setup_accounts_lunchflow_item_path(lunchflow_item),
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
23
app/views/lunchflow_items/_subtype_select.html.erb
Normal file
23
app/views/lunchflow_items/_subtype_select.html.erb
Normal file
@@ -0,0 +1,23 @@
|
||||
<div class="subtype-select" data-type="<%= account_type %>" style="display: none;">
|
||||
<% if subtype_config[:options].present? %>
|
||||
<%= label_tag "account_subtypes[#{lunchflow_account.id}]", subtype_config[:label],
|
||||
class: "block text-sm font-medium text-primary mb-2" %>
|
||||
<% selected_value = "" %>
|
||||
<% if account_type == "Depository" %>
|
||||
<% n = lunchflow_account.name.to_s.downcase %>
|
||||
<% selected_value = "" %>
|
||||
<% if n =~ /\bchecking\b|\bchequing\b|\bck\b|demand\s+deposit/ %>
|
||||
<% selected_value = "checking" %>
|
||||
<% elsif n =~ /\bsavings\b|\bsv\b/ %>
|
||||
<% selected_value = "savings" %>
|
||||
<% elsif n =~ /money\s+market|\bmm\b/ %>
|
||||
<% selected_value = "money_market" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= select_tag "account_subtypes[#{lunchflow_account.id}]",
|
||||
options_for_select([["Select #{account_type == 'Depository' ? 'subtype' : 'type'}", ""]] + subtype_config[:options], selected_value),
|
||||
{ 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" } %>
|
||||
<% else %>
|
||||
<p class="text-sm text-secondary"><%= subtype_config[:message] %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
111
app/views/lunchflow_items/setup_accounts.html.erb
Normal file
111
app/views/lunchflow_items/setup_accounts.html.erb
Normal file
@@ -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 %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "building-2", class: "text-primary" %>
|
||||
<span class="text-primary"><%= t(".subtitle") %></span>
|
||||
</div>
|
||||
<% 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| %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% if @api_error.present? %>
|
||||
<div class="p-8 flex flex-col gap-3 items-center justify-center text-center">
|
||||
<%= icon "alert-circle", size: "lg", class: "text-destructive" %>
|
||||
<p class="text-primary font-medium"><%= t(".fetch_failed") %></p>
|
||||
<p class="text-destructive text-sm"><%= @api_error %></p>
|
||||
</div>
|
||||
<% elsif @lunchflow_accounts.empty? %>
|
||||
<div class="p-8 flex flex-col gap-3 items-center justify-center text-center">
|
||||
<%= icon "check-circle", size: "lg", class: "text-success" %>
|
||||
<p class="text-primary font-medium"><%= t(".no_accounts_to_setup") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".all_accounts_linked") %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-surface border border-primary p-4 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
|
||||
<div>
|
||||
<p class="text-sm text-primary mb-2">
|
||||
<strong><%= t(".choose_account_type") %></strong>
|
||||
</p>
|
||||
<ul class="text-xs text-secondary space-y-1 list-disc list-inside">
|
||||
<% @account_type_options.reject { |_, type| type == "skip" }.each do |label, type| %>
|
||||
<li><strong><%= label %></strong></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% @lunchflow_accounts.each do |lunchflow_account| %>
|
||||
<div class="border border-primary rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="font-medium text-primary">
|
||||
<%= lunchflow_account.name %>
|
||||
<% if lunchflow_account.institution_metadata.present? && lunchflow_account.institution_metadata['name'].present? %>
|
||||
<span class="text-secondary">• <%= lunchflow_account.institution_metadata["name"] %></span>
|
||||
<% end %>
|
||||
</h3>
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t(".balance") %>: <%= number_to_currency(lunchflow_account.current_balance || 0, unit: lunchflow_account.currency) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-controller="account-type-selector" data-account-type-selector-account-id-value="<%= lunchflow_account.id %>">
|
||||
<div>
|
||||
<%= 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"
|
||||
} } %>
|
||||
</div>
|
||||
|
||||
<!-- Subtype dropdowns (shown/hidden based on account type) -->
|
||||
<div data-account-type-selector-target="subtypeContainer">
|
||||
<% @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 %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= 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
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<%= tag.div id: dom_id(plaid_item) do %>
|
||||
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<summary class="flex items-center justify-between gap-2 focus-visible:outline-hidden">
|
||||
<summary class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
<%# locals: (title:, subtitle: nil, content:) %>
|
||||
<section class="bg-container shadow-border-xs rounded-xl p-4 space-y-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
|
||||
<% if subtitle.present? %>
|
||||
<p class="text-secondary text-sm mt-1"><%= subtitle %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<%= content %>
|
||||
</div>
|
||||
</section>
|
||||
<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true) %>
|
||||
<% if collapsible %>
|
||||
<details <%= "open" if open %> class="group bg-container shadow-border-xs rounded-xl p-4">
|
||||
<summary class="flex items-center justify-between gap-2 cursor-pointer rounded-lg list-none [&::-webkit-details-marker]:hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %>
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
|
||||
<% if subtitle.present? %>
|
||||
<p class="text-secondary text-sm"><%= subtitle %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="space-y-4 mt-4">
|
||||
<%= content %>
|
||||
</div>
|
||||
</details>
|
||||
<% else %>
|
||||
<section class="bg-container shadow-border-xs rounded-xl p-4 space-y-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-primary"><%= title %></h2>
|
||||
<% if subtitle.present? %>
|
||||
<p class="text-secondary text-sm mt-1"><%= subtitle %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<%= content %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
@@ -52,11 +52,14 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% items = local_assigns[:lunchflow_items] || @lunchflow_items || Current.family.lunchflow_items.where.not(api_key: nil) %>
|
||||
<% if items&.any? %>
|
||||
<div class="flex items-center gap-2">
|
||||
<% items = local_assigns[:lunchflow_items] || @lunchflow_items || Current.family.lunchflow_items.where.not(api_key: [nil, ""]) %>
|
||||
<div class="flex items-center gap-2">
|
||||
<% if items&.any? %>
|
||||
<div class="w-2 h-2 bg-success rounded-full"></div>
|
||||
<p class="text-sm text-secondary">Configured and ready to use. Visit the <a href="<%= accounts_path %>" class="link">Accounts</a> tab to manage and set up accounts.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<p class="text-sm text-secondary">Not configured</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,10 +74,13 @@
|
||||
<% end %>
|
||||
|
||||
<%# Show configuration status %>
|
||||
<% if configuration.configured? %>
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<% if configuration.configured? %>
|
||||
<div class="w-2 h-2 bg-success rounded-full"></div>
|
||||
<p class="text-sm text-secondary">Configured and ready to use</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<p class="text-sm text-secondary">Not configured</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,12 +36,13 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @simplefin_items&.any? %>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<% if @simplefin_items&.any? %>
|
||||
<div class="w-2 h-2 bg-success rounded-full"></div>
|
||||
<p class="text-sm text-secondary">Configured and ready to use. Visit the <a href="<%= accounts_path %>" class="link">Accounts</a> tab to manage and set up accounts.</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-sm text-secondary">No SimpleFIN connections yet.</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<p class="text-sm text-secondary">Not configured</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%= content_for :page_title, "Bank Sync Providers" %>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<p class="text-secondary mb-4">
|
||||
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 %>
|
||||
<turbo-frame id="lunchflow-providers-panel">
|
||||
<%= render "settings/providers/lunchflow_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "SimpleFIN" do %>
|
||||
<%= settings_section title: "SimpleFIN", collapsible: true, open: false do %>
|
||||
<turbo-frame id="simplefin-providers-panel">
|
||||
<%= render "settings/providers/simplefin_panel" %>
|
||||
</turbo-frame>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<%= tag.div id: dom_id(simplefin_item) do %>
|
||||
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<summary class="flex items-center justify-between gap-2 focus-visible:outline-hidden">
|
||||
<summary class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -319,6 +319,8 @@ Rails.application.routes.draw do
|
||||
|
||||
member do
|
||||
post :sync
|
||||
get :setup_accounts
|
||||
post :complete_account_setup
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user