mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 03:54:08 +00:00
Feature/simplefin integration (#94)
* Add HTTParty gem for SimpleFin API integration - Add HTTParty gem for making HTTP requests to SimpleFin API - Required for SimpleFin protocol implementation * Add SimpleFin database schema - Create simplefin_items table for SimpleFin connections - Create simplefin_accounts table for account metadata - Add simplefin_account_id to accounts table for linking - Add external_id to transactions for deduplication - Enable encrypted storage of SimpleFin access URLs * Implement SimpleFin API client and data models - Add SimplefinItem model with sync capabilities and encryption - Add SimplefinAccount model for account data mapping - Implement Provider::Simplefin API client with token exchange - Add SimpleFin protocol support with proper error handling - Include sync jobs, importers, and processors for data flow - Add family SimpleFin connectivity mixin * Update core models for SimpleFin integration - Add SimpleFin account creation methods to Account model - Implement intelligent account type mapping from names - Add SimpleFin linkable functionality to Account - Include SimpleFin items in Family model associations - Support account creation with user-selected types * Add SimpleFin controllers and routing - Create SimplefinItemsController with CRUD operations - Add account setup flow with user type selection - Include sync management and error handling - Update AccountsController to display SimpleFin items - Add routes for SimpleFin item management and setup * Add SimpleFin user interface components - Create SimpleFin connection management views - Add account setup modal with type selection - Include connection form with token input and instructions - Update accounts index to display SimpleFin items - Add SimpleFin option to account method selector - Include SimpleFin in settings navigation * Add user account type selection workflow - Add pending_account_setup field to SimpleFin items - Enable pausing sync for user account type selection - Allow users to choose account types during import - Prevent automatic account creation until user confirms * Add tests for SimpleFin integration - Add SimplefinItem model tests with fixtures - Add SimplefinAccount model tests - Add SimplefinItemsController tests - Include test coverage for sync and account creation * Fix account show page for SimpleFin accounts - Update sync button routing to handle SimpleFin accounts - Add SimpleFin item sync path alongside existing Plaid support - Prevent NoMethodError when viewing SimpleFin-linked accounts - Support proper sync routing for Plaid, SimpleFin, and manual accounts * Complete subtype selection for SimpleFin accounts - Add subtype database columns to all accountable models - Create Stimulus controller for dynamic subtype dropdown interaction - Add delegation from Account to accountable subtype for clean API access - Update SimpleFin account setup form with working subtype selection - Fix account display to show proper subtype labels instead of generic "Cash" Users can now select both account type and subtype during SimpleFin import, and the selected subtypes are properly saved and displayed in the UI. * Fix dark mode compatibility for SimpleFin UI components - Replace hardcoded colors with design system tokens throughout SimpleFin views - Fix method selector hover states to use bg-surface instead of bg-gray-50 - Update SimpleFin form to use styled_form_with and standard form patterns - Replace custom button styling with design system button components - Fix info boxes and containers to use bg-surface and border-primary - Replace hardcoded green/blue colors with text-primary, text-secondary, text-link - Remove custom text area styling to allow form builder defaults (dark mode support) All SimpleFin components now properly adapt to both light and dark themes with correct contrast and visibility. * Fix SimpleFin integration bugs and improve code quality - Fix upsert method to handle string/symbol keys with indifferent access - Add missing show route and view for SimpleFin items - Fix test fixtures to use correct user references - Update test data to match real-world JSON format (string keys, BigDecimal) - Apply code formatting and linting fixes (rubocop, erb_lint) - Ensure all SimpleFin tests pass (16/16 passing) * Remove SimpleFin demo file with outdated setup token * Update SimpleFin User-Agent to use Sure Finance branding * Remove unused SimpleFin account type mapping logic - Remove map_simplefin_type_to_accountable_type method (no longer needed) - Remove create_from_simplefin_account method (manual setup only) - Simplify account type selection UI to not pre-select defaults - Update processor to log error if account missing (safety check) - All account creation now goes through manual user selection flow * Gate SimpleFin option behind US region check SimpleFin is primarily for North American financial institutions, so only show the option when US banking connections are available. * Refactor SimpleFin controller to use model method - Move SimpleFin item creation logic from controller to Family#create_simplefin_item! - Remove duplication between controller and model - Simplify controller to focus on web request/response handling - Remove unused simplefin_provider method - Follow Rails best practices for fat models, skinny controllers * Fix critical data integrity issue in SimpleFin date parsing - Remove fallback to Date.current when transaction dates fail to parse - Raise ArgumentError instead to ensure data integrity - Log detailed error messages for debugging - Skip transactions with invalid dates rather than using incorrect dates - Prevents hard-to-debug issues with balances and financial reports * Address all Gemini code review feedback for SimpleFin integration - Remove debug console.log statements from JavaScript controller - Consolidate duplicate SimpleFin account creation methods into single method - Refactor SimplefinItemsController to reduce complexity with helper methods - Fix HTTParty thread-safety by moving SSL options to class level - Remove redundant HTTParty options from individual requests - Add proper error logging for invalid currency URIs - Extract sync button path logic to AccountsHelper#sync_path_for method - DRY up repeated subtype dropdown code with reusable partial and data structure All SimpleFin tests passing (16/16). Code quality improvements maintain backward compatibility while following Rails best practices. * Fix tests for subtype delegation to accountable models The subtype attribute was moved from Account to individual accountable models to enable users to select specific subtypes during SimpleFin account import. This change allows for better account categorization and more precise display of account types (e.g., "HSA" instead of generic "Cash"). However, tests and the PlaidAccount processor weren't updated to work with the new delegation pattern. This commit fixes: - PlaidAccount::Processor now sets subtype on accountable and uses enrichable pattern to respect user locks - PropertiesController updated to handle subtype via accountable_attributes - Test fixtures corrected to set subtype on accountable models not Account - Tests updated to work with the delegated subtype pattern All originally failing tests now pass: - PropertiesControllerTest#test_updates_property_overview - PlaidAccount::ProcessorTest (2 failing tests) - AccountTest#test_gets_short/long_subtype_label * Fix trailing whitespace (rubocop auto-fix) * Add option to "skip" adding an account * Revert "Gate SimpleFin option behind US region check" This reverts commit43b339940b. * Fix SimpleFin transaction syncing and clean up debug logging - Fix transaction creation to use Entry/entryable pattern instead of creating Transaction directly - Handle both string and symbol keys in transaction data using with_indifferent_access - Fix amount parsing to use BigDecimal instead of converting to cents - Use plaid_id field for external ID storage to prevent duplicates - Remove excessive debug logging while keeping essential error logging SimpleFin transaction sync now works correctly, creating proper Entry records with accurate dollar amounts and preventing duplicate transactions. * Not sure how skipping worked for me the first time * Fix SimpleFin new account setup flow and UI dark mode issues - Fix accounts showing as 'unknown' by displaying proper account type from Account model - Fix new accounts in existing connections not triggering setup flow with correct query - Fix dark mode colors throughout SimpleFin views using design system tokens - Improve UI logic to show existing accounts alongside new account setup prompt - Remove balance attribute error when creating CreditCard accounts - Simplify CreditCard subtype selection (auto-default to credit_card) * Fix linter issues (trailing whitespace and ERB formatting) * Remove SimpleFin button from create accounts view SimpleFin doesn't work like Plaid - no need for separate connection creation for new accounts, just refresh existing connection. * Add missing SimpleFin attributes and fix balance attribute error - Add balance_date field to SimpleFin accounts to capture balance timestamp from protocol - Enhanced build_simplefin_accountable_attributes to set available_credit for CreditCard accounts - Fixed model mismatch where balance was being set on accountable models instead of Account model - Updated tests to verify balance_date parsing functionality This addresses the balance attribute error from commit6681537band ensures we're capturing all available SimpleFin protocol data properly. * Store all SimpleFin protocol fields in JSONB following existing patterns * Fix SimpleFin API date parameter format and improve error handling - Change date parameters from string format to Unix timestamps as required by SimpleFin API - Add better error handling for 400 Bad Request responses - Add more detailed error logging for debugging failed API calls This fixes the issue where SimpleFin was only returning recent transactions instead of historical data when start_date was provided. * Implement comprehensive historical transaction sync for SimpleFin - Add start_date parameter to SimpleFin API calls for historical data - Use 100-year lookback for first sync to capture all available history - Use 7-day buffer for incremental syncs to catch late-posting transactions - Fix transaction storage to prevent data loss during account updates - Remove verbose logging for cleaner output This ensures users get all their historical transactions on first sync, not just recent ones. * Fix SimpleFin transaction sign convention to match Maybe's format - Negate SimpleFin amounts to convert from banking convention to Maybe's format - SimpleFin: expenses negative, income positive (banking convention) - Maybe: expenses positive, income negative (internal convention) - Improve date parsing to handle multiple date formats (Unix timestamps, strings, Date objects) This fixes the issue where expenses showed as negative in the UI instead of positive. * Add SimpleFin account association and fix balance handling for liabilities - Add belongs_to :simplefin_account association to Account model - Fix balance handling for credit cards and loans (use absolute value) - SimpleFin returns negative balances for liabilities, but Maybe expects positive This enables displaying organization names and ensures correct balance display. * Display organization names throughout SimpleFin interface - Show institution names under SimpleFin connection titles - Display organization names next to account names (e.g., "360 Checking • Capital One") - Add organization info to all SimpleFin account displays: - Account setup page - SimpleFin item details page - Regular account lists for SimpleFin accounts - Use org_data from SimpleFin accounts with fallback to institution_name This improves account identification by showing which financial institution each account belongs to throughout the SimpleFin workflow. * Fix SimpleFin UI styling to match design system - Replace custom styles with DS components (DS::FilledIcon, DS::Link, DS::Button) - Use proper design system tokens instead of hardcoded colors - Fix form select styling to match design system patterns - Update empty states to use consistent styling - Ensure all SimpleFin views follow the app's design system This makes the SimpleFin interface consistent with the rest of the app. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
1
Gemfile
1
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
139
app/controllers/simplefin_items_controller.rb
Normal file
139
app/controllers/simplefin_items_controller.rb
Normal file
@@ -0,0 +1,139 @@
|
||||
class SimplefinItemsController < ApplicationController
|
||||
before_action :set_simplefin_item, only: [ :show, :destroy, :sync, :setup_accounts, :complete_account_setup ]
|
||||
|
||||
def index
|
||||
@simplefin_items = Current.family.simplefin_items.active.ordered
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
@simplefin_item = Current.family.simplefin_items.build
|
||||
end
|
||||
|
||||
def create
|
||||
setup_token = simplefin_params[:setup_token]
|
||||
|
||||
return render_error("Please enter a SimpleFin setup token.") if setup_token.blank?
|
||||
|
||||
begin
|
||||
@simplefin_item = Current.family.create_simplefin_item!(
|
||||
setup_token: setup_token,
|
||||
item_name: "SimpleFin Connection"
|
||||
)
|
||||
|
||||
redirect_to simplefin_items_path, notice: "SimpleFin connection added successfully! Your accounts will appear shortly as they sync in the background."
|
||||
rescue ArgumentError, URI::InvalidURIError
|
||||
render_error("Invalid setup token. Please check that you copied the complete token from SimpleFin Bridge.", setup_token)
|
||||
rescue Provider::Simplefin::SimplefinError => e
|
||||
error_message = case e.error_type
|
||||
when :token_compromised
|
||||
"The setup token may be compromised, expired, or already used. Please create a new one."
|
||||
else
|
||||
"Failed to connect: #{e.message}"
|
||||
end
|
||||
render_error(error_message, setup_token)
|
||||
rescue => e
|
||||
Rails.logger.error("SimpleFin connection error: #{e.message}")
|
||||
render_error("An unexpected error occurred. Please try again or contact support.", setup_token)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@simplefin_item.destroy_later
|
||||
redirect_to simplefin_items_path, notice: "SimpleFin connection will be removed"
|
||||
end
|
||||
|
||||
def sync
|
||||
@simplefin_item.sync_later
|
||||
redirect_to simplefin_item_path(@simplefin_item), notice: "Sync started"
|
||||
end
|
||||
|
||||
def setup_accounts
|
||||
@simplefin_accounts = @simplefin_item.simplefin_accounts.includes(:account).where(accounts: { id: nil })
|
||||
@account_type_options = [
|
||||
[ "Checking or Savings Account", "Depository" ],
|
||||
[ "Credit Card", "CreditCard" ],
|
||||
[ "Investment Account", "Investment" ],
|
||||
[ "Loan or Mortgage", "Loan" ],
|
||||
[ "Other Asset", "OtherAsset" ],
|
||||
[ "Skip - don't add", "Skip" ]
|
||||
]
|
||||
|
||||
# Subtype options for each account type
|
||||
@subtype_options = {
|
||||
"Depository" => {
|
||||
label: "Account Subtype:",
|
||||
options: Depository::SUBTYPES.map { |k, v| [ v[:long], k ] }
|
||||
},
|
||||
"CreditCard" => {
|
||||
label: "",
|
||||
options: [],
|
||||
message: "Credit cards will be automatically set up as credit card accounts."
|
||||
},
|
||||
"Investment" => {
|
||||
label: "Investment Type:",
|
||||
options: Investment::SUBTYPES.map { |k, v| [ v[:long], k ] }
|
||||
},
|
||||
"Loan" => {
|
||||
label: "Loan Type:",
|
||||
options: Loan::SUBTYPES.map { |k, v| [ v[:long], k ] }
|
||||
},
|
||||
"OtherAsset" => {
|
||||
label: nil,
|
||||
options: [],
|
||||
message: "No additional options needed for Other Assets."
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def complete_account_setup
|
||||
account_types = params[:account_types] || {}
|
||||
account_subtypes = params[:account_subtypes] || {}
|
||||
|
||||
account_types.each do |simplefin_account_id, selected_type|
|
||||
# Skip accounts that the user chose not to add
|
||||
next if selected_type == "Skip"
|
||||
|
||||
simplefin_account = @simplefin_item.simplefin_accounts.find(simplefin_account_id)
|
||||
selected_subtype = account_subtypes[simplefin_account_id]
|
||||
|
||||
# Default subtype for CreditCard since it only has one option
|
||||
selected_subtype = "credit_card" if selected_type == "CreditCard" && selected_subtype.blank?
|
||||
|
||||
# Create account with user-selected type and subtype
|
||||
account = Account.create_from_simplefin_account(
|
||||
simplefin_account,
|
||||
selected_type,
|
||||
selected_subtype
|
||||
)
|
||||
simplefin_account.update!(account: account)
|
||||
end
|
||||
|
||||
# Clear pending status and mark as complete
|
||||
@simplefin_item.update!(pending_account_setup: false)
|
||||
|
||||
# Schedule account syncs for the newly created accounts
|
||||
@simplefin_item.schedule_account_syncs
|
||||
|
||||
redirect_to simplefin_items_path, notice: "SimpleFin accounts have been set up successfully!"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_simplefin_item
|
||||
@simplefin_item = Current.family.simplefin_items.find(params[:id])
|
||||
end
|
||||
|
||||
def simplefin_params
|
||||
params.require(:simplefin_item).permit(:setup_token)
|
||||
end
|
||||
|
||||
def render_error(message, setup_token = nil)
|
||||
@simplefin_item = Current.family.simplefin_items.build(setup_token: setup_token)
|
||||
@error_message = message
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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}]`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ],
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
87
app/models/provider/simplefin.rb
Normal file
87
app/models/provider/simplefin.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
class Provider::Simplefin
|
||||
include HTTParty
|
||||
|
||||
headers "User-Agent" => "Sure Finance SimpleFin Client"
|
||||
default_options.merge!(verify: true, ssl_verify_mode: :peer)
|
||||
|
||||
def initialize
|
||||
end
|
||||
|
||||
def claim_access_url(setup_token)
|
||||
# Decode the base64 setup token to get the claim URL
|
||||
claim_url = Base64.decode64(setup_token)
|
||||
|
||||
response = HTTParty.post(claim_url)
|
||||
|
||||
case response.code
|
||||
when 200
|
||||
# The response body contains the access URL with embedded credentials
|
||||
response.body.strip
|
||||
when 403
|
||||
raise SimplefinError.new("Setup token may be compromised, expired, or already used", :token_compromised)
|
||||
else
|
||||
raise SimplefinError.new("Failed to claim access URL: #{response.code} #{response.message}", :claim_failed)
|
||||
end
|
||||
end
|
||||
|
||||
def get_accounts(access_url, start_date: nil, end_date: nil, pending: nil)
|
||||
# Build query parameters
|
||||
query_params = {}
|
||||
|
||||
# SimpleFin expects Unix timestamps for dates
|
||||
if start_date
|
||||
start_timestamp = start_date.to_time.to_i
|
||||
query_params["start-date"] = start_timestamp.to_s
|
||||
end
|
||||
|
||||
if end_date
|
||||
end_timestamp = end_date.to_time.to_i
|
||||
query_params["end-date"] = end_timestamp.to_s
|
||||
end
|
||||
|
||||
query_params["pending"] = pending ? "1" : "0" unless pending.nil?
|
||||
|
||||
accounts_url = "#{access_url}/accounts"
|
||||
accounts_url += "?#{URI.encode_www_form(query_params)}" unless query_params.empty?
|
||||
|
||||
|
||||
# The access URL already contains HTTP Basic Auth credentials
|
||||
response = HTTParty.get(accounts_url)
|
||||
|
||||
|
||||
case response.code
|
||||
when 200
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
when 400
|
||||
Rails.logger.error "SimpleFin API: Bad request - #{response.body}"
|
||||
raise SimplefinError.new("Bad request to SimpleFin API: #{response.body}", :bad_request)
|
||||
when 403
|
||||
raise SimplefinError.new("Access URL is no longer valid", :access_forbidden)
|
||||
when 402
|
||||
raise SimplefinError.new("Payment required to access this account", :payment_required)
|
||||
else
|
||||
Rails.logger.error "SimpleFin API: Unexpected response - Code: #{response.code}, Body: #{response.body}"
|
||||
raise SimplefinError.new("Failed to fetch accounts: #{response.code} #{response.message} - #{response.body}", :fetch_failed)
|
||||
end
|
||||
end
|
||||
|
||||
def get_info(base_url)
|
||||
response = HTTParty.get("#{base_url}/info")
|
||||
|
||||
case response.code
|
||||
when 200
|
||||
response.body.strip.split("\n")
|
||||
else
|
||||
raise SimplefinError.new("Failed to get server info: #{response.code} #{response.message}", :info_failed)
|
||||
end
|
||||
end
|
||||
|
||||
class SimplefinError < StandardError
|
||||
attr_reader :error_type
|
||||
|
||||
def initialize(message, error_type = :unknown)
|
||||
super(message)
|
||||
@error_type = error_type
|
||||
end
|
||||
end
|
||||
end
|
||||
93
app/models/simplefin_account.rb
Normal file
93
app/models/simplefin_account.rb
Normal file
@@ -0,0 +1,93 @@
|
||||
class SimplefinAccount < ApplicationRecord
|
||||
belongs_to :simplefin_item
|
||||
|
||||
has_one :account, dependent: :destroy
|
||||
|
||||
validates :name, :account_type, :currency, presence: true
|
||||
validate :has_balance
|
||||
|
||||
def upsert_simplefin_snapshot!(account_snapshot)
|
||||
# Convert to symbol keys or handle both string and symbol keys
|
||||
snapshot = account_snapshot.with_indifferent_access
|
||||
|
||||
# Map SimpleFin field names to our field names
|
||||
update!(
|
||||
current_balance: parse_balance(snapshot[:balance]),
|
||||
available_balance: parse_balance(snapshot[:"available-balance"]),
|
||||
currency: parse_currency(snapshot[:currency]),
|
||||
account_type: snapshot["type"] || "unknown",
|
||||
account_subtype: snapshot["subtype"],
|
||||
name: snapshot[:name],
|
||||
account_id: snapshot[:id],
|
||||
balance_date: parse_balance_date(snapshot[:"balance-date"]),
|
||||
extra: snapshot[:extra],
|
||||
org_data: snapshot[:org],
|
||||
raw_payload: account_snapshot
|
||||
)
|
||||
end
|
||||
|
||||
def upsert_simplefin_transactions_snapshot!(transactions_snapshot)
|
||||
assign_attributes(
|
||||
raw_transactions_payload: transactions_snapshot
|
||||
)
|
||||
|
||||
save!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_balance(balance_value)
|
||||
return nil if balance_value.nil?
|
||||
|
||||
case balance_value
|
||||
when String
|
||||
BigDecimal(balance_value)
|
||||
when Numeric
|
||||
BigDecimal(balance_value.to_s)
|
||||
else
|
||||
nil
|
||||
end
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
|
||||
def parse_currency(currency_value)
|
||||
return "USD" if currency_value.nil?
|
||||
|
||||
# SimpleFin currency can be a 3-letter code or a URL for custom currencies
|
||||
if currency_value.start_with?("http")
|
||||
# For custom currency URLs, we'll just use the last part as currency code
|
||||
# This is a simplification - in production you might want to fetch the currency info
|
||||
begin
|
||||
URI.parse(currency_value).path.split("/").last.upcase
|
||||
rescue URI::InvalidURIError => e
|
||||
Rails.logger.warn("Invalid currency URI for SimpleFin account: #{currency_value}, error: #{e.message}")
|
||||
"USD"
|
||||
end
|
||||
else
|
||||
currency_value.upcase
|
||||
end
|
||||
end
|
||||
|
||||
def parse_balance_date(balance_date_value)
|
||||
return nil if balance_date_value.nil?
|
||||
|
||||
case balance_date_value
|
||||
when String
|
||||
Time.parse(balance_date_value)
|
||||
when Numeric
|
||||
Time.at(balance_date_value)
|
||||
when Time, DateTime
|
||||
balance_date_value
|
||||
else
|
||||
nil
|
||||
end
|
||||
rescue ArgumentError, TypeError
|
||||
Rails.logger.warn("Invalid balance date for SimpleFin account: #{balance_date_value}")
|
||||
nil
|
||||
end
|
||||
def has_balance
|
||||
return if current_balance.present? || available_balance.present?
|
||||
errors.add(:base, "SimpleFin account must have either current or available balance")
|
||||
end
|
||||
end
|
||||
109
app/models/simplefin_account/processor.rb
Normal file
109
app/models/simplefin_account/processor.rb
Normal file
@@ -0,0 +1,109 @@
|
||||
class SimplefinAccount::Processor
|
||||
attr_reader :simplefin_account
|
||||
|
||||
def initialize(simplefin_account)
|
||||
@simplefin_account = simplefin_account
|
||||
end
|
||||
|
||||
def process
|
||||
ensure_account_exists
|
||||
process_transactions
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_account_exists
|
||||
return if simplefin_account.account.present?
|
||||
|
||||
# This should not happen in normal flow since accounts are created manually
|
||||
# during setup, but keeping as safety check
|
||||
Rails.logger.error("SimpleFin account #{simplefin_account.id} has no associated Account - this should not happen after manual setup")
|
||||
end
|
||||
|
||||
def process_transactions
|
||||
return unless simplefin_account.raw_transactions_payload.present?
|
||||
|
||||
account = simplefin_account.account
|
||||
transactions_data = simplefin_account.raw_transactions_payload
|
||||
|
||||
transactions_data.each do |transaction_data|
|
||||
process_transaction(account, transaction_data)
|
||||
end
|
||||
end
|
||||
|
||||
def process_transaction(account, transaction_data)
|
||||
# Handle both string and symbol keys
|
||||
data = transaction_data.with_indifferent_access
|
||||
|
||||
|
||||
# Convert SimpleFin transaction to internal Transaction format
|
||||
amount = parse_amount(data[:amount], account.currency)
|
||||
posted_date = parse_date(data[:posted])
|
||||
|
||||
# Use plaid_id field for external ID (works for both Plaid and SimpleFin)
|
||||
external_id = "simplefin_#{data[:id]}"
|
||||
|
||||
# Check if entry already exists
|
||||
existing_entry = Entry.find_by(plaid_id: external_id)
|
||||
|
||||
unless existing_entry
|
||||
# Create the transaction (entryable)
|
||||
transaction = Transaction.new(
|
||||
external_id: external_id
|
||||
)
|
||||
|
||||
# Create the entry with the transaction
|
||||
Entry.create!(
|
||||
account: account,
|
||||
name: data[:description] || "Unknown transaction",
|
||||
amount: amount,
|
||||
date: posted_date,
|
||||
currency: account.currency,
|
||||
entryable: transaction,
|
||||
plaid_id: external_id
|
||||
)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error("Failed to process SimpleFin transaction #{data[:id]}: #{e.message}")
|
||||
# Don't fail the entire sync for one bad transaction
|
||||
end
|
||||
|
||||
def parse_amount(amount_value, currency)
|
||||
parsed_amount = case amount_value
|
||||
when String
|
||||
BigDecimal(amount_value)
|
||||
when Numeric
|
||||
BigDecimal(amount_value.to_s)
|
||||
else
|
||||
BigDecimal("0")
|
||||
end
|
||||
|
||||
# SimpleFin uses banking convention (expenses negative, income positive)
|
||||
# Maybe expects opposite convention (expenses positive, income negative)
|
||||
# So we negate the amount to convert from SimpleFin to Maybe format
|
||||
-parsed_amount
|
||||
rescue ArgumentError => e
|
||||
Rails.logger.error "Failed to parse SimpleFin transaction amount: #{amount_value.inspect} - #{e.message}"
|
||||
BigDecimal("0")
|
||||
end
|
||||
|
||||
def parse_date(date_value)
|
||||
case date_value
|
||||
when String
|
||||
Date.parse(date_value)
|
||||
when Integer, Float
|
||||
# Unix timestamp
|
||||
Time.at(date_value).to_date
|
||||
when Time, DateTime
|
||||
date_value.to_date
|
||||
when Date
|
||||
date_value
|
||||
else
|
||||
Rails.logger.error("SimpleFin transaction has invalid date value: #{date_value.inspect}")
|
||||
raise ArgumentError, "Invalid date format: #{date_value.inspect}"
|
||||
end
|
||||
rescue ArgumentError, TypeError => e
|
||||
Rails.logger.error("Failed to parse SimpleFin transaction date '#{date_value}': #{e.message}")
|
||||
raise ArgumentError, "Unable to parse transaction date: #{date_value.inspect}"
|
||||
end
|
||||
end
|
||||
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
|
||||
107
app/models/simplefin_item/importer.rb
Normal file
107
app/models/simplefin_item/importer.rb
Normal file
@@ -0,0 +1,107 @@
|
||||
class SimplefinItem::Importer
|
||||
attr_reader :simplefin_item, :simplefin_provider
|
||||
|
||||
def initialize(simplefin_item, simplefin_provider:)
|
||||
@simplefin_item = simplefin_item
|
||||
@simplefin_provider = simplefin_provider
|
||||
end
|
||||
|
||||
def import
|
||||
# Determine start date based on sync history
|
||||
start_date = determine_sync_start_date
|
||||
|
||||
if start_date
|
||||
else
|
||||
end
|
||||
|
||||
accounts_data = simplefin_provider.get_accounts(
|
||||
simplefin_item.access_url,
|
||||
start_date: start_date
|
||||
)
|
||||
|
||||
# Handle errors if present
|
||||
if accounts_data[:errors] && accounts_data[:errors].any?
|
||||
handle_errors(accounts_data[:errors])
|
||||
return
|
||||
end
|
||||
|
||||
# Store raw payload
|
||||
simplefin_item.upsert_simplefin_snapshot!(accounts_data)
|
||||
|
||||
# Import accounts - accounts_data[:accounts] is an array
|
||||
accounts_data[:accounts]&.each do |account_data|
|
||||
import_account(account_data)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def determine_sync_start_date
|
||||
# For the first sync, get all available data by using a very wide date range
|
||||
# SimpleFin requires a start_date parameter - without it, only returns recent transactions
|
||||
unless simplefin_item.last_synced_at
|
||||
return 100.years.ago # Set to 100 years for first sync to get everything just to be sure
|
||||
end
|
||||
|
||||
# For subsequent syncs, fetch from last sync date with a buffer
|
||||
# Use 7 days buffer to ensure we don't miss any late-posting transactions
|
||||
simplefin_item.last_synced_at - 7.days
|
||||
end
|
||||
|
||||
def import_account(account_data)
|
||||
# Import organization data from the account if present and not already imported
|
||||
if account_data[:org] && simplefin_item.institution_id.blank?
|
||||
import_organization(account_data[:org])
|
||||
end
|
||||
|
||||
simplefin_account = simplefin_item.simplefin_accounts.find_or_initialize_by(
|
||||
account_id: account_data[:id]
|
||||
)
|
||||
|
||||
# Store transactions temporarily
|
||||
transactions = account_data[:transactions]
|
||||
|
||||
# Update account snapshot first (without transactions)
|
||||
simplefin_account.upsert_simplefin_snapshot!(account_data)
|
||||
|
||||
# Then save transactions separately (so they don't get overwritten)
|
||||
if transactions && transactions.any?
|
||||
simplefin_account.update!(raw_transactions_payload: transactions)
|
||||
else
|
||||
end
|
||||
end
|
||||
|
||||
def import_organization(org_data)
|
||||
# Create normalized institution data for compatibility
|
||||
normalized_data = {
|
||||
id: org_data[:domain] || org_data[:"sfin-url"],
|
||||
name: org_data[:name] || extract_domain_name(org_data[:domain]),
|
||||
url: org_data[:domain] || org_data[:"sfin-url"],
|
||||
# Store the complete raw organization data
|
||||
raw_org_data: org_data
|
||||
}
|
||||
|
||||
simplefin_item.upsert_simplefin_institution_snapshot!(normalized_data)
|
||||
end
|
||||
|
||||
def extract_domain_name(domain)
|
||||
return "Unknown Institution" if domain.blank?
|
||||
|
||||
# Extract a readable name from domain like "mybank.com" -> "Mybank"
|
||||
domain.split(".").first.capitalize
|
||||
end
|
||||
|
||||
def handle_errors(errors)
|
||||
error_messages = errors.map { |error| error[:description] || error[:message] }.join(", ")
|
||||
|
||||
# Mark item as requiring update for certain error types
|
||||
if errors.any? { |error| error[:code] == "auth_failure" || error[:code] == "token_expired" }
|
||||
simplefin_item.update!(status: :requires_update)
|
||||
end
|
||||
|
||||
raise Provider::Simplefin::SimplefinError.new(
|
||||
"SimpleFin API errors: #{error_messages}",
|
||||
:api_error
|
||||
)
|
||||
end
|
||||
end
|
||||
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
|
||||
34
app/models/simplefin_item/syncer.rb
Normal file
34
app/models/simplefin_item/syncer.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
class SimplefinItem::Syncer
|
||||
attr_reader :simplefin_item
|
||||
|
||||
def initialize(simplefin_item)
|
||||
@simplefin_item = simplefin_item
|
||||
end
|
||||
|
||||
def perform_sync(sync)
|
||||
# Loads item metadata, accounts, transactions from SimpleFin API
|
||||
simplefin_item.import_latest_simplefin_data
|
||||
|
||||
# Check if we have new SimpleFin accounts that need setup
|
||||
unlinked_accounts = simplefin_item.simplefin_accounts.includes(:account).where(accounts: { id: nil })
|
||||
if unlinked_accounts.any?
|
||||
# Mark as pending account setup so user can choose account types
|
||||
simplefin_item.update!(pending_account_setup: true)
|
||||
return
|
||||
end
|
||||
|
||||
# Processes the raw SimpleFin data and updates internal domain objects
|
||||
simplefin_item.process_accounts
|
||||
|
||||
# All data is synced, so we can now run an account sync to calculate historical balances and more
|
||||
simplefin_item.schedule_account_syncs(
|
||||
parent_sync: sync,
|
||||
window_start_date: sync.window_start_date,
|
||||
window_end_date: sync.window_end_date
|
||||
)
|
||||
end
|
||||
|
||||
def perform_post_sync
|
||||
# no-op
|
||||
end
|
||||
end
|
||||
@@ -16,7 +16,12 @@
|
||||
</span>
|
||||
</p>
|
||||
<% else %>
|
||||
<%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
|
||||
<div>
|
||||
<%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
|
||||
<% if account.simplefin_account&.org_data&.dig('name') %>
|
||||
<span class="text-secondary">• <%= account.simplefin_account.org_data["name"] %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if account.long_subtype_label %>
|
||||
<p class="text-sm text-secondary truncate"><%= account.long_subtype_label %></p>
|
||||
<% end %>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<% if @manual_accounts.empty? && @plaid_items.empty? %>
|
||||
<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? %>
|
||||
<%= render "empty" %>
|
||||
<% else %>
|
||||
<div class="space-y-2">
|
||||
@@ -29,6 +29,10 @@
|
||||
<%= render @plaid_items.sort_by(&:created_at) %>
|
||||
<% end %>
|
||||
|
||||
<% if @simplefin_items.any? %>
|
||||
<%= render @simplefin_items.sort_by(&:created_at) %>
|
||||
<% end %>
|
||||
|
||||
<% if @manual_accounts.any? %>
|
||||
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %>
|
||||
<% end %>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<% if show_us_link %>
|
||||
<%# Default US-only Link %>
|
||||
<%= link_to new_plaid_item_path(region: "us", accountable_type: accountable_type),
|
||||
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2",
|
||||
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-primary px-2 hover:bg-surface rounded-lg p-2",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
|
||||
<%= icon("link-2") %>
|
||||
@@ -24,7 +24,7 @@
|
||||
<%# EU Link %>
|
||||
<% if show_eu_link %>
|
||||
<%= link_to new_plaid_item_path(region: "eu", accountable_type: accountable_type),
|
||||
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2",
|
||||
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-primary px-2 hover:bg-surface rounded-lg p-2",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
|
||||
<%= icon("link-2") %>
|
||||
@@ -32,5 +32,6 @@
|
||||
<%= t("accounts.new.method_selector.connected_entry_eu") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -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
|
||||
) %>
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
},
|
||||
|
||||
104
app/views/simplefin_items/_simplefin_item.html.erb
Normal file
104
app/views/simplefin_items/_simplefin_item.html.erb
Normal file
@@ -0,0 +1,104 @@
|
||||
<%# locals: (simplefin_item:) %>
|
||||
|
||||
<%= tag.div id: dom_id(simplefin_item) do %>
|
||||
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<summary class="flex items-center justify-between gap-2 focus-visible:outline-hidden">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
|
||||
|
||||
<% if simplefin_item.logo.attached? %>
|
||||
<div class="flex items-center justify-center h-8 w-8 rounded-full overflow-hidden">
|
||||
<%= image_tag simplefin_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= render DS::FilledIcon.new(
|
||||
variant: :container,
|
||||
text: simplefin_item.name.first.upcase,
|
||||
size: "md"
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
<div class="pl-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<%= tag.p simplefin_item.name, class: "font-medium text-primary" %>
|
||||
<% if simplefin_item.institution_name.present? %>
|
||||
<p class="text-secondary text-xs"><%= simplefin_item.institution_name %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if simplefin_item.scheduled_for_deletion? %>
|
||||
<p class="text-destructive text-sm animate-pulse">(deletion in progress...)</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if simplefin_item.syncing? %>
|
||||
<div class="text-secondary flex items-center gap-1">
|
||||
<%= icon "loader", size: "sm", class: "animate-pulse" %>
|
||||
<%= tag.span "Syncing..." %>
|
||||
</div>
|
||||
<% elsif simplefin_item.requires_update? %>
|
||||
<div class="text-warning flex items-center gap-1">
|
||||
<%= icon "alert-triangle", size: "sm", color: "warning" %>
|
||||
<%= tag.span "Requires Update" %>
|
||||
</div>
|
||||
<% elsif simplefin_item.sync_error.present? %>
|
||||
<div class="text-secondary flex items-center gap-1">
|
||||
<%= icon "alert-circle", size: "sm", color: "destructive" %>
|
||||
<%= tag.span "Error", class: "text-destructive" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-secondary">
|
||||
<%= simplefin_item.last_synced_at ? "Last synced #{time_ago_in_words(simplefin_item.last_synced_at)} ago" : "Never synced" %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<% if Rails.env.development? %>
|
||||
<%= icon(
|
||||
"refresh-cw",
|
||||
as_button: true,
|
||||
href: sync_simplefin_item_path(simplefin_item)
|
||||
) %>
|
||||
<% end %>
|
||||
|
||||
<%= render DS::Menu.new do |menu| %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: "Delete",
|
||||
icon: "trash-2",
|
||||
href: simplefin_item_path(simplefin_item),
|
||||
method: :delete,
|
||||
confirm: CustomConfirm.for_resource_deletion(simplefin_item.name, high_severity: true)
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<% unless simplefin_item.scheduled_for_deletion? %>
|
||||
<div class="space-y-4 mt-4">
|
||||
<% if simplefin_item.accounts.any? %>
|
||||
<%= render "accounts/index/account_groups", accounts: simplefin_item.accounts %>
|
||||
<% end %>
|
||||
|
||||
<% if simplefin_item.pending_account_setup? %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
<p class="text-primary font-medium text-sm">New accounts ready to set up</p>
|
||||
<p class="text-secondary text-sm">Choose account types for your newly imported SimpleFin accounts.</p>
|
||||
<%= render DS::Link.new(
|
||||
text: "Set Up New Accounts",
|
||||
icon: "settings",
|
||||
variant: "primary",
|
||||
href: setup_accounts_simplefin_item_path(simplefin_item)
|
||||
) %>
|
||||
</div>
|
||||
<% elsif simplefin_item.accounts.empty? %>
|
||||
<div class="p-4 flex flex-col gap-3 items-center justify-center">
|
||||
<p class="text-primary font-medium text-sm">No accounts found</p>
|
||||
<p class="text-secondary text-sm">This connection doesn't have any synchronized accounts yet.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</details>
|
||||
<% end %>
|
||||
14
app/views/simplefin_items/_subtype_select.html.erb
Normal file
14
app/views/simplefin_items/_subtype_select.html.erb
Normal file
@@ -0,0 +1,14 @@
|
||||
<div class="subtype-select" data-type="<%= account_type %>" style="display: none;">
|
||||
<% if subtype_config[:options].present? %>
|
||||
<%= label_tag "account_subtypes[#{simplefin_account.id}]", subtype_config[:label],
|
||||
class: "block text-sm font-medium text-primary mb-2" %>
|
||||
<% selected_value = account_type == "Depository" ?
|
||||
(simplefin_account.name.downcase.include?("checking") ? "checking" :
|
||||
simplefin_account.name.downcase.include?("savings") ? "savings" : "") : "" %>
|
||||
<%= select_tag "account_subtypes[#{simplefin_account.id}]",
|
||||
options_for_select([["Select #{account_type == 'Depository' ? 'subtype' : 'type'}", ""]] + subtype_config[:options], selected_value),
|
||||
{ class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full" } %>
|
||||
<% else %>
|
||||
<p class="text-sm text-secondary"><%= subtype_config[:message] %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
42
app/views/simplefin_items/index.html.erb
Normal file
42
app/views/simplefin_items/index.html.erb
Normal file
@@ -0,0 +1,42 @@
|
||||
<% content_for :title, "SimpleFin Connections" %>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-primary">SimpleFin Connections</h1>
|
||||
<p class="text-secondary mt-1">Manage your SimpleFin bank account connections</p>
|
||||
</div>
|
||||
|
||||
<%= render DS::Link.new(
|
||||
text: "Add Connection",
|
||||
icon: "plus",
|
||||
variant: "primary",
|
||||
href: new_simplefin_item_path
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<% if @simplefin_items.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @simplefin_items.each do |simplefin_item| %>
|
||||
<%= render "simplefin_item", simplefin_item: simplefin_item %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<div class="space-y-3 text-center flex flex-col items-center">
|
||||
<%= render DS::FilledIcon.new(
|
||||
variant: :container,
|
||||
icon: "building-2",
|
||||
) %>
|
||||
|
||||
<p class="text-sm font-medium text-primary">No SimpleFin connections</p>
|
||||
<p class="text-secondary text-sm">Connect your bank accounts through SimpleFin to automatically sync transactions.</p>
|
||||
<%= render DS::Link.new(
|
||||
text: "Add your first connection",
|
||||
variant: "primary",
|
||||
href: new_simplefin_item_path
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
46
app/views/simplefin_items/new.html.erb
Normal file
46
app/views/simplefin_items/new.html.erb
Normal file
@@ -0,0 +1,46 @@
|
||||
<% content_for :title, "Add SimpleFin Connection" %>
|
||||
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: "Add SimpleFin Connection") %>
|
||||
<% dialog.with_body do %>
|
||||
<% if @error_message.present? %>
|
||||
<%= render DS::Alert.new(message: @error_message, variant: :error) %>
|
||||
<% end %>
|
||||
<%= styled_form_with model: @simplefin_item, local: true, data: { turbo: false }, class: "flex flex-col gap-4 justify-between grow text-primary" do |form| %>
|
||||
<div class="grow space-y-2">
|
||||
<%= form.text_area :setup_token,
|
||||
label: "SimpleFin Setup Token",
|
||||
placeholder: "Paste your SimpleFin setup token here...",
|
||||
rows: 4,
|
||||
required: true %>
|
||||
|
||||
<p class="text-xs text-secondary">
|
||||
Get your setup token from
|
||||
<%= link_to "SimpleFin Bridge", "https://bridge.simplefin.org/simplefin/create",
|
||||
target: "_blank",
|
||||
class: "text-link underline" %>
|
||||
</p>
|
||||
|
||||
<div class="bg-surface border border-primary p-4 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-primary mb-1">How to get your setup token:</h3>
|
||||
<ol class="text-xs text-secondary space-y-1 list-decimal list-inside">
|
||||
<li>Visit <%= link_to "SimpleFin Bridge", "https://bridge.simplefin.org/simplefin/create", target: "_blank", class: "text-link underline" %></li>
|
||||
<li>Connect your bank account using your online banking credentials</li>
|
||||
<li>Copy the SimpleFin setup token that appears (it will be a long Base64-encoded string)</li>
|
||||
<li>Paste it above and click "Add Connection"</li>
|
||||
</ol>
|
||||
<p class="text-xs text-secondary mt-2">
|
||||
<strong>Note:</strong> Setup tokens can only be used once. If the connection fails, you'll need to create a new token.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form.submit "Add Connection" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
95
app/views/simplefin_items/setup_accounts.html.erb
Normal file
95
app/views/simplefin_items/setup_accounts.html.erb
Normal file
@@ -0,0 +1,95 @@
|
||||
<% content_for :title, "Set Up SimpleFin Accounts" %>
|
||||
|
||||
<%= render DS::Dialog.new do |dialog| %>
|
||||
<% dialog.with_header(title: "Set Up Your SimpleFin Accounts") do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "building-2", class: "text-primary" %>
|
||||
<span class="text-primary">Choose the correct account types for your imported accounts</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% dialog.with_body do %>
|
||||
<%= form_with url: complete_account_setup_simplefin_item_path(@simplefin_item),
|
||||
method: :post,
|
||||
local: true,
|
||||
data: { turbo: false },
|
||||
class: "space-y-6" do |form| %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-surface border border-primary p-4 rounded-lg">
|
||||
<div class="flex items-start gap-3">
|
||||
<%= icon "info", size: "sm", class: "text-primary mt-0.5 flex-shrink-0" %>
|
||||
<div>
|
||||
<p class="text-sm text-primary mb-2">
|
||||
<strong>Choose the correct account type for each SimpleFin account:</strong>
|
||||
</p>
|
||||
<ul class="text-xs text-secondary space-y-1 list-disc list-inside">
|
||||
<li><strong>Checking or Savings</strong> - Regular bank accounts</li>
|
||||
<li><strong>Credit Card</strong> - Credit card accounts</li>
|
||||
<li><strong>Investment</strong> - Brokerage, 401(k), IRA accounts</li>
|
||||
<li><strong>Loan or Mortgage</strong> - Debt accounts</li>
|
||||
<li><strong>Other Asset</strong> - Everything else</li>
|
||||
<li><strong>Skip - don't add</strong> - Don't import this account</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% @simplefin_accounts.each do |simplefin_account| %>
|
||||
<div class="border border-primary rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="font-medium text-primary">
|
||||
<%= simplefin_account.name %>
|
||||
<% if simplefin_account.org_data.present? && simplefin_account.org_data['name'].present? %>
|
||||
<span class="text-secondary">• <%= simplefin_account.org_data["name"] %></span>
|
||||
<% elsif @simplefin_item.institution_name.present? %>
|
||||
<span class="text-secondary">• <%= @simplefin_item.institution_name %></span>
|
||||
<% end %>
|
||||
</h3>
|
||||
<p class="text-sm text-secondary">
|
||||
Balance: <%= number_to_currency(simplefin_account.current_balance || 0, unit: simplefin_account.currency) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3" data-controller="account-type-selector" data-account-type-selector-account-id-value="<%= simplefin_account.id %>">
|
||||
<div>
|
||||
<%= label_tag "account_types[#{simplefin_account.id}]", "Account Type:",
|
||||
class: "block text-sm font-medium text-primary mb-2" %>
|
||||
<%= select_tag "account_types[#{simplefin_account.id}]",
|
||||
options_for_select(@account_type_options),
|
||||
{ class: "appearance-none bg-container border border-primary rounded-md px-3 py-2 text-sm leading-6 text-primary focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none w-full",
|
||||
data: {
|
||||
action: "change->account-type-selector#updateSubtype"
|
||||
} } %>
|
||||
</div>
|
||||
|
||||
<!-- Subtype dropdowns (shown/hidden based on account type) -->
|
||||
<div data-account-type-selector-target="subtypeContainer">
|
||||
<% @subtype_options.each do |account_type, subtype_config| %>
|
||||
<%= render "subtype_select", account_type: account_type, subtype_config: subtype_config, simplefin_account: simplefin_account %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= render DS::Button.new(
|
||||
text: "Create Accounts",
|
||||
variant: "primary",
|
||||
icon: "plus",
|
||||
type: "submit",
|
||||
class: "flex-1"
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: "Cancel",
|
||||
variant: "secondary",
|
||||
href: simplefin_items_path
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
105
app/views/simplefin_items/show.html.erb
Normal file
105
app/views/simplefin_items/show.html.erb
Normal file
@@ -0,0 +1,105 @@
|
||||
<% content_for :title, @simplefin_item.name %>
|
||||
|
||||
<div class="mb-8">
|
||||
<%= link_to simplefin_items_path, class: "text-secondary hover:text-primary" do %>
|
||||
← Back to SimpleFin Connections
|
||||
<% end %>
|
||||
<h1 class="text-2xl font-bold mt-2"><%= @simplefin_item.name %></h1>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<%= button_to sync_simplefin_item_path(@simplefin_item), method: :post, class: "inline-flex items-center gap-2 px-4 py-2 bg-surface border border-primary rounded-lg text-primary font-medium hover:bg-surface-hover focus:ring-2 focus:ring-primary focus:ring-offset-2" do %>
|
||||
<%= icon "refresh-cw", size: "sm" %>
|
||||
Sync
|
||||
<% end %>
|
||||
<%= button_to simplefin_item_path(@simplefin_item), method: :delete, data: { confirm: "Are you sure?" }, class: "inline-flex items-center gap-2 px-4 py-2 bg-destructive border border-destructive rounded-lg text-white font-medium hover:bg-destructive-hover focus:ring-2 focus:ring-destructive focus:ring-offset-2" do %>
|
||||
<%= icon "trash", size: "sm" %>
|
||||
Delete
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<% if @simplefin_item.syncing? %>
|
||||
<div class="p-4 bg-surface border border-primary rounded-lg">
|
||||
<div class="flex items-center">
|
||||
<%= icon "loader-2", class: "w-5 h-5 text-primary animate-spin mr-2" %>
|
||||
<p class="text-primary">Syncing accounts...</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @simplefin_item.accounts.any? %>
|
||||
<%= render "accounts/index/account_groups", accounts: @simplefin_item.accounts %>
|
||||
<% elsif @simplefin_item.simplefin_accounts.any? %>
|
||||
<div class="bg-container-inset p-1 rounded-xl">
|
||||
<div class="flex items-center px-4 py-2 text-xs font-medium text-secondary">
|
||||
<p>SimpleFin Accounts</p>
|
||||
<span class="text-subdued mx-2">·</span>
|
||||
<p><%= @simplefin_item.simplefin_accounts.count %></p>
|
||||
</div>
|
||||
<div class="bg-container rounded-lg shadow-border-xs">
|
||||
<% @simplefin_item.simplefin_accounts.each_with_index do |simplefin_account, index| %>
|
||||
<div class="p-4 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<%= render DS::FilledIcon.new(
|
||||
variant: :container,
|
||||
text: simplefin_account.name.first.upcase,
|
||||
size: "md"
|
||||
) %>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-primary">
|
||||
<%= simplefin_account.name %>
|
||||
<% if simplefin_account.org_data.present? && simplefin_account.org_data['name'].present? %>
|
||||
<span class="text-secondary">• <%= simplefin_account.org_data["name"] %></span>
|
||||
<% elsif @simplefin_item.institution_name.present? %>
|
||||
<span class="text-secondary">• <%= @simplefin_item.institution_name %></span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="text-sm text-secondary">
|
||||
<%= simplefin_account.account_type&.humanize || "Unknown Type" %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-8">
|
||||
<p class="text-sm font-medium text-primary">
|
||||
<%= number_to_currency(simplefin_account.current_balance || 0) %>
|
||||
</p>
|
||||
<% if simplefin_account.account %>
|
||||
<%= render DS::Link.new(
|
||||
text: "View Account",
|
||||
href: account_path(simplefin_account.account),
|
||||
variant: :outline
|
||||
) %>
|
||||
<% else %>
|
||||
<%= render DS::Link.new(
|
||||
text: "Set Up Account",
|
||||
href: setup_accounts_simplefin_item_path(@simplefin_item),
|
||||
variant: :primary,
|
||||
icon: "settings"
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% unless index == @simplefin_item.simplefin_accounts.count - 1 %>
|
||||
<%= render "shared/ruler" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<div class="space-y-3 text-center flex flex-col items-center">
|
||||
<%= render DS::FilledIcon.new(
|
||||
variant: :container,
|
||||
icon: "building-2",
|
||||
) %>
|
||||
|
||||
<p class="text-sm font-medium text-primary">No accounts found</p>
|
||||
<p class="text-secondary text-sm">Try syncing again to import your accounts.</p>
|
||||
<%= button_to sync_simplefin_item_path(@simplefin_item), method: :post, class: "inline-flex items-center gap-2 px-4 py-2 bg-primary border border-primary rounded-lg text-white font-medium hover:bg-primary-hover focus:ring-2 focus:ring-primary focus:ring-offset-2" do %>
|
||||
<%= icon "refresh-cw", size: "sm" %>
|
||||
Sync Now
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
20
db/migrate/20250807143728_create_simplefin_items.rb
Normal file
20
db/migrate/20250807143728_create_simplefin_items.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
class CreateSimplefinItems < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :simplefin_items, id: :uuid do |t|
|
||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||
t.text :access_url
|
||||
t.string :name
|
||||
t.string :institution_id
|
||||
t.string :institution_name
|
||||
t.string :institution_url
|
||||
t.string :status, default: "good"
|
||||
t.boolean :scheduled_for_deletion, default: false
|
||||
|
||||
t.index :status
|
||||
t.jsonb :raw_payload
|
||||
t.jsonb :raw_institution_payload
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
20
db/migrate/20250807143819_create_simplefin_accounts.rb
Normal file
20
db/migrate/20250807143819_create_simplefin_accounts.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
class CreateSimplefinAccounts < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :simplefin_accounts, id: :uuid do |t|
|
||||
t.references :simplefin_item, null: false, foreign_key: true, type: :uuid
|
||||
t.string :name
|
||||
t.string :account_id
|
||||
t.string :currency
|
||||
t.decimal :current_balance, precision: 19, scale: 4
|
||||
t.decimal :available_balance, precision: 19, scale: 4
|
||||
|
||||
t.index :account_id
|
||||
t.string :account_type
|
||||
t.string :account_subtype
|
||||
t.jsonb :raw_payload
|
||||
t.jsonb :raw_transactions_payload
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
13
db/migrate/20250807170943_add_subtype_to_accountables.rb
Normal file
13
db/migrate/20250807170943_add_subtype_to_accountables.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class AddSubtypeToAccountables < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :depositories, :subtype, :string
|
||||
add_column :investments, :subtype, :string
|
||||
add_column :loans, :subtype, :string
|
||||
add_column :credit_cards, :subtype, :string
|
||||
add_column :other_assets, :subtype, :string
|
||||
add_column :other_liabilities, :subtype, :string
|
||||
add_column :properties, :subtype, :string
|
||||
add_column :vehicles, :subtype, :string
|
||||
add_column :cryptos, :subtype, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddBalanceDateToSimplefinAccounts < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :simplefin_accounts, :balance_date, :datetime
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddExtraSimplefinAccountFields < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :simplefin_accounts, :extra, :jsonb
|
||||
add_column :simplefin_accounts, :org_data, :jsonb
|
||||
end
|
||||
end
|
||||
56
db/schema.rb
generated
56
db/schema.rb
generated
@@ -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"
|
||||
|
||||
@@ -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?
|
||||
|
||||
45
test/controllers/simplefin_items_controller_test.rb
Normal file
45
test/controllers/simplefin_items_controller_test.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
require "test_helper"
|
||||
|
||||
class SimplefinItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in users(:family_admin)
|
||||
@family = families(:dylan_family)
|
||||
@simplefin_item = SimplefinItem.create!(
|
||||
family: @family,
|
||||
name: "Test Connection",
|
||||
access_url: "https://example.com/test_access"
|
||||
)
|
||||
end
|
||||
|
||||
test "should get index" do
|
||||
get simplefin_items_url
|
||||
assert_response :success
|
||||
assert_includes response.body, @simplefin_item.name
|
||||
end
|
||||
|
||||
test "should get new" do
|
||||
get new_simplefin_item_url
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should show simplefin item" do
|
||||
get simplefin_item_url(@simplefin_item)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should destroy simplefin item" do
|
||||
assert_difference("SimplefinItem.count", 0) do # doesn't actually delete immediately
|
||||
delete simplefin_item_url(@simplefin_item)
|
||||
end
|
||||
|
||||
assert_redirected_to simplefin_items_path
|
||||
@simplefin_item.reload
|
||||
assert @simplefin_item.scheduled_for_deletion?
|
||||
end
|
||||
|
||||
test "should sync simplefin item" do
|
||||
post sync_simplefin_item_url(@simplefin_item)
|
||||
assert_redirected_to simplefin_item_path(@simplefin_item)
|
||||
assert_equal "Sync started", flash[:notice]
|
||||
end
|
||||
end
|
||||
1
test/fixtures/accounts.yml
vendored
1
test/fixtures/accounts.yml
vendored
@@ -30,7 +30,6 @@ connected:
|
||||
name: Plaid Depository Account
|
||||
balance: 5000
|
||||
currency: USD
|
||||
subtype: checking
|
||||
accountable_type: Depository
|
||||
accountable: two
|
||||
plaid_account: one
|
||||
|
||||
2
test/fixtures/depositories.yml
vendored
2
test/fixtures/depositories.yml
vendored
@@ -1,2 +1,2 @@
|
||||
one: { }
|
||||
two: {}
|
||||
two: { subtype: checking }
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
84
test/models/simplefin_account_test.rb
Normal file
84
test/models/simplefin_account_test.rb
Normal file
@@ -0,0 +1,84 @@
|
||||
require "test_helper"
|
||||
|
||||
class SimplefinAccountTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@simplefin_item = SimplefinItem.create!(
|
||||
family: @family,
|
||||
name: "Test SimpleFin Connection",
|
||||
access_url: "https://example.com/access_token"
|
||||
)
|
||||
@simplefin_account = SimplefinAccount.create!(
|
||||
simplefin_item: @simplefin_item,
|
||||
name: "Test Checking Account",
|
||||
account_id: "test_checking_123",
|
||||
currency: "USD",
|
||||
account_type: "checking",
|
||||
current_balance: 1500.50
|
||||
)
|
||||
end
|
||||
|
||||
test "belongs to simplefin_item" do
|
||||
assert_equal @simplefin_item, @simplefin_account.simplefin_item
|
||||
end
|
||||
|
||||
test "validates presence of required fields" do
|
||||
account = SimplefinAccount.new
|
||||
refute account.valid?
|
||||
|
||||
assert_includes account.errors[:name], "can't be blank"
|
||||
assert_includes account.errors[:account_type], "can't be blank"
|
||||
assert_includes account.errors[:currency], "can't be blank"
|
||||
end
|
||||
|
||||
test "validates balance presence" do
|
||||
account = SimplefinAccount.new(
|
||||
simplefin_item: @simplefin_item,
|
||||
name: "No Balance Account",
|
||||
account_id: "no_balance_123",
|
||||
currency: "USD",
|
||||
account_type: "checking"
|
||||
)
|
||||
|
||||
refute account.valid?
|
||||
assert_includes account.errors[:base], "SimpleFin account must have either current or available balance"
|
||||
end
|
||||
|
||||
test "can upsert snapshot data" do
|
||||
balance_date = "2024-01-15T10:30:00Z"
|
||||
snapshot = {
|
||||
"balance" => 2000.0,
|
||||
"available-balance" => 1800.0,
|
||||
"balance-date" => balance_date,
|
||||
"currency" => "USD",
|
||||
"type" => "savings",
|
||||
"subtype" => "savings",
|
||||
"name" => "Updated Savings Account",
|
||||
"id" => "updated_123",
|
||||
"extra" => { "account_number_last_4" => "1234" },
|
||||
"org" => { "domain" => "testbank.com", "name" => "Test Bank" }
|
||||
}
|
||||
|
||||
@simplefin_account.upsert_simplefin_snapshot!(snapshot)
|
||||
|
||||
assert_equal BigDecimal("2000.0"), @simplefin_account.current_balance
|
||||
assert_equal BigDecimal("1800.0"), @simplefin_account.available_balance
|
||||
assert_equal Time.parse(balance_date), @simplefin_account.balance_date
|
||||
assert_equal "savings", @simplefin_account.account_type
|
||||
assert_equal "Updated Savings Account", @simplefin_account.name
|
||||
assert_equal({ "account_number_last_4" => "1234" }, @simplefin_account.extra)
|
||||
assert_equal({ "domain" => "testbank.com", "name" => "Test Bank" }, @simplefin_account.org_data)
|
||||
assert_equal snapshot, @simplefin_account.raw_payload
|
||||
end
|
||||
|
||||
test "can upsert transactions" do
|
||||
transactions = [
|
||||
{ "id" => "txn_1", "amount" => -50.00, "description" => "Coffee Shop", "posted" => "2024-01-01" },
|
||||
{ "id" => "txn_2", "amount" => 1000.00, "description" => "Paycheck", "posted" => "2024-01-02" }
|
||||
]
|
||||
|
||||
@simplefin_account.upsert_simplefin_transactions_snapshot!(transactions)
|
||||
|
||||
assert_equal transactions, @simplefin_account.raw_transactions_payload
|
||||
end
|
||||
end
|
||||
64
test/models/simplefin_item_test.rb
Normal file
64
test/models/simplefin_item_test.rb
Normal file
@@ -0,0 +1,64 @@
|
||||
require "test_helper"
|
||||
|
||||
class SimplefinItemTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@simplefin_item = SimplefinItem.create!(
|
||||
family: @family,
|
||||
name: "Test SimpleFin Connection",
|
||||
access_url: "https://example.com/access_token"
|
||||
)
|
||||
end
|
||||
|
||||
test "belongs to family" do
|
||||
assert_equal @family, @simplefin_item.family
|
||||
end
|
||||
|
||||
test "has many simplefin_accounts" do
|
||||
account = @simplefin_item.simplefin_accounts.create!(
|
||||
name: "Test Account",
|
||||
account_id: "test_123",
|
||||
currency: "USD",
|
||||
account_type: "checking",
|
||||
current_balance: 1000.00
|
||||
)
|
||||
|
||||
assert_includes @simplefin_item.simplefin_accounts, account
|
||||
end
|
||||
|
||||
test "has good status by default" do
|
||||
assert_equal "good", @simplefin_item.status
|
||||
end
|
||||
|
||||
test "can be marked for deletion" do
|
||||
refute @simplefin_item.scheduled_for_deletion?
|
||||
|
||||
@simplefin_item.destroy_later
|
||||
|
||||
assert @simplefin_item.scheduled_for_deletion?
|
||||
end
|
||||
|
||||
test "is syncable" do
|
||||
assert_respond_to @simplefin_item, :sync_later
|
||||
assert_respond_to @simplefin_item, :syncing?
|
||||
end
|
||||
|
||||
test "scopes work correctly" do
|
||||
# Create one for deletion
|
||||
item_for_deletion = SimplefinItem.create!(
|
||||
family: @family,
|
||||
name: "Delete Me",
|
||||
access_url: "https://example.com/delete_token",
|
||||
scheduled_for_deletion: true
|
||||
)
|
||||
|
||||
active_items = SimplefinItem.active
|
||||
ordered_items = SimplefinItem.ordered
|
||||
|
||||
assert_includes active_items, @simplefin_item
|
||||
refute_includes active_items, item_for_deletion
|
||||
|
||||
assert_equal [ @simplefin_item, item_for_deletion ].sort_by(&:created_at).reverse,
|
||||
ordered_items.to_a
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user