mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
Refactor family generator to centralize concern creation, improve SDK support, and add tests, views, jobs, and sync logic.
This commit is contained in:
@@ -90,23 +90,11 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
say "Created account model: #{account_model_path}", :green
|
||||
end
|
||||
|
||||
# Create Provided concern
|
||||
provided_concern_path = "app/models/#{file_name}_item/provided.rb"
|
||||
if File.exist?(provided_concern_path)
|
||||
say "Provided concern already exists: #{provided_concern_path}", :skip
|
||||
else
|
||||
template "provided_concern.rb.tt", provided_concern_path
|
||||
say "Created Provided concern: #{provided_concern_path}", :green
|
||||
end
|
||||
# Create item subdirectory models/concerns
|
||||
create_item_concerns
|
||||
|
||||
# Create Unlinking concern
|
||||
unlinking_concern_path = "app/models/#{file_name}_item/unlinking.rb"
|
||||
if File.exist?(unlinking_concern_path)
|
||||
say "Unlinking concern already exists: #{unlinking_concern_path}", :skip
|
||||
else
|
||||
template "unlinking_concern.rb.tt", unlinking_concern_path
|
||||
say "Created Unlinking concern: #{unlinking_concern_path}", :green
|
||||
end
|
||||
# Create account subdirectory models/concerns
|
||||
create_account_concerns
|
||||
|
||||
# Create Family Connectable concern
|
||||
connectable_concern_path = "app/models/family/#{file_name}_connectable.rb"
|
||||
@@ -118,6 +106,62 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
end
|
||||
end
|
||||
|
||||
def create_provider_sdk
|
||||
return if options[:skip_adapter]
|
||||
|
||||
sdk_path = "app/models/provider/#{file_name}.rb"
|
||||
if File.exist?(sdk_path)
|
||||
say "Provider SDK already exists: #{sdk_path}", :skip
|
||||
else
|
||||
template "provider_sdk.rb.tt", sdk_path
|
||||
say "Created Provider SDK: #{sdk_path}", :green
|
||||
end
|
||||
end
|
||||
|
||||
def create_background_jobs
|
||||
# Activities fetch job
|
||||
activities_job_path = "app/jobs/#{file_name}_activities_fetch_job.rb"
|
||||
if File.exist?(activities_job_path)
|
||||
say "Activities fetch job already exists: #{activities_job_path}", :skip
|
||||
else
|
||||
template "activities_fetch_job.rb.tt", activities_job_path
|
||||
say "Created Activities fetch job: #{activities_job_path}", :green
|
||||
end
|
||||
|
||||
# Connection cleanup job
|
||||
cleanup_job_path = "app/jobs/#{file_name}_connection_cleanup_job.rb"
|
||||
if File.exist?(cleanup_job_path)
|
||||
say "Connection cleanup job already exists: #{cleanup_job_path}", :skip
|
||||
else
|
||||
template "connection_cleanup_job.rb.tt", cleanup_job_path
|
||||
say "Created Connection cleanup job: #{cleanup_job_path}", :green
|
||||
end
|
||||
end
|
||||
|
||||
def create_tests
|
||||
# Create test directory
|
||||
test_dir = "test/models/#{file_name}_account"
|
||||
FileUtils.mkdir_p(test_dir) unless options[:pretend]
|
||||
|
||||
# DataHelpers test
|
||||
data_helpers_test_path = "#{test_dir}/data_helpers_test.rb"
|
||||
if File.exist?(data_helpers_test_path)
|
||||
say "DataHelpers test already exists: #{data_helpers_test_path}", :skip
|
||||
else
|
||||
template "data_helpers_test.rb.tt", data_helpers_test_path
|
||||
say "Created DataHelpers test: #{data_helpers_test_path}", :green
|
||||
end
|
||||
|
||||
# Processor test
|
||||
processor_test_path = "#{test_dir}/processor_test.rb"
|
||||
if File.exist?(processor_test_path)
|
||||
say "Processor test already exists: #{processor_test_path}", :skip
|
||||
else
|
||||
template "processor_test.rb.tt", processor_test_path
|
||||
say "Created Processor test: #{processor_test_path}", :green
|
||||
end
|
||||
end
|
||||
|
||||
def update_family_model
|
||||
family_model_path = "app/models/family.rb"
|
||||
return unless File.exist?(family_model_path)
|
||||
@@ -155,7 +199,7 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
new_include_line = "#{indentation}include #{connectable_module}\n"
|
||||
lines.insert(class_line_index + 1, new_include_line)
|
||||
|
||||
File.write(family_model_path, lines.join)
|
||||
write_file(family_model_path, lines.join)
|
||||
say "Added #{connectable_module} to Family model", :green
|
||||
else
|
||||
say "Could not find class declaration in Family model, please add manually: include #{connectable_module}", :yellow
|
||||
@@ -171,6 +215,41 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
"app/views/settings/providers/_#{file_name}_panel.html.erb"
|
||||
end
|
||||
|
||||
def create_item_views
|
||||
return if options[:skip_view]
|
||||
|
||||
# Create views directory
|
||||
view_dir = "app/views/#{file_name}_items"
|
||||
FileUtils.mkdir_p(view_dir) unless options[:pretend]
|
||||
|
||||
# Setup accounts dialog
|
||||
setup_accounts_path = "#{view_dir}/setup_accounts.html.erb"
|
||||
if File.exist?(setup_accounts_path)
|
||||
say "Setup accounts view already exists: #{setup_accounts_path}", :skip
|
||||
else
|
||||
template "setup_accounts.html.erb.tt", setup_accounts_path
|
||||
say "Created setup_accounts view: #{setup_accounts_path}", :green
|
||||
end
|
||||
|
||||
# Select existing account dialog
|
||||
select_existing_path = "#{view_dir}/select_existing_account.html.erb"
|
||||
if File.exist?(select_existing_path)
|
||||
say "Select existing account view already exists: #{select_existing_path}", :skip
|
||||
else
|
||||
template "select_existing_account.html.erb.tt", select_existing_path
|
||||
say "Created select_existing_account view: #{select_existing_path}", :green
|
||||
end
|
||||
|
||||
# Item partial for accounts index
|
||||
item_partial_path = "#{view_dir}/_#{file_name}_item.html.erb"
|
||||
if File.exist?(item_partial_path)
|
||||
say "Item partial already exists: #{item_partial_path}", :skip
|
||||
else
|
||||
template "item_partial.html.erb.tt", item_partial_path
|
||||
say "Created item partial: #{item_partial_path}", :green
|
||||
end
|
||||
end
|
||||
|
||||
def create_controller
|
||||
return if options[:skip_controller]
|
||||
|
||||
@@ -259,7 +338,7 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
# Remove trailing whitespace/newline, add || and new condition
|
||||
lines[last_condition_index] = last_condition_line.rstrip + " || \\\n#{indentation}#{new_condition}\n"
|
||||
|
||||
File.write(controller_path, lines.join)
|
||||
write_file(controller_path, lines.join)
|
||||
say "Added #{file_name} to provider exclusion list", :green
|
||||
else
|
||||
say "Could not find reject block boundaries in settings controller", :yellow
|
||||
@@ -295,7 +374,7 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
indentation = lines[last_items_index][/^\s*/]
|
||||
new_line = "#{indentation}#{items_var} = Current.family.#{file_name}_items.ordered.select(:id)\n"
|
||||
lines.insert(last_items_index + 1, new_line)
|
||||
File.write(controller_path, lines.join)
|
||||
write_file(controller_path, lines.join)
|
||||
say "Added #{items_var} instance variable", :green
|
||||
else
|
||||
say "Could not find existing @*_items assignments, please add manually: #{items_var} = Current.family.#{file_name}_items.ordered.select(:id)", :yellow
|
||||
@@ -357,7 +436,7 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
indentation = lines[last_items_index][/^\s*/]
|
||||
new_line = "#{indentation}#{items_var} = family.#{file_name}_items.ordered.includes(:syncs, :#{file_name}_accounts)\n"
|
||||
lines.insert(last_items_index + 1, new_line)
|
||||
File.write(controller_path, lines.join)
|
||||
write_file(controller_path, lines.join)
|
||||
say "Added #{items_var} to accounts controller index", :green
|
||||
else
|
||||
say "Could not find @*_items assignments in accounts controller", :yellow
|
||||
@@ -399,7 +478,7 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
"#{section.strip}\n\n <% if @manual_accounts.any? %>"
|
||||
)
|
||||
|
||||
File.write(view_path, content)
|
||||
write_file(view_path, content)
|
||||
say "Added #{class_name} section to accounts index view", :green
|
||||
end
|
||||
|
||||
@@ -412,7 +491,7 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
return
|
||||
end
|
||||
|
||||
FileUtils.mkdir_p(locale_dir)
|
||||
FileUtils.mkdir_p(locale_dir) unless options[:pretend]
|
||||
template "locale.en.yml.tt", locale_path
|
||||
say "Created locale file: #{locale_path}", :green
|
||||
end
|
||||
@@ -436,17 +515,36 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
say " - app/models/#{file_name}_account.rb"
|
||||
say " - app/models/#{file_name}_item/provided.rb"
|
||||
say " - app/models/#{file_name}_item/unlinking.rb"
|
||||
say " - app/models/#{file_name}_item/syncer.rb"
|
||||
say " - app/models/#{file_name}_item/importer.rb"
|
||||
say " - app/models/#{file_name}_account/data_helpers.rb"
|
||||
say " - app/models/#{file_name}_account/processor.rb"
|
||||
say " - app/models/#{file_name}_account/holdings_processor.rb"
|
||||
say " - app/models/#{file_name}_account/activities_processor.rb"
|
||||
say " - app/models/family/#{file_name}_connectable.rb"
|
||||
say " 🔌 Adapter: app/models/provider/#{file_name}_adapter.rb"
|
||||
say " 🔌 Provider:"
|
||||
say " - app/models/provider/#{file_name}.rb (SDK wrapper)"
|
||||
say " - app/models/provider/#{file_name}_adapter.rb"
|
||||
say " 🎮 Controller: app/controllers/#{file_name}_items_controller.rb"
|
||||
say " 🖼️ View: app/views/settings/providers/_#{file_name}_panel.html.erb"
|
||||
say " 🖼️ Views:"
|
||||
say " - app/views/settings/providers/_#{file_name}_panel.html.erb"
|
||||
say " - app/views/#{file_name}_items/setup_accounts.html.erb"
|
||||
say " - app/views/#{file_name}_items/select_existing_account.html.erb"
|
||||
say " - app/views/#{file_name}_items/_#{file_name}_item.html.erb"
|
||||
say " ⚡ Jobs:"
|
||||
say " - app/jobs/#{file_name}_activities_fetch_job.rb"
|
||||
say " - app/jobs/#{file_name}_connection_cleanup_job.rb"
|
||||
say " 🧪 Tests:"
|
||||
say " - test/models/#{file_name}_account/data_helpers_test.rb"
|
||||
say " - test/models/#{file_name}_account/processor_test.rb"
|
||||
say " 🛣️ Routes: Updated config/routes.rb"
|
||||
say " 🌐 Locale: config/locales/views/#{file_name}_items/en.yml"
|
||||
say " ⚙️ Settings: Updated controllers, views, and Family model"
|
||||
|
||||
if parsed_fields.any?
|
||||
say "\nCredential fields:", :cyan
|
||||
parsed_fields.each do |field|
|
||||
secret_flag = field[:secret] ? " 🔒 (encrypted)" : ""
|
||||
secret_flag = field[:secret] ? " (encrypted)" : ""
|
||||
default_flag = field[:default] ? " [default: #{field[:default]}]" : ""
|
||||
say " - #{field[:name]}: #{field[:type]}#{secret_flag}#{default_flag}"
|
||||
end
|
||||
@@ -454,7 +552,7 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
|
||||
say "\nDatabase tables created:", :cyan
|
||||
say " - #{table_name} (stores per-family credentials)"
|
||||
say " - #{file_name}_accounts (stores individual account data)"
|
||||
say " - #{file_name}_accounts (stores individual account data with investment support)"
|
||||
|
||||
say "\nNext steps:", :yellow
|
||||
say " 1. Run migrations:"
|
||||
@@ -462,20 +560,20 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
say ""
|
||||
say " 2. Implement the provider SDK in:"
|
||||
say " app/models/provider/#{file_name}.rb"
|
||||
say " - Add API client initialization"
|
||||
say " - Implement list_accounts, get_holdings, get_activities methods"
|
||||
say ""
|
||||
say " 3. Update #{class_name}Item::Provided concern:"
|
||||
say " app/models/#{file_name}_item/provided.rb"
|
||||
say " Implement the #{file_name}_provider method"
|
||||
say " 3. Customize the Importer class:"
|
||||
say " app/models/#{file_name}_item/importer.rb"
|
||||
say " - Map API response fields to account fields"
|
||||
say ""
|
||||
say " 4. Customize the adapter's build_provider method:"
|
||||
say " app/models/provider/#{file_name}_adapter.rb"
|
||||
say " 4. Customize the Processors:"
|
||||
say " app/models/#{file_name}_account/holdings_processor.rb"
|
||||
say " app/models/#{file_name}_account/activities_processor.rb"
|
||||
say " - Map provider field names to Sure fields"
|
||||
say " - Customize activity type mappings"
|
||||
say ""
|
||||
say " 5. Add any custom business logic:"
|
||||
say " - Import methods in #{class_name}Item"
|
||||
say " - Processing logic for accounts"
|
||||
say " - Sync strategies"
|
||||
say ""
|
||||
say " 6. Test the integration:"
|
||||
say " 5. Test the integration:"
|
||||
say " Visit /settings/providers and configure credentials"
|
||||
say ""
|
||||
say " 📚 See docs/PER_FAMILY_PROVIDER_GUIDE.md for detailed documentation"
|
||||
@@ -508,7 +606,7 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
/(enum :source, \{[^}]+)(})/,
|
||||
"\\1, #{file_name}: \"#{file_name}\"\\2"
|
||||
)
|
||||
File.write(model_path, updated_content)
|
||||
write_file(model_path, updated_content)
|
||||
say "Added #{file_name} to #{model_name} source enum", :green
|
||||
else
|
||||
say "Could not find source enum in #{model_name}", :yellow
|
||||
@@ -552,13 +650,106 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
|
||||
if method_end
|
||||
lines.insert(method_end, sync_stats_block)
|
||||
File.write(controller_path, lines.join)
|
||||
write_file(controller_path, lines.join)
|
||||
say "Added #{stats_var} to build_sync_stats_maps", :green
|
||||
else
|
||||
say "Could not find build_sync_stats_maps method end", :yellow
|
||||
end
|
||||
end
|
||||
|
||||
def create_item_concerns
|
||||
# Create item subdirectory
|
||||
item_dir = "app/models/#{file_name}_item"
|
||||
FileUtils.mkdir_p(item_dir) unless options[:pretend]
|
||||
|
||||
# Provided concern
|
||||
provided_concern_path = "#{item_dir}/provided.rb"
|
||||
if File.exist?(provided_concern_path)
|
||||
say "Provided concern already exists: #{provided_concern_path}", :skip
|
||||
else
|
||||
template "provided_concern.rb.tt", provided_concern_path
|
||||
say "Created Provided concern: #{provided_concern_path}", :green
|
||||
end
|
||||
|
||||
# Unlinking concern
|
||||
unlinking_concern_path = "#{item_dir}/unlinking.rb"
|
||||
if File.exist?(unlinking_concern_path)
|
||||
say "Unlinking concern already exists: #{unlinking_concern_path}", :skip
|
||||
else
|
||||
template "unlinking_concern.rb.tt", unlinking_concern_path
|
||||
say "Created Unlinking concern: #{unlinking_concern_path}", :green
|
||||
end
|
||||
|
||||
# Syncer class
|
||||
syncer_path = "#{item_dir}/syncer.rb"
|
||||
if File.exist?(syncer_path)
|
||||
say "Syncer class already exists: #{syncer_path}", :skip
|
||||
else
|
||||
template "syncer.rb.tt", syncer_path
|
||||
say "Created Syncer class: #{syncer_path}", :green
|
||||
end
|
||||
|
||||
# Importer class
|
||||
importer_path = "#{item_dir}/importer.rb"
|
||||
if File.exist?(importer_path)
|
||||
say "Importer class already exists: #{importer_path}", :skip
|
||||
else
|
||||
template "importer.rb.tt", importer_path
|
||||
say "Created Importer class: #{importer_path}", :green
|
||||
end
|
||||
end
|
||||
|
||||
def create_account_concerns
|
||||
# Create account subdirectory
|
||||
account_dir = "app/models/#{file_name}_account"
|
||||
FileUtils.mkdir_p(account_dir) unless options[:pretend]
|
||||
|
||||
# DataHelpers concern
|
||||
data_helpers_path = "#{account_dir}/data_helpers.rb"
|
||||
if File.exist?(data_helpers_path)
|
||||
say "DataHelpers concern already exists: #{data_helpers_path}", :skip
|
||||
else
|
||||
template "data_helpers.rb.tt", data_helpers_path
|
||||
say "Created DataHelpers concern: #{data_helpers_path}", :green
|
||||
end
|
||||
|
||||
# Processor class
|
||||
processor_path = "#{account_dir}/processor.rb"
|
||||
if File.exist?(processor_path)
|
||||
say "Processor class already exists: #{processor_path}", :skip
|
||||
else
|
||||
template "account_processor.rb.tt", processor_path
|
||||
say "Created Processor class: #{processor_path}", :green
|
||||
end
|
||||
|
||||
# HoldingsProcessor class
|
||||
holdings_processor_path = "#{account_dir}/holdings_processor.rb"
|
||||
if File.exist?(holdings_processor_path)
|
||||
say "HoldingsProcessor class already exists: #{holdings_processor_path}", :skip
|
||||
else
|
||||
template "holdings_processor.rb.tt", holdings_processor_path
|
||||
say "Created HoldingsProcessor class: #{holdings_processor_path}", :green
|
||||
end
|
||||
|
||||
# ActivitiesProcessor class
|
||||
activities_processor_path = "#{account_dir}/activities_processor.rb"
|
||||
if File.exist?(activities_processor_path)
|
||||
say "ActivitiesProcessor class already exists: #{activities_processor_path}", :skip
|
||||
else
|
||||
template "activities_processor.rb.tt", activities_processor_path
|
||||
say "Created ActivitiesProcessor class: #{activities_processor_path}", :green
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to write files while respecting --pretend flag
|
||||
def write_file(path, content)
|
||||
if options[:pretend]
|
||||
say "Would modify: #{path}", :yellow
|
||||
return
|
||||
end
|
||||
File.write(path, content)
|
||||
end
|
||||
|
||||
def table_name
|
||||
"#{file_name}_items"
|
||||
end
|
||||
|
||||
@@ -1,38 +1,60 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class <%= class_name %>Account < ApplicationRecord
|
||||
include CurrencyNormalizable
|
||||
include <%= class_name %>Account::DataHelpers
|
||||
|
||||
belongs_to :<%= file_name %>_item
|
||||
|
||||
# New association through account_providers
|
||||
# Association through account_providers
|
||||
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
|
||||
|
||||
# Scopes
|
||||
scope :with_linked, -> { joins(:account_provider) }
|
||||
scope :without_linked, -> { left_joins(:account_provider).where(account_providers: { id: nil }) }
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
# Callbacks
|
||||
after_destroy :enqueue_connection_cleanup
|
||||
|
||||
# Helper to get account using account_providers system
|
||||
def current_account
|
||||
account
|
||||
end
|
||||
|
||||
def upsert_<%= file_name %>_snapshot!(account_snapshot)
|
||||
# Convert to symbol keys or handle both string and symbol keys
|
||||
snapshot = account_snapshot.with_indifferent_access
|
||||
# Idempotently create or update AccountProvider link
|
||||
# CRITICAL: After creation, reload association to avoid stale nil
|
||||
def ensure_account_provider!(linked_account)
|
||||
return nil unless linked_account
|
||||
|
||||
provider = account_provider || build_account_provider
|
||||
provider.account = linked_account
|
||||
provider.save!
|
||||
|
||||
# Reload to clear cached nil value
|
||||
reload_account_provider
|
||||
account_provider
|
||||
end
|
||||
|
||||
def upsert_from_<%= file_name %>!(account_data)
|
||||
# Convert SDK object to hash if needed
|
||||
data = sdk_object_to_hash(account_data).with_indifferent_access
|
||||
|
||||
# Map <%= class_name %> field names to our field names
|
||||
# TODO: Customize this mapping based on your provider's API response
|
||||
update!(
|
||||
current_balance: snapshot[:balance] || snapshot[:current_balance],
|
||||
currency: parse_currency(snapshot[:currency]) || "USD",
|
||||
name: snapshot[:name],
|
||||
account_id: snapshot[:id]&.to_s,
|
||||
account_status: snapshot[:status],
|
||||
provider: snapshot[:provider],
|
||||
institution_metadata: {
|
||||
name: snapshot[:institution_name],
|
||||
logo: snapshot[:institution_logo]
|
||||
}.compact,
|
||||
raw_payload: account_snapshot
|
||||
<%= file_name %>_account_id: (data[:id] || data[:account_id])&.to_s,
|
||||
name: data[:name] || data[:account_name],
|
||||
current_balance: parse_decimal(data[:balance] || data[:current_balance]),
|
||||
currency: extract_currency(data, fallback: "USD"),
|
||||
account_status: data[:status] || data[:account_status],
|
||||
account_type: data[:type] || data[:account_type],
|
||||
provider: data[:provider] || data[:brokerage_name],
|
||||
institution_metadata: extract_institution_metadata(data),
|
||||
raw_payload: account_data
|
||||
)
|
||||
end
|
||||
|
||||
@@ -44,9 +66,48 @@ class <%= class_name %>Account < ApplicationRecord
|
||||
save!
|
||||
end
|
||||
|
||||
# Store holdings snapshot - return early if empty to avoid setting timestamps incorrectly
|
||||
def upsert_holdings_snapshot!(holdings_data)
|
||||
return if holdings_data.blank?
|
||||
|
||||
update!(
|
||||
raw_holdings_payload: holdings_data,
|
||||
last_holdings_sync: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
# Store activities snapshot - return early if empty to avoid setting timestamps incorrectly
|
||||
def upsert_activities_snapshot!(activities_data)
|
||||
return if activities_data.blank?
|
||||
|
||||
update!(
|
||||
raw_activities_payload: activities_data,
|
||||
last_activities_sync: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log_invalid_currency(currency_value)
|
||||
Rails.logger.warn("Invalid currency code '#{currency_value}' for <%= class_name %> account #{id}, defaulting to USD")
|
||||
end
|
||||
def extract_institution_metadata(data)
|
||||
{
|
||||
name: data[:institution_name] || data.dig(:institution, :name),
|
||||
logo: data[:institution_logo] || data.dig(:institution, :logo),
|
||||
domain: data[:institution_domain] || data.dig(:institution, :domain)
|
||||
}.compact
|
||||
end
|
||||
|
||||
def enqueue_connection_cleanup
|
||||
return unless <%= file_name %>_authorization_id.present?
|
||||
return unless <%= file_name %>_item
|
||||
|
||||
<%= class_name %>ConnectionCleanupJob.perform_later(
|
||||
<%= file_name %>_item_id: <%= file_name %>_item.id,
|
||||
authorization_id: <%= file_name %>_authorization_id,
|
||||
account_id: id
|
||||
)
|
||||
end
|
||||
|
||||
def log_invalid_currency(currency_value)
|
||||
Rails.logger.warn("Invalid currency code '#{currency_value}' for <%= class_name %> account #{id}, defaulting to USD")
|
||||
end
|
||||
end
|
||||
|
||||
112
lib/generators/provider/family/templates/account_processor.rb.tt
Normal file
112
lib/generators/provider/family/templates/account_processor.rb.tt
Normal file
@@ -0,0 +1,112 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class <%= class_name %>Account::Processor
|
||||
include <%= class_name %>Account::DataHelpers
|
||||
|
||||
attr_reader :<%= file_name %>_account
|
||||
|
||||
def initialize(<%= file_name %>_account)
|
||||
@<%= file_name %>_account = <%= file_name %>_account
|
||||
end
|
||||
|
||||
def process
|
||||
account = <%= file_name %>_account.current_account
|
||||
return unless account
|
||||
|
||||
Rails.logger.info "<%= class_name %>Account::Processor - Processing account #{<%= file_name %>_account.id} -> Sure account #{account.id}"
|
||||
|
||||
# Update account balance FIRST (before processing holdings/activities)
|
||||
# This creates the current_anchor valuation needed for reverse sync
|
||||
update_account_balance(account)
|
||||
|
||||
# Process holdings
|
||||
holdings_count = <%= file_name %>_account.raw_holdings_payload&.size || 0
|
||||
Rails.logger.info "<%= class_name %>Account::Processor - Holdings payload has #{holdings_count} items"
|
||||
|
||||
if <%= file_name %>_account.raw_holdings_payload.present?
|
||||
Rails.logger.info "<%= class_name %>Account::Processor - Processing holdings..."
|
||||
<%= class_name %>Account::HoldingsProcessor.new(<%= file_name %>_account).process
|
||||
else
|
||||
Rails.logger.warn "<%= class_name %>Account::Processor - No holdings payload to process"
|
||||
end
|
||||
|
||||
# Process activities (trades, dividends, etc.)
|
||||
activities_count = <%= file_name %>_account.raw_activities_payload&.size || 0
|
||||
Rails.logger.info "<%= class_name %>Account::Processor - Activities payload has #{activities_count} items"
|
||||
|
||||
if <%= file_name %>_account.raw_activities_payload.present?
|
||||
Rails.logger.info "<%= class_name %>Account::Processor - Processing activities..."
|
||||
<%= class_name %>Account::ActivitiesProcessor.new(<%= file_name %>_account).process
|
||||
else
|
||||
Rails.logger.warn "<%= class_name %>Account::Processor - No activities payload to process"
|
||||
end
|
||||
|
||||
# Trigger immediate UI refresh so entries appear in the activity feed
|
||||
account.broadcast_sync_complete
|
||||
Rails.logger.info "<%= class_name %>Account::Processor - Broadcast sync complete for account #{account.id}"
|
||||
|
||||
{ holdings_processed: holdings_count > 0, activities_processed: activities_count > 0 }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_account_balance(account)
|
||||
# Calculate total balance and cash balance from provider data
|
||||
total_balance = calculate_total_balance
|
||||
cash_balance = calculate_cash_balance
|
||||
|
||||
Rails.logger.info "<%= class_name %>Account::Processor - Balance update: total=#{total_balance}, cash=#{cash_balance}"
|
||||
|
||||
# Update the cached fields on the account
|
||||
account.assign_attributes(
|
||||
balance: total_balance,
|
||||
cash_balance: cash_balance,
|
||||
currency: <%= file_name %>_account.currency || account.currency
|
||||
)
|
||||
account.save!
|
||||
|
||||
# Create or update the current balance anchor valuation for linked accounts
|
||||
# This is critical for reverse sync to work correctly
|
||||
account.set_current_balance(total_balance)
|
||||
end
|
||||
|
||||
def calculate_total_balance
|
||||
# Calculate total from holdings + cash for accuracy
|
||||
holdings_value = calculate_holdings_value
|
||||
cash_value = <%= file_name %>_account.cash_balance || 0
|
||||
|
||||
calculated_total = holdings_value + cash_value
|
||||
|
||||
# Use calculated total if we have holdings, otherwise trust API value
|
||||
if holdings_value > 0
|
||||
Rails.logger.info "<%= class_name %>Account::Processor - Using calculated total: holdings=#{holdings_value} + cash=#{cash_value} = #{calculated_total}"
|
||||
calculated_total
|
||||
elsif <%= file_name %>_account.current_balance.present?
|
||||
Rails.logger.info "<%= class_name %>Account::Processor - Using API total: #{<%= file_name %>_account.current_balance}"
|
||||
<%= file_name %>_account.current_balance
|
||||
else
|
||||
calculated_total
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_cash_balance
|
||||
# Use provider's cash_balance directly
|
||||
# Note: Can be negative for margin accounts
|
||||
cash = <%= file_name %>_account.cash_balance
|
||||
Rails.logger.info "<%= class_name %>Account::Processor - Cash balance from API: #{cash.inspect}"
|
||||
cash || BigDecimal("0")
|
||||
end
|
||||
|
||||
def calculate_holdings_value
|
||||
holdings_data = <%= file_name %>_account.raw_holdings_payload || []
|
||||
return 0 if holdings_data.empty?
|
||||
|
||||
holdings_data.sum do |holding|
|
||||
data = holding.is_a?(Hash) ? holding.with_indifferent_access : {}
|
||||
# TODO: Customize field names based on your provider's format
|
||||
units = parse_decimal(data[:units] || data[:quantity]) || 0
|
||||
price = parse_decimal(data[:price]) || 0
|
||||
units * price
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,125 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class <%= class_name %>ActivitiesFetchJob < ApplicationJob
|
||||
include <%= class_name %>Account::DataHelpers
|
||||
include Sidekiq::Throttled::Job
|
||||
|
||||
queue_as :default
|
||||
|
||||
MAX_RETRIES = 6
|
||||
RETRY_INTERVAL = 10.seconds
|
||||
|
||||
sidekiq_options lock: :until_executed,
|
||||
lock_args_method: ->(args) { args.first },
|
||||
on_conflict: :log
|
||||
|
||||
def perform(<%= file_name %>_account, start_date: nil, retry_count: 0)
|
||||
@<%= file_name %>_account = <%= file_name %>_account
|
||||
@start_date = start_date || 3.years.ago.to_date
|
||||
@retry_count = retry_count
|
||||
|
||||
return clear_pending_flag unless valid_for_fetch?
|
||||
|
||||
fetch_and_process_activities
|
||||
rescue => e
|
||||
Rails.logger.error("<%= class_name %>ActivitiesFetchJob error: #{e.class} - #{e.message}")
|
||||
clear_pending_flag
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_for_fetch?
|
||||
return false unless @<%= file_name %>_account
|
||||
return false unless @<%= file_name %>_account.<%= file_name %>_item
|
||||
return false unless @<%= file_name %>_account.current_account
|
||||
true
|
||||
end
|
||||
|
||||
def fetch_and_process_activities
|
||||
activities = fetch_activities
|
||||
|
||||
if activities.blank? && @retry_count < MAX_RETRIES
|
||||
schedule_retry
|
||||
return
|
||||
end
|
||||
|
||||
if activities.any?
|
||||
merged = merge_activities(existing_activities, activities)
|
||||
@<%= file_name %>_account.upsert_activities_snapshot!(merged)
|
||||
@<%= file_name %>_account.update!(last_activities_sync: Time.current)
|
||||
|
||||
<%= class_name %>Account::ActivitiesProcessor.new(@<%= file_name %>_account).process
|
||||
end
|
||||
|
||||
clear_pending_flag
|
||||
broadcast_updates
|
||||
end
|
||||
|
||||
def fetch_activities
|
||||
provider = @<%= file_name %>_account.<%= file_name %>_item.<%= file_name %>_provider
|
||||
credentials = @<%= file_name %>_account.<%= file_name %>_item.<%= file_name %>_credentials
|
||||
|
||||
return [] unless provider && credentials
|
||||
|
||||
# TODO: Implement API call to fetch activities
|
||||
# provider.get_activities(
|
||||
# account_id: @<%= file_name %>_account.<%= file_name %>_account_id,
|
||||
# start_date: @start_date,
|
||||
# end_date: Date.current,
|
||||
# **credentials
|
||||
# )
|
||||
[]
|
||||
rescue => e
|
||||
Rails.logger.error("<%= class_name %>ActivitiesFetchJob - API error: #{e.message}")
|
||||
[]
|
||||
end
|
||||
|
||||
def existing_activities
|
||||
@<%= file_name %>_account.raw_activities_payload || []
|
||||
end
|
||||
|
||||
def merge_activities(existing, new_activities)
|
||||
by_id = {}
|
||||
existing.each { |a| by_id[activity_key(a)] = a }
|
||||
new_activities.each do |a|
|
||||
activity_hash = sdk_object_to_hash(a)
|
||||
by_id[activity_key(activity_hash)] = activity_hash
|
||||
end
|
||||
by_id.values
|
||||
end
|
||||
|
||||
def activity_key(activity)
|
||||
activity = activity.with_indifferent_access if activity.is_a?(Hash)
|
||||
activity[:id] || activity["id"] ||
|
||||
[ activity[:date], activity[:type], activity[:amount], activity[:symbol] ].join("-")
|
||||
end
|
||||
|
||||
def schedule_retry
|
||||
Rails.logger.info(
|
||||
"<%= class_name %>ActivitiesFetchJob - No activities found, scheduling retry " \
|
||||
"#{@retry_count + 1}/#{MAX_RETRIES} in #{RETRY_INTERVAL.to_i}s"
|
||||
)
|
||||
|
||||
self.class.set(wait: RETRY_INTERVAL).perform_later(
|
||||
@<%= file_name %>_account,
|
||||
start_date: @start_date,
|
||||
retry_count: @retry_count + 1
|
||||
)
|
||||
end
|
||||
|
||||
def clear_pending_flag
|
||||
@<%= file_name %>_account.update!(activities_fetch_pending: false)
|
||||
end
|
||||
|
||||
def broadcast_updates
|
||||
@<%= file_name %>_account.current_account&.broadcast_sync_complete
|
||||
@<%= file_name %>_account.<%= file_name %>_item&.broadcast_replace_to(
|
||||
@<%= file_name %>_account.<%= file_name %>_item.family,
|
||||
target: "<%= file_name %>_item_#{@<%= file_name %>_account.<%= file_name %>_item.id}",
|
||||
partial: "<%= file_name %>_items/<%= file_name %>_item"
|
||||
)
|
||||
rescue => e
|
||||
Rails.logger.warn("<%= class_name %>ActivitiesFetchJob - Broadcast failed: #{e.message}")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,228 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class <%= class_name %>Account::ActivitiesProcessor
|
||||
include <%= class_name %>Account::DataHelpers
|
||||
|
||||
# Map provider activity types to Sure activity labels
|
||||
# TODO: Customize for your provider's activity types
|
||||
ACTIVITY_TYPE_TO_LABEL = {
|
||||
"BUY" => "Buy",
|
||||
"SELL" => "Sell",
|
||||
"DIVIDEND" => "Dividend",
|
||||
"DIV" => "Dividend",
|
||||
"CONTRIBUTION" => "Contribution",
|
||||
"WITHDRAWAL" => "Withdrawal",
|
||||
"TRANSFER_IN" => "Transfer",
|
||||
"TRANSFER_OUT" => "Transfer",
|
||||
"TRANSFER" => "Transfer",
|
||||
"INTEREST" => "Interest",
|
||||
"FEE" => "Fee",
|
||||
"TAX" => "Fee",
|
||||
"REINVEST" => "Reinvestment",
|
||||
"SPLIT" => "Other",
|
||||
"MERGER" => "Other",
|
||||
"OTHER" => "Other"
|
||||
}.freeze
|
||||
|
||||
# Activity types that result in Trade records (involves securities)
|
||||
TRADE_TYPES = %w[BUY SELL REINVEST].freeze
|
||||
|
||||
# Sell-side activity types (quantity should be negative)
|
||||
SELL_SIDE_TYPES = %w[SELL].freeze
|
||||
|
||||
# Activity types that result in Transaction records (cash movements)
|
||||
CASH_TYPES = %w[DIVIDEND DIV CONTRIBUTION WITHDRAWAL TRANSFER_IN TRANSFER_OUT TRANSFER INTEREST FEE TAX].freeze
|
||||
|
||||
def initialize(<%= file_name %>_account)
|
||||
@<%= file_name %>_account = <%= file_name %>_account
|
||||
end
|
||||
|
||||
def process
|
||||
activities_data = @<%= file_name %>_account.raw_activities_payload
|
||||
return { trades: 0, transactions: 0 } if activities_data.blank?
|
||||
|
||||
Rails.logger.info "<%= class_name %>Account::ActivitiesProcessor - Processing #{activities_data.size} activities"
|
||||
|
||||
@trades_count = 0
|
||||
@transactions_count = 0
|
||||
|
||||
activities_data.each do |activity_data|
|
||||
process_activity(activity_data.with_indifferent_access)
|
||||
rescue => e
|
||||
Rails.logger.error "<%= class_name %>Account::ActivitiesProcessor - Failed to process activity: #{e.message}"
|
||||
Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace
|
||||
end
|
||||
|
||||
{ trades: @trades_count, transactions: @transactions_count }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account
|
||||
@<%= file_name %>_account.current_account
|
||||
end
|
||||
|
||||
def import_adapter
|
||||
@import_adapter ||= Account::ProviderImportAdapter.new(account)
|
||||
end
|
||||
|
||||
def process_activity(data)
|
||||
# TODO: Customize activity type field name
|
||||
activity_type = (data[:type] || data[:activity_type])&.upcase
|
||||
return if activity_type.blank?
|
||||
|
||||
# Get external ID for deduplication
|
||||
external_id = (data[:id] || data[:transaction_id]).to_s
|
||||
return if external_id.blank?
|
||||
|
||||
Rails.logger.info "<%= class_name %>Account::ActivitiesProcessor - Processing activity: type=#{activity_type}, id=#{external_id}"
|
||||
|
||||
# Determine if this is a trade or cash activity
|
||||
if trade_activity?(activity_type)
|
||||
process_trade(data, activity_type, external_id)
|
||||
else
|
||||
process_cash_activity(data, activity_type, external_id)
|
||||
end
|
||||
end
|
||||
|
||||
def trade_activity?(activity_type)
|
||||
TRADE_TYPES.include?(activity_type)
|
||||
end
|
||||
|
||||
def process_trade(data, activity_type, external_id)
|
||||
# TODO: Customize ticker extraction based on your provider's format
|
||||
ticker = data[:symbol] || data[:ticker]
|
||||
if ticker.blank?
|
||||
Rails.logger.warn "<%= class_name %>Account::ActivitiesProcessor - Skipping trade without symbol: #{external_id}"
|
||||
return
|
||||
end
|
||||
|
||||
# Resolve security
|
||||
security = resolve_security(ticker, data)
|
||||
return unless security
|
||||
|
||||
# TODO: Customize field names based on your provider's format
|
||||
quantity = parse_decimal(data[:units]) || parse_decimal(data[:quantity])
|
||||
price = parse_decimal(data[:price])
|
||||
|
||||
if quantity.nil?
|
||||
Rails.logger.warn "<%= class_name %>Account::ActivitiesProcessor - Skipping trade without quantity: #{external_id}"
|
||||
return
|
||||
end
|
||||
|
||||
# Determine sign based on activity type (sell-side should be negative)
|
||||
quantity = if SELL_SIDE_TYPES.include?(activity_type)
|
||||
-quantity.abs
|
||||
else
|
||||
quantity.abs
|
||||
end
|
||||
|
||||
# Calculate amount
|
||||
amount = if price
|
||||
quantity * price
|
||||
else
|
||||
parse_decimal(data[:amount]) || parse_decimal(data[:trade_value])
|
||||
end
|
||||
|
||||
if amount.nil?
|
||||
Rails.logger.warn "<%= class_name %>Account::ActivitiesProcessor - Skipping trade without amount: #{external_id}"
|
||||
return
|
||||
end
|
||||
|
||||
# Get the activity date
|
||||
# TODO: Customize date field names
|
||||
activity_date = parse_date(data[:settlement_date]) ||
|
||||
parse_date(data[:trade_date]) ||
|
||||
parse_date(data[:date]) ||
|
||||
Date.current
|
||||
|
||||
currency = extract_currency(data, fallback: account.currency)
|
||||
description = data[:description] || "#{activity_type} #{ticker}"
|
||||
|
||||
Rails.logger.info "<%= class_name %>Account::ActivitiesProcessor - Importing trade: #{ticker} qty=#{quantity} price=#{price} date=#{activity_date}"
|
||||
|
||||
result = import_adapter.import_trade(
|
||||
external_id: external_id,
|
||||
security: security,
|
||||
quantity: quantity,
|
||||
price: price,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: activity_date,
|
||||
name: description,
|
||||
source: "<%= file_name %>",
|
||||
activity_label: label_from_type(activity_type)
|
||||
)
|
||||
@trades_count += 1 if result
|
||||
end
|
||||
|
||||
def process_cash_activity(data, activity_type, external_id)
|
||||
# TODO: Customize amount field names
|
||||
amount = parse_decimal(data[:amount]) ||
|
||||
parse_decimal(data[:net_amount])
|
||||
return if amount.nil? || amount.zero?
|
||||
|
||||
# Get the activity date
|
||||
# TODO: Customize date field names
|
||||
activity_date = parse_date(data[:settlement_date]) ||
|
||||
parse_date(data[:trade_date]) ||
|
||||
parse_date(data[:date]) ||
|
||||
Date.current
|
||||
|
||||
# Build description
|
||||
symbol = data[:symbol] || data[:ticker]
|
||||
description = data[:description] || build_description(activity_type, symbol)
|
||||
|
||||
# Normalize amount sign for certain activity types
|
||||
amount = normalize_cash_amount(amount, activity_type)
|
||||
|
||||
currency = extract_currency(data, fallback: account.currency)
|
||||
|
||||
Rails.logger.info "<%= class_name %>Account::ActivitiesProcessor - Importing cash activity: type=#{activity_type} amount=#{amount} date=#{activity_date}"
|
||||
|
||||
result = import_adapter.import_transaction(
|
||||
external_id: external_id,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: activity_date,
|
||||
name: description,
|
||||
source: "<%= file_name %>",
|
||||
investment_activity_label: label_from_type(activity_type)
|
||||
)
|
||||
@transactions_count += 1 if result
|
||||
end
|
||||
|
||||
def normalize_cash_amount(amount, activity_type)
|
||||
case activity_type
|
||||
when "WITHDRAWAL", "TRANSFER_OUT", "FEE", "TAX"
|
||||
-amount.abs # These should be negative (money out)
|
||||
when "CONTRIBUTION", "TRANSFER_IN", "DIVIDEND", "DIV", "INTEREST"
|
||||
amount.abs # These should be positive (money in)
|
||||
else
|
||||
amount
|
||||
end
|
||||
end
|
||||
|
||||
def build_description(activity_type, symbol)
|
||||
type_label = label_from_type(activity_type)
|
||||
if symbol.present?
|
||||
"#{type_label} - #{symbol}"
|
||||
else
|
||||
type_label
|
||||
end
|
||||
end
|
||||
|
||||
def label_from_type(activity_type)
|
||||
normalized_type = activity_type&.upcase
|
||||
label = ACTIVITY_TYPE_TO_LABEL[normalized_type]
|
||||
|
||||
if label.nil? && normalized_type.present?
|
||||
Rails.logger.warn(
|
||||
"<%= class_name %>Account::ActivitiesProcessor - Unmapped activity type '#{normalized_type}' " \
|
||||
"for account #{@<%= file_name %>_account.id}. Consider adding to ACTIVITY_TYPE_TO_LABEL mapping."
|
||||
)
|
||||
end
|
||||
|
||||
label || "Other"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class <%= class_name %>ConnectionCleanupJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(<%= file_name %>_item_id:, authorization_id:, account_id:)
|
||||
Rails.logger.info(
|
||||
"<%= class_name %>ConnectionCleanupJob - Cleaning up connection #{authorization_id} " \
|
||||
"for former account #{account_id}"
|
||||
)
|
||||
|
||||
<%= file_name %>_item = <%= class_name %>Item.find_by(id: <%= file_name %>_item_id)
|
||||
return unless <%= file_name %>_item
|
||||
|
||||
# Check if other accounts still use this connection
|
||||
if <%= file_name %>_item.<%= file_name %>_accounts
|
||||
.where(<%= file_name %>_authorization_id: authorization_id)
|
||||
.exists?
|
||||
Rails.logger.info("<%= class_name %>ConnectionCleanupJob - Connection still in use, skipping")
|
||||
return
|
||||
end
|
||||
|
||||
# Delete from provider API
|
||||
delete_connection(<%= file_name %>_item, authorization_id)
|
||||
|
||||
Rails.logger.info("<%= class_name %>ConnectionCleanupJob - Connection #{authorization_id} deleted")
|
||||
rescue => e
|
||||
Rails.logger.warn(
|
||||
"<%= class_name %>ConnectionCleanupJob - Failed: #{e.class} - #{e.message}"
|
||||
)
|
||||
# Don't raise - cleanup failures shouldn't block other operations
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delete_connection(<%= file_name %>_item, authorization_id)
|
||||
provider = <%= file_name %>_item.<%= file_name %>_provider
|
||||
return unless provider
|
||||
|
||||
credentials = <%= file_name %>_item.<%= file_name %>_credentials
|
||||
return unless credentials
|
||||
|
||||
# TODO: Implement API call to delete connection
|
||||
# Example:
|
||||
# provider.delete_connection(
|
||||
# authorization_id: authorization_id,
|
||||
# **credentials
|
||||
# )
|
||||
nil # Placeholder until provider.delete_connection is implemented
|
||||
rescue => e
|
||||
Rails.logger.warn(
|
||||
"<%= class_name %>ConnectionCleanupJob - API delete failed: #{e.message}"
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class <%= class_name %>ItemsController < ApplicationController
|
||||
before_action :set_<%= file_name %>_item, only: [:show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup]
|
||||
before_action :set_<%= file_name %>_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
|
||||
|
||||
def index
|
||||
@<%= table_name %> = Current.family.<%= table_name %>.ordered
|
||||
@@ -97,57 +99,226 @@ class <%= class_name %>ItemsController < ApplicationController
|
||||
end
|
||||
|
||||
# Collection actions for account linking flow
|
||||
# TODO: Implement these when you have the provider SDK ready
|
||||
|
||||
def preload_accounts
|
||||
# TODO: Fetch accounts from provider API and cache them
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
# Trigger a sync to fetch accounts from the provider
|
||||
<%= file_name %>_item = Current.family.<%= file_name %>_items.first
|
||||
unless <%= file_name %>_item&.credentials_configured?
|
||||
redirect_to settings_providers_path, alert: t(".no_credentials_configured")
|
||||
return
|
||||
end
|
||||
|
||||
<%= file_name %>_item.sync_later unless <%= file_name %>_item.syncing?
|
||||
redirect_to select_accounts_<%= file_name %>_items_path(accountable_type: params[:accountable_type], return_to: params[:return_to])
|
||||
end
|
||||
|
||||
def select_accounts
|
||||
# TODO: Show UI to select which accounts to link
|
||||
@accountable_type = params[:accountable_type]
|
||||
@return_to = params[:return_to]
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
|
||||
<%= file_name %>_item = Current.family.<%= file_name %>_items.first
|
||||
unless <%= file_name %>_item&.credentials_configured?
|
||||
redirect_to settings_providers_path, alert: t(".no_credentials_configured")
|
||||
return
|
||||
end
|
||||
|
||||
@<%= file_name %>_accounts = <%= file_name %>_item.<%= file_name %>_accounts
|
||||
.left_joins(:account_provider)
|
||||
.where(account_providers: { id: nil })
|
||||
.order(:name)
|
||||
end
|
||||
|
||||
def link_accounts
|
||||
# TODO: Link selected accounts
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
<%= file_name %>_item = Current.family.<%= file_name %>_items.first
|
||||
unless <%= file_name %>_item&.credentials_configured?
|
||||
redirect_to settings_providers_path, alert: t(".no_api_key")
|
||||
return
|
||||
end
|
||||
|
||||
selected_ids = params[:selected_account_ids] || []
|
||||
if selected_ids.empty?
|
||||
redirect_to select_accounts_<%= file_name %>_items_path, alert: t(".no_accounts_selected")
|
||||
return
|
||||
end
|
||||
|
||||
accountable_type = params[:accountable_type] || "Depository"
|
||||
created_count = 0
|
||||
already_linked_count = 0
|
||||
invalid_count = 0
|
||||
|
||||
<%= file_name %>_item.<%= file_name %>_accounts.where(id: selected_ids).find_each do |<%= file_name %>_account|
|
||||
# Skip if already linked
|
||||
if <%= file_name %>_account.account_provider.present?
|
||||
already_linked_count += 1
|
||||
next
|
||||
end
|
||||
|
||||
# Skip if invalid name
|
||||
if <%= file_name %>_account.name.blank?
|
||||
invalid_count += 1
|
||||
next
|
||||
end
|
||||
|
||||
# Create Sure account and link
|
||||
link_<%= file_name %>_account(<%= file_name %>_account, accountable_type)
|
||||
created_count += 1
|
||||
rescue => e
|
||||
Rails.logger.error "<%= class_name %>ItemsController#link_accounts - Failed to link account: #{e.message}"
|
||||
end
|
||||
|
||||
if created_count > 0
|
||||
<%= file_name %>_item.sync_later unless <%= file_name %>_item.syncing?
|
||||
redirect_to accounts_path, notice: t(".success", count: created_count)
|
||||
else
|
||||
redirect_to select_accounts_<%= file_name %>_items_path, alert: t(".link_failed")
|
||||
end
|
||||
end
|
||||
|
||||
def select_existing_account
|
||||
# TODO: Show UI to link an existing account to provider
|
||||
@account_id = params[:account_id]
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
@<%= file_name %>_item = Current.family.<%= file_name %>_items.first
|
||||
|
||||
unless @<%= file_name %>_item&.credentials_configured?
|
||||
redirect_to settings_providers_path, alert: t(".no_credentials_configured")
|
||||
return
|
||||
end
|
||||
|
||||
@<%= file_name %>_accounts = @<%= file_name %>_item.<%= file_name %>_accounts
|
||||
.left_joins(:account_provider)
|
||||
.where(account_providers: { id: nil })
|
||||
.order(:name)
|
||||
end
|
||||
|
||||
def link_existing_account
|
||||
# TODO: Link an existing account to a provider account
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
account = Current.family.accounts.find(params[:account_id])
|
||||
<%= file_name %>_item = Current.family.<%= file_name %>_items.first
|
||||
|
||||
unless <%= file_name %>_item&.credentials_configured?
|
||||
redirect_to settings_providers_path, alert: t(".no_api_key")
|
||||
return
|
||||
end
|
||||
|
||||
<%= file_name %>_account = <%= file_name %>_item.<%= file_name %>_accounts.find(params[:<%= file_name %>_account_id])
|
||||
|
||||
if <%= file_name %>_account.account_provider.present?
|
||||
redirect_to account_path(account), alert: t(".provider_account_already_linked")
|
||||
return
|
||||
end
|
||||
|
||||
<%= file_name %>_account.ensure_account_provider!(account)
|
||||
<%= file_name %>_item.sync_later unless <%= file_name %>_item.syncing?
|
||||
|
||||
redirect_to account_path(account), notice: t(".success", account_name: account.name)
|
||||
end
|
||||
|
||||
def setup_accounts
|
||||
# TODO: Show account setup UI
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
@unlinked_accounts = @<%= file_name %>_item.unlinked_<%= file_name %>_accounts.order(:name)
|
||||
|
||||
if @unlinked_accounts.empty?
|
||||
redirect_to accounts_path, notice: t(".all_accounts_linked")
|
||||
end
|
||||
end
|
||||
|
||||
def complete_account_setup
|
||||
# TODO: Complete the account setup process
|
||||
redirect_to settings_providers_path, alert: "Not implemented yet"
|
||||
account_configs = params[:accounts] || {}
|
||||
|
||||
if account_configs.empty?
|
||||
redirect_to setup_accounts_<%= file_name %>_item_path(@<%= file_name %>_item), alert: t(".no_accounts")
|
||||
return
|
||||
end
|
||||
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
account_configs.each do |<%= file_name %>_account_id, config|
|
||||
next if config[:account_type] == "skip"
|
||||
|
||||
<%= file_name %>_account = @<%= file_name %>_item.<%= file_name %>_accounts.find_by(id: <%= file_name %>_account_id)
|
||||
next unless <%= file_name %>_account
|
||||
next if <%= file_name %>_account.account_provider.present?
|
||||
|
||||
accountable_type = infer_accountable_type(config[:account_type], config[:subtype])
|
||||
account = create_account_from_<%= file_name %>(<%= file_name %>_account, accountable_type, config)
|
||||
|
||||
if account&.persisted?
|
||||
<%= file_name %>_account.ensure_account_provider!(account)
|
||||
<%= file_name %>_account.update!(sync_start_date: config[:sync_start_date]) if config[:sync_start_date].present?
|
||||
created_count += 1
|
||||
else
|
||||
skipped_count += 1
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "<%= class_name %>ItemsController#complete_account_setup - Error: #{e.message}"
|
||||
skipped_count += 1
|
||||
end
|
||||
|
||||
if created_count > 0
|
||||
@<%= file_name %>_item.sync_later unless @<%= file_name %>_item.syncing?
|
||||
redirect_to accounts_path, notice: t(".success", count: created_count)
|
||||
elsif skipped_count > 0 && created_count == 0
|
||||
redirect_to accounts_path, notice: t(".all_skipped")
|
||||
else
|
||||
redirect_to setup_accounts_<%= file_name %>_item_path(@<%= file_name %>_item), alert: t(".creation_failed", error: "Unknown error")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_<%= file_name %>_item
|
||||
@<%= file_name %>_item = Current.family.<%= table_name %>.find(params[:id])
|
||||
end
|
||||
def set_<%= file_name %>_item
|
||||
@<%= file_name %>_item = Current.family.<%= table_name %>.find(params[:id])
|
||||
end
|
||||
|
||||
def <%= file_name %>_item_params
|
||||
params.require(:<%= file_name %>_item).permit(
|
||||
:name,
|
||||
:sync_start_date<% parsed_fields.each do |field| %>,
|
||||
:<%= field[:name] %><% end %>
|
||||
)
|
||||
end
|
||||
def <%= file_name %>_item_params
|
||||
params.require(:<%= file_name %>_item).permit(
|
||||
:name,
|
||||
:sync_start_date<% parsed_fields.each do |field| %>,
|
||||
:<%= field[:name] %><% end %>
|
||||
)
|
||||
end
|
||||
|
||||
def link_<%= file_name %>_account(<%= file_name %>_account, accountable_type)
|
||||
account = Current.family.accounts.create!(
|
||||
name: <%= file_name %>_account.name,
|
||||
balance: <%= file_name %>_account.current_balance || 0,
|
||||
currency: <%= file_name %>_account.currency || "USD",
|
||||
accountable: accountable_type.constantize.new
|
||||
)
|
||||
|
||||
<%= file_name %>_account.ensure_account_provider!(account)
|
||||
account
|
||||
end
|
||||
|
||||
def create_account_from_<%= file_name %>(<%= file_name %>_account, accountable_type, config)
|
||||
accountable_class = accountable_type.constantize
|
||||
accountable_attrs = {}
|
||||
|
||||
# Set subtype if the accountable supports it
|
||||
if config[:subtype].present? && accountable_class.respond_to?(:subtypes)
|
||||
accountable_attrs[:subtype] = config[:subtype]
|
||||
end
|
||||
|
||||
Current.family.accounts.create!(
|
||||
name: <%= file_name %>_account.name,
|
||||
balance: config[:balance].present? ? config[:balance].to_d : (<%= file_name %>_account.current_balance || 0),
|
||||
currency: <%= file_name %>_account.currency || "USD",
|
||||
accountable: accountable_class.new(accountable_attrs)
|
||||
)
|
||||
end
|
||||
|
||||
def infer_accountable_type(account_type, subtype = nil)
|
||||
case account_type&.downcase
|
||||
when "depository"
|
||||
"Depository"
|
||||
when "credit_card"
|
||||
"CreditCard"
|
||||
when "investment"
|
||||
"Investment"
|
||||
when "loan"
|
||||
"Loan"
|
||||
when "other_asset"
|
||||
"OtherAsset"
|
||||
else
|
||||
"Depository"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
156
lib/generators/provider/family/templates/data_helpers.rb.tt
Normal file
156
lib/generators/provider/family/templates/data_helpers.rb.tt
Normal file
@@ -0,0 +1,156 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module <%= class_name %>Account::DataHelpers
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
private
|
||||
|
||||
# Convert SDK objects to hashes via JSON round-trip
|
||||
# Many SDKs return objects that don't have proper #to_h methods
|
||||
def sdk_object_to_hash(obj)
|
||||
return obj if obj.is_a?(Hash)
|
||||
|
||||
if obj.respond_to?(:to_json)
|
||||
JSON.parse(obj.to_json)
|
||||
elsif obj.respond_to?(:to_h)
|
||||
obj.to_h
|
||||
else
|
||||
obj
|
||||
end
|
||||
rescue JSON::ParserError, TypeError
|
||||
obj.respond_to?(:to_h) ? obj.to_h : {}
|
||||
end
|
||||
|
||||
def parse_decimal(value)
|
||||
return nil if value.nil?
|
||||
|
||||
case value
|
||||
when BigDecimal
|
||||
value
|
||||
when String
|
||||
BigDecimal(value)
|
||||
when Numeric
|
||||
BigDecimal(value.to_s)
|
||||
else
|
||||
nil
|
||||
end
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error("<%= class_name %>Account::DataHelpers - Failed to parse decimal value: #{value.inspect} - #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def parse_date(date_value)
|
||||
return nil if date_value.nil?
|
||||
|
||||
case date_value
|
||||
when Date
|
||||
date_value
|
||||
when String
|
||||
# Use Time.zone.parse for external timestamps (Rails timezone guidelines)
|
||||
Time.zone.parse(date_value)&.to_date
|
||||
when Time, DateTime, ActiveSupport::TimeWithZone
|
||||
date_value.to_date
|
||||
else
|
||||
nil
|
||||
end
|
||||
rescue ArgumentError, TypeError => e
|
||||
Rails.logger.error("<%= class_name %>Account::DataHelpers - Failed to parse date: #{date_value.inspect} - #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
# Find or create security with race condition handling
|
||||
def resolve_security(symbol, symbol_data = {})
|
||||
ticker = symbol.to_s.upcase.strip
|
||||
return nil if ticker.blank?
|
||||
|
||||
security = Security.find_by(ticker: ticker)
|
||||
|
||||
# If security exists but has a bad name (looks like a hash), update it
|
||||
if security && security.name&.start_with?("{")
|
||||
new_name = extract_security_name(symbol_data, ticker)
|
||||
Rails.logger.info "<%= class_name %>Account::DataHelpers - Fixing security name: #{security.name.first(50)}... -> #{new_name}"
|
||||
security.update!(name: new_name)
|
||||
end
|
||||
|
||||
return security if security
|
||||
|
||||
# Create new security
|
||||
security_name = extract_security_name(symbol_data, ticker)
|
||||
|
||||
Rails.logger.info "<%= class_name %>Account::DataHelpers - Creating security: ticker=#{ticker}, name=#{security_name}"
|
||||
|
||||
Security.create!(
|
||||
ticker: ticker,
|
||||
name: security_name,
|
||||
exchange_mic: extract_exchange(symbol_data),
|
||||
country_code: extract_country_code(symbol_data)
|
||||
)
|
||||
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique => e
|
||||
# Handle race condition - another process may have created it
|
||||
Rails.logger.error "<%= class_name %>Account::DataHelpers - Failed to create security #{ticker}: #{e.message}"
|
||||
Security.find_by(ticker: ticker)
|
||||
end
|
||||
|
||||
def extract_security_name(symbol_data, fallback_ticker)
|
||||
symbol_data = symbol_data.with_indifferent_access if symbol_data.respond_to?(:with_indifferent_access)
|
||||
|
||||
# Try various paths where the name might be
|
||||
name = symbol_data[:name] || symbol_data[:description]
|
||||
|
||||
# If description is missing or looks like a type description, use ticker
|
||||
if name.blank? || name.is_a?(Hash) || name =~ /^(COMMON STOCK|CRYPTOCURRENCY|ETF|MUTUAL FUND)$/i
|
||||
name = fallback_ticker
|
||||
end
|
||||
|
||||
# Titleize for readability if it's all caps
|
||||
name = name.titleize if name == name.upcase && name.length > 4
|
||||
|
||||
name
|
||||
end
|
||||
|
||||
def extract_exchange(symbol_data)
|
||||
symbol_data = symbol_data.with_indifferent_access if symbol_data.respond_to?(:with_indifferent_access)
|
||||
|
||||
exchange = symbol_data[:exchange]
|
||||
return nil unless exchange.is_a?(Hash)
|
||||
|
||||
exchange.with_indifferent_access[:mic_code] || exchange.with_indifferent_access[:id]
|
||||
end
|
||||
|
||||
def extract_country_code(symbol_data)
|
||||
symbol_data = symbol_data.with_indifferent_access if symbol_data.respond_to?(:with_indifferent_access)
|
||||
|
||||
# Try to extract country from currency or exchange
|
||||
currency = symbol_data[:currency]
|
||||
currency = currency.dig(:code) if currency.is_a?(Hash)
|
||||
|
||||
case currency
|
||||
when "USD"
|
||||
"US"
|
||||
when "CAD"
|
||||
"CA"
|
||||
when "GBP", "GBX"
|
||||
"GB"
|
||||
when "EUR"
|
||||
nil # Could be many countries
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Handle currency as string or object (API inconsistency)
|
||||
def extract_currency(data, fallback: nil)
|
||||
data = data.with_indifferent_access if data.respond_to?(:with_indifferent_access)
|
||||
|
||||
currency_data = data[:currency]
|
||||
return fallback if currency_data.blank?
|
||||
|
||||
if currency_data.is_a?(Hash)
|
||||
currency_data.with_indifferent_access[:code] || fallback
|
||||
elsif currency_data.is_a?(String)
|
||||
currency_data.upcase
|
||||
else
|
||||
fallback
|
||||
end
|
||||
end
|
||||
end
|
||||
180
lib/generators/provider/family/templates/data_helpers_test.rb.tt
Normal file
180
lib/generators/provider/family/templates/data_helpers_test.rb.tt
Normal file
@@ -0,0 +1,180 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class <%= class_name %>Account::DataHelpersTest < ActiveSupport::TestCase
|
||||
# Create a test class that includes the concern
|
||||
class TestHelper
|
||||
include <%= class_name %>Account::DataHelpers
|
||||
|
||||
# Make private methods public for testing
|
||||
public :parse_decimal, :parse_date, :resolve_security, :extract_currency, :extract_security_name
|
||||
end
|
||||
|
||||
setup do
|
||||
@helper = TestHelper.new
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# parse_decimal tests
|
||||
# ==========================================================================
|
||||
|
||||
test "parse_decimal returns nil for nil input" do
|
||||
assert_nil @helper.parse_decimal(nil)
|
||||
end
|
||||
|
||||
test "parse_decimal parses string to BigDecimal" do
|
||||
result = @helper.parse_decimal("123.45")
|
||||
assert_instance_of BigDecimal, result
|
||||
assert_equal BigDecimal("123.45"), result
|
||||
end
|
||||
|
||||
test "parse_decimal handles integer input" do
|
||||
result = @helper.parse_decimal(100)
|
||||
assert_instance_of BigDecimal, result
|
||||
assert_equal BigDecimal("100"), result
|
||||
end
|
||||
|
||||
test "parse_decimal handles float input" do
|
||||
result = @helper.parse_decimal(99.99)
|
||||
assert_instance_of BigDecimal, result
|
||||
assert_in_delta 99.99, result.to_f, 0.001
|
||||
end
|
||||
|
||||
test "parse_decimal returns BigDecimal unchanged" do
|
||||
input = BigDecimal("50.25")
|
||||
result = @helper.parse_decimal(input)
|
||||
assert_equal input, result
|
||||
end
|
||||
|
||||
test "parse_decimal returns nil for invalid string" do
|
||||
assert_nil @helper.parse_decimal("not a number")
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# parse_date tests
|
||||
# ==========================================================================
|
||||
|
||||
test "parse_date returns nil for nil input" do
|
||||
assert_nil @helper.parse_date(nil)
|
||||
end
|
||||
|
||||
test "parse_date returns Date unchanged" do
|
||||
input = Date.new(2024, 6, 15)
|
||||
result = @helper.parse_date(input)
|
||||
assert_equal input, result
|
||||
end
|
||||
|
||||
test "parse_date parses ISO date string" do
|
||||
result = @helper.parse_date("2024-06-15")
|
||||
assert_instance_of Date, result
|
||||
assert_equal Date.new(2024, 6, 15), result
|
||||
end
|
||||
|
||||
test "parse_date parses datetime string to date" do
|
||||
result = @helper.parse_date("2024-06-15T10:30:00Z")
|
||||
assert_instance_of Date, result
|
||||
assert_equal Date.new(2024, 6, 15), result
|
||||
end
|
||||
|
||||
test "parse_date converts Time to Date" do
|
||||
input = Time.zone.parse("2024-06-15 10:30:00")
|
||||
result = @helper.parse_date(input)
|
||||
assert_instance_of Date, result
|
||||
assert_equal Date.new(2024, 6, 15), result
|
||||
end
|
||||
|
||||
test "parse_date returns nil for invalid string" do
|
||||
assert_nil @helper.parse_date("not a date")
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# resolve_security tests
|
||||
# ==========================================================================
|
||||
|
||||
test "resolve_security returns nil for blank ticker" do
|
||||
assert_nil @helper.resolve_security("")
|
||||
assert_nil @helper.resolve_security(" ")
|
||||
assert_nil @helper.resolve_security(nil)
|
||||
end
|
||||
|
||||
test "resolve_security finds existing security" do
|
||||
existing = Security.create!(ticker: "XYZTEST", name: "Test Security Inc")
|
||||
|
||||
result = @helper.resolve_security("xyztest")
|
||||
assert_equal existing, result
|
||||
end
|
||||
|
||||
test "resolve_security creates new security when not found" do
|
||||
symbol_data = { name: "Test Company Inc" }
|
||||
|
||||
result = @helper.resolve_security("TEST", symbol_data)
|
||||
|
||||
assert_not_nil result
|
||||
assert_equal "TEST", result.ticker
|
||||
assert_equal "Test Company Inc", result.name
|
||||
end
|
||||
|
||||
test "resolve_security upcases ticker" do
|
||||
symbol_data = { name: "Lowercase Test" }
|
||||
|
||||
result = @helper.resolve_security("lower", symbol_data)
|
||||
|
||||
assert_equal "LOWER", result.ticker
|
||||
end
|
||||
|
||||
test "resolve_security uses ticker as fallback name" do
|
||||
# Use short ticker (<=4 chars) to avoid titleize behavior
|
||||
result = @helper.resolve_security("XYZ1", {})
|
||||
|
||||
assert_equal "XYZ1", result.name
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# extract_currency tests
|
||||
# ==========================================================================
|
||||
|
||||
test "extract_currency returns fallback for nil currency" do
|
||||
result = @helper.extract_currency({}, fallback: "USD")
|
||||
assert_equal "USD", result
|
||||
end
|
||||
|
||||
test "extract_currency extracts string currency" do
|
||||
result = @helper.extract_currency({ currency: "cad" })
|
||||
assert_equal "CAD", result
|
||||
end
|
||||
|
||||
test "extract_currency extracts currency from hash with code key" do
|
||||
result = @helper.extract_currency({ currency: { code: "EUR" } })
|
||||
assert_equal "EUR", result
|
||||
end
|
||||
|
||||
test "extract_currency handles indifferent access" do
|
||||
result = @helper.extract_currency({ "currency" => { "code" => "GBP" } })
|
||||
assert_equal "GBP", result
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# extract_security_name tests
|
||||
# ==========================================================================
|
||||
|
||||
test "extract_security_name uses name field" do
|
||||
result = @helper.extract_security_name({ name: "Apple Inc" }, "AAPL")
|
||||
assert_equal "Apple Inc", result
|
||||
end
|
||||
|
||||
test "extract_security_name falls back to description" do
|
||||
result = @helper.extract_security_name({ description: "Microsoft Corp" }, "MSFT")
|
||||
assert_equal "Microsoft Corp", result
|
||||
end
|
||||
|
||||
test "extract_security_name uses ticker as fallback" do
|
||||
result = @helper.extract_security_name({}, "GOOG")
|
||||
assert_equal "GOOG", result
|
||||
end
|
||||
|
||||
test "extract_security_name ignores generic type descriptions" do
|
||||
result = @helper.extract_security_name({ name: "COMMON STOCK" }, "IBM")
|
||||
assert_equal "IBM", result
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,126 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class <%= class_name %>Account::HoldingsProcessor
|
||||
include <%= class_name %>Account::DataHelpers
|
||||
|
||||
def initialize(<%= file_name %>_account)
|
||||
@<%= file_name %>_account = <%= file_name %>_account
|
||||
end
|
||||
|
||||
def process
|
||||
return unless account.present?
|
||||
|
||||
holdings_data = @<%= file_name %>_account.raw_holdings_payload
|
||||
return if holdings_data.blank?
|
||||
|
||||
Rails.logger.info "<%= class_name %>Account::HoldingsProcessor - Processing #{holdings_data.size} holdings"
|
||||
|
||||
# Log sample of first holding to understand structure
|
||||
if holdings_data.first
|
||||
sample = holdings_data.first
|
||||
Rails.logger.info "<%= class_name %>Account::HoldingsProcessor - Sample holding keys: #{sample.keys.first(10).join(', ')}"
|
||||
end
|
||||
|
||||
holdings_data.each_with_index do |holding_data, idx|
|
||||
Rails.logger.info "<%= class_name %>Account::HoldingsProcessor - Processing holding #{idx + 1}/#{holdings_data.size}"
|
||||
process_holding(holding_data.with_indifferent_access)
|
||||
rescue => e
|
||||
Rails.logger.error "<%= class_name %>Account::HoldingsProcessor - Failed to process holding #{idx + 1}: #{e.class} - #{e.message}"
|
||||
Rails.logger.error e.backtrace.first(5).join("\n") if e.backtrace
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account
|
||||
@<%= file_name %>_account.current_account
|
||||
end
|
||||
|
||||
def import_adapter
|
||||
@import_adapter ||= Account::ProviderImportAdapter.new(account)
|
||||
end
|
||||
|
||||
def process_holding(data)
|
||||
# TODO: Customize ticker extraction based on your provider's format
|
||||
# Example: ticker = data[:symbol] || data[:ticker]
|
||||
ticker = extract_ticker(data)
|
||||
return if ticker.blank?
|
||||
|
||||
Rails.logger.info "<%= class_name %>Account::HoldingsProcessor - Processing holding for ticker: #{ticker}"
|
||||
|
||||
# Resolve or create the security
|
||||
security = resolve_security(ticker, data)
|
||||
return unless security
|
||||
|
||||
# TODO: Customize field names based on your provider's format
|
||||
quantity = parse_decimal(data[:units] || data[:quantity])
|
||||
price = parse_decimal(data[:price])
|
||||
return if quantity.nil? || price.nil?
|
||||
|
||||
# Calculate amount
|
||||
amount = quantity * price
|
||||
|
||||
# Get the holding date (use current date if not provided)
|
||||
holding_date = Date.current
|
||||
|
||||
# Extract currency
|
||||
currency = extract_currency(data, fallback: account.currency)
|
||||
|
||||
Rails.logger.info "<%= class_name %>Account::HoldingsProcessor - Importing holding: #{ticker} qty=#{quantity} price=#{price} currency=#{currency}"
|
||||
|
||||
# Import the holding via the adapter
|
||||
import_adapter.import_holding(
|
||||
security: security,
|
||||
quantity: quantity,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: holding_date,
|
||||
price: price,
|
||||
account_provider_id: @<%= file_name %>_account.account_provider&.id,
|
||||
source: "<%= file_name %>",
|
||||
delete_future_holdings: false
|
||||
)
|
||||
|
||||
# Store cost basis if available
|
||||
# TODO: Customize cost basis field name
|
||||
avg_price = data[:average_purchase_price] || data[:cost_basis] || data[:avg_cost]
|
||||
if avg_price.present?
|
||||
update_holding_cost_basis(security, avg_price)
|
||||
end
|
||||
end
|
||||
|
||||
def extract_ticker(data)
|
||||
# TODO: Customize based on your provider's format
|
||||
# Some providers nest symbol data, others have it flat
|
||||
#
|
||||
# Example for flat structure:
|
||||
# data[:symbol] || data[:ticker]
|
||||
#
|
||||
# Example for nested structure:
|
||||
# symbol_data = data[:symbol] || {}
|
||||
# symbol_data = symbol_data[:symbol] if symbol_data.is_a?(Hash)
|
||||
# symbol_data.is_a?(String) ? symbol_data : symbol_data[:ticker]
|
||||
|
||||
data[:symbol] || data[:ticker]
|
||||
end
|
||||
|
||||
def update_holding_cost_basis(security, avg_cost)
|
||||
# Find the most recent holding and update cost basis if not locked
|
||||
holding = account.holdings
|
||||
.where(security: security)
|
||||
.where("cost_basis_source != 'manual' OR cost_basis_source IS NULL")
|
||||
.order(date: :desc)
|
||||
.first
|
||||
|
||||
return unless holding
|
||||
|
||||
# Store per-share cost, not total cost
|
||||
cost_basis = parse_decimal(avg_cost)
|
||||
return if cost_basis.nil?
|
||||
|
||||
holding.update!(
|
||||
cost_basis: cost_basis,
|
||||
cost_basis_source: "provider"
|
||||
)
|
||||
end
|
||||
end
|
||||
244
lib/generators/provider/family/templates/importer.rb.tt
Normal file
244
lib/generators/provider/family/templates/importer.rb.tt
Normal file
@@ -0,0 +1,244 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class <%= class_name %>Item::Importer
|
||||
include SyncStats::Collector
|
||||
include <%= class_name %>Account::DataHelpers
|
||||
|
||||
# Chunk size for fetching activities
|
||||
ACTIVITY_CHUNK_DAYS = 365
|
||||
MAX_ACTIVITY_CHUNKS = 3 # Up to 3 years of history
|
||||
|
||||
# Minimum existing activities required before using incremental sync
|
||||
MINIMUM_HISTORY_FOR_INCREMENTAL = 10
|
||||
|
||||
attr_reader :<%= file_name %>_item, :<%= file_name %>_provider, :sync
|
||||
|
||||
def initialize(<%= file_name %>_item, <%= file_name %>_provider:, sync: nil)
|
||||
@<%= file_name %>_item = <%= file_name %>_item
|
||||
@<%= file_name %>_provider = <%= file_name %>_provider
|
||||
@sync = sync
|
||||
end
|
||||
|
||||
class CredentialsError < StandardError; end
|
||||
|
||||
def import
|
||||
Rails.logger.info "<%= class_name %>Item::Importer - Starting import for item #{<%= file_name %>_item.id}"
|
||||
|
||||
credentials = <%= file_name %>_item.<%= file_name %>_credentials
|
||||
unless credentials
|
||||
raise CredentialsError, "No <%= class_name %> credentials configured for item #{<%= file_name %>_item.id}"
|
||||
end
|
||||
|
||||
# Step 1: Fetch and store all accounts
|
||||
import_accounts(credentials)
|
||||
|
||||
# Step 2: For LINKED accounts only, fetch holdings and activities
|
||||
# Unlinked accounts just need basic info (name, balance) for the setup modal
|
||||
linked_accounts = <%= class_name %>Account
|
||||
.where(<%= file_name %>_item_id: <%= file_name %>_item.id)
|
||||
.joins(:account_provider)
|
||||
|
||||
Rails.logger.info "<%= class_name %>Item::Importer - Found #{linked_accounts.count} linked accounts to process"
|
||||
|
||||
linked_accounts.each do |<%= file_name %>_account|
|
||||
Rails.logger.info "<%= class_name %>Item::Importer - Processing linked account #{<%= file_name %>_account.id}"
|
||||
import_account_data(<%= file_name %>_account, credentials)
|
||||
end
|
||||
|
||||
# Update raw payload on the item
|
||||
<%= file_name %>_item.upsert_<%= file_name %>_snapshot!(stats)
|
||||
rescue Provider::<%= class_name %>::AuthenticationError => e
|
||||
<%= file_name %>_item.update!(status: :requires_update)
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stats
|
||||
@stats ||= {}
|
||||
end
|
||||
|
||||
def persist_stats!
|
||||
return unless sync&.respond_to?(:sync_stats)
|
||||
merged = (sync.sync_stats || {}).merge(stats)
|
||||
sync.update_columns(sync_stats: merged)
|
||||
end
|
||||
|
||||
def import_accounts(credentials)
|
||||
Rails.logger.info "<%= class_name %>Item::Importer - Fetching accounts"
|
||||
|
||||
# TODO: Implement API call to fetch accounts
|
||||
# accounts_data = <%= file_name %>_provider.list_accounts(...)
|
||||
accounts_data = []
|
||||
|
||||
stats["api_requests"] = stats.fetch("api_requests", 0) + 1
|
||||
stats["total_accounts"] = accounts_data.size
|
||||
|
||||
# Track upstream account IDs to detect removed accounts
|
||||
upstream_account_ids = []
|
||||
|
||||
accounts_data.each do |account_data|
|
||||
begin
|
||||
import_account(account_data, credentials)
|
||||
# TODO: Extract account ID from your provider's response format
|
||||
# upstream_account_ids << account_data[:id].to_s if account_data[:id]
|
||||
rescue => e
|
||||
Rails.logger.error "<%= class_name %>Item::Importer - Failed to import account: #{e.message}"
|
||||
stats["accounts_skipped"] = stats.fetch("accounts_skipped", 0) + 1
|
||||
register_error(e, account_data: account_data)
|
||||
end
|
||||
end
|
||||
|
||||
persist_stats!
|
||||
|
||||
# Clean up accounts that no longer exist upstream
|
||||
prune_removed_accounts(upstream_account_ids)
|
||||
end
|
||||
|
||||
def import_account(account_data, credentials)
|
||||
# TODO: Customize based on your provider's account ID field
|
||||
# <%= file_name %>_account_id = account_data[:id].to_s
|
||||
# return if <%= file_name %>_account_id.blank?
|
||||
|
||||
# <%= file_name %>_account = <%= file_name %>_item.<%= file_name %>_accounts.find_or_initialize_by(
|
||||
# <%= file_name %>_account_id: <%= file_name %>_account_id
|
||||
# )
|
||||
|
||||
# Update from API data
|
||||
# <%= file_name %>_account.upsert_from_<%= file_name %>!(account_data)
|
||||
|
||||
stats["accounts_imported"] = stats.fetch("accounts_imported", 0) + 1
|
||||
end
|
||||
|
||||
def import_account_data(<%= file_name %>_account, credentials)
|
||||
# Import holdings
|
||||
import_holdings(<%= file_name %>_account, credentials)
|
||||
|
||||
# Import activities
|
||||
import_activities(<%= file_name %>_account, credentials)
|
||||
end
|
||||
|
||||
def import_holdings(<%= file_name %>_account, credentials)
|
||||
Rails.logger.info "<%= class_name %>Item::Importer - Fetching holdings for account #{<%= file_name %>_account.id}"
|
||||
|
||||
begin
|
||||
# TODO: Implement API call to fetch holdings
|
||||
# holdings_data = <%= file_name %>_provider.get_holdings(account_id: <%= file_name %>_account.<%= file_name %>_account_id)
|
||||
holdings_data = []
|
||||
|
||||
stats["api_requests"] = stats.fetch("api_requests", 0) + 1
|
||||
|
||||
if holdings_data.any?
|
||||
# Convert SDK objects to hashes for storage
|
||||
holdings_hashes = holdings_data.map { |h| sdk_object_to_hash(h) }
|
||||
<%= file_name %>_account.upsert_holdings_snapshot!(holdings_hashes)
|
||||
stats["holdings_found"] = stats.fetch("holdings_found", 0) + holdings_data.size
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn "<%= class_name %>Item::Importer - Failed to fetch holdings: #{e.message}"
|
||||
register_error(e, context: "holdings", account_id: <%= file_name %>_account.id)
|
||||
end
|
||||
end
|
||||
|
||||
def import_activities(<%= file_name %>_account, credentials)
|
||||
Rails.logger.info "<%= class_name %>Item::Importer - Fetching activities for account #{<%= file_name %>_account.id}"
|
||||
|
||||
begin
|
||||
# Determine date range
|
||||
start_date = calculate_start_date(<%= file_name %>_account)
|
||||
end_date = Date.current
|
||||
|
||||
# TODO: Implement API call to fetch activities
|
||||
# activities_data = <%= file_name %>_provider.get_activities(
|
||||
# account_id: <%= file_name %>_account.<%= file_name %>_account_id,
|
||||
# start_date: start_date,
|
||||
# end_date: end_date
|
||||
# )
|
||||
activities_data = []
|
||||
|
||||
stats["api_requests"] = stats.fetch("api_requests", 0) + 1
|
||||
|
||||
if activities_data.any?
|
||||
# Convert SDK objects to hashes and merge with existing
|
||||
activities_hashes = activities_data.map { |a| sdk_object_to_hash(a) }
|
||||
merged = merge_activities(<%= file_name %>_account.raw_activities_payload || [], activities_hashes)
|
||||
<%= file_name %>_account.upsert_activities_snapshot!(merged)
|
||||
stats["activities_found"] = stats.fetch("activities_found", 0) + activities_data.size
|
||||
elsif fresh_linked_account?(<%= file_name %>_account)
|
||||
# Fresh account with no activities - schedule background fetch
|
||||
schedule_background_activities_fetch(<%= file_name %>_account, start_date)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn "<%= class_name %>Item::Importer - Failed to fetch activities: #{e.message}"
|
||||
register_error(e, context: "activities", account_id: <%= file_name %>_account.id)
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_start_date(<%= file_name %>_account)
|
||||
# Use user-specified start date if available
|
||||
user_start = <%= file_name %>_account.sync_start_date
|
||||
return user_start if user_start.present?
|
||||
|
||||
# For accounts with existing history, use incremental sync
|
||||
existing_count = (<%= file_name %>_account.raw_activities_payload || []).size
|
||||
if existing_count >= MINIMUM_HISTORY_FOR_INCREMENTAL && <%= file_name %>_account.last_activities_sync.present?
|
||||
# Incremental: go back 30 days from last sync to catch updates
|
||||
(<%= file_name %>_account.last_activities_sync - 30.days).to_date
|
||||
else
|
||||
# Full sync: go back up to 3 years
|
||||
(ACTIVITY_CHUNK_DAYS * MAX_ACTIVITY_CHUNKS).days.ago.to_date
|
||||
end
|
||||
end
|
||||
|
||||
def fresh_linked_account?(<%= file_name %>_account)
|
||||
# Account was just linked and has no activity history yet
|
||||
<%= file_name %>_account.last_activities_sync.nil? &&
|
||||
(<%= file_name %>_account.raw_activities_payload || []).empty?
|
||||
end
|
||||
|
||||
def schedule_background_activities_fetch(<%= file_name %>_account, start_date)
|
||||
return if <%= file_name %>_account.activities_fetch_pending?
|
||||
|
||||
Rails.logger.info "<%= class_name %>Item::Importer - Scheduling background activities fetch for account #{<%= file_name %>_account.id}"
|
||||
|
||||
<%= file_name %>_account.update!(activities_fetch_pending: true)
|
||||
<%= class_name %>ActivitiesFetchJob.perform_later(<%= file_name %>_account, start_date: start_date)
|
||||
end
|
||||
|
||||
def merge_activities(existing, new_activities)
|
||||
# Merge by ID, preferring newer data
|
||||
by_id = {}
|
||||
existing.each { |a| by_id[activity_key(a)] = a }
|
||||
new_activities.each { |a| by_id[activity_key(a)] = a }
|
||||
by_id.values
|
||||
end
|
||||
|
||||
def activity_key(activity)
|
||||
activity = activity.with_indifferent_access if activity.is_a?(Hash)
|
||||
# Use ID if available, otherwise generate key from date/type/amount
|
||||
activity[:id] || activity["id"] ||
|
||||
[ activity[:date], activity[:type], activity[:amount], activity[:symbol] ].join("-")
|
||||
end
|
||||
|
||||
def prune_removed_accounts(upstream_account_ids)
|
||||
return if upstream_account_ids.empty?
|
||||
|
||||
# Find accounts that exist locally but not upstream
|
||||
removed = <%= file_name %>_item.<%= file_name %>_accounts
|
||||
.where.not(<%= file_name %>_account_id: upstream_account_ids)
|
||||
|
||||
if removed.any?
|
||||
Rails.logger.info "<%= class_name %>Item::Importer - Pruning #{removed.count} removed accounts"
|
||||
removed.destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
def register_error(error, **context)
|
||||
stats["errors"] ||= []
|
||||
stats["errors"] << {
|
||||
message: error.message,
|
||||
context: context.to_s,
|
||||
timestamp: Time.current.iso8601
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class <%= class_name %>Item < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking
|
||||
|
||||
@@ -34,37 +36,40 @@ class <%= class_name %>Item < ApplicationRecord
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
scope :needs_update, -> { where(status: :requires_update) }
|
||||
|
||||
def syncer
|
||||
<%= class_name %>Item::Syncer.new(self)
|
||||
end
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
# TODO: Implement data import from provider API
|
||||
# This method should fetch the latest data from the provider and import it.
|
||||
# May need provider-specific validation (e.g., session validity checks).
|
||||
# See LunchflowItem#import_latest_lunchflow_data or EnableBankingItem#import_latest_enable_banking_data for examples.
|
||||
def import_latest_<%= file_name %>_data
|
||||
# Override syncing? to include background activities fetch
|
||||
def syncing?
|
||||
super || <%= file_name %>_accounts.where(activities_fetch_pending: true).exists?
|
||||
end
|
||||
|
||||
# Import data from provider API
|
||||
def import_latest_<%= file_name %>_data(sync: nil)
|
||||
provider = <%= file_name %>_provider
|
||||
unless provider
|
||||
Rails.logger.error "<%= class_name %>Item #{id} - Cannot import: provider is not configured"
|
||||
raise StandardError.new("<%= class_name %> provider is not configured")
|
||||
raise StandardError, I18n.t("<%= file_name %>_items.errors.provider_not_configured")
|
||||
end
|
||||
|
||||
# TODO: Add any provider-specific validation here (e.g., session checks)
|
||||
<%= class_name %>Item::Importer.new(self, <%= file_name %>_provider: provider).import
|
||||
<%= class_name %>Item::Importer.new(self, <%= file_name %>_provider: provider, sync: sync).import
|
||||
rescue => e
|
||||
Rails.logger.error "<%= class_name %>Item #{id} - Failed to import data: #{e.message}"
|
||||
raise
|
||||
end
|
||||
|
||||
# TODO: Implement account processing logic
|
||||
# This method processes linked accounts after data import.
|
||||
# Customize based on your provider's data structure and processing needs.
|
||||
# Process linked accounts after data import
|
||||
def process_accounts
|
||||
return [] if <%= file_name %>_accounts.empty?
|
||||
|
||||
results = []
|
||||
<%= file_name %>_accounts.joins(:account).merge(Account.visible).each do |<%= file_name %>_account|
|
||||
linked_<%= file_name %>_accounts.includes(account_provider: :account).each do |<%= file_name %>_account|
|
||||
begin
|
||||
result = <%= class_name %>Account::Processor.new(<%= file_name %>_account).process
|
||||
results << { <%= file_name %>_account_id: <%= file_name %>_account.id, success: true, result: result }
|
||||
@@ -77,8 +82,7 @@ class <%= class_name %>Item < ApplicationRecord
|
||||
results
|
||||
end
|
||||
|
||||
# TODO: Customize sync scheduling if needed
|
||||
# This method schedules sync jobs for all linked accounts.
|
||||
# Schedule sync jobs for all linked accounts
|
||||
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
||||
return [] if accounts.empty?
|
||||
|
||||
@@ -109,24 +113,30 @@ class <%= class_name %>Item < ApplicationRecord
|
||||
end
|
||||
|
||||
def has_completed_initial_setup?
|
||||
# Setup is complete if we have any linked accounts
|
||||
accounts.any?
|
||||
end
|
||||
|
||||
# TODO: Customize sync status summary if needed
|
||||
# Some providers use latest_sync.sync_stats, others use count methods directly.
|
||||
# See SimplefinItem#sync_status_summary or EnableBankingItem#sync_status_summary for examples.
|
||||
# Linked accounts (have AccountProvider association)
|
||||
def linked_<%= file_name %>_accounts
|
||||
<%= file_name %>_accounts.joins(:account_provider)
|
||||
end
|
||||
|
||||
# Unlinked accounts (no AccountProvider association)
|
||||
def unlinked_<%= file_name %>_accounts
|
||||
<%= file_name %>_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
|
||||
end
|
||||
|
||||
def sync_status_summary
|
||||
total_accounts = total_accounts_count
|
||||
linked_count = linked_accounts_count
|
||||
unlinked_count = unlinked_accounts_count
|
||||
|
||||
if total_accounts == 0
|
||||
"No accounts found"
|
||||
I18n.t("<%= file_name %>_items.sync_status.no_accounts")
|
||||
elsif unlinked_count == 0
|
||||
"#{linked_count} #{'account'.pluralize(linked_count)} synced"
|
||||
I18n.t("<%= file_name %>_items.sync_status.synced", count: linked_count)
|
||||
else
|
||||
"#{linked_count} synced, #{unlinked_count} need setup"
|
||||
I18n.t("<%= file_name %>_items.sync_status.synced_with_setup", linked: linked_count, unlinked: unlinked_count)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -146,9 +156,6 @@ class <%= class_name %>Item < ApplicationRecord
|
||||
institution_name.presence || institution_domain.presence || name
|
||||
end
|
||||
|
||||
# TODO: Customize based on how your provider stores institution data
|
||||
# SimpleFin uses org_data, others use institution_metadata.
|
||||
# Adjust the field name and key lookups as needed.
|
||||
def connected_institutions
|
||||
<%= file_name %>_accounts.includes(:account)
|
||||
.where.not(institution_metadata: nil)
|
||||
@@ -156,17 +163,13 @@ class <%= class_name %>Item < ApplicationRecord
|
||||
.uniq { |inst| inst["name"] || inst["institution_name"] }
|
||||
end
|
||||
|
||||
# TODO: Customize institution summary if your provider has special fields
|
||||
# EnableBanking uses aspsp_name as a fallback, for example.
|
||||
def institution_summary
|
||||
institutions = connected_institutions
|
||||
case institutions.count
|
||||
when 0
|
||||
"No institutions connected"
|
||||
when 1
|
||||
institutions.first["name"] || institutions.first["institution_name"] || "1 institution"
|
||||
I18n.t("<%= file_name %>_items.institution_summary.none")
|
||||
else
|
||||
"#{institutions.count} institutions"
|
||||
I18n.t("<%= file_name %>_items.institution_summary.count", count: institutions.count)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -177,11 +180,9 @@ class <%= class_name %>Item < ApplicationRecord
|
||||
true
|
||||
<% end -%>
|
||||
end
|
||||
|
||||
<% parsed_fields.select { |f| f[:default] }.each do |field| -%>
|
||||
<% parsed_fields.select { |f| f[:default] }.each do |field| %>
|
||||
def effective_<%= field[:name] %>
|
||||
<%= field[:name] %>.presence || "<%= field[:default] %>"
|
||||
end
|
||||
|
||||
<% end -%>
|
||||
end
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<%%= "<" + "%# locals: (#{file_name}_item:) %" + ">" %>
|
||||
|
||||
<%%= "<" + "%= tag.div id: dom_id(#{file_name}_item) do %" + ">" %>
|
||||
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<summary class="flex items-center justify-between gap-2 focus-visible:outline-hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<%%= "<" + "%= icon \"chevron-right\", class: \"group-open:transform group-open:rotate-90\" %" + ">" %>
|
||||
|
||||
<div class="flex items-center justify-center h-8 w-8 bg-primary/10 rounded-full">
|
||||
<div class="flex items-center justify-center">
|
||||
<%%= "<" + "%= tag.p #{file_name}_item.name.first.upcase, class: \"text-primary text-xs font-medium\" %" + ">" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pl-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<%%= "<" + "%= tag.p #{file_name}_item.name, class: \"font-medium text-primary\" %" + ">" %>
|
||||
<%%= "<" + "% if #{file_name}_item.scheduled_for_deletion? %" + ">" %>
|
||||
<p class="text-destructive text-sm animate-pulse"><%%= "<" + "%= t(\".deletion_in_progress\") %" + ">" %></p>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
</div>
|
||||
<p class="text-xs text-secondary"><%%= "<" + "%= t(\".provider_name\") %" + ">" %></p>
|
||||
<%%= "<" + "% if #{file_name}_item.syncing? %" + ">" %>
|
||||
<div class="text-secondary flex items-center gap-1">
|
||||
<%%= "<" + "%= icon \"loader\", size: \"sm\", class: \"animate-spin\" %" + ">" %>
|
||||
<%%= "<" + "%= tag.span t(\".syncing\") %" + ">" %>
|
||||
</div>
|
||||
<%%= "<" + "% elsif #{file_name}_item.requires_update? %" + ">" %>
|
||||
<div class="text-warning flex items-center gap-1">
|
||||
<%%= "<" + "%= icon \"alert-triangle\", size: \"sm\", color: \"warning\" %" + ">" %>
|
||||
<%%= "<" + "%= tag.span t(\".requires_update\") %" + ">" %>
|
||||
</div>
|
||||
<%%= "<" + "% else %" + ">" %>
|
||||
<p class="text-secondary">
|
||||
<%%= "<" + "% if #{file_name}_item.last_synced_at %" + ">" %>
|
||||
<%%= "<" + "% if #{file_name}_item.sync_status_summary %" + ">" %>
|
||||
<%%= "<" + "%= t(\".status_with_summary\", timestamp: time_ago_in_words(#{file_name}_item.last_synced_at), summary: #{file_name}_item.sync_status_summary) %" + ">" %>
|
||||
<%%= "<" + "% else %" + ">" %>
|
||||
<%%= "<" + "%= t(\".status\", timestamp: time_ago_in_words(#{file_name}_item.last_synced_at)) %" + ">" %>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
<%%= "<" + "% else %" + ">" %>
|
||||
<%%= "<" + "%= t(\".status_never\") %" + ">" %>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
</p>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%%= "<" + "% if #{file_name}_item.requires_update? %" + ">" %>
|
||||
<%%= "<" + "%= render DS::Link.new(" %>
|
||||
text: t(".update_credentials"),
|
||||
icon: "refresh-cw",
|
||||
variant: "secondary",
|
||||
href: settings_providers_path,
|
||||
frame: "_top"
|
||||
) <%%= "%" + ">" %>
|
||||
<%%= "<" + "% else %" + ">" %>
|
||||
<%%= "<" + "%= icon(" %>
|
||||
"refresh-cw",
|
||||
as_button: true,
|
||||
href: sync_<%= file_name %>_item_path(<%= file_name %>_item),
|
||||
disabled: <%= file_name %>_item.syncing?
|
||||
) <%%= "%" + ">" %>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
|
||||
<%%= "<" + "%= render DS::Menu.new do |menu| %" + ">" %>
|
||||
<%%= "<" + "% menu.with_item(" %>
|
||||
variant: "button",
|
||||
text: t(".delete"),
|
||||
icon: "trash-2",
|
||||
href: <%= file_name %>_item_path(<%= file_name %>_item),
|
||||
method: :delete,
|
||||
confirm: CustomConfirm.for_resource_deletion(<%= file_name %>_item.name, high_severity: true)
|
||||
) <%%= "%" + ">" %>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<%%= "<" + "% unless #{file_name}_item.scheduled_for_deletion? %" + ">" %>
|
||||
<div class="space-y-4 mt-4">
|
||||
<%%= "<" + "% if #{file_name}_item.accounts.any? %" + ">" %>
|
||||
<%%= "<" + "%= render \"accounts/index/account_groups\", accounts: #{file_name}_item.accounts %" + ">" %>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
|
||||
<%%= "<" + "%# Sync summary (collapsible) - using shared ProviderSyncSummary component %" + ">" %>
|
||||
<%%= "<" + "% stats = if defined?(@#{file_name}_sync_stats_map) && @#{file_name}_sync_stats_map" %>
|
||||
@<%= file_name %>_sync_stats_map[<%= file_name %>_item.id] || {}
|
||||
else
|
||||
<%= file_name %>_item.syncs.ordered.first&.sync_stats || {}
|
||||
end <%%= "%" + ">" %>
|
||||
<%%= "<" + "%= render ProviderSyncSummary.new(" %>
|
||||
stats: stats,
|
||||
provider_item: <%= file_name %>_item
|
||||
) <%%= "%" + ">" %>
|
||||
|
||||
<%%= "<" + "%# Compute unlinked accounts (no AccountProvider link) %" + ">" %>
|
||||
<%%= "<" + "% unlinked_count = #{file_name}_item.unlinked_#{file_name}_accounts.count rescue 0 %" + ">" %>
|
||||
|
||||
<%%= "<" + "% if unlinked_count.to_i > 0 && #{file_name}_item.accounts.empty? %" + ">" %>
|
||||
<%%= "<" + "%# No accounts imported yet - show prominent setup prompt %" + ">" %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
<p class="text-primary font-medium text-sm"><%%= "<" + "%= t(\".setup_needed\") %" + ">" %></p>
|
||||
<p class="text-secondary text-sm"><%%= "<" + "%= t(\".setup_description\") %" + ">" %></p>
|
||||
<%%= "<" + "%= render DS::Link.new(" %>
|
||||
text: t(".setup_action"),
|
||||
icon: "plus",
|
||||
variant: "primary",
|
||||
href: setup_accounts_<%= file_name %>_item_path(<%= file_name %>_item),
|
||||
frame: :modal
|
||||
) <%%= "%" + ">" %>
|
||||
</div>
|
||||
<%%= "<" + "% elsif unlinked_count.to_i > 0 %" + ">" %>
|
||||
<%%= "<" + "%# Some accounts imported, more available - show subtle link %" + ">" %>
|
||||
<div class="pt-2 border-t border-primary">
|
||||
<%%= "<" + "%= link_to setup_accounts_#{file_name}_item_path(#{file_name}_item)," %>
|
||||
data: { turbo_frame: :modal },
|
||||
class: "flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors" do <%%= "%" + ">" %>
|
||||
<%%= "<" + "%= icon \"plus\", size: \"sm\" %" + ">" %>
|
||||
<span><%%= "<" + "%= t(\".more_accounts_available\", count: unlinked_count) %" + ">" %></span>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
</div>
|
||||
<%%= "<" + "% elsif #{file_name}_item.accounts.empty? && #{file_name}_item.#{file_name}_accounts.none? %" + ">" %>
|
||||
<%%= "<" + "%# No provider accounts at all - waiting for sync %" + ">" %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
<p class="text-primary font-medium text-sm"><%%= "<" + "%= t(\".no_accounts_title\") %" + ">" %></p>
|
||||
<p class="text-secondary text-sm"><%%= "<" + "%= t(\".no_accounts_description\") %" + ">" %></p>
|
||||
</div>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
</div>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
</details>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
@@ -1,15 +1,69 @@
|
||||
---
|
||||
en:
|
||||
<%= file_name %>_items:
|
||||
# Model method strings (i18n for item_model.rb)
|
||||
sync_status:
|
||||
no_accounts: "No accounts found"
|
||||
synced:
|
||||
one: "%%{count} account synced"
|
||||
other: "%%{count} accounts synced"
|
||||
synced_with_setup: "%%{linked} synced, %%{unlinked} need setup"
|
||||
institution_summary:
|
||||
none: "No institutions connected"
|
||||
count:
|
||||
one: "%%{count} institution"
|
||||
other: "%%{count} institutions"
|
||||
errors:
|
||||
provider_not_configured: "<%= class_name %> provider is not configured"
|
||||
|
||||
# Syncer status messages
|
||||
sync:
|
||||
status:
|
||||
importing: "Importing accounts from <%= class_name %>..."
|
||||
processing: "Processing holdings and activities..."
|
||||
calculating: "Calculating balances..."
|
||||
importing_data: "Importing account data..."
|
||||
checking_setup: "Checking account configuration..."
|
||||
needs_setup: "%%{count} accounts need setup..."
|
||||
success: "Sync started"
|
||||
|
||||
# Panel (settings view)
|
||||
panel:
|
||||
setup_instructions: "Setup instructions:"
|
||||
step_1: "Visit your <%= class_name.titleize %> dashboard to get your credentials"
|
||||
step_2: "Enter your credentials below and click the Save button"
|
||||
step_3: "After a successful connection, go to the Accounts tab to set up new accounts"
|
||||
field_descriptions: "Field descriptions:"
|
||||
optional: "(Optional)"
|
||||
save_button: "Save Configuration"
|
||||
update_button: "Update Configuration"
|
||||
status_configured_html: "Configured and ready to use. Visit the <a href=\"%%{accounts_path}\" class=\"link\">Accounts</a> tab to manage and set up accounts."
|
||||
status_not_configured: "Not configured"
|
||||
fields:
|
||||
<% parsed_fields.each do |field| -%>
|
||||
<%= field[:name] %>:
|
||||
label: "<%= field[:name].titleize %>"
|
||||
description: "Your <%= class_name.titleize %> <%= field[:name].humanize.downcase %>"
|
||||
placeholder_new: "Paste <%= field[:name].humanize.downcase %> here"
|
||||
placeholder_update: "Enter new <%= field[:name].humanize.downcase %> to update"
|
||||
<% end -%>
|
||||
|
||||
# CRUD success messages
|
||||
create:
|
||||
success: <%= class_name %> connection created successfully
|
||||
success: "<%= class_name %> connection created successfully"
|
||||
update:
|
||||
success: "<%= class_name %> connection updated"
|
||||
destroy:
|
||||
success: <%= class_name %> connection removed
|
||||
success: "<%= class_name %> connection removed"
|
||||
index:
|
||||
title: <%= class_name %> Connections
|
||||
title: "<%= class_name %> Connections"
|
||||
|
||||
# Loading states
|
||||
loading:
|
||||
loading_message: Loading <%= class_name %> accounts...
|
||||
loading_title: Loading
|
||||
loading_message: "Loading <%= class_name %> accounts..."
|
||||
loading_title: "Loading"
|
||||
|
||||
# Account linking
|
||||
link_accounts:
|
||||
all_already_linked:
|
||||
one: "The selected account (%%{names}) is already linked"
|
||||
@@ -18,79 +72,112 @@ en:
|
||||
invalid_account_names:
|
||||
one: "Cannot link account with blank name"
|
||||
other: "Cannot link %%{count} accounts with blank names"
|
||||
link_failed: Failed to link accounts
|
||||
no_accounts_selected: Please select at least one account
|
||||
no_api_key: <%= class_name %> API key not found. Please configure it in Provider Settings.
|
||||
link_failed: "Failed to link accounts"
|
||||
no_accounts_selected: "Please select at least one account"
|
||||
no_api_key: "<%= class_name %> API key not found. Please configure it in Provider Settings."
|
||||
partial_invalid: "Successfully linked %%{created_count} account(s), %%{already_linked_count} were already linked, %%{invalid_count} account(s) had invalid names"
|
||||
partial_success: "Successfully linked %%{created_count} account(s). %%{already_linked_count} account(s) were already linked: %%{already_linked_names}"
|
||||
success:
|
||||
one: "Successfully linked %%{count} account"
|
||||
other: "Successfully linked %%{count} accounts"
|
||||
|
||||
# Provider item display (used in _item partial)
|
||||
<%= file_name %>_item:
|
||||
accounts_need_setup: Accounts need setup
|
||||
delete: Delete connection
|
||||
deletion_in_progress: deletion in progress...
|
||||
error: Error
|
||||
no_accounts_description: This connection has no linked accounts yet.
|
||||
no_accounts_title: No accounts
|
||||
setup_action: Set Up New Accounts
|
||||
accounts_need_setup: "Accounts need setup"
|
||||
delete: "Delete connection"
|
||||
deletion_in_progress: "deletion in progress..."
|
||||
error: "Error"
|
||||
more_accounts_available:
|
||||
one: "%%{count} more account available"
|
||||
other: "%%{count} more accounts available"
|
||||
no_accounts_description: "This connection has no linked accounts yet."
|
||||
no_accounts_title: "No accounts"
|
||||
provider_name: "<%= class_name %>"
|
||||
requires_update: "Connection needs update"
|
||||
setup_action: "Set Up New Accounts"
|
||||
setup_description: "%%{linked} of %%{total} accounts linked. Choose account types for your newly imported <%= class_name %> accounts."
|
||||
setup_needed: New accounts ready to set up
|
||||
setup_needed: "New accounts ready to set up"
|
||||
status: "Synced %%{timestamp} ago"
|
||||
status_never: Never synced
|
||||
status_never: "Never synced"
|
||||
status_with_summary: "Last synced %%{timestamp} ago - %%{summary}"
|
||||
syncing: Syncing...
|
||||
total: Total
|
||||
unlinked: Unlinked
|
||||
syncing: "Syncing..."
|
||||
total: "Total"
|
||||
unlinked: "Unlinked"
|
||||
update_credentials: "Update credentials"
|
||||
|
||||
# Select accounts view
|
||||
select_accounts:
|
||||
accounts_selected: accounts selected
|
||||
accounts_selected: "accounts selected"
|
||||
api_error: "API error: %%{message}"
|
||||
cancel: Cancel
|
||||
configure_name_in_provider: Cannot import - please configure account name in <%= class_name %>
|
||||
description: Select the accounts you want to link to your %%{product_name} account.
|
||||
link_accounts: Link selected accounts
|
||||
no_accounts_found: No accounts found. Please check your API key configuration.
|
||||
no_api_key: <%= class_name %> API key is not configured. Please configure it in Settings.
|
||||
no_credentials_configured: Please configure your <%= class_name %> credentials first in Provider Settings.
|
||||
cancel: "Cancel"
|
||||
configure_name_in_provider: "Cannot import - please configure account name in <%= class_name %>"
|
||||
description: "Select the accounts you want to link to your %%{product_name} account."
|
||||
link_accounts: "Link selected accounts"
|
||||
no_accounts_found: "No accounts found. Please check your API key configuration."
|
||||
no_api_key: "<%= class_name %> API key is not configured. Please configure it in Settings."
|
||||
no_credentials_configured: "Please configure your <%= class_name %> credentials first in Provider Settings."
|
||||
no_name_placeholder: "(No name)"
|
||||
title: Select <%= class_name %> Accounts
|
||||
title: "Select <%= class_name %> Accounts"
|
||||
|
||||
# Select existing account view
|
||||
select_existing_account:
|
||||
account_already_linked: This account is already linked to a provider
|
||||
all_accounts_already_linked: All <%= class_name %> accounts are already linked
|
||||
account_already_linked: "This account is already linked to a provider"
|
||||
all_accounts_already_linked: "All <%= class_name %> accounts are already linked"
|
||||
api_error: "API error: %%{message}"
|
||||
cancel: Cancel
|
||||
configure_name_in_provider: Cannot import - please configure account name in <%= class_name %>
|
||||
description: Select a <%= class_name %> account to link with this account. Transactions will be synced and deduplicated automatically.
|
||||
link_account: Link account
|
||||
no_account_specified: No account specified
|
||||
no_accounts_found: No <%= class_name %> accounts found. Please check your API key configuration.
|
||||
no_api_key: <%= class_name %> API key is not configured. Please configure it in Settings.
|
||||
no_credentials_configured: Please configure your <%= class_name %> credentials first in Provider Settings.
|
||||
balance_label: "Balance:"
|
||||
cancel: "Cancel"
|
||||
cancel_button: "Cancel"
|
||||
configure_name_in_provider: "Cannot import - please configure account name in <%= class_name %>"
|
||||
connect_hint: "Connect a <%= class_name %> account to enable automatic syncing."
|
||||
description: "Select a <%= class_name %> account to link with this account. Transactions will be synced and deduplicated automatically."
|
||||
header: "Link with <%= class_name %>"
|
||||
link_account: "Link account"
|
||||
link_button: "Link this account"
|
||||
linking_to: "Linking to:"
|
||||
no_account_specified: "No account specified"
|
||||
no_accounts: "No unlinked <%= class_name %> accounts found."
|
||||
no_accounts_found: "No <%= class_name %> accounts found. Please check your API key configuration."
|
||||
no_api_key: "<%= class_name %> API key is not configured. Please configure it in Settings."
|
||||
no_credentials_configured: "Please configure your <%= class_name %> credentials first in Provider Settings."
|
||||
no_name_placeholder: "(No name)"
|
||||
settings_link: "Go to Provider Settings"
|
||||
subtitle: "Choose a <%= class_name %> account"
|
||||
title: "Link %%{account_name} with <%= class_name %>"
|
||||
|
||||
# Link existing account
|
||||
link_existing_account:
|
||||
account_already_linked: This account is already linked to a provider
|
||||
account_already_linked: "This account is already linked to a provider"
|
||||
api_error: "API error: %%{message}"
|
||||
invalid_account_name: Cannot link account with blank name
|
||||
provider_account_already_linked: This <%= class_name %> account is already linked to another account
|
||||
provider_account_not_found: <%= class_name %> account not found
|
||||
missing_parameters: Missing required parameters
|
||||
no_api_key: <%= class_name %> API key not found. Please configure it in Provider Settings.
|
||||
invalid_account_name: "Cannot link account with blank name"
|
||||
provider_account_already_linked: "This <%= class_name %> account is already linked to another account"
|
||||
provider_account_not_found: "<%= class_name %> account not found"
|
||||
missing_parameters: "Missing required parameters"
|
||||
no_api_key: "<%= class_name %> API key not found. Please configure it in Provider Settings."
|
||||
success: "Successfully linked %%{account_name} with <%= class_name %>"
|
||||
|
||||
# Setup accounts wizard
|
||||
setup_accounts:
|
||||
account_type_label: "Account Type:"
|
||||
accounts_count:
|
||||
one: "%%{count} account available"
|
||||
other: "%%{count} accounts available"
|
||||
all_accounts_linked: "All your <%= class_name %> accounts have already been set up."
|
||||
api_error: "API error: %%{message}"
|
||||
creating: "Creating accounts..."
|
||||
fetch_failed: "Failed to Fetch Accounts"
|
||||
import_selected: "Import selected accounts"
|
||||
instructions: "Select the accounts you want to import from <%= class_name %>. You can choose multiple accounts."
|
||||
no_accounts: "No unlinked accounts found from this <%= class_name %> connection."
|
||||
no_accounts_to_setup: "No Accounts to Set Up"
|
||||
no_api_key: "<%= class_name %> API key is not configured. Please check your connection settings."
|
||||
select_all: "Select all"
|
||||
account_types:
|
||||
skip: Skip this account
|
||||
depository: Checking or Savings Account
|
||||
credit_card: Credit Card
|
||||
investment: Investment Account
|
||||
loan: Loan or Mortgage
|
||||
other_asset: Other Asset
|
||||
skip: "Skip this account"
|
||||
depository: "Checking or Savings Account"
|
||||
credit_card: "Credit Card"
|
||||
investment: "Investment Account"
|
||||
loan: "Loan or Mortgage"
|
||||
other_asset: "Other Asset"
|
||||
subtype_labels:
|
||||
depository: "Account Subtype:"
|
||||
credit_card: ""
|
||||
@@ -102,46 +189,48 @@ en:
|
||||
other_asset: "No additional options needed for Other Assets."
|
||||
subtypes:
|
||||
depository:
|
||||
checking: Checking
|
||||
savings: Savings
|
||||
hsa: Health Savings Account
|
||||
cd: Certificate of Deposit
|
||||
money_market: Money Market
|
||||
checking: "Checking"
|
||||
savings: "Savings"
|
||||
hsa: "Health Savings Account"
|
||||
cd: "Certificate of Deposit"
|
||||
money_market: "Money Market"
|
||||
investment:
|
||||
brokerage: Brokerage
|
||||
pension: Pension
|
||||
retirement: Retirement
|
||||
brokerage: "Brokerage"
|
||||
pension: "Pension"
|
||||
retirement: "Retirement"
|
||||
"401k": "401(k)"
|
||||
roth_401k: "Roth 401(k)"
|
||||
"403b": "403(b)"
|
||||
tsp: Thrift Savings Plan
|
||||
tsp: "Thrift Savings Plan"
|
||||
"529_plan": "529 Plan"
|
||||
hsa: Health Savings Account
|
||||
mutual_fund: Mutual Fund
|
||||
ira: Traditional IRA
|
||||
roth_ira: Roth IRA
|
||||
angel: Angel
|
||||
hsa: "Health Savings Account"
|
||||
mutual_fund: "Mutual Fund"
|
||||
ira: "Traditional IRA"
|
||||
roth_ira: "Roth IRA"
|
||||
angel: "Angel"
|
||||
loan:
|
||||
mortgage: Mortgage
|
||||
student: Student Loan
|
||||
auto: Auto Loan
|
||||
other: Other Loan
|
||||
balance: Balance
|
||||
cancel: Cancel
|
||||
mortgage: "Mortgage"
|
||||
student: "Student Loan"
|
||||
auto: "Auto Loan"
|
||||
other: "Other Loan"
|
||||
balance: "Balance"
|
||||
cancel: "Cancel"
|
||||
choose_account_type: "Choose the correct account type for each <%= class_name %> account:"
|
||||
create_accounts: Create Accounts
|
||||
creating_accounts: Creating Accounts...
|
||||
create_accounts: "Create Accounts"
|
||||
creating_accounts: "Creating Accounts..."
|
||||
historical_data_range: "Historical Data Range:"
|
||||
subtitle: Choose the correct account types for your imported accounts
|
||||
sync_start_date_help: Select how far back you want to sync transaction history.
|
||||
subtitle: "Choose the correct account types for your imported accounts"
|
||||
sync_start_date_help: "Select how far back you want to sync transaction history."
|
||||
sync_start_date_label: "Start syncing transactions from:"
|
||||
title: Set Up Your <%= class_name %> Accounts
|
||||
title: "Set Up Your <%= class_name %> Accounts"
|
||||
|
||||
# Complete account setup
|
||||
complete_account_setup:
|
||||
all_skipped: "All accounts were skipped. No accounts were created."
|
||||
creation_failed: "Failed to create accounts: %%{error}"
|
||||
no_accounts: "No accounts to set up."
|
||||
success: "Successfully created %%{count} account(s)."
|
||||
sync:
|
||||
success: Sync started
|
||||
update:
|
||||
success: <%= class_name %> connection updated
|
||||
|
||||
# Preload accounts
|
||||
preload_accounts:
|
||||
no_credentials_configured: "Please configure your <%= class_name %> credentials first in Provider Settings."
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Create<%= class_name %>ItemsAndAccounts < ActiveRecord::Migration<%= migration_version %>
|
||||
def change
|
||||
# Create provider items table (stores per-family connection credentials)
|
||||
@@ -40,11 +42,14 @@ class Create<%= class_name %>ItemsAndAccounts < ActiveRecord::Migration<%= migra
|
||||
|
||||
# Account identification
|
||||
t.string :name
|
||||
t.string :account_id
|
||||
t.string :<%= file_name %>_account_id
|
||||
t.string :<%= file_name %>_authorization_id
|
||||
t.string :account_number
|
||||
|
||||
# Account details
|
||||
t.string :currency
|
||||
t.decimal :current_balance, precision: 19, scale: 4
|
||||
t.decimal :cash_balance, precision: 19, scale: 4, default: 0.0
|
||||
t.string :account_status
|
||||
t.string :account_type
|
||||
t.string :provider
|
||||
@@ -54,9 +59,20 @@ class Create<%= class_name %>ItemsAndAccounts < ActiveRecord::Migration<%= migra
|
||||
t.jsonb :raw_payload
|
||||
t.jsonb :raw_transactions_payload
|
||||
|
||||
# Investment data (holdings, activities)
|
||||
t.jsonb :raw_holdings_payload, default: []
|
||||
t.jsonb :raw_activities_payload, default: []
|
||||
t.datetime :last_holdings_sync
|
||||
t.datetime :last_activities_sync
|
||||
t.boolean :activities_fetch_pending, default: false
|
||||
|
||||
# Sync settings
|
||||
t.date :sync_start_date
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :<%= file_name %>_accounts, :account_id
|
||||
add_index :<%= file_name %>_accounts, :<%= file_name %>_account_id, unique: true
|
||||
add_index :<%= file_name %>_accounts, :<%= file_name %>_authorization_id
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,71 +1,72 @@
|
||||
<div class="space-y-4">
|
||||
<div class="prose prose-sm text-secondary">
|
||||
<p class="text-primary font-medium">Setup instructions:</p>
|
||||
<p class="text-primary font-medium"><%= "<" + "%= t(\"#{file_name}_items.panel.setup_instructions\") %" + ">" %></p>
|
||||
<ol>
|
||||
<li>Visit your <%= class_name.titleize %> dashboard to get your credentials</li>
|
||||
<li>Enter your credentials below and click the Save button</li>
|
||||
<li>After a successful connection, go to the Accounts tab to set up new accounts</li>
|
||||
<li><%= "<" + "%= t(\"#{file_name}_items.panel.step_1\") %" + ">" %></li>
|
||||
<li><%= "<" + "%= t(\"#{file_name}_items.panel.step_2\") %" + ">" %></li>
|
||||
<li><%= "<" + "%= t(\"#{file_name}_items.panel.step_3\") %" + ">" %></li>
|
||||
</ol>
|
||||
|
||||
<p class="text-primary font-medium">Field descriptions:</p>
|
||||
<p class="text-primary font-medium"><%= "<" + "%= t(\"#{file_name}_items.panel.field_descriptions\") %" + ">" %></p>
|
||||
<ul>
|
||||
<% parsed_fields.each do |field| -%>
|
||||
<li><strong><%= field[:name].titleize %>:</strong> Your <%= class_name.titleize %> <%= field[:name].humanize.downcase %><%= field[:secret] ? ' (required)' : '' %><%= field[:default] ? " (optional, defaults to #{field[:default]})" : '' %></li>
|
||||
<li><strong><%= "<" + "%= t(\"#{file_name}_items.panel.fields.#{field[:name]}.label\") %" + ">" %>:</strong> <%= "<" + "%= t(\"#{file_name}_items.panel.fields.#{field[:name]}.description\") %" + ">" %><%= field[:secret] ? ' (required)' : '' %><%= field[:default] ? " (optional, defaults to #{field[:default]})" : '' %></li>
|
||||
<% end -%>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<%% error_msg = local_assigns[:error_message] || @error_message %>
|
||||
<%% if error_msg.present? %>
|
||||
<%= "<" + "% error_msg = local_assigns[:error_message] || @error_message %" + ">" %>
|
||||
<%= "<" + "% if error_msg.present? %" + ">" %>
|
||||
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
|
||||
<p class="line-clamp-3" title="<%%= error_msg %>"><%%= error_msg %></p>
|
||||
<p class="line-clamp-3" title="<%= "<" + "%= error_msg %" + ">" %>"><%= "<" + "%= error_msg %" + ">" %></p>
|
||||
</div>
|
||||
<%% end %>
|
||||
<%= "<" + "% end %" + ">" %>
|
||||
|
||||
<%%
|
||||
<%= "<" + "%" %>
|
||||
# Get or initialize a <%= file_name %>_item for this family
|
||||
# - If family has an item WITH credentials, use it (for updates)
|
||||
# - If family has an item WITHOUT credentials, use it (to add credentials)
|
||||
# - If family has no items at all, create a new one
|
||||
<%= file_name %>_item = Current.family.<%= file_name %>_items.first_or_initialize(name: "<%= class_name.titleize %> Connection")
|
||||
is_new_record = <%= file_name %>_item.new_record?
|
||||
%>
|
||||
<%= "%" + ">" %>
|
||||
|
||||
<%%= styled_form_with model: <%= file_name %>_item,
|
||||
<%= "<" + "%= styled_form_with model: #{file_name}_item," %>
|
||||
url: is_new_record ? <%= file_name %>_items_path : <%= file_name %>_item_path(<%= file_name %>_item),
|
||||
scope: :<%= file_name %>_item,
|
||||
method: is_new_record ? :post : :patch,
|
||||
data: { turbo: true },
|
||||
class: "space-y-3" do |form| %>
|
||||
class: "space-y-3" do |form| <%= "%" + ">" %>
|
||||
<% parsed_fields.each do |field| -%>
|
||||
<%%= form.<%= %w[text string].include?(field[:type]) ? 'text_field' : 'number_field' %> :<%= field[:name] %>,
|
||||
label: "<%= field[:name].titleize %><%= field[:default] ? ' (Optional)' : '' %>",
|
||||
<%= "<" + "%= form.#{%w[text string].include?(field[:type]) ? 'text_field' : 'number_field'} :#{field[:name]}," %>
|
||||
label: t("<%= file_name %>_items.panel.fields.<%= field[:name] %>.label")<%= field[:default] ? " + \" \" + t(\"#{file_name}_items.panel.optional\")" : '' %>,
|
||||
<% if field[:secret] -%>
|
||||
placeholder: is_new_record ? "Paste <%= field[:name].humanize.downcase %> here" : "Enter new <%= field[:name].humanize.downcase %> to update",
|
||||
type: :password %>
|
||||
placeholder: is_new_record ? t("<%= file_name %>_items.panel.fields.<%= field[:name] %>.placeholder_new") : t("<%= file_name %>_items.panel.fields.<%= field[:name] %>.placeholder_update"),
|
||||
type: :password <%= "%" + ">" %>
|
||||
<% elsif field[:default] -%>
|
||||
placeholder: "<%= field[:default] %> (default)",
|
||||
value: <%= file_name %>_item.<%= field[:name] %> %>
|
||||
value: <%= file_name %>_item.<%= field[:name] %> <%= "%" + ">" %>
|
||||
<% else -%>
|
||||
placeholder: is_new_record ? "Enter <%= field[:name].humanize.downcase %>" : "Enter new <%= field[:name].humanize.downcase %> to update",
|
||||
value: <%= file_name %>_item.<%= field[:name] %> %>
|
||||
placeholder: is_new_record ? t("<%= file_name %>_items.panel.fields.<%= field[:name] %>.placeholder_new") : t("<%= file_name %>_items.panel.fields.<%= field[:name] %>.placeholder_update"),
|
||||
value: <%= file_name %>_item.<%= field[:name] %> <%= "%" + ">" %>
|
||||
<% end -%>
|
||||
|
||||
<% end -%>
|
||||
<div class="flex justify-end">
|
||||
<%%= form.submit is_new_record ? "Save Configuration" : "Update Configuration",
|
||||
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %>
|
||||
<%= "<" + "%= form.submit is_new_record ? t(\"#{file_name}_items.panel.save_button\") : t(\"#{file_name}_items.panel.update_button\")," %>
|
||||
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium btn btn--primary" <%= "%" + ">" %>
|
||||
</div>
|
||||
<%% end %>
|
||||
<%= "<" + "% end %" + ">" %>
|
||||
|
||||
<%% items = local_assigns[:<%= file_name %>_items] || @<%= file_name %>_items || Current.family.<%= file_name %>_items.where.not(<%= parsed_fields.select { |f| f[:secret] }.first&.dig(:name) || 'api_key' %>: [nil, ""]) %>
|
||||
<% secret_field = parsed_fields.select { |f| f[:secret] }.first&.dig(:name) || 'api_key' -%>
|
||||
<%= "<" + "% items = local_assigns[:#{file_name}_items] || @#{file_name}_items || Current.family.#{file_name}_items.where.not(#{secret_field}: [nil, \"\"]) %" + ">" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%% if items&.any? %>
|
||||
<%= "<" + "% if items&.any? %" + ">" %>
|
||||
<div class="w-2 h-2 bg-success rounded-full"></div>
|
||||
<p class="text-sm text-secondary">Configured and ready to use. Visit the <a href="<%%= accounts_path %>" class="link">Accounts</a> tab to manage and set up accounts.</p>
|
||||
<%% else %>
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<p class="text-sm text-secondary">Not configured</p>
|
||||
<%% end %>
|
||||
<p class="text-sm text-secondary"><%= "<" + "%= t(\"#{file_name}_items.panel.status_configured_html\", accounts_path: accounts_path).html_safe %" + ">" %></p>
|
||||
<%= "<" + "% else %" + ">" %>
|
||||
<div class="w-2 h-2 bg-gray rounded-full"></div>
|
||||
<p class="text-sm text-secondary"><%= "<" + "%= t(\"#{file_name}_items.panel.status_not_configured\") %" + ">" %></p>
|
||||
<%= "<" + "% end %" + ">" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
133
lib/generators/provider/family/templates/processor_test.rb.tt
Normal file
133
lib/generators/provider/family/templates/processor_test.rb.tt
Normal file
@@ -0,0 +1,133 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class <%= class_name %>Account::ProcessorTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:empty)
|
||||
# TODO: Create or reference your <%= file_name %>_item fixture
|
||||
# @<%= file_name %>_item = <%= file_name %>_items(:configured_item)
|
||||
# @<%= file_name %>_account = <%= file_name %>_accounts(:test_account)
|
||||
|
||||
# Create a linked Sure account for the provider account
|
||||
@account = @family.accounts.create!(
|
||||
name: "Test Investment",
|
||||
balance: 10000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
|
||||
# TODO: Link the provider account to the Sure account
|
||||
# @<%= file_name %>_account.ensure_account_provider!(@account)
|
||||
# @<%= file_name %>_account.reload
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# Processor tests
|
||||
# ==========================================================================
|
||||
|
||||
test "processor initializes with <%= file_name %>_account" do
|
||||
skip "TODO: Set up <%= file_name %>_account fixture"
|
||||
|
||||
# processor = <%= class_name %>Account::Processor.new(@<%= file_name %>_account)
|
||||
# assert_not_nil processor
|
||||
end
|
||||
|
||||
test "processor skips processing when no linked account" do
|
||||
skip "TODO: Set up <%= file_name %>_account fixture"
|
||||
|
||||
# Remove the account provider link
|
||||
# @<%= file_name %>_account.account_provider&.destroy
|
||||
# @<%= file_name %>_account.reload
|
||||
|
||||
# processor = <%= class_name %>Account::Processor.new(@<%= file_name %>_account)
|
||||
# assert_nothing_raised { processor.process }
|
||||
end
|
||||
|
||||
test "processor updates account balance" do
|
||||
skip "TODO: Set up <%= file_name %>_account fixture"
|
||||
|
||||
# @<%= file_name %>_account.update!(current_balance: 15000)
|
||||
#
|
||||
# processor = <%= class_name %>Account::Processor.new(@<%= file_name %>_account)
|
||||
# processor.process
|
||||
#
|
||||
# @account.reload
|
||||
# assert_equal 15000, @account.balance.to_f
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# HoldingsProcessor tests
|
||||
# ==========================================================================
|
||||
|
||||
test "holdings processor creates holdings from raw payload" do
|
||||
skip "TODO: Set up <%= file_name %>_account fixture and holdings payload"
|
||||
|
||||
# @<%= file_name %>_account.update!(raw_holdings_payload: [
|
||||
# {
|
||||
# "symbol" => { "symbol" => "AAPL", "name" => "Apple Inc" },
|
||||
# "units" => 10,
|
||||
# "price" => 150.00,
|
||||
# "currency" => { "code" => "USD" }
|
||||
# }
|
||||
# ])
|
||||
#
|
||||
# processor = <%= class_name %>Account::HoldingsProcessor.new(@<%= file_name %>_account)
|
||||
# processor.process
|
||||
#
|
||||
# holding = @account.holdings.find_by(security: Security.find_by(ticker: "AAPL"))
|
||||
# assert_not_nil holding
|
||||
# assert_equal 10, holding.qty.to_f
|
||||
end
|
||||
|
||||
test "holdings processor skips blank symbols" do
|
||||
skip "TODO: Set up <%= file_name %>_account fixture"
|
||||
|
||||
# @<%= file_name %>_account.update!(raw_holdings_payload: [
|
||||
# { "symbol" => nil, "units" => 10, "price" => 100.00 }
|
||||
# ])
|
||||
#
|
||||
# processor = <%= class_name %>Account::HoldingsProcessor.new(@<%= file_name %>_account)
|
||||
# assert_nothing_raised { processor.process }
|
||||
end
|
||||
|
||||
# ==========================================================================
|
||||
# ActivitiesProcessor tests
|
||||
# ==========================================================================
|
||||
|
||||
test "activities processor creates trades from raw payload" do
|
||||
skip "TODO: Set up <%= file_name %>_account fixture and activities payload"
|
||||
|
||||
# @<%= file_name %>_account.update!(raw_activities_payload: [
|
||||
# {
|
||||
# "id" => "trade_001",
|
||||
# "type" => "BUY",
|
||||
# "symbol" => { "symbol" => "AAPL", "name" => "Apple Inc" },
|
||||
# "units" => 10,
|
||||
# "price" => 150.00,
|
||||
# "settlement_date" => Date.current.to_s,
|
||||
# "currency" => { "code" => "USD" }
|
||||
# }
|
||||
# ])
|
||||
#
|
||||
# processor = <%= class_name %>Account::ActivitiesProcessor.new(@<%= file_name %>_account)
|
||||
# processor.process
|
||||
#
|
||||
# entry = @account.entries.find_by(external_id: "trade_001", source: "<%= file_name %>")
|
||||
# assert_not_nil entry
|
||||
# assert entry.entryable.is_a?(Trade)
|
||||
end
|
||||
|
||||
test "activities processor skips activities without external_id" do
|
||||
skip "TODO: Set up <%= file_name %>_account fixture"
|
||||
|
||||
# @<%= file_name %>_account.update!(raw_activities_payload: [
|
||||
# { "id" => nil, "type" => "BUY", "units" => 10, "price" => 100.00 }
|
||||
# ])
|
||||
#
|
||||
# processor = <%= class_name %>Account::ActivitiesProcessor.new(@<%= file_name %>_account)
|
||||
# processor.process
|
||||
#
|
||||
# assert_equal 0, @account.entries.where(source: "<%= file_name %>").count
|
||||
end
|
||||
end
|
||||
@@ -1,11 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module <%= class_name %>Item::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def <%= file_name %>_provider
|
||||
return nil unless credentials_configured?
|
||||
|
||||
# TODO: Implement provider instantiation
|
||||
# Provider::<%= class_name %>.new(<%= parsed_fields.select { |f| f[:secret] }.first&.dig(:name) || 'api_key' %><%= parsed_fields.select { |f| f[:default] }.any? ? ", " + parsed_fields.select { |f| f[:default] }.map { |f| "#{f[:name]}: effective_#{f[:name]}" }.join(", ") : "" %>)
|
||||
raise NotImplementedError, "Implement <%= file_name %>_provider method in #{__FILE__}"
|
||||
Provider::<%= class_name %>.new(
|
||||
<% parsed_fields.select { |f| f[:secret] }.each_with_index do |field, index| -%>
|
||||
<%= field[:name] %>: <%= field[:name] %><%= index < parsed_fields.select { |f| f[:secret] }.size - 1 ? ',' : '' %>
|
||||
<% end -%>
|
||||
<% if parsed_fields.select { |f| f[:default] }.any? -%>
|
||||
<% parsed_fields.select { |f| f[:default] }.each_with_index do |field, index| -%>
|
||||
<%= field[:name] %>: effective_<%= field[:name] %><%= index < parsed_fields.select { |f| f[:default] }.size - 1 ? ',' : '' %>
|
||||
<% end -%>
|
||||
<% end -%>
|
||||
)
|
||||
end
|
||||
|
||||
# Returns credentials hash for API calls that need them passed explicitly
|
||||
def <%= file_name %>_credentials
|
||||
return nil unless credentials_configured?
|
||||
|
||||
{
|
||||
<% parsed_fields.select { |f| f[:secret] }.each_with_index do |field, index| -%>
|
||||
<%= field[:name] %>: <%= field[:name] %><%= index < parsed_fields.select { |f| f[:secret] }.size - 1 ? ',' : '' %>
|
||||
<% end -%>
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
118
lib/generators/provider/family/templates/provider_sdk.rb.tt
Normal file
118
lib/generators/provider/family/templates/provider_sdk.rb.tt
Normal file
@@ -0,0 +1,118 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Provider::<%= class_name %>
|
||||
class Error < StandardError; end
|
||||
class ConfigurationError < Error; end
|
||||
class AuthenticationError < Error; end
|
||||
class ApiError < Error
|
||||
attr_reader :status_code, :response_body
|
||||
|
||||
def initialize(message, status_code: nil, response_body: nil)
|
||||
super(message)
|
||||
@status_code = status_code
|
||||
@response_body = response_body
|
||||
end
|
||||
end
|
||||
|
||||
# Retry configuration for transient network failures
|
||||
MAX_RETRIES = 3
|
||||
INITIAL_RETRY_DELAY = 2 # seconds
|
||||
MAX_RETRY_DELAY = 30 # seconds
|
||||
|
||||
def initialize(<%= parsed_fields.map { |f| "#{f[:name]}:" }.join(", ") %>)
|
||||
<% parsed_fields.each do |field| -%>
|
||||
@<%= field[:name] %> = <%= field[:name] %>
|
||||
<% end -%>
|
||||
validate_configuration!
|
||||
end
|
||||
|
||||
# TODO: Implement provider-specific API methods
|
||||
# Example methods based on common provider patterns:
|
||||
|
||||
# def list_accounts(**credentials)
|
||||
# with_retries("list_accounts") do
|
||||
# # API call to list accounts
|
||||
# end
|
||||
# end
|
||||
|
||||
# def get_holdings(account_id:, **credentials)
|
||||
# with_retries("get_holdings") do
|
||||
# # API call to get holdings for an account
|
||||
# end
|
||||
# end
|
||||
|
||||
# def get_activities(account_id:, start_date:, end_date: Date.current, **credentials)
|
||||
# with_retries("get_activities") do
|
||||
# # API call to get activities/transactions
|
||||
# end
|
||||
# end
|
||||
|
||||
# def delete_connection(authorization_id:, **credentials)
|
||||
# with_retries("delete_connection") do
|
||||
# # API call to delete a connection
|
||||
# end
|
||||
# end
|
||||
|
||||
private
|
||||
|
||||
def validate_configuration!
|
||||
<% parsed_fields.select { |f| f[:secret] }.each do |field| -%>
|
||||
raise ConfigurationError, "<%= field[:name].humanize %> is required" if @<%= field[:name] %>.blank?
|
||||
<% end -%>
|
||||
end
|
||||
|
||||
def with_retries(operation_name, max_retries: MAX_RETRIES)
|
||||
retries = 0
|
||||
|
||||
begin
|
||||
yield
|
||||
rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Errno::ECONNRESET, Errno::ETIMEDOUT => e
|
||||
retries += 1
|
||||
|
||||
if retries <= max_retries
|
||||
delay = calculate_retry_delay(retries)
|
||||
Rails.logger.warn(
|
||||
"<%= class_name %> API: #{operation_name} failed (attempt #{retries}/#{max_retries}): " \
|
||||
"#{e.class}: #{e.message}. Retrying in #{delay}s..."
|
||||
)
|
||||
sleep(delay)
|
||||
retry
|
||||
else
|
||||
Rails.logger.error(
|
||||
"<%= class_name %> API: #{operation_name} failed after #{max_retries} retries: " \
|
||||
"#{e.class}: #{e.message}"
|
||||
)
|
||||
raise ApiError.new("Network error after #{max_retries} retries: #{e.message}")
|
||||
end
|
||||
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
|
||||
|
||||
def handle_api_error(error, operation)
|
||||
status = error.respond_to?(:code) ? error.code : nil
|
||||
body = error.respond_to?(:response_body) ? error.response_body : nil
|
||||
|
||||
Rails.logger.error("<%= class_name %> API error (#{operation}): #{status} - #{error.message}")
|
||||
|
||||
case status
|
||||
when 401, 403
|
||||
raise AuthenticationError, "Authentication failed: #{error.message}"
|
||||
when 429
|
||||
raise ApiError.new("Rate limit exceeded. Please try again later.", status_code: status, response_body: body)
|
||||
when 500..599
|
||||
raise ApiError.new("<%= class_name %> server error (#{status}). Please try again later.", status_code: status, response_body: body)
|
||||
else
|
||||
raise ApiError.new("<%= class_name %> API error: #{error.message}", status_code: status, response_body: body)
|
||||
end
|
||||
end
|
||||
|
||||
# TODO: Implement api_client method
|
||||
# def api_client
|
||||
# @api_client ||= SomeProviderGem::Client.new(api_key: @api_key)
|
||||
# end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
<%%= "<" + "% content_for :title, t(\"#{file_name}_items.select_existing_account.title\") %" + ">" %>
|
||||
|
||||
<%%= "<" + "%= render DS::Dialog.new do |dialog| %" + ">" %>
|
||||
<%%= "<" + "% dialog.with_header(title: t(\"#{file_name}_items.select_existing_account.header\")) do %" + ">" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%%= "<" + "%= icon \"link\", class: \"text-primary\" %" + ">" %>
|
||||
<span class="text-primary"><%%= "<" + "%= t(\"#{file_name}_items.select_existing_account.subtitle\", account_name: @account.name) %" + ">" %></span>
|
||||
</div>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
|
||||
<%%= "<" + "% dialog.with_body do %" + ">" %>
|
||||
<%%= "<" + "% if @#{file_name}_accounts.blank? %" + ">" %>
|
||||
<div class="text-center py-8">
|
||||
<%%= "<" + "%= icon \"alert-circle\", class: \"text-warning mx-auto mb-4\", size: \"lg\" %" + ">" %>
|
||||
<p class="text-secondary"><%%= "<" + "%= t(\"#{file_name}_items.select_existing_account.no_accounts\") %" + ">" %></p>
|
||||
<p class="text-sm text-secondary mt-2"><%%= "<" + "%= t(\"#{file_name}_items.select_existing_account.connect_hint\") %" + ">" %></p>
|
||||
<%%= "<" + "%= link_to t(\"#{file_name}_items.select_existing_account.settings_link\"), settings_providers_path, class: \"btn btn--primary btn--sm mt-4\" %" + ">" %>
|
||||
</div>
|
||||
<%%= "<" + "% else %" + ">" %>
|
||||
<div class="space-y-4">
|
||||
<div class="bg-surface border border-primary p-4 rounded-lg mb-4">
|
||||
<p class="text-sm text-primary">
|
||||
<strong><%%= "<" + "%= t(\"#{file_name}_items.select_existing_account.linking_to\") %" + ">" %></strong>
|
||||
<%%= "<" + "%= @account.name %" + ">" %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%%= "<" + "% @#{file_name}_accounts.each do |#{file_name}_account| %" + ">" %>
|
||||
<%%= "<" + "%= form_with url: link_existing_account_#{file_name}_items_path," %>
|
||||
method: :post,
|
||||
local: true,
|
||||
class: "border border-primary rounded-lg p-4 hover:bg-surface transition-colors" do |form| <%%= "%" + ">" %>
|
||||
<%%= "<" + "%= hidden_field_tag :account_id, @account.id %" + ">" %>
|
||||
<%%= "<" + "%= hidden_field_tag :#{file_name}_account_id, #{file_name}_account.id %" + ">" %>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 class="font-medium text-primary"><%%= "<" + "%= #{file_name}_account.name %" + ">" %></h4>
|
||||
<p class="text-sm text-secondary">
|
||||
<%%= "<" + "%= t(\"#{file_name}_items.select_existing_account.balance_label\") %" + ">" %>
|
||||
<%%= "<" + "%= number_to_currency(#{file_name}_account.current_balance || 0, unit: Money::Currency.new(#{file_name}_account.currency || \"USD\").symbol) %" + ">" %>
|
||||
</p>
|
||||
</div>
|
||||
<%%= "<" + "%= render DS::Button.new(" %>
|
||||
text: t("<%= file_name %>_items.select_existing_account.link_button"),
|
||||
variant: "primary",
|
||||
size: "sm",
|
||||
type: "submit"
|
||||
) <%%= "%" + ">" %>
|
||||
</div>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<%%= "<" + "%= render DS::Link.new(" %>
|
||||
text: t("<%= file_name %>_items.select_existing_account.cancel_button"),
|
||||
variant: "secondary",
|
||||
href: account_path(@account)
|
||||
) <%%= "%" + ">" %>
|
||||
</div>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
@@ -0,0 +1,107 @@
|
||||
<%%= "<" + "% content_for :title, t(\"#{file_name}_items.setup_accounts.title\") %" + ">" %>
|
||||
|
||||
<%%= "<" + "%= render DS::Dialog.new do |dialog| %" + ">" %>
|
||||
<%%= "<" + "% dialog.with_header(title: t(\"#{file_name}_items.setup_accounts.title\")) do %" + ">" %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%%= "<" + "%= icon \"settings\", class: \"text-primary\" %" + ">" %>
|
||||
<span class="text-primary"><%%= "<" + "%= t(\"#{file_name}_items.setup_accounts.subtitle\") %" + ">" %></span>
|
||||
</div>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
|
||||
<%%= "<" + "% dialog.with_body do %" + ">" %>
|
||||
<%%= "<" + "%= form_with url: complete_account_setup_#{file_name}_item_path(@#{file_name}_item)," %>
|
||||
method: :post,
|
||||
local: true,
|
||||
id: "<%= file_name %>-setup-form",
|
||||
data: {
|
||||
controller: "loading-button",
|
||||
action: "submit->loading-button#showLoading",
|
||||
loading_button_loading_text_value: t("<%= file_name %>_items.setup_accounts.creating"),
|
||||
turbo_frame: "_top"
|
||||
},
|
||||
class: "space-y-6" do |form| <%%= "%" + ">" %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-surface border border-primary p-4 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<%%= "<" + "%= icon \"info\", size: \"sm\", class: \"text-primary mt-0.5 flex-shrink-0\" %" + ">" %>
|
||||
<div>
|
||||
<p class="text-sm text-primary">
|
||||
<%%= "<" + "%= t(\"#{file_name}_items.setup_accounts.instructions\") %" + ">" %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%%= "<" + "% if @unlinked_accounts.empty? %" + ">" %>
|
||||
<div class="text-center py-8">
|
||||
<%%= "<" + "%= icon \"alert-circle\", class: \"text-warning mx-auto mb-4\", size: \"lg\" %" + ">" %>
|
||||
<p class="text-secondary"><%%= "<" + "%= t(\"#{file_name}_items.setup_accounts.no_accounts\") %" + ">" %></p>
|
||||
</div>
|
||||
<%%= "<" + "% else %" + ">" %>
|
||||
<div data-controller="select-all">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-secondary">
|
||||
<%%= "<" + "%= t(\"#{file_name}_items.setup_accounts.accounts_count\", count: @unlinked_accounts.count) %" + ">" %>
|
||||
</span>
|
||||
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox"
|
||||
id="<%= file_name %>-select-all"
|
||||
data-action="change->select-all#toggle"
|
||||
class="checkbox checkbox--dark">
|
||||
<span class="text-secondary"><%%= "<" + "%= t(\"#{file_name}_items.setup_accounts.select_all\") %" + ">" %></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<%%= "<" + "% @unlinked_accounts.each do |#{file_name}_account| %" + ">" %>
|
||||
<label for="acc_<%%= "<" + "%= #{file_name}_account.id %" + ">" %>" class="flex items-center gap-3 p-3 border border-primary rounded-lg hover:bg-surface transition-colors cursor-pointer">
|
||||
<%%= "<" + "%= check_box_tag \"selected_accounts[]\"," %>
|
||||
<%%= "<" + "%= #{file_name}_account.id," %>
|
||||
false,
|
||||
id: "acc_#{<%%= "<" + "%= #{file_name}_account.id %" + ">" %>}",
|
||||
class: "checkbox checkbox--dark",
|
||||
data: { select_all_target: "checkbox" } <%%= "%" + ">" %>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-primary truncate">
|
||||
<%%= "<" + "%= #{file_name}_account.name %" + ">" %>
|
||||
</p>
|
||||
<%%= "<" + "% if #{file_name}_account.account_type.present? %" + ">" %>
|
||||
<p class="text-xs text-secondary">
|
||||
<%%= "<" + "%= #{file_name}_account.account_type.titleize %" + ">" %>
|
||||
</p>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<p class="text-sm font-medium text-primary">
|
||||
<%%= "<" + "%= number_to_currency(#{file_name}_account.current_balance || 0, unit: Money::Currency.new(#{file_name}_account.currency || \"USD\").symbol) %" + ">" %>
|
||||
</p>
|
||||
<p class="text-xs text-secondary">
|
||||
<%%= "<" + "%= #{file_name}_account.currency || \"USD\" %" + ">" %>
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
</div>
|
||||
</div>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%%= "<" + "%= render DS::Button.new(" %>
|
||||
text: t("<%= file_name %>_items.setup_accounts.import_selected"),
|
||||
variant: "primary",
|
||||
icon: "plus",
|
||||
type: "submit",
|
||||
class: "flex-1",
|
||||
data: { loading_button_target: "button" }
|
||||
) <%%= "%" + ">" %>
|
||||
<%%= "<" + "%= render DS::Link.new(" %>
|
||||
text: t("<%= file_name %>_items.setup_accounts.cancel"),
|
||||
variant: "secondary",
|
||||
href: accounts_path
|
||||
) <%%= "%" + ">" %>
|
||||
</div>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
<%%= "<" + "% end %" + ">" %>
|
||||
86
lib/generators/provider/family/templates/syncer.rb.tt
Normal file
86
lib/generators/provider/family/templates/syncer.rb.tt
Normal file
@@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class <%= class_name %>Item::Syncer
|
||||
include SyncStats::Collector
|
||||
|
||||
attr_reader :<%= file_name %>_item
|
||||
|
||||
def initialize(<%= file_name %>_item)
|
||||
@<%= file_name %>_item = <%= file_name %>_item
|
||||
end
|
||||
|
||||
def perform_sync(sync)
|
||||
Rails.logger.info "<%= class_name %>Item::Syncer - Starting sync for item #{<%= file_name %>_item.id}"
|
||||
|
||||
# Phase 1: Import data from provider API
|
||||
sync.update!(status_text: I18n.t("<%= file_name %>_items.sync.status.importing")) if sync.respond_to?(:status_text)
|
||||
<%= file_name %>_item.import_latest_<%= file_name %>_data(sync: sync)
|
||||
|
||||
# Phase 2: Collect setup statistics
|
||||
finalize_setup_counts(sync)
|
||||
|
||||
# Phase 3: Process holdings and activities for linked accounts
|
||||
linked_<%= file_name %>_accounts = <%= file_name %>_item.linked_<%= file_name %>_accounts.includes(account_provider: :account)
|
||||
if linked_<%= file_name %>_accounts.any?
|
||||
sync.update!(status_text: I18n.t("<%= file_name %>_items.sync.status.processing")) if sync.respond_to?(:status_text)
|
||||
mark_import_started(sync)
|
||||
<%= file_name %>_item.process_accounts
|
||||
|
||||
# Phase 4: Schedule balance calculations
|
||||
sync.update!(status_text: I18n.t("<%= file_name %>_items.sync.status.calculating")) if sync.respond_to?(:status_text)
|
||||
<%= file_name %>_item.schedule_account_syncs(
|
||||
parent_sync: sync,
|
||||
window_start_date: sync.window_start_date,
|
||||
window_end_date: sync.window_end_date
|
||||
)
|
||||
|
||||
# Phase 5: Collect transaction, trades, and holdings statistics
|
||||
account_ids = linked_<%= file_name %>_accounts.filter_map { |pa| pa.current_account&.id }
|
||||
collect_transaction_stats(sync, account_ids: account_ids, source: "<%= file_name %>")
|
||||
collect_trades_stats(sync, account_ids: account_ids, source: "<%= file_name %>")
|
||||
collect_holdings_stats(sync, holdings_count: count_holdings, label: "processed")
|
||||
end
|
||||
|
||||
# Mark sync health
|
||||
collect_health_stats(sync, errors: nil)
|
||||
rescue Provider::<%= class_name %>::AuthenticationError => e
|
||||
<%= file_name %>_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
|
||||
|
||||
# Public: called by Sync after finalization
|
||||
def perform_post_sync
|
||||
# Override for post-sync cleanup if needed
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def count_holdings
|
||||
<%= file_name %>_item.<%= file_name %>_accounts.sum { |pa| Array(pa.raw_holdings_payload).size }
|
||||
end
|
||||
|
||||
def mark_import_started(sync)
|
||||
# Mark that we're now processing imported data
|
||||
sync.update!(status_text: I18n.t("<%= file_name %>_items.sync.status.importing_data")) if sync.respond_to?(:status_text)
|
||||
end
|
||||
|
||||
def finalize_setup_counts(sync)
|
||||
sync.update!(status_text: I18n.t("<%= file_name %>_items.sync.status.checking_setup")) if sync.respond_to?(:status_text)
|
||||
|
||||
unlinked_count = <%= file_name %>_item.unlinked_accounts_count
|
||||
|
||||
if unlinked_count > 0
|
||||
<%= file_name %>_item.update!(pending_account_setup: true)
|
||||
sync.update!(status_text: I18n.t("<%= file_name %>_items.sync.status.needs_setup", count: unlinked_count)) if sync.respond_to?(:status_text)
|
||||
else
|
||||
<%= file_name %>_item.update!(pending_account_setup: false)
|
||||
end
|
||||
|
||||
# Collect setup stats
|
||||
collect_setup_stats(sync, provider_accounts: <%= file_name %>_item.<%= file_name %>_accounts)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user