mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
Mercury integration (#723)
* Initial mercury impl * FIX both mercury and generator class * Finish mercury integration and provider generator * Fix schema * Fix linter and tags * Update routes.rb * Avoid schema drift --------- Signed-off-by: soky srm <sokysrm@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -11,6 +11,7 @@ class AccountsController < ApplicationController
|
||||
@lunchflow_items = family.lunchflow_items.ordered.includes(:syncs, :lunchflow_accounts)
|
||||
@enable_banking_items = family.enable_banking_items.ordered.includes(:syncs)
|
||||
@coinstats_items = family.coinstats_items.ordered.includes(:coinstats_accounts, :accounts, :syncs)
|
||||
@mercury_items = family.mercury_items.ordered.includes(:syncs, :mercury_accounts)
|
||||
@coinbase_items = family.coinbase_items.ordered.includes(:coinbase_accounts, :accounts, :syncs)
|
||||
|
||||
# Build sync stats maps for all providers
|
||||
@@ -242,6 +243,13 @@ class AccountsController < ApplicationController
|
||||
@coinstats_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
||||
end
|
||||
|
||||
# Mercury sync stats
|
||||
@mercury_sync_stats_map = {}
|
||||
@mercury_items.each do |item|
|
||||
latest_sync = item.syncs.ordered.first
|
||||
@mercury_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
|
||||
end
|
||||
|
||||
# Coinbase sync stats
|
||||
@coinbase_sync_stats_map = {}
|
||||
@coinbase_unlinked_count_map = {}
|
||||
|
||||
779
app/controllers/mercury_items_controller.rb
Normal file
779
app/controllers/mercury_items_controller.rb
Normal file
@@ -0,0 +1,779 @@
|
||||
class MercuryItemsController < ApplicationController
|
||||
before_action :set_mercury_item, only: [ :show, :edit, :update, :destroy, :sync, :setup_accounts, :complete_account_setup ]
|
||||
|
||||
def index
|
||||
@mercury_items = Current.family.mercury_items.active.ordered
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
# Preload Mercury accounts in background (async, non-blocking)
|
||||
def preload_accounts
|
||||
begin
|
||||
# Check if family has credentials
|
||||
unless Current.family.has_mercury_credentials?
|
||||
render json: { success: false, error: "no_credentials", has_accounts: false }
|
||||
return
|
||||
end
|
||||
|
||||
cache_key = "mercury_accounts_#{Current.family.id}"
|
||||
|
||||
# Check if already cached
|
||||
cached_accounts = Rails.cache.read(cache_key)
|
||||
|
||||
if cached_accounts.present?
|
||||
render json: { success: true, has_accounts: cached_accounts.any?, cached: true }
|
||||
return
|
||||
end
|
||||
|
||||
# Fetch from API
|
||||
mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family)
|
||||
|
||||
unless mercury_provider.present?
|
||||
render json: { success: false, error: "no_api_token", has_accounts: false }
|
||||
return
|
||||
end
|
||||
|
||||
accounts_data = mercury_provider.get_accounts
|
||||
available_accounts = accounts_data[:accounts] || []
|
||||
|
||||
# Cache the accounts for 5 minutes
|
||||
Rails.cache.write(cache_key, available_accounts, expires_in: 5.minutes)
|
||||
|
||||
render json: { success: true, has_accounts: available_accounts.any?, cached: false }
|
||||
rescue Provider::Mercury::MercuryError => e
|
||||
Rails.logger.error("Mercury preload error: #{e.message}")
|
||||
# API error (bad token, network issue, etc) - keep button visible, show error when clicked
|
||||
render json: { success: false, error: "api_error", error_message: e.message, has_accounts: nil }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error preloading Mercury accounts: #{e.class}: #{e.message}")
|
||||
# Unexpected error - keep button visible, show error when clicked
|
||||
render json: { success: false, error: "unexpected_error", error_message: e.message, has_accounts: nil }
|
||||
end
|
||||
end
|
||||
|
||||
# Fetch available accounts from Mercury API and show selection UI
|
||||
def select_accounts
|
||||
begin
|
||||
# Check if family has Mercury credentials configured
|
||||
unless Current.family.has_mercury_credentials?
|
||||
if turbo_frame_request?
|
||||
# Render setup modal for turbo frame requests
|
||||
render partial: "mercury_items/setup_required", layout: false
|
||||
else
|
||||
# Redirect for regular requests
|
||||
redirect_to settings_providers_path,
|
||||
alert: t(".no_credentials_configured",
|
||||
default: "Please configure your Mercury API token first in Provider Settings.")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
cache_key = "mercury_accounts_#{Current.family.id}"
|
||||
|
||||
# Try to get cached accounts first
|
||||
@available_accounts = Rails.cache.read(cache_key)
|
||||
|
||||
# If not cached, fetch from API
|
||||
if @available_accounts.nil?
|
||||
mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family)
|
||||
|
||||
unless mercury_provider.present?
|
||||
redirect_to settings_providers_path, alert: t(".no_api_token",
|
||||
default: "Mercury API token not found. Please configure it in Provider Settings.")
|
||||
return
|
||||
end
|
||||
|
||||
accounts_data = mercury_provider.get_accounts
|
||||
|
||||
@available_accounts = accounts_data[:accounts] || []
|
||||
|
||||
# Cache the accounts for 5 minutes
|
||||
Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes)
|
||||
end
|
||||
|
||||
# Filter out already linked accounts
|
||||
mercury_item = Current.family.mercury_items.first
|
||||
if mercury_item
|
||||
linked_account_ids = mercury_item.mercury_accounts.joins(:account_provider).pluck(:account_id)
|
||||
@available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) }
|
||||
end
|
||||
|
||||
@accountable_type = params[:accountable_type] || "Depository"
|
||||
@return_to = safe_return_to_path
|
||||
|
||||
if @available_accounts.empty?
|
||||
redirect_to new_account_path, alert: t(".no_accounts_found")
|
||||
return
|
||||
end
|
||||
|
||||
render layout: false
|
||||
rescue Provider::Mercury::MercuryError => e
|
||||
Rails.logger.error("Mercury API error in select_accounts: #{e.message}")
|
||||
@error_message = e.message
|
||||
@return_path = safe_return_to_path
|
||||
render partial: "mercury_items/api_error",
|
||||
locals: { error_message: @error_message, return_path: @return_path },
|
||||
layout: false
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error in select_accounts: #{e.class}: #{e.message}")
|
||||
@error_message = "An unexpected error occurred. Please try again later."
|
||||
@return_path = safe_return_to_path
|
||||
render partial: "mercury_items/api_error",
|
||||
locals: { error_message: @error_message, return_path: @return_path },
|
||||
layout: false
|
||||
end
|
||||
end
|
||||
|
||||
# Create accounts from selected Mercury accounts
|
||||
def link_accounts
|
||||
selected_account_ids = params[:account_ids] || []
|
||||
accountable_type = params[:accountable_type] || "Depository"
|
||||
return_to = safe_return_to_path
|
||||
|
||||
if selected_account_ids.empty?
|
||||
redirect_to new_account_path, alert: t(".no_accounts_selected")
|
||||
return
|
||||
end
|
||||
|
||||
# Create or find mercury_item for this family
|
||||
mercury_item = Current.family.mercury_items.first_or_create!(
|
||||
name: "Mercury Connection"
|
||||
)
|
||||
|
||||
# Fetch account details from API
|
||||
mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family)
|
||||
unless mercury_provider.present?
|
||||
redirect_to new_account_path, alert: t(".no_api_token")
|
||||
return
|
||||
end
|
||||
|
||||
accounts_data = mercury_provider.get_accounts
|
||||
|
||||
created_accounts = []
|
||||
already_linked_accounts = []
|
||||
invalid_accounts = []
|
||||
|
||||
selected_account_ids.each do |account_id|
|
||||
# Find the account data from API response
|
||||
account_data = accounts_data[:accounts].find { |acc| acc[:id].to_s == account_id.to_s }
|
||||
next unless account_data
|
||||
|
||||
# Get account name
|
||||
account_name = account_data[:nickname].presence || account_data[:name].presence || account_data[:legalBusinessName].presence
|
||||
|
||||
# Validate account name is not blank (required by Account model)
|
||||
if account_name.blank?
|
||||
invalid_accounts << account_id
|
||||
Rails.logger.warn "MercuryItemsController - Skipping account #{account_id} with blank name"
|
||||
next
|
||||
end
|
||||
|
||||
# Create or find mercury_account
|
||||
mercury_account = mercury_item.mercury_accounts.find_or_initialize_by(
|
||||
account_id: account_id.to_s
|
||||
)
|
||||
mercury_account.upsert_mercury_snapshot!(account_data)
|
||||
mercury_account.save!
|
||||
|
||||
# Check if this mercury_account is already linked
|
||||
if mercury_account.account_provider.present?
|
||||
already_linked_accounts << account_name
|
||||
next
|
||||
end
|
||||
|
||||
# Create the internal Account with proper balance initialization
|
||||
account = Account.create_and_sync(
|
||||
{
|
||||
family: Current.family,
|
||||
name: account_name,
|
||||
balance: 0, # Initial balance will be set during sync
|
||||
currency: "USD", # Mercury is US-only
|
||||
accountable_type: accountable_type,
|
||||
accountable_attributes: {}
|
||||
},
|
||||
skip_initial_sync: true
|
||||
)
|
||||
|
||||
# Link account to mercury_account via account_providers join table
|
||||
AccountProvider.create!(
|
||||
account: account,
|
||||
provider: mercury_account
|
||||
)
|
||||
|
||||
created_accounts << account
|
||||
end
|
||||
|
||||
# Trigger sync to fetch transactions if any accounts were created
|
||||
mercury_item.sync_later if created_accounts.any?
|
||||
|
||||
# Build appropriate flash message
|
||||
if invalid_accounts.any? && created_accounts.empty? && already_linked_accounts.empty?
|
||||
# All selected accounts were invalid (blank names)
|
||||
redirect_to new_account_path, alert: t(".invalid_account_names", count: invalid_accounts.count)
|
||||
elsif invalid_accounts.any? && (created_accounts.any? || already_linked_accounts.any?)
|
||||
# Some accounts were created/already linked, but some had invalid names
|
||||
redirect_to return_to || accounts_path,
|
||||
alert: t(".partial_invalid",
|
||||
created_count: created_accounts.count,
|
||||
already_linked_count: already_linked_accounts.count,
|
||||
invalid_count: invalid_accounts.count)
|
||||
elsif created_accounts.any? && already_linked_accounts.any?
|
||||
redirect_to return_to || accounts_path,
|
||||
notice: t(".partial_success",
|
||||
created_count: created_accounts.count,
|
||||
already_linked_count: already_linked_accounts.count,
|
||||
already_linked_names: already_linked_accounts.join(", "))
|
||||
elsif created_accounts.any?
|
||||
redirect_to return_to || accounts_path,
|
||||
notice: t(".success", count: created_accounts.count)
|
||||
elsif already_linked_accounts.any?
|
||||
redirect_to return_to || accounts_path,
|
||||
alert: t(".all_already_linked",
|
||||
count: already_linked_accounts.count,
|
||||
names: already_linked_accounts.join(", "))
|
||||
else
|
||||
redirect_to new_account_path, alert: t(".link_failed")
|
||||
end
|
||||
rescue Provider::Mercury::MercuryError => e
|
||||
redirect_to new_account_path, alert: t(".api_error", message: e.message)
|
||||
end
|
||||
|
||||
# Fetch available Mercury accounts to link with an existing account
|
||||
def select_existing_account
|
||||
account_id = params[:account_id]
|
||||
|
||||
unless account_id.present?
|
||||
redirect_to accounts_path, alert: t(".no_account_specified")
|
||||
return
|
||||
end
|
||||
|
||||
@account = Current.family.accounts.find(account_id)
|
||||
|
||||
# Check if account is already linked
|
||||
if @account.account_providers.exists?
|
||||
redirect_to accounts_path, alert: t(".account_already_linked")
|
||||
return
|
||||
end
|
||||
|
||||
# Check if family has Mercury credentials configured
|
||||
unless Current.family.has_mercury_credentials?
|
||||
if turbo_frame_request?
|
||||
# Render setup modal for turbo frame requests
|
||||
render partial: "mercury_items/setup_required", layout: false
|
||||
else
|
||||
# Redirect for regular requests
|
||||
redirect_to settings_providers_path,
|
||||
alert: t(".no_credentials_configured",
|
||||
default: "Please configure your Mercury API token first in Provider Settings.")
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
cache_key = "mercury_accounts_#{Current.family.id}"
|
||||
|
||||
# Try to get cached accounts first
|
||||
@available_accounts = Rails.cache.read(cache_key)
|
||||
|
||||
# If not cached, fetch from API
|
||||
if @available_accounts.nil?
|
||||
mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family)
|
||||
|
||||
unless mercury_provider.present?
|
||||
redirect_to settings_providers_path, alert: t(".no_api_token",
|
||||
default: "Mercury API token not found. Please configure it in Provider Settings.")
|
||||
return
|
||||
end
|
||||
|
||||
accounts_data = mercury_provider.get_accounts
|
||||
|
||||
@available_accounts = accounts_data[:accounts] || []
|
||||
|
||||
# Cache the accounts for 5 minutes
|
||||
Rails.cache.write(cache_key, @available_accounts, expires_in: 5.minutes)
|
||||
end
|
||||
|
||||
if @available_accounts.empty?
|
||||
redirect_to accounts_path, alert: t(".no_accounts_found")
|
||||
return
|
||||
end
|
||||
|
||||
# Filter out already linked accounts
|
||||
mercury_item = Current.family.mercury_items.first
|
||||
if mercury_item
|
||||
linked_account_ids = mercury_item.mercury_accounts.joins(:account_provider).pluck(:account_id)
|
||||
@available_accounts = @available_accounts.reject { |acc| linked_account_ids.include?(acc[:id].to_s) }
|
||||
end
|
||||
|
||||
if @available_accounts.empty?
|
||||
redirect_to accounts_path, alert: t(".all_accounts_already_linked")
|
||||
return
|
||||
end
|
||||
|
||||
@return_to = safe_return_to_path
|
||||
|
||||
render layout: false
|
||||
rescue Provider::Mercury::MercuryError => e
|
||||
Rails.logger.error("Mercury API error in select_existing_account: #{e.message}")
|
||||
@error_message = e.message
|
||||
render partial: "mercury_items/api_error",
|
||||
locals: { error_message: @error_message, return_path: accounts_path },
|
||||
layout: false
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error in select_existing_account: #{e.class}: #{e.message}")
|
||||
@error_message = "An unexpected error occurred. Please try again later."
|
||||
render partial: "mercury_items/api_error",
|
||||
locals: { error_message: @error_message, return_path: accounts_path },
|
||||
layout: false
|
||||
end
|
||||
end
|
||||
|
||||
# Link a selected Mercury account to an existing account
|
||||
def link_existing_account
|
||||
account_id = params[:account_id]
|
||||
mercury_account_id = params[:mercury_account_id]
|
||||
return_to = safe_return_to_path
|
||||
|
||||
unless account_id.present? && mercury_account_id.present?
|
||||
redirect_to accounts_path, alert: t(".missing_parameters")
|
||||
return
|
||||
end
|
||||
|
||||
@account = Current.family.accounts.find(account_id)
|
||||
|
||||
# Check if account is already linked
|
||||
if @account.account_providers.exists?
|
||||
redirect_to accounts_path, alert: t(".account_already_linked")
|
||||
return
|
||||
end
|
||||
|
||||
# Create or find mercury_item for this family
|
||||
mercury_item = Current.family.mercury_items.first_or_create!(
|
||||
name: "Mercury Connection"
|
||||
)
|
||||
|
||||
# Fetch account details from API
|
||||
mercury_provider = Provider::MercuryAdapter.build_provider(family: Current.family)
|
||||
unless mercury_provider.present?
|
||||
redirect_to accounts_path, alert: t(".no_api_token")
|
||||
return
|
||||
end
|
||||
|
||||
accounts_data = mercury_provider.get_accounts
|
||||
|
||||
# Find the selected Mercury account data
|
||||
account_data = accounts_data[:accounts].find { |acc| acc[:id].to_s == mercury_account_id.to_s }
|
||||
unless account_data
|
||||
redirect_to accounts_path, alert: t(".mercury_account_not_found")
|
||||
return
|
||||
end
|
||||
|
||||
# Get account name
|
||||
account_name = account_data[:nickname].presence || account_data[:name].presence || account_data[:legalBusinessName].presence
|
||||
|
||||
# Validate account name is not blank (required by Account model)
|
||||
if account_name.blank?
|
||||
redirect_to accounts_path, alert: t(".invalid_account_name")
|
||||
return
|
||||
end
|
||||
|
||||
# Create or find mercury_account
|
||||
mercury_account = mercury_item.mercury_accounts.find_or_initialize_by(
|
||||
account_id: mercury_account_id.to_s
|
||||
)
|
||||
mercury_account.upsert_mercury_snapshot!(account_data)
|
||||
mercury_account.save!
|
||||
|
||||
# Check if this mercury_account is already linked to another account
|
||||
if mercury_account.account_provider.present?
|
||||
redirect_to accounts_path, alert: t(".mercury_account_already_linked")
|
||||
return
|
||||
end
|
||||
|
||||
# Link account to mercury_account via account_providers join table
|
||||
AccountProvider.create!(
|
||||
account: @account,
|
||||
provider: mercury_account
|
||||
)
|
||||
|
||||
# Trigger sync to fetch transactions
|
||||
mercury_item.sync_later
|
||||
|
||||
redirect_to return_to || accounts_path,
|
||||
notice: t(".success", account_name: @account.name)
|
||||
rescue Provider::Mercury::MercuryError => e
|
||||
redirect_to accounts_path, alert: t(".api_error", message: e.message)
|
||||
end
|
||||
|
||||
def new
|
||||
@mercury_item = Current.family.mercury_items.build
|
||||
end
|
||||
|
||||
def create
|
||||
@mercury_item = Current.family.mercury_items.build(mercury_item_params)
|
||||
@mercury_item.name ||= "Mercury Connection"
|
||||
|
||||
if @mercury_item.save
|
||||
# Trigger initial sync to fetch accounts
|
||||
@mercury_item.sync_later
|
||||
|
||||
if turbo_frame_request?
|
||||
flash.now[:notice] = t(".success")
|
||||
@mercury_items = Current.family.mercury_items.ordered
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"mercury-providers-panel",
|
||||
partial: "settings/providers/mercury_panel",
|
||||
locals: { mercury_items: @mercury_items }
|
||||
),
|
||||
*flash_notification_stream_items
|
||||
]
|
||||
else
|
||||
redirect_to accounts_path, notice: t(".success"), status: :see_other
|
||||
end
|
||||
else
|
||||
@error_message = @mercury_item.errors.full_messages.join(", ")
|
||||
|
||||
if turbo_frame_request?
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"mercury-providers-panel",
|
||||
partial: "settings/providers/mercury_panel",
|
||||
locals: { error_message: @error_message }
|
||||
), status: :unprocessable_entity
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @mercury_item.update(mercury_item_params)
|
||||
if turbo_frame_request?
|
||||
flash.now[:notice] = t(".success")
|
||||
@mercury_items = Current.family.mercury_items.ordered
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"mercury-providers-panel",
|
||||
partial: "settings/providers/mercury_panel",
|
||||
locals: { mercury_items: @mercury_items }
|
||||
),
|
||||
*flash_notification_stream_items
|
||||
]
|
||||
else
|
||||
redirect_to accounts_path, notice: t(".success"), status: :see_other
|
||||
end
|
||||
else
|
||||
@error_message = @mercury_item.errors.full_messages.join(", ")
|
||||
|
||||
if turbo_frame_request?
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"mercury-providers-panel",
|
||||
partial: "settings/providers/mercury_panel",
|
||||
locals: { error_message: @error_message }
|
||||
), status: :unprocessable_entity
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
# Ensure we detach provider links before scheduling deletion
|
||||
begin
|
||||
@mercury_item.unlink_all!(dry_run: false)
|
||||
rescue => e
|
||||
Rails.logger.warn("Mercury unlink during destroy failed: #{e.class} - #{e.message}")
|
||||
end
|
||||
@mercury_item.destroy_later
|
||||
redirect_to accounts_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def sync
|
||||
unless @mercury_item.syncing?
|
||||
@mercury_item.sync_later
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to accounts_path }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
|
||||
# Show unlinked Mercury accounts for setup
|
||||
def setup_accounts
|
||||
# First, ensure we have the latest accounts from the API
|
||||
@api_error = fetch_mercury_accounts_from_api
|
||||
|
||||
# Get Mercury accounts that are not linked (no AccountProvider)
|
||||
@mercury_accounts = @mercury_item.mercury_accounts
|
||||
.left_joins(:account_provider)
|
||||
.where(account_providers: { id: nil })
|
||||
|
||||
# Get supported account types from the adapter
|
||||
supported_types = Provider::MercuryAdapter.supported_account_types
|
||||
|
||||
# Map of account type keys to their internal values
|
||||
account_type_keys = {
|
||||
"depository" => "Depository",
|
||||
"credit_card" => "CreditCard",
|
||||
"investment" => "Investment",
|
||||
"loan" => "Loan",
|
||||
"other_asset" => "OtherAsset"
|
||||
}
|
||||
|
||||
# Build account type options using i18n, filtering to supported types
|
||||
all_account_type_options = account_type_keys.filter_map do |key, type|
|
||||
next unless supported_types.include?(type)
|
||||
[ t(".account_types.#{key}"), type ]
|
||||
end
|
||||
|
||||
# Add "Skip" option at the beginning
|
||||
@account_type_options = [ [ t(".account_types.skip"), "skip" ] ] + all_account_type_options
|
||||
|
||||
# Helper to translate subtype options
|
||||
translate_subtypes = ->(type_key, subtypes_hash) {
|
||||
subtypes_hash.keys.map { |k| [ t(".subtypes.#{type_key}.#{k}"), k ] }
|
||||
}
|
||||
|
||||
# Subtype options for each account type (only include supported types)
|
||||
all_subtype_options = {
|
||||
"Depository" => {
|
||||
label: t(".subtype_labels.depository"),
|
||||
options: translate_subtypes.call("depository", Depository::SUBTYPES)
|
||||
},
|
||||
"CreditCard" => {
|
||||
label: t(".subtype_labels.credit_card"),
|
||||
options: [],
|
||||
message: t(".subtype_messages.credit_card")
|
||||
},
|
||||
"Investment" => {
|
||||
label: t(".subtype_labels.investment"),
|
||||
options: translate_subtypes.call("investment", Investment::SUBTYPES)
|
||||
},
|
||||
"Loan" => {
|
||||
label: t(".subtype_labels.loan"),
|
||||
options: translate_subtypes.call("loan", Loan::SUBTYPES)
|
||||
},
|
||||
"OtherAsset" => {
|
||||
label: t(".subtype_labels.other_asset").presence,
|
||||
options: [],
|
||||
message: t(".subtype_messages.other_asset")
|
||||
}
|
||||
}
|
||||
|
||||
@subtype_options = all_subtype_options.slice(*supported_types)
|
||||
end
|
||||
|
||||
def complete_account_setup
|
||||
account_types = params[:account_types] || {}
|
||||
account_subtypes = params[:account_subtypes] || {}
|
||||
|
||||
# Valid account types for this provider
|
||||
valid_types = Provider::MercuryAdapter.supported_account_types
|
||||
|
||||
created_accounts = []
|
||||
skipped_count = 0
|
||||
|
||||
begin
|
||||
ActiveRecord::Base.transaction do
|
||||
account_types.each do |mercury_account_id, selected_type|
|
||||
# Skip accounts marked as "skip"
|
||||
if selected_type == "skip" || selected_type.blank?
|
||||
skipped_count += 1
|
||||
next
|
||||
end
|
||||
|
||||
# Validate account type is supported
|
||||
unless valid_types.include?(selected_type)
|
||||
Rails.logger.warn("Invalid account type '#{selected_type}' submitted for Mercury account #{mercury_account_id}")
|
||||
next
|
||||
end
|
||||
|
||||
# Find account - scoped to this item to prevent cross-item manipulation
|
||||
mercury_account = @mercury_item.mercury_accounts.find_by(id: mercury_account_id)
|
||||
unless mercury_account
|
||||
Rails.logger.warn("Mercury account #{mercury_account_id} not found for item #{@mercury_item.id}")
|
||||
next
|
||||
end
|
||||
|
||||
# Skip if already linked (race condition protection)
|
||||
if mercury_account.account_provider.present?
|
||||
Rails.logger.info("Mercury account #{mercury_account_id} already linked, skipping")
|
||||
next
|
||||
end
|
||||
|
||||
selected_subtype = account_subtypes[mercury_account_id]
|
||||
|
||||
# Default subtype for CreditCard since it only has one option
|
||||
selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank?
|
||||
|
||||
# Create account with user-selected type and subtype (raises on failure)
|
||||
# Skip initial sync - provider sync will handle balance creation with correct currency
|
||||
account = Account.create_and_sync(
|
||||
{
|
||||
family: Current.family,
|
||||
name: mercury_account.name,
|
||||
balance: mercury_account.current_balance || 0,
|
||||
currency: "USD", # Mercury is US-only
|
||||
accountable_type: selected_type,
|
||||
accountable_attributes: selected_subtype.present? ? { subtype: selected_subtype } : {}
|
||||
},
|
||||
skip_initial_sync: true
|
||||
)
|
||||
|
||||
# Link account to mercury_account via account_providers join table (raises on failure)
|
||||
AccountProvider.create!(
|
||||
account: account,
|
||||
provider: mercury_account
|
||||
)
|
||||
|
||||
created_accounts << account
|
||||
end
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
|
||||
Rails.logger.error("Mercury account setup failed: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.first(10).join("\n"))
|
||||
flash[:alert] = t(".creation_failed", error: e.message)
|
||||
redirect_to accounts_path, status: :see_other
|
||||
return
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Mercury account setup failed unexpectedly: #{e.class} - #{e.message}")
|
||||
Rails.logger.error(e.backtrace.first(10).join("\n"))
|
||||
flash[:alert] = t(".creation_failed", error: "An unexpected error occurred")
|
||||
redirect_to accounts_path, status: :see_other
|
||||
return
|
||||
end
|
||||
|
||||
# Trigger a sync to process transactions
|
||||
@mercury_item.sync_later if created_accounts.any?
|
||||
|
||||
# Set appropriate flash message
|
||||
if created_accounts.any?
|
||||
flash[:notice] = t(".success", count: created_accounts.count)
|
||||
elsif skipped_count > 0
|
||||
flash[:notice] = t(".all_skipped")
|
||||
else
|
||||
flash[:notice] = t(".no_accounts")
|
||||
end
|
||||
|
||||
if turbo_frame_request?
|
||||
# Recompute data needed by Accounts#index partials
|
||||
@manual_accounts = Account.uncached {
|
||||
Current.family.accounts
|
||||
.visible_manual
|
||||
.order(:name)
|
||||
.to_a
|
||||
}
|
||||
@mercury_items = Current.family.mercury_items.ordered
|
||||
|
||||
manual_accounts_stream = if @manual_accounts.any?
|
||||
turbo_stream.update(
|
||||
"manual-accounts",
|
||||
partial: "accounts/index/manual_accounts",
|
||||
locals: { accounts: @manual_accounts }
|
||||
)
|
||||
else
|
||||
turbo_stream.replace("manual-accounts", view_context.tag.div(id: "manual-accounts"))
|
||||
end
|
||||
|
||||
render turbo_stream: [
|
||||
manual_accounts_stream,
|
||||
turbo_stream.replace(
|
||||
ActionView::RecordIdentifier.dom_id(@mercury_item),
|
||||
partial: "mercury_items/mercury_item",
|
||||
locals: { mercury_item: @mercury_item }
|
||||
)
|
||||
] + Array(flash_notification_stream_items)
|
||||
else
|
||||
redirect_to accounts_path, status: :see_other
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Fetch Mercury accounts from the API and store them locally
|
||||
# Returns nil on success, or an error message string on failure
|
||||
def fetch_mercury_accounts_from_api
|
||||
# Skip if we already have accounts cached
|
||||
return nil unless @mercury_item.mercury_accounts.empty?
|
||||
|
||||
# Validate API token is configured
|
||||
unless @mercury_item.credentials_configured?
|
||||
return t("mercury_items.setup_accounts.no_api_token")
|
||||
end
|
||||
|
||||
# Use the specific mercury_item's provider (scoped to this family's item)
|
||||
mercury_provider = @mercury_item.mercury_provider
|
||||
unless mercury_provider.present?
|
||||
return t("mercury_items.setup_accounts.no_api_token")
|
||||
end
|
||||
|
||||
begin
|
||||
accounts_data = mercury_provider.get_accounts
|
||||
available_accounts = accounts_data[:accounts] || []
|
||||
|
||||
if available_accounts.empty?
|
||||
Rails.logger.info("Mercury API returned no accounts for item #{@mercury_item.id}")
|
||||
return nil
|
||||
end
|
||||
|
||||
available_accounts.each do |account_data|
|
||||
account_name = account_data[:nickname].presence || account_data[:name].presence || account_data[:legalBusinessName].presence
|
||||
next if account_name.blank?
|
||||
|
||||
mercury_account = @mercury_item.mercury_accounts.find_or_initialize_by(
|
||||
account_id: account_data[:id].to_s
|
||||
)
|
||||
mercury_account.upsert_mercury_snapshot!(account_data)
|
||||
mercury_account.save!
|
||||
end
|
||||
|
||||
nil # Success
|
||||
rescue Provider::Mercury::MercuryError => e
|
||||
Rails.logger.error("Mercury API error: #{e.message}")
|
||||
t("mercury_items.setup_accounts.api_error", message: e.message)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error fetching Mercury accounts: #{e.class}: #{e.message}")
|
||||
t("mercury_items.setup_accounts.api_error", message: e.message)
|
||||
end
|
||||
end
|
||||
|
||||
def set_mercury_item
|
||||
@mercury_item = Current.family.mercury_items.find(params[:id])
|
||||
end
|
||||
|
||||
def mercury_item_params
|
||||
params.require(:mercury_item).permit(:name, :sync_start_date, :token, :base_url)
|
||||
end
|
||||
|
||||
# Sanitize return_to parameter to prevent XSS attacks
|
||||
# Only allow internal paths, reject external URLs and javascript: URIs
|
||||
def safe_return_to_path
|
||||
return nil if params[:return_to].blank?
|
||||
|
||||
return_to = params[:return_to].to_s
|
||||
|
||||
# Parse the URL to check if it's external
|
||||
begin
|
||||
uri = URI.parse(return_to)
|
||||
|
||||
# Reject absolute URLs with schemes (http:, https:, javascript:, etc.)
|
||||
# Only allow relative paths
|
||||
return nil if uri.scheme.present?
|
||||
|
||||
# Ensure the path starts with / (is a relative path)
|
||||
return nil unless return_to.start_with?("/")
|
||||
|
||||
return_to
|
||||
rescue URI::InvalidURIError
|
||||
# If the URI is invalid, reject it
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -126,6 +126,7 @@ class Settings::ProvidersController < ApplicationController
|
||||
config.provider_key.to_s.casecmp("simplefin").zero? || config.provider_key.to_s.casecmp("lunchflow").zero? || \
|
||||
config.provider_key.to_s.casecmp("enable_banking").zero? || \
|
||||
config.provider_key.to_s.casecmp("coinstats").zero? || \
|
||||
config.provider_key.to_s.casecmp("mercury").zero?
|
||||
config.provider_key.to_s.casecmp("coinbase").zero?
|
||||
end
|
||||
|
||||
@@ -134,6 +135,7 @@ class Settings::ProvidersController < ApplicationController
|
||||
@lunchflow_items = Current.family.lunchflow_items.where.not(api_key: [ nil, "" ]).ordered.select(:id)
|
||||
@enable_banking_items = Current.family.enable_banking_items.ordered # Enable Banking panel needs session info for status display
|
||||
@coinstats_items = Current.family.coinstats_items.ordered # CoinStats panel needs account info for status display
|
||||
@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
|
||||
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" }
|
||||
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury" }
|
||||
end
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class Family < ApplicationRecord
|
||||
include MercuryConnectable
|
||||
include CoinbaseConnectable
|
||||
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable, CoinstatsConnectable
|
||||
|
||||
|
||||
28
app/models/family/mercury_connectable.rb
Normal file
28
app/models/family/mercury_connectable.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
module Family::MercuryConnectable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
has_many :mercury_items, dependent: :destroy
|
||||
end
|
||||
|
||||
def can_connect_mercury?
|
||||
# Families can configure their own Mercury credentials
|
||||
true
|
||||
end
|
||||
|
||||
def create_mercury_item!(token:, base_url: nil, item_name: nil)
|
||||
mercury_item = mercury_items.create!(
|
||||
name: item_name || "Mercury Connection",
|
||||
token: token,
|
||||
base_url: base_url
|
||||
)
|
||||
|
||||
mercury_item.sync_later
|
||||
|
||||
mercury_item
|
||||
end
|
||||
|
||||
def has_mercury_credentials?
|
||||
mercury_items.where.not(token: nil).exists?
|
||||
end
|
||||
end
|
||||
60
app/models/mercury_account.rb
Normal file
60
app/models/mercury_account.rb
Normal file
@@ -0,0 +1,60 @@
|
||||
class MercuryAccount < ApplicationRecord
|
||||
include CurrencyNormalizable
|
||||
|
||||
belongs_to :mercury_item
|
||||
|
||||
# New association through account_providers
|
||||
has_one :account_provider, as: :provider, dependent: :destroy
|
||||
has_one :account, through: :account_provider, source: :account
|
||||
has_one :linked_account, through: :account_provider, source: :account
|
||||
|
||||
validates :name, :currency, presence: true
|
||||
|
||||
# Helper to get account using account_providers system
|
||||
def current_account
|
||||
account
|
||||
end
|
||||
|
||||
def upsert_mercury_snapshot!(account_snapshot)
|
||||
# Convert to symbol keys or handle both string and symbol keys
|
||||
snapshot = account_snapshot.with_indifferent_access
|
||||
|
||||
# Map Mercury field names to our field names
|
||||
# Mercury API fields: id, name, currentBalance, availableBalance, status, type, kind,
|
||||
# legalBusinessName, nickname, routingNumber, accountNumber, etc.
|
||||
account_name = snapshot[:nickname].presence || snapshot[:name].presence || snapshot[:legalBusinessName].presence
|
||||
|
||||
update!(
|
||||
current_balance: snapshot[:currentBalance] || snapshot[:current_balance] || 0,
|
||||
currency: "USD", # Mercury is US-only, always USD
|
||||
name: account_name,
|
||||
account_id: snapshot[:id]&.to_s,
|
||||
account_status: snapshot[:status],
|
||||
provider: "mercury",
|
||||
institution_metadata: {
|
||||
name: "Mercury",
|
||||
domain: "mercury.com",
|
||||
url: "https://mercury.com",
|
||||
account_type: snapshot[:type],
|
||||
account_kind: snapshot[:kind],
|
||||
legal_business_name: snapshot[:legalBusinessName],
|
||||
available_balance: snapshot[:availableBalance]
|
||||
}.compact,
|
||||
raw_payload: account_snapshot
|
||||
)
|
||||
end
|
||||
|
||||
def upsert_mercury_transactions_snapshot!(transactions_snapshot)
|
||||
assign_attributes(
|
||||
raw_transactions_payload: transactions_snapshot
|
||||
)
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log_invalid_currency(currency_value)
|
||||
Rails.logger.warn("Invalid currency code '#{currency_value}' for Mercury account #{id}, defaulting to USD")
|
||||
end
|
||||
end
|
||||
78
app/models/mercury_account/processor.rb
Normal file
78
app/models/mercury_account/processor.rb
Normal file
@@ -0,0 +1,78 @@
|
||||
class MercuryAccount::Processor
|
||||
include CurrencyNormalizable
|
||||
|
||||
attr_reader :mercury_account
|
||||
|
||||
def initialize(mercury_account)
|
||||
@mercury_account = mercury_account
|
||||
end
|
||||
|
||||
def process
|
||||
unless mercury_account.current_account.present?
|
||||
Rails.logger.info "MercuryAccount::Processor - No linked account for mercury_account #{mercury_account.id}, skipping processing"
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.info "MercuryAccount::Processor - Processing mercury_account #{mercury_account.id} (account #{mercury_account.account_id})"
|
||||
|
||||
begin
|
||||
process_account!
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "MercuryAccount::Processor - Failed to process account #{mercury_account.id}: #{e.message}"
|
||||
Rails.logger.error "Backtrace: #{e.backtrace.join("\n")}"
|
||||
report_exception(e, "account")
|
||||
raise
|
||||
end
|
||||
|
||||
process_transactions
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_account!
|
||||
if mercury_account.current_account.blank?
|
||||
Rails.logger.error("Mercury account #{mercury_account.id} has no associated Account")
|
||||
return
|
||||
end
|
||||
|
||||
# Update account balance from latest Mercury data
|
||||
account = mercury_account.current_account
|
||||
balance = mercury_account.current_balance || 0
|
||||
|
||||
# Mercury balance convention:
|
||||
# - currentBalance is the actual balance of the account
|
||||
# - For checking/savings (Depository): positive = money in account
|
||||
# - For credit lines: positive = money owed, negative = credit available
|
||||
#
|
||||
# No sign conversion needed for Depository accounts
|
||||
# Credit accounts are not typically offered by Mercury, but handle just in case
|
||||
if account.accountable_type == "CreditCard" || account.accountable_type == "Loan"
|
||||
balance = -balance
|
||||
end
|
||||
|
||||
# Mercury is US-only, always USD
|
||||
currency = "USD"
|
||||
|
||||
# Update account balance
|
||||
account.update!(
|
||||
balance: balance,
|
||||
cash_balance: balance,
|
||||
currency: currency
|
||||
)
|
||||
end
|
||||
|
||||
def process_transactions
|
||||
MercuryAccount::Transactions::Processor.new(mercury_account).process
|
||||
rescue => e
|
||||
report_exception(e, "transactions")
|
||||
end
|
||||
|
||||
def report_exception(error, context)
|
||||
Sentry.capture_exception(error) do |scope|
|
||||
scope.set_tags(
|
||||
mercury_account_id: mercury_account.id,
|
||||
context: context
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
71
app/models/mercury_account/transactions/processor.rb
Normal file
71
app/models/mercury_account/transactions/processor.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
class MercuryAccount::Transactions::Processor
|
||||
attr_reader :mercury_account
|
||||
|
||||
def initialize(mercury_account)
|
||||
@mercury_account = mercury_account
|
||||
end
|
||||
|
||||
def process
|
||||
unless mercury_account.raw_transactions_payload.present?
|
||||
Rails.logger.info "MercuryAccount::Transactions::Processor - No transactions in raw_transactions_payload for mercury_account #{mercury_account.id}"
|
||||
return { success: true, total: 0, imported: 0, failed: 0, errors: [] }
|
||||
end
|
||||
|
||||
total_count = mercury_account.raw_transactions_payload.count
|
||||
Rails.logger.info "MercuryAccount::Transactions::Processor - Processing #{total_count} transactions for mercury_account #{mercury_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.
|
||||
mercury_account.raw_transactions_payload.each_with_index do |transaction_data, index|
|
||||
begin
|
||||
result = MercuryEntry::Processor.new(
|
||||
transaction_data,
|
||||
mercury_account: mercury_account
|
||||
).process
|
||||
|
||||
if result.nil?
|
||||
# Transaction was skipped (e.g., no linked account)
|
||||
failed_count += 1
|
||||
errors << { index: index, transaction_id: transaction_data[:id], error: "No linked account" }
|
||||
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 "MercuryAccount::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 "MercuryAccount::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 "MercuryAccount::Transactions::Processor - Completed with #{failed_count} failures out of #{total_count} transactions"
|
||||
else
|
||||
Rails.logger.info "MercuryAccount::Transactions::Processor - Successfully processed #{imported_count} transactions"
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
166
app/models/mercury_entry/processor.rb
Normal file
166
app/models/mercury_entry/processor.rb
Normal file
@@ -0,0 +1,166 @@
|
||||
require "digest/md5"
|
||||
|
||||
class MercuryEntry::Processor
|
||||
include CurrencyNormalizable
|
||||
|
||||
# mercury_transaction is the raw hash fetched from Mercury API and converted to JSONB
|
||||
# Transaction structure: { id, amount, bankDescription, counterpartyId, counterpartyName,
|
||||
# counterpartyNickname, createdAt, dashboardLink, details,
|
||||
# estimatedDeliveryDate, failedAt, kind, note, postedAt,
|
||||
# reasonForFailure, status }
|
||||
def initialize(mercury_transaction, mercury_account:)
|
||||
@mercury_transaction = mercury_transaction
|
||||
@mercury_account = mercury_account
|
||||
end
|
||||
|
||||
def process
|
||||
# Validate that we have a linked account before processing
|
||||
unless account.present?
|
||||
Rails.logger.warn "MercuryEntry::Processor - No linked account for mercury_account #{mercury_account.id}, skipping transaction #{external_id}"
|
||||
return nil
|
||||
end
|
||||
|
||||
# Skip failed transactions
|
||||
if data[:status] == "failed"
|
||||
Rails.logger.debug "MercuryEntry::Processor - Skipping failed transaction #{external_id}"
|
||||
return nil
|
||||
end
|
||||
|
||||
# Wrap import in error handling to catch validation and save errors
|
||||
begin
|
||||
import_adapter.import_transaction(
|
||||
external_id: external_id,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
date: date,
|
||||
name: name,
|
||||
source: "mercury",
|
||||
merchant: merchant,
|
||||
notes: notes
|
||||
)
|
||||
rescue ArgumentError => e
|
||||
# Re-raise validation errors (missing required fields, invalid data)
|
||||
Rails.logger.error "MercuryEntry::Processor - Validation error for transaction #{external_id}: #{e.message}"
|
||||
raise
|
||||
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
|
||||
# Handle database save errors
|
||||
Rails.logger.error "MercuryEntry::Processor - Failed to save transaction #{external_id}: #{e.message}"
|
||||
raise StandardError.new("Failed to import transaction: #{e.message}")
|
||||
rescue => e
|
||||
# Catch unexpected errors with full context
|
||||
Rails.logger.error "MercuryEntry::Processor - Unexpected error processing transaction #{external_id}: #{e.class} - #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
raise StandardError.new("Unexpected error importing transaction: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :mercury_transaction, :mercury_account
|
||||
|
||||
def import_adapter
|
||||
@import_adapter ||= Account::ProviderImportAdapter.new(account)
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= mercury_account.current_account
|
||||
end
|
||||
|
||||
def data
|
||||
@data ||= mercury_transaction.with_indifferent_access
|
||||
end
|
||||
|
||||
def external_id
|
||||
id = data[:id].presence
|
||||
raise ArgumentError, "Mercury transaction missing required field 'id'" unless id
|
||||
"mercury_#{id}"
|
||||
end
|
||||
|
||||
def name
|
||||
# Use counterparty name or bank description
|
||||
data[:counterpartyNickname].presence ||
|
||||
data[:counterpartyName].presence ||
|
||||
data[:bankDescription].presence ||
|
||||
"Unknown transaction"
|
||||
end
|
||||
|
||||
def notes
|
||||
# Combine note and details if present
|
||||
note_parts = []
|
||||
note_parts << data[:note] if data[:note].present?
|
||||
note_parts << data[:details] if data[:details].present?
|
||||
note_parts.any? ? note_parts.join(" - ") : nil
|
||||
end
|
||||
|
||||
def merchant
|
||||
counterparty_name = data[:counterpartyName].presence
|
||||
return nil unless counterparty_name.present?
|
||||
|
||||
# Create a stable merchant ID from the counterparty name
|
||||
# Using digest to ensure uniqueness while keeping it deterministic
|
||||
merchant_name = counterparty_name.to_s.strip
|
||||
return nil if merchant_name.blank?
|
||||
|
||||
merchant_id = Digest::MD5.hexdigest(merchant_name.downcase)
|
||||
|
||||
@merchant ||= begin
|
||||
import_adapter.find_or_create_merchant(
|
||||
provider_merchant_id: "mercury_merchant_#{merchant_id}",
|
||||
name: merchant_name,
|
||||
source: "mercury"
|
||||
)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "MercuryEntry::Processor - Failed to create merchant '#{merchant_name}': #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def amount
|
||||
parsed_amount = case data[:amount]
|
||||
when String
|
||||
BigDecimal(data[:amount])
|
||||
when Numeric
|
||||
BigDecimal(data[:amount].to_s)
|
||||
else
|
||||
BigDecimal("0")
|
||||
end
|
||||
|
||||
# Mercury uses standard convention where:
|
||||
# - Negative amounts are money going out (expenses)
|
||||
# - Positive amounts are money coming in (income)
|
||||
# Our app uses opposite convention (expenses positive, income negative)
|
||||
# So we negate the amount to convert from Mercury to our format
|
||||
-parsed_amount
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error "Failed to parse Mercury transaction amount: #{data[:amount].inspect} - #{e.message}"
|
||||
raise
|
||||
end
|
||||
|
||||
def currency
|
||||
# Mercury is US-only, always USD
|
||||
"USD"
|
||||
end
|
||||
|
||||
def date
|
||||
# Mercury provides createdAt and postedAt - use postedAt if available, otherwise createdAt
|
||||
date_value = data[:postedAt].presence || data[:createdAt].presence
|
||||
|
||||
case date_value
|
||||
when String
|
||||
# Mercury uses ISO 8601 format: "2024-01-15T10:30:00Z"
|
||||
DateTime.parse(date_value).to_date
|
||||
when Integer, Float
|
||||
# Unix timestamp
|
||||
Time.at(date_value).to_date
|
||||
when Time, DateTime
|
||||
date_value.to_date
|
||||
when Date
|
||||
date_value
|
||||
else
|
||||
Rails.logger.error("Mercury transaction has invalid date value: #{date_value.inspect}")
|
||||
raise ArgumentError, "Invalid date format: #{date_value.inspect}"
|
||||
end
|
||||
rescue ArgumentError, TypeError => e
|
||||
Rails.logger.error("Failed to parse Mercury transaction date '#{date_value}': #{e.message}")
|
||||
raise ArgumentError, "Unable to parse transaction date: #{date_value.inspect}"
|
||||
end
|
||||
end
|
||||
176
app/models/mercury_item.rb
Normal file
176
app/models/mercury_item.rb
Normal file
@@ -0,0 +1,176 @@
|
||||
class MercuryItem < ApplicationRecord
|
||||
include Syncable, Provided, Unlinking
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
# Helper to detect if ActiveRecord Encryption is configured for this app
|
||||
def self.encryption_ready?
|
||||
creds_ready = Rails.application.credentials.active_record_encryption.present?
|
||||
env_ready = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"].present? &&
|
||||
ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"].present?
|
||||
creds_ready || env_ready
|
||||
end
|
||||
|
||||
# Encrypt sensitive credentials if ActiveRecord encryption is configured
|
||||
if encryption_ready?
|
||||
encrypts :token, deterministic: true
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
validates :token, presence: true, on: :create
|
||||
|
||||
belongs_to :family
|
||||
has_one_attached :logo
|
||||
|
||||
has_many :mercury_accounts, dependent: :destroy
|
||||
has_many :accounts, through: :mercury_accounts
|
||||
|
||||
scope :active, -> { where(scheduled_for_deletion: false) }
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
scope :needs_update, -> { where(status: :requires_update) }
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
|
||||
# TODO: Implement data import from provider API
|
||||
# This method should fetch the latest data from the provider and import it.
|
||||
# May need provider-specific validation (e.g., session validity checks).
|
||||
# See LunchflowItem#import_latest_lunchflow_data or EnableBankingItem#import_latest_enable_banking_data for examples.
|
||||
def import_latest_mercury_data
|
||||
provider = mercury_provider
|
||||
unless provider
|
||||
Rails.logger.error "MercuryItem #{id} - Cannot import: provider is not configured"
|
||||
raise StandardError.new("Mercury provider is not configured")
|
||||
end
|
||||
|
||||
# TODO: Add any provider-specific validation here (e.g., session checks)
|
||||
MercuryItem::Importer.new(self, mercury_provider: provider).import
|
||||
rescue => e
|
||||
Rails.logger.error "MercuryItem #{id} - Failed to import data: #{e.message}"
|
||||
raise
|
||||
end
|
||||
|
||||
# TODO: Implement account processing logic
|
||||
# This method processes linked accounts after data import.
|
||||
# Customize based on your provider's data structure and processing needs.
|
||||
def process_accounts
|
||||
return [] if mercury_accounts.empty?
|
||||
|
||||
results = []
|
||||
mercury_accounts.joins(:account).merge(Account.visible).each do |mercury_account|
|
||||
begin
|
||||
result = MercuryAccount::Processor.new(mercury_account).process
|
||||
results << { mercury_account_id: mercury_account.id, success: true, result: result }
|
||||
rescue => e
|
||||
Rails.logger.error "MercuryItem #{id} - Failed to process account #{mercury_account.id}: #{e.message}"
|
||||
results << { mercury_account_id: mercury_account.id, success: false, error: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
# TODO: Customize sync scheduling if needed
|
||||
# This method schedules sync jobs for all linked accounts.
|
||||
def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
|
||||
return [] if accounts.empty?
|
||||
|
||||
results = []
|
||||
accounts.visible.each do |account|
|
||||
begin
|
||||
account.sync_later(
|
||||
parent_sync: parent_sync,
|
||||
window_start_date: window_start_date,
|
||||
window_end_date: window_end_date
|
||||
)
|
||||
results << { account_id: account.id, success: true }
|
||||
rescue => e
|
||||
Rails.logger.error "MercuryItem #{id} - Failed to schedule sync for account #{account.id}: #{e.message}"
|
||||
results << { account_id: account.id, success: false, error: e.message }
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
def upsert_mercury_snapshot!(accounts_snapshot)
|
||||
assign_attributes(
|
||||
raw_payload: accounts_snapshot
|
||||
)
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
def has_completed_initial_setup?
|
||||
# Setup is complete if we have any linked accounts
|
||||
accounts.any?
|
||||
end
|
||||
|
||||
# TODO: Customize sync status summary if needed
|
||||
# Some providers use latest_sync.sync_stats, others use count methods directly.
|
||||
# See SimplefinItem#sync_status_summary or EnableBankingItem#sync_status_summary for examples.
|
||||
def sync_status_summary
|
||||
total_accounts = total_accounts_count
|
||||
linked_count = linked_accounts_count
|
||||
unlinked_count = unlinked_accounts_count
|
||||
|
||||
if total_accounts == 0
|
||||
"No accounts found"
|
||||
elsif unlinked_count == 0
|
||||
"#{linked_count} #{'account'.pluralize(linked_count)} synced"
|
||||
else
|
||||
"#{linked_count} synced, #{unlinked_count} need setup"
|
||||
end
|
||||
end
|
||||
|
||||
def linked_accounts_count
|
||||
mercury_accounts.joins(:account_provider).count
|
||||
end
|
||||
|
||||
def unlinked_accounts_count
|
||||
mercury_accounts.left_joins(:account_provider).where(account_providers: { id: nil }).count
|
||||
end
|
||||
|
||||
def total_accounts_count
|
||||
mercury_accounts.count
|
||||
end
|
||||
|
||||
def institution_display_name
|
||||
institution_name.presence || institution_domain.presence || name
|
||||
end
|
||||
|
||||
# TODO: Customize based on how your provider stores institution data
|
||||
# SimpleFin uses org_data, others use institution_metadata.
|
||||
# Adjust the field name and key lookups as needed.
|
||||
def connected_institutions
|
||||
mercury_accounts.includes(:account)
|
||||
.where.not(institution_metadata: nil)
|
||||
.map { |acc| acc.institution_metadata }
|
||||
.uniq { |inst| inst["name"] || inst["institution_name"] }
|
||||
end
|
||||
|
||||
# TODO: Customize institution summary if your provider has special fields
|
||||
# EnableBanking uses aspsp_name as a fallback, for example.
|
||||
def institution_summary
|
||||
institutions = connected_institutions
|
||||
case institutions.count
|
||||
when 0
|
||||
"No institutions connected"
|
||||
when 1
|
||||
institutions.first["name"] || institutions.first["institution_name"] || "1 institution"
|
||||
else
|
||||
"#{institutions.count} institutions"
|
||||
end
|
||||
end
|
||||
|
||||
def credentials_configured?
|
||||
token.present?
|
||||
end
|
||||
|
||||
def effective_base_url
|
||||
base_url.presence || "https://api.mercury.com/api/v1"
|
||||
end
|
||||
end
|
||||
302
app/models/mercury_item/importer.rb
Normal file
302
app/models/mercury_item/importer.rb
Normal file
@@ -0,0 +1,302 @@
|
||||
class MercuryItem::Importer
|
||||
attr_reader :mercury_item, :mercury_provider
|
||||
|
||||
def initialize(mercury_item, mercury_provider:)
|
||||
@mercury_item = mercury_item
|
||||
@mercury_provider = mercury_provider
|
||||
end
|
||||
|
||||
def import
|
||||
Rails.logger.info "MercuryItem::Importer - Starting import for item #{mercury_item.id}"
|
||||
|
||||
# Step 1: Fetch all accounts from Mercury
|
||||
accounts_data = fetch_accounts_data
|
||||
unless accounts_data
|
||||
Rails.logger.error "MercuryItem::Importer - Failed to fetch accounts data for item #{mercury_item.id}"
|
||||
return { success: false, error: "Failed to fetch accounts data", accounts_imported: 0, transactions_imported: 0 }
|
||||
end
|
||||
|
||||
# Store raw payload
|
||||
begin
|
||||
mercury_item.upsert_mercury_snapshot!(accounts_data)
|
||||
rescue => e
|
||||
Rails.logger.error "MercuryItem::Importer - Failed to store accounts snapshot: #{e.message}"
|
||||
# Continue with import even if snapshot storage fails
|
||||
end
|
||||
|
||||
# Step 2: Update linked accounts and create records for new accounts from API
|
||||
accounts_updated = 0
|
||||
accounts_created = 0
|
||||
accounts_failed = 0
|
||||
|
||||
if accounts_data[:accounts].present?
|
||||
# Get linked mercury account IDs (ones actually imported/used by the user)
|
||||
linked_account_ids = mercury_item.mercury_accounts
|
||||
.joins(:account_provider)
|
||||
.pluck(:account_id)
|
||||
.map(&:to_s)
|
||||
|
||||
# Get all existing mercury account IDs (linked or not)
|
||||
all_existing_ids = mercury_item.mercury_accounts.pluck(:account_id).map(&:to_s)
|
||||
|
||||
accounts_data[:accounts].each do |account_data|
|
||||
account_id = account_data[:id]&.to_s
|
||||
next unless account_id.present?
|
||||
|
||||
# Mercury uses 'name' or 'nickname' for account name
|
||||
account_name = account_data[:nickname].presence || account_data[:name].presence || account_data[:legalBusinessName].presence
|
||||
next if account_name.blank?
|
||||
|
||||
if linked_account_ids.include?(account_id)
|
||||
# Update existing linked accounts
|
||||
begin
|
||||
import_account(account_data)
|
||||
accounts_updated += 1
|
||||
rescue => e
|
||||
accounts_failed += 1
|
||||
Rails.logger.error "MercuryItem::Importer - Failed to update account #{account_id}: #{e.message}"
|
||||
end
|
||||
elsif !all_existing_ids.include?(account_id)
|
||||
# Create new unlinked mercury_account records for accounts we haven't seen before
|
||||
# This allows users to link them later via "Setup new accounts"
|
||||
begin
|
||||
mercury_account = mercury_item.mercury_accounts.build(
|
||||
account_id: account_id,
|
||||
name: account_name,
|
||||
currency: "USD" # Mercury is US-only, always USD
|
||||
)
|
||||
mercury_account.upsert_mercury_snapshot!(account_data)
|
||||
accounts_created += 1
|
||||
Rails.logger.info "MercuryItem::Importer - Created new unlinked account record for #{account_id}"
|
||||
rescue => e
|
||||
accounts_failed += 1
|
||||
Rails.logger.error "MercuryItem::Importer - Failed to create account #{account_id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.info "MercuryItem::Importer - Updated #{accounts_updated} accounts, created #{accounts_created} new (#{accounts_failed} failed)"
|
||||
|
||||
# Step 3: Fetch transactions only for linked accounts with active status
|
||||
transactions_imported = 0
|
||||
transactions_failed = 0
|
||||
|
||||
mercury_item.mercury_accounts.joins(:account).merge(Account.visible).each do |mercury_account|
|
||||
begin
|
||||
result = fetch_and_store_transactions(mercury_account)
|
||||
if result[:success]
|
||||
transactions_imported += result[:transactions_count]
|
||||
else
|
||||
transactions_failed += 1
|
||||
end
|
||||
rescue => e
|
||||
transactions_failed += 1
|
||||
Rails.logger.error "MercuryItem::Importer - Failed to fetch/store transactions for account #{mercury_account.account_id}: #{e.message}"
|
||||
# Continue with other accounts even if one fails
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.info "MercuryItem::Importer - Completed import for item #{mercury_item.id}: #{accounts_updated} accounts updated, #{accounts_created} new accounts discovered, #{transactions_imported} transactions"
|
||||
|
||||
{
|
||||
success: accounts_failed == 0 && transactions_failed == 0,
|
||||
accounts_updated: accounts_updated,
|
||||
accounts_created: accounts_created,
|
||||
accounts_failed: accounts_failed,
|
||||
transactions_imported: transactions_imported,
|
||||
transactions_failed: transactions_failed
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_accounts_data
|
||||
begin
|
||||
accounts_data = mercury_provider.get_accounts
|
||||
rescue Provider::Mercury::MercuryError => e
|
||||
# Handle authentication errors by marking item as requiring update
|
||||
if e.error_type == :unauthorized || e.error_type == :access_forbidden
|
||||
begin
|
||||
mercury_item.update!(status: :requires_update)
|
||||
rescue => update_error
|
||||
Rails.logger.error "MercuryItem::Importer - Failed to update item status: #{update_error.message}"
|
||||
end
|
||||
end
|
||||
Rails.logger.error "MercuryItem::Importer - Mercury API error: #{e.message}"
|
||||
return nil
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "MercuryItem::Importer - Failed to parse Mercury API response: #{e.message}"
|
||||
return nil
|
||||
rescue => e
|
||||
Rails.logger.error "MercuryItem::Importer - Unexpected error fetching accounts: #{e.class} - #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
return nil
|
||||
end
|
||||
|
||||
# Validate response structure
|
||||
unless accounts_data.is_a?(Hash)
|
||||
Rails.logger.error "MercuryItem::Importer - Invalid accounts_data format: expected Hash, got #{accounts_data.class}"
|
||||
return nil
|
||||
end
|
||||
|
||||
# Handle errors if present in response
|
||||
if accounts_data[:error].present?
|
||||
handle_error(accounts_data[:error])
|
||||
return nil
|
||||
end
|
||||
|
||||
accounts_data
|
||||
end
|
||||
|
||||
def import_account(account_data)
|
||||
# Validate account data structure
|
||||
unless account_data.is_a?(Hash)
|
||||
Rails.logger.error "MercuryItem::Importer - Invalid account_data format: expected Hash, got #{account_data.class}"
|
||||
raise ArgumentError, "Invalid account data format"
|
||||
end
|
||||
|
||||
account_id = account_data[:id]
|
||||
|
||||
# Validate required account_id
|
||||
if account_id.blank?
|
||||
Rails.logger.warn "MercuryItem::Importer - Skipping account with missing ID"
|
||||
raise ArgumentError, "Account ID is required"
|
||||
end
|
||||
|
||||
# Only find existing accounts, don't create new ones during sync
|
||||
mercury_account = mercury_item.mercury_accounts.find_by(
|
||||
account_id: account_id.to_s
|
||||
)
|
||||
|
||||
# Skip if account wasn't previously selected
|
||||
unless mercury_account
|
||||
Rails.logger.debug "MercuryItem::Importer - Skipping unselected account #{account_id}"
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
mercury_account.upsert_mercury_snapshot!(account_data)
|
||||
mercury_account.save!
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "MercuryItem::Importer - Failed to save mercury_account: #{e.message}"
|
||||
raise StandardError.new("Failed to save account: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_and_store_transactions(mercury_account)
|
||||
start_date = determine_sync_start_date(mercury_account)
|
||||
Rails.logger.info "MercuryItem::Importer - Fetching transactions for account #{mercury_account.account_id} from #{start_date}"
|
||||
|
||||
begin
|
||||
# Fetch transactions
|
||||
transactions_data = mercury_provider.get_account_transactions(
|
||||
mercury_account.account_id,
|
||||
start_date: start_date
|
||||
)
|
||||
|
||||
# Validate response structure
|
||||
unless transactions_data.is_a?(Hash)
|
||||
Rails.logger.error "MercuryItem::Importer - Invalid transactions_data format for account #{mercury_account.account_id}"
|
||||
return { success: false, transactions_count: 0, error: "Invalid response format" }
|
||||
end
|
||||
|
||||
transactions_count = transactions_data[:transactions]&.count || 0
|
||||
Rails.logger.info "MercuryItem::Importer - Fetched #{transactions_count} transactions for account #{mercury_account.account_id}"
|
||||
|
||||
# Store transactions in the account
|
||||
if transactions_data[:transactions].present?
|
||||
begin
|
||||
existing_transactions = mercury_account.raw_transactions_payload.to_a
|
||||
|
||||
# Build set of existing transaction IDs for efficient lookup
|
||||
existing_ids = existing_transactions.map do |tx|
|
||||
tx.with_indifferent_access[:id]
|
||||
end.to_set
|
||||
|
||||
# Filter to ONLY truly new transactions (skip duplicates)
|
||||
# Transactions are immutable on the bank side, so we don't need to update them
|
||||
new_transactions = transactions_data[:transactions].select do |tx|
|
||||
next false unless tx.is_a?(Hash)
|
||||
|
||||
tx_id = tx.with_indifferent_access[:id]
|
||||
tx_id.present? && !existing_ids.include?(tx_id)
|
||||
end
|
||||
|
||||
if new_transactions.any?
|
||||
Rails.logger.info "MercuryItem::Importer - Storing #{new_transactions.count} new transactions (#{existing_transactions.count} existing, #{transactions_data[:transactions].count - new_transactions.count} duplicates skipped) for account #{mercury_account.account_id}"
|
||||
mercury_account.upsert_mercury_transactions_snapshot!(existing_transactions + new_transactions)
|
||||
else
|
||||
Rails.logger.info "MercuryItem::Importer - No new transactions to store (all #{transactions_data[:transactions].count} were duplicates) for account #{mercury_account.account_id}"
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "MercuryItem::Importer - Failed to store transactions for account #{mercury_account.account_id}: #{e.message}"
|
||||
return { success: false, transactions_count: 0, error: "Failed to store transactions: #{e.message}" }
|
||||
end
|
||||
else
|
||||
Rails.logger.info "MercuryItem::Importer - No transactions to store for account #{mercury_account.account_id}"
|
||||
end
|
||||
|
||||
{ success: true, transactions_count: transactions_count }
|
||||
rescue Provider::Mercury::MercuryError => e
|
||||
Rails.logger.error "MercuryItem::Importer - Mercury API error for account #{mercury_account.id}: #{e.message}"
|
||||
{ success: false, transactions_count: 0, error: e.message }
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "MercuryItem::Importer - Failed to parse transaction response for account #{mercury_account.id}: #{e.message}"
|
||||
{ success: false, transactions_count: 0, error: "Failed to parse response" }
|
||||
rescue => e
|
||||
Rails.logger.error "MercuryItem::Importer - Unexpected error fetching transactions for account #{mercury_account.id}: #{e.class} - #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
{ success: false, transactions_count: 0, error: "Unexpected error: #{e.message}" }
|
||||
end
|
||||
end
|
||||
|
||||
def determine_sync_start_date(mercury_account)
|
||||
# Check if this account has any stored transactions
|
||||
# If not, treat it as a first sync for this account even if the item has been synced before
|
||||
has_stored_transactions = mercury_account.raw_transactions_payload.to_a.any?
|
||||
|
||||
if has_stored_transactions
|
||||
# Account has been synced before, use item-level logic with buffer
|
||||
# For subsequent syncs, fetch from last sync date with a buffer
|
||||
if mercury_item.last_synced_at
|
||||
mercury_item.last_synced_at - 7.days
|
||||
else
|
||||
# Fallback if item hasn't been synced but account has transactions
|
||||
90.days.ago
|
||||
end
|
||||
else
|
||||
# Account has no stored transactions - this is a first sync for this account
|
||||
# Use account creation date or a generous historical window
|
||||
account_baseline = mercury_account.created_at || Time.current
|
||||
first_sync_window = [ account_baseline - 7.days, 90.days.ago ].max
|
||||
|
||||
# Use the more recent of: (account created - 7 days) or (90 days ago)
|
||||
# This caps old accounts at 90 days while respecting recent account creation dates
|
||||
first_sync_window
|
||||
end
|
||||
end
|
||||
|
||||
def handle_error(error_message)
|
||||
# Mark item as requiring update for authentication-related errors
|
||||
error_msg_lower = error_message.to_s.downcase
|
||||
needs_update = error_msg_lower.include?("authentication") ||
|
||||
error_msg_lower.include?("unauthorized") ||
|
||||
error_msg_lower.include?("api key") ||
|
||||
error_msg_lower.include?("api token")
|
||||
|
||||
if needs_update
|
||||
begin
|
||||
mercury_item.update!(status: :requires_update)
|
||||
rescue => e
|
||||
Rails.logger.error "MercuryItem::Importer - Failed to update item status: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.error "MercuryItem::Importer - API error: #{error_message}"
|
||||
raise Provider::Mercury::MercuryError.new(
|
||||
"Mercury API error: #{error_message}",
|
||||
:api_error
|
||||
)
|
||||
end
|
||||
end
|
||||
13
app/models/mercury_item/provided.rb
Normal file
13
app/models/mercury_item/provided.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module MercuryItem::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def mercury_provider
|
||||
return nil unless credentials_configured?
|
||||
|
||||
Provider::Mercury.new(token, base_url: effective_base_url)
|
||||
end
|
||||
|
||||
def syncer
|
||||
MercuryItem::Syncer.new(self)
|
||||
end
|
||||
end
|
||||
25
app/models/mercury_item/sync_complete_event.rb
Normal file
25
app/models/mercury_item/sync_complete_event.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class MercuryItem::SyncCompleteEvent
|
||||
attr_reader :mercury_item
|
||||
|
||||
def initialize(mercury_item)
|
||||
@mercury_item = mercury_item
|
||||
end
|
||||
|
||||
def broadcast
|
||||
# Update UI with latest account data
|
||||
mercury_item.accounts.each do |account|
|
||||
account.broadcast_sync_complete
|
||||
end
|
||||
|
||||
# Update the Mercury item view
|
||||
mercury_item.broadcast_replace_to(
|
||||
mercury_item.family,
|
||||
target: "mercury_item_#{mercury_item.id}",
|
||||
partial: "mercury_items/mercury_item",
|
||||
locals: { mercury_item: mercury_item }
|
||||
)
|
||||
|
||||
# Let family handle sync notifications
|
||||
mercury_item.family.broadcast_sync_complete
|
||||
end
|
||||
end
|
||||
64
app/models/mercury_item/syncer.rb
Normal file
64
app/models/mercury_item/syncer.rb
Normal file
@@ -0,0 +1,64 @@
|
||||
class MercuryItem::Syncer
|
||||
include SyncStats::Collector
|
||||
|
||||
attr_reader :mercury_item
|
||||
|
||||
def initialize(mercury_item)
|
||||
@mercury_item = mercury_item
|
||||
end
|
||||
|
||||
def perform_sync(sync)
|
||||
# Phase 1: Import data from Mercury API
|
||||
sync.update!(status_text: "Importing accounts from Mercury...") if sync.respond_to?(:status_text)
|
||||
mercury_item.import_latest_mercury_data
|
||||
|
||||
# Phase 2: Collect setup statistics using shared concern
|
||||
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
|
||||
collect_setup_stats(sync, provider_accounts: mercury_item.mercury_accounts)
|
||||
|
||||
# Check for unlinked accounts
|
||||
linked_accounts = mercury_item.mercury_accounts.joins(:account_provider)
|
||||
unlinked_accounts = mercury_item.mercury_accounts.left_joins(:account_provider).where(account_providers: { id: nil })
|
||||
|
||||
# Set pending_account_setup if there are unlinked accounts
|
||||
if unlinked_accounts.any?
|
||||
mercury_item.update!(pending_account_setup: true)
|
||||
sync.update!(status_text: "#{unlinked_accounts.count} accounts need setup...") if sync.respond_to?(:status_text)
|
||||
else
|
||||
mercury_item.update!(pending_account_setup: false)
|
||||
end
|
||||
|
||||
# Phase 3: Process transactions for linked accounts only
|
||||
if linked_accounts.any?
|
||||
sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text)
|
||||
mark_import_started(sync)
|
||||
Rails.logger.info "MercuryItem::Syncer - Processing #{linked_accounts.count} linked accounts"
|
||||
mercury_item.process_accounts
|
||||
Rails.logger.info "MercuryItem::Syncer - Finished processing accounts"
|
||||
|
||||
# Phase 4: Schedule balance calculations for linked accounts
|
||||
sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text)
|
||||
mercury_item.schedule_account_syncs(
|
||||
parent_sync: sync,
|
||||
window_start_date: sync.window_start_date,
|
||||
window_end_date: sync.window_end_date
|
||||
)
|
||||
|
||||
# Phase 5: Collect transaction statistics
|
||||
account_ids = linked_accounts.includes(:account_provider).filter_map { |ma| ma.current_account&.id }
|
||||
collect_transaction_stats(sync, account_ids: account_ids, source: "mercury")
|
||||
else
|
||||
Rails.logger.info "MercuryItem::Syncer - No linked accounts to process"
|
||||
end
|
||||
|
||||
# Mark sync health
|
||||
collect_health_stats(sync, errors: nil)
|
||||
rescue => e
|
||||
collect_health_stats(sync, errors: [ { message: e.message, category: "sync_error" } ])
|
||||
raise
|
||||
end
|
||||
|
||||
def perform_post_sync
|
||||
# no-op
|
||||
end
|
||||
end
|
||||
49
app/models/mercury_item/unlinking.rb
Normal file
49
app/models/mercury_item/unlinking.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module MercuryItem::Unlinking
|
||||
# Concern that encapsulates unlinking logic for a Mercury item.
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Idempotently remove all connections between this Mercury item and local accounts.
|
||||
# - Detaches any AccountProvider links for each MercuryAccount
|
||||
# - Detaches Holdings that point at the AccountProvider links
|
||||
# Returns a per-account result payload for observability
|
||||
def unlink_all!(dry_run: false)
|
||||
results = []
|
||||
|
||||
mercury_accounts.find_each do |provider_account|
|
||||
links = AccountProvider.where(provider_type: "MercuryAccount", provider_id: provider_account.id).to_a
|
||||
link_ids = links.map(&:id)
|
||||
result = {
|
||||
provider_account_id: provider_account.id,
|
||||
name: provider_account.name,
|
||||
provider_link_ids: link_ids
|
||||
}
|
||||
results << result
|
||||
|
||||
next if dry_run
|
||||
|
||||
begin
|
||||
ActiveRecord::Base.transaction do
|
||||
# Detach holdings for any provider links found
|
||||
if link_ids.any?
|
||||
Holding.where(account_provider_id: link_ids).update_all(account_provider_id: nil)
|
||||
end
|
||||
|
||||
# Destroy all provider links
|
||||
links.each do |ap|
|
||||
ap.destroy!
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn(
|
||||
"MercuryItem Unlinker: failed to fully unlink provider account ##{provider_account.id} (links=#{link_ids.inspect}): #{e.class} - #{e.message}"
|
||||
)
|
||||
# Record error for observability; continue with other accounts
|
||||
result[:error] = e.message
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
end
|
||||
157
app/models/provider/mercury.rb
Normal file
157
app/models/provider/mercury.rb
Normal file
@@ -0,0 +1,157 @@
|
||||
class Provider::Mercury
|
||||
include HTTParty
|
||||
|
||||
headers "User-Agent" => "Sure Finance Mercury Client"
|
||||
default_options.merge!(verify: true, ssl_verify_mode: OpenSSL::SSL::VERIFY_PEER, timeout: 120)
|
||||
|
||||
attr_reader :token, :base_url
|
||||
|
||||
def initialize(token, base_url: "https://api.mercury.com/api/v1")
|
||||
@token = token
|
||||
@base_url = base_url
|
||||
end
|
||||
|
||||
# Get all accounts
|
||||
# Returns: { accounts: [...] }
|
||||
# Account structure: { id, name, currentBalance, availableBalance, status, type, kind, legalBusinessName, nickname }
|
||||
def get_accounts
|
||||
response = self.class.get(
|
||||
"#{@base_url}/accounts",
|
||||
headers: auth_headers
|
||||
)
|
||||
|
||||
handle_response(response)
|
||||
rescue MercuryError
|
||||
raise
|
||||
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
Rails.logger.error "Mercury API: GET /accounts failed: #{e.class}: #{e.message}"
|
||||
raise MercuryError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||
rescue => e
|
||||
Rails.logger.error "Mercury API: Unexpected error during GET /accounts: #{e.class}: #{e.message}"
|
||||
raise MercuryError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||
end
|
||||
|
||||
# Get a single account by ID
|
||||
# Returns: { id, name, currentBalance, availableBalance, status, type, kind, ... }
|
||||
def get_account(account_id)
|
||||
path = "/account/#{ERB::Util.url_encode(account_id.to_s)}"
|
||||
|
||||
response = self.class.get(
|
||||
"#{@base_url}#{path}",
|
||||
headers: auth_headers
|
||||
)
|
||||
|
||||
handle_response(response)
|
||||
rescue MercuryError
|
||||
raise
|
||||
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
Rails.logger.error "Mercury API: GET #{path} failed: #{e.class}: #{e.message}"
|
||||
raise MercuryError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||
rescue => e
|
||||
Rails.logger.error "Mercury API: Unexpected error during GET #{path}: #{e.class}: #{e.message}"
|
||||
raise MercuryError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||
end
|
||||
|
||||
# Get transactions for a specific account
|
||||
# Returns: { transactions: [...], total: N }
|
||||
# Transaction structure: { id, amount, bankDescription, counterpartyId, counterpartyName,
|
||||
# counterpartyNickname, createdAt, dashboardLink, details,
|
||||
# estimatedDeliveryDate, failedAt, kind, note, postedAt,
|
||||
# reasonForFailure, status }
|
||||
def get_account_transactions(account_id, start_date: nil, end_date: nil, offset: nil, limit: nil)
|
||||
query_params = {}
|
||||
|
||||
if start_date
|
||||
query_params[:start] = start_date.to_date.to_s
|
||||
end
|
||||
|
||||
if end_date
|
||||
query_params[:end] = end_date.to_date.to_s
|
||||
end
|
||||
|
||||
if offset
|
||||
query_params[:offset] = offset.to_i
|
||||
end
|
||||
|
||||
if limit
|
||||
query_params[:limit] = limit.to_i
|
||||
end
|
||||
|
||||
path = "/account/#{ERB::Util.url_encode(account_id.to_s)}/transactions"
|
||||
path += "?#{URI.encode_www_form(query_params)}" unless query_params.empty?
|
||||
|
||||
response = self.class.get(
|
||||
"#{@base_url}#{path}",
|
||||
headers: auth_headers
|
||||
)
|
||||
|
||||
handle_response(response)
|
||||
rescue MercuryError
|
||||
raise
|
||||
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
Rails.logger.error "Mercury API: GET #{path} failed: #{e.class}: #{e.message}"
|
||||
raise MercuryError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||
rescue => e
|
||||
Rails.logger.error "Mercury API: Unexpected error during GET #{path}: #{e.class}: #{e.message}"
|
||||
raise MercuryError.new("Exception during GET request: #{e.message}", :request_failed)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def auth_headers
|
||||
{
|
||||
"Authorization" => "Bearer #{token}",
|
||||
"Content-Type" => "application/json",
|
||||
"Accept" => "application/json"
|
||||
}
|
||||
end
|
||||
|
||||
def handle_response(response)
|
||||
case response.code
|
||||
when 200
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
when 400
|
||||
Rails.logger.error "Mercury API: Bad request - #{response.body}"
|
||||
raise MercuryError.new("Bad request to Mercury API: #{response.body}", :bad_request)
|
||||
when 401
|
||||
# Parse the error response for more specific messages
|
||||
error_message = parse_error_message(response.body)
|
||||
raise MercuryError.new(error_message, :unauthorized)
|
||||
when 403
|
||||
raise MercuryError.new("Access forbidden - check your API token permissions", :access_forbidden)
|
||||
when 404
|
||||
raise MercuryError.new("Resource not found", :not_found)
|
||||
when 429
|
||||
raise MercuryError.new("Rate limit exceeded. Please try again later.", :rate_limited)
|
||||
else
|
||||
Rails.logger.error "Mercury API: Unexpected response - Code: #{response.code}, Body: #{response.body}"
|
||||
raise MercuryError.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_error_message(body)
|
||||
parsed = JSON.parse(body, symbolize_names: true)
|
||||
errors = parsed[:errors] || {}
|
||||
|
||||
case errors[:errorCode]
|
||||
when "ipNotWhitelisted"
|
||||
ip = errors[:ip] || "unknown"
|
||||
"IP address not whitelisted (#{ip}). Add your IP to the API token's whitelist in Mercury dashboard."
|
||||
when "noTokenInDBButMaybeMalformed"
|
||||
"Invalid token format. Make sure to include the 'secret-token:' prefix."
|
||||
else
|
||||
errors[:message] || "Invalid API token"
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
"Invalid API token"
|
||||
end
|
||||
|
||||
class MercuryError < StandardError
|
||||
attr_reader :error_type
|
||||
|
||||
def initialize(message, error_type = :unknown)
|
||||
super(message)
|
||||
@error_type = error_type
|
||||
end
|
||||
end
|
||||
end
|
||||
105
app/models/provider/mercury_adapter.rb
Normal file
105
app/models/provider/mercury_adapter.rb
Normal file
@@ -0,0 +1,105 @@
|
||||
class Provider::MercuryAdapter < Provider::Base
|
||||
include Provider::Syncable
|
||||
include Provider::InstitutionMetadata
|
||||
|
||||
# Register this adapter with the factory
|
||||
Provider::Factory.register("MercuryAccount", self)
|
||||
|
||||
# Define which account types this provider supports
|
||||
# Mercury is primarily a business banking provider with checking/savings accounts
|
||||
def self.supported_account_types
|
||||
%w[Depository]
|
||||
end
|
||||
|
||||
# Returns connection configurations for this provider
|
||||
def self.connection_configs(family:)
|
||||
return [] unless family.can_connect_mercury?
|
||||
|
||||
[ {
|
||||
key: "mercury",
|
||||
name: "Mercury",
|
||||
description: "Connect to your bank via Mercury",
|
||||
can_connect: true,
|
||||
new_account_path: ->(accountable_type, return_to) {
|
||||
Rails.application.routes.url_helpers.select_accounts_mercury_items_path(
|
||||
accountable_type: accountable_type,
|
||||
return_to: return_to
|
||||
)
|
||||
},
|
||||
existing_account_path: ->(account_id) {
|
||||
Rails.application.routes.url_helpers.select_existing_account_mercury_items_path(
|
||||
account_id: account_id
|
||||
)
|
||||
}
|
||||
} ]
|
||||
end
|
||||
|
||||
def provider_name
|
||||
"mercury"
|
||||
end
|
||||
|
||||
# Build a Mercury provider instance with family-specific credentials
|
||||
# @param family [Family] The family to get credentials for (required)
|
||||
# @return [Provider::Mercury, nil] Returns nil if credentials are not configured
|
||||
def self.build_provider(family: nil)
|
||||
return nil unless family.present?
|
||||
|
||||
# Get family-specific credentials
|
||||
mercury_item = family.mercury_items.where.not(token: nil).first
|
||||
return nil unless mercury_item&.credentials_configured?
|
||||
|
||||
Provider::Mercury.new(
|
||||
mercury_item.token,
|
||||
base_url: mercury_item.effective_base_url
|
||||
)
|
||||
end
|
||||
|
||||
def sync_path
|
||||
Rails.application.routes.url_helpers.sync_mercury_item_path(item)
|
||||
end
|
||||
|
||||
def item
|
||||
provider_account.mercury_item
|
||||
end
|
||||
|
||||
def can_delete_holdings?
|
||||
false
|
||||
end
|
||||
|
||||
def institution_domain
|
||||
metadata = provider_account.institution_metadata
|
||||
return nil unless metadata.present?
|
||||
|
||||
domain = metadata["domain"]
|
||||
url = metadata["url"]
|
||||
|
||||
# Derive domain from URL if missing
|
||||
if domain.blank? && url.present?
|
||||
begin
|
||||
domain = URI.parse(url).host&.gsub(/^www\./, "")
|
||||
rescue URI::InvalidURIError
|
||||
Rails.logger.warn("Invalid institution URL for Mercury account #{provider_account.id}: #{url}")
|
||||
end
|
||||
end
|
||||
|
||||
domain
|
||||
end
|
||||
|
||||
def institution_name
|
||||
metadata = provider_account.institution_metadata
|
||||
return nil unless metadata.present?
|
||||
|
||||
metadata["name"] || item&.institution_name
|
||||
end
|
||||
|
||||
def institution_url
|
||||
metadata = provider_account.institution_metadata
|
||||
return nil unless metadata.present?
|
||||
|
||||
metadata["url"] || item&.institution_url
|
||||
end
|
||||
|
||||
def institution_color
|
||||
item&.institution_color
|
||||
end
|
||||
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" }
|
||||
enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury" }
|
||||
|
||||
validates :name, uniqueness: { scope: [ :source ] }
|
||||
validates :source, presence: true
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? %>
|
||||
<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @mercury_items.empty? && @coinbase_items.empty? %>
|
||||
<%= render "empty" %>
|
||||
<% else %>
|
||||
<div class="space-y-2">
|
||||
@@ -45,6 +45,10 @@
|
||||
<%= render @coinstats_items.sort_by(&:created_at) %>
|
||||
<% end %>
|
||||
|
||||
<% if @mercury_items.any? %>
|
||||
<%= render @mercury_items.sort_by(&:created_at) %>
|
||||
<% end %>
|
||||
|
||||
<% if @coinbase_items.any? %>
|
||||
<%= render @coinbase_items.sort_by(&:created_at) %>
|
||||
<% end %>
|
||||
|
||||
@@ -43,15 +43,15 @@
|
||||
</td>
|
||||
<td class="px-2 py-3">
|
||||
<% if export.processing? || export.pending? %>
|
||||
<%= render 'shared/badge' do %>
|
||||
<%= render "shared/badge" do %>
|
||||
<%= t("family_exports.table.row.status.in_progress") %>
|
||||
<% end %>
|
||||
<% elsif export.completed? %>
|
||||
<%= render 'shared/badge', color: 'success' do %>
|
||||
<%= render "shared/badge", color: "success" do %>
|
||||
<%= t("family_exports.table.row.status.complete") %>
|
||||
<% end %>
|
||||
<% elsif export.failed? %>
|
||||
<%= render 'shared/badge', color: 'error' do %>
|
||||
<%= render "shared/badge", color: "error" do %>
|
||||
<%= t("family_exports.table.row.status.failed") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -41,32 +41,32 @@
|
||||
<% if import.account.present? %>
|
||||
<%= import.account.name + " " %>
|
||||
<% end %>
|
||||
<%= import.type.titleize.gsub(/ Import\z/, '') %>
|
||||
<%= import.type.titleize.gsub(/ Import\z/, "") %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-2 py-3">
|
||||
<% if import.pending? %>
|
||||
<%= render 'shared/badge' do %>
|
||||
<%= render "shared/badge" do %>
|
||||
<%= t("imports.table.row.status.in_progress") %>
|
||||
<% end %>
|
||||
<% elsif import.importing? %>
|
||||
<%= render 'shared/badge', color: 'warning', pulse: true do %>
|
||||
<%= render "shared/badge", color: "warning", pulse: true do %>
|
||||
<%= t("imports.table.row.status.uploading") %>
|
||||
<% end %>
|
||||
<% elsif import.failed? %>
|
||||
<%= render 'shared/badge', color: 'error' do %>
|
||||
<%= render "shared/badge", color: "error" do %>
|
||||
<%= t("imports.table.row.status.failed") %>
|
||||
<% end %>
|
||||
<% elsif import.reverting? %>
|
||||
<%= render 'shared/badge', color: 'warning' do %>
|
||||
<%= render "shared/badge", color: "warning" do %>
|
||||
<%= t("imports.table.row.status.reverting") %>
|
||||
<% end %>
|
||||
<% elsif import.revert_failed? %>
|
||||
<%= render 'shared/badge', color: 'error' do %>
|
||||
<%= render "shared/badge", color: "error" do %>
|
||||
<%= t("imports.table.row.status.revert_failed") %>
|
||||
<% end %>
|
||||
<% elsif import.complete? %>
|
||||
<%= render 'shared/badge', color: 'success' do %>
|
||||
<%= render "shared/badge", color: "success" do %>
|
||||
<%= t("imports.table.row.status.complete") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
36
app/views/mercury_items/_api_error.html.erb
Normal file
36
app/views/mercury_items/_api_error.html.erb
Normal file
@@ -0,0 +1,36 @@
|
||||
<%# locals: (error_message:, return_path:) %>
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: "Mercury Connection Error") %>
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon("alert-circle", class: "text-destructive w-5 h-5 shrink-0 mt-0.5") %>
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-primary mb-2">Unable to connect to Mercury</p>
|
||||
<p class="text-secondary"><%= error_message %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
|
||||
<p class="font-medium text-primary">Common Issues:</p>
|
||||
<ul class="list-disc list-inside space-y-1 text-secondary">
|
||||
<li><strong>Invalid API Token:</strong> Check your API token in Provider Settings</li>
|
||||
<li><strong>Expired Credentials:</strong> Generate a new API token from Mercury</li>
|
||||
<li><strong>Insufficient Permissions:</strong> Ensure your token has read-only access</li>
|
||||
<li><strong>Network Issue:</strong> Check your internet connection</li>
|
||||
<li><strong>Service Down:</strong> Mercury API may be temporarily unavailable</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<%= link_to settings_providers_path,
|
||||
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors",
|
||||
data: { turbo: false } do %>
|
||||
Check Provider Settings
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
130
app/views/mercury_items/_mercury_item.html.erb
Normal file
130
app/views/mercury_items/_mercury_item.html.erb
Normal file
@@ -0,0 +1,130 @@
|
||||
<%# locals: (mercury_item:) %>
|
||||
|
||||
<%= tag.div id: dom_id(mercury_item) do %>
|
||||
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<summary class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||
|
||||
<div class="flex items-center justify-center h-8 w-8 bg-blue-600/10 rounded-full">
|
||||
<% if mercury_item.logo.attached? %>
|
||||
<%= image_tag mercury_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %>
|
||||
<% else %>
|
||||
<div class="flex items-center justify-center">
|
||||
<%= tag.p mercury_item.name.first.upcase, class: "text-blue-600 text-xs font-medium" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="pl-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= tag.p mercury_item.name, class: "font-medium text-primary" %>
|
||||
<% if mercury_item.scheduled_for_deletion? %>
|
||||
<p class="text-destructive text-sm animate-pulse"><%= t(".deletion_in_progress") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if mercury_item.accounts.any? %>
|
||||
<p class="text-xs text-secondary">
|
||||
<%= mercury_item.institution_summary %>
|
||||
</p>
|
||||
<% end %>
|
||||
<% if mercury_item.syncing? %>
|
||||
<div class="text-secondary flex items-center gap-1">
|
||||
<%= icon "loader", size: "sm", class: "animate-spin" %>
|
||||
<%= tag.span t(".syncing") %>
|
||||
</div>
|
||||
<% elsif mercury_item.sync_error.present? %>
|
||||
<div class="text-secondary flex items-center gap-1">
|
||||
<%= render DS::Tooltip.new(text: mercury_item.sync_error, icon: "alert-circle", size: "sm", color: "destructive") %>
|
||||
<%= tag.span t(".error"), class: "text-destructive" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-secondary">
|
||||
<% if mercury_item.last_synced_at %>
|
||||
<% if mercury_item.sync_status_summary %>
|
||||
<%= t(".status_with_summary", timestamp: time_ago_in_words(mercury_item.last_synced_at), summary: mercury_item.sync_status_summary) %>
|
||||
<% else %>
|
||||
<%= t(".status", timestamp: time_ago_in_words(mercury_item.last_synced_at)) %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= t(".status_never") %>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<% if Rails.env.development? %>
|
||||
<%= icon(
|
||||
"refresh-cw",
|
||||
as_button: true,
|
||||
href: sync_mercury_item_path(mercury_item)
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
<%= render DS::Menu.new do |menu| %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".delete"),
|
||||
icon: "trash-2",
|
||||
href: mercury_item_path(mercury_item),
|
||||
method: :delete,
|
||||
confirm: CustomConfirm.for_resource_deletion(mercury_item.name, high_severity: true)
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<% unless mercury_item.scheduled_for_deletion? %>
|
||||
<div class="space-y-4 mt-4">
|
||||
<% if mercury_item.accounts.any? %>
|
||||
<%= render "accounts/index/account_groups", accounts: mercury_item.accounts %>
|
||||
<% end %>
|
||||
|
||||
<%# Sync summary (collapsible) - using shared ProviderSyncSummary component %>
|
||||
<% stats = if defined?(@mercury_sync_stats_map) && @mercury_sync_stats_map
|
||||
@mercury_sync_stats_map[mercury_item.id] || {}
|
||||
else
|
||||
mercury_item.syncs.ordered.first&.sync_stats || {}
|
||||
end %>
|
||||
<%= render ProviderSyncSummary.new(
|
||||
stats: stats,
|
||||
provider_item: mercury_item,
|
||||
institutions_count: mercury_item.connected_institutions.size
|
||||
) %>
|
||||
|
||||
<%# Use model methods for consistent counts %>
|
||||
<% unlinked_count = mercury_item.unlinked_accounts_count %>
|
||||
<% linked_count = mercury_item.linked_accounts_count %>
|
||||
<% total_count = mercury_item.total_accounts_count %>
|
||||
|
||||
<% if unlinked_count > 0 %>
|
||||
<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", linked: linked_count, total: total_count) %></p>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".setup_action"),
|
||||
icon: "settings",
|
||||
variant: "primary",
|
||||
href: setup_accounts_mercury_item_path(mercury_item),
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
<% elsif mercury_item.accounts.empty? && total_count == 0 %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
<p class="text-primary font-medium text-sm"><%= t(".no_accounts_title") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".no_accounts_description") %></p>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".setup_action"),
|
||||
icon: "settings",
|
||||
variant: "primary",
|
||||
href: setup_accounts_mercury_item_path(mercury_item),
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</details>
|
||||
<% end %>
|
||||
34
app/views/mercury_items/_setup_required.html.erb
Normal file
34
app/views/mercury_items/_setup_required.html.erb
Normal file
@@ -0,0 +1,34 @@
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: "Mercury Setup Required") %>
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon("alert-circle", class: "text-warning w-5 h-5 shrink-0 mt-0.5") %>
|
||||
<div class="text-sm text-secondary">
|
||||
<p class="font-medium text-primary mb-2">API Token Not Configured</p>
|
||||
<p>Before you can link Mercury accounts, you need to configure your Mercury API token.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-surface rounded-lg p-4 space-y-2 text-sm">
|
||||
<p class="font-medium text-primary">Setup Steps:</p>
|
||||
<ol class="list-decimal list-inside space-y-1 text-secondary">
|
||||
<li>Go to <strong>Settings > Providers</strong></li>
|
||||
<li>Find the <strong>Mercury</strong> section</li>
|
||||
<li>Enter your Mercury API token</li>
|
||||
<li>Return here to link your accounts</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<%= link_to settings_providers_path,
|
||||
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors",
|
||||
data: { turbo: false } do %>
|
||||
Go to Provider Settings
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
23
app/views/mercury_items/_subtype_select.html.erb
Normal file
23
app/views/mercury_items/_subtype_select.html.erb
Normal file
@@ -0,0 +1,23 @@
|
||||
<div class="subtype-select" data-type="<%= account_type %>" style="display: none;">
|
||||
<% if subtype_config[:options].present? %>
|
||||
<%= label_tag "account_subtypes[#{mercury_account.id}]", subtype_config[:label],
|
||||
class: "block text-sm font-medium text-primary mb-2" %>
|
||||
<% selected_value = "" %>
|
||||
<% if account_type == "Depository" %>
|
||||
<% n = mercury_account.name.to_s.downcase %>
|
||||
<% selected_value = "" %>
|
||||
<% if n =~ /\bchecking\b|\bchequing\b|\bck\b|demand\s+deposit/ %>
|
||||
<% selected_value = "checking" %>
|
||||
<% elsif n =~ /\bsavings\b|\bsv\b/ %>
|
||||
<% selected_value = "savings" %>
|
||||
<% elsif n =~ /money\s+market|\bmm\b/ %>
|
||||
<% selected_value = "money_market" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= select_tag "account_subtypes[#{mercury_account.id}]",
|
||||
options_for_select([["Select #{account_type == 'Depository' ? 'subtype' : 'type'}", ""]] + subtype_config[:options], selected_value),
|
||||
{ class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full" } %>
|
||||
<% else %>
|
||||
<p class="text-sm text-secondary"><%= subtype_config[:message] %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
57
app/views/mercury_items/select_accounts.html.erb
Normal file
57
app/views/mercury_items/select_accounts.html.erb
Normal file
@@ -0,0 +1,57 @@
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title")) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t(".description", product_name: product_name) %>
|
||||
</p>
|
||||
|
||||
<form action="<%= link_accounts_mercury_items_path %>" method="post" class="space-y-4" data-turbo-frame="_top">
|
||||
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
|
||||
<%= hidden_field_tag :accountable_type, @accountable_type %>
|
||||
<%= hidden_field_tag :return_to, @return_to %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<% @available_accounts.each do |account| %>
|
||||
<% account_name = account[:nickname].presence || account[:name].presence || account[:legalBusinessName].presence %>
|
||||
<% has_blank_name = account_name.blank? %>
|
||||
<label class="flex items-start gap-3 p-3 border <%= has_blank_name ? "border-error bg-error/5" : "border-primary" %> rounded-lg <%= has_blank_name ? "cursor-not-allowed opacity-60" : "hover:bg-subtle cursor-pointer" %> transition-colors">
|
||||
<%= check_box_tag "account_ids[]", account[:id], false, disabled: has_blank_name, class: "mt-1" %>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm <%= has_blank_name ? "text-error" : "text-primary" %>">
|
||||
<% if has_blank_name %>
|
||||
<%= t(".no_name_placeholder") %>
|
||||
<% else %>
|
||||
<%= account_name %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
Mercury • USD • <%= account[:status] %>
|
||||
<% if account[:type].present? %>
|
||||
• <%= account[:type].titleize %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if has_blank_name %>
|
||||
<div class="text-xs text-error mt-1">
|
||||
<%= t(".configure_name_in_mercury") %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<%= link_to t(".cancel"), @return_to || new_account_path,
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
|
||||
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
|
||||
<%= submit_tag t(".link_accounts"),
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
57
app/views/mercury_items/select_existing_account.html.erb
Normal file
57
app/views/mercury_items/select_existing_account.html.erb
Normal file
@@ -0,0 +1,57 @@
|
||||
<%= turbo_frame_tag "modal" do %>
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title", account_name: @account.name)) %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-secondary">
|
||||
<%= t(".description") %>
|
||||
</p>
|
||||
|
||||
<form action="<%= link_existing_account_mercury_items_path %>" method="post" class="space-y-4" data-turbo-frame="_top">
|
||||
<%= hidden_field_tag :authenticity_token, form_authenticity_token %>
|
||||
<%= hidden_field_tag :account_id, @account.id %>
|
||||
<%= hidden_field_tag :return_to, @return_to %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<% @available_accounts.each do |account| %>
|
||||
<% account_name = account[:nickname].presence || account[:name].presence || account[:legalBusinessName].presence %>
|
||||
<% has_blank_name = account_name.blank? %>
|
||||
<label class="flex items-start gap-3 p-3 border <%= has_blank_name ? "border-error bg-error/5" : "border-primary" %> rounded-lg <%= has_blank_name ? "cursor-not-allowed opacity-60" : "hover:bg-subtle cursor-pointer" %> transition-colors">
|
||||
<%= radio_button_tag "mercury_account_id", account[:id], false, disabled: has_blank_name, class: "mt-1" %>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm <%= has_blank_name ? "text-error" : "text-primary" %>">
|
||||
<% if has_blank_name %>
|
||||
<%= t(".no_name_placeholder") %>
|
||||
<% else %>
|
||||
<%= account_name %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="text-xs text-secondary mt-1">
|
||||
Mercury • USD • <%= account[:status] %>
|
||||
<% if account[:type].present? %>
|
||||
• <%= account[:type].titleize %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if has_blank_name %>
|
||||
<div class="text-xs text-error mt-1">
|
||||
<%= t(".configure_name_in_mercury") %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end pt-4">
|
||||
<%= link_to t(".cancel"), @return_to || accounts_path,
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
|
||||
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
|
||||
<%= submit_tag t(".link_account"),
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
105
app/views/mercury_items/setup_accounts.html.erb
Normal file
105
app/views/mercury_items/setup_accounts.html.erb
Normal file
@@ -0,0 +1,105 @@
|
||||
<% content_for :title, "Set Up Mercury Accounts" %>
|
||||
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title")) do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "building-2", class: "text-primary" %>
|
||||
<span class="text-primary"><%= t(".subtitle") %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<%= form_with url: complete_account_setup_mercury_item_path(@mercury_item),
|
||||
method: :post,
|
||||
local: true,
|
||||
data: {
|
||||
controller: "loading-button",
|
||||
action: "submit->loading-button#showLoading",
|
||||
loading_button_loading_text_value: t(".creating_accounts"),
|
||||
turbo_frame: "_top"
|
||||
},
|
||||
class: "space-y-6" do |form| %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% if @api_error.present? %>
|
||||
<div class="p-8 flex flex-col gap-3 items-center justify-center text-center">
|
||||
<%= icon "alert-circle", size: "lg", class: "text-destructive" %>
|
||||
<p class="text-primary font-medium"><%= t(".fetch_failed") %></p>
|
||||
<p class="text-destructive text-sm"><%= @api_error %></p>
|
||||
</div>
|
||||
<% elsif @mercury_accounts.empty? %>
|
||||
<div class="p-8 flex flex-col gap-3 items-center justify-center text-center">
|
||||
<%= icon "check-circle", size: "lg", class: "text-success" %>
|
||||
<p class="text-primary font-medium"><%= t(".no_accounts_to_setup") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".all_accounts_linked") %></p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-surface border border-primary p-4 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
|
||||
<div>
|
||||
<p class="text-sm text-primary mb-2">
|
||||
<strong><%= t(".choose_account_type") %></strong>
|
||||
</p>
|
||||
<ul class="text-xs text-secondary space-y-1 list-disc list-inside">
|
||||
<% @account_type_options.reject { |_, type| type == "skip" }.each do |label, type| %>
|
||||
<li><strong><%= label %></strong></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% @mercury_accounts.each do |mercury_account| %>
|
||||
<div class="border border-primary rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="font-medium text-primary">
|
||||
<%= mercury_account.name %>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-controller="account-type-selector" data-account-type-selector-account-id-value="<%= mercury_account.id %>">
|
||||
<div>
|
||||
<%= label_tag "account_types[#{mercury_account.id}]", t(".account_type_label"),
|
||||
class: "block text-sm font-medium text-primary mb-2" %>
|
||||
<%= select_tag "account_types[#{mercury_account.id}]",
|
||||
options_for_select(@account_type_options, "skip"),
|
||||
{ class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full",
|
||||
data: {
|
||||
action: "change->account-type-selector#updateSubtype"
|
||||
} } %>
|
||||
</div>
|
||||
|
||||
<!-- Subtype dropdowns (shown/hidden based on account type) -->
|
||||
<div data-account-type-selector-target="subtypeContainer">
|
||||
<% @subtype_options.each do |account_type, subtype_config| %>
|
||||
<%= render "mercury_items/subtype_select", account_type: account_type, subtype_config: subtype_config, mercury_account: mercury_account %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= render DS::Button.new(
|
||||
text: t(".create_accounts"),
|
||||
variant: "primary",
|
||||
icon: "plus",
|
||||
type: "submit",
|
||||
class: "flex-1",
|
||||
disabled: @api_error.present? || @mercury_accounts.empty?,
|
||||
data: { loading_button_target: "button" }
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".cancel"),
|
||||
variant: "secondary",
|
||||
href: accounts_path
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -74,7 +74,7 @@
|
||||
category_content = capture do
|
||||
%>
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="h-7 w-7 flex-shrink-0 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center"
|
||||
<div class="h-7 w-7 flex-shrink-0 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center"
|
||||
style="
|
||||
background-color: color-mix(in oklab, <%= category[:color] %> 10%, transparent);
|
||||
border-color: color-mix(in oklab, <%= category[:color] %> 10%, transparent);
|
||||
|
||||
@@ -78,4 +78,4 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
73
app/views/settings/providers/_mercury_panel.html.erb
Normal file
73
app/views/settings/providers/_mercury_panel.html.erb
Normal file
@@ -0,0 +1,73 @@
|
||||
<div class="space-y-4">
|
||||
<div class="prose prose-sm text-secondary">
|
||||
<p class="text-primary font-medium">Setup instructions:</p>
|
||||
<ol>
|
||||
<li>Visit <a href="https://mercury.com" target="_blank" rel="noopener noreferrer" class="link">Mercury</a> and log in to your account</li>
|
||||
<li>Go to Settings > Developer > API Tokens</li>
|
||||
<li>Create a new API token with "Read Only" access</li>
|
||||
<li><strong>Important:</strong> Add your server's IP address to the token's whitelist</li>
|
||||
<li>Copy the <strong>full token</strong> (including the <code>secret-token:</code> prefix) and paste it below</li>
|
||||
<li>After a successful connection, go to the Accounts tab to set up new accounts</li>
|
||||
</ol>
|
||||
|
||||
<p class="text-primary font-medium">Field descriptions:</p>
|
||||
<ul>
|
||||
<li><strong>API Token:</strong> Your full Mercury API token including the <code>secret-token:</code> prefix (required)</li>
|
||||
<li><strong>Base URL:</strong> Mercury API URL (optional, defaults to https://api.mercury.com/api/v1)</li>
|
||||
</ul>
|
||||
|
||||
<p class="text-sm text-muted-foreground mt-2">
|
||||
<strong>Note:</strong> For sandbox testing, use <code>https://api-sandbox.mercury.com/api/v1</code> as the Base URL.
|
||||
Mercury requires IP whitelisting - make sure to add your IP in the Mercury dashboard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<% error_msg = local_assigns[:error_message] || @error_message %>
|
||||
<% if error_msg.present? %>
|
||||
<div class="p-2 rounded-md bg-destructive/10 text-destructive text-sm overflow-hidden">
|
||||
<p class="line-clamp-3" title="<%= error_msg %>"><%= error_msg %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%
|
||||
# Get or initialize a mercury_item for this family
|
||||
# - If family has an item WITH credentials, use it (for updates)
|
||||
# - If family has an item WITHOUT credentials, use it (to add credentials)
|
||||
# - If family has no items at all, create a new one
|
||||
mercury_item = Current.family.mercury_items.first_or_initialize(name: "Mercury Connection")
|
||||
is_new_record = mercury_item.new_record?
|
||||
%>
|
||||
|
||||
<%= styled_form_with model: mercury_item,
|
||||
url: is_new_record ? mercury_items_path : mercury_item_path(mercury_item),
|
||||
scope: :mercury_item,
|
||||
method: is_new_record ? :post : :patch,
|
||||
data: { turbo: true },
|
||||
class: "space-y-3" do |form| %>
|
||||
<%= form.text_field :token,
|
||||
label: "Token",
|
||||
placeholder: is_new_record ? "Paste token here" : "Enter new token to update",
|
||||
type: :password %>
|
||||
|
||||
<%= form.text_field :base_url,
|
||||
label: "Base Url (Optional)",
|
||||
placeholder: "https://api.mercury.com/api/v1 (default)",
|
||||
value: mercury_item.base_url %>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<%= form.submit is_new_record ? "Save Configuration" : "Update Configuration",
|
||||
class: "inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-gray-900 focus:ring-offset-2 transition-colors" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% items = local_assigns[:mercury_items] || @mercury_items || Current.family.mercury_items.where.not(token: [nil, ""]) %>
|
||||
<div class="flex items-center gap-2">
|
||||
<% if items&.any? %>
|
||||
<div class="w-2 h-2 bg-success rounded-full"></div>
|
||||
<p class="text-sm text-secondary">Configured and ready to use. Visit the <a href="<%= accounts_path %>" class="link">Accounts</a> tab to manage and set up accounts.</p>
|
||||
<% else %>
|
||||
<div class="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<p class="text-sm text-secondary">Not configured</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,6 +42,12 @@
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Mercury", collapsible: true, open: false do %>
|
||||
<turbo-frame id="mercury-providers-panel">
|
||||
<%= render "settings/providers/mercury_panel" %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Coinbase (beta)", collapsible: true, open: false do %>
|
||||
<turbo-frame id="coinbase-providers-panel">
|
||||
<%= render "settings/providers/coinbase_panel" %>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<%
|
||||
def badge_classes(c, p)
|
||||
classes = case c
|
||||
when 'success'
|
||||
'bg-green-500/5 text-green-500'
|
||||
when 'error'
|
||||
'bg-red-500/5 text-red-500'
|
||||
when 'warning'
|
||||
'bg-orange-500/5 text-orange-500'
|
||||
when "success"
|
||||
"bg-green-500/5 text-green-500"
|
||||
when "error"
|
||||
"bg-red-500/5 text-red-500"
|
||||
when "warning"
|
||||
"bg-orange-500/5 text-orange-500"
|
||||
else
|
||||
'bg-gray-500/5 text-secondary'
|
||||
"bg-gray-500/5 text-secondary"
|
||||
end
|
||||
|
||||
p ? "#{classes} animate-pulse" : classes
|
||||
@@ -19,4 +19,4 @@
|
||||
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full ring ring-alpha-black-50 text-xs <%= badge_classes(color, pulse) %>">
|
||||
<%= yield %>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
<div class="col-span-8 flex items-center gap-4">
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
class: "checkbox checkbox--light hidden lg:block",
|
||||
data: {
|
||||
id: entry.id,
|
||||
"bulk-select-target": "row",
|
||||
data: {
|
||||
id: entry.id,
|
||||
"bulk-select-target": "row",
|
||||
action: "bulk-select#toggleRowSelection",
|
||||
checkbox_toggle_target: "selectionEntry"
|
||||
} %>
|
||||
|
||||
147
config/locales/views/mercury_items/en.yml
Normal file
147
config/locales/views/mercury_items/en.yml
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
en:
|
||||
mercury_items:
|
||||
create:
|
||||
success: Mercury connection created successfully
|
||||
destroy:
|
||||
success: Mercury connection removed
|
||||
index:
|
||||
title: Mercury Connections
|
||||
loading:
|
||||
loading_message: Loading Mercury accounts...
|
||||
loading_title: Loading
|
||||
link_accounts:
|
||||
all_already_linked:
|
||||
one: "The selected account (%{names}) is already linked"
|
||||
other: "All %{count} selected accounts are already linked: %{names}"
|
||||
api_error: "API error: %{message}"
|
||||
invalid_account_names:
|
||||
one: "Cannot link account with blank name"
|
||||
other: "Cannot link %{count} accounts with blank names"
|
||||
link_failed: Failed to link accounts
|
||||
no_accounts_selected: Please select at least one account
|
||||
no_api_token: Mercury API token not found. Please configure it in Provider Settings.
|
||||
partial_invalid: "Successfully linked %{created_count} account(s), %{already_linked_count} were already linked, %{invalid_count} account(s) had invalid names"
|
||||
partial_success: "Successfully linked %{created_count} account(s). %{already_linked_count} account(s) were already linked: %{already_linked_names}"
|
||||
success:
|
||||
one: "Successfully linked %{count} account"
|
||||
other: "Successfully linked %{count} accounts"
|
||||
mercury_item:
|
||||
accounts_need_setup: Accounts need setup
|
||||
delete: Delete connection
|
||||
deletion_in_progress: deletion in progress...
|
||||
error: Error
|
||||
no_accounts_description: This connection has no linked accounts yet.
|
||||
no_accounts_title: No accounts
|
||||
setup_action: Set Up New Accounts
|
||||
setup_description: "%{linked} of %{total} accounts linked. Choose account types for your newly imported Mercury accounts."
|
||||
setup_needed: New accounts ready to set up
|
||||
status: "Synced %{timestamp} ago"
|
||||
status_never: Never synced
|
||||
status_with_summary: "Last synced %{timestamp} ago - %{summary}"
|
||||
syncing: Syncing...
|
||||
total: Total
|
||||
unlinked: Unlinked
|
||||
select_accounts:
|
||||
accounts_selected: accounts selected
|
||||
api_error: "API error: %{message}"
|
||||
cancel: Cancel
|
||||
configure_name_in_mercury: Cannot import - please configure account name in Mercury
|
||||
description: Select the accounts you want to link to your %{product_name} account.
|
||||
link_accounts: Link selected accounts
|
||||
no_accounts_found: No accounts found. Please check your API token configuration.
|
||||
no_api_token: Mercury API token is not configured. Please configure it in Settings.
|
||||
no_credentials_configured: Please configure your Mercury API token first in Provider Settings.
|
||||
no_name_placeholder: "(No name)"
|
||||
title: Select Mercury Accounts
|
||||
select_existing_account:
|
||||
account_already_linked: This account is already linked to a provider
|
||||
all_accounts_already_linked: All Mercury accounts are already linked
|
||||
api_error: "API error: %{message}"
|
||||
cancel: Cancel
|
||||
configure_name_in_mercury: Cannot import - please configure account name in Mercury
|
||||
description: Select a Mercury account to link with this account. Transactions will be synced and deduplicated automatically.
|
||||
link_account: Link account
|
||||
no_account_specified: No account specified
|
||||
no_accounts_found: No Mercury accounts found. Please check your API token configuration.
|
||||
no_api_token: Mercury API token is not configured. Please configure it in Settings.
|
||||
no_credentials_configured: Please configure your Mercury API token first in Provider Settings.
|
||||
no_name_placeholder: "(No name)"
|
||||
title: "Link %{account_name} with Mercury"
|
||||
link_existing_account:
|
||||
account_already_linked: This account is already linked to a provider
|
||||
api_error: "API error: %{message}"
|
||||
invalid_account_name: Cannot link account with blank name
|
||||
mercury_account_already_linked: This Mercury account is already linked to another account
|
||||
mercury_account_not_found: Mercury account not found
|
||||
missing_parameters: Missing required parameters
|
||||
no_api_token: Mercury API token not found. Please configure it in Provider Settings.
|
||||
success: "Successfully linked %{account_name} with Mercury"
|
||||
setup_accounts:
|
||||
account_type_label: "Account Type:"
|
||||
all_accounts_linked: "All your Mercury accounts have already been set up."
|
||||
api_error: "API error: %{message}"
|
||||
fetch_failed: "Failed to Fetch Accounts"
|
||||
no_accounts_to_setup: "No Accounts to Set Up"
|
||||
no_api_token: "Mercury API token is not configured. Please check your connection settings."
|
||||
account_types:
|
||||
skip: Skip this account
|
||||
depository: Checking or Savings Account
|
||||
credit_card: Credit Card
|
||||
investment: Investment Account
|
||||
loan: Loan or Mortgage
|
||||
other_asset: Other Asset
|
||||
subtype_labels:
|
||||
depository: "Account Subtype:"
|
||||
credit_card: ""
|
||||
investment: "Investment Type:"
|
||||
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."
|
||||
subtypes:
|
||||
depository:
|
||||
checking: Checking
|
||||
savings: Savings
|
||||
hsa: Health Savings Account
|
||||
cd: Certificate of Deposit
|
||||
money_market: Money Market
|
||||
investment:
|
||||
brokerage: Brokerage
|
||||
pension: Pension
|
||||
retirement: Retirement
|
||||
"401k": "401(k)"
|
||||
roth_401k: "Roth 401(k)"
|
||||
"403b": "403(b)"
|
||||
tsp: Thrift Savings Plan
|
||||
"529_plan": "529 Plan"
|
||||
hsa: Health Savings Account
|
||||
mutual_fund: Mutual Fund
|
||||
ira: Traditional IRA
|
||||
roth_ira: Roth IRA
|
||||
angel: Angel
|
||||
loan:
|
||||
mortgage: Mortgage
|
||||
student: Student Loan
|
||||
auto: Auto Loan
|
||||
other: Other Loan
|
||||
balance: Balance
|
||||
cancel: Cancel
|
||||
choose_account_type: "Choose the correct account type for each Mercury account:"
|
||||
create_accounts: Create Accounts
|
||||
creating_accounts: Creating Accounts...
|
||||
historical_data_range: "Historical Data Range:"
|
||||
subtitle: Choose the correct account types for your imported accounts
|
||||
sync_start_date_help: Select how far back you want to sync transaction history. Maximum 3 years of history available.
|
||||
sync_start_date_label: "Start syncing transactions from:"
|
||||
title: Set Up Your Mercury Accounts
|
||||
complete_account_setup:
|
||||
all_skipped: "All accounts were skipped. No accounts were created."
|
||||
creation_failed: "Failed to create accounts: %{error}"
|
||||
no_accounts: "No accounts to set up."
|
||||
success: "Successfully created %{count} account(s)."
|
||||
sync:
|
||||
success: Sync started
|
||||
update:
|
||||
success: Mercury connection updated
|
||||
@@ -2,6 +2,22 @@ require "sidekiq/web"
|
||||
require "sidekiq/cron/web"
|
||||
|
||||
Rails.application.routes.draw do
|
||||
resources :mercury_items, only: %i[index new create show edit update destroy] do
|
||||
collection do
|
||||
get :preload_accounts
|
||||
get :select_accounts
|
||||
post :link_accounts
|
||||
get :select_existing_account
|
||||
post :link_existing_account
|
||||
end
|
||||
|
||||
member do
|
||||
post :sync
|
||||
get :setup_accounts
|
||||
post :complete_account_setup
|
||||
end
|
||||
end
|
||||
|
||||
resources :coinbase_items, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do
|
||||
collection do
|
||||
get :preload_accounts
|
||||
@@ -17,6 +33,7 @@ Rails.application.routes.draw do
|
||||
post :complete_account_setup
|
||||
end
|
||||
end
|
||||
|
||||
# CoinStats routes
|
||||
resources :coinstats_items, only: [ :index, :new, :create, :update, :destroy ] do
|
||||
collection do
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
class CreateMercuryItemsAndAccounts < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
# Create provider items table (stores per-family connection credentials)
|
||||
create_table :mercury_items, id: :uuid do |t|
|
||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||
t.string :name
|
||||
|
||||
# Institution metadata
|
||||
t.string :institution_id
|
||||
t.string :institution_name
|
||||
t.string :institution_domain
|
||||
t.string :institution_url
|
||||
t.string :institution_color
|
||||
|
||||
# Status and lifecycle
|
||||
t.string :status, default: "good"
|
||||
t.boolean :scheduled_for_deletion, default: false
|
||||
t.boolean :pending_account_setup, default: false
|
||||
|
||||
# Sync settings
|
||||
t.datetime :sync_start_date
|
||||
|
||||
# Raw data storage
|
||||
t.jsonb :raw_payload
|
||||
t.jsonb :raw_institution_payload
|
||||
|
||||
# Provider-specific credential fields
|
||||
t.text :token
|
||||
t.string :base_url
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :mercury_items, :status
|
||||
|
||||
# Create provider accounts table (stores individual account data from provider)
|
||||
create_table :mercury_accounts, id: :uuid do |t|
|
||||
t.references :mercury_item, null: false, foreign_key: true, type: :uuid
|
||||
|
||||
# Account identification
|
||||
t.string :name
|
||||
t.string :account_id, null: false
|
||||
|
||||
# Account details
|
||||
t.string :currency
|
||||
t.decimal :current_balance, precision: 19, scale: 4
|
||||
t.string :account_status
|
||||
t.string :account_type
|
||||
t.string :provider
|
||||
|
||||
# Metadata and raw data
|
||||
t.jsonb :institution_metadata
|
||||
t.jsonb :raw_payload
|
||||
t.jsonb :raw_transactions_payload
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :mercury_accounts, :account_id, unique: true
|
||||
end
|
||||
end
|
||||
44
db/schema.rb
generated
44
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_01_19_005756) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_01_21_101345) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -778,6 +778,46 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_19_005756) do
|
||||
t.index ["type"], name: "index_merchants_on_type"
|
||||
end
|
||||
|
||||
create_table "mercury_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "mercury_item_id", null: false
|
||||
t.string "name"
|
||||
t.string "account_id", null: false
|
||||
t.string "currency"
|
||||
t.decimal "current_balance", precision: 19, scale: 4
|
||||
t.string "account_status"
|
||||
t.string "account_type"
|
||||
t.string "provider"
|
||||
t.jsonb "institution_metadata"
|
||||
t.jsonb "raw_payload"
|
||||
t.jsonb "raw_transactions_payload"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_mercury_accounts_on_account_id", unique: true
|
||||
t.index ["mercury_item_id"], name: "index_mercury_accounts_on_mercury_item_id"
|
||||
end
|
||||
|
||||
create_table "mercury_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.string "name"
|
||||
t.string "institution_id"
|
||||
t.string "institution_name"
|
||||
t.string "institution_domain"
|
||||
t.string "institution_url"
|
||||
t.string "institution_color"
|
||||
t.string "status", default: "good"
|
||||
t.boolean "scheduled_for_deletion", default: false
|
||||
t.boolean "pending_account_setup", default: false
|
||||
t.datetime "sync_start_date"
|
||||
t.jsonb "raw_payload"
|
||||
t.jsonb "raw_institution_payload"
|
||||
t.text "token"
|
||||
t.string "base_url"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["family_id"], name: "index_mercury_items_on_family_id"
|
||||
t.index ["status"], name: "index_mercury_items_on_status"
|
||||
end
|
||||
|
||||
create_table "messages", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "chat_id", null: false
|
||||
t.string "type", null: false
|
||||
@@ -1365,6 +1405,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_01_19_005756) do
|
||||
add_foreign_key "lunchflow_accounts", "lunchflow_items"
|
||||
add_foreign_key "lunchflow_items", "families"
|
||||
add_foreign_key "merchants", "families"
|
||||
add_foreign_key "mercury_accounts", "mercury_items"
|
||||
add_foreign_key "mercury_items", "families"
|
||||
add_foreign_key "messages", "chats"
|
||||
add_foreign_key "mobile_devices", "users"
|
||||
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
|
||||
|
||||
@@ -318,11 +318,11 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
# Add section before the last closing div (at end of file)
|
||||
section_content = <<~ERB
|
||||
|
||||
<%%= settings_section title: "#{class_name}", collapsible: true, open: false do %>
|
||||
<%= settings_section title: "#{class_name}", collapsible: true, open: false do %>
|
||||
<turbo-frame id="#{file_name}-providers-panel">
|
||||
<%%= render "settings/providers/#{file_name}_panel" %>
|
||||
<%= render "settings/providers/#{file_name}_panel" %>
|
||||
</turbo-frame>
|
||||
<%% end %>
|
||||
<% end %>
|
||||
ERB
|
||||
|
||||
# Insert before the final </div> at the end of file
|
||||
@@ -331,6 +331,99 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
end
|
||||
end
|
||||
|
||||
def update_accounts_controller
|
||||
controller_path = "app/controllers/accounts_controller.rb"
|
||||
return unless File.exist?(controller_path)
|
||||
|
||||
content = File.read(controller_path)
|
||||
items_var = "@#{file_name}_items"
|
||||
|
||||
# Check if already added
|
||||
if content.include?(items_var)
|
||||
say "Accounts controller already has #{items_var}", :skip
|
||||
return
|
||||
end
|
||||
|
||||
# Add to index action - find the last @*_items line and insert after it
|
||||
lines = content.lines
|
||||
last_items_index = nil
|
||||
lines.each_with_index do |line, index|
|
||||
if line =~ /@\w+_items = family\.\w+_items\.ordered/
|
||||
last_items_index = index
|
||||
end
|
||||
end
|
||||
|
||||
if last_items_index
|
||||
indentation = lines[last_items_index][/^\s*/]
|
||||
new_line = "#{indentation}#{items_var} = family.#{file_name}_items.ordered.includes(:syncs, :#{file_name}_accounts)\n"
|
||||
lines.insert(last_items_index + 1, new_line)
|
||||
File.write(controller_path, lines.join)
|
||||
say "Added #{items_var} to accounts controller index", :green
|
||||
else
|
||||
say "Could not find @*_items assignments in accounts controller", :yellow
|
||||
end
|
||||
|
||||
# Add sync stats map
|
||||
add_accounts_controller_sync_stats_map(controller_path)
|
||||
end
|
||||
|
||||
def update_accounts_index_view
|
||||
view_path = "app/views/accounts/index.html.erb"
|
||||
return unless File.exist?(view_path)
|
||||
|
||||
content = File.read(view_path)
|
||||
items_var = "@#{file_name}_items"
|
||||
|
||||
if content.include?(items_var)
|
||||
say "Accounts index view already has #{class_name} section", :skip
|
||||
return
|
||||
end
|
||||
|
||||
# Add to empty check - find the existing pattern and append our check
|
||||
content = content.gsub(
|
||||
/@coinstats_items\.empty\? %>/,
|
||||
"@coinstats_items.empty? && #{items_var}.empty? %>"
|
||||
)
|
||||
|
||||
# Add provider section before manual_accounts
|
||||
section = <<~ERB
|
||||
|
||||
<% if #{items_var}.any? %>
|
||||
<%= render #{items_var}.sort_by(&:created_at) %>
|
||||
<% end %>
|
||||
|
||||
ERB
|
||||
|
||||
content = content.gsub(
|
||||
/<% if @manual_accounts\.any\? %>/,
|
||||
"#{section.strip}\n\n <% if @manual_accounts.any? %>"
|
||||
)
|
||||
|
||||
File.write(view_path, content)
|
||||
say "Added #{class_name} section to accounts index view", :green
|
||||
end
|
||||
|
||||
def create_locale_file
|
||||
locale_dir = "config/locales/views/#{file_name}_items"
|
||||
locale_path = "#{locale_dir}/en.yml"
|
||||
|
||||
if File.exist?(locale_path)
|
||||
say "Locale file already exists: #{locale_path}", :skip
|
||||
return
|
||||
end
|
||||
|
||||
FileUtils.mkdir_p(locale_dir)
|
||||
template "locale.en.yml.tt", locale_path
|
||||
say "Created locale file: #{locale_path}", :green
|
||||
end
|
||||
|
||||
def update_source_enums
|
||||
# Add the new provider to the source enum in ProviderMerchant and DataEnrichment
|
||||
# These enums track which provider created a merchant or enrichment record
|
||||
update_source_enum("app/models/provider_merchant.rb")
|
||||
update_source_enum("app/models/data_enrichment.rb")
|
||||
end
|
||||
|
||||
def show_summary
|
||||
say "\n" + "=" * 80, :green
|
||||
say "Successfully generated per-family provider: #{class_name}", :green
|
||||
@@ -395,6 +488,77 @@ class Provider::FamilyGenerator < Rails::Generators::NamedBase
|
||||
|
||||
private
|
||||
|
||||
def update_source_enum(model_path)
|
||||
return unless File.exist?(model_path)
|
||||
|
||||
content = File.read(model_path)
|
||||
model_name = File.basename(model_path, ".rb").camelize
|
||||
|
||||
# Check if provider is already in the enum
|
||||
if content.include?("#{file_name}: \"#{file_name}\"")
|
||||
say "#{model_name} source enum already includes #{file_name}", :skip
|
||||
return
|
||||
end
|
||||
|
||||
# Find the enum :source line and add the new provider
|
||||
# Pattern: enum :source, { key: "value", ... }
|
||||
if content =~ /(enum :source, \{[^}]+)(})/
|
||||
# Insert the new provider before the closing brace
|
||||
updated_content = content.sub(
|
||||
/(enum :source, \{[^}]+)(})/,
|
||||
"\\1, #{file_name}: \"#{file_name}\"\\2"
|
||||
)
|
||||
File.write(model_path, updated_content)
|
||||
say "Added #{file_name} to #{model_name} source enum", :green
|
||||
else
|
||||
say "Could not find source enum in #{model_name}", :yellow
|
||||
end
|
||||
end
|
||||
|
||||
def add_accounts_controller_sync_stats_map(controller_path)
|
||||
content = File.read(controller_path)
|
||||
stats_var = "@#{file_name}_sync_stats_map"
|
||||
|
||||
if content.include?(stats_var)
|
||||
say "Accounts controller already has #{stats_var}", :skip
|
||||
return
|
||||
end
|
||||
|
||||
# Find the build_sync_stats_maps method and add our stats map before the closing 'end'
|
||||
sync_stats_block = <<~RUBY
|
||||
|
||||
# #{class_name} sync stats
|
||||
#{stats_var} = {}
|
||||
@#{file_name}_items.each do |item|
|
||||
latest_sync = item.syncs.ordered.first
|
||||
#{stats_var}[item.id] = latest_sync&.sync_stats || {}
|
||||
end
|
||||
RUBY
|
||||
|
||||
lines = content.lines
|
||||
method_start = nil
|
||||
method_end = nil
|
||||
indent_level = 0
|
||||
|
||||
lines.each_with_index do |line, index|
|
||||
if line.include?("def build_sync_stats_maps")
|
||||
method_start = index
|
||||
indent_level = line[/^\s*/].length
|
||||
elsif method_start && line =~ /^#{' ' * indent_level}end\s*$/
|
||||
method_end = index
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
if method_end
|
||||
lines.insert(method_end, sync_stats_block)
|
||||
File.write(controller_path, lines.join)
|
||||
say "Added #{stats_var} to build_sync_stats_maps", :green
|
||||
else
|
||||
say "Could not find build_sync_stats_maps method end", :yellow
|
||||
end
|
||||
end
|
||||
|
||||
def table_name
|
||||
"#{file_name}_items"
|
||||
end
|
||||
|
||||
147
lib/generators/provider/family/templates/locale.en.yml.tt
Normal file
147
lib/generators/provider/family/templates/locale.en.yml.tt
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
en:
|
||||
<%= file_name %>_items:
|
||||
create:
|
||||
success: <%= class_name %> connection created successfully
|
||||
destroy:
|
||||
success: <%= class_name %> connection removed
|
||||
index:
|
||||
title: <%= class_name %> Connections
|
||||
loading:
|
||||
loading_message: Loading <%= class_name %> accounts...
|
||||
loading_title: Loading
|
||||
link_accounts:
|
||||
all_already_linked:
|
||||
one: "The selected account (%%{names}) is already linked"
|
||||
other: "All %%{count} selected accounts are already linked: %%{names}"
|
||||
api_error: "API error: %%{message}"
|
||||
invalid_account_names:
|
||||
one: "Cannot link account with blank name"
|
||||
other: "Cannot link %%{count} accounts with blank names"
|
||||
link_failed: Failed to link accounts
|
||||
no_accounts_selected: Please select at least one account
|
||||
no_api_key: <%= class_name %> API key not found. Please configure it in Provider Settings.
|
||||
partial_invalid: "Successfully linked %%{created_count} account(s), %%{already_linked_count} were already linked, %%{invalid_count} account(s) had invalid names"
|
||||
partial_success: "Successfully linked %%{created_count} account(s). %%{already_linked_count} account(s) were already linked: %%{already_linked_names}"
|
||||
success:
|
||||
one: "Successfully linked %%{count} account"
|
||||
other: "Successfully linked %%{count} accounts"
|
||||
<%= file_name %>_item:
|
||||
accounts_need_setup: Accounts need setup
|
||||
delete: Delete connection
|
||||
deletion_in_progress: deletion in progress...
|
||||
error: Error
|
||||
no_accounts_description: This connection has no linked accounts yet.
|
||||
no_accounts_title: No accounts
|
||||
setup_action: Set Up New Accounts
|
||||
setup_description: "%%{linked} of %%{total} accounts linked. Choose account types for your newly imported <%= class_name %> accounts."
|
||||
setup_needed: New accounts ready to set up
|
||||
status: "Synced %%{timestamp} ago"
|
||||
status_never: Never synced
|
||||
status_with_summary: "Last synced %%{timestamp} ago - %%{summary}"
|
||||
syncing: Syncing...
|
||||
total: Total
|
||||
unlinked: Unlinked
|
||||
select_accounts:
|
||||
accounts_selected: accounts selected
|
||||
api_error: "API error: %%{message}"
|
||||
cancel: Cancel
|
||||
configure_name_in_provider: Cannot import - please configure account name in <%= class_name %>
|
||||
description: Select the accounts you want to link to your %%{product_name} account.
|
||||
link_accounts: Link selected accounts
|
||||
no_accounts_found: No accounts found. Please check your API key configuration.
|
||||
no_api_key: <%= class_name %> API key is not configured. Please configure it in Settings.
|
||||
no_credentials_configured: Please configure your <%= class_name %> credentials first in Provider Settings.
|
||||
no_name_placeholder: "(No name)"
|
||||
title: Select <%= class_name %> Accounts
|
||||
select_existing_account:
|
||||
account_already_linked: This account is already linked to a provider
|
||||
all_accounts_already_linked: All <%= class_name %> accounts are already linked
|
||||
api_error: "API error: %%{message}"
|
||||
cancel: Cancel
|
||||
configure_name_in_provider: Cannot import - please configure account name in <%= class_name %>
|
||||
description: Select a <%= class_name %> account to link with this account. Transactions will be synced and deduplicated automatically.
|
||||
link_account: Link account
|
||||
no_account_specified: No account specified
|
||||
no_accounts_found: No <%= class_name %> accounts found. Please check your API key configuration.
|
||||
no_api_key: <%= class_name %> API key is not configured. Please configure it in Settings.
|
||||
no_credentials_configured: Please configure your <%= class_name %> credentials first in Provider Settings.
|
||||
no_name_placeholder: "(No name)"
|
||||
title: "Link %%{account_name} with <%= class_name %>"
|
||||
link_existing_account:
|
||||
account_already_linked: This account is already linked to a provider
|
||||
api_error: "API error: %%{message}"
|
||||
invalid_account_name: Cannot link account with blank name
|
||||
provider_account_already_linked: This <%= class_name %> account is already linked to another account
|
||||
provider_account_not_found: <%= class_name %> account not found
|
||||
missing_parameters: Missing required parameters
|
||||
no_api_key: <%= class_name %> API key not found. Please configure it in Provider Settings.
|
||||
success: "Successfully linked %%{account_name} with <%= class_name %>"
|
||||
setup_accounts:
|
||||
account_type_label: "Account Type:"
|
||||
all_accounts_linked: "All your <%= class_name %> accounts have already been set up."
|
||||
api_error: "API error: %%{message}"
|
||||
fetch_failed: "Failed to Fetch Accounts"
|
||||
no_accounts_to_setup: "No Accounts to Set Up"
|
||||
no_api_key: "<%= class_name %> API key is not configured. Please check your connection settings."
|
||||
account_types:
|
||||
skip: Skip this account
|
||||
depository: Checking or Savings Account
|
||||
credit_card: Credit Card
|
||||
investment: Investment Account
|
||||
loan: Loan or Mortgage
|
||||
other_asset: Other Asset
|
||||
subtype_labels:
|
||||
depository: "Account Subtype:"
|
||||
credit_card: ""
|
||||
investment: "Investment Type:"
|
||||
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."
|
||||
subtypes:
|
||||
depository:
|
||||
checking: Checking
|
||||
savings: Savings
|
||||
hsa: Health Savings Account
|
||||
cd: Certificate of Deposit
|
||||
money_market: Money Market
|
||||
investment:
|
||||
brokerage: Brokerage
|
||||
pension: Pension
|
||||
retirement: Retirement
|
||||
"401k": "401(k)"
|
||||
roth_401k: "Roth 401(k)"
|
||||
"403b": "403(b)"
|
||||
tsp: Thrift Savings Plan
|
||||
"529_plan": "529 Plan"
|
||||
hsa: Health Savings Account
|
||||
mutual_fund: Mutual Fund
|
||||
ira: Traditional IRA
|
||||
roth_ira: Roth IRA
|
||||
angel: Angel
|
||||
loan:
|
||||
mortgage: Mortgage
|
||||
student: Student Loan
|
||||
auto: Auto Loan
|
||||
other: Other Loan
|
||||
balance: Balance
|
||||
cancel: Cancel
|
||||
choose_account_type: "Choose the correct account type for each <%= class_name %> account:"
|
||||
create_accounts: Create Accounts
|
||||
creating_accounts: Creating Accounts...
|
||||
historical_data_range: "Historical Data Range:"
|
||||
subtitle: Choose the correct account types for your imported accounts
|
||||
sync_start_date_help: Select how far back you want to sync transaction history.
|
||||
sync_start_date_label: "Start syncing transactions from:"
|
||||
title: Set Up Your <%= class_name %> Accounts
|
||||
complete_account_setup:
|
||||
all_skipped: "All accounts were skipped. No accounts were created."
|
||||
creation_failed: "Failed to create accounts: %%{error}"
|
||||
no_accounts: "No accounts to set up."
|
||||
success: "Successfully created %%{count} account(s)."
|
||||
sync:
|
||||
success: Sync started
|
||||
update:
|
||||
success: <%= class_name %> connection updated
|
||||
6
test/fixtures/mercury_accounts.yml
vendored
Normal file
6
test/fixtures/mercury_accounts.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
checking_account:
|
||||
mercury_item: one
|
||||
account_id: "merc_acc_checking_1"
|
||||
name: "Mercury Checking"
|
||||
currency: USD
|
||||
current_balance: 10000.00
|
||||
6
test/fixtures/mercury_items.yml
vendored
Normal file
6
test/fixtures/mercury_items.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
one:
|
||||
family: dylan_family
|
||||
name: "Test Mercury Connection"
|
||||
token: "test_mercury_token_123"
|
||||
base_url: "https://api-sandbox.mercury.com/api/v1"
|
||||
status: good
|
||||
49
test/models/mercury_item_test.rb
Normal file
49
test/models/mercury_item_test.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
require "test_helper"
|
||||
|
||||
class MercuryItemTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@mercury_item = mercury_items(:one)
|
||||
end
|
||||
|
||||
test "fixture is valid" do
|
||||
assert @mercury_item.valid?
|
||||
end
|
||||
|
||||
test "belongs to family" do
|
||||
assert_equal families(:dylan_family), @mercury_item.family
|
||||
end
|
||||
|
||||
test "credentials_configured returns true when token present" do
|
||||
assert @mercury_item.credentials_configured?
|
||||
end
|
||||
|
||||
test "credentials_configured returns false when token blank" do
|
||||
@mercury_item.token = nil
|
||||
assert_not @mercury_item.credentials_configured?
|
||||
end
|
||||
|
||||
test "effective_base_url returns custom url when set" do
|
||||
assert_equal "https://api-sandbox.mercury.com/api/v1", @mercury_item.effective_base_url
|
||||
end
|
||||
|
||||
test "effective_base_url returns default when base_url blank" do
|
||||
@mercury_item.base_url = nil
|
||||
assert_equal "https://api.mercury.com/api/v1", @mercury_item.effective_base_url
|
||||
end
|
||||
|
||||
test "mercury_provider returns Provider::Mercury instance" do
|
||||
provider = @mercury_item.mercury_provider
|
||||
assert_instance_of Provider::Mercury, provider
|
||||
assert_equal @mercury_item.token, provider.token
|
||||
end
|
||||
|
||||
test "mercury_provider returns nil when credentials not configured" do
|
||||
@mercury_item.token = nil
|
||||
assert_nil @mercury_item.mercury_provider
|
||||
end
|
||||
|
||||
test "syncer returns MercuryItem::Syncer instance" do
|
||||
syncer = @mercury_item.send(:syncer)
|
||||
assert_instance_of MercuryItem::Syncer, syncer
|
||||
end
|
||||
end
|
||||
38
test/models/provider/mercury_adapter_test.rb
Normal file
38
test/models/provider/mercury_adapter_test.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
require "test_helper"
|
||||
|
||||
class Provider::MercuryAdapterTest < ActiveSupport::TestCase
|
||||
test "supports Depository accounts" do
|
||||
assert_includes Provider::MercuryAdapter.supported_account_types, "Depository"
|
||||
end
|
||||
|
||||
test "does not support Investment accounts" do
|
||||
assert_not_includes Provider::MercuryAdapter.supported_account_types, "Investment"
|
||||
end
|
||||
|
||||
test "returns connection configs for any family" do
|
||||
# Mercury is a per-family provider - any family can connect
|
||||
family = families(:dylan_family)
|
||||
configs = Provider::MercuryAdapter.connection_configs(family: family)
|
||||
|
||||
assert_equal 1, configs.length
|
||||
assert_equal "mercury", configs.first[:key]
|
||||
assert_equal "Mercury", configs.first[:name]
|
||||
assert configs.first[:can_connect]
|
||||
end
|
||||
|
||||
test "build_provider returns nil when family is nil" do
|
||||
assert_nil Provider::MercuryAdapter.build_provider(family: nil)
|
||||
end
|
||||
|
||||
test "build_provider returns nil when family has no mercury items" do
|
||||
family = families(:empty)
|
||||
assert_nil Provider::MercuryAdapter.build_provider(family: family)
|
||||
end
|
||||
|
||||
test "build_provider returns Mercury provider when credentials configured" do
|
||||
family = families(:dylan_family)
|
||||
provider = Provider::MercuryAdapter.build_provider(family: family)
|
||||
|
||||
assert_instance_of Provider::Mercury, provider
|
||||
end
|
||||
end
|
||||
29
test/models/provider/mercury_test.rb
Normal file
29
test/models/provider/mercury_test.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
require "test_helper"
|
||||
|
||||
class Provider::MercuryTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@provider = Provider::Mercury.new("test_token", base_url: "https://api-sandbox.mercury.com/api/v1")
|
||||
end
|
||||
|
||||
test "initializes with token and default base_url" do
|
||||
provider = Provider::Mercury.new("my_token")
|
||||
assert_equal "my_token", provider.token
|
||||
assert_equal "https://api.mercury.com/api/v1", provider.base_url
|
||||
end
|
||||
|
||||
test "initializes with custom base_url" do
|
||||
assert_equal "test_token", @provider.token
|
||||
assert_equal "https://api-sandbox.mercury.com/api/v1", @provider.base_url
|
||||
end
|
||||
|
||||
test "MercuryError includes error_type" do
|
||||
error = Provider::Mercury::MercuryError.new("Test error", :unauthorized)
|
||||
assert_equal "Test error", error.message
|
||||
assert_equal :unauthorized, error.error_type
|
||||
end
|
||||
|
||||
test "MercuryError defaults error_type to unknown" do
|
||||
error = Provider::Mercury::MercuryError.new("Test error")
|
||||
assert_equal :unknown, error.error_type
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user