mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
221
app/controllers/lunchflow_items_controller.rb
Normal file
221
app/controllers/lunchflow_items_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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" ],
|
||||
|
||||
12
app/models/family/lunchflow_connectable.rb
Normal file
12
app/models/family/lunchflow_connectable.rb
Normal 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
|
||||
@@ -1,3 +1,5 @@
|
||||
require "digest/md5"
|
||||
|
||||
class IncomeStatement
|
||||
include Monetizable
|
||||
|
||||
|
||||
44
app/models/lunchflow_account.rb
Normal file
44
app/models/lunchflow_account.rb
Normal 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
|
||||
65
app/models/lunchflow_account/processor.rb
Normal file
65
app/models/lunchflow_account/processor.rb
Normal 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
|
||||
71
app/models/lunchflow_account/transactions/processor.rb
Normal file
71
app/models/lunchflow_account/transactions/processor.rb
Normal 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
|
||||
147
app/models/lunchflow_entry/processor.rb
Normal file
147
app/models/lunchflow_entry/processor.rb
Normal 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
|
||||
147
app/models/lunchflow_item.rb
Normal file
147
app/models/lunchflow_item.rb
Normal 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
|
||||
310
app/models/lunchflow_item/importer.rb
Normal file
310
app/models/lunchflow_item/importer.rb
Normal 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
|
||||
7
app/models/lunchflow_item/provided.rb
Normal file
7
app/models/lunchflow_item/provided.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module LunchflowItem::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def lunchflow_provider
|
||||
Provider::LunchflowAdapter.build_provider
|
||||
end
|
||||
end
|
||||
25
app/models/lunchflow_item/sync_complete_event.rb
Normal file
25
app/models/lunchflow_item/sync_complete_event.rb
Normal 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
|
||||
61
app/models/lunchflow_item/syncer.rb
Normal file
61
app/models/lunchflow_item/syncer.rb
Normal 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
|
||||
120
app/models/provider/lunchflow.rb
Normal file
120
app/models/provider/lunchflow.rb
Normal 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
|
||||
104
app/models/provider/lunchflow_adapter.rb
Normal file
104
app/models/provider/lunchflow_adapter.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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| %>
|
||||
|
||||
@@ -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| %>
|
||||
|
||||
16
app/views/lunchflow_items/_loading.html.erb
Normal file
16
app/views/lunchflow_items/_loading.html.erb
Normal 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 %>
|
||||
79
app/views/lunchflow_items/_lunchflow_item.html.erb
Normal file
79
app/views/lunchflow_items/_lunchflow_item.html.erb
Normal 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 %>
|
||||
43
app/views/lunchflow_items/select_accounts.html.erb
Normal file
43
app/views/lunchflow_items/select_accounts.html.erb
Normal 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 %>
|
||||
@@ -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 %>
|
||||
|
||||
Reference in New Issue
Block a user