mirror of
https://github.com/we-promise/sure.git
synced 2026-04-20 20:44:08 +00:00
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:
25
app/models/family/simplefin_connectable.rb
Normal file
25
app/models/family/simplefin_connectable.rb
Normal 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
|
||||
89
app/models/provider/simplefin.rb
Normal file
89
app/models/provider/simplefin.rb
Normal 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
|
||||
34
app/models/provider/simplefin/demo.rb
Normal file
34
app/models/provider/simplefin/demo.rb
Normal 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
|
||||
66
app/models/simplefin_account.rb
Normal file
66
app/models/simplefin_account.rb
Normal 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
|
||||
84
app/models/simplefin_account/processor.rb
Normal file
84
app/models/simplefin_account/processor.rb
Normal 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
|
||||
76
app/models/simplefin_item.rb
Normal file
76
app/models/simplefin_item.rb
Normal 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
|
||||
75
app/models/simplefin_item/importer.rb
Normal file
75
app/models/simplefin_item/importer.rb
Normal 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
|
||||
7
app/models/simplefin_item/provided.rb
Normal file
7
app/models/simplefin_item/provided.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module SimplefinItem::Provided
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def simplefin_provider
|
||||
@simplefin_provider ||= Provider::Simplefin.new
|
||||
end
|
||||
end
|
||||
25
app/models/simplefin_item/sync_complete_event.rb
Normal file
25
app/models/simplefin_item/sync_complete_event.rb
Normal 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
|
||||
33
app/models/simplefin_item/syncer.rb
Normal file
33
app/models/simplefin_item/syncer.rb
Normal 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
|
||||
Reference in New Issue
Block a user