Files
sure/app/controllers/redbark_items_controller.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

361 lines
12 KiB
Ruby

# frozen_string_literal: true
class RedbarkItemsController < ApplicationController
ALLOWED_ACCOUNTABLE_TYPES = %w[Depository CreditCard Investment Loan OtherAsset OtherLiability Crypto Property Vehicle].freeze
before_action :set_redbark_item, only: [ :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
before_action :require_admin!, only: [ :create, :select_accounts, :select_existing_account, :link_existing_account, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
def create
@redbark_item = Current.family.redbark_items.build(redbark_item_params)
@redbark_item.name ||= t("redbark_items.default_name")
if @redbark_item.save
# Trigger the initial sync so accounts appear without a manual refresh
@redbark_item.sync_later
if turbo_frame_request?
flash.now[:notice] = t(".success")
@redbark_items = Current.family.redbark_items.ordered
render turbo_stream: [
turbo_stream.replace(
"redbark-providers-panel",
partial: "settings/providers/redbark_panel",
locals: { redbark_items: @redbark_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
else
@error_message = @redbark_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"redbark-providers-panel",
partial: "settings/providers/redbark_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :see_other
end
end
end
def update
update_params = redbark_item_params
# A fresh key means the connection can be retried - clear requires_update
update_params = update_params.merge(status: :good) if update_params[:api_key].present?
if @redbark_item.update(update_params)
# Rotated credentials should be exercised right away
@redbark_item.sync_later if update_params[:api_key].present? && !@redbark_item.syncing?
if turbo_frame_request?
flash.now[:notice] = t(".success")
@redbark_items = Current.family.redbark_items.ordered
render turbo_stream: [
turbo_stream.replace(
"redbark-providers-panel",
partial: "settings/providers/redbark_panel",
locals: { redbark_items: @redbark_items }
),
*flash_notification_stream_items
]
else
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
else
@error_message = @redbark_item.errors.full_messages.join(", ")
if turbo_frame_request?
render turbo_stream: turbo_stream.replace(
"redbark-providers-panel",
partial: "settings/providers/redbark_panel",
locals: { error_message: @error_message }
), status: :unprocessable_entity
else
redirect_to settings_providers_path, alert: @error_message, status: :see_other
end
end
end
def destroy
# Detach provider links before scheduling deletion; abort if anything
# failed to unlink so we never orphan holdings or provider links
unlink_results = @redbark_item.unlink_all!(dry_run: false)
failed = unlink_results.select { |r| r[:error].present? }
if failed.any?
redirect_to settings_providers_path, alert: t(".unlink_failed", count: failed.count), status: :see_other
return
end
@redbark_item.destroy_later
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
end
def sync
unless @redbark_item.syncing?
@redbark_item.sync_later
end
respond_to do |format|
format.html { redirect_back_or_to accounts_path }
format.json { head :ok }
end
end
# Collection actions for account linking flow
def select_accounts
redbark_item = Current.family.redbark_items.first
unless redbark_item&.credentials_configured?
if turbo_frame_request?
render partial: "redbark_items/setup_required", layout: false
else
redirect_to settings_providers_path, alert: t(".no_credentials_configured")
end
return
end
# The account-linking UI lives in the setup_accounts view
redirect_to setup_accounts_redbark_item_path(redbark_item, return_to: safe_return_to_path)
end
def select_existing_account
@account = Current.family.accounts.find(params[:account_id])
@redbark_item = Current.family.redbark_items.first
unless @redbark_item&.credentials_configured?
redirect_to settings_providers_path, alert: t(".no_credentials_configured")
return
end
@redbark_accounts = @redbark_item.redbark_accounts
.without_linked
.order(:name)
end
def link_existing_account
account = Current.family.accounts.find(params[:account_id])
redbark_item = Current.family.redbark_items.first
unless redbark_item&.credentials_configured?
redirect_to settings_providers_path, alert: t(".no_api_key")
return
end
redbark_account = redbark_item.redbark_accounts.find(params[:redbark_account_id])
if redbark_account.account_provider.present?
redirect_to account_path(account), alert: t(".provider_account_already_linked")
return
end
if account.account_providers.exists?
redirect_to account_path(account), alert: t(".account_already_linked")
return
end
redbark_account.ensure_account_provider!(account)
redbark_account.update!(ignored: false)
redbark_item.sync_later unless redbark_item.syncing?
redirect_to account_path(account), notice: t(".success", account_name: account.name)
end
def setup_accounts
# A fresh connection has no imported accounts yet - fetch them inline so
# the setup dialog is usable straight after the API key is saved
@api_error = fetch_redbark_accounts_from_api
@unlinked_accounts = @redbark_item.unlinked_redbark_accounts.order(:name)
if @unlinked_accounts.empty? && @api_error.nil?
redirect_to accounts_path, notice: t(".all_accounts_linked")
end
end
def complete_account_setup
account_configs = params[:accounts] || {}
if account_configs.empty?
redirect_to setup_accounts_redbark_item_path(@redbark_item), alert: t(".no_accounts")
return
end
created_count = 0
skipped_count = 0
failed_count = 0
account_configs.each do |redbark_account_id, config|
redbark_account = @redbark_item.redbark_accounts.find_by(id: redbark_account_id)
next unless redbark_account
next if redbark_account.account_provider.present?
# Remember the user's choice to skip so the account stops resurfacing
# as "needs setup" on every sync
if config[:account_type] == "skip" || config[:account_type].blank?
redbark_account.update!(ignored: true)
skipped_count += 1
next
end
accountable_type = infer_accountable_type(config[:account_type], config[:subtype])
# Atomic: roll back the manual account if linking the provider fails
ActiveRecord::Base.transaction do
account = create_account_from_redbark(redbark_account, accountable_type, config)
redbark_account.ensure_account_provider!(account)
redbark_account.update!(ignored: false)
redbark_account.update!(sync_start_date: config[:sync_start_date]) if config[:sync_start_date].present?
end
created_count += 1
rescue => e
DebugLogEntry.capture(
category: "provider_sync",
level: "error",
message: "Redbark account setup failed",
source: self.class.name,
provider_key: "redbark",
family: Current.family,
metadata: { redbark_account_id: redbark_account_id, error_class: e.class.name, error: e.message }
)
failed_count += 1
end
@redbark_item.sync_later if created_count > 0 && !@redbark_item.syncing?
if failed_count > 0
redirect_to setup_accounts_redbark_item_path(@redbark_item), alert: t(".setup_failed", count: failed_count)
elsif created_count > 0
redirect_to accounts_path, notice: t(".success", count: created_count)
elsif skipped_count > 0
redirect_to accounts_path, notice: t(".all_skipped")
else
redirect_to setup_accounts_redbark_item_path(@redbark_item), alert: t(".creation_failed_generic")
end
end
private
def set_redbark_item
@redbark_item = Current.family.redbark_items.find(params[:id])
end
# Imports accounts synchronously when the item has none yet.
# Returns nil on success, or an error message string on failure.
def fetch_redbark_accounts_from_api
return nil if @redbark_item.redbark_accounts.any?
return t("redbark_items.setup_accounts.no_api_key") unless @redbark_item.credentials_configured?
@redbark_item.import_latest_redbark_data
nil
rescue Provider::Redbark::Error => e
t("redbark_items.setup_accounts.api_error", message: e.message)
rescue StandardError => e
DebugLogEntry.capture(
category: "provider_sync",
level: "error",
message: "Inline Redbark account fetch failed",
source: self.class.name,
provider_key: "redbark",
family: Current.family,
metadata: { redbark_item_id: @redbark_item.id, error_class: e.class.name, error: e.message }
)
t("redbark_items.setup_accounts.api_error", message: e.message)
end
# Only allow internal relative paths in return_to
def safe_return_to_path
return nil if params[:return_to].blank?
return_to = params[:return_to].to_s
begin
uri = URI.parse(return_to)
return nil if uri.scheme.present?
return nil if uri.host.present?
return nil unless return_to.start_with?("/")
return_to
rescue URI::InvalidURIError
nil
end
end
def redbark_item_params
params.require(:redbark_item).permit(
:name,
:sync_start_date,
:api_key
)
end
def link_redbark_account(redbark_account, accountable_type)
accountable_class = validated_accountable_class(accountable_type)
account = Current.family.accounts.create!(
name: redbark_account.name,
balance: redbark_account.current_balance || 0,
currency: redbark_account.currency || "AUD",
accountable: accountable_class.new
)
redbark_account.ensure_account_provider!(account)
redbark_account.update!(ignored: false)
account
end
def create_account_from_redbark(redbark_account, accountable_type, config)
accountable_class = validated_accountable_class(accountable_type)
accountable_attrs = {}
# Set subtype if the accountable supports it
if config[:subtype].present? && accountable_class.respond_to?(:subtypes)
accountable_attrs[:subtype] = config[:subtype]
end
Current.family.accounts.create!(
name: redbark_account.name,
balance: config[:balance].present? ? config[:balance].to_d : (redbark_account.current_balance || 0),
currency: redbark_account.currency || "AUD",
accountable: accountable_class.new(accountable_attrs)
)
end
def infer_accountable_type(account_type, subtype = nil)
case account_type&.downcase
when "depository"
"Depository"
when "credit_card"
"CreditCard"
when "investment"
"Investment"
when "loan"
"Loan"
when "other_asset"
"OtherAsset"
when "other_liability"
"OtherLiability"
when "crypto"
"Crypto"
when "property"
"Property"
when "vehicle"
"Vehicle"
else
"Depository"
end
end
def validated_accountable_class(accountable_type)
unless ALLOWED_ACCOUNTABLE_TYPES.include?(accountable_type)
raise ArgumentError, "Invalid accountable type: #{accountable_type}"
end
accountable_type.constantize
end
end