Feature/simplefin integration (#94)

* 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 43b339940b.

* 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 <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Himmelschmidt
2025-08-11 20:59:16 -04:00
committed by GitHub
parent 6d4a5dd743
commit 7e36b1c7c5
50 changed files with 1608 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}]`)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
module SimplefinItem::Provided
extend ActiveSupport::Concern
def simplefin_provider
@simplefin_provider ||= Provider::Simplefin.new
end
end

View File

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

View File

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

View File

@@ -16,7 +16,12 @@
</span>
</p>
<% else %>
<%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
<div>
<%= 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') %>
<span class="text-secondary">• <%= account.simplefin_account.org_data["name"] %></span>
<% end %>
</div>
<% if account.long_subtype_label %>
<p class="text-sm text-secondary truncate"><%= account.long_subtype_label %></p>
<% end %>

View File

@@ -21,7 +21,7 @@
</div>
</header>
<% if @manual_accounts.empty? && @plaid_items.empty? %>
<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? %>
<%= render "empty" %>
<% else %>
<div class="space-y-2">
@@ -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 %>

View File

@@ -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 %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= 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 %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= icon("link-2") %>
@@ -32,5 +32,6 @@
<%= t("accounts.new.method_selector.connected_entry_eu") %>
<% end %>
<% end %>
</div>
<% end %>

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
<%# locals: (simplefin_item:) %>
<%= tag.div id: dom_id(simplefin_item) do %>
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
<summary class="flex items-center justify-between gap-2 focus-visible:outline-hidden">
<div class="flex items-center gap-2">
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
<% if simplefin_item.logo.attached? %>
<div class="flex items-center justify-center h-8 w-8 rounded-full overflow-hidden">
<%= image_tag simplefin_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %>
</div>
<% else %>
<%= render DS::FilledIcon.new(
variant: :container,
text: simplefin_item.name.first.upcase,
size: "md"
) %>
<% end %>
<div class="pl-1 text-sm">
<div class="flex items-center gap-2">
<div>
<%= tag.p simplefin_item.name, class: "font-medium text-primary" %>
<% if simplefin_item.institution_name.present? %>
<p class="text-secondary text-xs"><%= simplefin_item.institution_name %></p>
<% end %>
</div>
<% if simplefin_item.scheduled_for_deletion? %>
<p class="text-destructive text-sm animate-pulse">(deletion in progress...)</p>
<% end %>
</div>
<% if simplefin_item.syncing? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "loader", size: "sm", class: "animate-pulse" %>
<%= tag.span "Syncing..." %>
</div>
<% elsif simplefin_item.requires_update? %>
<div class="text-warning flex items-center gap-1">
<%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span "Requires Update" %>
</div>
<% elsif simplefin_item.sync_error.present? %>
<div class="text-secondary flex items-center gap-1">
<%= icon "alert-circle", size: "sm", color: "destructive" %>
<%= tag.span "Error", class: "text-destructive" %>
</div>
<% else %>
<p class="text-secondary">
<%= simplefin_item.last_synced_at ? "Last synced #{time_ago_in_words(simplefin_item.last_synced_at)} ago" : "Never synced" %>
</p>
<% end %>
</div>
</div>
<div class="flex items-center gap-2">
<% 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 %>
</div>
</summary>
<% unless simplefin_item.scheduled_for_deletion? %>
<div class="space-y-4 mt-4">
<% if simplefin_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: simplefin_item.accounts %>
<% end %>
<% if simplefin_item.pending_account_setup? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm">New accounts ready to set up</p>
<p class="text-secondary text-sm">Choose account types for your newly imported SimpleFin accounts.</p>
<%= render DS::Link.new(
text: "Set Up New Accounts",
icon: "settings",
variant: "primary",
href: setup_accounts_simplefin_item_path(simplefin_item)
) %>
</div>
<% elsif simplefin_item.accounts.empty? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<p class="text-primary font-medium text-sm">No accounts found</p>
<p class="text-secondary text-sm">This connection doesn't have any synchronized accounts yet.</p>
</div>
<% end %>
</div>
<% end %>
</details>
<% end %>

View File

@@ -0,0 +1,14 @@
<div class="subtype-select" data-type="<%= account_type %>" style="display: none;">
<% 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 %>
<p class="text-sm text-secondary"><%= subtype_config[:message] %></p>
<% end %>
</div>

View File

@@ -0,0 +1,42 @@
<% content_for :title, "SimpleFin Connections" %>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-primary">SimpleFin Connections</h1>
<p class="text-secondary mt-1">Manage your SimpleFin bank account connections</p>
</div>
<%= render DS::Link.new(
text: "Add Connection",
icon: "plus",
variant: "primary",
href: new_simplefin_item_path
) %>
</div>
<% if @simplefin_items.any? %>
<div class="space-y-4">
<% @simplefin_items.each do |simplefin_item| %>
<%= render "simplefin_item", simplefin_item: simplefin_item %>
<% end %>
</div>
<% else %>
<div class="text-center py-12">
<div class="space-y-3 text-center flex flex-col items-center">
<%= render DS::FilledIcon.new(
variant: :container,
icon: "building-2",
) %>
<p class="text-sm font-medium text-primary">No SimpleFin connections</p>
<p class="text-secondary text-sm">Connect your bank accounts through SimpleFin to automatically sync transactions.</p>
<%= render DS::Link.new(
text: "Add your first connection",
variant: "primary",
href: new_simplefin_item_path
) %>
</div>
</div>
<% end %>
</div>

View File

@@ -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| %>
<div class="grow space-y-2">
<%= form.text_area :setup_token,
label: "SimpleFin Setup Token",
placeholder: "Paste your SimpleFin setup token here...",
rows: 4,
required: true %>
<p class="text-xs text-secondary">
Get your setup token from
<%= link_to "SimpleFin Bridge", "https://bridge.simplefin.org/simplefin/create",
target: "_blank",
class: "text-link underline" %>
</p>
<div class="bg-surface border border-primary p-4 rounded-lg">
<div class="flex items-start gap-3">
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div>
<h3 class="text-sm font-medium text-primary mb-1">How to get your setup token:</h3>
<ol class="text-xs text-secondary space-y-1 list-decimal list-inside">
<li>Visit <%= link_to "SimpleFin Bridge", "https://bridge.simplefin.org/simplefin/create", target: "_blank", class: "text-link underline" %></li>
<li>Connect your bank account using your online banking credentials</li>
<li>Copy the SimpleFin setup token that appears (it will be a long Base64-encoded string)</li>
<li>Paste it above and click "Add Connection"</li>
</ol>
<p class="text-xs text-secondary mt-2">
<strong>Note:</strong> Setup tokens can only be used once. If the connection fails, you'll need to create a new token.
</p>
</div>
</div>
</div>
</div>
<%= form.submit "Add Connection" %>
<% end %>
<% end %>
<% end %>

View File

@@ -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 %>
<div class="flex items-center gap-2">
<%= icon "building-2", class: "text-primary" %>
<span class="text-primary">Choose the correct account types for your imported accounts</span>
</div>
<% 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| %>
<div class="space-y-4">
<div class="bg-surface border border-primary p-4 rounded-lg">
<div class="flex items-start gap-3">
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
<div>
<p class="text-sm text-primary mb-2">
<strong>Choose the correct account type for each SimpleFin account:</strong>
</p>
<ul class="text-xs text-secondary space-y-1 list-disc list-inside">
<li><strong>Checking or Savings</strong> - Regular bank accounts</li>
<li><strong>Credit Card</strong> - Credit card accounts</li>
<li><strong>Investment</strong> - Brokerage, 401(k), IRA accounts</li>
<li><strong>Loan or Mortgage</strong> - Debt accounts</li>
<li><strong>Other Asset</strong> - Everything else</li>
<li><strong>Skip - don't add</strong> - Don't import this account</li>
</ul>
</div>
</div>
</div>
<% @simplefin_accounts.each do |simplefin_account| %>
<div class="border border-primary rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<div>
<h3 class="font-medium text-primary">
<%= simplefin_account.name %>
<% if simplefin_account.org_data.present? && simplefin_account.org_data['name'].present? %>
<span class="text-secondary">• <%= simplefin_account.org_data["name"] %></span>
<% elsif @simplefin_item.institution_name.present? %>
<span class="text-secondary">• <%= @simplefin_item.institution_name %></span>
<% end %>
</h3>
<p class="text-sm text-secondary">
Balance: <%= number_to_currency(simplefin_account.current_balance || 0, unit: simplefin_account.currency) %>
</p>
</div>
</div>
<div class="space-y-3" data-controller="account-type-selector" data-account-type-selector-account-id-value="<%= simplefin_account.id %>">
<div>
<%= 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"
} } %>
</div>
<!-- Subtype dropdowns (shown/hidden based on account type) -->
<div data-account-type-selector-target="subtypeContainer">
<% @subtype_options.each do |account_type, subtype_config| %>
<%= render "subtype_select", account_type: account_type, subtype_config: subtype_config, simplefin_account: simplefin_account %>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<div class="flex gap-3">
<%= 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
) %>
</div>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,105 @@
<% content_for :title, @simplefin_item.name %>
<div class="mb-8">
<%= link_to simplefin_items_path, class: "text-secondary hover:text-primary" do %>
← Back to SimpleFin Connections
<% end %>
<h1 class="text-2xl font-bold mt-2"><%= @simplefin_item.name %></h1>
<div class="flex gap-3 mt-4">
<%= 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 %>
</div>
</div>
<div class="space-y-6">
<% if @simplefin_item.syncing? %>
<div class="p-4 bg-surface border border-primary rounded-lg">
<div class="flex items-center">
<%= icon "loader-2", class: "w-5 h-5 text-primary animate-spin mr-2" %>
<p class="text-primary">Syncing accounts...</p>
</div>
</div>
<% end %>
<% if @simplefin_item.accounts.any? %>
<%= render "accounts/index/account_groups", accounts: @simplefin_item.accounts %>
<% elsif @simplefin_item.simplefin_accounts.any? %>
<div class="bg-container-inset p-1 rounded-xl">
<div class="flex items-center px-4 py-2 text-xs font-medium text-secondary">
<p>SimpleFin Accounts</p>
<span class="text-subdued mx-2">&middot;</span>
<p><%= @simplefin_item.simplefin_accounts.count %></p>
</div>
<div class="bg-container rounded-lg shadow-border-xs">
<% @simplefin_item.simplefin_accounts.each_with_index do |simplefin_account, index| %>
<div class="p-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<%= render DS::FilledIcon.new(
variant: :container,
text: simplefin_account.name.first.upcase,
size: "md"
) %>
<div>
<p class="text-sm font-medium text-primary">
<%= simplefin_account.name %>
<% if simplefin_account.org_data.present? && simplefin_account.org_data['name'].present? %>
<span class="text-secondary">• <%= simplefin_account.org_data["name"] %></span>
<% elsif @simplefin_item.institution_name.present? %>
<span class="text-secondary">• <%= @simplefin_item.institution_name %></span>
<% end %>
</p>
<p class="text-sm text-secondary">
<%= simplefin_account.account_type&.humanize || "Unknown Type" %>
</p>
</div>
</div>
<div class="flex items-center gap-8">
<p class="text-sm font-medium text-primary">
<%= number_to_currency(simplefin_account.current_balance || 0) %>
</p>
<% 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 %>
</div>
</div>
<% unless index == @simplefin_item.simplefin_accounts.count - 1 %>
<%= render "shared/ruler" %>
<% end %>
<% end %>
</div>
</div>
<% else %>
<div class="text-center py-12">
<div class="space-y-3 text-center flex flex-col items-center">
<%= render DS::FilledIcon.new(
variant: :container,
icon: "building-2",
) %>
<p class="text-sm font-medium text-primary">No accounts found</p>
<p class="text-secondary text-sm">Try syncing again to import your accounts.</p>
<%= 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 %>
</div>
</div>
<% end %>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
class AddBalanceDateToSimplefinAccounts < ActiveRecord::Migration[7.2]
def change
add_column :simplefin_accounts, :balance_date, :datetime
end
end

View File

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

56
db/schema.rb generated
View File

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

View File

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

View File

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

View File

@@ -30,7 +30,6 @@ connected:
name: Plaid Depository Account
balance: 5000
currency: USD
subtype: checking
accountable_type: Depository
accountable: two
plaid_account: one

View File

@@ -1,2 +1,2 @@
one: { }
two: {}
two: { subtype: checking }

View File

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

View File

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

View File

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

View File

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