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

+
    +
  1. Visit <%= link_to "SimpleFin Bridge", "https://bridge.simplefin.org/simplefin/create", target: "_blank", class: "text-link underline" %>
  2. +
  3. Connect your bank account using your online banking credentials
  4. +
  5. Copy the SimpleFin setup token that appears (it will be a long Base64-encoded string)
  6. +
  7. Paste it above and click "Add Connection"
  8. +
+

+ 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