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 %" + ">" %> +
+ +
+ <%%= "<" + "%= icon \"chevron-right\", class: \"group-open:transform group-open:rotate-90\" %" + ">" %> + +
+
+ <%%= "<" + "%= tag.p #{file_name}_item.name.first.upcase, class: \"text-primary text-xs font-medium\" %" + ">" %> +
+
+ +
+
+ <%%= "<" + "%= tag.p #{file_name}_item.name, class: \"font-medium text-primary\" %" + ">" %> + <%%= "<" + "% if #{file_name}_item.scheduled_for_deletion? %" + ">" %> +

<%%= "<" + "%= t(\".deletion_in_progress\") %" + ">" %>

+ <%%= "<" + "% end %" + ">" %> +
+

<%%= "<" + "%= t(\".provider_name\") %" + ">" %>

+ <%%= "<" + "% if #{file_name}_item.syncing? %" + ">" %> +
+ <%%= "<" + "%= icon \"loader\", size: \"sm\", class: \"animate-spin\" %" + ">" %> + <%%= "<" + "%= tag.span t(\".syncing\") %" + ">" %> +
+ <%%= "<" + "% elsif #{file_name}_item.requires_update? %" + ">" %> +
+ <%%= "<" + "%= icon \"alert-triangle\", size: \"sm\", color: \"warning\" %" + ">" %> + <%%= "<" + "%= tag.span t(\".requires_update\") %" + ">" %> +
+ <%%= "<" + "% else %" + ">" %> +

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

+ <%%= "<" + "% end %" + ">" %> +
+
+ +
+ <%%= "<" + "% 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 %" + ">" %> +
+
+ + <%%= "<" + "% unless #{file_name}_item.scheduled_for_deletion? %" + ">" %> +
+ <%%= "<" + "% 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 %" + ">" %> +
+

<%%= "<" + "%= t(\".setup_needed\") %" + ">" %>

+

<%%= "<" + "%= t(\".setup_description\") %" + ">" %>

+ <%%= "<" + "%= render DS::Link.new(" %> + text: t(".setup_action"), + icon: "plus", + variant: "primary", + href: setup_accounts_<%= file_name %>_item_path(<%= file_name %>_item), + frame: :modal + ) <%%= "%" + ">" %> +
+ <%%= "<" + "% elsif unlinked_count.to_i > 0 %" + ">" %> + <%%= "<" + "%# Some accounts imported, more available - show subtle link %" + ">" %> +
+ <%%= "<" + "%= 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\" %" + ">" %> + <%%= "<" + "%= t(\".more_accounts_available\", count: unlinked_count) %" + ">" %> + <%%= "<" + "% end %" + ">" %> +
+ <%%= "<" + "% elsif #{file_name}_item.accounts.empty? && #{file_name}_item.#{file_name}_accounts.none? %" + ">" %> + <%%= "<" + "%# No provider accounts at all - waiting for sync %" + ">" %> +
+

<%%= "<" + "%= t(\".no_accounts_title\") %" + ">" %>

+

<%%= "<" + "%= t(\".no_accounts_description\") %" + ">" %>

+
+ <%%= "<" + "% end %" + ">" %> +
+ <%%= "<" + "% end %" + ">" %> +
+<%%= "<" + "% end %" + ">" %> diff --git a/lib/generators/provider/family/templates/locale.en.yml.tt b/lib/generators/provider/family/templates/locale.en.yml.tt index bcdbeae6c..625e37770 100644 --- a/lib/generators/provider/family/templates/locale.en.yml.tt +++ b/lib/generators/provider/family/templates/locale.en.yml.tt @@ -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 Accounts 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." diff --git a/lib/generators/provider/family/templates/migration.rb.tt b/lib/generators/provider/family/templates/migration.rb.tt index d61b1ea2b..d23ace32a 100644 --- a/lib/generators/provider/family/templates/migration.rb.tt +++ b/lib/generators/provider/family/templates/migration.rb.tt @@ -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 diff --git a/lib/generators/provider/family/templates/panel.html.erb.tt b/lib/generators/provider/family/templates/panel.html.erb.tt index a3c94e25f..aef496400 100644 --- a/lib/generators/provider/family/templates/panel.html.erb.tt +++ b/lib/generators/provider/family/templates/panel.html.erb.tt @@ -1,71 +1,72 @@
-

Setup instructions:

+

<%= "<" + "%= t(\"#{file_name}_items.panel.setup_instructions\") %" + ">" %>

    -
  1. Visit your <%= class_name.titleize %> dashboard to get your credentials
  2. -
  3. Enter your credentials below and click the Save button
  4. -
  5. After a successful connection, go to the Accounts tab to set up new accounts
  6. +
  7. <%= "<" + "%= t(\"#{file_name}_items.panel.step_1\") %" + ">" %>
  8. +
  9. <%= "<" + "%= t(\"#{file_name}_items.panel.step_2\") %" + ">" %>
  10. +
  11. <%= "<" + "%= t(\"#{file_name}_items.panel.step_3\") %" + ">" %>
-

Field descriptions:

+

<%= "<" + "%= t(\"#{file_name}_items.panel.field_descriptions\") %" + ">" %>

- <%% error_msg = local_assigns[:error_message] || @error_message %> - <%% if error_msg.present? %> + <%= "<" + "% error_msg = local_assigns[:error_message] || @error_message %" + ">" %> + <%= "<" + "% if error_msg.present? %" + ">" %>
-

<%%= error_msg %>

+

" %>"><%= "<" + "%= error_msg %" + ">" %>

- <%% 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 -%>
- <%%= 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" <%= "%" + ">" %>
- <%% 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, \"\"]) %" + ">" %>
- <%% if items&.any? %> + <%= "<" + "% if items&.any? %" + ">" %>
-

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 %" + ">" %>
diff --git a/lib/generators/provider/family/templates/processor_test.rb.tt b/lib/generators/provider/family/templates/processor_test.rb.tt new file mode 100644 index 000000000..9302a004e --- /dev/null +++ b/lib/generators/provider/family/templates/processor_test.rb.tt @@ -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 diff --git a/lib/generators/provider/family/templates/provided_concern.rb.tt b/lib/generators/provider/family/templates/provided_concern.rb.tt index c193b0da5..c0d25f790 100644 --- a/lib/generators/provider/family/templates/provided_concern.rb.tt +++ b/lib/generators/provider/family/templates/provided_concern.rb.tt @@ -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 diff --git a/lib/generators/provider/family/templates/provider_sdk.rb.tt b/lib/generators/provider/family/templates/provider_sdk.rb.tt new file mode 100644 index 000000000..7d074a860 --- /dev/null +++ b/lib/generators/provider/family/templates/provider_sdk.rb.tt @@ -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 diff --git a/lib/generators/provider/family/templates/select_existing_account.html.erb.tt b/lib/generators/provider/family/templates/select_existing_account.html.erb.tt new file mode 100644 index 000000000..f08e3ada0 --- /dev/null +++ b/lib/generators/provider/family/templates/select_existing_account.html.erb.tt @@ -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 %" + ">" %> +
+ <%%= "<" + "%= icon \"link\", class: \"text-primary\" %" + ">" %> + <%%= "<" + "%= t(\"#{file_name}_items.select_existing_account.subtitle\", account_name: @account.name) %" + ">" %> +
+ <%%= "<" + "% end %" + ">" %> + + <%%= "<" + "% dialog.with_body do %" + ">" %> + <%%= "<" + "% if @#{file_name}_accounts.blank? %" + ">" %> +
+ <%%= "<" + "%= icon \"alert-circle\", class: \"text-warning mx-auto mb-4\", size: \"lg\" %" + ">" %> +

<%%= "<" + "%= 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\" %" + ">" %> +
+ <%%= "<" + "% else %" + ">" %> +
+
+

+ <%%= "<" + "%= t(\"#{file_name}_items.select_existing_account.linking_to\") %" + ">" %> + <%%= "<" + "%= @account.name %" + ">" %> +

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

<%%= "<" + "%= #{file_name}_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) %" + ">" %> +

+
+ <%%= "<" + "%= render DS::Button.new(" %> + text: t("<%= file_name %>_items.select_existing_account.link_button"), + variant: "primary", + size: "sm", + type: "submit" + ) <%%= "%" + ">" %> +
+ <%%= "<" + "% end %" + ">" %> + <%%= "<" + "% end %" + ">" %> +
+ +
+ <%%= "<" + "%= render DS::Link.new(" %> + text: t("<%= file_name %>_items.select_existing_account.cancel_button"), + variant: "secondary", + href: account_path(@account) + ) <%%= "%" + ">" %> +
+ <%%= "<" + "% end %" + ">" %> + <%%= "<" + "% end %" + ">" %> +<%%= "<" + "% end %" + ">" %> diff --git a/lib/generators/provider/family/templates/setup_accounts.html.erb.tt b/lib/generators/provider/family/templates/setup_accounts.html.erb.tt new file mode 100644 index 000000000..c76b25ef6 --- /dev/null +++ b/lib/generators/provider/family/templates/setup_accounts.html.erb.tt @@ -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 %" + ">" %> +
+ <%%= "<" + "%= icon \"settings\", class: \"text-primary\" %" + ">" %> + <%%= "<" + "%= t(\"#{file_name}_items.setup_accounts.subtitle\") %" + ">" %> +
+ <%%= "<" + "% 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| <%%= "%" + ">" %> + +
+
+
+ <%%= "<" + "%= icon \"info\", size: \"sm\", class: \"text-primary mt-0.5 flex-shrink-0\" %" + ">" %> +
+

+ <%%= "<" + "%= t(\"#{file_name}_items.setup_accounts.instructions\") %" + ">" %> +

+
+
+
+ + <%%= "<" + "% if @unlinked_accounts.empty? %" + ">" %> +
+ <%%= "<" + "%= icon \"alert-circle\", class: \"text-warning mx-auto mb-4\", size: \"lg\" %" + ">" %> +

<%%= "<" + "%= t(\"#{file_name}_items.setup_accounts.no_accounts\") %" + ">" %>

+
+ <%%= "<" + "% else %" + ">" %> +
+
+ + <%%= "<" + "%= t(\"#{file_name}_items.setup_accounts.accounts_count\", count: @unlinked_accounts.count) %" + ">" %> + + +
+ +
+ <%%= "<" + "% @unlinked_accounts.each do |#{file_name}_account| %" + ">" %> + + <%%= "<" + "% end %" + ">" %> +
+
+ <%%= "<" + "% end %" + ">" %> +
+ +
+ <%%= "<" + "%= 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 + ) <%%= "%" + ">" %> +
+ <%%= "<" + "% end %" + ">" %> + <%%= "<" + "% end %" + ">" %> +<%%= "<" + "% end %" + ">" %> diff --git a/lib/generators/provider/family/templates/syncer.rb.tt b/lib/generators/provider/family/templates/syncer.rb.tt new file mode 100644 index 000000000..d05afa611 --- /dev/null +++ b/lib/generators/provider/family/templates/syncer.rb.tt @@ -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