mirror of
https://github.com/we-promise/sure.git
synced 2026-04-22 21:44:11 +00:00
* 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>
758 lines
27 KiB
Ruby
758 lines
27 KiB
Ruby
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
|