mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 08:32:15 +00:00
* add redbark provider integration - per family api key provider, built like the lunchflow integration - syncs accounts, balances and transactions from api.redbark.com - account setup flow, settings panel, locales and routes - tests and fixtures * harden redbark integration based on prior provider pr feedback - use DebugLogEntry.capture for sync/import/unlink failures - retry 429s and 5xxs with backoff, raise on page cap instead of truncating - keep raw response bodies out of logs and errors - not null constraints on account columns, migration base 7.2 - persist ignored flag for skipped accounts so they stop nagging setup - validate api key on every save, re-arm status on key rotation - destroy aborts if unlink fails, atomic account create and link - require_admin on mutating actions, see_other on error redirects - single grouped query for item account counts - i18n default connection name, blank password field value - controller and provider tests * fix issues found in second review sweep - add missing syncable scope, without it every family sync raises - kick off a sync on connection create and on key rotation - setup dialog fetches accounts inline for fresh connections and shows api errors - skip balance write when no balance has been fetched yet, never anchor a false zero - exclude stale and non banking accounts from the batched balances call, per account fallback if the batch is rejected - detect the server row ceiling and empty pages instead of silently truncating history - user sync start date only governs the initial backfill, incremental after that - fetch connections before the per account loop so auth errors propagate once - drop untemplated index/show/new/edit routes and dead preload/link_accounts actions - stable dom id on the settings panel so repeat turbo replaces keep working * skip brokerage connections, found in live testing - the transactions endpoint 400s for brokerage connections, they belong to /v1/trades - only import accounts from banking and documents connections - guard transaction fetches for any legacy linked non banking account * address review feedback - treat the truncation header as a pagination signal: split the date window and refetch instead of failing the account - prune stale pending rows from the snapshot so settled pendings cant come back as duplicates - block linking a sure account that already has another provider feed - count setup failures separately from skips and surface an error instead of "all skipped" - add not nulls on redbark_items name and api key - enqueue the destroy job after the flag commits, not inside the transaction - swap bg-gray-400 for bg-surface-inset, drop amounts from info logs, remove i18n default fallbacks - tests for window splitting, pending pruning and encrypted payload round trip * fix issues from convention review - benign skips (unlinked account, blank id, unparseable rows) no longer count as failures, tracked separately so a clean batch reports success - currency parsing goes through extract_currency so hash shaped payloads resolve instead of falling to the default - merchant ids use truncated sha256 instead of md5 - debug log entries for import failures and account sync scheduling failures * bound the raw transactions snapshot to the fetch window - trim raw_transactions_payload to the current fetch window on merge, same as brex - keep rows without a parseable date, drop settled pendings as before - surface skipped rows in the aggregate debug log entry with imported/skipped counts
357 lines
13 KiB
Ruby
357 lines
13 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class RedbarkItem::Importer
|
|
include SyncStats::Collector
|
|
include RedbarkAccount::DataHelpers
|
|
include CurrencyNormalizable
|
|
|
|
attr_reader :redbark_item, :redbark_provider, :sync
|
|
|
|
def initialize(redbark_item, redbark_provider:, sync: nil)
|
|
@redbark_item = redbark_item
|
|
@redbark_provider = redbark_provider
|
|
@sync = sync
|
|
end
|
|
|
|
def import
|
|
Rails.logger.info "RedbarkItem::Importer - Starting import for item #{redbark_item.id}"
|
|
|
|
# Step 1: Fetch and store all accounts (with connection metadata for institutions)
|
|
import_accounts
|
|
|
|
# Step 2: For linked accounts only, fetch transactions and balances.
|
|
# Unlinked accounts just need basic info (name, institution) for the setup modal.
|
|
linked_accounts = redbark_item.linked_redbark_accounts.to_a
|
|
|
|
Rails.logger.info "RedbarkItem::Importer - Found #{linked_accounts.count} linked accounts to process"
|
|
|
|
linked_accounts.each do |redbark_account|
|
|
import_transactions(redbark_account)
|
|
end
|
|
|
|
import_balances(linked_accounts)
|
|
|
|
# Store import stats on the item as the raw snapshot
|
|
redbark_item.upsert_redbark_snapshot!(stats)
|
|
|
|
stats
|
|
rescue Provider::Redbark::AuthenticationError
|
|
redbark_item.update!(status: :requires_update)
|
|
raise
|
|
end
|
|
|
|
private
|
|
|
|
def stats
|
|
@stats ||= {}
|
|
end
|
|
|
|
def persist_stats!
|
|
return unless sync&.respond_to?(:sync_stats)
|
|
merged = (sync.sync_stats || {}).merge(stats)
|
|
sync.update_columns(sync_stats: merged)
|
|
end
|
|
|
|
def connections_by_id
|
|
@connections_by_id ||= begin
|
|
stats["api_requests"] = stats.fetch("api_requests", 0) + 1
|
|
redbark_provider.list_connections.index_by { |c| c[:id].to_s }
|
|
rescue Provider::Redbark::AuthenticationError
|
|
raise
|
|
rescue => e
|
|
capture_failure("Connections fetch failed; institution metadata will be limited", e)
|
|
{}
|
|
end
|
|
end
|
|
|
|
def import_accounts
|
|
Rails.logger.info "RedbarkItem::Importer - Fetching accounts"
|
|
|
|
accounts_data = redbark_provider.list_accounts
|
|
|
|
stats["api_requests"] = stats.fetch("api_requests", 0) + 1
|
|
stats["total_accounts"] = accounts_data.size
|
|
|
|
# Fetch connections once, before the per-account rescue below - an auth
|
|
# failure here must propagate and mark the item requires_update, not get
|
|
# swallowed as N per-account errors
|
|
connections_by_id
|
|
|
|
upstream_account_ids = []
|
|
|
|
accounts_data.each do |account_data|
|
|
begin
|
|
account_id = account_data[:id]&.to_s
|
|
next if account_id.blank?
|
|
next if account_data[:name].blank?
|
|
|
|
# Only banking and documents connections carry transactions;
|
|
# brokerage accounts belong to /v1/trades and are out of scope here
|
|
connection = connections_by_id[account_data[:connectionId].to_s]
|
|
next unless transactable_connection?(connection)
|
|
|
|
upstream_account_ids << account_id
|
|
|
|
redbark_account = redbark_item.redbark_accounts.find_or_initialize_by(
|
|
redbark_account_id: account_id
|
|
)
|
|
redbark_account.upsert_from_redbark!(account_data, connection_data: connection)
|
|
|
|
stats["accounts_imported"] = stats.fetch("accounts_imported", 0) + 1
|
|
rescue => e
|
|
capture_failure("Account import failed", e, account_id: account_data[:id])
|
|
stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1
|
|
register_error(e, account_id: account_data[:id])
|
|
end
|
|
end
|
|
|
|
persist_stats!
|
|
|
|
@upstream_account_ids = upstream_account_ids
|
|
|
|
prune_removed_accounts(upstream_account_ids)
|
|
end
|
|
|
|
def import_transactions(redbark_account)
|
|
Rails.logger.info "RedbarkItem::Importer - Fetching transactions for account #{redbark_account.id}"
|
|
|
|
if redbark_account.connection_id.blank?
|
|
Rails.logger.warn "RedbarkItem::Importer - Account #{redbark_account.id} has no connection_id, skipping transactions"
|
|
return
|
|
end
|
|
|
|
# A linked account can still sit on a non-transactable connection
|
|
# (e.g. brokerage rows linked before this guard existed) - the
|
|
# transactions endpoint rejects those outright
|
|
connection = connections_by_id[redbark_account.connection_id.to_s]
|
|
unless transactable_connection?(connection)
|
|
Rails.logger.info "RedbarkItem::Importer - Skipping transactions for non-banking account #{redbark_account.id}"
|
|
return
|
|
end
|
|
|
|
begin
|
|
start_date = calculate_transaction_start_date(redbark_account)
|
|
|
|
transactions_data = redbark_provider.get_transactions(
|
|
connection_id: redbark_account.connection_id,
|
|
account_id: redbark_account.redbark_account_id,
|
|
start_date: start_date,
|
|
end_date: Date.current,
|
|
include_pending: Rails.configuration.x.redbark.include_pending
|
|
)
|
|
|
|
stats["api_requests"] = stats.fetch("api_requests", 0) + 1
|
|
|
|
if transactions_data.any?
|
|
transactions_hashes = transactions_data.map { |t| sdk_object_to_hash(t) }
|
|
merged = merge_transactions(
|
|
redbark_account.raw_transactions_payload || [],
|
|
transactions_hashes,
|
|
window_start: start_date
|
|
)
|
|
redbark_account.upsert_redbark_transactions_snapshot!(merged)
|
|
stats["transactions_found"] = stats.fetch("transactions_found", 0) + transactions_data.size
|
|
end
|
|
rescue Provider::Redbark::AuthenticationError
|
|
raise
|
|
rescue => e
|
|
capture_failure("Transactions fetch failed", e, account_id: redbark_account.redbark_account_id)
|
|
register_error(e, context: "transactions", account_id: redbark_account.id)
|
|
end
|
|
end
|
|
|
|
# One balances call covers every eligible linked account. A failed fetch
|
|
# leaves current_balance untouched so the processor keeps the previous
|
|
# balance instead of writing zeros.
|
|
def import_balances(linked_accounts)
|
|
eligible_accounts = balance_eligible_accounts(linked_accounts)
|
|
account_ids = eligible_accounts.map(&:redbark_account_id).compact
|
|
return if account_ids.empty?
|
|
|
|
begin
|
|
balances = fetch_balances_with_fallback(account_ids)
|
|
|
|
balances_by_id = balances.index_by { |b| b[:accountId].to_s }
|
|
|
|
eligible_accounts.each do |redbark_account|
|
|
balance_data = balances_by_id[redbark_account.redbark_account_id]
|
|
next unless balance_data
|
|
|
|
amount = parse_decimal(balance_data[:currentBalance])
|
|
next if amount.nil?
|
|
|
|
redbark_account.update!(
|
|
current_balance: amount,
|
|
currency: extract_currency(balance_data, fallback: redbark_account.currency)
|
|
)
|
|
stats["balances_updated"] = stats.fetch("balances_updated", 0) + 1
|
|
end
|
|
rescue Provider::Redbark::AuthenticationError
|
|
raise
|
|
rescue => e
|
|
capture_failure("Balances fetch failed; keeping previous balances", e)
|
|
register_error(e, context: "balances")
|
|
end
|
|
end
|
|
|
|
# The balances endpoint rejects the WHOLE batch when any requested id is
|
|
# unknown (404) or non-banking (400), so exclude accounts that no longer
|
|
# exist upstream and accounts on non-banking connections up front.
|
|
def balance_eligible_accounts(linked_accounts)
|
|
linked_accounts.select do |redbark_account|
|
|
next false if redbark_account.redbark_account_id.blank?
|
|
|
|
if @upstream_account_ids.present? && !@upstream_account_ids.include?(redbark_account.redbark_account_id)
|
|
next false
|
|
end
|
|
|
|
connection = connections_by_id[redbark_account.connection_id.to_s]
|
|
next true if connection.nil? # no connection metadata - let the fallback sort it out
|
|
|
|
category = connection[:category].to_s
|
|
category.blank? || category == "banking"
|
|
end
|
|
end
|
|
|
|
# If the batch is still rejected (stale id or category we could not see),
|
|
# fall back to per-account requests so one bad account cannot freeze
|
|
# balance updates for the rest of the item.
|
|
def fetch_balances_with_fallback(account_ids)
|
|
stats["api_requests"] = stats.fetch("api_requests", 0) + 1
|
|
redbark_provider.get_balances(account_ids: account_ids)
|
|
rescue Provider::Redbark::AuthenticationError
|
|
raise
|
|
rescue Provider::Redbark::Error => e
|
|
raise unless %i[not_found bad_request].include?(e.error_type)
|
|
raise if account_ids.size <= 1
|
|
|
|
capture_failure("Batched balances call rejected; retrying per account", e)
|
|
|
|
account_ids.flat_map do |account_id|
|
|
begin
|
|
stats["api_requests"] = stats.fetch("api_requests", 0) + 1
|
|
redbark_provider.get_balances(account_ids: [ account_id ])
|
|
rescue Provider::Redbark::AuthenticationError
|
|
raise
|
|
rescue Provider::Redbark::Error => account_error
|
|
capture_failure("Balance fetch failed for account; keeping previous balance", account_error, account_id: account_id)
|
|
[]
|
|
end
|
|
end
|
|
end
|
|
|
|
def calculate_transaction_start_date(redbark_account)
|
|
has_stored_transactions = (redbark_account.raw_transactions_payload || []).any?
|
|
|
|
if has_stored_transactions && redbark_item.last_synced_at.present?
|
|
# Incremental: go back 7 days from last sync to catch late-posting
|
|
# transactions. The user's sync_start_date governs the initial
|
|
# backfill only - re-fetching the whole window every sync would blow
|
|
# through the API's row ceiling on busy accounts.
|
|
(redbark_item.last_synced_at - 7.days).to_date
|
|
elsif redbark_account.sync_start_date.present?
|
|
redbark_account.sync_start_date
|
|
else
|
|
# First sync for this account: pull a 90 day history
|
|
90.days.ago.to_date
|
|
end
|
|
end
|
|
|
|
# The snapshot is bounded to the current fetch window - durable history
|
|
# lives in entries (deduped by external_id), so older rows are dropped
|
|
# rather than accumulated forever. Rows without a parseable date are kept.
|
|
# A pending row absent from the refetch has settled (possibly under a new
|
|
# id) - keeping it would duplicate the posted transaction.
|
|
def merge_transactions(existing, new_transactions, window_start: nil)
|
|
new_keys = new_transactions.map { |t| transaction_key(t) }.to_set
|
|
|
|
by_id = {}
|
|
existing.each do |t|
|
|
next unless within_window?(t, window_start)
|
|
next if stale_pending?(t, new_keys)
|
|
by_id[transaction_key(t)] = t
|
|
end
|
|
new_transactions.each { |t| by_id[transaction_key(t)] = t }
|
|
by_id.values
|
|
end
|
|
|
|
def within_window?(transaction, window_start)
|
|
return true if window_start.nil?
|
|
|
|
t = transaction.is_a?(Hash) ? transaction.with_indifferent_access : transaction
|
|
date = Date.parse(t[:date].to_s) rescue nil
|
|
date.nil? || date >= window_start
|
|
end
|
|
|
|
def stale_pending?(transaction, new_keys)
|
|
t = transaction.is_a?(Hash) ? transaction.with_indifferent_access : transaction
|
|
t[:status].to_s == "pending" && !new_keys.include?(transaction_key(t))
|
|
end
|
|
|
|
def transaction_key(transaction)
|
|
transaction = transaction.with_indifferent_access if transaction.is_a?(Hash)
|
|
transaction[:id].presence ||
|
|
[ transaction[:date], transaction[:amount], transaction[:description] ].join("-")
|
|
end
|
|
|
|
# Removes records that no longer exist upstream and are not linked to any
|
|
# Account. Guarded to a non-empty upstream list so a transient empty or
|
|
# failed response can never wipe out all accounts.
|
|
def prune_removed_accounts(upstream_account_ids)
|
|
return if upstream_account_ids.empty?
|
|
|
|
scope = redbark_item.redbark_accounts.includes(:account_provider)
|
|
orphaned = scope
|
|
.where.not(redbark_account_id: upstream_account_ids)
|
|
.or(scope.where(redbark_account_id: nil))
|
|
|
|
orphaned.each do |redbark_account|
|
|
if redbark_account.account_provider.present?
|
|
Rails.logger.info "RedbarkItem::Importer - Keeping stale RedbarkAccount #{redbark_account.id} (still linked to an Account)"
|
|
next
|
|
end
|
|
|
|
begin
|
|
Rails.logger.info "RedbarkItem::Importer - Pruning orphaned RedbarkAccount #{redbark_account.id} (no longer exists upstream)"
|
|
redbark_account.destroy
|
|
stats["accounts_pruned"] = stats.fetch("accounts_pruned", 0) + 1
|
|
rescue => e
|
|
capture_failure("Failed to prune orphaned account", e, redbark_account_id: redbark_account.id)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Banking and documents connections serve /v1/transactions; anything else
|
|
# (brokerage) does not. Unknown connections get the benefit of the doubt.
|
|
def transactable_connection?(connection)
|
|
return true if connection.nil?
|
|
|
|
category = connection[:category].to_s
|
|
category.blank? || %w[banking documents].include?(category)
|
|
end
|
|
|
|
def register_error(error, **context)
|
|
stats["errors"] ||= []
|
|
stats["errors"] << {
|
|
message: error.message,
|
|
context: context.to_s,
|
|
timestamp: Time.current.iso8601
|
|
}
|
|
end
|
|
|
|
# Surfaces provider failures in the /settings/debug super-admin UI so
|
|
# support can diagnose without log access
|
|
def capture_failure(message, error, **metadata)
|
|
DebugLogEntry.capture(
|
|
category: "provider_sync",
|
|
level: "warn",
|
|
message: message,
|
|
source: self.class.name,
|
|
provider_key: "redbark",
|
|
family: redbark_item.family,
|
|
metadata: { error_class: error.class.name, error: error.message }.merge(metadata)
|
|
)
|
|
Rails.logger.warn "RedbarkItem::Importer - #{message}: #{error.class}: #{error.message}"
|
|
end
|
|
end
|