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:
Sophtron Rocky
2026-04-19 17:16:04 +08:00
committed by GitHub
parent 5965604359
commit b32e9dbc45
40 changed files with 3625 additions and 8 deletions

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View 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

View File

@@ -18,6 +18,7 @@ class Family::Syncer
coinstats_items
mercury_items
snaptrade_items
sophtron_items
].freeze
def initialize(family)

View 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

View 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

View File

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

View 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

View 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

View 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

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

View 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

View 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

View 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

View 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

View 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

View File

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

View File

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

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

View File

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

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

View File

@@ -0,0 +1,16 @@
<%= turbo_frame_tag "modal" do %>
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".loading_title")) %>
<% dialog.with_body do %>
<div class="flex items-center justify-center py-8">
<div class="flex flex-col items-center gap-4">
<%= icon("loader-circle", class: "h-8 w-8 animate-spin text-primary") %>
<p class="text-sm text-secondary">
<%= t(".loading_message") %>
</p>
</div>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,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 %>

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

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

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

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

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

View 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"

View File

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

View File

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

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

View File

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

View File

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