mirror of
https://github.com/we-promise/sure.git
synced 2026-04-18 03:24:09 +00:00
Harden SimpleFin sync: retries, safer imports, manual relinking, and data-quality reconciliation (#544)
* Add tests and enhance logic for SimpleFin account synchronization and reconciliation - Added retry logic with exponential backoff for network errors in `Provider::Simplefin`. - Introduced tests to verify retry functionality and error handling for rate-limit, server errors, and stale data. - Updated `SimplefinItem` to detect stale sync status and reconciliation issues. - Enhanced UI to display stale sync warnings and data integrity notices. - Improved SimpleFin account matching during updates with multi-tier strategy (ID, fingerprint, fuzzy match). - Added transaction reconciliation logic to detect data gaps, transaction count drops, and duplicate transaction IDs. * Introduce `SimplefinConnectionUpdateJob` for asynchronous SimpleFin connection updates - Moved SimpleFin connection update logic to `SimplefinConnectionUpdateJob` to improve response times by offloading network retries, data fetching, and reconciliation tasks. - Enhanced SimpleFin account matching with a multi-tier strategy (ID, fingerprint, fuzzy name match). - Added retry logic and bounded latency for token claim requests in `Provider::Simplefin`. - Updated tests to cover the new job flow and ensure correct account reconciliation during updates. * Remove unused SimpleFin account matching logic and improve error handling in `SimplefinConnectionUpdateJob` - Deleted the multi-tier account matching logic from `SimplefinItemsController` as it is no longer used. - Enhanced error handling in `SimplefinConnectionUpdateJob` to gracefully handle import failures, ensuring orphaned items can be manually resolved. - Updated job flow to conditionally set item status based on the success of import operations. * Fix SimpleFin sync: check both legacy FK and AccountProvider for linked accounts * Add crypto, checking, savings, and cash account detection; refine subtype selection and linking - Enhanced `Simplefin::AccountTypeMapper` to include detection for crypto, checking, savings, and standalone cash accounts. - Improved subtype selection UI with validation and warning indicators for missing selections. - Updated SimpleFin account linking to handle both legacy FK and `AccountProvider` associations consistently. - Refined job flow and importer logic for better handling of linked accounts and subtype inference. * Improve `SimplefinConnectionUpdateJob` and holdings processing logic - Fixed race condition in `SimplefinConnectionUpdateJob` by moving `destroy_later` calls outside of transactions. - Updated fuzzy name match logic to use Levenshtein distance for better accuracy. - Enhanced synthetic ticker generation in holdings processor with hash suffix for uniqueness. * Refine SimpleFin entry processing logic and ensure `extra` data persistence - Simplified pending flag determination to rely solely on provider-supplied values. - Fixed potential stale values in `extra` by ensuring deep merge overwrite with `entry.transaction.save!`. * Replace hardcoded fallback transaction description with localized string * Refine pending flag logic in SimpleFin processor tests - Adjust test to prevent falsely inferring pending status from missing posted dates. - Ensure provider explicitly sets pending flag for transactions. * Add `has_many :holdings` association to `AccountProvider` with `dependent: :nullify` --------- Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
This commit is contained in:
@@ -21,43 +21,21 @@ class SimplefinItemsController < ApplicationController
|
||||
return render_error(t(".errors.blank_token"), context: :edit) if setup_token.blank?
|
||||
|
||||
begin
|
||||
# Create new SimpleFin item data with updated token
|
||||
updated_item = Current.family.create_simplefin_item!(
|
||||
setup_token: setup_token,
|
||||
item_name: @simplefin_item.name
|
||||
# Validate token shape early so the user gets immediate feedback.
|
||||
claim_url = Base64.decode64(setup_token)
|
||||
URI.parse(claim_url)
|
||||
|
||||
# Updating a SimpleFin connection can involve network retries/backoff and account import.
|
||||
# Do it asynchronously so web requests aren't blocked by retry sleeps.
|
||||
SimplefinConnectionUpdateJob.perform_later(
|
||||
family_id: Current.family.id,
|
||||
old_simplefin_item_id: @simplefin_item.id,
|
||||
setup_token: setup_token
|
||||
)
|
||||
|
||||
# Ensure new simplefin_accounts are created & have account_id set
|
||||
updated_item.import_latest_simplefin_data
|
||||
|
||||
# Transfer accounts from old item to new item
|
||||
ActiveRecord::Base.transaction do
|
||||
@simplefin_item.simplefin_accounts.each do |old_account|
|
||||
if old_account.account.present?
|
||||
# Find matching account in new item by account_id
|
||||
new_account = updated_item.simplefin_accounts.find_by(account_id: old_account.account_id)
|
||||
if new_account
|
||||
# Transfer the account directly to the new SimpleFin account
|
||||
# This will automatically break the old association
|
||||
old_account.account.update!(simplefin_account_id: new_account.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Mark old item for deletion
|
||||
@simplefin_item.destroy_later
|
||||
end
|
||||
|
||||
# Clear any requires_update status on new item
|
||||
updated_item.update!(status: :good)
|
||||
|
||||
if turbo_frame_request?
|
||||
@simplefin_items = Current.family.simplefin_items.ordered
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"simplefin-providers-panel",
|
||||
partial: "settings/providers/simplefin_panel",
|
||||
locals: { simplefin_items: @simplefin_items }
|
||||
)
|
||||
flash.now[:notice] = t(".success")
|
||||
render turbo_stream: Array(flash_notification_stream_items)
|
||||
else
|
||||
redirect_to accounts_path, notice: t(".success"), status: :see_other
|
||||
end
|
||||
@@ -157,12 +135,16 @@ class SimplefinItemsController < ApplicationController
|
||||
end
|
||||
|
||||
def setup_accounts
|
||||
@simplefin_accounts = @simplefin_item.simplefin_accounts.includes(:account).where(accounts: { id: nil })
|
||||
# Only show unlinked accounts - check both legacy FK and AccountProvider
|
||||
@simplefin_accounts = @simplefin_item.simplefin_accounts
|
||||
.left_joins(:account, :account_provider)
|
||||
.where(accounts: { id: nil }, account_providers: { id: nil })
|
||||
@account_type_options = [
|
||||
[ "Skip this account", "skip" ],
|
||||
[ "Checking or Savings Account", "Depository" ],
|
||||
[ "Credit Card", "CreditCard" ],
|
||||
[ "Investment Account", "Investment" ],
|
||||
[ "Crypto Account", "Crypto" ],
|
||||
[ "Loan or Mortgage", "Loan" ],
|
||||
[ "Other Asset", "OtherAsset" ]
|
||||
]
|
||||
@@ -208,6 +190,11 @@ class SimplefinItemsController < ApplicationController
|
||||
label: "Loan Type:",
|
||||
options: Loan::SUBTYPES.map { |k, v| [ v[:long], k ] }
|
||||
},
|
||||
"Crypto" => {
|
||||
label: nil,
|
||||
options: [],
|
||||
message: "Crypto accounts track cryptocurrency holdings."
|
||||
},
|
||||
"OtherAsset" => {
|
||||
label: nil,
|
||||
options: [],
|
||||
@@ -225,8 +212,8 @@ class SimplefinItemsController < ApplicationController
|
||||
@simplefin_item.update!(sync_start_date: params[:sync_start_date])
|
||||
end
|
||||
|
||||
# Valid account types for this provider (plus OtherAsset which SimpleFIN UI allows)
|
||||
valid_types = Provider::SimplefinAdapter.supported_account_types + [ "OtherAsset" ]
|
||||
# Valid account types for this provider (plus Crypto and OtherAsset which SimpleFIN UI allows)
|
||||
valid_types = Provider::SimplefinAdapter.supported_account_types + [ "Crypto", "OtherAsset" ]
|
||||
|
||||
created_accounts = []
|
||||
skipped_count = 0
|
||||
@@ -269,6 +256,8 @@ class SimplefinItemsController < ApplicationController
|
||||
selected_subtype
|
||||
)
|
||||
simplefin_account.update!(account: account)
|
||||
# Also create AccountProvider for consistency with the new linking system
|
||||
simplefin_account.ensure_account_provider!
|
||||
created_accounts << account
|
||||
end
|
||||
|
||||
|
||||
@@ -42,4 +42,27 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearWarning(event) {
|
||||
// When user selects a subtype value, clear all warning styling
|
||||
const select = event.target
|
||||
if (select.value) {
|
||||
// Clear the subtype dropdown warning
|
||||
const warningContainer = select.closest('.ring-2')
|
||||
if (warningContainer) {
|
||||
warningContainer.classList.remove('ring-2', 'ring-warning/50', 'rounded-md', 'p-2', '-m-2')
|
||||
const warningText = warningContainer.querySelector('.text-warning')
|
||||
if (warningText) {
|
||||
warningText.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the parent card's warning border
|
||||
const card = this.element.closest('.border-2.border-warning')
|
||||
if (card) {
|
||||
card.classList.remove('border-2', 'border-warning', 'bg-warning/5')
|
||||
card.classList.add('border', 'border-primary')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
167
app/jobs/simplefin_connection_update_job.rb
Normal file
167
app/jobs/simplefin_connection_update_job.rb
Normal file
@@ -0,0 +1,167 @@
|
||||
class SimplefinConnectionUpdateJob < ApplicationJob
|
||||
queue_as :high_priority
|
||||
|
||||
# Disable automatic retries for this job since the setup token is single-use.
|
||||
# If the token claim succeeds but import fails, retrying would fail at claim.
|
||||
discard_on Provider::Simplefin::SimplefinError do |job, error|
|
||||
Rails.logger.error(
|
||||
"SimplefinConnectionUpdateJob discarded: #{error.class} - #{error.message} " \
|
||||
"(family_id=#{job.arguments.first[:family_id]}, old_item_id=#{job.arguments.first[:old_simplefin_item_id]})"
|
||||
)
|
||||
end
|
||||
|
||||
def perform(family_id:, old_simplefin_item_id:, setup_token:)
|
||||
family = Family.find(family_id)
|
||||
old_item = family.simplefin_items.find(old_simplefin_item_id)
|
||||
|
||||
# Step 1: Claim the token and create the new item.
|
||||
# This is the critical step - if it fails, we can safely retry.
|
||||
# If it succeeds, the token is consumed and we must not retry the claim.
|
||||
updated_item = family.create_simplefin_item!(
|
||||
setup_token: setup_token,
|
||||
item_name: old_item.name
|
||||
)
|
||||
|
||||
# Step 2: Import accounts from SimpleFin.
|
||||
# If this fails, we have an orphaned item but the token is already consumed.
|
||||
# We handle this gracefully by marking the item and continuing.
|
||||
begin
|
||||
updated_item.import_latest_simplefin_data
|
||||
rescue => e
|
||||
Rails.logger.error(
|
||||
"SimplefinConnectionUpdateJob: import failed for new item #{updated_item.id}: " \
|
||||
"#{e.class} - #{e.message}. Item created but may need manual sync."
|
||||
)
|
||||
# Mark the item as needing attention but don't fail the job entirely.
|
||||
# The item exists and can be synced manually later.
|
||||
updated_item.update!(status: :requires_update)
|
||||
# Still proceed to transfer accounts and schedule old item deletion
|
||||
end
|
||||
|
||||
# Step 3: Transfer account links from old to new item.
|
||||
# This is idempotent and safe to retry.
|
||||
# Check for linked accounts via BOTH legacy FK and AccountProvider.
|
||||
ActiveRecord::Base.transaction do
|
||||
old_item.simplefin_accounts.includes(:account, account_provider: :account).each do |old_account|
|
||||
# Get the linked account via either system
|
||||
linked_account = old_account.current_account
|
||||
next unless linked_account.present?
|
||||
|
||||
new_simplefin_account = find_matching_simplefin_account(old_account, updated_item.simplefin_accounts)
|
||||
next unless new_simplefin_account
|
||||
|
||||
# Update legacy FK
|
||||
linked_account.update!(simplefin_account_id: new_simplefin_account.id)
|
||||
|
||||
# Also migrate AccountProvider if it exists
|
||||
if old_account.account_provider.present?
|
||||
old_account.account_provider.update!(
|
||||
provider_type: "SimplefinAccount",
|
||||
provider_id: new_simplefin_account.id
|
||||
)
|
||||
else
|
||||
# Create AccountProvider for consistency
|
||||
new_simplefin_account.ensure_account_provider!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Schedule deletion outside transaction to avoid race condition where
|
||||
# the job is enqueued even if the transaction rolls back
|
||||
old_item.destroy_later
|
||||
|
||||
# Only mark as good if import succeeded (status wasn't set to requires_update above)
|
||||
updated_item.update!(status: :good) unless updated_item.requires_update?
|
||||
end
|
||||
|
||||
private
|
||||
# Find a matching SimpleFin account in the new item's accounts.
|
||||
# Uses a multi-tier matching strategy:
|
||||
# 1. Exact account_id match (preferred)
|
||||
# 2. Fingerprint match (name + institution + account_type)
|
||||
# 3. Fuzzy name match with same institution (fallback)
|
||||
def find_matching_simplefin_account(old_account, new_accounts)
|
||||
exact_match = new_accounts.find_by(account_id: old_account.account_id)
|
||||
return exact_match if exact_match
|
||||
|
||||
old_fingerprint = account_fingerprint(old_account)
|
||||
fingerprint_match = new_accounts.find { |new_account| account_fingerprint(new_account) == old_fingerprint }
|
||||
return fingerprint_match if fingerprint_match
|
||||
|
||||
old_institution = extract_institution_id(old_account)
|
||||
old_name_normalized = normalize_account_name(old_account.name)
|
||||
|
||||
new_accounts.find do |new_account|
|
||||
new_institution = extract_institution_id(new_account)
|
||||
new_name_normalized = normalize_account_name(new_account.name)
|
||||
|
||||
next false unless old_institution.present? && old_institution == new_institution
|
||||
|
||||
names_similar?(old_name_normalized, new_name_normalized)
|
||||
end
|
||||
end
|
||||
|
||||
def account_fingerprint(simplefin_account)
|
||||
institution_id = extract_institution_id(simplefin_account)
|
||||
name_normalized = normalize_account_name(simplefin_account.name)
|
||||
account_type = simplefin_account.account_type.to_s.downcase
|
||||
|
||||
"#{institution_id}:#{name_normalized}:#{account_type}"
|
||||
end
|
||||
|
||||
def extract_institution_id(simplefin_account)
|
||||
org_data = simplefin_account.org_data
|
||||
return nil unless org_data.is_a?(Hash)
|
||||
|
||||
org_data["id"] || org_data["domain"] || org_data["name"]&.downcase&.gsub(/\s+/, "_")
|
||||
end
|
||||
|
||||
def normalize_account_name(name)
|
||||
return "" if name.blank?
|
||||
|
||||
name.to_s
|
||||
.downcase
|
||||
.gsub(/[^a-z0-9]/, "")
|
||||
end
|
||||
|
||||
def names_similar?(name1, name2)
|
||||
return false if name1.blank? || name2.blank?
|
||||
|
||||
return true if name1 == name2
|
||||
return true if name1.include?(name2) || name2.include?(name1)
|
||||
|
||||
longer = [ name1.length, name2.length ].max
|
||||
return false if longer == 0
|
||||
|
||||
# Use Levenshtein distance for more accurate similarity
|
||||
distance = levenshtein_distance(name1, name2)
|
||||
similarity = 1.0 - (distance.to_f / longer)
|
||||
similarity >= 0.8
|
||||
end
|
||||
|
||||
# Compute Levenshtein edit distance between two strings
|
||||
def levenshtein_distance(s1, s2)
|
||||
m, n = s1.length, s2.length
|
||||
return n if m.zero?
|
||||
return m if n.zero?
|
||||
|
||||
# Use a single array and update in place for memory efficiency
|
||||
prev_row = (0..n).to_a
|
||||
curr_row = []
|
||||
|
||||
(1..m).each do |i|
|
||||
curr_row[0] = i
|
||||
(1..n).each do |j|
|
||||
cost = s1[i - 1] == s2[j - 1] ? 0 : 1
|
||||
curr_row[j] = [
|
||||
prev_row[j] + 1, # deletion
|
||||
curr_row[j - 1] + 1, # insertion
|
||||
prev_row[j - 1] + cost # substitution
|
||||
].min
|
||||
end
|
||||
prev_row, curr_row = curr_row, prev_row
|
||||
end
|
||||
|
||||
prev_row[n]
|
||||
end
|
||||
end
|
||||
@@ -75,6 +75,7 @@ class Account::ProviderImportAdapter
|
||||
existing = entry.transaction.extra || {}
|
||||
incoming = extra.is_a?(Hash) ? extra.deep_stringify_keys : {}
|
||||
entry.transaction.extra = existing.deep_merge(incoming)
|
||||
entry.transaction.save!
|
||||
end
|
||||
entry.save!
|
||||
entry
|
||||
|
||||
@@ -2,6 +2,8 @@ class AccountProvider < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :provider, polymorphic: true
|
||||
|
||||
has_many :holdings, dependent: :nullify
|
||||
|
||||
validates :account_id, uniqueness: { scope: :provider_type }
|
||||
validates :provider_id, uniqueness: { scope: :provider_type }
|
||||
|
||||
|
||||
@@ -9,6 +9,22 @@ class Provider::Simplefin
|
||||
headers "User-Agent" => "Sure Finance SimpleFin Client"
|
||||
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
|
||||
|
||||
# Retry configuration for transient network failures
|
||||
MAX_RETRIES = 3
|
||||
INITIAL_RETRY_DELAY = 2 # seconds
|
||||
MAX_RETRY_DELAY = 30 # seconds
|
||||
|
||||
# Errors that are safe to retry (transient network issues)
|
||||
RETRYABLE_ERRORS = [
|
||||
SocketError,
|
||||
Net::OpenTimeout,
|
||||
Net::ReadTimeout,
|
||||
Errno::ECONNRESET,
|
||||
Errno::ECONNREFUSED,
|
||||
Errno::ETIMEDOUT,
|
||||
EOFError
|
||||
].freeze
|
||||
|
||||
def initialize
|
||||
end
|
||||
|
||||
@@ -16,7 +32,11 @@ class Provider::Simplefin
|
||||
# Decode the base64 setup token to get the claim URL
|
||||
claim_url = Base64.decode64(setup_token)
|
||||
|
||||
response = HTTParty.post(claim_url)
|
||||
# Use retry logic for transient network failures during token claim
|
||||
# Claim should be fast; keep request-path latency bounded.
|
||||
response = with_retries("POST /claim", max_retries: 1, sleep: false) do
|
||||
HTTParty.post(claim_url, timeout: 15)
|
||||
end
|
||||
|
||||
case response.code
|
||||
when 200
|
||||
@@ -49,19 +69,12 @@ class Provider::Simplefin
|
||||
accounts_url = "#{access_url}/accounts"
|
||||
accounts_url += "?#{URI.encode_www_form(query_params)}" unless query_params.empty?
|
||||
|
||||
|
||||
# The access URL already contains HTTP Basic Auth credentials
|
||||
begin
|
||||
response = HTTParty.get(accounts_url)
|
||||
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
Rails.logger.error "SimpleFin API: GET /accounts failed: #{e.class}: #{e.message}"
|
||||
raise SimplefinError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||
rescue => e
|
||||
Rails.logger.error "SimpleFin API: Unexpected error during GET /accounts: #{e.class}: #{e.message}"
|
||||
raise SimplefinError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||
# Use retry logic with exponential backoff for transient network failures
|
||||
response = with_retries("GET /accounts") do
|
||||
HTTParty.get(accounts_url)
|
||||
end
|
||||
|
||||
|
||||
case response.code
|
||||
when 200
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
@@ -72,6 +85,12 @@ class Provider::Simplefin
|
||||
raise SimplefinError.new("Access URL is no longer valid", :access_forbidden)
|
||||
when 402
|
||||
raise SimplefinError.new("Payment required to access this account", :payment_required)
|
||||
when 429
|
||||
Rails.logger.warn "SimpleFin API: Rate limited - #{response.body}"
|
||||
raise SimplefinError.new("SimpleFin rate limit exceeded. Please try again later.", :rate_limited)
|
||||
when 500..599
|
||||
Rails.logger.error "SimpleFin API: Server error - Code: #{response.code}, Body: #{response.body}"
|
||||
raise SimplefinError.new("SimpleFin server error (#{response.code}). Please try again later.", :server_error)
|
||||
else
|
||||
Rails.logger.error "SimpleFin API: Unexpected response - Code: #{response.code}, Body: #{response.body}"
|
||||
raise SimplefinError.new("Failed to fetch accounts: #{response.code} #{response.message} - #{response.body}", :fetch_failed)
|
||||
@@ -97,4 +116,55 @@ class Provider::Simplefin
|
||||
@error_type = error_type
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Execute a block with retry logic and exponential backoff for transient network errors.
|
||||
# This helps handle temporary network issues that cause autosync failures while
|
||||
# manual sync (with user retry) succeeds.
|
||||
def with_retries(operation_name, max_retries: MAX_RETRIES, sleep: true)
|
||||
retries = 0
|
||||
|
||||
begin
|
||||
yield
|
||||
rescue *RETRYABLE_ERRORS => e
|
||||
retries += 1
|
||||
|
||||
if retries <= max_retries
|
||||
delay = calculate_retry_delay(retries)
|
||||
Rails.logger.warn(
|
||||
"SimpleFin API: #{operation_name} failed (attempt #{retries}/#{max_retries}): " \
|
||||
"#{e.class}: #{e.message}. Retrying in #{delay}s..."
|
||||
)
|
||||
Kernel.sleep(delay) if sleep && delay.to_f.positive?
|
||||
retry
|
||||
else
|
||||
Rails.logger.error(
|
||||
"SimpleFin API: #{operation_name} failed after #{max_retries} retries: " \
|
||||
"#{e.class}: #{e.message}"
|
||||
)
|
||||
raise SimplefinError.new(
|
||||
"Network error after #{max_retries} retries: #{e.message}",
|
||||
:network_error
|
||||
)
|
||||
end
|
||||
rescue SimplefinError => e
|
||||
# Preserve original error type and message.
|
||||
raise
|
||||
rescue => e
|
||||
# Non-retryable errors are logged and re-raised immediately
|
||||
Rails.logger.error "SimpleFin API: #{operation_name} failed with non-retryable error: #{e.class}: #{e.message}"
|
||||
raise SimplefinError.new("Exception during #{operation_name}: #{e.message}", :request_failed)
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate delay with exponential backoff and jitter
|
||||
def calculate_retry_delay(retry_count)
|
||||
# Exponential backoff: 2^retry * initial_delay
|
||||
base_delay = INITIAL_RETRY_DELAY * (2 ** (retry_count - 1))
|
||||
# Add jitter (0-25% of base delay) to prevent thundering herd
|
||||
jitter = base_delay * rand * 0.25
|
||||
# Cap at max delay
|
||||
[ base_delay + jitter, MAX_RETRY_DELAY ].min
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,6 +12,11 @@ module Simplefin
|
||||
CREDIT_NAME_KEYWORDS = /\b(credit|card)\b/i.freeze
|
||||
CREDIT_BRAND_KEYWORDS = /\b(visa|mastercard|amex|american express|discover|apple card|freedom unlimited|quicksilver)\b/i.freeze
|
||||
LOAN_KEYWORDS = /\b(loan|mortgage|heloc|line of credit|loc)\b/i.freeze
|
||||
CHECKING_KEYWORDS = /\b(checking|chequing|dda|demand deposit)\b/i.freeze
|
||||
SAVINGS_KEYWORDS = /\b(savings|sav|money market|mma|high.yield)\b/i.freeze
|
||||
CRYPTO_KEYWORDS = /\b(bitcoin|btc|ethereum|eth|crypto|cryptocurrency|litecoin|dogecoin|solana)\b/i.freeze
|
||||
# "Cash" as a standalone name (not "cash back", "cash rewards", etc.)
|
||||
CASH_ACCOUNT_PATTERN = /\A\s*cash\s*\z/i.freeze
|
||||
|
||||
# Explicit investment subtype tokens mapped to known SUBTYPES keys
|
||||
EXPLICIT_INVESTMENT_TOKENS = {
|
||||
@@ -53,18 +58,23 @@ module Simplefin
|
||||
end
|
||||
end
|
||||
|
||||
# 1) Holdings present => Investment (high confidence)
|
||||
# 1) Crypto keywords → Crypto account (check before holdings since crypto accounts may have holdings)
|
||||
if CRYPTO_KEYWORDS.match?(nm)
|
||||
return Inference.new(accountable_type: "Crypto", confidence: :high)
|
||||
end
|
||||
|
||||
# 2) Holdings present => Investment (high confidence)
|
||||
if holdings_present
|
||||
# Do not guess generic retirement; explicit tokens handled above
|
||||
return Inference.new(accountable_type: "Investment", subtype: nil, confidence: :high)
|
||||
end
|
||||
|
||||
# 2) Name suggests LOAN (high confidence)
|
||||
# 3) Name suggests LOAN (high confidence)
|
||||
if LOAN_KEYWORDS.match?(nm)
|
||||
return Inference.new(accountable_type: "Loan", confidence: :high)
|
||||
end
|
||||
|
||||
# 3) Credit card signals
|
||||
# 4) Credit card signals
|
||||
# - Name contains credit/card (medium to high)
|
||||
# - Card brands (Visa/Mastercard/Amex/Discover/Apple Card) → high
|
||||
# - Or negative balance with available-balance present (medium)
|
||||
@@ -76,14 +86,26 @@ module Simplefin
|
||||
return Inference.new(accountable_type: "CreditCard", confidence: :high)
|
||||
end
|
||||
|
||||
# 4) Retirement keywords without holdings still point to Investment (retirement)
|
||||
# 5) Retirement keywords without holdings still point to Investment (retirement)
|
||||
if RETIREMENT_KEYWORDS.match?(nm)
|
||||
# If the name contains 'brokerage', avoid forcing retirement subtype
|
||||
subtype = BROKERAGE_KEYWORD.match?(nm) ? nil : "retirement"
|
||||
return Inference.new(accountable_type: "Investment", subtype: subtype, confidence: :high)
|
||||
end
|
||||
|
||||
# 5) Default
|
||||
# 6) Checking/Savings/Cash accounts (high confidence when name explicitly says so)
|
||||
if CHECKING_KEYWORDS.match?(nm)
|
||||
return Inference.new(accountable_type: "Depository", subtype: "checking", confidence: :high)
|
||||
end
|
||||
if SAVINGS_KEYWORDS.match?(nm)
|
||||
return Inference.new(accountable_type: "Depository", subtype: "savings", confidence: :high)
|
||||
end
|
||||
# "Cash" as a standalone account name (like Cash App's "Cash" account) → checking
|
||||
if CASH_ACCOUNT_PATTERN.match?(nm)
|
||||
return Inference.new(accountable_type: "Depository", subtype: "checking", confidence: :high)
|
||||
end
|
||||
|
||||
# 7) Default - unknown account type, let user decide
|
||||
Inference.new(accountable_type: "Depository", confidence: :low)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -80,7 +80,7 @@ class SimplefinAccount < ApplicationRecord
|
||||
end
|
||||
|
||||
def parse_currency(currency_value)
|
||||
return "USD" if currency_value.nil?
|
||||
return "USD" if currency_value.blank?
|
||||
|
||||
# SimpleFin currency can be a 3-letter code or a URL for custom currencies
|
||||
if currency_value.start_with?("http")
|
||||
|
||||
@@ -9,13 +9,29 @@ class SimplefinAccount::Investments::HoldingsProcessor
|
||||
|
||||
holdings_data.each do |simplefin_holding|
|
||||
begin
|
||||
symbol = simplefin_holding["symbol"]
|
||||
symbol = simplefin_holding["symbol"].presence
|
||||
holding_id = simplefin_holding["id"]
|
||||
description = simplefin_holding["description"].to_s.strip
|
||||
|
||||
Rails.logger.debug({ event: "simplefin.holding.start", sfa_id: simplefin_account.id, account_id: account&.id, id: holding_id, symbol: symbol, raw: simplefin_holding }.to_json)
|
||||
|
||||
unless symbol.present? && holding_id.present?
|
||||
Rails.logger.debug({ event: "simplefin.holding.skip", reason: "missing_symbol_or_id", id: holding_id, symbol: symbol }.to_json)
|
||||
unless holding_id.present?
|
||||
Rails.logger.debug({ event: "simplefin.holding.skip", reason: "missing_id", id: holding_id, symbol: symbol }.to_json)
|
||||
next
|
||||
end
|
||||
|
||||
# If symbol is missing but we have a description, create a synthetic ticker
|
||||
# This allows tracking holdings like 401k funds that don't have standard symbols
|
||||
# Append a hash suffix to ensure uniqueness for similar descriptions
|
||||
if symbol.blank? && description.present?
|
||||
normalized = description.gsub(/[^a-zA-Z0-9]/, "_").upcase.truncate(24, omission: "")
|
||||
hash_suffix = Digest::MD5.hexdigest(description)[0..4].upcase
|
||||
symbol = "CUSTOM:#{normalized}_#{hash_suffix}"
|
||||
Rails.logger.info("SimpleFin: using synthetic ticker #{symbol} for holding #{holding_id} (#{description})")
|
||||
end
|
||||
|
||||
unless symbol.present?
|
||||
Rails.logger.debug({ event: "simplefin.holding.skip", reason: "no_symbol_or_description", id: holding_id }.to_json)
|
||||
next
|
||||
end
|
||||
|
||||
@@ -57,7 +73,7 @@ class SimplefinAccount::Investments::HoldingsProcessor
|
||||
security: security,
|
||||
quantity: qty,
|
||||
amount: computed_amount,
|
||||
currency: simplefin_holding["currency"] || "USD",
|
||||
currency: simplefin_holding["currency"].presence || "USD",
|
||||
date: holding_date,
|
||||
price: price,
|
||||
cost_basis: cost_basis,
|
||||
@@ -101,14 +117,23 @@ class SimplefinAccount::Investments::HoldingsProcessor
|
||||
if !sym.include?(":") && (is_crypto_account || is_crypto_symbol || mentions_crypto)
|
||||
sym = "CRYPTO:#{sym}"
|
||||
end
|
||||
|
||||
# Custom tickers (from holdings without symbols) should always be offline
|
||||
is_custom = sym.start_with?("CUSTOM:")
|
||||
|
||||
# Use Security::Resolver to find or create the security, but be resilient
|
||||
begin
|
||||
if is_custom
|
||||
# Skip resolver for custom tickers - create offline security directly
|
||||
raise "Custom ticker - skipping resolver"
|
||||
end
|
||||
Security::Resolver.new(sym).resolve
|
||||
rescue => e
|
||||
# If provider search fails or any unexpected error occurs, fall back to an offline security
|
||||
Rails.logger.warn "SimpleFin: resolver failed for symbol=#{sym}: #{e.class} - #{e.message}; falling back to offline security"
|
||||
Rails.logger.warn "SimpleFin: resolver failed for symbol=#{sym}: #{e.class} - #{e.message}; falling back to offline security" unless is_custom
|
||||
Security.find_or_initialize_by(ticker: sym).tap do |sec|
|
||||
sec.offline = true if sec.respond_to?(:offline) && sec.offline != true
|
||||
sec.name = description.presence if sec.name.blank? && description.present?
|
||||
sec.save! if sec.changed?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,12 +34,13 @@ class SimplefinEntry::Processor
|
||||
# Include provider-supplied extra hash if present
|
||||
sf["extra"] = data[:extra] if data[:extra].is_a?(Hash)
|
||||
|
||||
# Pending detection: honor provider flag or infer from missing/zero posted with present transacted_at
|
||||
posted_val = data[:posted]
|
||||
posted_missing = posted_val.blank? || posted_val == 0 || posted_val == "0"
|
||||
if ActiveModel::Type::Boolean.new.cast(data[:pending]) || (posted_missing && data[:transacted_at].present?)
|
||||
# Pending detection: only use explicit provider flag
|
||||
# We always set the key (true or false) to ensure deep_merge overwrites any stale value
|
||||
if ActiveModel::Type::Boolean.new.cast(data[:pending])
|
||||
sf["pending"] = true
|
||||
Rails.logger.debug("SimpleFIN: flagged pending transaction #{external_id}")
|
||||
else
|
||||
sf["pending"] = false
|
||||
end
|
||||
|
||||
# FX metadata: when tx currency differs from account currency
|
||||
@@ -87,7 +88,7 @@ class SimplefinEntry::Processor
|
||||
elsif description.present?
|
||||
description
|
||||
else
|
||||
data[:memo] || "Unknown transaction"
|
||||
data[:memo] || I18n.t("transactions.unknown_name")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -142,7 +143,7 @@ class SimplefinEntry::Processor
|
||||
|
||||
def posted_date
|
||||
val = data[:posted]
|
||||
# Treat 0 / "0" as missing to avoid Unix epoch 1970-01-01 for pendings
|
||||
# Treat 0 / "0" as missing to avoid Unix epoch 1970-01-01
|
||||
return nil if val == 0 || val == "0"
|
||||
Simplefin::DateUtils.parse_provider_date(val)
|
||||
end
|
||||
|
||||
@@ -56,7 +56,10 @@ class SimplefinItem < ApplicationRecord
|
||||
end
|
||||
|
||||
def process_accounts
|
||||
simplefin_accounts.joins(:account).each do |simplefin_account|
|
||||
# Process accounts linked via BOTH legacy FK and AccountProvider
|
||||
simplefin_accounts.includes(:account, account_provider: :account).each do |simplefin_account|
|
||||
# Only process if there's a linked account (via either system)
|
||||
next unless simplefin_account.current_account.present?
|
||||
SimplefinAccount::Processor.new(simplefin_account).process
|
||||
end
|
||||
end
|
||||
@@ -192,6 +195,58 @@ class SimplefinItem < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# Detect if sync data appears stale (no new transactions for extended period)
|
||||
# Returns a hash with :stale (boolean) and :message (string) if stale
|
||||
def stale_sync_status
|
||||
return { stale: false } unless last_synced_at.present?
|
||||
|
||||
# Check if last sync was more than 3 days ago
|
||||
days_since_sync = (Date.current - last_synced_at.to_date).to_i
|
||||
if days_since_sync > 3
|
||||
return {
|
||||
stale: true,
|
||||
days_since_sync: days_since_sync,
|
||||
message: "Last successful sync was #{days_since_sync} days ago. Your SimpleFin connection may need attention."
|
||||
}
|
||||
end
|
||||
|
||||
# Check if linked accounts have recent transactions
|
||||
linked_accounts = accounts
|
||||
return { stale: false } if linked_accounts.empty?
|
||||
|
||||
# Find the most recent transaction date across all linked accounts
|
||||
latest_transaction_date = Entry.where(account_id: linked_accounts.map(&:id))
|
||||
.where(entryable_type: "Transaction")
|
||||
.maximum(:date)
|
||||
|
||||
if latest_transaction_date.present?
|
||||
days_since_transaction = (Date.current - latest_transaction_date).to_i
|
||||
if days_since_transaction > 14
|
||||
return {
|
||||
stale: true,
|
||||
days_since_transaction: days_since_transaction,
|
||||
message: "No new transactions in #{days_since_transaction} days. Check your SimpleFin dashboard to ensure your bank connections are active."
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
{ stale: false }
|
||||
end
|
||||
|
||||
# Check if the SimpleFin connection needs user attention
|
||||
def needs_attention?
|
||||
requires_update? || stale_sync_status[:stale] || pending_account_setup?
|
||||
end
|
||||
|
||||
# Get a summary of issues requiring attention
|
||||
def attention_summary
|
||||
issues = []
|
||||
issues << "Connection needs update" if requires_update?
|
||||
issues << stale_sync_status[:message] if stale_sync_status[:stale]
|
||||
issues << "Accounts need setup" if pending_account_setup?
|
||||
issues
|
||||
end
|
||||
|
||||
private
|
||||
def remove_simplefin_item
|
||||
# SimpleFin doesn't require server-side cleanup like Plaid
|
||||
|
||||
@@ -15,11 +15,23 @@ class SimplefinItem::Importer
|
||||
Rails.logger.info "SimplefinItem::Importer - last_synced_at: #{simplefin_item.last_synced_at.inspect}"
|
||||
Rails.logger.info "SimplefinItem::Importer - sync_start_date: #{simplefin_item.sync_start_date.inspect}"
|
||||
|
||||
# Clear stale error and reconciliation stats from previous syncs at the start of a full import
|
||||
# This ensures the UI doesn't show outdated warnings from old sync runs
|
||||
if sync.respond_to?(:sync_stats)
|
||||
sync.update_columns(sync_stats: {
|
||||
"cleared_at" => Time.current.iso8601,
|
||||
"import_started" => true
|
||||
})
|
||||
end
|
||||
|
||||
begin
|
||||
# Defensive guard: If last_synced_at is set but there are linked accounts
|
||||
# with no transactions captured yet (typical after a balances-only run),
|
||||
# force the first full run to use chunked history to backfill.
|
||||
linked_accounts = simplefin_item.simplefin_accounts.joins(:account)
|
||||
#
|
||||
# Check for linked accounts via BOTH legacy FK (accounts.simplefin_account_id) AND
|
||||
# the new AccountProvider system. An account is "linked" if either association exists.
|
||||
linked_accounts = simplefin_item.simplefin_accounts.select { |sfa| sfa.current_account.present? }
|
||||
no_txns_yet = linked_accounts.any? && linked_accounts.all? { |sfa| sfa.raw_transactions_payload.blank? }
|
||||
|
||||
if simplefin_item.last_synced_at.nil? || no_txns_yet
|
||||
@@ -569,7 +581,18 @@ class SimplefinItem::Importer
|
||||
end
|
||||
end
|
||||
|
||||
attrs[:raw_transactions_payload] = best_by_key.values
|
||||
merged_transactions = best_by_key.values
|
||||
attrs[:raw_transactions_payload] = merged_transactions
|
||||
|
||||
# NOTE: Reconciliation disabled - it analyzes the SimpleFin API response
|
||||
# which only contains ~90 days of history, creating misleading "gap" warnings
|
||||
# that don't reflect actual database state. Re-enable if we improve it to
|
||||
# compare against database transactions instead of just the API response.
|
||||
# begin
|
||||
# reconcile_transactions(simplefin_account, merged_transactions)
|
||||
# rescue => e
|
||||
# Rails.logger.warn("SimpleFin: reconciliation failed for sfa=#{simplefin_account.id || account_id}: #{e.class} - #{e.message}")
|
||||
# end
|
||||
end
|
||||
|
||||
# Track whether incoming holdings are new/changed so we can materialize and refresh balances
|
||||
@@ -783,4 +806,164 @@ class SimplefinItem::Importer
|
||||
# Default to 7 days buffer for subsequent syncs
|
||||
7
|
||||
end
|
||||
|
||||
# Transaction reconciliation: detect potential data gaps or missing transactions
|
||||
# This helps identify when SimpleFin may not be returning complete data
|
||||
def reconcile_transactions(simplefin_account, new_transactions)
|
||||
return if new_transactions.blank?
|
||||
|
||||
account_id = simplefin_account.account_id
|
||||
existing_transactions = simplefin_account.raw_transactions_payload.to_a
|
||||
reconciliation = { account_id: account_id, issues: [] }
|
||||
|
||||
# 1. Check for unexpected transaction count drops
|
||||
# If we previously had more transactions and now have fewer (after merge),
|
||||
# something may have been removed upstream
|
||||
if existing_transactions.any?
|
||||
existing_count = existing_transactions.size
|
||||
new_count = new_transactions.size
|
||||
|
||||
# After merging, we should have at least as many as before
|
||||
# A significant drop (>10%) could indicate data loss
|
||||
if new_count < existing_count
|
||||
drop_pct = ((existing_count - new_count).to_f / existing_count * 100).round(1)
|
||||
if drop_pct > 10
|
||||
reconciliation[:issues] << {
|
||||
type: "transaction_count_drop",
|
||||
message: "Transaction count dropped from #{existing_count} to #{new_count} (#{drop_pct}% decrease)",
|
||||
severity: drop_pct > 25 ? "high" : "medium"
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# 2. Detect gaps in transaction history
|
||||
# Look for periods with no transactions that seem unusual
|
||||
gaps = detect_transaction_gaps(new_transactions)
|
||||
if gaps.any?
|
||||
reconciliation[:issues] += gaps.map do |gap|
|
||||
{
|
||||
type: "transaction_gap",
|
||||
message: "No transactions between #{gap[:start_date]} and #{gap[:end_date]} (#{gap[:days]} days)",
|
||||
severity: gap[:days] > 30 ? "high" : "medium",
|
||||
gap_start: gap[:start_date],
|
||||
gap_end: gap[:end_date],
|
||||
gap_days: gap[:days]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# 3. Check for stale data (most recent transaction is old)
|
||||
latest_tx_date = extract_latest_transaction_date(new_transactions)
|
||||
if latest_tx_date.present?
|
||||
days_since_latest = (Date.current - latest_tx_date).to_i
|
||||
if days_since_latest > 7
|
||||
reconciliation[:issues] << {
|
||||
type: "stale_transactions",
|
||||
message: "Most recent transaction is #{days_since_latest} days old",
|
||||
severity: days_since_latest > 14 ? "high" : "medium",
|
||||
latest_date: latest_tx_date.to_s,
|
||||
days_stale: days_since_latest
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# 4. Check for duplicate transaction IDs (data integrity issue)
|
||||
duplicate_ids = find_duplicate_transaction_ids(new_transactions)
|
||||
if duplicate_ids.any?
|
||||
reconciliation[:issues] << {
|
||||
type: "duplicate_ids",
|
||||
message: "Found #{duplicate_ids.size} duplicate transaction ID(s)",
|
||||
severity: "low",
|
||||
duplicate_count: duplicate_ids.size
|
||||
}
|
||||
end
|
||||
|
||||
# Record reconciliation results in stats
|
||||
if reconciliation[:issues].any?
|
||||
stats["reconciliation"] ||= {}
|
||||
stats["reconciliation"][account_id] = reconciliation
|
||||
|
||||
# Count issues by severity
|
||||
high_severity = reconciliation[:issues].count { |i| i[:severity] == "high" }
|
||||
medium_severity = reconciliation[:issues].count { |i| i[:severity] == "medium" }
|
||||
|
||||
if high_severity > 0
|
||||
stats["reconciliation_warnings"] = stats.fetch("reconciliation_warnings", 0) + high_severity
|
||||
Rails.logger.warn("SimpleFin reconciliation: #{high_severity} high-severity issue(s) for account #{account_id}")
|
||||
|
||||
ActiveSupport::Notifications.instrument(
|
||||
"simplefin.reconciliation_warning",
|
||||
item_id: simplefin_item.id,
|
||||
account_id: account_id,
|
||||
issues: reconciliation[:issues]
|
||||
)
|
||||
end
|
||||
|
||||
if medium_severity > 0
|
||||
stats["reconciliation_notices"] = stats.fetch("reconciliation_notices", 0) + medium_severity
|
||||
end
|
||||
|
||||
persist_stats!
|
||||
end
|
||||
|
||||
reconciliation
|
||||
end
|
||||
|
||||
# Detect gaps in transaction history (periods with no activity)
|
||||
def detect_transaction_gaps(transactions)
|
||||
return [] if transactions.blank? || transactions.size < 2
|
||||
|
||||
# Extract and sort transaction dates
|
||||
dates = transactions.map do |tx|
|
||||
t = tx.with_indifferent_access
|
||||
posted = t[:posted]
|
||||
next nil if posted.blank? || posted.to_i <= 0
|
||||
Time.at(posted.to_i).to_date
|
||||
end.compact.uniq.sort
|
||||
|
||||
return [] if dates.size < 2
|
||||
|
||||
gaps = []
|
||||
min_gap_days = 14 # Only report gaps of 2+ weeks
|
||||
|
||||
dates.each_cons(2) do |earlier, later|
|
||||
gap_days = (later - earlier).to_i
|
||||
if gap_days >= min_gap_days
|
||||
gaps << {
|
||||
start_date: earlier.to_s,
|
||||
end_date: later.to_s,
|
||||
days: gap_days
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Limit to top 3 largest gaps to avoid noise
|
||||
gaps.sort_by { |g| -g[:days] }.first(3)
|
||||
end
|
||||
|
||||
# Extract the most recent transaction date
|
||||
def extract_latest_transaction_date(transactions)
|
||||
return nil if transactions.blank?
|
||||
|
||||
latest_timestamp = transactions.map do |tx|
|
||||
t = tx.with_indifferent_access
|
||||
posted = t[:posted]
|
||||
posted.to_i if posted.present? && posted.to_i > 0
|
||||
end.compact.max
|
||||
|
||||
latest_timestamp ? Time.at(latest_timestamp).to_date : nil
|
||||
end
|
||||
|
||||
# Find duplicate transaction IDs
|
||||
def find_duplicate_transaction_ids(transactions)
|
||||
return [] if transactions.blank?
|
||||
|
||||
ids = transactions.map do |tx|
|
||||
t = tx.with_indifferent_access
|
||||
t[:id] || t[:fitid]
|
||||
end.compact
|
||||
|
||||
ids.group_by(&:itself).select { |_, v| v.size > 1 }.keys
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,7 +10,15 @@ class SimplefinItem::Syncer
|
||||
# can review and manually link accounts first. This mirrors the historical flow
|
||||
# users expect: initial 7-day balances snapshot, then full chunked history after linking.
|
||||
begin
|
||||
if simplefin_item.simplefin_accounts.joins(:account).count == 0
|
||||
# Check for linked accounts via BOTH legacy FK (accounts.simplefin_account_id) AND
|
||||
# the new AccountProvider system. An account is "linked" if either association exists.
|
||||
linked_via_legacy = simplefin_item.simplefin_accounts.joins(:account).count
|
||||
linked_via_provider = simplefin_item.simplefin_accounts.joins(:account_provider).count
|
||||
total_linked = simplefin_item.simplefin_accounts.select { |sfa| sfa.current_account.present? }.count
|
||||
|
||||
Rails.logger.info("SimplefinItem::Syncer - linked check: legacy=#{linked_via_legacy}, provider=#{linked_via_provider}, total=#{total_linked}")
|
||||
|
||||
if total_linked == 0
|
||||
sync.update!(status_text: "Discovering accounts (balances only)...") if sync.respond_to?(:status_text)
|
||||
# Pre-mark the sync as balances_only for runtime only (no persistence)
|
||||
begin
|
||||
@@ -52,8 +60,9 @@ class SimplefinItem::Syncer
|
||||
finalize_setup_counts(sync)
|
||||
|
||||
# Process transactions/holdings only for linked accounts
|
||||
linked_accounts = simplefin_item.simplefin_accounts.joins(:account)
|
||||
if linked_accounts.any?
|
||||
# Check both legacy FK and AccountProvider associations
|
||||
linked_simplefin_accounts = simplefin_item.simplefin_accounts.select { |sfa| sfa.current_account.present? }
|
||||
if linked_simplefin_accounts.any?
|
||||
sync.update!(status_text: "Processing transactions and holdings...") if sync.respond_to?(:status_text)
|
||||
simplefin_item.process_accounts
|
||||
|
||||
@@ -77,7 +86,11 @@ class SimplefinItem::Syncer
|
||||
def finalize_setup_counts(sync)
|
||||
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
|
||||
total_accounts = simplefin_item.simplefin_accounts.count
|
||||
linked_accounts = simplefin_item.simplefin_accounts.joins(:account)
|
||||
|
||||
# Count linked accounts using both legacy FK and AccountProvider associations
|
||||
linked_count = simplefin_item.simplefin_accounts.count { |sfa| sfa.current_account.present? }
|
||||
|
||||
# Unlinked = no legacy FK AND no AccountProvider
|
||||
unlinked_accounts = simplefin_item.simplefin_accounts
|
||||
.left_joins(:account, :account_provider)
|
||||
.where(accounts: { id: nil }, account_providers: { id: nil })
|
||||
@@ -93,7 +106,7 @@ class SimplefinItem::Syncer
|
||||
existing = (sync.sync_stats || {})
|
||||
setup_stats = {
|
||||
"total_accounts" => total_accounts,
|
||||
"linked_accounts" => linked_accounts.count,
|
||||
"linked_accounts" => linked_count,
|
||||
"unlinked_accounts" => unlinked_accounts.count
|
||||
}
|
||||
sync.update!(sync_stats: existing.merge(setup_stats))
|
||||
@@ -185,7 +198,8 @@ class SimplefinItem::Syncer
|
||||
window_start = sync.created_at || 30.minutes.ago
|
||||
window_end = Time.current
|
||||
|
||||
account_ids = simplefin_item.simplefin_accounts.joins(:account).pluck("accounts.id")
|
||||
# Get account IDs via BOTH legacy FK and AccountProvider to ensure we capture all linked accounts
|
||||
account_ids = simplefin_item.simplefin_accounts.filter_map { |sfa| sfa.current_account&.id }
|
||||
return {} if account_ids.empty?
|
||||
|
||||
tx_scope = Entry.where(account_id: account_ids, source: "simplefin", entryable_type: "Transaction")
|
||||
@@ -193,14 +207,16 @@ class SimplefinItem::Syncer
|
||||
tx_updated = tx_scope.where(updated_at: window_start..window_end).where.not(created_at: window_start..window_end).count
|
||||
tx_seen = tx_imported + tx_updated
|
||||
|
||||
holdings_scope = Holding.where(account_id: account_ids)
|
||||
holdings_processed = holdings_scope.where(created_at: window_start..window_end).count
|
||||
# Count holdings from raw_holdings_payload (what the sync found) rather than
|
||||
# the database. Holdings are applied asynchronously via SimplefinHoldingsApplyJob,
|
||||
# so database counts would always be 0 at this point.
|
||||
holdings_found = simplefin_item.simplefin_accounts.sum { |sfa| Array(sfa.raw_holdings_payload).size }
|
||||
|
||||
{
|
||||
"tx_imported" => tx_imported,
|
||||
"tx_updated" => tx_updated,
|
||||
"tx_seen" => tx_seen,
|
||||
"holdings_processed" => holdings_processed,
|
||||
"holdings_found" => holdings_found,
|
||||
"window_start" => window_start,
|
||||
"window_end" => window_end
|
||||
}
|
||||
|
||||
@@ -117,12 +117,12 @@
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<% @recent_runs.each do |run| %>
|
||||
<tr class="<%= 'bg-red-50 theme-dark:bg-red-950/30' if run.failed? %>">
|
||||
<tr class="<%= "bg-red-50 theme-dark:bg-red-950/30" if run.failed? %>">
|
||||
<td class="px-4 py-3 text-sm text-primary whitespace-nowrap">
|
||||
<%= run.executed_at.strftime("%b %d, %Y %I:%M %p") %>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-primary text-center">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium <%= run.execution_type == 'manual' ? 'bg-blue-50 text-blue-700 theme-dark:bg-blue-950/30 theme-dark:text-blue-400' : 'bg-purple-50 text-purple-700 theme-dark:bg-purple-950/30 theme-dark:text-purple-400' %>">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium <%= run.execution_type == "manual" ? "bg-blue-50 text-blue-700 theme-dark:bg-blue-950/30 theme-dark:text-blue-400" : "bg-purple-50 text-purple-700 theme-dark:bg-purple-950/30 theme-dark:text-purple-400" %>">
|
||||
<%= t("rules.recent_runs.execution_types.#{run.execution_type}") %>
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -16,9 +16,31 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Compute unlinked count early for badge display %>
|
||||
<% header_unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map
|
||||
@simplefin_unlinked_count_map[simplefin_item.id] || 0
|
||||
else
|
||||
begin
|
||||
simplefin_item.simplefin_accounts
|
||||
.left_joins(:account, :account_provider)
|
||||
.where(accounts: { id: nil }, account_providers: { id: nil })
|
||||
.count
|
||||
rescue => e
|
||||
0
|
||||
end
|
||||
end %>
|
||||
|
||||
<div class="pl-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= tag.p simplefin_item.name, class: "font-medium text-primary" %>
|
||||
<% if header_unlinked_count.to_i > 0 %>
|
||||
<%= link_to setup_accounts_simplefin_item_path(simplefin_item),
|
||||
data: { turbo_frame: :modal },
|
||||
class: "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning hover:bg-warning/20 transition-colors" do %>
|
||||
<%= icon "alert-circle", size: "xs" %>
|
||||
<span><%= header_unlinked_count %> <%= header_unlinked_count == 1 ? "account" : "accounts" %> need setup</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if simplefin_item.scheduled_for_deletion? %>
|
||||
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
|
||||
<% end %>
|
||||
@@ -77,6 +99,11 @@
|
||||
<%= icon "alert-triangle", size: "sm", color: "warning" %>
|
||||
<%= tag.span t(".requires_update") %>
|
||||
</div>
|
||||
<% elsif (stale_status = simplefin_item.stale_sync_status)[:stale] %>
|
||||
<div class="text-warning flex items-center gap-1">
|
||||
<%= icon "alert-circle", size: "sm", color: "warning" %>
|
||||
<%= tag.span stale_status[:message], class: "text-sm" %>
|
||||
</div>
|
||||
<% elsif simplefin_item.rate_limited_message.present? %>
|
||||
<div class="text-warning flex items-center gap-1">
|
||||
<%= icon "clock", size: "sm", color: "warning" %>
|
||||
@@ -125,8 +152,6 @@
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
|
||||
|
||||
<%= render DS::Menu.new do |menu| %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
@@ -146,7 +171,6 @@
|
||||
<%= render "accounts/index/account_groups", accounts: simplefin_item.accounts %>
|
||||
<% end %>
|
||||
|
||||
|
||||
<%# Sync summary (collapsible)
|
||||
Prefer controller-provided map; fallback to latest sync stats so Turbo broadcasts
|
||||
can render the summary without requiring a full page refresh. %>
|
||||
@@ -192,23 +216,28 @@
|
||||
<div>
|
||||
<h4 class="text-primary font-medium mb-1">Holdings</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<span>Processed: <%= stats["holdings_processed"].to_i %></span>
|
||||
<span>Found: <%= stats["holdings_found"].to_i %></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-primary font-medium mb-1">Health</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<% if stats["rate_limited"].present? || stats["rate_limited_at"].present? %>
|
||||
<% ts = stats["rate_limited_at"] %>
|
||||
<% ago = (ts.present? ? (begin; time_ago_in_words(Time.parse(ts)); rescue StandardError; nil; end) : nil) %>
|
||||
<span class="text-warning">Rate limited <%= ago ? "(#{ago} ago)" : "recently" %></span>
|
||||
<% end %>
|
||||
<% total_errors = stats["total_errors"].to_i %>
|
||||
<% if total_errors > 0 %>
|
||||
<span class="text-destructive">Errors: <%= total_errors %></span>
|
||||
<% else %>
|
||||
<span>Errors: 0</span>
|
||||
<% end %>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<% if stats["rate_limited"].present? || stats["rate_limited_at"].present? %>
|
||||
<% ts = stats["rate_limited_at"] %>
|
||||
<% ago = (ts.present? ? (begin; time_ago_in_words(Time.parse(ts)); rescue StandardError; nil; end) : nil) %>
|
||||
<span class="text-warning">Rate limited <%= ago ? "(#{ago} ago)" : "recently" %></span>
|
||||
<% end %>
|
||||
<% total_errors = stats["total_errors"].to_i %>
|
||||
<% import_started = stats["import_started"].present? %>
|
||||
<% if total_errors > 0 %>
|
||||
<span class="text-destructive">Errors: <%= total_errors %></span>
|
||||
<% elsif import_started %>
|
||||
<span class="text-success">Errors: 0</span>
|
||||
<% else %>
|
||||
<span>Errors: 0</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<div class="subtype-select" data-type="<%= account_type %>" style="display: none;">
|
||||
<%# Use centralized @inferred_map for subtype pre-selection %>
|
||||
<% inferred = @inferred_map&.dig(simplefin_account.id) || {} %>
|
||||
<% selected_value = "" %>
|
||||
<% if inferred[:confidence] == :high && inferred[:type] == account_type && inferred[:subtype].present? %>
|
||||
<% selected_value = inferred[:subtype] %>
|
||||
<% end %>
|
||||
<% needs_attention = subtype_config[:options].present? && selected_value.blank? %>
|
||||
|
||||
<div class="subtype-select" data-type="<%= account_type %>" data-needs-attention="<%= needs_attention %>" style="display: none;">
|
||||
<% if subtype_config[:options].present? %>
|
||||
<%= label_tag "account_subtypes[#{simplefin_account.id}]", subtype_config[:label],
|
||||
class: "block text-sm font-medium text-primary mb-2" %>
|
||||
<% selected_value = "" %>
|
||||
<% if account_type == "Depository" %>
|
||||
<% n = simplefin_account.name.to_s.downcase %>
|
||||
<% selected_value = "" %>
|
||||
<% if n =~ /\bchecking\b|\bchequing\b|\bck\b|demand\s+deposit/ %>
|
||||
<% selected_value = "checking" %>
|
||||
<% elsif n =~ /\bsavings\b|\bsv\b/ %>
|
||||
<% selected_value = "savings" %>
|
||||
<% elsif n =~ /money\s+market|\bmm\b/ %>
|
||||
<% selected_value = "money_market" %>
|
||||
<div class="<%= "ring-2 ring-warning/50 rounded-md p-2 -m-2" if needs_attention %>">
|
||||
<% if needs_attention %>
|
||||
<div class="flex items-center gap-1 text-warning text-xs mb-2">
|
||||
<%= icon "alert-circle", size: "xs" %>
|
||||
<span>Please select a <%= account_type == "Depository" ? "subtype" : "type" %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
<% elsif account_type == "Investment" %>
|
||||
<% inferred = @inferred_map&.dig(simplefin_account.id) || {} %>
|
||||
<% if inferred[:confidence] == :high && inferred[:type] == "Investment" && inferred[:subtype].present? %>
|
||||
<% selected_value = inferred[:subtype] %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= select_tag "account_subtypes[#{simplefin_account.id}]",
|
||||
options_for_select([["Select #{account_type == 'Depository' ? 'subtype' : 'type'}", ""]] + subtype_config[:options], selected_value),
|
||||
{ class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full" } %>
|
||||
<%= label_tag "account_subtypes[#{simplefin_account.id}]", subtype_config[:label],
|
||||
class: "block text-sm font-medium text-primary mb-2" %>
|
||||
<%= select_tag "account_subtypes[#{simplefin_account.id}]",
|
||||
options_for_select([["Select #{account_type == 'Depository' ? 'subtype' : 'type'}", ""]] + subtype_config[:options], selected_value),
|
||||
{ class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full",
|
||||
data: { action: "change->account-type-selector#clearWarning" } } %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-sm text-secondary"><%= subtype_config[:message] %></p>
|
||||
<% end %>
|
||||
|
||||
@@ -39,27 +39,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Date Range Selection -->
|
||||
<!-- Sync Data Information -->
|
||||
<div class="bg-surface border border-primary p-4 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon "calendar", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
|
||||
<%= icon "clock", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-primary mb-3">
|
||||
<strong>Historical Data Range:</strong>
|
||||
<p class="text-sm text-primary mb-2">
|
||||
<strong>Transaction History:</strong>
|
||||
</p>
|
||||
<p class="text-xs text-secondary">
|
||||
SimpleFin typically provides <strong>60-90 days</strong> of transaction history, depending on your bank.
|
||||
After initial setup, new transactions will sync automatically going forward.
|
||||
Historical data availability varies by institution and account type.
|
||||
</p>
|
||||
<%= form.date_field :sync_start_date,
|
||||
label: "Start syncing transactions from:",
|
||||
value: @simplefin_item.sync_start_date || 1.year.ago.to_date,
|
||||
min: 3.years.ago.to_date,
|
||||
max: Date.current,
|
||||
class: "w-full max-w-xs",
|
||||
help_text: "Select how far back you want to sync transaction history. Maximum 3 years of history available." %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% @simplefin_accounts.each do |simplefin_account| %>
|
||||
<div class="border border-primary rounded-lg p-4">
|
||||
<% inferred = @inferred_map[simplefin_account.id] || {} %>
|
||||
<% selected_type = inferred[:confidence] == :high ? inferred[:type] : "skip" %>
|
||||
<%# Check if this account needs user attention (type selected but subtype missing) %>
|
||||
<% types_with_subtypes = %w[Depository Investment Loan] %>
|
||||
<% needs_subtype_attention = selected_type != "skip" && types_with_subtypes.include?(selected_type) && inferred[:subtype].blank? %>
|
||||
|
||||
<div class="rounded-lg p-4 <%= needs_subtype_attention ? "border-2 border-warning bg-warning/5" : "border border-primary" %>">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="font-medium text-primary">
|
||||
@@ -78,8 +82,6 @@
|
||||
<div>
|
||||
<%= label_tag "account_types[#{simplefin_account.id}]", "Account Type:",
|
||||
class: "block text-sm font-medium text-primary mb-2" %>
|
||||
<% inferred = @inferred_map[simplefin_account.id] || {} %>
|
||||
<% selected_type = inferred[:confidence] == :high ? inferred[:type] : "skip" %>
|
||||
<%= select_tag "account_types[#{simplefin_account.id}]",
|
||||
options_for_select(@account_type_options, selected_type),
|
||||
{ class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
en:
|
||||
transactions:
|
||||
unknown_name: Unknown transaction
|
||||
form:
|
||||
account: Account
|
||||
account_prompt: Select an Account
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
include ActiveJob::TestHelper
|
||||
fixtures :users, :families
|
||||
setup do
|
||||
sign_in users(:family_admin)
|
||||
@@ -154,22 +155,20 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "should update simplefin item with valid token" do
|
||||
@simplefin_item.update!(status: :requires_update)
|
||||
|
||||
# Mock the SimpleFin provider to prevent real API calls
|
||||
mock_provider = mock()
|
||||
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
|
||||
mock_provider.expects(:get_accounts).returns({ accounts: [] }).at_least_once
|
||||
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
|
||||
token = Base64.strict_encode64("https://example.com/claim")
|
||||
|
||||
# Let the real create_simplefin_item! method run - don't mock it
|
||||
SimplefinConnectionUpdateJob.expects(:perform_later).with(
|
||||
family_id: @family.id,
|
||||
old_simplefin_item_id: @simplefin_item.id,
|
||||
setup_token: token
|
||||
).once
|
||||
|
||||
patch simplefin_item_url(@simplefin_item), params: {
|
||||
simplefin_item: { setup_token: "valid_token" }
|
||||
simplefin_item: { setup_token: token }
|
||||
}
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal "SimpleFin connection updated.", flash[:notice]
|
||||
@simplefin_item.reload
|
||||
assert @simplefin_item.scheduled_for_deletion?
|
||||
end
|
||||
|
||||
test "should handle update with invalid token" do
|
||||
@@ -186,6 +185,8 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "should transfer accounts when updating simplefin item token" do
|
||||
@simplefin_item.update!(status: :requires_update)
|
||||
|
||||
token = Base64.strict_encode64("https://example.com/claim")
|
||||
|
||||
# Create old SimpleFin accounts linked to Maybe accounts
|
||||
old_simplefin_account1 = @simplefin_item.simplefin_accounts.create!(
|
||||
name: "Test Checking",
|
||||
@@ -228,7 +229,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
# Mock only the external API calls, let business logic run
|
||||
mock_provider = mock()
|
||||
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
|
||||
mock_provider.expects(:claim_access_url).with(token).returns("https://example.com/new_access")
|
||||
mock_provider.expects(:get_accounts).returns({
|
||||
accounts: [
|
||||
{
|
||||
@@ -251,10 +252,13 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
}).at_least_once
|
||||
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
|
||||
|
||||
# Perform the update
|
||||
patch simplefin_item_url(@simplefin_item), params: {
|
||||
simplefin_item: { setup_token: "valid_token" }
|
||||
}
|
||||
# Perform the update (async job), but execute enqueued jobs inline so we can
|
||||
# assert the link transfers.
|
||||
perform_enqueued_jobs(only: SimplefinConnectionUpdateJob) do
|
||||
patch simplefin_item_url(@simplefin_item), params: {
|
||||
simplefin_item: { setup_token: token }
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
assert_equal "SimpleFin connection updated.", flash[:notice]
|
||||
@@ -279,11 +283,7 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal new_sf_account1.id, maybe_account1.simplefin_account_id
|
||||
assert_equal new_sf_account2.id, maybe_account2.simplefin_account_id
|
||||
|
||||
# Verify old SimpleFin accounts no longer reference Maybe accounts
|
||||
old_simplefin_account1.reload
|
||||
old_simplefin_account2.reload
|
||||
assert_nil old_simplefin_account1.current_account
|
||||
assert_nil old_simplefin_account2.current_account
|
||||
# The old item will be deleted asynchronously; until then, legacy links should be moved.
|
||||
|
||||
# Verify old SimpleFin item is scheduled for deletion
|
||||
@simplefin_item.reload
|
||||
@@ -293,6 +293,8 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "should handle partial account matching during token update" do
|
||||
@simplefin_item.update!(status: :requires_update)
|
||||
|
||||
token = Base64.strict_encode64("https://example.com/claim")
|
||||
|
||||
# Create old SimpleFin account
|
||||
old_simplefin_account = @simplefin_item.simplefin_accounts.create!(
|
||||
name: "Test Checking",
|
||||
@@ -316,19 +318,19 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
# Mock only the external API calls, let business logic run
|
||||
mock_provider = mock()
|
||||
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
|
||||
mock_provider.expects(:claim_access_url).with(token).returns("https://example.com/new_access")
|
||||
# Return empty accounts list to simulate account was removed from bank
|
||||
mock_provider.expects(:get_accounts).returns({ accounts: [] }).at_least_once
|
||||
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
|
||||
|
||||
# Perform update
|
||||
patch simplefin_item_url(@simplefin_item), params: {
|
||||
simplefin_item: { setup_token: "valid_token" }
|
||||
}
|
||||
perform_enqueued_jobs(only: SimplefinConnectionUpdateJob) do
|
||||
patch simplefin_item_url(@simplefin_item), params: {
|
||||
simplefin_item: { setup_token: token }
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :redirect
|
||||
uri2 = URI(response.redirect_url)
|
||||
assert_equal "/accounts", uri2.path
|
||||
assert_redirected_to accounts_path
|
||||
|
||||
# Verify Maybe account still linked to old SimpleFin account (no transfer occurred)
|
||||
maybe_account.reload
|
||||
@@ -450,30 +452,27 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "update redirects to accounts after setup without forcing a modal" do
|
||||
@simplefin_item.update!(status: :requires_update)
|
||||
|
||||
# Mock provider to return one account so updated_item creates SFAs
|
||||
mock_provider = mock()
|
||||
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
|
||||
mock_provider.expects(:get_accounts).returns({
|
||||
accounts: [
|
||||
{ id: "sf_auto_open_1", name: "Auto Open Checking", type: "depository", currency: "USD", balance: 100, transactions: [] }
|
||||
]
|
||||
}).at_least_once
|
||||
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
|
||||
token = Base64.strict_encode64("https://example.com/claim")
|
||||
|
||||
patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: "valid_token" } }
|
||||
SimplefinConnectionUpdateJob.expects(:perform_later).with(
|
||||
family_id: @family.id,
|
||||
old_simplefin_item_id: @simplefin_item.id,
|
||||
setup_token: token
|
||||
).once
|
||||
|
||||
assert_response :redirect
|
||||
uri = URI(response.redirect_url)
|
||||
assert_equal "/accounts", uri.path
|
||||
patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: token } }
|
||||
|
||||
assert_redirected_to accounts_path
|
||||
end
|
||||
|
||||
test "create does not auto-open when no candidates or unlinked" do
|
||||
# Mock provider interactions for item creation (no immediate account import on create)
|
||||
mock_provider = mock()
|
||||
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
|
||||
token = Base64.strict_encode64("https://example.com/claim")
|
||||
mock_provider.expects(:claim_access_url).with(token).returns("https://example.com/new_access")
|
||||
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
|
||||
|
||||
post simplefin_items_url, params: { simplefin_item: { setup_token: "valid_token" } }
|
||||
post simplefin_items_url, params: { simplefin_item: { setup_token: token } }
|
||||
|
||||
assert_response :redirect
|
||||
uri = URI(response.redirect_url)
|
||||
@@ -485,12 +484,15 @@ class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "update does not auto-open when no SFAs present" do
|
||||
@simplefin_item.update!(status: :requires_update)
|
||||
|
||||
mock_provider = mock()
|
||||
mock_provider.expects(:claim_access_url).with("valid_token").returns("https://example.com/new_access")
|
||||
mock_provider.expects(:get_accounts).returns({ accounts: [] }).at_least_once
|
||||
Provider::Simplefin.expects(:new).returns(mock_provider).at_least_once
|
||||
token = Base64.strict_encode64("https://example.com/claim")
|
||||
|
||||
patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: "valid_token" } }
|
||||
SimplefinConnectionUpdateJob.expects(:perform_later).with(
|
||||
family_id: @family.id,
|
||||
old_simplefin_item_id: @simplefin_item.id,
|
||||
setup_token: token
|
||||
).once
|
||||
|
||||
patch simplefin_item_url(@simplefin_item), params: { simplefin_item: { setup_token: token } }
|
||||
|
||||
assert_response :redirect
|
||||
uri = URI(response.redirect_url)
|
||||
|
||||
144
test/models/provider/simplefin_test.rb
Normal file
144
test/models/provider/simplefin_test.rb
Normal file
@@ -0,0 +1,144 @@
|
||||
require "test_helper"
|
||||
|
||||
class Provider::SimplefinTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = Provider::Simplefin.new
|
||||
@access_url = "https://example.com/simplefin/access"
|
||||
end
|
||||
|
||||
test "retries on Net::ReadTimeout and succeeds on retry" do
|
||||
# First call raises timeout, second call succeeds
|
||||
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
|
||||
|
||||
HTTParty.expects(:get)
|
||||
.times(2)
|
||||
.raises(Net::ReadTimeout.new("Connection timed out"))
|
||||
.then.returns(mock_response)
|
||||
|
||||
# Stub sleep to avoid actual delays in tests
|
||||
@provider.stubs(:sleep)
|
||||
|
||||
result = @provider.get_accounts(@access_url)
|
||||
assert_equal({ accounts: [] }, result)
|
||||
end
|
||||
|
||||
test "retries on Net::OpenTimeout and succeeds on retry" do
|
||||
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
|
||||
|
||||
HTTParty.expects(:get)
|
||||
.times(2)
|
||||
.raises(Net::OpenTimeout.new("Connection timed out"))
|
||||
.then.returns(mock_response)
|
||||
|
||||
@provider.stubs(:sleep)
|
||||
|
||||
result = @provider.get_accounts(@access_url)
|
||||
assert_equal({ accounts: [] }, result)
|
||||
end
|
||||
|
||||
test "retries on SocketError and succeeds on retry" do
|
||||
mock_response = OpenStruct.new(code: 200, body: '{"accounts": []}')
|
||||
|
||||
HTTParty.expects(:get)
|
||||
.times(2)
|
||||
.raises(SocketError.new("Failed to open TCP connection"))
|
||||
.then.returns(mock_response)
|
||||
|
||||
@provider.stubs(:sleep)
|
||||
|
||||
result = @provider.get_accounts(@access_url)
|
||||
assert_equal({ accounts: [] }, result)
|
||||
end
|
||||
|
||||
test "raises SimplefinError after max retries exceeded" do
|
||||
HTTParty.expects(:get)
|
||||
.times(4) # Initial + 3 retries
|
||||
.raises(Net::ReadTimeout.new("Connection timed out"))
|
||||
|
||||
@provider.stubs(:sleep)
|
||||
|
||||
error = assert_raises(Provider::Simplefin::SimplefinError) do
|
||||
@provider.get_accounts(@access_url)
|
||||
end
|
||||
|
||||
assert_equal :network_error, error.error_type
|
||||
assert_match(/Network error after 3 retries/, error.message)
|
||||
end
|
||||
|
||||
test "does not retry on non-retryable errors" do
|
||||
HTTParty.expects(:get)
|
||||
.times(1)
|
||||
.raises(ArgumentError.new("Invalid argument"))
|
||||
|
||||
error = assert_raises(Provider::Simplefin::SimplefinError) do
|
||||
@provider.get_accounts(@access_url)
|
||||
end
|
||||
|
||||
assert_equal :request_failed, error.error_type
|
||||
end
|
||||
|
||||
test "handles HTTP 429 rate limit response" do
|
||||
mock_response = OpenStruct.new(code: 429, body: "Rate limit exceeded")
|
||||
|
||||
HTTParty.expects(:get).returns(mock_response)
|
||||
|
||||
error = assert_raises(Provider::Simplefin::SimplefinError) do
|
||||
@provider.get_accounts(@access_url)
|
||||
end
|
||||
|
||||
assert_equal :rate_limited, error.error_type
|
||||
assert_match(/rate limit exceeded/i, error.message)
|
||||
end
|
||||
|
||||
test "handles HTTP 500 server error response" do
|
||||
mock_response = OpenStruct.new(code: 500, body: "Internal Server Error")
|
||||
|
||||
HTTParty.expects(:get).returns(mock_response)
|
||||
|
||||
error = assert_raises(Provider::Simplefin::SimplefinError) do
|
||||
@provider.get_accounts(@access_url)
|
||||
end
|
||||
|
||||
assert_equal :server_error, error.error_type
|
||||
end
|
||||
|
||||
test "claim_access_url retries on network errors" do
|
||||
setup_token = Base64.encode64("https://example.com/claim")
|
||||
mock_response = OpenStruct.new(code: 200, body: "https://example.com/access")
|
||||
|
||||
HTTParty.expects(:post)
|
||||
.times(2)
|
||||
.raises(Net::ReadTimeout.new("Connection timed out"))
|
||||
.then.returns(mock_response)
|
||||
|
||||
@provider.stubs(:sleep)
|
||||
|
||||
result = @provider.claim_access_url(setup_token)
|
||||
assert_equal "https://example.com/access", result
|
||||
end
|
||||
|
||||
test "exponential backoff delay increases with retries" do
|
||||
provider = Provider::Simplefin.new
|
||||
|
||||
# Access private method for testing
|
||||
delay1 = provider.send(:calculate_retry_delay, 1)
|
||||
delay2 = provider.send(:calculate_retry_delay, 2)
|
||||
delay3 = provider.send(:calculate_retry_delay, 3)
|
||||
|
||||
# Delays should increase (accounting for jitter)
|
||||
# Base delays: 2, 4, 8 seconds (with up to 25% jitter)
|
||||
assert delay1 >= 2 && delay1 <= 2.5, "First retry delay should be ~2s"
|
||||
assert delay2 >= 4 && delay2 <= 5, "Second retry delay should be ~4s"
|
||||
assert delay3 >= 8 && delay3 <= 10, "Third retry delay should be ~8s"
|
||||
end
|
||||
|
||||
test "retry delay is capped at MAX_RETRY_DELAY" do
|
||||
provider = Provider::Simplefin.new
|
||||
|
||||
# Test with a high retry count that would exceed max delay
|
||||
delay = provider.send(:calculate_retry_delay, 10)
|
||||
|
||||
assert delay <= Provider::Simplefin::MAX_RETRY_DELAY,
|
||||
"Delay should be capped at MAX_RETRY_DELAY (#{Provider::Simplefin::MAX_RETRY_DELAY}s)"
|
||||
end
|
||||
end
|
||||
@@ -53,7 +53,9 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase
|
||||
assert_equal "Order #1234", sf["description"]
|
||||
assert_equal({ "category" => "restaurants", "check_number" => nil }, sf["extra"])
|
||||
end
|
||||
test "flags pending transaction when posted is nil and transacted_at present" do
|
||||
test "does not flag pending when posted is nil but provider pending flag not set" do
|
||||
# Previously we inferred pending from missing posted date, but this was too aggressive -
|
||||
# some providers don't supply posted dates even for settled transactions
|
||||
tx = {
|
||||
id: "tx_pending_1",
|
||||
amount: "-20.00",
|
||||
@@ -70,7 +72,7 @@ class SimplefinEntry::ProcessorTest < ActiveSupport::TestCase
|
||||
entry = @account.entries.find_by!(external_id: "simplefin_tx_pending_1", source: "simplefin")
|
||||
sf = entry.transaction.extra.fetch("simplefin")
|
||||
|
||||
assert_equal true, sf["pending"], "expected pending flag to be true"
|
||||
assert_equal false, sf["pending"], "expected pending flag to be false when provider doesn't explicitly set pending"
|
||||
end
|
||||
|
||||
test "captures FX metadata when tx currency differs from account currency" do
|
||||
|
||||
Reference in New Issue
Block a user