Refactor family generator to centralize concern creation, improve SDK support, and add tests, views, jobs, and sync logic.

This commit is contained in:
luckyPipewrench
2026-01-22 22:07:22 -05:00
parent 3f5fff27ea
commit 17693f0418
22 changed files with 2651 additions and 234 deletions

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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 %" + ">" %>

View File

@@ -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."

View File

@@ -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

View File

@@ -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>

View 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

View File

@@ -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

View 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

View File

@@ -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 %" + ">" %>

View File

@@ -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 %" + ">" %>

View 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