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/provider/simplefin.rb b/app/models/provider/simplefin.rb new file mode 100644 index 000000000..f9280511c --- /dev/null +++ b/app/models/provider/simplefin.rb @@ -0,0 +1,89 @@ +class Provider::Simplefin + include HTTParty + + headers "User-Agent" => "Maybe Finance SimpleFin Client" + + def initialize + self.class.default_options.merge!(verify: true, ssl_verify_mode: :peer) + 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, { + headers: { + "User-Agent" => "Maybe Finance SimpleFin Client" + }, + verify: true, + ssl_verify_mode: :peer + }) + + 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 = {} + query_params["start-date"] = start_date.strftime("%Y-%m-%d") if start_date + query_params["end-date"] = end_date.strftime("%Y-%m-%d") if end_date + 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, { + headers: { + "User-Agent" => "Maybe Finance SimpleFin Client" + }, + verify: true, + ssl_verify_mode: :peer + }) + + case response.code + when 200 + JSON.parse(response.body, symbolize_names: true) + 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 + raise SimplefinError.new("Failed to fetch accounts: #{response.code} #{response.message}", :fetch_failed) + end + end + + def get_info(base_url) + response = HTTParty.get("#{base_url}/info", { + headers: { + "User-Agent" => "Maybe Finance SimpleFin Client" + }, + verify: true, + ssl_verify_mode: :peer + }) + + 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/provider/simplefin/demo.rb b/app/models/provider/simplefin/demo.rb new file mode 100644 index 000000000..81815c933 --- /dev/null +++ b/app/models/provider/simplefin/demo.rb @@ -0,0 +1,34 @@ +class Provider::Simplefin::Demo + # Demo setup token from SimpleFin documentation + DEMO_TOKEN = "aHR0cHM6Ly9icmlkZ2Uuc2ltcGxlZmluLm9yZy9zaW1wbGVmaW4vY2xhaW0vZGVtbw==" + + def self.test_connection + provider = Provider::Simplefin.new + + begin + # Claim the demo token + access_url = provider.claim_access_url(DEMO_TOKEN) + puts "✓ Successfully claimed access URL: #{access_url[0..50]}..." + + # Get account data + accounts_data = provider.get_accounts(access_url) + puts "✓ Successfully retrieved accounts data" + puts " - Accounts count: #{accounts_data[:accounts]&.count || 0}" + puts " - Errors: #{accounts_data[:errors] || 'None'}" + + if accounts_data[:accounts]&.any? + account = accounts_data[:accounts].first + puts " - First account: #{account[:name]} (#{account[:currency]}) - Balance: #{account[:balance]}" + puts " - Transactions: #{account[:transactions]&.count || 0}" + end + + true + rescue Provider::Simplefin::SimplefinError => e + puts "✗ SimpleFin error: #{e.message} (#{e.error_type})" + false + rescue => e + puts "✗ Unexpected error: #{e.message}" + false + end + end +end diff --git a/app/models/simplefin_account.rb b/app/models/simplefin_account.rb new file mode 100644 index 000000000..a84e64c99 --- /dev/null +++ b/app/models/simplefin_account.rb @@ -0,0 +1,66 @@ +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) + # Map SimpleFin field names to our field names + assign_attributes( + current_balance: parse_balance(account_snapshot[:balance]), + available_balance: parse_balance(account_snapshot[:"available-balance"]), + currency: parse_currency(account_snapshot[:currency]), + account_type: account_snapshot[:type] || "unknown", + account_subtype: account_snapshot[:subtype], + name: account_snapshot[:name], + account_id: account_snapshot[:id], + raw_payload: account_snapshot + ) + + save! + 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 + URI.parse(currency_value).path.split("/").last.upcase rescue "USD" + else + currency_value.upcase + end + 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..39ec66110 --- /dev/null +++ b/app/models/simplefin_account/processor.rb @@ -0,0 +1,84 @@ +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? + + account = Account.create_from_simplefin_account(simplefin_account) + simplefin_account.update!(account: account) + 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) + # Convert SimpleFin transaction to internal Transaction format + amount_cents = parse_amount(transaction_data[:amount], account.currency) + posted_date = parse_date(transaction_data[:posted]) + + transaction_attributes = { + account: account, + name: transaction_data[:description] || "Unknown transaction", + amount: Money.new(amount_cents, account.currency), + date: posted_date, + currency: account.currency, + raw_data: transaction_data + } + + # Use external ID to prevent duplicates + external_id = "simplefin_#{transaction_data[:id]}" + + Transaction.find_or_create_by(external_id: external_id) do |transaction| + transaction.assign_attributes(transaction_attributes) + end + rescue => e + Rails.logger.error("Failed to process SimpleFin transaction #{transaction_data[:id]}: #{e.message}") + # Don't fail the entire sync for one bad transaction + end + + def parse_amount(amount_value, currency) + case amount_value + when String + (BigDecimal(amount_value) * 100).to_i + when Numeric + (amount_value * 100).to_i + else + 0 + end + rescue ArgumentError + 0 + end + + def parse_date(date_value) + case date_value + when String + Date.parse(date_value) + when Integer + # Unix timestamp + Time.at(date_value).to_date + else + Date.current + end + rescue ArgumentError, TypeError + Date.current + 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..a36b41e7f --- /dev/null +++ b/app/models/simplefin_item/importer.rb @@ -0,0 +1,75 @@ +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 + accounts_data = simplefin_provider.get_accounts(simplefin_item.access_url) + + # 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 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] + ) + + simplefin_account.upsert_simplefin_snapshot!(account_data) + + # Import transactions if present + if account_data[:transactions] && account_data[:transactions].any? + simplefin_account.upsert_simplefin_transactions_snapshot!(account_data[:transactions]) + end + end + + def import_organization(org_data) + simplefin_item.upsert_simplefin_institution_snapshot!({ + 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"] + }) + 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..570571af9 --- /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 \ No newline at end of file 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..3243e14f5 --- /dev/null +++ b/app/models/simplefin_item/syncer.rb @@ -0,0 +1,33 @@ +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 this is the first sync and we have new accounts to set up + if simplefin_item.accounts.empty? && simplefin_item.simplefin_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