mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 16:42:18 +00:00
* feat(up): add Up Bank (AU) provider integration Adds Up Bank as a per-family, token-based bank sync provider, modelled on the existing Akahu integration. Up uses a JSON:API REST API with a personal access token (Bearer), cursor pagination via links.next, and returns both HELD (pending) and SETTLED transactions from one endpoint. New: - Provider::Up client (JSON:API unwrap, links.next pagination, retries, typed errors, /util/ping) + Provider::UpAdapter (Factory-registered, Depository + Loan). - UpItem / UpAccount models with Provided, Unlinking, Syncer, SyncCompleteEvent, Importer, Processor, Transactions::Processor, and UpEntry::Processor (amount sign flip, HELD->pending, foreignAmount FX, merchant from description, stale-pending pruning). - Family::UpConnectable, UpItemsController, routes, settings panel + connect flow views, accounts index wiring, initializer, en locale, and model tests. Core wiring: - "up" added to Transaction::PENDING_PROVIDERS, the three pending-match SQL blocks in Account::ProviderImportAdapter, Provider::Metadata::REGISTRY, ProviderMerchant/DataEnrichment source enums, ProviderConnectionStatus, settings provider panels, and financial data reset. Migration create_up_items_and_accounts must be run before use. No external API endpoints added (no OpenAPI changes). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(up): dump up tables to schema and make since filter TZ-safe The feature commit added the up_items/up_accounts migration but never re-dumped db/schema.rb, leaving the schema version and tables stale. Add the two table definitions and foreign keys and bump the schema version so a fresh DB load matches the migration. Also format a bare Date `since` as UTC midnight instead of the server's local zone, so `filter[since]` is deterministic regardless of where the app runs (previously shifted by the local UTC offset). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(up): address code review feedback Behavior/correctness: - Persist skipped accounts via a new up_accounts.ignored flag and a needs_setup scope, so skipped accounts stop resurfacing as "needs setup" on every sync. Linking clears the flag. - destroy now checks unlink_all! per-account results and aborts deletion (alert) if any unlink failed, instead of swallowing failures. - render_provider_panel_error redirect uses :see_other (was an invalid 4xx redirect status). - Up provider adapter falls back to item institution name/url when institution_metadata is absent (early return previously blocked it). Resilience/security: - fetch_all_resources guards against an API repeating the same links.next cursor (Set#add?), preventing infinite pagination. - HTTP client validates absolute URLs (from links.next) against Up's HTTPS host before sending the bearer token, preventing credential leakage to untrusted hosts. Diagnostics: - Route provider sync/import failures through DebugLogEntry.capture (controller, UpItem, syncer, unlinking) with family/account context. Low-level HTTP client and currency-normalization warnings keep Rails.logger to match existing provider conventions. Data integrity: - up_accounts.name and currency are NOT NULL (align with model presence validations); account_id stays nullable (allow_nil uniqueness). Forms: - select_existing_account radio is required; controller guards a blank/ unknown up_account_id with a friendly alert instead of RecordNotFound. Tests: - Add UpAccount needs_setup scope test, pagination loop guard test, untrusted-host rejection test; tighten filter[since] assertion to the exact UTC timestamp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(up): address second-round review feedback - Capture sync/import failures via DebugLogEntry so swallowed errors in account/transaction fetching and transaction processing surface in /settings/debug instead of only Rails.logger. - Gate UP_DEBUG_RAW raw payload dump to local envs to avoid leaking PII (merchant names, amounts, account IDs) in managed/production logs. - Collapse linked/unlinked/total account counts into one memoized query instead of 3 separate COUNTs per rendered item. - Rename "Set Up Up Accounts" locale title to "Link Up Accounts". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(up): add method docstrings and align failed_result keys Add docstrings to all Up provider source files (controller, models, providers, concerns) to satisfy the 80% docstring coverage threshold. Third-round review: failed_result now mirrors import's result shape (accounts_updated/created/failed, transactions_imported/failed) instead of the stale accounts_imported key, so failure results stay consistent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
251 lines
10 KiB
Ruby
251 lines
10 KiB
Ruby
# Imports Up Bank accounts and transactions for a single UpItem connection.
|
|
# Fetches account snapshots and per-account transaction history from the Up
|
|
# provider, persisting raw snapshots and returning aggregate import statistics.
|
|
class UpItem::Importer
|
|
attr_reader :up_item, :up_provider
|
|
|
|
# Build an importer for the given +up_item+ using the supplied +up_provider+ client.
|
|
def initialize(up_item, up_provider:)
|
|
@up_item = up_item
|
|
@up_provider = up_provider
|
|
end
|
|
|
|
# Run the full import (accounts then transactions) and return a result hash
|
|
# of success flag and per-entity counts. On a failed accounts fetch, returns
|
|
# a +failed_result+ with the same shape and zeroed counts.
|
|
def import
|
|
Rails.logger.info "UpItem::Importer - Starting import for item #{up_item.id}"
|
|
|
|
accounts_data = fetch_accounts_data
|
|
return failed_result("Failed to fetch accounts data") unless accounts_data
|
|
|
|
up_item.upsert_up_snapshot!(accounts_data)
|
|
|
|
account_stats = import_accounts(accounts_data)
|
|
transaction_stats = import_transactions
|
|
|
|
Rails.logger.info(
|
|
"UpItem::Importer - Completed import for item #{up_item.id}: " \
|
|
"#{account_stats[:updated]} accounts updated, #{account_stats[:created]} new accounts discovered, " \
|
|
"#{transaction_stats[:imported]} transactions"
|
|
)
|
|
|
|
{
|
|
success: account_stats[:failed].zero? && transaction_stats[:failed].zero?,
|
|
accounts_updated: account_stats[:updated],
|
|
accounts_created: account_stats[:created],
|
|
accounts_failed: account_stats[:failed],
|
|
transactions_imported: transaction_stats[:imported],
|
|
transactions_failed: transaction_stats[:failed]
|
|
}
|
|
end
|
|
|
|
private
|
|
|
|
# Fetch the current account list from Up, returning a hash of +items+ or
|
|
# +nil+ on any provider/parse error (which is logged and captured).
|
|
def fetch_accounts_data
|
|
items = up_provider.get_accounts
|
|
{ items: items }
|
|
rescue Provider::Up::UpError => e
|
|
mark_requires_update! if e.error_type.in?([ :unauthorized, :access_forbidden ])
|
|
Rails.logger.error "UpItem::Importer - Up API error: #{e.error_type}"
|
|
capture_sync_error("Failed to fetch accounts data", e, error_type: e.error_type)
|
|
nil
|
|
rescue JSON::ParserError => e
|
|
Rails.logger.error "UpItem::Importer - Failed to parse Up API response: #{e.class}"
|
|
capture_sync_error("Failed to parse Up accounts response", e)
|
|
nil
|
|
rescue => e
|
|
Rails.logger.error "UpItem::Importer - Unexpected error fetching accounts: #{e.class}"
|
|
Rails.logger.error e.backtrace.join("\n")
|
|
capture_sync_error("Unexpected error fetching accounts", e)
|
|
nil
|
|
end
|
|
|
|
# Upsert snapshots for linked accounts and record newly discovered ones,
|
|
# returning a stats hash of +updated+, +created+, and +failed+ counts.
|
|
def import_accounts(accounts_data)
|
|
stats = { updated: 0, created: 0, failed: 0 }
|
|
accounts = Array(accounts_data[:items])
|
|
linked_account_ids = up_item.up_accounts.joins(:account_provider).pluck(:account_id).map(&:to_s)
|
|
all_existing_ids = up_item.up_accounts.pluck(:account_id).map(&:to_s)
|
|
|
|
accounts.each do |account_data|
|
|
account = account_data.with_indifferent_access
|
|
account_id = account[:id].presence
|
|
next if account_id.blank?
|
|
next if account[:displayName].blank?
|
|
|
|
if linked_account_ids.include?(account_id.to_s)
|
|
import_account(account)
|
|
stats[:updated] += 1
|
|
elsif !all_existing_ids.include?(account_id.to_s)
|
|
up_account = up_item.up_accounts.build(account_id: account_id.to_s)
|
|
up_account.upsert_up_snapshot!(account)
|
|
stats[:created] += 1
|
|
end
|
|
rescue => e
|
|
stats[:failed] += 1
|
|
Rails.logger.error "UpItem::Importer - Failed to import account #{account_id}: #{e.message}"
|
|
end
|
|
|
|
stats
|
|
end
|
|
|
|
# Upsert the snapshot for a single already-linked Up account.
|
|
def import_account(account_data)
|
|
account = account_data.with_indifferent_access
|
|
up_account = up_item.up_accounts.find_by(account_id: account[:id].to_s)
|
|
return unless up_account
|
|
|
|
up_account.upsert_up_snapshot!(account)
|
|
end
|
|
|
|
# Fetch and store transactions for every visible linked account, returning
|
|
# a stats hash of +imported+ and +failed+ counts.
|
|
def import_transactions
|
|
stats = { imported: 0, failed: 0 }
|
|
|
|
up_item.up_accounts.joins(:account).merge(Account.visible).each do |up_account|
|
|
result = fetch_and_store_transactions(up_account)
|
|
if result[:success]
|
|
stats[:imported] += result[:transactions_count]
|
|
else
|
|
stats[:failed] += 1
|
|
end
|
|
rescue => e
|
|
stats[:failed] += 1
|
|
Rails.logger.error "UpItem::Importer - Failed to fetch/store transactions for Up account #{up_account.id}: #{e.class}"
|
|
end
|
|
|
|
stats
|
|
end
|
|
|
|
# Fetch transactions for +up_account+ since its sync start date and persist
|
|
# them, returning a result hash with +success+ and +transactions_count+.
|
|
def fetch_and_store_transactions(up_account)
|
|
start_date = determine_sync_start_date(up_account)
|
|
Rails.logger.info "UpItem::Importer - Fetching transactions for Up account #{up_account.id} since #{start_date}"
|
|
|
|
transactions = up_provider.get_account_transactions(
|
|
account_id: up_account.account_id,
|
|
since: start_date
|
|
)
|
|
|
|
if Rails.configuration.x.up.debug_raw && Rails.env.local?
|
|
Rails.logger.debug "Up raw transactions response: #{transactions.to_json}"
|
|
end
|
|
|
|
store_transactions(up_account, fresh_transactions: Array(transactions))
|
|
|
|
{ success: true, transactions_count: Array(transactions).count }
|
|
rescue Provider::Up::UpError => e
|
|
mark_requires_update! if e.error_type.in?([ :unauthorized, :access_forbidden ])
|
|
Rails.logger.error "UpItem::Importer - Up API error for account #{up_account.id}: #{e.error_type}"
|
|
capture_sync_error("Failed to fetch transactions", e, up_account: up_account, error_type: e.error_type)
|
|
{ success: false, transactions_count: 0, error: I18n.t("up_item.errors.transactions_failed") }
|
|
rescue JSON::ParserError => e
|
|
Rails.logger.error "UpItem::Importer - Failed to parse transaction response for account #{up_account.id}: #{e.class}"
|
|
capture_sync_error("Failed to parse Up transactions response", e, up_account: up_account)
|
|
{ success: false, transactions_count: 0, error: "Failed to parse response" }
|
|
rescue => e
|
|
Rails.logger.error "UpItem::Importer - Unexpected error fetching transactions for account #{up_account.id}: #{e.class}"
|
|
Rails.logger.error e.backtrace.join("\n")
|
|
capture_sync_error("Unexpected error fetching transactions", e, up_account: up_account)
|
|
{ success: false, transactions_count: 0, error: I18n.t("up_item.errors.transactions_failed") }
|
|
end
|
|
|
|
# Up returns both HELD (pending) and SETTLED transactions from the same endpoint.
|
|
# Settled history accumulates; HELD transactions are only retained while they remain
|
|
# present in the latest fetch, so cancelled/settled holds drop out of storage (and the
|
|
# transactions processor prunes their stale pending entries).
|
|
def store_transactions(up_account, fresh_transactions:)
|
|
existing = up_account.raw_transactions_payload.to_a
|
|
existing_settled = existing.reject { |tx| UpEntry::Processor.pending?(tx) }
|
|
|
|
by_id = {}
|
|
existing_settled.each do |tx|
|
|
key = transaction_id(tx)
|
|
by_id[key] = tx if key.present?
|
|
end
|
|
fresh_transactions.each do |tx|
|
|
next unless tx.is_a?(Hash)
|
|
|
|
key = transaction_id(tx)
|
|
by_id[key] = tx if key.present?
|
|
end
|
|
|
|
final_transactions = by_id.values
|
|
|
|
if final_transactions != existing
|
|
Rails.logger.info(
|
|
"UpItem::Importer - Storing #{final_transactions.count} transactions " \
|
|
"(#{existing.count} existing) for account #{up_account.account_id}"
|
|
)
|
|
up_account.upsert_up_transactions_snapshot!(final_transactions)
|
|
else
|
|
Rails.logger.info "UpItem::Importer - No transaction changes for account #{up_account.account_id}"
|
|
end
|
|
end
|
|
|
|
# Extract the Up transaction id from a raw transaction hash, or +nil+.
|
|
def transaction_id(transaction)
|
|
data = transaction.with_indifferent_access
|
|
data[:id].presence
|
|
end
|
|
|
|
# Resolve the date from which to fetch transactions for +up_account+,
|
|
# preferring explicit per-account/item start dates, then a recent window.
|
|
def determine_sync_start_date(up_account)
|
|
return up_account.sync_start_date if up_account.sync_start_date.present?
|
|
return up_item.sync_start_date if up_item.sync_start_date.present?
|
|
|
|
has_stored_transactions = up_account.raw_transactions_payload.to_a.any?
|
|
if has_stored_transactions && up_item.last_synced_at
|
|
up_item.last_synced_at - 7.days
|
|
else
|
|
90.days.ago
|
|
end
|
|
end
|
|
|
|
# Record a provider sync error as a DebugLogEntry with structured metadata
|
|
# for support, attaching family and account provider when available.
|
|
def capture_sync_error(message, error, up_account: nil, error_type: nil)
|
|
metadata = { up_item_id: up_item.id, error_class: error.class.name, error_message: error.message }
|
|
metadata[:up_account_id] = up_account.id if up_account
|
|
metadata[:error_type] = error_type if error_type
|
|
|
|
DebugLogEntry.capture(
|
|
category: "provider_sync_error",
|
|
level: "error",
|
|
message: message,
|
|
source: self.class.name,
|
|
provider_key: "up",
|
|
family: up_item.family,
|
|
account_provider: up_account&.account_provider,
|
|
metadata: metadata
|
|
)
|
|
end
|
|
|
|
# Flag the item as requiring re-authorization, swallowing update errors.
|
|
def mark_requires_update!
|
|
up_item.update!(status: :requires_update)
|
|
rescue => e
|
|
Rails.logger.error "UpItem::Importer - Failed to update item status: #{e.message}"
|
|
end
|
|
|
|
# Build a failure result mirroring +import+'s shape with zeroed counts.
|
|
def failed_result(error)
|
|
{
|
|
success: false,
|
|
error: error,
|
|
accounts_updated: 0,
|
|
accounts_created: 0,
|
|
accounts_failed: 0,
|
|
transactions_imported: 0,
|
|
transactions_failed: 0
|
|
}
|
|
end
|
|
end
|