mirror of
https://github.com/we-promise/sure.git
synced 2026-05-07 12:54:04 +00:00
* Add Sophtron Provider * fix syncer test issue * fix schema wrong merge * sync #588 * sync code for #588 * fixed a view issue * modified by comment * modified * modifed * modified * modified * fixed a schema issue * use global subtypes * add some locales * fix a safe_return_to_path * fix exposing raw exception messages issue * fix a merged issue * update schema.rb * fix a schema issue * fix some issue * Update bank sync controller to reflect beta status Signed-off-by: Juan José Mata <jjmata@jjmata.com> * Rename settings section title to 'Sophtron (alpha)' Signed-off-by: Juan José Mata <jjmata@jjmata.com> * Consistency in alpha/beta for Sophtron * Good PR suggestions from CodeRabbit --------- Signed-off-by: soky srm <sokysrm@gmail.com> Signed-off-by: Sophtron Rocky <rocky@sophtron.com> Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Signed-off-by: Juan José Mata <jjmata@jjmata.com> Co-authored-by: soky srm <sokysrm@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <jjmata@jjmata.com>
374 lines
14 KiB
Ruby
374 lines
14 KiB
Ruby
# Sophtron API client for account aggregation.
|
|
#
|
|
# This provider implements the Sophtron API v2 for fetching bank account data,
|
|
# transactions, and balances. It uses HMAC-SHA256 authentication for secure
|
|
# API requests.
|
|
#
|
|
# The Sophtron API organizes data hierarchically:
|
|
# - Customers (identified by customer_id)
|
|
# - Accounts (identified by account_id within a customer)
|
|
# - Transactions (identified by transaction_id within an account)
|
|
#
|
|
# @example Initialize a Sophtron provider
|
|
# provider = Provider::Sophtron.new(
|
|
# "user123",
|
|
# "base64_encoded_access_key",
|
|
# base_url: "https://api.sophtron.com/api/v2"
|
|
# )
|
|
#
|
|
# @see https://www.sophtron.com Documentation for Sophtron API
|
|
class Provider::Sophtron < Provider
|
|
include HTTParty
|
|
|
|
headers "User-Agent" => "Sure Finance So Client"
|
|
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
|
|
|
|
attr_reader :user_id, :access_key, :base_url
|
|
|
|
# Initializes a new Sophtron API client.
|
|
#
|
|
# @param user_id [String] Sophtron User ID for authentication
|
|
# @param access_key [String] Base64-encoded Sophtron Access Key
|
|
# @param base_url [String] Base URL for the Sophtron API (defaults to production)
|
|
def initialize(user_id, access_key, base_url: "https://api.sophtron.com/api/v2")
|
|
@user_id = user_id
|
|
@access_key = access_key
|
|
@base_url = base_url
|
|
super()
|
|
end
|
|
|
|
# Fetches all accounts across all customers for this Sophtron user.
|
|
#
|
|
# This method:
|
|
# 1. Fetches the list of customer IDs
|
|
# 2. For each customer, fetches their accounts
|
|
# 3. Normalizes and deduplicates the account data
|
|
# 4. Returns a combined list of all accounts
|
|
#
|
|
# @return [Hash] Account data with keys:
|
|
# - :accounts [Array<Hash>] Array of account objects
|
|
# - :total [Integer] Total number of accounts
|
|
# @raise [Provider::Error] if the API request fails
|
|
# @example
|
|
# result = provider.get_accounts
|
|
# # => { accounts: [{id: "123", account_name: "Checking", ...}], total: 1 }
|
|
def get_accounts
|
|
with_provider_response do
|
|
# fetching accounts for sophtron
|
|
# Obtain customer IDs using a dedicated helper
|
|
customer_ids = get_customer_ids
|
|
|
|
all_accounts = []
|
|
customer_ids.each do |cust_id|
|
|
begin
|
|
accounts_resp = get_customer_accounts(cust_id)
|
|
|
|
# `handle_response` returns parsed JSON (hash/array) so normalize
|
|
raw_accounts = if accounts_resp.is_a?(Hash) && accounts_resp[:accounts].is_a?(Array)
|
|
accounts_resp[:accounts]
|
|
elsif accounts_resp.is_a?(Array)
|
|
accounts_resp
|
|
else
|
|
[]
|
|
end
|
|
|
|
normalized = raw_accounts.map { |a| a.transform_keys { |k| k.to_s.underscore }.with_indifferent_access }
|
|
|
|
# Ensure each account has a customer_id set
|
|
normalized.each do |acc|
|
|
# check common variants that may already exist
|
|
existing = acc[:customer_id]
|
|
acc[:customer_id] = cust_id.to_s if existing.blank?
|
|
end
|
|
|
|
all_accounts.concat(normalized)
|
|
rescue Provider::Error => e
|
|
Rails.logger.warn("Failed to fetch accounts for customer #{cust_id}: #{e.message}")
|
|
rescue => e
|
|
Rails.logger.warn("Unexpected error fetching accounts for customer #{cust_id}: #{e.class} #{e.message}")
|
|
end
|
|
end
|
|
|
|
# Deduplicate by id where present
|
|
unique_accounts = all_accounts.uniq { |a| a[:id].to_s }
|
|
|
|
{ accounts: unique_accounts, total: unique_accounts.length }
|
|
end
|
|
end
|
|
|
|
# Fetches transactions for a specific account.
|
|
#
|
|
# Retrieves transaction history for a given account within a date range.
|
|
# If no end date is provided, defaults to tomorrow to include today's transactions.
|
|
#
|
|
# @param customer_id [String] Sophtron customer ID
|
|
# @param account_id [String] Sophtron account ID
|
|
# @param start_date [Date, nil] Start date for transaction history (optional)
|
|
# @param end_date [Date, nil] End date for transaction history (defaults to tomorrow)
|
|
# @return [Hash] Transaction data with keys:
|
|
# - :transactions [Array<Hash>] Array of transaction objects
|
|
# - :total [Integer] Total number of transactions
|
|
# @raise [Provider::Error] if the API request fails
|
|
# @example
|
|
# result = provider.get_account_transactions("cust123", "acct456", start_date: 30.days.ago)
|
|
# # => { transactions: [{id: "tx1", amount: -50.00, ...}], total: 25 }
|
|
def get_account_transactions(customer_id, account_id, start_date: nil, end_date: nil)
|
|
with_provider_response do
|
|
query_params = {}
|
|
|
|
if start_date
|
|
query_params[:startDate] = start_date.to_date
|
|
end
|
|
if end_date
|
|
query_params[:endDate] = end_date.to_date
|
|
else
|
|
query_params[:endDate] = Date.tomorrow
|
|
end
|
|
|
|
path = "/customers/#{ERB::Util.url_encode(customer_id.to_s)}/accounts/#{ERB::Util.url_encode(account_id.to_s)}/transactions"
|
|
path += "?#{URI.encode_www_form(query_params)}" unless query_params.empty?
|
|
url = "#{@base_url}#{path}"
|
|
|
|
response = self.class.get(
|
|
url,
|
|
headers: auth_headers(url: url, http_method: "GET")
|
|
)
|
|
|
|
parsed = handle_response(response)
|
|
# Normalize transactions response into { transactions: [...], total: N }
|
|
if parsed.is_a?(Array)
|
|
txs = parsed.map { |tx| tx.transform_keys { |k| k.to_s.underscore }.with_indifferent_access }
|
|
mapped = txs.map { |tx| map_transaction(tx, account_id) }
|
|
{ transactions: mapped, total: mapped.length }
|
|
elsif parsed.is_a?(Hash)
|
|
if parsed[:transactions].is_a?(Array)
|
|
txs = parsed[:transactions].map { |tx| tx.transform_keys { |k| k.to_s.underscore }.with_indifferent_access }
|
|
mapped = txs.map { |tx| map_transaction(tx, account_id) }
|
|
parsed[:transactions] = mapped
|
|
parsed[:total] = parsed[:total] || mapped.length
|
|
parsed
|
|
else
|
|
# Single transaction object -> wrap and map
|
|
single = parsed.transform_keys { |k| k.to_s.underscore }.with_indifferent_access
|
|
mapped = map_transaction(single, account_id)
|
|
{ transactions: [ mapped ], total: 1 }
|
|
end
|
|
else
|
|
{ transactions: [], total: 0 }
|
|
end
|
|
end
|
|
end
|
|
|
|
# Fetches the current balance for a specific account.
|
|
#
|
|
# @param customer_id [String] Sophtron customer ID
|
|
# @param account_id [String] Sophtron account ID
|
|
# @return [Hash] Balance data with keys:
|
|
# - :balance [Hash] Balance information
|
|
# - :amount [Numeric] Current balance amount
|
|
# - :currency [String] Currency code (defaults to "USD")
|
|
# @raise [Provider::Error] if the API request fails
|
|
# @example
|
|
# result = provider.get_account_balance("cust123", "acct456")
|
|
# # => { balance: { amount: 1000.00, currency: "USD" } }
|
|
def get_account_balance(customer_id, account_id)
|
|
with_provider_response do
|
|
path = "/customers/#{ERB::Util.url_encode(customer_id.to_s)}/accounts/#{ERB::Util.url_encode(account_id.to_s)}"
|
|
url = "#{@base_url}#{path}"
|
|
|
|
response = self.class.get(
|
|
url,
|
|
headers: auth_headers(url: url, http_method: "GET")
|
|
)
|
|
|
|
parsed = handle_response(response)
|
|
|
|
# Normalize balance information into { balance: { amount: N, currency: "XXX" } }
|
|
# Sophtron returns balance as flat fields: Balance and BalanceCurrency (capitalized)
|
|
# After JSON symbolization these become: :Balance and :BalanceCurrency
|
|
balance_amount = parsed[:Balance] || parsed[:balance]
|
|
balance_currency = parsed[:BalanceCurrency] || parsed[:balance_currency]
|
|
|
|
if parsed.is_a?(Hash) && balance_amount.present?
|
|
result = {
|
|
balance: {
|
|
amount: balance_amount,
|
|
currency: balance_currency.presence || "USD"
|
|
}
|
|
}
|
|
else
|
|
result = { balance: { amount: 0, currency: "USD" } }
|
|
end
|
|
result
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def sophtron_auth_code(url:, http_method:)
|
|
require "base64"
|
|
require "openssl"
|
|
# sophtron auth code generation
|
|
# Parse path portion of the URL and use the last "/..." segment (matching upstream examples)
|
|
uri = URI.parse(url)
|
|
# Sign the last path segment (lowercased) and include the query string if present
|
|
path = (uri.path || "").downcase
|
|
idx = path.rindex("/")
|
|
last_seg = idx ? path[idx..-1] : path
|
|
query_str = uri.query ? "?#{uri.query.to_s.downcase}" : ""
|
|
auth_path = "#{last_seg}#{query_str}"
|
|
# Build the plain text to sign: "METHOD\n/auth_path"
|
|
plain_key = "#{http_method.to_s.upcase}\n#{auth_path}"
|
|
# Decode the base64 access key and compute HMAC-SHA256
|
|
begin
|
|
key_bytes = Base64.decode64(@access_key.to_s)
|
|
rescue => decode_err
|
|
Rails.logger.error("[sophtron_auth_code] Failed to decode access_key: #{decode_err.class}: #{decode_err.message}")
|
|
raise
|
|
end
|
|
signature = OpenSSL::HMAC.digest(OpenSSL::Digest.new("sha256"), key_bytes, plain_key)
|
|
sig_b64_str = Base64.strict_encode64(signature)
|
|
auth_code = "FIApiAUTH:#{@user_id}:#{sig_b64_str}:#{auth_path}"
|
|
auth_code
|
|
end
|
|
|
|
def auth_headers(url:, http_method:)
|
|
{
|
|
"Authorization" => sophtron_auth_code(url: url, http_method: http_method),
|
|
"Content-Type" => "application/json",
|
|
"Accept" => "application/json"
|
|
}
|
|
end
|
|
|
|
# Fetch list of customer IDs by calling GET /customers and extracting identifier fields
|
|
def get_customer_ids
|
|
url = "#{@base_url}/customers"
|
|
response = self.class.get(
|
|
url,
|
|
headers: auth_headers(url: url, http_method: "GET")
|
|
)
|
|
parsed = handle_response(response)
|
|
ids = []
|
|
if parsed.is_a?(Array)
|
|
ids = parsed.map do |r|
|
|
next unless r.is_a?(Hash)
|
|
# Find a key that likely contains the customer id (handles :CustomerID, :customerID, :customer_id, :ID, :id)
|
|
key = r.keys.find { |k| k.to_s.downcase.include?("customer") && k.to_s.downcase.include?("id") } ||
|
|
r.keys.find { |k| k.to_s.downcase == "id" }
|
|
r[key]
|
|
end.compact
|
|
elsif parsed.is_a?(Hash)
|
|
if parsed[:customers].is_a?(Array)
|
|
ids = parsed[:customers].map do |r|
|
|
next unless r.is_a?(Hash)
|
|
key = r.keys.find { |k| k.to_s.downcase.include?("customer") && k.to_s.downcase.include?("id") } ||
|
|
r.keys.find { |k| k.to_s.downcase == "id" }
|
|
r[key]
|
|
end.compact
|
|
else
|
|
key = parsed.keys.find { |k| k.to_s.downcase.include?("customer") && k.to_s.downcase.include?("id") } ||
|
|
parsed.keys.find { |k| k.to_s.downcase == "id" }
|
|
ids = [ parsed[key] ].compact
|
|
end
|
|
end
|
|
|
|
# Normalize to strings and unique (avoid destructive methods that may return nil)
|
|
ids = ids.map(&:to_s).compact.uniq
|
|
ids
|
|
end
|
|
|
|
# Fetch accounts for a specific customer via GET /customers/:customer_id/accounts
|
|
def get_customer_accounts(customer_id)
|
|
path = "/customers/#{ERB::Util.url_encode(customer_id.to_s)}/accounts"
|
|
url = "#{@base_url}#{path}"
|
|
response = self.class.get(
|
|
url,
|
|
headers: auth_headers(url: url, http_method: "GET")
|
|
)
|
|
handle_response(response)
|
|
end
|
|
|
|
# Map a normalized Sophtron transaction hash into our standard transaction shape
|
|
# Returns: { id, accountId, type, status, amount, currency, date, merchant, description }
|
|
def map_transaction(tx, account_id)
|
|
tx = tx.with_indifferent_access
|
|
{
|
|
id: tx[:transaction_id],
|
|
accountId: account_id,
|
|
type: tx[:type] || "unknown",
|
|
status: tx[:status] || "completed",
|
|
amount: tx[:amount] || 0.0,
|
|
currency: tx[:currency] || "USD",
|
|
date: tx[:transaction_date] || nil,
|
|
merchant: tx[:merchant] || extract_merchant(tx[:description]) ||"",
|
|
description: tx[:description] || ""
|
|
}.with_indifferent_access
|
|
end
|
|
|
|
def extract_merchant(line)
|
|
return nil if line.nil?
|
|
line = line.strip
|
|
return nil if line.empty?
|
|
|
|
# 1. Handle special bank fees and automated transactions
|
|
if line =~ /INSUFFICIENT FUNDS FEE/i
|
|
return "Bank Fee: Insufficient Funds"
|
|
elsif line =~ /OVERDRAFT PROTECTION/i
|
|
return "Bank Transfer: Overdraft Protection"
|
|
elsif line =~ /AUTO PAY WF HOME MTG/i
|
|
return "Wells Fargo Home Mortgage"
|
|
elsif line =~ /PAYDAY LOAN/i
|
|
return "Payday Loan"
|
|
end
|
|
|
|
# 2. Refined CHECKCARD Pattern
|
|
# Logic:
|
|
# - Start after 'CHECKCARD XXXX '
|
|
# - Capture everything (.+?)
|
|
# - STOP when we see:
|
|
# a) Two or more spaces (\s{2,})
|
|
# b) A masked number (x{3,})
|
|
# c) A pattern of [One Word] + [Space] + [State Code] (\s+\S+\s+[A-Z]{2}\b)
|
|
# The (\s+\S+) part matches the city, so we stop BEFORE it.
|
|
if line =~ /CHECKCARD \d{4}\s+(.+?)(?=\s{2,}|x{3,}|\s+\S+\s+[A-Z]{2}\b)/i
|
|
return $1.strip
|
|
end
|
|
|
|
# 3. Handle standard purchase rows (e.g., EXXONMOBIL POS 12/08)
|
|
# Stops before date (MM/DD) or hash (#)
|
|
if line =~ /^(.+?)(?=\s+\d{2}\/\d{2}|\s+#)/
|
|
name = $1.strip
|
|
return name.gsub(/\s+POS$/i, "").strip
|
|
end
|
|
|
|
# 4. Fallback for other formats
|
|
line[0..25].strip
|
|
end
|
|
|
|
def handle_response(response)
|
|
case response.code
|
|
when 200
|
|
begin
|
|
JSON.parse(response.body, symbolize_names: true)
|
|
rescue JSON::ParserError => e
|
|
Rails.logger.error "Sophtron API: Invalid JSON response - #{e.message}"
|
|
raise Provider::Error.new("Invalid JSON response from Sophtron API", :invalid_response)
|
|
end
|
|
when 400
|
|
Rails.logger.error "Sophtron API: Bad request - #{response.body}"
|
|
raise Provider::Error.new("Bad request to Sophtron API: #{response.body}", :bad_request)
|
|
when 401
|
|
raise Provider::Error.new("Invalid User ID or Access key", :unauthorized)
|
|
when 403
|
|
raise Provider::Error.new("Access forbidden - check your User ID and Access key permissions", :access_forbidden)
|
|
when 404
|
|
raise Provider::Error.new("Resource not found", :not_found)
|
|
when 429
|
|
raise Provider::Error.new("Rate limit exceeded. Please try again later.", :rate_limited)
|
|
else
|
|
Rails.logger.error "Sophtron API: Unexpected response - Code: #{response.code}, Body: #{response.body}"
|
|
raise Provider::Error.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed)
|
|
end
|
|
end
|
|
end
|