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:
soky srm
2026-01-22 20:37:07 +01:00
committed by GitHub
parent 7842b4a044
commit 179552657c
46 changed files with 3345 additions and 30 deletions

View File

@@ -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 = {}

View 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

View File

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

View File

@@ -1,5 +1,5 @@
class DataEnrichment < ApplicationRecord
belongs_to :enrichable, polymorphic: true
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats" }
enum :source, { rule: "rule", plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats", mercury: "mercury" }
end

View File

@@ -1,4 +1,5 @@
class Family < ApplicationRecord
include MercuryConnectable
include CoinbaseConnectable
include PlaidConnectable, SimplefinConnectable, LunchflowConnectable, EnableBankingConnectable, Syncable, AutoTransferMatchable, Subscribeable, CoinstatsConnectable

View 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

View 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

View 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

View 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

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -1,5 +1,5 @@
class ProviderMerchant < Merchant
enum :source, { plaid: "plaid", simplefin: "simplefin", lunchflow: "lunchflow", synth: "synth", ai: "ai", enable_banking: "enable_banking", coinstats: "coinstats" }
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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

View File

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

View File

@@ -78,4 +78,4 @@
</div>
<% end %>
</div>
</div>
</div>

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

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

View File

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

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

View 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

View 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

View 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