Lunchflow integration (#259)

* First pass lunch flow

* Fixes

- Fix apikey not being saved properly due to provider no reload support
- Fix proper messages if we try to link existing accounts.

* Fix better error handling

* Filter existing transactions and skip duplicates

* FIX messaging

* Branding :)

* Fix XSS and linter

* FIX provider concern

- also fix code duplication

* FIX md5 digest

* Updated determine_sync_start_date to be account-aware

* Review fixes

* Broaden error catch to not crash UI

* Fix buttons styling

* FIX process account error handling

* FIX account cap and url parsing

* Lunch Flow brand

* Found orphan i18n strings

* Remove per conversation with @sokie

---------

Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
soky srm
2025-10-30 14:07:16 +01:00
committed by GitHub
parent 801a3e87a9
commit 5eadfaad98
35 changed files with 1712 additions and 45 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
require "digest/md5"
class IncomeStatement
include Monetizable

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
module LunchflowItem::Provided
extend ActiveSupport::Concern
def lunchflow_provider
Provider::LunchflowAdapter.build_provider
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
</div>
</header>
<% 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 %>
<div class="space-y-2">
@@ -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 %>

View File

@@ -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 %>
<div class="text-sm">
@@ -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 %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= icon("link-2") %>
</span>
<%= t("accounts.new.method_selector.lunchflow_entry") %>
<% end %>
<% end %>
</div>
<% end %>

View File

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

View File

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

View File

@@ -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 %>
<div class="flex items-center justify-center py-8">
<div class="flex flex-col items-center gap-4">
<%= icon("loader-circle", class: "h-8 w-8 animate-spin text-primary") %>
<p class="text-sm text-secondary">
<%= t(".loading_message") %>
</p>
</div>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,79 @@
<%# locals: (lunchflow_item:) %>
<%= 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">
<div 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 bg-orange-600/10 rounded-full">
<% if lunchflow_item.logo.attached? %>
<%= image_tag lunchflow_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %>
<% else %>
<div class="flex items-center justify-center">
<%= tag.p lunchflow_item.name.first.upcase, class: "text-orange-600 text-xs font-medium" %>
</div>
<% end %>
</div>
<div class="pl-1 text-sm">
<div class="flex items-center gap-2">
<%= tag.p lunchflow_item.name, class: "font-medium text-primary" %>
<% if lunchflow_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
<% end %>
</div>
<% if lunchflow_item.syncing? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "loader", size: "sm", class: "animate-spin" %>
<%= tag.span t(".syncing") %>
</div>
<% elsif lunchflow_item.sync_error.present? %>
<div class="text-secondary flex items-center gap-1">
<%= render DS::Tooltip.new(text: lunchflow_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %>
<%= tag.span t(".error"), class: "text-destructive" %>
</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") %>
</p>
<% end %>
</div>
</div>
<div class="flex items-center gap-2">
<% 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 %>
</div>
</summary>
<% unless lunchflow_item.scheduled_for_deletion? %>
<div class="space-y-4 mt-4">
<% if lunchflow_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: lunchflow_item.accounts %>
<% else %>
<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>
</div>
<% end %>
</div>
<% end %>
</details>
<% end %>

View File

@@ -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 %>
<div class="space-y-4">
<p class="text-sm text-secondary">
<%= t(".description") %>
</p>
<form action="<%= link_accounts_lunchflow_items_path %>" method="post" class="space-y-4" data-turbo-frame="_top">
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
<%= hidden_field_tag :accountable_type, @accountable_type %>
<%= hidden_field_tag :return_to, @return_to %>
<div class="space-y-2">
<% @available_accounts.each do |account| %>
<label class="flex items-start gap-3 p-3 border border-primary rounded-lg hover:bg-subtle cursor-pointer transition-colors">
<%= check_box_tag "account_ids[]", account[:id], false, class: "mt-1" %>
<div class="flex-1">
<div class="font-medium text-sm text-primary">
<%= account[:name] %>
</div>
<div class="text-xs text-secondary mt-1">
<%= account[:institution_name] %> • <%= account[:currency] %> • <%= account[:status] %>
</div>
</div>
</label>
<% end %>
</div>
<div class="flex gap-2 justify-end pt-4">
<%= link_to t(".cancel"), @return_to || new_account_path,
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
data: { turbo_frame: "_top" } %>
<%= submit_tag t(".link_accounts"),
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %>
</div>
</form>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -148,9 +148,9 @@
</div>
<% else %>
<header class="flex items-center justify-between">
<h1 class="text-primary text-xl font-medium">API Key</h1>
<h1 class="text-primary text-xl font-medium"><%= t(".no_api_key.title") %></h1>
<%= 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"
) %>
<div class="flex-1">
<h3 class="font-medium text-primary">Access your account data programmatically</h3>
<p class="text-secondary text-sm mt-1">Generate an API key to integrate with your applications and access your financial data securely.</p>
<h3 class="font-medium text-primary"><%= t(".no_api_key.heading", product_name: product_name) %></h3>
<p class="text-secondary text-sm mt-1"><%= t(".no_api_key.description") %></p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">What you can do with API keys:</h4>
<h4 class="font-medium text-primary mb-3"><%= t(".no_api_key.what_you_can_do") %></h4>
<ul class="space-y-2 text-sm text-secondary">
<li class="flex items-start gap-2">
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
<span>Access your accounts and balances</span>
<span><%= t(".no_api_key.feature_1") %></span>
</li>
<li class="flex items-start gap-2">
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
<span>View transaction history</span>
<span><%= t(".no_api_key.feature_2") %></span>
</li>
<li class="flex items-start gap-2">
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
<span>Create new transactions</span>
</li>
<li class="flex items-start gap-2">
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
<span>Integrate with third-party applications</span>
<span><%= t(".no_api_key.feature_3") %></span>
</li>
</ul>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-2"><%= t(".no_api_key.security_note_title") %></h4>
<p class="text-secondary text-sm"><%= t(".no_api_key.security_note") %></p>
</div>
</div>
</div>
<% end %>