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
This commit is contained in:
Sholom Ber
2025-08-07 12:39:29 -04:00
parent 9561e73e17
commit 8497703518
10 changed files with 514 additions and 0 deletions

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

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

View File

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

View File

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

View File

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

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

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