mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 07:49:01 +00:00
Merge branch 'main' into feat/savings-goals
Signed-off-by: Guillem Arias Fauste <accounts@gariasf.com>
This commit is contained in:
@@ -11,6 +11,7 @@ RUN apt-get update -qq \
|
||||
git \
|
||||
imagemagick \
|
||||
iproute2 \
|
||||
libvips42 \
|
||||
libpq-dev \
|
||||
libyaml-dev \
|
||||
libyaml-0-2 \
|
||||
|
||||
48
.github/workflows/ci.yml
vendored
48
.github/workflows/ci.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
- name: Lint/Format js code
|
||||
run: npm run lint
|
||||
|
||||
test:
|
||||
test_unit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -121,6 +121,52 @@ jobs:
|
||||
- name: Unit and integration tests
|
||||
run: bin/rails test
|
||||
|
||||
test_system:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
PLAID_CLIENT_ID: foo
|
||||
PLAID_SECRET: bar
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||
REDIS_URL: redis://localhost:6379
|
||||
RAILS_ENV: test
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Install packages
|
||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y google-chrome-stable curl libvips postgresql-client libpq-dev
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version
|
||||
bundler-cache: true
|
||||
|
||||
- name: DB setup and smoke test
|
||||
run: |
|
||||
bin/rails db:create
|
||||
bin/rails db:schema:load
|
||||
bin/rails db:seed
|
||||
|
||||
- name: System tests
|
||||
run: DISABLE_PARALLELIZATION=true bin/rails test:system
|
||||
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -429,7 +429,7 @@ jobs:
|
||||
uses: actions/checkout@v4.2.0
|
||||
with:
|
||||
ref: ${{ steps.source_branch.outputs.branch }}
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
token: ${{ secrets.GH_PAT || github.token }}
|
||||
|
||||
- name: Bump pre-release version
|
||||
run: |
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.7.1-alpha.6
|
||||
0.7.1-alpha.7
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
<div class="<%= container_classes %>">
|
||||
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0 mt-0.5" %>
|
||||
<%= tag.div(class: container_classes, role: aria_role, "aria-labelledby": (aria_role && title.present?) ? title_id : nil) do %>
|
||||
<% if title.present? %>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0 -mt-0.5" %>
|
||||
<p id="<%= title_id %>" class="text-sm font-semibold text-primary flex-1 leading-5">
|
||||
<span class="sr-only"><%= variant_label %>:</span>
|
||||
<%= title %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 text-sm text-primary space-y-1">
|
||||
<% if title.present? %>
|
||||
<p class="font-medium text-primary"><%= title %></p>
|
||||
<% if content.present? || message.present? %>
|
||||
<div class="text-sm text-primary mt-2 pl-7 space-y-1">
|
||||
<% if content.present? %>
|
||||
<%= content %>
|
||||
<% else %>
|
||||
<%= message %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if content.present? %>
|
||||
<%= content %>
|
||||
<% elsif message.present? %>
|
||||
<%= message %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% elsif content.present? %>
|
||||
<div class="flex items-start gap-3">
|
||||
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0" %>
|
||||
<div class="flex-1 text-sm text-primary space-y-1">
|
||||
<span class="sr-only"><%= variant_label %>:</span>
|
||||
<%= content %>
|
||||
</div>
|
||||
</div>
|
||||
<% elsif message.present? %>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= helpers.icon icon_name, size: "sm", color: icon_color, class: "shrink-0 -mt-0.5" %>
|
||||
<p class="text-sm text-primary flex-1 leading-5">
|
||||
<span class="sr-only"><%= variant_label %>:</span>
|
||||
<%= message %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
class DS::Alert < DesignSystemComponent
|
||||
VARIANTS = %i[info success warning error destructive].freeze
|
||||
LIVE_MODES = %i[none status alert].freeze
|
||||
|
||||
def initialize(message: nil, title: nil, variant: :info)
|
||||
def initialize(message: nil, title: nil, variant: :info, live: :none)
|
||||
@message = message
|
||||
@title = title
|
||||
@variant = normalize_variant(variant)
|
||||
@live = normalize_live(live)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :message, :title, :variant
|
||||
attr_reader :message, :title, :variant, :live
|
||||
|
||||
def normalize_variant(raw)
|
||||
sym = raw.respond_to?(:to_sym) ? raw.to_sym : nil
|
||||
VARIANTS.include?(sym) ? sym : :info
|
||||
end
|
||||
|
||||
def normalize_live(raw)
|
||||
sym = raw.respond_to?(:to_sym) ? raw.to_sym : nil
|
||||
case sym
|
||||
when :polite then :status
|
||||
when :assertive then :alert
|
||||
when *LIVE_MODES then sym
|
||||
else :none
|
||||
end
|
||||
end
|
||||
|
||||
def container_classes
|
||||
base_classes = "flex items-start gap-3 p-4 rounded-lg border"
|
||||
base_classes = "p-4 rounded-lg border"
|
||||
|
||||
variant_classes = case variant
|
||||
when :info
|
||||
@@ -57,4 +69,19 @@ class DS::Alert < DesignSystemComponent
|
||||
"info"
|
||||
end
|
||||
end
|
||||
|
||||
def aria_role
|
||||
case live
|
||||
when :status then "status"
|
||||
when :alert then "alert"
|
||||
end
|
||||
end
|
||||
|
||||
def variant_label
|
||||
I18n.t("ds.alert.variants.#{variant}")
|
||||
end
|
||||
|
||||
def title_id
|
||||
@title_id ||= "DS-alert-title-#{SecureRandom.hex(4)}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -133,8 +133,8 @@ class DS::Dialog < DesignSystemComponent
|
||||
variant: "icon",
|
||||
class: classes,
|
||||
icon: "x",
|
||||
title: I18n.t("common.close"),
|
||||
aria_label: I18n.t("common.close"),
|
||||
title: I18n.t("ds.dialog.close"),
|
||||
aria_label: I18n.t("ds.dialog.close"),
|
||||
data: { action: "DS--dialog#close" }
|
||||
)
|
||||
end
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium"><%= end_balance_money.format %></span>
|
||||
<span class="font-medium privacy-sensitive"><%= end_balance_money.format %></span>
|
||||
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
|
||||
</div>
|
||||
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
|
||||
|
||||
@@ -18,8 +18,10 @@ class AccountsController < ApplicationController
|
||||
@enable_banking_items = visible_provider_items(family.enable_banking_items.ordered.includes(:syncs))
|
||||
@coinstats_items = visible_provider_items(family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs))
|
||||
@mercury_items = visible_provider_items(family.mercury_items.ordered.includes(:syncs, :mercury_accounts))
|
||||
@brex_items = visible_provider_items(family.brex_items.ordered.includes(:accounts, :syncs, brex_accounts: :account_provider))
|
||||
@coinbase_items = visible_provider_items(family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs))
|
||||
@snaptrade_items = visible_provider_items(family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts))
|
||||
@ibkr_items = visible_provider_items(family.ibkr_items.ordered.includes(:syncs, :ibkr_accounts))
|
||||
@indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts))
|
||||
@sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts))
|
||||
|
||||
@@ -47,13 +49,14 @@ class AccountsController < ApplicationController
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
@tab = params[:tab]
|
||||
@q = params.fetch(:q, {}).permit(:search, status: [])
|
||||
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological
|
||||
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological.includes(:entryable)
|
||||
|
||||
@pagy, @entries = pagy(
|
||||
entries,
|
||||
limit: safe_per_page,
|
||||
params: request.query_parameters.except("tab").merge("tab" => "activity")
|
||||
)
|
||||
Transaction::ActivitySecurityPreloader.new(@entries).preload
|
||||
|
||||
@activity_feed_data = Account::ActivityFeedData.new(@account, @entries)
|
||||
end
|
||||
@@ -315,6 +318,27 @@ class AccountsController < ApplicationController
|
||||
@mercury_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
||||
end
|
||||
|
||||
# Brex sync stats
|
||||
@brex_sync_stats_map = {}
|
||||
@brex_account_counts_map = {}
|
||||
@brex_institutions_count_map = {}
|
||||
@brex_items.each do |item|
|
||||
latest_sync = item.syncs.ordered.first
|
||||
@brex_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
||||
brex_accounts = item.brex_accounts.to_a
|
||||
linked_count = brex_accounts.count { |brex_account| brex_account.account_provider.present? }
|
||||
total_count = brex_accounts.count
|
||||
@brex_account_counts_map[item.id] = {
|
||||
linked: linked_count,
|
||||
unlinked: total_count - linked_count,
|
||||
total: total_count
|
||||
}
|
||||
@brex_institutions_count_map[item.id] = brex_accounts
|
||||
.filter_map(&:institution_metadata)
|
||||
.uniq { |institution| institution["name"] || institution["institution_name"] }
|
||||
.count
|
||||
end
|
||||
|
||||
# Coinbase sync stats
|
||||
@coinbase_sync_stats_map = {}
|
||||
@coinbase_unlinked_count_map = {}
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
class Api::V1::BaseController < ApplicationController
|
||||
include Doorkeeper::Rails::Helpers
|
||||
|
||||
UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
||||
private_constant :UUID_PATTERN
|
||||
|
||||
InvalidFilterError = Class.new(StandardError)
|
||||
|
||||
class << self
|
||||
def valid_uuid?(value)
|
||||
UuidFormat.valid?(value)
|
||||
end
|
||||
end
|
||||
|
||||
# Skip regular session-based authentication for API
|
||||
skip_authentication
|
||||
|
||||
@@ -220,7 +223,7 @@ class Api::V1::BaseController < ApplicationController
|
||||
end
|
||||
|
||||
def valid_uuid?(value)
|
||||
value.to_s.match?(UUID_PATTERN)
|
||||
self.class.valid_uuid?(value)
|
||||
end
|
||||
|
||||
def safe_page_param
|
||||
|
||||
@@ -4,7 +4,7 @@ class Api::V1::ImportsController < Api::V1::BaseController
|
||||
include Pagy::Backend
|
||||
|
||||
# Ensure proper scope authorization
|
||||
before_action :ensure_read_scope, only: [ :index, :show, :rows ]
|
||||
before_action :ensure_read_scope, only: [ :index, :show, :rows, :preflight ]
|
||||
before_action :ensure_write_scope, only: [ :create ]
|
||||
before_action :set_import_with_rows, only: [ :show ]
|
||||
before_action :set_import, only: [ :rows ]
|
||||
@@ -77,10 +77,10 @@ class Api::V1::ImportsController < Api::V1::BaseController
|
||||
if params[:file].present?
|
||||
file = params[:file]
|
||||
|
||||
if file.size > Import::MAX_CSV_SIZE
|
||||
if file.size > Import.max_csv_size
|
||||
return render json: {
|
||||
error: "file_too_large",
|
||||
message: "File is too large. Maximum size is #{Import::MAX_CSV_SIZE / 1.megabyte}MB."
|
||||
message: "File is too large. Maximum size is #{Import.max_csv_size / 1.megabyte}MB."
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@@ -93,10 +93,10 @@ class Api::V1::ImportsController < Api::V1::BaseController
|
||||
|
||||
@import.raw_file_str = file.read
|
||||
elsif params[:raw_file_content].present?
|
||||
if params[:raw_file_content].bytesize > Import::MAX_CSV_SIZE
|
||||
if params[:raw_file_content].bytesize > Import.max_csv_size
|
||||
return render json: {
|
||||
error: "content_too_large",
|
||||
message: "Content is too large. Maximum size is #{Import::MAX_CSV_SIZE / 1.megabyte}MB."
|
||||
message: "Content is too large. Maximum size is #{Import.max_csv_size / 1.megabyte}MB."
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@@ -136,6 +136,30 @@ class Api::V1::ImportsController < Api::V1::BaseController
|
||||
render json: { error: "internal_server_error", message: e.message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def preflight
|
||||
preflight_result = Import::Preflight.new(family: current_resource_owner.family, params: preflight_params).call
|
||||
render json: preflight_result.payload, status: preflight_result.status
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: {
|
||||
error: "record_not_found",
|
||||
message: "The requested resource was not found"
|
||||
}, status: :not_found
|
||||
rescue CSV::MalformedCSVError => e
|
||||
render json: {
|
||||
error: "invalid_csv",
|
||||
message: "CSV content could not be parsed",
|
||||
errors: [ e.message ]
|
||||
}, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "ImportsController#preflight error: #{e.message}"
|
||||
e.backtrace&.each { |line| Rails.logger.error line }
|
||||
|
||||
render json: {
|
||||
error: "internal_server_error",
|
||||
message: "Error: #{e.message}"
|
||||
}, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_import
|
||||
@@ -186,10 +210,15 @@ class Api::V1::ImportsController < Api::V1::BaseController
|
||||
:signage_convention,
|
||||
:col_sep,
|
||||
:amount_type_strategy,
|
||||
:amount_type_inflow_value
|
||||
:amount_type_inflow_value,
|
||||
:rows_to_skip
|
||||
)
|
||||
end
|
||||
|
||||
def preflight_params
|
||||
params.permit(*Import::Preflight::PARAM_KEYS)
|
||||
end
|
||||
|
||||
def create_sure_import(family)
|
||||
content, filename, content_type = sure_import_upload_attributes
|
||||
return unless content
|
||||
@@ -282,10 +311,10 @@ class Api::V1::ImportsController < Api::V1::BaseController
|
||||
end
|
||||
|
||||
def sure_import_file_upload_attributes(file)
|
||||
if file.size > SureImport::MAX_NDJSON_SIZE
|
||||
if file.size > SureImport.max_ndjson_size
|
||||
render json: {
|
||||
error: "file_too_large",
|
||||
message: "File is too large. Maximum size is #{SureImport::MAX_NDJSON_SIZE / 1.megabyte}MB."
|
||||
message: "File is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB."
|
||||
}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
@@ -308,10 +337,10 @@ class Api::V1::ImportsController < Api::V1::BaseController
|
||||
end
|
||||
|
||||
def sure_import_raw_content_attributes(content)
|
||||
if content.bytesize > SureImport::MAX_NDJSON_SIZE
|
||||
if content.bytesize > SureImport.max_ndjson_size
|
||||
render json: {
|
||||
error: "content_too_large",
|
||||
message: "Content is too large. Maximum size is #{SureImport::MAX_NDJSON_SIZE / 1.megabyte}MB."
|
||||
message: "Content is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB."
|
||||
}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@@ -10,8 +10,11 @@ class Api::V1::TransactionsController < Api::V1::BaseController
|
||||
|
||||
def index
|
||||
family = current_resource_owner.family
|
||||
accessible_account_ids = family.accounts.accessible_by(current_resource_owner).select(:id)
|
||||
transactions_query = family.transactions.visible
|
||||
accessible_account_ids = family.accounts
|
||||
.accessible_by(current_resource_owner)
|
||||
.where.not(status: "pending_deletion")
|
||||
.select(:id)
|
||||
transactions_query = family.transactions
|
||||
.joins(:entry).where(entries: { account_id: accessible_account_ids })
|
||||
|
||||
# Apply filters
|
||||
@@ -69,7 +72,7 @@ class Api::V1::TransactionsController < Api::V1::BaseController
|
||||
family = current_resource_owner.family
|
||||
|
||||
# Validate account_id is present
|
||||
unless transaction_params[:account_id].present?
|
||||
unless account_id_param.present?
|
||||
render json: {
|
||||
error: "validation_failed",
|
||||
message: "Account ID is required",
|
||||
@@ -78,7 +81,21 @@ class Api::V1::TransactionsController < Api::V1::BaseController
|
||||
return
|
||||
end
|
||||
|
||||
account = family.accounts.writable_by(current_resource_owner).find(transaction_params[:account_id])
|
||||
if idempotency_source_param.present? && idempotency_external_id.blank?
|
||||
render json: {
|
||||
error: "validation_failed",
|
||||
message: "Source requires external_id",
|
||||
errors: [ "Source requires external_id" ]
|
||||
}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
account = family.accounts.writable_by(current_resource_owner).find(account_id_param)
|
||||
|
||||
if idempotency_key_requested? && (existing_entry = existing_idempotent_entry(account))
|
||||
return render_existing_idempotent_entry(existing_entry)
|
||||
end
|
||||
|
||||
@entry = account.entries.new(entry_params_for_create)
|
||||
|
||||
if @entry.save
|
||||
@@ -96,6 +113,12 @@ class Api::V1::TransactionsController < Api::V1::BaseController
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
if idempotency_key_requested? && account && (existing_entry = existing_idempotent_entry(account))
|
||||
render_existing_idempotent_entry(existing_entry)
|
||||
else
|
||||
raise
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "TransactionsController#create error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
@@ -178,6 +201,8 @@ end
|
||||
private
|
||||
|
||||
def set_transaction
|
||||
raise ActiveRecord::RecordNotFound unless valid_uuid?(params[:id])
|
||||
|
||||
family = current_resource_owner.family
|
||||
@transaction = family.transactions
|
||||
.joins(entry: :account)
|
||||
@@ -282,11 +307,15 @@ end
|
||||
|
||||
def transaction_params
|
||||
params.require(:transaction).permit(
|
||||
:account_id, :date, :amount, :name, :description, :notes, :currency,
|
||||
:date, :amount, :name, :description, :notes, :currency,
|
||||
:category_id, :merchant_id, :nature, tag_ids: []
|
||||
)
|
||||
end
|
||||
|
||||
def account_id_param
|
||||
params.dig(:transaction, :account_id).presence
|
||||
end
|
||||
|
||||
def entry_params_for_create
|
||||
entry_params = {
|
||||
name: transaction_params[:name] || transaction_params[:description],
|
||||
@@ -301,6 +330,10 @@ end
|
||||
tag_ids: transaction_params[:tag_ids] || []
|
||||
}
|
||||
}
|
||||
if idempotency_key_requested?
|
||||
entry_params[:external_id] = idempotency_external_id
|
||||
entry_params[:source] = idempotency_source
|
||||
end
|
||||
|
||||
entry_params.compact
|
||||
end
|
||||
@@ -339,6 +372,49 @@ end
|
||||
params.dig(:transaction, :nature).present?
|
||||
end
|
||||
|
||||
def idempotency_key_requested?
|
||||
idempotency_external_id.present?
|
||||
end
|
||||
|
||||
def idempotency_external_id
|
||||
idempotency_param_value(:external_id)
|
||||
end
|
||||
|
||||
def idempotency_source
|
||||
idempotency_source_param.presence || "api"
|
||||
end
|
||||
|
||||
def idempotency_source_param
|
||||
idempotency_param_value(:source)
|
||||
end
|
||||
|
||||
def idempotency_param_value(key)
|
||||
value = params.dig(:transaction, key)
|
||||
value.to_s.presence if value.is_a?(String) || value.is_a?(Numeric)
|
||||
end
|
||||
|
||||
def existing_idempotent_entry(account)
|
||||
account.entries.find_by(
|
||||
external_id: idempotency_external_id,
|
||||
source: idempotency_source
|
||||
)
|
||||
end
|
||||
|
||||
def render_existing_idempotent_entry(entry)
|
||||
unless entry.entryable.is_a?(Transaction)
|
||||
render json: {
|
||||
error: "validation_failed",
|
||||
message: "External ID already exists for a non-transaction entry",
|
||||
errors: [ "External ID already exists for a non-transaction entry" ]
|
||||
}, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@entry = entry
|
||||
@transaction = entry.transaction
|
||||
render :show, status: :ok
|
||||
end
|
||||
|
||||
def calculate_signed_amount
|
||||
amount = transaction_params[:amount].to_f
|
||||
nature = transaction_params[:nature]
|
||||
|
||||
@@ -269,10 +269,6 @@ class Api::V1::ValuationsController < Api::V1::BaseController
|
||||
raise InvalidFilterError, "#{key} must be an ISO 8601 date"
|
||||
end
|
||||
|
||||
def valid_uuid?(value)
|
||||
value.to_s.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
|
||||
end
|
||||
|
||||
def safe_page_param
|
||||
page = params[:page].to_i
|
||||
page > 0 ? page : 1
|
||||
|
||||
132
app/controllers/brex_items/account_flows_controller.rb
Normal file
132
app/controllers/brex_items/account_flows_controller.rb
Normal file
@@ -0,0 +1,132 @@
|
||||
class BrexItems::AccountFlowsController < ApplicationController
|
||||
before_action :require_admin!
|
||||
|
||||
def preload_accounts
|
||||
render json: brex_account_flow.preload_payload
|
||||
end
|
||||
|
||||
def select_accounts
|
||||
@accountable_type = params[:accountable_type] || "Depository"
|
||||
@return_to = safe_return_to_path
|
||||
result = brex_account_flow.select_accounts_result(accountable_type: @accountable_type)
|
||||
|
||||
return handle_brex_selection_result(result, empty_path: new_account_path, api_return_path: @return_to) unless result.success?
|
||||
|
||||
@brex_item = result.brex_item
|
||||
@available_accounts = result.available_accounts
|
||||
|
||||
render "brex_items/select_accounts", layout: false
|
||||
end
|
||||
|
||||
def link_accounts
|
||||
result = brex_account_flow.link_new_accounts_result(
|
||||
account_ids: params[:account_ids] || [],
|
||||
accountable_type: params[:accountable_type] || "Depository"
|
||||
)
|
||||
|
||||
redirect_with_navigation(result, return_to: safe_return_to_path)
|
||||
end
|
||||
|
||||
def select_existing_account
|
||||
return redirect_to accounts_path, alert: t("brex_items.select_existing_account.no_account_specified") if params[:account_id].blank?
|
||||
|
||||
@account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
return redirect_to accounts_path, alert: t("brex_items.select_existing_account.no_account_specified") unless @account
|
||||
|
||||
result = brex_account_flow.select_existing_account_result(account: @account)
|
||||
|
||||
return handle_brex_selection_result(result, empty_path: accounts_path, api_return_path: accounts_path) unless result.success?
|
||||
|
||||
@brex_item = result.brex_item
|
||||
@available_accounts = result.available_accounts
|
||||
@return_to = safe_return_to_path
|
||||
|
||||
render "brex_items/select_existing_account", layout: false
|
||||
end
|
||||
|
||||
def link_existing_account
|
||||
return redirect_to accounts_path, alert: t("brex_items.link_existing_account.no_account_specified") if params[:account_id].blank?
|
||||
|
||||
account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
return redirect_to accounts_path, alert: t("brex_items.link_existing_account.no_account_specified") unless account
|
||||
|
||||
result = brex_account_flow.link_existing_account_result(
|
||||
account: account,
|
||||
brex_account_id: params[:brex_account_id]
|
||||
)
|
||||
|
||||
redirect_with_navigation(result, return_to: safe_return_to_path)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def brex_account_flow
|
||||
@brex_account_flow ||= BrexItem::AccountFlow.new(family: Current.family, brex_item_id: params[:brex_item_id])
|
||||
end
|
||||
|
||||
def handle_brex_selection_result(result, empty_path:, api_return_path:)
|
||||
case result.status
|
||||
when :empty, :account_already_linked
|
||||
redirect_to empty_path, alert: result.message
|
||||
when :no_api_token, :select_connection
|
||||
redirect_to settings_providers_path, alert: result.message
|
||||
when :setup_required
|
||||
if turbo_frame_request?
|
||||
render partial: "brex_items/setup_required", layout: false
|
||||
else
|
||||
redirect_to settings_providers_path, alert: result.message
|
||||
end
|
||||
when :api_error, :unexpected_error
|
||||
render_api_error_partial(result.message, api_return_path)
|
||||
else
|
||||
redirect_to settings_providers_path, alert: result.message
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_with_navigation(result, return_to:)
|
||||
redirect_to navigation_path_for(result.target, return_to: return_to), result.flash_type => result.message
|
||||
end
|
||||
|
||||
def navigation_path_for(target, return_to:)
|
||||
{
|
||||
new_account: new_account_path,
|
||||
settings_providers: settings_providers_path,
|
||||
return_to_or_accounts: return_to || accounts_path
|
||||
}.fetch(target, accounts_path)
|
||||
end
|
||||
|
||||
def render_api_error_partial(error_message, return_path)
|
||||
render partial: "brex_items/api_error", locals: { error_message: error_message, return_path: return_path }, layout: false
|
||||
end
|
||||
|
||||
def safe_return_to_path
|
||||
return nil if params[:return_to].blank?
|
||||
|
||||
return_to = params[:return_to].to_s.strip
|
||||
return nil unless return_to.start_with?("/")
|
||||
|
||||
second_character = return_to[1]
|
||||
return nil if second_character.blank?
|
||||
return nil if second_character == "/" || second_character == "\\"
|
||||
return nil if second_character.match?(/[[:space:][:cntrl:]]/)
|
||||
return nil if encoded_path_separator?(return_to)
|
||||
|
||||
uri = URI.parse(return_to)
|
||||
|
||||
return nil if uri.scheme.present? || uri.host.present?
|
||||
|
||||
return_to
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def encoded_path_separator?(return_to)
|
||||
encoded_second_character = return_to[1, 3]
|
||||
return false unless encoded_second_character&.start_with?("%")
|
||||
|
||||
decoded = URI.decode_www_form_component(encoded_second_character)
|
||||
decoded == "/" || decoded == "\\"
|
||||
rescue ArgumentError
|
||||
false
|
||||
end
|
||||
end
|
||||
109
app/controllers/brex_items/account_setups_controller.rb
Normal file
109
app/controllers/brex_items/account_setups_controller.rb
Normal file
@@ -0,0 +1,109 @@
|
||||
class BrexItems::AccountSetupsController < ApplicationController
|
||||
before_action :require_admin!
|
||||
before_action :set_brex_item
|
||||
|
||||
def setup_accounts
|
||||
flow = brex_account_flow
|
||||
@api_error = flow.import_accounts_with_user_facing_error
|
||||
@brex_accounts = flow.unlinked_brex_accounts
|
||||
@account_type_options = flow.account_type_options
|
||||
@displayable_account_type_options = flow.displayable_account_type_options
|
||||
@subtype_options = flow.subtype_options
|
||||
|
||||
render "brex_items/setup_accounts"
|
||||
end
|
||||
|
||||
def complete_account_setup
|
||||
result = brex_account_flow.complete_setup_result(
|
||||
account_types: sanitized_account_types,
|
||||
account_subtypes: sanitized_account_subtypes
|
||||
)
|
||||
|
||||
unless result.success?
|
||||
redirect_to accounts_path, alert: result.message, status: :see_other
|
||||
return
|
||||
end
|
||||
|
||||
flash[:notice] = result.message
|
||||
|
||||
if turbo_frame_request?
|
||||
render_accounts_update_after_setup
|
||||
else
|
||||
redirect_to accounts_path, status: :see_other
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_brex_item
|
||||
@brex_item = Current.family.brex_items.find(params[:id])
|
||||
end
|
||||
|
||||
def brex_account_flow
|
||||
@brex_account_flow ||= BrexItem::AccountFlow.new(family: Current.family, brex_item: @brex_item)
|
||||
end
|
||||
|
||||
def render_accounts_update_after_setup
|
||||
@manual_accounts = Account.uncached { Current.family.accounts.visible_manual.order(:name).to_a }
|
||||
@brex_items = Current.family.brex_items.ordered
|
||||
|
||||
manual_accounts_stream = if @manual_accounts.any?
|
||||
turbo_stream.update(
|
||||
"manual-accounts",
|
||||
partial: "accounts/index/manual_accounts",
|
||||
locals: { accounts: @manual_accounts }
|
||||
)
|
||||
else
|
||||
turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts"))
|
||||
end
|
||||
|
||||
render turbo_stream: [
|
||||
manual_accounts_stream,
|
||||
turbo_stream.replace(
|
||||
ActionView::RecordIdentifier.dom_id(@brex_item),
|
||||
partial: "brex_items/brex_item",
|
||||
locals: { brex_item: @brex_item }
|
||||
)
|
||||
] + Array(flash_notification_stream_items)
|
||||
end
|
||||
|
||||
def sanitized_account_types
|
||||
supported_types = Provider::BrexAdapter.supported_account_types
|
||||
|
||||
setup_param_hash(:account_types, allowed_account_ids).each_with_object({}) do |(account_id, selected_type), sanitized|
|
||||
next unless allowed_account_ids.include?(account_id.to_s)
|
||||
|
||||
normalized_type = selected_type.to_s
|
||||
sanitized[account_id.to_s] = supported_types.include?(normalized_type) ? normalized_type : "skip"
|
||||
end
|
||||
end
|
||||
|
||||
def sanitized_account_subtypes
|
||||
allowed_subtypes = (Depository::SUBTYPES.keys + CreditCard::SUBTYPES.keys).map(&:to_s)
|
||||
|
||||
setup_param_hash(:account_subtypes, allowed_account_ids).each_with_object({}) do |(account_id, selected_subtype), sanitized|
|
||||
next unless allowed_account_ids.include?(account_id.to_s)
|
||||
next if selected_subtype.blank?
|
||||
next unless allowed_subtypes.include?(selected_subtype.to_s)
|
||||
|
||||
sanitized[account_id.to_s] = selected_subtype.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def setup_param_hash(key, allowed_keys)
|
||||
raw_params = params.fetch(key, {})
|
||||
return {} if raw_params.blank?
|
||||
|
||||
if raw_params.is_a?(ActionController::Parameters)
|
||||
raw_params.permit(*allowed_keys).to_h
|
||||
elsif raw_params.is_a?(Hash)
|
||||
raw_params.slice(*allowed_keys)
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def allowed_account_ids
|
||||
@allowed_account_ids ||= @brex_item.brex_accounts.pluck(:id).map(&:to_s)
|
||||
end
|
||||
end
|
||||
98
app/controllers/brex_items_controller.rb
Normal file
98
app/controllers/brex_items_controller.rb
Normal file
@@ -0,0 +1,98 @@
|
||||
class BrexItemsController < ApplicationController
|
||||
before_action :set_brex_item, only: [ :show, :edit, :update, :destroy, :sync ]
|
||||
before_action :require_admin!, only: [ :new, :create, :edit, :update, :destroy, :sync ]
|
||||
|
||||
def index
|
||||
@brex_items = Current.family.brex_items.active.ordered
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
@brex_item = Current.family.brex_items.build
|
||||
end
|
||||
|
||||
def create
|
||||
@brex_item = Current.family.brex_items.build(brex_item_params)
|
||||
@brex_item.name = t("brex_items.default_connection_name") if @brex_item.name.blank?
|
||||
|
||||
if @brex_item.save
|
||||
@brex_item.sync_later
|
||||
render_provider_panel_success(t(".success"))
|
||||
else
|
||||
render_provider_panel_error
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if BrexItem::AccountFlow.update_item_with_cache_expiration(@brex_item, family: Current.family, attributes: brex_item_params)
|
||||
render_provider_panel_success(t(".success"))
|
||||
else
|
||||
render_provider_panel_error
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@brex_item.unlink_all!(dry_run: false)
|
||||
@brex_item.destroy_later
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def sync
|
||||
@brex_item.sync_later unless @brex_item.syncing?
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to accounts_path }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_provider_panel_success(message)
|
||||
return redirect_to accounts_path, notice: message, status: :see_other unless turbo_frame_request?
|
||||
|
||||
flash.now[:notice] = message
|
||||
@brex_items = Current.family.brex_items.active.ordered.includes(:syncs, :brex_accounts)
|
||||
render_brex_provider_panel(locals: { brex_items: @brex_items }, include_flash: true)
|
||||
end
|
||||
|
||||
def render_provider_panel_error
|
||||
@error_message = @brex_item.errors.full_messages.join(", ")
|
||||
return redirect_to settings_providers_path, alert: @error_message, status: :see_other unless turbo_frame_request?
|
||||
|
||||
render_brex_provider_panel(locals: { error_message: @error_message }, status: :unprocessable_entity)
|
||||
end
|
||||
|
||||
def render_brex_provider_panel(locals:, status: :ok, include_flash: false)
|
||||
streams = [
|
||||
turbo_stream.replace(
|
||||
"brex-providers-panel",
|
||||
partial: "settings/providers/brex_panel",
|
||||
locals: locals
|
||||
)
|
||||
]
|
||||
streams += flash_notification_stream_items if include_flash
|
||||
render turbo_stream: streams, status: status
|
||||
end
|
||||
|
||||
def set_brex_item
|
||||
@brex_item = Current.family.brex_items.find(params[:id])
|
||||
end
|
||||
|
||||
def brex_item_params
|
||||
permitted = params.require(:brex_item).permit(:name, :sync_start_date, :token, :base_url)
|
||||
permitted.delete(:token) if @brex_item&.persisted? && permitted[:token].blank?
|
||||
permitted[:token] = permitted[:token].to_s.strip if permitted[:token].present?
|
||||
if permitted.key?(:base_url)
|
||||
permitted[:base_url] = permitted[:base_url].to_s.strip
|
||||
permitted[:base_url] = nil if permitted[:base_url].blank?
|
||||
end
|
||||
permitted
|
||||
end
|
||||
end
|
||||
236
app/controllers/ibkr_items_controller.rb
Normal file
236
app/controllers/ibkr_items_controller.rb
Normal file
@@ -0,0 +1,236 @@
|
||||
class IbkrItemsController < ApplicationController
|
||||
before_action :set_ibkr_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
|
||||
@ibkr_item = Current.family.ibkr_items.build(ibkr_item_params)
|
||||
@ibkr_item.name ||= t("ibkr_items.defaults.name")
|
||||
|
||||
if @ibkr_item.save
|
||||
@ibkr_item.sync_later
|
||||
|
||||
if turbo_frame_request?
|
||||
flash.now[:notice] = t(".success")
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"ibkr-providers-panel",
|
||||
partial: "settings/providers/ibkr_panel"
|
||||
),
|
||||
*flash_notification_stream_items
|
||||
]
|
||||
else
|
||||
redirect_to accounts_path, notice: t(".success"), status: :see_other
|
||||
end
|
||||
else
|
||||
@error_message = @ibkr_item.errors.full_messages.join(", ")
|
||||
|
||||
if turbo_frame_request?
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"ibkr-providers-panel",
|
||||
partial: "settings/providers/ibkr_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
|
||||
attrs = ibkr_item_params.to_h
|
||||
attrs["query_id"] = @ibkr_item.query_id if attrs["query_id"].blank?
|
||||
attrs["token"] = @ibkr_item.token if attrs["token"].blank?
|
||||
|
||||
if @ibkr_item.update(attrs.merge(status: :good))
|
||||
@ibkr_item.sync_later unless @ibkr_item.syncing?
|
||||
|
||||
if turbo_frame_request?
|
||||
flash.now[:notice] = t(".success")
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"ibkr-providers-panel",
|
||||
partial: "settings/providers/ibkr_panel"
|
||||
),
|
||||
*flash_notification_stream_items
|
||||
]
|
||||
else
|
||||
redirect_to accounts_path, notice: t(".success"), status: :see_other
|
||||
end
|
||||
else
|
||||
@error_message = @ibkr_item.errors.full_messages.join(", ")
|
||||
|
||||
if turbo_frame_request?
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"ibkr-providers-panel",
|
||||
partial: "settings/providers/ibkr_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
|
||||
begin
|
||||
@ibkr_item.unlink_all!(dry_run: false)
|
||||
rescue => e
|
||||
Rails.logger.warn("IBKR unlink during destroy failed: #{e.class} - #{e.message}")
|
||||
end
|
||||
|
||||
@ibkr_item.destroy_later
|
||||
redirect_to settings_providers_path, notice: t(".success"), status: :see_other
|
||||
end
|
||||
|
||||
def sync
|
||||
@ibkr_item.sync_later unless @ibkr_item.syncing?
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to accounts_path }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
|
||||
def select_accounts
|
||||
ibkr_item = current_ibkr_item
|
||||
unless ibkr_item
|
||||
redirect_to settings_providers_path, alert: t(".not_configured")
|
||||
return
|
||||
end
|
||||
|
||||
redirect_to setup_accounts_ibkr_item_path(ibkr_item)
|
||||
end
|
||||
|
||||
def select_existing_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
@available_ibkr_accounts = Current.family.ibkr_items
|
||||
.includes(ibkr_accounts: { account_provider: :account })
|
||||
.flat_map(&:ibkr_accounts)
|
||||
.select { |ibkr_account| ibkr_account.account_provider.nil? }
|
||||
.sort_by { |ibkr_account| ibkr_account.updated_at || ibkr_account.created_at }
|
||||
.reverse
|
||||
|
||||
render :select_existing_account, layout: false
|
||||
end
|
||||
|
||||
def link_existing_account
|
||||
account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
ibkr_account = Current.family.ibkr_items
|
||||
.joins(:ibkr_accounts)
|
||||
.where(ibkr_accounts: { id: params[:ibkr_account_id] })
|
||||
.first
|
||||
&.ibkr_accounts
|
||||
&.find_by(id: params[:ibkr_account_id])
|
||||
|
||||
if account.blank? || ibkr_account.blank?
|
||||
redirect_to settings_providers_path, alert: t(".not_found")
|
||||
return
|
||||
end
|
||||
|
||||
if account.accountable_type != "Investment" || account.account_providers.any? || account.plaid_account_id.present? || account.simplefin_account_id.present?
|
||||
redirect_to account_path(account), alert: t(".only_manual_investment")
|
||||
return
|
||||
end
|
||||
|
||||
provider = nil
|
||||
|
||||
ibkr_account.with_lock do
|
||||
if ibkr_account.current_account.present?
|
||||
redirect_to account_path(account), alert: t(".already_linked")
|
||||
return
|
||||
end
|
||||
|
||||
provider = ibkr_account.ensure_account_provider!(account)
|
||||
end
|
||||
|
||||
raise "Failed to create AccountProvider link" unless provider
|
||||
|
||||
begin
|
||||
IbkrAccount::Processor.new(ibkr_account.reload).process
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to process linked IBKR account #{ibkr_account.id}: #{e.class} - #{e.message}")
|
||||
end
|
||||
|
||||
ibkr_account.ibkr_item.sync_later unless ibkr_account.ibkr_item.syncing?
|
||||
redirect_to account_path(account), notice: t(".success"), status: :see_other
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to link existing IBKR account: #{e.class} - #{e.message}")
|
||||
redirect_to settings_providers_path, alert: t(".failed"), status: :see_other
|
||||
end
|
||||
|
||||
def setup_accounts
|
||||
@ibkr_accounts = @ibkr_item.ibkr_accounts.includes(account_provider: :account)
|
||||
@linked_accounts = @ibkr_accounts.select { |ibkr_account| ibkr_account.current_account.present? }
|
||||
@unlinked_accounts = @ibkr_accounts.reject { |ibkr_account| ibkr_account.current_account.present? }
|
||||
|
||||
no_accounts = @linked_accounts.blank? && @unlinked_accounts.blank?
|
||||
latest_sync = @ibkr_item.syncs.ordered.first
|
||||
should_sync = latest_sync.nil? || !latest_sync.completed?
|
||||
|
||||
if no_accounts && !@ibkr_item.syncing? && should_sync
|
||||
@ibkr_item.sync_later
|
||||
end
|
||||
|
||||
@linkable_accounts = Current.family.accounts
|
||||
.visible
|
||||
.where(accountable_type: "Investment")
|
||||
.left_joins(:account_providers)
|
||||
.where(account_providers: { id: nil })
|
||||
.order(:name)
|
||||
|
||||
@syncing = @ibkr_item.syncing?
|
||||
@waiting_for_sync = no_accounts && @syncing
|
||||
@no_accounts_found = no_accounts && !@syncing && @ibkr_item.last_synced_at.present?
|
||||
end
|
||||
|
||||
def complete_account_setup
|
||||
selected_accounts = Array(params[:account_ids]).reject(&:blank?)
|
||||
created_accounts = []
|
||||
|
||||
selected_accounts.each do |ibkr_account_id|
|
||||
ibkr_account = @ibkr_item.ibkr_accounts.find_by(id: ibkr_account_id)
|
||||
next unless ibkr_account
|
||||
|
||||
ibkr_account.with_lock do
|
||||
next if ibkr_account.current_account.present?
|
||||
|
||||
account = Account.create_from_ibkr_account(ibkr_account)
|
||||
ibkr_account.ensure_account_provider!(account)
|
||||
created_accounts << account
|
||||
end
|
||||
|
||||
begin
|
||||
IbkrAccount::Processor.new(ibkr_account.reload).process
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to process IBKR account #{ibkr_account.id} after setup: #{e.class} - #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
@ibkr_item.update!(pending_account_setup: @ibkr_item.unlinked_accounts_count.positive?)
|
||||
@ibkr_item.sync_later if created_accounts.any?
|
||||
|
||||
if created_accounts.any?
|
||||
redirect_to accounts_path, notice: t(".success", count: created_accounts.count), status: :see_other
|
||||
elsif selected_accounts.empty?
|
||||
redirect_to setup_accounts_ibkr_item_path(@ibkr_item), alert: t(".none_selected"), status: :see_other
|
||||
else
|
||||
redirect_to setup_accounts_ibkr_item_path(@ibkr_item), alert: t(".none_created"), status: :see_other
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_ibkr_item
|
||||
@ibkr_item = Current.family.ibkr_items.find(params[:id])
|
||||
end
|
||||
|
||||
def current_ibkr_item
|
||||
active_items = Current.family.ibkr_items.active
|
||||
|
||||
active_items.syncable.ordered.first || active_items.ordered.first
|
||||
end
|
||||
|
||||
def ibkr_item_params
|
||||
params.require(:ibkr_item).permit(:name, :query_id, :token)
|
||||
end
|
||||
end
|
||||
241
app/controllers/kraken_items_controller.rb
Normal file
241
app/controllers/kraken_items_controller.rb
Normal file
@@ -0,0 +1,241 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class KrakenItemsController < ApplicationController
|
||||
before_action :set_kraken_item, only: %i[update destroy sync setup_accounts complete_account_setup]
|
||||
before_action :require_admin!, only: %i[create select_accounts link_accounts select_existing_account link_existing_account update destroy sync setup_accounts complete_account_setup]
|
||||
|
||||
def create
|
||||
@kraken_item = Current.family.kraken_items.build(kraken_item_params)
|
||||
@kraken_item.name ||= t(".default_name")
|
||||
|
||||
if @kraken_item.save
|
||||
@kraken_item.set_kraken_institution_defaults!
|
||||
@kraken_item.sync_later
|
||||
render_panel_success(t(".success"))
|
||||
else
|
||||
render_panel_error(@kraken_item.errors.full_messages.join(", "))
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @kraken_item.update(kraken_item_params)
|
||||
render_panel_success(t(".success"))
|
||||
else
|
||||
render_panel_error(@kraken_item.errors.full_messages.join(", "))
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@kraken_item.unlink_all!(dry_run: false)
|
||||
@kraken_item.destroy_later
|
||||
redirect_to settings_providers_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def sync
|
||||
@kraken_item.sync_later unless @kraken_item.syncing?
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to settings_providers_path }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
|
||||
def select_accounts
|
||||
account_flow = kraken_item_account_flow_context
|
||||
kraken_item = account_flow[:kraken_item]
|
||||
|
||||
unless kraken_item
|
||||
redirect_to settings_providers_path, alert: kraken_item_selection_message(account_flow[:credentialed_items])
|
||||
return
|
||||
end
|
||||
|
||||
redirect_to setup_accounts_kraken_item_path(kraken_item, return_to: safe_return_to_path), status: :see_other
|
||||
end
|
||||
|
||||
def link_accounts
|
||||
kraken_item = kraken_item_account_flow_context[:kraken_item]
|
||||
unless kraken_item
|
||||
redirect_to settings_providers_path, alert: t(".select_connection")
|
||||
return
|
||||
end
|
||||
|
||||
redirect_to setup_accounts_kraken_item_path(kraken_item), status: :see_other
|
||||
end
|
||||
|
||||
def select_existing_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
account_flow = kraken_item_account_flow_context
|
||||
@kraken_item = account_flow[:kraken_item]
|
||||
|
||||
unless manual_crypto_exchange_account?(@account)
|
||||
redirect_to accounts_path, alert: t("kraken_items.link_existing_account.errors.only_manual")
|
||||
return
|
||||
end
|
||||
|
||||
unless @kraken_item
|
||||
redirect_to settings_providers_path, alert: kraken_item_selection_message(account_flow[:credentialed_items])
|
||||
return
|
||||
end
|
||||
|
||||
@available_kraken_accounts = @kraken_item.kraken_accounts
|
||||
.left_joins(:account_provider)
|
||||
.where(account_providers: { id: nil })
|
||||
.order(:name)
|
||||
|
||||
render :select_existing_account, layout: false
|
||||
end
|
||||
|
||||
def link_existing_account
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
kraken_item = kraken_item_account_flow_context[:kraken_item]
|
||||
|
||||
unless manual_crypto_exchange_account?(@account)
|
||||
return redirect_or_flash_error(t(".errors.only_manual"), account_path(@account))
|
||||
end
|
||||
|
||||
unless kraken_item
|
||||
redirect_to settings_providers_path, alert: t(".select_connection")
|
||||
return
|
||||
end
|
||||
|
||||
kraken_account = kraken_item.kraken_accounts.find_by(id: params[:kraken_account_id])
|
||||
unless kraken_account
|
||||
return redirect_or_flash_error(t(".errors.invalid_kraken_account"), account_path(@account))
|
||||
end
|
||||
if kraken_account.account_provider.present?
|
||||
return redirect_or_flash_error(t(".errors.kraken_account_already_linked"), account_path(@account))
|
||||
end
|
||||
|
||||
AccountProvider.create!(account: @account, provider: kraken_account)
|
||||
kraken_item.sync_later
|
||||
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def setup_accounts
|
||||
@kraken_accounts = unlinked_accounts_for(@kraken_item)
|
||||
end
|
||||
|
||||
def complete_account_setup
|
||||
selected_accounts = Array(params[:selected_accounts]).reject(&:blank?)
|
||||
created_accounts = []
|
||||
|
||||
selected_accounts.each do |kraken_account_id|
|
||||
kraken_account = @kraken_item.kraken_accounts.find_by(id: kraken_account_id)
|
||||
next unless kraken_account
|
||||
|
||||
kraken_account.with_lock do
|
||||
next if kraken_account.account_provider.present?
|
||||
|
||||
account = Account.create_from_kraken_account(kraken_account)
|
||||
provider_link = kraken_account.ensure_account_provider!(account)
|
||||
provider_link ? created_accounts << account : account.destroy!
|
||||
end
|
||||
|
||||
KrakenAccount::Processor.new(kraken_account.reload).process
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to setup account for KrakenAccount #{kraken_account_id}: #{e.message}")
|
||||
end
|
||||
|
||||
@kraken_item.update!(pending_account_setup: unlinked_accounts_for(@kraken_item).exists?)
|
||||
@kraken_item.sync_later if created_accounts.any?
|
||||
|
||||
notice = if created_accounts.any?
|
||||
t(".success", count: created_accounts.count)
|
||||
elsif selected_accounts.empty?
|
||||
t(".none_selected")
|
||||
else
|
||||
t(".no_accounts")
|
||||
end
|
||||
|
||||
redirect_to accounts_path, notice: notice, status: :see_other
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_kraken_item
|
||||
@kraken_item = Current.family.kraken_items.find(params[:id])
|
||||
end
|
||||
|
||||
def kraken_item_params
|
||||
permitted = params.require(:kraken_item).permit(:name, :sync_start_date, :api_key, :api_secret)
|
||||
if @kraken_item&.persisted?
|
||||
permitted.delete(:api_key) if permitted[:api_key].blank?
|
||||
permitted.delete(:api_secret) if permitted[:api_secret].blank?
|
||||
end
|
||||
permitted
|
||||
end
|
||||
|
||||
def render_panel_success(message)
|
||||
if turbo_frame_request?
|
||||
flash.now[:notice] = message
|
||||
@kraken_items = Current.family.kraken_items.active.ordered
|
||||
stream = turbo_stream.update("kraken-providers-panel", partial: "settings/providers/kraken_panel", locals: { kraken_items: @kraken_items })
|
||||
render turbo_stream: [ stream, *flash_notification_stream_items ]
|
||||
else
|
||||
redirect_to settings_providers_path, notice: message, status: :see_other
|
||||
end
|
||||
end
|
||||
|
||||
def render_panel_error(message)
|
||||
if turbo_frame_request?
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"kraken-providers-panel",
|
||||
partial: "settings/providers/kraken_panel",
|
||||
locals: { error_message: message }
|
||||
), status: :unprocessable_entity
|
||||
else
|
||||
redirect_to settings_providers_path, alert: message, status: :see_other
|
||||
end
|
||||
end
|
||||
|
||||
def kraken_item_account_flow_context
|
||||
credentialed_items = Current.family.kraken_items.active.credentials_configured.ordered.select(&:credentials_configured?)
|
||||
item = if params[:kraken_item_id].present?
|
||||
credentialed_items.find { |candidate| candidate.id.to_s == params[:kraken_item_id].to_s }
|
||||
elsif credentialed_items.one?
|
||||
credentialed_items.first
|
||||
end
|
||||
|
||||
{ kraken_item: item, credentialed_items: credentialed_items }
|
||||
end
|
||||
|
||||
def unlinked_accounts_for(kraken_item)
|
||||
kraken_item.kraken_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).order(:name)
|
||||
end
|
||||
|
||||
def kraken_item_selection_message(credentialed_items)
|
||||
if credentialed_items.count > 1 && params[:kraken_item_id].blank?
|
||||
t("kraken_items.select_accounts.select_connection")
|
||||
else
|
||||
t("kraken_items.select_accounts.no_credentials_configured")
|
||||
end
|
||||
end
|
||||
|
||||
def manual_crypto_exchange_account?(account)
|
||||
account.manual_crypto_exchange?
|
||||
end
|
||||
|
||||
def redirect_or_flash_error(message, fallback_path)
|
||||
if turbo_frame_request?
|
||||
flash.now[:alert] = message
|
||||
render turbo_stream: Array(flash_notification_stream_items)
|
||||
else
|
||||
redirect_to fallback_path, alert: message
|
||||
end
|
||||
end
|
||||
|
||||
def safe_return_to_path
|
||||
return nil if params[:return_to].blank?
|
||||
|
||||
value = params[:return_to].to_s
|
||||
uri = URI.parse(value)
|
||||
return nil if uri.scheme.present?
|
||||
return nil if uri.host.present?
|
||||
return nil unless value.start_with?("/")
|
||||
|
||||
value
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -187,9 +187,12 @@ class Settings::ProvidersController < ApplicationController
|
||||
{ key: "enable_banking", title: "Enable Banking", turbo_id: "enable_banking", partial: "enable_banking_panel" },
|
||||
{ key: "coinstats", title: "CoinStats", turbo_id: "coinstats", partial: "coinstats_panel" },
|
||||
{ key: "mercury", title: "Mercury", turbo_id: "mercury", partial: "mercury_panel" },
|
||||
{ key: "brex", title: "Brex", turbo_id: "brex", partial: "brex_panel" },
|
||||
{ key: "coinbase", title: "Coinbase", turbo_id: "coinbase", partial: "coinbase_panel" },
|
||||
{ key: "binance", title: "Binance", turbo_id: "binance", partial: "binance_panel" },
|
||||
{ key: "kraken", title: "Kraken", turbo_id: "kraken", partial: "kraken_panel" },
|
||||
{ key: "snaptrade", title: "SnapTrade", turbo_id: "snaptrade", partial: "snaptrade_panel", auto_open: "manage" },
|
||||
{ key: "ibkr", title: "Interactive Brokers", turbo_id: "ibkr", partial: "ibkr_panel" },
|
||||
{ key: "indexa_capital", title: "Indexa Capital", turbo_id: "indexa_capital", partial: "indexa_capital_panel" },
|
||||
{ key: "sophtron", title: "Sophtron", turbo_id: "sophtron", partial: "sophtron_panel" }
|
||||
].freeze
|
||||
@@ -203,9 +206,12 @@ class Settings::ProvidersController < ApplicationController
|
||||
"enable_banking" => "EnableBankingItem",
|
||||
"coinstats" => "CoinstatsItem",
|
||||
"mercury" => "MercuryItem",
|
||||
"brex" => "BrexItem",
|
||||
"coinbase" => "CoinbaseItem",
|
||||
"binance" => "BinanceItem",
|
||||
"kraken" => "KrakenItem",
|
||||
"snaptrade" => "SnaptradeItem",
|
||||
"ibkr" => "IbkrItem",
|
||||
"indexa_capital" => "IndexaCapitalItem",
|
||||
"sophtron" => "SophtronItem"
|
||||
}.freeze
|
||||
@@ -222,12 +228,18 @@ class Settings::ProvidersController < ApplicationController
|
||||
@coinstats_items = Current.family.coinstats_items.ordered
|
||||
when "mercury"
|
||||
@mercury_items = Current.family.mercury_items.active.ordered.includes(:syncs, :mercury_accounts)
|
||||
when "brex"
|
||||
@brex_items = Current.family.brex_items.active.ordered.includes(:syncs, :brex_accounts)
|
||||
when "coinbase"
|
||||
@coinbase_items = Current.family.coinbase_items.ordered
|
||||
when "binance"
|
||||
@binance_items = Current.family.binance_items.active.ordered
|
||||
when "kraken"
|
||||
@kraken_items = Current.family.kraken_items.active.ordered
|
||||
when "snaptrade"
|
||||
@snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered
|
||||
when "ibkr"
|
||||
@ibkr_items = Current.family.ibkr_items.ordered
|
||||
when "indexa_capital"
|
||||
@indexa_capital_items = Current.family.indexa_capital_items.ordered
|
||||
when "sophtron"
|
||||
@@ -251,10 +263,13 @@ class Settings::ProvidersController < ApplicationController
|
||||
@sophtron_items = Current.family.sophtron_items.where.not(user_id: [ nil, "" ], access_key: [ nil, "" ]).ordered.select(:id)
|
||||
@coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display
|
||||
@mercury_items = Current.family.mercury_items.active.ordered
|
||||
@brex_items = Current.family.brex_items.active.ordered
|
||||
@coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display
|
||||
@snaptrade_items = Current.family.snaptrade_items.ordered
|
||||
@ibkr_items = Current.family.ibkr_items.ordered.select(:id)
|
||||
@indexa_capital_items = Current.family.indexa_capital_items.ordered.select(:id)
|
||||
@binance_items = Current.family.binance_items.active.ordered
|
||||
@kraken_items = Current.family.kraken_items.active.ordered
|
||||
|
||||
@provider_sync_health = compute_provider_sync_health(family_panel_items)
|
||||
|
||||
@@ -277,9 +292,12 @@ class Settings::ProvidersController < ApplicationController
|
||||
"enable_banking" => @enable_banking_items,
|
||||
"coinstats" => @coinstats_items,
|
||||
"mercury" => @mercury_items,
|
||||
"brex" => @brex_items,
|
||||
"coinbase" => @coinbase_items,
|
||||
"binance" => @binance_items,
|
||||
"kraken" => @kraken_items,
|
||||
"snaptrade" => @snaptrade_items,
|
||||
"ibkr" => @ibkr_items,
|
||||
"indexa_capital" => @indexa_capital_items,
|
||||
"sophtron" => @sophtron_items
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class TransactionsController < ApplicationController
|
||||
)
|
||||
|
||||
@pagy, @transactions = pagy(base_scope, limit: safe_per_page)
|
||||
Transaction::ActivitySecurityPreloader.new(@transactions).preload
|
||||
|
||||
# Preload split parent data
|
||||
entry_ids = @transactions.map { |t| t.entry.id }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class TransfersController < ApplicationController
|
||||
include StreamExtensions
|
||||
|
||||
before_action :set_transfer, only: %i[show destroy update]
|
||||
before_action :set_transfer, only: %i[show destroy update mark_as_recurring]
|
||||
before_action :set_accounts, only: %i[new create]
|
||||
|
||||
def new
|
||||
@@ -11,6 +11,17 @@ class TransfersController < ApplicationController
|
||||
|
||||
def show
|
||||
@categories = Current.family.categories.alphabetically
|
||||
|
||||
# Whether the current user can hit `mark_as_recurring`: feature flag on,
|
||||
# AND they have write access to BOTH transfer endpoints. Gating the
|
||||
# view button on this avoids showing a CTA that the controller would
|
||||
# reject via `require_account_permission!` for read-only sharers.
|
||||
endpoint_ids = [ @transfer.from_account&.id, @transfer.to_account&.id ].compact
|
||||
writable_endpoint_count = Account.writable_by(Current.user).where(id: endpoint_ids).distinct.count
|
||||
@can_mark_as_recurring_transfer =
|
||||
!Current.family.recurring_transactions_disabled? &&
|
||||
endpoint_ids.size == 2 &&
|
||||
writable_endpoint_count == 2
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -75,6 +86,65 @@ class TransfersController < ApplicationController
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def mark_as_recurring
|
||||
if Current.family.recurring_transactions_disabled?
|
||||
flash[:alert] = t("recurring_transactions.transfer_feature_disabled")
|
||||
redirect_back_or_to transactions_path
|
||||
return
|
||||
end
|
||||
|
||||
source_account = @transfer.from_account
|
||||
destination_account = @transfer.to_account
|
||||
|
||||
if source_account.nil? || destination_account.nil?
|
||||
flash[:alert] = t("recurring_transactions.unexpected_error")
|
||||
redirect_back_or_to transactions_path
|
||||
return
|
||||
end
|
||||
|
||||
return unless require_account_permission!(source_account)
|
||||
return unless require_account_permission!(destination_account)
|
||||
|
||||
existing = Current.family.recurring_transactions.find_by(
|
||||
account_id: source_account.id,
|
||||
destination_account_id: destination_account.id,
|
||||
amount: @transfer.outflow_transaction.entry.amount,
|
||||
currency: @transfer.outflow_transaction.entry.currency
|
||||
)
|
||||
|
||||
if existing
|
||||
flash[:alert] = t("recurring_transactions.transfer_already_exists")
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_path }
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
RecurringTransaction.create_from_transfer(@transfer)
|
||||
flash[:notice] = t("recurring_transactions.transfer_marked_as_recurring")
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_path }
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
||||
# RecordNotUnique covers the race window between `find_by` and `create!`
|
||||
# (the partial unique index protects us at the DB level).
|
||||
flash[:alert] = t("recurring_transactions.transfer_creation_failed")
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_path }
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error(
|
||||
"transfers#mark_as_recurring failed: #{e.class} #{e.message} " \
|
||||
"(transfer=#{@transfer&.id} family=#{Current.family&.id} user=#{Current.user&.id})"
|
||||
)
|
||||
flash[:alert] = t("recurring_transactions.unexpected_error")
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transactions_path }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_transfer
|
||||
# Finds the transfer and ensures the user has access to it
|
||||
|
||||
76
app/helpers/brex_items_helper.rb
Normal file
76
app/helpers/brex_items_helper.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module BrexItemsHelper
|
||||
BrexAccountDisplay = Struct.new(
|
||||
:id,
|
||||
:name,
|
||||
:kind,
|
||||
:currency,
|
||||
:status,
|
||||
:blank_name,
|
||||
keyword_init: true
|
||||
) do
|
||||
alias_method :blank_name?, :blank_name
|
||||
end
|
||||
|
||||
def brex_account_display(account)
|
||||
data = account.with_indifferent_access
|
||||
kind = BrexAccount.kind_for(data)
|
||||
name = BrexAccount.name_for(data)
|
||||
|
||||
BrexAccountDisplay.new(
|
||||
id: data[:id],
|
||||
name: name,
|
||||
kind: kind,
|
||||
currency: BrexAccount.currency_code_from_money(data[:current_balance] || data[:available_balance] || data[:account_limit]),
|
||||
status: data[:status],
|
||||
blank_name: name.blank?
|
||||
)
|
||||
end
|
||||
|
||||
def brex_account_metadata(display)
|
||||
parts = [
|
||||
t("brex_items.account_metadata.provider"),
|
||||
display.currency,
|
||||
translated_brex_metadata_value("kinds", display.kind),
|
||||
translated_brex_metadata_value("statuses", display.status)
|
||||
].compact
|
||||
|
||||
parts.join(t("brex_items.account_metadata.separator"))
|
||||
end
|
||||
|
||||
def brex_item_render_locals(brex_item, sync_stats_map: nil, account_counts_map: nil, institutions_count_map: nil)
|
||||
counts = (account_counts_map || {})[brex_item.id] || {}
|
||||
|
||||
{
|
||||
brex_item: brex_item,
|
||||
stats: (sync_stats_map || {})[brex_item.id] || brex_item.syncs.ordered.first&.sync_stats || {},
|
||||
unlinked_count: counts[:unlinked] || brex_item.unlinked_accounts_count,
|
||||
linked_count: counts[:linked] || brex_item.linked_accounts_count,
|
||||
total_count: counts[:total] || brex_item.total_accounts_count,
|
||||
institutions_count: (institutions_count_map || {})[brex_item.id] || brex_item.connected_institutions.size
|
||||
}
|
||||
end
|
||||
|
||||
def default_brex_depository_subtype(account_name)
|
||||
normalized_name = account_name.to_s.downcase
|
||||
|
||||
if normalized_name.match?(/\bchecking\b|\bchequing\b|\bck\b|demand\s+deposit/)
|
||||
"checking"
|
||||
elsif normalized_name.match?(/\bsavings\b|\bsv\b/)
|
||||
"savings"
|
||||
elsif normalized_name.match?(/money\s+market|\bmm\b/)
|
||||
"money_market"
|
||||
else
|
||||
"checking"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def translated_brex_metadata_value(scope, value)
|
||||
key = value.to_s
|
||||
return nil if key.blank?
|
||||
|
||||
t("brex_items.#{scope}.#{key}", default: key.titleize)
|
||||
end
|
||||
end
|
||||
@@ -86,12 +86,18 @@ module SettingsHelper
|
||||
when "mercury"
|
||||
return { status: :off } unless @mercury_items&.any?
|
||||
sync_based_summary(key)
|
||||
when "brex"
|
||||
return { status: :off } unless @brex_items&.any?
|
||||
sync_based_summary(key)
|
||||
when "coinbase"
|
||||
return { status: :off } unless @coinbase_items&.any?
|
||||
sync_based_summary(key)
|
||||
when "binance"
|
||||
return { status: :off } unless @binance_items&.any?
|
||||
sync_based_summary(key)
|
||||
when "kraken"
|
||||
return { status: :off } unless @kraken_items&.any?
|
||||
sync_based_summary(key)
|
||||
when "snaptrade"
|
||||
configured_item = @snaptrade_items&.find(&:credentials_configured?)
|
||||
return { status: :off } unless configured_item
|
||||
@@ -99,6 +105,9 @@ module SettingsHelper
|
||||
return { status: :warn, meta: t("settings.providers.meta.registration_needed") }
|
||||
end
|
||||
sync_based_summary(key)
|
||||
when "ibkr"
|
||||
return { status: :off } unless @ibkr_items&.any?
|
||||
sync_based_summary(key)
|
||||
when "indexa_capital"
|
||||
return { status: :off } unless @indexa_capital_items&.any?
|
||||
sync_based_summary(key)
|
||||
|
||||
@@ -18,7 +18,8 @@ export default class extends Controller {
|
||||
// Hide all subtype selects
|
||||
const subtypeSelects = container.querySelectorAll('.subtype-select')
|
||||
subtypeSelects.forEach(select => {
|
||||
select.style.display = 'none'
|
||||
select.classList.add('hidden')
|
||||
select.style.removeProperty('display')
|
||||
// Clear the name attribute so it doesn't get submitted
|
||||
const selectElement = select.querySelector('select')
|
||||
if (selectElement) {
|
||||
@@ -34,7 +35,8 @@ export default class extends Controller {
|
||||
// Show the relevant subtype select
|
||||
const relevantSubtype = container.querySelector(`[data-type="${selectedType}"]`)
|
||||
if (relevantSubtype) {
|
||||
relevantSubtype.style.display = 'block'
|
||||
relevantSubtype.classList.remove('hidden')
|
||||
relevantSubtype.style.removeProperty('display')
|
||||
// Re-add the name attribute so it gets submitted
|
||||
const selectElement = relevantSubtype.querySelector('select')
|
||||
if (selectElement) {
|
||||
@@ -65,4 +67,4 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ export default class extends Controller {
|
||||
.append("div")
|
||||
.attr(
|
||||
"class",
|
||||
"bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0 top-0",
|
||||
"bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0 top-0 privacy-sensitive",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ class Account < ApplicationRecord
|
||||
has_many :goal_accounts, dependent: :destroy
|
||||
has_many :goals, through: :goal_accounts
|
||||
has_many :goal_contributions, dependent: :destroy
|
||||
# Inverse for recurring transfers where this account is the destination.
|
||||
# Account#recurring_transactions only matches account_id; without this
|
||||
# association, destroying the destination account would hit the FK
|
||||
# cascade silently and the AR cache wouldn't reflect the deletion.
|
||||
has_many :inbound_recurring_transfers,
|
||||
class_name: "RecurringTransaction",
|
||||
foreign_key: :destination_account_id,
|
||||
dependent: :destroy
|
||||
|
||||
monetize :balance, :cash_balance
|
||||
|
||||
@@ -269,6 +277,47 @@ class Account < ApplicationRecord
|
||||
create_and_sync(attributes, skip_initial_sync: true)
|
||||
end
|
||||
|
||||
def create_from_ibkr_account(ibkr_account)
|
||||
family = ibkr_account.ibkr_item.family
|
||||
default_name = if ibkr_account.ibkr_account_id.present?
|
||||
"Interactive Brokers (#{ibkr_account.ibkr_account_id})"
|
||||
else
|
||||
"Interactive Brokers"
|
||||
end
|
||||
|
||||
attributes = {
|
||||
family: family,
|
||||
name: default_name,
|
||||
balance: 0,
|
||||
cash_balance: 0,
|
||||
currency: ibkr_account.currency.presence || family.currency,
|
||||
accountable_type: "Investment",
|
||||
accountable_attributes: {
|
||||
subtype: "brokerage"
|
||||
}
|
||||
}
|
||||
|
||||
create_and_sync(attributes, skip_initial_sync: true)
|
||||
end
|
||||
|
||||
def create_from_kraken_account(kraken_account)
|
||||
family = kraken_account.kraken_item.family
|
||||
|
||||
attributes = {
|
||||
family: family,
|
||||
name: kraken_account.name,
|
||||
balance: (kraken_account.current_balance || 0).to_d,
|
||||
cash_balance: 0,
|
||||
currency: kraken_account.currency.presence || family.currency,
|
||||
accountable_type: "Crypto",
|
||||
accountable_attributes: {
|
||||
subtype: "exchange",
|
||||
tax_treatment: "taxable"
|
||||
}
|
||||
}
|
||||
|
||||
create_and_sync(attributes, skip_initial_sync: true)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -301,6 +350,14 @@ class Account < ApplicationRecord
|
||||
read_attribute(:institution_domain).presence || provider&.institution_domain
|
||||
end
|
||||
|
||||
def manual_crypto_exchange?
|
||||
accountable_type == "Crypto" &&
|
||||
accountable&.subtype == "exchange" &&
|
||||
account_providers.none? &&
|
||||
plaid_account_id.blank? &&
|
||||
simplefin_account_id.blank?
|
||||
end
|
||||
|
||||
def logo_url
|
||||
if institution_domain.present? && Setting.brand_fetch_client_id.present?
|
||||
logo_size = Setting.brand_fetch_logo_size
|
||||
@@ -331,15 +388,23 @@ class Account < ApplicationRecord
|
||||
end
|
||||
|
||||
def current_holdings
|
||||
holdings
|
||||
.where(currency: currency)
|
||||
.where.not(qty: 0)
|
||||
.where(
|
||||
id: holdings.select("DISTINCT ON (security_id) id")
|
||||
.where(currency: currency)
|
||||
.order(:security_id, date: :desc)
|
||||
)
|
||||
.order(amount: :desc)
|
||||
if (provider_snapshot_date = latest_provider_holdings_snapshot_date)
|
||||
holdings
|
||||
.where.not(account_provider_id: nil)
|
||||
.where(date: provider_snapshot_date)
|
||||
.where.not(qty: 0)
|
||||
.order(amount: :desc)
|
||||
else
|
||||
holdings
|
||||
.where(currency: currency)
|
||||
.where.not(qty: 0)
|
||||
.where(
|
||||
id: holdings.select("DISTINCT ON (security_id) id")
|
||||
.where(currency: currency)
|
||||
.order(:security_id, date: :desc)
|
||||
)
|
||||
.order(amount: :desc)
|
||||
end
|
||||
end
|
||||
|
||||
def latest_provider_holdings_snapshot_date
|
||||
|
||||
@@ -51,7 +51,11 @@ class Account::OpeningBalanceManager
|
||||
end
|
||||
|
||||
def oldest_entry_date
|
||||
@oldest_entry_date ||= account.entries.minimum(:date)
|
||||
if opening_anchor_valuation&.entry
|
||||
account.entries.where.not(id: opening_anchor_valuation.entry.id).minimum(:date)
|
||||
else
|
||||
account.entries.minimum(:date)
|
||||
end
|
||||
end
|
||||
|
||||
def default_date
|
||||
|
||||
@@ -109,8 +109,28 @@ class Account::ProviderImportAdapter
|
||||
end
|
||||
|
||||
if pending_match
|
||||
old_pending_external_id = pending_match.external_id
|
||||
pending_entry_date = pending_match.date
|
||||
entry = pending_match
|
||||
entry.assign_attributes(external_id: external_id)
|
||||
|
||||
# Clear the pending flag so this entry no longer shows as pending after being claimed
|
||||
# by a booked transaction. Also record the old external_id so the sync engine can
|
||||
# exclude it from re-import (preventing the old pending from being recreated on the
|
||||
# next sync when the stored raw payload still contains the pending transaction data).
|
||||
if entry.entryable.is_a?(Transaction)
|
||||
ex = (entry.transaction.extra || {}).deep_dup
|
||||
Transaction::PENDING_PROVIDERS.each do |provider|
|
||||
next unless ex.key?(provider)
|
||||
ex[provider].delete("pending")
|
||||
ex.delete(provider) if ex[provider].empty?
|
||||
end
|
||||
if old_pending_external_id.present?
|
||||
existing_claims = Array.wrap(ex["auto_claimed_pending_ids"])
|
||||
ex["auto_claimed_pending_ids"] = (existing_claims + [ old_pending_external_id ]).uniq
|
||||
end
|
||||
entry.transaction.extra = ex
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -120,7 +140,7 @@ class Account::ProviderImportAdapter
|
||||
entry.assign_attributes(
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: date
|
||||
date: pending_entry_date || date
|
||||
)
|
||||
|
||||
# Use enrichment pattern to respect user overrides
|
||||
@@ -551,8 +571,9 @@ class Account::ProviderImportAdapter
|
||||
# @param external_id [String, nil] Provider's unique ID (optional, for deduplication)
|
||||
# @param source [String] Provider name
|
||||
# @param activity_label [String, nil] Investment activity label (e.g., "Buy", "Sell", "Reinvestment")
|
||||
# @param exchange_rate [BigDecimal, Numeric, nil] Optional provider-supplied FX rate into the account currency
|
||||
# @return [Entry] The created entry with trade
|
||||
def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:, activity_label: nil)
|
||||
def import_trade(security:, quantity:, price:, amount:, currency:, date:, name: nil, external_id: nil, source:, activity_label: nil, exchange_rate: nil)
|
||||
raise ArgumentError, "security is required" if security.nil?
|
||||
raise ArgumentError, "source is required" if source.blank?
|
||||
|
||||
@@ -585,13 +606,16 @@ class Account::ProviderImportAdapter
|
||||
end
|
||||
|
||||
# Always update Trade attributes (works for both new and existing records)
|
||||
entry.entryable.assign_attributes(
|
||||
trade_attributes = {
|
||||
security: security,
|
||||
qty: quantity,
|
||||
price: price,
|
||||
currency: currency,
|
||||
investment_activity_label: activity_label || (quantity > 0 ? "Buy" : "Sell")
|
||||
)
|
||||
}
|
||||
trade_attributes[:exchange_rate] = exchange_rate unless exchange_rate.nil?
|
||||
|
||||
entry.entryable.assign_attributes(trade_attributes)
|
||||
|
||||
entry.assign_attributes(
|
||||
date: date,
|
||||
|
||||
@@ -9,6 +9,7 @@ class Account::Syncer
|
||||
Rails.logger.info("Processing balances (#{account.linked? ? 'reverse' : 'forward'})")
|
||||
import_market_data
|
||||
materialize_balances(window_start_date: sync.window_start_date)
|
||||
apply_provider_balance_overrides
|
||||
end
|
||||
|
||||
def perform_post_sync
|
||||
@@ -34,4 +35,16 @@ class Account::Syncer
|
||||
Rails.logger.error("Error syncing market data for account #{account.id}: #{e.message}")
|
||||
Sentry.capture_exception(e)
|
||||
end
|
||||
|
||||
def apply_provider_balance_overrides
|
||||
return unless account.linked_to?("IbkrAccount")
|
||||
|
||||
ibkr_account = account.account_providers.find_by(provider_type: "IbkrAccount")&.provider
|
||||
return unless ibkr_account
|
||||
|
||||
IbkrAccount::HistoricalBalancesSync.new(ibkr_account).sync!
|
||||
rescue => e
|
||||
Rails.logger.error("Error syncing IBKR historical balances for account #{account.id}: #{e.class} - #{e.message}")
|
||||
Sentry.capture_exception(e)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,10 +37,7 @@ class Balance::SyncCache
|
||||
@converted_entries ||= account.entries.excluding_split_parents.includes(:entryable).order(:date).to_a.map do |e|
|
||||
converted_entry = e.dup
|
||||
|
||||
# Extract custom exchange rate if present on Transaction
|
||||
custom_rate = if e.entryable.is_a?(Transaction)
|
||||
e.entryable.extra&.dig("exchange_rate")
|
||||
end
|
||||
custom_rate = e.entryable.exchange_rate if e.entryable.respond_to?(:exchange_rate)
|
||||
|
||||
# Use Money#exchange_to with custom rate if available, standard lookup otherwise
|
||||
converted_entry.amount = converted_entry.amount_money.exchange_to(
|
||||
|
||||
204
app/models/brex_account.rb
Normal file
204
app/models/brex_account.rb
Normal file
@@ -0,0 +1,204 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BrexAccount < ApplicationRecord
|
||||
include CurrencyNormalizable, Encryptable
|
||||
|
||||
CARD_PRIMARY_ACCOUNT_ID = "card_primary"
|
||||
|
||||
if encryption_ready?
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_transactions_payload
|
||||
end
|
||||
|
||||
belongs_to :brex_item
|
||||
|
||||
has_one :account_provider, as: :provider, dependent: :destroy
|
||||
has_one :account, through: :account_provider, source: :account
|
||||
|
||||
validates :name, :currency, presence: true
|
||||
validates :account_id, uniqueness: { scope: :brex_item_id }
|
||||
validates :account_kind, inclusion: { in: %w[cash card] }
|
||||
|
||||
def self.card_account_id
|
||||
CARD_PRIMARY_ACCOUNT_ID
|
||||
end
|
||||
|
||||
def self.kind_for(account_data)
|
||||
return account_data.account_kind if account_data.respond_to?(:account_kind)
|
||||
|
||||
data = account_data.with_indifferent_access
|
||||
kind = data[:account_kind].presence || data[:kind].presence || "cash"
|
||||
kind.to_s == "credit_card" ? "card" : kind.to_s
|
||||
end
|
||||
|
||||
def self.name_for(account_data)
|
||||
data = account_data.with_indifferent_access
|
||||
kind = kind_for(data)
|
||||
|
||||
if kind == "card"
|
||||
data[:name].presence || I18n.t("brex_items.default_card_name", default: "Brex Card")
|
||||
else
|
||||
data[:name].presence || data[:display_name].presence || I18n.t("brex_items.default_cash_name", id: data[:id], default: "Brex Cash #{data[:id]}")
|
||||
end
|
||||
end
|
||||
|
||||
def self.currency_for(account_data)
|
||||
data = account_data.with_indifferent_access
|
||||
currency_code_from_money(data[:current_balance] || data[:available_balance] || data[:account_limit])
|
||||
end
|
||||
|
||||
def self.default_account_type_for(account_data)
|
||||
kind_for(account_data) == "card" ? "CreditCard" : "Depository"
|
||||
end
|
||||
|
||||
def self.default_accountable_attributes(accountable_type)
|
||||
case accountable_type
|
||||
when "CreditCard"
|
||||
{ subtype: CreditCard::DEFAULT_SUBTYPE }
|
||||
when "Depository"
|
||||
{ subtype: Depository::DEFAULT_SUBTYPE }
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def self.money_to_decimal(money_payload)
|
||||
return nil if money_payload.blank?
|
||||
|
||||
payload = money_payload.is_a?(Hash) ? money_payload.with_indifferent_access : { amount: money_payload, currency: "USD" }
|
||||
amount = payload[:amount]
|
||||
return nil if amount.nil?
|
||||
|
||||
currency = currency_code_from_money(payload)
|
||||
divisor = Money::Currency.new(currency).minor_unit_conversion
|
||||
BigDecimal(amount.to_s) / BigDecimal(divisor.to_s)
|
||||
rescue Money::Currency::UnknownCurrencyError, ArgumentError
|
||||
Rails.logger.warn("Invalid Brex money payload #{money_payload.inspect}, defaulting conversion to USD")
|
||||
begin
|
||||
safe_amount = BigDecimal(payload[:amount].to_s)
|
||||
safe_amount / BigDecimal(Money::Currency.new("USD").minor_unit_conversion.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
BigDecimal("0")
|
||||
end
|
||||
end
|
||||
|
||||
def self.currency_code_from_money(money_payload)
|
||||
payload = money_payload.is_a?(Hash) ? money_payload.with_indifferent_access : {}
|
||||
currency = payload[:currency].presence || "USD"
|
||||
Money::Currency.new(currency).iso_code
|
||||
rescue Money::Currency::UnknownCurrencyError
|
||||
"USD"
|
||||
end
|
||||
|
||||
def self.sanitize_payload(payload)
|
||||
case payload
|
||||
when Array
|
||||
payload.map { |value| sanitize_payload(value) }
|
||||
when Hash
|
||||
payload.each_with_object({}) do |(key, value), sanitized|
|
||||
key_string = key.to_s
|
||||
normalized_key = key_string.downcase
|
||||
|
||||
if sensitive_number_key?(normalized_key)
|
||||
sanitized["#{key_string}_last4"] = last_four(value)
|
||||
elsif normalized_key == "card_metadata"
|
||||
sanitized[key_string] = sanitize_card_metadata(value)
|
||||
elsif sensitive_secret_key?(normalized_key)
|
||||
sanitized[key_string] = "[FILTERED]"
|
||||
else
|
||||
sanitized[key_string] = sanitize_payload(value)
|
||||
end
|
||||
end
|
||||
else
|
||||
payload
|
||||
end
|
||||
end
|
||||
|
||||
def self.last_four(value)
|
||||
digits = value.to_s.gsub(/\D/, "")
|
||||
digits.last(4) if digits.present?
|
||||
end
|
||||
|
||||
def self.sanitize_card_metadata(value)
|
||||
return nil unless value.is_a?(Hash)
|
||||
|
||||
metadata = value.with_indifferent_access
|
||||
{
|
||||
"card_id" => metadata[:card_id].presence || metadata[:id].presence,
|
||||
"card_name" => metadata[:card_name].presence || metadata[:name].presence,
|
||||
"card_type" => metadata[:card_type].presence || metadata[:type].presence,
|
||||
"last_four" => last_four(metadata[:last_four].presence || metadata[:last4].presence || metadata[:card_last_four].presence)
|
||||
}.compact
|
||||
end
|
||||
|
||||
def current_account
|
||||
account
|
||||
end
|
||||
|
||||
def linked_account
|
||||
account
|
||||
end
|
||||
|
||||
def cash?
|
||||
account_kind == "cash"
|
||||
end
|
||||
|
||||
def card?
|
||||
account_kind == "card"
|
||||
end
|
||||
|
||||
def upsert_brex_snapshot!(account_snapshot)
|
||||
snapshot = account_snapshot.with_indifferent_access
|
||||
kind = snapshot[:account_kind].presence || snapshot[:kind].presence || "cash"
|
||||
kind = "card" if kind.to_s == "credit_card"
|
||||
|
||||
update!(
|
||||
current_balance: self.class.money_to_decimal(snapshot[:current_balance]),
|
||||
available_balance: self.class.money_to_decimal(snapshot[:available_balance]),
|
||||
account_limit: self.class.money_to_decimal(snapshot[:account_limit]),
|
||||
currency: self.class.currency_code_from_money(snapshot[:current_balance] || snapshot[:available_balance] || snapshot[:account_limit]),
|
||||
name: self.class.name_for(snapshot.merge(account_kind: kind)),
|
||||
account_id: snapshot[:id]&.to_s,
|
||||
account_kind: kind,
|
||||
account_status: snapshot[:status],
|
||||
account_type: snapshot[:type],
|
||||
provider: "brex",
|
||||
institution_metadata: build_institution_metadata(snapshot, kind),
|
||||
raw_payload: self.class.sanitize_payload(account_snapshot)
|
||||
)
|
||||
end
|
||||
|
||||
def upsert_brex_transactions_snapshot!(transactions_snapshot)
|
||||
update!(
|
||||
raw_transactions_payload: self.class.sanitize_payload(transactions_snapshot)
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.sensitive_number_key?(normalized_key)
|
||||
normalized_key.in?(%w[account_number routing_number pan primary_account_number card_number])
|
||||
end
|
||||
|
||||
def self.sensitive_secret_key?(normalized_key)
|
||||
normalized_key.include?("token") ||
|
||||
normalized_key.include?("secret") ||
|
||||
normalized_key.in?(%w[api_key access_key authorization cvc cvv security_code])
|
||||
end
|
||||
private_class_method :sensitive_number_key?, :sensitive_secret_key?
|
||||
|
||||
def build_institution_metadata(snapshot, kind)
|
||||
{
|
||||
name: "Brex",
|
||||
domain: "brex.com",
|
||||
url: "https://brex.com",
|
||||
account_kind: kind,
|
||||
account_type: snapshot[:type],
|
||||
primary: snapshot[:primary],
|
||||
account_number_last4: self.class.last_four(snapshot[:account_number]),
|
||||
routing_number_last4: self.class.last_four(snapshot[:routing_number]),
|
||||
status: snapshot[:status],
|
||||
current_statement_period: self.class.sanitize_payload(snapshot[:current_statement_period])
|
||||
}.compact
|
||||
end
|
||||
end
|
||||
78
app/models/brex_account/processor.rb
Normal file
78
app/models/brex_account/processor.rb
Normal file
@@ -0,0 +1,78 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BrexAccount::Processor
|
||||
include CurrencyNormalizable
|
||||
|
||||
attr_reader :brex_account
|
||||
|
||||
def initialize(brex_account)
|
||||
@brex_account = brex_account
|
||||
end
|
||||
|
||||
def process
|
||||
unless brex_account.current_account.present?
|
||||
Rails.logger.info "BrexAccount::Processor - No linked account for brex_account #{brex_account.id}, skipping processing"
|
||||
return
|
||||
end
|
||||
|
||||
process_account!
|
||||
process_transactions
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "BrexAccount::Processor - Failed to process account #{brex_account.id}: #{e.message}"
|
||||
report_exception(e, "account")
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_account!
|
||||
account = brex_account.current_account
|
||||
balance = brex_account.current_balance
|
||||
currency = parse_currency(brex_account.currency)
|
||||
|
||||
if balance.nil?
|
||||
Rails.logger.warn "BrexAccount::Processor - current_balance is nil for brex_account #{brex_account.id}, defaulting to 0"
|
||||
balance = 0
|
||||
end
|
||||
|
||||
if currency.nil?
|
||||
Rails.logger.warn "BrexAccount::Processor - currency parse failed for brex_account #{brex_account.id}: #{brex_account.currency.inspect}, defaulting to USD"
|
||||
Sentry.capture_message("BrexAccount currency parse failed", level: :warning) do |scope|
|
||||
scope.set_tags(brex_account_id: brex_account.id)
|
||||
scope.set_context("brex_account", {
|
||||
id: brex_account.id,
|
||||
currency: brex_account.currency
|
||||
})
|
||||
end
|
||||
currency = "USD"
|
||||
end
|
||||
|
||||
account.update!(
|
||||
balance: balance,
|
||||
cash_balance: balance,
|
||||
currency: currency
|
||||
)
|
||||
|
||||
if account.accountable_type == "CreditCard" && brex_account.available_balance.present?
|
||||
account.accountable.update!(available_credit: brex_account.available_balance)
|
||||
end
|
||||
end
|
||||
|
||||
# Transaction import errors are logged and swallowed so balance sync can continue.
|
||||
def process_transactions
|
||||
BrexAccount::Transactions::Processor.new(brex_account).process
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "BrexAccount::Processor - Failed to process transactions for brex_account #{brex_account.id}: #{e.message}"
|
||||
Rails.logger.error Array(e.backtrace).first(10).join("\n")
|
||||
report_exception(e, "transactions")
|
||||
end
|
||||
|
||||
def report_exception(error, context)
|
||||
Sentry.capture_exception(error) do |scope|
|
||||
scope.set_tags(
|
||||
brex_account_id: brex_account.id,
|
||||
context: context
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
83
app/models/brex_account/transactions/processor.rb
Normal file
83
app/models/brex_account/transactions/processor.rb
Normal file
@@ -0,0 +1,83 @@
|
||||
class BrexAccount::Transactions::Processor
|
||||
attr_reader :brex_account
|
||||
|
||||
def initialize(brex_account)
|
||||
@brex_account = brex_account
|
||||
end
|
||||
|
||||
def process
|
||||
unless brex_account.raw_transactions_payload.present?
|
||||
Rails.logger.info "BrexAccount::Transactions::Processor - No transactions in raw_transactions_payload for brex_account #{brex_account.id}"
|
||||
return { success: true, total: 0, imported: 0, skipped: 0, failed: 0, errors: [], skipped_transactions: [] }
|
||||
end
|
||||
|
||||
total_count = brex_account.raw_transactions_payload.count
|
||||
Rails.logger.info "BrexAccount::Transactions::Processor - Processing #{total_count} transactions for brex_account #{brex_account.id}"
|
||||
|
||||
imported_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
errors = []
|
||||
skipped = []
|
||||
|
||||
# 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.
|
||||
brex_account.raw_transactions_payload.each_with_index do |transaction_data, index|
|
||||
begin
|
||||
result = BrexEntry::Processor.new(
|
||||
transaction_data,
|
||||
brex_account: brex_account
|
||||
).process
|
||||
|
||||
if result == :skipped
|
||||
skipped_count += 1
|
||||
skipped << { index: index, transaction_id: transaction_id_for(transaction_data), reason: "No linked account" }
|
||||
elsif result.nil?
|
||||
failed_count += 1
|
||||
errors << { index: index, transaction_id: transaction_id_for(transaction_data), error: "No transaction imported" }
|
||||
else
|
||||
imported_count += 1
|
||||
end
|
||||
rescue ArgumentError => e
|
||||
# Validation error - log and continue
|
||||
failed_count += 1
|
||||
transaction_id = transaction_id_for(transaction_data)
|
||||
error_message = "Validation error: #{e.message}"
|
||||
Rails.logger.error "BrexAccount::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_id_for(transaction_data)
|
||||
error_message = "#{e.class}: #{e.message}"
|
||||
Rails.logger.error "BrexAccount::Transactions::Processor - Error processing transaction #{transaction_id}: #{error_message}"
|
||||
Rails.logger.error Array(e.backtrace).first(10).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,
|
||||
skipped_transactions: skipped
|
||||
}
|
||||
|
||||
if failed_count > 0
|
||||
Rails.logger.warn "BrexAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions"
|
||||
else
|
||||
Rails.logger.info "BrexAccount::Transactions::Processor - Successfully processed #{imported_count} transactions"
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transaction_id_for(transaction_data)
|
||||
transaction_data&.dig(:id) || transaction_data&.dig("id") || "unknown"
|
||||
end
|
||||
end
|
||||
180
app/models/brex_entry/processor.rb
Normal file
180
app/models/brex_entry/processor.rb
Normal file
@@ -0,0 +1,180 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "digest/md5"
|
||||
|
||||
class BrexEntry::Processor
|
||||
include CurrencyNormalizable
|
||||
|
||||
def initialize(brex_transaction, brex_account:)
|
||||
@brex_transaction = brex_transaction
|
||||
@brex_account = brex_account
|
||||
end
|
||||
|
||||
def process
|
||||
cached_external_id = nil
|
||||
cached_external_id = external_id
|
||||
|
||||
unless account.present?
|
||||
Rails.logger.warn "BrexEntry::Processor - No linked account for brex_account #{brex_account.id}, skipping transaction #{cached_external_id}"
|
||||
return :skipped
|
||||
end
|
||||
|
||||
import_adapter.import_transaction(
|
||||
external_id: cached_external_id,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: name,
|
||||
source: "brex",
|
||||
merchant: merchant,
|
||||
notes: notes,
|
||||
extra: extra
|
||||
)
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error "BrexEntry::Processor - Validation error for transaction #{cached_external_id || safe_external_id}: #{e.message}"
|
||||
raise
|
||||
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
|
||||
Rails.logger.error "BrexEntry::Processor - Failed to save transaction #{cached_external_id || safe_external_id}: #{e.message}"
|
||||
raise StandardError.new("Failed to import transaction: #{e.message}")
|
||||
rescue => e
|
||||
Rails.logger.error "BrexEntry::Processor - Unexpected error processing transaction #{cached_external_id || safe_external_id}: #{e.class} - #{e.message}"
|
||||
Rails.logger.error Array(e.backtrace).join("\n")
|
||||
raise StandardError.new("Unexpected error importing transaction: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :brex_transaction, :brex_account
|
||||
|
||||
def import_adapter
|
||||
@import_adapter ||= Account::ProviderImportAdapter.new(account)
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= brex_account.current_account
|
||||
end
|
||||
|
||||
def data
|
||||
@data ||= brex_transaction.with_indifferent_access
|
||||
end
|
||||
|
||||
def external_id
|
||||
id = data[:id].presence
|
||||
raise ArgumentError, "Brex transaction missing required field 'id'" unless id
|
||||
|
||||
"brex_#{id}"
|
||||
end
|
||||
|
||||
def safe_external_id
|
||||
external_id
|
||||
rescue ArgumentError
|
||||
"brex_unknown"
|
||||
end
|
||||
|
||||
def name
|
||||
data[:description].presence ||
|
||||
merchant_payload[:raw_descriptor].presence ||
|
||||
merchant_payload[:name].presence ||
|
||||
I18n.t("brex_items.entries.default_name")
|
||||
end
|
||||
|
||||
def notes
|
||||
note_parts = []
|
||||
note_parts << data[:type] if data[:type].present?
|
||||
note_parts << data[:expense_id] if data[:expense_id].present?
|
||||
note_parts.any? ? note_parts.join(" - ") : nil
|
||||
end
|
||||
|
||||
def merchant
|
||||
merchant_name = merchant_payload[:raw_descriptor].presence || merchant_payload[:name].presence
|
||||
return @merchant if instance_variable_defined?(:@merchant)
|
||||
return @merchant = nil if merchant_name.blank?
|
||||
|
||||
merchant_name = merchant_name.to_s.strip
|
||||
return @merchant = nil if merchant_name.blank?
|
||||
|
||||
merchant_id = Digest::MD5.hexdigest(merchant_name.downcase)
|
||||
|
||||
@merchant = import_adapter.find_or_create_merchant(
|
||||
provider_merchant_id: "brex_merchant_#{merchant_id}",
|
||||
name: merchant_name,
|
||||
source: "brex"
|
||||
)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "BrexEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}"
|
||||
@merchant = nil
|
||||
end
|
||||
|
||||
def merchant_payload
|
||||
@merchant_payload ||= begin
|
||||
payload = data[:merchant]
|
||||
payload.is_a?(Hash) ? payload.with_indifferent_access : {}
|
||||
end
|
||||
end
|
||||
|
||||
def amount
|
||||
BrexAccount.money_to_decimal(data[:amount]) || BigDecimal("0")
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error "Failed to parse Brex transaction amount: #{data[:amount].inspect} - #{e.message}"
|
||||
raise
|
||||
end
|
||||
|
||||
def currency
|
||||
amount_currency = transaction_amount_currency
|
||||
log_invalid_currency(amount_currency) if amount_currency.blank? && data[:amount].present?
|
||||
|
||||
parse_currency(amount_currency) ||
|
||||
parse_currency(brex_account.currency) ||
|
||||
"USD"
|
||||
end
|
||||
|
||||
def transaction_amount_currency
|
||||
amount_payload = data[:amount]
|
||||
return nil unless amount_payload.is_a?(Hash)
|
||||
|
||||
amount_payload.with_indifferent_access[:currency]
|
||||
end
|
||||
|
||||
def log_invalid_currency(currency_value)
|
||||
Rails.logger.warn(
|
||||
"Invalid Brex currency #{currency_value.inspect} for transaction #{data[:id].presence || 'unknown'} " \
|
||||
"on brex_account #{brex_account.id} amount=#{data[:amount].inspect} account_currency=#{brex_account.currency.inspect}; defaulting to fallback"
|
||||
)
|
||||
end
|
||||
|
||||
def date
|
||||
date_value = data[:posted_at_date].presence || data[:initiated_at_date].presence
|
||||
|
||||
case date_value
|
||||
when String
|
||||
Date.parse(date_value)
|
||||
when Integer, Float
|
||||
Time.at(date_value).to_date
|
||||
when Time, DateTime
|
||||
date_value.to_date
|
||||
when Date
|
||||
date_value
|
||||
else
|
||||
raise ArgumentError, "Invalid date format: #{date_value.inspect}"
|
||||
end
|
||||
rescue ArgumentError, TypeError => e
|
||||
Rails.logger.error("Failed to parse Brex transaction date '#{date_value}': #{e.message}")
|
||||
raise ArgumentError, "Unable to parse transaction date: #{date_value.inspect}"
|
||||
end
|
||||
|
||||
def extra
|
||||
{
|
||||
brex: {
|
||||
transaction_id: data[:id],
|
||||
account_kind: brex_account.account_kind,
|
||||
type: data[:type],
|
||||
card_id: data[:card_id],
|
||||
transfer_id: data[:transfer_id],
|
||||
expense_id: data[:expense_id],
|
||||
card_transaction_operation_reference_id: data[:card_transaction_operation_reference_id],
|
||||
initiated_at_date: data[:initiated_at_date],
|
||||
posted_at_date: data[:posted_at_date],
|
||||
merchant: BrexAccount.sanitize_payload(data[:merchant])
|
||||
}.compact
|
||||
}
|
||||
end
|
||||
end
|
||||
197
app/models/brex_item.rb
Normal file
197
app/models/brex_item.rb
Normal file
@@ -0,0 +1,197 @@
|
||||
class BrexItem < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking, Encryptable
|
||||
|
||||
BLANK_TOKEN_SENTINELS = [ "", " ", " ", " ", "\t", "\n", "\r" ].freeze
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
if encryption_ready?
|
||||
encrypts :token, deterministic: true
|
||||
encrypts :raw_payload
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
validates :token, presence: true, on: :create
|
||||
validate :base_url_must_be_official_brex_url
|
||||
validate :token_cannot_be_blank_when_changed
|
||||
before_validation :normalize_token
|
||||
before_validation :normalize_base_url
|
||||
|
||||
belongs_to :family
|
||||
has_one_attached :logo, dependent: :purge_later
|
||||
|
||||
has_many :brex_accounts, dependent: :destroy
|
||||
has_many :accounts, through: :brex_accounts
|
||||
|
||||
scope :active, -> { where(scheduled_for_deletion: false) }
|
||||
scope :syncable, -> { active }
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
scope :needs_update, -> { where(status: :requires_update) }
|
||||
scope :with_credentials, -> { where.not(token: [ nil, *BLANK_TOKEN_SENTINELS ]).where("BTRIM(token) <> ''") }
|
||||
|
||||
def self.resolve_for(family:, brex_item_id: nil)
|
||||
normalized_id = brex_item_id.to_s.strip.presence
|
||||
|
||||
if normalized_id.present?
|
||||
return family.brex_items.active.with_credentials.find_by(id: normalized_id)
|
||||
end
|
||||
|
||||
credentialed_items = family.brex_items.active.with_credentials.ordered
|
||||
credentialed_items.first if credentialed_items.one?
|
||||
end
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
def import_latest_brex_data(sync_start_date: nil)
|
||||
provider = brex_provider
|
||||
unless provider
|
||||
Rails.logger.error "BrexItem #{id} - Cannot import: provider is not configured"
|
||||
raise Provider::Brex::BrexError.new("Brex provider is not configured", :not_configured)
|
||||
end
|
||||
|
||||
BrexItem::Importer.new(self, brex_provider: provider, sync_start_date: sync_start_date).import
|
||||
rescue => e
|
||||
Rails.logger.error "BrexItem #{id} - Failed to import data: #{e.message}"
|
||||
raise
|
||||
end
|
||||
|
||||
def process_accounts
|
||||
return [] if brex_accounts.empty?
|
||||
|
||||
results = []
|
||||
brex_accounts.joins(:account).includes(:account).merge(Account.visible).each do |brex_account|
|
||||
begin
|
||||
result = BrexAccount::Processor.new(brex_account).process
|
||||
results << { brex_account_id: brex_account.id, success: true, result: result }
|
||||
rescue => e
|
||||
Rails.logger.error "BrexItem #{id} - Failed to process account #{brex_account.id}: #{e.message}"
|
||||
results << { brex_account_id: brex_account.id, success: false, error: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
||||
return [] if accounts.empty?
|
||||
|
||||
results = []
|
||||
accounts.visible.each do |account|
|
||||
begin
|
||||
account.sync_later(
|
||||
parent_sync: parent_sync,
|
||||
window_start_date: window_start_date,
|
||||
window_end_date: window_end_date
|
||||
)
|
||||
results << { account_id: account.id, success: true }
|
||||
rescue => e
|
||||
Rails.logger.error "BrexItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}"
|
||||
results << { account_id: account.id, success: false, error: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
def upsert_brex_snapshot!(accounts_snapshot)
|
||||
update!(raw_payload: BrexAccount.sanitize_payload(accounts_snapshot))
|
||||
end
|
||||
|
||||
def has_completed_initial_setup?
|
||||
# Setup is complete if we have any linked accounts
|
||||
accounts.any?
|
||||
end
|
||||
|
||||
def sync_status_summary
|
||||
total_accounts = total_accounts_count
|
||||
linked_count = linked_accounts_count
|
||||
unlinked_count = unlinked_accounts_count
|
||||
|
||||
if total_accounts == 0
|
||||
I18n.t("brex_items.sync_status.no_accounts")
|
||||
elsif unlinked_count == 0
|
||||
I18n.t("brex_items.sync_status.all_synced", count: linked_count)
|
||||
else
|
||||
I18n.t("brex_items.sync_status.partial_setup", synced: linked_count, pending: unlinked_count)
|
||||
end
|
||||
end
|
||||
|
||||
def linked_accounts_count
|
||||
brex_accounts.joins(:account_provider).count
|
||||
end
|
||||
|
||||
def unlinked_accounts_count
|
||||
brex_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
|
||||
end
|
||||
|
||||
def total_accounts_count
|
||||
brex_accounts.count
|
||||
end
|
||||
|
||||
def institution_display_name
|
||||
institution_name.presence || institution_domain.presence || name
|
||||
end
|
||||
|
||||
def connected_institutions
|
||||
brex_accounts.where.not(institution_metadata: nil)
|
||||
.pluck(:institution_metadata)
|
||||
.compact
|
||||
.uniq { |inst| inst["name"] || inst["institution_name"] }
|
||||
end
|
||||
|
||||
def institution_summary
|
||||
institutions = connected_institutions
|
||||
case institutions.count
|
||||
when 0
|
||||
I18n.t("brex_items.institution_summary.none")
|
||||
when 1
|
||||
name = institutions.first["name"] ||
|
||||
institutions.first["institution_name"] ||
|
||||
I18n.t("brex_items.institution_summary.count", count: 1)
|
||||
I18n.t("brex_items.institution_summary.one", name: name)
|
||||
else
|
||||
I18n.t("brex_items.institution_summary.count", count: institutions.count)
|
||||
end
|
||||
end
|
||||
|
||||
def credentials_configured?
|
||||
token.to_s.strip.present?
|
||||
end
|
||||
|
||||
def effective_base_url
|
||||
return Provider::Brex::DEFAULT_BASE_URL if base_url.blank?
|
||||
|
||||
Provider::Brex.normalize_base_url(base_url)
|
||||
end
|
||||
|
||||
private
|
||||
def normalize_token
|
||||
self.token = token&.strip
|
||||
end
|
||||
|
||||
def token_cannot_be_blank_when_changed
|
||||
return unless persisted? && will_save_change_to_token? && token.blank?
|
||||
|
||||
errors.add(:token, :blank)
|
||||
end
|
||||
|
||||
def normalize_base_url
|
||||
stripped = base_url.to_s.strip
|
||||
if stripped.blank?
|
||||
self.base_url = nil
|
||||
return
|
||||
end
|
||||
|
||||
normalized = Provider::Brex.normalize_base_url(stripped)
|
||||
self.base_url = normalized if normalized.present?
|
||||
end
|
||||
|
||||
def base_url_must_be_official_brex_url
|
||||
return if base_url.blank? || Provider::Brex.allowed_base_url?(base_url)
|
||||
|
||||
errors.add(:base_url, :official_hosts_only)
|
||||
end
|
||||
end
|
||||
425
app/models/brex_item/account_flow.rb
Normal file
425
app/models/brex_item/account_flow.rb
Normal file
@@ -0,0 +1,425 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BrexItem::AccountFlow
|
||||
require_dependency "brex_item/account_flow/setup"
|
||||
|
||||
include Setup
|
||||
|
||||
CACHE_TTL = 5.minutes
|
||||
|
||||
class NoApiTokenError < StandardError; end
|
||||
class AccountNotFoundError < StandardError; end
|
||||
class InvalidAccountNameError < StandardError; end
|
||||
class AccountAlreadyLinkedError < StandardError; end
|
||||
|
||||
NavigationResult = Data.define(:target, :flash_type, :message)
|
||||
|
||||
SelectionResult = Data.define(:status, :brex_item, :available_accounts, :accountable_type, :message) do
|
||||
def success? = status == :success
|
||||
def setup_required? = status == :setup_required
|
||||
def provider_error? = status.in?([ :api_error, :unexpected_error ])
|
||||
end
|
||||
|
||||
LinkAccountsResult = Data.define(:created_accounts, :already_linked_names, :invalid_account_ids) do
|
||||
def created_count = created_accounts.count
|
||||
def already_linked_count = already_linked_names.count
|
||||
def invalid_count = invalid_account_ids.count
|
||||
end
|
||||
|
||||
SetupResult = Data.define(:created_accounts, :skipped_count, :failed_count) do
|
||||
def created_count = created_accounts.count
|
||||
end
|
||||
|
||||
SetupCompletion = Data.define(:success, :message) do
|
||||
def success? = success
|
||||
end
|
||||
|
||||
attr_reader :family, :brex_item_id, :brex_item, :credentialed_items
|
||||
|
||||
def initialize(family:, brex_item_id: nil, brex_item: nil)
|
||||
@family = family
|
||||
@brex_item_id = brex_item_id.to_s.strip.presence
|
||||
@credentialed_items = family.brex_items.active.with_credentials.ordered
|
||||
@brex_item = brex_item || BrexItem.resolve_for(family: family, brex_item_id: @brex_item_id)
|
||||
end
|
||||
|
||||
def self.cache_key(family, brex_item)
|
||||
"brex_accounts_#{family.id}_#{brex_item.id}"
|
||||
end
|
||||
|
||||
def self.cache_sensitive_update?(permitted_params)
|
||||
permitted_params.key?(:token) || permitted_params.key?(:base_url)
|
||||
end
|
||||
|
||||
def self.update_item_with_cache_expiration(brex_item, family:, attributes:)
|
||||
expire_accounts_cache = cache_sensitive_update?(attributes)
|
||||
updated = brex_item.update(attributes)
|
||||
|
||||
Rails.cache.delete(cache_key(family, brex_item)) if updated && expire_accounts_cache
|
||||
|
||||
updated
|
||||
end
|
||||
|
||||
def selected?
|
||||
brex_item.present?
|
||||
end
|
||||
|
||||
def selection_required?
|
||||
credentialed_items.count > 1 && brex_item_id.blank?
|
||||
end
|
||||
|
||||
def preload_payload
|
||||
return selection_error_payload if !selected?
|
||||
return { success: false, error: "no_credentials", has_accounts: false } unless brex_item.credentials_configured?
|
||||
|
||||
cached_accounts = Rails.cache.read(cache_key)
|
||||
cached = !cached_accounts.nil?
|
||||
available_accounts = cached ? cached_accounts : fetch_and_cache_accounts
|
||||
|
||||
{ success: true, has_accounts: available_accounts.any?, cached: cached }
|
||||
rescue NoApiTokenError
|
||||
{ success: false, error: "no_api_token", has_accounts: false }
|
||||
rescue Provider::Brex::BrexError => e
|
||||
Rails.logger.error("Brex preload error: #{e.message}")
|
||||
{ success: false, error: "api_error", error_message: e.message, has_accounts: nil }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error preloading Brex accounts: #{e.class}: #{e.message}")
|
||||
{ success: false, error: "unexpected_error", error_message: I18n.t("brex_items.errors.unexpected_error"), has_accounts: nil }
|
||||
end
|
||||
|
||||
def select_accounts_result(accountable_type:)
|
||||
selection_result_for(
|
||||
scope: "brex_items.select_accounts",
|
||||
accountable_type: accountable_type,
|
||||
empty_message_key: "no_accounts_found",
|
||||
log_context: "select_accounts"
|
||||
)
|
||||
end
|
||||
|
||||
def select_existing_account_result(account:)
|
||||
return linked_account_result if account.account_providers.exists?
|
||||
|
||||
selection_result_for(
|
||||
scope: "brex_items.select_existing_account",
|
||||
accountable_type: account.accountable_type,
|
||||
empty_message_key: "all_accounts_already_linked",
|
||||
log_context: "select_existing_account"
|
||||
)
|
||||
end
|
||||
|
||||
def link_new_accounts_result(account_ids:, accountable_type:)
|
||||
return navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.no_accounts_selected")) if account_ids.blank?
|
||||
return navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.invalid_account_type")) unless supported_account_type?(accountable_type)
|
||||
return navigation(:settings_providers, :alert, I18n.t("brex_items.link_accounts.select_connection")) unless selected?
|
||||
|
||||
link_navigation_result(link_new_accounts!(account_ids: account_ids, accountable_type: accountable_type))
|
||||
rescue NoApiTokenError
|
||||
navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.no_api_token"))
|
||||
rescue Provider::Brex::BrexError => e
|
||||
navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.api_error", message: e.message))
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Brex account linking failed: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(Array(e.backtrace).first(10).join("\n"))
|
||||
navigation(:new_account, :alert, I18n.t("brex_items.errors.unexpected_error"))
|
||||
end
|
||||
|
||||
def link_existing_account_result(account:, brex_account_id:)
|
||||
return navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.missing_parameters")) if account.blank? || brex_account_id.blank?
|
||||
return navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.account_already_linked")) if account.account_providers.exists?
|
||||
return navigation(:settings_providers, :alert, I18n.t("brex_items.link_existing_account.select_connection")) unless selected?
|
||||
|
||||
link_existing_account!(account: account, brex_account_id: brex_account_id)
|
||||
|
||||
navigation(:return_to_or_accounts, :notice, I18n.t("brex_items.link_existing_account.success", account_name: account.name))
|
||||
rescue NoApiTokenError
|
||||
navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.no_api_token"))
|
||||
rescue AccountNotFoundError
|
||||
navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.provider_account_not_found"))
|
||||
rescue InvalidAccountNameError
|
||||
navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.invalid_account_name"))
|
||||
rescue AccountAlreadyLinkedError
|
||||
navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.provider_account_already_linked"))
|
||||
rescue Provider::Brex::BrexError => e
|
||||
navigation(:accounts, :alert, I18n.t("brex_items.link_existing_account.api_error", message: e.message))
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Brex existing account linking failed: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(Array(e.backtrace).first(10).join("\n"))
|
||||
navigation(:accounts, :alert, I18n.t("brex_items.errors.unexpected_error"))
|
||||
end
|
||||
|
||||
def link_new_accounts!(account_ids:, accountable_type:)
|
||||
raise ArgumentError, "Unsupported Brex account type: #{accountable_type}" unless supported_account_type?(accountable_type)
|
||||
|
||||
created_accounts = []
|
||||
already_linked_names = []
|
||||
invalid_account_ids = []
|
||||
accounts_by_id = indexed_accounts
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
account_ids.each do |account_id|
|
||||
account_data = accounts_by_id[account_id.to_s]
|
||||
next unless account_data
|
||||
|
||||
account_name = BrexAccount.name_for(account_data)
|
||||
|
||||
if account_name.blank?
|
||||
invalid_account_ids << account_id
|
||||
Rails.logger.warn "BrexItem::AccountFlow - Skipping account #{account_id} with blank name"
|
||||
next
|
||||
end
|
||||
|
||||
brex_account = upsert_brex_account!(account_id, account_data)
|
||||
|
||||
if brex_account.account_provider.present?
|
||||
already_linked_names << account_name
|
||||
next
|
||||
end
|
||||
|
||||
account = Account.create_and_sync(
|
||||
{
|
||||
family: family,
|
||||
name: account_name,
|
||||
balance: 0,
|
||||
currency: BrexAccount.currency_for(account_data),
|
||||
accountable_type: accountable_type,
|
||||
accountable_attributes: BrexAccount.default_accountable_attributes(accountable_type)
|
||||
},
|
||||
skip_initial_sync: true
|
||||
)
|
||||
|
||||
AccountProvider.create!(account: account, provider: brex_account)
|
||||
created_accounts << account
|
||||
end
|
||||
end
|
||||
|
||||
brex_item.sync_later if created_accounts.any?
|
||||
|
||||
LinkAccountsResult.new(
|
||||
created_accounts: created_accounts,
|
||||
already_linked_names: already_linked_names,
|
||||
invalid_account_ids: invalid_account_ids
|
||||
)
|
||||
end
|
||||
|
||||
def link_existing_account!(account:, brex_account_id:)
|
||||
account_data = indexed_accounts[brex_account_id.to_s]
|
||||
raise AccountNotFoundError unless account_data
|
||||
|
||||
account_name = BrexAccount.name_for(account_data)
|
||||
raise InvalidAccountNameError if account_name.blank?
|
||||
|
||||
brex_account = nil
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
brex_account = upsert_brex_account!(brex_account_id, account_data)
|
||||
raise AccountAlreadyLinkedError if brex_account.account_provider.present?
|
||||
|
||||
AccountProvider.create!(account: account, provider: brex_account)
|
||||
end
|
||||
|
||||
brex_item.sync_later
|
||||
|
||||
brex_account
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def selection_error_payload
|
||||
if brex_item_id.present?
|
||||
return {
|
||||
success: false,
|
||||
error: "select_connection",
|
||||
error_message: I18n.t("brex_items.select_accounts.select_connection"),
|
||||
has_accounts: nil
|
||||
}
|
||||
end
|
||||
|
||||
return { success: false, error: "no_credentials", has_accounts: false } unless selection_required?
|
||||
|
||||
{
|
||||
success: false,
|
||||
error: "select_connection",
|
||||
error_message: I18n.t("brex_items.select_accounts.select_connection"),
|
||||
has_accounts: nil
|
||||
}
|
||||
end
|
||||
|
||||
def selection_failure_result(scope, accountable_type: nil)
|
||||
if selection_required?
|
||||
SelectionResult.new(
|
||||
status: :select_connection,
|
||||
brex_item: nil,
|
||||
available_accounts: [],
|
||||
accountable_type: accountable_type,
|
||||
message: I18n.t("#{scope}.select_connection")
|
||||
)
|
||||
else
|
||||
SelectionResult.new(
|
||||
status: :setup_required,
|
||||
brex_item: nil,
|
||||
available_accounts: [],
|
||||
accountable_type: accountable_type,
|
||||
message: I18n.t("#{scope}.no_credentials_configured")
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def selection_result_for(scope:, accountable_type:, empty_message_key:, log_context:)
|
||||
return selection_failure_result(scope, accountable_type: accountable_type) unless selected?
|
||||
|
||||
available_accounts = filter_accounts(unlinked_available_accounts, accountable_type)
|
||||
if available_accounts.empty?
|
||||
return selection_result(
|
||||
status: :empty,
|
||||
accountable_type: accountable_type,
|
||||
message: I18n.t("#{scope}.#{empty_message_key}")
|
||||
)
|
||||
end
|
||||
|
||||
selection_result(status: :success, accountable_type: accountable_type, available_accounts: available_accounts)
|
||||
rescue NoApiTokenError
|
||||
selection_result(
|
||||
status: :no_api_token,
|
||||
accountable_type: accountable_type,
|
||||
message: I18n.t("#{scope}.no_api_token")
|
||||
)
|
||||
rescue Provider::Brex::BrexError => e
|
||||
Rails.logger.error("Brex API error in #{log_context}: #{e.message}")
|
||||
selection_result(status: :api_error, accountable_type: accountable_type, message: e.message)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error in #{log_context}: #{e.class}: #{e.message}")
|
||||
selection_result(
|
||||
status: :unexpected_error,
|
||||
accountable_type: accountable_type,
|
||||
message: I18n.t("#{scope}.unexpected_error")
|
||||
)
|
||||
end
|
||||
|
||||
def selection_result(status:, accountable_type:, available_accounts: [], message: nil)
|
||||
SelectionResult.new(
|
||||
status: status,
|
||||
brex_item: brex_item,
|
||||
available_accounts: available_accounts,
|
||||
accountable_type: accountable_type,
|
||||
message: message
|
||||
)
|
||||
end
|
||||
|
||||
def linked_account_result
|
||||
SelectionResult.new(
|
||||
status: :account_already_linked,
|
||||
brex_item: brex_item,
|
||||
available_accounts: [],
|
||||
accountable_type: nil,
|
||||
message: I18n.t("brex_items.select_existing_account.account_already_linked")
|
||||
)
|
||||
end
|
||||
|
||||
def link_navigation_result(result)
|
||||
if result.invalid_count.positive? && result.created_count.zero? && result.already_linked_count.zero?
|
||||
navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.invalid_account_names", count: result.invalid_count))
|
||||
elsif result.invalid_count.positive? && (result.created_count.positive? || result.already_linked_count.positive?)
|
||||
navigation(
|
||||
:return_to_or_accounts,
|
||||
:alert,
|
||||
I18n.t(
|
||||
"brex_items.link_accounts.partial_invalid",
|
||||
created_count: result.created_count,
|
||||
already_linked_count: result.already_linked_count,
|
||||
invalid_count: result.invalid_count
|
||||
)
|
||||
)
|
||||
elsif result.created_count.positive? && result.already_linked_count.positive?
|
||||
navigation(
|
||||
:return_to_or_accounts,
|
||||
:notice,
|
||||
I18n.t(
|
||||
"brex_items.link_accounts.partial_success",
|
||||
created_count: result.created_count,
|
||||
already_linked_count: result.already_linked_count,
|
||||
already_linked_names: result.already_linked_names.join(", ")
|
||||
)
|
||||
)
|
||||
elsif result.created_count.positive?
|
||||
navigation(:return_to_or_accounts, :notice, I18n.t("brex_items.link_accounts.success", count: result.created_count))
|
||||
elsif result.already_linked_count.positive?
|
||||
navigation(
|
||||
:return_to_or_accounts,
|
||||
:alert,
|
||||
I18n.t(
|
||||
"brex_items.link_accounts.all_already_linked",
|
||||
count: result.already_linked_count,
|
||||
names: result.already_linked_names.join(", ")
|
||||
)
|
||||
)
|
||||
else
|
||||
navigation(:new_account, :alert, I18n.t("brex_items.link_accounts.link_failed"))
|
||||
end
|
||||
end
|
||||
|
||||
def navigation(target, flash_type, message)
|
||||
NavigationResult.new(target: target, flash_type: flash_type, message: message)
|
||||
end
|
||||
|
||||
def cache_key
|
||||
self.class.cache_key(family, brex_item)
|
||||
end
|
||||
|
||||
def fetch_accounts
|
||||
provider = brex_item&.brex_provider
|
||||
raise NoApiTokenError unless provider.present?
|
||||
|
||||
accounts_data = provider.get_accounts
|
||||
accounts_data[:accounts] || []
|
||||
end
|
||||
|
||||
def accounts
|
||||
cached_accounts = Rails.cache.read(cache_key)
|
||||
return cached_accounts unless cached_accounts.nil?
|
||||
|
||||
fetch_and_cache_accounts
|
||||
end
|
||||
|
||||
def fetch_and_cache_accounts
|
||||
available_accounts = fetch_accounts
|
||||
Rails.cache.write(cache_key, available_accounts, expires_in: CACHE_TTL)
|
||||
available_accounts
|
||||
end
|
||||
|
||||
def unlinked_available_accounts
|
||||
linked_account_ids = brex_item.brex_accounts
|
||||
.joins(:account_provider)
|
||||
.pluck("#{BrexAccount.table_name}.account_id")
|
||||
.map(&:to_s)
|
||||
accounts.reject { |account| linked_account_ids.include?(account.with_indifferent_access[:id].to_s) }
|
||||
end
|
||||
|
||||
def filter_accounts(accounts, accountable_type)
|
||||
return [] unless Provider::BrexAdapter.supported_account_types.include?(accountable_type)
|
||||
|
||||
accounts.select do |account|
|
||||
case accountable_type
|
||||
when "CreditCard"
|
||||
BrexAccount.kind_for(account) == "card"
|
||||
when "Depository"
|
||||
BrexAccount.kind_for(account) == "cash"
|
||||
else
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def indexed_accounts
|
||||
accounts.index_by { |account| account.with_indifferent_access[:id].to_s }
|
||||
end
|
||||
|
||||
def upsert_brex_account!(account_id, account_data)
|
||||
brex_account = brex_item.brex_accounts.find_or_initialize_by(account_id: account_id.to_s)
|
||||
brex_account.upsert_brex_snapshot!(account_data)
|
||||
brex_account
|
||||
end
|
||||
|
||||
def supported_account_type?(accountable_type)
|
||||
Provider::BrexAdapter.supported_account_types.include?(accountable_type)
|
||||
end
|
||||
end
|
||||
242
app/models/brex_item/account_flow/setup.rb
Normal file
242
app/models/brex_item/account_flow/setup.rb
Normal file
@@ -0,0 +1,242 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BrexItem::AccountFlow
|
||||
module Setup
|
||||
def import_accounts_from_api_if_needed
|
||||
raise NoApiTokenError unless brex_item&.credentials_configured?
|
||||
|
||||
available_accounts = fetch_accounts
|
||||
return nil if available_accounts.empty?
|
||||
|
||||
existing_accounts = brex_item.brex_accounts.index_by(&:account_id)
|
||||
|
||||
available_accounts.each do |account_data|
|
||||
account_id = account_data.with_indifferent_access[:id].to_s
|
||||
account_name = BrexAccount.name_for(account_data)
|
||||
next if account_id.blank? || account_name.blank?
|
||||
|
||||
brex_account = existing_accounts[account_id]
|
||||
next if brex_account.present? && !brex_account_snapshot_changed?(brex_account, account_data)
|
||||
|
||||
upsert_brex_account!(account_id, account_data)
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def unlinked_brex_accounts
|
||||
brex_item.brex_accounts
|
||||
.left_joins(:account_provider)
|
||||
.where(account_providers: { id: nil })
|
||||
end
|
||||
|
||||
def account_type_options
|
||||
supported_types = Provider::BrexAdapter.supported_account_types
|
||||
account_type_keys = {
|
||||
"depository" => "Depository",
|
||||
"credit_card" => "CreditCard",
|
||||
"investment" => "Investment",
|
||||
"loan" => "Loan",
|
||||
"other_asset" => "OtherAsset"
|
||||
}
|
||||
|
||||
options = account_type_keys.filter_map do |key, type|
|
||||
next unless supported_types.include?(type)
|
||||
|
||||
[ I18n.t("brex_items.setup_accounts.account_types.#{key}"), type ]
|
||||
end
|
||||
|
||||
[ [ I18n.t("brex_items.setup_accounts.account_types.skip"), "skip" ] ] + options
|
||||
end
|
||||
|
||||
def displayable_account_type_options
|
||||
account_type_options.reject { |_, type| type == "skip" }
|
||||
end
|
||||
|
||||
def subtype_options
|
||||
supported_types = Provider::BrexAdapter.supported_account_types
|
||||
all_subtype_options = {
|
||||
"Depository" => {
|
||||
label: I18n.t("brex_items.setup_accounts.subtype_labels.depository"),
|
||||
options: translate_subtypes("depository", Depository::SUBTYPES)
|
||||
},
|
||||
"CreditCard" => {
|
||||
label: I18n.t("brex_items.setup_accounts.subtype_labels.credit_card"),
|
||||
options: [],
|
||||
message: I18n.t("brex_items.setup_accounts.subtype_messages.credit_card")
|
||||
},
|
||||
"Investment" => {
|
||||
label: I18n.t("brex_items.setup_accounts.subtype_labels.investment"),
|
||||
options: translate_subtypes("investment", Investment::SUBTYPES)
|
||||
},
|
||||
"Loan" => {
|
||||
label: I18n.t("brex_items.setup_accounts.subtype_labels.loan"),
|
||||
options: translate_subtypes("loan", Loan::SUBTYPES)
|
||||
},
|
||||
"OtherAsset" => {
|
||||
label: I18n.t("brex_items.setup_accounts.subtype_labels.other_asset", default: "Other asset"),
|
||||
options: [],
|
||||
message: I18n.t("brex_items.setup_accounts.subtype_messages.other_asset")
|
||||
}
|
||||
}
|
||||
|
||||
all_subtype_options.slice(*supported_types)
|
||||
end
|
||||
|
||||
def complete_setup!(account_types:, account_subtypes:)
|
||||
created_accounts = []
|
||||
skipped_count = 0
|
||||
valid_types = Provider::BrexAdapter.supported_account_types
|
||||
failed_count = 0
|
||||
|
||||
submitted_brex_accounts = brex_item.brex_accounts
|
||||
.where(id: account_types.keys)
|
||||
.includes(:account_provider)
|
||||
.index_by { |brex_account| brex_account.id.to_s }
|
||||
|
||||
account_types.each do |brex_account_id, selected_type|
|
||||
if selected_type == "skip" || selected_type.blank?
|
||||
skipped_count += 1
|
||||
next
|
||||
end
|
||||
|
||||
unless valid_types.include?(selected_type)
|
||||
Rails.logger.warn("Invalid account type '#{selected_type}' submitted for Brex account #{brex_account_id}")
|
||||
skipped_count += 1
|
||||
next
|
||||
end
|
||||
|
||||
brex_account = submitted_brex_accounts[brex_account_id.to_s]
|
||||
unless brex_account
|
||||
Rails.logger.warn("Brex account #{brex_account_id} not found for item #{brex_item.id}")
|
||||
next
|
||||
end
|
||||
|
||||
if brex_account.account_provider.present?
|
||||
Rails.logger.info("Brex account #{brex_account_id} already linked, skipping")
|
||||
next
|
||||
end
|
||||
|
||||
selected_subtype = selected_subtype_for(
|
||||
selected_type: selected_type,
|
||||
submitted_subtype: account_subtypes[brex_account_id]
|
||||
)
|
||||
|
||||
begin
|
||||
ActiveRecord::Base.transaction do
|
||||
account = Account.create_and_sync(
|
||||
{
|
||||
family: family,
|
||||
name: brex_account.name,
|
||||
balance: brex_account.current_balance || 0,
|
||||
currency: brex_account.currency.presence || family.currency,
|
||||
accountable_type: selected_type,
|
||||
accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {}
|
||||
},
|
||||
skip_initial_sync: true
|
||||
)
|
||||
|
||||
AccountProvider.create!(account: account, provider: brex_account)
|
||||
created_accounts << account
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
|
||||
failed_count += 1
|
||||
Rails.logger.error("Brex account setup failed for #{brex_account_id}: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(Array(e.backtrace).first(10).join("\n"))
|
||||
end
|
||||
end
|
||||
|
||||
brex_item.sync_later if created_accounts.any?
|
||||
|
||||
SetupResult.new(created_accounts: created_accounts, skipped_count: skipped_count, failed_count: failed_count)
|
||||
end
|
||||
|
||||
def import_accounts_with_user_facing_error
|
||||
import_accounts_from_api_if_needed
|
||||
rescue NoApiTokenError
|
||||
I18n.t("brex_items.setup_accounts.no_api_token")
|
||||
rescue Provider::Brex::BrexError => e
|
||||
Rails.logger.error("Brex API error: #{e.message}")
|
||||
I18n.t("brex_items.setup_accounts.api_error", message: e.message)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error fetching Brex accounts: #{e.class}: #{e.message}")
|
||||
I18n.t("brex_items.setup_accounts.api_error", message: I18n.t("brex_items.errors.unexpected_error"))
|
||||
end
|
||||
|
||||
def complete_setup_result(account_types:, account_subtypes:)
|
||||
result = complete_setup!(account_types: account_types, account_subtypes: account_subtypes)
|
||||
|
||||
SetupCompletion.new(success: result.failed_count.zero? && result.created_count.positive?, message: setup_notice(result))
|
||||
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
|
||||
Rails.logger.error("Brex account setup failed: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(Array(e.backtrace).first(10).join("\n"))
|
||||
SetupCompletion.new(
|
||||
success: false,
|
||||
message: I18n.t("brex_items.complete_account_setup.creation_failed", error: e.message)
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Brex account setup failed unexpectedly: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(Array(e.backtrace).first(10).join("\n"))
|
||||
SetupCompletion.new(
|
||||
success: false,
|
||||
message: I18n.t(
|
||||
"brex_items.complete_account_setup.creation_failed",
|
||||
error: I18n.t("brex_items.complete_account_setup.unexpected_error")
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_notice(result)
|
||||
if result.failed_count.positive? && result.created_count.positive?
|
||||
I18n.t("brex_items.complete_account_setup.partial_success", created_count: result.created_count, failed_count: result.failed_count)
|
||||
elsif result.skipped_count.positive? && result.created_count.positive?
|
||||
I18n.t("brex_items.complete_account_setup.partial_skipped", created_count: result.created_count, skipped_count: result.skipped_count)
|
||||
elsif result.failed_count.positive?
|
||||
I18n.t("brex_items.complete_account_setup.creation_failed_count", count: result.failed_count)
|
||||
elsif result.created_count.positive?
|
||||
I18n.t("brex_items.complete_account_setup.success", count: result.created_count)
|
||||
elsif result.skipped_count.positive?
|
||||
I18n.t("brex_items.complete_account_setup.all_skipped")
|
||||
else
|
||||
I18n.t("brex_items.complete_account_setup.no_accounts")
|
||||
end
|
||||
end
|
||||
|
||||
def brex_account_snapshot_changed?(brex_account, account_data)
|
||||
snapshot = account_data.with_indifferent_access
|
||||
balances = snapshot.slice(:current_balance, :available_balance, :account_limit)
|
||||
|
||||
expected = {
|
||||
account_kind: BrexAccount.kind_for(snapshot),
|
||||
account_status: snapshot[:status],
|
||||
account_type: snapshot[:type],
|
||||
available_balance: BrexAccount.money_to_decimal(balances[:available_balance]),
|
||||
current_balance: BrexAccount.money_to_decimal(balances[:current_balance]),
|
||||
account_limit: BrexAccount.money_to_decimal(balances[:account_limit]),
|
||||
currency: BrexAccount.currency_code_from_money(balances[:current_balance] || balances[:available_balance] || balances[:account_limit]),
|
||||
name: BrexAccount.name_for(snapshot),
|
||||
raw_payload: BrexAccount.sanitize_payload(account_data)
|
||||
}
|
||||
|
||||
expected.any? { |attribute, value| brex_account.public_send(attribute) != value }
|
||||
end
|
||||
|
||||
def translate_subtypes(type_key, subtypes_hash)
|
||||
subtypes_hash.map do |key, value|
|
||||
[
|
||||
I18n.t("brex_items.setup_accounts.subtypes.#{type_key}.#{key}", default: value[:long] || key.to_s.humanize),
|
||||
key
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
def selected_subtype_for(selected_type:, submitted_subtype:)
|
||||
return CreditCard::DEFAULT_SUBTYPE if selected_type == "CreditCard" && submitted_subtype.blank?
|
||||
return Depository::DEFAULT_SUBTYPE if selected_type == "Depository" && submitted_subtype.blank?
|
||||
|
||||
submitted_subtype
|
||||
end
|
||||
end
|
||||
end
|
||||
245
app/models/brex_item/importer.rb
Normal file
245
app/models/brex_item/importer.rb
Normal file
@@ -0,0 +1,245 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class BrexItem::Importer
|
||||
attr_reader :brex_item, :brex_provider, :sync_start_date
|
||||
|
||||
def initialize(brex_item, brex_provider:, sync_start_date: nil)
|
||||
@brex_item = brex_item
|
||||
@brex_provider = brex_provider
|
||||
@sync_start_date = sync_start_date
|
||||
end
|
||||
|
||||
def import
|
||||
Rails.logger.info "BrexItem::Importer - Starting import for item #{brex_item.id}"
|
||||
|
||||
accounts_data = fetch_accounts_data
|
||||
return failed_result("Failed to fetch accounts data") unless accounts_data
|
||||
|
||||
store_item_snapshot(accounts_data)
|
||||
|
||||
account_result = import_accounts(accounts_data[:accounts].to_a)
|
||||
transaction_result = import_transactions
|
||||
|
||||
brex_item.update!(status: :good) if account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero?
|
||||
|
||||
{
|
||||
success: account_result[:accounts_failed].zero? && transaction_result[:transactions_failed].zero?,
|
||||
**account_result,
|
||||
**transaction_result
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_accounts_data
|
||||
accounts_data = brex_provider.get_accounts
|
||||
|
||||
unless accounts_data.is_a?(Hash)
|
||||
Rails.logger.error "BrexItem::Importer - Invalid accounts_data format: expected Hash, got #{accounts_data.class}"
|
||||
return nil
|
||||
end
|
||||
|
||||
accounts_data
|
||||
rescue Provider::Brex::BrexError => e
|
||||
mark_requires_update_if_credentials_error(e)
|
||||
Rails.logger.error "BrexItem::Importer - Brex API error: #{e.message} trace_id=#{e.trace_id}"
|
||||
nil
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "BrexItem::Importer - Failed to parse Brex API response: #{e.message}"
|
||||
nil
|
||||
rescue => e
|
||||
Rails.logger.error "BrexItem::Importer - Unexpected error fetching accounts: #{e.class} - #{e.message}"
|
||||
Rails.logger.error Array(e.backtrace).join("\n")
|
||||
nil
|
||||
end
|
||||
|
||||
def store_item_snapshot(accounts_data)
|
||||
brex_item.upsert_brex_snapshot!(accounts_data)
|
||||
rescue => e
|
||||
Rails.logger.error "BrexItem::Importer - Failed to store accounts snapshot: #{e.message}"
|
||||
Sentry.capture_exception(e) do |scope|
|
||||
scope.set_tags(brex_item_id: brex_item.id)
|
||||
scope.set_context("brex_item_snapshot", {
|
||||
brex_item_id: brex_item.id,
|
||||
accounts_data: BrexAccount.sanitize_payload(accounts_data)
|
||||
})
|
||||
end
|
||||
raise
|
||||
end
|
||||
|
||||
def import_accounts(accounts)
|
||||
accounts_updated = 0
|
||||
accounts_created = 0
|
||||
accounts_failed = 0
|
||||
|
||||
all_existing_ids = brex_item.brex_accounts.pluck("#{BrexAccount.table_name}.account_id").map(&:to_s)
|
||||
|
||||
accounts.each do |account_data|
|
||||
snapshot = account_data.with_indifferent_access
|
||||
account_id = snapshot[:id].to_s
|
||||
account_name = BrexAccount.name_for(snapshot)
|
||||
next if account_id.blank? || account_name.blank?
|
||||
|
||||
if all_existing_ids.include?(account_id)
|
||||
import_account(snapshot)
|
||||
accounts_updated += 1
|
||||
else
|
||||
import_account(snapshot)
|
||||
accounts_created += 1
|
||||
all_existing_ids << account_id
|
||||
end
|
||||
rescue => e
|
||||
accounts_failed += 1
|
||||
Rails.logger.error "BrexItem::Importer - Failed to import account #{account_id.presence || 'unknown'}: #{e.message}"
|
||||
end
|
||||
|
||||
{
|
||||
accounts_updated: accounts_updated,
|
||||
accounts_created: accounts_created,
|
||||
accounts_failed: accounts_failed
|
||||
}
|
||||
end
|
||||
|
||||
def import_account(account_data)
|
||||
account_id = account_data[:id].to_s
|
||||
raise ArgumentError, "Account ID is required" if account_id.blank?
|
||||
|
||||
brex_account = brex_item.brex_accounts.find_or_initialize_by(account_id: account_id)
|
||||
brex_account.name ||= BrexAccount.name_for(account_data)
|
||||
brex_account.currency ||= BrexAccount.currency_code_from_money(account_data[:current_balance] || account_data[:available_balance] || account_data[:account_limit])
|
||||
brex_account.upsert_brex_snapshot!(account_data)
|
||||
brex_account
|
||||
end
|
||||
|
||||
def import_transactions
|
||||
transactions_imported = 0
|
||||
transactions_failed = 0
|
||||
|
||||
brex_item.brex_accounts.joins(:account).merge(Account.visible).find_each do |brex_account|
|
||||
result = fetch_and_store_transactions(brex_account)
|
||||
if result[:success]
|
||||
transactions_imported += result[:transactions_count]
|
||||
else
|
||||
transactions_failed += 1
|
||||
end
|
||||
rescue => e
|
||||
transactions_failed += 1
|
||||
Rails.logger.error "BrexItem::Importer - Failed to fetch/store transactions for account #{brex_account.account_id}: #{e.message}"
|
||||
end
|
||||
|
||||
{
|
||||
transactions_imported: transactions_imported,
|
||||
transactions_failed: transactions_failed
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_and_store_transactions(brex_account)
|
||||
start_date = determine_sync_start_date(brex_account)
|
||||
Rails.logger.info "BrexItem::Importer - Fetching #{brex_account.account_kind} transactions for account #{brex_account.account_id} from #{start_date}"
|
||||
|
||||
transactions_data = if brex_account.card?
|
||||
brex_provider.get_primary_card_transactions(start_date: start_date)
|
||||
else
|
||||
brex_provider.get_cash_transactions(brex_account.account_id, start_date: start_date)
|
||||
end
|
||||
|
||||
unless transactions_data.is_a?(Hash)
|
||||
Rails.logger.error "BrexItem::Importer - Invalid transactions_data format for account #{brex_account.account_id}"
|
||||
return { success: false, transactions_count: 0, error: "Invalid response format" }
|
||||
end
|
||||
|
||||
transactions = transactions_data[:transactions].to_a
|
||||
created_count = store_new_transactions(brex_account, transactions, window_start_date: start_date)
|
||||
|
||||
{ success: true, transactions_count: created_count }
|
||||
rescue Provider::Brex::BrexError => e
|
||||
mark_requires_update_if_credentials_error(e)
|
||||
Rails.logger.error "BrexItem::Importer - Brex API error for account #{brex_account.account_id}: #{e.message} trace_id=#{e.trace_id}"
|
||||
{ success: false, transactions_count: 0, error: e.message }
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "BrexItem::Importer - Failed to parse transaction response for account #{brex_account.account_id}: #{e.message}"
|
||||
{ success: false, transactions_count: 0, error: "Failed to parse response" }
|
||||
rescue => e
|
||||
Rails.logger.error "BrexItem::Importer - Unexpected error fetching transactions for account #{brex_account.account_id}: #{e.class} - #{e.message}"
|
||||
Rails.logger.error Array(e.backtrace).join("\n")
|
||||
{ success: false, transactions_count: 0, error: "Unexpected error: #{e.message}" }
|
||||
end
|
||||
|
||||
def store_new_transactions(brex_account, transactions, window_start_date:)
|
||||
existing_payload = brex_account.raw_transactions_payload.to_a
|
||||
existing_transactions = transactions_in_window(existing_payload, window_start_date)
|
||||
existing_ids = existing_transactions.map { |tx| tx.with_indifferent_access[:id] }.to_set
|
||||
|
||||
new_transactions = transactions.select do |tx|
|
||||
tx_id = tx.with_indifferent_access[:id]
|
||||
tx_id.present? && !existing_ids.include?(tx_id) && transaction_in_window?(tx, window_start_date)
|
||||
end
|
||||
|
||||
return 0 if new_transactions.empty? && existing_transactions.count == existing_payload.count
|
||||
|
||||
brex_account.upsert_brex_transactions_snapshot!(existing_transactions + new_transactions)
|
||||
new_transactions.count
|
||||
end
|
||||
|
||||
def transactions_in_window(transactions, window_start_date)
|
||||
transactions.select { |transaction| transaction_in_window?(transaction, window_start_date) }
|
||||
end
|
||||
|
||||
def transaction_in_window?(transaction, window_start_date)
|
||||
return true if window_start_date.blank?
|
||||
|
||||
transaction_date = transaction_date_for(transaction)
|
||||
return true if transaction_date.blank?
|
||||
|
||||
transaction_date >= window_start_date.to_date
|
||||
end
|
||||
|
||||
def transaction_date_for(transaction)
|
||||
data = transaction.with_indifferent_access
|
||||
date_value = data[:posted_at_date].presence || data[:initiated_at_date].presence || data[:posted_at].presence || data[:created_at].presence
|
||||
|
||||
case date_value
|
||||
when Date
|
||||
date_value
|
||||
when Time, DateTime
|
||||
date_value.to_date
|
||||
when String
|
||||
Date.parse(date_value)
|
||||
else
|
||||
nil
|
||||
end
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def determine_sync_start_date(brex_account)
|
||||
return sync_start_date if sync_start_date.present?
|
||||
|
||||
if brex_account.raw_transactions_payload.to_a.any?
|
||||
brex_item.last_synced_at ? brex_item.last_synced_at - 7.days : 90.days.ago
|
||||
else
|
||||
account_baseline = brex_account.created_at || Time.current
|
||||
[ account_baseline - 7.days, 90.days.ago ].max
|
||||
end
|
||||
end
|
||||
|
||||
def mark_requires_update_if_credentials_error(error)
|
||||
return unless error.error_type.in?([ :unauthorized, :access_forbidden ])
|
||||
|
||||
brex_item.update!(status: :requires_update)
|
||||
rescue => update_error
|
||||
Rails.logger.error "BrexItem::Importer - Failed to update item status: #{update_error.message}"
|
||||
end
|
||||
|
||||
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
|
||||
16
app/models/brex_item/provided.rb
Normal file
16
app/models/brex_item/provided.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
module BrexItem::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def brex_provider
|
||||
return nil unless credentials_configured?
|
||||
|
||||
base_url = effective_base_url
|
||||
return nil unless base_url.present?
|
||||
|
||||
Provider::Brex.new(token.to_s.strip, base_url: base_url)
|
||||
end
|
||||
|
||||
def syncer
|
||||
BrexItem::Syncer.new(self)
|
||||
end
|
||||
end
|
||||
148
app/models/brex_item/syncer.rb
Normal file
148
app/models/brex_item/syncer.rb
Normal file
@@ -0,0 +1,148 @@
|
||||
class BrexItem::Syncer
|
||||
include SyncStats::Collector
|
||||
|
||||
SafeSyncError = Class.new(StandardError)
|
||||
|
||||
attr_reader :brex_item
|
||||
|
||||
def initialize(brex_item)
|
||||
@brex_item = brex_item
|
||||
end
|
||||
|
||||
def perform_sync(sync)
|
||||
sync_errors = []
|
||||
|
||||
# Phase 1: Import data from Brex API
|
||||
update_status(sync, :importing_accounts)
|
||||
import_result = brex_item.import_latest_brex_data(sync_start_date: sync.window_start_date)
|
||||
sync_errors.concat(import_result_errors(import_result))
|
||||
|
||||
# Phase 2: Collect setup statistics
|
||||
update_status(sync, :checking_account_configuration)
|
||||
|
||||
linked_count = brex_item.brex_accounts.joins(:account_provider).count
|
||||
unlinked_count = brex_item.brex_accounts
|
||||
.left_joins(:account_provider)
|
||||
.where(account_providers: { id: nil })
|
||||
.count
|
||||
total_count = linked_count + unlinked_count
|
||||
collect_brex_setup_stats(
|
||||
sync,
|
||||
total_count: total_count,
|
||||
linked_count: linked_count,
|
||||
unlinked_count: unlinked_count
|
||||
)
|
||||
|
||||
# Set pending_account_setup if there are unlinked accounts
|
||||
if unlinked_count.positive?
|
||||
brex_item.update!(pending_account_setup: true)
|
||||
update_status(sync, :accounts_need_setup, count: unlinked_count)
|
||||
else
|
||||
brex_item.update!(pending_account_setup: false)
|
||||
end
|
||||
|
||||
# Phase 3: Process transactions for linked accounts only
|
||||
if linked_count.positive?
|
||||
linked_accounts = brex_item.brex_accounts.joins(:account_provider)
|
||||
update_status(sync, :processing_transactions)
|
||||
mark_import_started(sync)
|
||||
Rails.logger.info "BrexItem::Syncer - Processing #{linked_count} linked accounts"
|
||||
process_results = brex_item.process_accounts
|
||||
sync_errors.concat(result_failure_errors(process_results, category: :account_processing_error, message_key: :account_processing_failed))
|
||||
Rails.logger.info "BrexItem::Syncer - Finished processing accounts"
|
||||
|
||||
# Phase 4: Schedule balance calculations for linked accounts
|
||||
update_status(sync, :calculating_balances)
|
||||
schedule_results = brex_item.schedule_account_syncs(
|
||||
parent_sync: sync,
|
||||
window_start_date: sync.window_start_date,
|
||||
window_end_date: sync.window_end_date
|
||||
)
|
||||
sync_errors.concat(result_failure_errors(schedule_results, category: :account_sync_error, message_key: :account_sync_failed))
|
||||
|
||||
# Phase 5: Collect transaction statistics
|
||||
account_ids = linked_accounts
|
||||
.includes(account_provider: :account)
|
||||
.filter_map { |ma| ma.current_account&.id }
|
||||
collect_transaction_stats(sync, account_ids: account_ids, source: "brex")
|
||||
else
|
||||
Rails.logger.info "BrexItem::Syncer - No linked accounts to process"
|
||||
end
|
||||
|
||||
# Mark sync health
|
||||
collect_health_stats(sync, errors: sync_errors.presence)
|
||||
rescue => e
|
||||
safe_message = user_safe_error_message(e)
|
||||
Rails.logger.error "BrexItem::Syncer - sync failed for Brex item #{brex_item.id}: #{e.class} - #{e.message}"
|
||||
Rails.logger.error Array(e.backtrace).first(10).join("\n")
|
||||
Sentry.capture_exception(e) do |scope|
|
||||
scope.set_tags(brex_item_id: brex_item.id)
|
||||
end
|
||||
collect_health_stats(sync, errors: [ { message: safe_message, category: "sync_error" } ])
|
||||
raise SafeSyncError, safe_message
|
||||
end
|
||||
|
||||
def perform_post_sync
|
||||
# no-op
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_status(sync, key, **options)
|
||||
return unless sync.respond_to?(:status_text)
|
||||
|
||||
sync.update!(status_text: I18n.t("brex_items.syncer.#{key}", **options))
|
||||
end
|
||||
|
||||
def collect_brex_setup_stats(sync, total_count:, linked_count:, unlinked_count:)
|
||||
return {} unless sync.respond_to?(:sync_stats)
|
||||
|
||||
setup_stats = {
|
||||
"total_accounts" => total_count,
|
||||
"linked_accounts" => linked_count,
|
||||
"unlinked_accounts" => unlinked_count
|
||||
}
|
||||
|
||||
merge_sync_stats(sync, setup_stats)
|
||||
setup_stats
|
||||
end
|
||||
|
||||
def import_result_errors(result)
|
||||
return [] if result.is_a?(Hash) && result[:success]
|
||||
|
||||
unless result.is_a?(Hash)
|
||||
return [ sync_error(:import_error, :import_failed) ]
|
||||
end
|
||||
|
||||
errors = []
|
||||
accounts_failed = result[:accounts_failed].to_i
|
||||
transactions_failed = result[:transactions_failed].to_i
|
||||
|
||||
errors << sync_error(:account_import_error, :accounts_failed, count: accounts_failed) if accounts_failed.positive?
|
||||
errors << sync_error(:transaction_import_error, :transactions_failed, count: transactions_failed) if transactions_failed.positive?
|
||||
errors << sync_error(:import_error, :import_failed) if errors.empty?
|
||||
errors
|
||||
end
|
||||
|
||||
def result_failure_errors(results, category:, message_key:)
|
||||
failed_count = Array(results).count { |result| result.is_a?(Hash) && result[:success] == false }
|
||||
return [] unless failed_count.positive?
|
||||
|
||||
[ sync_error(category, message_key, count: failed_count) ]
|
||||
end
|
||||
|
||||
def sync_error(category, message_key, **options)
|
||||
{
|
||||
message: I18n.t("brex_items.syncer.#{message_key}", **options),
|
||||
category: category.to_s
|
||||
}
|
||||
end
|
||||
|
||||
def user_safe_error_message(error)
|
||||
if error.is_a?(Provider::Brex::BrexError) && error.error_type.in?([ :unauthorized, :access_forbidden ])
|
||||
I18n.t("brex_items.syncer.credentials_invalid")
|
||||
else
|
||||
I18n.t("brex_items.syncer.failed")
|
||||
end
|
||||
end
|
||||
end
|
||||
56
app/models/brex_item/unlinking.rb
Normal file
56
app/models/brex_item/unlinking.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module BrexItem::Unlinking
|
||||
# Concern that encapsulates unlinking logic for a Brex item.
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Idempotently remove all connections between this Brex item and local accounts.
|
||||
# - Detaches any AccountProvider links for each BrexAccount
|
||||
# - Detaches Holdings that point at the AccountProvider links
|
||||
# Returns a per-account result payload for observability
|
||||
def unlink_all!(dry_run: false)
|
||||
results = []
|
||||
|
||||
brex_accounts.find_each do |provider_account|
|
||||
result = {
|
||||
provider_account_id: provider_account.id,
|
||||
name: provider_account.name,
|
||||
provider_link_ids: []
|
||||
}
|
||||
results << result
|
||||
|
||||
if dry_run
|
||||
result[:provider_link_ids] = AccountProvider.where(provider_type: "BrexAccount", provider_id: provider_account.id).ids
|
||||
next
|
||||
end
|
||||
|
||||
link_ids = []
|
||||
|
||||
begin
|
||||
ActiveRecord::Base.transaction do
|
||||
links = AccountProvider.where(provider_type: "BrexAccount", provider_id: provider_account.id).to_a
|
||||
link_ids = links.map(&:id)
|
||||
result[:provider_link_ids] = link_ids
|
||||
|
||||
# Detach holdings for any provider links found
|
||||
if link_ids.any?
|
||||
Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil)
|
||||
end
|
||||
|
||||
# Destroy all provider links
|
||||
links.each do |ap|
|
||||
ap.destroy!
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn(
|
||||
"BrexItem Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}"
|
||||
)
|
||||
# Record error for observability; continue with other accounts
|
||||
result[:error] = e.message
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
end
|
||||
@@ -6,11 +6,7 @@ module Encryptable
|
||||
# This allows encryption to be optional - if not configured, sensitive fields
|
||||
# are stored in plaintext (useful for development or legacy deployments).
|
||||
def encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
ActiveRecordEncryptionConfig.explicitly_configured?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class CreditCard < ApplicationRecord
|
||||
include Accountable
|
||||
|
||||
DEFAULT_SUBTYPE = "credit_card"
|
||||
|
||||
SUBTYPES = {
|
||||
"credit_card" => { short: "Credit Card", long: "Credit Card" }
|
||||
}.freeze
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
class DataEnrichment < ApplicationRecord
|
||||
belongs_to :enrichable, polymorphic: true
|
||||
|
||||
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital", sophtron: "sophtron" }
|
||||
enum :source, {
|
||||
rule: "rule",
|
||||
plaid: "plaid",
|
||||
simplefin: "simplefin",
|
||||
lunchflow: "lunchflow",
|
||||
synth: "synth",
|
||||
ai: "ai",
|
||||
enable_banking: "enable_banking",
|
||||
coinstats: "coinstats",
|
||||
mercury: "mercury",
|
||||
brex: "brex",
|
||||
indexa_capital: "indexa_capital",
|
||||
sophtron: "sophtron",
|
||||
ibkr: "ibkr"
|
||||
}
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class Depository < ApplicationRecord
|
||||
include Accountable
|
||||
|
||||
DEFAULT_SUBTYPE = "checking"
|
||||
|
||||
SUBTYPES = {
|
||||
"checking" => { short: "Checking", long: "Checking" },
|
||||
"savings" => { short: "Savings", long: "Savings" },
|
||||
|
||||
@@ -23,29 +23,51 @@ class EnableBankingAccount::Transactions::Processor
|
||||
Account::ProviderImportAdapter.new(enable_banking_account.current_account)
|
||||
end
|
||||
|
||||
# Pre-fetch external_ids that were manually merged and must not be re-imported.
|
||||
# One query per sync; O(1) Set lookup per transaction — avoids N+1.
|
||||
# Uses a lateral jsonb_array_elements join to extract only the ID strings in SQL,
|
||||
# avoiding loading full extra blobs into Ruby. Handles both Array (current) and
|
||||
# Hash (legacy) formats via jsonb_typeof.
|
||||
# Pre-fetch external_ids that must not be re-imported.
|
||||
# One query per category per sync; O(1) Set lookup per transaction — avoids N+1.
|
||||
excluded_ids = if enable_banking_account.current_account
|
||||
Transaction.joins(:entry)
|
||||
.where(entries: { account_id: enable_banking_account.current_account.id })
|
||||
.where("transactions.extra ? 'manual_merge'")
|
||||
.joins(
|
||||
Arel.sql(<<~SQL.squish)
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
CASE jsonb_typeof(transactions.extra->'manual_merge')
|
||||
WHEN 'array' THEN transactions.extra->'manual_merge'
|
||||
WHEN 'object' THEN jsonb_build_array(transactions.extra->'manual_merge')
|
||||
ELSE '[]'::jsonb
|
||||
END
|
||||
) AS merge_elem
|
||||
SQL
|
||||
)
|
||||
.pluck(Arel.sql("merge_elem->>'merged_from_external_id'"))
|
||||
.compact
|
||||
.to_set
|
||||
account_id = enable_banking_account.current_account.id
|
||||
|
||||
# 1. Manually merged: pending entries the user explicitly merged into a posted transaction.
|
||||
# Uses a lateral join to extract merged_from_external_id from the manual_merge JSON
|
||||
# (handles both Array current format and legacy Hash format via jsonb_typeof).
|
||||
manually_merged_ids = Transaction.joins(:entry)
|
||||
.where(entries: { account_id: account_id })
|
||||
.where("transactions.extra ? 'manual_merge'")
|
||||
.joins(
|
||||
Arel.sql(<<~SQL.squish)
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
CASE jsonb_typeof(transactions.extra->'manual_merge')
|
||||
WHEN 'array' THEN transactions.extra->'manual_merge'
|
||||
WHEN 'object' THEN jsonb_build_array(transactions.extra->'manual_merge')
|
||||
ELSE '[]'::jsonb
|
||||
END
|
||||
) AS merge_elem
|
||||
SQL
|
||||
)
|
||||
.pluck(Arel.sql("merge_elem->>'merged_from_external_id'"))
|
||||
.compact
|
||||
.to_set
|
||||
|
||||
# 2. Auto-claimed: pending entries that were automatically matched to a booked transaction
|
||||
# by the amount/date heuristic. Their old external_ids are stored in
|
||||
# extra["auto_claimed_pending_ids"] so they are not re-imported as new pending entries
|
||||
# on subsequent syncs (the stored raw payload still contains the old pending data).
|
||||
auto_claimed_ids = Transaction.joins(:entry)
|
||||
.where(entries: { account_id: account_id })
|
||||
.where("transactions.extra ? 'auto_claimed_pending_ids'")
|
||||
.joins(
|
||||
Arel.sql(<<~SQL.squish)
|
||||
CROSS JOIN LATERAL jsonb_array_elements_text(
|
||||
transactions.extra->'auto_claimed_pending_ids'
|
||||
) AS claimed_id
|
||||
SQL
|
||||
)
|
||||
.pluck(Arel.sql("claimed_id"))
|
||||
.compact
|
||||
.to_set
|
||||
|
||||
manually_merged_ids | auto_claimed_ids
|
||||
else
|
||||
Set.new
|
||||
end
|
||||
|
||||
@@ -13,7 +13,25 @@ class EnableBankingEntry::Processor
|
||||
def self.compute_external_id(raw_transaction_data)
|
||||
data = raw_transaction_data.with_indifferent_access
|
||||
id = data[:transaction_id].presence || data[:entry_reference].presence
|
||||
id ? "enable_banking_#{id}" : nil
|
||||
return "enable_banking_#{id}" if id
|
||||
|
||||
# Some ASPSPs omit both transaction_id and entry_reference (both are optional
|
||||
# in PSD2). Generate a deterministic content-based ID so these transactions
|
||||
# can still be imported idempotently. Uses the same fields as the importer's
|
||||
# dedup key so the two strategies stay in sync.
|
||||
date = data[:booking_date].presence || data[:value_date].presence || data[:transaction_date]
|
||||
amount = data.dig(:transaction_amount, :amount).presence || data[:amount]
|
||||
currency = data.dig(:transaction_amount, :currency).presence || data[:currency]
|
||||
direction = data[:credit_debit_indicator]
|
||||
creditor = data.dig(:creditor, :name).presence || data[:creditor_name]
|
||||
debtor = data.dig(:debtor, :name).presence || data[:debtor_name]
|
||||
remittance = data[:remittance_information]
|
||||
remittance_key = remittance.is_a?(Array) ? remittance.compact.map(&:to_s).sort.join("|") : remittance.to_s
|
||||
|
||||
content = [ date, amount, currency, direction, creditor, debtor, remittance_key ].map(&:to_s).join("\x1F")
|
||||
return nil if content.gsub("\x1F", "").blank?
|
||||
|
||||
"enable_banking_content_#{Digest::MD5.hexdigest(content)}"
|
||||
end
|
||||
|
||||
def initialize(enable_banking_transaction, enable_banking_account:, import_adapter: nil)
|
||||
@@ -75,7 +93,7 @@ class EnableBankingEntry::Processor
|
||||
|
||||
def external_id
|
||||
id = self.class.compute_external_id(data)
|
||||
raise ArgumentError, "Enable Banking transaction missing required field 'transaction_id'" unless id
|
||||
raise ArgumentError, "Enable Banking transaction missing required identifier (transaction_id, entry_reference, or identifiable content)" unless id
|
||||
id
|
||||
end
|
||||
|
||||
|
||||
@@ -243,26 +243,36 @@ class EnableBankingItem::Importer
|
||||
pending_transactions = []
|
||||
if include_pending
|
||||
# Also fetch pending transactions (visible for 1-3 days before they become BOOK) if setting is enabled
|
||||
pending_transactions = fetch_paginated_transactions(
|
||||
enable_banking_account,
|
||||
start_date: start_date,
|
||||
transaction_status: "PDNG",
|
||||
psu_headers: enable_banking_item.build_psu_headers
|
||||
)
|
||||
begin
|
||||
pending_transactions = fetch_paginated_transactions(
|
||||
enable_banking_account,
|
||||
start_date: start_date,
|
||||
transaction_status: "PDNG",
|
||||
psu_headers: enable_banking_item.build_psu_headers
|
||||
)
|
||||
rescue Provider::EnableBanking::EnableBankingError => e
|
||||
raise unless e.error_type == :validation_error && e.message.include?("transactionStatus")
|
||||
Rails.logger.warn "EnableBankingItem::Importer - ASPSP does not support PDNG transaction status for account #{enable_banking_account.uid}, skipping pending transactions. API error: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
book_ids = all_transactions
|
||||
.map { |tx| tx.with_indifferent_access[:transaction_id].presence }
|
||||
book_fingerprints = all_transactions
|
||||
.map { |tx| EnableBankingEntry::Processor.compute_external_id(tx) }
|
||||
.compact.to_set
|
||||
|
||||
# Also index all booked entry_references so a pending row that lacks
|
||||
# transaction_id can still be matched when the settled BOOK row adds one
|
||||
# (fingerprints differ; entry_reference stays the same across settlement).
|
||||
book_entry_refs = all_transactions
|
||||
.select { |tx| tx.with_indifferent_access[:transaction_id].blank? }
|
||||
.map { |tx| tx.with_indifferent_access[:entry_reference].presence }
|
||||
.compact.to_set
|
||||
|
||||
pending_transactions.reject! do |tx|
|
||||
tx = tx.with_indifferent_access
|
||||
tx[:transaction_id].present? ? book_ids.include?(tx[:transaction_id]) : book_entry_refs.include?(tx[:entry_reference].presence)
|
||||
tx_ia = tx.with_indifferent_access
|
||||
fp = EnableBankingEntry::Processor.compute_external_id(tx_ia)
|
||||
entry_ref = tx_ia[:entry_reference].presence
|
||||
(fp.present? && book_fingerprints.include?(fp)) ||
|
||||
(entry_ref.present? && book_entry_refs.include?(entry_ref))
|
||||
end
|
||||
|
||||
all_transactions = all_transactions + tag_as_pending(pending_transactions)
|
||||
@@ -291,14 +301,17 @@ class EnableBankingItem::Importer
|
||||
if all_transactions.any?
|
||||
|
||||
# C4: Remove stored PDNG entries that have now settled as BOOK.
|
||||
# When a BOOK transaction arrives with the same transaction_id as a stored
|
||||
# PDNG entry, the pending entry is stale — drop it to avoid duplicates.
|
||||
book_ids = all_transactions
|
||||
# Two match strategies run in parallel:
|
||||
# 1. Fingerprint: covers same-ID rows and ID-less rows matched by content.
|
||||
# 2. Entry-reference cross-match: covers the case where a pending row had
|
||||
# no transaction_id but the settled BOOK row gained one — fingerprints
|
||||
# diverge (enable_banking_<ref> vs enable_banking_<txn_id>) but the
|
||||
# shared entry_reference is a reliable settlement signal.
|
||||
book_fingerprints = all_transactions
|
||||
.reject { |tx| tx.with_indifferent_access[:_pending] }
|
||||
.map { |tx| tx.with_indifferent_access[:transaction_id].presence }
|
||||
.map { |tx| EnableBankingEntry::Processor.compute_external_id(tx) }
|
||||
.compact.to_set
|
||||
|
||||
# Fallback: collect entry_references for BOOK rows that have no transaction_id
|
||||
book_entry_refs = all_transactions
|
||||
.reject { |tx| tx.with_indifferent_access[:_pending] }
|
||||
.map { |tx| tx.with_indifferent_access[:entry_reference].presence }
|
||||
@@ -310,21 +323,20 @@ class EnableBankingItem::Importer
|
||||
pending_flag = tx.dig(:extra, :enable_banking, :pending) || tx[:_pending]
|
||||
next false unless pending_flag
|
||||
|
||||
tx[:transaction_id].present? ?
|
||||
book_ids.include?(tx[:transaction_id]) :
|
||||
book_entry_refs.include?(tx[:entry_reference].presence)
|
||||
fp = EnableBankingEntry::Processor.compute_external_id(tx)
|
||||
entry_ref = tx[:entry_reference].presence
|
||||
(fp.present? && book_fingerprints.include?(fp)) ||
|
||||
(entry_ref.present? && book_entry_refs.include?(entry_ref))
|
||||
end
|
||||
end
|
||||
|
||||
existing_ids = existing_transactions.map { |tx|
|
||||
tx = tx.with_indifferent_access
|
||||
tx[:transaction_id].presence || tx[:entry_reference].presence
|
||||
EnableBankingEntry::Processor.compute_external_id(tx)
|
||||
}.compact.to_set
|
||||
|
||||
new_transactions = all_transactions.select do |tx|
|
||||
# Use transaction_id if present, otherwise fall back to entry_reference
|
||||
tx_id = tx[:transaction_id].presence || tx[:entry_reference].presence
|
||||
tx_id.present? && !existing_ids.include?(tx_id)
|
||||
ext_id = EnableBankingEntry::Processor.compute_external_id(tx)
|
||||
ext_id.present? && !existing_ids.include?(ext_id)
|
||||
end
|
||||
|
||||
if new_transactions.any? || removed_pending
|
||||
@@ -398,7 +410,7 @@ class EnableBankingItem::Importer
|
||||
# omit transaction_id rarely produce such exact duplicates in the same
|
||||
# API response; timestamps or remittance info usually differ. (Issue #954)
|
||||
def build_transaction_content_key(tx)
|
||||
date = tx[:booking_date].presence || tx[:value_date]
|
||||
date = tx[:booking_date].presence || tx[:value_date].presence || tx[:transaction_date]
|
||||
amount = tx.dig(:transaction_amount, :amount).presence || tx[:amount]
|
||||
currency = tx.dig(:transaction_amount, :currency).presence || tx[:currency]
|
||||
creditor = tx.dig(:creditor, :name).presence || tx[:creditor_name]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
class Family < ApplicationRecord
|
||||
include Syncable, AutoTransferMatchable, Subscribeable, VectorSearchable
|
||||
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable
|
||||
include CoinbaseConnectable, BinanceConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, SophtronConnectable
|
||||
include IndexaCapitalConnectable
|
||||
include CoinbaseConnectable, BinanceConnectable, KrakenConnectable, CoinstatsConnectable, SnaptradeConnectable, MercuryConnectable, BrexConnectable, SophtronConnectable
|
||||
include IndexaCapitalConnectable, IbkrConnectable
|
||||
|
||||
DATE_FORMATS = [
|
||||
[ "MM-DD-YYYY", "%m-%d-%Y" ],
|
||||
|
||||
29
app/models/family/brex_connectable.rb
Normal file
29
app/models/family/brex_connectable.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Family::BrexConnectable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :brex_items, dependent: :destroy
|
||||
end
|
||||
|
||||
def can_connect_brex?
|
||||
true
|
||||
end
|
||||
|
||||
def create_brex_item!(token:, base_url: nil, item_name: nil)
|
||||
brex_item = brex_items.create!(
|
||||
name: item_name.presence || I18n.t("brex_items.default_connection_name"),
|
||||
token: token,
|
||||
base_url: base_url
|
||||
)
|
||||
|
||||
brex_item.sync_later
|
||||
|
||||
brex_item
|
||||
end
|
||||
|
||||
def has_brex_credentials?
|
||||
brex_items.active.with_credentials.exists?
|
||||
end
|
||||
end
|
||||
@@ -29,6 +29,10 @@ class Family::DataExporter
|
||||
zipfile.put_next_entry("rules.csv")
|
||||
zipfile.write generate_rules_csv
|
||||
|
||||
# Add attachment manifest metadata. Binary file payloads are not included.
|
||||
zipfile.put_next_entry("attachments.json")
|
||||
zipfile.write generate_attachments_manifest
|
||||
|
||||
# Add all.ndjson
|
||||
zipfile.put_next_entry("all.ndjson")
|
||||
zipfile.write generate_ndjson
|
||||
@@ -138,6 +142,69 @@ class Family::DataExporter
|
||||
end
|
||||
end
|
||||
|
||||
def generate_attachments_manifest
|
||||
{
|
||||
version: 1,
|
||||
binary_included: false,
|
||||
attachments: attachment_manifest_items
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def attachment_manifest_items
|
||||
(transaction_attachment_manifest_items + family_document_attachment_manifest_items)
|
||||
.sort_by { |item| [ item[:record_type], item[:record_id].to_s, item[:filename].to_s, item[:id].to_s ] }
|
||||
end
|
||||
|
||||
def transaction_attachment_manifest_items
|
||||
@family.transactions
|
||||
.with_attached_attachments
|
||||
.includes(:attachments_attachments, entry: :account)
|
||||
.flat_map do |transaction|
|
||||
transaction.attachments.map do |attachment|
|
||||
attachment_manifest_item(
|
||||
attachment,
|
||||
record_type: "Transaction",
|
||||
record_id: transaction.id,
|
||||
extra: {
|
||||
entry_id: transaction.entry.id,
|
||||
account_id: transaction.entry.account_id
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def family_document_attachment_manifest_items
|
||||
@family.family_documents.with_attached_file.filter_map do |document|
|
||||
next unless document.file.attached?
|
||||
|
||||
attachment_manifest_item(
|
||||
document.file.attachment,
|
||||
record_type: "FamilyDocument",
|
||||
record_id: document.id,
|
||||
extra: {
|
||||
status: document.status
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def attachment_manifest_item(attachment, record_type:, record_id:, extra: {})
|
||||
blob = attachment.blob
|
||||
{
|
||||
id: attachment.id,
|
||||
record_type: record_type,
|
||||
record_id: record_id,
|
||||
name: attachment.name,
|
||||
filename: blob.filename.to_s,
|
||||
content_type: blob.content_type,
|
||||
byte_size: blob.byte_size,
|
||||
checksum: blob.checksum,
|
||||
binary_included: false,
|
||||
created_at: attachment.created_at
|
||||
}.merge(extra)
|
||||
end
|
||||
|
||||
def generate_ndjson
|
||||
lines = []
|
||||
|
||||
@@ -426,11 +493,14 @@ class Family::DataExporter
|
||||
end
|
||||
|
||||
def serialize_condition(condition)
|
||||
operand = resolve_condition_operand(condition)
|
||||
data = {
|
||||
condition_type: condition.condition_type,
|
||||
operator: condition.operator,
|
||||
value: resolve_condition_value(condition)
|
||||
value: operand[:value]
|
||||
}
|
||||
value_ref = operand[:value_ref]
|
||||
data[:value_ref] = value_ref if value_ref.present?
|
||||
|
||||
if condition.compound? && condition.sub_conditions.any?
|
||||
data[:sub_conditions] = condition.sub_conditions.map { |sub| serialize_condition(sub) }
|
||||
@@ -440,52 +510,79 @@ class Family::DataExporter
|
||||
end
|
||||
|
||||
def serialize_action(action)
|
||||
{
|
||||
operand = resolve_action_operand(action)
|
||||
data = {
|
||||
action_type: action.action_type,
|
||||
value: resolve_action_value(action)
|
||||
value: operand[:value]
|
||||
}
|
||||
value_ref = operand[:value_ref]
|
||||
data[:value_ref] = value_ref if value_ref.present?
|
||||
|
||||
data
|
||||
end
|
||||
|
||||
def resolve_condition_value(condition)
|
||||
return condition.value unless condition.value.present?
|
||||
def resolve_condition_operand(condition)
|
||||
return rule_operand(condition.value) unless condition.value.present?
|
||||
|
||||
# Map category UUIDs to names for portability
|
||||
if condition.condition_type == "transaction_category" && condition.value.present?
|
||||
category = @family.categories.find_by(id: condition.value)
|
||||
return category&.name || condition.value
|
||||
if condition.condition_type == "transaction_category"
|
||||
return rule_operand(condition.value, type: "Category", relation: @family.categories)
|
||||
end
|
||||
|
||||
# Map merchant UUIDs to names for portability
|
||||
if condition.condition_type == "transaction_merchant" && condition.value.present?
|
||||
merchant = @family.merchants.find_by(id: condition.value)
|
||||
return merchant&.name || condition.value
|
||||
if condition.condition_type == "transaction_merchant"
|
||||
return rule_operand(condition.value, type: "Merchant", relation: @family.merchants)
|
||||
end
|
||||
|
||||
condition.value
|
||||
rule_operand(condition.value)
|
||||
end
|
||||
|
||||
def resolve_action_value(action)
|
||||
return action.value unless action.value.present?
|
||||
def resolve_action_operand(action)
|
||||
return rule_operand(action.value) unless action.value.present?
|
||||
|
||||
# Map category UUIDs to names for portability
|
||||
if action.action_type == "set_transaction_category" && action.value.present?
|
||||
category = @family.categories.find_by(id: action.value) || @family.categories.find_by(name: action.value)
|
||||
return category&.name || action.value
|
||||
if action.action_type == "set_transaction_category"
|
||||
return rule_operand(action.value, type: "Category", relation: @family.categories, fallback_to_name: true)
|
||||
end
|
||||
|
||||
# Map merchant UUIDs to names for portability
|
||||
if action.action_type == "set_transaction_merchant" && action.value.present?
|
||||
merchant = @family.merchants.find_by(id: action.value) || @family.merchants.find_by(name: action.value)
|
||||
return merchant&.name || action.value
|
||||
if action.action_type == "set_transaction_merchant"
|
||||
return rule_operand(action.value, type: "Merchant", relation: @family.merchants, fallback_to_name: true)
|
||||
end
|
||||
|
||||
# Map tag UUIDs to names for portability
|
||||
if action.action_type == "set_transaction_tags" && action.value.present?
|
||||
tag = @family.tags.find_by(id: action.value) || @family.tags.find_by(name: action.value)
|
||||
return tag&.name || action.value
|
||||
if action.action_type == "set_transaction_tags"
|
||||
return rule_operand(action.value, type: "Tag", relation: @family.tags, fallback_to_name: true)
|
||||
end
|
||||
|
||||
action.value
|
||||
rule_operand(action.value)
|
||||
end
|
||||
|
||||
def rule_operand(value, type: nil, relation: nil, fallback_to_name: false)
|
||||
record = relation && resolve_rule_operand_record(relation, value, fallback_to_name: fallback_to_name)
|
||||
|
||||
{
|
||||
value: record&.name || value,
|
||||
value_ref: record ? rule_value_ref(type, record) : nil
|
||||
}
|
||||
end
|
||||
|
||||
def resolve_rule_operand_record(relation, value, fallback_to_name:)
|
||||
return relation.find_by(id: value) if uuid_like?(value)
|
||||
|
||||
relation.find_by(name: value) if fallback_to_name
|
||||
end
|
||||
|
||||
def rule_value_ref(type, record)
|
||||
{
|
||||
type: type,
|
||||
id: record.id,
|
||||
name: record.name
|
||||
}
|
||||
end
|
||||
|
||||
def uuid_like?(value)
|
||||
UuidFormat.valid?(value)
|
||||
end
|
||||
|
||||
def serialize_conditions_for_csv(conditions)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require "set"
|
||||
|
||||
class Family::DataImporter
|
||||
SUPPORTED_TYPES = %w[Account Category Tag Merchant RecurringTransaction Transaction Transfer RejectedTransfer Trade Holding Valuation Budget BudgetCategory Rule].freeze
|
||||
SUPPORTED_TYPES = %w[Account Balance Category Tag Merchant RecurringTransaction Transaction Transfer RejectedTransfer Trade Holding Valuation Budget BudgetCategory Rule].freeze
|
||||
ACCOUNTABLE_TYPES = Accountable::TYPES.freeze
|
||||
|
||||
def initialize(family, ndjson_content)
|
||||
@@ -30,6 +30,7 @@ class Family::DataImporter
|
||||
Import.transaction do
|
||||
# Import in dependency order
|
||||
import_accounts(records["Account"] || [])
|
||||
import_balances(records["Balance"] || [])
|
||||
import_categories(records["Category"] || [])
|
||||
import_tags(records["Tag"] || [])
|
||||
import_merchants(records["Merchant"] || [])
|
||||
@@ -128,6 +129,49 @@ class Family::DataImporter
|
||||
status.to_s.in?(%w[active disabled draft]) ? status.to_s : "active"
|
||||
end
|
||||
|
||||
def import_balances(records)
|
||||
records.each do |record|
|
||||
data = record["data"] || {}
|
||||
new_account_id = @id_mappings[:accounts][data["account_id"]]
|
||||
balance_date = parse_import_date(data["date"])
|
||||
next if new_account_id.blank? || balance_date.blank? || data["balance"].blank?
|
||||
|
||||
account = @family.accounts.find(new_account_id)
|
||||
currency = data["currency"].presence || account.currency
|
||||
balance = account.balances.find_or_initialize_by(date: balance_date, currency: currency)
|
||||
|
||||
balance.assign_attributes(imported_balance_attributes(data))
|
||||
balance.save!
|
||||
end
|
||||
end
|
||||
|
||||
def imported_balance_attributes(data)
|
||||
attributes = {
|
||||
balance: data["balance"].to_d,
|
||||
cash_balance: optional_decimal(data["cash_balance"]),
|
||||
start_cash_balance: optional_decimal(data["start_cash_balance"]),
|
||||
start_non_cash_balance: optional_decimal(data["start_non_cash_balance"]),
|
||||
cash_inflows: optional_decimal(data["cash_inflows"]),
|
||||
cash_outflows: optional_decimal(data["cash_outflows"]),
|
||||
non_cash_inflows: optional_decimal(data["non_cash_inflows"]),
|
||||
non_cash_outflows: optional_decimal(data["non_cash_outflows"]),
|
||||
net_market_flows: optional_decimal(data["net_market_flows"]),
|
||||
cash_adjustments: optional_decimal(data["cash_adjustments"]),
|
||||
non_cash_adjustments: optional_decimal(data["non_cash_adjustments"])
|
||||
}.compact
|
||||
|
||||
attributes[:flows_factor] = balance_flows_factor_for(data["flows_factor"]) if data["flows_factor"].present?
|
||||
attributes
|
||||
end
|
||||
|
||||
def optional_decimal(value)
|
||||
value.presence&.to_d
|
||||
end
|
||||
|
||||
def balance_flows_factor_for(value)
|
||||
value.to_i.in?([ -1, 1 ]) ? value.to_i : 1
|
||||
end
|
||||
|
||||
def import_categories(records)
|
||||
# First pass: create all categories without parent relationships
|
||||
parent_mappings = {}
|
||||
@@ -472,7 +516,7 @@ class Family::DataImporter
|
||||
|
||||
# Account-level opening balances must precede every imported account
|
||||
# activity, including standalone valuation snapshots.
|
||||
%w[Transaction Trade Holding Valuation].each do |type|
|
||||
%w[Balance Transaction Trade Holding Valuation].each do |type|
|
||||
records[type].to_a.each do |record|
|
||||
data = record["data"] || {}
|
||||
account_id = data["account_id"]
|
||||
@@ -627,7 +671,7 @@ class Family::DataImporter
|
||||
|
||||
def resolve_rule_condition_value(condition_data)
|
||||
condition_type = condition_data["condition_type"]
|
||||
value = condition_data["value"]
|
||||
value = rule_operand_value(condition_data)
|
||||
|
||||
return value unless value.present?
|
||||
|
||||
@@ -655,7 +699,7 @@ class Family::DataImporter
|
||||
|
||||
def resolve_rule_action_value(action_data)
|
||||
action_type = action_data["action_type"]
|
||||
value = action_data["value"]
|
||||
value = rule_operand_value(action_data)
|
||||
|
||||
return value unless value.present?
|
||||
|
||||
@@ -688,6 +732,21 @@ class Family::DataImporter
|
||||
value
|
||||
end
|
||||
|
||||
def rule_operand_value(data)
|
||||
raw_value = data["value"]
|
||||
value = raw_value.is_a?(String) ? raw_value.presence : raw_value
|
||||
value_ref_name = data.dig("value_ref", "name")
|
||||
|
||||
return value_ref_name if value.is_a?(String) && uuid_like?(value) && value_ref_name.present?
|
||||
return value unless value.nil?
|
||||
|
||||
value_ref_name
|
||||
end
|
||||
|
||||
def uuid_like?(value)
|
||||
UuidFormat.valid?(value)
|
||||
end
|
||||
|
||||
def importable_cost_basis_source(value)
|
||||
source = value.to_s
|
||||
Holding::COST_BASIS_SOURCES.include?(source) ? source : nil
|
||||
|
||||
22
app/models/family/ibkr_connectable.rb
Normal file
22
app/models/family/ibkr_connectable.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
module Family::IbkrConnectable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :ibkr_items, dependent: :destroy
|
||||
end
|
||||
|
||||
def can_connect_ibkr?
|
||||
true
|
||||
end
|
||||
|
||||
def create_ibkr_item!(query_id:, token:, item_name: nil)
|
||||
ibkr_item = ibkr_items.create!(
|
||||
name: item_name.presence || "Interactive Brokers",
|
||||
query_id: query_id,
|
||||
token: token
|
||||
)
|
||||
|
||||
ibkr_item.sync_later
|
||||
ibkr_item
|
||||
end
|
||||
end
|
||||
29
app/models/family/kraken_connectable.rb
Normal file
29
app/models/family/kraken_connectable.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Family::KrakenConnectable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :kraken_items, dependent: :destroy
|
||||
end
|
||||
|
||||
def can_connect_kraken?
|
||||
true
|
||||
end
|
||||
|
||||
def create_kraken_item!(api_key:, api_secret:, item_name: nil)
|
||||
item = kraken_items.create!(
|
||||
name: item_name || "Kraken",
|
||||
api_key: api_key,
|
||||
api_secret: api_secret
|
||||
)
|
||||
|
||||
item.set_kraken_institution_defaults!
|
||||
item.sync_later
|
||||
item
|
||||
end
|
||||
|
||||
def has_kraken_credentials?
|
||||
kraken_items.active.any?(&:credentials_configured?)
|
||||
end
|
||||
end
|
||||
@@ -17,6 +17,7 @@ class Family::Syncer
|
||||
coinbase_items
|
||||
coinstats_items
|
||||
mercury_items
|
||||
brex_items
|
||||
binance_items
|
||||
snaptrade_items
|
||||
sophtron_items
|
||||
|
||||
@@ -38,7 +38,7 @@ class Holding < ApplicationRecord
|
||||
return nil unless amount
|
||||
return 0 if amount.zero?
|
||||
|
||||
account.balance.zero? ? 1 : amount / account.balance * 100
|
||||
account.balance.zero? ? 1 : amount_in_account_currency / account.balance * 100
|
||||
end
|
||||
|
||||
# Returns average cost per share, or nil if unknown.
|
||||
@@ -256,6 +256,14 @@ class Holding < ApplicationRecord
|
||||
end
|
||||
|
||||
private
|
||||
def amount_in_account_currency
|
||||
return amount if currency == account.currency
|
||||
|
||||
Money.new(amount, currency).exchange_to(account.currency, date: date).amount
|
||||
rescue Money::ConversionError
|
||||
amount
|
||||
end
|
||||
|
||||
def calculate_trend
|
||||
return nil unless amount_money
|
||||
return nil if avg_cost.nil? # Can't calculate trend without cost basis (0 is valid for airdrops)
|
||||
|
||||
@@ -22,10 +22,10 @@ class Holding::Materializer
|
||||
# securities are still needed to derive sane balance charts between sync snapshots.
|
||||
cleanup_shadowed_calculated_holdings
|
||||
|
||||
# Also remove calculated rows on the provider's latest snapshot date when those
|
||||
# securities are no longer present in the provider payload. This keeps "current"
|
||||
# holdings/balance composition aligned with the provider snapshot while preserving
|
||||
# older calculated history.
|
||||
# Also remove non-provider rows on the provider's latest snapshot date for securities
|
||||
# that appear in the provider snapshot. The provider snapshot is authoritative for
|
||||
# those securities on that day, even when it is denominated in a different currency
|
||||
# than the account or the reverse-calculated holdings.
|
||||
cleanup_stale_calculated_rows_on_latest_provider_snapshot
|
||||
|
||||
# Reload holdings association to clear any cached stale data
|
||||
@@ -152,17 +152,12 @@ class Holding::Materializer
|
||||
.where(date: provider_snapshot_date)
|
||||
.distinct
|
||||
.pluck(:security_id)
|
||||
return if provider_security_ids.empty?
|
||||
|
||||
scope = account.holdings
|
||||
.where(account_provider_id: nil, date: provider_snapshot_date)
|
||||
deleted_count = account.holdings
|
||||
.where(account_provider_id: nil, date: provider_snapshot_date, security_id: provider_security_ids)
|
||||
.delete_all
|
||||
|
||||
scope = if provider_security_ids.any?
|
||||
scope.where.not(security_id: provider_security_ids)
|
||||
else
|
||||
scope
|
||||
end
|
||||
|
||||
deleted_count = scope.delete_all
|
||||
Rails.logger.info("Cleaned up #{deleted_count} stale calculated holdings on latest provider snapshot date") if deleted_count > 0
|
||||
end
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ class Holding::PortfolioCache
|
||||
price_money = Money.new(price.price, price.currency)
|
||||
|
||||
begin
|
||||
converted_amount = price_money.exchange_to(account.currency).amount
|
||||
converted_amount = price_money.exchange_to(account.currency, date: date).amount
|
||||
rescue Money::ConversionError
|
||||
converted_amount = price.price
|
||||
end
|
||||
|
||||
78
app/models/ibkr_account.rb
Normal file
78
app/models/ibkr_account.rb
Normal file
@@ -0,0 +1,78 @@
|
||||
class IbkrAccount < ApplicationRecord
|
||||
include CurrencyNormalizable, Encryptable
|
||||
include IbkrAccount::DataHelpers
|
||||
|
||||
if encryption_ready?
|
||||
encrypts :raw_holdings_payload
|
||||
encrypts :raw_activities_payload
|
||||
encrypts :raw_cash_report_payload
|
||||
encrypts :raw_equity_summary_payload
|
||||
end
|
||||
|
||||
belongs_to :ibkr_item
|
||||
|
||||
has_one :account_provider, as: :provider, dependent: :destroy
|
||||
has_one :account, through: :account_provider, source: :account
|
||||
has_one :linked_account, through: :account_provider, source: :account
|
||||
|
||||
validates :name, :currency, presence: true
|
||||
validates :ibkr_account_id, uniqueness: { scope: :ibkr_item_id, allow_nil: true }
|
||||
|
||||
def current_account
|
||||
account || linked_account
|
||||
end
|
||||
|
||||
def ensure_account_provider!(account = nil)
|
||||
if account_provider.present?
|
||||
account_provider.update!(account: account) if account && account_provider.account_id != account.id
|
||||
return account_provider
|
||||
end
|
||||
|
||||
acct = account || current_account
|
||||
return nil unless acct
|
||||
|
||||
provider = AccountProvider
|
||||
.find_or_initialize_by(provider_type: "IbkrAccount", provider_id: id)
|
||||
.tap do |record|
|
||||
record.account = acct
|
||||
record.save!
|
||||
end
|
||||
|
||||
reload_account_provider
|
||||
provider
|
||||
rescue => e
|
||||
Rails.logger.warn("IbkrAccount##{id}: failed to ensure AccountProvider link: #{e.class} - #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def upsert_from_ibkr_statement!(account_data)
|
||||
data = account_data.with_indifferent_access
|
||||
|
||||
update!(
|
||||
ibkr_account_id: data[:ibkr_account_id],
|
||||
name: data[:name],
|
||||
currency: parse_currency(data[:currency]) || "USD",
|
||||
current_balance: data[:current_balance],
|
||||
cash_balance: data[:cash_balance],
|
||||
institution_metadata: {
|
||||
provider_name: "Interactive Brokers",
|
||||
statement_from_date: data.dig(:statement, :from_date),
|
||||
statement_to_date: data.dig(:statement, :to_date)
|
||||
}.compact,
|
||||
report_date: data[:report_date],
|
||||
raw_holdings_payload: data[:open_positions] || [],
|
||||
raw_activities_payload: {
|
||||
trades: data[:trades] || [],
|
||||
cash_transactions: data[:cash_transactions] || []
|
||||
},
|
||||
raw_cash_report_payload: data[:cash_report] || [],
|
||||
raw_equity_summary_payload: data[:equity_summary_in_base] || [],
|
||||
last_holdings_sync: Time.current,
|
||||
last_activities_sync: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def ibkr_provider
|
||||
ibkr_item.ibkr_provider
|
||||
end
|
||||
end
|
||||
221
app/models/ibkr_account/activities_processor.rb
Normal file
221
app/models/ibkr_account/activities_processor.rb
Normal file
@@ -0,0 +1,221 @@
|
||||
class IbkrAccount::ActivitiesProcessor
|
||||
include IbkrAccount::DataHelpers
|
||||
|
||||
SUPPORTED_CASH_TRANSACTION_TYPES = [ "DEPOSITS/WITHDRAWALS", "DIVIDENDS" ].freeze
|
||||
|
||||
def initialize(ibkr_account)
|
||||
@ibkr_account = ibkr_account
|
||||
end
|
||||
|
||||
def process
|
||||
return { trades: 0, transactions: 0 } unless account.present?
|
||||
|
||||
activities = (@ibkr_account.raw_activities_payload || {}).with_indifferent_access
|
||||
trades = Array(activities[:trades])
|
||||
cash_transactions = Array(activities[:cash_transactions])
|
||||
@fee_transactions_count = 0
|
||||
|
||||
trades_count = trades.sum { |trade| process_trade(trade.with_indifferent_access) ? 1 : 0 }
|
||||
cash_transactions_count = cash_transactions.sum { |cash_transaction| process_cash_transaction(cash_transaction.with_indifferent_access) ? 1 : 0 }
|
||||
|
||||
{
|
||||
trades: trades_count,
|
||||
transactions: cash_transactions_count + @fee_transactions_count
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account
|
||||
@ibkr_account.current_account
|
||||
end
|
||||
|
||||
def import_adapter
|
||||
@import_adapter ||= Account::ProviderImportAdapter.new(account)
|
||||
end
|
||||
|
||||
def process_trade(row)
|
||||
return false unless supported_trade?(row)
|
||||
|
||||
security = resolve_security(row)
|
||||
return false unless security
|
||||
|
||||
quantity = parse_decimal(row[:quantity])
|
||||
native_price = parse_decimal(row[:trade_price])
|
||||
return false if quantity.nil? || native_price.nil?
|
||||
|
||||
buy_sell = row[:buy_sell].to_s.upcase
|
||||
signed_quantity = buy_sell == "SELL" ? -quantity.abs : quantity.abs
|
||||
native_amount = buy_sell == "SELL" ? -(native_price * quantity.abs) : (native_price * quantity.abs)
|
||||
currency = extract_currency(row, fallback: @ibkr_account.currency)
|
||||
date = trade_date_for(row)
|
||||
external_id = "ibkr_trade_#{row[:trade_id]}"
|
||||
|
||||
import_adapter.import_trade(
|
||||
external_id: external_id,
|
||||
security: security,
|
||||
quantity: signed_quantity,
|
||||
price: native_price,
|
||||
amount: native_amount,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: build_trade_name(security.ticker, signed_quantity),
|
||||
source: "ibkr",
|
||||
activity_label: buy_sell == "SELL" ? "Sell" : "Buy",
|
||||
exchange_rate: parse_decimal(row[:fx_rate_to_base])&.to_f
|
||||
)
|
||||
|
||||
import_commission_transaction(row, security, date)
|
||||
true
|
||||
rescue => e
|
||||
Rails.logger.error("IbkrAccount::ActivitiesProcessor - Failed to process trade #{row[:trade_id]}: #{e.message}")
|
||||
false
|
||||
end
|
||||
|
||||
def process_cash_transaction(row)
|
||||
return false unless supported_cash_transaction?(row)
|
||||
|
||||
amount = parse_decimal(row[:amount])
|
||||
return false if amount.nil? || amount.zero?
|
||||
|
||||
label, signed_amount = classify_cash_transaction(row, amount)
|
||||
return false unless label
|
||||
currency = extract_currency(row, fallback: @ibkr_account.currency)
|
||||
security = resolve_security_for_cash_transaction(row)
|
||||
|
||||
import_adapter.import_transaction(
|
||||
external_id: "ibkr_cash_#{row[:transaction_id]}",
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
date: parse_date(row[:report_date]),
|
||||
name: build_cash_transaction_name(row, label, security),
|
||||
source: "ibkr",
|
||||
investment_activity_label: label,
|
||||
extra: {
|
||||
exchange_rate: parse_decimal(row[:fx_rate_to_base])&.to_f,
|
||||
security_id: security&.id,
|
||||
ibkr: {
|
||||
transaction_id: row[:transaction_id],
|
||||
type: row[:type],
|
||||
conid: row[:conid],
|
||||
amount: row[:amount],
|
||||
currency: row[:currency],
|
||||
fx_rate_to_base: row[:fx_rate_to_base],
|
||||
report_date: row[:report_date]
|
||||
}.compact
|
||||
}
|
||||
)
|
||||
|
||||
true
|
||||
rescue => e
|
||||
Rails.logger.error("IbkrAccount::ActivitiesProcessor - Failed to process cash transaction #{row[:transaction_id]}: #{e.message}")
|
||||
false
|
||||
end
|
||||
|
||||
def import_commission_transaction(row, security, date)
|
||||
commission = parse_decimal(row[:ib_commission])
|
||||
return if commission.nil? || commission.zero?
|
||||
currency = row.with_indifferent_access[:ib_commission_currency].to_s.upcase.presence || @ibkr_account.currency
|
||||
ticker = security&.ticker || row.with_indifferent_access[:symbol]
|
||||
|
||||
result = import_adapter.import_transaction(
|
||||
external_id: "ibkr_trade_fee_#{row[:trade_id]}",
|
||||
amount: commission.abs,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: "Trade Commission for #{ticker}",
|
||||
source: "ibkr",
|
||||
investment_activity_label: "Fee",
|
||||
extra: {
|
||||
exchange_rate: parse_decimal(row[:fx_rate_to_base])&.to_f,
|
||||
security_id: security&.id,
|
||||
ibkr: {
|
||||
trade_id: row[:trade_id],
|
||||
transaction_id: row[:transaction_id],
|
||||
ib_commission: row[:ib_commission],
|
||||
ib_commission_currency: row[:ib_commission_currency],
|
||||
fx_rate_to_base: row[:fx_rate_to_base]
|
||||
}.compact
|
||||
}
|
||||
)
|
||||
|
||||
@fee_transactions_count += 1 if result
|
||||
end
|
||||
|
||||
def build_trade_name(ticker, signed_quantity)
|
||||
action = signed_quantity.negative? ? "Sell" : "Buy"
|
||||
"#{action} #{signed_quantity.abs} shares of #{ticker}"
|
||||
end
|
||||
|
||||
def supported_trade?(row)
|
||||
row[:asset_category].to_s == "STK" &&
|
||||
row[:buy_sell].present? &&
|
||||
row[:conid].present? &&
|
||||
row[:currency].present? &&
|
||||
row[:quantity].present? &&
|
||||
row[:symbol].present? &&
|
||||
row[:trade_date].present? &&
|
||||
row[:trade_id].present? &&
|
||||
row[:trade_price].present? &&
|
||||
row[:transaction_id].present? &&
|
||||
fx_rate_available?(row)
|
||||
end
|
||||
|
||||
def supported_cash_transaction?(row)
|
||||
type = row[:type].to_s.upcase.strip
|
||||
return false unless SUPPORTED_CASH_TRANSACTION_TYPES.include?(type)
|
||||
return false unless row[:transaction_id].present? && row[:amount].present? && row[:currency].present? && row[:report_date].present?
|
||||
return false unless fx_rate_available?(row)
|
||||
|
||||
type != "DIVIDENDS" || row[:conid].present?
|
||||
end
|
||||
|
||||
def classify_cash_transaction(row, amount)
|
||||
type = row[:type].to_s.upcase.strip
|
||||
|
||||
case type
|
||||
when "DEPOSITS/WITHDRAWALS"
|
||||
amount.positive? ? [ "Contribution", -amount.abs ] : [ "Withdrawal", amount.abs ]
|
||||
when "DIVIDENDS"
|
||||
[ "Dividend", -amount.abs ]
|
||||
else
|
||||
[ nil, nil ]
|
||||
end
|
||||
end
|
||||
|
||||
def build_cash_transaction_name(row, label, security = nil)
|
||||
return label unless label == "Dividend"
|
||||
|
||||
ticker = security&.ticker || security_symbol_for_conid(row[:conid]) || row[:conid]
|
||||
"Dividend from #{ticker}"
|
||||
end
|
||||
|
||||
def resolve_security_for_cash_transaction(row)
|
||||
symbol = security_symbol_for_conid(row[:conid])
|
||||
return nil if symbol.blank?
|
||||
|
||||
resolve_security({ symbol: symbol })
|
||||
end
|
||||
|
||||
def security_symbol_for_conid(conid)
|
||||
return nil if conid.blank?
|
||||
|
||||
holding_symbol = Array(@ibkr_account.raw_holdings_payload).find do |holding|
|
||||
holding.with_indifferent_access[:conid].to_s == conid.to_s
|
||||
end&.with_indifferent_access&.dig(:symbol)
|
||||
return holding_symbol if holding_symbol.present?
|
||||
|
||||
Array(@ibkr_account.raw_activities_payload&.dig("trades") || @ibkr_account.raw_activities_payload&.dig(:trades)).find do |trade|
|
||||
trade.with_indifferent_access[:conid].to_s == conid.to_s
|
||||
end&.with_indifferent_access&.dig(:symbol)
|
||||
end
|
||||
|
||||
|
||||
def fx_rate_available?(row)
|
||||
source_currency = extract_currency(row, fallback: nil)
|
||||
return false if source_currency.blank?
|
||||
return true if source_currency == @ibkr_account.currency
|
||||
|
||||
row[:fx_rate_to_base].present?
|
||||
end
|
||||
end
|
||||
78
app/models/ibkr_account/data_helpers.rb
Normal file
78
app/models/ibkr_account/data_helpers.rb
Normal file
@@ -0,0 +1,78 @@
|
||||
module IbkrAccount::DataHelpers
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
def parse_decimal(value)
|
||||
return nil if value.nil?
|
||||
|
||||
normalized = value.is_a?(String) ? value.delete(",").strip : value.to_s
|
||||
return nil if normalized.blank? || normalized == "-"
|
||||
|
||||
BigDecimal(normalized)
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def parse_date(value)
|
||||
return nil if value.blank?
|
||||
|
||||
case value
|
||||
when Date
|
||||
value
|
||||
when Time, DateTime, ActiveSupport::TimeWithZone
|
||||
value.to_date
|
||||
else
|
||||
normalized = value.to_s.tr(";", " ")
|
||||
Time.zone.parse(normalized)&.to_date || Date.parse(normalized)
|
||||
end
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def parse_datetime(value)
|
||||
return nil if value.blank?
|
||||
|
||||
case value
|
||||
when Time, DateTime, ActiveSupport::TimeWithZone
|
||||
value.in_time_zone
|
||||
when Date
|
||||
value.in_time_zone
|
||||
else
|
||||
Time.zone.parse(value.to_s.tr(";", " "))
|
||||
end
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def resolve_security(row)
|
||||
data = row.with_indifferent_access
|
||||
ticker = data[:symbol].to_s.strip.upcase
|
||||
return nil if ticker.blank?
|
||||
|
||||
Security.find_by(ticker: ticker) || create_security_from_row(ticker)
|
||||
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
||||
Security.find_by(ticker: ticker)
|
||||
end
|
||||
|
||||
def trade_date_for(row)
|
||||
data = row.with_indifferent_access
|
||||
parsed_trade_date = parse_date(data[:trade_date])
|
||||
return parsed_trade_date if parsed_trade_date
|
||||
|
||||
Rails.logger.warn(
|
||||
"IbkrAccount::DataHelpers - Missing or invalid trade_date, falling back to Date.current. " \
|
||||
"trade_id=#{data[:trade_id].inspect}"
|
||||
)
|
||||
Date.current
|
||||
end
|
||||
|
||||
def extract_currency(row, fallback: nil)
|
||||
value = row.with_indifferent_access[:currency]
|
||||
value.present? ? value.to_s.upcase : fallback
|
||||
end
|
||||
|
||||
def create_security_from_row(ticker)
|
||||
Security.create!(ticker: ticker, name: ticker)
|
||||
end
|
||||
end
|
||||
79
app/models/ibkr_account/historical_balances_sync.rb
Normal file
79
app/models/ibkr_account/historical_balances_sync.rb
Normal file
@@ -0,0 +1,79 @@
|
||||
class IbkrAccount::HistoricalBalancesSync
|
||||
include IbkrAccount::DataHelpers
|
||||
|
||||
attr_reader :ibkr_account
|
||||
|
||||
def initialize(ibkr_account)
|
||||
@ibkr_account = ibkr_account
|
||||
end
|
||||
|
||||
def sync!
|
||||
return unless account.present?
|
||||
return if normalized_rows.empty?
|
||||
|
||||
account.balances.upsert_all(
|
||||
balance_rows,
|
||||
unique_by: %i[account_id date currency]
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def account
|
||||
ibkr_account.current_account
|
||||
end
|
||||
|
||||
def normalized_rows
|
||||
@normalized_rows ||= Array(ibkr_account.raw_equity_summary_payload)
|
||||
.filter_map do |row|
|
||||
next unless row.is_a?(Hash)
|
||||
|
||||
data = row.with_indifferent_access
|
||||
currency = data[:currency].presence&.upcase
|
||||
account_currency = ibkr_account.currency.to_s.upcase
|
||||
next if currency.present? && currency != account_currency
|
||||
|
||||
date = parse_date(data[:report_date])
|
||||
total = parse_decimal(data[:total])
|
||||
cash = parse_decimal(data[:cash]) || BigDecimal("0")
|
||||
next unless date && total
|
||||
|
||||
{
|
||||
date: date,
|
||||
total: total,
|
||||
cash: cash,
|
||||
non_cash: total - cash
|
||||
}
|
||||
end
|
||||
.sort_by { |row| row[:date] }
|
||||
end
|
||||
|
||||
def balance_rows
|
||||
current_time = Time.current
|
||||
|
||||
normalized_rows.each_with_index.map do |row, index|
|
||||
previous_row = index.zero? ? nil : normalized_rows[index - 1]
|
||||
start_cash_balance = previous_row ? previous_row[:cash] : row[:cash]
|
||||
start_non_cash_balance = previous_row ? previous_row[:non_cash] : row[:non_cash]
|
||||
|
||||
{
|
||||
account_id: account.id,
|
||||
date: row[:date],
|
||||
balance: row[:total],
|
||||
cash_balance: row[:cash],
|
||||
currency: account.currency,
|
||||
start_cash_balance: start_cash_balance,
|
||||
start_non_cash_balance: start_non_cash_balance,
|
||||
cash_inflows: 0,
|
||||
cash_outflows: 0,
|
||||
non_cash_inflows: 0,
|
||||
non_cash_outflows: 0,
|
||||
net_market_flows: 0,
|
||||
cash_adjustments: row[:cash] - start_cash_balance,
|
||||
non_cash_adjustments: row[:non_cash] - start_non_cash_balance,
|
||||
flows_factor: 1,
|
||||
created_at: current_time,
|
||||
updated_at: current_time
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
105
app/models/ibkr_account/holdings_processor.rb
Normal file
105
app/models/ibkr_account/holdings_processor.rb
Normal file
@@ -0,0 +1,105 @@
|
||||
class IbkrAccount::HoldingsProcessor
|
||||
include IbkrAccount::DataHelpers
|
||||
|
||||
def initialize(ibkr_account)
|
||||
@ibkr_account = ibkr_account
|
||||
end
|
||||
|
||||
def process
|
||||
return unless account.present?
|
||||
|
||||
grouped_positions.each_value do |group|
|
||||
process_group(group)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account
|
||||
@ibkr_account.current_account
|
||||
end
|
||||
|
||||
def import_adapter
|
||||
@import_adapter ||= Account::ProviderImportAdapter.new(account)
|
||||
end
|
||||
|
||||
def grouped_positions
|
||||
Array(@ibkr_account.raw_holdings_payload).each_with_object({}) do |position, groups|
|
||||
data = position.with_indifferent_access
|
||||
next unless supported_position?(data)
|
||||
|
||||
symbol_key = data[:conid].presence || data[:symbol].presence || data[:security_id].presence
|
||||
currency = extract_currency(data, fallback: @ibkr_account.currency)
|
||||
report_date = parse_date(data[:report_date]) || @ibkr_account.report_date || Date.current
|
||||
key = [ symbol_key, currency, report_date ]
|
||||
groups[key] ||= []
|
||||
groups[key] << data
|
||||
end
|
||||
end
|
||||
|
||||
def process_group(rows)
|
||||
sample = rows.first
|
||||
security = resolve_security(sample)
|
||||
return unless security
|
||||
|
||||
quantity = rows.sum { |row| parse_decimal(row[:position]) || BigDecimal("0") }
|
||||
return if quantity.zero?
|
||||
|
||||
price = parse_decimal(sample[:mark_price])
|
||||
cost_basis = weighted_cost_basis_for(rows)
|
||||
return unless price && cost_basis
|
||||
|
||||
amount = quantity.abs * price
|
||||
|
||||
currency = extract_currency(sample, fallback: @ibkr_account.currency)
|
||||
report_date = parse_date(sample[:report_date]) || @ibkr_account.report_date || Date.current
|
||||
external_id = [ "ibkr", @ibkr_account.ibkr_account_id, sample[:conid].presence || security.ticker, report_date, currency ].join("_")
|
||||
|
||||
import_adapter.import_holding(
|
||||
security: security,
|
||||
quantity: quantity,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: report_date,
|
||||
price: price || BigDecimal("0"),
|
||||
cost_basis: cost_basis,
|
||||
external_id: external_id,
|
||||
source: "ibkr",
|
||||
account_provider_id: @ibkr_account.account_provider&.id,
|
||||
delete_future_holdings: false
|
||||
)
|
||||
end
|
||||
|
||||
def weighted_cost_basis_for(rows)
|
||||
total_quantity = BigDecimal("0")
|
||||
total_cost = BigDecimal("0")
|
||||
|
||||
rows.each do |row|
|
||||
row_quantity = parse_decimal(row[:position])
|
||||
row_cost_basis = parse_decimal(row[:cost_basis_price])
|
||||
return nil unless row_quantity && row_cost_basis
|
||||
|
||||
total_quantity += row_quantity.abs
|
||||
total_cost += row_quantity.abs * row_cost_basis
|
||||
end
|
||||
|
||||
return nil if total_quantity.zero?
|
||||
|
||||
total_cost / total_quantity
|
||||
end
|
||||
|
||||
def supported_position?(row)
|
||||
row[:asset_category].to_s == "STK" &&
|
||||
row[:side].to_s == "Long" &&
|
||||
row[:conid].present? &&
|
||||
row[:security_id].present? &&
|
||||
row[:security_id_type].present? &&
|
||||
row[:symbol].present? &&
|
||||
row[:currency].present? &&
|
||||
row[:fx_rate_to_base].present? &&
|
||||
row[:position].present? &&
|
||||
row[:mark_price].present? &&
|
||||
row[:cost_basis_price].present? &&
|
||||
row[:report_date].present?
|
||||
end
|
||||
end
|
||||
56
app/models/ibkr_account/processor.rb
Normal file
56
app/models/ibkr_account/processor.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class IbkrAccount::Processor
|
||||
attr_reader :ibkr_account
|
||||
|
||||
def initialize(ibkr_account)
|
||||
@ibkr_account = ibkr_account
|
||||
end
|
||||
|
||||
def process
|
||||
return unless ibkr_account.current_account.present?
|
||||
|
||||
update_account_balance!
|
||||
IbkrAccount::HoldingsProcessor.new(ibkr_account).process
|
||||
IbkrAccount::ActivitiesProcessor.new(ibkr_account).process
|
||||
repair_default_opening_anchor!
|
||||
|
||||
ibkr_account.current_account.broadcast_sync_complete
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_account_balance!
|
||||
account = ibkr_account.current_account
|
||||
|
||||
total_balance = ibkr_account.current_balance || ibkr_account.cash_balance || 0
|
||||
cash_balance = ibkr_account.cash_balance || 0
|
||||
|
||||
account.assign_attributes(
|
||||
balance: total_balance,
|
||||
cash_balance: cash_balance,
|
||||
currency: ibkr_account.currency
|
||||
)
|
||||
account.save!
|
||||
account.set_current_balance(total_balance)
|
||||
end
|
||||
|
||||
def repair_default_opening_anchor!
|
||||
account = ibkr_account.current_account
|
||||
return unless account&.linked_to?("IbkrAccount")
|
||||
return unless account.has_opening_anchor?
|
||||
|
||||
opening_anchor_entry = account.valuations.opening_anchor.includes(:entry).first&.entry
|
||||
return unless opening_anchor_entry
|
||||
return unless opening_anchor_entry.created_at.to_date == account.created_at.to_date
|
||||
return unless account.entries.where.not(entryable_type: "Valuation").exists?
|
||||
|
||||
imported_current_balance = (ibkr_account.current_balance || ibkr_account.cash_balance || 0).to_d
|
||||
return unless opening_anchor_entry.amount.to_d == imported_current_balance
|
||||
|
||||
result = Account::OpeningBalanceManager.new(account).set_opening_balance(
|
||||
balance: 0,
|
||||
date: opening_anchor_entry.date
|
||||
)
|
||||
|
||||
raise result.error if result.error
|
||||
end
|
||||
end
|
||||
124
app/models/ibkr_item.rb
Normal file
124
app/models/ibkr_item.rb
Normal file
@@ -0,0 +1,124 @@
|
||||
class IbkrItem < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking, Encryptable
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
if encryption_ready?
|
||||
encrypts :query_id, deterministic: true
|
||||
encrypts :token
|
||||
encrypts :raw_payload
|
||||
end
|
||||
|
||||
belongs_to :family
|
||||
has_one_attached :logo, dependent: :purge_later
|
||||
|
||||
has_many :ibkr_accounts, dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :query_id, presence: true, on: :create
|
||||
validates :token, presence: true, on: :create
|
||||
|
||||
scope :active, -> { where(scheduled_for_deletion: false) }
|
||||
scope :syncable, -> { active.where.not(query_id: [ nil, "" ]).where.not(token: nil) }
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
scope :needs_update, -> { where(status: :requires_update) }
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
def credentials_configured?
|
||||
query_id.present? && token.present?
|
||||
end
|
||||
|
||||
def import_latest_ibkr_data
|
||||
provider = ibkr_provider
|
||||
raise StandardError, "IBKR provider is not configured" unless provider
|
||||
|
||||
IbkrItem::Importer.new(self, ibkr_provider: provider).import
|
||||
rescue => e
|
||||
Rails.logger.error("IbkrItem #{id} - Failed to import data: #{e.message}")
|
||||
raise
|
||||
end
|
||||
|
||||
def process_accounts
|
||||
return [] if ibkr_accounts.empty?
|
||||
|
||||
linked_ibkr_accounts.includes(account_provider: :account).each_with_object([]) do |ibkr_account, results|
|
||||
account = ibkr_account.current_account
|
||||
next unless account
|
||||
next if account.pending_deletion? || account.disabled?
|
||||
|
||||
begin
|
||||
result = IbkrAccount::Processor.new(ibkr_account).process
|
||||
results << { ibkr_account_id: ibkr_account.id, success: true, result: result }
|
||||
rescue => e
|
||||
Rails.logger.error("IbkrItem #{id} - Failed to process account #{ibkr_account.id}: #{e.message}")
|
||||
results << { ibkr_account_id: ibkr_account.id, success: false, error: e.message }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
||||
accounts.reject { |account| account.pending_deletion? || account.disabled? }.each_with_object([]) do |account, results|
|
||||
begin
|
||||
account.sync_later(
|
||||
parent_sync: parent_sync,
|
||||
window_start_date: window_start_date,
|
||||
window_end_date: window_end_date
|
||||
)
|
||||
results << { account_id: account.id, success: true }
|
||||
rescue => e
|
||||
Rails.logger.error("IbkrItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}")
|
||||
results << { account_id: account.id, success: false, error: e.message }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_ibkr_snapshot!(payload)
|
||||
update!(raw_payload: payload, status: :good)
|
||||
end
|
||||
|
||||
def accounts
|
||||
ibkr_accounts.includes(account_provider: :account).filter_map(&:current_account).uniq
|
||||
end
|
||||
|
||||
def linked_ibkr_accounts
|
||||
ibkr_accounts.joins(:account_provider)
|
||||
end
|
||||
|
||||
def linked_accounts_count
|
||||
ibkr_accounts.joins(:account_provider).count
|
||||
end
|
||||
|
||||
def unlinked_accounts_count
|
||||
ibkr_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
|
||||
end
|
||||
|
||||
def total_accounts_count
|
||||
ibkr_accounts.count
|
||||
end
|
||||
|
||||
def has_completed_initial_setup?
|
||||
accounts.any?
|
||||
end
|
||||
|
||||
def sync_status_summary
|
||||
total_accounts = total_accounts_count
|
||||
linked_count = linked_accounts_count
|
||||
unlinked_count = unlinked_accounts_count
|
||||
|
||||
if total_accounts.zero?
|
||||
I18n.t("ibkr_items.sync_status.no_accounts")
|
||||
elsif unlinked_count.zero?
|
||||
I18n.t("ibkr_items.sync_status.all_linked", count: linked_count)
|
||||
else
|
||||
I18n.t("ibkr_items.sync_status.partial", linked: linked_count, unlinked: unlinked_count)
|
||||
end
|
||||
end
|
||||
|
||||
def institution_display_name
|
||||
I18n.t("ibkr_items.defaults.name")
|
||||
end
|
||||
end
|
||||
33
app/models/ibkr_item/importer.rb
Normal file
33
app/models/ibkr_item/importer.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
class IbkrItem::Importer
|
||||
attr_reader :ibkr_item, :ibkr_provider
|
||||
|
||||
def initialize(ibkr_item, ibkr_provider:)
|
||||
@ibkr_item = ibkr_item
|
||||
@ibkr_provider = ibkr_provider
|
||||
end
|
||||
|
||||
def import
|
||||
xml_body = ibkr_provider.download_statement
|
||||
parsed_report = IbkrItem::ReportParser.new(xml_body).parse
|
||||
|
||||
accounts_imported = 0
|
||||
ibkr_item.transaction do
|
||||
ibkr_item.upsert_ibkr_snapshot!(parsed_report[:metadata].merge("fetched_at" => Time.current.iso8601))
|
||||
|
||||
parsed_report[:accounts].each do |account_data|
|
||||
next if account_data[:ibkr_account_id].blank?
|
||||
|
||||
ibkr_account = ibkr_item.ibkr_accounts.find_or_initialize_by(ibkr_account_id: account_data[:ibkr_account_id])
|
||||
ibkr_account.upsert_from_ibkr_statement!(account_data)
|
||||
accounts_imported += 1
|
||||
end
|
||||
|
||||
ibkr_item.update!(status: :good)
|
||||
end
|
||||
|
||||
{
|
||||
success: true,
|
||||
accounts_imported: accounts_imported
|
||||
}
|
||||
end
|
||||
end
|
||||
9
app/models/ibkr_item/provided.rb
Normal file
9
app/models/ibkr_item/provided.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module IbkrItem::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def ibkr_provider
|
||||
return nil unless credentials_configured?
|
||||
|
||||
Provider::IbkrFlex.new(query_id: query_id, token: token)
|
||||
end
|
||||
end
|
||||
143
app/models/ibkr_item/report_parser.rb
Normal file
143
app/models/ibkr_item/report_parser.rb
Normal file
@@ -0,0 +1,143 @@
|
||||
class IbkrItem::ReportParser
|
||||
include IbkrAccount::DataHelpers
|
||||
|
||||
class ParseError < StandardError; end
|
||||
|
||||
POSITION_VALUE_CONTAINER_NAMES = %w[ChangeInPositionValues].freeze
|
||||
POSITION_VALUE_ROW_NAMES = %w[ChangeInPositionValue].freeze
|
||||
CASH_REPORT_CONTAINER_NAMES = %w[CashReport CashReports].freeze
|
||||
CASH_REPORT_ROW_NAMES = %w[CashReport CashReportCurrency CashReportRow].freeze
|
||||
EQUITY_SUMMARY_CONTAINER_NAMES = %w[EquitySummaryInBase].freeze
|
||||
EQUITY_SUMMARY_ROW_NAMES = %w[EquitySummaryByReportDateInBase].freeze
|
||||
OPEN_POSITION_CONTAINER_NAMES = %w[OpenPositions].freeze
|
||||
OPEN_POSITION_ROW_NAMES = %w[OpenPosition].freeze
|
||||
TRADES_CONTAINER_NAMES = %w[Trades].freeze
|
||||
TRADE_ROW_NAMES = %w[Trade].freeze
|
||||
CASH_TRANSACTION_CONTAINER_NAMES = %w[CashTransactions].freeze
|
||||
CASH_TRANSACTION_ROW_NAMES = %w[CashTransaction].freeze
|
||||
|
||||
def initialize(xml_body)
|
||||
@document = Nokogiri::XML(xml_body.to_s) { |config| config.strict.noblanks }
|
||||
rescue Nokogiri::XML::SyntaxError => e
|
||||
raise ParseError, "Invalid IBKR Flex XML: #{e.message}"
|
||||
end
|
||||
|
||||
def parse
|
||||
validate_document!
|
||||
|
||||
{
|
||||
metadata: root_metadata,
|
||||
accounts: flex_statements.map { |statement| parse_statement(statement) }
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_document!
|
||||
raise ParseError, "Invalid IBKR Flex XML: missing FlexQueryResponse root." unless @document.at_xpath("//FlexQueryResponse")
|
||||
raise ParseError, "Invalid IBKR Flex XML: no FlexStatement nodes found." if flex_statements.empty?
|
||||
end
|
||||
|
||||
def flex_statements
|
||||
@document.xpath("//FlexStatement")
|
||||
end
|
||||
|
||||
def root_metadata
|
||||
node_attributes(@document.at_xpath("//FlexQueryResponse"))
|
||||
end
|
||||
|
||||
def parse_statement(statement)
|
||||
statement_data = node_attributes(statement)
|
||||
account_information = node_attributes(statement.at_xpath("./AccountInformation"))
|
||||
position_values = section_rows(statement, POSITION_VALUE_CONTAINER_NAMES, POSITION_VALUE_ROW_NAMES)
|
||||
cash_report = section_rows(statement, CASH_REPORT_CONTAINER_NAMES, CASH_REPORT_ROW_NAMES)
|
||||
equity_summary_in_base = section_rows(statement, EQUITY_SUMMARY_CONTAINER_NAMES, EQUITY_SUMMARY_ROW_NAMES)
|
||||
open_positions = section_rows(statement, OPEN_POSITION_CONTAINER_NAMES, OPEN_POSITION_ROW_NAMES)
|
||||
trades = section_rows(statement, TRADES_CONTAINER_NAMES, TRADE_ROW_NAMES)
|
||||
cash_transactions = section_rows(statement, CASH_TRANSACTION_CONTAINER_NAMES, CASH_TRANSACTION_ROW_NAMES)
|
||||
account_id = account_information["account_id"].presence || statement_data["account_id"]
|
||||
|
||||
raise ParseError, "Invalid IBKR Flex XML: missing account identifier in FlexStatement." if account_id.blank?
|
||||
|
||||
currency = account_information["currency"].presence&.upcase || "USD"
|
||||
report_date = open_positions.filter_map { |row| parse_date(row["report_date"]) }.max ||
|
||||
equity_summary_in_base.filter_map { |row| parse_date(row["report_date"]) }.max ||
|
||||
parse_date(statement_data["to_date"]) ||
|
||||
Date.current
|
||||
|
||||
{
|
||||
ibkr_account_id: account_id,
|
||||
name: account_id,
|
||||
currency: currency,
|
||||
cash_balance: extract_cash_balance(cash_report, currency),
|
||||
current_balance: extract_total_balance(position_values, cash_report, currency),
|
||||
report_date: report_date,
|
||||
statement: statement_data,
|
||||
cash_report: cash_report,
|
||||
equity_summary_in_base: equity_summary_in_base,
|
||||
open_positions: open_positions,
|
||||
trades: trades,
|
||||
cash_transactions: cash_transactions,
|
||||
raw_payload: {
|
||||
statement: statement_data,
|
||||
cash_report: cash_report,
|
||||
equity_summary_in_base: equity_summary_in_base,
|
||||
open_positions: open_positions,
|
||||
trades: trades,
|
||||
cash_transactions: cash_transactions
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def section_rows(statement, container_names, row_names)
|
||||
rows = []
|
||||
|
||||
container_names.each do |container_name|
|
||||
statement.xpath("./#{container_name}").each do |container|
|
||||
children = container.element_children
|
||||
|
||||
if children.any?
|
||||
rows.concat(children.select { |child| row_names.include?(child.name) })
|
||||
elsif row_names.include?(container.name)
|
||||
rows << container
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if rows.empty?
|
||||
row_names.each do |row_name|
|
||||
rows.concat(statement.xpath("./#{row_name}"))
|
||||
end
|
||||
end
|
||||
|
||||
rows.map { |row| node_attributes(row) }.reject(&:blank?)
|
||||
end
|
||||
|
||||
def node_attributes(node)
|
||||
return {} unless node
|
||||
|
||||
node.attribute_nodes.each_with_object({}) do |attribute, result|
|
||||
result[attribute.name.underscore] = attribute.value
|
||||
end
|
||||
end
|
||||
|
||||
def extract_cash_balance(cash_rows, account_currency)
|
||||
base_summary = cash_rows.find { |row| row["currency"] == "BASE_SUMMARY" }
|
||||
account_row = cash_rows.find { |row| row["currency"] == account_currency }
|
||||
row = base_summary || account_row
|
||||
|
||||
parse_decimal(row&.fetch("ending_cash", nil)) || BigDecimal("0")
|
||||
end
|
||||
|
||||
def extract_current_balance(position_values, account_currency)
|
||||
base_summary = position_values.find { |row| row["currency"] == "BASE_SUMMARY" }
|
||||
account_row = position_values.find { |row| row["currency"] == account_currency }
|
||||
row = base_summary || account_row
|
||||
|
||||
parse_decimal(row&.fetch("end_of_period_value", nil)) || BigDecimal("0")
|
||||
end
|
||||
|
||||
def extract_total_balance(position_values, cash_rows, account_currency)
|
||||
extract_current_balance(position_values, account_currency) + extract_cash_balance(cash_rows, account_currency)
|
||||
end
|
||||
end
|
||||
22
app/models/ibkr_item/sync_complete_event.rb
Normal file
22
app/models/ibkr_item/sync_complete_event.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class IbkrItem::SyncCompleteEvent
|
||||
attr_reader :ibkr_item
|
||||
|
||||
def initialize(ibkr_item)
|
||||
@ibkr_item = ibkr_item
|
||||
end
|
||||
|
||||
def broadcast
|
||||
ibkr_item.accounts.each do |account|
|
||||
account.broadcast_sync_complete
|
||||
end
|
||||
|
||||
ibkr_item.broadcast_replace_to(
|
||||
ibkr_item.family,
|
||||
target: "ibkr_item_#{ibkr_item.id}",
|
||||
partial: "ibkr_items/ibkr_item",
|
||||
locals: { ibkr_item: ibkr_item }
|
||||
)
|
||||
|
||||
ibkr_item.family.broadcast_sync_complete
|
||||
end
|
||||
end
|
||||
68
app/models/ibkr_item/syncer.rb
Normal file
68
app/models/ibkr_item/syncer.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
class IbkrItem::Syncer
|
||||
include SyncStats::Collector
|
||||
|
||||
attr_reader :ibkr_item
|
||||
|
||||
def initialize(ibkr_item)
|
||||
@ibkr_item = ibkr_item
|
||||
end
|
||||
|
||||
def perform_sync(sync)
|
||||
sync.update!(status_text: "Checking IBKR credentials...") if sync.respond_to?(:status_text)
|
||||
unless ibkr_item.credentials_configured?
|
||||
ibkr_item.update!(status: :requires_update)
|
||||
raise Provider::IbkrFlex::ConfigurationError, "IBKR credentials are missing."
|
||||
end
|
||||
|
||||
sync.update!(status_text: "Importing IBKR accounts...") if sync.respond_to?(:status_text)
|
||||
ibkr_item.import_latest_ibkr_data
|
||||
|
||||
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
|
||||
collect_setup_stats(sync, provider_accounts: ibkr_item.ibkr_accounts.to_a)
|
||||
|
||||
unlinked_accounts = ibkr_item.ibkr_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
|
||||
linked_accounts = ibkr_item.ibkr_accounts.joins(:account).merge(Account.visible)
|
||||
|
||||
if unlinked_accounts.any?
|
||||
ibkr_item.update!(pending_account_setup: true)
|
||||
sync.update!(status_text: "#{unlinked_accounts.count} IBKR account(s) need setup...") if sync.respond_to?(:status_text)
|
||||
else
|
||||
ibkr_item.update!(pending_account_setup: false)
|
||||
end
|
||||
|
||||
if linked_accounts.any?
|
||||
sync.update!(status_text: "Processing holdings and activity...") if sync.respond_to?(:status_text)
|
||||
ibkr_item.process_accounts
|
||||
|
||||
sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text)
|
||||
ibkr_item.schedule_account_syncs(
|
||||
parent_sync: sync,
|
||||
window_start_date: sync.window_start_date,
|
||||
window_end_date: sync.window_end_date
|
||||
)
|
||||
|
||||
account_ids = linked_accounts.includes(:account).filter_map { |provider_account| provider_account.account&.id }
|
||||
collect_transaction_stats(sync, account_ids: account_ids, source: "ibkr") if account_ids.any?
|
||||
collect_trades_stats(sync, account_ids: account_ids, source: "ibkr") if account_ids.any?
|
||||
collect_holdings_stats(sync, holdings_count: count_holdings, label: "processed")
|
||||
end
|
||||
|
||||
collect_health_stats(sync, errors: nil)
|
||||
rescue Provider::IbkrFlex::AuthenticationError, Provider::IbkrFlex::ConfigurationError => e
|
||||
ibkr_item.update!(status: :requires_update)
|
||||
collect_health_stats(sync, errors: [ { message: e.message, category: "auth_error" } ])
|
||||
raise
|
||||
rescue => e
|
||||
collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ])
|
||||
raise
|
||||
end
|
||||
|
||||
def perform_post_sync
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def count_holdings
|
||||
ibkr_item.ibkr_accounts.sum { |account| Array(account.raw_holdings_payload).size }
|
||||
end
|
||||
end
|
||||
37
app/models/ibkr_item/unlinking.rb
Normal file
37
app/models/ibkr_item/unlinking.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module IbkrItem::Unlinking
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def unlink_all!(dry_run: false)
|
||||
results = []
|
||||
|
||||
ibkr_accounts.find_each do |provider_account|
|
||||
links = AccountProvider.where(provider_type: "IbkrAccount", provider_id: provider_account.id).to_a
|
||||
link_ids = links.map(&:id)
|
||||
result = {
|
||||
provider_account_id: provider_account.id,
|
||||
name: provider_account.name,
|
||||
provider_link_ids: link_ids
|
||||
}
|
||||
results << result
|
||||
|
||||
next if dry_run
|
||||
|
||||
begin
|
||||
ActiveRecord::Base.transaction do
|
||||
Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) if link_ids.any?
|
||||
links.each(&:destroy!)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn(
|
||||
"IbkrItem Unlinker: failed to fully unlink provider account ##{provider_account.id} " \
|
||||
"(links=#{link_ids.inspect}): #{e.class} - #{e.message}"
|
||||
)
|
||||
result[:error] = e.message
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@ class Import < ApplicationRecord
|
||||
MaxRowCountExceededError = Class.new(StandardError)
|
||||
MappingError = Class.new(StandardError)
|
||||
|
||||
# Shared CSV upload/content limit for web and API imports, including preflight.
|
||||
MAX_CSV_SIZE = 10.megabytes
|
||||
MAX_PDF_SIZE = 25.megabytes
|
||||
ALLOWED_CSV_MIME_TYPES = %w[text/csv text/plain application/vnd.ms-excel application/csv].freeze
|
||||
@@ -24,6 +25,10 @@ class Import < ApplicationRecord
|
||||
Date.new(1970, 1, 1)..Date.today.next_year(5)
|
||||
end
|
||||
|
||||
def self.max_csv_size
|
||||
MAX_CSV_SIZE
|
||||
end
|
||||
|
||||
AMOUNT_TYPE_STRATEGIES = %w[signed_amount custom_column].freeze
|
||||
|
||||
belongs_to :family
|
||||
|
||||
454
app/models/import/preflight.rb
Normal file
454
app/models/import/preflight.rb
Normal file
@@ -0,0 +1,454 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Import::Preflight
|
||||
Response = Struct.new(:status, :payload, keyword_init: true)
|
||||
|
||||
class PreflightError < StandardError
|
||||
attr_reader :status, :payload
|
||||
|
||||
def initialize(response)
|
||||
@status = response.status
|
||||
@payload = response.payload
|
||||
super(response.payload[:message])
|
||||
end
|
||||
end
|
||||
|
||||
CONFIG_PARAM_KEYS = %i[
|
||||
date_col_label
|
||||
amount_col_label
|
||||
name_col_label
|
||||
category_col_label
|
||||
tags_col_label
|
||||
notes_col_label
|
||||
account_col_label
|
||||
qty_col_label
|
||||
ticker_col_label
|
||||
price_col_label
|
||||
entity_type_col_label
|
||||
currency_col_label
|
||||
exchange_operating_mic_col_label
|
||||
date_format
|
||||
number_format
|
||||
signage_convention
|
||||
col_sep
|
||||
amount_type_strategy
|
||||
amount_type_inflow_value
|
||||
rows_to_skip
|
||||
].freeze
|
||||
|
||||
PARAM_KEYS = ([
|
||||
:type,
|
||||
:account_id,
|
||||
:file,
|
||||
:raw_file_content
|
||||
] + CONFIG_PARAM_KEYS).freeze
|
||||
|
||||
UNSUPPORTED_PREFLIGHT_IMPORT_TYPES = %w[PdfImport QifImport].freeze
|
||||
IMPORT_TYPES = (Import::TYPES - UNSUPPORTED_PREFLIGHT_IMPORT_TYPES).freeze
|
||||
|
||||
def initialize(family:, params:)
|
||||
@family = family
|
||||
@params = params.to_h.symbolize_keys
|
||||
end
|
||||
|
||||
def call
|
||||
type = preflight_import_type
|
||||
return invalid_import_type_response unless type
|
||||
|
||||
type == "SureImport" ? sure_import_response : csv_import_response(type)
|
||||
rescue PreflightError => e
|
||||
Response.new(status: e.status, payload: e.payload)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :family, :params
|
||||
|
||||
def preflight_import_type
|
||||
type = params[:type].to_s
|
||||
return "TransactionImport" if type.blank?
|
||||
|
||||
type if IMPORT_TYPES.include?(type)
|
||||
end
|
||||
|
||||
def invalid_import_type_response
|
||||
Response.new(
|
||||
status: :unprocessable_entity,
|
||||
payload: {
|
||||
error: "invalid_import_type",
|
||||
message: "type must be one of: #{IMPORT_TYPES.join(', ')}"
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def sure_import_response
|
||||
upload_attributes = sure_import_upload_attributes
|
||||
return missing_sure_content_response unless upload_attributes
|
||||
|
||||
content, filename, content_type = upload_attributes
|
||||
Response.new(
|
||||
status: :ok,
|
||||
payload: {
|
||||
data: sure_import_preflight_payload(content, filename, content_type)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def csv_import_response(type)
|
||||
upload_attributes = csv_upload_attributes
|
||||
return missing_csv_content_response unless upload_attributes
|
||||
|
||||
content, filename, content_type = upload_attributes
|
||||
import = family.imports.build(import_config_params.merge(type: type, raw_file_str: content))
|
||||
import.account = preflight_account if params[:account_id].present?
|
||||
apply_import_defaults(import)
|
||||
|
||||
return unsupported_import_type_response unless import.requires_csv_workflow?
|
||||
|
||||
unless import.valid?
|
||||
return Response.new(
|
||||
status: :ok,
|
||||
payload: {
|
||||
data: csv_preflight_payload(
|
||||
import: import,
|
||||
type: type,
|
||||
filename: filename,
|
||||
content_type: content_type,
|
||||
content: content,
|
||||
parsed_rows_count: 0,
|
||||
csv_headers: [],
|
||||
missing_required_headers: [],
|
||||
errors: validation_errors(import),
|
||||
warnings: []
|
||||
)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
csv_content = csv_content_for(import, content)
|
||||
csv = Import.parse_csv_str(csv_content, col_sep: import.col_sep)
|
||||
parsed_rows_count = csv.length
|
||||
csv_headers = Array(csv.headers).compact
|
||||
missing_required_headers = missing_required_headers(import, csv_headers)
|
||||
errors = validation_errors(import)
|
||||
|
||||
if missing_required_headers.any?
|
||||
errors << {
|
||||
code: "missing_required_headers",
|
||||
message: "Missing required columns: #{missing_required_headers.join(', ')}"
|
||||
}
|
||||
end
|
||||
|
||||
if parsed_rows_count.zero?
|
||||
errors << {
|
||||
code: "no_data_rows",
|
||||
message: "No data rows were found."
|
||||
}
|
||||
end
|
||||
|
||||
warnings = []
|
||||
warnings << "Row count exceeds this import type's publish limit." if parsed_rows_count > import.max_row_count
|
||||
|
||||
Response.new(
|
||||
status: :ok,
|
||||
payload: {
|
||||
data: csv_preflight_payload(
|
||||
import: import,
|
||||
type: type,
|
||||
filename: filename,
|
||||
content_type: content_type,
|
||||
content: content,
|
||||
parsed_rows_count: parsed_rows_count,
|
||||
csv_headers: csv_headers,
|
||||
missing_required_headers: missing_required_headers,
|
||||
errors: errors,
|
||||
warnings: warnings
|
||||
)
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def import_config_params
|
||||
params.slice(*CONFIG_PARAM_KEYS)
|
||||
end
|
||||
|
||||
def preflight_account
|
||||
raise ActiveRecord::RecordNotFound unless Api::V1::BaseController.valid_uuid?(params[:account_id])
|
||||
|
||||
family.accounts.find(params[:account_id])
|
||||
end
|
||||
|
||||
def csv_upload_attributes
|
||||
if params[:file].present?
|
||||
csv_file_upload_attributes(params[:file])
|
||||
elsif params[:raw_file_content].present?
|
||||
csv_raw_content_attributes(params[:raw_file_content].to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def csv_file_upload_attributes(file)
|
||||
raise_response csv_file_too_large_response if file.size > Import.max_csv_size
|
||||
raise_response invalid_csv_file_type_response unless Import::ALLOWED_CSV_MIME_TYPES.include?(file.content_type)
|
||||
|
||||
[
|
||||
file.read,
|
||||
file.original_filename.presence || "import.csv",
|
||||
file.content_type.presence || "text/csv"
|
||||
]
|
||||
end
|
||||
|
||||
def csv_raw_content_attributes(content)
|
||||
raise_response csv_content_too_large_response if content.bytesize > Import.max_csv_size
|
||||
|
||||
[ content, "import.csv", "text/csv" ]
|
||||
end
|
||||
|
||||
def sure_import_upload_attributes
|
||||
if params[:file].present?
|
||||
sure_import_file_upload_attributes(params[:file])
|
||||
elsif params[:raw_file_content].present?
|
||||
sure_import_raw_content_attributes(params[:raw_file_content].to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def sure_import_file_upload_attributes(file)
|
||||
raise_response sure_file_too_large_response if file.size > SureImport.max_ndjson_size
|
||||
|
||||
extension = File.extname(file.original_filename.to_s).downcase
|
||||
unless SureImport::ALLOWED_NDJSON_CONTENT_TYPES.include?(file.content_type) || extension.in?(%w[.ndjson .json])
|
||||
raise_response invalid_sure_file_type_response
|
||||
end
|
||||
|
||||
[
|
||||
file.read,
|
||||
file.original_filename.presence || "sure-import.ndjson",
|
||||
file.content_type.presence || "application/x-ndjson"
|
||||
]
|
||||
end
|
||||
|
||||
def sure_import_raw_content_attributes(content)
|
||||
raise_response sure_content_too_large_response if content.bytesize > SureImport.max_ndjson_size
|
||||
|
||||
[ content, "sure-import.ndjson", "application/x-ndjson" ]
|
||||
end
|
||||
|
||||
def sure_import_preflight_payload(content, filename, content_type)
|
||||
line_counts = Hash.new(0)
|
||||
errors = []
|
||||
valid_rows_count = 0
|
||||
nonblank_rows_count = 0
|
||||
|
||||
content.each_line.with_index(1) do |line, line_number|
|
||||
next if line.strip.blank?
|
||||
|
||||
nonblank_rows_count += 1
|
||||
record = JSON.parse(line)
|
||||
|
||||
unless record.is_a?(Hash)
|
||||
errors << {
|
||||
code: "invalid_ndjson_record",
|
||||
message: "Line #{line_number} must be a JSON object."
|
||||
}
|
||||
next
|
||||
end
|
||||
|
||||
if record["type"].blank? || !record.key?("data")
|
||||
errors << {
|
||||
code: "invalid_ndjson_record",
|
||||
message: "Line #{line_number} must include type and data."
|
||||
}
|
||||
next
|
||||
end
|
||||
|
||||
valid_rows_count += 1
|
||||
line_counts[record["type"]] += 1
|
||||
rescue JSON::ParserError => e
|
||||
errors << {
|
||||
code: "invalid_json",
|
||||
message: "Line #{line_number} is not valid JSON: #{e.message}"
|
||||
}
|
||||
end
|
||||
|
||||
if nonblank_rows_count.zero?
|
||||
errors << {
|
||||
code: "no_data_rows",
|
||||
message: "No data rows were found."
|
||||
}
|
||||
end
|
||||
|
||||
entity_counts = SureImport.dry_run_totals_from_line_type_counts(line_counts)
|
||||
unsupported_types = line_counts.keys - SureImport.importable_ndjson_types
|
||||
warnings = []
|
||||
warnings << "No importable records were found." if nonblank_rows_count.positive? && entity_counts.values.sum.zero?
|
||||
warnings << "Some records use unsupported types: #{unsupported_types.join(', ')}" if unsupported_types.any?
|
||||
warnings << "Row count exceeds this import type's publish limit." if nonblank_rows_count > SureImport.max_row_count
|
||||
|
||||
{
|
||||
type: "SureImport",
|
||||
valid: errors.empty?,
|
||||
content: content_payload(filename, content_type, content),
|
||||
stats: {
|
||||
rows_count: nonblank_rows_count,
|
||||
valid_rows_count: valid_rows_count,
|
||||
invalid_rows_count: nonblank_rows_count - valid_rows_count,
|
||||
entity_counts: entity_counts,
|
||||
record_type_counts: line_counts
|
||||
},
|
||||
errors: errors,
|
||||
warnings: warnings
|
||||
}
|
||||
end
|
||||
|
||||
def content_payload(filename, content_type, content)
|
||||
{
|
||||
filename: filename,
|
||||
content_type: content_type,
|
||||
byte_size: content.bytesize
|
||||
}
|
||||
end
|
||||
|
||||
def csv_content_for(import, content)
|
||||
return content unless import.rows_to_skip.to_i.positive?
|
||||
|
||||
content.lines.drop(import.rows_to_skip.to_i).join
|
||||
end
|
||||
|
||||
def apply_import_defaults(import)
|
||||
return unless import.is_a?(MintImport)
|
||||
|
||||
MintImport.default_column_mappings.each do |attribute, value|
|
||||
import.public_send("#{attribute}=", value) if import.public_send(attribute).blank?
|
||||
end
|
||||
end
|
||||
|
||||
def validation_errors(import)
|
||||
import.errors.full_messages.map { |message| { code: "validation_failed", message: message } }
|
||||
end
|
||||
|
||||
def csv_preflight_payload(import:, type:, filename:, content_type:, content:, parsed_rows_count:, csv_headers:, missing_required_headers:, errors:, warnings:)
|
||||
{
|
||||
type: type,
|
||||
valid: errors.empty?,
|
||||
content: content_payload(filename, content_type, content),
|
||||
stats: {
|
||||
rows_count: parsed_rows_count
|
||||
},
|
||||
headers: csv_headers,
|
||||
required_headers: required_header_labels(import),
|
||||
missing_required_headers: missing_required_headers,
|
||||
errors: errors,
|
||||
warnings: warnings
|
||||
}
|
||||
end
|
||||
|
||||
def required_header_labels(import)
|
||||
import.required_column_keys.filter_map do |key|
|
||||
import.respond_to?("#{key}_col_label") ? import.public_send("#{key}_col_label").presence || key.to_s : key.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def missing_required_headers(import, headers)
|
||||
normalized_headers = Array(headers).compact.to_h { |header| [ normalized_header(header), header ] }
|
||||
|
||||
required_header_labels(import).reject do |header|
|
||||
normalized_headers.key?(normalized_header(header))
|
||||
end
|
||||
end
|
||||
|
||||
def normalized_header(header)
|
||||
header.to_s.strip.downcase.gsub(/\*/, "").gsub(/[\s-]+/, "_")
|
||||
end
|
||||
|
||||
def missing_csv_content_response
|
||||
Response.new(
|
||||
status: :unprocessable_entity,
|
||||
payload: {
|
||||
error: "missing_content",
|
||||
message: "Provide a CSV file or raw_file_content."
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def missing_sure_content_response
|
||||
Response.new(
|
||||
status: :unprocessable_entity,
|
||||
payload: {
|
||||
error: "missing_content",
|
||||
message: "Provide a Sure NDJSON file or raw_file_content."
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def csv_file_too_large_response
|
||||
Response.new(
|
||||
status: :unprocessable_entity,
|
||||
payload: {
|
||||
error: "file_too_large",
|
||||
message: "File is too large. Maximum size is #{Import.max_csv_size / 1.megabyte}MB."
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def csv_content_too_large_response
|
||||
Response.new(
|
||||
status: :unprocessable_entity,
|
||||
payload: {
|
||||
error: "content_too_large",
|
||||
message: "Content is too large. Maximum size is #{Import.max_csv_size / 1.megabyte}MB."
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def invalid_csv_file_type_response
|
||||
Response.new(
|
||||
status: :unprocessable_entity,
|
||||
payload: {
|
||||
error: "invalid_file_type",
|
||||
message: "Invalid file type. Please upload a CSV file."
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def sure_file_too_large_response
|
||||
Response.new(
|
||||
status: :unprocessable_entity,
|
||||
payload: {
|
||||
error: "file_too_large",
|
||||
message: "File is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB."
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def sure_content_too_large_response
|
||||
Response.new(
|
||||
status: :unprocessable_entity,
|
||||
payload: {
|
||||
error: "content_too_large",
|
||||
message: "Content is too large. Maximum size is #{SureImport.max_ndjson_size / 1.megabyte}MB."
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def invalid_sure_file_type_response
|
||||
Response.new(
|
||||
status: :unprocessable_entity,
|
||||
payload: {
|
||||
error: "invalid_file_type",
|
||||
message: "Invalid file type. Please upload a Sure NDJSON file."
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def raise_response(response)
|
||||
raise PreflightError, response
|
||||
end
|
||||
|
||||
def unsupported_import_type_response
|
||||
Response.new(
|
||||
status: :unprocessable_entity,
|
||||
payload: {
|
||||
error: "unsupported_import_type",
|
||||
message: "Preflight supports CSV import types and SureImport."
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
39
app/models/kraken_account.rb
Normal file
39
app/models/kraken_account.rb
Normal file
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class KrakenAccount < ApplicationRecord
|
||||
include Encryptable
|
||||
|
||||
STABLECOINS = %w[USDT USDC DAI PYUSD USDP TUSD USDG].freeze
|
||||
FIAT_CURRENCIES = %w[USD EUR GBP CAD AUD CHF JPY AED].freeze
|
||||
|
||||
if encryption_ready?
|
||||
encrypts :raw_payload
|
||||
encrypts :raw_transactions_payload
|
||||
end
|
||||
|
||||
belongs_to :kraken_item
|
||||
|
||||
has_one :account_provider, as: :provider, dependent: :destroy
|
||||
has_one :account, through: :account_provider, source: :account
|
||||
|
||||
validates :name, :account_id, :account_type, :currency, presence: true
|
||||
|
||||
def current_account
|
||||
account
|
||||
end
|
||||
|
||||
def ensure_account_provider!(target_account = nil)
|
||||
acct = target_account || current_account
|
||||
return nil unless acct
|
||||
|
||||
AccountProvider
|
||||
.find_or_initialize_by(provider_type: "KrakenAccount", provider_id: id)
|
||||
.tap do |ap|
|
||||
ap.account = acct
|
||||
ap.save!
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("KrakenAccount #{id}: failed to link account provider - #{e.class}: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
67
app/models/kraken_account/asset_normalizer.rb
Normal file
67
app/models/kraken_account/asset_normalizer.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class KrakenAccount::AssetNormalizer
|
||||
SUFFIX_PATTERN = /(\.[A-Z])\z/
|
||||
FIAT_PREFIXES = {
|
||||
"ZUSD" => "USD",
|
||||
"ZEUR" => "EUR",
|
||||
"ZGBP" => "GBP",
|
||||
"ZCAD" => "CAD",
|
||||
"ZAUD" => "AUD",
|
||||
"ZCHF" => "CHF",
|
||||
"ZJPY" => "JPY"
|
||||
}.freeze
|
||||
SYMBOL_FALLBACKS = {
|
||||
"XBT" => "BTC",
|
||||
"XXBT" => "BTC",
|
||||
"XETH" => "ETH",
|
||||
"ZUSD" => "USD"
|
||||
}.freeze
|
||||
|
||||
def initialize(asset_metadata = {})
|
||||
@asset_metadata = asset_metadata || {}
|
||||
end
|
||||
|
||||
def normalize(raw_asset)
|
||||
raw = raw_asset.to_s.upcase
|
||||
suffix = raw[SUFFIX_PATTERN, 1]
|
||||
raw_base = suffix ? raw.delete_suffix(suffix) : raw
|
||||
|
||||
metadata = metadata_for(raw, raw_base)
|
||||
base_symbol = metadata_symbol(metadata, raw_base)
|
||||
normalized_base = normalize_base_symbol(base_symbol)
|
||||
symbol = suffix.present? ? "#{normalized_base}#{suffix}" : normalized_base
|
||||
|
||||
{
|
||||
raw_asset: raw,
|
||||
raw_base: raw_base,
|
||||
symbol: symbol,
|
||||
price_symbol: normalized_base,
|
||||
suffix: suffix,
|
||||
metadata: metadata
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :asset_metadata
|
||||
|
||||
def metadata_for(raw, raw_base)
|
||||
asset_metadata[raw] || asset_metadata[raw_base] || asset_metadata.values.find do |metadata|
|
||||
candidate = metadata_symbol(metadata, raw_base)
|
||||
[ raw, raw_base ].include?(candidate.to_s.upcase)
|
||||
end
|
||||
end
|
||||
|
||||
def metadata_symbol(metadata, fallback)
|
||||
return fallback unless metadata.is_a?(Hash)
|
||||
|
||||
metadata["altname"].presence || metadata["display_name"].presence || fallback
|
||||
end
|
||||
|
||||
def normalize_base_symbol(symbol)
|
||||
value = symbol.to_s.upcase
|
||||
value = FIAT_PREFIXES[value] if FIAT_PREFIXES.key?(value)
|
||||
SYMBOL_FALLBACKS[value] || value
|
||||
end
|
||||
end
|
||||
84
app/models/kraken_account/holdings_processor.rb
Normal file
84
app/models/kraken_account/holdings_processor.rb
Normal file
@@ -0,0 +1,84 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class KrakenAccount::HoldingsProcessor
|
||||
include KrakenAccount::UsdConverter
|
||||
|
||||
def initialize(kraken_account)
|
||||
@kraken_account = kraken_account
|
||||
end
|
||||
|
||||
def process
|
||||
return unless account&.accountable_type == "Crypto"
|
||||
|
||||
raw_assets.each { |asset| process_asset(asset) }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "KrakenAccount::HoldingsProcessor - error: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :kraken_account
|
||||
|
||||
def target_currency
|
||||
kraken_account.kraken_item&.family&.currency
|
||||
end
|
||||
|
||||
def account
|
||||
kraken_account.current_account
|
||||
end
|
||||
|
||||
def raw_assets
|
||||
kraken_account.raw_payload&.dig("assets") || []
|
||||
end
|
||||
|
||||
def process_asset(asset)
|
||||
symbol = asset["symbol"] || asset[:symbol]
|
||||
price_symbol = asset["price_symbol"] || asset[:price_symbol] || symbol
|
||||
total = (asset["balance"] || asset[:balance] || 0).to_d
|
||||
price_usd = asset["price_usd"] || asset[:price_usd]
|
||||
source = asset["source"] || asset[:source] || "spot"
|
||||
|
||||
return if symbol.blank? || total.zero? || price_usd.blank?
|
||||
|
||||
security = resolve_security(symbol)
|
||||
return unless security
|
||||
|
||||
amount_usd = total * price_usd.to_d
|
||||
amount, amount_stale, amount_rate_date = convert_from_usd(amount_usd, date: Date.current)
|
||||
price, price_stale, price_rate_date = convert_from_usd(price_usd.to_d, date: Date.current)
|
||||
log_stale_rate(symbol, "amount", amount_rate_date) if amount_stale
|
||||
log_stale_rate(symbol, "price", price_rate_date) if price_stale
|
||||
|
||||
import_adapter.import_holding(
|
||||
security: security,
|
||||
quantity: total,
|
||||
amount: amount,
|
||||
currency: target_currency,
|
||||
date: Date.current,
|
||||
price: price,
|
||||
cost_basis: nil,
|
||||
external_id: "kraken_#{symbol}_#{source}_#{Date.current}",
|
||||
account_provider_id: kraken_account.account_provider&.id,
|
||||
source: "kraken",
|
||||
delete_future_holdings: false
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "KrakenAccount::HoldingsProcessor - failed asset symbol=#{symbol.presence || "unknown"}: #{e.message}"
|
||||
end
|
||||
|
||||
def import_adapter
|
||||
@import_adapter ||= Account::ProviderImportAdapter.new(account)
|
||||
end
|
||||
|
||||
def resolve_security(symbol)
|
||||
ticker = symbol.to_s.include?(":") ? symbol.to_s : "CRYPTO:#{symbol}"
|
||||
KrakenAccount::SecurityResolver.resolve(ticker, symbol)
|
||||
end
|
||||
|
||||
def log_stale_rate(symbol, field, rate_date)
|
||||
Rails.logger.warn(
|
||||
"KrakenAccount::HoldingsProcessor - stale FX rate for #{field} symbol=#{symbol} rate_date=#{rate_date || "unknown"}"
|
||||
)
|
||||
end
|
||||
end
|
||||
122
app/models/kraken_account/processor.rb
Normal file
122
app/models/kraken_account/processor.rb
Normal file
@@ -0,0 +1,122 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class KrakenAccount::Processor
|
||||
include KrakenAccount::UsdConverter
|
||||
|
||||
attr_reader :kraken_account
|
||||
|
||||
def initialize(kraken_account)
|
||||
@kraken_account = kraken_account
|
||||
end
|
||||
|
||||
def process
|
||||
return unless kraken_account.current_account.present?
|
||||
|
||||
KrakenAccount::HoldingsProcessor.new(kraken_account).process
|
||||
process_account!
|
||||
process_trades
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def target_currency
|
||||
kraken_account.kraken_item&.family&.currency
|
||||
end
|
||||
|
||||
def process_account!
|
||||
account = kraken_account.current_account
|
||||
amount, stale, rate_date = convert_from_usd((kraken_account.current_balance || 0).to_d, date: Date.current)
|
||||
|
||||
account.update!(
|
||||
balance: amount,
|
||||
cash_balance: 0,
|
||||
currency: target_currency
|
||||
)
|
||||
|
||||
kraken_account.update!(extra: kraken_account.extra.to_h.deep_merge(build_stale_extra(stale, rate_date, Date.current)))
|
||||
end
|
||||
|
||||
def process_trades
|
||||
raw_trades.each do |txid, trade|
|
||||
process_trade(txid, trade)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "KrakenAccount::Processor - trade processing failed: #{e.message}"
|
||||
end
|
||||
|
||||
def raw_trades
|
||||
kraken_account.raw_transactions_payload&.dig("trades") || {}
|
||||
end
|
||||
|
||||
def process_trade(txid, trade)
|
||||
account = kraken_account.current_account
|
||||
return unless account
|
||||
|
||||
external_id = "kraken_trade_#{txid}"
|
||||
return if account.entries.exists?(external_id: external_id, source: "kraken")
|
||||
|
||||
type = trade["type"].to_s.downcase
|
||||
return unless %w[buy sell].include?(type)
|
||||
|
||||
pair = trade["pair"].to_s
|
||||
base_symbol, quote_symbol = infer_pair_symbols(pair, trade)
|
||||
return if base_symbol.blank?
|
||||
|
||||
qty = trade["vol"].to_d
|
||||
return if qty.zero?
|
||||
|
||||
price = trade["price"].to_d
|
||||
cost = trade["cost"].presence&.to_d
|
||||
cost ||= (qty * price).round(8)
|
||||
fee = trade["fee"].presence&.to_d || 0
|
||||
currency = quote_symbol.presence || "USD"
|
||||
date = Time.zone.at(trade["time"].to_d).to_date
|
||||
security = KrakenAccount::SecurityResolver.resolve("CRYPTO:#{base_symbol}", base_symbol)
|
||||
return unless security
|
||||
|
||||
entry_amount = type == "buy" ? -cost : cost
|
||||
trade_qty = type == "buy" ? qty : -qty
|
||||
label = type == "buy" ? "Buy" : "Sell"
|
||||
|
||||
account.entries.create!(
|
||||
date: date,
|
||||
name: "#{label} #{qty.round(8)} #{base_symbol}",
|
||||
amount: entry_amount,
|
||||
currency: currency,
|
||||
external_id: external_id,
|
||||
source: "kraken",
|
||||
notes: trade["ordertxid"].presence,
|
||||
entryable: Trade.new(
|
||||
security: security,
|
||||
qty: trade_qty,
|
||||
price: price,
|
||||
currency: currency,
|
||||
fee: fee,
|
||||
investment_activity_label: label
|
||||
)
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "KrakenAccount::Processor - failed to process trade #{txid}: #{e.message}"
|
||||
end
|
||||
|
||||
def infer_pair_symbols(pair, trade)
|
||||
pair_metadata = kraken_account.raw_payload&.dig("pair_metadata") || {}
|
||||
metadata = pair_metadata[pair] || pair_metadata.values.find { |candidate| candidate["altname"].to_s == pair }
|
||||
normalizer = KrakenAccount::AssetNormalizer.new(kraken_account.raw_payload&.dig("asset_metadata") || {})
|
||||
|
||||
if metadata
|
||||
base = normalizer.normalize(metadata["base"])[:symbol]
|
||||
quote = normalizer.normalize(metadata["quote"])[:symbol]
|
||||
return [ base, quote ]
|
||||
end
|
||||
|
||||
altname = trade["pair"].to_s
|
||||
%w[USDT USDC USD EUR GBP BTC ETH].each do |quote|
|
||||
next unless altname.end_with?(quote)
|
||||
|
||||
return [ normalizer.normalize(altname.delete_suffix(quote))[:symbol], quote ]
|
||||
end
|
||||
|
||||
[ altname, "USD" ]
|
||||
end
|
||||
end
|
||||
16
app/models/kraken_account/security_resolver.rb
Normal file
16
app/models/kraken_account/security_resolver.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class KrakenAccount::SecurityResolver
|
||||
EXCHANGE_MIC = "XKRA"
|
||||
|
||||
def self.resolve(ticker, symbol)
|
||||
Security::Resolver.new(ticker).resolve
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "KrakenAccount::SecurityResolver - resolver failed for #{ticker}: #{e.message}"
|
||||
Security.find_or_initialize_by(ticker: ticker, exchange_operating_mic: EXCHANGE_MIC).tap do |security|
|
||||
security.name = symbol if security.name.blank?
|
||||
security.offline = true unless security.offline
|
||||
security.save! if security.changed?
|
||||
end
|
||||
end
|
||||
end
|
||||
32
app/models/kraken_account/usd_converter.rb
Normal file
32
app/models/kraken_account/usd_converter.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module KrakenAccount::UsdConverter
|
||||
private
|
||||
|
||||
def convert_from_usd(amount, date: Date.current)
|
||||
return [ amount.to_d, false, nil ] if target_currency == "USD"
|
||||
|
||||
rate = ExchangeRate.find_or_fetch_rate(from: "USD", to: target_currency, date: date)
|
||||
return [ amount.to_d, true, nil ] if rate.nil?
|
||||
|
||||
converted = Money.new(amount, "USD").exchange_to(target_currency, custom_rate: rate.rate).amount
|
||||
stale = rate.date != date
|
||||
rate_date = stale ? rate.date : nil
|
||||
|
||||
[ converted, stale, rate_date ]
|
||||
end
|
||||
|
||||
def build_stale_extra(stale, rate_date, target_date)
|
||||
kraken_meta = if stale
|
||||
{
|
||||
"stale_rate" => true,
|
||||
"rate_date_used" => rate_date&.to_s,
|
||||
"rate_target_date" => target_date&.to_s
|
||||
}
|
||||
else
|
||||
{ "stale_rate" => false }
|
||||
end
|
||||
|
||||
{ "kraken" => kraken_meta }
|
||||
end
|
||||
end
|
||||
153
app/models/kraken_item.rb
Normal file
153
app/models/kraken_item.rb
Normal file
@@ -0,0 +1,153 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class KrakenItem < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking, Encryptable
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
if encryption_ready?
|
||||
encrypts :api_key, deterministic: true
|
||||
encrypts :api_secret
|
||||
encrypts :raw_payload
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
validates :api_key, presence: true
|
||||
validates :api_secret, presence: true
|
||||
|
||||
belongs_to :family
|
||||
has_one_attached :logo, dependent: :purge_later
|
||||
|
||||
has_many :kraken_accounts, dependent: :destroy
|
||||
has_many :accounts, through: :kraken_accounts
|
||||
|
||||
scope :active, -> { where(scheduled_for_deletion: false) }
|
||||
scope :syncable, -> { active }
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
scope :needs_update, -> { where(status: :requires_update) }
|
||||
scope :credentials_configured, -> { where.not(api_key: [ nil, "" ]).where.not(api_secret: nil) }
|
||||
|
||||
before_validation :strip_credentials
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
def import_latest_kraken_data
|
||||
provider = kraken_provider
|
||||
raise StandardError, "Kraken credentials not configured" unless provider
|
||||
|
||||
KrakenItem::Importer.new(self, kraken_provider: provider).import
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "KrakenItem #{id} - Failed to import: #{e.full_message}"
|
||||
raise
|
||||
end
|
||||
|
||||
def process_accounts
|
||||
return [] if kraken_accounts.empty?
|
||||
|
||||
results = []
|
||||
kraken_accounts.joins(:account).merge(Account.visible).each do |kraken_account|
|
||||
begin
|
||||
result = KrakenAccount::Processor.new(kraken_account).process
|
||||
results << { kraken_account_id: kraken_account.id, success: true, result: result }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "KrakenItem #{id} - Failed to process account #{kraken_account.id}: #{e.full_message}"
|
||||
results << { kraken_account_id: kraken_account.id, success: false, error: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
||||
return [] if accounts.empty?
|
||||
|
||||
accounts.visible.map do |account|
|
||||
account.sync_later(
|
||||
parent_sync: parent_sync,
|
||||
window_start_date: window_start_date,
|
||||
window_end_date: window_end_date
|
||||
)
|
||||
{ account_id: account.id, success: true }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "KrakenItem #{id} - Failed to schedule sync for account #{account.id}: #{e.full_message}"
|
||||
{ account_id: account.id, success: false, error: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_kraken_snapshot!(payload)
|
||||
update!(raw_payload: payload)
|
||||
end
|
||||
|
||||
def has_completed_initial_setup?
|
||||
accounts.any?
|
||||
end
|
||||
|
||||
def sync_status_summary
|
||||
total = total_accounts_count
|
||||
linked = linked_accounts_count
|
||||
unlinked = unlinked_accounts_count
|
||||
|
||||
if total.zero?
|
||||
I18n.t("kraken_items.kraken_item.sync_status.no_accounts")
|
||||
elsif unlinked.zero?
|
||||
I18n.t("kraken_items.kraken_item.sync_status.all_synced", count: linked)
|
||||
else
|
||||
I18n.t("kraken_items.kraken_item.sync_status.partial_sync", linked_count: linked, unlinked_count: unlinked)
|
||||
end
|
||||
end
|
||||
|
||||
def linked_accounts_count
|
||||
kraken_accounts.joins(:account_provider).count
|
||||
end
|
||||
|
||||
def unlinked_accounts_count
|
||||
kraken_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
|
||||
end
|
||||
|
||||
def total_accounts_count
|
||||
kraken_accounts.count
|
||||
end
|
||||
|
||||
def stale_rate_accounts
|
||||
kraken_accounts
|
||||
.joins(:account)
|
||||
.where(accounts: { status: "active" })
|
||||
.where("kraken_accounts.extra -> 'kraken' ->> 'stale_rate' = 'true'")
|
||||
end
|
||||
|
||||
def institution_display_name
|
||||
institution_name.presence || institution_domain.presence || name
|
||||
end
|
||||
|
||||
def credentials_configured?
|
||||
api_key.to_s.strip.present? && api_secret.to_s.strip.present?
|
||||
end
|
||||
|
||||
def next_nonce!
|
||||
with_lock do
|
||||
candidate = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
||||
candidate = last_nonce.to_i + 1 if candidate <= last_nonce.to_i
|
||||
update!(last_nonce: candidate)
|
||||
candidate.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def set_kraken_institution_defaults!
|
||||
update!(
|
||||
institution_name: "Kraken",
|
||||
institution_domain: "kraken.com",
|
||||
institution_url: "https://www.kraken.com",
|
||||
institution_color: "#5841D8"
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def strip_credentials
|
||||
self.api_key = api_key.to_s.strip if api_key_changed? && !api_key.nil?
|
||||
self.api_secret = api_secret.to_s.strip if api_secret_changed? && !api_secret.nil?
|
||||
end
|
||||
end
|
||||
187
app/models/kraken_item/importer.rb
Normal file
187
app/models/kraken_item/importer.rb
Normal file
@@ -0,0 +1,187 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class KrakenItem::Importer
|
||||
MAX_TRADE_PAGES = 200
|
||||
TRADE_PAGE_SIZE = 50
|
||||
|
||||
attr_reader :kraken_item, :kraken_provider
|
||||
|
||||
def initialize(kraken_item, kraken_provider:)
|
||||
@kraken_item = kraken_item
|
||||
@kraken_provider = kraken_provider
|
||||
end
|
||||
|
||||
def import
|
||||
api_key_info = kraken_provider.get_api_key_info
|
||||
|
||||
asset_metadata = kraken_provider.get_asset_info || {}
|
||||
pair_metadata = kraken_provider.get_asset_pairs || {}
|
||||
balances = kraken_provider.get_extended_balance || {}
|
||||
assets = parse_assets(balances, asset_metadata)
|
||||
trades = fetch_trades
|
||||
|
||||
total_usd = assets.sum { |asset| asset[:amount_usd].to_d }.round(2)
|
||||
kraken_account = upsert_kraken_account(
|
||||
assets: assets,
|
||||
balances: balances,
|
||||
trades: trades,
|
||||
asset_metadata: asset_metadata,
|
||||
pair_metadata: pair_metadata,
|
||||
api_key_info: api_key_info,
|
||||
total_usd: total_usd
|
||||
)
|
||||
|
||||
kraken_item.upsert_kraken_snapshot!({
|
||||
"api_key_info" => api_key_info,
|
||||
"balances" => balances,
|
||||
"asset_metadata" => asset_metadata,
|
||||
"pair_metadata" => pair_metadata,
|
||||
"imported_at" => Time.current.iso8601
|
||||
})
|
||||
|
||||
{ success: true, account_id: kraken_account.id, assets_imported: assets.size, trades_imported: trades.size, total_usd: total_usd }
|
||||
rescue Provider::Kraken::PermissionError => e
|
||||
kraken_item.update!(status: :requires_update)
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
def parse_assets(balances, asset_metadata)
|
||||
normalizer = KrakenAccount::AssetNormalizer.new(asset_metadata)
|
||||
|
||||
balances.filter_map do |raw_asset, balance_data|
|
||||
parsed = normalizer.normalize(raw_asset)
|
||||
balance = balance_data.fetch("balance", "0").to_d
|
||||
credit = balance_data.fetch("credit", "0").to_d
|
||||
credit_used = balance_data.fetch("credit_used", "0").to_d
|
||||
hold_trade = balance_data.fetch("hold_trade", "0").to_d
|
||||
available = balance + credit - credit_used - hold_trade
|
||||
|
||||
next if balance.zero? && hold_trade.zero?
|
||||
|
||||
price_usd, price_status = price_for(parsed[:price_symbol])
|
||||
amount_usd = price_usd ? (balance * price_usd).round(2) : 0.to_d
|
||||
|
||||
parsed.merge(
|
||||
balance: balance.to_s("F"),
|
||||
available: available.to_s("F"),
|
||||
hold_trade: hold_trade.to_s("F"),
|
||||
price_usd: price_usd&.to_s("F"),
|
||||
amount_usd: amount_usd.to_s("F"),
|
||||
price_status: price_status,
|
||||
source: "spot"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def price_for(symbol)
|
||||
return [ 1.to_d, "exact" ] if symbol == "USD" || KrakenAccount::STABLECOINS.include?(symbol)
|
||||
|
||||
if KrakenAccount::FIAT_CURRENCIES.include?(symbol)
|
||||
rate = ExchangeRate.find_or_fetch_rate(from: symbol, to: "USD", date: Date.current)
|
||||
return [ rate.rate.to_d, rate.date == Date.current ? "exact" : "stale" ] if rate
|
||||
|
||||
return [ nil, "missing" ]
|
||||
end
|
||||
|
||||
ticker_price = ticker_price_for(symbol)
|
||||
return [ ticker_price, "exact" ] if ticker_price
|
||||
|
||||
[ nil, "missing" ]
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "KrakenItem::Importer - could not price #{symbol}: #{e.message}"
|
||||
[ nil, "missing" ]
|
||||
end
|
||||
|
||||
def ticker_price_for(symbol)
|
||||
pair_candidates_for(symbol).each do |pair|
|
||||
response = kraken_provider.get_ticker(pair)
|
||||
ticker_payload = response&.values&.first
|
||||
price = ticker_payload&.dig("c", 0)
|
||||
return price.to_d if price.present?
|
||||
rescue Provider::Kraken::ApiError
|
||||
next
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def pair_candidates_for(symbol)
|
||||
kraken_symbol = symbol == "BTC" ? "XBT" : symbol
|
||||
[
|
||||
"#{kraken_symbol}USD",
|
||||
"#{symbol}USD",
|
||||
"X#{kraken_symbol}ZUSD",
|
||||
"#{kraken_symbol}USDT",
|
||||
"#{symbol}USDT"
|
||||
].uniq
|
||||
end
|
||||
|
||||
def fetch_trades
|
||||
start_time = kraken_item.sync_start_date&.to_i
|
||||
offset = 0
|
||||
all_trades = {}
|
||||
|
||||
MAX_TRADE_PAGES.times do
|
||||
result = kraken_provider.get_trades_history(start: start_time, offset: offset)
|
||||
trades = result.to_h.fetch("trades", {})
|
||||
duplicate_trade_ids = all_trades.keys & trades.keys
|
||||
if duplicate_trade_ids.any?
|
||||
Rails.logger.warn("KrakenItem::Importer - #{duplicate_trade_ids.size} duplicate trade ids from Kraken page ignored")
|
||||
end
|
||||
all_trades.merge!(trades.except(*duplicate_trade_ids))
|
||||
|
||||
count = result.to_h["count"].to_i
|
||||
break if trades.size < TRADE_PAGE_SIZE
|
||||
|
||||
offset += trades.size
|
||||
break if count.positive? && offset >= count
|
||||
end
|
||||
|
||||
all_trades
|
||||
end
|
||||
|
||||
def upsert_kraken_account(assets:, balances:, trades:, asset_metadata:, pair_metadata:, api_key_info:, total_usd:)
|
||||
kraken_item.kraken_accounts.find_or_initialize_by(account_id: "combined").tap do |account|
|
||||
account.assign_attributes(
|
||||
name: kraken_item.institution_name.presence || "Kraken",
|
||||
account_type: "combined",
|
||||
currency: "USD",
|
||||
current_balance: total_usd,
|
||||
institution_metadata: institution_metadata(assets),
|
||||
raw_payload: {
|
||||
"balances" => balances,
|
||||
"assets" => assets.map(&:stringify_keys),
|
||||
"asset_metadata" => asset_metadata,
|
||||
"pair_metadata" => pair_metadata,
|
||||
"api_key_info" => api_key_info,
|
||||
"fetched_at" => Time.current.iso8601
|
||||
},
|
||||
raw_transactions_payload: {
|
||||
"trades" => trades,
|
||||
"fetched_at" => Time.current.iso8601
|
||||
},
|
||||
extra: account.extra.to_h.deep_merge(price_metadata(assets))
|
||||
)
|
||||
account.save!
|
||||
end
|
||||
end
|
||||
|
||||
def institution_metadata(assets)
|
||||
{
|
||||
"name" => "Kraken",
|
||||
"domain" => "kraken.com",
|
||||
"url" => "https://www.kraken.com",
|
||||
"color" => "#5841D8",
|
||||
"asset_count" => assets.size,
|
||||
"assets" => assets.map { |asset| asset[:symbol] }
|
||||
}
|
||||
end
|
||||
|
||||
def price_metadata(assets)
|
||||
missing = assets.select { |asset| asset[:price_status] == "missing" }.map { |asset| asset[:symbol] }
|
||||
stale = assets.select { |asset| asset[:price_status] == "stale" }.map { |asset| asset[:symbol] }
|
||||
|
||||
{ "kraken" => { "missing_prices" => missing, "stale_prices" => stale } }
|
||||
end
|
||||
end
|
||||
15
app/models/kraken_item/provided.rb
Normal file
15
app/models/kraken_item/provided.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module KrakenItem::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def kraken_provider
|
||||
return nil unless credentials_configured?
|
||||
|
||||
Provider::Kraken.new(
|
||||
api_key: api_key.to_s.strip,
|
||||
api_secret: api_secret.to_s.strip,
|
||||
nonce_generator: -> { next_nonce! }
|
||||
)
|
||||
end
|
||||
end
|
||||
20
app/models/kraken_item/sync_complete_event.rb
Normal file
20
app/models/kraken_item/sync_complete_event.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class KrakenItem::SyncCompleteEvent
|
||||
def initialize(kraken_item)
|
||||
raise ArgumentError, "kraken_item is required" unless kraken_item.respond_to?(:family) && kraken_item.respond_to?(:id)
|
||||
|
||||
@kraken_item = kraken_item
|
||||
end
|
||||
|
||||
def broadcast
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
@kraken_item.family,
|
||||
target: ActionView::RecordIdentifier.dom_id(@kraken_item),
|
||||
partial: "kraken_items/kraken_item",
|
||||
locals: { kraken_item: @kraken_item }
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("KrakenItem::SyncCompleteEvent failed for #{@kraken_item.id}: #{e.class}")
|
||||
end
|
||||
end
|
||||
81
app/models/kraken_item/syncer.rb
Normal file
81
app/models/kraken_item/syncer.rb
Normal file
@@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class KrakenItem::Syncer
|
||||
include SyncStats::Collector
|
||||
|
||||
attr_reader :kraken_item
|
||||
|
||||
def initialize(kraken_item)
|
||||
@kraken_item = kraken_item
|
||||
end
|
||||
|
||||
def perform_sync(sync)
|
||||
sync.update!(status_text: I18n.t("kraken_item.syncer.checking_credentials")) if sync.respond_to?(:status_text)
|
||||
unless kraken_item.credentials_configured?
|
||||
kraken_item.update!(status: :requires_update)
|
||||
mark_failed(sync, I18n.t("kraken_item.syncer.credentials_invalid"))
|
||||
return
|
||||
end
|
||||
|
||||
sync.update!(status_text: I18n.t("kraken_item.syncer.importing_accounts")) if sync.respond_to?(:status_text)
|
||||
kraken_item.import_latest_kraken_data
|
||||
kraken_item.update!(status: :good) if kraken_item.requires_update?
|
||||
|
||||
sync.update!(status_text: I18n.t("kraken_item.syncer.checking_configuration")) if sync.respond_to?(:status_text)
|
||||
collect_setup_stats(sync, provider_accounts: kraken_item.kraken_accounts.to_a)
|
||||
|
||||
unlinked = kraken_item.kraken_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
|
||||
linked = kraken_item.kraken_accounts.joins(:account_provider).joins(:account).merge(Account.visible)
|
||||
|
||||
if unlinked.any?
|
||||
kraken_item.update!(pending_account_setup: true)
|
||||
sync.update!(status_text: I18n.t("kraken_item.syncer.accounts_need_setup", count: unlinked.count)) if sync.respond_to?(:status_text)
|
||||
else
|
||||
kraken_item.update!(pending_account_setup: false)
|
||||
end
|
||||
|
||||
return unless linked.any?
|
||||
|
||||
sync.update!(status_text: I18n.t("kraken_item.syncer.processing_accounts")) if sync.respond_to?(:status_text)
|
||||
kraken_item.process_accounts
|
||||
|
||||
sync.update!(status_text: I18n.t("kraken_item.syncer.calculating_balances")) if sync.respond_to?(:status_text)
|
||||
kraken_item.schedule_account_syncs(
|
||||
parent_sync: sync,
|
||||
window_start_date: sync.window_start_date,
|
||||
window_end_date: sync.window_end_date
|
||||
)
|
||||
|
||||
account_ids = linked.map { |kraken_account| kraken_account.current_account&.id }.compact
|
||||
if account_ids.any?
|
||||
collect_transaction_stats(sync, account_ids: account_ids, source: "kraken")
|
||||
collect_trades_stats(sync, account_ids: account_ids, source: "kraken")
|
||||
end
|
||||
rescue Provider::Kraken::AuthenticationError, Provider::Kraken::PermissionError, Provider::Kraken::OTPRequiredError => e
|
||||
kraken_item.update!(status: :requires_update)
|
||||
mark_failed(sync, e.message)
|
||||
raise
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "KrakenItem::Syncer - unexpected error during sync: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}"
|
||||
mark_failed(sync, e.message)
|
||||
raise
|
||||
end
|
||||
|
||||
def perform_post_sync
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mark_failed(sync, error_message)
|
||||
sync.start! if sync.respond_to?(:may_start?) && sync.may_start?
|
||||
|
||||
if sync.respond_to?(:may_fail?) && sync.may_fail?
|
||||
sync.fail!
|
||||
elsif sync.respond_to?(:status)
|
||||
sync.update!(status: :failed)
|
||||
end
|
||||
|
||||
sync.update!(error: error_message) if sync.respond_to?(:error)
|
||||
sync.update!(status_text: error_message) if sync.respond_to?(:status_text)
|
||||
end
|
||||
end
|
||||
37
app/models/kraken_item/unlinking.rb
Normal file
37
app/models/kraken_item/unlinking.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module KrakenItem::Unlinking
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def unlink_all!(dry_run: false)
|
||||
results = []
|
||||
links_by_provider_id = AccountProvider
|
||||
.where(provider_type: KrakenAccount.name, provider_id: kraken_accounts.select(:id))
|
||||
.group_by { |link| link.provider_id.to_s }
|
||||
|
||||
kraken_accounts.find_each do |provider_account|
|
||||
links = links_by_provider_id[provider_account.id.to_s] || []
|
||||
link_ids = links.map(&:id)
|
||||
result = {
|
||||
provider_account_id: provider_account.id,
|
||||
name: provider_account.name,
|
||||
provider_link_ids: link_ids
|
||||
}
|
||||
results << result
|
||||
|
||||
next if dry_run
|
||||
|
||||
begin
|
||||
ActiveRecord::Base.transaction do
|
||||
Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil) if link_ids.any?
|
||||
links.each(&:destroy!)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("KrakenItem Unlinker: failed to unlink ##{provider_account.id}: #{e.class} - #{e.message}")
|
||||
result[:error] = e.message
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,8 @@
|
||||
class LunchflowItem < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking, Encryptable
|
||||
|
||||
DEFAULT_BASE_URL = "https://lunchflow.app/api/v1".freeze
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
# Encrypt sensitive credentials and raw payloads if ActiveRecord encryption is configured
|
||||
@@ -154,6 +156,17 @@ class LunchflowItem < ApplicationRecord
|
||||
end
|
||||
|
||||
def effective_base_url
|
||||
base_url.presence || "https://lunchflow.app/api/v1"
|
||||
return DEFAULT_BASE_URL if base_url.blank?
|
||||
|
||||
uri = URI.parse(base_url)
|
||||
return DEFAULT_BASE_URL unless uri.is_a?(URI::HTTPS)
|
||||
return DEFAULT_BASE_URL unless uri.host == "lunchflow.app"
|
||||
return DEFAULT_BASE_URL unless [ "", "/", "/api/v1", "/api/v1/" ].include?(uri.path)
|
||||
return DEFAULT_BASE_URL unless uri.query.blank?
|
||||
return DEFAULT_BASE_URL unless uri.fragment.blank?
|
||||
|
||||
DEFAULT_BASE_URL
|
||||
rescue URI::InvalidURIError
|
||||
DEFAULT_BASE_URL
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
class MintImport < Import
|
||||
after_create :set_mappings
|
||||
|
||||
DEFAULT_COLUMN_MAPPINGS = {
|
||||
signage_convention: "inflows_positive",
|
||||
date_col_label: "Date",
|
||||
date_format: "%m/%d/%Y",
|
||||
name_col_label: "Description",
|
||||
amount_col_label: "Amount",
|
||||
currency_col_label: "Currency",
|
||||
account_col_label: "Account Name",
|
||||
category_col_label: "Category",
|
||||
tags_col_label: "Labels",
|
||||
notes_col_label: "Notes",
|
||||
entity_type_col_label: "Transaction Type"
|
||||
}.freeze
|
||||
|
||||
def self.default_column_mappings
|
||||
DEFAULT_COLUMN_MAPPINGS
|
||||
end
|
||||
|
||||
def generate_rows_from_csv
|
||||
rows.destroy_all
|
||||
|
||||
@@ -83,18 +101,7 @@ class MintImport < Import
|
||||
|
||||
private
|
||||
def set_mappings
|
||||
self.signage_convention = "inflows_positive"
|
||||
self.date_col_label = "Date"
|
||||
self.date_format = "%m/%d/%Y"
|
||||
self.name_col_label = "Description"
|
||||
self.amount_col_label = "Amount"
|
||||
self.currency_col_label = "Currency"
|
||||
self.account_col_label = "Account Name"
|
||||
self.category_col_label = "Category"
|
||||
self.tags_col_label = "Labels"
|
||||
self.notes_col_label = "Notes"
|
||||
self.entity_type_col_label = "Transaction Type"
|
||||
|
||||
assign_attributes(self.class.default_column_mappings)
|
||||
save!
|
||||
end
|
||||
end
|
||||
|
||||
@@ -25,10 +25,13 @@ class OidcIdentity < ApplicationRecord
|
||||
groups: groups
|
||||
})
|
||||
|
||||
# Sync name to user if provided (keep existing if IdP doesn't provide)
|
||||
# Sync name to user only when Sure has nothing on file (first link, or an
|
||||
# admin blanked the field). Edits made inside Sure must survive subsequent
|
||||
# SSO logins — previously the IdP value won unconditionally and clobbered
|
||||
# any manually-edited name on every login (#1103).
|
||||
user.update!(
|
||||
first_name: auth.info&.first_name.presence || user.first_name,
|
||||
last_name: auth.info&.last_name.presence || user.last_name
|
||||
first_name: user.first_name.presence || auth.info&.first_name.presence,
|
||||
last_name: user.last_name.presence || auth.info&.last_name.presence
|
||||
)
|
||||
|
||||
# Apply role mapping based on group membership
|
||||
|
||||
@@ -35,6 +35,16 @@ class Provider::BinancePublic < Provider
|
||||
MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||
SEARCH_LIMIT = 25
|
||||
|
||||
# USD-pegged stablecoins. Binance has no self-pair (USDTUSDT is invalid) and
|
||||
# the few stablecoin/USDT pairs that do exist (USDCUSDT, etc.) hover at ~1.0
|
||||
# with sub-cent noise — synthesizing a flat 1.0 USD price is both accurate
|
||||
# enough and avoids surfacing transient depeg ticks from market data.
|
||||
USD_STABLECOINS = %w[USDT USDC BUSD DAI FDUSD TUSD USDP PYUSD].freeze
|
||||
|
||||
# Symbol prefix applied by holdings processors (CoinStats, Coinbase, Kraken,
|
||||
# Binance, SimpleFIN, Lunchflow) to distinguish crypto from stock tickers.
|
||||
CRYPTO_PREFIX = "CRYPTO:".freeze
|
||||
|
||||
def initialize
|
||||
# No API key required — public market data only.
|
||||
end
|
||||
@@ -58,9 +68,13 @@ class Provider::BinancePublic < Provider
|
||||
|
||||
def search_securities(symbol, country_code: nil, exchange_operating_mic: nil)
|
||||
with_provider_response do
|
||||
query = symbol.to_s.strip.upcase
|
||||
query = symbol.to_s.strip.upcase.delete_prefix(CRYPTO_PREFIX)
|
||||
next [] if query.empty?
|
||||
|
||||
if USD_STABLECOINS.include?(query)
|
||||
next [ stablecoin_search_result(query) ]
|
||||
end
|
||||
|
||||
symbols = exchange_info_symbols
|
||||
|
||||
matches = symbols.select do |s|
|
||||
@@ -128,10 +142,12 @@ class Provider::BinancePublic < Provider
|
||||
# logo_url is intentionally nil — crypto logos are set at save time by
|
||||
# Security#generate_logo_url_from_brandfetch via the /crypto/{base}
|
||||
# route, not returned from this provider.
|
||||
links = parsed[:binance_pair] ? "https://www.binance.com/en/trade/#{parsed[:binance_pair]}" : nil
|
||||
|
||||
SecurityInfo.new(
|
||||
symbol: symbol,
|
||||
name: parsed[:base],
|
||||
links: "https://www.binance.com/en/trade/#{parsed[:binance_pair]}",
|
||||
links: links,
|
||||
logo_url: nil,
|
||||
description: nil,
|
||||
kind: "crypto",
|
||||
@@ -161,6 +177,10 @@ class Provider::BinancePublic < Provider
|
||||
parsed = parse_ticker(symbol)
|
||||
raise InvalidSecurityPriceError, "Unsupported Binance ticker: #{symbol}" if parsed.nil?
|
||||
|
||||
if parsed[:stablecoin]
|
||||
next stablecoin_prices(symbol, parsed, start_date, end_date, exchange_operating_mic)
|
||||
end
|
||||
|
||||
binance_pair = parsed[:binance_pair]
|
||||
display_currency = parsed[:display_currency]
|
||||
prices = []
|
||||
@@ -220,6 +240,36 @@ class Provider::BinancePublic < Provider
|
||||
end
|
||||
|
||||
private
|
||||
# Synthetic search hit for a USD-pegged stablecoin. Binance has no self-pair
|
||||
# (USDTUSDT etc. don't exist), so we manufacture a result instead of letting
|
||||
# the resolver fall back to an offline CRYPTO:* row. The downstream price
|
||||
# path short-circuits via parse_ticker -> stablecoin_prices.
|
||||
def stablecoin_search_result(base)
|
||||
Security.new(
|
||||
symbol: "#{base}USD",
|
||||
name: base,
|
||||
logo_url: ::Security.brandfetch_crypto_url(base),
|
||||
exchange_operating_mic: BINANCE_MIC,
|
||||
country_code: nil,
|
||||
currency: "USD"
|
||||
)
|
||||
end
|
||||
|
||||
# Synthesize flat 1.0 USD prices for USD-pegged stablecoins across the
|
||||
# requested range. Avoids a Binance round-trip (there is no self-pair like
|
||||
# USDTUSDT) and produces stable values for portfolio aggregation.
|
||||
def stablecoin_prices(symbol, parsed, start_date, end_date, exchange_operating_mic)
|
||||
(start_date..end_date).map do |date|
|
||||
Price.new(
|
||||
symbol: symbol,
|
||||
date: date,
|
||||
price: 1.0,
|
||||
currency: parsed[:display_currency],
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV["BINANCE_PUBLIC_URL"] || "https://data-api.binance.vision"
|
||||
end
|
||||
@@ -247,11 +297,24 @@ class Provider::BinancePublic < Provider
|
||||
end
|
||||
end
|
||||
|
||||
# Maps a user-visible ticker (e.g. "BTCUSD", "ETHEUR") to the Binance pair
|
||||
# symbol, base asset, and display currency. Returns nil if the ticker does
|
||||
# not end with a supported quote currency.
|
||||
# Maps a user-visible ticker to the Binance pair symbol, base asset, and
|
||||
# display currency. Accepts:
|
||||
# - "BTCUSD"/"ETHEUR" — fiat suffix from search_securities output
|
||||
# - "CRYPTO:BTCUSD" — prefixed form stored by holdings processors
|
||||
# - "CRYPTO:SOL"/"SOL" — bare base asset; defaults to the USDT pair (USD)
|
||||
# - "CRYPTO:USDT"/"USDT" — USD-pegged stablecoin; binance_pair is nil and
|
||||
# callers short-circuit to a synthetic 1.0 USD price
|
||||
# Returns nil only when the input is empty after stripping the prefix.
|
||||
def parse_ticker(ticker)
|
||||
ticker_up = ticker.to_s.upcase
|
||||
raw = ticker.to_s.upcase
|
||||
prefixed = raw.start_with?(CRYPTO_PREFIX)
|
||||
ticker_up = raw.delete_prefix(CRYPTO_PREFIX)
|
||||
return nil if ticker_up.empty?
|
||||
|
||||
if USD_STABLECOINS.include?(ticker_up)
|
||||
return { binance_pair: nil, base: ticker_up, display_currency: "USD", stablecoin: true }
|
||||
end
|
||||
|
||||
SUPPORTED_QUOTES.each do |quote|
|
||||
display_currency = QUOTE_TO_CURRENCY[quote]
|
||||
next unless ticker_up.end_with?(display_currency)
|
||||
@@ -259,9 +322,22 @@ class Provider::BinancePublic < Provider
|
||||
base = ticker_up.delete_suffix(display_currency)
|
||||
next if base.empty?
|
||||
|
||||
# "{stablecoin}USD" form (e.g. "USDTUSD" produced by search_securities)
|
||||
# routes to synthetic 1.0 USD pricing — there is no Binance self-pair.
|
||||
if display_currency == "USD" && USD_STABLECOINS.include?(base)
|
||||
return { binance_pair: nil, base: base, display_currency: "USD", stablecoin: true }
|
||||
end
|
||||
|
||||
return { binance_pair: "#{base}#{quote}", base: base, display_currency: display_currency }
|
||||
end
|
||||
nil
|
||||
|
||||
# No fiat suffix matched. Only treat the input as a bare base asset when
|
||||
# it arrived with the CRYPTO: prefix from a holdings processor — that
|
||||
# tells us it really is a single coin symbol (SOL, TRUMP, KAITO), not a
|
||||
# malformed pair like "BTCBNB" or "BTCGBP" that we want to reject.
|
||||
return nil unless prefixed
|
||||
|
||||
{ binance_pair: "#{ticker_up}USDT", base: ticker_up, display_currency: "USD" }
|
||||
end
|
||||
|
||||
# Cached for 24h — exchangeInfo returns the full symbol universe (thousands
|
||||
|
||||
271
app/models/provider/brex.rb
Normal file
271
app/models/provider/brex.rb
Normal file
@@ -0,0 +1,271 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Provider::Brex
|
||||
include HTTParty
|
||||
extend SslConfigurable
|
||||
|
||||
DEFAULT_BASE_URL = "https://api.brex.com"
|
||||
STAGING_BASE_URL = "https://api-staging.brex.com"
|
||||
ALLOWED_BASE_URLS = [ DEFAULT_BASE_URL, STAGING_BASE_URL ].freeze
|
||||
DEFAULT_LIMIT = 1000
|
||||
# Transaction syncs are date-window bounded; this is only a runaway cursor guard.
|
||||
MAX_PAGES = 25
|
||||
|
||||
headers "User-Agent" => "Sure Finance Brex Client"
|
||||
default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
|
||||
|
||||
attr_reader :token, :base_url
|
||||
|
||||
def initialize(token, base_url: DEFAULT_BASE_URL)
|
||||
@token = token.to_s.strip
|
||||
@base_url = self.class.normalize_base_url(base_url)
|
||||
raise ArgumentError, "Brex base URL must be blank or one of: #{ALLOWED_BASE_URLS.join(', ')}" unless @base_url.present?
|
||||
end
|
||||
|
||||
def self.normalize_base_url(value)
|
||||
stripped = value.to_s.strip
|
||||
return DEFAULT_BASE_URL if stripped.blank?
|
||||
|
||||
uri = URI.parse(stripped)
|
||||
return nil unless uri.is_a?(URI::HTTPS)
|
||||
return nil if uri.userinfo.present?
|
||||
return nil if uri.query.present? || uri.fragment.present?
|
||||
return nil unless uri.path.blank? || uri.path == "/"
|
||||
return nil unless uri.port == 443
|
||||
|
||||
# This exact allowlist is the SSRF boundary; arbitrary Brex-like hosts are never accepted.
|
||||
normalized = "#{uri.scheme.downcase}://#{uri.host.to_s.downcase}"
|
||||
ALLOWED_BASE_URLS.include?(normalized) ? normalized : nil
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def self.allowed_base_url?(value)
|
||||
normalize_base_url(value).present?
|
||||
end
|
||||
|
||||
def get_accounts
|
||||
cash_accounts = get_cash_accounts
|
||||
card_accounts = get_card_accounts
|
||||
|
||||
accounts = cash_accounts.dup
|
||||
accounts << aggregate_card_account(card_accounts) if card_accounts.any?
|
||||
|
||||
{
|
||||
accounts: accounts,
|
||||
cash_accounts: cash_accounts,
|
||||
card_accounts: card_accounts
|
||||
}
|
||||
end
|
||||
|
||||
def get_cash_accounts
|
||||
get_paginated("/v2/accounts/cash").map { |account| account.with_indifferent_access.merge(account_kind: "cash") }
|
||||
end
|
||||
|
||||
def get_card_accounts
|
||||
get_paginated("/v2/accounts/card").map { |account| account.with_indifferent_access.merge(account_kind: "card") }
|
||||
end
|
||||
|
||||
def get_cash_transactions(account_id, start_date: nil)
|
||||
path = "/v2/transactions/cash/#{ERB::Util.url_encode(account_id.to_s)}"
|
||||
{
|
||||
transactions: get_paginated(path, params: posted_at_start_params(start_date))
|
||||
}
|
||||
end
|
||||
|
||||
def get_primary_card_transactions(start_date: nil)
|
||||
{
|
||||
transactions: get_paginated("/v2/transactions/card/primary", params: posted_at_start_params(start_date))
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def aggregate_card_account(card_accounts)
|
||||
totals = %i[current_balance available_balance account_limit].index_with do |field|
|
||||
sum_money(card_accounts.filter_map { |account| account.with_indifferent_access[field] })
|
||||
end
|
||||
|
||||
{
|
||||
id: BrexAccount.card_account_id,
|
||||
name: "Brex Card",
|
||||
account_kind: "card",
|
||||
status: card_accounts.map { |account| account.with_indifferent_access[:status] }.compact.first,
|
||||
card_accounts_count: card_accounts.count,
|
||||
current_balance: totals[:current_balance],
|
||||
available_balance: totals[:available_balance],
|
||||
account_limit: totals[:account_limit],
|
||||
raw_card_accounts: BrexAccount.sanitize_payload(card_accounts)
|
||||
}.compact
|
||||
end
|
||||
|
||||
def sum_money(money_values)
|
||||
normalized = money_values.compact
|
||||
return nil if normalized.empty?
|
||||
|
||||
currencies = normalized.map { |money| BrexAccount.currency_code_from_money(money) }.uniq
|
||||
if currencies.many?
|
||||
Rails.logger.warn "Brex API: Cannot aggregate card balances with mixed currencies: #{currencies.join(', ')}"
|
||||
return nil
|
||||
end
|
||||
|
||||
currency = currencies.first
|
||||
total = normalized.sum do |money|
|
||||
money.with_indifferent_access[:amount].to_i
|
||||
end
|
||||
|
||||
{ amount: total, currency: currency }
|
||||
end
|
||||
|
||||
def posted_at_start_params(start_date)
|
||||
return {} if start_date.blank?
|
||||
|
||||
{ posted_at_start: rfc3339_start_date(start_date) }
|
||||
end
|
||||
|
||||
def get_paginated(path, params: {})
|
||||
records = []
|
||||
cursor = nil
|
||||
seen_cursors = Set.new
|
||||
page_count = 0
|
||||
|
||||
loop do
|
||||
page_count += 1
|
||||
raise BrexError.new("Brex pagination exceeded #{MAX_PAGES} pages", :pagination_error) if page_count > MAX_PAGES
|
||||
|
||||
page_params = params.compact.merge(limit: DEFAULT_LIMIT)
|
||||
page_params[:cursor] = cursor if cursor.present?
|
||||
|
||||
response_payload = get_json(path, params: page_params)
|
||||
if response_payload.is_a?(Array)
|
||||
records.concat(response_payload)
|
||||
break
|
||||
end
|
||||
|
||||
page_records = extract_records(response_payload)
|
||||
records.concat(page_records)
|
||||
|
||||
next_cursor = response_payload.with_indifferent_access[:next_cursor]
|
||||
break if next_cursor.blank?
|
||||
|
||||
if seen_cursors.include?(next_cursor)
|
||||
raise BrexError.new("Brex pagination returned a repeated cursor", :pagination_error)
|
||||
end
|
||||
|
||||
seen_cursors.add(next_cursor)
|
||||
cursor = next_cursor
|
||||
end
|
||||
|
||||
records
|
||||
end
|
||||
|
||||
def get_json(path, params: {})
|
||||
query = params.present? ? "?#{URI.encode_www_form(params)}" : ""
|
||||
request_path = "#{path}#{query}"
|
||||
|
||||
response = self.class.get(
|
||||
"#{base_url}#{request_path}",
|
||||
headers: auth_headers
|
||||
)
|
||||
|
||||
handle_response(response, path: path)
|
||||
rescue BrexError
|
||||
raise
|
||||
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
Rails.logger.error "Brex API: GET #{path} failed: #{e.class}: #{e.message}"
|
||||
raise BrexError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "Brex API: invalid JSON for GET #{path}: #{e.message}"
|
||||
raise BrexError.new("Invalid response from Brex API", :invalid_response)
|
||||
rescue => e
|
||||
Rails.logger.error "Brex API: Unexpected error during GET #{path}: #{e.class}: #{e.message}"
|
||||
raise BrexError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||
end
|
||||
|
||||
def extract_records(response_payload)
|
||||
return response_payload if response_payload.is_a?(Array)
|
||||
|
||||
payload = response_payload.with_indifferent_access
|
||||
payload[:items] ||
|
||||
payload[:data] ||
|
||||
payload[:accounts] ||
|
||||
payload[:transactions] ||
|
||||
[]
|
||||
end
|
||||
|
||||
def auth_headers
|
||||
{
|
||||
"Authorization" => "Bearer #{token}",
|
||||
"Content-Type" => "application/json",
|
||||
"Accept" => "application/json"
|
||||
}
|
||||
end
|
||||
|
||||
def handle_response(response, path:)
|
||||
trace_id = brex_trace_id(response)
|
||||
|
||||
case response.code
|
||||
when 200
|
||||
parse_json(response.body)
|
||||
when 400
|
||||
Rails.logger.error "Brex API: bad request for #{path} trace_id=#{trace_id}"
|
||||
raise BrexError.new("Bad request to Brex API", :bad_request, http_status: 400, trace_id: trace_id)
|
||||
when 401
|
||||
Rails.logger.warn "Brex API: unauthorized for #{path} trace_id=#{trace_id}"
|
||||
raise BrexError.new("Invalid Brex API token or account permissions", :unauthorized, http_status: 401, trace_id: trace_id)
|
||||
when 403
|
||||
Rails.logger.warn "Brex API: access forbidden for #{path} trace_id=#{trace_id}"
|
||||
raise BrexError.new("Access forbidden - check Brex API token scopes", :access_forbidden, http_status: 403, trace_id: trace_id)
|
||||
when 404
|
||||
Rails.logger.warn "Brex API: resource not found for #{path} trace_id=#{trace_id}"
|
||||
raise BrexError.new("Brex resource not found", :not_found, http_status: 404, trace_id: trace_id)
|
||||
when 429
|
||||
Rails.logger.warn "Brex API: rate limited for #{path} trace_id=#{trace_id}"
|
||||
raise BrexError.new("Brex rate limit exceeded. Please try again later.", :rate_limited, http_status: 429, trace_id: trace_id)
|
||||
else
|
||||
Rails.logger.error "Brex API: unexpected response code=#{response.code} path=#{path} trace_id=#{trace_id}"
|
||||
raise BrexError.new("Failed to fetch data from Brex API: HTTP #{response.code}", :fetch_failed, http_status: response.code, trace_id: trace_id)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_json(body)
|
||||
return {} if body.blank?
|
||||
|
||||
JSON.parse(body, symbolize_names: true)
|
||||
end
|
||||
|
||||
def rfc3339_start_date(start_date)
|
||||
time =
|
||||
case start_date
|
||||
when Time
|
||||
start_date
|
||||
when DateTime
|
||||
start_date.to_time
|
||||
when Date
|
||||
start_date.to_time(:utc)
|
||||
else
|
||||
Time.zone.parse(start_date.to_s)
|
||||
end
|
||||
|
||||
raise ArgumentError, "Invalid start_date: #{start_date.inspect}" if time.nil?
|
||||
|
||||
time.utc.iso8601
|
||||
end
|
||||
|
||||
def brex_trace_id(response)
|
||||
headers = response.respond_to?(:headers) ? response.headers : {}
|
||||
headers["X-Brex-Trace-Id"].presence ||
|
||||
headers["x-brex-trace-id"].presence
|
||||
end
|
||||
|
||||
class BrexError < StandardError
|
||||
attr_reader :error_type, :http_status, :trace_id
|
||||
|
||||
def initialize(message, error_type = :unknown, http_status: nil, trace_id: nil)
|
||||
super(message)
|
||||
@error_type = error_type
|
||||
@http_status = http_status
|
||||
@trace_id = trace_id
|
||||
end
|
||||
end
|
||||
end
|
||||
119
app/models/provider/brex_adapter.rb
Normal file
119
app/models/provider/brex_adapter.rb
Normal file
@@ -0,0 +1,119 @@
|
||||
class Provider::BrexAdapter < Provider::Base
|
||||
include Provider::Syncable
|
||||
include Provider::InstitutionMetadata
|
||||
|
||||
# Register this adapter with the factory
|
||||
Provider::Factory.register("BrexAccount", self)
|
||||
|
||||
def self.supported_account_types
|
||||
%w[Depository CreditCard]
|
||||
end
|
||||
|
||||
# Returns connection configurations for this provider
|
||||
def self.connection_configs(family:)
|
||||
return [] unless family.can_connect_brex?
|
||||
|
||||
brex_items = family.brex_items.active.with_credentials.ordered
|
||||
|
||||
return [ connection_config_for(nil) ] if brex_items.empty?
|
||||
|
||||
brex_items.map { |brex_item| connection_config_for(brex_item) }
|
||||
end
|
||||
|
||||
def provider_name
|
||||
"brex"
|
||||
end
|
||||
|
||||
# Build a Brex provider instance with family-specific credentials
|
||||
# @param family [Family] The family to get credentials for (required)
|
||||
# @return [Provider::Brex, nil] Returns nil if credentials are not configured
|
||||
def self.build_provider(family: nil, brex_item_id: nil)
|
||||
return nil unless family.present?
|
||||
|
||||
brex_item = BrexItem.resolve_for(family: family, brex_item_id: brex_item_id)
|
||||
return nil unless brex_item&.credentials_configured?
|
||||
|
||||
base_url = brex_item.effective_base_url
|
||||
return nil unless base_url.present?
|
||||
|
||||
Provider::Brex.new(
|
||||
brex_item.token.to_s.strip,
|
||||
base_url: base_url
|
||||
)
|
||||
end
|
||||
|
||||
def self.connection_config_for(brex_item)
|
||||
path_params = ->(extra = {}) do
|
||||
brex_item.present? ? extra.merge(brex_item_id: brex_item.id) : extra
|
||||
end
|
||||
|
||||
{
|
||||
key: brex_item.present? ? "brex_#{brex_item.id}" : "brex",
|
||||
name: brex_item.present? ? I18n.t("brex_items.provider_connection.name", name: brex_item.name) : I18n.t("brex_items.provider_connection.default_name"),
|
||||
description: brex_item.present? ? I18n.t("brex_items.provider_connection.description", name: brex_item.name) : I18n.t("brex_items.provider_connection.default_description"),
|
||||
can_connect: true,
|
||||
new_account_path: ->(accountable_type, return_to) {
|
||||
Rails.application.routes.url_helpers.select_accounts_brex_items_path(
|
||||
path_params.call(accountable_type: accountable_type, return_to: return_to)
|
||||
)
|
||||
},
|
||||
existing_account_path: ->(account_id) {
|
||||
Rails.application.routes.url_helpers.select_existing_account_brex_items_path(
|
||||
path_params.call(account_id: account_id)
|
||||
)
|
||||
}
|
||||
}
|
||||
end
|
||||
private_class_method :connection_config_for
|
||||
|
||||
def sync_path
|
||||
Rails.application.routes.url_helpers.sync_brex_item_path(item)
|
||||
end
|
||||
|
||||
def item
|
||||
provider_account.brex_item
|
||||
end
|
||||
|
||||
def can_delete_holdings?
|
||||
false
|
||||
end
|
||||
|
||||
def institution_domain
|
||||
metadata = provider_account.institution_metadata
|
||||
return nil unless metadata.present?
|
||||
|
||||
domain = metadata["domain"]
|
||||
url = metadata["url"]
|
||||
|
||||
# Derive domain from URL if missing
|
||||
if domain.blank? && url.present?
|
||||
begin
|
||||
parsed_host = URI.parse(url).host
|
||||
Rails.logger.warn("Brex account #{provider_account.id} institution URL has no host: #{url}") if parsed_host.nil?
|
||||
domain = parsed_host&.gsub(/^www\./, "")
|
||||
rescue URI::InvalidURIError
|
||||
Rails.logger.warn("Invalid institution URL for Brex account #{provider_account.id}: #{url}")
|
||||
end
|
||||
end
|
||||
|
||||
domain
|
||||
end
|
||||
|
||||
def institution_name
|
||||
metadata = provider_account.institution_metadata
|
||||
|
||||
metadata&.dig("name") || item&.institution_name
|
||||
end
|
||||
|
||||
def institution_url
|
||||
metadata = provider_account.institution_metadata
|
||||
|
||||
metadata&.dig("url") || item&.institution_url
|
||||
end
|
||||
|
||||
def institution_color
|
||||
metadata = provider_account.institution_metadata
|
||||
|
||||
metadata&.dig("color") || item&.institution_color
|
||||
end
|
||||
end
|
||||
59
app/models/provider/ibkr_adapter.rb
Normal file
59
app/models/provider/ibkr_adapter.rb
Normal file
@@ -0,0 +1,59 @@
|
||||
class Provider::IbkrAdapter < Provider::Base
|
||||
include Provider::Syncable
|
||||
include Provider::InstitutionMetadata
|
||||
|
||||
Provider::Factory.register("IbkrAccount", self)
|
||||
|
||||
def self.supported_account_types
|
||||
%w[Investment]
|
||||
end
|
||||
|
||||
def self.connection_configs(family:)
|
||||
return [] unless family.can_connect_ibkr?
|
||||
|
||||
[ {
|
||||
key: "ibkr",
|
||||
name: I18n.t("providers.ibkr.name"),
|
||||
description: I18n.t("providers.ibkr.connection_description"),
|
||||
can_connect: true,
|
||||
new_account_path: ->(_accountable_type, _return_to) {
|
||||
Rails.application.routes.url_helpers.select_accounts_ibkr_items_path
|
||||
},
|
||||
existing_account_path: ->(account_id) {
|
||||
Rails.application.routes.url_helpers.select_existing_account_ibkr_items_path(account_id: account_id)
|
||||
}
|
||||
} ]
|
||||
end
|
||||
|
||||
def provider_name
|
||||
"ibkr"
|
||||
end
|
||||
|
||||
def sync_path
|
||||
Rails.application.routes.url_helpers.sync_ibkr_item_path(item)
|
||||
end
|
||||
|
||||
def item
|
||||
provider_account.ibkr_item
|
||||
end
|
||||
|
||||
def can_delete_holdings?
|
||||
false
|
||||
end
|
||||
|
||||
def institution_domain
|
||||
"interactivebrokers.com"
|
||||
end
|
||||
|
||||
def institution_name
|
||||
I18n.t("providers.ibkr.institution_name")
|
||||
end
|
||||
|
||||
def institution_url
|
||||
"https://www.interactivebrokers.com"
|
||||
end
|
||||
|
||||
def institution_color
|
||||
"#D32F2F"
|
||||
end
|
||||
end
|
||||
144
app/models/provider/ibkr_flex.rb
Normal file
144
app/models/provider/ibkr_flex.rb
Normal file
@@ -0,0 +1,144 @@
|
||||
class Provider::IbkrFlex
|
||||
include HTTParty
|
||||
extend SslConfigurable
|
||||
|
||||
class Error < StandardError; end
|
||||
class AuthenticationError < Error; end
|
||||
class ConfigurationError < Error; end
|
||||
class ApiError < Error
|
||||
attr_reader :status_code, :response_body, :error_code
|
||||
|
||||
def initialize(message, status_code: nil, response_body: nil, error_code: nil)
|
||||
super(message)
|
||||
@status_code = status_code
|
||||
@response_body = response_body
|
||||
@error_code = error_code
|
||||
end
|
||||
end
|
||||
|
||||
base_uri "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService"
|
||||
headers "User-Agent" => "Sure Finance IBKR Flex Client"
|
||||
default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
|
||||
|
||||
MAX_RETRIES = 3
|
||||
INITIAL_RETRY_DELAY = 2
|
||||
MAX_RETRY_DELAY = 30
|
||||
POLL_INTERVAL = 3
|
||||
MAX_POLL_ATTEMPTS = 20
|
||||
PENDING_ERROR_CODES = %w[1004 1019].freeze
|
||||
|
||||
RETRYABLE_ERRORS = [
|
||||
SocketError,
|
||||
Net::OpenTimeout,
|
||||
Net::ReadTimeout,
|
||||
Errno::ECONNRESET,
|
||||
Errno::ECONNREFUSED,
|
||||
Errno::ETIMEDOUT,
|
||||
EOFError
|
||||
].freeze
|
||||
|
||||
attr_reader :query_id, :token
|
||||
|
||||
def initialize(query_id:, token:)
|
||||
raise ConfigurationError, "query_id is required" if query_id.blank?
|
||||
raise ConfigurationError, "token is required" if token.blank?
|
||||
|
||||
@query_id = query_id.to_s.strip
|
||||
@token = token.to_s.strip
|
||||
end
|
||||
|
||||
def download_statement
|
||||
reference_code = request_reference_code
|
||||
poll_statement(reference_code)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_reference_code
|
||||
response = with_retries("SendRequest") do
|
||||
self.class.get("/SendRequest", query: { t: token, q: query_id, v: 3 })
|
||||
end
|
||||
|
||||
xml = parse_xml(response.body)
|
||||
error = response_error(xml, response)
|
||||
raise error if error
|
||||
|
||||
reference_code = xml.at_xpath("//ReferenceCode")&.text.to_s.strip
|
||||
raise ApiError.new("IBKR Flex did not return a reference code.", status_code: response.code, response_body: response.body) if reference_code.blank?
|
||||
|
||||
reference_code
|
||||
end
|
||||
|
||||
def poll_statement(reference_code)
|
||||
attempts = 0
|
||||
|
||||
loop do
|
||||
attempts += 1
|
||||
response = with_retries("GetStatement") do
|
||||
self.class.get("/GetStatement", query: { t: token, q: reference_code, v: 3 })
|
||||
end
|
||||
|
||||
xml = parse_xml(response.body)
|
||||
return response.body if xml.at_xpath("//FlexQueryResponse")
|
||||
|
||||
error = response_error(xml, response)
|
||||
if error.is_a?(ApiError) && PENDING_ERROR_CODES.include?(error.error_code.to_s)
|
||||
raise ApiError.new("IBKR Flex statement is still being generated.", error_code: error.error_code) if attempts >= MAX_POLL_ATTEMPTS
|
||||
|
||||
sleep(POLL_INTERVAL)
|
||||
next
|
||||
end
|
||||
|
||||
raise(error || ApiError.new("IBKR Flex returned an unexpected response.", status_code: response.code, response_body: response.body))
|
||||
end
|
||||
end
|
||||
|
||||
def response_error(xml, response)
|
||||
error_code = xml.at_xpath("//ErrorCode")&.text.to_s.strip.presence
|
||||
error_message = xml.at_xpath("//ErrorMessage")&.text.to_s.strip.presence
|
||||
|
||||
return nil if error_code.blank? && response.success?
|
||||
|
||||
message = error_message.presence || "IBKR Flex request failed"
|
||||
|
||||
case error_code
|
||||
when "1012", "1015"
|
||||
AuthenticationError.new(message)
|
||||
when "1014"
|
||||
ConfigurationError.new(message)
|
||||
else
|
||||
ApiError.new(message, status_code: response.code, response_body: response.body, error_code: error_code)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_xml(body)
|
||||
Nokogiri::XML(body.to_s)
|
||||
end
|
||||
|
||||
def with_retries(operation_name, max_retries: MAX_RETRIES)
|
||||
retries = 0
|
||||
|
||||
begin
|
||||
yield
|
||||
rescue *RETRYABLE_ERRORS => e
|
||||
retries += 1
|
||||
|
||||
if retries <= max_retries
|
||||
delay = calculate_retry_delay(retries)
|
||||
Rails.logger.warn(
|
||||
"IBKR Flex: #{operation_name} failed (attempt #{retries}/#{max_retries}): #{e.class}: #{e.message}. Retrying in #{delay}s..."
|
||||
)
|
||||
sleep(delay)
|
||||
retry
|
||||
end
|
||||
|
||||
raise ApiError.new("Network error after #{max_retries} retries: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_retry_delay(retry_count)
|
||||
base_delay = INITIAL_RETRY_DELAY * (2**(retry_count - 1))
|
||||
jitter = base_delay * rand * 0.25
|
||||
[ base_delay + jitter, MAX_RETRY_DELAY ].min
|
||||
end
|
||||
end
|
||||
153
app/models/provider/kraken.rb
Normal file
153
app/models/provider/kraken.rb
Normal file
@@ -0,0 +1,153 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Provider::Kraken
|
||||
include HTTParty
|
||||
extend SslConfigurable
|
||||
|
||||
class Error < StandardError; end
|
||||
class AuthenticationError < Error; end
|
||||
class PermissionError < Error; end
|
||||
class RateLimitError < Error; end
|
||||
class NonceError < Error; end
|
||||
class OTPRequiredError < Error; end
|
||||
class ApiError < Error; end
|
||||
|
||||
BASE_URL = "https://api.kraken.com"
|
||||
PRIVATE_PREFIX = "/0/private"
|
||||
PUBLIC_PREFIX = "/0/public"
|
||||
|
||||
base_uri BASE_URL
|
||||
default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options))
|
||||
|
||||
attr_reader :api_key, :api_secret
|
||||
|
||||
def initialize(api_key:, api_secret:, nonce_generator: nil)
|
||||
@api_key = api_key # pipelock:ignore user-supplied Kraken credential kept in memory for signed requests
|
||||
@api_secret = api_secret # pipelock:ignore user-supplied Kraken credential kept in memory for signed requests
|
||||
@nonce_generator = nonce_generator || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond).to_s }
|
||||
end
|
||||
|
||||
def get_api_key_info
|
||||
private_post("GetApiKeyInfo")
|
||||
end
|
||||
|
||||
def get_extended_balance
|
||||
private_post("BalanceEx")
|
||||
end
|
||||
|
||||
def get_trades_history(start: nil, offset: nil)
|
||||
params = {}
|
||||
params["start"] = start.to_i.to_s if start.present?
|
||||
params["ofs"] = offset.to_i.to_s if offset.present?
|
||||
|
||||
private_post("TradesHistory", params)
|
||||
end
|
||||
|
||||
def get_asset_info(asset: nil)
|
||||
params = {}
|
||||
params["asset"] = asset if asset.present?
|
||||
public_get("Assets", params)
|
||||
end
|
||||
|
||||
def get_asset_pairs(pair: nil)
|
||||
params = {}
|
||||
params["pair"] = pair if pair.present?
|
||||
public_get("AssetPairs", params)
|
||||
end
|
||||
|
||||
def get_ticker(pair)
|
||||
public_get("Ticker", "pair" => pair)
|
||||
end
|
||||
|
||||
def get_ohlc(pair, interval: 1440, since: nil)
|
||||
params = { "pair" => pair, "interval" => interval.to_s }
|
||||
params["since"] = since.to_i.to_s if since.present?
|
||||
public_get("OHLC", params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :nonce_generator
|
||||
|
||||
def public_get(method, params = {})
|
||||
response = self.class.get("#{PUBLIC_PREFIX}/#{method}", query: params)
|
||||
handle_response(response)
|
||||
end
|
||||
|
||||
def private_post(method, params = {})
|
||||
path = "#{PRIVATE_PREFIX}/#{method}"
|
||||
request_params = { "nonce" => nonce_generator.call.to_s }.merge(stringify_params(params))
|
||||
body = URI.encode_www_form(request_params)
|
||||
|
||||
response = self.class.post(
|
||||
path,
|
||||
body: body,
|
||||
headers: auth_headers(path, request_params).merge("Content-Type" => "application/x-www-form-urlencoded")
|
||||
)
|
||||
|
||||
handle_response(response)
|
||||
end
|
||||
|
||||
def stringify_params(params)
|
||||
params.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value.to_s }
|
||||
end
|
||||
|
||||
def auth_headers(path, params)
|
||||
{
|
||||
"API-Key" => api_key,
|
||||
"API-Sign" => sign(path, params)
|
||||
}
|
||||
end
|
||||
|
||||
def sign(path, params)
|
||||
encoded_payload = URI.encode_www_form(params)
|
||||
nonce = params.fetch("nonce").to_s
|
||||
digest = OpenSSL::Digest::SHA256.digest(nonce + encoded_payload)
|
||||
hmac = OpenSSL::HMAC.digest("sha512", Base64.decode64(api_secret), path + digest)
|
||||
Base64.strict_encode64(hmac)
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
parsed = response.parsed_response
|
||||
|
||||
unless response.code.between?(200, 299)
|
||||
raise ApiError, "Kraken API request failed: #{response.code}"
|
||||
end
|
||||
|
||||
unless parsed.is_a?(Hash)
|
||||
raise ApiError, "Malformed Kraken API response"
|
||||
end
|
||||
|
||||
unless parsed.key?("error")
|
||||
raise ApiError, "Malformed Kraken API response: missing error"
|
||||
end
|
||||
|
||||
errors = Array(parsed["error"]).reject(&:blank?)
|
||||
raise classified_error(errors) if errors.any?
|
||||
|
||||
unless parsed.key?("result")
|
||||
raise ApiError, "Malformed Kraken API response: missing result"
|
||||
end
|
||||
|
||||
parsed["result"]
|
||||
end
|
||||
|
||||
def classified_error(errors)
|
||||
message = errors.join(", ")
|
||||
|
||||
case message
|
||||
when /Invalid key|Invalid signature|Temporary lockout/i
|
||||
AuthenticationError.new(message)
|
||||
when /Invalid nonce/i
|
||||
NonceError.new(message)
|
||||
when /Permission denied|Invalid permissions/i
|
||||
PermissionError.new(message)
|
||||
when /Rate limit exceeded|Too many requests|limit exceeded|Throttled/i
|
||||
RateLimitError.new(message)
|
||||
when /otp|2fa|two.factor/i
|
||||
OTPRequiredError.new(message)
|
||||
else
|
||||
ApiError.new(message)
|
||||
end
|
||||
end
|
||||
end
|
||||
110
app/models/provider/kraken_adapter.rb
Normal file
110
app/models/provider/kraken_adapter.rb
Normal file
@@ -0,0 +1,110 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Provider::KrakenAdapter < Provider::Base
|
||||
include Provider::Syncable
|
||||
include Provider::InstitutionMetadata
|
||||
|
||||
Provider::Factory.register("KrakenAccount", self)
|
||||
|
||||
def self.supported_account_types
|
||||
%w[Crypto]
|
||||
end
|
||||
|
||||
def self.connection_configs(family:)
|
||||
return [] unless family.can_connect_kraken?
|
||||
|
||||
kraken_items = family.kraken_items.active.credentials_configured.ordered.select(&:credentials_configured?)
|
||||
return [ connection_config_for(nil) ] if kraken_items.empty?
|
||||
|
||||
kraken_items.map { |kraken_item| connection_config_for(kraken_item) }
|
||||
end
|
||||
|
||||
def self.build_provider(family: nil, kraken_item_id: nil)
|
||||
return nil unless family.present?
|
||||
|
||||
kraken_item = resolve_kraken_item(family, kraken_item_id)
|
||||
return nil unless kraken_item&.credentials_configured?
|
||||
|
||||
kraken_item.kraken_provider
|
||||
end
|
||||
|
||||
def provider_name
|
||||
"kraken"
|
||||
end
|
||||
|
||||
def sync_path
|
||||
return unless item
|
||||
|
||||
Rails.application.routes.url_helpers.sync_kraken_item_path(item)
|
||||
end
|
||||
|
||||
def item
|
||||
provider_account.kraken_item
|
||||
end
|
||||
|
||||
def can_delete_holdings?
|
||||
false
|
||||
end
|
||||
|
||||
def institution_domain
|
||||
institution_metadata_value("domain")
|
||||
end
|
||||
|
||||
def institution_name
|
||||
institution_metadata_value("name")
|
||||
end
|
||||
|
||||
def institution_url
|
||||
institution_metadata_value("url")
|
||||
end
|
||||
|
||||
def institution_color
|
||||
institution_metadata_value("color")
|
||||
end
|
||||
|
||||
def self.connection_config_for(kraken_item)
|
||||
path_params = ->(extra = {}) do
|
||||
kraken_item.present? ? extra.merge(kraken_item_id: kraken_item.id) : extra
|
||||
end
|
||||
|
||||
{
|
||||
key: kraken_item.present? ? "kraken_#{kraken_item.id}" : "kraken",
|
||||
name: kraken_item.present? ? I18n.t("kraken_items.provider_connection.name", name: kraken_item.name) : I18n.t("kraken_items.provider_connection.default_name"),
|
||||
description: kraken_item.present? ? I18n.t("kraken_items.provider_connection.description", name: kraken_item.name) : I18n.t("kraken_items.provider_connection.default_description"),
|
||||
can_connect: true,
|
||||
new_account_path: ->(accountable_type, return_to) {
|
||||
Rails.application.routes.url_helpers.select_accounts_kraken_items_path(
|
||||
path_params.call(accountable_type: accountable_type, return_to: return_to)
|
||||
)
|
||||
},
|
||||
existing_account_path: ->(account_id) {
|
||||
Rails.application.routes.url_helpers.select_existing_account_kraken_items_path(
|
||||
path_params.call(account_id: account_id)
|
||||
)
|
||||
}
|
||||
}
|
||||
end
|
||||
private_class_method :connection_config_for
|
||||
|
||||
def self.resolve_kraken_item(family, kraken_item_id)
|
||||
if kraken_item_id.present?
|
||||
item = family.kraken_items.active.credentials_configured.find_by(id: kraken_item_id)
|
||||
return item if item&.credentials_configured?
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
credentialed_items = family.kraken_items.active.credentials_configured.ordered.select(&:credentials_configured?)
|
||||
return credentialed_items.first if credentialed_items.one?
|
||||
|
||||
nil
|
||||
end
|
||||
private_class_method :resolve_kraken_item
|
||||
|
||||
private
|
||||
|
||||
def institution_metadata_value(key)
|
||||
metadata = provider_account.institution_metadata || {}
|
||||
metadata[key] || item&.public_send("institution_#{key}")
|
||||
end
|
||||
end
|
||||
@@ -6,9 +6,12 @@ class Provider
|
||||
enable_banking: { region: "EU", kind: "Bank", maturity: :beta, logo_text: "EB", logo_bg: "bg-purple-600" },
|
||||
coinstats: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CS", logo_bg: "bg-pink-600" },
|
||||
mercury: { region: "US", kind: "Bank", maturity: :beta, logo_text: "ME", logo_bg: "bg-cyan-600" },
|
||||
brex: { region: "US", kind: "Bank", maturity: :beta, logo_text: "BX", logo_bg: "bg-emerald-600" },
|
||||
coinbase: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "CB", logo_bg: "bg-blue-500" },
|
||||
binance: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "BI", logo_bg: "bg-yellow-600" },
|
||||
kraken: { region: "Global", kind: "Crypto", maturity: :beta, logo_text: "KR", logo_bg: "bg-violet-600" },
|
||||
snaptrade: { region: "US / CA", kind: "Investment", maturity: :beta, logo_text: "ST", logo_bg: "bg-green-600" },
|
||||
ibkr: { region: "Global", kind: "Investment", maturity: :beta, logo_text: "IB", logo_bg: "bg-red-600" },
|
||||
indexa_capital: { region: "ES", kind: "Investment", maturity: :alpha, logo_text: "IC", logo_bg: "bg-red-600" },
|
||||
sophtron: { region: "US", kind: "Bank", maturity: :alpha, logo_text: "SO", logo_bg: "bg-teal-600" },
|
||||
plaid: { region: "US", kind: "Bank", tier: "Paid", maturity: :stable, logo_text: "PL", logo_bg: "bg-indigo-600" },
|
||||
|
||||
@@ -8,9 +8,12 @@ class ProviderConnectionStatus
|
||||
{ key: "enable_banking", type: "EnableBankingItem", association: :enable_banking_items, accounts: :enable_banking_accounts },
|
||||
{ key: "coinbase", type: "CoinbaseItem", association: :coinbase_items, accounts: :coinbase_accounts },
|
||||
{ key: "binance", type: "BinanceItem", association: :binance_items, accounts: :binance_accounts },
|
||||
{ key: "kraken", type: "KrakenItem", association: :kraken_items, accounts: :kraken_accounts },
|
||||
{ key: "coinstats", type: "CoinstatsItem", association: :coinstats_items, accounts: :coinstats_accounts },
|
||||
{ key: "snaptrade", type: "SnaptradeItem", association: :snaptrade_items, accounts: :snaptrade_accounts, linked_accounts: :linked_accounts },
|
||||
{ key: "ibkr", type: "IbkrItem", association: :ibkr_items, accounts: :ibkr_accounts },
|
||||
{ key: "mercury", type: "MercuryItem", association: :mercury_items, accounts: :mercury_accounts },
|
||||
{ key: "brex", type: "BrexItem", association: :brex_items, accounts: :brex_accounts },
|
||||
{ key: "sophtron", type: "SophtronItem", association: :sophtron_items, accounts: :sophtron_accounts },
|
||||
{ key: "indexa_capital", type: "IndexaCapitalItem", association: :indexa_capital_items, accounts: :indexa_capital_accounts }
|
||||
].freeze
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ProviderMerchant < Merchant
|
||||
enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", indexa_capital: "indexa_capital", sophtron: "sophtron" }
|
||||
enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury", brex: "brex", indexa_capital: "indexa_capital", sophtron: "sophtron" }
|
||||
|
||||
validates :name, uniqueness: { scope: [ :source ] }
|
||||
validates :source, presence: true
|
||||
|
||||
@@ -3,6 +3,7 @@ class RecurringTransaction < ApplicationRecord
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :account, optional: true
|
||||
belongs_to :destination_account, optional: true, class_name: "Account"
|
||||
belongs_to :merchant, optional: true
|
||||
|
||||
monetize :amount
|
||||
@@ -19,6 +20,7 @@ class RecurringTransaction < ApplicationRecord
|
||||
validates :occurrence_count, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
validate :merchant_or_name_present
|
||||
validate :amount_variance_consistency
|
||||
validate :transfer_endpoints_consistent
|
||||
|
||||
def merchant_or_name_present
|
||||
if merchant_id.blank? && name.blank?
|
||||
@@ -36,10 +38,50 @@ class RecurringTransaction < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# When this row represents a recurring transfer, both endpoints must be
|
||||
# present, belong to the same family, and not be the same account.
|
||||
def transfer_endpoints_consistent
|
||||
return if destination_account_id.blank?
|
||||
|
||||
if account_id.blank?
|
||||
errors.add(:account, "must be present on a recurring transfer")
|
||||
elsif account.blank?
|
||||
# account_id references a row that was destroyed. Mirror the
|
||||
# destination_account.blank? branch so the source side surfaces a
|
||||
# normal validation error too.
|
||||
errors.add(:account, "must exist")
|
||||
elsif destination_account.blank?
|
||||
# destination_account_id references a row that was destroyed (or never
|
||||
# existed). Surface as a normal validation error instead of letting
|
||||
# the FK fire on save.
|
||||
errors.add(:destination_account, "must exist")
|
||||
elsif account_id == destination_account_id
|
||||
errors.add(:destination_account, "cannot be the same as the source account")
|
||||
elsif account.family_id != destination_account.family_id
|
||||
errors.add(:destination_account, "must belong to the same family as the source account")
|
||||
end
|
||||
end
|
||||
|
||||
def transfer?
|
||||
destination_account_id.present?
|
||||
end
|
||||
|
||||
scope :for_family, ->(family) { where(family: family) }
|
||||
scope :expected_soon, -> { active.where("next_expected_date <= ?", 1.month.from_now) }
|
||||
scope :accessible_by, ->(user) {
|
||||
where(account_id: Account.accessible_by(user).select(:id)).or(where(account_id: nil))
|
||||
accessible_account_ids = Account.accessible_by(user).select(:id)
|
||||
# A recurring row is accessible when:
|
||||
# * its account_id is in the user's accessible set or null (legacy rows
|
||||
# with no account scoping survive), AND
|
||||
# * its destination_account_id is also accessible OR null (so a recurring
|
||||
# transfer never leaks into the list of a user without access to BOTH
|
||||
# endpoints).
|
||||
where(account_id: accessible_account_ids)
|
||||
.or(where(account_id: nil))
|
||||
.merge(
|
||||
where(destination_account_id: accessible_account_ids)
|
||||
.or(where(destination_account_id: nil))
|
||||
)
|
||||
}
|
||||
|
||||
# Class methods for identification and cleanup
|
||||
@@ -58,6 +100,44 @@ class RecurringTransaction < ApplicationRecord
|
||||
Cleaner.new(family).cleanup_stale_transactions
|
||||
end
|
||||
|
||||
# Create a manual recurring transfer from an existing Transfer pair.
|
||||
# Mirrors `create_from_transaction` but populates source + destination
|
||||
# accounts and skips merchant / variance lookup -- transfers are
|
||||
# account-pair-shaped, not merchant-shaped.
|
||||
def self.create_from_transfer(transfer)
|
||||
outflow_entry = transfer.outflow_transaction&.entry
|
||||
inflow_entry = transfer.inflow_transaction&.entry
|
||||
|
||||
raise ArgumentError, "transfer is missing one of its entries" unless outflow_entry && inflow_entry
|
||||
|
||||
source_account = outflow_entry.account
|
||||
destination_account = inflow_entry.account
|
||||
family = source_account.family
|
||||
|
||||
expected_day = outflow_entry.date.day
|
||||
next_expected = calculate_next_expected_date_from_today(expected_day)
|
||||
|
||||
create!(
|
||||
family: family,
|
||||
account: source_account,
|
||||
destination_account: destination_account,
|
||||
merchant_id: nil,
|
||||
# Transfer#name yields "Payment to ..." for liability destinations
|
||||
# and "Transfer to ..." otherwise, matching Transfer::Creator's
|
||||
# name_prefix logic so the recurring row reads consistently with
|
||||
# the originating Transfer.
|
||||
name: transfer.name,
|
||||
amount: outflow_entry.amount, # positive (outflow), per Sure sign convention
|
||||
currency: outflow_entry.currency,
|
||||
expected_day_of_month: expected_day,
|
||||
last_occurrence_date: outflow_entry.date,
|
||||
next_expected_date: next_expected,
|
||||
status: "active",
|
||||
occurrence_count: 1,
|
||||
manual: true
|
||||
)
|
||||
end
|
||||
|
||||
# Create a manual recurring transaction from an existing transaction
|
||||
# Automatically calculates amount variance from past 6 months of matching transactions
|
||||
def self.create_from_transaction(transaction, date_variance: 2)
|
||||
@@ -313,7 +393,10 @@ class RecurringTransaction < ApplicationRecord
|
||||
amount_min: expected_amount_min,
|
||||
amount_max: expected_amount_max,
|
||||
amount_avg: expected_amount_avg,
|
||||
has_variance: has_amount_variance?
|
||||
has_variance: has_amount_variance?,
|
||||
transfer: transfer?,
|
||||
source_account: account,
|
||||
destination_account: destination_account
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -7,11 +7,21 @@ class RecurringTransaction
|
||||
end
|
||||
|
||||
# Mark recurring transactions as inactive if they haven't occurred recently
|
||||
# Uses 2 months for automatic recurring, 6 months for manual recurring
|
||||
# Uses 2 months for automatic recurring, 6 months for manual recurring.
|
||||
#
|
||||
# Transfer rows (destination_account_id present) are skipped: their
|
||||
# `matching_transactions` helper looks at single-account name/amount
|
||||
# which never matches a Transfer pair, so the Cleaner would
|
||||
# incorrectly mark a still-recurring transfer inactive at the
|
||||
# 6-month threshold. Issue #1590 tracks pair-detection-aware
|
||||
# matching for recurring transfers.
|
||||
def cleanup_stale_transactions
|
||||
stale_count = 0
|
||||
|
||||
family.recurring_transactions.active.find_each do |recurring_transaction|
|
||||
family.recurring_transactions
|
||||
.active
|
||||
.where(destination_account_id: nil)
|
||||
.find_each do |recurring_transaction|
|
||||
next unless recurring_transaction.should_be_inactive?
|
||||
|
||||
# Determine threshold based on manual flag
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user