From 5f151ec66f68a423a38e28e654c66f1f189569de Mon Sep 17 00:00:00 2001 From: Cameron Roudebush Date: Fri, 9 May 2025 14:34:44 -0400 Subject: [PATCH] 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 --- .env.example | 1 + app/controllers/accounts_controller.rb | 1 + .../concerns/accountable_resource.rb | 2 +- app/controllers/simple_fin_controller.rb | 21 +-- app/jobs/family_reset_job.rb | 1 + app/models/account/linkable.rb | 8 + app/models/assistant/function/get_accounts.rb | 1 + app/models/simple_fin_account.rb | 144 ++++++++++++----- app/views/accounts/index.html.erb | 6 +- .../accounts/new/_method_selector.html.erb | 2 +- app/views/accounts/show/_header.html.erb | 4 +- app/views/accounts/show/_menu.html.erb | 2 +- app/views/simple_fin/new.html.erb | 4 +- .../_simple_fin_connection.html.erb | 30 ++++ config/initializers/simple_fin.rb | 3 + config/locales/simple_fin/en.yml | 1 + config/routes.rb | 10 +- ... 20250509134646_simple_fin_integration.rb} | 18 ++- db/schema.rb | 8 + sample.simple.fin.json | 153 +++++++++++++++++- 20 files changed, 339 insertions(+), 81 deletions(-) create mode 100644 app/views/simple_fin_connections/_simple_fin_connection.html.erb rename db/migrate/{20250509134646_setup_simple_fin_integration.rb => 20250509134646_simple_fin_integration.rb} (55%) diff --git a/.env.example b/.env.example index 409e23de..20095a17 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,7 @@ APP_DOMAIN= # 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 SIMPLE_FIN_ACCESS_URL= +SIMPLE_FIN_UPDATE_CRON="0 6 * * *" # Disable enforcing SSL connections # DISABLE_SSL=true diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index f003ab31..65728c64 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -5,6 +5,7 @@ class AccountsController < ApplicationController def index @manual_accounts = family.accounts.manual.alphabetically @plaid_items = family.plaid_items.ordered + @simple_fin_connections = family.simple_fin_connections.ordered render layout: "settings" end diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index ec1d5401..687df0cb 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -52,7 +52,7 @@ module AccountableResource end def destroy - if @account.linked? + if !@account.can_delete? redirect_to account_path(@account), alert: "Cannot delete a linked account" else @account.destroy_later diff --git a/app/controllers/simple_fin_controller.rb b/app/controllers/simple_fin_controller.rb index 81fdd076..89a0d701 100644 --- a/app/controllers/simple_fin_controller.rb +++ b/app/controllers/simple_fin_controller.rb @@ -21,18 +21,11 @@ class SimpleFinController < ApplicationController Current.family.accounts.find_by(name: acc["name"]) end - - ## - # Starts a sync across all SimpleFIN accounts - def sync - puts "Should sync" - end - def create selected_ids = params[:selected_account_ids] if selected_ids.blank? 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 end @@ -65,24 +58,20 @@ class SimpleFinController < ApplicationController # 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, simple_fin_connection ) - # Optionally, trigger an initial sync for the new account if needed, - # though find_or_create_from_simple_fin_data! already populates it. - # 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! + # Trigger an account sync of our data + simple_fin_account.sync_account_data!(acc_detail) end end redirect_to root_path, notice: t(".accounts_created_success") rescue StandardError => e Rails.logger.error "SimpleFIN: Failed to create accounts - #{e.message}" - redirect_to new_simple_fin_connection_path + redirect_to new_simple_fin_path end private diff --git a/app/jobs/family_reset_job.rb b/app/jobs/family_reset_job.rb index 185df111..0de6f57f 100644 --- a/app/jobs/family_reset_job.rb +++ b/app/jobs/family_reset_job.rb @@ -10,6 +10,7 @@ class FamilyResetJob < ApplicationJob family.tags.destroy_all family.merchants.destroy_all family.plaid_items.destroy_all + family.simple_fin_connections.destroy_all family.imports.destroy_all family.budgets.destroy_all diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb index 12859684..0265d8b2 100644 --- a/app/models/account/linkable.rb +++ b/app/models/account/linkable.rb @@ -6,6 +6,14 @@ module Account::Linkable belongs_to :simple_fin_account, optional: true 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 def linked? plaid_account_id.present? || simple_fin_account_id.present? diff --git a/app/models/assistant/function/get_accounts.rb b/app/models/assistant/function/get_accounts.rb index b912d81d..45125115 100644 --- a/app/models/assistant/function/get_accounts.rb +++ b/app/models/assistant/function/get_accounts.rb @@ -22,6 +22,7 @@ class Assistant::Function::GetAccounts < Assistant::Function type: account.accountable_type, start_date: account.start_date, is_plaid_linked: account.plaid_account_id.present?, + is_simple_fin_linked: account.simple_fin_id.present?, is_active: account.is_active, historical_balances: historical_balances(account) } diff --git a/app/models/simple_fin_account.rb b/app/models/simple_fin_account.rb index 4309ee70..24f8ec94 100644 --- a/app/models/simple_fin_account.rb +++ b/app/models/simple_fin_account.rb @@ -15,6 +15,9 @@ class SimpleFinAccount < ApplicationRecord validates :external_id, presence: true, uniqueness: true validates :simple_fin_connection_id, presence: true + after_destroy :cleanup_connection_if_orphaned + + class << self 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| @@ -35,66 +38,129 @@ class SimpleFinAccount < ApplicationRecord # sfa.sf_subtype = sf_account_data["name"]&.include?("Credit") ? "Credit Card" : accountable_klass.name end end + def family + simple_fin_connection&.family + end end # sf_account_data is a hash from Provider::SimpleFin#get_available_accounts def sync_account_data!(sf_account_data) # 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 - # 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!( + self.update!( current_balance: sf_account_data["balance"].to_d, available_balance: sf_account_data["available-balance"]&.to_d, currency: sf_account_data["currency"], - # sf_type: derive_sf_type(sf_account_data), # Potentially update type/subtype - # sf_subtype: derive_sf_subtype(sf_account_data), - simple_fin_errors: sf_account_data["errors"] || [], # Assuming errors might come on account data + # simple_fin_errors: sf_account_data["errors"] || [], account_attributes: { - id: account.id, + id: self.account.id, balance: sf_account_data["balance"].to_d, - # cash_balance: derive_sf_cash_balance(sf_account_data), # If applicable last_synced_at: Time.current, 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 - # TODO: Implement if SimpleFIN provides investment transactions/holdings - # def sync_investments!(transactions:, holdings:, securities:) - # # Similar to PlaidInvestmentSync.new(self).sync!(...) - # end + # sf_holdings_data is an array of holding hashes from SimpleFIN for this specific account + def sync_holdings!(sf_holdings_data) + # 'account' here refers to self.account + 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 - # def sync_transactions!(added:, modified:, removed:) - # # Similar to PlaidAccount's sync_transactions! - # end + # Get existing SimpleFIN holding IDs for this account to detect deletions + existing_provider_holding_ids = self.account.holdings.where.not(simple_fin_holding_id: nil).pluck(:simple_fin_holding_id) + current_provider_holding_ids = sf_holdings_data.map { |h_data| h_data["id"] } - def family - simple_fin_connection&.family + # Delete holdings that are no longer present in SimpleFIN's data + 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 private - # Example helper, if needed - # def derive_sf_cash_balance(sf_balances) - # if account.investment? - # sf_balances["available-balance"]&.to_d || 0 - # else - # sf_balances["balance"]&.to_d - # end - # end + def cleanup_connection_if_orphaned + # Reload the connection to get the most up-to-date count of associated accounts + connection = simple_fin_connection.reload + connection.destroy_later if connection.simple_fin_accounts.empty? + end end diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 4893e44c..5a30ea93 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -23,7 +23,7 @@ -<% if @manual_accounts.empty? && @plaid_items.empty? %> +<% if @manual_accounts.empty? && @plaid_items.empty? && @simple_fin_connections.empty? %> <%= render "empty" %> <% else %>
@@ -31,6 +31,10 @@ <%= render @plaid_items.sort_by(&:created_at) %> <% end %> + <% if @simple_fin_connections.any? %> + <%= render @simple_fin_connections.sort_by(&:created_at) %> + <% end %> + <% if @manual_accounts.any? %> <%= render "accounts/index/manual_accounts", accounts: @manual_accounts %> <% end %> diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index 9c8ba556..ae08558a 100644 --- a/app/views/accounts/new/_method_selector.html.erb +++ b/app/views/accounts/new/_method_selector.html.erb @@ -10,7 +10,7 @@ <% end %> <% 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 %> <%= image_tag "simple-fin-logo.svg", class: "w-6 h-6" %> diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb index 5d9a0f16..a6dc4fd0 100644 --- a/app/views/accounts/show/_header.html.erb +++ b/app/views/accounts/show/_header.html.erb @@ -34,9 +34,7 @@ <% end %> <% elsif account.simple_fin_account_id.present? %> <%# SimpleFIN information %> - <%= image_tag "simple-fin-logo.svg", class: "h-6 w-auto", title: "Connected via SimpleFIN" %> - <%# TODO: Add manual sync %> - + <%= image_tag "simple-fin-logo.svg", class: "h-6 w-auto", title: "Connected via SimpleFIN. This account will auto refresh." %> <% else %> <%= icon( "refresh-cw", diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb index 0691f34e..7e452d28 100644 --- a/app/views/accounts/show/_menu.html.erb +++ b/app/views/accounts/show/_menu.html.erb @@ -13,7 +13,7 @@ ) %> <% end %> - <% unless account.linked? %> + <% if account.can_delete? %> <% menu.with_item( variant: "button", text: "Delete account", diff --git a/app/views/simple_fin/new.html.erb b/app/views/simple_fin/new.html.erb index db1a172b..fafb3de0 100644 --- a/app/views/simple_fin/new.html.erb +++ b/app/views/simple_fin/new.html.erb @@ -5,10 +5,10 @@

No accounts found matching this type from SimpleFIN.

Please ensure your SimpleFIN subscription is active.

- <%= 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" %>
<% 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 %> <% @simple_fin_accounts.each_with_index do |account, index| %>
diff --git a/app/views/simple_fin_connections/_simple_fin_connection.html.erb b/app/views/simple_fin_connections/_simple_fin_connection.html.erb new file mode 100644 index 00000000..9aa5ee5e --- /dev/null +++ b/app/views/simple_fin_connections/_simple_fin_connection.html.erb @@ -0,0 +1,30 @@ +<%# locals: (simple_fin_connection:) %> + +
+
+
+
+ <% if simple_fin_connection.logo.attached? %> + <%= image_tag simple_fin_connection.logo, class: "h-8 w-8 rounded-full" %> + <% else %> +
+ <%= simple_fin_connection.institution_name&.first || "?" %> +
+ <% end %> +

+ <%= simple_fin_connection.institution_name || "SimpleFIN Connection" %> +

+
+
+ <%# 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") %> +
+
+
+ +
    + <% simple_fin_connection.accounts.includes(:accountable, :logo_attachment).active.alphabetically.each do |account| %> + <%= render "accounts/account", account: account %> + <% end %> +
+
\ No newline at end of file diff --git a/config/initializers/simple_fin.rb b/config/initializers/simple_fin.rb index ef391e22..64746feb 100644 --- a/config/initializers/simple_fin.rb +++ b/config/initializers/simple_fin.rb @@ -6,5 +6,8 @@ Rails.application.configure do if ENV["SIMPLE_FIN_ACCESS_URL"].present? config.simple_fin = OpenStruct.new() 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 diff --git a/config/locales/simple_fin/en.yml b/config/locales/simple_fin/en.yml index 441b31ab..3d2f37b4 100644 --- a/config/locales/simple_fin/en.yml +++ b/config/locales/simple_fin/en.yml @@ -6,3 +6,4 @@ en: create: accounts_created_success: "Accounts Successfully Created" fetch_failed: "Failed to fetch accounts" + no_acc_selected: "No accounts were selected to add" diff --git a/config/routes.rb b/config/routes.rb index ed338e6d..c1825b41 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -201,14 +201,8 @@ Rails.application.routes.draw do end # SimpleFIN routes - namespace :simple_fin do - resources :connections, only: [ :new, :create, :sync ], controller: "/simple_fin" - 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" + resources :simple_fin, only: %i[create new] do + end namespace :webhooks do diff --git a/db/migrate/20250509134646_setup_simple_fin_integration.rb b/db/migrate/20250509134646_simple_fin_integration.rb similarity index 55% rename from db/migrate/20250509134646_setup_simple_fin_integration.rb rename to db/migrate/20250509134646_simple_fin_integration.rb index cafc139b..b403dd19 100644 --- a/db/migrate/20250509134646_setup_simple_fin_integration.rb +++ b/db/migrate/20250509134646_simple_fin_integration.rb @@ -1,13 +1,13 @@ -class SetupSimpleFinIntegration < ActiveRecord::Migration[7.2] +class SimpleFinIntegration < ActiveRecord::Migration[7.2] def change create_table :simple_fin_connections do |t| t.references :family, null: false, foreign_key: true, type: :uuid - t.string :name - t.string :institution_id + t.string :name # e.g., "Chase via SimpleFIN" or user-defined + t.string :institution_id # From SimpleFIN org.id (e.g., "www.chase.com") t.string :institution_name t.string :institution_url 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.boolean :scheduled_for_deletion, default: false t.string :api_versions_supported, array: true, default: [] @@ -23,11 +23,21 @@ class SetupSimpleFinIntegration < ActiveRecord::Migration[7.2] t.string :currency t.string :sf_type t.string :sf_subtype + t.string :simple_fin_errors, array: true, default: [] t.timestamps end 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_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 diff --git a/db/schema.rb b/db/schema.rb index fb4a642e..b18556e8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -202,8 +202,11 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_09_134646) do t.boolean "excluded", default: false t.string "plaid_id" 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 ["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 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.datetime "created_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"], name: "index_holdings_on_account_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 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 "sf_type" t.string "sf_subtype" + t.string "simple_fin_errors", default: [], array: true t.datetime "created_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 @@ -685,6 +692,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_05_09_134646) do t.jsonb "locked_attributes", default: {} t.string "plaid_category" t.string "plaid_category_detailed" + t.string "simple_fin_category" t.index ["category_id"], name: "index_transactions_on_category_id" t.index ["merchant_id"], name: "index_transactions_on_merchant_id" end diff --git a/sample.simple.fin.json b/sample.simple.fin.json index b6571c6e..9162fdb7 100644 --- a/sample.simple.fin.json +++ b/sample.simple.fin.json @@ -17,7 +17,38 @@ "balance": "-250.75", "available-balance": "5000.00", "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": [] }, { @@ -34,7 +65,28 @@ "balance": "-150320.90", "available-balance": "0.00", "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": [] }, { @@ -255,7 +307,28 @@ "balance": "1234.56", "available-balance": "1200.00", "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": [] }, { @@ -272,7 +345,38 @@ "balance": "3456.78", "available-balance": "3456.78", "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": [] }, { @@ -289,7 +393,18 @@ "balance": "25000.00", "available-balance": "24900.00", "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": [] }, { @@ -309,6 +424,34 @@ "transactions": [], "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": { "domain": "www.discover.com",