From 37dc1b3c7e0d14ddad1316a2e8b6c677524ae204 Mon Sep 17 00:00:00 2001 From: Cameron Roudebush Date: Fri, 9 May 2025 11:29:59 -0400 Subject: [PATCH] feat(SimpleFIN): Adding initial SimpleFIN integration - Still early stages - SimpleFIN can be queried for account information - Adding some interfaces to be able to select the SimpleFIN accounts found from SimpleFIN bridge --- .env.example | 5 + .gitignore | 4 +- app/assets/images/simple-fin-logo.svg | 19 + .../concerns/accountable_resource.rb | 13 + app/controllers/simple_fin_controller.rb | 103 ++++++ app/models/account.rb | 35 +- app/models/account/linkable.rb | 3 +- app/models/family.rb | 7 + app/models/provider/registry.rb | 10 +- app/models/provider/simple_fin.rb | 135 +++++++ app/models/simple_fin_account.rb | 100 ++++++ app/models/simple_fin_connection.rb | 87 +++++ app/views/accounts/_logo.html.erb | 2 +- .../accounts/new/_method_selector.html.erb | 11 +- app/views/accounts/show/_header.html.erb | 6 + app/views/credit_cards/new.html.erb | 2 +- app/views/cryptos/new.html.erb | 2 +- app/views/depositories/new.html.erb | 2 +- app/views/investments/new.html.erb | 2 +- app/views/loans/new.html.erb | 2 +- app/views/simple_fin/new.html.erb | 70 ++++ config/initializers/simple_fin.rb | 10 + config/locales/simple_fin/en.yml | 8 + config/locales/views/accounts/en.yml | 1 + config/routes.rb | 11 + ...0509134646_setup_simple_fin_integration.rb | 33 ++ db/schema.rb | 37 +- sample.simple.fin.json | 333 ++++++++++++++++++ 28 files changed, 1032 insertions(+), 21 deletions(-) create mode 100644 app/assets/images/simple-fin-logo.svg create mode 100644 app/controllers/simple_fin_controller.rb create mode 100644 app/models/provider/simple_fin.rb create mode 100644 app/models/simple_fin_account.rb create mode 100644 app/models/simple_fin_connection.rb create mode 100644 app/views/simple_fin/new.html.erb create mode 100644 config/initializers/simple_fin.rb create mode 100644 config/locales/simple_fin/en.yml create mode 100644 db/migrate/20250509134646_setup_simple_fin_integration.rb create mode 100644 sample.simple.fin.json diff --git a/.env.example b/.env.example index 528dad8b..409e23de 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,11 @@ POSTGRES_USER=postgres # This is the domain that your Maybe instance will be hosted at. It is used to generate links in emails and other places. APP_DOMAIN= +## SimpleFIN +# 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= + # Disable enforcing SSL connections # DISABLE_SSL=true diff --git a/.gitignore b/.gitignore index eeb5e8f2..6618bc20 100644 --- a/.gitignore +++ b/.gitignore @@ -70,4 +70,6 @@ node_modules compose.yml -plaid_test_accounts/ \ No newline at end of file +plaid_test_accounts/ +dump.rdb +dev.docs.md \ No newline at end of file diff --git a/app/assets/images/simple-fin-logo.svg b/app/assets/images/simple-fin-logo.svg new file mode 100644 index 00000000..e67d126c --- /dev/null +++ b/app/assets/images/simple-fin-logo.svg @@ -0,0 +1,19 @@ + + + + + + + + + image/svg+xml + + + + + + + + + + \ No newline at end of file diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 16fdbebd..7be8048f 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -6,6 +6,8 @@ module AccountableResource before_action :set_account, only: [ :show, :edit, :update, :destroy ] before_action :set_link_token, only: :new + before_action :set_accountable_type + before_action :set_simple_fin_avail end class_methods do @@ -55,6 +57,17 @@ module AccountableResource end private + + def set_accountable_type + @accountable_type = accountable_type() + end + + def set_simple_fin_avail + @simple_fin_avail = Current.family.get_simple_fin_available( + accountable_type: accountable_type.name, + ) + end + def set_link_token @us_link_token = Current.family.get_link_token( webhooks_url: plaid_us_webhooks_url, diff --git a/app/controllers/simple_fin_controller.rb b/app/controllers/simple_fin_controller.rb new file mode 100644 index 00000000..81fdd076 --- /dev/null +++ b/app/controllers/simple_fin_controller.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +class SimpleFinController < ApplicationController + before_action :set_accountable_type + before_action :authenticate_user! + before_action :set_simple_fin_provider + before_action :require_simple_fin_provider + + def new + @simple_fin_accounts = @simple_fin_provider.get_available_accounts(@accountable_type) + # Filter accounts we already have + @simple_fin_accounts = @simple_fin_accounts.filter { |acc| !account_exists(acc) } + rescue StandardError => e + Rails.logger.error "SimpleFIN: Failed to fetch accounts - #{e.message}" + redirect_to new_account_path, alert: t(".fetch_failed") + end + + ## + # Returns if an account exists for this family and this ID + def account_exists(acc) + Current.family.accounts.find_by(name: acc["name"]) + end + + + ## + # Starts a sync across all SimpleFIN accounts + def sync + puts "Should sync" + end + + def create + selected_ids = params[:selected_account_ids] + if selected_ids.blank? + Rails.logger.error "No accounts were selected." + redirect_to new_simple_fin_connection_path(accountable_type: @accountable_type) + return + end + + all_available_accounts = @simple_fin_provider.get_available_accounts(@accountable_type) + accounts_to_create_details = all_available_accounts.filter { |acc| selected_ids.include?(acc["id"]) } + + # Group selected accounts by their institution ID (org.id) + accounts_by_institution = accounts_to_create_details.group_by { |acc| acc.dig("org", "id") } + + accounts_by_institution.each do |institution_id, sf_accounts_for_institution| + 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" + 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 + 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 account_exists(acc_detail) + + # Get sub type for this account from params + sub_type = params[:account][acc_detail["id"]]["subtype"] + acc_detail["subtype"] = sub_type + + + # Create SimpleFinAccount and its associated Account + SimpleFinAccount.find_or_create_from_simple_fin_data!( + acc_detail, + simple_fin_connection + ) + + # Optionally, trigger an initial sync for the new account if needed, + # though find_or_create_from_simple_fin_data! already populates it. + # simple_fin_account.sync_account_data!(acc_detail) + + # The Account record is created via accepts_nested_attributes_for in SimpleFinAccount + # or by the logic within find_or_create_from_simple_fin_data! + end + end + + redirect_to root_path, notice: t(".accounts_created_success") + rescue StandardError => e + Rails.logger.error "SimpleFIN: Failed to create accounts - #{e.message}" + redirect_to new_simple_fin_connection_path + end + + private + + def set_accountable_type + @accountable_type = params[:accountable_type] + end + + def set_simple_fin_provider + @simple_fin_provider = Provider::Registry.get_provider(:simple_fin) + end + + def require_simple_fin_provider + unless @simple_fin_provider&.is_available(Current.user.id, @accountable_type) + redirect_to new_account_path + end + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 85167e22..c01bc069 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -4,6 +4,7 @@ class Account < ApplicationRecord validates :name, :balance, :currency, presence: true belongs_to :family + belongs_to :simple_fin_account, optional: true belongs_to :import, optional: true has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" @@ -22,7 +23,7 @@ class Account < ApplicationRecord scope :assets, -> { where(classification: "asset") } scope :liabilities, -> { where(classification: "liability") } scope :alphabetically, -> { order(:name) } - scope :manual, -> { where(plaid_account_id: nil) } + scope :manual, -> { where(plaid_account_id: nil, simple_fin_account_id: nil) } has_one_attached :logo @@ -30,6 +31,8 @@ class Account < ApplicationRecord accepts_nested_attributes_for :accountable, update_only: true + before_destroy :destroy_associated_provider_accounts + class << self def create_and_sync(attributes) attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty @@ -62,17 +65,25 @@ class Account < ApplicationRecord end def institution_domain - url_string = plaid_account&.plaid_item&.institution_url + 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 + end + return nil unless url_string.present? - begin - uri = URI.parse(url_string) - # Use safe navigation on .host before calling gsub - uri.host&.gsub(/^www\./, "") - rescue URI::InvalidURIError - # Log a warning if the URL is invalid and return nil - Rails.logger.warn("Invalid institution URL encountered for account #{id}: #{url_string}") - nil + if simple_fin_account.present? + # It's already the domain, so just return it. + url_string + else + # It's a full URL (from Plaid), so parse it. + begin + URI.parse(url_string).host&.gsub(/^www\./, "") + rescue URI::InvalidURIError + Rails.logger.warn("Invalid institution URL encountered for Plaid account #{id}: #{url_string}") + nil + end end end @@ -175,6 +186,10 @@ class Account < ApplicationRecord accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name end + def destroy_associated_provider_accounts + simple_fin_account.destroy if simple_fin_account.present? + end + private def sync_balances strategy = linked? ? :reverse : :forward diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb index ee0871bd..12859684 100644 --- a/app/models/account/linkable.rb +++ b/app/models/account/linkable.rb @@ -3,11 +3,12 @@ module Account::Linkable included do belongs_to :plaid_account, optional: true + belongs_to :simple_fin_account, optional: true end # A "linked" account gets transaction and balance data from a third party like Plaid def linked? - plaid_account_id.present? + plaid_account_id.present? || simple_fin_account_id.present? end # An "offline" or "unlinked" account is one where the user tracks values and diff --git a/app/models/family.rb b/app/models/family.rb index ed919b11..12e899ae 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -16,6 +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 :invitations, dependent: :destroy has_many :imports, dependent: :destroy @@ -130,6 +131,12 @@ class Family < ApplicationRecord ).link_token end + def get_simple_fin_available(accountable_type: nil) + provider = Provider::Registry.get_provider(:simple_fin) + + provider.is_available(id, accountable_type) + end + def requires_data_provider? # If family has any trades, they need a provider for historical prices return true if trades.any? diff --git a/app/models/provider/registry.rb b/app/models/provider/registry.rb index 16fa81a2..1237e193 100644 --- a/app/models/provider/registry.rb +++ b/app/models/provider/registry.rb @@ -63,6 +63,14 @@ class Provider::Registry Provider::Openai.new(access_token) end + + def simple_fin + config = Rails.application.config.simple_fin + + return nil unless config.present? + + Provider::SimpleFin.new(config, region: :us) + end end def initialize(concept) @@ -94,7 +102,7 @@ class Provider::Registry when :llm %i[openai] else - %i[synth plaid_us plaid_eu github openai] + %i[synth plaid_us plaid_eu github openai simple_fin] end end end diff --git a/app/models/provider/simple_fin.rb b/app/models/provider/simple_fin.rb new file mode 100644 index 00000000..9fc764b0 --- /dev/null +++ b/app/models/provider/simple_fin.rb @@ -0,0 +1,135 @@ +class Provider::SimpleFin + attr_reader :client, :region + + # SimpleFIN only supports these account types + MAYBE_SUPPORTED_SIMPLE_FIN_PRODUCTS = %w[Depository Investment Loan CreditCard].freeze + # TODO + MAX_HISTORY_DAYS = 1 + + def initialize(config, region: :us) + @region = region + @is_supported_api = is_supported_api() + end + + ## + # Verifies that SimpleFIN is available for use with these parameters + # + # @param [string] user_id + # @param [string] accountable_type The account type that we are checking + def is_available(user_id, accountable_type) + # Verify we have support for this accountable type + is_supported_account_type = MAYBE_SUPPORTED_SIMPLE_FIN_PRODUCTS.include?(accountable_type) + return false unless is_supported_account_type + + # Verify it is configured + config = Rails.application.config.simple_fin + return false unless config.present? + + # Make sure this API version is supported + is_supported_api = @is_supported_api + + is_supported_api + end + + ## + # Sends a request to the SimpleFIN endpoint + # + # @param [Boolean] include_creds Controls if credentials should be included or if this request should be anonymous. Default true. + def send_request_to_sf(path, include_creds = true) + # Grab access URL from the env + config = Rails.application.config.simple_fin + access_url = config["ACCESS_URL"] + # Add the access URL to the path + uri = URI.parse(access_url + path) + # Setup the request + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == "https") # Enable SSL if the scheme is https + request = Net::HTTP::Get.new(uri.request_uri) + if include_creds && uri.user && uri.password + request.basic_auth(uri.user, uri.password) + end + + # Send the request + begin + response = http.request(request) + if response.is_a?(Net::HTTPSuccess) + parsed_data = JSON.parse(response.body) + # Check for errors from the API + if parsed_data.key?("errors") && parsed_data["errors"].is_a?(Array) && !parsed_data["errors"].empty? + error_messages = parsed_data["errors"].join(", ") + Rails.logger.error("SimpleFIN API returned errors for #{uri.host}#{uri.path}: #{error_messages}") + end + parsed_data + else + # Handle HTTP non-success (4xx, 5xx errors) + body_summary = response.body.to_s.truncate(500) + Rails.logger.error("SimpleFIN HTTP Request Failed for #{uri.host}#{uri.path}. Status: #{response.code} #{response.message}. Body: #{body_summary}") + raise "SimpleFIN HTTP Request Failed: #{response.code} #{response.message}. Body: #{body_summary}" + end + end + end + + ## + # Sends a request to get all available accounts from SimpleFIN + # + # @param [str] accountable_type The name of the account type we're looking for. + # @param [int?] trans_start_date A linux epoch of the start date to get transactions of. + # @param [int?] trans_end_date A linux epoch of the end date to get transactions between. + # @param [Boolean] trans_pending If we should include pending transactions. Default is true. + def get_available_accounts(accountable_type, trans_start_date = nil, trans_end_date = nil, trans_pending = true) + endpoint = "/accounts?pending=#{trans_pending}" + + # Add any parameters we care about + if trans_start_date + endpoint += "&trans_start_date=#{trans_start_date}" + end + if trans_end_date + endpoint += "&trans_end_date=#{trans_end_date}" + end + + # account_info = send_request_to_sf(endpoint) + # accounts = account_info["accounts"] + # 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 = [] + if File.exist?(json_file_path) + file_content = File.read(json_file_path) + parsed_json = JSON.parse(file_content) + accounts = parsed_json["accounts"] || [] + 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 + if account.key?("holdings") && account["holdings"].is_a?(Array) && !account["holdings"].empty? + account["type"] = "Investment" + elsif account["balance"].to_d <= 0 && account["name"]&.downcase&.include?("card") + account["type"] = "CreditCard" + elsif account["balance"].to_d.negative? # Could be loan or credit card + account["type"] = "Loan" # Default for negative balance if not clearly a card + else + account["type"] = "Depository" # Default for positive balance + end + end + + # Update accounts to only include relevant accounts to the typ + accounts.filter { |acc| acc["type"] == accountable_type } + end + + # Returns if this is a supported API of SimpleFIN by the access url in the config. + def is_supported_api + # Make sure the config is loaded since this is called early + config = Rails.application.config.simple_fin + return false unless config.present? + + get_api_versions().include?("1.0") + end + + # Returns the API versions currently supported by the given SimpleFIN access url. + def get_api_versions + ver_info = send_request_to_sf("/info", false) + ver_info["versions"] + end +end diff --git a/app/models/simple_fin_account.rb b/app/models/simple_fin_account.rb new file mode 100644 index 00000000..4309ee70 --- /dev/null +++ b/app/models/simple_fin_account.rb @@ -0,0 +1,100 @@ +class SimpleFinAccount < ApplicationRecord + TYPE_MAPPING = { + "Depository" => Depository, + "CreditCard" => CreditCard, + "Loan" => Loan, + "Investment" => Investment, + "Other" => OtherAsset + } + + belongs_to :simple_fin_connection + 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 + + class << self + def find_or_create_from_simple_fin_data!(sf_account_data, sfc) + sfc.simple_fin_accounts.find_or_create_by!(external_id: sf_account_data["id"]) do |sfa| + sfa.account = sfc.family.accounts.new( + name: sf_account_data["name"], + balance: sf_account_data["balance"].to_d, + currency: sf_account_data["currency"], + accountable: TYPE_MAPPING[sf_account_data["type"]].new, + subtype: sf_account_data["subtype"], + simple_fin_account: sfa, # Explicitly associate back + last_synced_at: Time.current # Mark as synced upon creation + ) + # Populate other fields on sfa from sf_account_data if needed + # sfa.current_balance = sf_account_data["balance"].to_d + # sfa.available_balance = sf_account_data["available-balance"]&.to_d + # sfa.currency = sf_account_data["currency"] + # sfa.sf_type = accountable_type + # sfa.sf_subtype = sf_account_data["name"]&.include?("Credit") ? "Credit Card" : accountable_klass.name + end + end + end + + # sf_account_data is a hash from Provider::SimpleFin#get_available_accounts + def sync_account_data!(sf_account_data) + # Ensure accountable_attributes has the ID for updates + accountable_attributes = { id: account.accountable_id } + + # Example: Update specific accountable types like PlaidAccount does + # This will depend on the structure of sf_account_data and your Accountable models + # case account.accountable_type + # when "CreditCard" + # accountable_attributes.merge!( + # # minimum_payment: sf_account_data.dig("credit_details", "minimum_payment"), + # # apr: sf_account_data.dig("credit_details", "apr") + # ) + # when "Loan" + # accountable_attributes.merge!( + # # interest_rate: sf_account_data.dig("loan_details", "interest_rate") + # ) + # end + + update!( + current_balance: sf_account_data["balance"].to_d, + available_balance: sf_account_data["available-balance"]&.to_d, + currency: sf_account_data["currency"], + # sf_type: derive_sf_type(sf_account_data), # Potentially update type/subtype + # sf_subtype: derive_sf_subtype(sf_account_data), + simple_fin_errors: sf_account_data["errors"] || [], # Assuming errors might come on account data + account_attributes: { + id: account.id, + balance: sf_account_data["balance"].to_d, + # cash_balance: derive_sf_cash_balance(sf_account_data), # If applicable + last_synced_at: Time.current, + accountable_attributes: accountable_attributes + } + ) + end + + # TODO: Implement if SimpleFIN provides investment transactions/holdings + # def sync_investments!(transactions:, holdings:, securities:) + # # Similar to PlaidInvestmentSync.new(self).sync!(...) + # end + + # TODO: Implement if SimpleFIN provides transactions + # def sync_transactions!(added:, modified:, removed:) + # # Similar to PlaidAccount's sync_transactions! + # end + + def family + simple_fin_connection&.family + end + + private + + # Example helper, if needed + # def derive_sf_cash_balance(sf_balances) + # if account.investment? + # sf_balances["available-balance"]&.to_d || 0 + # else + # sf_balances["balance"]&.to_d + # end + # end +end diff --git a/app/models/simple_fin_connection.rb b/app/models/simple_fin_connection.rb new file mode 100644 index 00000000..6aa0d155 --- /dev/null +++ b/app/models/simple_fin_connection.rb @@ -0,0 +1,87 @@ +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 diff --git a/app/views/accounts/_logo.html.erb b/app/views/accounts/_logo.html.erb index 8eec2153..23e0b9f7 100644 --- a/app/views/accounts/_logo.html.erb +++ b/app/views/accounts/_logo.html.erb @@ -7,7 +7,7 @@ "full" => "w-full h-full" } %> -<% if account.plaid_account_id? && account.institution_domain.present? %> +<% if (account.plaid_account_id? || account.simple_fin_account_id?) && account.institution_domain.present? %> <%= image_tag "https://logo.synthfinance.com/#{account.institution_domain}", class: "shrink-0 rounded-full #{size_classes[size]}" %> <% elsif account.logo.attached? %> <%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %> diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index 25dc9f62..9c8ba556 100644 --- a/app/views/accounts/new/_method_selector.html.erb +++ b/app/views/accounts/new/_method_selector.html.erb @@ -1,4 +1,4 @@ -<%# locals: (path:, us_link_token: nil, eu_link_token: nil) %> +<%# locals: (path:, us_link_token: nil, eu_link_token: nil, simple_fin_avail: nil, accountable_type: nil) %> <%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
@@ -9,6 +9,15 @@ <%= t("accounts.new.method_selector.manual_entry") %> <% end %> + <% if simple_fin_avail %> + <%= link_to new_simple_fin_connection_path(accountable_type: @accountable_type), class: "flex items-center gap-4 w-full text-center text-primary focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-surface rounded-lg p-2" do %> + + <%= image_tag "simple-fin-logo.svg", class: "w-6 h-6" %> + + <%= t("accounts.new.method_selector.connected_simple_fin") %> + <% end %> + <% end %> + <% if us_link_token %> <%# Default US-only Link %>
<% end %> +
<% if account.plaid_account_id.present? %> <% if Rails.env.development? %> @@ -31,6 +32,11 @@ frame: :_top ) %> <% 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" %> + <%# TODO: Add manual sync %> + <% else %> <%= icon( "refresh-cw", diff --git a/app/views/credit_cards/new.html.erb b/app/views/credit_cards/new.html.erb index 0f7d4fcd..35a836ec 100644 --- a/app/views/credit_cards/new.html.erb +++ b/app/views/credit_cards/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_credit_card_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> + <%= render "accounts/new/method_selector", path: new_credit_card_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token, simple_fin_avail: @simple_fin_avail, accountable_type: @accountable_type %> <% else %> <%= render DialogComponent.new do |dialog| %> <% dialog.with_header(title: t(".title")) %> diff --git a/app/views/cryptos/new.html.erb b/app/views/cryptos/new.html.erb index 5587cfae..8dc8d439 100644 --- a/app/views/cryptos/new.html.erb +++ b/app/views/cryptos/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_crypto_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> + <%= render "accounts/new/method_selector", path: new_crypto_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token, simple_fin_avail: @simple_fin_avail, accountable_type: @accountable_type %> <% else %> <%= render DialogComponent.new do |dialog| %> <% dialog.with_header(title: t(".title")) %> diff --git a/app/views/depositories/new.html.erb b/app/views/depositories/new.html.erb index 4f08e3c9..f615d8e0 100644 --- a/app/views/depositories/new.html.erb +++ b/app/views/depositories/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_depository_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> + <%= render "accounts/new/method_selector", path: new_depository_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token, simple_fin_avail: @simple_fin_avail, accountable_type: @accountable_type %> <% else %> <%= render DialogComponent.new do |dialog| %> <% dialog.with_header(title: t(".title")) %> diff --git a/app/views/investments/new.html.erb b/app/views/investments/new.html.erb index 737c5818..baec874a 100644 --- a/app/views/investments/new.html.erb +++ b/app/views/investments/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_investment_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> + <%= render "accounts/new/method_selector", path: new_investment_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token, simple_fin_avail: @simple_fin_avail, accountable_type: @accountable_type %> <% else %> <%= render DialogComponent.new do |dialog| %> <% dialog.with_header(title: t(".title")) %> diff --git a/app/views/loans/new.html.erb b/app/views/loans/new.html.erb index de4c9575..7185c47f 100644 --- a/app/views/loans/new.html.erb +++ b/app/views/loans/new.html.erb @@ -1,5 +1,5 @@ <% if params[:step] == "method_select" %> - <%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %> + <%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token, simple_fin_avail: @simple_fin_avail, accountable_type: @accountable_type %> <% else %> <%= render DialogComponent.new do |dialog| %> <% dialog.with_header(title: t(".title")) %> diff --git a/app/views/simple_fin/new.html.erb b/app/views/simple_fin/new.html.erb new file mode 100644 index 00000000..db1a172b --- /dev/null +++ b/app/views/simple_fin/new.html.erb @@ -0,0 +1,70 @@ +<%# locals: @simple_fin_accounts %> + +<%= render layout: "accounts/new/container", locals: { title: "Select SimpleFIN Accounts", back_path: new_account_path } do %> + <% if @simple_fin_accounts.blank? %> +
+

No accounts found matching this type from SimpleFIN.

+

Please ensure your SimpleFIN subscription is active.

+ <%= link_to "Try Again", new_simple_fin_connection_path(accountable_type: @accountable_type), class: "mt-4 inline-block text-primary hover:underline" %> +
+ <% else %> + <%= styled_form_with url: simple_fin_connections_path(accountable_type: @accountable_type), method: :post, data: { turbo: false } do |form| %> + <%# Render each account option parsed from SimpleFIN %> + <% @simple_fin_accounts.each_with_index do |account, index| %> +
+ +
+ <% end %> + + +
+ <%= form.submit t("simple_fin.form.add_accounts"), class: "w-full btn btn-primary", + data: { + disable_with: "Adding Accounts..." + } %> +
+ <% end %> + <% end %> +<% end %> \ No newline at end of file diff --git a/config/initializers/simple_fin.rb b/config/initializers/simple_fin.rb new file mode 100644 index 00000000..ef391e22 --- /dev/null +++ b/config/initializers/simple_fin.rb @@ -0,0 +1,10 @@ +require "ostruct" + +Rails.application.configure do + config.simple_fin = nil + + if ENV["SIMPLE_FIN_ACCESS_URL"].present? + config.simple_fin = OpenStruct.new() + config.simple_fin["ACCESS_URL"] = ENV["SIMPLE_FIN_ACCESS_URL"] + end +end diff --git a/config/locales/simple_fin/en.yml b/config/locales/simple_fin/en.yml new file mode 100644 index 00000000..441b31ab --- /dev/null +++ b/config/locales/simple_fin/en.yml @@ -0,0 +1,8 @@ +--- +en: + simple_fin: + form: + add_accounts: "Add Selected Accounts" + create: + accounts_created_success: "Accounts Successfully Created" + fetch_failed: "Failed to fetch accounts" diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index e384ef79..e40a32ea 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -28,6 +28,7 @@ en: method_selector: connected_entry: Link account connected_entry_eu: Link EU account + connected_simple_fin: Link SimpleFIN account manual_entry: Enter account balance title: How would you like to add it? title: What would you like to add? diff --git a/config/routes.rb b/config/routes.rb index 8384d116..ed338e6d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -200,6 +200,17 @@ Rails.application.routes.draw do end end + # SimpleFIN routes + namespace :simple_fin do + resources :connections, only: [ :new, :create, :sync ], controller: "/simple_fin" + end + + # SimpleFIN routes (controller: SimpleFinController) + get "simple_fin/select_accounts", to: "simple_fin#select_accounts", as: "select_simple_fin_accounts" + # Defines POST /simple_fin/create_selected_accounts -> simple_fin#create_selected_accounts + post "simple_fin/create_selected_accounts", to: "simple_fin#create_selected_accounts", as: "create_selected_simple_fin_accounts" + + namespace :webhooks do post "plaid" post "plaid_eu" diff --git a/db/migrate/20250509134646_setup_simple_fin_integration.rb b/db/migrate/20250509134646_setup_simple_fin_integration.rb new file mode 100644 index 00000000..cafc139b --- /dev/null +++ b/db/migrate/20250509134646_setup_simple_fin_integration.rb @@ -0,0 +1,33 @@ +class SetupSimpleFinIntegration < ActiveRecord::Migration[7.2] + def change + create_table :simple_fin_connections do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + 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.boolean :scheduled_for_deletion, default: false + t.string :api_versions_supported, array: true, default: [] + + t.timestamps + end + + create_table :simple_fin_accounts do |t| + t.references :simple_fin_connection, null: false, foreign_key: true + 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.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_reference :accounts, :simple_fin_account, foreign_key: true, null: true, index: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 9a7e5fcf..fb4a642e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_05_02_164951) do +ActiveRecord::Schema[7.2].define(version: 2025_05_09_134646) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -37,6 +37,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_02_164951) do t.datetime "last_synced_at" t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" t.jsonb "locked_attributes", default: {} + t.bigint "simple_fin_account_id" t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type" @@ -44,6 +45,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_02_164951) do t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["import_id"], name: "index_accounts_on_import_id" t.index ["plaid_account_id"], name: "index_accounts_on_plaid_account_id" + t.index ["simple_fin_account_id"], name: "index_accounts_on_simple_fin_account_id" end create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -551,6 +553,36 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_02_164951) do t.index ["var"], name: "index_settings_on_var", unique: true end + create_table "simple_fin_accounts", force: :cascade do |t| + t.bigint "simple_fin_connection_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.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" + end + + create_table "simple_fin_connections", 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.boolean "scheduled_for_deletion", default: false + t.string "api_versions_supported", default: [], array: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id"], name: "index_simple_fin_connections_on_family_id" + end + create_table "stock_exchanges", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name", null: false t.string "acronym" @@ -721,6 +753,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_02_164951) do add_foreign_key "accounts", "families" add_foreign_key "accounts", "imports" add_foreign_key "accounts", "plaid_accounts" + add_foreign_key "accounts", "simple_fin_accounts" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "balances", "accounts", on_delete: :cascade @@ -753,6 +786,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_02_164951) 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 "subscriptions", "families" add_foreign_key "syncs", "syncs", column: "parent_id" add_foreign_key "taggings", "tags" diff --git a/sample.simple.fin.json b/sample.simple.fin.json new file mode 100644 index 00000000..b6571c6e --- /dev/null +++ b/sample.simple.fin.json @@ -0,0 +1,333 @@ +{ + "errors": [ + "Connection to Fidelity Investments may need attention" + ], + "accounts": [ + { + "org": { + "domain": "www.chase.com", + "name": "Chase Bank", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.chase.com", + "id": "www.chase.com" + }, + "id": "ACT-RANDOM-CHASE-CREDIT-001", + "name": "Credit Card Account", + "currency": "USD", + "balance": "-250.75", + "available-balance": "5000.00", + "balance-date": 1700000001, + "transactions": [], + "holdings": [] + }, + { + "org": { + "domain": "www.chase.com", + "name": "Chase Bank", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.chase.com", + "id": "www.chase.com" + }, + "id": "ACT-RANDOM-CHASE-LOAN-002", + "name": "Loan Account", + "currency": "USD", + "balance": "-150320.90", + "available-balance": "0.00", + "balance-date": 1700000002, + "transactions": [], + "holdings": [] + }, + { + "org": { + "domain": "www.fidelity.com", + "name": "Fidelity Investments", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.fidelity.com", + "id": "www.fidelity.com" + }, + "id": "ACT-RANDOM-FIDELITY-INVEST-003", + "name": "Brokerage Investment Account", + "currency": "USD", + "balance": "5873.12", + "available-balance": "5873.12", + "balance-date": 1700000003, + "transactions": [], + "holdings": [ + { + "id": "HOL-RANDOM-FBTC-001", + "created": 1700000101, + "currency": "USD", + "cost_basis": "150.00", + "description": "Generic Bitcoin Fund", + "market_value": "180.25", + "purchase_price": "50.00", + "shares": "3.00", + "symbol": "GBTCF" + }, + { + "id": "HOL-RANDOM-SPAXX-002", + "created": 1700000102, + "currency": "USD", + "cost_basis": "0.00", + "description": "Generic Money Market Fund", + "market_value": "50.10", + "purchase_price": "1.00", + "shares": "50.10", + "symbol": "GMMF" + }, + { + "id": "HOL-RANDOM-VXUS-003", + "created": 1700000103, + "currency": "USD", + "cost_basis": "350.50", + "description": "Generic International Stock Fund", + "market_value": "400.75", + "purchase_price": "55.00", + "shares": "7.00", + "symbol": "GISF" + }, + { + "id": "HOL-RANDOM-VOO-004", + "created": 1700000104, + "currency": "USD", + "cost_basis": "500.00", + "description": "Generic S&P 500 ETF", + "market_value": "550.80", + "purchase_price": "450.00", + "shares": "1.10", + "symbol": "GSPX" + } + ] + }, + { + "org": { + "domain": "www.fidelity.com", + "name": "Fidelity Investments", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.fidelity.com", + "id": "www.fidelity.com" + }, + "id": "ACT-RANDOM-FIDELITY-RETIRE-004", + "name": "Retirement Account", + "currency": "USD", + "balance": "25050.30", + "available-balance": "150.30", + "balance-date": 1700000004, + "transactions": [], + "holdings": [ + { + "id": "HOL-RANDOM-FXAIX-005", + "created": 1700000105, + "currency": "USD", + "cost_basis": "10000.00", + "description": "Generic 500 Index Fund", + "market_value": "11500.00", + "purchase_price": "150.00", + "shares": "70.00", + "symbol": "G500IF" + }, + { + "id": "HOL-RANDOM-FMCSX-006", + "created": 1700000106, + "currency": "USD", + "cost_basis": "3000.00", + "description": "Generic Mid Cap Stock Fund", + "market_value": "3300.00", + "purchase_price": "30.00", + "shares": "100.00", + "symbol": "GMCSF" + }, + { + "id": "HOL-RANDOM-SPAXX-007", + "created": 1700000107, + "currency": "USD", + "cost_basis": "0.00", + "description": "Generic Money Market Fund", + "market_value": "150.30", + "purchase_price": "1.00", + "shares": "150.30", + "symbol": "GMMF" + }, + { + "id": "HOL-RANDOM-FBGRX-008", + "created": 1700000108, + "currency": "USD", + "cost_basis": "5000.00", + "description": "Generic Blue Chip Growth Fund", + "market_value": "5800.00", + "purchase_price": "180.00", + "shares": "25.00", + "symbol": "GBCGF" + }, + { + "id": "HOL-RANDOM-NVDA-009", + "created": 1700000109, + "currency": "USD", + "cost_basis": "1000.00", + "description": "Generic Tech Stock", + "market_value": "1200.00", + "purchase_price": "100.00", + "shares": "10.00", + "symbol": "GTECH" + } + ] + }, + { + "org": { + "domain": "www.fidelity.com", + "name": "Fidelity Investments", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.fidelity.com", + "id": "www.fidelity.com" + }, + "id": "ACT-RANDOM-FIDELITY-TARGET-005", + "name": "Target Date Fund Account", + "currency": "USD", + "balance": "150000.00", + "available-balance": "0.00", + "balance-date": 1700000005, + "transactions": [], + "holdings": [ + { + "id": "HOL-RANDOM-RFKTX-010", + "created": 1700000110, + "currency": "USD", + "cost_basis": "120000.00", + "description": "Generic Target Date 2055 Fund", + "market_value": "150000.00", + "purchase_price": "20.00", + "shares": "6000.00", + "symbol": "GTD55" + } + ] + }, + { + "org": { + "domain": "www.fidelity.com", + "name": "Fidelity Investments", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.fidelity.com", + "id": "www.fidelity.com" + }, + "id": "ACT-RANDOM-FIDELITY-HSA-006", + "name": "Health Savings Account", + "currency": "USD", + "balance": "3050.75", + "available-balance": "50.75", + "balance-date": 1700000006, + "transactions": [], + "holdings": [ + { + "id": "HOL-RANDOM-FXAIX-011", + "created": 1700000111, + "currency": "USD", + "cost_basis": "2800.00", + "description": "Generic 500 Index Fund", + "market_value": "3000.00", + "purchase_price": "180.00", + "shares": "15.00", + "symbol": "G500IF" + }, + { + "id": "HOL-RANDOM-FDRXX-012", + "created": 1700000112, + "currency": "USD", + "cost_basis": "0.00", + "description": "Generic Cash Reserves Fund", + "market_value": "50.75", + "purchase_price": "1.00", + "shares": "50.75", + "symbol": "GCRF" + } + ] + }, + { + "org": { + "domain": "www.wpcu.coop", + "name": "Wright-Patt Credit Union", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.wpcu.coop/Index.aspx", + "id": "www.wpcu.coop" + }, + "id": "ACT-RANDOM-WPCU-SAVINGS-007", + "name": "Savings Account", + "currency": "USD", + "balance": "1234.56", + "available-balance": "1200.00", + "balance-date": 1700000007, + "transactions": [], + "holdings": [] + }, + { + "org": { + "domain": "www.wpcu.coop", + "name": "Wright-Patt Credit Union", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.wpcu.coop/Index.aspx", + "id": "www.wpcu.coop" + }, + "id": "ACT-RANDOM-WPCU-CHECKING-008", + "name": "Checking Account", + "currency": "USD", + "balance": "3456.78", + "available-balance": "3456.78", + "balance-date": 1700000008, + "transactions": [], + "holdings": [] + }, + { + "org": { + "domain": "www.wpcu.coop", + "name": "Wright-Patt Credit Union", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.wpcu.coop/Index.aspx", + "id": "www.wpcu.coop" + }, + "id": "ACT-RANDOM-WPCU-MMA-009", + "name": "Money Market Account", + "currency": "USD", + "balance": "25000.00", + "available-balance": "24900.00", + "balance-date": 1700000009, + "transactions": [], + "holdings": [] + }, + { + "org": { + "domain": "www.toyotafinancial.com", + "name": "Toyota Financial", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.toyotafinancial.com", + "id": "www.toyotafinancial.com" + }, + "id": "ACT-RANDOM-TOYOTA-LOAN-010", + "name": "Auto Loan", + "currency": "USD", + "balance": "-25000.50", + "available-balance": "0.00", + "balance-date": 1700000010, + "transactions": [], + "holdings": [] + }, + { + "org": { + "domain": "www.discover.com", + "name": "Discover Credit Card", + "sfin-url": "https://beta-bridge.simplefin.org/simplefin", + "url": "https://www.discover.com", + "id": "www.discover.com" + }, + "id": "ACT-RANDOM-DISCOVER-CREDIT-CARD", + "name": "Discover Credit Card", + "currency": "USD", + "balance": "-10000", + "available-balance": "0.00", + "balance-date": 1746720778, + "transactions": [], + "holdings": [] + } + ], + "x-api-message": [ + "Sample data generated. Provide a 'start-date' parameter to receive transactions prior to a specific date." + ] +} \ No newline at end of file