diff --git a/lib/generators/provider/family/family_generator.rb b/lib/generators/provider/family/family_generator.rb
index fb38528d4..c94d34855 100644
--- a/lib/generators/provider/family/family_generator.rb
+++ b/lib/generators/provider/family/family_generator.rb
@@ -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
diff --git a/lib/generators/provider/family/templates/account_model.rb.tt b/lib/generators/provider/family/templates/account_model.rb.tt
index d61f00758..91992ce37 100644
--- a/lib/generators/provider/family/templates/account_model.rb.tt
+++ b/lib/generators/provider/family/templates/account_model.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/account_processor.rb.tt b/lib/generators/provider/family/templates/account_processor.rb.tt
new file mode 100644
index 000000000..ac402d05c
--- /dev/null
+++ b/lib/generators/provider/family/templates/account_processor.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/activities_fetch_job.rb.tt b/lib/generators/provider/family/templates/activities_fetch_job.rb.tt
new file mode 100644
index 000000000..a37362bf0
--- /dev/null
+++ b/lib/generators/provider/family/templates/activities_fetch_job.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/activities_processor.rb.tt b/lib/generators/provider/family/templates/activities_processor.rb.tt
new file mode 100644
index 000000000..d55fc5ba5
--- /dev/null
+++ b/lib/generators/provider/family/templates/activities_processor.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/connection_cleanup_job.rb.tt b/lib/generators/provider/family/templates/connection_cleanup_job.rb.tt
new file mode 100644
index 000000000..469e93f43
--- /dev/null
+++ b/lib/generators/provider/family/templates/connection_cleanup_job.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/controller.rb.tt b/lib/generators/provider/family/templates/controller.rb.tt
index cd9334d9c..fb3d29e5e 100644
--- a/lib/generators/provider/family/templates/controller.rb.tt
+++ b/lib/generators/provider/family/templates/controller.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/data_helpers.rb.tt b/lib/generators/provider/family/templates/data_helpers.rb.tt
new file mode 100644
index 000000000..afac91f10
--- /dev/null
+++ b/lib/generators/provider/family/templates/data_helpers.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/data_helpers_test.rb.tt b/lib/generators/provider/family/templates/data_helpers_test.rb.tt
new file mode 100644
index 000000000..f8be0e9fa
--- /dev/null
+++ b/lib/generators/provider/family/templates/data_helpers_test.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/holdings_processor.rb.tt b/lib/generators/provider/family/templates/holdings_processor.rb.tt
new file mode 100644
index 000000000..cdfa81ae8
--- /dev/null
+++ b/lib/generators/provider/family/templates/holdings_processor.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/importer.rb.tt b/lib/generators/provider/family/templates/importer.rb.tt
new file mode 100644
index 000000000..b736b15ef
--- /dev/null
+++ b/lib/generators/provider/family/templates/importer.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/item_model.rb.tt b/lib/generators/provider/family/templates/item_model.rb.tt
index 37709516f..12f485df0 100644
--- a/lib/generators/provider/family/templates/item_model.rb.tt
+++ b/lib/generators/provider/family/templates/item_model.rb.tt
@@ -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
diff --git a/lib/generators/provider/family/templates/item_partial.html.erb.tt b/lib/generators/provider/family/templates/item_partial.html.erb.tt
new file mode 100644
index 000000000..1c9878997
--- /dev/null
+++ b/lib/generators/provider/family/templates/item_partial.html.erb.tt
@@ -0,0 +1,133 @@
+<%%= "<" + "%# locals: (#{file_name}_item:) %" + ">" %>
+
+<%%= "<" + "%= tag.div id: dom_id(#{file_name}_item) do %" + ">" %>
+ <%%= "<" + "%= t(\".deletion_in_progress\") %" + ">" %> <%%= "<" + "%= t(\".provider_name\") %" + ">" %>
+ <%%= "<" + "% 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 %" + ">" %>
+ <%%= "<" + "%= t(\".setup_needed\") %" + ">" %> <%%= "<" + "%= t(\".setup_description\") %" + ">" %> <%%= "<" + "%= t(\".no_accounts_title\") %" + ">" %> <%%= "<" + "%= t(\".no_accounts_description\") %" + ">" %>
+
+
+ <%%= "<" + "% unless #{file_name}_item.scheduled_for_deletion? %" + ">" %>
+
Setup instructions:
+<%= "<" + "%= t(\"#{file_name}_items.panel.setup_instructions\") %" + ">" %>
Field descriptions:
+<%= "<" + "%= t(\"#{file_name}_items.panel.field_descriptions\") %" + ">" %>
<%%= error_msg %>
+" %>"><%= "<" + "%= error_msg %" + ">" %>
Configured and ready to use. Visit the Accounts tab to manage and set up accounts.
- <%% else %> - -Not configured
- <%% end %> +<%= "<" + "%= t(\"#{file_name}_items.panel.status_configured_html\", accounts_path: accounts_path).html_safe %" + ">" %>
+ <%= "<" + "% else %" + ">" %> + +<%= "<" + "%= t(\"#{file_name}_items.panel.status_not_configured\") %" + ">" %>
+ <%= "<" + "% end %" + ">" %><%%= "<" + "%= t(\"#{file_name}_items.select_existing_account.no_accounts\") %" + ">" %>
+<%%= "<" + "%= t(\"#{file_name}_items.select_existing_account.connect_hint\") %" + ">" %>
+ <%%= "<" + "%= link_to t(\"#{file_name}_items.select_existing_account.settings_link\"), settings_providers_path, class: \"btn btn--primary btn--sm mt-4\" %" + ">" %> ++ <%%= "<" + "%= t(\"#{file_name}_items.select_existing_account.linking_to\") %" + ">" %> + <%%= "<" + "%= @account.name %" + ">" %> +
++ <%%= "<" + "%= 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) %" + ">" %> +
++ <%%= "<" + "%= t(\"#{file_name}_items.setup_accounts.instructions\") %" + ">" %> +
+<%%= "<" + "%= t(\"#{file_name}_items.setup_accounts.no_accounts\") %" + ">" %>
+