[codex] Complete Sophtron account mapping (#1698)

* Complete Sophtron account mapping

* Clarify Sophtron login challenge flow

* Add Sophtron connection UI timeout

* Treat Sophtron timeout jobs as failed

* Reset failed Sophtron connection state

* Handle stale Sophtron connection jobs

* Advance Sophtron polling timeout

* Shorten Sophtron connection timeout

* Fix Sophtron modal polling updates

* Stabilize Sophtron MFA polling

* Give Sophtron OTP challenges more time

* Clarify Sophtron institution login failures

* Extend Sophtron polling during login progress

* Probe Sophtron accounts after completed MFA step

* Align Sophtron dialogs with design system

* Start Sophtron initial load after linking accounts

* Fix Sophtron initial transaction load

* Fail Sophtron sync without institution connection

* Fix tests

* Wrap Sophtron account linking in transaction

* Wrap Sophtron provider responses

* Fix Sophtron MFA security tests

* Guard Sophtron MFA challenge arrays

* Respect Sophtron initial load window

* Use unique Sophtron MFA answer field ids

* Address Sophtron review follow-ups

* Fix Sophtron transaction sync refresh

* Avoid blocking Sophtron refresh polling

* Move Sophtron account helpers to model

* Keep Sophtron grouping provider-level

* Start new Sophtron institution links

* Isolate Sophtron institution connections

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
Juan José Mata
2026-05-08 15:15:23 +02:00
committed by GitHub
parent 5fa1c034b4
commit 81cdccb768
31 changed files with 3327 additions and 934 deletions

View File

@@ -18,12 +18,14 @@ module Family::SophtronConnectable
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
def configured_sophtron_item
sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.first
end
end

View File

@@ -1,373 +1,473 @@
# 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
# Sophtron uses two API shapes:
# - V2 REST endpoints for customer provisioning.
# - V1 RPC-style endpoints for institution connection, jobs, MFA, accounts, and transactions.
class Provider::Sophtron < Provider
include HTTParty
headers "User-Agent" => "Sure Finance So Client"
DEFAULT_BASE_URL = "https://api.sophtron.com/api"
USER_AGENT = "Sure Finance Sophtron Client"
FAILURE_JOB_STATUSES = %w[Completed Timeout Failed Failure Error].freeze
headers "User-Agent" => USER_AGENT
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
Error = Class.new(Provider::Error) do
attr_reader :error_type
def initialize(message, error_type = :unknown, details: nil)
@error_type = error_type
super(message, details: details)
end
end
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")
def initialize(user_id, access_key, base_url: DEFAULT_BASE_URL)
@user_id = user_id
@access_key = access_key
@base_url = base_url
@base_url = normalize_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
def auth_header_for(method, api_path)
auth_path = self.class.auth_path(api_path)
plain_key = "#{method.to_s.upcase}\n#{auth_path}"
key_bytes = Base64.decode64(access_key.to_s)
raise ArgumentError, "decoded key is empty" if key_bytes.blank?
signature = OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), key_bytes, plain_key)
"FIApiAUTH:#{user_id}:#{Base64.strict_encode64(signature)}:#{auth_path}"
rescue ArgumentError => e
raise Error.new("Invalid Sophtron Access Key: #{e.message}", :invalid_access_key)
end
def self.auth_path(api_path)
path = URI.parse(api_path.to_s).path
last_segment = path.to_s.split("/").last.to_s
"/#{last_segment}".downcase
rescue URI::InvalidURIError
last_segment = api_path.to_s.split("?").first.to_s.split("/").last.to_s
"/#{last_segment}".downcase
end
def self.job_success?(job)
job = job.with_indifferent_access
job[:SuccessFlag] == true || job[:success_flag] == true || job[:LastStatus].to_s == "AccountsReady" || job[:last_status].to_s == "AccountsReady"
end
def self.job_failed?(job)
job = job.with_indifferent_access
success_flag = job.key?(:SuccessFlag) ? job[:SuccessFlag] : job[:success_flag]
last_status = job[:LastStatus] || job[:last_status]
success_flag == false && failure_job_status?(last_status)
end
def self.job_completed?(job)
job = job.with_indifferent_access
(job[:LastStatus] || job[:last_status]).to_s == "Completed" && !job_failed?(job)
end
def self.failure_job_status?(last_status)
status = last_status.to_s
FAILURE_JOB_STATUSES.include?(status) || status.match?(/timeout|fail|error/i)
end
def self.job_requires_input?(job)
job = job.with_indifferent_access
job[:SecurityQuestion].present? ||
job[:security_question].present? ||
job[:TokenMethod].present? ||
job[:token_method].present? ||
job_token_input_required?(job) ||
job[:TokenRead].present? ||
job[:token_read].present? ||
job[:CaptchaImage].present? ||
job[:captcha_image].present?
end
def self.job_token_input_required?(job)
job = job.with_indifferent_access
token_input = job[:TokenInput] || job[:token_input]
token_input.blank? && (
job[:TokenSentFlag] == true ||
job[:token_sent_flag] == true ||
job[:TokenInputName].present? ||
job[:token_input_name].present? ||
job[:LastStep].to_s == "TokenInput" ||
job[:last_step].to_s == "TokenInput"
)
end
def self.parse_json_array(value)
return [] if value.blank?
return value if value.is_a?(Array)
parsed = JSON.parse(value.to_s)
parsed.is_a?(Array) ? parsed : Array(parsed)
rescue JSON::ParserError
Array(value)
end
def self.response_data!(response)
return response unless response.respond_to?(:success?) && response.respond_to?(:data)
return response.data if response.success?
raise response.error || Error.new("Sophtron provider response did not include data", :invalid_response)
end
# GET /api/Institution/HealthCheckAuth
def health_check_auth
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 }
request(:get, "/Institution/HealthCheckAuth", parse_json: false)
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)
# GET /api/v2/customers
def list_customers
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
parsed = request(:get, "/v2/customers")
extract_array_response(parsed, :customers, :Customers)
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)
# POST /api/v2/customers
def create_customer(unique_id:, name:, source: "Sure")
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"
}
request(
:post,
"/v2/customers",
body: {
UniqueID: unique_id,
Name: name,
Source: source
}
else
result = { balance: { amount: 0, currency: "USD" } }
end
result
)
end
end
# POST /api/Institution/GetInstitutionByName
def search_institutions(institution_name)
with_provider_response do
parsed = request(
:post,
"/Institution/GetInstitutionByName",
body: { InstitutionName: institution_name.to_s }
)
extract_array_response(parsed, :institutions, :Institutions)
end
end
# POST /api/UserInstitution/GetUserInstitutionsByUser
def list_user_institutions
with_provider_response do
parsed = request(
:post,
"/UserInstitution/GetUserInstitutionsByUser",
body: { UserID: user_id }
)
extract_array_response(parsed, :user_institutions, :UserInstitutions)
end
end
# POST /api/UserInstitution/CreateUserInstitution
def create_user_institution(institution_id:, username:, password:, pin: "")
with_provider_response do
request(
:post,
"/UserInstitution/CreateUserInstitution",
body: {
UserID: user_id,
InstitutionID: institution_id,
UserName: username,
Password: password,
PIN: pin.to_s
}
)
end
end
# POST /api/Job/GetJobInformationByID
def get_job_information(job_id)
with_provider_response do
fetch_job_information(job_id)
end
end
# POST /api/Job/UpdateJobSecurityAnswer
def update_job_security_answer(job_id, answers)
security_answer = answers.is_a?(String) ? answers : Array(answers).to_json
with_provider_response do
request(
:post,
"/Job/UpdateJobSecurityAnswer",
body: { JobID: job_id, SecurityAnswer: security_answer }
)
end
end
# POST /api/Job/UpdateJobTokenInput
def update_job_token_input(job_id, token_choice: nil, token_input: nil, verify_phone_flag: nil)
with_provider_response do
request(
:post,
"/Job/UpdateJobTokenInput",
body: {
JobID: job_id,
TokenChoice: token_choice,
TokenInput: token_input,
VerifyPhoneFlag: verify_phone_flag
}
)
end
end
# POST /api/Job/UpdateJobCaptcha
def update_job_captcha(job_id, captcha_input)
with_provider_response do
request(
:post,
"/Job/UpdateJobCaptcha",
body: { JobID: job_id, CaptchaInput: captcha_input }
)
end
end
# POST /api/UserInstitution/GetUserInstitutionAccounts
def get_user_institution_accounts(user_institution_id)
with_provider_response do
fetch_user_institution_accounts(user_institution_id)
end
end
def get_accounts(user_institution_id)
with_provider_response do
accounts = fetch_user_institution_accounts(user_institution_id)
normalized = accounts.map { |account| normalize_account(account, user_institution_id: user_institution_id) }
{ accounts: normalized, total: normalized.size }
end
end
# POST /api/UserInstitutionAccount/RefreshUserInstitutionAccount
def refresh_account(account_id)
with_provider_response do
request(
:post,
"/UserInstitutionAccount/RefreshUserInstitutionAccount",
body: { AccountID: account_id }
)
end
end
# POST /api/Transaction/GetTransactionsByTransactionDate
def get_account_transactions(account_id, start_date: nil, end_date: nil)
with_provider_response do
parsed = request(
:post,
"/Transaction/GetTransactionsByTransactionDate",
body: {
AccountID: account_id,
StartDate: (start_date || 120.days.ago).to_date.to_s,
EndDate: (end_date || Date.tomorrow).to_date.to_s
}
)
raw_transactions = extract_array_response(parsed, :transactions, :Transactions)
transactions = raw_transactions.map { |transaction| normalize_transaction(transaction, account_id) }
{ transactions: transactions, total: transactions.size }
end
end
def poll_job(job_id, **)
get_job_information(job_id)
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
def default_error_transformer(error)
return error if error.is_a?(Error)
super
end
def auth_headers(url:, http_method:)
def fetch_job_information(job_id)
request(
:post,
"/Job/GetJobInformationByID",
body: { JobID: job_id }
)
end
def fetch_user_institution_accounts(user_institution_id)
parsed = request(
:post,
"/UserInstitution/GetUserInstitutionAccounts",
body: { UserInstitutionID: user_institution_id }
)
extract_array_response(parsed, :accounts, :Accounts)
end
def extract_array_response(parsed, *keys)
return parsed if parsed.is_a?(Array)
return [] if parsed.respond_to?(:empty?) && parsed.empty?
if parsed.respond_to?(:with_indifferent_access)
parsed = parsed.with_indifferent_access
keys.each do |key|
return Array(parsed[key]) if parsed.key?(key)
end
end
raise Error.new("Invalid Sophtron response format", :invalid_response, details: parsed)
end
def request(method, api_path, body: nil, parse_json: true)
options = { headers: auth_headers(method: method, api_path: api_path) }
options[:body] = body.to_json if body
response = self.class.public_send(method, "#{base_url}#{api_path}", options)
handle_response(response, parse_json: parse_json)
rescue Error
raise
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
raise Error.new("Sophtron request failed: #{e.message}", :request_failed)
rescue StandardError => e
raise Error.new("Sophtron request failed: #{e.message}", :request_failed)
end
def auth_headers(method:, api_path:)
{
"Authorization" => sophtron_auth_code(url: url, http_method: http_method),
"Authorization" => auth_header_for(method, api_path),
"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
def handle_response(response, parse_json: true)
body = response.body.to_s
case response.code.to_i
when 200, 201, 204
return {} if body.strip.blank?
parse_json ? JSON.parse(body, symbolize_names: true) : parse_optional_json(body)
when 400
raise Error.new("Bad request to Sophtron API: #{body}", :bad_request, details: body)
when 401
raise Error.new("Invalid Sophtron User ID or Access Key", :unauthorized, details: body)
when 403
raise Error.new("Access forbidden by Sophtron", :access_forbidden, details: body)
when 404
raise Error.new("Sophtron resource not found", :not_found, details: body)
when 429
raise Error.new("Sophtron rate limit exceeded. Please try again later.", :rate_limited, details: body)
else
raise Error.new(
"Sophtron API request failed: #{response.code} #{response.message} - #{body}",
:fetch_failed,
details: body
)
end
rescue JSON::ParserError => e
raise Error.new("Invalid JSON response from Sophtron API: #{e.message}", :invalid_response, details: body)
end
def parse_optional_json(body)
JSON.parse(body, symbolize_names: true)
rescue JSON::ParserError
body
end
def normalize_base_url(value)
url = value.presence || DEFAULT_BASE_URL
url = url.to_s.chomp("/")
url = url.delete_suffix("/v2") if url.end_with?("/v2")
parsed = URI.parse(url)
parsed.path.to_s.end_with?("/api") ? url : "#{url}/api"
rescue URI::InvalidURIError
DEFAULT_BASE_URL
end
def normalize_account(account, user_institution_id:)
account = account.with_indifferent_access
account_id = first_present(account, :AccountID, :account_id, :id)
account_name = first_present(account, :AccountName, :account_name, :name)
account_number = first_present(account, :AccountNumber, :account_number)
currency = first_present(account, :BalanceCurrency, :balance_currency, :Currency, :currency).presence || "USD"
{
id: account_id,
account_id: account_id,
account_name: account_name,
name: account_name,
account_type: first_present(account, :AccountType, :account_type, :type).presence || "unknown",
sub_type: first_present(account, :AccountSubType, :account_sub_type, :SubType, :sub_type).presence || "unknown",
balance: first_present(account, :AccountBalance, :account_balance, :Balance, :balance),
balance_currency: currency,
currency: currency,
account_number_mask: mask_account_number(account_number),
status: first_present(account, :AccountStatus, :account_status, :Status, :status).presence || "active",
user_institution_id: user_institution_id,
institution_name: first_present(account, :InstitutionName, :institution_name),
raw_payload: account.to_h
}.with_indifferent_access
end
def normalize_transaction(transaction, account_id)
transaction = transaction.with_indifferent_access
{
id: first_present(transaction, :TransactionID, :TransactionId, :transaction_id, :transactionId, :ID, :id),
accountId: account_id,
type: first_present(transaction, :Type, :type).presence || "unknown",
status: first_present(transaction, :Status, :status).presence || "completed",
amount: first_present(transaction, :Amount, :amount).presence || 0,
currency: first_present(transaction, :Currency, :currency).presence || "USD",
date: first_present(transaction, :TransactionDate, :transaction_date, :Date, :date),
merchant: first_present(transaction, :Merchant, :merchant).presence || extract_merchant(first_present(transaction, :Description, :description)).presence || "",
description: first_present(transaction, :Description, :description).presence || ""
}.with_indifferent_access
end
def first_present(hash, *keys)
keys.each do |key|
value = hash[key]
return value if value.present?
end
# Normalize to strings and unique (avoid destructive methods that may return nil)
ids = ids.map(&:to_s).compact.uniq
ids
nil
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
def mask_account_number(account_number)
return nil if account_number.blank?
# 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
last_four = account_number.to_s.gsub(/\s+/, "").last(4)
last_four.present? ? "****#{last_four}" : nil
end
def extract_merchant(line)
return nil if line.nil?
line = line.strip
line = line.to_s.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"
"Bank Fee: Insufficient Funds"
elsif line =~ /OVERDRAFT PROTECTION/i
return "Bank Transfer: Overdraft Protection"
"Bank Transfer: Overdraft Protection"
elsif line =~ /AUTO PAY WF HOME MTG/i
return "Wells Fargo Home Mortgage"
"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)
"Payday Loan"
elsif line =~ /CHECKCARD \d{4}\s+(.+?)(?=\s{2,}|x{3,}|\s+\S+\s+[A-Z]{2}\b)/i
Regexp.last_match(1).strip
elsif line =~ /^(.+?)(?=\s+\d{2}\/\d{2}|\s+#)/
Regexp.last_match(1).strip.gsub(/\s+POS$/i, "").strip
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)
line[0..25].strip
end
end
end

View File

@@ -22,7 +22,8 @@ class Provider::SophtronAdapter < Provider::Base
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
return_to: return_to,
connect_new_institution: true
)
},
existing_account_path: ->(account_id) {
@@ -45,7 +46,7 @@ class Provider::SophtronAdapter < Provider::Base
return nil unless family.present?
# Get family-specific credentials
sophtron_item = family.sophtron_items.where.not(user_id: nil, access_key: nil).first
sophtron_item = family.configured_sophtron_item
return nil unless sophtron_item&.credentials_configured?
Provider::Sophtron.new(
@@ -88,10 +89,16 @@ class Provider::SophtronAdapter < Provider::Base
end
def institution_name
metadata = provider_account.institution_metadata
metadata = provider_account.institution_metadata || {}
return nil unless metadata.present?
metadata["name"] || item&.institution_name
metadata_name = metadata["name"].presence || metadata["institution_name"].presence
return metadata_name if metadata_name.present?
metadata_user_institution_id = metadata["user_institution_id"].presence || metadata["UserInstitutionID"].presence
return item&.institution_name if metadata_user_institution_id.present? && metadata_user_institution_id == item&.user_institution_id
nil
end
def institution_url

View File

@@ -46,20 +46,37 @@ class SophtronAccount < ApplicationRecord
def upsert_sophtron_snapshot!(account_snapshot)
# Convert to symbol keys or handle both string and symbol keys
snapshot = account_snapshot.with_indifferent_access
account_id = first_present(snapshot, :account_id, :id, :AccountID)
account_name = first_present(snapshot, :account_name, :name, :AccountName)
account_number = first_present(snapshot, :account_number, :AccountNumber)
currency = first_present(snapshot, :balance_currency, :currency, :BalanceCurrency, :Currency)
balance = first_present(snapshot, :balance, :account_balance, :AccountBalance, :Balance)
available_balance = first_present(snapshot, :"available-balance", :available_balance, :AvailableBalance)
account_type = first_present(snapshot, :account_type, :type, :AccountType)
account_sub_type = first_present(snapshot, :sub_type, :account_sub_type, :AccountSubType, :SubType)
last_updated = first_present(snapshot, :last_updated, :LastUpdated)
institution_name = first_present(snapshot, :institution_name, :InstitutionName).presence || sophtron_item&.institution_name
user_institution_id = first_present(snapshot, :user_institution_id, :UserInstitutionID).presence || sophtron_item&.user_institution_id
# 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"]),
name: account_name,
account_id: account_id,
currency: parse_currency(currency) || "USD",
balance: parse_balance(balance),
available_balance: parse_balance(available_balance),
account_type: account_type.presence || "unknown",
account_sub_type: account_sub_type.presence || "unknown",
last_updated: parse_balance_date(last_updated),
account_status: first_present(snapshot, :account_status, :status, :AccountStatus, :Status),
account_number_mask: snapshot[:account_number_mask].presence || mask_account_number(account_number),
institution_metadata: {
name: institution_name,
user_institution_id: user_institution_id
}.compact,
raw_payload: account_snapshot,
customer_id: snapshot["customer_id"],
member_id: snapshot["member_id"]
customer_id: first_present(snapshot, :customer_id, :CustomerID) || customer_id,
member_id: first_present(snapshot, :member_id, :MemberID) || member_id
)
save!
@@ -127,4 +144,20 @@ class SophtronAccount < ApplicationRecord
return if balance.present? || available_balance.present?
errors.add(:base, "Sophtron account must have either current or available balance")
end
def first_present(hash, *keys)
keys.each do |key|
value = hash[key]
return value if value.present?
end
nil
end
def mask_account_number(account_number)
return nil if account_number.blank?
last_four = account_number.to_s.gsub(/\s+/, "").last(4)
last_four.present? ? "****#{last_four}" : nil
end
end

View File

@@ -14,6 +14,9 @@
class SophtronItem < ApplicationRecord
include Syncable, Provided, Unlinking
INITIAL_LOAD_LOOKBACK_DAYS = 120
MAX_TRANSACTION_HISTORY_YEARS = 3
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
# Helper to detect if ActiveRecord Encryption is configured for this app.
@@ -66,15 +69,15 @@ class SophtronItem < ApplicationRecord
#
# @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
# @raise [Provider::Sophtron::Error] if the Sophtron API returns an error
def import_latest_sophtron_data(sync: nil)
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
SophtronItem::Importer.new(self, sophtron_provider: provider, sync: sync).import
rescue => e
Rails.logger.error "SophtronItem #{id} - Failed to import data: #{e.message}"
raise
@@ -122,6 +125,24 @@ class SophtronItem < ApplicationRecord
results
end
def start_initial_load_later
active_sync = syncs.visible.ordered.first
sync_later(window_start_date: initial_load_window_start_date)
return unless active_sync&.reload&.syncing?
SophtronInitialLoadJob.set(wait: SophtronInitialLoadJob::RETRY_DELAY).perform_later(self)
end
def initial_load_window_start_date
configured_start = sync_start_date&.to_date
default_start = INITIAL_LOAD_LOOKBACK_DAYS.days.ago.to_date
max_history_start = MAX_TRANSACTION_HISTORY_YEARS.years.ago.to_date
[ configured_start || default_start, max_history_start ].max
end
def upsert_sophtron_snapshot!(accounts_snapshot)
assign_attributes(
raw_payload: accounts_snapshot
@@ -130,6 +151,118 @@ class SophtronItem < ApplicationRecord
save!
end
def ensure_customer!(provider: sophtron_provider)
return customer_id if customer_id.present?
raise Provider::Sophtron::Error.new("Sophtron provider is not configured", :configuration_error) unless provider
matching_customer = find_matching_customer(Provider::Sophtron.response_data!(provider.list_customers))
customer_payload = matching_customer || Provider::Sophtron.response_data!(
provider.create_customer(
unique_id: generated_customer_unique_id,
name: generated_customer_name,
source: "Sure"
)
)
# Some Sophtron endpoints may return an empty body on success; re-list to find
# the customer we just created if the create response does not include an id.
if extract_customer_id(customer_payload).blank?
customer_payload = find_matching_customer(Provider::Sophtron.response_data!(provider.list_customers))
end
extracted_customer_id = extract_customer_id(customer_payload)
raise Provider::Sophtron::Error.new("Sophtron customer response did not include CustomerID", :invalid_response) if extracted_customer_id.blank?
update!(
customer_id: extracted_customer_id,
customer_name: extract_customer_name(customer_payload).presence || generated_customer_name,
raw_customer_payload: customer_payload
)
customer_id
end
def connected_to_institution?
user_institution_id.present? && current_job_id.blank? && good? && !failed_connection_job?
end
def failed_connection_job?
payload = raw_job_payload || {}
payload = payload.with_indifferent_access if payload.respond_to?(:with_indifferent_access)
success_flag = if payload.respond_to?(:key?) && payload.key?(:SuccessFlag)
payload[:SuccessFlag]
elsif payload.respond_to?(:key?)
payload[:success_flag]
end
last_status = job_status.presence ||
(payload[:LastStatus] if payload.respond_to?(:[])) ||
(payload[:last_status] if payload.respond_to?(:[]))
success_flag == false && Provider::Sophtron.failure_job_status?(last_status)
end
def upsert_job_snapshot!(job_payload)
job_payload = job_payload.with_indifferent_access
update!(
job_status: job_payload[:LastStatus] || job_payload[:last_status],
raw_job_payload: job_payload
)
end
def fetch_remote_accounts(force: false)
cache_key = "sophtron_accounts_#{family.id}_#{id}_#{user_institution_id}"
cached = Rails.cache.read(cache_key)
return cached if cached.present? && !force
accounts_data = Provider::Sophtron.response_data!(sophtron_provider.get_accounts(user_institution_id))
accounts = accounts_data[:accounts] || []
Rails.cache.write(cache_key, accounts, expires_in: 5.minutes)
persist_remote_sophtron_accounts(accounts)
accounts
end
def persist_remote_sophtron_accounts(accounts)
Array(accounts).each do |account_data|
account_data = account_data.with_indifferent_access
next if account_data[:account_name].blank?
upsert_sophtron_account(account_data)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.warn("Skipping Sophtron account #{self.class.external_account_id(account_data)}: #{e.message}")
end
end
def reject_already_linked(accounts)
linked_account_ids = sophtron_accounts.joins(:account_provider).pluck(:account_id).map(&:to_s)
Array(accounts).reject { |account| linked_account_ids.include?(self.class.external_account_id(account).to_s) }
end
def upsert_sophtron_account(account_data)
sophtron_accounts.find_or_initialize_by(
account_id: self.class.external_account_id(account_data).to_s
).tap do |sophtron_account|
sophtron_account.upsert_sophtron_snapshot!(account_data)
end
end
def build_mfa_challenge(job)
job = job.with_indifferent_access
{
security_questions: Provider::Sophtron.parse_json_array(job[:SecurityQuestion] || job[:security_question]),
token_methods: Provider::Sophtron.parse_json_array(job[:TokenMethod] || job[:token_method]),
token_sent: Provider::Sophtron.job_token_input_required?(job),
token_read: job[:TokenRead] || job[:token_read],
captcha_image: job[:CaptchaImage] || job[:captcha_image]
}
end
def self.external_account_id(account_data)
account_data.with_indifferent_access[:account_id] || account_data.with_indifferent_access[:id]
end
def has_completed_initial_setup?
# Setup is complete if we have any linked accounts
accounts.any?
@@ -167,6 +300,10 @@ class SophtronItem < ApplicationRecord
institution_name.presence || institution_domain.presence || name
end
def provider_display_name
I18n.t("sophtron_items.defaults.name", default: "Sophtron Connection")
end
def connected_institutions
# Get unique institutions from all accounts
sophtron_accounts.includes(:account)
@@ -193,6 +330,40 @@ class SophtronItem < ApplicationRecord
end
def effective_base_url
base_url.presence || "https://api.sophtron.com/api/v2"
base_url.presence || Provider::Sophtron::DEFAULT_BASE_URL
end
def generated_customer_unique_id
"sure-family-#{family.id}"
end
def generated_customer_name
"Sure family #{family.id}"
end
private
def find_matching_customer(customers)
customers = Array(customers)
customers.find do |customer|
extract_customer_id(customer).to_s == generated_customer_unique_id
end || customers.find do |customer|
extract_customer_name(customer).to_s == generated_customer_name
end
end
def extract_customer_id(customer_payload)
return nil unless customer_payload.respond_to?(:with_indifferent_access)
customer_payload = customer_payload.with_indifferent_access
customer_payload[:CustomerID] || customer_payload[:customer_id] || customer_payload[:id]
end
def extract_customer_name(customer_payload)
return nil unless customer_payload.respond_to?(:with_indifferent_access)
customer_payload = customer_payload.with_indifferent_access
customer_payload[:CustomerName] || customer_payload[:customer_name] || customer_payload[:name]
end
end

View File

@@ -14,15 +14,19 @@ require "set"
# explicitly connected to Maybe Accounts). This allows users to selectively
# import accounts of their choosing.
class SophtronItem::Importer
attr_reader :sophtron_item, :sophtron_provider
INCREMENTAL_SYNC_BUFFER_DAYS = 60
attr_reader :sophtron_item, :sophtron_provider, :sync
# 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:)
# @param sync [Sync, nil] Optional sync record whose window should guide import scope
def initialize(sophtron_item, sophtron_provider:, sync: nil)
@sophtron_item = sophtron_item
@sophtron_provider = sophtron_provider
@sync = sync
end
# Performs the complete import process for this Sophtron item.
@@ -48,6 +52,25 @@ class SophtronItem::Importer
# # accounts_failed: 0, transactions_imported: 150, transactions_failed: 0 }
def import
Rails.logger.info "SophtronItem::Importer - Starting import for item #{sophtron_item.id}"
unless sophtron_item.user_institution_id.present?
error_message = "Sophtron institution connection is incomplete"
Rails.logger.warn "SophtronItem::Importer - Item #{sophtron_item.id} has no Sophtron UserInstitutionID"
sophtron_item.update!(
status: :requires_update,
last_connection_error: error_message
)
return {
success: false,
error: error_message,
accounts_updated: 0,
accounts_created: 0,
accounts_failed: 0,
transactions_imported: 0,
transactions_failed: 0
}
end
# Step 1: Fetch all accounts from Sophtron
accounts_data = fetch_accounts_data
unless accounts_data
@@ -124,6 +147,7 @@ class SophtronItem::Importer
transactions_imported += result[:transactions_count]
else
transactions_failed += 1
break if result[:requires_update]
end
rescue => e
transactions_failed += 1
@@ -144,16 +168,16 @@ class SophtronItem::Importer
}
end
def import_transactions_after_refresh(sophtron_account)
fetch_and_store_transactions(sophtron_account, refresh: false)
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
accounts_data = Provider::Sophtron.response_data!(sophtron_provider.get_accounts(sophtron_item.user_institution_id))
rescue Provider::Sophtron::Error => e
# Handle authentication errors by marking item as requiring update
if e.error_type == :unauthorized || e.error_type == :access_forbidden
begin
@@ -245,34 +269,41 @@ class SophtronItem::Importer
# - :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)
def fetch_and_store_transactions(sophtron_account, refresh: true)
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
if refresh && !initial_transaction_fetch?(sophtron_account)
refresh_result = refresh_account_before_transaction_fetch(sophtron_account)
return refresh_result if refresh_result.present?
end
# Fetch transactions
transactions_data = Provider::Sophtron.response_data!(
sophtron_provider.get_account_transactions(
sophtron_account.account_id,
start_date: start_date
)
)
# 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
transactions = transactions_data[:transactions]
unless transactions.is_a?(Array)
Rails.logger.error "SophtronItem::Importer - Missing transactions array for account #{sophtron_account.account_id}"
return { success: false, transactions_count: 0, error: "Missing transactions array" }
end
transactions_count = transactions.count
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?
if transactions.any?
begin
existing_transactions = sophtron_account.raw_transactions_payload.to_a
@@ -283,7 +314,7 @@ class SophtronItem::Importer
# 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|
new_transactions = transactions.select do |tx|
next false unless tx.is_a?(Hash)
tx_id = tx.with_indifferent_access[:id]
@@ -291,10 +322,11 @@ class SophtronItem::Importer
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}"
Rails.logger.info "SophtronItem::Importer - Storing #{new_transactions.count} new transactions (#{existing_transactions.count} existing, #{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}"
Rails.logger.info "SophtronItem::Importer - No new transactions to store (all #{transactions.count} were duplicates) for account #{sophtron_account.account_id}"
sophtron_account.upsert_sophtron_transactions_snapshot!(existing_transactions) if sophtron_account.raw_transactions_payload.nil?
end
rescue => e
Rails.logger.error "SophtronItem::Importer - Failed to store transactions for account #{sophtron_account.account_id}: #{e.message}"
@@ -302,20 +334,15 @@ class SophtronItem::Importer
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}"
sophtron_account.upsert_sophtron_transactions_snapshot!([]) if sophtron_account.raw_transactions_payload.nil?
end
{ success: true, transactions_count: transactions_count }
rescue Provider::Error => e
rescue Provider::Sophtron::Error => e
requires_update = e.error_type.in?([ :unauthorized, :access_forbidden ])
sophtron_item.update!(status: :requires_update) if requires_update
Rails.logger.error "SophtronItem::Importer - Sophtron API error for account #{sophtron_account.id}: #{e.message}"
{ success: false, transactions_count: 0, error: e.message }
{ success: false, transactions_count: 0, error: e.message, requires_update: requires_update }
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" }
@@ -326,93 +353,80 @@ class SophtronItem::Importer
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
def refresh_account_before_transaction_fetch(sophtron_account)
refresh_response = Provider::Sophtron.response_data!(sophtron_provider.refresh_account(sophtron_account.account_id))
job_id = refresh_response.with_indifferent_access[:JobID] || refresh_response.with_indifferent_access[:job_id]
return nil if job_id.blank?
# 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
job = Provider::Sophtron.response_data!(sophtron_provider.get_job_information(job_id))
sophtron_item.upsert_job_snapshot!(job)
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
if Provider::Sophtron.job_requires_input?(job)
sophtron_item.update!(
status: :requires_update,
current_job_id: job_id,
last_connection_error: "Sophtron refresh requires MFA"
)
return { success: false, transactions_count: 0, error: "Sophtron refresh requires MFA", requires_update: true }
end
if Provider::Sophtron.job_failed?(job)
return { success: false, transactions_count: 0, error: "Sophtron refresh failed" }
end
unless Provider::Sophtron.job_success?(job) || Provider::Sophtron.job_completed?(job)
SophtronRefreshPollJob.set(wait: SophtronRefreshPollJob::POLL_INTERVAL).perform_later(
sophtron_account,
job_id: job_id,
sync: sync
)
return { success: true, transactions_count: 0, refresh_pending: true }
end
nil
rescue Provider::Sophtron::Error => e
requires_update = e.error_type.in?([ :unauthorized, :access_forbidden ])
sophtron_item.update!(status: :requires_update) if requires_update
Rails.logger.error "SophtronItem::Importer - Sophtron API error refreshing account #{sophtron_account.id}: #{e.message}"
{ success: false, transactions_count: 0, error: e.message, requires_update: requires_update }
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
# - For accounts with stored transactions: uses last sync date minus a buffer
# - For new accounts: uses the sync window or provider default initial lookback
#
# This ensures we capture any late-arriving transactions while limiting
# the historical window for new accounts.
# This captures late-arriving transactions while keeping history bounded.
#
# @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?
configured_start = sync&.window_start_date || sophtron_item.sync_start_date&.to_date
max_history_start = SophtronItem::MAX_TRANSACTION_HISTORY_YEARS.years.ago.to_date
floor_start = configured_start ? [ configured_start, max_history_start ].max : nil
if has_stored_transactions
if !initial_transaction_fetch?(sophtron_account)
# 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
[ sophtron_item.last_synced_at.to_date - INCREMENTAL_SYNC_BUFFER_DAYS, floor_start ].compact.max
else
# Fallback if item hasn't been synced but account has transactions
floor_start || 120.days.ago
floor_start || sophtron_item.initial_load_window_start_date
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
# Use the configured sync window if present, otherwise the provider's default initial lookback.
floor_start || sophtron_item.initial_load_window_start_date
end
end
def initial_transaction_fetch?(sophtron_account)
sophtron_account.raw_transactions_payload.nil? && sophtron_item.last_synced_at.blank?
end
# Handles API errors and marks the item for re-authentication if needed.
#
# Authentication-related errors cause the item status to be set to
@@ -420,7 +434,7 @@ class SophtronItem::Importer
#
# @param error_message [String] The error message from the API
# @return [void]
# @raise [Provider::Error] Always raises an error with the message
# @raise [Provider::Sophtron::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
@@ -438,7 +452,7 @@ class SophtronItem::Importer
end
Rails.logger.error "SophtronItem::Importer - API error: #{error_message}"
raise Provider::Error.new(
raise Provider::Sophtron::Error.new(
"Sophtron API error: #{error_message}",
:api_error
)

View File

@@ -36,7 +36,8 @@ class SophtronItem::Syncer
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
import_result = sophtron_item.import_latest_sophtron_data(sync: sync)
import_errors = import_errors_for(import_result)
# 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)
@@ -79,9 +80,14 @@ class SophtronItem::Syncer
end
# Mark sync health
collect_health_stats(sync, errors: nil)
if import_errors.present?
collect_health_stats(sync, errors: import_errors)
raise StandardError.new(import_errors.map { |error| error[:message] }.join(", "))
else
collect_health_stats(sync, errors: nil)
end
rescue => e
collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ])
collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ]) unless sync_errors_recorded?(sync)
raise
end
@@ -93,4 +99,37 @@ class SophtronItem::Syncer
def perform_post_sync
# no-op
end
private
def import_errors_for(import_result)
return [] if import_result.blank? || import_result[:success]
if import_result[:error].present?
return [ { message: import_result[:error], category: "sync_error" } ]
end
errors = []
if import_result[:accounts_failed].to_i.positive?
errors << {
message: "#{import_result[:accounts_failed]} #{'account'.pluralize(import_result[:accounts_failed])} failed to import",
category: "account_import"
}
end
if import_result[:transactions_failed].to_i.positive?
errors << {
message: "#{import_result[:transactions_failed]} #{'account'.pluralize(import_result[:transactions_failed])} failed to import transactions",
category: "transaction_import"
}
end
errors.presence || [ { message: "Sophtron import failed", category: "sync_error" } ]
end
def sync_errors_recorded?(sync)
return false unless sync.respond_to?(:sync_stats)
sync.sync_stats.to_h["total_errors"].to_i.positive?
end
end