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\") %" + ">" %>
<% parsed_fields.each do |field| -%>
- - <%= "<" + "%= t(\"#{file_name}_items.panel.fields.#{field[:name]}.label\") %" + ">" %>: <%= "<" + "%= t(\"#{file_name}_items.panel.fields.#{field[:name]}.description\") %" + ">" %><%= field[:secret] ? ' (required)' : '' %><%= field[:default] ? " (optional, defaults to #{field[:default]})" : '' %>
+ - <%= "<" + "%= t(\"#{file_name}_items.panel.fields.#{field[:name]}.label\") %" + ">" %>: <%= "<" + "%= t(\"#{file_name}_items.panel.fields.#{field[:name]}.description\") %" + ">" %><% if field[:secret] -%> <%= "<" + "%= t(\"#{file_name}_items.panel.required\") %" + ">" %><% end -%><% if field[:default] -%> <%= "<" + "%= t(\"#{file_name}_items.panel.optional_with_default\", default_value: \"#{field[:default]}\") %" + ">" %><% end -%>
<% end -%>
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