mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 14:31:25 +00:00
Add banking support to family generator, including transactions processor, SDK updates, and related templates. Streamline logic for handling provider types.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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? %>
|
||||
<div id="manual-accounts">
|
||||
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %>
|
||||
|
||||
@@ -59,4 +59,10 @@
|
||||
<%= render "settings/providers/snaptrade_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Testprovider", collapsible: true, open: false do %>
|
||||
<turbo-frame id="testprovider-providers-panel">
|
||||
<%= render "settings/providers/testprovider_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<%%= "<" + "%# No accounts imported yet - show prominent setup prompt %" + ">" %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
<p class="text-primary font-medium text-sm"><%%= "<" + "%= t(\".setup_needed\") %" + ">" %></p>
|
||||
<p class="text-secondary text-sm"><%%= "<" + "%= t(\".setup_description\") %" + ">" %></p>
|
||||
<p class="text-secondary text-sm"><%%= "<" + "%= t(\".setup_description\", linked: #{file_name}_item.accounts.count, total: #{file_name}_item.#{file_name}_accounts.count) %" + ">" %></p>
|
||||
<%%= "<" + "%= render DS::Link.new(" %>
|
||||
text: t(".setup_action"),
|
||||
icon: "plus",
|
||||
|
||||
@@ -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 <a href=\"%%{accounts_path}\" class=\"link\">Accounts</a> 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<p class="text-primary font-medium"><%= "<" + "%= t(\"#{file_name}_items.panel.field_descriptions\") %" + ">" %></p>
|
||||
<ul>
|
||||
<% parsed_fields.each do |field| -%>
|
||||
<li><strong><%= "<" + "%= t(\"#{file_name}_items.panel.fields.#{field[:name]}.label\") %" + ">" %>:</strong> <%= "<" + "%= t(\"#{file_name}_items.panel.fields.#{field[:name]}.description\") %" + ">" %><%= field[:secret] ? ' (required)' : '' %><%= field[:default] ? " (optional, defaults to #{field[:default]})" : '' %></li>
|
||||
<li><strong><%= "<" + "%= t(\"#{file_name}_items.panel.fields.#{field[:name]}.label\") %" + ">" %>:</strong> <%= "<" + "%= 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 -%></li>
|
||||
<% end -%>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user