diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index c7d23cf51..d4e9b85ab 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -14,6 +14,7 @@ class AccountsController < ApplicationController @mercury_items = family.mercury_items.ordered.includes(:syncs, :mercury_accounts) @coinbase_items = family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs) @snaptrade_items = family.snaptrade_items.ordered.includes(:syncs, :snaptrade_accounts) + @testprovider_items = family.testprovider_items.ordered.includes(:syncs, :testprovider_accounts) # Build sync stats maps for all providers build_sync_stats_maps @@ -277,5 +278,12 @@ class AccountsController < ApplicationController .count @coinbase_unlinked_count_map[item.id] = count end + +# Testprovider sync stats +@testprovider_sync_stats_map = {} +@testprovider_items.each do |item| + latest_sync = item.syncs.ordered.first + @testprovider_sync_stats_map[item.id] = latest_sync&.sync_stats || {} +end end end diff --git a/app/controllers/settings/providers_controller.rb b/app/controllers/settings/providers_controller.rb index 196acfca4..e3da0b37d 100644 --- a/app/controllers/settings/providers_controller.rb +++ b/app/controllers/settings/providers_controller.rb @@ -126,7 +126,8 @@ class Settings::ProvidersController < ApplicationController config.provider_key.to_s.casecmp("coinstats").zero? || \ config.provider_key.to_s.casecmp("mercury").zero? || \ config.provider_key.to_s.casecmp("coinbase").zero? || \ - config.provider_key.to_s.casecmp("snaptrade").zero? + config.provider_key.to_s.casecmp("snaptrade").zero? || \ + config.provider_key.to_s.casecmp("testprovider").zero? end # Providers page only needs to know whether any SimpleFin/Lunchflow connections exist with valid credentials @@ -137,5 +138,6 @@ class Settings::ProvidersController < ApplicationController @mercury_items = Current.family.mercury_items.ordered.select(:id) @coinbase_items = Current.family.coinbase_items.ordered # Coinbase panel needs name and sync info for status display @snaptrade_items = Current.family.snaptrade_items.includes(:snaptrade_accounts).ordered + @testprovider_items = Current.family.testprovider_items.ordered.select(:id) end end diff --git a/app/models/data_enrichment.rb b/app/models/data_enrichment.rb index 2639dd451..ef9087a79 100644 --- a/app/models/data_enrichment.rb +++ b/app/models/data_enrichment.rb @@ -1,5 +1,5 @@ class DataEnrichment < ApplicationRecord belongs_to :enrichable, polymorphic: true - enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury" } + enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury" , testprovider: "testprovider"} end diff --git a/app/models/provider_merchant.rb b/app/models/provider_merchant.rb index 7a9eca82f..23103538c 100644 --- a/app/models/provider_merchant.rb +++ b/app/models/provider_merchant.rb @@ -1,5 +1,5 @@ class ProviderMerchant < Merchant - enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury" } + enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury" , testprovider: "testprovider"} validates :name, uniqueness: { scope: [ :source ] } validates :source, presence: true diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 6370f5a18..3e0b3e97a 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -57,6 +57,10 @@ <%= render @snaptrade_items.sort_by(&:created_at) %> <% end %> + <% if @testprovider_items.any? %> + <%= render @testprovider_items.sort_by(&:created_at) %> +<% end %> + <% if @manual_accounts.any? %>
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %> diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 1b6f433c4..3c23c0a45 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -59,4 +59,10 @@ <%= render "settings/providers/snaptrade_panel" %> <% end %> + +<%= settings_section title: "Testprovider", collapsible: true, open: false do %> + + <%= render "settings/providers/testprovider_panel" %> + +<% end %>
diff --git a/lib/generators/provider/family/family_generator.rb b/lib/generators/provider/family/family_generator.rb index c94d34855..e0d5b7755 100644 --- a/lib/generators/provider/family/family_generator.rb +++ b/lib/generators/provider/family/family_generator.rb @@ -4,11 +4,11 @@ require "rails/generators/active_record" # Generator for creating per-family provider integrations # # Usage: -# rails g provider:family NAME field:type:secret field:type ... +# rails g provider:family NAME field:type:secret field:type ... [--type=banking|investment] # # Examples: -# rails g provider:family lunchflow api_key:text:secret base_url:string -# rails g provider:family my_bank access_token:text:secret refresh_token:text:secret +# rails g provider:family lunchflow api_key:text:secret base_url:string --type=banking +# rails g provider:family my_broker access_token:text:secret --type=investment # # Field format: # name:type[:secret] @@ -16,6 +16,10 @@ require "rails/generators/active_record" # - type: Database column type (text, string, integer, boolean) # - secret: Optional flag indicating this field should be encrypted # +# Provider type: +# --type=banking - For bank/credit card providers (transactions only, no holdings/activities) +# --type=investment - For brokerage providers (holdings + activities) [default] +# # This generates: # - Migration creating complete provider_items and provider_accounts tables # - Models for items, accounts, and provided concern @@ -35,6 +39,9 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase class_option :skip_view, type: :boolean, default: false, desc: "Skip generating view" class_option :skip_controller, type: :boolean, default: false, desc: "Skip generating controller" class_option :skip_adapter, type: :boolean, default: false, desc: "Skip generating adapter" + class_option :type, type: :string, default: "investment", + enum: %w[banking investment], + desc: "Provider type: banking (transactions only) or investment (holdings + activities)" def validate_fields if parsed_fields.empty? @@ -119,16 +126,18 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase 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 + # Activities fetch job (investment providers only) + if investment_provider? + 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 end - # Connection cleanup job + # Connection cleanup job (both types may need cleanup on unlink) 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 @@ -505,7 +514,7 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase def show_summary say "\n" + "=" * 80, :green - say "Successfully generated per-family provider: #{class_name}", :green + say "Successfully generated per-family #{options[:type]} provider: #{class_name}", :green say "=" * 80, :green say "\nGenerated files:", :cyan @@ -519,8 +528,13 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase 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" + if investment_provider? + say " - app/models/#{file_name}_account/holdings_processor.rb" + say " - app/models/#{file_name}_account/activities_processor.rb" + end + if banking_provider? + say " - app/models/#{file_name}_account/transactions/processor.rb" + end say " - app/models/family/#{file_name}_connectable.rb" say " ๐Ÿ”Œ Provider:" say " - app/models/provider/#{file_name}.rb (SDK wrapper)" @@ -532,7 +546,9 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase 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" + if investment_provider? + say " - app/jobs/#{file_name}_activities_fetch_job.rb" + end say " - app/jobs/#{file_name}_connection_cleanup_job.rb" say " ๐Ÿงช Tests:" say " - test/models/#{file_name}_account/data_helpers_test.rb" @@ -552,7 +568,11 @@ 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 with investment support)" + if investment_provider? + say " - #{file_name}_accounts (stores individual account data with holdings/activities support)" + else + say " - #{file_name}_accounts (stores individual account data with transactions support)" + end say "\nNext steps:", :yellow say " 1. Run migrations:" @@ -561,17 +581,26 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase 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" + if investment_provider? + say " - Implement list_accounts, get_holdings, get_activities methods" + else + say " - Implement list_accounts, get_transactions methods" + end say "" 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 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" + if investment_provider? + 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" + else + say " app/models/#{file_name}_account/transactions/processor.rb" + say " - Map provider transaction format to Sure entries" + end say "" say " 5. Test the integration:" say " Visit /settings/providers and configure credentials" @@ -704,7 +733,7 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase account_dir = "app/models/#{file_name}_account" FileUtils.mkdir_p(account_dir) unless options[:pretend] - # DataHelpers concern + # DataHelpers concern (both types benefit from parse_decimal, parse_date, extract_currency) data_helpers_path = "#{account_dir}/data_helpers.rb" if File.exist?(data_helpers_path) say "DataHelpers concern already exists: #{data_helpers_path}", :skip @@ -722,22 +751,38 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase 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 + if investment_provider? + # HoldingsProcessor class (investment only) + 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 (investment only) + 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 - # 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 + if banking_provider? + # TransactionsProcessor class (banking only) + transactions_dir = "#{account_dir}/transactions" + FileUtils.mkdir_p(transactions_dir) unless options[:pretend] + + transactions_processor_path = "#{transactions_dir}/processor.rb" + if File.exist?(transactions_processor_path) + say "TransactionsProcessor class already exists: #{transactions_processor_path}", :skip + else + template "transactions_processor.rb.tt", transactions_processor_path + say "Created TransactionsProcessor class: #{transactions_processor_path}", :green + end end end @@ -758,6 +803,14 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" end + def banking_provider? + options[:type] == "banking" + end + + def investment_provider? + options[:type] == "investment" + end + def parsed_fields @parsed_fields ||= fields.map do |field_def| # Handle default values with colons (like URLs) by extracting them first diff --git a/lib/generators/provider/family/templates/account_model.rb.tt b/lib/generators/provider/family/templates/account_model.rb.tt index 91992ce37..a2fc4bc5f 100644 --- a/lib/generators/provider/family/templates/account_model.rb.tt +++ b/lib/generators/provider/family/templates/account_model.rb.tt @@ -57,6 +57,7 @@ class <%= class_name %>Account < ApplicationRecord raw_payload: account_data ) end +<% if banking_provider? -%> def upsert_<%= file_name %>_transactions_snapshot!(transactions_snapshot) assign_attributes( @@ -65,6 +66,8 @@ class <%= class_name %>Account < ApplicationRecord save! end +<% end -%> +<% if investment_provider? -%> # Store holdings snapshot - return early if empty to avoid setting timestamps incorrectly def upsert_holdings_snapshot!(holdings_data) @@ -85,6 +88,7 @@ class <%= class_name %>Account < ApplicationRecord last_activities_sync: Time.current ) end +<% end -%> private @@ -97,14 +101,22 @@ class <%= class_name %>Account < ApplicationRecord end def enqueue_connection_cleanup - return unless <%= file_name %>_authorization_id.present? return unless <%= file_name %>_item +<% if investment_provider? -%> + return unless <%= file_name %>_authorization_id.present? + <%= class_name %>ConnectionCleanupJob.perform_later( <%= file_name %>_item_id: <%= file_name %>_item.id, authorization_id: <%= file_name %>_authorization_id, account_id: id ) +<% else -%> + <%= class_name %>ConnectionCleanupJob.perform_later( + <%= file_name %>_item_id: <%= file_name %>_item.id, + account_id: id + ) +<% end -%> end def log_invalid_currency(currency_value) diff --git a/lib/generators/provider/family/templates/account_processor.rb.tt b/lib/generators/provider/family/templates/account_processor.rb.tt index ac402d05c..80dd5f846 100644 --- a/lib/generators/provider/family/templates/account_processor.rb.tt +++ b/lib/generators/provider/family/templates/account_processor.rb.tt @@ -15,9 +15,9 @@ class <%= class_name %>Account::Processor 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 FIRST (before processing transactions/holdings/activities) update_account_balance(account) +<% if investment_provider? -%> # Process holdings holdings_count = <%= file_name %>_account.raw_holdings_payload&.size || 0 @@ -40,17 +40,35 @@ class <%= class_name %>Account::Processor else Rails.logger.warn "<%= class_name %>Account::Processor - No activities payload to process" end +<% else -%> + + # Process transactions + transactions_count = <%= file_name %>_account.raw_transactions_payload&.size || 0 + Rails.logger.info "<%= class_name %>Account::Processor - Transactions payload has #{transactions_count} items" + + if <%= file_name %>_account.raw_transactions_payload.present? + Rails.logger.info "<%= class_name %>Account::Processor - Processing transactions..." + <%= class_name %>Account::Transactions::Processor.new(<%= file_name %>_account).process + else + Rails.logger.warn "<%= class_name %>Account::Processor - No transactions payload to process" + end +<% 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}" +<% if investment_provider? -%> { holdings_processed: holdings_count > 0, activities_processed: activities_count > 0 } +<% else -%> + { transactions_processed: transactions_count > 0 } +<% end -%> end private def update_account_balance(account) +<% if investment_provider? -%> # Calculate total balance and cash balance from provider data total_balance = calculate_total_balance cash_balance = calculate_cash_balance @@ -64,11 +82,32 @@ class <%= class_name %>Account::Processor currency: <%= file_name %>_account.currency || account.currency ) account.save! +<% else -%> + # Get balance from provider data + balance = <%= file_name %>_account.current_balance || 0 + + # Banking sign convention: + # - CreditCard and Loan accounts may need sign inversion + # Provider returns negative for positive balance, so we negate it + if account.accountable_type == "CreditCard" || account.accountable_type == "Loan" + balance = -balance + end + + Rails.logger.info "<%= class_name %>Account::Processor - Balance update: #{balance}" + + account.assign_attributes( + balance: balance, + cash_balance: balance, + currency: <%= file_name %>_account.currency || account.currency + ) + account.save! +<% end -%> # 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) + account.set_current_balance(<%= investment_provider? ? "total_balance" : "balance" %>) end +<% if investment_provider? -%> def calculate_total_balance # Calculate total from holdings + cash for accuracy @@ -109,4 +148,5 @@ class <%= class_name %>Account::Processor units * price end end +<% end -%> end diff --git a/lib/generators/provider/family/templates/adapter.rb.tt b/lib/generators/provider/family/templates/adapter.rb.tt index fd7075034..812aaecc6 100644 --- a/lib/generators/provider/family/templates/adapter.rb.tt +++ b/lib/generators/provider/family/templates/adapter.rb.tt @@ -7,7 +7,15 @@ class Provider::<%= class_name %>Adapter < Provider::Base # Define which account types this provider supports def self.supported_account_types +<% if banking_provider? -%> + # Banking providers typically support these account types + # TODO: Adjust based on your provider's capabilities %w[Depository CreditCard Loan] +<% else -%> + # Investment providers typically support these account types + # TODO: Adjust based on your provider's capabilities + %w[Investment Crypto] +<% end -%> end # Returns connection configurations for this provider @@ -71,9 +79,11 @@ class Provider::<%= class_name %>Adapter < Provider::Base provider_account.<%= file_name %>_item end +<% if investment_provider? -%> def can_delete_holdings? false end +<% end -%> def institution_domain metadata = provider_account.institution_metadata diff --git a/lib/generators/provider/family/templates/connection_cleanup_job.rb.tt b/lib/generators/provider/family/templates/connection_cleanup_job.rb.tt index 469e93f43..1aca50020 100644 --- a/lib/generators/provider/family/templates/connection_cleanup_job.rb.tt +++ b/lib/generators/provider/family/templates/connection_cleanup_job.rb.tt @@ -2,6 +2,7 @@ class <%= class_name %>ConnectionCleanupJob < ApplicationJob queue_as :default +<% if investment_provider? -%> def perform(<%= file_name %>_item_id:, authorization_id:, account_id:) Rails.logger.info( @@ -52,4 +53,26 @@ class <%= class_name %>ConnectionCleanupJob < ApplicationJob "<%= class_name %>ConnectionCleanupJob - API delete failed: #{e.message}" ) end +<% else -%> + + def perform(<%= file_name %>_item_id:, account_id:) + Rails.logger.info( + "<%= class_name %>ConnectionCleanupJob - Cleaning up for former account #{account_id}" + ) + + <%= file_name %>_item = <%= class_name %>Item.find_by(id: <%= file_name %>_item_id) + return unless <%= file_name %>_item + + # For banking providers, cleanup is typically simpler since there's no + # separate authorization concept - the item itself holds the credentials. + # Override this method if your provider needs specific cleanup logic. + + Rails.logger.info("<%= class_name %>ConnectionCleanupJob - Cleanup complete for account #{account_id}") + rescue => e + Rails.logger.warn( + "<%= class_name %>ConnectionCleanupJob - Failed: #{e.class} - #{e.message}" + ) + # Don't raise - cleanup failures shouldn't block other operations + end +<% end -%> end diff --git a/lib/generators/provider/family/templates/controller.rb.tt b/lib/generators/provider/family/templates/controller.rb.tt index 788800aea..99677a814 100644 --- a/lib/generators/provider/family/templates/controller.rb.tt +++ b/lib/generators/provider/family/templates/controller.rb.tt @@ -321,6 +321,14 @@ class <%= class_name %>ItemsController < ApplicationController "Loan" when "other_asset" "OtherAsset" + when "other_liability" + "OtherLiability" + when "crypto" + "Crypto" + when "property" + "Property" + when "vehicle" + "Vehicle" else "Depository" 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 index f8be0e9fa..e6a0eb71d 100644 --- a/lib/generators/provider/family/templates/data_helpers_test.rb.tt +++ b/lib/generators/provider/family/templates/data_helpers_test.rb.tt @@ -8,7 +8,11 @@ class <%= class_name %>Account::DataHelpersTest < ActiveSupport::TestCase include <%= class_name %>Account::DataHelpers # Make private methods public for testing +<% if investment_provider? -%> public :parse_decimal, :parse_date, :resolve_security, :extract_currency, :extract_security_name +<% else -%> + public :parse_decimal, :parse_date, :extract_currency +<% end -%> end setup do @@ -89,7 +93,32 @@ class <%= class_name %>Account::DataHelpersTest < ActiveSupport::TestCase end # ========================================================================== - # resolve_security tests + # 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 +<% if investment_provider? -%> + + # ========================================================================== + # resolve_security tests (investment providers only) # ========================================================================== test "resolve_security returns nil for blank ticker" do @@ -131,31 +160,7 @@ class <%= class_name %>Account::DataHelpersTest < ActiveSupport::TestCase 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 + # extract_security_name tests (investment providers only) # ========================================================================== test "extract_security_name uses name field" do @@ -177,4 +182,5 @@ class <%= class_name %>Account::DataHelpersTest < ActiveSupport::TestCase result = @helper.extract_security_name({ name: "COMMON STOCK" }, "IBM") assert_equal "IBM", result end +<% end -%> end diff --git a/lib/generators/provider/family/templates/importer.rb.tt b/lib/generators/provider/family/templates/importer.rb.tt index b736b15ef..360d5f638 100644 --- a/lib/generators/provider/family/templates/importer.rb.tt +++ b/lib/generators/provider/family/templates/importer.rb.tt @@ -3,6 +3,7 @@ class <%= class_name %>Item::Importer include SyncStats::Collector include <%= class_name %>Account::DataHelpers +<% if investment_provider? -%> # Chunk size for fetching activities ACTIVITY_CHUNK_DAYS = 365 @@ -10,6 +11,7 @@ class <%= class_name %>Item::Importer # Minimum existing activities required before using incremental sync MINIMUM_HISTORY_FOR_INCREMENTAL = 10 +<% end -%> attr_reader :<%= file_name %>_item, :<%= file_name %>_provider, :sync @@ -32,7 +34,7 @@ class <%= class_name %>Item::Importer # Step 1: Fetch and store all accounts import_accounts(credentials) - # Step 2: For LINKED accounts only, fetch holdings and activities + # Step 2: For LINKED accounts only, fetch data # 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) @@ -111,12 +113,18 @@ class <%= class_name %>Item::Importer end def import_account_data(<%= file_name %>_account, credentials) +<% if investment_provider? -%> # Import holdings import_holdings(<%= file_name %>_account, credentials) # Import activities import_activities(<%= file_name %>_account, credentials) +<% else -%> + # Import transactions + import_transactions(<%= file_name %>_account, credentials) +<% end -%> end +<% if investment_provider? -%> def import_holdings(<%= file_name %>_account, credentials) Rails.logger.info "<%= class_name %>Item::Importer - Fetching holdings for account #{<%= file_name %>_account.id}" @@ -219,6 +227,70 @@ class <%= class_name %>Item::Importer activity[:id] || activity["id"] || [ activity[:date], activity[:type], activity[:amount], activity[:symbol] ].join("-") end +<% else -%> + + def import_transactions(<%= file_name %>_account, credentials) + Rails.logger.info "<%= class_name %>Item::Importer - Fetching transactions for account #{<%= file_name %>_account.id}" + + begin + # Determine date range + start_date = calculate_transaction_start_date(<%= file_name %>_account) + end_date = Date.current + + # TODO: Implement API call to fetch transactions + # transactions_data = <%= file_name %>_provider.get_transactions( + # account_id: <%= file_name %>_account.<%= file_name %>_account_id, + # start_date: start_date, + # end_date: end_date + # ) + transactions_data = [] + + stats["api_requests"] = stats.fetch("api_requests", 0) + 1 + + if transactions_data.any? + # Convert SDK objects to hashes and merge with existing + transactions_hashes = transactions_data.map { |t| sdk_object_to_hash(t) } + merged = merge_transactions(<%= file_name %>_account.raw_transactions_payload || [], transactions_hashes) + <%= file_name %>_account.upsert_<%= file_name %>_transactions_snapshot!(merged) + stats["transactions_found"] = stats.fetch("transactions_found", 0) + transactions_data.size + end + rescue => e + Rails.logger.warn "<%= class_name %>Item::Importer - Failed to fetch transactions: #{e.message}" + register_error(e, context: "transactions", account_id: <%= file_name %>_account.id) + end + end + + def calculate_transaction_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 transactions, use incremental sync + existing_count = (<%= file_name %>_account.raw_transactions_payload || []).size + if existing_count >= 10 && <%= file_name %>_item.last_synced_at.present? + # Incremental: go back 7 days from last sync to catch updates + (<%= file_name %>_item.last_synced_at - 7.days).to_date + else + # Full sync: go back 90 days + 90.days.ago.to_date + end + end + + def merge_transactions(existing, new_transactions) + # Merge by ID, preferring newer data + by_id = {} + existing.each { |t| by_id[transaction_key(t)] = t } + new_transactions.each { |t| by_id[transaction_key(t)] = t } + by_id.values + end + + def transaction_key(transaction) + transaction = transaction.with_indifferent_access if transaction.is_a?(Hash) + # Use ID if available, otherwise generate key from date/amount/description + transaction[:id] || transaction["id"] || + [ transaction[:date], transaction[:amount], transaction[:description] ].join("-") + end +<% end -%> def prune_removed_accounts(upstream_account_ids) return if upstream_account_ids.empty? diff --git a/lib/generators/provider/family/templates/item_model.rb.tt b/lib/generators/provider/family/templates/item_model.rb.tt index 12f485df0..9cfbf1997 100644 --- a/lib/generators/provider/family/templates/item_model.rb.tt +++ b/lib/generators/provider/family/templates/item_model.rb.tt @@ -45,10 +45,12 @@ class <%= class_name %>Item < ApplicationRecord DestroyJob.perform_later(self) end +<% if investment_provider? -%> # Override syncing? to include background activities fetch def syncing? super || <%= file_name %>_accounts.where(activities_fetch_pending: true).exists? end +<% end -%> # Import data from provider API def import_latest_<%= file_name %>_data(sync: nil) diff --git a/lib/generators/provider/family/templates/item_partial.html.erb.tt b/lib/generators/provider/family/templates/item_partial.html.erb.tt index 5c2f4dd94..9ca152933 100644 --- a/lib/generators/provider/family/templates/item_partial.html.erb.tt +++ b/lib/generators/provider/family/templates/item_partial.html.erb.tt @@ -101,7 +101,7 @@ <%%= "<" + "%# No accounts imported yet - show prominent setup prompt %" + ">" %>

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

-

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

+

<%%= "<" + "%= t(\".setup_description\", linked: #{file_name}_item.accounts.count, total: #{file_name}_item.#{file_name}_accounts.count) %" + ">" %>

<%%= "<" + "%= render DS::Link.new(" %> text: t(".setup_action"), icon: "plus", diff --git a/lib/generators/provider/family/templates/locale.en.yml.tt b/lib/generators/provider/family/templates/locale.en.yml.tt index 625e37770..155c93c18 100644 --- a/lib/generators/provider/family/templates/locale.en.yml.tt +++ b/lib/generators/provider/family/templates/locale.en.yml.tt @@ -20,7 +20,11 @@ en: sync: status: importing: "Importing accounts from <%= class_name %>..." +<% if investment_provider? -%> processing: "Processing holdings and activities..." +<% else -%> + processing: "Processing transactions..." +<% end -%> calculating: "Calculating balances..." importing_data: "Importing account data..." checking_setup: "Checking account configuration..." @@ -35,6 +39,8 @@ en: step_3: "After a successful connection, go to the Accounts tab to set up new accounts" field_descriptions: "Field descriptions:" optional: "(Optional)" + required: "(required)" + optional_with_default: "(optional, defaults to %%{default_value})" 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." @@ -173,20 +179,34 @@ en: select_all: "Select all" account_types: skip: "Skip this account" +<% if banking_provider? -%> + depository: "Checking or Savings Account" + credit_card: "Credit Card" + loan: "Loan or Mortgage" + other_asset: "Other Asset" +<% else -%> depository: "Checking or Savings Account" credit_card: "Credit Card" investment: "Investment Account" + crypto: "Cryptocurrency Account" loan: "Loan or Mortgage" other_asset: "Other Asset" +<% end -%> subtype_labels: depository: "Account Subtype:" credit_card: "" +<% if investment_provider? -%> investment: "Investment Type:" + crypto: "" +<% end -%> loan: "Loan Type:" other_asset: "" subtype_messages: credit_card: "Credit cards will be automatically set up as credit card accounts." other_asset: "No additional options needed for Other Assets." +<% if investment_provider? -%> + crypto: "Cryptocurrency accounts will be set up to track holdings and transactions." +<% end -%> subtypes: depository: checking: "Checking" @@ -194,6 +214,7 @@ en: hsa: "Health Savings Account" cd: "Certificate of Deposit" money_market: "Money Market" +<% if investment_provider? -%> investment: brokerage: "Brokerage" pension: "Pension" @@ -208,6 +229,7 @@ en: ira: "Traditional IRA" roth_ira: "Roth IRA" angel: "Angel" +<% end -%> loan: mortgage: "Mortgage" student: "Student Loan" diff --git a/lib/generators/provider/family/templates/migration.rb.tt b/lib/generators/provider/family/templates/migration.rb.tt index d23ace32a..098781b6b 100644 --- a/lib/generators/provider/family/templates/migration.rb.tt +++ b/lib/generators/provider/family/templates/migration.rb.tt @@ -43,13 +43,11 @@ class Create<%= class_name %>ItemsAndAccounts < ActiveRecord::Migration<%= migra # Account identification t.string :name 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 @@ -57,14 +55,20 @@ class Create<%= class_name %>ItemsAndAccounts < ActiveRecord::Migration<%= migra # Metadata and raw data t.jsonb :institution_metadata t.jsonb :raw_payload +<% if banking_provider? -%> t.jsonb :raw_transactions_payload +<% end -%> +<% if investment_provider? -%> - # Investment data (holdings, activities) + # Investment-specific columns + t.string :<%= file_name %>_authorization_id + t.decimal :cash_balance, precision: 19, scale: 4, default: 0.0 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 +<% end -%> # Sync settings t.date :sync_start_date @@ -73,6 +77,8 @@ class Create<%= class_name %>ItemsAndAccounts < ActiveRecord::Migration<%= migra end add_index :<%= file_name %>_accounts, :<%= file_name %>_account_id, unique: true +<% if investment_provider? -%> add_index :<%= file_name %>_accounts, :<%= file_name %>_authorization_id +<% end -%> 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 aef496400..17433f318 100644 --- a/lib/generators/provider/family/templates/panel.html.erb.tt +++ b/lib/generators/provider/family/templates/panel.html.erb.tt @@ -10,7 +10,7 @@

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

diff --git a/lib/generators/provider/family/templates/processor_test.rb.tt b/lib/generators/provider/family/templates/processor_test.rb.tt index 9302a004e..1942cce3d 100644 --- a/lib/generators/provider/family/templates/processor_test.rb.tt +++ b/lib/generators/provider/family/templates/processor_test.rb.tt @@ -11,10 +11,14 @@ class <%= class_name %>Account::ProcessorTest < ActiveSupport::TestCase # Create a linked Sure account for the provider account @account = @family.accounts.create!( - name: "Test Investment", + name: "Test Account", balance: 10000, currency: "USD", +<% if investment_provider? -%> accountable: Investment.new +<% else -%> + accountable: Depository.new +<% end -%> ) # TODO: Link the provider account to the Sure account @@ -55,6 +59,7 @@ class <%= class_name %>Account::ProcessorTest < ActiveSupport::TestCase # @account.reload # assert_equal 15000, @account.balance.to_f end +<% if investment_provider? -%> # ========================================================================== # HoldingsProcessor tests @@ -130,4 +135,55 @@ class <%= class_name %>Account::ProcessorTest < ActiveSupport::TestCase # # assert_equal 0, @account.entries.where(source: "<%= file_name %>").count end +<% else -%> + + # ========================================================================== + # TransactionsProcessor tests + # ========================================================================== + + test "transactions processor creates entries from raw payload" do + skip "TODO: Set up <%= file_name %>_account fixture and transactions payload" + + # @<%= file_name %>_account.update!(raw_transactions_payload: [ + # { + # "id" => "txn_001", + # "amount" => 50.00, + # "date" => Date.current.to_s, + # "name" => "Coffee Shop", + # "pending" => false + # } + # ]) + # + # processor = <%= class_name %>Account::Transactions::Processor.new(@<%= file_name %>_account) + # result = processor.process + # + # assert result[:success] + # assert_equal 1, result[:imported] + end + + test "transactions processor handles missing transaction id gracefully" do + skip "TODO: Set up <%= file_name %>_account fixture" + + # @<%= file_name %>_account.update!(raw_transactions_payload: [ + # { "id" => nil, "amount" => 50.00, "date" => Date.current.to_s } + # ]) + # + # processor = <%= class_name %>Account::Transactions::Processor.new(@<%= file_name %>_account) + # result = processor.process + # + # assert_equal 1, result[:failed] + end + + test "transactions processor returns empty result when no transactions" do + skip "TODO: Set up <%= file_name %>_account fixture" + + # @<%= file_name %>_account.update!(raw_transactions_payload: []) + # + # processor = <%= class_name %>Account::Transactions::Processor.new(@<%= file_name %>_account) + # result = processor.process + # + # assert result[:success] + # assert_equal 0, result[:total] + 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 index 7d074a860..930861723 100644 --- a/lib/generators/provider/family/templates/provider_sdk.rb.tt +++ b/lib/generators/provider/family/templates/provider_sdk.rb.tt @@ -1,62 +1,132 @@ # 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 + include HTTParty - def initialize(message, status_code: nil, response_body: nil) + headers "User-Agent" => "Sure Finance <%= class_name %> Client" + default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120) + + class Error < StandardError + attr_reader :error_type + + def initialize(message, error_type = :unknown) super(message) - @status_code = status_code - @response_body = response_body + @error_type = error_type end end - # Retry configuration for transient network failures - MAX_RETRIES = 3 - INITIAL_RETRY_DELAY = 2 # seconds - MAX_RETRY_DELAY = 30 # seconds + class ConfigurationError < Error; end + class AuthenticationError < Error; end - def initialize(<%= parsed_fields.map { |f| "#{f[:name]}:" }.join(", ") %>) -<% parsed_fields.each do |field| -%> - @<%= field[:name] %> = <%= field[:name] %> +<% secret_fields = parsed_fields.select { |f| f[:secret] } -%> +<% non_secret_fields = parsed_fields.reject { |f| f[:secret] } -%> +<% all_attrs = parsed_fields.map { |f| f[:name] } -%> + attr_reader <%= all_attrs.map { |f| ":#{f}" }.join(", ") %> + + def initialize(<%= all_attrs.map { |f| "#{f}:" }.join(", ") %>) +<% all_attrs.each do |f| -%> + @<%= f %> = <%= f %> <% end -%> validate_configuration! end +<% if investment_provider? -%> # TODO: Implement provider-specific API methods - # Example methods based on common provider patterns: + # Example methods for investment providers: - # def list_accounts(**credentials) + # def list_accounts # with_retries("list_accounts") do - # # API call to list accounts + # response = self.class.get( + # "#{base_url}/accounts", + # headers: auth_headers + # ) + # handle_response(response) # end # end - # def get_holdings(account_id:, **credentials) + # def get_holdings(account_id:) # with_retries("get_holdings") do - # # API call to get holdings for an account + # response = self.class.get( + # "#{base_url}/accounts/#{account_id}/holdings", + # headers: auth_headers + # ) + # handle_response(response) # end # end - # def get_activities(account_id:, start_date:, end_date: Date.current, **credentials) + # def get_activities(account_id:, start_date:, end_date: Date.current) # with_retries("get_activities") do - # # API call to get activities/transactions + # response = self.class.get( + # "#{base_url}/accounts/#{account_id}/activities", + # headers: auth_headers, + # query: { start_date: start_date.to_s, end_date: end_date.to_s } + # ) + # handle_response(response) # end # end - # def delete_connection(authorization_id:, **credentials) + # def delete_connection(authorization_id:) # with_retries("delete_connection") do - # # API call to delete a connection + # response = self.class.delete( + # "#{base_url}/authorizations/#{authorization_id}", + # headers: auth_headers + # ) + # handle_response(response) # end # end +<% else -%> + + # TODO: Implement provider-specific API methods + # Example methods for banking providers: + + # def list_accounts + # with_retries("list_accounts") do + # response = self.class.get( + # "#{base_url}/accounts", + # headers: auth_headers + # ) + # handle_response(response) + # end + # end + + # def get_transactions(account_id:, start_date:, end_date: Date.current, include_pending: true) + # with_retries("get_transactions") do + # response = self.class.get( + # "#{base_url}/accounts/#{account_id}/transactions", + # headers: auth_headers, + # query: { + # start_date: start_date.to_s, + # end_date: end_date.to_s, + # include_pending: include_pending + # } + # ) + # handle_response(response) + # end + # end + + # def get_balance(account_id:) + # with_retries("get_balance") do + # response = self.class.get( + # "#{base_url}/accounts/#{account_id}/balance", + # headers: auth_headers + # ) + # handle_response(response) + # end + # end +<% end -%> private + RETRYABLE_ERRORS = [ + SocketError, Net::OpenTimeout, Net::ReadTimeout, + Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ETIMEDOUT, EOFError + ].freeze + + MAX_RETRIES = 3 + INITIAL_RETRY_DELAY = 2 # seconds + def validate_configuration! -<% parsed_fields.select { |f| f[:secret] }.each do |field| -%> +<% secret_fields.each do |field| -%> raise ConfigurationError, "<%= field[:name].humanize %> is required" if @<%= field[:name] %>.blank? <% end -%> end @@ -66,7 +136,7 @@ class Provider::<%= class_name %> begin yield - rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Errno::ECONNRESET, Errno::ETIMEDOUT => e + rescue *RETRYABLE_ERRORS => e retries += 1 if retries <= max_retries @@ -82,7 +152,7 @@ class Provider::<%= class_name %> "<%= 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}") + raise Error.new("Network error after #{max_retries} retries: #{e.message}", :network_error) end end end @@ -90,29 +160,46 @@ class Provider::<%= class_name %> 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 + [ base_delay + jitter, 30 ].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 + def auth_headers + # TODO: Customize based on your provider's authentication method + { +<% if secret_fields.any? -%> + "Authorization" => "Bearer #{@<%= secret_fields.first[:name] %>}", +<% end -%> + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end - Rails.logger.error("<%= class_name %> API error (#{operation}): #{status} - #{error.message}") - - case status - when 401, 403 - raise AuthenticationError, "Authentication failed: #{error.message}" + def handle_response(response) + case response.code + when 200, 201 + JSON.parse(response.body, symbolize_names: true) + when 400 + Rails.logger.error "<%= class_name %> API: Bad request - #{response.body}" + raise Error.new("Bad request: #{response.body}", :bad_request) + when 401 + raise AuthenticationError.new("Invalid credentials", :unauthorized) + when 403 + raise AuthenticationError.new("Access forbidden - check your permissions", :access_forbidden) + when 404 + raise Error.new("Resource not found", :not_found) when 429 - raise ApiError.new("Rate limit exceeded. Please try again later.", status_code: status, response_body: body) + raise Error.new("Rate limit exceeded. Please try again later.", :rate_limited) when 500..599 - raise ApiError.new("<%= class_name %> server error (#{status}). Please try again later.", status_code: status, response_body: body) + raise Error.new("<%= class_name %> server error (#{response.code}). Please try again later.", :server_error) else - raise ApiError.new("<%= class_name %> API error: #{error.message}", status_code: status, response_body: body) + Rails.logger.error "<%= class_name %> API: Unexpected response - Code: #{response.code}, Body: #{response.body}" + raise Error.new("Unexpected error: #{response.code} - #{response.body}", :unknown) end end +<% if non_secret_fields.any? { |f| f[:name] == "base_url" } -%> - # TODO: Implement api_client method - # def api_client - # @api_client ||= SomeProviderGem::Client.new(api_key: @api_key) - # end + def base_url + @base_url.presence || "<%= "https://api.example.com/v1" %>" # TODO: Set your provider's default base URL + end +<% end -%> end diff --git a/lib/generators/provider/family/templates/syncer.rb.tt b/lib/generators/provider/family/templates/syncer.rb.tt index d05afa611..67979b538 100644 --- a/lib/generators/provider/family/templates/syncer.rb.tt +++ b/lib/generators/provider/family/templates/syncer.rb.tt @@ -19,7 +19,7 @@ class <%= class_name %>Item::Syncer # Phase 2: Collect setup statistics finalize_setup_counts(sync) - # Phase 3: Process holdings and activities for linked accounts + # Phase 3: Process data 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) @@ -34,11 +34,15 @@ class <%= class_name %>Item::Syncer window_end_date: sync.window_end_date ) - # Phase 5: Collect transaction, trades, and holdings statistics + # Phase 5: Collect statistics account_ids = linked_<%= file_name %>_accounts.filter_map { |pa| pa.current_account&.id } +<% if investment_provider? -%> 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") +<% else -%> + collect_transaction_stats(sync, account_ids: account_ids, source: "<%= file_name %>") +<% end -%> end # Mark sync health @@ -58,10 +62,12 @@ class <%= class_name %>Item::Syncer end private +<% if investment_provider? -%> def count_holdings <%= file_name %>_item.<%= file_name %>_accounts.sum { |pa| Array(pa.raw_holdings_payload).size } end +<% end -%> def mark_import_started(sync) # Mark that we're now processing imported data diff --git a/lib/generators/provider/family/templates/transactions_processor.rb.tt b/lib/generators/provider/family/templates/transactions_processor.rb.tt new file mode 100644 index 000000000..d3ef1639a --- /dev/null +++ b/lib/generators/provider/family/templates/transactions_processor.rb.tt @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +class <%= class_name %>Account::Transactions::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 + unless <%= file_name %>_account.raw_transactions_payload.present? + Rails.logger.info "<%= class_name %>Account::Transactions::Processor - No transactions in raw_transactions_payload for <%= file_name %>_account #{<%= file_name %>_account.id}" + return { success: true, total: 0, imported: 0, failed: 0, errors: [] } + end + + total_count = <%= file_name %>_account.raw_transactions_payload.count + Rails.logger.info "<%= class_name %>Account::Transactions::Processor - Processing #{total_count} transactions for <%= file_name %>_account #{<%= file_name %>_account.id}" + + imported_count = 0 + failed_count = 0 + errors = [] + + # Each entry is processed inside a transaction, but to avoid locking up the DB when + # there are hundreds or thousands of transactions, we process them individually. + <%= file_name %>_account.raw_transactions_payload.each_with_index do |transaction_data, index| + begin + result = process_transaction(transaction_data) + + if result.nil? + # Transaction was skipped (e.g., no linked account or blank external_id) + failed_count += 1 + transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" + errors << { index: index, transaction_id: transaction_id, error: "Skipped" } + else + imported_count += 1 + end + rescue ArgumentError => e + # Validation error - log and continue + failed_count += 1 + transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" + error_message = "Validation error: #{e.message}" + Rails.logger.error "<%= class_name %>Account::Transactions::Processor - #{error_message} (transaction #{transaction_id})" + errors << { index: index, transaction_id: transaction_id, error: error_message } + rescue => e + # Unexpected error - log with full context and continue + failed_count += 1 + transaction_id = transaction_data.try(:[], :id) || transaction_data.try(:[], "id") || "unknown" + error_message = "#{e.class}: #{e.message}" + Rails.logger.error "<%= class_name %>Account::Transactions::Processor - Error processing transaction #{transaction_id}: #{error_message}" + Rails.logger.error e.backtrace.join("\n") + errors << { index: index, transaction_id: transaction_id, error: error_message } + end + end + + result = { + success: failed_count == 0, + total: total_count, + imported: imported_count, + failed: failed_count, + errors: errors + } + + if failed_count > 0 + Rails.logger.warn "<%= class_name %>Account::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions" + else + Rails.logger.info "<%= class_name %>Account::Transactions::Processor - Successfully processed #{imported_count} transactions" + end + + result + end + + private + + def account + @<%= file_name %>_account.current_account + end + + def import_adapter + @import_adapter ||= Account::ProviderImportAdapter.new(account) + end + + def process_transaction(transaction_data) + return nil unless account.present? + + data = transaction_data.with_indifferent_access + + # TODO: Customize based on your provider's transaction format + # Extract transaction fields from the provider's API response + external_id = (data[:id] || data[:transaction_id]).to_s + return nil if external_id.blank? + + # Parse transaction attributes + amount = parse_transaction_amount(data) + return nil if amount.nil? + + # TODO: Customize date field names based on your provider + date = parse_date(data[:date] || data[:transaction_date] || data[:posted_at]) + return nil if date.nil? + + name = data[:name] || data[:description] || data[:merchant_name] || "Transaction" + currency = extract_currency(data, fallback: account.currency) + + # Build provider-specific metadata for transaction.extra + extra = build_extra_metadata(data) + + Rails.logger.info "<%= class_name %>Account::Transactions::Processor - Importing transaction: id=#{external_id} amount=#{amount} date=#{date}" + + # Use ProviderImportAdapter for proper deduplication via external_id + source + import_adapter.import_transaction( + external_id: external_id, + amount: amount, + currency: currency, + date: date, + name: name[0..254], # Limit to 255 chars + source: "<%= file_name %>", + extra: extra + ) + end + + def parse_transaction_amount(data) + amount = parse_decimal(data[:amount]) + return nil if amount.nil? + + # TODO: Adjust sign convention based on your provider + # Most banking APIs use positive amounts for debits (money out) + # and negative amounts for credits (money in) + # Sure convention: positive = money out, negative = money in + # + # If your provider uses the opposite convention, negate the amount: + # amount = -amount + amount + end + + def build_extra_metadata(data) + # TODO: Customize which fields to store based on your provider + { + "<%= file_name %>" => { + "id" => data[:id] || data[:transaction_id], + "pending" => data[:pending] || data[:is_pending], + "merchant" => data[:merchant] || data[:merchant_name], + "category" => data[:category] + }.compact + } + end +end