mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Add Sophtron Provider (#596)
* Add Sophtron Provider * fix syncer test issue * fix schema wrong merge * sync #588 * sync code for #588 * fixed a view issue * modified by comment * modified * modifed * modified * modified * fixed a schema issue * use global subtypes * add some locales * fix a safe_return_to_path * fix exposing raw exception messages issue * fix a merged issue * update schema.rb * fix a schema issue * fix some issue * Update bank sync controller to reflect beta status Signed-off-by: Juan José Mata <jjmata@jjmata.com> * Rename settings section title to 'Sophtron (alpha)' Signed-off-by: Juan José Mata <jjmata@jjmata.com> * Consistency in alpha/beta for Sophtron * Good PR suggestions from CodeRabbit --------- Signed-off-by: soky srm <sokysrm@gmail.com> Signed-off-by: Sophtron Rocky <rocky@sophtron.com> Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Signed-off-by: Juan José Mata <jjmata@jjmata.com> Co-authored-by: soky srm <sokysrm@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <jjmata@jjmata.com>
This commit is contained in:
@@ -20,6 +20,7 @@ class AccountsController < ApplicationController
|
||||
@coinbase_items = visible_provider_items(family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs))
|
||||
@snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts))
|
||||
@indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts))
|
||||
@sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts))
|
||||
|
||||
# Build sync stats maps for all providers
|
||||
build_sync_stats_maps
|
||||
@@ -299,6 +300,13 @@ class AccountsController < ApplicationController
|
||||
@coinstats_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
||||
end
|
||||
|
||||
# Sophtron sync stats
|
||||
@sophtron_sync_stats_map = {}
|
||||
@sophtron_items.each do |item|
|
||||
latest_sync = item.syncs.ordered.first
|
||||
@sophtron_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
||||
end
|
||||
|
||||
# Mercury sync stats
|
||||
@mercury_sync_stats_map = {}
|
||||
@mercury_items.each do |item|
|
||||
|
||||
@@ -30,6 +30,13 @@ class Settings::BankSyncController < ApplicationController
|
||||
path: "https://enablebanking.com",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer"
|
||||
},
|
||||
{
|
||||
name: "Sophtron (alpha)",
|
||||
description: "US & Canada bank, credit card, investment, loan, insurance, utility, and other connections.",
|
||||
path: "https://www.sophtron.com/",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer"
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@ class Settings::ProvidersController < ApplicationController
|
||||
def show
|
||||
@breadcrumbs = [
|
||||
[ "Home", root_path ],
|
||||
[ "Sync Providers", nil ]
|
||||
[ "Bank Sync Providers", nil ]
|
||||
]
|
||||
|
||||
prepare_show_context
|
||||
@@ -125,7 +125,8 @@ class Settings::ProvidersController < ApplicationController
|
||||
Provider::Factory.ensure_adapters_loaded
|
||||
@provider_configurations = Provider::ConfigurationRegistry.all.reject do |config|
|
||||
config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \
|
||||
config.provider_key.to_s.casecmp("enable_banking").zero? || \
|
||||
config.provider_key.to_s.casecmp("enable_banking").zero? || \
|
||||
config.provider_key.to_s.casecmp("sophtron").zero? || \
|
||||
config.provider_key.to_s.casecmp("coinstats").zero? || \
|
||||
config.provider_key.to_s.casecmp("mercury").zero? || \
|
||||
config.provider_key.to_s.casecmp("coinbase").zero? || \
|
||||
@@ -137,6 +138,8 @@ class Settings::ProvidersController < ApplicationController
|
||||
@simplefin_items = Current.family.simplefin_items.where.not(access_url: [ nil, "" ]).ordered.select(:id)
|
||||
@lunchflow_items = Current.family.lunchflow_items.where.not(api_key: [ nil, "" ]).ordered.select(:id)
|
||||
@enable_banking_items = Current.family.enable_banking_items.ordered # Enable Banking panel needs session info for status display
|
||||
# Providers page only needs to know whether any Sophtron connections exist with valid credentials
|
||||
@sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id)
|
||||
@coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display
|
||||
@mercury_items = Current.family.mercury_items.ordered.select(:id)
|
||||
@coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display
|
||||
|
||||
757
app/controllers/sophtron_items_controller.rb
Normal file
757
app/controllers/sophtron_items_controller.rb
Normal file
@@ -0,0 +1,757 @@
|
||||
class SophtronItemsController < ApplicationController
|
||||
before_action :set_sophtron_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
|
||||
|
||||
def index
|
||||
@sophtron_items = Current.family.sophtron_items.active.ordered
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
# Preload Sophtron accounts in background (async, non-blocking)
|
||||
def preload_accounts
|
||||
begin
|
||||
# Check if family has credentials
|
||||
unless Current.family.has_sophtron_credentials?
|
||||
render json: { success: false, error: "no_credentials_configured", has_accounts: false }
|
||||
return
|
||||
end
|
||||
|
||||
cache_key = "sophtron_accounts_#{Current.family.id}"
|
||||
|
||||
# Check if already cached
|
||||
cached_accounts = Rails.cache.read(cache_key)
|
||||
|
||||
if cached_accounts.present?
|
||||
render json: { success: true, has_accounts: cached_accounts.any?, cached: true }
|
||||
return
|
||||
end
|
||||
|
||||
# Fetch from API
|
||||
sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family)
|
||||
|
||||
unless sophtron_provider.present?
|
||||
render json: { success: false, error: "no_access_key", has_accounts: false }
|
||||
return
|
||||
end
|
||||
|
||||
response = sophtron_provider.get_accounts
|
||||
available_accounts = response.data[:accounts] || []
|
||||
|
||||
# Cache the accounts for 5 minutes
|
||||
Rails.cache.write(cache_key, available_accounts, expires_in: 5.minutes)
|
||||
|
||||
render json: { success: true, has_accounts: available_accounts.any?, cached: false }
|
||||
rescue Provider::Error => e
|
||||
Rails.logger.error("Sophtron preload error: #{e.message}")
|
||||
# API error (bad key, network issue, etc) - keep button visible, show error when clicked
|
||||
render json: { success: false, error: "api_error", error_message: t(".api_error"), has_accounts: nil }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error preloading Sophtron accounts: #{e.class}: #{e.message}")
|
||||
# Unexpected error - keep button visible, show error when clicked
|
||||
render json: { success: false, error: "unexpected_error", error_message: t(".unexpected_error"), has_accounts: nil }
|
||||
end
|
||||
end
|
||||
|
||||
# Fetch available accounts from Sophtron API and show selection UI
|
||||
def select_accounts
|
||||
begin
|
||||
# Check if family has Sophtron credentials configured
|
||||
unless Current.family.has_sophtron_credentials?
|
||||
if turbo_frame_request?
|
||||
# Render setup modal for turbo frame requests
|
||||
render partial: "sophtron_items/setup_required", layout: false
|
||||
else
|
||||
# Redirect for regular requests
|
||||
redirect_to settings_providers_path,
|
||||
alert: t(".no_credentials_configured")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
cache_key = "sophtron_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?
|
||||
sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family)
|
||||
|
||||
unless sophtron_provider.present?
|
||||
redirect_to settings_providers_path, alert: t(".no_access_key")
|
||||
return
|
||||
end
|
||||
|
||||
response = sophtron_provider.get_accounts
|
||||
@available_accounts = response.data[:accounts] || []
|
||||
|
||||
# Cache the accounts for 5 minutes
|
||||
Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes)
|
||||
end
|
||||
|
||||
# Filter out already linked accounts
|
||||
sophtron_item = Current.family.sophtron_items.first
|
||||
if sophtron_item
|
||||
linked_account_ids = sophtron_item.sophtron_accounts.joins(:account_provider).pluck(:account_id)
|
||||
@available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) }
|
||||
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::Error => e
|
||||
Rails.logger.error("Sophtron API error in select_accounts: #{e.message}")
|
||||
@error_message = t(".api_error")
|
||||
@return_path = safe_return_to_path
|
||||
render partial: "sophtron_items/api_error",
|
||||
locals: { error_message: @error_message, return_path: @return_path },
|
||||
layout: false
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error in select_accounts: #{e.class}: #{e.message}")
|
||||
@error_message = t(".unexpected_error")
|
||||
@return_path = safe_return_to_path
|
||||
render partial: "sophtron_items/api_error",
|
||||
locals: { error_message: @error_message, return_path: @return_path },
|
||||
layout: false
|
||||
end
|
||||
end
|
||||
|
||||
# Create accounts from selected Sophtron 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 sophtron_item for this family
|
||||
sophtron_item = Current.family.sophtron_items.first_or_create!(
|
||||
name: t("sophtron_items.defaults.name")
|
||||
)
|
||||
|
||||
# Fetch account details from API
|
||||
sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family)
|
||||
unless sophtron_provider.present?
|
||||
redirect_to new_account_path, alert: t(".no_access_key")
|
||||
return
|
||||
end
|
||||
|
||||
response = sophtron_provider.get_accounts
|
||||
|
||||
created_accounts = []
|
||||
already_linked_accounts = []
|
||||
invalid_accounts = []
|
||||
|
||||
selected_account_ids.each do |account_id|
|
||||
# Find the account data from API response
|
||||
account_data = response.data[:accounts].find { |acc| acc[:id].to_s == account_id.to_s }
|
||||
next unless account_data
|
||||
|
||||
# Validate account name is not blank (required by Account model)
|
||||
if account_data[:account_name].blank?
|
||||
invalid_accounts << account_id
|
||||
Rails.logger.warn "SophtronItemsController - Skipping account #{account_id} with blank name"
|
||||
next
|
||||
end
|
||||
|
||||
# Create or find sophtron_account
|
||||
sophtron_account = sophtron_item.sophtron_accounts.find_or_initialize_by(
|
||||
account_id: account_id.to_s
|
||||
)
|
||||
sophtron_account.upsert_sophtron_snapshot!(account_data)
|
||||
sophtron_account.save!
|
||||
# Check if this sophtron_account is already linked
|
||||
if sophtron_account.account_provider.present?
|
||||
already_linked_accounts << account_data[:account_name]
|
||||
next
|
||||
end
|
||||
|
||||
# Create the internal Account with proper balance initialization
|
||||
account = Account.create_and_sync(
|
||||
{
|
||||
family: Current.family,
|
||||
name: account_data[:account_name],
|
||||
balance: 0, # Initial balance will be set during sync
|
||||
currency: account_data[:currency] || "USD",
|
||||
accountable_type: accountable_type,
|
||||
accountable_attributes: {}
|
||||
},
|
||||
skip_initial_sync: true
|
||||
)
|
||||
# Link account to sophtron_account via account_providers join table
|
||||
AccountProvider.create!(
|
||||
account: account,
|
||||
provider: sophtron_account
|
||||
)
|
||||
|
||||
created_accounts << account
|
||||
end
|
||||
|
||||
# Trigger sync to fetch transactions if any accounts were created
|
||||
sophtron_item.sync_later if created_accounts.any?
|
||||
|
||||
# Build appropriate flash message
|
||||
if invalid_accounts.any? && created_accounts.empty? && already_linked_accounts.empty?
|
||||
# All selected accounts were invalid (blank names)
|
||||
redirect_to new_account_path, alert: t(".invalid_account_names", count: invalid_accounts.count)
|
||||
elsif invalid_accounts.any? && (created_accounts.any? || already_linked_accounts.any?)
|
||||
# Some accounts were created/already linked, but some had invalid names
|
||||
redirect_to return_to || accounts_path,
|
||||
alert: t(".partial_invalid",
|
||||
created_count: created_accounts.count,
|
||||
already_linked_count: already_linked_accounts.count,
|
||||
invalid_count: invalid_accounts.count)
|
||||
elsif 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::Error => e
|
||||
redirect_to new_account_path, alert: t(".api_error")
|
||||
Rails.logger.error("Sophtron API error in link_accounts: #{e.message}")
|
||||
end
|
||||
|
||||
# Fetch available Sophtron accounts to link with an existing account
|
||||
def select_existing_account
|
||||
account_id = params[:account_id]
|
||||
|
||||
unless account_id.present?
|
||||
redirect_to accounts_path, alert: t(".no_account_specified")
|
||||
return
|
||||
end
|
||||
|
||||
@account = Current.family.accounts.find(account_id)
|
||||
|
||||
# Check if account is already linked
|
||||
if @account.account_providers.exists?
|
||||
redirect_to accounts_path, alert: t(".account_already_linked")
|
||||
return
|
||||
end
|
||||
|
||||
# Check if family has Sophtron credentials configured
|
||||
unless Current.family.has_sophtron_credentials?
|
||||
if turbo_frame_request?
|
||||
# Render setup modal for turbo frame requests
|
||||
render partial: "sophtron_items/setup_required", layout: false
|
||||
else
|
||||
# Redirect for regular requests
|
||||
redirect_to settings_providers_path,
|
||||
alert: t(".no_credentials_configured",
|
||||
default: "Please configure your Sophtron API key first in Provider Settings.")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
cache_key = "sophtron_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?
|
||||
sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family)
|
||||
|
||||
unless sophtron_provider.present?
|
||||
redirect_to settings_providers_path, alert: t(".no_access_key")
|
||||
return
|
||||
end
|
||||
|
||||
response = sophtron_provider.get_accounts
|
||||
@available_accounts = response.data[:accounts] || []
|
||||
|
||||
# Cache the accounts for 5 minutes
|
||||
Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes)
|
||||
end
|
||||
|
||||
if @available_accounts.empty?
|
||||
redirect_to accounts_path, alert: t(".no_accounts_found")
|
||||
return
|
||||
end
|
||||
|
||||
# Filter out already linked accounts
|
||||
sophtron_item = Current.family.sophtron_items.first
|
||||
if sophtron_item
|
||||
linked_account_ids = sophtron_item.sophtron_accounts.joins(:account_provider).pluck(:account_id)
|
||||
@available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) }
|
||||
end
|
||||
|
||||
if @available_accounts.empty?
|
||||
redirect_to accounts_path, alert: t(".all_accounts_already_linked")
|
||||
return
|
||||
end
|
||||
|
||||
@return_to = safe_return_to_path
|
||||
|
||||
render layout: false
|
||||
rescue Provider::Error => e
|
||||
Rails.logger.error("Sophtron API error in select_existing_account: #{e.message}")
|
||||
@error_message = t(".api_error", message: e.message)
|
||||
render partial: "sophtron_items/api_error",
|
||||
locals: { error_message: @error_message, return_path: accounts_path },
|
||||
layout: false
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error in select_existing_account: #{e.class}: #{e.message}")
|
||||
@error_message = t(".unexpected_error")
|
||||
render partial: "sophtron_items/api_error",
|
||||
locals: { error_message: @error_message, return_path: accounts_path },
|
||||
layout: false
|
||||
end
|
||||
end
|
||||
|
||||
# Link a selected Sophtron account to an existing account
|
||||
def link_existing_account
|
||||
account_id = params[:account_id]
|
||||
sophtron_account_id = params[:sophtron_account_id]
|
||||
return_to = safe_return_to_path
|
||||
|
||||
unless account_id.present? && sophtron_account_id.present?
|
||||
redirect_to accounts_path, alert: t(".missing_parameters")
|
||||
return
|
||||
end
|
||||
|
||||
@account = Current.family.accounts.find(account_id)
|
||||
|
||||
# Check if account is already linked
|
||||
if @account.account_providers.exists?
|
||||
redirect_to accounts_path, alert: t(".account_already_linked")
|
||||
return
|
||||
end
|
||||
|
||||
# Create or find sophtron_item for this family
|
||||
sophtron_item = Current.family.sophtron_items.first_or_create!(
|
||||
name: "Sophtron Connection"
|
||||
)
|
||||
|
||||
# Fetch account details from API
|
||||
sophtron_provider = Provider::SophtronAdapter.build_provider(family: Current.family)
|
||||
unless sophtron_provider.present?
|
||||
redirect_to accounts_path, alert: t(".no_access_key")
|
||||
return
|
||||
end
|
||||
|
||||
response = sophtron_provider.get_accounts
|
||||
|
||||
# Find the selected Sophtron account data
|
||||
account_data = response.data[:accounts].find { |acc| acc[:id].to_s == sophtron_account_id.to_s }
|
||||
unless account_data
|
||||
redirect_to accounts_path, alert: t(".sophtron_account_not_found")
|
||||
return
|
||||
end
|
||||
|
||||
# Validate account name is not blank (required by Account model)
|
||||
if account_data[:account_name].blank?
|
||||
redirect_to accounts_path, alert: t(".invalid_account_name")
|
||||
return
|
||||
end
|
||||
|
||||
# Create or find sophtron_account
|
||||
sophtron_account = sophtron_item.sophtron_accounts.find_or_initialize_by(
|
||||
account_id: sophtron_account_id.to_s
|
||||
)
|
||||
sophtron_account.upsert_sophtron_snapshot!(account_data)
|
||||
sophtron_account.save!
|
||||
|
||||
# Check if this sophtron_account is already linked to another account
|
||||
if sophtron_account.account_provider.present?
|
||||
redirect_to accounts_path, alert: t(".sophtron_account_already_linked")
|
||||
return
|
||||
end
|
||||
|
||||
# Link account to sophtron_account via account_providers join table
|
||||
AccountProvider.create!(
|
||||
account: @account,
|
||||
provider: sophtron_account
|
||||
)
|
||||
|
||||
# Trigger sync to fetch transactions
|
||||
sophtron_item.sync_later
|
||||
redirect_to return_to || accounts_path,
|
||||
notice: t(".success", account_name: @account.name)
|
||||
rescue Provider::Error => e
|
||||
Rails.logger.error("Sophtron API error in link_existing_account: #{e.message}")
|
||||
redirect_to accounts_path, alert: t(".api_error")
|
||||
end
|
||||
|
||||
def new
|
||||
@sophtron_item = Current.family.sophtron_items.build
|
||||
end
|
||||
|
||||
def create
|
||||
@sophtron_item = Current.family.sophtron_items.build(sophtron_params)
|
||||
@sophtron_item.name ||= t("sophtron_items.defaults.name")
|
||||
if @sophtron_item.save
|
||||
# Trigger initial sync to fetch accounts
|
||||
@sophtron_item.sync_later
|
||||
if turbo_frame_request?
|
||||
flash.now[:notice] = t(".success")
|
||||
@sophtron_items = Current.family.sophtron_items.ordered
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"sophtron-providers-panel",
|
||||
partial: "settings/providers/sophtron_panel",
|
||||
locals: { sophtron_items: @sophtron_items }
|
||||
),
|
||||
*flash_notification_stream_items
|
||||
]
|
||||
else
|
||||
redirect_to accounts_path, notice: t(".success"), status: :see_other
|
||||
end
|
||||
else
|
||||
@error_message = @sophtron_item.errors.full_messages.join(", ")
|
||||
if turbo_frame_request?
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"sophtron-providers-panel",
|
||||
partial: "settings/providers/sophtron_panel",
|
||||
locals: { error_message: @error_message }
|
||||
), status: :unprocessable_entity
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @sophtron_item.update(sophtron_params)
|
||||
if turbo_frame_request?
|
||||
flash.now[:notice] = t(".success")
|
||||
@sophtron_items = Current.family.sophtron_items.ordered
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"sophtron-providers-panel",
|
||||
partial: "settings/providers/sophtron_panel",
|
||||
locals: { sophtron_items: @sophtron_items }
|
||||
),
|
||||
*flash_notification_stream_items
|
||||
]
|
||||
else
|
||||
redirect_to accounts_path, notice: t(".success"), status: :see_other
|
||||
end
|
||||
else
|
||||
@error_message = @sophtron_item.errors.full_messages.join(", ")
|
||||
if turbo_frame_request?
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"sophtron-providers-panel",
|
||||
partial: "settings/providers/sophtron_panel",
|
||||
locals: { error_message: @error_message }
|
||||
), status: :unprocessable_entity
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
# Ensure we detach provider links before scheduling deletion
|
||||
begin
|
||||
@sophtron_item.unlink_all!(dry_run: false)
|
||||
rescue => e
|
||||
Rails.logger.warn("Sophtron unlink during destroy failed: #{e.class} - #{e.message}")
|
||||
end
|
||||
@sophtron_item.destroy_later
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def sync
|
||||
unless @sophtron_item.syncing?
|
||||
@sophtron_item.sync_later
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to accounts_path }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
|
||||
# Show unlinked Sophtron accounts for setup (similar to SimpleFIN setup_accounts)
|
||||
def setup_accounts
|
||||
# First, ensure we have the latest accounts from the API
|
||||
@api_error = fetch_sophtron_accounts_from_api
|
||||
|
||||
# Get Sophtron accounts that are not linked (no AccountProvider)
|
||||
@sophtron_accounts = @sophtron_item.sophtron_accounts
|
||||
.left_joins(:account_provider)
|
||||
.where(account_providers: { id: nil })
|
||||
|
||||
# Get supported account types from the adapter
|
||||
supported_types = Provider::SophtronAdapter.supported_account_types
|
||||
|
||||
# Map of account type keys to their internal values
|
||||
account_type_keys = {
|
||||
"depository" => "Depository",
|
||||
"credit_card" => "CreditCard",
|
||||
"investment" => "Investment",
|
||||
"loan" => "Loan",
|
||||
"other_asset" => "OtherAsset"
|
||||
}
|
||||
|
||||
# Build account type options using i18n, filtering to supported types
|
||||
all_account_type_options = account_type_keys.filter_map do |key, type|
|
||||
next unless supported_types.include?(type)
|
||||
[ t(".account_types.#{key}"), type ]
|
||||
end
|
||||
|
||||
# Add "Skip" option at the beginning
|
||||
@account_type_options = [ [ t(".account_types.skip"), "skip" ] ] + all_account_type_options
|
||||
|
||||
# Subtype options for each account type
|
||||
@subtype_options = {
|
||||
"Depository" => {
|
||||
label: "Account Subtype:",
|
||||
options: Depository::SUBTYPES.map { |k, v| [ v[:long], k ] }
|
||||
},
|
||||
"CreditCard" => {
|
||||
label: "",
|
||||
options: [],
|
||||
message: "Credit cards will be automatically set up as credit card accounts."
|
||||
},
|
||||
"Investment" => {
|
||||
label: "Investment Type:",
|
||||
options: Investment::SUBTYPES.map { |k, v| [ v[:long], k ] }
|
||||
},
|
||||
"Loan" => {
|
||||
label: "Loan Type:",
|
||||
options: Loan::SUBTYPES.map { |k, v| [ v[:long], k ] }
|
||||
},
|
||||
"Crypto" => {
|
||||
label: nil,
|
||||
options: [],
|
||||
message: "Crypto accounts track cryptocurrency holdings."
|
||||
},
|
||||
"OtherAsset" => {
|
||||
label: nil,
|
||||
options: [],
|
||||
message: "No additional options needed for Other Assets."
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def complete_account_setup
|
||||
account_types = params[:account_types] || {}
|
||||
account_subtypes = params[:account_subtypes] || {}
|
||||
|
||||
# Valid account types for this provider
|
||||
valid_types = Provider::SophtronAdapter.supported_account_types
|
||||
|
||||
created_accounts = []
|
||||
skipped_count = 0
|
||||
|
||||
begin
|
||||
ActiveRecord::Base.transaction do
|
||||
account_types.each do |sophtron_account_id, selected_type|
|
||||
# Skip accounts marked as "skip"
|
||||
if selected_type == "skip" || selected_type.blank?
|
||||
skipped_count += 1
|
||||
next
|
||||
end
|
||||
|
||||
# Validate account type is supported
|
||||
unless valid_types.include?(selected_type)
|
||||
Rails.logger.warn("Invalid account type '#{selected_type}' submitted for Sophtron account #{sophtron_account_id}")
|
||||
next
|
||||
end
|
||||
|
||||
# Find account - scoped to this item to prevent cross-item manipulation
|
||||
sophtron_account = @sophtron_item.sophtron_accounts.find_by(id: sophtron_account_id)
|
||||
unless sophtron_account
|
||||
Rails.logger.warn("Sophtron account #{sophtron_account_id} not found for item #{@sophtron_item.id}")
|
||||
next
|
||||
end
|
||||
|
||||
# Skip if already linked (race condition protection)
|
||||
if sophtron_account.account_provider.present?
|
||||
Rails.logger.info("Sophtron account #{sophtron_account_id} already linked, skipping")
|
||||
next
|
||||
end
|
||||
|
||||
selected_subtype = account_subtypes[sophtron_account_id]
|
||||
|
||||
# Default subtype for CreditCard since it only has one option
|
||||
selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank?
|
||||
|
||||
# Create account with user-selected type and subtype (raises on failure)
|
||||
account = Account.create_and_sync(
|
||||
{
|
||||
family: Current.family,
|
||||
name: sophtron_account.name,
|
||||
balance: sophtron_account.balance || 0,
|
||||
currency: sophtron_account.currency || "USD",
|
||||
accountable_type: selected_type,
|
||||
accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {}
|
||||
},
|
||||
skip_initial_sync: true
|
||||
)
|
||||
|
||||
# Link account to sophtron_account via account_providers join table (raises on failure)
|
||||
AccountProvider.create!(
|
||||
account: account,
|
||||
provider: sophtron_account
|
||||
)
|
||||
|
||||
created_accounts << account
|
||||
end
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
|
||||
Rails.logger.error("Sophtron account setup failed: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.first(10).join("\n"))
|
||||
flash[:alert] = t(".creation_failed")
|
||||
redirect_to accounts_path, status: :see_other
|
||||
return
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Sophtron account setup failed unexpectedly: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.first(10).join("\n"))
|
||||
flash[:alert] = t(".unexpected_error")
|
||||
redirect_to accounts_path, status: :see_other
|
||||
return
|
||||
end
|
||||
|
||||
# Trigger a sync to process transactions
|
||||
@sophtron_item.sync_later if created_accounts.any?
|
||||
|
||||
# Set appropriate flash message
|
||||
if created_accounts.any?
|
||||
flash[:notice] = t(".success", count: created_accounts.count)
|
||||
elsif skipped_count > 0
|
||||
flash[:notice] = t(".all_skipped")
|
||||
else
|
||||
flash[:notice] = t(".no_accounts")
|
||||
end
|
||||
|
||||
if turbo_frame_request?
|
||||
# Recompute data needed by Accounts#index partials
|
||||
@manual_accounts = Account.uncached {
|
||||
Current.family.accounts
|
||||
.visible_manual
|
||||
.order(:name)
|
||||
.to_a
|
||||
}
|
||||
@sophtron_items = Current.family.sophtron_items.ordered
|
||||
manual_accounts_stream = if @manual_accounts.any?
|
||||
turbo_stream.update(
|
||||
"manual-accounts",
|
||||
partial: "accounts/index/manual_accounts",
|
||||
locals: { accounts: @manual_accounts }
|
||||
)
|
||||
else
|
||||
turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts"))
|
||||
end
|
||||
|
||||
render turbo_stream: [
|
||||
manual_accounts_stream,
|
||||
turbo_stream.replace(
|
||||
ActionView::RecordIdentifier.dom_id(@sophtron_item),
|
||||
partial: "sophtron_items/sophtron_item",
|
||||
locals: { sophtron_item: @sophtron_item }
|
||||
)
|
||||
] + Array(flash_notification_stream_items)
|
||||
else
|
||||
redirect_to accounts_path, status: :see_other
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Fetch Sophtron accounts from the API and store them locally
|
||||
# Returns nil on success, or an error message string on failure
|
||||
def fetch_sophtron_accounts_from_api
|
||||
# Skip if we already have accounts cached
|
||||
return nil unless @sophtron_item.sophtron_accounts.empty?
|
||||
|
||||
# Validate Access key is configured
|
||||
unless @sophtron_item.credentials_configured?
|
||||
return t("sophtron_items.setup_accounts.no_access_key")
|
||||
end
|
||||
|
||||
# Use the specific sophtron_item's provider (scoped to this family's item)
|
||||
sophtron_provider = @sophtron_item.sophtron_provider
|
||||
unless sophtron_provider.present?
|
||||
return t("sophtron_items.setup_accounts.no_access_key")
|
||||
end
|
||||
|
||||
begin
|
||||
response = sophtron_provider.get_accounts
|
||||
available_accounts = response.data[:accounts] || []
|
||||
|
||||
if available_accounts.empty?
|
||||
return nil
|
||||
end
|
||||
|
||||
available_accounts.each_with_index do |account_data, index|
|
||||
next if account_data[:account_name].blank?
|
||||
|
||||
sophtron_account = @sophtron_item.sophtron_accounts.find_or_initialize_by(
|
||||
account_id: account_data[:account_id].to_s
|
||||
)
|
||||
sophtron_account.upsert_sophtron_snapshot!(account_data)
|
||||
sophtron_account.save!
|
||||
end
|
||||
|
||||
nil # Success
|
||||
rescue Provider::Error => e
|
||||
Rails.logger.error("Sophtron API error: #{e.message}")
|
||||
t("sophtron_items.setup_accounts.api_error")
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error fetching Sophtron accounts: #{e.class}: #{e.message}")
|
||||
Rails.logger.error(e.backtrace.first(10).join("\n"))
|
||||
t("sophtron_items.setup_accounts.api_error")
|
||||
end
|
||||
end
|
||||
|
||||
def set_sophtron_item
|
||||
@sophtron_item = Current.family.sophtron_items.find(params[:id])
|
||||
end
|
||||
|
||||
def sophtron_params
|
||||
params.require(:sophtron_item).permit(:name, :user_id, :access_key, :base_url, :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? || uri.host.present?
|
||||
return nil if return_to.start_with?("//")
|
||||
# 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
|
||||
@@ -51,6 +51,7 @@ class IdentifyRecurringTransactionsJob < ApplicationJob
|
||||
return true if family.simplefin_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:simplefin_items)
|
||||
return true if family.lunchflow_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:lunchflow_items)
|
||||
return true if family.enable_banking_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:enable_banking_items)
|
||||
return true if family.sophtron_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:sophtron_items)
|
||||
|
||||
# Check accounts' syncs
|
||||
return true if family.accounts.joins(:syncs).merge(Sync.incomplete).exists?
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class DataEnrichment < ApplicationRecord
|
||||
belongs_to :enrichable, polymorphic: true
|
||||
|
||||
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital" }
|
||||
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital", sophtron: "sophtron" }
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Family < ApplicationRecord
|
||||
include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable
|
||||
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable
|
||||
include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable
|
||||
include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, SophtronConnectable
|
||||
include IndexaCapitalConnectable
|
||||
|
||||
DATE_FORMATS = [
|
||||
|
||||
29
app/models/family/sophtron_connectable.rb
Normal file
29
app/models/family/sophtron_connectable.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
module Family::SophtronConnectable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :sophtron_items, dependent: :destroy
|
||||
end
|
||||
|
||||
def can_connect_sophtron?
|
||||
# Families can now configure their own Sophtron credentials
|
||||
true
|
||||
end
|
||||
|
||||
def create_sophtron_item!(user_id:, access_key:, base_url: nil, item_name: nil)
|
||||
sophtron_item = sophtron_items.create!(
|
||||
name: item_name || "Sophtron Connection",
|
||||
user_id: user_id,
|
||||
access_key: access_key,
|
||||
base_url: base_url
|
||||
)
|
||||
|
||||
sophtron_item.sync_later
|
||||
|
||||
sophtron_item
|
||||
end
|
||||
|
||||
def has_sophtron_credentials?
|
||||
sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).exists?
|
||||
end
|
||||
end
|
||||
@@ -18,6 +18,7 @@ class Family::Syncer
|
||||
coinstats_items
|
||||
mercury_items
|
||||
snaptrade_items
|
||||
sophtron_items
|
||||
].freeze
|
||||
|
||||
def initialize(family)
|
||||
|
||||
373
app/models/provider/sophtron.rb
Normal file
373
app/models/provider/sophtron.rb
Normal file
@@ -0,0 +1,373 @@
|
||||
# Sophtron API client for account aggregation.
|
||||
#
|
||||
# This provider implements the Sophtron API v2 for fetching bank account data,
|
||||
# transactions, and balances. It uses HMAC-SHA256 authentication for secure
|
||||
# API requests.
|
||||
#
|
||||
# The Sophtron API organizes data hierarchically:
|
||||
# - Customers (identified by customer_id)
|
||||
# - Accounts (identified by account_id within a customer)
|
||||
# - Transactions (identified by transaction_id within an account)
|
||||
#
|
||||
# @example Initialize a Sophtron provider
|
||||
# provider = Provider::Sophtron.new(
|
||||
# "user123",
|
||||
# "base64_encoded_access_key",
|
||||
# base_url: "https://api.sophtron.com/api/v2"
|
||||
# )
|
||||
#
|
||||
# @see https://www.sophtron.com Documentation for Sophtron API
|
||||
class Provider::Sophtron < Provider
|
||||
include HTTParty
|
||||
|
||||
headers "User-Agent" => "Sure Finance So Client"
|
||||
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
|
||||
|
||||
attr_reader :user_id, :access_key, :base_url
|
||||
|
||||
# Initializes a new Sophtron API client.
|
||||
#
|
||||
# @param user_id [String] Sophtron User ID for authentication
|
||||
# @param access_key [String] Base64-encoded Sophtron Access Key
|
||||
# @param base_url [String] Base URL for the Sophtron API (defaults to production)
|
||||
def initialize(user_id, access_key, base_url: "https://api.sophtron.com/api/v2")
|
||||
@user_id = user_id
|
||||
@access_key = access_key
|
||||
@base_url = base_url
|
||||
super()
|
||||
end
|
||||
|
||||
# Fetches all accounts across all customers for this Sophtron user.
|
||||
#
|
||||
# This method:
|
||||
# 1. Fetches the list of customer IDs
|
||||
# 2. For each customer, fetches their accounts
|
||||
# 3. Normalizes and deduplicates the account data
|
||||
# 4. Returns a combined list of all accounts
|
||||
#
|
||||
# @return [Hash] Account data with keys:
|
||||
# - :accounts [Array<Hash>] Array of account objects
|
||||
# - :total [Integer] Total number of accounts
|
||||
# @raise [Provider::Error] if the API request fails
|
||||
# @example
|
||||
# result = provider.get_accounts
|
||||
# # => { accounts: [{id: "123", account_name: "Checking", ...}], total: 1 }
|
||||
def get_accounts
|
||||
with_provider_response do
|
||||
# fetching accounts for sophtron
|
||||
# Obtain customer IDs using a dedicated helper
|
||||
customer_ids = get_customer_ids
|
||||
|
||||
all_accounts = []
|
||||
customer_ids.each do |cust_id|
|
||||
begin
|
||||
accounts_resp = get_customer_accounts(cust_id)
|
||||
|
||||
# `handle_response` returns parsed JSON (hash/array) so normalize
|
||||
raw_accounts = if accounts_resp.is_a?(Hash) && accounts_resp[:accounts].is_a?(Array)
|
||||
accounts_resp[:accounts]
|
||||
elsif accounts_resp.is_a?(Array)
|
||||
accounts_resp
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
normalized = raw_accounts.map { |a| a.transform_keys { |k| k.to_s.underscore }.with_indifferent_access }
|
||||
|
||||
# Ensure each account has a customer_id set
|
||||
normalized.each do |acc|
|
||||
# check common variants that may already exist
|
||||
existing = acc[:customer_id]
|
||||
acc[:customer_id] = cust_id.to_s if existing.blank?
|
||||
end
|
||||
|
||||
all_accounts.concat(normalized)
|
||||
rescue Provider::Error => e
|
||||
Rails.logger.warn("Failed to fetch accounts for customer #{cust_id}: #{e.message}")
|
||||
rescue => e
|
||||
Rails.logger.warn("Unexpected error fetching accounts for customer #{cust_id}: #{e.class} #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
# Deduplicate by id where present
|
||||
unique_accounts = all_accounts.uniq { |a| a[:id].to_s }
|
||||
|
||||
{ accounts: unique_accounts, total: unique_accounts.length }
|
||||
end
|
||||
end
|
||||
|
||||
# Fetches transactions for a specific account.
|
||||
#
|
||||
# Retrieves transaction history for a given account within a date range.
|
||||
# If no end date is provided, defaults to tomorrow to include today's transactions.
|
||||
#
|
||||
# @param customer_id [String] Sophtron customer ID
|
||||
# @param account_id [String] Sophtron account ID
|
||||
# @param start_date [Date, nil] Start date for transaction history (optional)
|
||||
# @param end_date [Date, nil] End date for transaction history (defaults to tomorrow)
|
||||
# @return [Hash] Transaction data with keys:
|
||||
# - :transactions [Array<Hash>] Array of transaction objects
|
||||
# - :total [Integer] Total number of transactions
|
||||
# @raise [Provider::Error] if the API request fails
|
||||
# @example
|
||||
# result = provider.get_account_transactions("cust123", "acct456", start_date: 30.days.ago)
|
||||
# # => { transactions: [{id: "tx1", amount: -50.00, ...}], total: 25 }
|
||||
def get_account_transactions(customer_id, account_id, start_date: nil, end_date: nil)
|
||||
with_provider_response do
|
||||
query_params = {}
|
||||
|
||||
if start_date
|
||||
query_params[:startDate] = start_date.to_date
|
||||
end
|
||||
if end_date
|
||||
query_params[:endDate] = end_date.to_date
|
||||
else
|
||||
query_params[:endDate] = Date.tomorrow
|
||||
end
|
||||
|
||||
path = "/customers/#{ERB::Util.url_encode(customer_id.to_s)}/accounts/#{ERB::Util.url_encode(account_id.to_s)}/transactions"
|
||||
path += "?#{URI.encode_www_form(query_params)}" unless query_params.empty?
|
||||
url = "#{@base_url}#{path}"
|
||||
|
||||
response = self.class.get(
|
||||
url,
|
||||
headers: auth_headers(url: url, http_method: "GET")
|
||||
)
|
||||
|
||||
parsed = handle_response(response)
|
||||
# Normalize transactions response into { transactions: [...], total: N }
|
||||
if parsed.is_a?(Array)
|
||||
txs = parsed.map { |tx| tx.transform_keys { |k| k.to_s.underscore }.with_indifferent_access }
|
||||
mapped = txs.map { |tx| map_transaction(tx, account_id) }
|
||||
{ transactions: mapped, total: mapped.length }
|
||||
elsif parsed.is_a?(Hash)
|
||||
if parsed[:transactions].is_a?(Array)
|
||||
txs = parsed[:transactions].map { |tx| tx.transform_keys { |k| k.to_s.underscore }.with_indifferent_access }
|
||||
mapped = txs.map { |tx| map_transaction(tx, account_id) }
|
||||
parsed[:transactions] = mapped
|
||||
parsed[:total] = parsed[:total] || mapped.length
|
||||
parsed
|
||||
else
|
||||
# Single transaction object -> wrap and map
|
||||
single = parsed.transform_keys { |k| k.to_s.underscore }.with_indifferent_access
|
||||
mapped = map_transaction(single, account_id)
|
||||
{ transactions: [ mapped ], total: 1 }
|
||||
end
|
||||
else
|
||||
{ transactions: [], total: 0 }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Fetches the current balance for a specific account.
|
||||
#
|
||||
# @param customer_id [String] Sophtron customer ID
|
||||
# @param account_id [String] Sophtron account ID
|
||||
# @return [Hash] Balance data with keys:
|
||||
# - :balance [Hash] Balance information
|
||||
# - :amount [Numeric] Current balance amount
|
||||
# - :currency [String] Currency code (defaults to "USD")
|
||||
# @raise [Provider::Error] if the API request fails
|
||||
# @example
|
||||
# result = provider.get_account_balance("cust123", "acct456")
|
||||
# # => { balance: { amount: 1000.00, currency: "USD" } }
|
||||
def get_account_balance(customer_id, account_id)
|
||||
with_provider_response do
|
||||
path = "/customers/#{ERB::Util.url_encode(customer_id.to_s)}/accounts/#{ERB::Util.url_encode(account_id.to_s)}"
|
||||
url = "#{@base_url}#{path}"
|
||||
|
||||
response = self.class.get(
|
||||
url,
|
||||
headers: auth_headers(url: url, http_method: "GET")
|
||||
)
|
||||
|
||||
parsed = handle_response(response)
|
||||
|
||||
# Normalize balance information into { balance: { amount: N, currency: "XXX" } }
|
||||
# Sophtron returns balance as flat fields: Balance and BalanceCurrency (capitalized)
|
||||
# After JSON symbolization these become: :Balance and :BalanceCurrency
|
||||
balance_amount = parsed[:Balance] || parsed[:balance]
|
||||
balance_currency = parsed[:BalanceCurrency] || parsed[:balance_currency]
|
||||
|
||||
if parsed.is_a?(Hash) && balance_amount.present?
|
||||
result = {
|
||||
balance: {
|
||||
amount: balance_amount,
|
||||
currency: balance_currency.presence || "USD"
|
||||
}
|
||||
}
|
||||
else
|
||||
result = { balance: { amount: 0, currency: "USD" } }
|
||||
end
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sophtron_auth_code(url:, http_method:)
|
||||
require "base64"
|
||||
require "openssl"
|
||||
# sophtron auth code generation
|
||||
# Parse path portion of the URL and use the last "/..." segment (matching upstream examples)
|
||||
uri = URI.parse(url)
|
||||
# Sign the last path segment (lowercased) and include the query string if present
|
||||
path = (uri.path || "").downcase
|
||||
idx = path.rindex("/")
|
||||
last_seg = idx ? path[idx..-1] : path
|
||||
query_str = uri.query ? "?#{uri.query.to_s.downcase}" : ""
|
||||
auth_path = "#{last_seg}#{query_str}"
|
||||
# Build the plain text to sign: "METHOD\n/auth_path"
|
||||
plain_key = "#{http_method.to_s.upcase}\n#{auth_path}"
|
||||
# Decode the base64 access key and compute HMAC-SHA256
|
||||
begin
|
||||
key_bytes = Base64.decode64(@access_key.to_s)
|
||||
rescue => decode_err
|
||||
Rails.logger.error("[sophtron_auth_code] Failed to decode access_key: #{decode_err.class}: #{decode_err.message}")
|
||||
raise
|
||||
end
|
||||
signature = OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), key_bytes, plain_key)
|
||||
sig_b64_str = Base64.strict_encode64(signature)
|
||||
auth_code = "FIApiAUTH:#{@user_id}:#{sig_b64_str}:#{auth_path}"
|
||||
auth_code
|
||||
end
|
||||
|
||||
def auth_headers(url:, http_method:)
|
||||
{
|
||||
"Authorization" => sophtron_auth_code(url: url, http_method: http_method),
|
||||
"Content-Type" => "application/json",
|
||||
"Accept" => "application/json"
|
||||
}
|
||||
end
|
||||
|
||||
# Fetch list of customer IDs by calling GET /customers and extracting identifier fields
|
||||
def get_customer_ids
|
||||
url = "#{@base_url}/customers"
|
||||
response = self.class.get(
|
||||
url,
|
||||
headers: auth_headers(url: url, http_method: "GET")
|
||||
)
|
||||
parsed = handle_response(response)
|
||||
ids = []
|
||||
if parsed.is_a?(Array)
|
||||
ids = parsed.map do |r|
|
||||
next unless r.is_a?(Hash)
|
||||
# Find a key that likely contains the customer id (handles :CustomerID, :customerID, :customer_id, :ID, :id)
|
||||
key = r.keys.find { |k| k.to_s.downcase.include?("customer") && k.to_s.downcase.include?("id") } ||
|
||||
r.keys.find { |k| k.to_s.downcase == "id" }
|
||||
r[key]
|
||||
end.compact
|
||||
elsif parsed.is_a?(Hash)
|
||||
if parsed[:customers].is_a?(Array)
|
||||
ids = parsed[:customers].map do |r|
|
||||
next unless r.is_a?(Hash)
|
||||
key = r.keys.find { |k| k.to_s.downcase.include?("customer") && k.to_s.downcase.include?("id") } ||
|
||||
r.keys.find { |k| k.to_s.downcase == "id" }
|
||||
r[key]
|
||||
end.compact
|
||||
else
|
||||
key = parsed.keys.find { |k| k.to_s.downcase.include?("customer") && k.to_s.downcase.include?("id") } ||
|
||||
parsed.keys.find { |k| k.to_s.downcase == "id" }
|
||||
ids = [ parsed[key] ].compact
|
||||
end
|
||||
end
|
||||
|
||||
# Normalize to strings and unique (avoid destructive methods that may return nil)
|
||||
ids = ids.map(&:to_s).compact.uniq
|
||||
ids
|
||||
end
|
||||
|
||||
# Fetch accounts for a specific customer via GET /customers/:customer_id/accounts
|
||||
def get_customer_accounts(customer_id)
|
||||
path = "/customers/#{ERB::Util.url_encode(customer_id.to_s)}/accounts"
|
||||
url = "#{@base_url}#{path}"
|
||||
response = self.class.get(
|
||||
url,
|
||||
headers: auth_headers(url: url, http_method: "GET")
|
||||
)
|
||||
handle_response(response)
|
||||
end
|
||||
|
||||
# Map a normalized Sophtron transaction hash into our standard transaction shape
|
||||
# Returns: { id, accountId, type, status, amount, currency, date, merchant, description }
|
||||
def map_transaction(tx, account_id)
|
||||
tx = tx.with_indifferent_access
|
||||
{
|
||||
id: tx[:transaction_id],
|
||||
accountId: account_id,
|
||||
type: tx[:type] || "unknown",
|
||||
status: tx[:status] || "completed",
|
||||
amount: tx[:amount] || 0.0,
|
||||
currency: tx[:currency] || "USD",
|
||||
date: tx[:transaction_date] || nil,
|
||||
merchant: tx[:merchant] || extract_merchant(tx[:description]) ||"",
|
||||
description: tx[:description] || ""
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
def extract_merchant(line)
|
||||
return nil if line.nil?
|
||||
line = line.strip
|
||||
return nil if line.empty?
|
||||
|
||||
# 1. Handle special bank fees and automated transactions
|
||||
if line =~ /INSUFFICIENT FUNDS FEE/i
|
||||
return "Bank Fee: Insufficient Funds"
|
||||
elsif line =~ /OVERDRAFT PROTECTION/i
|
||||
return "Bank Transfer: Overdraft Protection"
|
||||
elsif line =~ /AUTO PAY WF HOME MTG/i
|
||||
return "Wells Fargo Home Mortgage"
|
||||
elsif line =~ /PAYDAY LOAN/i
|
||||
return "Payday Loan"
|
||||
end
|
||||
|
||||
# 2. Refined CHECKCARD Pattern
|
||||
# Logic:
|
||||
# - Start after 'CHECKCARD XXXX '
|
||||
# - Capture everything (.+?)
|
||||
# - STOP when we see:
|
||||
# a) Two or more spaces (\s{2,})
|
||||
# b) A masked number (x{3,})
|
||||
# c) A pattern of [One Word] + [Space] + [State Code] (\s+\S+\s+[A-Z]{2}\b)
|
||||
# The (\s+\S+) part matches the city, so we stop BEFORE it.
|
||||
if line =~ /CHECKCARD \d{4}\s+(.+?)(?=\s{2,}|x{3,}|\s+\S+\s+[A-Z]{2}\b)/i
|
||||
return $1.strip
|
||||
end
|
||||
|
||||
# 3. Handle standard purchase rows (e.g., EXXONMOBIL POS 12/08)
|
||||
# Stops before date (MM/DD) or hash (#)
|
||||
if line =~ /^(.+?)(?=\s+\d{2}\/\d{2}|\s+#)/
|
||||
name = $1.strip
|
||||
return name.gsub(/\s+POS$/i, "").strip
|
||||
end
|
||||
|
||||
# 4. Fallback for other formats
|
||||
line[0..25].strip
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
case response.code
|
||||
when 200
|
||||
begin
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Sophtron API: Invalid JSON response - #{e.message}"
|
||||
raise Provider::Error.new("Invalid JSON response from Sophtron API", :invalid_response)
|
||||
end
|
||||
when 400
|
||||
Rails.logger.error "Sophtron API: Bad request - #{response.body}"
|
||||
raise Provider::Error.new("Bad request to Sophtron API: #{response.body}", :bad_request)
|
||||
when 401
|
||||
raise Provider::Error.new("Invalid User ID or Access key", :unauthorized)
|
||||
when 403
|
||||
raise Provider::Error.new("Access forbidden - check your User ID and Access key permissions", :access_forbidden)
|
||||
when 404
|
||||
raise Provider::Error.new("Resource not found", :not_found)
|
||||
when 429
|
||||
raise Provider::Error.new("Rate limit exceeded. Please try again later.", :rate_limited)
|
||||
else
|
||||
Rails.logger.error "Sophtron API: Unexpected response - Code: #{response.code}, Body: #{response.body}"
|
||||
raise Provider::Error.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed)
|
||||
end
|
||||
end
|
||||
end
|
||||
107
app/models/provider/sophtron_adapter.rb
Normal file
107
app/models/provider/sophtron_adapter.rb
Normal file
@@ -0,0 +1,107 @@
|
||||
class Provider::SophtronAdapter < Provider::Base
|
||||
include Provider::Syncable
|
||||
include Provider::InstitutionMetadata
|
||||
|
||||
# Register this adapter with the factory
|
||||
Provider::Factory.register("SophtronAccount", self)
|
||||
|
||||
# Define which account types this provider supports
|
||||
def self.supported_account_types
|
||||
%w[Depository CreditCard Loan Investment]
|
||||
end
|
||||
|
||||
# Returns connection configurations for this provider
|
||||
def self.connection_configs(family:)
|
||||
return [] unless family.can_connect_sophtron?
|
||||
|
||||
[ {
|
||||
key: "sophtron",
|
||||
name: "Sophtron",
|
||||
description: "Connect to your bank via Sophtron's secure API aggregation service.",
|
||||
can_connect: true,
|
||||
new_account_path: ->(accountable_type, return_to) {
|
||||
Rails.application.routes.url_helpers.select_accounts_sophtron_items_path(
|
||||
accountable_type: accountable_type,
|
||||
return_to: return_to
|
||||
)
|
||||
},
|
||||
existing_account_path: ->(account_id) {
|
||||
Rails.application.routes.url_helpers.select_existing_account_sophtron_items_path(
|
||||
account_id: account_id
|
||||
)
|
||||
}
|
||||
} ]
|
||||
end
|
||||
|
||||
def provider_name
|
||||
"sophtron"
|
||||
end
|
||||
|
||||
# Build a Sophtron provider instance with family-specific credentials
|
||||
# Sophtron is now fully per-family - no global credentials supported
|
||||
# @param family [Family] The family to get credentials for (required)
|
||||
# @return [Provider::Sophtron, nil] Returns nil if User ID and Access key is not configured
|
||||
def self.build_provider(family: nil)
|
||||
return nil unless family.present?
|
||||
|
||||
# Get family-specific credentials
|
||||
sophtron_item = family.sophtron_items.where.not(user_id: nil, access_key: nil).first
|
||||
return nil unless sophtron_item&.credentials_configured?
|
||||
|
||||
Provider::Sophtron.new(
|
||||
sophtron_item.user_id,
|
||||
sophtron_item.access_key,
|
||||
base_url: sophtron_item.effective_base_url
|
||||
)
|
||||
end
|
||||
|
||||
def sync_path
|
||||
Rails.application.routes.url_helpers.sync_sophtron_item_path(item)
|
||||
end
|
||||
|
||||
def item
|
||||
provider_account.sophtron_item
|
||||
end
|
||||
|
||||
def can_delete_holdings?
|
||||
false
|
||||
end
|
||||
|
||||
def institution_domain
|
||||
# Sophtron 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 Sophtron 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", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital" }
|
||||
enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital", sophtron: "sophtron" }
|
||||
|
||||
validates :name, uniqueness: { scope: [ :source ] }
|
||||
validates :source, presence: true
|
||||
|
||||
130
app/models/sophtron_account.rb
Normal file
130
app/models/sophtron_account.rb
Normal file
@@ -0,0 +1,130 @@
|
||||
# Represents a single bank account from Sophtron.
|
||||
#
|
||||
# A SophtronAccount stores account-level data fetched from the Sophtron API,
|
||||
# including balances, account type, and raw transaction data. It can be linked
|
||||
# to a Maybe Account through the account_provider association.
|
||||
#
|
||||
# @attr [String] name Account name from Sophtron
|
||||
# @attr [String] account_id Sophtron's unique identifier for this account
|
||||
# @attr [String] customer_id Sophtron customer ID this account belongs to
|
||||
# @attr [String] member_id Sophtron member ID
|
||||
# @attr [String] currency Three-letter currency code (e.g., 'USD')
|
||||
# @attr [Decimal] balance Current account balance
|
||||
# @attr [Decimal] available_balance Available balance (for credit accounts)
|
||||
# @attr [String] account_type Type of account (e.g., 'checking', 'savings')
|
||||
# @attr [String] account_sub_type Detailed account subtype
|
||||
# @attr [JSONB] raw_payload Raw account data from Sophtron API
|
||||
# @attr [JSONB] raw_transactions_payload Raw transaction data from Sophtron API
|
||||
# @attr [DateTime] last_updated When Sophtron last updated this account
|
||||
class SophtronAccount < ApplicationRecord
|
||||
include CurrencyNormalizable
|
||||
|
||||
belongs_to :sophtron_item
|
||||
|
||||
# Association to link this Sophtron account to a Maybe Account
|
||||
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
|
||||
validate :has_balance
|
||||
# Returns the linked Maybe Account for this Sophtron account.
|
||||
#
|
||||
# @return [Account, nil] The linked Maybe Account, or nil if not linked
|
||||
def current_account
|
||||
account
|
||||
end
|
||||
|
||||
# Updates this SophtronAccount with fresh data from the Sophtron API.
|
||||
#
|
||||
# Maps Sophtron field names to our database schema and saves the changes.
|
||||
# Stores the complete raw payload for reference.
|
||||
#
|
||||
# @param account_snapshot [Hash] Raw account data from Sophtron API
|
||||
# @return [Boolean] true if save was successful
|
||||
# @raise [ActiveRecord::RecordInvalid] if validation fails
|
||||
def upsert_sophtron_snapshot!(account_snapshot)
|
||||
# Convert to symbol keys or handle both string and symbol keys
|
||||
snapshot = account_snapshot.with_indifferent_access
|
||||
|
||||
# Map Sophtron field names to our field names
|
||||
assign_attributes(
|
||||
name: snapshot[:account_name],
|
||||
account_id: snapshot[:account_id],
|
||||
currency: parse_currency(snapshot[:balance_currency]) || "USD",
|
||||
balance: parse_balance(snapshot[:balance]),
|
||||
available_balance: parse_balance(snapshot[:"available-balance"]),
|
||||
account_type: snapshot["account_type"] || "unknown",
|
||||
account_sub_type: snapshot["sub_type"] || "unknown",
|
||||
last_updated: parse_balance_date(snapshot[:"last_updated"]),
|
||||
raw_payload: account_snapshot,
|
||||
customer_id: snapshot["customer_id"],
|
||||
member_id: snapshot["member_id"]
|
||||
)
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
# Stores raw transaction data from the Sophtron API.
|
||||
#
|
||||
# This method saves the raw transaction payload which will later be
|
||||
# processed by SophtronAccount::Transactions::Processor to create
|
||||
# actual Transaction records.
|
||||
#
|
||||
# @param transactions_snapshot [Array<Hash>] Array of raw transaction data
|
||||
# @return [Boolean] true if save was successful
|
||||
# @raise [ActiveRecord::RecordInvalid] if validation fails
|
||||
def upsert_sophtron_transactions_snapshot!(transactions_snapshot)
|
||||
assign_attributes(
|
||||
raw_transactions_payload: transactions_snapshot
|
||||
)
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log_invalid_currency(currency_value)
|
||||
Rails.logger.warn("Invalid currency code '#{currency_value}' for Sophtron account #{id}, defaulting to USD")
|
||||
end
|
||||
|
||||
|
||||
def parse_balance(balance_value)
|
||||
return nil if balance_value.nil?
|
||||
|
||||
case balance_value
|
||||
when String
|
||||
BigDecimal(balance_value)
|
||||
when Numeric
|
||||
BigDecimal(balance_value.to_s)
|
||||
else
|
||||
nil
|
||||
end
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def parse_balance_date(balance_date_value)
|
||||
return nil if balance_date_value.nil?
|
||||
|
||||
case balance_date_value
|
||||
when String
|
||||
Time.parse(balance_date_value)
|
||||
when Numeric
|
||||
t = balance_date_value
|
||||
t = (t / 1000.0) if t > 1_000_000_000_000 # likely ms epoch
|
||||
Time.at(t)
|
||||
when Time, DateTime
|
||||
balance_date_value
|
||||
else
|
||||
nil
|
||||
end
|
||||
rescue ArgumentError, TypeError
|
||||
Rails.logger.warn("Invalid balance date for Sophtron account: #{balance_date_value}")
|
||||
nil
|
||||
end
|
||||
def has_balance
|
||||
return if balance.present? || available_balance.present?
|
||||
errors.add(:base, "Sophtron account must have either current or available balance")
|
||||
end
|
||||
end
|
||||
119
app/models/sophtron_account/processor.rb
Normal file
119
app/models/sophtron_account/processor.rb
Normal file
@@ -0,0 +1,119 @@
|
||||
# Processes a SophtronAccount to update Maybe Account and Transaction records.
|
||||
#
|
||||
# This processor is responsible for:
|
||||
# 1. Updating the linked Maybe Account's balance from Sophtron data
|
||||
# 2. Processing stored transactions to create Maybe Transaction records
|
||||
#
|
||||
# The processor handles currency normalization and sign conventions for
|
||||
# different account types (e.g., credit cards use inverted signs).
|
||||
class SophtronAccount::Processor
|
||||
include CurrencyNormalizable
|
||||
|
||||
attr_reader :sophtron_account
|
||||
|
||||
# Initializes a new processor for a Sophtron account.
|
||||
#
|
||||
# @param sophtron_account [SophtronAccount] The account to process
|
||||
def initialize(sophtron_account)
|
||||
@sophtron_account = sophtron_account
|
||||
end
|
||||
|
||||
# Processes the account to update balances and transactions.
|
||||
#
|
||||
# This method:
|
||||
# - Validates that the account is linked to a Maybe Account
|
||||
# - Updates the Maybe Account's balance from Sophtron data
|
||||
# - Processes all stored transactions to create Transaction records
|
||||
#
|
||||
# @return [Hash, nil] Transaction processing result hash or nil if no linked account
|
||||
# @raise [StandardError] if processing fails (errors are logged and reported to Sentry)
|
||||
def process
|
||||
unless sophtron_account.current_account.present?
|
||||
Rails.logger.info "SophtronAccount::Processor - No linked account for sophtron_account #{sophtron_account.id}, skipping processing"
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.info "SophtronAccount::Processor - Processing sophtron_account #{sophtron_account.id} (account #{sophtron_account.account_id})"
|
||||
begin
|
||||
process_account!
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "SophtronAccount::Processor - Failed to process account #{sophtron_account.id}: #{e.message}"
|
||||
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
|
||||
report_exception(e, "account")
|
||||
raise
|
||||
end
|
||||
|
||||
process_transactions
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Updates the linked Maybe Account's balance from Sophtron data.
|
||||
#
|
||||
# Handles sign conventions for different account types:
|
||||
# - CreditCard and Loan accounts use inverted signs (negated)
|
||||
# - Other account types use Sophtron's native sign convention
|
||||
#
|
||||
# @return [void]
|
||||
# @raise [ActiveRecord::RecordInvalid] if the account update fails
|
||||
def process_account!
|
||||
if sophtron_account.current_account.blank?
|
||||
Rails.logger.error("Sophtron account #{sophtron_account.id} has no associated Account")
|
||||
return
|
||||
end
|
||||
|
||||
# Update account balance from latest Sophtron data
|
||||
account = sophtron_account.current_account
|
||||
balance = sophtron_account.balance || sophtron_account.available_balance || 0
|
||||
|
||||
# Sophtron balance convention matches our app convention:
|
||||
# - Positive balance = debt (you owe money)
|
||||
# - Negative balance = credit balance (bank owes you, e.g., overpayment)
|
||||
# No sign conversion needed - pass through as-is (same as Plaid)
|
||||
#
|
||||
# Exception: CreditCard and Loan accounts return inverted signs
|
||||
# Provider returns negative for positive balance, so we negate it
|
||||
if account.accountable_type == "CreditCard" || account.accountable_type == "Loan"
|
||||
balance = -balance
|
||||
end
|
||||
|
||||
# Normalize currency with fallback chain: parsed sophtron currency -> existing account currency -> USD
|
||||
currency = parse_currency(sophtron_account.currency) || account.currency || "USD"
|
||||
# Update account balance
|
||||
account.update!(
|
||||
balance: balance,
|
||||
cash_balance: balance,
|
||||
currency: currency
|
||||
)
|
||||
end
|
||||
|
||||
# Processes all stored transactions for this account.
|
||||
#
|
||||
# Delegates to SophtronAccount::Transactions::Processor to convert
|
||||
# raw transaction data into Maybe Transaction records.
|
||||
#
|
||||
# @return [void]
|
||||
# @raise [StandardError] if transaction processing fails
|
||||
def process_transactions
|
||||
SophtronAccount::Transactions::Processor.new(sophtron_account).process
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "SophtronAccount::Processor - Failed to process transactions for sophtron_account #{sophtron_account.id}: #{e.message}"
|
||||
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
|
||||
report_exception(e, "transactions")
|
||||
raise
|
||||
end
|
||||
|
||||
# Reports an exception to Sentry with Sophtron account context.
|
||||
#
|
||||
# @param error [Exception] The error to report
|
||||
# @param context [String] Additional context (e.g., 'account', 'transactions')
|
||||
# @return [void]
|
||||
def report_exception(error, context)
|
||||
Sentry.capture_exception(error) do |scope|
|
||||
scope.set_tags(
|
||||
sophtron_account_id: sophtron_account.id,
|
||||
context: context
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
98
app/models/sophtron_account/transactions/processor.rb
Normal file
98
app/models/sophtron_account/transactions/processor.rb
Normal file
@@ -0,0 +1,98 @@
|
||||
# Processes raw transaction data to create Maybe Transaction records.
|
||||
#
|
||||
# This processor takes the raw transaction payload stored in a SophtronAccount
|
||||
# and converts each transaction into a Maybe Transaction record using
|
||||
# SophtronEntry::Processor. It processes transactions individually to avoid
|
||||
# database lock issues when handling large transaction volumes.
|
||||
#
|
||||
# The processor is resilient to errors - if one transaction fails, it logs
|
||||
# the error and continues processing the remaining transactions.
|
||||
class SophtronAccount::Transactions::Processor
|
||||
attr_reader :sophtron_account
|
||||
|
||||
# Initializes a new transaction processor.
|
||||
#
|
||||
# @param sophtron_account [SophtronAccount] The account whose transactions to process
|
||||
def initialize(sophtron_account)
|
||||
@sophtron_account = sophtron_account
|
||||
end
|
||||
|
||||
# Processes all transactions in the raw_transactions_payload.
|
||||
#
|
||||
# Each transaction is processed individually to avoid database lock contention.
|
||||
# Errors are caught and logged, allowing the process to continue with remaining
|
||||
# transactions.
|
||||
#
|
||||
# @return [Hash] Processing results with the following keys:
|
||||
# - :success [Boolean] true if all transactions processed successfully
|
||||
# - :total [Integer] Total number of transactions found
|
||||
# - :imported [Integer] Number of transactions successfully imported
|
||||
# - :failed [Integer] Number of transactions that failed
|
||||
# - :errors [Array<Hash>] Details of any errors encountered
|
||||
# @example
|
||||
# result = processor.process
|
||||
# # => { success: true, total: 100, imported: 98, failed: 2, errors: [...] }
|
||||
def process
|
||||
unless sophtron_account.raw_transactions_payload.present?
|
||||
Rails.logger.info "SophtronAccount::Transactions::Processor - No transactions in raw_transactions_payload for sophtron_account #{sophtron_account.id}"
|
||||
return { success: true, total: 0, imported: 0, failed: 0, errors: [] }
|
||||
end
|
||||
|
||||
total_count = sophtron_account.raw_transactions_payload.count
|
||||
Rails.logger.info "SophtronAccount::Transactions::Processor - Processing #{total_count} transactions for sophtron_account #{sophtron_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.
|
||||
sophtron_account.raw_transactions_payload.each_with_index do |transaction_data, index|
|
||||
begin
|
||||
result = SophtronEntry::Processor.new(
|
||||
transaction_data,
|
||||
sophtron_account: sophtron_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 "SophtronAccount::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 "SophtronAccount::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 "SophtronAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions"
|
||||
else
|
||||
Rails.logger.info "SophtronAccount::Transactions::Processor - Successfully processed #{imported_count} transactions"
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
229
app/models/sophtron_entry/processor.rb
Normal file
229
app/models/sophtron_entry/processor.rb
Normal file
@@ -0,0 +1,229 @@
|
||||
require "digest/md5"
|
||||
|
||||
# Processes a single Sophtron transaction and creates/updates a Maybe Transaction.
|
||||
#
|
||||
# This processor takes raw transaction data from the Sophtron API and converts it
|
||||
# into a Maybe Transaction record using the Account::ProviderImportAdapter.
|
||||
# It handles currency normalization, merchant matching, and data validation.
|
||||
#
|
||||
# Expected transaction structure from Sophtron:
|
||||
# {
|
||||
# id: String,
|
||||
# accountId: String,
|
||||
# amount: Numeric,
|
||||
# currency: String,
|
||||
# date: String/Date,
|
||||
# merchant: String,
|
||||
# description: String
|
||||
# }
|
||||
class SophtronEntry::Processor
|
||||
include CurrencyNormalizable
|
||||
|
||||
# Initializes a new processor for a Sophtron transaction.
|
||||
#
|
||||
# @param sophtron_transaction [Hash] Raw transaction data from Sophtron API
|
||||
# @param sophtron_account [SophtronAccount] The account this transaction belongs to
|
||||
def initialize(sophtron_transaction, sophtron_account:)
|
||||
@sophtron_transaction = sophtron_transaction
|
||||
@sophtron_account = sophtron_account
|
||||
end
|
||||
|
||||
# Processes the transaction and creates/updates a Maybe Transaction record.
|
||||
#
|
||||
# This method validates the transaction data, creates or finds a merchant,
|
||||
# and uses the ProviderImportAdapter to import the transaction into Maybe.
|
||||
# It respects user overrides through the enrichment pattern.
|
||||
#
|
||||
# @return [Entry, nil] The created/updated Entry, or nil if account not linked
|
||||
# @raise [ArgumentError] if required transaction fields are missing
|
||||
# @raise [StandardError] if the transaction cannot be saved
|
||||
def process
|
||||
# Validate that we have a linked account before processing
|
||||
unless account.present?
|
||||
Rails.logger.warn "SophtronEntry::Processor - No linked account for sophtron_account #{sophtron_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: "sophtron",
|
||||
merchant: merchant,
|
||||
notes: notes
|
||||
)
|
||||
rescue ArgumentError => e
|
||||
# Re-raise validation errors (missing required fields, invalid data)
|
||||
Rails.logger.error "SophtronEntry::Processor - Validation error for transaction #{external_id}: #{e.message}"
|
||||
raise
|
||||
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
|
||||
# Handle database save errors
|
||||
Rails.logger.error "SophtronEntry::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 "SophtronEntry::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 :sophtron_transaction, :sophtron_account
|
||||
|
||||
# Returns the import adapter for this transaction's account.
|
||||
#
|
||||
# @return [Account::ProviderImportAdapter] Adapter for importing transactions
|
||||
def import_adapter
|
||||
@import_adapter ||= Account::ProviderImportAdapter.new(account)
|
||||
end
|
||||
|
||||
# Returns the linked Maybe Account for this transaction.
|
||||
#
|
||||
# @return [Account, nil] The linked account
|
||||
def account
|
||||
@account ||= sophtron_account.current_account
|
||||
end
|
||||
|
||||
# Returns the transaction data with indifferent access.
|
||||
#
|
||||
# @return [ActiveSupport::HashWithIndifferentAccess] Normalized transaction data
|
||||
def data
|
||||
@data ||= sophtron_transaction.with_indifferent_access
|
||||
end
|
||||
|
||||
# Generates a unique external ID for this transaction.
|
||||
#
|
||||
# Prefixes the Sophtron transaction ID with 'sophtron_' to avoid conflicts
|
||||
# with other providers.
|
||||
#
|
||||
# @return [String] The external ID (e.g., 'sophtron_12345')
|
||||
# @raise [ArgumentError] if the transaction ID is missing
|
||||
def external_id
|
||||
id = data[:id].presence
|
||||
raise ArgumentError, "Sophtron transaction missing required field 'id'" unless id
|
||||
"sophtron_#{id}"
|
||||
end
|
||||
|
||||
# Extracts the transaction name from the data.
|
||||
#
|
||||
# Falls back to "Unknown transaction" if merchant is not present.
|
||||
#
|
||||
# @return [String] The transaction name
|
||||
def name
|
||||
data[:merchant].presence || t("sophtron_items.sophtron_entry.processor.unknown_transaction")
|
||||
end
|
||||
|
||||
# Extracts optional notes/description from the transaction.
|
||||
#
|
||||
# @return [String, nil] Transaction description
|
||||
def notes
|
||||
data[:description].presence
|
||||
end
|
||||
|
||||
# Finds or creates a merchant for this transaction.
|
||||
#
|
||||
# Creates a deterministic merchant ID using MD5 hash of the merchant name.
|
||||
# This ensures the same merchant name always maps to the same merchant record.
|
||||
#
|
||||
# @return [Merchant, nil] The merchant object, or nil if merchant data is missing
|
||||
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: "sophtron_merchant_#{merchant_id}",
|
||||
name: merchant_name,
|
||||
source: "sophtron"
|
||||
)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "SophtronEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Parses and converts the transaction amount.
|
||||
#
|
||||
# Sophtron uses standard banking convention (negative = expense, positive = income)
|
||||
# while Maybe uses inverted signs (positive = expense, negative = income).
|
||||
# This method negates the amount to convert between conventions.
|
||||
#
|
||||
# @return [BigDecimal] The converted amount
|
||||
# @raise [ArgumentError] if the amount cannot be parsed
|
||||
def amount
|
||||
parsed_amount = case data[:amount]
|
||||
when String
|
||||
BigDecimal(data[:amount])
|
||||
when Numeric
|
||||
BigDecimal(data[:amount].to_s)
|
||||
else
|
||||
BigDecimal("0")
|
||||
end
|
||||
|
||||
# Sophtron 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 Sophtron to Maybe format
|
||||
-parsed_amount
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error "Failed to parse Sophtron transaction amount: #{data[:amount].inspect} - #{e.message}"
|
||||
raise
|
||||
end
|
||||
|
||||
# Extracts and normalizes the currency code.
|
||||
#
|
||||
# Falls back to the account currency, then USD if not specified.
|
||||
#
|
||||
# @return [String] Three-letter currency code (e.g., 'USD')
|
||||
def currency
|
||||
parse_currency(data[:currency]) || account&.currency || "USD"
|
||||
end
|
||||
|
||||
# Logs invalid currency codes.
|
||||
#
|
||||
# @param currency_value [String] The invalid currency code
|
||||
# @return [void]
|
||||
def log_invalid_currency(currency_value)
|
||||
Rails.logger.warn("Invalid currency code '#{currency_value}' in Sophtron transaction #{external_id}, falling back to account currency")
|
||||
end
|
||||
|
||||
# Parses the transaction date from various formats.
|
||||
#
|
||||
# Handles:
|
||||
# - String dates (ISO format)
|
||||
# - Unix timestamps (Integer/Float)
|
||||
# - Time/DateTime objects
|
||||
# - Date objects
|
||||
#
|
||||
# @return [Date] The parsed transaction date
|
||||
# @raise [ArgumentError] if the date cannot be parsed
|
||||
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("Sophtron 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 Sophtron transaction date '#{data[:date]}': #{e.message}")
|
||||
raise ArgumentError, "Unable to parse transaction date: #{data[:date].inspect}"
|
||||
end
|
||||
end
|
||||
198
app/models/sophtron_item.rb
Normal file
198
app/models/sophtron_item.rb
Normal file
@@ -0,0 +1,198 @@
|
||||
# Represents a Sophtron integration item for a family.
|
||||
#
|
||||
# A SophtronItem stores Sophtron API credentials and manages the connection
|
||||
# to a family's Sophtron account. It can have multiple associated SophtronAccounts,
|
||||
# which represent individual bank accounts linked through Sophtron.
|
||||
#
|
||||
# @attr [String] name The display name for this Sophtron connection
|
||||
# @attr [String] user_id Sophtron User ID (encrypted if encryption is configured)
|
||||
# @attr [String] access_key Sophtron Access Key (encrypted if encryption is configured)
|
||||
# @attr [String] base_url Base URL for Sophtron API (optional, defaults to production)
|
||||
# @attr [String] status Current status: 'good' or 'requires_update'
|
||||
# @attr [Boolean] scheduled_for_deletion Whether the item is scheduled for deletion
|
||||
# @attr [DateTime] last_synced_at When the last successful sync occurred
|
||||
class SophtronItem < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app.
|
||||
#
|
||||
# Checks both Rails credentials and environment variables for encryption keys.
|
||||
#
|
||||
# @return [Boolean] true if encryption is properly configured
|
||||
def self.encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured (credentials OR env vars)
|
||||
if encryption_ready?
|
||||
encrypts :user_id, deterministic: true
|
||||
encrypts :access_key, deterministic: true
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
validates :user_id, presence: true, on: :create
|
||||
validates :access_key, presence: true, on: :create
|
||||
|
||||
belongs_to :family
|
||||
has_one_attached :logo
|
||||
|
||||
has_many :sophtron_accounts, dependent: :destroy
|
||||
has_many :accounts, through: :sophtron_accounts
|
||||
|
||||
scope :active, -> { where(scheduled_for_deletion: false) }
|
||||
scope :syncable, -> { active }
|
||||
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
|
||||
|
||||
# Imports the latest account and transaction data from Sophtron.
|
||||
#
|
||||
# This method fetches all accounts and transactions from the Sophtron API
|
||||
# and updates the local database accordingly. It will:
|
||||
# - Fetch all accounts associated with the Sophtron connection
|
||||
# - Create new SophtronAccount records for newly discovered accounts
|
||||
# - Update existing linked accounts with latest data
|
||||
# - Fetch and store transactions for all linked accounts
|
||||
#
|
||||
# @return [Hash] Import results with counts of accounts and transactions imported
|
||||
# @raise [StandardError] if the Sophtron provider is not configured
|
||||
# @raise [Provider::Error] if the Sophtron API returns an error
|
||||
def import_latest_sophtron_data
|
||||
provider = sophtron_provider
|
||||
unless provider
|
||||
Rails.logger.error "SophtronItem #{id} - Cannot import: Sophtron provider is not configured (missing API key)"
|
||||
raise StandardError.new("Sophtron provider is not configured")
|
||||
end
|
||||
|
||||
SophtronItem::Importer.new(self, sophtron_provider: provider).import
|
||||
rescue => e
|
||||
Rails.logger.error "SophtronItem #{id} - Failed to import data: #{e.message}"
|
||||
raise
|
||||
end
|
||||
|
||||
def process_accounts
|
||||
return [] if sophtron_accounts.empty?
|
||||
|
||||
results = []
|
||||
# Only process accounts that are linked and have active status
|
||||
sophtron_accounts.joins(:account).merge(Account.visible).each do |sophtron_account|
|
||||
begin
|
||||
result = SophtronAccount::Processor.new(sophtron_account).process
|
||||
results << { sophtron_account_id: sophtron_account.id, success: true, result: result }
|
||||
rescue => e
|
||||
Rails.logger.error "SophtronItem #{id} - Failed to process account #{sophtron_account.id}: #{e.message}"
|
||||
results << { sophtron_account_id: sophtron_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 = []
|
||||
# Only schedule syncs for active accounts
|
||||
accounts.visible.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 "SophtronItem #{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_sophtron_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
|
||||
# Use centralized count helper methods for consistency
|
||||
total_accounts = total_accounts_count
|
||||
linked_count = linked_accounts_count
|
||||
unlinked_count = unlinked_accounts_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
|
||||
|
||||
def linked_accounts_count
|
||||
sophtron_accounts.joins(:account_provider).count
|
||||
end
|
||||
|
||||
def unlinked_accounts_count
|
||||
sophtron_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
|
||||
end
|
||||
|
||||
def total_accounts_count
|
||||
sophtron_accounts.count
|
||||
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
|
||||
sophtron_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
|
||||
|
||||
def credentials_configured?
|
||||
user_id.present? &&
|
||||
access_key.present?
|
||||
end
|
||||
|
||||
def effective_base_url
|
||||
base_url.presence || "https://api.sophtron.com/api/v2"
|
||||
end
|
||||
end
|
||||
446
app/models/sophtron_item/importer.rb
Normal file
446
app/models/sophtron_item/importer.rb
Normal file
@@ -0,0 +1,446 @@
|
||||
require "set"
|
||||
|
||||
# Imports account and transaction data from Sophtron API.
|
||||
#
|
||||
# This class orchestrates the complete import process for a SophtronItem:
|
||||
# 1. Fetches all accounts from Sophtron
|
||||
# 2. Updates existing linked accounts with latest data
|
||||
# 3. Creates SophtronAccount records for newly discovered accounts
|
||||
# 4. Fetches and stores transactions for all linked accounts
|
||||
# 5. Updates account balances
|
||||
#
|
||||
# The importer maintains a separation between "discovered" accounts (any account
|
||||
# returned by the Sophtron API) and "linked" accounts (accounts the user has
|
||||
# explicitly connected to Maybe Accounts). This allows users to selectively
|
||||
# import accounts of their choosing.
|
||||
class SophtronItem::Importer
|
||||
attr_reader :sophtron_item, :sophtron_provider
|
||||
|
||||
# Initializes a new importer.
|
||||
#
|
||||
# @param sophtron_item [SophtronItem] The Sophtron item to import data for
|
||||
# @param sophtron_provider [Provider::Sophtron] Configured Sophtron API client
|
||||
def initialize(sophtron_item, sophtron_provider:)
|
||||
@sophtron_item = sophtron_item
|
||||
@sophtron_provider = sophtron_provider
|
||||
end
|
||||
|
||||
# Performs the complete import process for this Sophtron item.
|
||||
#
|
||||
# This method:
|
||||
# - Fetches all accounts from Sophtron API
|
||||
# - Stores raw account data snapshot
|
||||
# - Updates existing linked accounts
|
||||
# - Creates records for newly discovered accounts
|
||||
# - Fetches transactions for all linked accounts
|
||||
# - Updates account balances
|
||||
#
|
||||
# @return [Hash] Import results with the following keys:
|
||||
# - :success [Boolean] Overall success status
|
||||
# - :accounts_updated [Integer] Number of existing accounts updated
|
||||
# - :accounts_created [Integer] Number of new account records created
|
||||
# - :accounts_failed [Integer] Number of accounts that failed to import
|
||||
# - :transactions_imported [Integer] Total number of transactions imported
|
||||
# - :transactions_failed [Integer] Number of accounts with transaction import failures
|
||||
# @example
|
||||
# result = importer.import
|
||||
# # => { success: true, accounts_updated: 2, accounts_created: 1,
|
||||
# # accounts_failed: 0, transactions_imported: 150, transactions_failed: 0 }
|
||||
def import
|
||||
Rails.logger.info "SophtronItem::Importer - Starting import for item #{sophtron_item.id}"
|
||||
# Step 1: Fetch all accounts from Sophtron
|
||||
accounts_data = fetch_accounts_data
|
||||
unless accounts_data
|
||||
Rails.logger.error "SophtronItem::Importer - Failed to fetch accounts data for item #{sophtron_item.id}"
|
||||
return { success: false, error: "Failed to fetch accounts data", accounts_imported: 0, transactions_imported: 0 }
|
||||
end
|
||||
|
||||
# Store raw payload
|
||||
begin
|
||||
sophtron_item.upsert_sophtron_snapshot!(accounts_data)
|
||||
rescue => e
|
||||
Rails.logger.error "SophtronItem::Importer - Failed to store accounts snapshot: #{e.message}"
|
||||
# Continue with import even if snapshot storage fails
|
||||
end
|
||||
|
||||
# Step 2: Update linked accounts and create records for new accounts from API
|
||||
accounts_updated = 0
|
||||
accounts_created = 0
|
||||
accounts_failed = 0
|
||||
|
||||
if accounts_data[:accounts].present?
|
||||
# Get linked sophtron account IDs (ones actually imported/used by the user)
|
||||
linked_account_ids = sophtron_item.sophtron_accounts
|
||||
.joins(:account_provider)
|
||||
.pluck(:account_id)
|
||||
.map(&:to_s)
|
||||
# Get all existing sophtron account IDs (linked or not)
|
||||
all_existing_ids = sophtron_item.sophtron_accounts.pluck(:account_id).map(&:to_s)
|
||||
accounts_data[:accounts].each do |account_data|
|
||||
account_id = (account_data[:account_id] || account_data[:id])&.to_s
|
||||
next unless account_id.present?
|
||||
account_name = account_data[:account_name] || account_data[:name]
|
||||
next if account_name.blank?
|
||||
if linked_account_ids.include?(account_id)
|
||||
# Update existing linked accounts
|
||||
begin
|
||||
import_account(account_data)
|
||||
accounts_updated += 1
|
||||
rescue => e
|
||||
accounts_failed += 1
|
||||
Rails.logger.error "SophtronItem::Importer - Failed to update account #{account_id}: #{e.message}"
|
||||
end
|
||||
elsif !all_existing_ids.include?(account_id)
|
||||
# Create new unlinked sophtron_account records for accounts we haven't seen before
|
||||
# This allows users to link them later via "Setup new accounts"
|
||||
begin
|
||||
sophtron_account = sophtron_item.sophtron_accounts.build(
|
||||
account_id: account_id,
|
||||
name: account_name,
|
||||
currency: account_data[:currency] || "USD"
|
||||
)
|
||||
sophtron_account.upsert_sophtron_snapshot!(account_data)
|
||||
accounts_created += 1
|
||||
Rails.logger.info "SophtronItem::Importer - Created new unlinked account record for #{account_id}"
|
||||
rescue => e
|
||||
accounts_failed += 1
|
||||
Rails.logger.error "SophtronItem::Importer - Failed to create account #{account_id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.info "SophtronItem::Importer - Updated #{accounts_updated} accounts, created #{accounts_created} new (#{accounts_failed} failed)"
|
||||
|
||||
# Step 3: Fetch transactions only for linked accounts with active status
|
||||
transactions_imported = 0
|
||||
transactions_failed = 0
|
||||
|
||||
linked_accounts = sophtron_item.sophtron_accounts.joins(:account).merge(Account.visible)
|
||||
linked_accounts.each do |sophtron_account|
|
||||
begin
|
||||
result = fetch_and_store_transactions(sophtron_account)
|
||||
if result[:success]
|
||||
transactions_imported += result[:transactions_count]
|
||||
else
|
||||
transactions_failed += 1
|
||||
end
|
||||
rescue => e
|
||||
transactions_failed += 1
|
||||
Rails.logger.error "SophtronItem::Importer - Failed to fetch/store transactions for account #{sophtron_account.account_id}: #{e.message}"
|
||||
# Continue with other accounts even if one fails
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.info "SophtronItem::Importer - Completed import for item #{sophtron_item.id}: #{accounts_updated} accounts updated, #{accounts_created} new accounts discovered, #{transactions_imported} transactions"
|
||||
|
||||
{
|
||||
success: accounts_failed == 0 && transactions_failed == 0,
|
||||
accounts_updated: accounts_updated,
|
||||
accounts_created: accounts_created,
|
||||
accounts_failed: accounts_failed,
|
||||
transactions_imported: transactions_imported,
|
||||
transactions_failed: transactions_failed
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_accounts_data
|
||||
begin
|
||||
accounts_data = sophtron_provider.get_accounts
|
||||
# Extract data from Provider::Response object if needed
|
||||
if accounts_data.respond_to?(:data)
|
||||
accounts_data = accounts_data.data
|
||||
end
|
||||
rescue Provider::Error => e
|
||||
# Handle authentication errors by marking item as requiring update
|
||||
if e.error_type == :unauthorized || e.error_type == :access_forbidden
|
||||
begin
|
||||
sophtron_item.update!(status: :requires_update)
|
||||
rescue => update_error
|
||||
Rails.logger.error "SophtronItem::Importer - Failed to update item status: #{update_error.message}"
|
||||
end
|
||||
end
|
||||
Rails.logger.error "SophtronItem::Importer - Sophtron API error: #{e.message}"
|
||||
return nil
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "SophtronItem::Importer - Failed to parse Sophtron API response: #{e.message}"
|
||||
return nil
|
||||
rescue => e
|
||||
Rails.logger.error "SophtronItem::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 "SophtronItem::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
|
||||
|
||||
# Imports and updates a single account from Sophtron data.
|
||||
#
|
||||
# This method only updates existing SophtronAccount records that were
|
||||
# previously created. It does not create new accounts during sync.
|
||||
#
|
||||
# @param account_data [Hash] Raw account data from Sophtron API
|
||||
# @return [void]
|
||||
# @raise [ArgumentError] if account_data is invalid or account_id is missing
|
||||
# @raise [StandardError] if the account cannot be saved
|
||||
def import_account(account_data)
|
||||
# Validate account data structure
|
||||
unless account_data.is_a?(Hash)
|
||||
Rails.logger.error "SophtronItem::Importer - Invalid account_data format: expected Hash, got #{account_data.class}"
|
||||
raise ArgumentError, "Invalid account data format"
|
||||
end
|
||||
|
||||
account_id = (account_data[:account_id] || account_data[:id])&.to_s
|
||||
|
||||
# Validate required account_id
|
||||
if account_id.blank?
|
||||
Rails.logger.warn "SophtronItem::Importer - Skipping account with missing ID"
|
||||
raise ArgumentError, "Account ID is required"
|
||||
end
|
||||
|
||||
# Only find existing accounts, don't create new ones during sync
|
||||
sophtron_account = sophtron_item.sophtron_accounts.find_by(
|
||||
account_id: account_id
|
||||
)
|
||||
|
||||
# Skip if account wasn't previously selected
|
||||
unless sophtron_account
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
sophtron_account.upsert_sophtron_snapshot!(account_data)
|
||||
sophtron_account.save!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "SophtronItem::Importer - Failed to save sophtron_account: #{e.message}"
|
||||
raise StandardError.new("Failed to save account: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
# Fetches and stores transactions for a Sophtron account.
|
||||
#
|
||||
# This method:
|
||||
# 1. Determines the appropriate sync start date
|
||||
# 2. Fetches transactions from the Sophtron API
|
||||
# 3. Deduplicates against existing transactions
|
||||
# 4. Stores new transactions in raw_transactions_payload
|
||||
# 5. Updates the account balance
|
||||
#
|
||||
# @param sophtron_account [SophtronAccount] The account to fetch transactions for
|
||||
# @return [Hash] Result with keys:
|
||||
# - :success [Boolean] Whether the fetch was successful
|
||||
# - :transactions_count [Integer] Number of transactions fetched
|
||||
# - :error [String, nil] Error message if failed
|
||||
def fetch_and_store_transactions(sophtron_account)
|
||||
start_date = determine_sync_start_date(sophtron_account)
|
||||
Rails.logger.info "SophtronItem::Importer - Fetching transactions for account #{sophtron_account.account_id} from #{start_date}"
|
||||
|
||||
begin
|
||||
# Fetch transactions
|
||||
transactions_data = sophtron_provider.get_account_transactions(
|
||||
sophtron_account.customer_id,
|
||||
sophtron_account.account_id,
|
||||
start_date: start_date
|
||||
)
|
||||
|
||||
# Extract data from Provider::Response object if needed
|
||||
if transactions_data.respond_to?(:data)
|
||||
transactions_data = transactions_data.data
|
||||
end
|
||||
|
||||
# Validate response structure
|
||||
unless transactions_data.is_a?(Hash)
|
||||
Rails.logger.error "SophtronItem::Importer - Invalid transactions_data format for account #{sophtron_account.account_id}"
|
||||
return { success: false, transactions_count: 0, error: "Invalid response format" }
|
||||
end
|
||||
|
||||
transactions_count = transactions_data[:transactions]&.count || 0
|
||||
Rails.logger.info "SophtronItem::Importer - Fetched #{transactions_count} transactions for account #{sophtron_account.account_id}"
|
||||
|
||||
# Store transactions in the account
|
||||
if transactions_data[:transactions].present?
|
||||
begin
|
||||
existing_transactions = sophtron_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 "SophtronItem::Importer - Storing #{new_transactions.count} new transactions (#{existing_transactions.count} existing, #{transactions_data[:transactions].count - new_transactions.count} duplicates skipped) for account #{sophtron_account.account_id}"
|
||||
sophtron_account.upsert_sophtron_transactions_snapshot!(existing_transactions + new_transactions)
|
||||
else
|
||||
Rails.logger.info "SophtronItem::Importer - No new transactions to store (all #{transactions_data[:transactions].count} were duplicates) for account #{sophtron_account.account_id}"
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "SophtronItem::Importer - Failed to store transactions for account #{sophtron_account.account_id}: #{e.message}"
|
||||
return { success: false, transactions_count: 0, error: "Failed to store transactions: #{e.message}" }
|
||||
end
|
||||
else
|
||||
Rails.logger.info "SophtronItem::Importer - No transactions to store for account #{sophtron_account.account_id}"
|
||||
end
|
||||
|
||||
# Fetch and update balance
|
||||
begin
|
||||
fetch_and_update_balance(sophtron_account)
|
||||
rescue => e
|
||||
# Log but don't fail transaction import if balance fetch fails
|
||||
Rails.logger.warn "SophtronItem::Importer - Failed to update balance for account #{sophtron_account.account_id}: #{e.message}"
|
||||
end
|
||||
|
||||
{ success: true, transactions_count: transactions_count }
|
||||
rescue Provider::Error => e
|
||||
Rails.logger.error "SophtronItem::Importer - Sophtron API error for account #{sophtron_account.id}: #{e.message}"
|
||||
{ success: false, transactions_count: 0, error: e.message }
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "SophtronItem::Importer - Failed to parse transaction response for account #{sophtron_account.id}: #{e.message}"
|
||||
{ success: false, transactions_count: 0, error: "Failed to parse response" }
|
||||
rescue => e
|
||||
Rails.logger.error "SophtronItem::Importer - Unexpected error fetching transactions for account #{sophtron_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(sophtron_account)
|
||||
begin
|
||||
balance_data = sophtron_provider.get_account_balance(sophtron_account.customer_id, sophtron_account.account_id)
|
||||
# Extract data from Provider::Response object if needed
|
||||
if balance_data.respond_to?(:data)
|
||||
balance_data = balance_data.data
|
||||
end
|
||||
|
||||
# Validate response structure
|
||||
unless balance_data.is_a?(Hash)
|
||||
Rails.logger.error "SophtronItem::Importer - Invalid balance_data format for account #{sophtron_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 "SophtronItem::Importer - Invalid balance info format for account #{sophtron_account.account_id}"
|
||||
return
|
||||
end
|
||||
|
||||
# Only update if we have a valid amount
|
||||
if balance_info[:amount].present?
|
||||
sophtron_account.update!(
|
||||
balance: balance_info[:amount],
|
||||
currency: balance_info[:currency].presence || sophtron_account.currency
|
||||
)
|
||||
else
|
||||
Rails.logger.warn "SophtronItem::Importer - No amount in balance data for account #{sophtron_account.account_id}"
|
||||
end
|
||||
else
|
||||
Rails.logger.warn "SophtronItem::Importer - No balance data returned for account #{sophtron_account.account_id}"
|
||||
end
|
||||
rescue Provider::Error => e
|
||||
Rails.logger.error "SophtronItem::Importer - Sophtron API error fetching balance for account #{sophtron_account.id}: #{e.message}"
|
||||
# Don't fail if balance fetch fails
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "SophtronItem::Importer - Failed to save balance for account #{sophtron_account.id}: #{e.message}"
|
||||
# Don't fail if balance save fails
|
||||
rescue => e
|
||||
Rails.logger.error "SophtronItem::Importer - Unexpected error updating balance for account #{sophtron_account.id}: #{e.class} - #{e.message}"
|
||||
# Don't fail if balance update fails
|
||||
end
|
||||
end
|
||||
|
||||
# Determines the appropriate start date for fetching transactions.
|
||||
#
|
||||
# Logic:
|
||||
# - For accounts with stored transactions: uses last sync date minus 60-day buffer
|
||||
# - For new accounts: uses account creation date minus 60 days, capped at 120 days ago
|
||||
#
|
||||
# This ensures we capture any late-arriving transactions while limiting
|
||||
# the historical window for new accounts.
|
||||
#
|
||||
# @param sophtron_account [SophtronAccount] The account to determine start date for
|
||||
# @return [Date] The start date for transaction sync
|
||||
def determine_sync_start_date(sophtron_account)
|
||||
configured_start = sophtron_item.sync_start_date&.to_time
|
||||
max_history_start = 3.years.ago
|
||||
floor_start = [ configured_start, max_history_start ].compact.max
|
||||
# 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 = sophtron_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 sophtron_item.last_synced_at
|
||||
[ sophtron_item.last_synced_at - 60.days, floor_start ].compact.max
|
||||
else
|
||||
# Fallback if item hasn't been synced but account has transactions
|
||||
floor_start || 120.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 = sophtron_account.created_at || Time.current
|
||||
first_sync_window = [ account_baseline - 60.days, floor_start || 120.days.ago ].max
|
||||
|
||||
# Use the more recent of: (account created - 60 days) or (120 days ago)
|
||||
# This caps old accounts at 120 days while respecting recent account creation dates
|
||||
first_sync_window
|
||||
end
|
||||
end
|
||||
|
||||
# Handles API errors and marks the item for re-authentication if needed.
|
||||
#
|
||||
# Authentication-related errors cause the item status to be set to
|
||||
# :requires_update, prompting the user to re-enter credentials.
|
||||
#
|
||||
# @param error_message [String] The error message from the API
|
||||
# @return [void]
|
||||
# @raise [Provider::Error] Always raises an error with the message
|
||||
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?("user id") ||
|
||||
error_msg_lower.include?("access key")
|
||||
|
||||
if needs_update
|
||||
begin
|
||||
sophtron_item.update!(status: :requires_update)
|
||||
rescue => e
|
||||
Rails.logger.error "SophtronItem::Importer - Failed to update item status: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.error "SophtronItem::Importer - API error: #{error_message}"
|
||||
raise Provider::Error.new(
|
||||
"Sophtron API error: #{error_message}",
|
||||
:api_error
|
||||
)
|
||||
end
|
||||
end
|
||||
9
app/models/sophtron_item/provided.rb
Normal file
9
app/models/sophtron_item/provided.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module SophtronItem::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def sophtron_provider
|
||||
return nil unless credentials_configured?
|
||||
|
||||
Provider::Sophtron.new(user_id, access_key, base_url: effective_base_url)
|
||||
end
|
||||
end
|
||||
25
app/models/sophtron_item/sync_complete_event.rb
Normal file
25
app/models/sophtron_item/sync_complete_event.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class SophtronItem::SyncCompleteEvent
|
||||
attr_reader :sophtron_item
|
||||
|
||||
def initialize(sophtron_item)
|
||||
@sophtron_item = sophtron_item
|
||||
end
|
||||
|
||||
def broadcast
|
||||
# Update UI with latest account data
|
||||
sophtron_item.accounts.each do |account|
|
||||
account.broadcast_sync_complete
|
||||
end
|
||||
|
||||
# Update the Sophtron item view
|
||||
sophtron_item.broadcast_replace_to(
|
||||
sophtron_item.family,
|
||||
target: "sophtron_item_#{sophtron_item.id}",
|
||||
partial: "sophtron_items/sophtron_item",
|
||||
locals: { sophtron_item: sophtron_item }
|
||||
)
|
||||
|
||||
# Let family handle sync notifications
|
||||
sophtron_item.family.broadcast_sync_complete
|
||||
end
|
||||
end
|
||||
96
app/models/sophtron_item/syncer.rb
Normal file
96
app/models/sophtron_item/syncer.rb
Normal file
@@ -0,0 +1,96 @@
|
||||
# Orchestrates the complete sync process for a SophtronItem.
|
||||
#
|
||||
# The syncer coordinates multiple phases:
|
||||
# 1. Import accounts and transactions from Sophtron API
|
||||
# 2. Check account setup status and collect statistics
|
||||
# 3. Process transactions for linked accounts
|
||||
# 4. Schedule balance calculations
|
||||
# 5. Collect sync statistics and health metrics
|
||||
#
|
||||
# This follows the same pattern as other provider syncers (SimpleFIN, Plaid)
|
||||
# and integrates with the Syncable concern.
|
||||
class SophtronItem::Syncer
|
||||
include SyncStats::Collector
|
||||
|
||||
attr_reader :sophtron_item
|
||||
|
||||
# Initializes a new syncer for a Sophtron item.
|
||||
#
|
||||
# @param sophtron_item [SophtronItem] The item to sync
|
||||
def initialize(sophtron_item)
|
||||
@sophtron_item = sophtron_item
|
||||
end
|
||||
|
||||
# Performs the complete sync process.
|
||||
#
|
||||
# This method orchestrates all phases of the sync:
|
||||
# - Imports fresh data from Sophtron API
|
||||
# - Updates linked accounts and creates new account records
|
||||
# - Processes transactions for linked accounts only
|
||||
# - Schedules balance calculations
|
||||
# - Collects statistics and health metrics
|
||||
#
|
||||
# @param sync [Sync] The sync record to track progress and status
|
||||
# @return [void]
|
||||
# @raise [StandardError] if any phase of the sync fails
|
||||
def perform_sync(sync)
|
||||
# Phase 1: Import data from Sophtron API
|
||||
sync.update!(status_text: t("sophtron_items.syncer.importing_accounts")) if sync.respond_to?(:status_text)
|
||||
sophtron_item.import_latest_sophtron_data
|
||||
|
||||
# Phase 2: Check account setup status and collect sync statistics
|
||||
sync.update!(status_text: t("sophtron_items.syncer.checking_account_configuration")) if sync.respond_to?(:status_text)
|
||||
collect_setup_stats(sync, provider_accounts: sophtron_item.sophtron_accounts)
|
||||
|
||||
# Check for unlinked accounts
|
||||
linked_accounts = sophtron_item.sophtron_accounts.joins(:account_provider)
|
||||
unlinked_accounts = sophtron_item.sophtron_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
|
||||
|
||||
# Set pending_account_setup if there are unlinked accounts
|
||||
unlinked_count = unlinked_accounts.count
|
||||
if unlinked_count.positive?
|
||||
sophtron_item.update!(pending_account_setup: true)
|
||||
sync.update!(status_text: t("sophtron_items.syncer.accounts_need_setup", count: unlinked_count)) if sync.respond_to?(:status_text)
|
||||
else
|
||||
sophtron_item.update!(pending_account_setup: false)
|
||||
end
|
||||
|
||||
# Phase 3: Process transactions for linked accounts only
|
||||
if linked_accounts.any?
|
||||
sync.update!(status_text: t("sophtron_items.syncer.processing_transactions")) if sync.respond_to?(:status_text)
|
||||
mark_import_started(sync)
|
||||
Rails.logger.info "SophtronItem::Syncer - Processing #{linked_accounts.count} linked accounts"
|
||||
sophtron_item.process_accounts
|
||||
Rails.logger.info "SophtronItem::Syncer - Finished processing accounts"
|
||||
|
||||
# Phase 4: Schedule balance calculations for linked accounts
|
||||
sync.update!(status_text: t("sophtron_items.syncer.calculating_balances")) if sync.respond_to?(:status_text)
|
||||
sophtron_item.schedule_account_syncs(
|
||||
parent_sync: sync,
|
||||
window_start_date: sync.window_start_date,
|
||||
window_end_date: sync.window_end_date
|
||||
)
|
||||
|
||||
# Phase 5: Collect transaction statistics
|
||||
account_ids = linked_accounts.includes(:account_provider).filter_map { |la| la.current_account&.id }
|
||||
collect_transaction_stats(sync, account_ids: account_ids, source: "sophtron")
|
||||
else
|
||||
Rails.logger.info "SophtronItem::Syncer - No linked accounts to process"
|
||||
end
|
||||
|
||||
# Mark sync health
|
||||
collect_health_stats(sync, errors: nil)
|
||||
rescue => e
|
||||
collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ])
|
||||
raise
|
||||
end
|
||||
|
||||
# Performs post-sync cleanup or actions.
|
||||
#
|
||||
# Currently a no-op for Sophtron items. Reserved for future use.
|
||||
#
|
||||
# @return [void]
|
||||
def perform_post_sync
|
||||
# no-op
|
||||
end
|
||||
end
|
||||
64
app/models/sophtron_item/unlinking.rb
Normal file
64
app/models/sophtron_item/unlinking.rb
Normal file
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module SophtronItem::Unlinking
|
||||
# Concern that encapsulates unlinking logic for a Sophtron item.
|
||||
# Mirrors the SimplefinItem::Unlinking behavior.
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Idempotently removes all connections between this Sophtron item and local accounts.
|
||||
#
|
||||
# This method:
|
||||
# - Finds all AccountProvider links for each SophtronAccount
|
||||
# - Detaches any Holdings associated with those links
|
||||
# - Destroys the AccountProvider links
|
||||
# - Returns detailed results for observability
|
||||
#
|
||||
# This mirrors the SimplefinItem::Unlinking behavior.
|
||||
#
|
||||
# @param dry_run [Boolean] If true, only report what would be unlinked without making changes
|
||||
# @return [Array<Hash>] Results for each account with keys:
|
||||
# - :sfa_id [Integer] The SophtronAccount ID
|
||||
# - :name [String] The account name
|
||||
# - :provider_link_ids [Array<Integer>] IDs of AccountProvider links found
|
||||
# @example
|
||||
# item.unlink_all!(dry_run: true) # Preview what would be unlinked
|
||||
# item.unlink_all! # Actually unlink all accounts
|
||||
def unlink_all!(dry_run: false)
|
||||
results = []
|
||||
|
||||
sophtron_accounts.find_each do |sfa|
|
||||
links = AccountProvider.where(provider_type: "SophtronAccount", provider_id: sfa.id).to_a
|
||||
link_ids = links.map(&:id)
|
||||
result = {
|
||||
sfa_id: sfa.id,
|
||||
name: sfa.name,
|
||||
provider_link_ids: link_ids
|
||||
}
|
||||
results << result
|
||||
|
||||
next if dry_run
|
||||
|
||||
begin
|
||||
ActiveRecord::Base.transaction do
|
||||
# Detach holdings for any provider links found
|
||||
if link_ids.any?
|
||||
Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil)
|
||||
end
|
||||
|
||||
# Destroy all provider links
|
||||
links.each do |ap|
|
||||
ap.destroy!
|
||||
end
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn(
|
||||
"SophtronItem Unlinker: failed to fully unlink SophtronAccount ##{sfa.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}"
|
||||
)
|
||||
# Record error for observability; continue with other accounts
|
||||
result[:error] = e.message
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
end
|
||||
@@ -17,7 +17,7 @@
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? %>
|
||||
<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? %>
|
||||
<%= render "empty" %>
|
||||
<% else %>
|
||||
<div class="space-y-2">
|
||||
@@ -41,6 +41,10 @@
|
||||
<%= render @coinstats_items.sort_by(&:created_at) %>
|
||||
<% end %>
|
||||
|
||||
<% if @sophtron_items.any? %>
|
||||
<%= render @sophtron_items.sort_by(&:created_at) %>
|
||||
<% end %>
|
||||
|
||||
<% if @mercury_items.any? %>
|
||||
<%= render @mercury_items.sort_by(&:created_at) %>
|
||||
<% end %>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"Plaid" => "#4da568",
|
||||
"SimpleFin" => "#e99537",
|
||||
"Enable Banking" => "#6471eb",
|
||||
"CoinStats" => "#FF9332" # https://coinstats.app/press-kit/
|
||||
"CoinStats" => "#FF9332", # https://coinstats.app/press-kit/
|
||||
"Sophtron" => "#1E90FF"
|
||||
} %>
|
||||
<% provider_color = provider_colors[provider_link[:name]] || "#6B7280" %>
|
||||
|
||||
|
||||
68
app/views/settings/providers/_sophtron_panel.html.erb
Normal file
68
app/views/settings/providers/_sophtron_panel.html.erb
Normal file
@@ -0,0 +1,68 @@
|
||||
<div class="space-y-4">
|
||||
<div class="prose prose-sm text-secondary">
|
||||
<p class="text-primary font-medium"><%= t("sophtron_items.sophtron_panel.setup_instructions_title") %></p>
|
||||
<ol>
|
||||
<li><%= t("sophtron_items.sophtron_panel.setup_instructions.step_1_html", url: "https://www.sophtron.com") %></li>
|
||||
<li><%= t("sophtron_items.sophtron_panel.setup_instructions.step_2") %></li>
|
||||
<li><%= t("sophtron_items.sophtron_panel.setup_instructions.step_3") %></li>
|
||||
</ol>
|
||||
|
||||
<p class="text-primary font-medium"><%= t("sophtron_items.sophtron_panel.field_descriptions_title") %></p>
|
||||
<ul>
|
||||
<li><%= t("sophtron_items.sophtron_panel.field_descriptions.user_id_html") %></li>
|
||||
<li><%= t("sophtron_items.sophtron_panel.field_descriptions.access_key_html") %></li>
|
||||
<li><%= t("sophtron_items.sophtron_panel.field_descriptions.base_url_html") %></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<% error_msg = local_assigns[:error_message] || @error_message %>
|
||||
<% if error_msg.present? %>
|
||||
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
|
||||
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%
|
||||
# Variables passed from controller
|
||||
sophtron_item = @sophtron_item || Current.family.sophtron_items.build
|
||||
is_new_record = @is_new_record || sophtron_item.new_record?
|
||||
sophtron_items = @sophtron_items
|
||||
%>
|
||||
|
||||
<%= styled_form_with model: sophtron_item,
|
||||
url: sophtron_item.new_record? ? sophtron_items_path : sophtron_item_path(sophtron_item),
|
||||
scope: :sophtron_item,
|
||||
method: sophtron_item.new_record? ? :post : :patch,
|
||||
data: { turbo: true },
|
||||
class: "space-y-3" do |form| %>
|
||||
<%= form.text_field :user_id,
|
||||
label: t("sophtron_items.sophtron_panel.fields.user_id.label"),
|
||||
placeholder: is_new_record ? t("sophtron_items.sophtron_panel.fields.user_id.placeholder_new") : t("sophtron_items.sophtron_panel.fields.user_id.placeholder_edit"),
|
||||
type: :password %>
|
||||
|
||||
<%= form.text_field :access_key,
|
||||
label: t("sophtron_items.sophtron_panel.fields.access_key.label"),
|
||||
placeholder: is_new_record ? t("sophtron_items.sophtron_panel.fields.access_key.placeholder_new") : t("sophtron_items.sophtron_panel.fields.access_key.placeholder_edit"),
|
||||
type: :password %>
|
||||
|
||||
<%= form.text_field :base_url,
|
||||
label: t("sophtron_items.sophtron_panel.fields.base_url.label"),
|
||||
placeholder: t("sophtron_items.sophtron_panel.fields.base_url.placeholder"),
|
||||
value: sophtron_item.base_url %>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<%= form.submit is_new_record ? t("sophtron_items.sophtron_panel.save") : t("sophtron_items.sophtron_panel.update"),
|
||||
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<% if Current.family.sophtron_items.any? %>
|
||||
<div class="w-2 h-2 bg-success rounded-full"></div>
|
||||
<p class="text-sm text-secondary"><%= t("sophtron_items.sophtron_panel.status.configured_html", accounts_path: accounts_path) %></p>
|
||||
<% else %>
|
||||
<div class="w-2 h-2 bg-muted rounded-full"></div>
|
||||
<p class="text-sm text-secondary"><%= t("sophtron_items.sophtron_panel.status.not_configured") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,5 +84,11 @@
|
||||
<%= render "settings/providers/indexa_capital_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Sophtron (alpha)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="sophtron-providers-panel">
|
||||
<%= render "settings/providers/sophtron_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
36
app/views/sophtron_items/_api_error.html.erb
Normal file
36
app/views/sophtron_items/_api_error.html.erb
Normal file
@@ -0,0 +1,36 @@
|
||||
<%# locals: (error_message:, return_path: nil) %>
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t("sophtron_items.api_error.title")) %>
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon("alert-circle", class: "text-destructive w-5 h-5 shrink-0 mt-0.5") %>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-primary mb-2"><%= t("sophtron_items.api_error.unable_to_connect") %></p>
|
||||
<p class="text-secondary"><%= h(error_message) %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
|
||||
<p class="font-medium text-primary"><%= t("sophtron_items.api_error.common_issues_title") %></p>
|
||||
<ul class="list-disc list-inside space-y-1 text-secondary">
|
||||
<li><%= t("sophtron_items.api_error.incorrect_user_id") %></li>
|
||||
<li><%= t("sophtron_items.api_error.invalid_access_key") %></li>
|
||||
<li><%= t("sophtron_items.api_error.expired_credentials") %></li>
|
||||
<li><%= t("sophtron_items.api_error.network_issue") %></li>
|
||||
<li><%= t("sophtron_items.api_error.service_down") %></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<%= link_to (return_path.presence || settings_providers_path),
|
||||
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover focus:outline-none focus:ring-2 focus:ring-primary transition-colors",
|
||||
data: { turbo: false } do %>
|
||||
<%= t("sophtron_items.api_error.check_provider_settings") %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
16
app/views/sophtron_items/_loading.html.erb
Normal file
16
app/views/sophtron_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 %>
|
||||
34
app/views/sophtron_items/_setup_required.html.erb
Normal file
34
app/views/sophtron_items/_setup_required.html.erb
Normal file
@@ -0,0 +1,34 @@
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t("sophtron_items.sophtron_setup_required.title")) %>
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %>
|
||||
<div class="text-sm text-secondary">
|
||||
<p class="font-medium text-primary mb-2"><%= t("sophtron_items.sophtron_setup_required.heading") %></p>
|
||||
<p><%= t("sophtron_items.sophtron_setup_required.description") %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
|
||||
<p class="font-medium text-primary"><%= t("sophtron_items.sophtron_setup_required.setup_steps_title") %></p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-secondary">
|
||||
<li><%= t("sophtron_items.sophtron_setup_required.step_1_html") %></li>
|
||||
<li><%= t("sophtron_items.sophtron_setup_required.step_2_html") %></li>
|
||||
<li><%= t("sophtron_items.sophtron_setup_required.step_3_html") %></li>
|
||||
<li><%= t("sophtron_items.sophtron_setup_required.step_4") %></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<%= link_to settings_providers_path,
|
||||
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors",
|
||||
data: { turbo: false } do %>
|
||||
<%= t("sophtron_items.sophtron_setup_required.go_to_provider_settings") %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
130
app/views/sophtron_items/_sophtron_item.html.erb
Normal file
130
app/views/sophtron_items/_sophtron_item.html.erb
Normal file
@@ -0,0 +1,130 @@
|
||||
<%# locals: (sophtron_item:) %>
|
||||
|
||||
<%= tag.div id: dom_id(sophtron_item) do %>
|
||||
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<summary class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||
|
||||
<div class="flex items-center justify-center h-8 w-8 bg-orange-600/10 rounded-full">
|
||||
<% if sophtron_item.logo.attached? %>
|
||||
<%= image_tag sophtron_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %>
|
||||
<% else %>
|
||||
<div class="flex items-center justify-center">
|
||||
<%= tag.p sophtron_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 sophtron_item.name, class: "font-medium text-primary" %>
|
||||
<% if sophtron_item.scheduled_for_deletion? %>
|
||||
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if sophtron_item.accounts.any? %>
|
||||
<p class="text-xs text-secondary">
|
||||
<%= sophtron_item.institution_summary %>
|
||||
</p>
|
||||
<% end %>
|
||||
<% if sophtron_item.syncing? %>
|
||||
<div class="text-secondary flex items-center gap-1">
|
||||
<%= icon "loader", size: "sm", class: "animate-spin" %>
|
||||
<%= tag.span t(".syncing") %>
|
||||
</div>
|
||||
<% elsif sophtron_item.sync_error.present? %>
|
||||
<div class="text-secondary flex items-center gap-1">
|
||||
<%= render DS::Tooltip.new(text: sophtron_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %>
|
||||
<%= tag.span t(".error"), class: "text-destructive" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-secondary">
|
||||
<% if sophtron_item.last_synced_at %>
|
||||
<% if sophtron_item.sync_status_summary %>
|
||||
<%= t(".status_with_summary", timestamp: time_ago_in_words(sophtron_item.last_synced_at), summary: sophtron_item.sync_status_summary) %>
|
||||
<% else %>
|
||||
<%= t(".status", timestamp: time_ago_in_words(sophtron_item.last_synced_at)) %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= t(".status_never") %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<% if Rails.env.development? %>
|
||||
<%= icon(
|
||||
"refresh-cw",
|
||||
as_button: true,
|
||||
href: sync_sophtron_item_path(sophtron_item)
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
<%= render DS::Menu.new do |menu| %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".delete"),
|
||||
icon: "trash-2",
|
||||
href: sophtron_item_path(sophtron_item),
|
||||
method: :delete,
|
||||
confirm: CustomConfirm.for_resource_deletion(sophtron_item.name, high_severity: true)
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<% unless sophtron_item.scheduled_for_deletion? %>
|
||||
<div class="space-y-4 mt-4">
|
||||
<% if sophtron_item.accounts.any? %>
|
||||
<%= render "accounts/index/account_groups", accounts: sophtron_item.accounts %>
|
||||
<% end %>
|
||||
|
||||
<%# Sync summary (collapsible) - using shared ProviderSyncSummary component %>
|
||||
<% stats = if defined?(@sophtron_sync_stats_map) && @sophtron_sync_stats_map
|
||||
@sophtron_sync_stats_map[sophtron_item.id] || {}
|
||||
else
|
||||
sophtron_item.syncs.ordered.first&.sync_stats || {}
|
||||
end %>
|
||||
<%= render ProviderSyncSummary.new(
|
||||
stats: stats,
|
||||
provider_item: sophtron_item,
|
||||
institutions_count: sophtron_item.connected_institutions.size
|
||||
) %>
|
||||
|
||||
<%# Use model methods for consistent counts %>
|
||||
<% unlinked_count = sophtron_item.unlinked_accounts_count %>
|
||||
<% linked_count = sophtron_item.linked_accounts_count %>
|
||||
<% total_count = sophtron_item.total_accounts_count %>
|
||||
|
||||
<% if unlinked_count > 0 %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".setup_description", linked: linked_count, total: total_count) %></p>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".setup_action"),
|
||||
icon: "settings",
|
||||
variant: "primary",
|
||||
href: setup_accounts_sophtron_item_path(sophtron_item),
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
<% elsif sophtron_item.accounts.empty? && total_count == 0 %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_title") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".no_accounts_description") %></p>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".setup_action"),
|
||||
icon: "settings",
|
||||
variant: "primary",
|
||||
href: setup_accounts_sophtron_item_path(sophtron_item),
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</details>
|
||||
<% end %>
|
||||
23
app/views/sophtron_items/_subtype_select.html.erb
Normal file
23
app/views/sophtron_items/_subtype_select.html.erb
Normal file
@@ -0,0 +1,23 @@
|
||||
<div class="subtype-select" data-type="<%= account_type %>" style="display: none;">
|
||||
<% if subtype_config[:options].present? %>
|
||||
<%= label_tag "account_subtypes[#{sophtron_account.id}]", subtype_config[:label],
|
||||
class: "block text-sm font-medium text-primary mb-2" %>
|
||||
<% selected_value = "" %>
|
||||
<% if account_type == "Depository" %>
|
||||
<% n = sophtron_account.name.to_s.downcase %>
|
||||
<% selected_value = "" %>
|
||||
<% if n =~ /\bchecking\b|\bchequing\b|\bck\b|demand\s+deposit/ %>
|
||||
<% selected_value = "checking" %>
|
||||
<% elsif n =~ /\bsavings\b|\bsv\b/ %>
|
||||
<% selected_value = "savings" %>
|
||||
<% elsif n =~ /money\s+market|\bmm\b/ %>
|
||||
<% selected_value = "money_market" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= select_tag "account_subtypes[#{sophtron_account.id}]",
|
||||
options_for_select([["Select #{account_type == 'Depository' ? 'subtype' : 'type'}", ""]] + subtype_config[:options], selected_value),
|
||||
{ class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full" } %>
|
||||
<% else %>
|
||||
<p class="text-sm text-secondary"><%= subtype_config[:message] %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
54
app/views/sophtron_items/select_accounts.html.erb
Normal file
54
app/views/sophtron_items/select_accounts.html.erb
Normal file
@@ -0,0 +1,54 @@
|
||||
<%= 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", product_name: product_name) %>
|
||||
</p>
|
||||
|
||||
<form action="<%= link_accounts_sophtron_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| %>
|
||||
<% Rails.logger.debug "Sophtron account data: #{account.inspect}" %>
|
||||
<% has_blank_name = account[:account_name].blank? %>
|
||||
<label class="flex items-start gap-3 p-3 border <%= has_blank_name ? "border-error bg-error/5" : "border-primary" %> rounded-lg <%= has_blank_name ? "cursor-not-allowed opacity-60" : "hover:bg-subtle cursor-pointer" %> transition-colors">
|
||||
<%= check_box_tag "account_ids[]", account[:id], false, disabled: has_blank_name, class: "mt-1" %>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm <%= has_blank_name ? "text-error" : "text-primary" %>">
|
||||
<% if has_blank_name %>
|
||||
<%= t(".no_name_placeholder") %>
|
||||
<% else %>
|
||||
<%= account[:account_name] %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
<%= account[:institution_name] %> • <%= account[:currency] %> • <%= account[:status] %>
|
||||
</div>
|
||||
<% if has_blank_name %>
|
||||
<div class="text-xs text-error mt-1">
|
||||
<%= t(".configure_name_in_sophtron") %>
|
||||
</div>
|
||||
<% end %>
|
||||
</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", action: "DS--dialog#close" } %>
|
||||
<%= 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 %>
|
||||
58
app/views/sophtron_items/select_existing_account.html.erb
Normal file
58
app/views/sophtron_items/select_existing_account.html.erb
Normal file
@@ -0,0 +1,58 @@
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title", account_name: @account.name)) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t(".description") %>
|
||||
</p>
|
||||
|
||||
<%= form_with url: link_existing_account_sophtron_items_path,
|
||||
method: :post,
|
||||
class: "space-y-4",
|
||||
data: { turbo_frame: "_top" } do %>
|
||||
<%= hidden_field_tag :account_id, @account.id %>
|
||||
<%= hidden_field_tag :return_to, @return_to %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<% @available_accounts.each do |account| %>
|
||||
<% has_blank_name = account[:account_name].blank? %>
|
||||
<label class="flex items-start gap-3 p-3 border <%= has_blank_name ? "border-error bg-error/5" : "border-primary" %> rounded-lg <%= has_blank_name ? "cursor-not-allowed opacity-60" : "hover:bg-subtle cursor-pointer" %> transition-colors">
|
||||
<%= radio_button_tag "sophtron_account_id", account[:id], false,
|
||||
required: true,
|
||||
disabled: has_blank_name,
|
||||
class: "mt-1" %>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm <%= has_blank_name ? "text-error" : "text-primary" %>">
|
||||
<% if has_blank_name %>
|
||||
<%= t(".no_name_placeholder") %>
|
||||
<% else %>
|
||||
<%= account[:account_name] %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
<%= account[:institution_name] %> • <%= account[:currency] %> • <%= account[:status] %>
|
||||
</div>
|
||||
<% if has_blank_name %>
|
||||
<div class="text-xs text-error mt-1">
|
||||
<%= t(".configure_name_in_sophtron") %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<%= link_to t(".cancel"), @return_to || accounts_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", action: "click->DS--dialog#close" } %>
|
||||
<%= submit_tag t(".link_account"),
|
||||
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>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
105
app/views/sophtron_items/setup_accounts.html.erb
Normal file
105
app/views/sophtron_items/setup_accounts.html.erb
Normal file
@@ -0,0 +1,105 @@
|
||||
<% content_for :title, "Set Up Sophtron Accounts" %>
|
||||
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title")) do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "building-2", class: "text-primary" %>
|
||||
<span class="text-primary"><%= t(".subtitle") %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<%= form_with url: complete_account_setup_sophtron_item_path(@sophtron_item),
|
||||
method: :post,
|
||||
local: true,
|
||||
data: {
|
||||
controller: "loading-button",
|
||||
action: "submit->loading-button#showLoading",
|
||||
loading_button_loading_text_value: t(".creating_accounts"),
|
||||
turbo_frame: "_top"
|
||||
},
|
||||
class: "space-y-6" do |form| %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% if @api_error.present? %>
|
||||
<div class="p-8 flex flex-col gap-3 items-center justify-center text-center">
|
||||
<%= icon "alert-circle", size: "lg", class: "text-destructive" %>
|
||||
<p class="text-primary font-medium"><%= t(".fetch_failed") %></p>
|
||||
<p class="text-destructive text-sm"><%= @api_error %></p>
|
||||
</div>
|
||||
<% elsif @sophtron_accounts.empty? %>
|
||||
<div class="p-8 flex flex-col gap-3 items-center justify-center text-center">
|
||||
<%= icon "check-circle", size: "lg", class: "text-success" %>
|
||||
<p class="text-primary font-medium"><%= t(".no_accounts_to_setup") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".all_accounts_linked") %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-surface border border-primary p-4 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
|
||||
<div>
|
||||
<p class="text-sm text-primary mb-2">
|
||||
<strong><%= t(".choose_account_type") %></strong>
|
||||
</p>
|
||||
<ul class="text-xs text-secondary space-y-1 list-disc list-inside">
|
||||
<% @account_type_options.reject { |_, type| type == "skip" }.each do |label, type| %>
|
||||
<li><strong><%= label %></strong></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% @sophtron_accounts.each do |sophtron_account| %>
|
||||
<div class="border border-primary rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="font-medium text-primary">
|
||||
<%= sophtron_account.name %>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-controller="account-type-selector" data-account-type-selector-account-id-value="<%= sophtron_account.id %>">
|
||||
<div>
|
||||
<%= label_tag "account_types[#{sophtron_account.id}]", t(".account_type_label"),
|
||||
class: "block text-sm font-medium text-primary mb-2" %>
|
||||
<%= select_tag "account_types[#{sophtron_account.id}]",
|
||||
options_for_select(@account_type_options, "skip"),
|
||||
{ class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full",
|
||||
data: {
|
||||
action: "change->account-type-selector#updateSubtype"
|
||||
} } %>
|
||||
</div>
|
||||
|
||||
<!-- Subtype dropdowns (shown/hidden based on account type) -->
|
||||
<div data-account-type-selector-target="subtypeContainer">
|
||||
<% @subtype_options.each do |account_type, subtype_config| %>
|
||||
<%= render "sophtron_items/subtype_select", account_type: account_type, subtype_config: subtype_config, sophtron_account: sophtron_account %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= render DS::Button.new(
|
||||
text: t(".create_accounts"),
|
||||
variant: "primary",
|
||||
icon: "plus",
|
||||
type: "submit",
|
||||
class: "flex-1",
|
||||
disabled: @api_error.present? || @sophtron_accounts.empty?,
|
||||
data: { loading_button_target: "button" }
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".cancel"),
|
||||
variant: "secondary",
|
||||
href: accounts_path
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
228
config/locales/views/sophtron_items/en.yml
Normal file
228
config/locales/views/sophtron_items/en.yml
Normal file
@@ -0,0 +1,228 @@
|
||||
---
|
||||
en:
|
||||
sophtron_items:
|
||||
defaults:
|
||||
name: Sophtron Connection
|
||||
new:
|
||||
title: Connect Sophtron
|
||||
user_id: User ID
|
||||
user_id_placeholder: paste your Sophtron User ID
|
||||
access_key: Access Key
|
||||
access_key_placeholder: paste your Sophtron Access Key
|
||||
connect: Connect
|
||||
cancel: Cancel
|
||||
create:
|
||||
success: Sophtron connection created successfully
|
||||
destroy:
|
||||
success: Sophtron connection removed
|
||||
update:
|
||||
success: Sophtron connection updated successfully! Your accounts are being reconnected.
|
||||
errors:
|
||||
blank_user_id: Please enter a Sophtron User ID.
|
||||
invalid_user_id: Invalid User ID. Please check that you copied the complete User ID from Sophtron.
|
||||
user_id_compromised: The User ID may be compromised, expired, or already used. Please create a new one.
|
||||
blank_access_key: Please enter a Sophtron Access Key.
|
||||
invalid_access_key: Invalid Access Key. Please check that you copied the complete Access Key from Sophtron.
|
||||
access_key_compromised: The Access Key may be compromised, expired, or already used. Please create a new one.
|
||||
update_failed: "Failed to update connection: %{message}"
|
||||
unexpected: An unexpected error occurred. Please try again or contact support.
|
||||
edit:
|
||||
user_id:
|
||||
label: "Sophtron User ID:"
|
||||
placeholder: "Paste your Sophtron User ID here..."
|
||||
help_text: "The User ID should be a long string starting with letters and numbers"
|
||||
access_key:
|
||||
label: "Sophtron Access Key:"
|
||||
placeholder: "Paste your Sophtron Access Key here..."
|
||||
help_text: "The Access Key should be a long string starting with letters and numbers"
|
||||
index:
|
||||
title: Sophtron Connections
|
||||
loading:
|
||||
loading_message: Loading Sophtron accounts...
|
||||
loading_title: Loading
|
||||
link_accounts:
|
||||
all_already_linked:
|
||||
one: "The selected account (%{names}) is already linked"
|
||||
other: "All %{count} selected accounts are already linked: %{names}"
|
||||
api_error: "API connection error"
|
||||
invalid_account_names:
|
||||
one: "Cannot link account with blank name"
|
||||
other: "Cannot link %{count} accounts with blank names"
|
||||
link_failed: Failed to link accounts
|
||||
no_accounts_selected: Please select at least one account
|
||||
partial_invalid: "Successfully linked %{created_count} account(s), %{already_linked_count} were already linked, %{invalid_count} account(s) had invalid names"
|
||||
partial_success: "Successfully linked %{created_count} account(s). %{already_linked_count} account(s) were already linked: %{already_linked_names}"
|
||||
success:
|
||||
one: "Successfully linked %{count} account"
|
||||
other: "Successfully linked %{count} accounts"
|
||||
no_credentials_configured: "Please configure your Sophtron API User ID and Access Key first in Provider Settings."
|
||||
no_accounts_found: No accounts found. Please check your API key configuration.
|
||||
no_access_key: Sophtron Access key is not configured. Please configure it in Settings.
|
||||
no_user_id: Sophtron User ID is not configured. Please configure it in Settings.
|
||||
sophtron_item:
|
||||
accounts_need_setup: Accounts need setup
|
||||
delete: Delete connection
|
||||
deletion_in_progress: deletion in progress...
|
||||
error: Error
|
||||
no_accounts_description: This connection has no linked accounts yet.
|
||||
no_accounts_title: No accounts
|
||||
setup_action: Set Up New Accounts
|
||||
setup_description: "%{linked} of %{total} accounts linked. Choose account types for your newly imported Sophtron accounts."
|
||||
setup_needed: New accounts ready to set up
|
||||
status: "Synced %{timestamp} ago"
|
||||
status_never: Never synced
|
||||
status_with_summary: "Last synced %{timestamp} ago • %{summary}"
|
||||
syncing: Syncing...
|
||||
total: Total
|
||||
unlinked: Unlinked
|
||||
preload_accounts:
|
||||
preload_accounts: preload accounts
|
||||
api_error: "API connection error"
|
||||
unexpected_error: "An unexpected error occurred"
|
||||
no_credentials_configured: "Please configure your Sophtron API User ID and Access Key first in Provider Settings."
|
||||
no_accounts_found: No accounts found. Please check your API key configuration.
|
||||
no_access_key: Sophtron Access key is not configured. Please configure it in Settings.
|
||||
no_user_id: Sophtron User ID is not configured. Please configure it in Settings.
|
||||
select_accounts:
|
||||
accounts_selected: accounts selected
|
||||
api_error: "API connection error"
|
||||
unexpected_error: "An unexpected error occurred"
|
||||
cancel: Cancel
|
||||
configure_name_in_sophtron: Cannot import - please configure account name in Sophtron
|
||||
description: Select the accounts you want to link to your %{product_name} account.
|
||||
link_accounts: Link selected accounts
|
||||
no_accounts_found: No accounts found. Please check your API key configuration.
|
||||
no_access_key: Sophtron Access key is not configured. Please configure it in Settings.
|
||||
no_user_id: Sophtron User ID is not configured. Please configure it in Settings.
|
||||
no_credentials_configured: "Please configure your Sophtron API User ID and Access Key first in Provider Settings."
|
||||
no_name_placeholder: "(No name)"
|
||||
title: Select Sophtron Accounts
|
||||
select_existing_account:
|
||||
account_already_linked: This account is already linked to a provider
|
||||
all_accounts_already_linked: All Sophtron accounts are already linked
|
||||
api_error: "API connection error"
|
||||
cancel: Cancel
|
||||
configure_name_in_sophtron: Cannot import - please configure account name in Sophtron
|
||||
description: Select a Sophtron account to link with this account. Transactions will be synced and deduplicated automatically.
|
||||
link_account: Link account
|
||||
no_account_specified: No account specified
|
||||
no_accounts_found: No Sophtron accounts found. Please check your API key configuration.
|
||||
no_access_key: Sophtron Access key is not configured. Please configure it in Settings.
|
||||
no_user_id: Sophtron User ID is not configured. Please configure it in Settings.
|
||||
no_name_placeholder: "(No name)"
|
||||
title: "Link %{account_name} with Sophtron"
|
||||
link_existing_account:
|
||||
account_already_linked: This account is already linked to a provider
|
||||
api_error: "API connection error"
|
||||
unexpected_error: "An unexpected error occurred"
|
||||
invalid_account_name: Cannot link account with blank name
|
||||
sophtron_account_already_linked: This Sophtron account is already linked to another account
|
||||
sophtron_account_not_found: Sophtron account not found
|
||||
missing_parameters: Missing required parameters
|
||||
success: "Successfully linked %{account_name} with Sophtron"
|
||||
setup_accounts:
|
||||
account_type_label: "Account Type:"
|
||||
all_accounts_linked: "All your Sophtron accounts have already been set up."
|
||||
api_error: "API connection error"
|
||||
unexpected_error: "An unexpected error occurred"
|
||||
fetch_failed: "Failed to Fetch Accounts"
|
||||
no_accounts_to_setup: "No Accounts to Set Up"
|
||||
no_access_key: "Sophtron Access key is not configured. Please check your connection settings."
|
||||
no_user_id: "Sophtron User ID is not configured. Please check your connection settings."
|
||||
account_types:
|
||||
skip: Skip this account
|
||||
depository: Checking or Savings Account
|
||||
credit_card: Credit Card
|
||||
investment: Investment Account
|
||||
loan: Loan or Mortgage
|
||||
other_asset: Other Asset
|
||||
subtype_labels:
|
||||
depository: "Account Subtype:"
|
||||
credit_card: ""
|
||||
investment: "Investment Type:"
|
||||
loan: "Loan Type:"
|
||||
other_asset: ""
|
||||
subtype_messages:
|
||||
credit_card: "Credit cards will be automatically set up as credit card accounts."
|
||||
other_asset: "No additional options needed for Other Assets."
|
||||
balance: Balance
|
||||
cancel: Cancel
|
||||
choose_account_type: "Choose the correct account type for each Sophtron account:"
|
||||
create_accounts: Create Accounts
|
||||
creating_accounts: Creating Accounts...
|
||||
historical_data_range: "Historical Data Range:"
|
||||
subtitle: Choose the correct account types for your imported accounts
|
||||
sync_start_date_help: Select how far back you want to sync transaction history. Maximum 3 years of history available.
|
||||
sync_start_date_label: "Start syncing transactions from:"
|
||||
title: Set Up Your Sophtron Accounts
|
||||
complete_account_setup:
|
||||
all_skipped: "All accounts were skipped. No accounts were created."
|
||||
creation_failed: "Failed to create accounts"
|
||||
api_error: "API connection error"
|
||||
unexpected_error: "An unexpected error occurred"
|
||||
no_accounts: "No accounts to set up."
|
||||
success: "Successfully created %{count} account(s)."
|
||||
sync:
|
||||
success: Sync started
|
||||
sophtron_setup_required:
|
||||
title: Sophtron Setup Required
|
||||
message: >
|
||||
To complete the setup of your Sophtron connection, please go to the Provider Settings page and follow the instructions to authorize and configure your Sophtron connection.
|
||||
go_to_provider_settings: Go to Provider Settings
|
||||
heading: "User ID and Access Key Not Configured"
|
||||
description: "Before you can link Sophtron accounts, you need to configure your Sophtron User ID and Access key."
|
||||
setup_steps_title: "Setup Steps:"
|
||||
step_1_html: "Go to <strong>Settings → Bank Sync Providers</strong>"
|
||||
step_2_html: "Find the <strong>Sophtron</strong> section"
|
||||
step_3_html: "Enter your Sophtron User ID and Access key"
|
||||
step_4: "Return here to link your accounts"
|
||||
api_error:
|
||||
title: "Sophtron Connection Error"
|
||||
unable_to_connect: "Unable to connect to Sophtron"
|
||||
common_issues_title: "Common Issues:"
|
||||
incorrect_user_id: "Incorrect User ID: Verify your User ID in Provider Settings"
|
||||
invalid_access_key: "Invalid Access Key: Check your Access Key in Provider Settings"
|
||||
expired_credentials: "Expired Credentials: Generate a new User ID and Access Key from Sophtron"
|
||||
network_issue: "Network Issue: Check your internet connection"
|
||||
service_down: "Service Down: Sophtron API may be temporarily unavailable"
|
||||
check_provider_settings: "Check Provider Settings"
|
||||
select_option: "Select %{type}"
|
||||
subtype: "subtype"
|
||||
type: "type"
|
||||
sophtron_panel:
|
||||
setup_instructions_title: "Setup instructions:"
|
||||
setup_instructions:
|
||||
step_1_html: 'Visit <a href="%{url}" target="_blank" rel="noopener noreferrer" class="link">Sophtron</a> to obtain your API credentials'
|
||||
step_2: "Copy your User ID and Access Key from your Sophtron account settings"
|
||||
step_3: "Paste the credentials below and click Save to enable Sophtron bank data sync"
|
||||
field_descriptions_title: "Field descriptions:"
|
||||
field_descriptions:
|
||||
user_id_html: "<strong>User ID:</strong> Your Sophtron User ID credential"
|
||||
access_key_html: "<strong>Access Key:</strong> Your Sophtron Access Key credential"
|
||||
base_url_html: "<strong>Base URL:</strong> The Sophtron API endpoint URL (usually provided by Sophtron)"
|
||||
fields:
|
||||
user_id:
|
||||
label: "User ID"
|
||||
placeholder_new: "Paste your Sophtron User ID"
|
||||
placeholder_edit: "••••••••"
|
||||
access_key:
|
||||
label: "Access Key"
|
||||
placeholder_new: "Paste your Sophtron Access Key"
|
||||
placeholder_edit: "••••••••"
|
||||
base_url:
|
||||
label: "Base URL"
|
||||
placeholder: "https://api.sophtron.com/v2"
|
||||
save: "Save Configuration"
|
||||
update: "Update Configuration"
|
||||
status:
|
||||
configured_html: 'Configured and ready to use. Visit the <a href="%{accounts_path}" class="link">Accounts</a> tab to manage and set up accounts.'
|
||||
not_configured: "Not configured"
|
||||
syncer:
|
||||
importing_accounts: "Importing accounts from Sophtron..."
|
||||
checking_account_configuration: "Checking account configuration..."
|
||||
accounts_need_setup: "%{count} account(s) need setup"
|
||||
processing_transactions: "Processing transactions for linked accounts..."
|
||||
calculating_balances: "Calculating balances for linked accounts..."
|
||||
sophtron_entry:
|
||||
processor:
|
||||
unknown_transaction: "Unknown transaction"
|
||||
@@ -510,6 +510,23 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
resources :sophtron_items, only: %i[index new create show edit update destroy] do
|
||||
collection do
|
||||
get :preload_accounts
|
||||
get :select_accounts
|
||||
post :link_accounts
|
||||
get :select_existing_account
|
||||
post :link_existing_account
|
||||
end
|
||||
|
||||
member do
|
||||
post :sync
|
||||
post :balances
|
||||
get :setup_accounts
|
||||
post :complete_account_setup
|
||||
end
|
||||
end
|
||||
|
||||
namespace :webhooks do
|
||||
post "plaid"
|
||||
post "plaid_eu"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
class CreateSophtronItemsAndAccounts < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
# Create provider items table (stores per-family connection credentials)
|
||||
create_table :sophtron_items, id: :uuid do |t|
|
||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||
t.string :name
|
||||
|
||||
# Institution metadata
|
||||
t.string :institution_id
|
||||
t.string :institution_name
|
||||
t.string :institution_domain
|
||||
t.string :institution_url
|
||||
t.string :institution_color
|
||||
|
||||
# Status and lifecycle
|
||||
t.string :status, default: "good"
|
||||
t.boolean :scheduled_for_deletion, default: false
|
||||
t.boolean :pending_account_setup, default: false
|
||||
|
||||
# Sync settings
|
||||
t.datetime :sync_start_date
|
||||
|
||||
# Raw data storage
|
||||
t.jsonb :raw_payload
|
||||
t.jsonb :raw_institution_payload
|
||||
|
||||
# Provider-specific credential fields
|
||||
t.string :user_id
|
||||
t.string :access_key
|
||||
t.string :base_url
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
add_index :sophtron_items, :status
|
||||
|
||||
# Create provider accounts table (stores individual account data from provider)
|
||||
create_table :sophtron_accounts, id: :uuid do |t|
|
||||
t.references :sophtron_item, null: false, foreign_key: true, type: :uuid
|
||||
# Account identification
|
||||
t.string :name, null: false
|
||||
t.string :account_id, null: false
|
||||
|
||||
# Account details
|
||||
t.string :currency
|
||||
t.decimal :balance, precision: 19, scale: 4
|
||||
t.decimal :available_balance, precision: 19, scale: 4
|
||||
t.string :account_status
|
||||
t.string :account_type
|
||||
t.string :account_sub_type
|
||||
t.datetime :last_updated
|
||||
|
||||
# Metadata and raw data
|
||||
t.jsonb :institution_metadata
|
||||
t.jsonb :raw_payload
|
||||
t.jsonb :raw_transactions_payload
|
||||
|
||||
t.string :customer_id, null: false
|
||||
t.string :member_id, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
add_index :sophtron_accounts, :account_id
|
||||
end
|
||||
end
|
||||
48
db/schema.rb
generated
48
db/schema.rb
generated
@@ -1383,6 +1383,52 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do
|
||||
t.index ["status"], name: "index_snaptrade_items_on_status"
|
||||
end
|
||||
|
||||
create_table "sophtron_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "sophtron_item_id", null: false
|
||||
t.string "name", null: false
|
||||
t.string "account_id", null: false
|
||||
t.string "currency"
|
||||
t.decimal "balance", precision: 19, scale: 4
|
||||
t.decimal "available_balance", precision: 19, scale: 4
|
||||
t.string "account_status"
|
||||
t.string "account_type"
|
||||
t.string "account_sub_type"
|
||||
t.datetime "last_updated"
|
||||
t.jsonb "institution_metadata"
|
||||
t.jsonb "raw_payload"
|
||||
t.jsonb "raw_transactions_payload"
|
||||
t.string "customer_id", null: false
|
||||
t.string "member_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_sophtron_accounts_on_account_id"
|
||||
t.index ["sophtron_item_id"], name: "index_sophtron_accounts_on_sophtron_item_id"
|
||||
t.index ["sophtron_item_id", "account_id"], name: "idx_unique_sophtron_accounts_per_item", unique: true
|
||||
end
|
||||
|
||||
create_table "sophtron_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.string "name"
|
||||
t.string "institution_id"
|
||||
t.string "institution_name"
|
||||
t.string "institution_domain"
|
||||
t.string "institution_url"
|
||||
t.string "institution_color"
|
||||
t.string "status", default: "good"
|
||||
t.boolean "scheduled_for_deletion", default: false
|
||||
t.boolean "pending_account_setup", default: false
|
||||
t.datetime "sync_start_date"
|
||||
t.jsonb "raw_payload"
|
||||
t.jsonb "raw_institution_payload"
|
||||
t.string "user_id", null: false
|
||||
t.string "access_key", null: false
|
||||
t.string "base_url"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["family_id"], name: "index_sophtron_items_on_family_id"
|
||||
t.index ["status"], name: "index_sophtron_items_on_status"
|
||||
end
|
||||
|
||||
create_table "sso_audit_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "user_id"
|
||||
t.string "event_type", null: false
|
||||
@@ -1666,6 +1712,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do
|
||||
add_foreign_key "simplefin_items", "families"
|
||||
add_foreign_key "snaptrade_accounts", "snaptrade_items"
|
||||
add_foreign_key "snaptrade_items", "families"
|
||||
add_foreign_key "sophtron_accounts", "sophtron_items"
|
||||
add_foreign_key "sophtron_items", "families"
|
||||
add_foreign_key "sso_audit_logs", "users"
|
||||
add_foreign_key "subscriptions", "families"
|
||||
add_foreign_key "syncs", "syncs", column: "parent_id"
|
||||
|
||||
@@ -124,6 +124,7 @@ namespace :dev do
|
||||
DevSyncStatsHelpers.generate_fake_stats_for_items(LunchflowItem, "lunchflow")
|
||||
DevSyncStatsHelpers.generate_fake_stats_for_items(EnableBankingItem, "enable_banking")
|
||||
DevSyncStatsHelpers.generate_fake_stats_for_items(CoinstatsItem, "coinstats")
|
||||
DevSyncStatsHelpers.generate_fake_stats_for_items(SophtronItem, "sophtron")
|
||||
|
||||
puts "Done! Refresh your browser to see the sync summaries."
|
||||
end
|
||||
@@ -154,7 +155,7 @@ namespace :dev do
|
||||
DevSyncStatsHelpers.generate_fake_stats_for_items(LunchflowItem, "lunchflow", include_issues: true)
|
||||
DevSyncStatsHelpers.generate_fake_stats_for_items(EnableBankingItem, "enable_banking", include_issues: true)
|
||||
DevSyncStatsHelpers.generate_fake_stats_for_items(CoinstatsItem, "coinstats", include_issues: true)
|
||||
|
||||
DevSyncStatsHelpers.generate_fake_stats_for_items(SophtronItem, "sophtron", include_issues: true)
|
||||
puts "Done! Refresh your browser to see the sync summaries with issues."
|
||||
end
|
||||
|
||||
@@ -231,6 +232,27 @@ namespace :dev do
|
||||
end
|
||||
puts " Created 2 LunchflowAccounts"
|
||||
|
||||
# Create a fake Sophtron item
|
||||
sophtron_item = family.sophtron_items.create!(
|
||||
name: "Test Sophtron Connection",
|
||||
user_id: "test-user-id-#{SecureRandom.hex(16)}",
|
||||
access_key: "test-access-key-#{SecureRandom.hex(32)}"
|
||||
)
|
||||
puts " Created SophtronItem: #{sophtron_item.name}"
|
||||
|
||||
# Create fake Sophtron accounts
|
||||
2.times do |i|
|
||||
sophtron_item.sophtron_accounts.create!(
|
||||
name: "Test Sophtron Account #{i + 1}",
|
||||
account_id: "test-sophtron-#{SecureRandom.hex(8)}",
|
||||
customer_id: "test-sophtron-#{SecureRandom.hex(8)}",
|
||||
member_id: "test-sophtron-#{SecureRandom.hex(8)}",
|
||||
currency: "USD",
|
||||
current_balance: rand(1000..50000)
|
||||
)
|
||||
end
|
||||
puts " Created 2 SophtronAccounts"
|
||||
|
||||
# Create a fake CoinStats item
|
||||
coinstats_item = family.coinstats_items.create!(
|
||||
name: "Test CoinStats Connection",
|
||||
@@ -288,6 +310,7 @@ namespace :dev do
|
||||
DevSyncStatsHelpers.generate_fake_stats_for_items(LunchflowItem, "lunchflow", include_issues: false)
|
||||
DevSyncStatsHelpers.generate_fake_stats_for_items(CoinstatsItem, "coinstats", include_issues: true)
|
||||
DevSyncStatsHelpers.generate_fake_stats_for_items(EnableBankingItem, "enable_banking", include_issues: false)
|
||||
DevSyncStatsHelpers.generate_fake_stats_for_items(SophtronItem, "sophtron", include_issues: false)
|
||||
|
||||
puts "\nDone! Visit /accounts to see the sync summaries."
|
||||
end
|
||||
@@ -308,6 +331,7 @@ namespace :dev do
|
||||
count += LunchflowItem.where("name LIKE ?", "Test %").destroy_all.count
|
||||
count += CoinstatsItem.where("name LIKE ?", "Test %").destroy_all.count
|
||||
count += EnableBankingItem.where("name LIKE ? OR institution_name LIKE ?", "Test %", "Test %").destroy_all.count
|
||||
count += SophtronItem.where("name LIKE ?", "Test %").destroy_all.count
|
||||
|
||||
puts "Removed #{count} test provider items. Done!"
|
||||
end
|
||||
|
||||
@@ -60,6 +60,7 @@ class Family::SyncerTest < ActiveSupport::TestCase
|
||||
SimplefinItem.any_instance.stubs(:sync_later)
|
||||
LunchflowItem.any_instance.stubs(:sync_later)
|
||||
EnableBankingItem.any_instance.stubs(:sync_later)
|
||||
SophtronItem.any_instance.stubs(:sync_later)
|
||||
|
||||
syncer.perform_sync(family_sync)
|
||||
syncer.perform_post_sync
|
||||
|
||||
Reference in New Issue
Block a user