mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
feat(errors): Updating error display to better handle SimpleFIN errors
- Also updating models to follow the plaid style a bit more
This commit is contained in:
parent
ce98a9f134
commit
1137f1f57b
20 changed files with 350 additions and 165 deletions
|
@ -52,7 +52,6 @@ APP_DOMAIN=
|
|||
# Allows configuration of SimpleFIN for account linking: https://www.simplefin.org/
|
||||
# You'll want to follow the steps here for getting an AccessURL https://beta-bridge.simplefin.org/info/developers
|
||||
SIMPLE_FIN_ACCESS_URL=
|
||||
SIMPLE_FIN_UPDATE_CRON="0 6 * * *"
|
||||
|
||||
# Disable enforcing SSL connections
|
||||
# DISABLE_SSL=true
|
||||
|
|
|
@ -5,7 +5,7 @@ class AccountsController < ApplicationController
|
|||
def index
|
||||
@manual_accounts = family.accounts.manual.alphabetically
|
||||
@plaid_items = family.plaid_items.ordered
|
||||
@simple_fin_connections = family.simple_fin_connections.ordered
|
||||
@simple_fin_items = family.simple_fin_items.ordered
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
class SimpleFinController < ApplicationController
|
||||
before_action :set_accountable_type
|
||||
before_action :authenticate_user!
|
||||
before_action :set_simple_fin_provider
|
||||
before_action :require_simple_fin_provider
|
||||
before_action :set_simple_fin_provider, only: %i[create new]
|
||||
before_action :require_simple_fin_provider, only: %i[create new]
|
||||
|
||||
def new
|
||||
@simple_fin_accounts = @simple_fin_provider.get_available_accounts(@accountable_type)
|
||||
|
@ -21,6 +21,20 @@ class SimpleFinController < ApplicationController
|
|||
Current.family.accounts.find_by(name: acc["name"])
|
||||
end
|
||||
|
||||
##
|
||||
# Requests all accounts to be re-synced
|
||||
def sync
|
||||
@simple_fin_item = Current.family.simple_fin_items.find(params[:id])
|
||||
unless @simple_fin_item.syncing?
|
||||
@simple_fin_item.sync_later
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to accounts_path }
|
||||
format.json { head :ok }
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
selected_ids = params[:selected_account_ids]
|
||||
if selected_ids.blank?
|
||||
|
@ -39,17 +53,17 @@ class SimpleFinController < ApplicationController
|
|||
first_sf_account = sf_accounts_for_institution.first # Use data from the first account for connection details
|
||||
org_details = first_sf_account["org"]
|
||||
|
||||
# Find or Create the SimpleFinConnection for this institution
|
||||
simple_fin_connection = Current.family.simple_fin_connections.find_or_create_by!(institution_id: institution_id) do |sfc|
|
||||
sfc.name = org_details["name"] || "SimpleFIN Connection"
|
||||
# Find or Create the SimpleFinItem for this institution
|
||||
simple_fin_item = Current.family.simple_fin_items.find_or_create_by!(institution_id: institution_id) do |sfc|
|
||||
sfc.institution_name = org_details["name"]
|
||||
sfc.institution_url = org_details["url"]
|
||||
sfc.institution_domain = org_details["domain"]
|
||||
sfc.last_synced_at = Time.current # Mark as synced upon creation
|
||||
# TODO: Fix
|
||||
sfc.last_sync_count_reset_at = Time.current # Mark as synced upon creation
|
||||
end
|
||||
|
||||
sf_accounts_for_institution.each do |acc_detail|
|
||||
next if simple_fin_connection.simple_fin_accounts.exists?(external_id: acc_detail["id"])
|
||||
next if simple_fin_item.simple_fin_accounts.exists?(external_id: acc_detail["id"])
|
||||
next if account_exists(acc_detail)
|
||||
|
||||
# Get sub type for this account from params
|
||||
|
@ -60,7 +74,7 @@ class SimpleFinController < ApplicationController
|
|||
# Create SimpleFinAccount and its associated Account
|
||||
simple_fin_account = SimpleFinAccount.find_or_create_from_simple_fin_data!(
|
||||
acc_detail,
|
||||
simple_fin_connection
|
||||
simple_fin_item
|
||||
)
|
||||
|
||||
# Trigger an account sync of our data
|
||||
|
@ -75,7 +89,6 @@ class SimpleFinController < ApplicationController
|
|||
end
|
||||
|
||||
private
|
||||
|
||||
def set_accountable_type
|
||||
@accountable_type = params[:accountable_type]
|
||||
end
|
||||
|
|
|
@ -10,7 +10,7 @@ class FamilyResetJob < ApplicationJob
|
|||
family.tags.destroy_all
|
||||
family.merchants.destroy_all
|
||||
family.plaid_items.destroy_all
|
||||
family.simple_fin_connections.destroy_all
|
||||
family.simple_fin_items.destroy_all
|
||||
family.imports.destroy_all
|
||||
family.budgets.destroy_all
|
||||
|
||||
|
|
|
@ -68,7 +68,7 @@ class Account < ApplicationRecord
|
|||
url_string = if plaid_account.present?
|
||||
plaid_account.plaid_item&.institution_url
|
||||
elsif simple_fin_account.present?
|
||||
simple_fin_account.simple_fin_connection&.institution_domain
|
||||
simple_fin_account.simple_fin_item&.institution_domain
|
||||
end
|
||||
|
||||
return nil unless url_string.present?
|
||||
|
|
|
@ -16,7 +16,7 @@ class Family < ApplicationRecord
|
|||
has_many :users, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :plaid_items, dependent: :destroy
|
||||
has_many :simple_fin_connections, dependent: :destroy
|
||||
has_many :simple_fin_items, dependent: :destroy
|
||||
has_many :invitations, dependent: :destroy
|
||||
|
||||
has_many :imports, dependent: :destroy
|
||||
|
@ -82,6 +82,11 @@ class Family < ApplicationRecord
|
|||
plaid_item.sync_later(start_date: start_date, parent_sync: sync)
|
||||
end
|
||||
|
||||
Rails.logger.info("Syncing simple_fin items for family #{id}")
|
||||
simple_fin_items.each do |simple_fin_item|
|
||||
simple_fin_item.sync_later(start_date: start_date, parent_sync: sync)
|
||||
end
|
||||
|
||||
Rails.logger.info("Applying rules for family #{id}")
|
||||
rules.each do |rule|
|
||||
rule.apply_later
|
||||
|
@ -103,8 +108,9 @@ class Family < ApplicationRecord
|
|||
Sync.where(
|
||||
"(syncable_type = 'Family' AND syncable_id = ?) OR
|
||||
(syncable_type = 'Account' AND syncable_id IN (SELECT id FROM accounts WHERE family_id = ? AND plaid_account_id IS NULL)) OR
|
||||
(syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?))",
|
||||
id, id, id
|
||||
(syncable_type = 'PlaidItem' AND syncable_id IN (SELECT id FROM plaid_items WHERE family_id = ?)) OR
|
||||
(syncable_type = 'SimpleFinItem' AND syncable_id IN (SELECT id FROM simple_fin_items WHERE family_id = ?))",
|
||||
id, id, id, id
|
||||
).where(status: [ "pending", "syncing" ], created_at: 10.minutes.ago..).exists?
|
||||
end
|
||||
|
||||
|
|
|
@ -92,14 +92,17 @@ class Provider::SimpleFin
|
|||
# TODO: Remove JSON Reading for real requests. Disabled currently due to preventing rate limits.
|
||||
json_file_path = Rails.root.join("sample.simple.fin.json")
|
||||
accounts = []
|
||||
error_messages = []
|
||||
if File.exist?(json_file_path)
|
||||
file_content = File.read(json_file_path)
|
||||
parsed_json = JSON.parse(file_content)
|
||||
accounts = parsed_json["accounts"] || []
|
||||
error_messages = parsed_json["errors"] || []
|
||||
else
|
||||
Rails.logger.warn "SimpleFIN: Sample JSON file not found at #{json_file_path}. Returning empty accounts."
|
||||
end
|
||||
|
||||
|
||||
# The only way we can really determine types right now is by some properties. Try and set their types
|
||||
accounts.each do |account|
|
||||
# Accounts can be considered Investment accounts if they have any holdings associated to them
|
||||
|
@ -112,10 +115,22 @@ class Provider::SimpleFin
|
|||
else
|
||||
account["type"] = "Depository" # Default for positive balance
|
||||
end
|
||||
|
||||
# Set error messages if related
|
||||
account["org"]["institution_errors"] = []
|
||||
error_messages.each do |error|
|
||||
if error.include? account["org"]["name"]
|
||||
account["org"]["institution_errors"].push(error)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Update accounts to only include relevant accounts to the typ
|
||||
accounts.filter { |acc| acc["type"] == accountable_type }
|
||||
if accountable_type == nil
|
||||
accounts
|
||||
else
|
||||
# Update accounts to only include relevant accounts to the type
|
||||
accounts.filter { |acc| acc["type"] == accountable_type }
|
||||
end
|
||||
end
|
||||
|
||||
# Returns if this is a supported API of SimpleFIN by the access url in the config.
|
||||
|
|
|
@ -7,13 +7,13 @@ class SimpleFinAccount < ApplicationRecord
|
|||
"Other" => OtherAsset
|
||||
}
|
||||
|
||||
belongs_to :simple_fin_connection
|
||||
belongs_to :simple_fin_item
|
||||
has_one :account, dependent: :destroy, foreign_key: :simple_fin_account_id, inverse_of: :simple_fin_account
|
||||
|
||||
accepts_nested_attributes_for :account
|
||||
|
||||
validates :external_id, presence: true, uniqueness: true
|
||||
validates :simple_fin_connection_id, presence: true
|
||||
validates :simple_fin_item_id, presence: true
|
||||
|
||||
after_destroy :cleanup_connection_if_orphaned
|
||||
|
||||
|
@ -60,7 +60,7 @@ class SimpleFinAccount < ApplicationRecord
|
|||
|
||||
|
||||
def family
|
||||
simple_fin_connection&.family
|
||||
simple_fin_item&.family
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -70,6 +70,8 @@ class SimpleFinAccount < ApplicationRecord
|
|||
# 'account' here refers to self.account (the associated Account instance)
|
||||
accountable_attributes = { id: self.account.accountable_id }
|
||||
|
||||
puts "SFA #{sf_account_data}"
|
||||
|
||||
self.update!(
|
||||
current_balance: sf_account_data["balance"].to_d,
|
||||
available_balance: sf_account_data["available-balance"]&.to_d,
|
||||
|
@ -83,6 +85,12 @@ class SimpleFinAccount < ApplicationRecord
|
|||
}
|
||||
)
|
||||
|
||||
self.simple_fin_item.update!(
|
||||
institution_errors: sf_account_data["org"]["institution_errors"],
|
||||
status: sf_account_data["org"]["institution_errors"].empty? ? :good : :requires_update
|
||||
|
||||
)
|
||||
|
||||
# Sync transactions if present in the data
|
||||
if sf_account_data["transactions"].is_a?(Array)
|
||||
sync_transactions!(sf_account_data["transactions"])
|
||||
|
@ -181,7 +189,7 @@ class SimpleFinAccount < ApplicationRecord
|
|||
|
||||
def cleanup_connection_if_orphaned
|
||||
# Reload the connection to get the most up-to-date count of associated accounts
|
||||
connection = simple_fin_connection.reload
|
||||
connection = simple_fin_item.reload
|
||||
connection.destroy_later if connection.simple_fin_accounts.empty?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,87 +0,0 @@
|
|||
class SimpleFinConnection < ApplicationRecord
|
||||
include Syncable
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
validates :name, presence: true
|
||||
validates :family_id, presence: true
|
||||
|
||||
belongs_to :family
|
||||
has_one_attached :logo
|
||||
|
||||
has_many :simple_fin_accounts, dependent: :destroy
|
||||
has_many :accounts, through: :simple_fin_accounts
|
||||
|
||||
scope :active, -> { where(scheduled_for_deletion: false) }
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
scope :needs_update, -> { where(status: :requires_update) }
|
||||
|
||||
class << self
|
||||
# `provided_access_url` is the full URL from SimpleFIN (https://user:pass@beta-bridge.simplefin.org/simplefin)
|
||||
# `connection_name` can be user-provided or derived.
|
||||
def create_and_sync_from_access_url(provided_access_url, connection_name, family_obj)
|
||||
# Basic validation of the URL format
|
||||
uri = URI.parse(provided_access_url)
|
||||
raise ArgumentError, "Invalid SimpleFIN Access URL: Missing credentials" unless uri.user && uri.password
|
||||
raise ArgumentError, "Invalid SimpleFIN Access URL: Must be HTTPS" unless uri.scheme == "https"
|
||||
|
||||
# Create the connection object first
|
||||
connection = family_obj.simple_fin_connections.create!(
|
||||
name: connection_name,
|
||||
access_url: provided_access_url,
|
||||
status: :good # Assume good initially
|
||||
)
|
||||
|
||||
# Perform an initial sync to populate institution details and accounts
|
||||
connection.sync_later
|
||||
connection
|
||||
end
|
||||
end
|
||||
|
||||
def sync_data(sync, start_date: nil)
|
||||
update!(last_synced_at: Time.current)
|
||||
Rails.logger.info("SimpleFinConnection: Starting sync for connection ID #{id}")
|
||||
|
||||
begin
|
||||
# Fetch initial info if not present (like institution details)
|
||||
if institution_id.blank? || api_versions_supported.blank?
|
||||
info_data = provider.get_api_versions_and_org_details_from_accounts
|
||||
update!(
|
||||
institution_id: info_data[:org_id],
|
||||
institution_name: info_data[:org_name],
|
||||
institution_url: info_data[:org_url],
|
||||
institution_domain: info_data[:org_domain],
|
||||
api_versions_supported: info_data[:versions]
|
||||
)
|
||||
end
|
||||
|
||||
sf_accounts_data = provider.get_available_accounts(nil) # Pass nil to get all types
|
||||
|
||||
sf_accounts_data.each do |sf_account_data|
|
||||
accountable_klass_name = Provider::SimpleFin::ACCOUNTABLE_TYPE_MAPPING.find { |key, _val| sf_account_data["type"]&.downcase == key.downcase }&.last
|
||||
accountable_klass_name ||= (sf_account_data["balance"].to_d >= 0 ? Depository : CreditCard) # Basic fallback
|
||||
accountable_klass = accountable_klass_name
|
||||
|
||||
sfa = simple_fin_accounts.find_or_create_from_simple_fin_data!(sf_account_data, self, accountable_klass)
|
||||
sfa.sync_account_data!(sf_account_data)
|
||||
end
|
||||
|
||||
update!(status: :good) if requires_update?
|
||||
Rails.logger.info("SimpleFinConnection: Sync completed for connection ID #{id}")
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("SimpleFinConnection: Sync failed for connection ID #{id}: #{e.message}")
|
||||
update!(status: :requires_update)
|
||||
raise e
|
||||
end
|
||||
end
|
||||
|
||||
def provider
|
||||
@provider ||= Provider::SimpleFin.new()
|
||||
end
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
end
|
178
app/models/simple_fin_item.rb
Normal file
178
app/models/simple_fin_item.rb
Normal file
|
@ -0,0 +1,178 @@
|
|||
class SimpleFinItem < ApplicationRecord
|
||||
include Syncable
|
||||
|
||||
enum :status, { good: "good", requires_update: "requires_update" }, default: :good
|
||||
|
||||
validates :institution_name, presence: true
|
||||
validates :family_id, presence: true
|
||||
|
||||
belongs_to :family
|
||||
has_one_attached :logo
|
||||
|
||||
has_many :simple_fin_accounts, dependent: :destroy
|
||||
has_many :accounts, through: :simple_fin_accounts
|
||||
|
||||
scope :active, -> { where(scheduled_for_deletion: false) }
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
scope :needs_update, -> { where(status: :requires_update) }
|
||||
|
||||
class << self
|
||||
# # `provided_access_url` is the full URL from SimpleFIN (https://user:pass@beta-bridge.simplefin.org/simplefin)
|
||||
# # `connection_name` can be user-provided or derived.
|
||||
# def create_and_sync_from_access_url(provided_access_url, connection_name, family_obj)
|
||||
# # Basic validation of the URL format
|
||||
# uri = URI.parse(provided_access_url)
|
||||
# raise ArgumentError, "Invalid SimpleFIN Access URL: Missing credentials" unless uri.user && uri.password
|
||||
# raise ArgumentError, "Invalid SimpleFIN Access URL: Must be HTTPS" unless uri.scheme == "https"
|
||||
|
||||
# # Create the connection object first
|
||||
# connection = family_obj.simple_fin_connections.create!(
|
||||
# name: connection_name,
|
||||
# access_url: provided_access_url,
|
||||
# status: :good # Assume good initially
|
||||
# )
|
||||
|
||||
# # Perform an initial sync to populate institution details and accounts
|
||||
# connection.sync_later
|
||||
# connection
|
||||
# end
|
||||
end
|
||||
|
||||
##
|
||||
# Syncs the simple_fin_item given and all other simple_fin accounts available (reduces calls to the API)
|
||||
def sync_data(sync, start_date: nil)
|
||||
# TODO: Rate limit
|
||||
# now = Time.current
|
||||
# # Rate Limiting Check
|
||||
# # We use a transaction here to ensure that checking the count, resetting it if needed,
|
||||
# # and incrementing it are atomic.
|
||||
# ActiveRecord::Base.transaction do
|
||||
# # Reload self to ensure we have the latest values from the DB,
|
||||
# # especially if this method could be called concurrently for the same item.
|
||||
# self.reload
|
||||
|
||||
# if self.last_sync_count_reset_at.nil? || self.last_sync_count_reset_at.to_date < now.to_date
|
||||
# # If it's a new day (or first sync ever for rate limiting), reset the count and the reset timestamp.
|
||||
# self.update_columns(syncs_today_count: 0, last_sync_count_reset_at: now)
|
||||
# self.reload # Reload again to get the just-updated values for the check below.
|
||||
# end
|
||||
|
||||
# if self.syncs_today_count >= 24
|
||||
# msg = "SimpleFinItem ID #{self.id}: Sync limit of 24 per day reached. Count: #{self.syncs_today_count}."
|
||||
# Rails.logger.warn(msg)
|
||||
# sync.fail!(StandardError.new(msg)) # Record failure in the Sync object
|
||||
# raise StandardError, msg # Raise to stop execution and ensure SyncJob handles it as a failure
|
||||
# end
|
||||
|
||||
# # If not rate-limited, increment the count for this sync attempt.
|
||||
# self.increment!(:syncs_today_count)
|
||||
# end
|
||||
|
||||
|
||||
# unless access_url.present?
|
||||
# # This is a configuration error for the connection itself.
|
||||
# msg = "SimpleFinConnection: Sync cannot proceed for connection ID #{id}: Missing access_url."
|
||||
# Rails.logger.error(msg)
|
||||
# update!(status: :requires_update) # Mark connection as needing attention
|
||||
# # Raise an error to ensure the SyncJob records this failure.
|
||||
# # Sync#perform will catch this and call sync.fail!
|
||||
# raise StandardError, msg
|
||||
# end
|
||||
|
||||
# TODO: Populate this
|
||||
# update!(last_synced_at: Time.current, status: :requires_update)
|
||||
|
||||
Rails.logger.info("SimpleFinConnection: Starting sync for connection ID #{id}")
|
||||
|
||||
begin
|
||||
# Fetch all accounts for this specific connection from SimpleFIN.
|
||||
sf_accounts_data = provider.get_available_accounts(nil)
|
||||
|
||||
|
||||
# Keep track of external IDs reported by the provider in this sync.
|
||||
# This can be used later to identify accounts that might have been removed on the SimpleFIN side.
|
||||
current_provider_external_ids = []
|
||||
|
||||
sf_accounts_data.each do |sf_account_data|
|
||||
current_provider_external_ids << sf_account_data["id"]
|
||||
|
||||
begin
|
||||
# Find or create the SimpleFinAccount record.
|
||||
sfa = SimpleFinAccount.find_by(external_id: sf_account_data["id"])
|
||||
rescue StandardError
|
||||
# Ignore because it could be non existent accounts from the central sync
|
||||
end
|
||||
|
||||
if sfa != nil
|
||||
# Sync the detailed data for this account (e.g., balance, and potentially transactions).
|
||||
# This method is expected to be on the SimpleFinAccount model.
|
||||
sfa.sync_account_data!(sf_account_data)
|
||||
end
|
||||
end
|
||||
|
||||
# Optional: You could add logic here to handle accounts that exist in your DB for this
|
||||
# SimpleFinConnection but were NOT reported by the provider in `sf_accounts_data`.
|
||||
# These could be marked as closed, archived, etc. For example:
|
||||
# simple_fin_accounts.where.not(external_id: current_provider_external_ids).find_each(&:archive!)
|
||||
|
||||
update!(status: :good) # Mark connection as successfully synced.
|
||||
Rails.logger.info("SimpleFinConnection: Sync completed for connection ID #{id}")
|
||||
|
||||
# rescue Provider::SimpleFin::AuthenticationError => e # Catch specific auth errors if your provider defines them.
|
||||
# Rails.logger.error("SimpleFinConnection: Authentication failed for connection ID #{id}: #{e.message}")
|
||||
# update!(status: :requires_update) # Mark the connection so the user knows to update credentials.
|
||||
# raise e # Re-raise so Sync#perform can record the failure.
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("SimpleFinConnection: Sync failed for connection ID #{id}: #{e.message}")
|
||||
update!(status: :requires_update)
|
||||
raise e # Re-raise so Sync#perform can record the failure.
|
||||
end
|
||||
end
|
||||
|
||||
# def sync_data(sync, start_date: nil)
|
||||
# update!(last_synced_at: Time.current)
|
||||
# Rails.logger.info("SimpleFinConnection: Starting sync for connection ID #{id}")
|
||||
|
||||
# # begin
|
||||
# # # Fetch initial info if not present (like institution details)
|
||||
# # if institution_id.blank? || api_versions_supported.blank?
|
||||
# # info_data = provider.get_api_versions_and_org_details_from_accounts
|
||||
# # update!(
|
||||
# # institution_id: info_data[:org_id],
|
||||
# # institution_name: info_data[:org_name],
|
||||
# # institution_url: info_data[:org_url],
|
||||
# # institution_domain: info_data[:org_domain],
|
||||
# # api_versions_supported: info_data[:versions]
|
||||
# # )
|
||||
# # end
|
||||
|
||||
# # sf_accounts_data = provider.get_available_accounts(nil) # Pass nil to get all types
|
||||
|
||||
# # sf_accounts_data.each do |sf_account_data|
|
||||
# # accountable_klass_name = Provider::SimpleFin::ACCOUNTABLE_TYPE_MAPPING.find { |key, _val| sf_account_data["type"]&.downcase == key.downcase }&.last
|
||||
# # accountable_klass_name ||= (sf_account_data["balance"].to_d >= 0 ? Depository : CreditCard) # Basic fallback
|
||||
# # accountable_klass = accountable_klass_name
|
||||
|
||||
# # sfa = simple_fin_accounts.find_or_create_from_simple_fin_data!(sf_account_data, self, accountable_klass)
|
||||
# # sfa.sync_account_data!(sf_account_data)
|
||||
# # end
|
||||
|
||||
# update!(status: :good) if requires_update?
|
||||
# Rails.logger.info("SimpleFinConnection: Sync completed for connection ID #{id}")
|
||||
|
||||
# # rescue StandardError => e
|
||||
# # Rails.logger.error("SimpleFinConnection: Sync failed for connection ID #{id}: #{e.message}")
|
||||
# # update!(status: :requires_update)
|
||||
# # raise e
|
||||
# # end
|
||||
# end
|
||||
|
||||
def provider
|
||||
@provider ||= Provider::Registry.get_provider(:simple_fin)
|
||||
end
|
||||
|
||||
def destroy_later
|
||||
update!(scheduled_for_deletion: true)
|
||||
DestroyJob.perform_later(self)
|
||||
end
|
||||
end
|
|
@ -30,6 +30,8 @@
|
|||
<% end %>
|
||||
</div>
|
||||
<div class="flex items-center gap-8">
|
||||
<%= render "accounts/account_error", account: account, given_title: "Account has an error", link_to_path: account_path(account, return_to: return_to) %>
|
||||
|
||||
<p class="text-sm font-medium <%= account.is_active ? "text-primary" : "text-subdued" %>">
|
||||
<%= format_money account.balance_money %>
|
||||
</p>
|
||||
|
|
34
app/views/accounts/_account_error.html.erb
Normal file
34
app/views/accounts/_account_error.html.erb
Normal file
|
@ -0,0 +1,34 @@
|
|||
<%# locals: (account:, link_to_path: nil, given_title: nil, target: nil) %>
|
||||
|
||||
<%# Flag indicators of account issues %>
|
||||
<% if account.simple_fin_account.simple_fin_item.status == "requires_update" %>
|
||||
<%= link_to link_to_path, target: target do %>
|
||||
<%= icon(
|
||||
"alert-triangle",
|
||||
id: "account-issue-icon",
|
||||
as_button: true,
|
||||
size: "sm",
|
||||
rel: "noopener noreferrer",
|
||||
disabled: account.syncing?,
|
||||
title: given_title
|
||||
) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%# Force some styling that keeps getting overwritten %>
|
||||
<style>
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.2); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
#account-issue-icon * {
|
||||
color: red;
|
||||
}
|
||||
|
||||
#account-issue-icon {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
</style>
|
|
@ -15,21 +15,30 @@
|
|||
|
||||
<div class="space-y-1">
|
||||
<% account_group.accounts.each do |account| %>
|
||||
<%= link_to account_path(account),
|
||||
class: class_names(
|
||||
"block flex items-center gap-2 px-3 py-2 rounded-lg",
|
||||
page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover"
|
||||
),
|
||||
data: { sidebar_tabs_target: "account", action: "click->sidebar-tabs#select" },
|
||||
title: account.name do %>
|
||||
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
|
||||
|
||||
<div class="min-w-0 grow">
|
||||
<%= tag.p account.name, class: "text-sm text-primary font-medium mb-0.5 truncate" %>
|
||||
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
|
||||
</div>
|
||||
<div style="width: 100%;"
|
||||
class="<%= class_names(
|
||||
"block flex items-center gap-2 px-3 py-2 rounded-lg",
|
||||
page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover"
|
||||
) %>"
|
||||
data-sidebar-tabs-target="account">
|
||||
|
||||
<div class="ml-auto text-right grow h-10">
|
||||
<%= link_to account_path(account),
|
||||
style: "overflow: hidden;",
|
||||
class: "flex items-center gap-2 grow",
|
||||
title: account.name,
|
||||
data: { action: "click->sidebar-tabs#select" } do %>
|
||||
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
|
||||
|
||||
<div style="max-width: 80%;">
|
||||
<%= tag.p account.name, class: "text-sm text-primary font-medium mb-0.5 truncate" %>
|
||||
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
|
||||
</div>
|
||||
<%# Render account error warnings %>
|
||||
<%= render "accounts/account_error", account: account, given_title: "Account has an error", link_to_path: account_path(account) %>
|
||||
<% end %>
|
||||
|
||||
<div class="ml-auto text-right h-10 shrink-0">
|
||||
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy" do %>
|
||||
|
@ -38,7 +47,7 @@
|
|||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<% if @manual_accounts.empty? && @plaid_items.empty? && @simple_fin_connections.empty? %>
|
||||
<% if @manual_accounts.empty? && @plaid_items.empty? && @simple_fin_items.empty? %>
|
||||
<%= render "empty" %>
|
||||
<% else %>
|
||||
<div class="space-y-2">
|
||||
|
@ -31,8 +31,8 @@
|
|||
<%= render @plaid_items.sort_by(&:created_at) %>
|
||||
<% end %>
|
||||
|
||||
<% if @simple_fin_connections.any? %>
|
||||
<%= render @simple_fin_connections.sort_by(&:created_at) %>
|
||||
<% if @simple_fin_items.any? %>
|
||||
<%= render @simple_fin_items.sort_by(&:created_at) %>
|
||||
<% end %>
|
||||
|
||||
<% if @manual_accounts.any? %>
|
||||
|
|
|
@ -14,21 +14,19 @@
|
|||
<%= tag.p format_money(account.balance_money), class: "text-primary text-3xl font-medium truncate" %>
|
||||
</div>
|
||||
|
||||
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= styled_form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||
<div class="flex items-center gap-2 w-[25rem]">
|
||||
<% if chart_view.present? %>
|
||||
<%= form.select :chart_view,
|
||||
[["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]],
|
||||
{ selected: chart_view },
|
||||
class: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0",
|
||||
data: { "auto-submit-form-target": "auto" } %>
|
||||
<% end %>
|
||||
|
||||
<%= form.select :period,
|
||||
Period.as_options,
|
||||
{ selected: period.key },
|
||||
data: { "auto-submit-form-target": "auto" },
|
||||
class: "bg-container border border-secondary rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
|
||||
data: { "auto-submit-form-target": "auto" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
@ -20,6 +20,13 @@
|
|||
<% end %>
|
||||
|
||||
|
||||
<%# Show error warnings %>
|
||||
<% if account.simple_fin_account_id.present? %>
|
||||
<div style="color: red; text-align: center;">
|
||||
<%= account.simple_fin_account.simple_fin_item.institution_errors.join(",") %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<% if account.plaid_account_id.present? %>
|
||||
<% if Rails.env.development? %>
|
||||
|
@ -33,8 +40,17 @@
|
|||
) %>
|
||||
<% end %>
|
||||
<% elsif account.simple_fin_account_id.present? %>
|
||||
<%# SimpleFIN information %>
|
||||
<%= image_tag "simple-fin-logo.svg", class: "h-6 w-auto", title: "Connected via SimpleFIN. This account will auto refresh." %>
|
||||
<%= render "accounts/account_error", account: account, link_to_path: "https://beta-bridge.simplefin.org/my-account", given_title: "This account has issues in SimpleFIN bridge. Update it and then re-sync.", target: :_blank %>
|
||||
<%# Re sync button. Will sync all SimpleFIN accounts %>
|
||||
<%= icon(
|
||||
"refresh-cw",
|
||||
as_button: true,
|
||||
size: "sm",
|
||||
href: sync_simple_fin_path(account.simple_fin_account.simple_fin_item),
|
||||
disabled: account.syncing?,
|
||||
frame: :_top
|
||||
) %>
|
||||
<% else %>
|
||||
<%= icon(
|
||||
"refresh-cw",
|
||||
|
@ -49,4 +65,4 @@
|
|||
<%= render "accounts/show/menu", account: account %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
|
@ -1,29 +1,25 @@
|
|||
<%# locals: (simple_fin_connection:) %>
|
||||
<%# locals: (simple_fin_item:) %>
|
||||
|
||||
<div class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<div class="px-4 py-3 sm:px-6 border-b border-gray-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<% if simple_fin_connection.logo.attached? %>
|
||||
<%= image_tag simple_fin_connection.logo, class: "h-8 w-8 rounded-full" %>
|
||||
<% if simple_fin_item.logo.attached? %>
|
||||
<%= image_tag simple_fin_item.logo, class: "h-8 w-8 rounded-full" %>
|
||||
<% else %>
|
||||
<div>
|
||||
<%= simple_fin_connection.institution_name&.first || "?" %>
|
||||
<%= simple_fin_item.institution_name&.first || "?" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<h3 >
|
||||
<%= simple_fin_connection.institution_name || "SimpleFIN Connection" %>
|
||||
<%= simple_fin_item.institution_name || "SimpleFIN Connection" %>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<%# Add a sync button for the entire connection if needed %>
|
||||
<%# Example: render ButtonComponent.new(href: sync_simple_fin_connection_path(simple_fin_connection), method: :post, text: "Sync", icon: "refresh-cw", variant: "outline", size: "sm") %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
<% simple_fin_connection.accounts.includes(:accountable, :logo_attachment).active.alphabetically.each do |account| %>
|
||||
<% simple_fin_item.accounts.includes(:accountable, :logo_attachment).active.alphabetically.each do |account| %>
|
||||
<%= render "accounts/account", account: account %>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
|
|
@ -202,6 +202,9 @@ Rails.application.routes.draw do
|
|||
|
||||
# SimpleFIN routes
|
||||
resources :simple_fin, only: %i[create new] do
|
||||
member do
|
||||
post :sync
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
|
|
@ -1,33 +1,31 @@
|
|||
class SimpleFinIntegration < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :simple_fin_connections do |t|
|
||||
create_table :simple_fin_items, id: :uuid do |t|
|
||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||
t.string :name # e.g., "Chase via SimpleFIN" or user-defined
|
||||
t.string :institution_id # From SimpleFIN org.id (e.g., "www.chase.com")
|
||||
t.string :institution_name
|
||||
t.string :institution_url
|
||||
t.string :institution_domain
|
||||
t.string :status, default: "good" # e.g., good, requires_update
|
||||
t.datetime :last_synced_at
|
||||
t.string :institution_errors, array: true, default: []
|
||||
t.boolean :scheduled_for_deletion, default: false
|
||||
t.string :api_versions_supported, array: true, default: []
|
||||
# Columns for rate limiting
|
||||
t.integer :syncs_today_count, default: 0, null: false
|
||||
t.datetime :last_sync_count_reset_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
create_table :simple_fin_accounts do |t|
|
||||
t.references :simple_fin_connection, null: false, foreign_key: true
|
||||
t.references :simple_fin_item, null: false, foreign_key: true, type: :uuid
|
||||
t.string :external_id, null: false
|
||||
t.decimal :current_balance, precision: 19, scale: 4
|
||||
t.decimal :available_balance, precision: 19, scale: 4
|
||||
t.string :currency
|
||||
t.string :sf_type
|
||||
t.string :sf_subtype
|
||||
t.string :simple_fin_errors, array: true, default: []
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
add_index :simple_fin_accounts, [ :simple_fin_connection_id, :external_id ], unique: true, name: 'index_sfa_on_sfc_id_and_external_id'
|
||||
add_index :simple_fin_accounts, [ :simple_fin_item_id, :external_id ], unique: true, name: 'index_sfa_on_sfc_id_and_external_id'
|
||||
|
||||
add_reference :accounts, :simple_fin_account, foreign_key: true, null: true, index: true
|
||||
|
||||
|
|
23
db/schema.rb
generated
23
db/schema.rb
generated
|
@ -560,34 +560,31 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_09_134646) do
|
|||
end
|
||||
|
||||
create_table "simple_fin_accounts", force: :cascade do |t|
|
||||
t.bigint "simple_fin_connection_id", null: false
|
||||
t.uuid "simple_fin_item_id", null: false
|
||||
t.string "external_id", null: false
|
||||
t.decimal "current_balance", precision: 19, scale: 4
|
||||
t.decimal "available_balance", precision: 19, scale: 4
|
||||
t.string "currency"
|
||||
t.string "sf_type"
|
||||
t.string "sf_subtype"
|
||||
t.string "simple_fin_errors", default: [], array: true
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["simple_fin_connection_id", "external_id"], name: "index_sfa_on_sfc_id_and_external_id", unique: true
|
||||
t.index ["simple_fin_connection_id"], name: "index_simple_fin_accounts_on_simple_fin_connection_id"
|
||||
t.index ["simple_fin_item_id", "external_id"], name: "index_sfa_on_sfc_id_and_external_id", unique: true
|
||||
t.index ["simple_fin_item_id"], name: "index_simple_fin_accounts_on_simple_fin_item_id"
|
||||
end
|
||||
|
||||
create_table "simple_fin_connections", force: :cascade do |t|
|
||||
create_table "simple_fin_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_url"
|
||||
t.string "institution_domain"
|
||||
t.string "status", default: "good"
|
||||
t.datetime "last_synced_at"
|
||||
t.string "institution_errors", default: [], array: true
|
||||
t.boolean "scheduled_for_deletion", default: false
|
||||
t.string "api_versions_supported", default: [], array: true
|
||||
t.integer "syncs_today_count", default: 0, null: false
|
||||
t.datetime "last_sync_count_reset_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["family_id"], name: "index_simple_fin_connections_on_family_id"
|
||||
t.index ["family_id"], name: "index_simple_fin_items_on_family_id"
|
||||
end
|
||||
|
||||
create_table "stock_exchanges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
@ -794,8 +791,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_09_134646) do
|
|||
add_foreign_key "security_prices", "securities"
|
||||
add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"
|
||||
add_foreign_key "sessions", "users"
|
||||
add_foreign_key "simple_fin_accounts", "simple_fin_connections"
|
||||
add_foreign_key "simple_fin_connections", "families"
|
||||
add_foreign_key "simple_fin_accounts", "simple_fin_items"
|
||||
add_foreign_key "simple_fin_items", "families"
|
||||
add_foreign_key "subscriptions", "families"
|
||||
add_foreign_key "syncs", "syncs", column: "parent_id"
|
||||
add_foreign_key "taggings", "tags"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue