diff --git a/Gemfile b/Gemfile
index 6cf4eebe5..6b56a8a01 100644
--- a/Gemfile
+++ b/Gemfile
@@ -69,6 +69,7 @@ gem "csv"
gem "redcarpet"
gem "stripe"
gem "plaid"
+gem "httparty"
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 3.0"
gem "activerecord-import"
diff --git a/Gemfile.lock b/Gemfile.lock
index 7edacc99f..eaa286281 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -239,6 +239,10 @@ GEM
turbo-rails (>= 1.2)
htmlbeautifier (1.4.3)
htmlentities (4.3.4)
+ httparty (0.23.1)
+ csv
+ mini_mime (>= 1.0.0)
+ multi_xml (>= 0.5.2)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.15)
@@ -335,6 +339,8 @@ GEM
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
+ multi_xml (0.7.2)
+ bigdecimal (~> 3.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
net-http (0.6.0)
@@ -648,6 +654,7 @@ DEPENDENCIES
foreman
hotwire-livereload
hotwire_combobox
+ httparty
i18n-tasks
image_processing (>= 1.2)
importmap-rails
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index b78c54ad9..e394d6ce9 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -5,6 +5,7 @@ class AccountsController < ApplicationController
def index
@manual_accounts = family.accounts.manual.alphabetically
@plaid_items = family.plaid_items.ordered
+ @simplefin_items = family.simplefin_items.ordered
render layout: "settings"
end
diff --git a/app/controllers/properties_controller.rb b/app/controllers/properties_controller.rb
index 7a1db5de3..f1df28d3d 100644
--- a/app/controllers/properties_controller.rb
+++ b/app/controllers/properties_controller.rb
@@ -89,7 +89,7 @@ class PropertiesController < ApplicationController
def property_params
params.require(:account)
- .permit(:name, :subtype, :accountable_type, accountable_attributes: [ :id, :year_built, :area_unit, :area_value ])
+ .permit(:name, :accountable_type, accountable_attributes: [ :id, :subtype, :year_built, :area_unit, :area_value ])
end
def set_property
diff --git a/app/controllers/simplefin_items_controller.rb b/app/controllers/simplefin_items_controller.rb
new file mode 100644
index 000000000..8e626859f
--- /dev/null
+++ b/app/controllers/simplefin_items_controller.rb
@@ -0,0 +1,139 @@
+class SimplefinItemsController < ApplicationController
+ before_action :set_simplefin_item, only: [ :show, :destroy, :sync, :setup_accounts, :complete_account_setup ]
+
+ def index
+ @simplefin_items = Current.family.simplefin_items.active.ordered
+ render layout: "settings"
+ end
+
+ def show
+ end
+
+ def new
+ @simplefin_item = Current.family.simplefin_items.build
+ end
+
+ def create
+ setup_token = simplefin_params[:setup_token]
+
+ return render_error("Please enter a SimpleFin setup token.") if setup_token.blank?
+
+ begin
+ @simplefin_item = Current.family.create_simplefin_item!(
+ setup_token: setup_token,
+ item_name: "SimpleFin Connection"
+ )
+
+ redirect_to simplefin_items_path, notice: "SimpleFin connection added successfully! Your accounts will appear shortly as they sync in the background."
+ rescue ArgumentError, URI::InvalidURIError
+ render_error("Invalid setup token. Please check that you copied the complete token from SimpleFin Bridge.", setup_token)
+ rescue Provider::Simplefin::SimplefinError => e
+ error_message = case e.error_type
+ when :token_compromised
+ "The setup token may be compromised, expired, or already used. Please create a new one."
+ else
+ "Failed to connect: #{e.message}"
+ end
+ render_error(error_message, setup_token)
+ rescue => e
+ Rails.logger.error("SimpleFin connection error: #{e.message}")
+ render_error("An unexpected error occurred. Please try again or contact support.", setup_token)
+ end
+ end
+
+ def destroy
+ @simplefin_item.destroy_later
+ redirect_to simplefin_items_path, notice: "SimpleFin connection will be removed"
+ end
+
+ def sync
+ @simplefin_item.sync_later
+ redirect_to simplefin_item_path(@simplefin_item), notice: "Sync started"
+ end
+
+ def setup_accounts
+ @simplefin_accounts = @simplefin_item.simplefin_accounts.includes(:account).where(accounts: { id: nil })
+ @account_type_options = [
+ [ "Checking or Savings Account", "Depository" ],
+ [ "Credit Card", "CreditCard" ],
+ [ "Investment Account", "Investment" ],
+ [ "Loan or Mortgage", "Loan" ],
+ [ "Other Asset", "OtherAsset" ],
+ [ "Skip - don't add", "Skip" ]
+ ]
+
+ # Subtype options for each account type
+ @subtype_options = {
+ "Depository" => {
+ label: "Account Subtype:",
+ options: Depository::SUBTYPES.map { |k, v| [ v[:long], k ] }
+ },
+ "CreditCard" => {
+ label: "",
+ options: [],
+ message: "Credit cards will be automatically set up as credit card accounts."
+ },
+ "Investment" => {
+ label: "Investment Type:",
+ options: Investment::SUBTYPES.map { |k, v| [ v[:long], k ] }
+ },
+ "Loan" => {
+ label: "Loan Type:",
+ options: Loan::SUBTYPES.map { |k, v| [ v[:long], k ] }
+ },
+ "OtherAsset" => {
+ label: nil,
+ options: [],
+ message: "No additional options needed for Other Assets."
+ }
+ }
+ end
+
+ def complete_account_setup
+ account_types = params[:account_types] || {}
+ account_subtypes = params[:account_subtypes] || {}
+
+ account_types.each do |simplefin_account_id, selected_type|
+ # Skip accounts that the user chose not to add
+ next if selected_type == "Skip"
+
+ simplefin_account = @simplefin_item.simplefin_accounts.find(simplefin_account_id)
+ selected_subtype = account_subtypes[simplefin_account_id]
+
+ # Default subtype for CreditCard since it only has one option
+ selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank?
+
+ # Create account with user-selected type and subtype
+ account = Account.create_from_simplefin_account(
+ simplefin_account,
+ selected_type,
+ selected_subtype
+ )
+ simplefin_account.update!(account: account)
+ end
+
+ # Clear pending status and mark as complete
+ @simplefin_item.update!(pending_account_setup: false)
+
+ # Schedule account syncs for the newly created accounts
+ @simplefin_item.schedule_account_syncs
+
+ redirect_to simplefin_items_path, notice: "SimpleFin accounts have been set up successfully!"
+ end
+
+ private
+
+ def set_simplefin_item
+ @simplefin_item = Current.family.simplefin_items.find(params[:id])
+ end
+
+ def simplefin_params
+ params.require(:simplefin_item).permit(:setup_token)
+ end
+
+ def render_error(message, setup_token = nil)
+ @simplefin_item = Current.family.simplefin_items.build(setup_token: setup_token)
+ @error_message = message
+ render :new, status: :unprocessable_entity
+ end
+end
diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb
index eedb86c33..de809abe4 100644
--- a/app/helpers/accounts_helper.rb
+++ b/app/helpers/accounts_helper.rb
@@ -3,4 +3,14 @@ module AccountsHelper
content = capture(&block)
render "accounts/summary_card", title: title, content: content
end
+
+ def sync_path_for(account)
+ if account.plaid_account_id.present?
+ sync_plaid_item_path(account.plaid_account.plaid_item)
+ elsif account.simplefin_account_id.present?
+ sync_simplefin_item_path(account.simplefin_account.simplefin_item)
+ else
+ sync_account_path(account)
+ end
+ end
end
diff --git a/app/javascript/controllers/account_type_selector_controller.js b/app/javascript/controllers/account_type_selector_controller.js
new file mode 100644
index 000000000..1f794ef0e
--- /dev/null
+++ b/app/javascript/controllers/account_type_selector_controller.js
@@ -0,0 +1,45 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["subtypeContainer"]
+ static values = { accountId: String }
+
+ connect() {
+ // Show initial subtype dropdown based on current selection
+ this.updateSubtype()
+ }
+
+ updateSubtype(event) {
+ const selectElement = this.element.querySelector('select[name^="account_types"]')
+ const selectedType = selectElement ? selectElement.value : ''
+ const container = this.subtypeContainerTarget
+ const accountId = this.accountIdValue
+
+ // Hide all subtype selects
+ const subtypeSelects = container.querySelectorAll('.subtype-select')
+ subtypeSelects.forEach(select => {
+ select.style.display = 'none'
+ // Clear the name attribute so it doesn't get submitted
+ const selectElement = select.querySelector('select')
+ if (selectElement) {
+ selectElement.removeAttribute('name')
+ }
+ })
+
+ // Don't show any subtype select for Skip option
+ if (selectedType === 'Skip') {
+ return
+ }
+
+ // Show the relevant subtype select
+ const relevantSubtype = container.querySelector(`[data-type="${selectedType}"]`)
+ if (relevantSubtype) {
+ relevantSubtype.style.display = 'block'
+ // Re-add the name attribute so it gets submitted
+ const selectElement = relevantSubtype.querySelector('select')
+ if (selectElement) {
+ selectElement.setAttribute('name', `account_subtypes[${accountId}]`)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/models/account.rb b/app/models/account.rb
index 6a21c3e34..30856f7a2 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -5,6 +5,7 @@ class Account < ApplicationRecord
belongs_to :family
belongs_to :import, optional: true
+ belongs_to :simplefin_account, optional: true
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
has_many :entries, dependent: :destroy
@@ -22,11 +23,12 @@ 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, simplefin_account_id: nil) }
has_one_attached :logo
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
+ delegate :subtype, to: :accountable, allow_nil: true
accepts_nested_attributes_for :accountable, update_only: true
@@ -71,6 +73,53 @@ class Account < ApplicationRecord
account.sync_later
account
end
+
+
+ def create_from_simplefin_account(simplefin_account, account_type, subtype = nil)
+ # Get the balance from SimpleFin
+ balance = simplefin_account.current_balance || simplefin_account.available_balance || 0
+
+ # SimpleFin returns negative balances for credit cards (liabilities)
+ # But Maybe expects positive balances for liabilities
+ if account_type == "CreditCard" || account_type == "Loan"
+ balance = balance.abs
+ end
+
+ attributes = {
+ family: simplefin_account.simplefin_item.family,
+ name: simplefin_account.name,
+ balance: balance,
+ currency: simplefin_account.currency,
+ accountable_type: account_type,
+ accountable_attributes: build_simplefin_accountable_attributes(simplefin_account, account_type, subtype),
+ simplefin_account_id: simplefin_account.id
+ }
+
+ create_and_sync(attributes)
+ end
+
+
+ private
+
+ def build_simplefin_accountable_attributes(simplefin_account, account_type, subtype)
+ attributes = {}
+ attributes[:subtype] = subtype if subtype.present?
+
+ # Set account-type-specific attributes from SimpleFin data
+ case account_type
+ when "CreditCard"
+ # For credit cards, available_balance often represents available credit
+ if simplefin_account.available_balance.present? && simplefin_account.available_balance > 0
+ attributes[:available_credit] = simplefin_account.available_balance
+ end
+ when "Loan"
+ # For loans, we might get additional data from the raw_payload
+ # This is where loan-specific information could be extracted if available
+ # Currently we don't have specific loan fields from SimpleFin protocol
+ end
+
+ attributes
+ end
end
def institution_domain
diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb
index 76b41bb10..2a57e71c2 100644
--- a/app/models/account/linkable.rb
+++ b/app/models/account/linkable.rb
@@ -3,11 +3,12 @@ module Account::Linkable
included do
belongs_to :plaid_account, optional: true
+ belongs_to :simplefin_account, optional: true
end
- # A "linked" account gets transaction and balance data from a third party like Plaid
+ # A "linked" account gets transaction and balance data from a third party like Plaid or SimpleFin
def linked?
- plaid_account_id.present?
+ plaid_account_id.present? || simplefin_account_id.present?
end
# An "offline" or "unlinked" account is one where the user tracks values and
diff --git a/app/models/family.rb b/app/models/family.rb
index 5f4fdbf87..4340e785f 100644
--- a/app/models/family.rb
+++ b/app/models/family.rb
@@ -1,5 +1,5 @@
class Family < ApplicationRecord
- include PlaidConnectable, Syncable, AutoTransferMatchable, Subscribeable
+ include PlaidConnectable, SimplefinConnectable, Syncable, AutoTransferMatchable, Subscribeable
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],
diff --git a/app/models/family/simplefin_connectable.rb b/app/models/family/simplefin_connectable.rb
new file mode 100644
index 000000000..2c48eca8e
--- /dev/null
+++ b/app/models/family/simplefin_connectable.rb
@@ -0,0 +1,25 @@
+module Family::SimplefinConnectable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :simplefin_items, dependent: :destroy
+ end
+
+ def can_connect_simplefin?
+ true # SimpleFin doesn't have regional restrictions like Plaid
+ end
+
+ def create_simplefin_item!(setup_token:, item_name: nil)
+ simplefin_provider = Provider::Simplefin.new
+ access_url = simplefin_provider.claim_access_url(setup_token)
+
+ simplefin_item = simplefin_items.create!(
+ name: item_name || "SimpleFin Connection",
+ access_url: access_url
+ )
+
+ simplefin_item.sync_later
+
+ simplefin_item
+ end
+end
diff --git a/app/models/plaid_account/processor.rb b/app/models/plaid_account/processor.rb
index b42bdf3b6..fa898b3b3 100644
--- a/app/models/plaid_account/processor.rb
+++ b/app/models/plaid_account/processor.rb
@@ -34,17 +34,30 @@ class PlaidAccount::Processor
plaid_account_id: plaid_account.id
)
- # Name and subtype are the only attributes a user can override for Plaid accounts
+ # Create or assign the accountable if needed
+ if account.accountable.nil?
+ accountable = map_accountable(plaid_account.plaid_type)
+ account.accountable = accountable
+ end
+
+ # Name and subtype are the attributes a user can override for Plaid accounts
+ # Use enrichable pattern to respect locked attributes
account.enrich_attributes(
{
- name: plaid_account.name,
+ name: plaid_account.name
+ },
+ source: "plaid"
+ )
+
+ # Enrich subtype on the accountable, respecting locks
+ account.accountable.enrich_attributes(
+ {
subtype: map_subtype(plaid_account.plaid_type, plaid_account.plaid_subtype)
},
source: "plaid"
)
account.assign_attributes(
- accountable: map_accountable(plaid_account.plaid_type),
balance: balance_calculator.balance,
currency: plaid_account.currency,
cash_balance: balance_calculator.cash_balance
diff --git a/app/models/provider/simplefin.rb b/app/models/provider/simplefin.rb
new file mode 100644
index 000000000..264f0aa30
--- /dev/null
+++ b/app/models/provider/simplefin.rb
@@ -0,0 +1,87 @@
+class Provider::Simplefin
+ include HTTParty
+
+ headers "User-Agent" => "Sure Finance SimpleFin Client"
+ default_options.merge!(verify: true, ssl_verify_mode: :peer)
+
+ def initialize
+ end
+
+ def claim_access_url(setup_token)
+ # Decode the base64 setup token to get the claim URL
+ claim_url = Base64.decode64(setup_token)
+
+ response = HTTParty.post(claim_url)
+
+ case response.code
+ when 200
+ # The response body contains the access URL with embedded credentials
+ response.body.strip
+ when 403
+ raise SimplefinError.new("Setup token may be compromised, expired, or already used", :token_compromised)
+ else
+ raise SimplefinError.new("Failed to claim access URL: #{response.code} #{response.message}", :claim_failed)
+ end
+ end
+
+ def get_accounts(access_url, start_date: nil, end_date: nil, pending: nil)
+ # Build query parameters
+ query_params = {}
+
+ # SimpleFin expects Unix timestamps for dates
+ if start_date
+ start_timestamp = start_date.to_time.to_i
+ query_params["start-date"] = start_timestamp.to_s
+ end
+
+ if end_date
+ end_timestamp = end_date.to_time.to_i
+ query_params["end-date"] = end_timestamp.to_s
+ end
+
+ query_params["pending"] = pending ? "1" : "0" unless pending.nil?
+
+ accounts_url = "#{access_url}/accounts"
+ accounts_url += "?#{URI.encode_www_form(query_params)}" unless query_params.empty?
+
+
+ # The access URL already contains HTTP Basic Auth credentials
+ response = HTTParty.get(accounts_url)
+
+
+ case response.code
+ when 200
+ JSON.parse(response.body, symbolize_names: true)
+ when 400
+ Rails.logger.error "SimpleFin API: Bad request - #{response.body}"
+ raise SimplefinError.new("Bad request to SimpleFin API: #{response.body}", :bad_request)
+ when 403
+ raise SimplefinError.new("Access URL is no longer valid", :access_forbidden)
+ when 402
+ raise SimplefinError.new("Payment required to access this account", :payment_required)
+ else
+ Rails.logger.error "SimpleFin API: Unexpected response - Code: #{response.code}, Body: #{response.body}"
+ raise SimplefinError.new("Failed to fetch accounts: #{response.code} #{response.message} - #{response.body}", :fetch_failed)
+ end
+ end
+
+ def get_info(base_url)
+ response = HTTParty.get("#{base_url}/info")
+
+ case response.code
+ when 200
+ response.body.strip.split("\n")
+ else
+ raise SimplefinError.new("Failed to get server info: #{response.code} #{response.message}", :info_failed)
+ end
+ end
+
+ class SimplefinError < StandardError
+ attr_reader :error_type
+
+ def initialize(message, error_type = :unknown)
+ super(message)
+ @error_type = error_type
+ end
+ end
+end
diff --git a/app/models/simplefin_account.rb b/app/models/simplefin_account.rb
new file mode 100644
index 000000000..3b2089c68
--- /dev/null
+++ b/app/models/simplefin_account.rb
@@ -0,0 +1,93 @@
+class SimplefinAccount < ApplicationRecord
+ belongs_to :simplefin_item
+
+ has_one :account, dependent: :destroy
+
+ validates :name, :account_type, :currency, presence: true
+ validate :has_balance
+
+ def upsert_simplefin_snapshot!(account_snapshot)
+ # Convert to symbol keys or handle both string and symbol keys
+ snapshot = account_snapshot.with_indifferent_access
+
+ # Map SimpleFin field names to our field names
+ update!(
+ current_balance: parse_balance(snapshot[:balance]),
+ available_balance: parse_balance(snapshot[:"available-balance"]),
+ currency: parse_currency(snapshot[:currency]),
+ account_type: snapshot["type"] || "unknown",
+ account_subtype: snapshot["subtype"],
+ name: snapshot[:name],
+ account_id: snapshot[:id],
+ balance_date: parse_balance_date(snapshot[:"balance-date"]),
+ extra: snapshot[:extra],
+ org_data: snapshot[:org],
+ raw_payload: account_snapshot
+ )
+ end
+
+ def upsert_simplefin_transactions_snapshot!(transactions_snapshot)
+ assign_attributes(
+ raw_transactions_payload: transactions_snapshot
+ )
+
+ save!
+ end
+
+ private
+
+ def parse_balance(balance_value)
+ return nil if balance_value.nil?
+
+ case balance_value
+ when String
+ BigDecimal(balance_value)
+ when Numeric
+ BigDecimal(balance_value.to_s)
+ else
+ nil
+ end
+ rescue ArgumentError
+ nil
+ end
+
+ def parse_currency(currency_value)
+ return "USD" if currency_value.nil?
+
+ # SimpleFin currency can be a 3-letter code or a URL for custom currencies
+ if currency_value.start_with?("http")
+ # For custom currency URLs, we'll just use the last part as currency code
+ # This is a simplification - in production you might want to fetch the currency info
+ begin
+ URI.parse(currency_value).path.split("/").last.upcase
+ rescue URI::InvalidURIError => e
+ Rails.logger.warn("Invalid currency URI for SimpleFin account: #{currency_value}, error: #{e.message}")
+ "USD"
+ end
+ else
+ currency_value.upcase
+ end
+ end
+
+ def parse_balance_date(balance_date_value)
+ return nil if balance_date_value.nil?
+
+ case balance_date_value
+ when String
+ Time.parse(balance_date_value)
+ when Numeric
+ Time.at(balance_date_value)
+ when Time, DateTime
+ balance_date_value
+ else
+ nil
+ end
+ rescue ArgumentError, TypeError
+ Rails.logger.warn("Invalid balance date for SimpleFin account: #{balance_date_value}")
+ nil
+ end
+ def has_balance
+ return if current_balance.present? || available_balance.present?
+ errors.add(:base, "SimpleFin account must have either current or available balance")
+ end
+end
diff --git a/app/models/simplefin_account/processor.rb b/app/models/simplefin_account/processor.rb
new file mode 100644
index 000000000..50be1a6ff
--- /dev/null
+++ b/app/models/simplefin_account/processor.rb
@@ -0,0 +1,109 @@
+class SimplefinAccount::Processor
+ attr_reader :simplefin_account
+
+ def initialize(simplefin_account)
+ @simplefin_account = simplefin_account
+ end
+
+ def process
+ ensure_account_exists
+ process_transactions
+ end
+
+ private
+
+ def ensure_account_exists
+ return if simplefin_account.account.present?
+
+ # This should not happen in normal flow since accounts are created manually
+ # during setup, but keeping as safety check
+ Rails.logger.error("SimpleFin account #{simplefin_account.id} has no associated Account - this should not happen after manual setup")
+ end
+
+ def process_transactions
+ return unless simplefin_account.raw_transactions_payload.present?
+
+ account = simplefin_account.account
+ transactions_data = simplefin_account.raw_transactions_payload
+
+ transactions_data.each do |transaction_data|
+ process_transaction(account, transaction_data)
+ end
+ end
+
+ def process_transaction(account, transaction_data)
+ # Handle both string and symbol keys
+ data = transaction_data.with_indifferent_access
+
+
+ # Convert SimpleFin transaction to internal Transaction format
+ amount = parse_amount(data[:amount], account.currency)
+ posted_date = parse_date(data[:posted])
+
+ # Use plaid_id field for external ID (works for both Plaid and SimpleFin)
+ external_id = "simplefin_#{data[:id]}"
+
+ # Check if entry already exists
+ existing_entry = Entry.find_by(plaid_id: external_id)
+
+ unless existing_entry
+ # Create the transaction (entryable)
+ transaction = Transaction.new(
+ external_id: external_id
+ )
+
+ # Create the entry with the transaction
+ Entry.create!(
+ account: account,
+ name: data[:description] || "Unknown transaction",
+ amount: amount,
+ date: posted_date,
+ currency: account.currency,
+ entryable: transaction,
+ plaid_id: external_id
+ )
+ end
+ rescue => e
+ Rails.logger.error("Failed to process SimpleFin transaction #{data[:id]}: #{e.message}")
+ # Don't fail the entire sync for one bad transaction
+ end
+
+ def parse_amount(amount_value, currency)
+ parsed_amount = case amount_value
+ when String
+ BigDecimal(amount_value)
+ when Numeric
+ BigDecimal(amount_value.to_s)
+ else
+ BigDecimal("0")
+ end
+
+ # SimpleFin uses banking convention (expenses negative, income positive)
+ # Maybe expects opposite convention (expenses positive, income negative)
+ # So we negate the amount to convert from SimpleFin to Maybe format
+ -parsed_amount
+ rescue ArgumentError => e
+ Rails.logger.error "Failed to parse SimpleFin transaction amount: #{amount_value.inspect} - #{e.message}"
+ BigDecimal("0")
+ end
+
+ def parse_date(date_value)
+ case date_value
+ when String
+ Date.parse(date_value)
+ when Integer, Float
+ # Unix timestamp
+ Time.at(date_value).to_date
+ when Time, DateTime
+ date_value.to_date
+ when Date
+ date_value
+ else
+ Rails.logger.error("SimpleFin transaction has invalid date value: #{date_value.inspect}")
+ raise ArgumentError, "Invalid date format: #{date_value.inspect}"
+ end
+ rescue ArgumentError, TypeError => e
+ Rails.logger.error("Failed to parse SimpleFin transaction date '#{date_value}': #{e.message}")
+ raise ArgumentError, "Unable to parse transaction date: #{date_value.inspect}"
+ end
+end
diff --git a/app/models/simplefin_item.rb b/app/models/simplefin_item.rb
new file mode 100644
index 000000000..c896438cc
--- /dev/null
+++ b/app/models/simplefin_item.rb
@@ -0,0 +1,76 @@
+class SimplefinItem < ApplicationRecord
+ include Syncable, Provided
+
+ enum :status, { good: "good", requires_update: "requires_update" }, default: :good
+
+ # Virtual attribute for the setup token form field
+ attr_accessor :setup_token
+
+ if Rails.application.credentials.active_record_encryption.present?
+ encrypts :access_url, deterministic: true
+ end
+
+ validates :name, :access_url, presence: true
+
+ before_destroy :remove_simplefin_item
+
+ belongs_to :family
+ has_one_attached :logo
+
+ has_many :simplefin_accounts, dependent: :destroy
+ has_many :accounts, through: :simplefin_accounts
+
+ scope :active, -> { where(scheduled_for_deletion: false) }
+ scope :ordered, -> { order(created_at: :desc) }
+ scope :needs_update, -> { where(status: :requires_update) }
+
+ def destroy_later
+ update!(scheduled_for_deletion: true)
+ DestroyJob.perform_later(self)
+ end
+
+ def import_latest_simplefin_data
+ SimplefinItem::Importer.new(self, simplefin_provider: simplefin_provider).import
+ end
+
+ def process_accounts
+ simplefin_accounts.each do |simplefin_account|
+ SimplefinAccount::Processor.new(simplefin_account).process
+ end
+ end
+
+ def schedule_account_syncs(parent_sync: nil, window_start_date: nil, window_end_date: nil)
+ accounts.each do |account|
+ account.sync_later(
+ parent_sync: parent_sync,
+ window_start_date: window_start_date,
+ window_end_date: window_end_date
+ )
+ end
+ end
+
+ def upsert_simplefin_snapshot!(accounts_snapshot)
+ assign_attributes(
+ raw_payload: accounts_snapshot,
+ )
+
+ save!
+ end
+
+ def upsert_simplefin_institution_snapshot!(institution_snapshot)
+ assign_attributes(
+ institution_id: institution_snapshot[:id],
+ institution_name: institution_snapshot[:name],
+ institution_url: institution_snapshot[:url],
+ raw_institution_payload: institution_snapshot
+ )
+
+ save!
+ end
+
+ private
+ def remove_simplefin_item
+ # SimpleFin doesn't require server-side cleanup like Plaid
+ # The access URL just becomes inactive
+ end
+end
diff --git a/app/models/simplefin_item/importer.rb b/app/models/simplefin_item/importer.rb
new file mode 100644
index 000000000..901f1195e
--- /dev/null
+++ b/app/models/simplefin_item/importer.rb
@@ -0,0 +1,107 @@
+class SimplefinItem::Importer
+ attr_reader :simplefin_item, :simplefin_provider
+
+ def initialize(simplefin_item, simplefin_provider:)
+ @simplefin_item = simplefin_item
+ @simplefin_provider = simplefin_provider
+ end
+
+ def import
+ # Determine start date based on sync history
+ start_date = determine_sync_start_date
+
+ if start_date
+ else
+ end
+
+ accounts_data = simplefin_provider.get_accounts(
+ simplefin_item.access_url,
+ start_date: start_date
+ )
+
+ # Handle errors if present
+ if accounts_data[:errors] && accounts_data[:errors].any?
+ handle_errors(accounts_data[:errors])
+ return
+ end
+
+ # Store raw payload
+ simplefin_item.upsert_simplefin_snapshot!(accounts_data)
+
+ # Import accounts - accounts_data[:accounts] is an array
+ accounts_data[:accounts]&.each do |account_data|
+ import_account(account_data)
+ end
+ end
+
+ private
+
+ def determine_sync_start_date
+ # For the first sync, get all available data by using a very wide date range
+ # SimpleFin requires a start_date parameter - without it, only returns recent transactions
+ unless simplefin_item.last_synced_at
+ return 100.years.ago # Set to 100 years for first sync to get everything just to be sure
+ end
+
+ # For subsequent syncs, fetch from last sync date with a buffer
+ # Use 7 days buffer to ensure we don't miss any late-posting transactions
+ simplefin_item.last_synced_at - 7.days
+ end
+
+ def import_account(account_data)
+ # Import organization data from the account if present and not already imported
+ if account_data[:org] && simplefin_item.institution_id.blank?
+ import_organization(account_data[:org])
+ end
+
+ simplefin_account = simplefin_item.simplefin_accounts.find_or_initialize_by(
+ account_id: account_data[:id]
+ )
+
+ # Store transactions temporarily
+ transactions = account_data[:transactions]
+
+ # Update account snapshot first (without transactions)
+ simplefin_account.upsert_simplefin_snapshot!(account_data)
+
+ # Then save transactions separately (so they don't get overwritten)
+ if transactions && transactions.any?
+ simplefin_account.update!(raw_transactions_payload: transactions)
+ else
+ end
+ end
+
+ def import_organization(org_data)
+ # Create normalized institution data for compatibility
+ normalized_data = {
+ id: org_data[:domain] || org_data[:"sfin-url"],
+ name: org_data[:name] || extract_domain_name(org_data[:domain]),
+ url: org_data[:domain] || org_data[:"sfin-url"],
+ # Store the complete raw organization data
+ raw_org_data: org_data
+ }
+
+ simplefin_item.upsert_simplefin_institution_snapshot!(normalized_data)
+ end
+
+ def extract_domain_name(domain)
+ return "Unknown Institution" if domain.blank?
+
+ # Extract a readable name from domain like "mybank.com" -> "Mybank"
+ domain.split(".").first.capitalize
+ end
+
+ def handle_errors(errors)
+ error_messages = errors.map { |error| error[:description] || error[:message] }.join(", ")
+
+ # Mark item as requiring update for certain error types
+ if errors.any? { |error| error[:code] == "auth_failure" || error[:code] == "token_expired" }
+ simplefin_item.update!(status: :requires_update)
+ end
+
+ raise Provider::Simplefin::SimplefinError.new(
+ "SimpleFin API errors: #{error_messages}",
+ :api_error
+ )
+ end
+end
diff --git a/app/models/simplefin_item/provided.rb b/app/models/simplefin_item/provided.rb
new file mode 100644
index 000000000..b79c13b97
--- /dev/null
+++ b/app/models/simplefin_item/provided.rb
@@ -0,0 +1,7 @@
+module SimplefinItem::Provided
+ extend ActiveSupport::Concern
+
+ def simplefin_provider
+ @simplefin_provider ||= Provider::Simplefin.new
+ end
+end
diff --git a/app/models/simplefin_item/sync_complete_event.rb b/app/models/simplefin_item/sync_complete_event.rb
new file mode 100644
index 000000000..315e89946
--- /dev/null
+++ b/app/models/simplefin_item/sync_complete_event.rb
@@ -0,0 +1,25 @@
+class SimplefinItem::SyncCompleteEvent
+ attr_reader :simplefin_item
+
+ def initialize(simplefin_item)
+ @simplefin_item = simplefin_item
+ end
+
+ def broadcast
+ # Update UI with latest account data
+ simplefin_item.accounts.each do |account|
+ account.broadcast_sync_complete
+ end
+
+ # Update the SimpleFin item view
+ simplefin_item.broadcast_replace_to(
+ simplefin_item.family,
+ target: "simplefin_item_#{simplefin_item.id}",
+ partial: "simplefin_items/simplefin_item",
+ locals: { simplefin_item: simplefin_item }
+ )
+
+ # Let family handle sync notifications
+ simplefin_item.family.broadcast_sync_complete
+ end
+end
diff --git a/app/models/simplefin_item/syncer.rb b/app/models/simplefin_item/syncer.rb
new file mode 100644
index 000000000..cf20a9912
--- /dev/null
+++ b/app/models/simplefin_item/syncer.rb
@@ -0,0 +1,34 @@
+class SimplefinItem::Syncer
+ attr_reader :simplefin_item
+
+ def initialize(simplefin_item)
+ @simplefin_item = simplefin_item
+ end
+
+ def perform_sync(sync)
+ # Loads item metadata, accounts, transactions from SimpleFin API
+ simplefin_item.import_latest_simplefin_data
+
+ # Check if we have new SimpleFin accounts that need setup
+ unlinked_accounts = simplefin_item.simplefin_accounts.includes(:account).where(accounts: { id: nil })
+ if unlinked_accounts.any?
+ # Mark as pending account setup so user can choose account types
+ simplefin_item.update!(pending_account_setup: true)
+ return
+ end
+
+ # Processes the raw SimpleFin data and updates internal domain objects
+ simplefin_item.process_accounts
+
+ # All data is synced, so we can now run an account sync to calculate historical balances and more
+ simplefin_item.schedule_account_syncs(
+ parent_sync: sync,
+ window_start_date: sync.window_start_date,
+ window_end_date: sync.window_end_date
+ )
+ end
+
+ def perform_post_sync
+ # no-op
+ end
+end
diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb
index b1e1be995..b4b88c1ee 100644
--- a/app/views/accounts/_account.html.erb
+++ b/app/views/accounts/_account.html.erb
@@ -16,7 +16,12 @@
<% else %>
- <%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
+
+ <%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
+ <% if account.simplefin_account&.org_data&.dig('name') %>
+ • <%= account.simplefin_account.org_data["name"] %>
+ <% end %>
+
<% if account.long_subtype_label %>
<%= account.long_subtype_label %>
<% end %>
diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb
index 3557ceb8a..c77557bd9 100644
--- a/app/views/accounts/index.html.erb
+++ b/app/views/accounts/index.html.erb
@@ -21,7 +21,7 @@
-<% if @manual_accounts.empty? && @plaid_items.empty? %>
+<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? %>
<%= render "empty" %>
<% else %>
@@ -29,6 +29,10 @@
<%= render @plaid_items.sort_by(&:created_at) %>
<% end %>
+ <% if @simplefin_items.any? %>
+ <%= render @simplefin_items.sort_by(&:created_at) %>
+ <% end %>
+
<% if @manual_accounts.any? %>
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %>
<% end %>
diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb
index 03beaa92f..5f26c1514 100644
--- a/app/views/accounts/new/_method_selector.html.erb
+++ b/app/views/accounts/new/_method_selector.html.erb
@@ -12,7 +12,7 @@
<% if show_us_link %>
<%# Default US-only Link %>
<%= link_to new_plaid_item_path(region: "us", accountable_type: accountable_type),
- 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",
+ class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-primary px-2 hover:bg-surface rounded-lg p-2",
data: { turbo_frame: "modal" } do %>
<%= icon("link-2") %>
@@ -24,7 +24,7 @@
<%# EU Link %>
<% if show_eu_link %>
<%= link_to new_plaid_item_path(region: "eu", accountable_type: accountable_type),
- 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",
+ class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-primary px-2 hover:bg-surface rounded-lg p-2",
data: { turbo_frame: "modal" } do %>
<%= icon("link-2") %>
@@ -32,5 +32,6 @@
<%= t("accounts.new.method_selector.connected_entry_eu") %>
<% end %>
<% end %>
+
<% end %>
diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb
index 421f188d4..6d151059a 100644
--- a/app/views/accounts/show/_header.html.erb
+++ b/app/views/accounts/show/_header.html.erb
@@ -32,7 +32,7 @@
"refresh-cw",
as_button: true,
size: "sm",
- href: account.linked? ? sync_plaid_item_path(account.plaid_account.plaid_item) : sync_account_path(account),
+ href: sync_path_for(account),
disabled: account.syncing?,
frame: :_top
) %>
diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb
index bd4887744..7fb1b18d8 100644
--- a/app/views/settings/_settings_nav.html.erb
+++ b/app/views/settings/_settings_nav.html.erb
@@ -10,6 +10,7 @@ nav_sections = [
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
{ label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? },
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },
+ { label: "SimpleFin", path: simplefin_items_path, icon: "building-2" },
{ label: t(".imports_label"), path: imports_path, icon: "download" }
]
},
diff --git a/app/views/simplefin_items/_simplefin_item.html.erb b/app/views/simplefin_items/_simplefin_item.html.erb
new file mode 100644
index 000000000..43db5e57e
--- /dev/null
+++ b/app/views/simplefin_items/_simplefin_item.html.erb
@@ -0,0 +1,104 @@
+<%# locals: (simplefin_item:) %>
+
+<%= tag.div id: dom_id(simplefin_item) do %>
+
+
+
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
+
+ <% if simplefin_item.logo.attached? %>
+
+ <%= image_tag simplefin_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %>
+
+ <% else %>
+ <%= render DS::FilledIcon.new(
+ variant: :container,
+ text: simplefin_item.name.first.upcase,
+ size: "md"
+ ) %>
+ <% end %>
+
+
+
+
+ <%= tag.p simplefin_item.name, class: "font-medium text-primary" %>
+ <% if simplefin_item.institution_name.present? %>
+
<%= simplefin_item.institution_name %>
+ <% end %>
+
+ <% if simplefin_item.scheduled_for_deletion? %>
+
(deletion in progress...)
+ <% end %>
+
+ <% if simplefin_item.syncing? %>
+
+ <%= icon "loader", size: "sm", class: "animate-pulse" %>
+ <%= tag.span "Syncing..." %>
+
+ <% elsif simplefin_item.requires_update? %>
+
+ <%= icon "alert-triangle", size: "sm", color: "warning" %>
+ <%= tag.span "Requires Update" %>
+
+ <% elsif simplefin_item.sync_error.present? %>
+
+ <%= icon "alert-circle", size: "sm", color: "destructive" %>
+ <%= tag.span "Error", class: "text-destructive" %>
+
+ <% else %>
+
+ <%= simplefin_item.last_synced_at ? "Last synced #{time_ago_in_words(simplefin_item.last_synced_at)} ago" : "Never synced" %>
+
+ <% end %>
+
+
+
+
+ <% if Rails.env.development? %>
+ <%= icon(
+ "refresh-cw",
+ as_button: true,
+ href: sync_simplefin_item_path(simplefin_item)
+ ) %>
+ <% end %>
+
+ <%= render DS::Menu.new do |menu| %>
+ <% menu.with_item(
+ variant: "button",
+ text: "Delete",
+ icon: "trash-2",
+ href: simplefin_item_path(simplefin_item),
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion(simplefin_item.name, high_severity: true)
+ ) %>
+ <% end %>
+
+
+
+ <% unless simplefin_item.scheduled_for_deletion? %>
+
+ <% if simplefin_item.accounts.any? %>
+ <%= render "accounts/index/account_groups", accounts: simplefin_item.accounts %>
+ <% end %>
+
+ <% if simplefin_item.pending_account_setup? %>
+
+
New accounts ready to set up
+
Choose account types for your newly imported SimpleFin accounts.
+ <%= render DS::Link.new(
+ text: "Set Up New Accounts",
+ icon: "settings",
+ variant: "primary",
+ href: setup_accounts_simplefin_item_path(simplefin_item)
+ ) %>
+
+ <% elsif simplefin_item.accounts.empty? %>
+
+
No accounts found
+
This connection doesn't have any synchronized accounts yet.
+
+ <% end %>
+
+ <% end %>
+
+<% end %>
diff --git a/app/views/simplefin_items/_subtype_select.html.erb b/app/views/simplefin_items/_subtype_select.html.erb
new file mode 100644
index 000000000..f8c5378af
--- /dev/null
+++ b/app/views/simplefin_items/_subtype_select.html.erb
@@ -0,0 +1,14 @@
+
+ <% if subtype_config[:options].present? %>
+ <%= label_tag "account_subtypes[#{simplefin_account.id}]", subtype_config[:label],
+ class: "block text-sm font-medium text-primary mb-2" %>
+ <% selected_value = account_type == "Depository" ?
+ (simplefin_account.name.downcase.include?("checking") ? "checking" :
+ simplefin_account.name.downcase.include?("savings") ? "savings" : "") : "" %>
+ <%= select_tag "account_subtypes[#{simplefin_account.id}]",
+ options_for_select([["Select #{account_type == 'Depository' ? 'subtype' : 'type'}", ""]] + subtype_config[:options], selected_value),
+ { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full" } %>
+ <% else %>
+
<%= subtype_config[:message] %>
+ <% end %>
+
diff --git a/app/views/simplefin_items/index.html.erb b/app/views/simplefin_items/index.html.erb
new file mode 100644
index 000000000..1b53079cd
--- /dev/null
+++ b/app/views/simplefin_items/index.html.erb
@@ -0,0 +1,42 @@
+<% content_for :title, "SimpleFin Connections" %>
+
+
+
+
+
SimpleFin Connections
+
Manage your SimpleFin bank account connections
+
+
+ <%= render DS::Link.new(
+ text: "Add Connection",
+ icon: "plus",
+ variant: "primary",
+ href: new_simplefin_item_path
+ ) %>
+
+
+ <% if @simplefin_items.any? %>
+
+ <% @simplefin_items.each do |simplefin_item| %>
+ <%= render "simplefin_item", simplefin_item: simplefin_item %>
+ <% end %>
+
+ <% else %>
+
+
+ <%= render DS::FilledIcon.new(
+ variant: :container,
+ icon: "building-2",
+ ) %>
+
+
No SimpleFin connections
+
Connect your bank accounts through SimpleFin to automatically sync transactions.
+ <%= render DS::Link.new(
+ text: "Add your first connection",
+ variant: "primary",
+ href: new_simplefin_item_path
+ ) %>
+
+
+ <% end %>
+
diff --git a/app/views/simplefin_items/new.html.erb b/app/views/simplefin_items/new.html.erb
new file mode 100644
index 000000000..8d1f72d7d
--- /dev/null
+++ b/app/views/simplefin_items/new.html.erb
@@ -0,0 +1,46 @@
+<% content_for :title, "Add SimpleFin Connection" %>
+
+<%= render DS::Dialog.new do |dialog| %>
+ <% dialog.with_header(title: "Add SimpleFin Connection") %>
+ <% dialog.with_body do %>
+ <% if @error_message.present? %>
+ <%= render DS::Alert.new(message: @error_message, variant: :error) %>
+ <% end %>
+ <%= styled_form_with model: @simplefin_item, local: true, data: { turbo: false }, class: "flex flex-col gap-4 justify-between grow text-primary" do |form| %>
+
+ <%= form.text_area :setup_token,
+ label: "SimpleFin Setup Token",
+ placeholder: "Paste your SimpleFin setup token here...",
+ rows: 4,
+ required: true %>
+
+
+ Get your setup token from
+ <%= link_to "SimpleFin Bridge", "https://bridge.simplefin.org/simplefin/create",
+ target: "_blank",
+ class: "text-link underline" %>
+
+
+
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
+
+
How to get your setup token:
+
+ - Visit <%= link_to "SimpleFin Bridge", "https://bridge.simplefin.org/simplefin/create", target: "_blank", class: "text-link underline" %>
+ - Connect your bank account using your online banking credentials
+ - Copy the SimpleFin setup token that appears (it will be a long Base64-encoded string)
+ - Paste it above and click "Add Connection"
+
+
+ Note: Setup tokens can only be used once. If the connection fails, you'll need to create a new token.
+
+
+
+
+
+
+ <%= form.submit "Add Connection" %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/simplefin_items/setup_accounts.html.erb b/app/views/simplefin_items/setup_accounts.html.erb
new file mode 100644
index 000000000..8979ae584
--- /dev/null
+++ b/app/views/simplefin_items/setup_accounts.html.erb
@@ -0,0 +1,95 @@
+<% content_for :title, "Set Up SimpleFin Accounts" %>
+
+<%= render DS::Dialog.new do |dialog| %>
+ <% dialog.with_header(title: "Set Up Your SimpleFin Accounts") do %>
+
+ <%= icon "building-2", class: "text-primary" %>
+ Choose the correct account types for your imported accounts
+
+ <% end %>
+
+ <% dialog.with_body do %>
+ <%= form_with url: complete_account_setup_simplefin_item_path(@simplefin_item),
+ method: :post,
+ local: true,
+ data: { turbo: false },
+ class: "space-y-6" do |form| %>
+
+
+
+
+ <%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
+
+
+ Choose the correct account type for each SimpleFin account:
+
+
+ - Checking or Savings - Regular bank accounts
+ - Credit Card - Credit card accounts
+ - Investment - Brokerage, 401(k), IRA accounts
+ - Loan or Mortgage - Debt accounts
+ - Other Asset - Everything else
+ - Skip - don't add - Don't import this account
+
+
+
+
+
+ <% @simplefin_accounts.each do |simplefin_account| %>
+
+
+
+
+ <%= simplefin_account.name %>
+ <% if simplefin_account.org_data.present? && simplefin_account.org_data['name'].present? %>
+ • <%= simplefin_account.org_data["name"] %>
+ <% elsif @simplefin_item.institution_name.present? %>
+ • <%= @simplefin_item.institution_name %>
+ <% end %>
+
+
+ Balance: <%= number_to_currency(simplefin_account.current_balance || 0, unit: simplefin_account.currency) %>
+
+
+
+
+
+
+ <%= label_tag "account_types[#{simplefin_account.id}]", "Account Type:",
+ class: "block text-sm font-medium text-primary mb-2" %>
+ <%= select_tag "account_types[#{simplefin_account.id}]",
+ options_for_select(@account_type_options),
+ { class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full",
+ data: {
+ action: "change->account-type-selector#updateSubtype"
+ } } %>
+
+
+
+
+ <% @subtype_options.each do |account_type, subtype_config| %>
+ <%= render "subtype_select", account_type: account_type, subtype_config: subtype_config, simplefin_account: simplefin_account %>
+ <% end %>
+
+
+
+ <% end %>
+
+
+
+ <%= render DS::Button.new(
+ text: "Create Accounts",
+ variant: "primary",
+ icon: "plus",
+ type: "submit",
+ class: "flex-1"
+ ) %>
+ <%= render DS::Link.new(
+ text: "Cancel",
+ variant: "secondary",
+ href: simplefin_items_path
+ ) %>
+
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/simplefin_items/show.html.erb b/app/views/simplefin_items/show.html.erb
new file mode 100644
index 000000000..eb28bd687
--- /dev/null
+++ b/app/views/simplefin_items/show.html.erb
@@ -0,0 +1,105 @@
+<% content_for :title, @simplefin_item.name %>
+
+
+ <%= link_to simplefin_items_path, class: "text-secondary hover:text-primary" do %>
+ ← Back to SimpleFin Connections
+ <% end %>
+
<%= @simplefin_item.name %>
+
+ <%= button_to sync_simplefin_item_path(@simplefin_item), method: :post, class: "inline-flex items-center gap-2 px-4 py-2 bg-surface border border-primary rounded-lg text-primary font-medium hover:bg-surface-hover focus:ring-2 focus:ring-primary focus:ring-offset-2" do %>
+ <%= icon "refresh-cw", size: "sm" %>
+ Sync
+ <% end %>
+ <%= button_to simplefin_item_path(@simplefin_item), method: :delete, data: { confirm: "Are you sure?" }, class: "inline-flex items-center gap-2 px-4 py-2 bg-destructive border border-destructive rounded-lg text-white font-medium hover:bg-destructive-hover focus:ring-2 focus:ring-destructive focus:ring-offset-2" do %>
+ <%= icon "trash", size: "sm" %>
+ Delete
+ <% end %>
+
+
+
+
+ <% if @simplefin_item.syncing? %>
+
+
+ <%= icon "loader-2", class: "w-5 h-5 text-primary animate-spin mr-2" %>
+
Syncing accounts...
+
+
+ <% end %>
+
+ <% if @simplefin_item.accounts.any? %>
+ <%= render "accounts/index/account_groups", accounts: @simplefin_item.accounts %>
+ <% elsif @simplefin_item.simplefin_accounts.any? %>
+
+
+
SimpleFin Accounts
+
·
+
<%= @simplefin_item.simplefin_accounts.count %>
+
+
+ <% @simplefin_item.simplefin_accounts.each_with_index do |simplefin_account, index| %>
+
+
+ <%= render DS::FilledIcon.new(
+ variant: :container,
+ text: simplefin_account.name.first.upcase,
+ size: "md"
+ ) %>
+
+
+ <%= simplefin_account.name %>
+ <% if simplefin_account.org_data.present? && simplefin_account.org_data['name'].present? %>
+ • <%= simplefin_account.org_data["name"] %>
+ <% elsif @simplefin_item.institution_name.present? %>
+ • <%= @simplefin_item.institution_name %>
+ <% end %>
+
+
+ <%= simplefin_account.account_type&.humanize || "Unknown Type" %>
+
+
+
+
+
+ <%= number_to_currency(simplefin_account.current_balance || 0) %>
+
+ <% if simplefin_account.account %>
+ <%= render DS::Link.new(
+ text: "View Account",
+ href: account_path(simplefin_account.account),
+ variant: :outline
+ ) %>
+ <% else %>
+ <%= render DS::Link.new(
+ text: "Set Up Account",
+ href: setup_accounts_simplefin_item_path(@simplefin_item),
+ variant: :primary,
+ icon: "settings"
+ ) %>
+ <% end %>
+
+
+ <% unless index == @simplefin_item.simplefin_accounts.count - 1 %>
+ <%= render "shared/ruler" %>
+ <% end %>
+ <% end %>
+
+
+ <% else %>
+
+
+ <%= render DS::FilledIcon.new(
+ variant: :container,
+ icon: "building-2",
+ ) %>
+
+
No accounts found
+
Try syncing again to import your accounts.
+ <%= button_to sync_simplefin_item_path(@simplefin_item), method: :post, class: "inline-flex items-center gap-2 px-4 py-2 bg-primary border border-primary rounded-lg text-white font-medium hover:bg-primary-hover focus:ring-2 focus:ring-primary focus:ring-offset-2" do %>
+ <%= icon "refresh-cw", size: "sm" %>
+ Sync Now
+ <% end %>
+
+
+ <% end %>
+
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb
index 9554fabe6..770f1482b 100644
--- a/app/views/transactions/show.html.erb
+++ b/app/views/transactions/show.html.erb
@@ -84,8 +84,7 @@
label: t(".tags_label"),
container_class: "h-40"
},
- { "data-controller": "multi-select", "data-auto-submit-form-target": "auto" }
- %>
+ { "data-controller": "multi-select", "data-auto-submit-form-target": "auto" } %>
<% end %>
<% end %>
diff --git a/config/routes.rb b/config/routes.rb
index d6c2bc7ac..9a5a66d53 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -251,6 +251,14 @@ Rails.application.routes.draw do
end
end
+ resources :simplefin_items, only: %i[index new create show destroy] do
+ member do
+ post :sync
+ get :setup_accounts
+ post :complete_account_setup
+ end
+ end
+
namespace :webhooks do
post "plaid"
post "plaid_eu"
diff --git a/db/migrate/20250807143728_create_simplefin_items.rb b/db/migrate/20250807143728_create_simplefin_items.rb
new file mode 100644
index 000000000..521e625c0
--- /dev/null
+++ b/db/migrate/20250807143728_create_simplefin_items.rb
@@ -0,0 +1,20 @@
+class CreateSimplefinItems < ActiveRecord::Migration[7.2]
+ def change
+ create_table :simplefin_items, id: :uuid do |t|
+ t.references :family, null: false, foreign_key: true, type: :uuid
+ t.text :access_url
+ t.string :name
+ t.string :institution_id
+ t.string :institution_name
+ t.string :institution_url
+ t.string :status, default: "good"
+ t.boolean :scheduled_for_deletion, default: false
+
+ t.index :status
+ t.jsonb :raw_payload
+ t.jsonb :raw_institution_payload
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20250807143819_create_simplefin_accounts.rb b/db/migrate/20250807143819_create_simplefin_accounts.rb
new file mode 100644
index 000000000..85ab9feb7
--- /dev/null
+++ b/db/migrate/20250807143819_create_simplefin_accounts.rb
@@ -0,0 +1,20 @@
+class CreateSimplefinAccounts < ActiveRecord::Migration[7.2]
+ def change
+ create_table :simplefin_accounts, id: :uuid do |t|
+ t.references :simplefin_item, null: false, foreign_key: true, type: :uuid
+ t.string :name
+ t.string :account_id
+ t.string :currency
+ t.decimal :current_balance, precision: 19, scale: 4
+ t.decimal :available_balance, precision: 19, scale: 4
+
+ t.index :account_id
+ t.string :account_type
+ t.string :account_subtype
+ t.jsonb :raw_payload
+ t.jsonb :raw_transactions_payload
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20250807144230_add_simplefin_account_id_to_accounts.rb b/db/migrate/20250807144230_add_simplefin_account_id_to_accounts.rb
new file mode 100644
index 000000000..0e5578920
--- /dev/null
+++ b/db/migrate/20250807144230_add_simplefin_account_id_to_accounts.rb
@@ -0,0 +1,5 @@
+class AddSimplefinAccountIdToAccounts < ActiveRecord::Migration[7.2]
+ def change
+ add_reference :accounts, :simplefin_account, null: true, foreign_key: true, type: :uuid
+ end
+end
diff --git a/db/migrate/20250807144857_add_external_id_to_transactions.rb b/db/migrate/20250807144857_add_external_id_to_transactions.rb
new file mode 100644
index 000000000..a3e80eb34
--- /dev/null
+++ b/db/migrate/20250807144857_add_external_id_to_transactions.rb
@@ -0,0 +1,6 @@
+class AddExternalIdToTransactions < ActiveRecord::Migration[7.2]
+ def change
+ add_column :transactions, :external_id, :string
+ add_index :transactions, :external_id
+ end
+end
diff --git a/db/migrate/20250807163541_add_pending_account_setup_to_simplefin_items.rb b/db/migrate/20250807163541_add_pending_account_setup_to_simplefin_items.rb
new file mode 100644
index 000000000..034599497
--- /dev/null
+++ b/db/migrate/20250807163541_add_pending_account_setup_to_simplefin_items.rb
@@ -0,0 +1,5 @@
+class AddPendingAccountSetupToSimplefinItems < ActiveRecord::Migration[7.2]
+ def change
+ add_column :simplefin_items, :pending_account_setup, :boolean, default: false, null: false
+ end
+end
diff --git a/db/migrate/20250807170943_add_subtype_to_accountables.rb b/db/migrate/20250807170943_add_subtype_to_accountables.rb
new file mode 100644
index 000000000..854d5a79f
--- /dev/null
+++ b/db/migrate/20250807170943_add_subtype_to_accountables.rb
@@ -0,0 +1,13 @@
+class AddSubtypeToAccountables < ActiveRecord::Migration[7.2]
+ def change
+ add_column :depositories, :subtype, :string
+ add_column :investments, :subtype, :string
+ add_column :loans, :subtype, :string
+ add_column :credit_cards, :subtype, :string
+ add_column :other_assets, :subtype, :string
+ add_column :other_liabilities, :subtype, :string
+ add_column :properties, :subtype, :string
+ add_column :vehicles, :subtype, :string
+ add_column :cryptos, :subtype, :string
+ end
+end
diff --git a/db/migrate/20250808141424_add_balance_date_to_simplefin_accounts.rb b/db/migrate/20250808141424_add_balance_date_to_simplefin_accounts.rb
new file mode 100644
index 000000000..014eaa95a
--- /dev/null
+++ b/db/migrate/20250808141424_add_balance_date_to_simplefin_accounts.rb
@@ -0,0 +1,5 @@
+class AddBalanceDateToSimplefinAccounts < ActiveRecord::Migration[7.2]
+ def change
+ add_column :simplefin_accounts, :balance_date, :datetime
+ end
+end
diff --git a/db/migrate/20250808143007_add_extra_simplefin_account_fields.rb b/db/migrate/20250808143007_add_extra_simplefin_account_fields.rb
new file mode 100644
index 000000000..0375aa59f
--- /dev/null
+++ b/db/migrate/20250808143007_add_extra_simplefin_account_fields.rb
@@ -0,0 +1,6 @@
+class AddExtraSimplefinAccountFields < ActiveRecord::Migration[7.2]
+ def change
+ add_column :simplefin_accounts, :extra, :jsonb
+ add_column :simplefin_accounts, :org_data, :jsonb
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8d4bb0d8a..dac00a6e5 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) do
+ActiveRecord::Schema[7.2].define(version: 2025_08_08_143007) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -35,6 +35,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) do
t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0"
t.jsonb "locked_attributes", default: {}
t.string "status", default: "active"
+ t.uuid "simplefin_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 ["currency"], name: "index_accounts_on_currency"
@@ -44,6 +45,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) 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 ["simplefin_account_id"], name: "index_accounts_on_simplefin_account_id"
t.index ["status"], name: "index_accounts_on_status"
end
@@ -191,12 +193,14 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) do
t.date "expiration_date"
t.decimal "annual_fee", precision: 10, scale: 2
t.jsonb "locked_attributes", default: {}
+ t.string "subtype"
end
create_table "cryptos", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {}
+ t.string "subtype"
end
create_table "data_enrichments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -216,6 +220,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {}
+ t.string "subtype"
end
create_table "entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -387,6 +392,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {}
+ t.string "subtype"
end
create_table "invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -421,6 +427,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) do
t.integer "term_months"
t.decimal "initial_balance", precision: 19, scale: 4
t.jsonb "locked_attributes", default: {}
+ t.string "subtype"
end
create_table "merchants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -519,12 +526,14 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {}
+ t.string "subtype"
end
create_table "other_liabilities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {}
+ t.string "subtype"
end
create_table "plaid_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -576,6 +585,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) do
t.integer "area_value"
t.string "area_unit"
t.jsonb "locked_attributes", default: {}
+ t.string "subtype"
end
create_table "rejected_transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -672,6 +682,44 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) do
t.index ["var"], name: "index_settings_on_var", unique: true
end
+ create_table "simplefin_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "simplefin_item_id", null: false
+ t.string "name"
+ t.string "account_id"
+ t.string "currency"
+ t.decimal "current_balance", precision: 19, scale: 4
+ t.decimal "available_balance", precision: 19, scale: 4
+ t.string "account_type"
+ t.string "account_subtype"
+ t.jsonb "raw_payload"
+ t.jsonb "raw_transactions_payload"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.datetime "balance_date"
+ t.jsonb "extra"
+ t.jsonb "org_data"
+ t.index ["account_id"], name: "index_simplefin_accounts_on_account_id"
+ t.index ["simplefin_item_id"], name: "index_simplefin_accounts_on_simplefin_item_id"
+ end
+
+ create_table "simplefin_items", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "family_id", null: false
+ t.text "access_url"
+ t.string "name"
+ t.string "institution_id"
+ t.string "institution_name"
+ t.string "institution_url"
+ t.string "status", default: "good"
+ t.boolean "scheduled_for_deletion", default: false
+ t.jsonb "raw_payload"
+ t.jsonb "raw_institution_payload"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.boolean "pending_account_setup", default: false, null: false
+ t.index ["family_id"], name: "index_simplefin_items_on_family_id"
+ t.index ["status"], name: "index_simplefin_items_on_status"
+ end
+
create_table "subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "family_id", null: false
t.string "status", null: false
@@ -756,7 +804,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) do
t.uuid "merchant_id"
t.jsonb "locked_attributes", default: {}
t.string "kind", default: "standard", null: false
+ t.string "external_id"
t.index ["category_id"], name: "index_transactions_on_category_id"
+ t.index ["external_id"], name: "index_transactions_on_external_id"
t.index ["kind"], name: "index_transactions_on_kind"
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
end
@@ -823,11 +873,13 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) do
t.string "make"
t.string "model"
t.jsonb "locked_attributes", default: {}
+ t.string "subtype"
end
add_foreign_key "accounts", "families"
add_foreign_key "accounts", "imports"
add_foreign_key "accounts", "plaid_accounts"
+ add_foreign_key "accounts", "simplefin_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 "api_keys", "users"
@@ -865,6 +917,8 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_31_134449) 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 "simplefin_accounts", "simplefin_items"
+ add_foreign_key "simplefin_items", "families"
add_foreign_key "subscriptions", "families"
add_foreign_key "syncs", "syncs", column: "parent_id"
add_foreign_key "taggings", "tags"
diff --git a/test/controllers/properties_controller_test.rb b/test/controllers/properties_controller_test.rb
index 34f76734d..872579b13 100644
--- a/test/controllers/properties_controller_test.rb
+++ b/test/controllers/properties_controller_test.rb
@@ -39,14 +39,17 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
patch property_path(@account), params: {
account: {
name: "Updated Property",
- subtype: "condo"
+ accountable_attributes: {
+ id: @account.accountable.id,
+ subtype: "condominium"
+ }
}
}
end
@account.reload
assert_equal "Updated Property", @account.name
- assert_equal "condo", @account.subtype
+ assert_equal "condominium", @account.subtype
# If account is active, it renders edit view; otherwise redirects to balances
if @account.active?
diff --git a/test/controllers/simplefin_items_controller_test.rb b/test/controllers/simplefin_items_controller_test.rb
new file mode 100644
index 000000000..1ba03fd4a
--- /dev/null
+++ b/test/controllers/simplefin_items_controller_test.rb
@@ -0,0 +1,45 @@
+require "test_helper"
+
+class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in users(:family_admin)
+ @family = families(:dylan_family)
+ @simplefin_item = SimplefinItem.create!(
+ family: @family,
+ name: "Test Connection",
+ access_url: "https://example.com/test_access"
+ )
+ end
+
+ test "should get index" do
+ get simplefin_items_url
+ assert_response :success
+ assert_includes response.body, @simplefin_item.name
+ end
+
+ test "should get new" do
+ get new_simplefin_item_url
+ assert_response :success
+ end
+
+ test "should show simplefin item" do
+ get simplefin_item_url(@simplefin_item)
+ assert_response :success
+ end
+
+ test "should destroy simplefin item" do
+ assert_difference("SimplefinItem.count", 0) do # doesn't actually delete immediately
+ delete simplefin_item_url(@simplefin_item)
+ end
+
+ assert_redirected_to simplefin_items_path
+ @simplefin_item.reload
+ assert @simplefin_item.scheduled_for_deletion?
+ end
+
+ test "should sync simplefin item" do
+ post sync_simplefin_item_url(@simplefin_item)
+ assert_redirected_to simplefin_item_path(@simplefin_item)
+ assert_equal "Sync started", flash[:notice]
+ end
+end
diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml
index 8692522e6..3b0354d39 100644
--- a/test/fixtures/accounts.yml
+++ b/test/fixtures/accounts.yml
@@ -30,7 +30,6 @@ connected:
name: Plaid Depository Account
balance: 5000
currency: USD
- subtype: checking
accountable_type: Depository
accountable: two
plaid_account: one
diff --git a/test/fixtures/depositories.yml b/test/fixtures/depositories.yml
index acfb5a66f..e862e9b8b 100644
--- a/test/fixtures/depositories.yml
+++ b/test/fixtures/depositories.yml
@@ -1,2 +1,2 @@
one: { }
-two: {}
\ No newline at end of file
+two: { subtype: checking }
\ No newline at end of file
diff --git a/test/models/account_test.rb b/test/models/account_test.rb
index c8eb9749f..3733fc700 100644
--- a/test/models/account_test.rb
+++ b/test/models/account_test.rb
@@ -15,19 +15,19 @@ class AccountTest < ActiveSupport::TestCase
end
test "gets short/long subtype label" do
+ investment = Investment.new(subtype: "hsa")
account = @family.accounts.create!(
name: "Test Investment",
balance: 1000,
currency: "USD",
- subtype: "hsa",
- accountable: Investment.new
+ accountable: investment
)
assert_equal "HSA", account.short_subtype_label
assert_equal "Health Savings Account", account.long_subtype_label
# Test with nil subtype
- account.update!(subtype: nil)
+ account.accountable.update!(subtype: nil)
assert_equal "Investments", account.short_subtype_label
assert_equal "Investments", account.long_subtype_label
end
diff --git a/test/models/plaid_account/processor_test.rb b/test/models/plaid_account/processor_test.rb
index ba6a002f0..6d6a4af74 100644
--- a/test/models/plaid_account/processor_test.rb
+++ b/test/models/plaid_account/processor_test.rb
@@ -46,12 +46,13 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
@plaid_account.account.update!(
name: "User updated name",
- subtype: "savings",
balance: 2000 # User cannot override balance. This will be overridden by the processor on next processing
)
+ @plaid_account.account.accountable.update!(subtype: "savings")
+
@plaid_account.account.lock_attr!(:name)
- @plaid_account.account.lock_attr!(:subtype)
+ @plaid_account.account.accountable.lock_attr!(:subtype)
@plaid_account.account.lock_attr!(:balance) # Even if balance somehow becomes locked, Plaid ignores it and overrides it
assert_no_difference "Account.count" do
diff --git a/test/models/simplefin_account_test.rb b/test/models/simplefin_account_test.rb
new file mode 100644
index 000000000..daa7347ac
--- /dev/null
+++ b/test/models/simplefin_account_test.rb
@@ -0,0 +1,84 @@
+require "test_helper"
+
+class SimplefinAccountTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @simplefin_item = SimplefinItem.create!(
+ family: @family,
+ name: "Test SimpleFin Connection",
+ access_url: "https://example.com/access_token"
+ )
+ @simplefin_account = SimplefinAccount.create!(
+ simplefin_item: @simplefin_item,
+ name: "Test Checking Account",
+ account_id: "test_checking_123",
+ currency: "USD",
+ account_type: "checking",
+ current_balance: 1500.50
+ )
+ end
+
+ test "belongs to simplefin_item" do
+ assert_equal @simplefin_item, @simplefin_account.simplefin_item
+ end
+
+ test "validates presence of required fields" do
+ account = SimplefinAccount.new
+ refute account.valid?
+
+ assert_includes account.errors[:name], "can't be blank"
+ assert_includes account.errors[:account_type], "can't be blank"
+ assert_includes account.errors[:currency], "can't be blank"
+ end
+
+ test "validates balance presence" do
+ account = SimplefinAccount.new(
+ simplefin_item: @simplefin_item,
+ name: "No Balance Account",
+ account_id: "no_balance_123",
+ currency: "USD",
+ account_type: "checking"
+ )
+
+ refute account.valid?
+ assert_includes account.errors[:base], "SimpleFin account must have either current or available balance"
+ end
+
+ test "can upsert snapshot data" do
+ balance_date = "2024-01-15T10:30:00Z"
+ snapshot = {
+ "balance" => 2000.0,
+ "available-balance" => 1800.0,
+ "balance-date" => balance_date,
+ "currency" => "USD",
+ "type" => "savings",
+ "subtype" => "savings",
+ "name" => "Updated Savings Account",
+ "id" => "updated_123",
+ "extra" => { "account_number_last_4" => "1234" },
+ "org" => { "domain" => "testbank.com", "name" => "Test Bank" }
+ }
+
+ @simplefin_account.upsert_simplefin_snapshot!(snapshot)
+
+ assert_equal BigDecimal("2000.0"), @simplefin_account.current_balance
+ assert_equal BigDecimal("1800.0"), @simplefin_account.available_balance
+ assert_equal Time.parse(balance_date), @simplefin_account.balance_date
+ assert_equal "savings", @simplefin_account.account_type
+ assert_equal "Updated Savings Account", @simplefin_account.name
+ assert_equal({ "account_number_last_4" => "1234" }, @simplefin_account.extra)
+ assert_equal({ "domain" => "testbank.com", "name" => "Test Bank" }, @simplefin_account.org_data)
+ assert_equal snapshot, @simplefin_account.raw_payload
+ end
+
+ test "can upsert transactions" do
+ transactions = [
+ { "id" => "txn_1", "amount" => -50.00, "description" => "Coffee Shop", "posted" => "2024-01-01" },
+ { "id" => "txn_2", "amount" => 1000.00, "description" => "Paycheck", "posted" => "2024-01-02" }
+ ]
+
+ @simplefin_account.upsert_simplefin_transactions_snapshot!(transactions)
+
+ assert_equal transactions, @simplefin_account.raw_transactions_payload
+ end
+end
diff --git a/test/models/simplefin_item_test.rb b/test/models/simplefin_item_test.rb
new file mode 100644
index 000000000..30a429867
--- /dev/null
+++ b/test/models/simplefin_item_test.rb
@@ -0,0 +1,64 @@
+require "test_helper"
+
+class SimplefinItemTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @simplefin_item = SimplefinItem.create!(
+ family: @family,
+ name: "Test SimpleFin Connection",
+ access_url: "https://example.com/access_token"
+ )
+ end
+
+ test "belongs to family" do
+ assert_equal @family, @simplefin_item.family
+ end
+
+ test "has many simplefin_accounts" do
+ account = @simplefin_item.simplefin_accounts.create!(
+ name: "Test Account",
+ account_id: "test_123",
+ currency: "USD",
+ account_type: "checking",
+ current_balance: 1000.00
+ )
+
+ assert_includes @simplefin_item.simplefin_accounts, account
+ end
+
+ test "has good status by default" do
+ assert_equal "good", @simplefin_item.status
+ end
+
+ test "can be marked for deletion" do
+ refute @simplefin_item.scheduled_for_deletion?
+
+ @simplefin_item.destroy_later
+
+ assert @simplefin_item.scheduled_for_deletion?
+ end
+
+ test "is syncable" do
+ assert_respond_to @simplefin_item, :sync_later
+ assert_respond_to @simplefin_item, :syncing?
+ end
+
+ test "scopes work correctly" do
+ # Create one for deletion
+ item_for_deletion = SimplefinItem.create!(
+ family: @family,
+ name: "Delete Me",
+ access_url: "https://example.com/delete_token",
+ scheduled_for_deletion: true
+ )
+
+ active_items = SimplefinItem.active
+ ordered_items = SimplefinItem.ordered
+
+ assert_includes active_items, @simplefin_item
+ refute_includes active_items, item_for_deletion
+
+ assert_equal [ @simplefin_item, item_for_deletion ].sort_by(&:created_at).reverse,
+ ordered_items.to_a
+ end
+end