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:
LPW
2026-01-05 16:11:47 -05:00
committed by GitHub
parent b3330a318d
commit c12c585a0e
21 changed files with 913 additions and 179 deletions

View File

@@ -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

View File

@@ -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')
}
}
}
}

View 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

View File

@@ -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

View File

@@ -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 }

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 %>

View File

@@ -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",

View File

@@ -1,6 +1,7 @@
---
en:
transactions:
unknown_name: Unknown transaction
form:
account: Account
account_prompt: Select an Account

View File

@@ -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)

View 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

View File

@@ -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