mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 15:35:22 +02:00
feat: Holdings + Transactions
- Added initial syncing for holdings and transactions - Fixed accounts display not displaying anything for SimpleFIN connections - Centralized all migrations to one file
This commit is contained in:
parent
a5cf70f2df
commit
5f151ec66f
20 changed files with 339 additions and 81 deletions
|
@ -52,6 +52,7 @@ APP_DOMAIN=
|
||||||
# Allows configuration of SimpleFIN for account linking: https://www.simplefin.org/
|
# Allows configuration of SimpleFIN for account linking: https://www.simplefin.org/
|
||||||
# You'll want to follow the steps here for getting an AccessURL https://beta-bridge.simplefin.org/info/developers
|
# You'll want to follow the steps here for getting an AccessURL https://beta-bridge.simplefin.org/info/developers
|
||||||
SIMPLE_FIN_ACCESS_URL=
|
SIMPLE_FIN_ACCESS_URL=
|
||||||
|
SIMPLE_FIN_UPDATE_CRON="0 6 * * *"
|
||||||
|
|
||||||
# Disable enforcing SSL connections
|
# Disable enforcing SSL connections
|
||||||
# DISABLE_SSL=true
|
# DISABLE_SSL=true
|
||||||
|
|
|
@ -5,6 +5,7 @@ class AccountsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
@manual_accounts = family.accounts.manual.alphabetically
|
@manual_accounts = family.accounts.manual.alphabetically
|
||||||
@plaid_items = family.plaid_items.ordered
|
@plaid_items = family.plaid_items.ordered
|
||||||
|
@simple_fin_connections = family.simple_fin_connections.ordered
|
||||||
|
|
||||||
render layout: "settings"
|
render layout: "settings"
|
||||||
end
|
end
|
||||||
|
|
|
@ -52,7 +52,7 @@ module AccountableResource
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
if @account.linked?
|
if !@account.can_delete?
|
||||||
redirect_to account_path(@account), alert: "Cannot delete a linked account"
|
redirect_to account_path(@account), alert: "Cannot delete a linked account"
|
||||||
else
|
else
|
||||||
@account.destroy_later
|
@account.destroy_later
|
||||||
|
|
|
@ -21,18 +21,11 @@ class SimpleFinController < ApplicationController
|
||||||
Current.family.accounts.find_by(name: acc["name"])
|
Current.family.accounts.find_by(name: acc["name"])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Starts a sync across all SimpleFIN accounts
|
|
||||||
def sync
|
|
||||||
puts "Should sync"
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
def create
|
||||||
selected_ids = params[:selected_account_ids]
|
selected_ids = params[:selected_account_ids]
|
||||||
if selected_ids.blank?
|
if selected_ids.blank?
|
||||||
Rails.logger.error "No accounts were selected."
|
Rails.logger.error "No accounts were selected."
|
||||||
redirect_to new_simple_fin_connection_path(accountable_type: @accountable_type)
|
redirect_to root_path, alert: t(".no_acc_selected")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -65,24 +58,20 @@ class SimpleFinController < ApplicationController
|
||||||
|
|
||||||
|
|
||||||
# Create SimpleFinAccount and its associated Account
|
# Create SimpleFinAccount and its associated Account
|
||||||
SimpleFinAccount.find_or_create_from_simple_fin_data!(
|
simple_fin_account = SimpleFinAccount.find_or_create_from_simple_fin_data!(
|
||||||
acc_detail,
|
acc_detail,
|
||||||
simple_fin_connection
|
simple_fin_connection
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optionally, trigger an initial sync for the new account if needed,
|
# Trigger an account sync of our data
|
||||||
# though find_or_create_from_simple_fin_data! already populates it.
|
simple_fin_account.sync_account_data!(acc_detail)
|
||||||
# simple_fin_account.sync_account_data!(acc_detail)
|
|
||||||
|
|
||||||
# The Account record is created via accepts_nested_attributes_for in SimpleFinAccount
|
|
||||||
# or by the logic within find_or_create_from_simple_fin_data!
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to root_path, notice: t(".accounts_created_success")
|
redirect_to root_path, notice: t(".accounts_created_success")
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Rails.logger.error "SimpleFIN: Failed to create accounts - #{e.message}"
|
Rails.logger.error "SimpleFIN: Failed to create accounts - #{e.message}"
|
||||||
redirect_to new_simple_fin_connection_path
|
redirect_to new_simple_fin_path
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -10,6 +10,7 @@ class FamilyResetJob < ApplicationJob
|
||||||
family.tags.destroy_all
|
family.tags.destroy_all
|
||||||
family.merchants.destroy_all
|
family.merchants.destroy_all
|
||||||
family.plaid_items.destroy_all
|
family.plaid_items.destroy_all
|
||||||
|
family.simple_fin_connections.destroy_all
|
||||||
family.imports.destroy_all
|
family.imports.destroy_all
|
||||||
family.budgets.destroy_all
|
family.budgets.destroy_all
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,14 @@ module Account::Linkable
|
||||||
belongs_to :simple_fin_account, optional: true
|
belongs_to :simple_fin_account, optional: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def can_delete?
|
||||||
|
# SimpleFIN accounts can be removed and re-added
|
||||||
|
if simple_fin_account_id.present?
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
!linked?
|
||||||
|
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
|
||||||
def linked?
|
def linked?
|
||||||
plaid_account_id.present? || simple_fin_account_id.present?
|
plaid_account_id.present? || simple_fin_account_id.present?
|
||||||
|
|
|
@ -22,6 +22,7 @@ class Assistant::Function::GetAccounts < Assistant::Function
|
||||||
type: account.accountable_type,
|
type: account.accountable_type,
|
||||||
start_date: account.start_date,
|
start_date: account.start_date,
|
||||||
is_plaid_linked: account.plaid_account_id.present?,
|
is_plaid_linked: account.plaid_account_id.present?,
|
||||||
|
is_simple_fin_linked: account.simple_fin_id.present?,
|
||||||
is_active: account.is_active,
|
is_active: account.is_active,
|
||||||
historical_balances: historical_balances(account)
|
historical_balances: historical_balances(account)
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,6 +15,9 @@ class SimpleFinAccount < ApplicationRecord
|
||||||
validates :external_id, presence: true, uniqueness: true
|
validates :external_id, presence: true, uniqueness: true
|
||||||
validates :simple_fin_connection_id, presence: true
|
validates :simple_fin_connection_id, presence: true
|
||||||
|
|
||||||
|
after_destroy :cleanup_connection_if_orphaned
|
||||||
|
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def find_or_create_from_simple_fin_data!(sf_account_data, sfc)
|
def find_or_create_from_simple_fin_data!(sf_account_data, sfc)
|
||||||
sfc.simple_fin_accounts.find_or_create_by!(external_id: sf_account_data["id"]) do |sfa|
|
sfc.simple_fin_accounts.find_or_create_by!(external_id: sf_account_data["id"]) do |sfa|
|
||||||
|
@ -35,66 +38,129 @@ class SimpleFinAccount < ApplicationRecord
|
||||||
# sfa.sf_subtype = sf_account_data["name"]&.include?("Credit") ? "Credit Card" : accountable_klass.name
|
# sfa.sf_subtype = sf_account_data["name"]&.include?("Credit") ? "Credit Card" : accountable_klass.name
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
def family
|
||||||
|
simple_fin_connection&.family
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# sf_account_data is a hash from Provider::SimpleFin#get_available_accounts
|
# sf_account_data is a hash from Provider::SimpleFin#get_available_accounts
|
||||||
def sync_account_data!(sf_account_data)
|
def sync_account_data!(sf_account_data)
|
||||||
# Ensure accountable_attributes has the ID for updates
|
# Ensure accountable_attributes has the ID for updates
|
||||||
accountable_attributes = { id: account.accountable_id }
|
# 'account' here refers to self.account (the associated Account instance)
|
||||||
|
accountable_attributes = { id: self.account.accountable_id }
|
||||||
|
|
||||||
# Example: Update specific accountable types like PlaidAccount does
|
self.update!(
|
||||||
# This will depend on the structure of sf_account_data and your Accountable models
|
|
||||||
# case account.accountable_type
|
|
||||||
# when "CreditCard"
|
|
||||||
# accountable_attributes.merge!(
|
|
||||||
# # minimum_payment: sf_account_data.dig("credit_details", "minimum_payment"),
|
|
||||||
# # apr: sf_account_data.dig("credit_details", "apr")
|
|
||||||
# )
|
|
||||||
# when "Loan"
|
|
||||||
# accountable_attributes.merge!(
|
|
||||||
# # interest_rate: sf_account_data.dig("loan_details", "interest_rate")
|
|
||||||
# )
|
|
||||||
# end
|
|
||||||
|
|
||||||
update!(
|
|
||||||
current_balance: sf_account_data["balance"].to_d,
|
current_balance: sf_account_data["balance"].to_d,
|
||||||
available_balance: sf_account_data["available-balance"]&.to_d,
|
available_balance: sf_account_data["available-balance"]&.to_d,
|
||||||
currency: sf_account_data["currency"],
|
currency: sf_account_data["currency"],
|
||||||
# sf_type: derive_sf_type(sf_account_data), # Potentially update type/subtype
|
# simple_fin_errors: sf_account_data["errors"] || [],
|
||||||
# sf_subtype: derive_sf_subtype(sf_account_data),
|
|
||||||
simple_fin_errors: sf_account_data["errors"] || [], # Assuming errors might come on account data
|
|
||||||
account_attributes: {
|
account_attributes: {
|
||||||
id: account.id,
|
id: self.account.id,
|
||||||
balance: sf_account_data["balance"].to_d,
|
balance: sf_account_data["balance"].to_d,
|
||||||
# cash_balance: derive_sf_cash_balance(sf_account_data), # If applicable
|
|
||||||
last_synced_at: Time.current,
|
last_synced_at: Time.current,
|
||||||
accountable_attributes: accountable_attributes
|
accountable_attributes: accountable_attributes
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Sync transactions if present in the data
|
||||||
|
if sf_account_data["transactions"].is_a?(Array)
|
||||||
|
sync_transactions!(sf_account_data["transactions"])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sync holdings if present in the data and it's an investment account
|
||||||
|
if self.account&.investment? && sf_account_data["holdings"].is_a?(Array)
|
||||||
|
sync_holdings!(sf_account_data["holdings"])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# TODO: Implement if SimpleFIN provides investment transactions/holdings
|
# sf_holdings_data is an array of holding hashes from SimpleFIN for this specific account
|
||||||
# def sync_investments!(transactions:, holdings:, securities:)
|
def sync_holdings!(sf_holdings_data)
|
||||||
# # Similar to PlaidInvestmentSync.new(self).sync!(...)
|
# 'account' here refers to self.account
|
||||||
# end
|
return unless self.account.present? && self.account.investment? && sf_holdings_data.is_a?(Array)
|
||||||
|
Rails.logger.info "SimpleFinAccount (#{self.account.id}): Entering sync_holdings! with #{sf_holdings_data.length} items."
|
||||||
|
|
||||||
# TODO: Implement if SimpleFIN provides transactions
|
# Get existing SimpleFIN holding IDs for this account to detect deletions
|
||||||
# def sync_transactions!(added:, modified:, removed:)
|
existing_provider_holding_ids = self.account.holdings.where.not(simple_fin_holding_id: nil).pluck(:simple_fin_holding_id)
|
||||||
# # Similar to PlaidAccount's sync_transactions!
|
current_provider_holding_ids = sf_holdings_data.map { |h_data| h_data["id"] }
|
||||||
# end
|
|
||||||
|
|
||||||
def family
|
# Delete holdings that are no longer present in SimpleFIN's data
|
||||||
simple_fin_connection&.family
|
holdings_to_delete_ids = existing_provider_holding_ids - current_provider_holding_ids
|
||||||
|
Rails.logger.info "SimpleFinAccount (#{self.account.id}): Will delete SF holding IDs: #{holdings_to_delete_ids}"
|
||||||
|
self.account.holdings.where(simple_fin_holding_id: holdings_to_delete_ids).destroy_all
|
||||||
|
|
||||||
|
sf_holdings_data.each do |holding_data|
|
||||||
|
# Find or create the Security based on the holding data
|
||||||
|
security = find_or_create_security_from_holding_data(holding_data)
|
||||||
|
next unless security # Skip if we can't determine a security
|
||||||
|
|
||||||
|
Rails.logger.info "SimpleFinAccount (#{self.account.id}): Processing SF holding ID #{holding_data['id']}"
|
||||||
|
existing_holding = self.account.holdings.find_or_initialize_by(
|
||||||
|
security: security,
|
||||||
|
date: Date.current,
|
||||||
|
currency: holding_data["currency"]
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_holding.qty = holding_data["shares"]&.to_d
|
||||||
|
existing_holding.price = holding_data["purchase_price"]&.to_d
|
||||||
|
existing_holding.amount = holding_data["market_value"]&.to_d
|
||||||
|
# Cost basis is at holding level, not per share
|
||||||
|
# existing_holding.cost_basis = holding_data["cost_basis"]&.to_d
|
||||||
|
existing_holding.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# sf_transactions_data is an array of transaction hashes from SimpleFIN for this specific account
|
||||||
|
def sync_transactions!(sf_transactions_data)
|
||||||
|
# 'account' here refers to self.account
|
||||||
|
return unless self.account.present? && sf_transactions_data.is_a?(Array)
|
||||||
|
|
||||||
|
sf_transactions_data.each do |transaction_data|
|
||||||
|
entry = self.account.entries.find_or_initialize_by(simple_fin_transaction_id: transaction_data["id"])
|
||||||
|
|
||||||
|
entry.assign_attributes(
|
||||||
|
name: transaction_data["description"],
|
||||||
|
amount: transaction_data["amount"].to_d,
|
||||||
|
currency: self.account.currency,
|
||||||
|
date: Time.at(transaction_data["posted"].to_i).to_date,
|
||||||
|
source: "simple_fin"
|
||||||
|
)
|
||||||
|
|
||||||
|
entry.entryable ||= Transaction.new
|
||||||
|
unless entry.entryable.is_a?(Transaction)
|
||||||
|
entry.entryable = Transaction.new
|
||||||
|
end
|
||||||
|
|
||||||
|
entry.entryable.simple_fin_category = transaction_data.dig("extra", "category") if entry.entryable.respond_to?(:simple_fin_category=)
|
||||||
|
|
||||||
|
if entry.changed? || entry.entryable.changed? # Check if entryable also changed
|
||||||
|
entry.save!
|
||||||
|
else
|
||||||
|
Rails.logger.info "SimpleFinAccount (#{self.account.id}): Entry for SF transaction ID #{transaction_data['id']} not changed, not saving."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
##
|
||||||
|
# Helper to find or create a Security record based on SimpleFIN holding data
|
||||||
|
# SimpleFIN data is less detailed than Plaid securities, often just providing symbol and description.
|
||||||
|
def find_or_create_security_from_holding_data(holding_data)
|
||||||
|
symbol = holding_data["symbol"]&.upcase
|
||||||
|
description = holding_data["description"]
|
||||||
|
|
||||||
|
# We need at least a symbol or description to create/find a security
|
||||||
|
return nil unless symbol.present? || description.present?
|
||||||
|
|
||||||
|
# Try finding by ticker first, then by name (description) if no ticker
|
||||||
|
Security.find_or_create_by!(ticker: symbol) do |sec|
|
||||||
|
sec.name = description if description.present?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Example helper, if needed
|
def cleanup_connection_if_orphaned
|
||||||
# def derive_sf_cash_balance(sf_balances)
|
# Reload the connection to get the most up-to-date count of associated accounts
|
||||||
# if account.investment?
|
connection = simple_fin_connection.reload
|
||||||
# sf_balances["available-balance"]&.to_d || 0
|
connection.destroy_later if connection.simple_fin_accounts.empty?
|
||||||
# else
|
end
|
||||||
# sf_balances["balance"]&.to_d
|
|
||||||
# end
|
|
||||||
# end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<% if @manual_accounts.empty? && @plaid_items.empty? %>
|
<% if @manual_accounts.empty? && @plaid_items.empty? && @simple_fin_connections.empty? %>
|
||||||
<%= render "empty" %>
|
<%= render "empty" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
@ -31,6 +31,10 @@
|
||||||
<%= render @plaid_items.sort_by(&:created_at) %>
|
<%= render @plaid_items.sort_by(&:created_at) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% if @simple_fin_connections.any? %>
|
||||||
|
<%= render @simple_fin_connections.sort_by(&:created_at) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% if @manual_accounts.any? %>
|
<% if @manual_accounts.any? %>
|
||||||
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %>
|
<%= render "accounts/index/manual_accounts", accounts: @manual_accounts %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if simple_fin_avail %>
|
<% if simple_fin_avail %>
|
||||||
<%= link_to new_simple_fin_connection_path(accountable_type: @accountable_type), class: "flex items-center gap-4 w-full text-center text-primary focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-surface rounded-lg p-2" do %>
|
<%= link_to new_simple_fin_path(accountable_type: @accountable_type), class: "flex items-center gap-4 w-full text-center text-primary focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-surface rounded-lg p-2" 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)]">
|
<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)]">
|
||||||
<%= image_tag "simple-fin-logo.svg", class: "w-6 h-6" %>
|
<%= image_tag "simple-fin-logo.svg", class: "w-6 h-6" %>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -34,9 +34,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
<% elsif account.simple_fin_account_id.present? %>
|
<% elsif account.simple_fin_account_id.present? %>
|
||||||
<%# SimpleFIN information %>
|
<%# SimpleFIN information %>
|
||||||
<%= image_tag "simple-fin-logo.svg", class: "h-6 w-auto", title: "Connected via SimpleFIN" %>
|
<%= image_tag "simple-fin-logo.svg", class: "h-6 w-auto", title: "Connected via SimpleFIN. This account will auto refresh." %>
|
||||||
<%# TODO: Add manual sync %>
|
|
||||||
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= icon(
|
<%= icon(
|
||||||
"refresh-cw",
|
"refresh-cw",
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
) %>
|
) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% unless account.linked? %>
|
<% if account.can_delete? %>
|
||||||
<% menu.with_item(
|
<% menu.with_item(
|
||||||
variant: "button",
|
variant: "button",
|
||||||
text: "Delete account",
|
text: "Delete account",
|
||||||
|
|
|
@ -5,10 +5,10 @@
|
||||||
<div class="text-center text-gray-500 py-8">
|
<div class="text-center text-gray-500 py-8">
|
||||||
<p>No accounts found matching this type from SimpleFIN.</p>
|
<p>No accounts found matching this type from SimpleFIN.</p>
|
||||||
<p>Please ensure your SimpleFIN subscription is active.</p>
|
<p>Please ensure your SimpleFIN subscription is active.</p>
|
||||||
<%= link_to "Try Again", new_simple_fin_connection_path(accountable_type: @accountable_type), class: "mt-4 inline-block text-primary hover:underline" %>
|
<%= link_to "Try Again", new_simple_fin_path(accountable_type: @accountable_type), class: "mt-4 inline-block text-primary hover:underline" %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= styled_form_with url: simple_fin_connections_path(accountable_type: @accountable_type), method: :post, data: { turbo: false } do |form| %>
|
<%= styled_form_with url: simple_fin_index_path(accountable_type: @accountable_type), method: :post, data: { turbo: false } do |form| %>
|
||||||
<%# Render each account option parsed from SimpleFIN %>
|
<%# Render each account option parsed from SimpleFIN %>
|
||||||
<% @simple_fin_accounts.each_with_index do |account, index| %>
|
<% @simple_fin_accounts.each_with_index do |account, index| %>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
<%# locals: (simple_fin_connection:) %>
|
||||||
|
|
||||||
|
<div class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||||
|
<div class="px-4 py-3 sm:px-6 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<% if simple_fin_connection.logo.attached? %>
|
||||||
|
<%= image_tag simple_fin_connection.logo, class: "h-8 w-8 rounded-full" %>
|
||||||
|
<% else %>
|
||||||
|
<div>
|
||||||
|
<%= simple_fin_connection.institution_name&.first || "?" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<h3 >
|
||||||
|
<%= simple_fin_connection.institution_name || "SimpleFIN Connection" %>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex-shrink-0">
|
||||||
|
<%# Add a sync button for the entire connection if needed %>
|
||||||
|
<%# Example: render ButtonComponent.new(href: sync_simple_fin_connection_path(simple_fin_connection), method: :post, text: "Sync", icon: "refresh-cw", variant: "outline", size: "sm") %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul role="list" class="divide-y divide-gray-200">
|
||||||
|
<% simple_fin_connection.accounts.includes(:accountable, :logo_attachment).active.alphabetically.each do |account| %>
|
||||||
|
<%= render "accounts/account", account: account %>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
|
@ -6,5 +6,8 @@ Rails.application.configure do
|
||||||
if ENV["SIMPLE_FIN_ACCESS_URL"].present?
|
if ENV["SIMPLE_FIN_ACCESS_URL"].present?
|
||||||
config.simple_fin = OpenStruct.new()
|
config.simple_fin = OpenStruct.new()
|
||||||
config.simple_fin["ACCESS_URL"] = ENV["SIMPLE_FIN_ACCESS_URL"]
|
config.simple_fin["ACCESS_URL"] = ENV["SIMPLE_FIN_ACCESS_URL"]
|
||||||
|
config.simple_fin["UPDATE_CRON"] = ENV["SIMPLE_FIN_UPDATE_CRON"]
|
||||||
|
# Fallback
|
||||||
|
config.simple_fin["UPDATE_CRON"] = "0 6 * * *" if config.simple_fin["UPDATE_CRON"].nil?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,3 +6,4 @@ en:
|
||||||
create:
|
create:
|
||||||
accounts_created_success: "Accounts Successfully Created"
|
accounts_created_success: "Accounts Successfully Created"
|
||||||
fetch_failed: "Failed to fetch accounts"
|
fetch_failed: "Failed to fetch accounts"
|
||||||
|
no_acc_selected: "No accounts were selected to add"
|
||||||
|
|
|
@ -201,14 +201,8 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
|
|
||||||
# SimpleFIN routes
|
# SimpleFIN routes
|
||||||
namespace :simple_fin do
|
resources :simple_fin, only: %i[create new] do
|
||||||
resources :connections, only: [ :new, :create, :sync ], controller: "/simple_fin"
|
end
|
||||||
end
|
|
||||||
|
|
||||||
# SimpleFIN routes (controller: SimpleFinController)
|
|
||||||
get "simple_fin/select_accounts", to: "simple_fin#select_accounts", as: "select_simple_fin_accounts"
|
|
||||||
# Defines POST /simple_fin/create_selected_accounts -> simple_fin#create_selected_accounts
|
|
||||||
post "simple_fin/create_selected_accounts", to: "simple_fin#create_selected_accounts", as: "create_selected_simple_fin_accounts"
|
|
||||||
|
|
||||||
|
|
||||||
namespace :webhooks do
|
namespace :webhooks do
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
class SetupSimpleFinIntegration < ActiveRecord::Migration[7.2]
|
class SimpleFinIntegration < ActiveRecord::Migration[7.2]
|
||||||
def change
|
def change
|
||||||
create_table :simple_fin_connections do |t|
|
create_table :simple_fin_connections do |t|
|
||||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||||
t.string :name
|
t.string :name # e.g., "Chase via SimpleFIN" or user-defined
|
||||||
t.string :institution_id
|
t.string :institution_id # From SimpleFIN org.id (e.g., "www.chase.com")
|
||||||
t.string :institution_name
|
t.string :institution_name
|
||||||
t.string :institution_url
|
t.string :institution_url
|
||||||
t.string :institution_domain
|
t.string :institution_domain
|
||||||
t.string :status, default: "good"
|
t.string :status, default: "good" # e.g., good, requires_update
|
||||||
t.datetime :last_synced_at
|
t.datetime :last_synced_at
|
||||||
t.boolean :scheduled_for_deletion, default: false
|
t.boolean :scheduled_for_deletion, default: false
|
||||||
t.string :api_versions_supported, array: true, default: []
|
t.string :api_versions_supported, array: true, default: []
|
||||||
|
@ -23,11 +23,21 @@ class SetupSimpleFinIntegration < ActiveRecord::Migration[7.2]
|
||||||
t.string :currency
|
t.string :currency
|
||||||
t.string :sf_type
|
t.string :sf_type
|
||||||
t.string :sf_subtype
|
t.string :sf_subtype
|
||||||
|
t.string :simple_fin_errors, array: true, default: []
|
||||||
|
|
||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
add_index :simple_fin_accounts, [ :simple_fin_connection_id, :external_id ], unique: true, name: 'index_sfa_on_sfc_id_and_external_id'
|
add_index :simple_fin_accounts, [ :simple_fin_connection_id, :external_id ], unique: true, name: 'index_sfa_on_sfc_id_and_external_id'
|
||||||
|
|
||||||
add_reference :accounts, :simple_fin_account, foreign_key: true, null: true, index: true
|
add_reference :accounts, :simple_fin_account, foreign_key: true, null: true, index: true
|
||||||
|
|
||||||
|
add_column :entries, :simple_fin_transaction_id, :string
|
||||||
|
add_index :entries, :simple_fin_transaction_id, unique: true, where: "simple_fin_transaction_id IS NOT NULL"
|
||||||
|
add_column :entries, :source, :string
|
||||||
|
add_column :transactions, :simple_fin_category, :string
|
||||||
|
|
||||||
|
add_column :holdings, :simple_fin_holding_id, :string
|
||||||
|
add_index :holdings, :simple_fin_holding_id, unique: true, where: "simple_fin_holding_id IS NOT NULL"
|
||||||
|
add_column :holdings, :source, :string
|
||||||
end
|
end
|
||||||
end
|
end
|
8
db/schema.rb
generated
8
db/schema.rb
generated
|
@ -202,8 +202,11 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_09_134646) do
|
||||||
t.boolean "excluded", default: false
|
t.boolean "excluded", default: false
|
||||||
t.string "plaid_id"
|
t.string "plaid_id"
|
||||||
t.jsonb "locked_attributes", default: {}
|
t.jsonb "locked_attributes", default: {}
|
||||||
|
t.string "simple_fin_transaction_id"
|
||||||
|
t.string "source"
|
||||||
t.index ["account_id"], name: "index_entries_on_account_id"
|
t.index ["account_id"], name: "index_entries_on_account_id"
|
||||||
t.index ["import_id"], name: "index_entries_on_import_id"
|
t.index ["import_id"], name: "index_entries_on_import_id"
|
||||||
|
t.index ["simple_fin_transaction_id"], name: "index_entries_on_simple_fin_transaction_id", unique: true, where: "(simple_fin_transaction_id IS NOT NULL)"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
@ -243,9 +246,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_09_134646) do
|
||||||
t.string "currency", null: false
|
t.string "currency", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "simple_fin_holding_id"
|
||||||
|
t.string "source"
|
||||||
t.index ["account_id", "security_id", "date", "currency"], name: "idx_on_account_id_security_id_date_currency_5323e39f8b", unique: true
|
t.index ["account_id", "security_id", "date", "currency"], name: "idx_on_account_id_security_id_date_currency_5323e39f8b", unique: true
|
||||||
t.index ["account_id"], name: "index_holdings_on_account_id"
|
t.index ["account_id"], name: "index_holdings_on_account_id"
|
||||||
t.index ["security_id"], name: "index_holdings_on_security_id"
|
t.index ["security_id"], name: "index_holdings_on_security_id"
|
||||||
|
t.index ["simple_fin_holding_id"], name: "index_holdings_on_simple_fin_holding_id", unique: true, where: "(simple_fin_holding_id IS NOT NULL)"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "impersonation_session_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "impersonation_session_logs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
@ -561,6 +567,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_09_134646) do
|
||||||
t.string "currency"
|
t.string "currency"
|
||||||
t.string "sf_type"
|
t.string "sf_type"
|
||||||
t.string "sf_subtype"
|
t.string "sf_subtype"
|
||||||
|
t.string "simple_fin_errors", default: [], array: true
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["simple_fin_connection_id", "external_id"], name: "index_sfa_on_sfc_id_and_external_id", unique: true
|
t.index ["simple_fin_connection_id", "external_id"], name: "index_sfa_on_sfc_id_and_external_id", unique: true
|
||||||
|
@ -685,6 +692,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_09_134646) do
|
||||||
t.jsonb "locked_attributes", default: {}
|
t.jsonb "locked_attributes", default: {}
|
||||||
t.string "plaid_category"
|
t.string "plaid_category"
|
||||||
t.string "plaid_category_detailed"
|
t.string "plaid_category_detailed"
|
||||||
|
t.string "simple_fin_category"
|
||||||
t.index ["category_id"], name: "index_transactions_on_category_id"
|
t.index ["category_id"], name: "index_transactions_on_category_id"
|
||||||
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
|
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,7 +17,38 @@
|
||||||
"balance": "-250.75",
|
"balance": "-250.75",
|
||||||
"available-balance": "5000.00",
|
"available-balance": "5000.00",
|
||||||
"balance-date": 1700000001,
|
"balance-date": 1700000001,
|
||||||
"transactions": [],
|
"transactions": [
|
||||||
|
{
|
||||||
|
"id": "TRN-CHASE-CREDIT-001-A",
|
||||||
|
"posted": 1699910000,
|
||||||
|
"amount": "-25.50",
|
||||||
|
"description": "Corner Cafe Lunch",
|
||||||
|
"pending": false,
|
||||||
|
"extra": {
|
||||||
|
"category": "Food & Dining"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TRN-CHASE-CREDIT-001-B",
|
||||||
|
"posted": 1699810000,
|
||||||
|
"amount": "-75.00",
|
||||||
|
"description": "Online Shopping - Books",
|
||||||
|
"pending": true,
|
||||||
|
"extra": {
|
||||||
|
"category": "Shopping"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TRN-CHASE-CREDIT-001-C",
|
||||||
|
"posted": 1699710000,
|
||||||
|
"amount": "100.00",
|
||||||
|
"description": "Payment Received - Thank You!",
|
||||||
|
"pending": false,
|
||||||
|
"extra": {
|
||||||
|
"category": "Payments"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"holdings": []
|
"holdings": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -34,7 +65,28 @@
|
||||||
"balance": "-150320.90",
|
"balance": "-150320.90",
|
||||||
"available-balance": "0.00",
|
"available-balance": "0.00",
|
||||||
"balance-date": 1700000002,
|
"balance-date": 1700000002,
|
||||||
"transactions": [],
|
"transactions": [
|
||||||
|
{
|
||||||
|
"id": "TRN-CHASE-LOAN-002-A",
|
||||||
|
"posted": 1699510000,
|
||||||
|
"amount": "-1200.00",
|
||||||
|
"description": "Monthly Loan Payment",
|
||||||
|
"pending": false,
|
||||||
|
"extra": {
|
||||||
|
"category": "Loan Payments"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TRN-CHASE-LOAN-002-B",
|
||||||
|
"posted": 1699410000,
|
||||||
|
"amount": "-5.20",
|
||||||
|
"description": "Interest Charge",
|
||||||
|
"pending": false,
|
||||||
|
"extra": {
|
||||||
|
"category": "Fees & Charges"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"holdings": []
|
"holdings": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -255,7 +307,28 @@
|
||||||
"balance": "1234.56",
|
"balance": "1234.56",
|
||||||
"available-balance": "1200.00",
|
"available-balance": "1200.00",
|
||||||
"balance-date": 1700000007,
|
"balance-date": 1700000007,
|
||||||
"transactions": [],
|
"transactions": [
|
||||||
|
{
|
||||||
|
"id": "TRN-WPCU-SAVINGS-007-A",
|
||||||
|
"posted": 1699310000,
|
||||||
|
"amount": "50.00",
|
||||||
|
"description": "Transfer from Checking",
|
||||||
|
"pending": false,
|
||||||
|
"extra": {
|
||||||
|
"category": "Transfers"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TRN-WPCU-SAVINGS-007-B",
|
||||||
|
"posted": 1699210000,
|
||||||
|
"amount": "0.25",
|
||||||
|
"description": "Monthly Interest Earned",
|
||||||
|
"pending": false,
|
||||||
|
"extra": {
|
||||||
|
"category": "Interest Income"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"holdings": []
|
"holdings": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -272,7 +345,38 @@
|
||||||
"balance": "3456.78",
|
"balance": "3456.78",
|
||||||
"available-balance": "3456.78",
|
"available-balance": "3456.78",
|
||||||
"balance-date": 1700000008,
|
"balance-date": 1700000008,
|
||||||
"transactions": [],
|
"transactions": [
|
||||||
|
{
|
||||||
|
"id": "TRN-WPCU-CHECKING-008-A",
|
||||||
|
"posted": 1699110000,
|
||||||
|
"amount": "-45.99",
|
||||||
|
"description": "Grocery Store Run",
|
||||||
|
"pending": false,
|
||||||
|
"extra": {
|
||||||
|
"category": "Groceries"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TRN-WPCU-CHECKING-008-B",
|
||||||
|
"posted": 1699010000,
|
||||||
|
"amount": "-12.50",
|
||||||
|
"description": "Coffee Shop Visit",
|
||||||
|
"pending": true,
|
||||||
|
"extra": {
|
||||||
|
"category": "Food & Dining"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "TRN-WPCU-CHECKING-008-C",
|
||||||
|
"posted": 1698910000,
|
||||||
|
"amount": "1500.00",
|
||||||
|
"description": "Direct Deposit - Paycheck",
|
||||||
|
"pending": false,
|
||||||
|
"extra": {
|
||||||
|
"category": "Income"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"holdings": []
|
"holdings": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -289,7 +393,18 @@
|
||||||
"balance": "25000.00",
|
"balance": "25000.00",
|
||||||
"available-balance": "24900.00",
|
"available-balance": "24900.00",
|
||||||
"balance-date": 1700000009,
|
"balance-date": 1700000009,
|
||||||
"transactions": [],
|
"transactions": [
|
||||||
|
{
|
||||||
|
"id": "TRN-WPCU-MMA-009-A",
|
||||||
|
"posted": 1698810000,
|
||||||
|
"amount": "200.00",
|
||||||
|
"description": "Transfer to Savings",
|
||||||
|
"pending": false,
|
||||||
|
"extra": {
|
||||||
|
"category": "Transfers"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"holdings": []
|
"holdings": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -309,6 +424,34 @@
|
||||||
"transactions": [],
|
"transactions": [],
|
||||||
"holdings": []
|
"holdings": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"org": {
|
||||||
|
"domain": "www.discover.com",
|
||||||
|
"name": "Discover Credit Card",
|
||||||
|
"sfin-url": "https://beta-bridge.simplefin.org/simplefin",
|
||||||
|
"url": "https://www.discover.com",
|
||||||
|
"id": "www.discover.com"
|
||||||
|
},
|
||||||
|
"id": "ACT-RANDOM-DISCOVER-CREDIT-CARD",
|
||||||
|
"name": "Discover Credit Card",
|
||||||
|
"currency": "USD",
|
||||||
|
"balance": "-10000",
|
||||||
|
"available-balance": "0.00",
|
||||||
|
"balance-date": 1746720778,
|
||||||
|
"transactions": [
|
||||||
|
{
|
||||||
|
"id": "TRN-TOYOTA-LOAN-010-A",
|
||||||
|
"posted": 1698710000,
|
||||||
|
"amount": "-550.75",
|
||||||
|
"description": "Car Payment - November",
|
||||||
|
"pending": false,
|
||||||
|
"extra": {
|
||||||
|
"category": "Auto Payment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"holdings": []
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"org": {
|
"org": {
|
||||||
"domain": "www.discover.com",
|
"domain": "www.discover.com",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue