1
0
Fork 0
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:
Cameron Roudebush 2025-05-09 11:29:59 -04:00
parent c26a7dd2dd
commit 37dc1b3c7e
28 changed files with 1032 additions and 21 deletions

View file

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

@ -70,4 +70,6 @@ node_modules
compose.yml
plaid_test_accounts/
plaid_test_accounts/
dump.rdb
dev.docs.md

View 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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View 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

View 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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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

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

View file

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

View file

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

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

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 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
View 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."
]
}