diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index dd4b08cdd..f7663b49f 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -6,6 +6,7 @@ class AccountsController < ApplicationController @manual_accounts = family.accounts.manual.alphabetically @plaid_items = family.plaid_items.ordered @simplefin_items = family.simplefin_items.ordered + @lunchflow_items = family.lunchflow_items.ordered render layout: "settings" end diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 3b06ff163..26d881dad 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -68,6 +68,32 @@ module AccountableResource 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 diff --git a/app/controllers/lunchflow_items_controller.rb b/app/controllers/lunchflow_items_controller.rb new file mode 100644 index 000000000..6b1fd308b --- /dev/null +++ b/app/controllers/lunchflow_items_controller.rb @@ -0,0 +1,221 @@ +class LunchflowItemsController < ApplicationController + before_action :set_lunchflow_item, only: [ :show, :edit, :update, :destroy, :sync ] + + def index + @lunchflow_items = Current.family.lunchflow_items.active.ordered + render layout: "settings" + end + + def show + end + + # Fetch available accounts from Lunchflow API and show selection UI + def select_accounts + begin + cache_key = "lunchflow_accounts_#{Current.family.id}" + + # Try to get cached accounts first + @available_accounts = Rails.cache.read(cache_key) + + # If not cached, fetch from API + if @available_accounts.nil? + lunchflow_provider = Provider::LunchflowAdapter.build_provider + + unless lunchflow_provider.present? + redirect_to new_account_path, alert: t(".no_api_key") + 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) + end + + @accountable_type = params[:accountable_type] || "Depository" + @return_to = safe_return_to_path + + if @available_accounts.empty? + redirect_to new_account_path, alert: t(".no_accounts_found") + return + end + + render layout: false + rescue Provider::Lunchflow::LunchflowError => e + redirect_to new_account_path, alert: t(".api_error", message: e.message) + end + end + + # Create accounts from selected Lunchflow accounts + def link_accounts + selected_account_ids = params[:account_ids] || [] + accountable_type = params[:accountable_type] || "Depository" + return_to = safe_return_to_path + + if selected_account_ids.empty? + redirect_to new_account_path, alert: t(".no_accounts_selected") + return + end + + # Create or find lunchflow_item for this family + lunchflow_item = Current.family.lunchflow_items.first_or_create!( + name: "Lunchflow Connection" + ) + + # Fetch account details from API + lunchflow_provider = Provider::LunchflowAdapter.build_provider + unless lunchflow_provider.present? + redirect_to new_account_path, alert: t(".no_api_key") + return + end + + accounts_data = lunchflow_provider.get_accounts + + created_accounts = [] + already_linked_accounts = [] + + selected_account_ids.each do |account_id| + # Find the account data from API response + account_data = accounts_data[:accounts].find { |acc| acc[:id].to_s == account_id.to_s } + next unless account_data + + # Create or find lunchflow_account + lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by( + account_id: account_id.to_s + ) + lunchflow_account.upsert_lunchflow_snapshot!(account_data) + lunchflow_account.save! + + # Check if this lunchflow_account is already linked + if lunchflow_account.account_provider.present? + already_linked_accounts << account_data[:name] + next + end + + # Create the internal Account with proper balance initialization + account = Account.create_and_sync( + family: Current.family, + name: account_data[:name], + balance: 0, # Initial balance will be set during sync + currency: account_data[:currency] || "USD", + accountable_type: accountable_type, + accountable_attributes: {} + ) + + # Link account to lunchflow_account via account_providers join table + AccountProvider.create!( + account: account, + provider: lunchflow_account + ) + + created_accounts << account + end + + # Trigger sync to fetch transactions if any accounts were created + lunchflow_item.sync_later if created_accounts.any? + + # Build appropriate flash message + if created_accounts.any? && already_linked_accounts.any? + redirect_to return_to || accounts_path, + notice: t(".partial_success", + created_count: created_accounts.count, + already_linked_count: already_linked_accounts.count, + already_linked_names: already_linked_accounts.join(", ")) + elsif created_accounts.any? + redirect_to return_to || accounts_path, + notice: t(".success", count: created_accounts.count) + elsif already_linked_accounts.any? + redirect_to return_to || accounts_path, + alert: t(".all_already_linked", + count: already_linked_accounts.count, + names: already_linked_accounts.join(", ")) + else + redirect_to new_account_path, alert: t(".link_failed") + end + rescue Provider::Lunchflow::LunchflowError => e + redirect_to new_account_path, alert: t(".api_error", message: e.message) + end + + def new + @lunchflow_item = Current.family.lunchflow_items.build + end + + def create + @lunchflow_item = Current.family.lunchflow_items.build(lunchflow_params) + @lunchflow_item.name = "Lunchflow Connection" + + if @lunchflow_item.save + # Trigger initial sync to fetch accounts + @lunchflow_item.sync_later + + redirect_to accounts_path, notice: t(".success") + else + @error_message = @lunchflow_item.errors.full_messages.join(", ") + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @lunchflow_item.update(lunchflow_params) + redirect_to accounts_path, notice: t(".success") + else + @error_message = @lunchflow_item.errors.full_messages.join(", ") + render :edit, status: :unprocessable_entity + end + end + + def destroy + @lunchflow_item.destroy_later + redirect_to accounts_path, notice: t(".success") + end + + def sync + unless @lunchflow_item.syncing? + @lunchflow_item.sync_later + end + + respond_to do |format| + format.html { redirect_back_or_to accounts_path } + format.json { head :ok } + end + end + + private + def set_lunchflow_item + @lunchflow_item = Current.family.lunchflow_items.find(params[:id]) + end + + def lunchflow_params + params.require(:lunchflow_item).permit(:name, :sync_start_date) + end + + # Sanitize return_to parameter to prevent XSS attacks + # Only allow internal paths, reject external URLs and javascript: URIs + def safe_return_to_path + return nil if params[:return_to].blank? + + return_to = params[:return_to].to_s + + # Parse the URL to check if it's external + begin + uri = URI.parse(return_to) + + # Reject absolute URLs with schemes (http:, https:, javascript:, etc.) + # Only allow relative paths + return nil if uri.scheme.present? + + # Ensure the path starts with / (is a relative path) + return nil unless return_to.start_with?("/") + + return_to + rescue URI::InvalidURIError + # If the URI is invalid, reject it + nil + end + end +end diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index ead34908f..8324ccd91 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -1,5 +1,5 @@ class DataEnrichment < ApplicationRecord belongs_to :enrichable, polymorphic: true - enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", synth: "synth", ai: "ai" } + enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai" } end diff --git a/app/models/family.rb b/app/models/family.rb index 0cc1e0848..2380a37df 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -1,5 +1,5 @@ class Family < ApplicationRecord - include PlaidConnectable, SimplefinConnectable, Syncable, AutoTransferMatchable, Subscribeable + include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, Syncable, AutoTransferMatchable, Subscribeable DATE_FORMATS = [ [ "MM-DD-YYYY", "%m-%d-%Y" ], diff --git a/app/models/family/lunchflow_connectable.rb b/app/models/family/lunchflow_connectable.rb new file mode 100644 index 000000000..bfc2e2047 --- /dev/null +++ b/app/models/family/lunchflow_connectable.rb @@ -0,0 +1,12 @@ +module Family::LunchflowConnectable + extend ActiveSupport::Concern + + included do + has_many :lunchflow_items, dependent: :destroy + end + + def can_connect_lunchflow? + # Check if the API key is configured + Provider::LunchflowAdapter.configured? + end +end diff --git a/app/models/income_statement.rb b/app/models/income_statement.rb index cfa066fe7..b30b352f0 100644 --- a/app/models/income_statement.rb +++ b/app/models/income_statement.rb @@ -1,3 +1,5 @@ +require "digest/md5" + class IncomeStatement include Monetizable diff --git a/app/models/lunchflow_account.rb b/app/models/lunchflow_account.rb new file mode 100644 index 000000000..cb5fb1019 --- /dev/null +++ b/app/models/lunchflow_account.rb @@ -0,0 +1,44 @@ +class LunchflowAccount < ApplicationRecord + belongs_to :lunchflow_item + + # New association through account_providers + has_one :account_provider, as: :provider, dependent: :destroy + has_one :account, through: :account_provider, source: :account + has_one :linked_account, through: :account_provider, source: :account + + validates :name, :currency, presence: true + + # Helper to get account using account_providers system + def current_account + account + end + + def upsert_lunchflow_snapshot!(account_snapshot) + # Convert to symbol keys or handle both string and symbol keys + snapshot = account_snapshot.with_indifferent_access + + # Map Lunchflow field names to our field names + # Lunchflow API returns: { id, name, institution_name, institution_logo, provider, currency, status } + update!( + current_balance: nil, # Balance not provided by accounts endpoint + currency: snapshot[:currency] || "USD", + name: snapshot[:name], + account_id: snapshot[:id].to_s, + account_status: snapshot[:status], + provider: snapshot[:provider], + institution_metadata: { + name: snapshot[:institution_name], + logo: snapshot[:institution_logo] + }.compact, + raw_payload: account_snapshot + ) + end + + def upsert_lunchflow_transactions_snapshot!(transactions_snapshot) + assign_attributes( + raw_transactions_payload: transactions_snapshot + ) + + save! + end +end diff --git a/app/models/lunchflow_account/processor.rb b/app/models/lunchflow_account/processor.rb new file mode 100644 index 000000000..a696e3ba2 --- /dev/null +++ b/app/models/lunchflow_account/processor.rb @@ -0,0 +1,65 @@ +class LunchflowAccount::Processor + attr_reader :lunchflow_account + + def initialize(lunchflow_account) + @lunchflow_account = lunchflow_account + end + + def process + unless lunchflow_account.current_account.present? + Rails.logger.info "LunchflowAccount::Processor - No linked account for lunchflow_account #{lunchflow_account.id}, skipping processing" + return + end + + Rails.logger.info "LunchflowAccount::Processor - Processing lunchflow_account #{lunchflow_account.id} (account #{lunchflow_account.account_id})" + + begin + process_account! + rescue StandardError => e + Rails.logger.error "LunchflowAccount::Processor - Failed to process account #{lunchflow_account.id}: #{e.message}" + Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}" + report_exception(e, "account") + raise + end + + process_transactions + end + + private + + def process_account! + if lunchflow_account.current_account.blank? + Rails.logger.error("Lunchflow account #{lunchflow_account.id} has no associated Account") + return + end + + # Update account balance from latest Lunchflow data + account = lunchflow_account.current_account + balance = lunchflow_account.current_balance || 0 + + # For credit cards and loans, ensure positive balances + if account.accountable_type == "CreditCard" || account.accountable_type == "Loan" + balance = balance.abs + end + + account.update!( + balance: balance, + cash_balance: balance + ) + end + + def process_transactions + LunchflowAccount::Transactions::Processor.new(lunchflow_account).process + rescue => e + report_exception(e, "transactions") + end + + def report_exception(error, context) + Sentry.capture_exception(error) do |scope| + scope.set_tags( + lunchflow_account_id: lunchflow_account.id, + context: context + ) + end + end +end diff --git a/app/models/lunchflow_account/transactions/processor.rb b/app/models/lunchflow_account/transactions/processor.rb new file mode 100644 index 000000000..ca8210412 --- /dev/null +++ b/app/models/lunchflow_account/transactions/processor.rb @@ -0,0 +1,71 @@ +class LunchflowAccount::Transactions::Processor + attr_reader :lunchflow_account + + def initialize(lunchflow_account) + @lunchflow_account = lunchflow_account + end + + def process + unless lunchflow_account.raw_transactions_payload.present? + Rails.logger.info "LunchflowAccount::Transactions::Processor - No transactions in raw_transactions_payload for lunchflow_account #{lunchflow_account.id}" + return { success: true, total: 0, imported: 0, failed: 0, errors: [] } + end + + total_count = lunchflow_account.raw_transactions_payload.count + Rails.logger.info "LunchflowAccount::Transactions::Processor - Processing #{total_count} transactions for lunchflow_account #{lunchflow_account.id}" + + imported_count = 0 + failed_count = 0 + errors = [] + + # Each entry is processed inside a transaction, but to avoid locking up the DB when + # there are hundreds or thousands of transactions, we process them individually. + lunchflow_account.raw_transactions_payload.each_with_index do |transaction_data, index| + begin + result = LunchflowEntry::Processor.new( + transaction_data, + lunchflow_account: lunchflow_account + ).process + + if result.nil? + # Transaction was skipped (e.g., no linked account) + failed_count += 1 + errors << { index: index, transaction_id: transaction_data[:id], error: "No linked account" } + else + imported_count += 1 + end + rescue ArgumentError => e + # Validation error - log and continue + failed_count += 1 + transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" + error_message = "Validation error: #{e.message}" + Rails.logger.error "LunchflowAccount::Transactions::Processor - #{error_message} (transaction #{transaction_id})" + errors << { index: index, transaction_id: transaction_id, error: error_message } + rescue => e + # Unexpected error - log with full context and continue + failed_count += 1 + transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" + error_message = "#{e.class}: #{e.message}" + Rails.logger.error "LunchflowAccount::Transactions::Processor - Error processing transaction #{transaction_id}: #{error_message}" + Rails.logger.error e.backtrace.join("\n") + errors << { index: index, transaction_id: transaction_id, error: error_message } + end + end + + result = { + success: failed_count == 0, + total: total_count, + imported: imported_count, + failed: failed_count, + errors: errors + } + + if failed_count > 0 + Rails.logger.warn "LunchflowAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions" + else + Rails.logger.info "LunchflowAccount::Transactions::Processor - Successfully processed #{imported_count} transactions" + end + + result + end +end diff --git a/app/models/lunchflow_entry/processor.rb b/app/models/lunchflow_entry/processor.rb new file mode 100644 index 000000000..971705e9c --- /dev/null +++ b/app/models/lunchflow_entry/processor.rb @@ -0,0 +1,147 @@ +require "digest/md5" + +class LunchflowEntry::Processor + # lunchflow_transaction is the raw hash fetched from Lunchflow API and converted to JSONB + # Transaction structure: { id, accountId, amount, currency, date, merchant, description } + def initialize(lunchflow_transaction, lunchflow_account:) + @lunchflow_transaction = lunchflow_transaction + @lunchflow_account = lunchflow_account + end + + def process + # Validate that we have a linked account before processing + unless account.present? + Rails.logger.warn "LunchflowEntry::Processor - No linked account for lunchflow_account #{lunchflow_account.id}, skipping transaction #{external_id}" + return nil + end + + # Wrap import in error handling to catch validation and save errors + begin + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name, + source: "lunchflow", + merchant: merchant + ) + rescue ArgumentError => e + # Re-raise validation errors (missing required fields, invalid data) + Rails.logger.error "LunchflowEntry::Processor - Validation error for transaction #{external_id}: #{e.message}" + raise + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e + # Handle database save errors + Rails.logger.error "LunchflowEntry::Processor - Failed to save transaction #{external_id}: #{e.message}" + raise StandardError.new("Failed to import transaction: #{e.message}") + rescue => e + # Catch unexpected errors with full context + Rails.logger.error "LunchflowEntry::Processor - Unexpected error processing transaction #{external_id}: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + raise StandardError.new("Unexpected error importing transaction: #{e.message}") + end + end + + private + attr_reader :lunchflow_transaction, :lunchflow_account + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def account + @account ||= lunchflow_account.current_account + end + + def data + @data ||= lunchflow_transaction.with_indifferent_access + end + + def external_id + id = data[:id].presence + raise ArgumentError, "Lunchflow transaction missing required field 'id'" unless id + "lunchflow_#{id}" + end + + def name + # Use Lunchflow's merchant and description to create informative transaction names + merchant_name = data[:merchant] + description = data[:description] + + # Combine merchant + description when both are present and different + if merchant_name.present? && description.present? && merchant_name != description + "#{merchant_name} - #{description}" + elsif merchant_name.present? + merchant_name + elsif description.present? + description + else + "Unknown transaction" + end + end + + def merchant + return nil unless data[:merchant].present? + + # Create a stable merchant ID from the merchant name + # Using digest to ensure uniqueness while keeping it deterministic + merchant_name = data[:merchant].to_s.strip + return nil if merchant_name.blank? + + merchant_id = Digest::MD5.hexdigest(merchant_name.downcase) + + @merchant ||= begin + import_adapter.find_or_create_merchant( + provider_merchant_id: "lunchflow_merchant_#{merchant_id}", + name: merchant_name, + source: "lunchflow" + ) + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "LunchflowEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}" + nil + end + end + + def amount + parsed_amount = case data[:amount] + when String + BigDecimal(data[:amount]) + when Numeric + BigDecimal(data[:amount].to_s) + else + BigDecimal("0") + end + + # Lunchflow likely uses standard convention where negative is expense, positive is income + # Maybe expects opposite convention (expenses positive, income negative) + # So we negate the amount to convert from Lunchflow to Maybe format + -parsed_amount + rescue ArgumentError => e + Rails.logger.error "Failed to parse Lunchflow transaction amount: #{data[:amount].inspect} - #{e.message}" + raise + end + + def currency + data[:currency].presence || account&.currency || "USD" + end + + def date + case data[:date] + when String + Date.parse(data[:date]) + when Integer, Float + # Unix timestamp + Time.at(data[:date]).to_date + when Time, DateTime + data[:date].to_date + when Date + data[:date] + else + Rails.logger.error("Lunchflow transaction has invalid date value: #{data[:date].inspect}") + raise ArgumentError, "Invalid date format: #{data[:date].inspect}" + end + rescue ArgumentError, TypeError => e + Rails.logger.error("Failed to parse Lunchflow transaction date '#{data[:date]}': #{e.message}") + raise ArgumentError, "Unable to parse transaction date: #{data[:date].inspect}" + end +end diff --git a/app/models/lunchflow_item.rb b/app/models/lunchflow_item.rb new file mode 100644 index 000000000..409285144 --- /dev/null +++ b/app/models/lunchflow_item.rb @@ -0,0 +1,147 @@ +class LunchflowItem < ApplicationRecord + include Syncable, Provided + + enum :status, { good: "good", requires_update: "requires_update" }, default: :good + + validates :name, presence: true + + belongs_to :family + has_one_attached :logo + + has_many :lunchflow_accounts, dependent: :destroy + has_many :accounts, through: :lunchflow_accounts + + scope :active, -> { where(scheduled_for_deletion: false) } + scope :ordered, -> { order(created_at: :desc) } + scope :needs_update, -> { where(status: :requires_update) } + + def destroy_later + update!(scheduled_for_deletion: true) + DestroyJob.perform_later(self) + end + + def import_latest_lunchflow_data + provider = lunchflow_provider + unless provider + Rails.logger.error "LunchflowItem #{id} - Cannot import: Lunchflow provider is not configured (missing API key)" + raise StandardError.new("Lunchflow provider is not configured") + end + + LunchflowItem::Importer.new(self, lunchflow_provider: provider).import + rescue => e + Rails.logger.error "LunchflowItem #{id} - Failed to import data: #{e.message}" + raise + end + + def process_accounts + return [] if lunchflow_accounts.empty? + + results = [] + lunchflow_accounts.joins(:account).each do |lunchflow_account| + begin + result = LunchflowAccount::Processor.new(lunchflow_account).process + results << { lunchflow_account_id: lunchflow_account.id, success: true, result: result } + rescue => e + Rails.logger.error "LunchflowItem #{id} - Failed to process account #{lunchflow_account.id}: #{e.message}" + results << { lunchflow_account_id: lunchflow_account.id, success: false, error: e.message } + # Continue processing other accounts even if one fails + end + end + + results + end + + def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil) + return [] if accounts.empty? + + results = [] + accounts.each do |account| + begin + account.sync_later( + parent_sync: parent_sync, + window_start_date: window_start_date, + window_end_date: window_end_date + ) + results << { account_id: account.id, success: true } + rescue => e + Rails.logger.error "LunchflowItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}" + results << { account_id: account.id, success: false, error: e.message } + # Continue scheduling other accounts even if one fails + end + end + + results + end + + def upsert_lunchflow_snapshot!(accounts_snapshot) + assign_attributes( + raw_payload: accounts_snapshot + ) + + save! + end + + def has_completed_initial_setup? + # Setup is complete if we have any linked accounts + accounts.any? + end + + def sync_status_summary + latest = latest_sync + return nil unless latest + + # 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 + 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 + end + end + + def institution_display_name + # Try to get institution name from stored metadata + institution_name.presence || institution_domain.presence || name + end + + def connected_institutions + # Get unique institutions from all accounts + lunchflow_accounts.includes(:account) + .where.not(institution_metadata: nil) + .map { |acc| acc.institution_metadata } + .uniq { |inst| inst["name"] || inst["institution_name"] } + end + + def institution_summary + institutions = connected_institutions + case institutions.count + when 0 + "No institutions connected" + when 1 + institutions.first["name"] || institutions.first["institution_name"] || "1 institution" + else + "#{institutions.count} institutions" + end + end +end diff --git a/app/models/lunchflow_item/importer.rb b/app/models/lunchflow_item/importer.rb new file mode 100644 index 000000000..e2b6136ba --- /dev/null +++ b/app/models/lunchflow_item/importer.rb @@ -0,0 +1,310 @@ +class LunchflowItem::Importer + attr_reader :lunchflow_item, :lunchflow_provider + + def initialize(lunchflow_item, lunchflow_provider:) + @lunchflow_item = lunchflow_item + @lunchflow_provider = lunchflow_provider + end + + def import + Rails.logger.info "LunchflowItem::Importer - Starting import for item #{lunchflow_item.id}" + + # Step 1: Fetch all accounts from Lunchflow + accounts_data = fetch_accounts_data + unless accounts_data + Rails.logger.error "LunchflowItem::Importer - Failed to fetch accounts data for item #{lunchflow_item.id}" + return { success: false, error: "Failed to fetch accounts data", accounts_imported: 0, transactions_imported: 0 } + end + + # Store raw payload + begin + lunchflow_item.upsert_lunchflow_snapshot!(accounts_data) + rescue => e + Rails.logger.error "LunchflowItem::Importer - Failed to store accounts snapshot: #{e.message}" + # Continue with import even if snapshot storage fails + end + + # Step 2: Import accounts + accounts_imported = 0 + accounts_failed = 0 + + if accounts_data[:accounts].present? + accounts_data[:accounts].each do |account_data| + begin + import_account(account_data) + accounts_imported += 1 + rescue => e + accounts_failed += 1 + account_id = account_data[:id] || "unknown" + Rails.logger.error "LunchflowItem::Importer - Failed to import account #{account_id}: #{e.message}" + # Continue importing other accounts even if one fails + end + end + end + + Rails.logger.info "LunchflowItem::Importer - Imported #{accounts_imported} accounts (#{accounts_failed} failed)" + + # Step 3: Fetch transactions for each account + transactions_imported = 0 + transactions_failed = 0 + + lunchflow_item.lunchflow_accounts.each do |lunchflow_account| + begin + result = fetch_and_store_transactions(lunchflow_account) + if result[:success] + transactions_imported += result[:transactions_count] + else + transactions_failed += 1 + end + rescue => e + transactions_failed += 1 + Rails.logger.error "LunchflowItem::Importer - Failed to fetch/store transactions for account #{lunchflow_account.account_id}: #{e.message}" + # Continue with other accounts even if one fails + end + end + + Rails.logger.info "LunchflowItem::Importer - Completed import for item #{lunchflow_item.id}: #{accounts_imported} accounts, #{transactions_imported} transactions" + + { + success: accounts_failed == 0 && transactions_failed == 0, + accounts_imported: accounts_imported, + accounts_failed: accounts_failed, + transactions_imported: transactions_imported, + transactions_failed: transactions_failed + } + end + + private + + def fetch_accounts_data + begin + accounts_data = lunchflow_provider.get_accounts + rescue Provider::Lunchflow::LunchflowError => e + # Handle authentication errors by marking item as requiring update + if e.error_type == :unauthorized || e.error_type == :access_forbidden + begin + lunchflow_item.update!(status: :requires_update) + rescue => update_error + Rails.logger.error "LunchflowItem::Importer - Failed to update item status: #{update_error.message}" + end + end + Rails.logger.error "LunchflowItem::Importer - Lunchflow API error: #{e.message}" + return nil + rescue JSON::ParserError => e + Rails.logger.error "LunchflowItem::Importer - Failed to parse Lunchflow API response: #{e.message}" + return nil + rescue => e + Rails.logger.error "LunchflowItem::Importer - Unexpected error fetching accounts: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + return nil + end + + # Validate response structure + unless accounts_data.is_a?(Hash) + Rails.logger.error "LunchflowItem::Importer - Invalid accounts_data format: expected Hash, got #{accounts_data.class}" + return nil + end + + # Handle errors if present in response + if accounts_data[:error].present? + handle_error(accounts_data[:error]) + return nil + end + + accounts_data + end + + def import_account(account_data) + # Validate account data structure + unless account_data.is_a?(Hash) + Rails.logger.error "LunchflowItem::Importer - Invalid account_data format: expected Hash, got #{account_data.class}" + raise ArgumentError, "Invalid account data format" + end + + account_id = account_data[:id] + + # Validate required account_id to prevent duplicate creation + if account_id.blank? + Rails.logger.warn "LunchflowItem::Importer - Skipping account with missing ID" + raise ArgumentError, "Account ID is required" + end + + lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by( + account_id: account_id.to_s + ) + + begin + lunchflow_account.upsert_lunchflow_snapshot!(account_data) + lunchflow_account.save! + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "LunchflowItem::Importer - Failed to save lunchflow_account: #{e.message}" + raise StandardError.new("Failed to save account: #{e.message}") + end + end + + def fetch_and_store_transactions(lunchflow_account) + start_date = determine_sync_start_date(lunchflow_account) + Rails.logger.info "LunchflowItem::Importer - Fetching transactions for account #{lunchflow_account.account_id} from #{start_date}" + + begin + # Fetch transactions + transactions_data = lunchflow_provider.get_account_transactions( + lunchflow_account.account_id, + start_date: start_date + ) + + # Validate response structure + unless transactions_data.is_a?(Hash) + Rails.logger.error "LunchflowItem::Importer - Invalid transactions_data format for account #{lunchflow_account.account_id}" + return { success: false, transactions_count: 0, error: "Invalid response format" } + end + + transactions_count = transactions_data[:transactions]&.count || 0 + Rails.logger.info "LunchflowItem::Importer - Fetched #{transactions_count} transactions for account #{lunchflow_account.account_id}" + + # Store transactions in the account + if transactions_data[:transactions].present? + begin + existing_transactions = lunchflow_account.raw_transactions_payload.to_a + + # Build set of existing transaction IDs for efficient lookup + existing_ids = existing_transactions.map do |tx| + tx.with_indifferent_access[:id] + end.to_set + + # Filter to ONLY truly new transactions (skip duplicates) + # Transactions are immutable on the bank side, so we don't need to update them + new_transactions = transactions_data[:transactions].select do |tx| + next false unless tx.is_a?(Hash) + + tx_id = tx.with_indifferent_access[:id] + tx_id.present? && !existing_ids.include?(tx_id) + end + + if new_transactions.any? + Rails.logger.info "LunchflowItem::Importer - Storing #{new_transactions.count} new transactions (#{existing_transactions.count} existing, #{transactions_data[:transactions].count - new_transactions.count} duplicates skipped) for account #{lunchflow_account.account_id}" + lunchflow_account.upsert_lunchflow_transactions_snapshot!(existing_transactions + new_transactions) + else + Rails.logger.info "LunchflowItem::Importer - No new transactions to store (all #{transactions_data[:transactions].count} were duplicates) for account #{lunchflow_account.account_id}" + end + rescue => e + Rails.logger.error "LunchflowItem::Importer - Failed to store transactions for account #{lunchflow_account.account_id}: #{e.message}" + return { success: false, transactions_count: 0, error: "Failed to store transactions: #{e.message}" } + end + else + Rails.logger.info "LunchflowItem::Importer - No transactions to store for account #{lunchflow_account.account_id}" + end + + # Fetch and update balance + begin + fetch_and_update_balance(lunchflow_account) + rescue => e + # Log but don't fail transaction import if balance fetch fails + Rails.logger.warn "LunchflowItem::Importer - Failed to update balance for account #{lunchflow_account.account_id}: #{e.message}" + end + + { success: true, transactions_count: transactions_count } + rescue Provider::Lunchflow::LunchflowError => e + Rails.logger.error "LunchflowItem::Importer - Lunchflow API error for account #{lunchflow_account.id}: #{e.message}" + { success: false, transactions_count: 0, error: e.message } + rescue JSON::ParserError => e + Rails.logger.error "LunchflowItem::Importer - Failed to parse transaction response for account #{lunchflow_account.id}: #{e.message}" + { success: false, transactions_count: 0, error: "Failed to parse response" } + rescue => e + Rails.logger.error "LunchflowItem::Importer - Unexpected error fetching transactions for account #{lunchflow_account.id}: #{e.class} - #{e.message}" + Rails.logger.error e.backtrace.join("\n") + { success: false, transactions_count: 0, error: "Unexpected error: #{e.message}" } + end + end + + def fetch_and_update_balance(lunchflow_account) + begin + balance_data = lunchflow_provider.get_account_balance(lunchflow_account.account_id) + + # Validate response structure + unless balance_data.is_a?(Hash) + Rails.logger.error "LunchflowItem::Importer - Invalid balance_data format for account #{lunchflow_account.account_id}" + return + end + + if balance_data[:balance].present? + balance_info = balance_data[:balance] + + # Validate balance info structure + unless balance_info.is_a?(Hash) + Rails.logger.error "LunchflowItem::Importer - Invalid balance info format for account #{lunchflow_account.account_id}" + return + end + + # Only update if we have a valid amount + if balance_info[:amount].present? + lunchflow_account.update!( + current_balance: balance_info[:amount], + currency: balance_info[:currency].presence || lunchflow_account.currency + ) + else + Rails.logger.warn "LunchflowItem::Importer - No amount in balance data for account #{lunchflow_account.account_id}" + end + else + Rails.logger.warn "LunchflowItem::Importer - No balance data returned for account #{lunchflow_account.account_id}" + end + rescue Provider::Lunchflow::LunchflowError => e + Rails.logger.error "LunchflowItem::Importer - Lunchflow API error fetching balance for account #{lunchflow_account.id}: #{e.message}" + # Don't fail if balance fetch fails + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error "LunchflowItem::Importer - Failed to save balance for account #{lunchflow_account.id}: #{e.message}" + # Don't fail if balance save fails + rescue => e + Rails.logger.error "LunchflowItem::Importer - Unexpected error updating balance for account #{lunchflow_account.id}: #{e.class} - #{e.message}" + # Don't fail if balance update fails + end + end + + def determine_sync_start_date(lunchflow_account) + # Check if this account has any stored transactions + # If not, treat it as a first sync for this account even if the item has been synced before + has_stored_transactions = lunchflow_account.raw_transactions_payload.to_a.any? + + if has_stored_transactions + # Account has been synced before, use item-level logic with buffer + # For subsequent syncs, fetch from last sync date with a buffer + if lunchflow_item.last_synced_at + lunchflow_item.last_synced_at - 7.days + else + # Fallback if item hasn't been synced but account has transactions + 90.days.ago + end + else + # Account has no stored transactions - this is a first sync for this account + # Use account creation date or a generous historical window + account_baseline = lunchflow_account.created_at || Time.current + first_sync_window = [ account_baseline - 7.days, 90.days.ago ].max + + # Use the more recent of: (account created - 7 days) or (90 days ago) + # This caps old accounts at 90 days while respecting recent account creation dates + first_sync_window + end + end + + def handle_error(error_message) + # Mark item as requiring update for authentication-related errors + error_msg_lower = error_message.to_s.downcase + needs_update = error_msg_lower.include?("authentication") || + error_msg_lower.include?("unauthorized") || + error_msg_lower.include?("api key") + + if needs_update + begin + lunchflow_item.update!(status: :requires_update) + rescue => e + Rails.logger.error "LunchflowItem::Importer - Failed to update item status: #{e.message}" + end + end + + Rails.logger.error "LunchflowItem::Importer - API error: #{error_message}" + raise Provider::Lunchflow::LunchflowError.new( + "Lunchflow API error: #{error_message}", + :api_error + ) + end +end diff --git a/app/models/lunchflow_item/provided.rb b/app/models/lunchflow_item/provided.rb new file mode 100644 index 000000000..2daa5e5d3 --- /dev/null +++ b/app/models/lunchflow_item/provided.rb @@ -0,0 +1,7 @@ +module LunchflowItem::Provided + extend ActiveSupport::Concern + + def lunchflow_provider + Provider::LunchflowAdapter.build_provider + end +end diff --git a/app/models/lunchflow_item/sync_complete_event.rb b/app/models/lunchflow_item/sync_complete_event.rb new file mode 100644 index 000000000..0a33c2714 --- /dev/null +++ b/app/models/lunchflow_item/sync_complete_event.rb @@ -0,0 +1,25 @@ +class LunchflowItem::SyncCompleteEvent + attr_reader :lunchflow_item + + def initialize(lunchflow_item) + @lunchflow_item = lunchflow_item + end + + def broadcast + # Update UI with latest account data + lunchflow_item.accounts.each do |account| + account.broadcast_sync_complete + end + + # Update the Lunchflow item view + lunchflow_item.broadcast_replace_to( + lunchflow_item.family, + target: "lunchflow_item_#{lunchflow_item.id}", + partial: "lunchflow_items/lunchflow_item", + locals: { lunchflow_item: lunchflow_item } + ) + + # Let family handle sync notifications + lunchflow_item.family.broadcast_sync_complete + end +end diff --git a/app/models/lunchflow_item/syncer.rb b/app/models/lunchflow_item/syncer.rb new file mode 100644 index 000000000..5618e63a9 --- /dev/null +++ b/app/models/lunchflow_item/syncer.rb @@ -0,0 +1,61 @@ +class LunchflowItem::Syncer + attr_reader :lunchflow_item + + def initialize(lunchflow_item) + @lunchflow_item = lunchflow_item + end + + def perform_sync(sync) + # Phase 1: Import data from Lunchflow API + sync.update!(status_text: "Importing accounts from Lunchflow...") if sync.respond_to?(:status_text) + lunchflow_item.import_latest_lunchflow_data + + # Phase 2: Check account setup status and collect sync statistics + sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text) + total_accounts = lunchflow_item.lunchflow_accounts.count + linked_accounts = lunchflow_item.lunchflow_accounts.joins(:account) + unlinked_accounts = lunchflow_item.lunchflow_accounts.includes(:account).where(accounts: { id: nil }) + + # Store sync statistics for display + sync_stats = { + total_accounts: total_accounts, + linked_accounts: linked_accounts.count, + unlinked_accounts: unlinked_accounts.count + } + + # Set pending_account_setup if there are unlinked accounts + if unlinked_accounts.any? + lunchflow_item.update!(pending_account_setup: true) + sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text) + else + lunchflow_item.update!(pending_account_setup: false) + end + + # Phase 3: Process transactions for linked accounts only + if linked_accounts.any? + sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text) + Rails.logger.info "LunchflowItem::Syncer - Processing #{linked_accounts.count} linked accounts" + lunchflow_item.process_accounts + Rails.logger.info "LunchflowItem::Syncer - Finished processing accounts" + + # Phase 4: Schedule balance calculations for linked accounts + sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text) + lunchflow_item.schedule_account_syncs( + parent_sync: sync, + window_start_date: sync.window_start_date, + window_end_date: sync.window_end_date + ) + else + Rails.logger.info "LunchflowItem::Syncer - No linked accounts to process" + end + + # Store sync statistics in the sync record for status display + if sync.respond_to?(:sync_stats) + sync.update!(sync_stats: sync_stats) + end + end + + def perform_post_sync + # no-op + end +end diff --git a/app/models/provider/lunchflow.rb b/app/models/provider/lunchflow.rb new file mode 100644 index 000000000..069987690 --- /dev/null +++ b/app/models/provider/lunchflow.rb @@ -0,0 +1,120 @@ +class Provider::Lunchflow + include HTTParty + + headers "User-Agent" => "Sure Finance Lunchflow Client" + default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + + attr_reader :api_key, :base_url + + def initialize(api_key, base_url: "https://lunchflow.app/api/v1") + @api_key = api_key + @base_url = base_url + end + + # Get all accounts + # Returns: { accounts: [...], total: N } + def get_accounts + response = self.class.get( + "#{@base_url}/accounts", + headers: auth_headers + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "Lunchflow 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}" + raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed) + end + + # Get transactions for a specific account + # Returns: { transactions: [...], total: N } + # Transaction structure: { id, accountId, amount, currency, date, merchant, description } + def get_account_transactions(account_id, start_date: nil, end_date: nil) + query_params = {} + + if start_date + query_params[:start_date] = start_date.to_date.to_s + end + + if end_date + query_params[:end_date] = end_date.to_date.to_s + end + + path = "/accounts/#{ERB::Util.url_encode(account_id.to_s)}/transactions" + path += "?#{URI.encode_www_form(query_params)}" unless query_params.empty? + + response = self.class.get( + "#{@base_url}#{path}", + headers: auth_headers + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "Lunchflow 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}" + raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed) + end + + # Get balance for a specific account + # Returns: { balance: { amount: N, currency: "USD" } } + def get_account_balance(account_id) + path = "/accounts/#{ERB::Util.url_encode(account_id.to_s)}/balance" + + response = self.class.get( + "#{@base_url}#{path}", + headers: auth_headers + ) + + handle_response(response) + rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e + Rails.logger.error "Lunchflow 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}" + raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed) + end + + private + + def auth_headers + { + "x-api-key" => api_key, + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def handle_response(response) + case response.code + when 200 + JSON.parse(response.body, symbolize_names: true) + when 400 + Rails.logger.error "Lunchflow API: Bad request - #{response.body}" + raise LunchflowError.new("Bad request to Lunchflow API: #{response.body}", :bad_request) + when 401 + raise LunchflowError.new("Invalid API key", :unauthorized) + when 403 + raise LunchflowError.new("Access forbidden - check your API key permissions", :access_forbidden) + when 404 + raise LunchflowError.new("Resource not found", :not_found) + 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}" + raise LunchflowError.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed) + end + end + + class LunchflowError < StandardError + attr_reader :error_type + + def initialize(message, error_type = :unknown) + super(message) + @error_type = error_type + end + end +end diff --git a/app/models/provider/lunchflow_adapter.rb b/app/models/provider/lunchflow_adapter.rb new file mode 100644 index 000000000..8eb2688e0 --- /dev/null +++ b/app/models/provider/lunchflow_adapter.rb @@ -0,0 +1,104 @@ +class Provider::LunchflowAdapter < Provider::Base + include Provider::Syncable + include Provider::InstitutionMetadata + include Provider::Configurable + + # Register this adapter with the factory + Provider::Factory.register("LunchflowAccount", self) + + # Configuration for Lunchflow + 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 + 3. Choose the appropriate environment (production or staging) + DESC + + field :api_key, + label: "API Key", + required: true, + secret: true, + env_key: "LUNCHFLOW_API_KEY", + description: "Your Lunchflow 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" + end + + def provider_name + "lunchflow" + end + + # Build a Lunchflow 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) + return nil unless api_key.present? + + base_url = config_value(:base_url).presence || "https://lunchflow.app/api/v1" + Provider::Lunchflow.new(api_key, base_url: base_url) + end + + # Reload Lunchflow configuration when settings are updated + def self.reload_configuration + # Lunchflow 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 + end + + def sync_path + Rails.application.routes.url_helpers.sync_lunchflow_item_path(item) + end + + def item + provider_account.lunchflow_item + end + + def can_delete_holdings? + false + end + + def institution_domain + # Lunchflow may provide institution metadata in account data + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + domain = metadata["domain"] + url = metadata["url"] + + # Derive domain from URL if missing + if domain.blank? && url.present? + begin + domain = URI.parse(url).host&.gsub(/^www\./, "") + rescue URI::InvalidURIError + Rails.logger.warn("Invalid institution URL for Lunchflow account #{provider_account.id}: #{url}") + end + end + + domain + end + + def institution_name + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["name"] || item&.institution_name + end + + def institution_url + metadata = provider_account.institution_metadata + return nil unless metadata.present? + + metadata["url"] || item&.institution_url + end + + def institution_color + item&.institution_color + end +end diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index 2ec1c88ba..add803979 100644 --- a/app/models/provider_merchant.rb +++ b/app/models/provider_merchant.rb @@ -1,5 +1,5 @@ class ProviderMerchant < Merchant - enum :source, { plaid: "plaid", simplefin: "simplefin", synth: "synth", ai: "ai" } + enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai" } validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/models/simplefin_account/transactions/merchant_detector.rb b/app/models/simplefin_account/transactions/merchant_detector.rb index abc2cb893..a7eabb48e 100644 --- a/app/models/simplefin_account/transactions/merchant_detector.rb +++ b/app/models/simplefin_account/transactions/merchant_detector.rb @@ -1,3 +1,5 @@ +require "digest/md5" + # Detects and creates merchant records from SimpleFin transaction data # SimpleFin provides clean payee data that works well for merchant identification class SimplefinAccount::Transactions::MerchantDetector diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index c77557bd9..d92631435 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -21,7 +21,7 @@ -<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? %> <%= render "empty" %> <% else %>
@@ -33,6 +33,10 @@ <%= render @simplefin_items.sort_by(&:created_at) %> <% end %> + <% if @lunchflow_items.any? %> + <%= render @lunchflow_items.sort_by(&:created_at) %> + <% end %> + <% if @manual_accounts.any? %> <%= render "accounts/index/manual_accounts", accounts: @manual_accounts %> <% end %> diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index 5f26c1514..8a3456744 100644 --- a/app/views/accounts/new/_method_selector.html.erb +++ b/app/views/accounts/new/_method_selector.html.erb @@ -1,4 +1,4 @@ -<%# locals: (path:, accountable_type:, show_us_link: true, show_eu_link: true) %> +<%# 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 %>
@@ -33,5 +33,20 @@ <% end %> <% end %> + <%# Lunchflow Link %> + <% if show_lunchflow_link %> + <%= link_to select_accounts_lunchflow_items_path(accountable_type: accountable_type, return_to: params[:return_to]), + 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" + } do %> + + <%= icon("link-2") %> + + <%= t("accounts.new.method_selector.lunchflow_entry") %> + <% end %> + <% end %> +
<% end %> diff --git a/app/views/credit_cards/new.html.erb b/app/views/credit_cards/new.html.erb index a81805f24..3d751334f 100644 --- a/app/views/credit_cards/new.html.erb +++ b/app/views/credit_cards/new.html.erb @@ -3,6 +3,7 @@ path: new_credit_card_path(return_to: params[:return_to]), show_us_link: @show_us_link, show_eu_link: @show_eu_link, + show_lunchflow_link: @show_lunchflow_link, accountable_type: "CreditCard" %> <% else %> <%= render DS::Dialog.new do |dialog| %> diff --git a/app/views/depositories/new.html.erb b/app/views/depositories/new.html.erb index f75ed00b5..331facaa0 100644 --- a/app/views/depositories/new.html.erb +++ b/app/views/depositories/new.html.erb @@ -3,6 +3,7 @@ path: new_depository_path(return_to: params[:return_to]), show_us_link: @show_us_link, show_eu_link: @show_eu_link, + show_lunchflow_link: @show_lunchflow_link, accountable_type: "Depository" %> <% else %> <%= render DS::Dialog.new do |dialog| %> diff --git a/app/views/lunchflow_items/_loading.html.erb b/app/views/lunchflow_items/_loading.html.erb new file mode 100644 index 000000000..e0ea126c1 --- /dev/null +++ b/app/views/lunchflow_items/_loading.html.erb @@ -0,0 +1,16 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".loading_title")) %> + + <% dialog.with_body do %> +
+
+ <%= icon("loader-circle", class: "h-8 w-8 animate-spin text-primary") %> +

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

+
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/lunchflow_items/_lunchflow_item.html.erb b/app/views/lunchflow_items/_lunchflow_item.html.erb new file mode 100644 index 000000000..be34da70b --- /dev/null +++ b/app/views/lunchflow_items/_lunchflow_item.html.erb @@ -0,0 +1,79 @@ +<%# locals: (lunchflow_item:) %> + +<%= tag.div id: dom_id(lunchflow_item) do %> +
+ +
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> + +
+ <% if lunchflow_item.logo.attached? %> + <%= image_tag lunchflow_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> + <% else %> +
+ <%= tag.p lunchflow_item.name.first.upcase, class: "text-orange-600 text-xs font-medium" %> +
+ <% end %> +
+ +
+
+ <%= tag.p lunchflow_item.name, class: "font-medium text-primary" %> + <% if lunchflow_item.scheduled_for_deletion? %> +

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

+ <% end %> +
+ <% if lunchflow_item.syncing? %> +
+ <%= icon "loader", size: "sm", class: "animate-spin" %> + <%= tag.span t(".syncing") %> +
+ <% elsif lunchflow_item.sync_error.present? %> +
+ <%= render DS::Tooltip.new(text: lunchflow_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %> + <%= tag.span t(".error"), class: "text-destructive" %> +
+ <% else %> +

+ <%= lunchflow_item.last_synced_at ? t(".status", timestamp: time_ago_in_words(lunchflow_item.last_synced_at)) : t(".status_never") %> +

+ <% end %> +
+
+ +
+ <% if Rails.env.development? %> + <%= icon( + "refresh-cw", + as_button: true, + href: sync_lunchflow_item_path(lunchflow_item) + ) %> + <% end %> + + <%= render DS::Menu.new do |menu| %> + <% menu.with_item( + variant: "button", + text: t(".delete"), + icon: "trash-2", + href: lunchflow_item_path(lunchflow_item), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(lunchflow_item.name, high_severity: true) + ) %> + <% end %> +
+
+ + <% unless lunchflow_item.scheduled_for_deletion? %> +
+ <% if lunchflow_item.accounts.any? %> + <%= render "accounts/index/account_groups", accounts: lunchflow_item.accounts %> + <% else %> +
+

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

+

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

+
+ <% end %> +
+ <% end %> +
+<% end %> diff --git a/app/views/lunchflow_items/select_accounts.html.erb b/app/views/lunchflow_items/select_accounts.html.erb new file mode 100644 index 000000000..c92cbaf02 --- /dev/null +++ b/app/views/lunchflow_items/select_accounts.html.erb @@ -0,0 +1,43 @@ +<%= turbo_frame_tag "modal" do %> + <%= render DS::Dialog.new do |dialog| %> + <% dialog.with_header(title: t(".title")) %> + + <% dialog.with_body do %> +
+

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

+ +
+ <%= hidden_field_tag :authenticity_token, form_authenticity_token %> + <%= hidden_field_tag :accountable_type, @accountable_type %> + <%= hidden_field_tag :return_to, @return_to %> + +
+ <% @available_accounts.each do |account| %> + + <% end %> +
+ +
+ <%= 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" } %> + <%= 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" %> +
+
+
+ <% end %> + <% end %> +<% end %> diff --git a/app/views/settings/api_keys/show.html.erb b/app/views/settings/api_keys/show.html.erb index 42dde73de..abddad549 100644 --- a/app/views/settings/api_keys/show.html.erb +++ b/app/views/settings/api_keys/show.html.erb @@ -148,9 +148,9 @@
<% else %>
-

API Key

+

<%= t(".no_api_key.title") %>

<%= render DS::Link.new( - text: "Create API Key", + text: t(".no_api_key.create_api_key"), href: new_settings_api_key_path, variant: "primary" ) %> @@ -166,33 +166,34 @@ size: "lg" ) %>
-

Access your account data programmatically

-

Generate an API key to integrate with your applications and access your financial data securely.

+

<%= t(".no_api_key.heading", product_name: product_name) %>

+

<%= t(".no_api_key.description") %>

-

What you can do with API keys:

+

<%= t(".no_api_key.what_you_can_do") %>

+ +
+

<%= t(".no_api_key.security_note_title") %>

+

<%= t(".no_api_key.security_note") %>

+
<% end %> diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index d553b1636..9e055af26 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -28,6 +28,7 @@ en: method_selector: connected_entry: Link account connected_entry_eu: Link EU account + lunchflow_entry: Link Lunch Flow account manual_entry: Enter account balance title: How would you like to add it? title: What would you like to add? diff --git a/config/locales/views/lunchflow_items/en.yml b/config/locales/views/lunchflow_items/en.yml new file mode 100644 index 000000000..5555903e7 --- /dev/null +++ b/config/locales/views/lunchflow_items/en.yml @@ -0,0 +1,45 @@ +--- +en: + lunchflow_items: + create: + success: Lunch Flow connection created successfully + destroy: + success: Lunch Flow connection removed + index: + title: Lunch Flow Connections + loading: + loading_message: Loading Lunch Flow accounts... + loading_title: Loading + link_accounts: + all_already_linked: + one: "The selected account (%{names}) is already linked" + other: "All %{count} selected accounts are already linked: %{names}" + api_error: "API error: %{message}" + link_failed: Failed to link accounts + no_accounts_selected: Please select at least one account + partial_success: "Successfully linked %{created_count} account(s). %{already_linked_count} account(s) were already linked: %{already_linked_names}" + success: + one: "Successfully linked %{count} account" + other: "Successfully linked %{count} accounts" + lunchflow_item: + 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 + status: "Synced %{timestamp} ago" + status_never: Never synced + syncing: Syncing... + select_accounts: + accounts_selected: accounts selected + api_error: "API error: %{message}" + cancel: Cancel + description: Select the accounts you want to link to your Sure account. + link_accounts: Link selected accounts + no_accounts_found: No accounts found. Please check your API key configuration. + no_api_key: Lunch Flow API key is not configured. Please configure it in Settings. + title: Select Lunch Flow Accounts + sync: + success: Sync started + update: + success: Lunch Flow connection updated diff --git a/config/locales/views/settings/api_keys/en.yml b/config/locales/views/settings/api_keys/en.yml index 05a526420..07f25246b 100644 --- a/config/locales/views/settings/api_keys/en.yml +++ b/config/locales/views/settings/api_keys/en.yml @@ -13,33 +13,34 @@ en: api_keys: show: title: "API Key Management" - no_api_key: - title: "API Key" - description: "Get programmatic access to your Maybe data with a secure API key." - what_you_can_do: "What you can do with the API:" - feature_1: "Access your account data programmatically" - feature_2: "Build custom integrations and applications" - feature_3: "Automate data retrieval and analysis" - security_note_title: "Security First" - security_note: "Your API key will have restricted permissions based on the scopes you select. You can only have one active API key at a time." - create_api_key: "Create API Key" - current_api_key: - title: "Your API Key" - description: "Your active API key is ready to use. Keep it secure and never share it publicly." - active: "Active" - key_name: "Name" - created_at: "Created" - last_used: "Last Used" - expires: "Expires" - ago: "ago" - never_used: "Never used" - never_expires: "Never expires" - permissions: "Permissions" - usage_instructions_title: "How to use your API key" - usage_instructions: "Include your API key in the X-Api-Key header when making requests to the Maybe API:" - regenerate_key: "Create New Key" - revoke_key: "Revoke Key" - revoke_confirmation: "Are you sure you want to revoke this API key? This action cannot be undone and will immediately disable all applications using this key." + no_api_key: + title: "API Key" + heading: "Access your account data programmatically" + description: "Get programmatic access to your Sure data with a secure API key." + what_you_can_do: "What you can do with the API:" + feature_1: "Access your account data programmatically" + feature_2: "Build custom integrations and applications" + feature_3: "Automate data retrieval and analysis" + security_note_title: "Security First" + security_note: "Your API key will have restricted permissions based on the scopes you select. You can only have one active API key at a time." + create_api_key: "Create API Key" + current_api_key: + title: "Your API Key" + description: "Your active API key is ready to use. Keep it secure and never share it publicly." + active: "Active" + key_name: "Name" + created_at: "Created" + last_used: "Last Used" + expires: "Expires" + ago: "ago" + never_used: "Never used" + never_expires: "Never expires" + permissions: "Permissions" + usage_instructions_title: "How to use your API key" + usage_instructions: "Include your API key in the X-Api-Key header when making requests to the Maybe API:" + regenerate_key: "Create New Key" + revoke_key: "Revoke Key" + revoke_confirmation: "Are you sure you want to revoke this API key? This action cannot be undone and will immediately disable all applications using this key." new: title: "Create API Key" create_new_key: "Create New API Key" diff --git a/config/routes.rb b/config/routes.rb index a48449f72..72a0abdb7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -273,6 +273,17 @@ Rails.application.routes.draw do end end + resources :lunchflow_items, only: %i[index new create show edit update destroy] do + collection do + get :select_accounts + post :link_accounts + end + + member do + post :sync + end + end + namespace :webhooks do post "plaid" post "plaid_eu" diff --git a/db/migrate/20251029135200_create_lunchflow_items.rb b/db/migrate/20251029135200_create_lunchflow_items.rb new file mode 100644 index 000000000..5304f1377 --- /dev/null +++ b/db/migrate/20251029135200_create_lunchflow_items.rb @@ -0,0 +1,23 @@ +class CreateLunchflowItems < ActiveRecord::Migration[7.2] + def change + create_table :lunchflow_items, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.string :name + t.string :institution_id + t.string :institution_name + t.string :institution_domain + t.string :institution_url + t.string :institution_color + t.string :status, default: "good" + t.boolean :scheduled_for_deletion, default: false + t.boolean :pending_account_setup, default: false + t.datetime :sync_start_date + + t.index :status + t.jsonb :raw_payload + t.jsonb :raw_institution_payload + + t.timestamps + end + end +end diff --git a/db/migrate/20251029135300_create_lunchflow_accounts.rb b/db/migrate/20251029135300_create_lunchflow_accounts.rb new file mode 100644 index 000000000..379ba811f --- /dev/null +++ b/db/migrate/20251029135300_create_lunchflow_accounts.rb @@ -0,0 +1,21 @@ +class CreateLunchflowAccounts < ActiveRecord::Migration[7.2] + def change + create_table :lunchflow_accounts, id: :uuid do |t| + t.references :lunchflow_item, null: false, foreign_key: true, type: :uuid + t.string :name + t.string :account_id + t.string :currency + t.decimal :current_balance, precision: 19, scale: 4 + t.string :account_status + t.string :provider + + t.index :account_id + t.string :account_type + t.jsonb :institution_metadata + t.jsonb :raw_payload + t.jsonb :raw_transactions_payload + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index eed02506e..a3cdddc55 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_10_28_174016) do +ActiveRecord::Schema[7.2].define(version: 2025_10_29_204447) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -465,6 +465,44 @@ ActiveRecord::Schema[7.2].define(version: 2025_10_28_174016) do t.string "subtype" end + create_table "lunchflow_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "lunchflow_item_id", null: false + t.string "name" + t.string "account_id" + t.string "currency" + t.decimal "current_balance", precision: 19, scale: 4 + t.string "account_status" + t.string "provider" + t.string "account_type" + t.jsonb "institution_metadata" + t.jsonb "raw_payload" + t.jsonb "raw_transactions_payload" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_lunchflow_accounts_on_account_id" + t.index ["lunchflow_item_id"], name: "index_lunchflow_accounts_on_lunchflow_item_id" + end + + create_table "lunchflow_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.string "name" + t.string "institution_id" + t.string "institution_name" + t.string "institution_domain" + t.string "institution_url" + t.string "institution_color" + t.string "status", default: "good" + t.boolean "scheduled_for_deletion", default: false + t.boolean "pending_account_setup", default: false + t.datetime "sync_start_date" + t.jsonb "raw_payload" + t.jsonb "raw_institution_payload" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_lunchflow_items_on_family_id" + t.index ["status"], name: "index_lunchflow_items_on_status" + end + create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name", null: false t.string "color" @@ -960,6 +998,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_10_28_174016) do add_foreign_key "invitations", "families" add_foreign_key "invitations", "users", column: "inviter_id" add_foreign_key "llm_usages", "families" + add_foreign_key "lunchflow_accounts", "lunchflow_items" + add_foreign_key "lunchflow_items", "families" add_foreign_key "merchants", "families" add_foreign_key "messages", "chats" add_foreign_key "mobile_devices", "users"