mirror of
https://github.com/we-promise/sure.git
synced 2026-09-05 14:51:15 +00:00
* fix(simplefin): stop repair_stale_linkages from hijacking a live linkage on a same-name twin repair_stale_linkages matched purely on case-insensitive display name, so two distinct upstream accounts sharing a name (e.g. two "CHECKING (0001)" accounts at the same institution) caused the unlinked twin to silently steal the linked account's AccountProvider, merge in its transactions, and overwrite its balance on every subsequent sync. Thread the upstream account_id set already computed during account discovery through to repair_stale_linkages so it only treats a linked account as stale when its account_id is actually absent upstream, and skip ambiguous multi-way name matches instead of picking the first one. Fixes #2852 * fix(simplefin): clear stale upstream_account_ids and log skipped repairs - Clear simplefin_item.upstream_account_ids at the start of each perform_account_discovery run so a later discovery that finds zero accounts can't reuse IDs from a prior run on the same SimplefinItem instance (CodeRabbit review finding). - Capture skipped stale-linkage repairs via DebugLogEntry so operators can see them in /settings/debug, not just the raw Rails log (Codex review finding). - Fix two pre-existing SimplefinAccount::Transactions::ProcessorInvestmentTest tests broken by the new upstream_account_ids nil-guard: they called process_accounts directly without going through the Importer, so they now set upstream_account_ids explicitly to simulate a legitimate "old account_id genuinely absent upstream" repair. - Add regression coverage for both fixes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Gerald <248542187+gfr-free@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
529 lines
20 KiB
Ruby
529 lines
20 KiB
Ruby
class SimplefinItem < ApplicationRecord
|
|
include Syncable, Provided, Encryptable
|
|
include SimplefinItem::Unlinking
|
|
|
|
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
|
|
|
# Virtual attribute for the setup token form field
|
|
attr_accessor :setup_token
|
|
|
|
# Transient, in-memory only. Set by SimplefinItem::Importer#perform_account_discovery
|
|
# to the set of account_ids returned by the current sync's unbounded discovery call.
|
|
# Used by #repair_stale_linkages to verify a "stale" linked account is actually absent
|
|
# upstream, rather than trusting a name match alone. Never persisted.
|
|
attr_accessor :upstream_account_ids
|
|
|
|
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
|
|
if encryption_ready?
|
|
encrypts :access_url, deterministic: true
|
|
encrypts :raw_payload
|
|
encrypts :raw_institution_payload
|
|
end
|
|
|
|
validates :name, presence: true
|
|
validates :access_url, presence: true, on: :create
|
|
|
|
before_destroy :remove_simplefin_item
|
|
|
|
belongs_to :family
|
|
has_one_attached :logo, dependent: :purge_later
|
|
|
|
has_many :simplefin_accounts, dependent: :destroy
|
|
has_many :legacy_accounts, through: :simplefin_accounts, source: :account
|
|
|
|
scope :active, -> { where(scheduled_for_deletion: false) }
|
|
scope :syncable, -> { active }
|
|
scope :ordered, -> { order(created_at: :desc) }
|
|
scope :needs_update, -> { where(status: :requires_update) }
|
|
|
|
# Get accounts from both new and legacy systems
|
|
def accounts
|
|
@accounts ||= simplefin_accounts
|
|
.includes(:account, account_provider: :account)
|
|
.map(&:current_account)
|
|
.compact
|
|
.uniq
|
|
end
|
|
|
|
def destroy_later
|
|
update!(scheduled_for_deletion: true)
|
|
DestroyJob.perform_later(self)
|
|
end
|
|
|
|
def import_latest_simplefin_data(sync: nil)
|
|
SimplefinItem::Importer.new(self, simplefin_provider: simplefin_provider, sync: sync).import
|
|
end
|
|
|
|
# Update the access_url by claiming a new setup token.
|
|
# This is used when reconnecting an existing SimpleFIN connection.
|
|
# Unlike create_simplefin_item!, this updates in-place, preserving all account linkages.
|
|
def update_access_url!(setup_token:)
|
|
new_access_url = simplefin_provider.claim_access_url(setup_token)
|
|
|
|
update!(
|
|
access_url: new_access_url,
|
|
status: :good
|
|
)
|
|
|
|
self
|
|
end
|
|
|
|
def process_accounts
|
|
# Process accounts linked via BOTH legacy FK and AccountProvider
|
|
# Use direct query to ensure fresh data from DB, bypassing any association cache
|
|
all_accounts = SimplefinAccount.where(simplefin_item_id: id).includes(:account, :linked_account, account_provider: :account).to_a
|
|
|
|
Rails.logger.info "=" * 60
|
|
Rails.logger.info "SimplefinItem#process_accounts START - Item #{id} (#{name})"
|
|
Rails.logger.info " Total SimplefinAccounts: #{all_accounts.count}"
|
|
|
|
# Log all accounts for debugging
|
|
all_accounts.each do |sfa|
|
|
acct = sfa.current_account
|
|
Rails.logger.info " - SimplefinAccount id=#{sfa.id} sf_account_id=#{sfa.account_id} name='#{sfa.name}'"
|
|
Rails.logger.info " linked_account: #{sfa.linked_account&.id || 'nil'}, account: #{sfa.account&.id || 'nil'}, current_account: #{acct&.id || 'nil'}"
|
|
Rails.logger.info " raw_transactions_payload count: #{sfa.raw_transactions_payload.to_a.count}"
|
|
end
|
|
|
|
# First, try to repair stale linkages (old SimplefinAccount linked but new one has data)
|
|
repair_stale_linkages(all_accounts)
|
|
|
|
# Re-fetch after repairs - use direct query for fresh data
|
|
all_accounts = SimplefinAccount.where(simplefin_item_id: id).includes(:account, :linked_account, account_provider: :account).to_a
|
|
|
|
linked = all_accounts.select { |sfa| sfa.current_account.present? }
|
|
unlinked = all_accounts.reject { |sfa| sfa.current_account.present? }
|
|
|
|
Rails.logger.info "SimplefinItem#process_accounts - After repair: #{linked.count} linked, #{unlinked.count} unlinked"
|
|
|
|
# Log unlinked accounts with transactions for debugging
|
|
unlinked_with_txns = unlinked.select { |sfa| sfa.raw_transactions_payload.to_a.any? }
|
|
if unlinked_with_txns.any?
|
|
Rails.logger.warn "SimplefinItem#process_accounts - #{unlinked_with_txns.count} UNLINKED account(s) have transactions that won't be processed:"
|
|
unlinked_with_txns.each do |sfa|
|
|
Rails.logger.warn " - SimplefinAccount id=#{sfa.id} name='#{sfa.name}' sf_account_id=#{sfa.account_id} txn_count=#{sfa.raw_transactions_payload.to_a.count}"
|
|
end
|
|
end
|
|
|
|
all_skipped_entries = []
|
|
|
|
linked.each do |simplefin_account|
|
|
acct = simplefin_account.current_account
|
|
Rails.logger.info "SimplefinItem#process_accounts - Processing: SimplefinAccount id=#{simplefin_account.id} name='#{simplefin_account.name}' -> Account id=#{acct.id} name='#{acct.name}' type=#{acct.accountable_type}"
|
|
processor = SimplefinAccount::Processor.new(simplefin_account)
|
|
processor.process
|
|
all_skipped_entries.concat(processor.skipped_entries)
|
|
end
|
|
|
|
Rails.logger.info "SimplefinItem#process_accounts END - #{all_skipped_entries.size} entries skipped (protected)"
|
|
Rails.logger.info "=" * 60
|
|
|
|
all_skipped_entries
|
|
end
|
|
|
|
# Repairs stale linkages when user re-adds institution in SimpleFIN.
|
|
# When a user deletes and re-adds an institution in SimpleFIN, new account IDs are generated.
|
|
# This causes old SimplefinAccounts to remain "linked" but stale (no new data),
|
|
# while new SimplefinAccounts have data but are unlinked.
|
|
# This method detects such cases and transfers the linkage from old to new.
|
|
def repair_stale_linkages(all_accounts)
|
|
linked = all_accounts.select { |sfa| sfa.current_account.present? }
|
|
unlinked = all_accounts.reject { |sfa| sfa.current_account.present? }
|
|
|
|
Rails.logger.info "SimplefinItem#repair_stale_linkages - #{linked.count} linked, #{unlinked.count} unlinked SimplefinAccounts"
|
|
|
|
# Find unlinked accounts that have transactions
|
|
unlinked_with_data = unlinked.select { |sfa| sfa.raw_transactions_payload.to_a.any? }
|
|
|
|
if unlinked_with_data.any?
|
|
Rails.logger.info "SimplefinItem#repair_stale_linkages - Found #{unlinked_with_data.count} unlinked SimplefinAccount(s) with transactions:"
|
|
unlinked_with_data.each do |sfa|
|
|
Rails.logger.info " - id=#{sfa.id} name='#{sfa.name}' account_id=#{sfa.account_id} txn_count=#{sfa.raw_transactions_payload.to_a.count}"
|
|
end
|
|
end
|
|
|
|
return if unlinked_with_data.empty?
|
|
|
|
# Without a current picture of what the provider actually returned this sync, a
|
|
# name match alone cannot distinguish a genuinely stale account (re-added institution,
|
|
# new account_id) from two distinct upstream accounts that merely share a display name.
|
|
# Refuse to act rather than risk hijacking a live linkage. See GH issue #2852.
|
|
if upstream_account_ids.nil?
|
|
Rails.logger.warn "SimplefinItem#repair_stale_linkages - upstream_account_ids unavailable, skipping repair to avoid hijacking a live linkage"
|
|
DebugLogEntry.capture(
|
|
category: "provider_sync_warning",
|
|
level: "warn",
|
|
message: "Skipped stale-linkage repair: upstream_account_ids unavailable",
|
|
source: self.class.name,
|
|
provider_key: "simplefin",
|
|
family: family,
|
|
metadata: { simplefin_item_id: id, unlinked_with_data_count: unlinked_with_data.count }
|
|
)
|
|
return
|
|
end
|
|
|
|
# For each unlinked account with data, try to find a matching linked account
|
|
unlinked_with_data.each do |new_sfa|
|
|
# Find linked SimplefinAccount with same name (case-insensitive) that is ALSO
|
|
# actually absent from the current upstream response. A name match alone is not
|
|
# enough: two distinct upstream accounts can legitimately share a display name.
|
|
stale_matches = linked.select do |old_sfa|
|
|
old_sfa.name.to_s.downcase.strip == new_sfa.name.to_s.downcase.strip &&
|
|
old_sfa.account_id.present? &&
|
|
upstream_account_ids.exclude?(old_sfa.account_id.to_s)
|
|
end
|
|
|
|
if stale_matches.size > 1
|
|
Rails.logger.warn "SimplefinItem#repair_stale_linkages - Multiple stale linked accounts match '#{new_sfa.name}': #{stale_matches.map(&:id).join(', ')}. Ambiguous, skipping repair."
|
|
next
|
|
end
|
|
|
|
stale_match = stale_matches.first
|
|
next unless stale_match
|
|
|
|
account = stale_match.current_account
|
|
Rails.logger.info "SimplefinItem#repair_stale_linkages - Found matching accounts:"
|
|
Rails.logger.info " - OLD: SimplefinAccount id=#{stale_match.id} account_id=#{stale_match.account_id} txn_count=#{stale_match.raw_transactions_payload.to_a.count}"
|
|
Rails.logger.info " - NEW: SimplefinAccount id=#{new_sfa.id} account_id=#{new_sfa.account_id} txn_count=#{new_sfa.raw_transactions_payload.to_a.count}"
|
|
Rails.logger.info " - Linked to Account: '#{account.name}' (id=#{account.id})"
|
|
|
|
# Transfer the linkage from old to new
|
|
begin
|
|
# Merge transactions from old to new before transferring
|
|
old_transactions = stale_match.raw_transactions_payload.to_a
|
|
new_transactions = new_sfa.raw_transactions_payload.to_a
|
|
if old_transactions.any?
|
|
Rails.logger.info "SimplefinItem#repair_stale_linkages - Merging #{old_transactions.count} transactions from old SimplefinAccount"
|
|
merged = merge_transactions(old_transactions, new_transactions)
|
|
new_sfa.update!(raw_transactions_payload: merged)
|
|
end
|
|
|
|
# Check if linked via legacy FK (use to_s for UUID comparison safety)
|
|
if account.simplefin_account_id.to_s == stale_match.id.to_s
|
|
account.simplefin_account_id = new_sfa.id
|
|
account.save!
|
|
end
|
|
|
|
# Check if linked via AccountProvider
|
|
if stale_match.account_provider.present?
|
|
Rails.logger.info "SimplefinItem#repair_stale_linkages - Transferring AccountProvider linkage from SimplefinAccount #{stale_match.id} to #{new_sfa.id}"
|
|
stale_match.account_provider.update!(provider: new_sfa)
|
|
end
|
|
|
|
# If the new one doesn't have an AccountProvider yet, create one
|
|
new_sfa.ensure_account_provider!
|
|
|
|
Rails.logger.info "SimplefinItem#repair_stale_linkages - Successfully transferred linkage for Account '#{account.name}' to SimplefinAccount id=#{new_sfa.id}"
|
|
|
|
# Clear transactions from stale SimplefinAccount and leave it orphaned
|
|
# We don't destroy it because has_one :account, dependent: :nullify would nullify the FK we just set
|
|
# IMPORTANT: Use update_all to bypass AR associations - stale_match.update! would
|
|
# trigger autosave on the preloaded account association, reverting the FK we just set!
|
|
SimplefinAccount.where(id: stale_match.id).update_all(raw_transactions_payload: [], raw_holdings_payload: [])
|
|
Rails.logger.info "SimplefinItem#repair_stale_linkages - Cleared data from stale SimplefinAccount id=#{stale_match.id} (leaving orphaned)"
|
|
rescue => e
|
|
Rails.logger.error "SimplefinItem#repair_stale_linkages - Failed to transfer linkage: #{e.class} - #{e.message}"
|
|
Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace
|
|
end
|
|
end
|
|
end
|
|
|
|
# Merge two arrays of transactions, deduplicating by ID.
|
|
# Fallback: uses composite key [posted, amount, description] when ID/fitid missing.
|
|
#
|
|
# Known edge cases with composite key fallback:
|
|
# 1. False positives: Two distinct transactions with identical posted/amount/description
|
|
# will be incorrectly merged (rare but possible).
|
|
# 2. Type inconsistency: If posted varies in type (String vs Integer), keys won't match.
|
|
# 3. Description variations: Minor differences (whitespace, case) prevent matching.
|
|
#
|
|
# SimpleFIN typically provides transaction IDs, so this fallback is rarely needed.
|
|
def merge_transactions(old_txns, new_txns)
|
|
by_id = {}
|
|
|
|
# Add old transactions first
|
|
old_txns.each do |tx|
|
|
t = tx.with_indifferent_access
|
|
key = t[:id] || t[:fitid] || [ t[:posted], t[:amount], t[:description] ]
|
|
by_id[key] = tx
|
|
end
|
|
|
|
# Add new transactions (overwrite old with same ID)
|
|
new_txns.each do |tx|
|
|
t = tx.with_indifferent_access
|
|
key = t[:id] || t[:fitid] || [ t[:posted], t[:amount], t[:description] ]
|
|
by_id[key] = tx
|
|
end
|
|
|
|
by_id.values
|
|
end
|
|
|
|
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
|
accounts.each do |account|
|
|
account.sync_later(
|
|
parent_sync: parent_sync,
|
|
window_start_date: window_start_date,
|
|
window_end_date: window_end_date
|
|
)
|
|
end
|
|
end
|
|
|
|
def upsert_simplefin_snapshot!(accounts_snapshot)
|
|
assign_attributes(
|
|
raw_payload: accounts_snapshot,
|
|
)
|
|
|
|
# Do not populate item-level institution fields from account data.
|
|
# Institution metadata belongs to each simplefin_account (in org_data).
|
|
|
|
save!
|
|
end
|
|
|
|
def upsert_institution_data!(org_data)
|
|
org = org_data.to_h.with_indifferent_access
|
|
url = org[:url] || org[:"sfin-url"]
|
|
domain = org[:domain]
|
|
|
|
# Derive domain from URL if missing
|
|
if domain.blank? && url.present?
|
|
begin
|
|
domain = URI.parse(url).host&.gsub(/^www\./, "")
|
|
rescue URI::InvalidURIError
|
|
Rails.logger.warn("Invalid SimpleFin institution URL: #{url.inspect}")
|
|
end
|
|
end
|
|
|
|
assign_attributes(
|
|
institution_id: org[:id],
|
|
institution_name: org[:name],
|
|
institution_domain: domain,
|
|
institution_url: url,
|
|
raw_institution_payload: org_data
|
|
)
|
|
end
|
|
|
|
|
|
def has_completed_initial_setup?
|
|
# Setup is complete if we have any linked accounts
|
|
accounts.any?
|
|
end
|
|
|
|
def sync_status_summary
|
|
latest = latest_sync
|
|
return nil unless latest
|
|
|
|
# If sync has statistics, use them
|
|
stats = parse_sync_stats(latest.sync_stats)
|
|
if stats.present?
|
|
total = stats["total_accounts"] || 0
|
|
linked = stats["linked_accounts"] || 0
|
|
unlinked = stats["unlinked_accounts"] || 0
|
|
|
|
if total == 0
|
|
"No accounts found"
|
|
elsif unlinked == 0
|
|
"#{linked} #{'account'.pluralize(linked)} synced"
|
|
else
|
|
"#{linked} synced, #{unlinked} need setup"
|
|
end
|
|
else
|
|
# Fallback to current account counts
|
|
total_accounts = simplefin_accounts.count
|
|
linked_count = accounts.count
|
|
unlinked_count = total_accounts - linked_count
|
|
|
|
if total_accounts == 0
|
|
"No accounts found"
|
|
elsif unlinked_count == 0
|
|
"#{linked_count} #{'account'.pluralize(linked_count)} synced"
|
|
else
|
|
"#{linked_count} synced, #{unlinked_count} need setup"
|
|
end
|
|
end
|
|
end
|
|
|
|
def institution_display_name
|
|
# Try to get institution name from stored metadata
|
|
institution_name.presence || institution_domain.presence || name
|
|
end
|
|
|
|
def connected_institutions
|
|
# Get unique institutions from all accounts
|
|
simplefin_accounts.includes(:account)
|
|
.where.not(org_data: nil)
|
|
.map { |acc| acc.org_data }
|
|
.uniq { |org| org["domain"] || org["name"] }
|
|
end
|
|
|
|
def institution_summary
|
|
institutions = connected_institutions
|
|
case institutions.count
|
|
when 0
|
|
"No institutions connected"
|
|
when 1
|
|
institutions.first["name"] || institutions.first["domain"] || "1 institution"
|
|
else
|
|
"#{institutions.count} institutions"
|
|
end
|
|
end
|
|
|
|
|
|
|
|
# Detect a recent rate-limited sync and return a friendly message, else nil
|
|
def rate_limited_message
|
|
latest = latest_sync
|
|
return nil unless latest
|
|
|
|
# Some Sync records may not have a status_text column; guard with respond_to?
|
|
parts = []
|
|
parts << latest.error if latest.respond_to?(:error)
|
|
parts << latest.status_text if latest.respond_to?(:status_text)
|
|
msg = parts.compact.join(" — ")
|
|
return nil if msg.blank?
|
|
|
|
down = msg.downcase
|
|
if down.include?("make fewer requests") || down.include?("only refreshed once every 24 hours") || down.include?("rate limit")
|
|
"You've hit SimpleFin's daily refresh limit. Please try again after the bridge refreshes (up to 24 hours)."
|
|
else
|
|
nil
|
|
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?
|
|
setup_token_update_required? || stale_sync_status[:stale] || pending_account_setup?
|
|
end
|
|
|
|
# Get a summary of issues requiring attention
|
|
def attention_summary
|
|
issues = []
|
|
issues << "Connection needs update" if setup_token_update_required?
|
|
issues << stale_sync_status[:message] if stale_sync_status[:stale]
|
|
issues << "Accounts need setup" if pending_account_setup?
|
|
issues
|
|
end
|
|
|
|
# A SimpleFIN setup token is only needed when the bridge access URL appears
|
|
# unusable. If the latest sync returned accounts, the access URL still works;
|
|
# any auth warning in that response belongs to an individual institution.
|
|
def setup_token_update_required?(latest_sync: nil)
|
|
return false unless requires_update?
|
|
|
|
latest = latest_sync || syncs.ordered.first
|
|
return true unless latest
|
|
return false if latest.in_progress?
|
|
|
|
stats = parse_sync_stats(latest.sync_stats).to_h.stringify_keys
|
|
stats["total_accounts"].to_i.zero?
|
|
end
|
|
|
|
def effective_status(latest_sync: nil)
|
|
setup_token_update_required?(latest_sync: latest_sync) ? status : "good"
|
|
end
|
|
|
|
# Get reconciled duplicates count from the last sync
|
|
# Returns { count: N, message: "..." } or { count: 0 } if none
|
|
def last_sync_reconciled_status
|
|
latest_sync = syncs.ordered.first
|
|
return { count: 0 } unless latest_sync
|
|
|
|
stats = parse_sync_stats(latest_sync.sync_stats)
|
|
count = stats&.dig("pending_reconciled").to_i
|
|
if count > 0
|
|
{
|
|
count: count,
|
|
message: I18n.t("simplefin_items.reconciled_status.message", count: count)
|
|
}
|
|
else
|
|
{ count: 0 }
|
|
end
|
|
end
|
|
|
|
# Count stale pending transactions (>8 days old) across all linked accounts
|
|
# Returns { count: N, accounts: [names] } or { count: 0 } if none
|
|
def stale_pending_status(days: 8)
|
|
# Get all accounts linked to this SimpleFIN item
|
|
# Eager-load both association paths to avoid N+1 on current_account method
|
|
linked_accounts = simplefin_accounts.includes(:account, :linked_account).filter_map(&:current_account)
|
|
return { count: 0 } if linked_accounts.empty?
|
|
|
|
# Batch query to avoid N+1
|
|
account_ids = linked_accounts.map(&:id)
|
|
counts_by_account = Entry.stale_pending(days: days)
|
|
.where(excluded: false)
|
|
.where(account_id: account_ids)
|
|
.group(:account_id)
|
|
.count
|
|
|
|
account_counts = linked_accounts
|
|
.map { |account| { account: account, count: counts_by_account[account.id].to_i } }
|
|
.select { |ac| ac[:count] > 0 }
|
|
|
|
total = account_counts.sum { |ac| ac[:count] }
|
|
if total > 0
|
|
{
|
|
count: total,
|
|
accounts: account_counts.map { |ac| ac[:account].name },
|
|
message: I18n.t("simplefin_items.stale_pending_status.message", count: total, days: days)
|
|
}
|
|
else
|
|
{ count: 0 }
|
|
end
|
|
end
|
|
|
|
private
|
|
# Parse sync_stats, handling cases where it might be a raw JSON string
|
|
# (e.g., from console testing or bypassed serialization)
|
|
def parse_sync_stats(sync_stats)
|
|
return nil if sync_stats.blank?
|
|
return sync_stats if sync_stats.is_a?(Hash)
|
|
|
|
if sync_stats.is_a?(String)
|
|
JSON.parse(sync_stats) rescue nil
|
|
end
|
|
end
|
|
|
|
def remove_simplefin_item
|
|
# SimpleFin doesn't require server-side cleanup like Plaid
|
|
# The access URL just becomes inactive
|
|
end
|
|
end
|