1
0
Fork 0
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:
Cameron Roudebush 2025-05-09 14:34:44 -04:00
parent a5cf70f2df
commit 5f151ec66f
20 changed files with 339 additions and 81 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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