From 7e36b1c7c56fb0187781b05d675190dddf9f4785 Mon Sep 17 00:00:00 2001
From: Himmelschmidt <46351743+Himmelschmidt@users.noreply.github.com>
Date: Mon, 11 Aug 2025 20:59:16 -0400
Subject: [PATCH] Feature/simplefin integration (#94)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Add HTTParty gem for SimpleFin API integration
- Add HTTParty gem for making HTTP requests to SimpleFin API
- Required for SimpleFin protocol implementation
* Add SimpleFin database schema
- Create simplefin_items table for SimpleFin connections
- Create simplefin_accounts table for account metadata
- Add simplefin_account_id to accounts table for linking
- Add external_id to transactions for deduplication
- Enable encrypted storage of SimpleFin access URLs
* Implement SimpleFin API client and data models
- Add SimplefinItem model with sync capabilities and encryption
- Add SimplefinAccount model for account data mapping
- Implement Provider::Simplefin API client with token exchange
- Add SimpleFin protocol support with proper error handling
- Include sync jobs, importers, and processors for data flow
- Add family SimpleFin connectivity mixin
* Update core models for SimpleFin integration
- Add SimpleFin account creation methods to Account model
- Implement intelligent account type mapping from names
- Add SimpleFin linkable functionality to Account
- Include SimpleFin items in Family model associations
- Support account creation with user-selected types
* Add SimpleFin controllers and routing
- Create SimplefinItemsController with CRUD operations
- Add account setup flow with user type selection
- Include sync management and error handling
- Update AccountsController to display SimpleFin items
- Add routes for SimpleFin item management and setup
* Add SimpleFin user interface components
- Create SimpleFin connection management views
- Add account setup modal with type selection
- Include connection form with token input and instructions
- Update accounts index to display SimpleFin items
- Add SimpleFin option to account method selector
- Include SimpleFin in settings navigation
* Add user account type selection workflow
- Add pending_account_setup field to SimpleFin items
- Enable pausing sync for user account type selection
- Allow users to choose account types during import
- Prevent automatic account creation until user confirms
* Add tests for SimpleFin integration
- Add SimplefinItem model tests with fixtures
- Add SimplefinAccount model tests
- Add SimplefinItemsController tests
- Include test coverage for sync and account creation
* Fix account show page for SimpleFin accounts
- Update sync button routing to handle SimpleFin accounts
- Add SimpleFin item sync path alongside existing Plaid support
- Prevent NoMethodError when viewing SimpleFin-linked accounts
- Support proper sync routing for Plaid, SimpleFin, and manual accounts
* Complete subtype selection for SimpleFin accounts
- Add subtype database columns to all accountable models
- Create Stimulus controller for dynamic subtype dropdown interaction
- Add delegation from Account to accountable subtype for clean API access
- Update SimpleFin account setup form with working subtype selection
- Fix account display to show proper subtype labels instead of generic "Cash"
Users can now select both account type and subtype during SimpleFin import,
and the selected subtypes are properly saved and displayed in the UI.
* Fix dark mode compatibility for SimpleFin UI components
- Replace hardcoded colors with design system tokens throughout SimpleFin views
- Fix method selector hover states to use bg-surface instead of bg-gray-50
- Update SimpleFin form to use styled_form_with and standard form patterns
- Replace custom button styling with design system button components
- Fix info boxes and containers to use bg-surface and border-primary
- Replace hardcoded green/blue colors with text-primary, text-secondary, text-link
- Remove custom text area styling to allow form builder defaults (dark mode support)
All SimpleFin components now properly adapt to both light and dark themes
with correct contrast and visibility.
* Fix SimpleFin integration bugs and improve code quality
- Fix upsert method to handle string/symbol keys with indifferent access
- Add missing show route and view for SimpleFin items
- Fix test fixtures to use correct user references
- Update test data to match real-world JSON format (string keys, BigDecimal)
- Apply code formatting and linting fixes (rubocop, erb_lint)
- Ensure all SimpleFin tests pass (16/16 passing)
* Remove SimpleFin demo file with outdated setup token
* Update SimpleFin User-Agent to use Sure Finance branding
* Remove unused SimpleFin account type mapping logic
- Remove map_simplefin_type_to_accountable_type method (no longer needed)
- Remove create_from_simplefin_account method (manual setup only)
- Simplify account type selection UI to not pre-select defaults
- Update processor to log error if account missing (safety check)
- All account creation now goes through manual user selection flow
* Gate SimpleFin option behind US region check
SimpleFin is primarily for North American financial institutions,
so only show the option when US banking connections are available.
* Refactor SimpleFin controller to use model method
- Move SimpleFin item creation logic from controller to Family#create_simplefin_item!
- Remove duplication between controller and model
- Simplify controller to focus on web request/response handling
- Remove unused simplefin_provider method
- Follow Rails best practices for fat models, skinny controllers
* Fix critical data integrity issue in SimpleFin date parsing
- Remove fallback to Date.current when transaction dates fail to parse
- Raise ArgumentError instead to ensure data integrity
- Log detailed error messages for debugging
- Skip transactions with invalid dates rather than using incorrect dates
- Prevents hard-to-debug issues with balances and financial reports
* Address all Gemini code review feedback for SimpleFin integration
- Remove debug console.log statements from JavaScript controller
- Consolidate duplicate SimpleFin account creation methods into single method
- Refactor SimplefinItemsController to reduce complexity with helper methods
- Fix HTTParty thread-safety by moving SSL options to class level
- Remove redundant HTTParty options from individual requests
- Add proper error logging for invalid currency URIs
- Extract sync button path logic to AccountsHelper#sync_path_for method
- DRY up repeated subtype dropdown code with reusable partial and data structure
All SimpleFin tests passing (16/16). Code quality improvements maintain
backward compatibility while following Rails best practices.
* Fix tests for subtype delegation to accountable models
The subtype attribute was moved from Account to individual accountable models
to enable users to select specific subtypes during SimpleFin account import.
This change allows for better account categorization and more precise display
of account types (e.g., "HSA" instead of generic "Cash").
However, tests and the PlaidAccount processor weren't updated to work with
the new delegation pattern. This commit fixes:
- PlaidAccount::Processor now sets subtype on accountable and uses enrichable
pattern to respect user locks
- PropertiesController updated to handle subtype via accountable_attributes
- Test fixtures corrected to set subtype on accountable models not Account
- Tests updated to work with the delegated subtype pattern
All originally failing tests now pass:
- PropertiesControllerTest#test_updates_property_overview
- PlaidAccount::ProcessorTest (2 failing tests)
- AccountTest#test_gets_short/long_subtype_label
* Fix trailing whitespace (rubocop auto-fix)
* Add option to "skip" adding an account
* Revert "Gate SimpleFin option behind US region check"
This reverts commit 43b339940b1e06c8a5d7a0cffdad273d074e7fc4.
* Fix SimpleFin transaction syncing and clean up debug logging
- Fix transaction creation to use Entry/entryable pattern instead of creating Transaction directly
- Handle both string and symbol keys in transaction data using with_indifferent_access
- Fix amount parsing to use BigDecimal instead of converting to cents
- Use plaid_id field for external ID storage to prevent duplicates
- Remove excessive debug logging while keeping essential error logging
SimpleFin transaction sync now works correctly, creating proper Entry records
with accurate dollar amounts and preventing duplicate transactions.
* Not sure how skipping worked for me the first time
* Fix SimpleFin new account setup flow and UI dark mode issues
- Fix accounts showing as 'unknown' by displaying proper account type from Account model
- Fix new accounts in existing connections not triggering setup flow with correct query
- Fix dark mode colors throughout SimpleFin views using design system tokens
- Improve UI logic to show existing accounts alongside new account setup prompt
- Remove balance attribute error when creating CreditCard accounts
- Simplify CreditCard subtype selection (auto-default to credit_card)
* Fix linter issues (trailing whitespace and ERB formatting)
* Remove SimpleFin button from create accounts view
SimpleFin doesn't work like Plaid - no need for separate connection creation for new accounts, just refresh existing connection.
* Add missing SimpleFin attributes and fix balance attribute error
- Add balance_date field to SimpleFin accounts to capture balance timestamp from protocol
- Enhanced build_simplefin_accountable_attributes to set available_credit for CreditCard accounts
- Fixed model mismatch where balance was being set on accountable models instead of Account model
- Updated tests to verify balance_date parsing functionality
This addresses the balance attribute error from commit 6681537b and ensures we're capturing
all available SimpleFin protocol data properly.
* Store all SimpleFin protocol fields in JSONB following existing patterns
* Fix SimpleFin API date parameter format and improve error handling
- Change date parameters from string format to Unix timestamps as required by SimpleFin API
- Add better error handling for 400 Bad Request responses
- Add more detailed error logging for debugging failed API calls
This fixes the issue where SimpleFin was only returning recent transactions
instead of historical data when start_date was provided.
* Implement comprehensive historical transaction sync for SimpleFin
- Add start_date parameter to SimpleFin API calls for historical data
- Use 100-year lookback for first sync to capture all available history
- Use 7-day buffer for incremental syncs to catch late-posting transactions
- Fix transaction storage to prevent data loss during account updates
- Remove verbose logging for cleaner output
This ensures users get all their historical transactions on first sync,
not just recent ones.
* Fix SimpleFin transaction sign convention to match Maybe's format
- Negate SimpleFin amounts to convert from banking convention to Maybe's format
- SimpleFin: expenses negative, income positive (banking convention)
- Maybe: expenses positive, income negative (internal convention)
- Improve date parsing to handle multiple date formats (Unix timestamps, strings, Date objects)
This fixes the issue where expenses showed as negative in the UI instead of positive.
* Add SimpleFin account association and fix balance handling for liabilities
- Add belongs_to :simplefin_account association to Account model
- Fix balance handling for credit cards and loans (use absolute value)
- SimpleFin returns negative balances for liabilities, but Maybe expects positive
This enables displaying organization names and ensures correct balance display.
* Display organization names throughout SimpleFin interface
- Show institution names under SimpleFin connection titles
- Display organization names next to account names (e.g., "360 Checking • Capital One")
- Add organization info to all SimpleFin account displays:
- Account setup page
- SimpleFin item details page
- Regular account lists for SimpleFin accounts
- Use org_data from SimpleFin accounts with fallback to institution_name
This improves account identification by showing which financial institution
each account belongs to throughout the SimpleFin workflow.
* Fix SimpleFin UI styling to match design system
- Replace custom styles with DS components (DS::FilledIcon, DS::Link, DS::Button)
- Use proper design system tokens instead of hardcoded colors
- Fix form select styling to match design system patterns
- Update empty states to use consistent styling
- Ensure all SimpleFin views follow the app's design system
This makes the SimpleFin interface consistent with the rest of the app.
---------
Signed-off-by: Juan José Mata
Co-authored-by: Juan José Mata
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
Gemfile | 1 +
Gemfile.lock | 7 +
app/controllers/accounts_controller.rb | 1 +
app/controllers/properties_controller.rb | 2 +-
app/controllers/simplefin_items_controller.rb | 139 ++++++++++++++++++
app/helpers/accounts_helper.rb | 10 ++
.../account_type_selector_controller.js | 45 ++++++
app/models/account.rb | 51 ++++++-
app/models/account/linkable.rb | 5 +-
app/models/family.rb | 2 +-
app/models/family/simplefin_connectable.rb | 25 ++++
app/models/plaid_account/processor.rb | 19 ++-
app/models/provider/simplefin.rb | 87 +++++++++++
app/models/simplefin_account.rb | 93 ++++++++++++
app/models/simplefin_account/processor.rb | 109 ++++++++++++++
app/models/simplefin_item.rb | 76 ++++++++++
app/models/simplefin_item/importer.rb | 107 ++++++++++++++
app/models/simplefin_item/provided.rb | 7 +
.../simplefin_item/sync_complete_event.rb | 25 ++++
app/models/simplefin_item/syncer.rb | 34 +++++
app/views/accounts/_account.html.erb | 7 +-
app/views/accounts/index.html.erb | 6 +-
.../accounts/new/_method_selector.html.erb | 5 +-
app/views/accounts/show/_header.html.erb | 2 +-
app/views/settings/_settings_nav.html.erb | 1 +
.../simplefin_items/_simplefin_item.html.erb | 104 +++++++++++++
.../simplefin_items/_subtype_select.html.erb | 14 ++
app/views/simplefin_items/index.html.erb | 42 ++++++
app/views/simplefin_items/new.html.erb | 46 ++++++
.../simplefin_items/setup_accounts.html.erb | 95 ++++++++++++
app/views/simplefin_items/show.html.erb | 105 +++++++++++++
app/views/transactions/show.html.erb | 3 +-
config/routes.rb | 8 +
.../20250807143728_create_simplefin_items.rb | 20 +++
...0250807143819_create_simplefin_accounts.rb | 20 +++
...30_add_simplefin_account_id_to_accounts.rb | 5 +
...7144857_add_external_id_to_transactions.rb | 6 +
...ending_account_setup_to_simplefin_items.rb | 5 +
...50807170943_add_subtype_to_accountables.rb | 13 ++
..._add_balance_date_to_simplefin_accounts.rb | 5 +
...3007_add_extra_simplefin_account_fields.rb | 6 +
db/schema.rb | 56 ++++++-
.../controllers/properties_controller_test.rb | 7 +-
.../simplefin_items_controller_test.rb | 45 ++++++
test/fixtures/accounts.yml | 1 -
test/fixtures/depositories.yml | 2 +-
test/models/account_test.rb | 6 +-
test/models/plaid_account/processor_test.rb | 5 +-
test/models/simplefin_account_test.rb | 84 +++++++++++
test/models/simplefin_item_test.rb | 64 ++++++++
50 files changed, 1608 insertions(+), 25 deletions(-)
create mode 100644 app/controllers/simplefin_items_controller.rb
create mode 100644 app/javascript/controllers/account_type_selector_controller.js
create mode 100644 app/models/family/simplefin_connectable.rb
create mode 100644 app/models/provider/simplefin.rb
create mode 100644 app/models/simplefin_account.rb
create mode 100644 app/models/simplefin_account/processor.rb
create mode 100644 app/models/simplefin_item.rb
create mode 100644 app/models/simplefin_item/importer.rb
create mode 100644 app/models/simplefin_item/provided.rb
create mode 100644 app/models/simplefin_item/sync_complete_event.rb
create mode 100644 app/models/simplefin_item/syncer.rb
create mode 100644 app/views/simplefin_items/_simplefin_item.html.erb
create mode 100644 app/views/simplefin_items/_subtype_select.html.erb
create mode 100644 app/views/simplefin_items/index.html.erb
create mode 100644 app/views/simplefin_items/new.html.erb
create mode 100644 app/views/simplefin_items/setup_accounts.html.erb
create mode 100644 app/views/simplefin_items/show.html.erb
create mode 100644 db/migrate/20250807143728_create_simplefin_items.rb
create mode 100644 db/migrate/20250807143819_create_simplefin_accounts.rb
create mode 100644 db/migrate/20250807144230_add_simplefin_account_id_to_accounts.rb
create mode 100644 db/migrate/20250807144857_add_external_id_to_transactions.rb
create mode 100644 db/migrate/20250807163541_add_pending_account_setup_to_simplefin_items.rb
create mode 100644 db/migrate/20250807170943_add_subtype_to_accountables.rb
create mode 100644 db/migrate/20250808141424_add_balance_date_to_simplefin_accounts.rb
create mode 100644 db/migrate/20250808143007_add_extra_simplefin_account_fields.rb
create mode 100644 test/controllers/simplefin_items_controller_test.rb
create mode 100644 test/models/simplefin_account_test.rb
create mode 100644 test/models/simplefin_item_test.rb
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