mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-08 15:05:22 +02:00
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
This commit is contained in:
parent
c26a7dd2dd
commit
37dc1b3c7e
28 changed files with 1032 additions and 21 deletions
|
@ -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
|
||||
|
||||
|
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -70,4 +70,6 @@ node_modules
|
|||
|
||||
compose.yml
|
||||
|
||||
plaid_test_accounts/
|
||||
plaid_test_accounts/
|
||||
dump.rdb
|
||||
dev.docs.md
|
19
app/assets/images/simple-fin-logo.svg
Normal file
19
app/assets/images/simple-fin-logo.svg
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="400.80807" height="400.80807" id="svg2" version="1.1" inkscape:version="0.48.2 r9819" sodipodi:docname="logo.svg">
|
||||
<defs id="defs4"/>
|
||||
<sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="0.62207031" inkscape:cx="197.68398" inkscape:cy="290.62177" inkscape:document-units="px" inkscape:current-layer="layer1" showgrid="false" inkscape:window-width="1024" inkscape:window-height="576" inkscape:window-x="0" inkscape:window-y="24" inkscape:window-maximized="1" fit-margin-top="0" fit-margin-left="0" fit-margin-right="0" fit-margin-bottom="0"/>
|
||||
<metadata id="metadata7">
|
||||
<rdf:RDF>
|
||||
<cc:Work rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(-177.31602,-357.87447)">
|
||||
<path sodipodi:type="arc" style="fill:#0088cc;fill-opacity:1;stroke:none" id="path2985" sodipodi:cx="377.72006" sodipodi:cy="558.2785" sodipodi:rx="200.40404" sodipodi:ry="200.40404" d="m 578.1241,558.2785 c 0,110.6801 -89.72394,200.40404 -200.40404,200.40404 -110.68009,0 -200.40404,-89.72394 -200.40404,-200.40404 0,-110.68009 89.72395,-200.40403 200.40404,-200.40403 110.6801,0 200.40404,89.72394 200.40404,200.40403 z"/>
|
||||
<text xml:space="preserve" style="font-size:40px;font-style:normal;font-weight:normal;line-height:125%;letter-spacing:0px;word-spacing:0px;fill:#000000;fill-opacity:1;stroke:none;font-family:Bitstream Vera Sans" x="186.47411" y="290.39044" id="text3755" sodipodi:linespacing="125%"><tspan sodipodi:role="line" id="tspan3757" x="186.47411" y="290.39044"/></text>
|
||||
<path style="fill:#ffffff;fill-opacity:1;stroke:none" id="path3819" d="m 100.68055,694.58641 c 0.0256,-15.9105 4.28138,-25.42566 9.49711,-44.69868 0.30192,-1.11565 4.39402,-6.26383 4.75355,-6.73525 13.30577,-14.05699 28.57267,-26.1248 43.56848,-38.30905 23.1752,-17.96966 45.98138,-36.39838 68.97695,-54.5954 25.23246,-20.18068 52.55008,-37.43733 80.07791,-54.25699 24.02926,-14.59948 48.48274,-28.48755 73.79172,-40.7464 7.29894,-3.45896 14.76462,-6.54532 22.1955,-9.70506 2.13782,-0.90905 6.59486,-2.947 9.13358,-3.36361 1.11289,-0.18262 2.2555,0.0131 3.38325,0.0196 0.97086,0.474 2.10247,0.70715 2.91256,1.42198 5.23179,4.61656 4.14682,19.28607 4.17295,25.47765 -0.0429,17.13278 -1.19086,34.32706 2.09338,51.2736 0.85727,4.42347 2.12742,8.75684 3.19113,13.13525 8.17884,26.97457 23.05512,51.09447 41.13248,72.5518 4.68272,5.55826 9.72352,10.8047 14.58529,16.20705 22.15552,22.81083 46.99994,42.8901 73.54139,60.3734 15.6998,10.34172 21.17345,13.04105 37.27545,22.05669 25.94586,14.2544 53.60668,24.79598 81.61226,34.15844 9.4481,3.16114 19.06026,5.80112 28.7134,8.25122 0,0 -8.9615,35.08176 -8.9615,35.08176 l 0,0 c -9.4402,-3.49388 -18.91686,-6.89614 -28.25502,-10.6566 -27.90618,-10.83812 -55.26379,-23.14595 -81.04208,-38.4578 -6.02659,-3.63743 -12.13164,-7.1479 -18.07975,-10.91229 -33.27091,-21.05623 -65.21876,-44.55412 -93.46858,-72.05419 -11.61768,-11.90928 -17.59008,-17.35319 -27.7119,-30.25024 -13.11545,-16.71149 -23.69486,-35.48855 -30.60195,-55.5839 -1.24034,-4.24016 -2.71223,-8.41934 -3.72101,-12.72047 -1.33171,-5.67799 -2.85371,-16.92387 -3.18429,-22.87705 -0.49011,-8.82574 0.30564,-17.73296 -0.13881,-26.56601 -0.1268,-5.61386 -0.14836,-11.25684 -0.70054,-16.85134 -0.10881,-1.10239 -0.28124,-2.19764 -0.43665,-3.29443 -0.10468,-0.7387 -0.0771,-1.51484 -0.34356,-2.21171 -0.17399,-0.45501 -0.62162,-0.75022 -0.93243,-1.12533 -4.08701,-0.52433 -6.73984,0.71612 -10.66154,2.03488 -7.29722,2.45385 -14.53575,5.09376 -21.66504,8.00117 -25.15749,11.09007 -49.65159,23.60307 -72.96162,38.23215 -27.38586,17.09947 -54.71739,34.40741 -79.79992,54.82655 -12.10321,9.55121 -23.9872,19.34799 -35.96643,29.05269 -7.45932,6.04299 -15.04899,11.92401 -22.50191,17.97489 -3.57407,2.90171 -7.08245,5.88339 -10.62368,8.82509 -9.03474,7.64273 -17.98702,15.32039 -26.5312,23.51501 -3.95738,3.79547 -8.89784,8.60554 -12.45997,12.89516 -1.43541,1.72856 -2.67492,3.61084 -4.01238,5.41625 -0.79894,1.41715 -1.71062,2.77641 -2.39681,4.25145 -0.52212,1.12236 -1.48943,4.71642 -1.21365,3.50967 13.90676,-60.85124 7.42381,-40.88512 7.81252,-28.05444 0,0 -14.04864,35.48281 -14.04864,35.48281 z" inkscape:connector-curvature="0"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 4.8 KiB |
|
@ -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,
|
||||
|
|
103
app/controllers/simple_fin_controller.rb
Normal file
103
app/controllers/simple_fin_controller.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
|
135
app/models/provider/simple_fin.rb
Normal file
135
app/models/provider/simple_fin.rb
Normal file
|
@ -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
|
100
app/models/simple_fin_account.rb
Normal file
100
app/models/simple_fin_account.rb
Normal file
|
@ -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
|
87
app/models/simple_fin_connection.rb
Normal file
87
app/models/simple_fin_connection.rb
Normal file
|
@ -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
|
|
@ -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]}" %>
|
||||
|
|
|
@ -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 %>
|
||||
<div class="text-sm">
|
||||
|
@ -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 %>
|
||||
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
|
||||
<%= image_tag "simple-fin-logo.svg", class: "w-6 h-6" %>
|
||||
</span>
|
||||
<%= t("accounts.new.method_selector.connected_simple_fin") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if us_link_token %>
|
||||
<%# Default US-only Link %>
|
||||
<button data-controller="plaid" data-action="plaid#open dialog#close" data-plaid-region-value="us" data-plaid-link-token-value="<%= us_link_token %>" class="text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2">
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<% 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",
|
||||
|
|
|
@ -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")) %>
|
||||
|
|
|
@ -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")) %>
|
||||
|
|
|
@ -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")) %>
|
||||
|
|
|
@ -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")) %>
|
||||
|
|
|
@ -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")) %>
|
||||
|
|
70
app/views/simple_fin/new.html.erb
Normal file
70
app/views/simple_fin/new.html.erb
Normal file
|
@ -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? %>
|
||||
<div class="text-center text-gray-500 py-8">
|
||||
<p>No accounts found matching this type from SimpleFIN.</p>
|
||||
<p>Please ensure your SimpleFIN subscription is active.</p>
|
||||
<%= link_to "Try Again", new_simple_fin_connection_path(accountable_type: @accountable_type), class: "mt-4 inline-block text-primary hover:underline" %>
|
||||
</div>
|
||||
<% 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| %>
|
||||
<div class="mb-4">
|
||||
<label class="flex items-start justify-between p-4 hover:bg-surface rounded-lg border border-gray-200 cursor-pointer">
|
||||
<div class="flex items-center gap-3">
|
||||
<%= check_box_tag 'selected_account_ids[]', account["id"], class: "border-gray-300 rounded" -%>
|
||||
<div class="flex-grow">
|
||||
<%# Account Name %>
|
||||
<div class="font-medium text-primary"><%= account["name"] %></div>
|
||||
<%# Account Source %>
|
||||
<% if account.dig("org", "name").present? %>
|
||||
<div class="text-xs text-gray-400 mt-0.5">
|
||||
Source:
|
||||
<% if account.dig("org", "url").present? %>
|
||||
<%= link_to account.dig("org", "name"), account.dig("org", "url"), target: "_blank", rel: "noopener noreferrer", class: "hover:underline" %>
|
||||
<% else %>
|
||||
<%= account.dig("org", "name") %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# SimpleFIN has a tough time determining account types. Let the user fill them out here. %>
|
||||
<div class="mt-2">
|
||||
<% if @accountable_type == "Depository" %>
|
||||
<%= form.select "account[#{account['id']}][subtype]",
|
||||
Depository::SUBTYPES.map { |k, v| [v[:long], k] },
|
||||
{ label: "Subtype", prompt: t("depositories.form.subtype_prompt"), include_blank: t("depositories.form.none") } %>
|
||||
<% elsif @accountable_type == "Investment" %>
|
||||
<%= form.select "account[#{account['id']}][subtype]",
|
||||
Investment::SUBTYPES.map { |k, v| [v[:long], k] },
|
||||
{ label: "Subtype", prompt: t("investments.form.subtype_prompt"), include_blank: t("investments.form.none") } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Current balance %>
|
||||
<% currency_unit = Money::Currency.all_instances.find { |currency| currency.iso_code == account["currency"] }.symbol %>
|
||||
<% balance_color_class = account["balance"].to_f < 0 ? "text-red-500" : "text-green-500" %>
|
||||
<div class="text-sm text-gray-700 font-medium">
|
||||
<span class="<%= balance_color_class %>">
|
||||
<%= number_to_currency(account["balance"], unit: currency_unit || "$") %>
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<%= form.submit t("simple_fin.form.add_accounts"), class: "w-full btn btn-primary",
|
||||
data: {
|
||||
disable_with: "Adding Accounts..."
|
||||
} %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
10
config/initializers/simple_fin.rb
Normal file
10
config/initializers/simple_fin.rb
Normal file
|
@ -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
|
8
config/locales/simple_fin/en.yml
Normal file
8
config/locales/simple_fin/en.yml
Normal file
|
@ -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"
|
|
@ -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?
|
||||
|
|
|
@ -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"
|
||||
|
|
33
db/migrate/20250509134646_setup_simple_fin_integration.rb
Normal file
33
db/migrate/20250509134646_setup_simple_fin_integration.rb
Normal file
|
@ -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
|
37
db/schema.rb
generated
37
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 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"
|
||||
|
|
333
sample.simple.fin.json
Normal file
333
sample.simple.fin.json
Normal file
|
@ -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."
|
||||
]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue