Files
sure/app/models/simplefin_item/importer.rb
LPW 3658e812a8 Add pending transaction handling and duplicate reconciliation logic (#602)
* Add pending transaction handling and duplicate reconciliation logic

- Implemented logic to exclude pending transactions from budgets and analytics calculations.
- Introduced mechanisms for reconciling pending transactions with posted versions.
- Added duplicate detection with support for merging or dismissing matches.
- Updated transaction search filters to include a `status_filter` for pending/confirmed transactions.
- Introduced UI elements for reviewing and resolving duplicates.
- Enhanced `ProviderSyncSummary` with stats for reconciled and stale pending transactions.

* Refactor translation handling and enhance transaction and sync logic

- Moved hardcoded strings to locale files for improved translation support.
- Refined styling for duplicate transaction indicators and sync summaries.
- Improved logic for excluding stale pending transactions and updating timestamps on batch exclusion.
- Added unique IDs to status filters for better element targeting in UI.
- Optimized database queries to avoid N+1 issues in stale pending calculations.

* Add sync settings and enhance pending transaction handling

- Introduced a new "Sync Settings" section in hosting settings with UI to toggle inclusion of pending transactions.
- Updated handling of pending transactions with improved inference logic for `posted=0` and `transacted_at` in processors.
- Added priority order for pending transaction inclusion: explicit argument > environment variable > runtime configurable setting.
- Refactored settings and controllers to store updated sync preferences.

* Refactor sync settings and pending transaction reconciliation

- Extracted logic for pending transaction reconciliation, stale exclusion, and unmatched tracking into dedicated methods for better maintainability.
- Updated sync settings to infer defaults from multiple provider environment variables (`SIMPLEFIN_INCLUDE_PENDING`, `PLAID_INCLUDE_PENDING`).
- Refined UI and messaging to handle multi-provider configurations in sync settings.

# Conflicts:
#	app/models/simplefin_item/importer.rb

* Debounce transaction reconciliation during imports

- Added per-run reconciliation debouncing to prevent repeated scans for the same account during chunked history imports.
- Trimmed size of reconciliation stats to retain recent details only.
- Introduced error tracking for reconciliation steps to improve UI visibility of issues.

* Apply ABS() in pending transaction queries and improve error handling

- Updated pending transaction logic to use ABS() for consistent handling of negative amounts.
- Adjusted amount bounds calculations to ensure accuracy for both positive and negative values.
- Refined exception handling in `merge_duplicate` to log failures and update user alert.
- Replaced `Date.today` with `Date.current` in tests to ensure timezone consistency.
- Minor optimization to avoid COUNT queries by loading limited records directly.

* Improve error handling in duplicate suggestion and dismissal logic

- Added exception handling for `store_duplicate_suggestion` to log failures and prevent crashes during fuzzy/low-confidence matches.
- Enhanced `dismiss_duplicate` action to handle `ActiveRecord::RecordInvalid` and display appropriate user alerts.

---------

Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
2026-01-10 20:11:00 +01:00

1268 lines
54 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
require "set"
class SimplefinItem::Importer
include SimplefinNumericHelpers
class RateLimitedError < StandardError; end
attr_reader :simplefin_item, :simplefin_provider, :sync
def initialize(simplefin_item, simplefin_provider:, sync: nil)
@simplefin_item = simplefin_item
@simplefin_provider = simplefin_provider
@sync = sync
@enqueued_holdings_job_ids = Set.new
@reconciled_account_ids = Set.new # Debounce pending reconciliation per run
end
def import
Rails.logger.info "SimplefinItem::Importer - Starting import for item #{simplefin_item.id}"
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.
#
# 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
# First sync (or balances-only pre-run) — use chunked approach to get full history
Rails.logger.info "SimplefinItem::Importer - Using CHUNKED HISTORY import (last_synced_at=#{simplefin_item.last_synced_at.inspect}, no_txns_yet=#{no_txns_yet})"
import_with_chunked_history
else
# Regular sync - use single request with buffer
Rails.logger.info "SimplefinItem::Importer - Using REGULAR SYNC (last_synced_at=#{simplefin_item.last_synced_at&.strftime('%Y-%m-%d %H:%M')})"
import_regular_sync
end
rescue RateLimitedError => e
stats["rate_limited"] = true
stats["rate_limited_at"] = Time.current.iso8601
persist_stats!
raise e
end
end
# Balances-only import: discover accounts and update account balances without transactions/holdings
def import_balances_only
Rails.logger.info "SimplefinItem::Importer - Balances-only import for item #{simplefin_item.id}"
stats["balances_only"] = true
# Fetch accounts without date filters
accounts_data = fetch_accounts_data(start_date: nil)
return if accounts_data.nil?
# Store snapshot for observability
simplefin_item.upsert_simplefin_snapshot!(accounts_data)
# Update counts (set to discovered for this run rather than accumulating)
discovered = accounts_data[:accounts]&.size.to_i
stats["total_accounts"] = discovered
persist_stats!
# Upsert SimpleFin accounts minimal attributes and update linked Account balances
accounts_data[:accounts].to_a.each do |account_data|
begin
import_account_minimal_and_balance(account_data)
rescue => e
stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1
cat = classify_error(e)
register_error(message: e.message, category: cat, account_id: account_data[:id], name: account_data[:name])
ensure
persist_stats!
end
end
end
private
# Minimal upsert and balance update for balances-only mode
def import_account_minimal_and_balance(account_data)
account_id = account_data[:id].to_s
return if account_id.blank?
sfa = simplefin_item.simplefin_accounts.find_or_initialize_by(account_id: account_id)
sfa.assign_attributes(
name: account_data[:name],
account_type: (account_data["type"].presence || account_data[:type].presence || sfa.account_type.presence || "unknown"),
currency: (account_data[:currency].presence || account_data["currency"].presence || sfa.currency.presence || sfa.current_account&.currency.presence || simplefin_item.family&.currency.presence || "USD"),
current_balance: account_data[:balance],
available_balance: account_data[:"available-balance"],
balance_date: (account_data["balance-date"] || account_data[:"balance-date"]),
raw_payload: account_data,
org_data: account_data[:org]
)
begin
sfa.save!
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
# Surface a friendly duplicate/validation signal in sync stats and continue
stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1
msg = e.message.to_s
if msg.downcase.include?("already been taken") || msg.downcase.include?("unique")
msg = "Duplicate upstream account detected for SimpleFin (account_id=#{account_id}). Try relinking to an existing manual account."
end
register_error(message: msg, category: "other", account_id: account_id, name: account_data[:name])
persist_stats!
return
end
# In pre-prompt balances-only discovery, do NOT auto-create provider-linked accounts.
# Only update balance for already-linked accounts (if any), to avoid creating duplicates in setup.
if (acct = sfa.current_account)
adapter = Account::ProviderImportAdapter.new(acct)
# Normalize balances for SimpleFIN liabilities so immediate UI is correct after discovery
bal = to_decimal(account_data[:balance])
avail = to_decimal(account_data[:"available-balance"])
observed = bal.nonzero? ? bal : avail
is_linked_liability = [ "CreditCard", "Loan" ].include?(acct.accountable_type)
inferred = begin
Simplefin::AccountTypeMapper.infer(
name: account_data[:name],
holdings: account_data[:holdings],
extra: account_data[:extra],
balance: bal,
available_balance: avail,
institution: account_data.dig(:org, :name)
)
rescue
nil
end
is_mapper_liability = inferred && [ "CreditCard", "Loan" ].include?(inferred.accountable_type)
is_liability = is_linked_liability || is_mapper_liability
normalized = observed
if is_liability
# Try the overpayment analyzer first (feature-flagged)
begin
result = SimplefinAccount::Liabilities::OverpaymentAnalyzer
.new(sfa, observed_balance: observed)
.call
case result.classification
when :credit
normalized = -observed.abs
when :debt
normalized = observed.abs
else
# Fallback to existing normalization when unknown/disabled
begin
obs = {
reason: result.reason,
tx_count: result.metrics[:tx_count],
charges_total: result.metrics[:charges_total],
payments_total: result.metrics[:payments_total],
observed: observed.to_s("F")
}.compact
Rails.logger.info("SimpleFIN overpayment heuristic (balances-only): unknown; falling back #{obs.inspect}")
rescue
# no-op
end
both_present = bal.nonzero? && avail.nonzero?
if both_present && same_sign?(bal, avail)
if bal.positive? && avail.positive?
normalized = -observed.abs
elsif bal.negative? && avail.negative?
normalized = observed.abs
end
else
normalized = -observed
end
end
rescue NameError
# Analyzer missing; use legacy path
both_present = bal.nonzero? && avail.nonzero?
if both_present && same_sign?(bal, avail)
if bal.positive? && avail.positive?
normalized = -observed.abs
elsif bal.negative? && avail.negative?
normalized = observed.abs
end
else
normalized = -observed
end
end
end
cash = if acct.accountable_type == "Investment"
# Leave investment cash to investment calculators in full run
normalized
else
normalized
end
adapter.update_balance(
balance: account_data[:balance],
cash_balance: account_data[:"available-balance"],
source: "simplefin"
)
end
end
def stats
@stats ||= {}
end
# Heuristics to set a SimpleFIN account inactive when upstream indicates closure/hidden
# or when we repeatedly observe zero balances and zero holdings. This should not block
# import and only sets a flag and suggestion via sync stats.
def update_inactive_state(simplefin_account, account_data)
payload = (account_data || {}).with_indifferent_access
raw = (simplefin_account.raw_payload || {}).with_indifferent_access
# Flags from payloads
closed = [ payload[:closed], payload[:hidden], payload.dig(:extra, :closed), raw[:closed], raw[:hidden] ].compact.any? { |v| v == true || v.to_s == "true" }
balance = payload[:balance]
avail = payload[:"available-balance"]
holdings = payload[:holdings]
amounts = [ balance, avail ].compact
zeroish_balance = amounts.any? && amounts.all? { |x| x.to_d.zero? rescue false }
no_holdings = !(holdings.is_a?(Array) && holdings.any?)
stats["zero_runs"] ||= {}
stats["inactive"] ||= {}
key = simplefin_account.account_id.presence || simplefin_account.id
key = key.to_s
# Ensure key exists and defaults to false (so tests don't read nil)
stats["inactive"][key] = false unless stats["inactive"].key?(key)
if closed
stats["inactive"][key] = true
stats["hints"] = Array(stats["hints"]) + [ "Some accounts appear closed/hidden upstream. You can relink or hide them." ]
return
end
# Skip zero balance detection for liability accounts (CreditCard, Loan) where
# 0 balance with no holdings is normal (paid off card/loan)
account_type = simplefin_account.current_account&.accountable_type
return if %w[CreditCard Loan].include?(account_type)
# Only count each account once per sync run to avoid false positives during
# chunked imports (which process the same account multiple times)
zero_balance_seen_keys << key if zeroish_balance && no_holdings
return if zero_balance_seen_keys.count(key) > 1
if zeroish_balance && no_holdings
stats["zero_runs"][key] = stats["zero_runs"][key].to_i + 1
# Cap to avoid unbounded growth
stats["zero_runs"][key] = [ stats["zero_runs"][key], 10 ].min
else
stats["zero_runs"][key] = 0
stats["inactive"][key] = false
end
if stats["zero_runs"][key].to_i >= 3
stats["inactive"][key] = true
stats["hints"] = Array(stats["hints"]) + [ "One or more accounts show no balance/holdings for multiple syncs — consider relinking or marking inactive." ]
end
end
# Track accounts that have been flagged for zero balance in this sync run
def zero_balance_seen_keys
@zero_balance_seen_keys ||= []
end
# Track seen error fingerprints during a single importer run to avoid double counting
def seen_errors
@seen_errors ||= Set.new
end
# Register an error into stats with de-duplication and bucketing
def register_error(message:, category:, account_id: nil, name: nil)
msg = message.to_s.strip
cat = (category.presence || "other").to_s
fp = [ account_id.to_s.presence, cat, msg ].compact.join("|")
first_time = !seen_errors.include?(fp)
seen_errors.add(fp)
if first_time
Rails.logger.warn(
"SimpleFin sync error (unique this run): category=#{cat} account_id=#{account_id.inspect} name=#{name.inspect} msg=#{msg}"
)
# Emit an instrumentation event for observability dashboards
ActiveSupport::Notifications.instrument(
"simplefin.error",
item_id: simplefin_item.id,
account_id: account_id,
account_name: name,
category: cat,
message: msg
)
else
# Keep logs tame; don't spam on repeats in the same run
end
stats["errors"] ||= []
buckets = stats["error_buckets"] ||= { "auth" => 0, "api" => 0, "network" => 0, "other" => 0 }
if first_time
stats["total_errors"] = stats.fetch("total_errors", 0) + 1
buckets[cat] = buckets.fetch(cat, 0) + 1
end
# Maintain a small rolling sample (not de-duped so users can see most recent context)
stats["errors"] << { account_id: account_id, name: name, message: msg, category: cat }
stats["errors"] = stats["errors"].last(5)
persist_stats!
end
def persist_stats!
return unless sync && sync.respond_to?(:sync_stats)
merged = (sync.sync_stats || {}).merge(stats)
sync.update_columns(sync_stats: merged) # avoid callbacks/validations during tight loops
end
def import_with_chunked_history
# SimpleFin's actual limit is 60 days (not 365 as documented)
# Use 60-day chunks to stay within limits
chunk_size_days = 60
max_requests = 22
current_end_date = Time.current
# Decide how far back to walk:
# - If the user set a custom sync_start_date, honor it
# - Else, for first-time chunked history, walk back up to the provider-safe
# limit implied by chunking so we actually import meaningful history.
# We do NOT use the small initial lookback (7 days) here, because that
# would clip the very first chunk to ~1 week and prevent further history.
user_start_date = simplefin_item.sync_start_date
implied_max_lookback_days = chunk_size_days * max_requests
default_start_date = implied_max_lookback_days.days.ago
target_start_date = user_start_date ? user_start_date.beginning_of_day : default_start_date
# Enforce maximum 3-year lookback to respect SimpleFin's actual 60-day limit per request
# With 22 requests max: 60 days × 22 = 1,320 days = 3.6 years, so 3 years is safe
max_lookback_date = 3.years.ago.beginning_of_day
if target_start_date < max_lookback_date
Rails.logger.info "SimpleFin: Limiting sync start date from #{target_start_date.strftime('%Y-%m-%d')} to #{max_lookback_date.strftime('%Y-%m-%d')} due to rate limits"
target_start_date = max_lookback_date
end
# Pre-step: Unbounded discovery to ensure we see all accounts even if the
# chunked window would otherwise filter out newly added, inactive accounts.
perform_account_discovery
total_accounts_imported = 0
chunk_count = 0
Rails.logger.info "SimpleFin chunked sync: syncing from #{target_start_date.strftime('%Y-%m-%d')} to #{current_end_date.strftime('%Y-%m-%d')}"
# Walk backwards from current_end_date in proper chunks
chunk_end_date = current_end_date
while chunk_count < max_requests && chunk_end_date > target_start_date
chunk_count += 1
# Calculate chunk start date - always use exactly chunk_size_days to stay within limits
chunk_start_date = chunk_end_date - chunk_size_days.days
# Don't go back further than the target start date
if chunk_start_date < target_start_date
chunk_start_date = target_start_date
end
# Verify we're within SimpleFin's limits
actual_days = (chunk_end_date.to_date - chunk_start_date.to_date).to_i
if actual_days > 365
Rails.logger.error "SimpleFin: Chunk exceeds 365 days (#{actual_days} days). This should not happen."
chunk_start_date = chunk_end_date - 365.days
end
Rails.logger.info "SimpleFin chunked sync: fetching chunk #{chunk_count}/#{max_requests} (#{chunk_start_date.strftime('%Y-%m-%d')} to #{chunk_end_date.strftime('%Y-%m-%d')}) - #{actual_days} days"
accounts_data = fetch_accounts_data(start_date: chunk_start_date, end_date: chunk_end_date)
return if accounts_data.nil? # Error already handled
# Store raw payload on first chunk only
if chunk_count == 1
simplefin_item.upsert_simplefin_snapshot!(accounts_data)
end
# Tally accounts returned for stats
chunk_accounts = accounts_data[:accounts]&.size.to_i
total_accounts_imported += chunk_accounts
# Treat total as max unique accounts seen this run, not per-chunk accumulation
stats["total_accounts"] = [ stats["total_accounts"].to_i, chunk_accounts ].max
# Import accounts and transactions for this chunk with per-account error skipping
accounts_data[:accounts]&.each do |account_data|
begin
import_account(account_data)
rescue => e
stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1
# Collect lightweight error info for UI stats
cat = classify_error(e)
begin
register_error(message: e.message.to_s, category: cat, account_id: account_data[:id], name: account_data[:name])
rescue
# no-op if account_data is missing keys
end
Rails.logger.warn("SimpleFin: Skipping account due to error: #{e.class} - #{e.message}")
ensure
persist_stats!
end
end
# Stop if we've reached our target start date
if chunk_start_date <= target_start_date
Rails.logger.info "SimpleFin chunked sync: reached target start date, stopping"
break
end
# Continue to next chunk - move the end date backwards
chunk_end_date = chunk_start_date
end
Rails.logger.info "SimpleFin chunked sync completed: #{chunk_count} chunks processed, #{total_accounts_imported} account records imported"
end
def import_regular_sync
perform_account_discovery
# Step 2: Fetch transactions/holdings using the regular window.
# Note: Don't pass explicit `pending:` here - let fetch_accounts_data use the
# SIMPLEFIN_INCLUDE_PENDING config. This allows users to disable pending transactions
# if their bank's SimpleFIN integration produces duplicates when pending→posted.
start_date = determine_sync_start_date
Rails.logger.info "SimplefinItem::Importer - import_regular_sync: last_synced_at=#{simplefin_item.last_synced_at&.strftime('%Y-%m-%d %H:%M')} => start_date=#{start_date&.strftime('%Y-%m-%d')}"
accounts_data = fetch_accounts_data(start_date: start_date)
return if accounts_data.nil? # Error already handled
# Store raw payload
simplefin_item.upsert_simplefin_snapshot!(accounts_data)
# Tally accounts for stats
count = accounts_data[:accounts]&.size.to_i
# Treat total as max unique accounts seen this run, not accumulation
stats["total_accounts"] = [ stats["total_accounts"].to_i, count ].max
# Import accounts (merges transactions/holdings into existing rows), skipping failures per-account
accounts_data[:accounts]&.each do |account_data|
begin
import_account(account_data)
rescue => e
stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1
cat = classify_error(e)
begin
register_error(message: e.message.to_s, category: cat, account_id: account_data[:id], name: account_data[:name])
rescue
# no-op if account_data is missing keys
end
Rails.logger.warn("SimpleFin: Skipping account during regular sync due to error: #{e.class} - #{e.message}")
ensure
persist_stats!
end
end
end
#
# Performs discovery of accounts in an unbounded way so providers that
# filter by date windows cannot hide newly created upstream accounts.
#
# Steps:
# - Request `/accounts` without dates; count results
# - If zero, retry with `pending: true` (some bridges only reveal new/pending)
# - If any accounts are returned, upsert a snapshot and import each account
#
# Returns nothing; side-effects are snapshot + account upserts.
def perform_account_discovery
Rails.logger.info "SimplefinItem::Importer - perform_account_discovery START (no date params - transactions may be empty)"
discovery_data = fetch_accounts_data(start_date: nil)
discovered_count = discovery_data&.dig(:accounts)&.size.to_i
Rails.logger.info "SimpleFin discovery (no params) returned #{discovered_count} accounts"
if discovered_count.zero?
discovery_data = fetch_accounts_data(start_date: nil, pending: true)
discovered_count = discovery_data&.dig(:accounts)&.size.to_i
Rails.logger.info "SimpleFin discovery (pending=1) returned #{discovered_count} accounts"
end
if discovery_data && discovered_count > 0
simplefin_item.upsert_simplefin_snapshot!(discovery_data)
# Treat total as max unique accounts seen this run, not accumulation
stats["total_accounts"] = [ stats["total_accounts"].to_i, discovered_count ].max
discovery_data[:accounts]&.each do |account_data|
begin
import_account(account_data)
rescue => e
stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1
cat = classify_error(e)
begin
register_error(message: e.message.to_s, category: cat, account_id: account_data[:id], name: account_data[:name])
rescue
# no-op if account_data is missing keys
end
Rails.logger.warn("SimpleFin discovery: Skipping account due to error: #{e.class} - #{e.message}")
ensure
persist_stats!
end
end
# Clean up orphaned SimplefinAccount records whose account_id no longer exists upstream.
# This handles the case where a user deletes and re-adds an institution in SimpleFIN,
# which generates new account IDs. Without this cleanup, both old (stale) and new
# SimplefinAccount records would appear in the setup UI as duplicates.
upstream_account_ids = discovery_data[:accounts].map { |a| a[:id].to_s }.compact
prune_orphaned_simplefin_accounts(upstream_account_ids)
end
end
# Removes SimplefinAccount records that no longer exist upstream and are not linked to any Account.
# This prevents duplicate accounts from appearing in the setup UI after a user re-adds an
# institution in SimpleFIN (which generates new account IDs).
def prune_orphaned_simplefin_accounts(upstream_account_ids)
return if upstream_account_ids.blank?
# Find SimplefinAccount records with account_ids NOT in the upstream set
# Eager-load associations to prevent N+1 queries when checking linkage
orphaned = simplefin_item.simplefin_accounts
.includes(:account, :account_provider)
.where.not(account_id: upstream_account_ids)
.where.not(account_id: nil)
orphaned.each do |sfa|
# Only delete if not linked to any Account (via legacy FK or AccountProvider)
# Note: sfa.account checks the legacy FK on Account.simplefin_account_id
# sfa.account_provider checks the new AccountProvider join table
linked_via_legacy = sfa.account.present?
linked_via_provider = sfa.account_provider.present?
if !linked_via_legacy && !linked_via_provider
Rails.logger.info "SimpleFin: Pruning orphaned SimplefinAccount id=#{sfa.id} account_id=#{sfa.account_id} (no longer exists upstream)"
stats["accounts_pruned"] = stats.fetch("accounts_pruned", 0) + 1
sfa.destroy
else
Rails.logger.info "SimpleFin: Keeping stale SimplefinAccount id=#{sfa.id} account_id=#{sfa.account_id} (still linked to Account)"
end
end
persist_stats! if stats["accounts_pruned"].to_i > 0
end
# Fetches accounts (and optionally transactions/holdings) from SimpleFin.
#
# Params:
# - start_date: Date or nil — when provided, provider may filter by date window
# - end_date: Date or nil — optional end of window
# - pending: Boolean or nil — when true, ask provider to include pending/new
#
# Returns a Hash payload with keys like :accounts, or nil when an error is
# handled internally via `handle_errors`.
def fetch_accounts_data(start_date:, end_date: nil, pending: nil)
# Determine whether to include pending based on explicit arg, env var, or Setting.
# Priority: explicit arg > env var > Setting (allows runtime changes via UI)
effective_pending = if !pending.nil?
pending
elsif ENV["SIMPLEFIN_INCLUDE_PENDING"].present?
Rails.configuration.x.simplefin.include_pending
else
Setting.syncs_include_pending
end
# Debug logging to track exactly what's being sent to SimpleFin API
start_str = start_date.respond_to?(:strftime) ? start_date.strftime("%Y-%m-%d") : "none"
end_str = end_date.respond_to?(:strftime) ? end_date.strftime("%Y-%m-%d") : "current"
days_requested = if start_date && end_date
(end_date.to_date - start_date.to_date).to_i
else
"unknown"
end
Rails.logger.info "SimplefinItem::Importer - API Request: #{start_str} to #{end_str} (#{days_requested} days) pending=#{effective_pending ? 1 : 0}"
begin
# Track API request count for quota awareness
stats["api_requests"] = stats.fetch("api_requests", 0) + 1
accounts_data = simplefin_provider.get_accounts(
simplefin_item.access_url,
start_date: start_date,
end_date: end_date,
pending: effective_pending
)
# Soft warning when approaching SimpleFin daily refresh guidance
if stats["api_requests"].to_i >= 20
stats["rate_limit_warning"] = true
end
rescue Provider::Simplefin::SimplefinError => e
# Handle authentication errors by marking item as requiring update
if e.error_type == :access_forbidden
simplefin_item.update!(status: :requires_update)
raise e
else
raise e
end
end
# Optional raw payload debug logging (guarded by ENV to avoid spam)
if Rails.configuration.x.simplefin.debug_raw
Rails.logger.debug("SimpleFIN raw: #{accounts_data.inspect}")
end
# Handle errors if present in response
if accounts_data[:errors] && accounts_data[:errors].any?
if accounts_data[:accounts].to_a.any?
# Partial failure: record errors for visibility but continue processing accounts
record_errors(accounts_data[:errors])
else
# Global failure: no accounts were returned; treat as fatal
handle_errors(accounts_data[:errors])
return nil
end
end
# Some servers return a top-level message/string rather than an errors array
if accounts_data[:error].present?
if accounts_data[:accounts].to_a.any?
record_errors([ accounts_data[:error] ])
else
handle_errors([ accounts_data[:error] ])
return nil
end
end
accounts_data
end
def determine_sync_start_date
# For the first sync, get only a limited amount of data to avoid SimpleFin API limits
# SimpleFin requires a start_date parameter - without it, only returns recent transactions
unless simplefin_item.last_synced_at
return initial_sync_lookback_period.days.ago
end
# For subsequent syncs, fetch from last sync date with a buffer
# Use buffer to ensure we don't miss any late-posting transactions
simplefin_item.last_synced_at - sync_buffer_period.days
end
def import_account(account_data)
account_id = account_data[:id].to_s
# Validate required account_id to prevent duplicate creation
return if account_id.blank?
simplefin_account = simplefin_item.simplefin_accounts.find_or_initialize_by(
account_id: account_id
)
# Store transactions and holdings separately from account data to avoid overwriting
transactions = account_data[:transactions]
holdings = account_data[:holdings]
# Log detailed info for accounts with holdings (investment accounts) to debug missing transactions
# Note: SimpleFIN doesn't include a 'type' field, so we detect investment accounts by presence of holdings or name
acct_name = account_data[:name].to_s.downcase
has_holdings = holdings.is_a?(Array) && holdings.any?
is_investment = has_holdings || acct_name.include?("ira") || acct_name.include?("401k") || acct_name.include?("retirement") || acct_name.include?("brokerage")
# Always log for all accounts to trace the import flow
Rails.logger.info "SimplefinItem::Importer#import_account - account_id=#{account_id} name='#{account_data[:name]}' txn_count=#{transactions&.count || 0} holdings_count=#{holdings&.count || 0}"
if is_investment
Rails.logger.info "SimpleFIN Investment Account Debug - account_id=#{account_id} name='#{account_data[:name]}'"
Rails.logger.info " - API response keys: #{account_data.keys.inspect}"
Rails.logger.info " - transactions count: #{transactions&.count || 0}"
Rails.logger.info " - holdings count: #{holdings&.count || 0}"
Rails.logger.info " - existing raw_transactions_payload count: #{simplefin_account.raw_transactions_payload.to_a.count}"
# Log transaction data
if transactions.is_a?(Array) && transactions.any?
Rails.logger.info " - Transaction IDs: #{transactions.map { |t| t[:id] || t["id"] }.inspect}"
else
Rails.logger.warn " - NO TRANSACTIONS in API response for investment account!"
# Log what the transactions field actually contains
Rails.logger.info " - transactions raw value: #{account_data[:transactions].inspect}"
end
end
# Update all attributes; only update transactions if present to avoid wiping prior data
attrs = {
name: account_data[:name],
account_type: (account_data["type"].presence || account_data[:type].presence || "unknown"),
currency: (account_data[:currency].presence || account_data["currency"].presence || simplefin_account.currency.presence || simplefin_account.current_account&.currency.presence || simplefin_item.family&.currency.presence || "USD"),
current_balance: account_data[:balance],
available_balance: account_data[:"available-balance"],
balance_date: (account_data["balance-date"] || account_data[:"balance-date"]),
raw_payload: account_data,
org_data: account_data[:org]
}
# Merge transactions from chunked/regular imports (accumulate history).
# Prefer non-pending records with a real posted timestamp over earlier
# pending placeholders that sometimes come back with posted: 0.
if transactions.is_a?(Array) && transactions.any?
existing_transactions = simplefin_account.raw_transactions_payload.to_a
Rails.logger.info "SimplefinItem::Importer#import_account - Merging transactions for account_id=#{account_id}: #{existing_transactions.count} existing + #{transactions.count} new"
# Build a map of key => best_tx
best_by_key = {}
comparator = lambda do |a, b|
ax = a.with_indifferent_access
bx = b.with_indifferent_access
# Key dates
a_posted = ax[:posted].to_i
b_posted = bx[:posted].to_i
a_trans = ax[:transacted_at].to_i
b_trans = bx[:transacted_at].to_i
a_pending = !!ax[:pending]
b_pending = !!bx[:pending]
# 1) Prefer real posted date over 0/blank
a_has_posted = a_posted > 0
b_has_posted = b_posted > 0
return a if a_has_posted && !b_has_posted
return b if b_has_posted && !a_has_posted
# 2) Prefer later posted date
if a_posted != b_posted
return a_posted > b_posted ? a : b
end
# 3) Prefer non-pending over pending
if a_pending != b_pending
return a_pending ? b : a
end
# 4) Prefer later transacted_at
if a_trans != b_trans
return a_trans > b_trans ? a : b
end
# 5) Stable: keep 'a'
a
end
build_key = lambda do |tx|
t = tx.with_indifferent_access
t[:id] || t[:fitid] || [ t[:posted], t[:amount], t[:description] ]
end
(existing_transactions + transactions).each do |tx|
key = build_key.call(tx)
if (cur = best_by_key[key])
best_by_key[key] = comparator.call(cur, tx)
else
best_by_key[key] = tx
end
end
merged_transactions = best_by_key.values
attrs[:raw_transactions_payload] = merged_transactions
Rails.logger.info "SimplefinItem::Importer#import_account - Merged result for account_id=#{account_id}: #{merged_transactions.count} total 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
else
Rails.logger.info "SimplefinItem::Importer#import_account - No transactions in API response for account_id=#{account_id} (transactions=#{transactions.inspect.first(100)})"
end
# Track whether incoming holdings are new/changed so we can materialize and refresh balances
holdings_changed = false
if holdings.is_a?(Array) && holdings.any?
prior = simplefin_account.raw_holdings_payload.to_a
if prior != holdings
attrs[:raw_holdings_payload] = holdings
# Also mirror into raw_payload['holdings'] so downstream calculators can use it
raw = simplefin_account.raw_payload.is_a?(Hash) ? simplefin_account.raw_payload.deep_dup : {}
raw = raw.with_indifferent_access
raw[:holdings] = holdings
attrs[:raw_payload] = raw
holdings_changed = true
end
end
simplefin_account.assign_attributes(attrs)
# Inactive detection/toggling (non-blocking)
begin
update_inactive_state(simplefin_account, account_data)
rescue => e
Rails.logger.warn("SimpleFin: inactive-state evaluation failed for sfa=#{simplefin_account.id || account_id}: #{e.class} - #{e.message}")
end
# Final validation before save to prevent duplicates
if simplefin_account.account_id.blank?
simplefin_account.account_id = account_id
end
begin
simplefin_account.save!
# Log final state after save for debugging
if is_investment
Rails.logger.info "SimplefinItem::Importer#import_account - SAVED account_id=#{account_id}: raw_transactions_payload now has #{simplefin_account.reload.raw_transactions_payload.to_a.count} transactions"
end
# Post-save side effects
acct = simplefin_account.current_account
if acct
# Handle pending transaction reconciliation (debounced per run to avoid
# repeated scans during chunked history imports)
unless @reconciled_account_ids.include?(acct.id)
@reconciled_account_ids << acct.id
reconcile_and_track_pending_duplicates(acct)
exclude_and_track_stale_pending(acct)
track_stale_unmatched_pending(acct)
end
# Refresh credit attributes when available-balance present
if acct.accountable_type == "CreditCard" && account_data[:"available-balance"].present?
begin
SimplefinAccount::Liabilities::CreditProcessor.new(simplefin_account).process
rescue => e
Rails.logger.warn("SimpleFin: credit post-import refresh failed for sfa=#{simplefin_account.id}: #{e.class} - #{e.message}")
end
end
# If holdings changed for an investment/crypto account, enqueue holdings apply job and recompute cash balance
if holdings_changed && [ "Investment", "Crypto" ].include?(acct.accountable_type)
# Debounce per importer run per SFA
unless @enqueued_holdings_job_ids.include?(simplefin_account.id)
SimplefinHoldingsApplyJob.perform_later(simplefin_account.id)
@enqueued_holdings_job_ids << simplefin_account.id
end
# Recompute cash balance using existing calculator; avoid altering canonical ledger balances
begin
calculator = SimplefinAccount::Investments::BalanceCalculator.new(simplefin_account)
new_cash = calculator.cash_balance
acct.update!(cash_balance: new_cash)
rescue => e
Rails.logger.warn("SimpleFin: cash balance recompute failed for sfa=#{simplefin_account.id}: #{e.class} - #{e.message}")
end
end
end
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
# Treat duplicates/validation failures as partial success: count and surface friendly error, then continue
stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1
msg = e.message.to_s
if msg.downcase.include?("already been taken") || msg.downcase.include?("unique")
msg = "Duplicate upstream account detected for SimpleFin (account_id=#{account_id}). Try relinking to an existing manual account."
end
register_error(message: msg, category: "other", account_id: account_id, name: account_data[:name])
persist_stats!
nil
ensure
# Ensure stats like zero_runs/inactive are persisted even when no errors occur,
# particularly helpful for focused unit tests that call import_account directly.
persist_stats!
end
end
# Record non-fatal provider errors into sync stats without raising, so the
# rest of the accounts can continue to import. This is used when the
# response contains both :accounts and :errors.
def record_errors(errors)
arr = Array(errors)
return if arr.empty?
# Determine if these errors indicate the item needs an update (e.g. 2FA)
needs_update = arr.any? do |error|
if error.is_a?(String)
down = error.downcase
down.include?("reauth") || down.include?("auth") || down.include?("two-factor") || down.include?("2fa") || down.include?("forbidden") || down.include?("unauthorized")
else
code = error[:code].to_s.downcase
type = error[:type].to_s.downcase
code.include?("auth") || code.include?("token") || type.include?("auth")
end
end
if needs_update
Rails.logger.warn("SimpleFin: marking item ##{simplefin_item.id} requires_update due to auth-related provider errors")
simplefin_item.update!(status: :requires_update)
ActiveSupport::Notifications.instrument(
"simplefin.item_requires_update",
item_id: simplefin_item.id,
reason: "provider_errors_partial",
count: arr.size
)
end
Rails.logger.info("SimpleFin: recording #{arr.size} non-fatal provider error(s) with partial data present")
ActiveSupport::Notifications.instrument(
"simplefin.provider_errors",
item_id: simplefin_item.id,
count: arr.size
)
arr.each do |error|
msg = if error.is_a?(String)
error
else
error[:description] || error[:message] || error[:error] || error.to_s
end
down = msg.to_s.downcase
category = if down.include?("timeout") || down.include?("timed out")
"network"
elsif down.include?("auth") || down.include?("reauth") || down.include?("forbidden") || down.include?("unauthorized") || down.include?("2fa") || down.include?("two-factor")
"auth"
elsif down.include?("429") || down.include?("rate limit")
"api"
else
"other"
end
register_error(message: msg, category: category)
end
end
def handle_errors(errors)
error_messages = errors.map { |error| error.is_a?(String) ? error : (error[:description] || error[:message]) }.join(", ")
# Mark item as requiring update for authentication-related errors
needs_update = errors.any? do |error|
if error.is_a?(String)
error.downcase.include?("reauthenticate") || error.downcase.include?("authentication")
else
error[:code] == "auth_failure" || error[:code] == "token_expired" ||
error[:type] == "authentication_error"
end
end
if needs_update
Rails.logger.warn("SimpleFin: marking item ##{simplefin_item.id} requires_update due to fatal auth error(s): #{error_messages}")
simplefin_item.update!(status: :requires_update)
end
down = error_messages.downcase
# Detect and surface rate-limit specifically with a friendlier exception
if down.include?("make fewer requests") ||
down.include?("only refreshed once every 24 hours") ||
down.include?("rate limit")
Rails.logger.info("SimpleFin: raising RateLimitedError for item ##{simplefin_item.id}: #{error_messages}")
ActiveSupport::Notifications.instrument(
"simplefin.rate_limited",
item_id: simplefin_item.id,
message: error_messages
)
raise RateLimitedError, "SimpleFin rate limit: data refreshes at most once every 24 hours. Try again later."
end
# Fall back to generic SimpleFin error classified as :api_error
Rails.logger.error("SimpleFin fatal API error for item ##{simplefin_item.id}: #{error_messages}")
ActiveSupport::Notifications.instrument(
"simplefin.fatal_error",
item_id: simplefin_item.id,
message: error_messages
)
raise Provider::Simplefin::SimplefinError.new(
"SimpleFin API errors: #{error_messages}",
:api_error
)
end
# Classify exceptions into simple buckets for UI stats
def classify_error(e)
msg = e.message.to_s.downcase
klass = e.class.name.to_s
# Avoid referencing Net::OpenTimeout/ReadTimeout constants (may not be loaded)
is_timeout = msg.include?("timeout") || msg.include?("timed out") || klass.include?("Timeout")
case
when is_timeout
"network"
when msg.include?("auth") || msg.include?("reauth") || msg.include?("forbidden") || msg.include?("unauthorized")
"auth"
when msg.include?("429") || msg.include?("too many requests") || msg.include?("rate limit") || msg.include?("5xx") || msg.include?("502") || msg.include?("503") || msg.include?("504")
"api"
else
"other"
end
end
def initial_sync_lookback_period
# Default to 60 days for initial sync to capture recent investment
# transactions (dividends, contributions, etc.). Providers that support
# deeper history will supply it via chunked fetches, and users can
# optionally set a custom `sync_start_date` to go further back.
60
end
def sync_buffer_period
# Default to 30 days buffer for subsequent syncs
# Investment accounts often have infrequent transactions (dividends, etc.)
# that would be missed with a shorter window
30
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
# Reconcile pending transactions that have a matching posted version
# Handles duplicates where pending and posted both exist (tip adjustments, etc.)
def reconcile_and_track_pending_duplicates(account)
reconcile_stats = Entry.reconcile_pending_duplicates(account: account, dry_run: false)
exact_matches = reconcile_stats[:details].select { |d| d[:match_type] == "exact" }
fuzzy_suggestions = reconcile_stats[:details].select { |d| d[:match_type] == "fuzzy_suggestion" }
if exact_matches.any?
stats["pending_reconciled"] = stats.fetch("pending_reconciled", 0) + exact_matches.size
stats["pending_reconciled_details"] ||= []
exact_matches.each do |detail|
stats["pending_reconciled_details"] << {
"account_name" => detail[:account],
"pending_name" => detail[:pending_name],
"posted_name" => detail[:posted_name]
}
end
stats["pending_reconciled_details"] = stats["pending_reconciled_details"].last(50)
end
if fuzzy_suggestions.any?
stats["duplicate_suggestions_created"] = stats.fetch("duplicate_suggestions_created", 0) + fuzzy_suggestions.size
stats["duplicate_suggestions_details"] ||= []
fuzzy_suggestions.each do |detail|
stats["duplicate_suggestions_details"] << {
"account_name" => detail[:account],
"pending_name" => detail[:pending_name],
"posted_name" => detail[:posted_name]
}
end
stats["duplicate_suggestions_details"] = stats["duplicate_suggestions_details"].last(50)
end
rescue => e
Rails.logger.warn("SimpleFin: pending reconciliation failed for account #{account.id}: #{e.class} - #{e.message}")
record_reconciliation_error("pending_reconciliation", account, e)
end
# Auto-exclude stale pending transactions (>8 days old with no matching posted version)
# Prevents orphaned pending transactions from affecting budgets indefinitely
def exclude_and_track_stale_pending(account)
excluded_count = Entry.auto_exclude_stale_pending(account: account)
return unless excluded_count > 0
stats["stale_pending_excluded"] = stats.fetch("stale_pending_excluded", 0) + excluded_count
stats["stale_pending_details"] ||= []
stats["stale_pending_details"] << {
"account_name" => account.name,
"account_id" => account.id,
"count" => excluded_count
}
stats["stale_pending_details"] = stats["stale_pending_details"].last(50)
rescue => e
Rails.logger.warn("SimpleFin: stale pending cleanup failed for account #{account.id}: #{e.class} - #{e.message}")
record_reconciliation_error("stale_pending_cleanup", account, e)
end
# Track stale pending transactions that couldn't be matched (for user awareness)
# These are >8 days old, still pending, and have no duplicate suggestion
def track_stale_unmatched_pending(account)
stale_unmatched = account.entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(excluded: false)
.where("entries.date < ?", 8.days.ago.to_date)
.where(<<~SQL.squish)
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
SQL
.where(<<~SQL.squish)
transactions.extra -> 'potential_posted_match' IS NULL
SQL
.count
return unless stale_unmatched > 0
stats["stale_unmatched_pending"] = stats.fetch("stale_unmatched_pending", 0) + stale_unmatched
stats["stale_unmatched_details"] ||= []
stats["stale_unmatched_details"] << {
"account_name" => account.name,
"account_id" => account.id,
"count" => stale_unmatched
}
stats["stale_unmatched_details"] = stats["stale_unmatched_details"].last(50)
rescue => e
Rails.logger.warn("SimpleFin: stale unmatched tracking failed for account #{account.id}: #{e.class} - #{e.message}")
record_reconciliation_error("stale_unmatched_tracking", account, e)
end
# Record reconciliation errors to sync_stats for UI visibility
def record_reconciliation_error(context, account, error)
stats["reconciliation_errors"] ||= []
stats["reconciliation_errors"] << {
"context" => context,
"account_id" => account.id,
"account_name" => account.name,
"error" => "#{error.class}: #{error.message}"
}
stats["reconciliation_errors"] = stats["reconciliation_errors"].last(20)
end
end