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:
luckyPipewrench
2026-01-23 11:31:57 -05:00
parent 3382c07194
commit b8ffe06974
23 changed files with 691 additions and 121 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 %>

View File

@@ -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>

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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?

View File

@@ -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)

View File

@@ -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",

View File

@@ -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"

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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