Files
sure/app/models/redbark_account/transactions/processor.rb
Oscar c6a240a183 feat(redbark): add australian bank sync (redbark) (#2794)
* 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
2026-07-26 07:40:25 +02:00

178 lines
6.2 KiB
Ruby

# frozen_string_literal: true
require "digest"
class RedbarkAccount::Transactions::Processor
include RedbarkAccount::DataHelpers
attr_reader :redbark_account
def initialize(redbark_account)
@redbark_account = redbark_account
end
def process
unless redbark_account.raw_transactions_payload.present?
Rails.logger.info "RedbarkAccount::Transactions::Processor - No transactions in raw_transactions_payload for redbark_account #{redbark_account.id}"
return { success: true, total: 0, imported: 0, skipped: 0, failed: 0, errors: [] }
end
total_count = redbark_account.raw_transactions_payload.count
Rails.logger.info "RedbarkAccount::Transactions::Processor - Processing #{total_count} transactions for redbark_account #{redbark_account.id}"
imported_count = 0
skipped_count = 0
failed_count = 0
errors = []
# Each entry is processed inside a transaction, but to avoid locking up the DB when
# there are hundreds or thousands of transactions, we process them individually.
redbark_account.raw_transactions_payload.each_with_index do |transaction_data, index|
begin
result = process_transaction(transaction_data)
if result.nil?
# Benign skip (no linked account, blank id, unparseable amount/date)
skipped_count += 1
else
imported_count += 1
end
rescue ArgumentError => e
# Validation error - log and continue
failed_count += 1
transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown"
error_message = "Validation error: #{e.message}"
Rails.logger.error "RedbarkAccount::Transactions::Processor - #{error_message} (transaction #{transaction_id})"
errors << { index: index, transaction_id: transaction_id, error: error_message }
rescue => e
# Unexpected error - log with full context and continue
failed_count += 1
transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown"
error_message = "#{e.class}: #{e.message}"
Rails.logger.error "RedbarkAccount::Transactions::Processor - Error processing transaction #{transaction_id}: #{error_message}"
Rails.logger.error e.backtrace.join("\n")
errors << { index: index, transaction_id: transaction_id, error: error_message }
end
end
result = {
success: failed_count == 0,
total: total_count,
imported: imported_count,
skipped: skipped_count,
failed: failed_count,
errors: errors
}
if failed_count > 0 || skipped_count > 0
DebugLogEntry.capture(
category: "provider_sync",
level: failed_count > 0 ? "warn" : "info",
message: "Redbark transaction processing completed with skipped or failed rows",
source: self.class.name,
provider_key: "redbark",
family: redbark_account.redbark_item.family,
metadata: { redbark_account_id: redbark_account.id, total: total_count, imported: imported_count, skipped: skipped_count, failed: failed_count, errors: errors.first(10) }
)
end
if failed_count > 0
Rails.logger.warn "RedbarkAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions"
else
Rails.logger.info "RedbarkAccount::Transactions::Processor - Successfully processed #{imported_count} transactions (#{skipped_count} skipped)"
end
result
end
private
def account
@redbark_account.current_account
end
def import_adapter
@import_adapter ||= Account::ProviderImportAdapter.new(account)
end
# Redbark transaction shape:
# { id, accountId, accountName, status, date, datetime, postDate, description,
# amount, direction, category, merchantName, merchantCategoryCode }
def process_transaction(transaction_data)
return nil unless account.present?
data = transaction_data.with_indifferent_access
redbark_id = data[:id].to_s
return nil if redbark_id.blank?
external_id = "redbark_#{redbark_id}"
amount = parse_transaction_amount(data)
return nil if amount.nil?
date = parse_date(data[:date] || data[:postDate])
return nil if date.nil?
name = data[:merchantName].presence || data[:description].presence || "Transaction"
# Transactions carry no per-item currency; they are in the account's currency
currency = account.currency
extra = build_extra_metadata(data)
Rails.logger.debug "RedbarkAccount::Transactions::Processor - Importing transaction: id=#{external_id} date=#{date}"
# Use ProviderImportAdapter for proper deduplication via external_id + source
import_adapter.import_transaction(
external_id: external_id,
amount: amount,
currency: currency,
date: date,
name: name[0..254], # Limit to 255 chars
source: "redbark",
merchant: merchant_for(data),
notes: data[:description].presence,
extra: extra
)
end
def parse_transaction_amount(data)
amount = parse_decimal(data[:amount])
return nil if amount.nil?
# Redbark returns CDR pre-signed amounts: positive = credit (money in),
# negative = debit (money out). Sure expects the opposite (positive =
# money out), so negate.
-amount
end
def merchant_for(data)
merchant_name = data[:merchantName].to_s.strip
return nil if merchant_name.blank?
merchant_id = Digest::SHA256.hexdigest(merchant_name.downcase)[0, 32]
import_adapter.find_or_create_merchant(
provider_merchant_id: "redbark_merchant_#{merchant_id}",
name: merchant_name,
source: "redbark"
)
rescue ActiveRecord::RecordInvalid => e
Rails.logger.error "RedbarkAccount::Transactions::Processor - Failed to create merchant '#{merchant_name}': #{e.message}"
nil
end
def build_extra_metadata(data)
{
"redbark" => {
"id" => data[:id],
"pending" => data[:status].to_s == "pending",
"merchant" => data[:merchantName],
"category" => data[:category],
"merchant_category_code" => data[:merchantCategoryCode],
"direction" => data[:direction]
}.compact
}
end
end