mirror of
https://github.com/we-promise/sure.git
synced 2026-05-12 23:25:00 +00:00
[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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user