mirror of
https://github.com/we-promise/sure.git
synced 2026-07-27 12:12:13 +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
361 lines
12 KiB
Ruby
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
|