diff --git a/app/views/users/_user_menu.html.erb b/app/views/users/_user_menu.html.erb
index 69d64d816d1..fcc2d688189 100644
--- a/app/views/users/_user_menu.html.erb
+++ b/app/views/users/_user_menu.html.erb
@@ -1,7 +1,7 @@
<%# locals: (user:, placement: "right-start", offset: 16) %>
- <%= render MenuComponent.new(variant: "avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials, placement: placement, offset: offset) do |menu| %>
+ <%= render DS::Menu.new(variant: "avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials, placement: placement, offset: offset) do |menu| %>
<%= menu.with_header do %>
diff --git a/app/views/valuations/_confirmation_contents.html.erb b/app/views/valuations/_confirmation_contents.html.erb
new file mode 100644
index 00000000000..d8fdf03280a
--- /dev/null
+++ b/app/views/valuations/_confirmation_contents.html.erb
@@ -0,0 +1,51 @@
+<%# locals: (account:, entry:, reconciliation_dry_run:, is_update:, action_verb:) %>
+
+
+ <% if account.investment? %>
+ <% brokerage_cash = reconciliation_dry_run.new_cash_balance || 0 %>
+ <% holdings_value = reconciliation_dry_run.new_balance - brokerage_cash %>
+
+
This will <%= action_verb %> the account value on <%= entry.date.strftime("%B %d, %Y") %> to:
+
+
+
+ Total account value
+ <%= Money.new(reconciliation_dry_run.new_balance, account.currency).format %>
+
+
+ Holdings value
+ <%= Money.new(holdings_value, account.currency).format %>
+
+
+ Brokerage cash
+ "><%= Money.new(brokerage_cash, account.currency).format %>
+
+
+ <% else %>
+
<%= action_verb.capitalize %>
+ <% if account.depository? %>
+ account balance
+ <% elsif account.credit_card? %>
+ credit card balance
+ <% elsif account.loan? %>
+ loan balance
+ <% elsif account.property? %>
+ property value
+ <% elsif account.vehicle? %>
+ vehicle value
+ <% elsif account.crypto? %>
+ crypto balance
+ <% elsif account.other_asset? %>
+ asset value
+ <% elsif account.other_liability? %>
+ liability balance
+ <% else %>
+ balance
+ <% end %>
+ on <%= entry.date.strftime("%B %d, %Y") %> to
+ <%= entry.amount_money.format %>.
+
+ <% end %>
+
+
All future transactions and balances will be recalculated based on this <%= is_update ? "change" : "update" %>.
+
diff --git a/app/views/valuations/_form.html.erb b/app/views/valuations/_form.html.erb
deleted file mode 100644
index 5429f3a7051..00000000000
--- a/app/views/valuations/_form.html.erb
+++ /dev/null
@@ -1,17 +0,0 @@
-<%# locals: (entry:, error_message:) %>
-
-<%= styled_form_with model: entry, url: valuations_path, class: "space-y-4" do |form| %>
- <%= form.hidden_field :account_id %>
-
- <% if error_message.present? %>
- <%= render AlertComponent.new(message: error_message, variant: :error) %>
- <% end %>
-
-
- <%= form.hidden_field :name, value: "Balance update" %>
- <%= form.date_field :date, label: true, required: true, value: Date.current, min: Entry.min_supported_date, max: Date.current %>
- <%= form.money_field :amount, label: t(".amount"), required: true %>
-
-
- <%= form.submit t(".submit") %>
-<% end %>
diff --git a/app/views/valuations/_valuation.html.erb b/app/views/valuations/_valuation.html.erb
index 176f21be785..9bb41ce1150 100644
--- a/app/views/valuations/_valuation.html.erb
+++ b/app/views/valuations/_valuation.html.erb
@@ -1,9 +1,9 @@
-<%# locals: (entry:, balance_trend: nil, **) %>
+<%# locals: (entry:, **) %>
<% valuation = entry.entryable %>
-<% color = balance_trend&.trend&.color || "#D444F1" %>
-<% icon = balance_trend&.trend&.icon || "plus" %>
+<% color = valuation.opening_anchor? ? "#D444F1" : "var(--color-gray)" %>
+<% icon = valuation.opening_anchor? ? "plus" : "minus" %>
<%= turbo_frame_tag dom_id(entry) do %>
<%= turbo_frame_tag dom_id(valuation) do %>
@@ -14,7 +14,7 @@
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
- <%= render FilledIconComponent.new(icon: icon, size: "sm", hex_color: color, rounded: true) %>
+ <%= render DS::FilledIcon.new(icon: icon, size: "sm", hex_color: color, rounded: true) %>
<%= link_to entry.name,
@@ -26,7 +26,7 @@
- <%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-primary" %>
+ <%= tag.p format_money(entry.amount_money), class: "font-bold text-sm text-primary" %>
<% end %>
diff --git a/app/views/valuations/confirm_create.html.erb b/app/views/valuations/confirm_create.html.erb
new file mode 100644
index 00000000000..906c957d6ae
--- /dev/null
+++ b/app/views/valuations/confirm_create.html.erb
@@ -0,0 +1,19 @@
+<%= render DS::Dialog.new do |dialog| %>
+ <% dialog.with_header(title: "Confirm new balance") %>
+ <% dialog.with_body do %>
+ <%= styled_form_with model: @entry, url: valuations_path, class: "space-y-4" do |form| %>
+ <%= form.hidden_field :account_id %>
+ <%= form.hidden_field :date %>
+ <%= form.hidden_field :amount %>
+
+ <%= render "confirmation_contents",
+ reconciliation_dry_run: @reconciliation_dry_run,
+ account: @account,
+ entry: @entry,
+ action_verb: "set",
+ is_update: false %>
+
+ <%= form.submit "Confirm" %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/valuations/confirm_update.html.erb b/app/views/valuations/confirm_update.html.erb
new file mode 100644
index 00000000000..720f7bd55f7
--- /dev/null
+++ b/app/views/valuations/confirm_update.html.erb
@@ -0,0 +1,18 @@
+<%= render DS::Dialog.new do |dialog| %>
+ <% dialog.with_header(title: "Update balance") %>
+ <% dialog.with_body do %>
+ <%= styled_form_with model: @entry, url: valuation_path(@entry), method: :patch, class: "space-y-4", data: { turbo_frame: :_top } do |form| %>
+ <%= form.hidden_field :date %>
+ <%= form.hidden_field :amount %>
+
+ <%= render "confirmation_contents",
+ reconciliation_dry_run: @reconciliation_dry_run,
+ account: @account,
+ entry: @entry,
+ action_verb: "update",
+ is_update: true %>
+
+ <%= form.submit "Update" %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/valuations/new.html.erb b/app/views/valuations/new.html.erb
index 82102f16af5..cf805d67508 100644
--- a/app/views/valuations/new.html.erb
+++ b/app/views/valuations/new.html.erb
@@ -1,6 +1,19 @@
-<%= render DialogComponent.new do |dialog| %>
+<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
- <%= render "form", entry: @entry, error_message: @error_message %>
+ <%= styled_form_with model: @entry, url: confirm_create_valuations_path, class: "space-y-4" do |form| %>
+ <%= form.hidden_field :account_id %>
+
+ <% if @error_message.present? %>
+ <%= render DS::Alert.new(message: @error_message, variant: :error) %>
+ <% end %>
+
+
+ <%= form.date_field :date, label: true, required: true, value: Date.current, min: Entry.min_supported_date, max: Date.current %>
+ <%= form.money_field :amount, label: t(".amount"), required: true, disable_currency: true %>
+
+
+ <%= form.submit t(".submit") %>
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/valuations/show.html.erb b/app/views/valuations/show.html.erb
index 6e96dcaa146..089d98a43a5 100644
--- a/app/views/valuations/show.html.erb
+++ b/app/views/valuations/show.html.erb
@@ -1,6 +1,6 @@
<% entry, account = @entry, @entry.account %>
-<%= render DialogComponent.new(variant: "drawer") do |dialog| %>
+<%= render DS::Dialog.new(variant: "drawer") do |dialog| %>
<% dialog.with_header do %>
<%= render "valuations/header", entry: @entry %>
<% end %>
@@ -8,25 +8,32 @@
<% dialog.with_body do %>
<% if @error_message.present? %>
- <%= render AlertComponent.new(message: @error_message, variant: :error) %>
+ <%= render DS::Alert.new(message: @error_message, variant: :error) %>
<% end %>
<% dialog.with_section(title: t(".overview"), open: true) do %>
<%= styled_form_with model: entry,
- url: entry_path(entry),
- class: "space-y-2",
- data: { controller: "auto-submit-form" } do |f| %>
+ url: confirm_update_valuation_path(entry),
+ method: :post,
+ data: { turbo_frame: :modal },
+ class: "space-y-4" do |f| %>
<%= f.date_field :date,
label: t(".date_label"),
- max: Date.current,
- "data-auto-submit-form-target": "auto" %>
+ max: Date.current %>
<%= f.money_field :amount,
- label: t(".amount"),
- auto_submit: true,
+ label: "Account value on date",
disable_currency: true %>
+
+
+ <%= render DS::Button.new(
+ text: "Update value",
+ variant: :primary,
+ type: "submit"
+ ) %>
+
<% end %>
<% end %>
@@ -34,9 +41,10 @@
<% dialog.with_section(title: t(".details")) do %>
<%= styled_form_with model: entry,
- url: entry_path(entry),
+ url: valuation_path(entry),
+ method: :patch,
class: "space-y-2",
- data: { controller: "auto-submit-form" } do |f| %>
+ data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "blur" } do |f| %>
<%= f.text_area :notes,
label: t(".note_label"),
placeholder: t(".note_placeholder"),
@@ -59,7 +67,7 @@
entry_path(entry),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
- data: { turbo_confirm: true, turbo_frame: "_top" } %>
+ data: { turbo_confirm: CustomConfirm.for_resource_deletion("value update").to_data_attribute, turbo_frame: "_top" } %>
<% end %>
diff --git a/app/views/vehicles/edit.html.erb b/app/views/vehicles/edit.html.erb
index 00424799752..8730837715e 100644
--- a/app/views/vehicles/edit.html.erb
+++ b/app/views/vehicles/edit.html.erb
@@ -1,4 +1,4 @@
-<%= render DialogComponent.new do |dialog| %>
+<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".edit", account: @account.name)) %>
<% dialog.with_body do %>
<%= render "form", account: @account, url: vehicle_path(@account) %>
diff --git a/app/views/vehicles/new.html.erb b/app/views/vehicles/new.html.erb
index 56422ce8667..2f09bcf4ac6 100644
--- a/app/views/vehicles/new.html.erb
+++ b/app/views/vehicles/new.html.erb
@@ -1,4 +1,4 @@
-<%= render DialogComponent.new do |dialog| %>
+<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<%= render "vehicles/form", account: @account, url: vehicles_path(return_to: params[:return_to]) %>
diff --git a/app/views/vehicles/show.html.erb b/app/views/vehicles/show.html.erb
deleted file mode 100644
index 555cd9269a2..00000000000
--- a/app/views/vehicles/show.html.erb
+++ /dev/null
@@ -1,6 +0,0 @@
-<%= render "accounts/show/template",
- account: @account,
- tabs: render("accounts/show/tabs", account: @account, tabs: [
- { key: "activity", contents: render("accounts/show/activity", account: @account) },
- { key: "overview", contents: render("vehicles/overview", account: @account) },
- ]) %>
diff --git a/app/views/vehicles/_overview.html.erb b/app/views/vehicles/tabs/_overview.html.erb
similarity index 97%
rename from app/views/vehicles/_overview.html.erb
rename to app/views/vehicles/tabs/_overview.html.erb
index e85925333fa..b71793bbcb4 100644
--- a/app/views/vehicles/_overview.html.erb
+++ b/app/views/vehicles/tabs/_overview.html.erb
@@ -33,7 +33,7 @@
- <%= render LinkComponent.new(
+ <%= render DS::Link.new(
text: "Edit account details",
variant: "ghost",
href: edit_vehicle_path(account),
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index 5ca904435d8..05172fd20b9 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -1,5 +1,28 @@
{
"ignored_warnings": [
+ {
+ "warning_type": "Redirect",
+ "warning_code": 18,
+ "fingerprint": "723b1970ca6bf16ea0c2c1afa0c00d3c54854a16568d6cb933e497947565d9ab",
+ "check_name": "Redirect",
+ "message": "Possible unprotected redirect",
+ "file": "app/controllers/family_exports_controller.rb",
+ "line": 30,
+ "link": "https://brakemanscanner.org/docs/warning_types/redirect/",
+ "code": "redirect_to(Current.family.family_exports.find(params[:id]).export_file, :allow_other_host => true)",
+ "render_path": null,
+ "location": {
+ "type": "method",
+ "class": "FamilyExportsController",
+ "method": "download"
+ },
+ "user_input": "Current.family.family_exports.find(params[:id]).export_file",
+ "confidence": "Weak",
+ "cwe_id": [
+ 601
+ ],
+ "note": ""
+ },
{
"warning_type": "Mass Assignment",
"warning_code": 105,
@@ -105,5 +128,5 @@
"note": ""
}
],
- "brakeman_version": "7.0.2"
+ "brakeman_version": "7.1.0"
}
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index 93f5528875a..3d225e58bd6 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -9,8 +9,11 @@ class Rack::Attack
request.ip if request.path == "/oauth/token"
end
+ # Determine limits based on self-hosted mode
+ self_hosted = Rails.application.config.app_mode.self_hosted?
+
# Throttle API requests per access token
- throttle("api/requests", limit: 100, period: 1.hour) do |request|
+ throttle("api/requests", limit: self_hosted ? 10_000 : 100, period: 1.hour) do |request|
if request.path.start_with?("/api/")
# Extract access token from Authorization header
auth_header = request.get_header("HTTP_AUTHORIZATION")
@@ -25,7 +28,7 @@ class Rack::Attack
end
# More permissive throttling for API requests by IP (for development/testing)
- throttle("api/ip", limit: 200, period: 1.hour) do |request|
+ throttle("api/ip", limit: self_hosted ? 20_000 : 200, period: 1.hour) do |request|
request.ip if request.path.start_with?("/api/")
end
diff --git a/config/initializers/version.rb b/config/initializers/version.rb
index 134b959af96..3aefc6bac7e 100644
--- a/config/initializers/version.rb
+++ b/config/initializers/version.rb
@@ -14,7 +14,7 @@ def commit_sha
private
def semver
- "0.5.0"
+ "0.6.0"
end
end
end
diff --git a/config/routes.rb b/config/routes.rb
index f04142873b9..d6c2bc7ac67 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -24,6 +24,12 @@
end
end
+ resources :family_exports, only: %i[new create index] do
+ member do
+ get :download
+ end
+ end
+
get "changelog", to: "pages#changelog"
get "feedback", to: "pages#feedback"
@@ -110,7 +116,10 @@
resources :holdings, only: %i[index new show destroy]
resources :trades, only: %i[show new create update destroy]
- resources :valuations, only: %i[show new create update destroy]
+ resources :valuations, only: %i[show new create update destroy] do
+ post :confirm_create, on: :collection
+ post :confirm_update, on: :member
+ end
namespace :transactions do
resource :bulk_deletion, only: :create
@@ -147,28 +156,27 @@
end
end
- resources :accounts, only: %i[index new], shallow: true do
+ resources :accounts, only: %i[index new show destroy], shallow: true do
member do
post :sync
- get :chart
get :sparkline
patch :toggle_active
end
+
+ collection do
+ post :sync_all
+ end
end
# Convenience routes for polymorphic paths
# Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123
- direct :account do |model, options|
- route_for model.accountable_name, model, options
- end
-
direct :edit_account do |model, options|
route_for "edit_#{model.accountable_name}", model, options
end
- resources :depositories, except: :index
- resources :investments, except: :index
- resources :properties, except: :index do
+ resources :depositories, only: %i[new create edit update]
+ resources :investments, only: %i[new create edit update]
+ resources :properties, only: %i[new create edit update] do
member do
get :balances
patch :update_balances
@@ -177,12 +185,12 @@
patch :update_address
end
end
- resources :vehicles, except: :index
- resources :credit_cards, except: :index
- resources :loans, except: :index
- resources :cryptos, except: :index
- resources :other_assets, except: :index
- resources :other_liabilities, except: :index
+ resources :vehicles, only: %i[new create edit update]
+ resources :credit_cards, only: %i[new create edit update]
+ resources :loans, only: %i[new create edit update]
+ resources :cryptos, only: %i[new create edit update]
+ resources :other_assets, only: %i[new create edit update]
+ resources :other_liabilities, only: %i[new create edit update]
resources :securities, only: :index
diff --git a/db/migrate/20250710225721_add_valuation_kind.rb b/db/migrate/20250710225721_add_valuation_kind.rb
new file mode 100644
index 00000000000..e6b80702ce2
--- /dev/null
+++ b/db/migrate/20250710225721_add_valuation_kind.rb
@@ -0,0 +1,5 @@
+class AddValuationKind < ActiveRecord::Migration[7.2]
+ def change
+ add_column :valuations, :kind, :string, default: "reconciliation", null: false
+ end
+end
diff --git a/db/migrate/20250718120146_add_indexes_to_core_models.rb b/db/migrate/20250718120146_add_indexes_to_core_models.rb
new file mode 100644
index 00000000000..bac38c8c158
--- /dev/null
+++ b/db/migrate/20250718120146_add_indexes_to_core_models.rb
@@ -0,0 +1,20 @@
+class AddIndexesToCoreModels < ActiveRecord::Migration[7.2]
+ def change
+ # Accounts table indexes
+ add_index :accounts, [ :family_id, :status ]
+ add_index :accounts, :status
+ add_index :accounts, :currency
+
+ # Balances table indexes
+ add_index :balances, [ :account_id, :date ], order: { date: :desc }
+
+ # Entries table indexes
+ add_index :entries, [ :account_id, :date ]
+ add_index :entries, :date
+ add_index :entries, :entryable_type
+ add_index :entries, "lower(name)", name: "index_entries_on_lower_name"
+
+ # Transfers table indexes
+ add_index :transfers, :status
+ end
+end
diff --git a/db/migrate/20250719121103_add_start_end_columns_to_balances.rb b/db/migrate/20250719121103_add_start_end_columns_to_balances.rb
new file mode 100644
index 00000000000..1c86443998b
--- /dev/null
+++ b/db/migrate/20250719121103_add_start_end_columns_to_balances.rb
@@ -0,0 +1,72 @@
+class AddStartEndColumnsToBalances < ActiveRecord::Migration[7.2]
+ def up
+ # Add new columns for balance tracking
+ add_column :balances, :start_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0
+ add_column :balances, :start_non_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0
+
+ # Flow tracking columns (absolute values)
+ add_column :balances, :cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
+ add_column :balances, :cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
+ add_column :balances, :non_cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
+ add_column :balances, :non_cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
+
+ # Market value changes
+ add_column :balances, :net_market_flows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
+
+ # Manual adjustments from valuations
+ add_column :balances, :cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0
+ add_column :balances, :non_cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0
+
+ # Flows factor determines *how* the flows affect the balance.
+ # Inflows increase asset accounts, while inflows decrease liability accounts (reducing debt via "payment")
+ add_column :balances, :flows_factor, :integer, null: false, default: 1
+
+ # Add generated columns
+ change_table :balances do |t|
+ t.virtual :start_balance, type: :decimal, precision: 19, scale: 4, stored: true,
+ as: "start_cash_balance + start_non_cash_balance"
+
+ t.virtual :end_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true,
+ as: "start_cash_balance + ((cash_inflows - cash_outflows) * flows_factor) + cash_adjustments"
+
+ t.virtual :end_non_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true,
+ as: "start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * flows_factor) + net_market_flows + non_cash_adjustments"
+
+ # Postgres doesn't support generated columns depending on other generated columns,
+ # but we want the integrity of the data to happen at the DB level, so this is the full formula.
+ # Formula: (cash components) + (non-cash components)
+ t.virtual :end_balance, type: :decimal, precision: 19, scale: 4, stored: true,
+ as: <<~SQL.squish
+ (
+ start_cash_balance +
+ ((cash_inflows - cash_outflows) * flows_factor) +
+ cash_adjustments
+ ) + (
+ start_non_cash_balance +
+ ((non_cash_inflows - non_cash_outflows) * flows_factor) +
+ net_market_flows +
+ non_cash_adjustments
+ )
+ SQL
+ end
+ end
+
+ def down
+ # Remove generated columns first (PostgreSQL requirement)
+ remove_column :balances, :start_balance
+ remove_column :balances, :end_cash_balance
+ remove_column :balances, :end_non_cash_balance
+ remove_column :balances, :end_balance
+
+ # Remove new columns
+ remove_column :balances, :start_cash_balance
+ remove_column :balances, :start_non_cash_balance
+ remove_column :balances, :cash_inflows
+ remove_column :balances, :cash_outflows
+ remove_column :balances, :non_cash_inflows
+ remove_column :balances, :non_cash_outflows
+ remove_column :balances, :net_market_flows
+ remove_column :balances, :cash_adjustments
+ remove_column :balances, :non_cash_adjustments
+ end
+end
diff --git a/db/migrate/20250724115507_create_family_exports.rb b/db/migrate/20250724115507_create_family_exports.rb
new file mode 100644
index 00000000000..d432d48d753
--- /dev/null
+++ b/db/migrate/20250724115507_create_family_exports.rb
@@ -0,0 +1,10 @@
+class CreateFamilyExports < ActiveRecord::Migration[7.2]
+ def change
+ create_table :family_exports, id: :uuid do |t|
+ t.references :family, null: false, foreign_key: true, type: :uuid
+ t.string :status, default: "pending", null: false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 8484bac88e1..5984a8f2a18 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_07_02_173231) do
+ActiveRecord::Schema[7.2].define(version: 2025_07_24_115507) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -37,11 +37,14 @@
t.string "status", default: "active"
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
+ t.index ["currency"], name: "index_accounts_on_currency"
t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type"
t.index ["family_id", "id"], name: "index_accounts_on_family_id_and_id"
+ t.index ["family_id", "status"], name: "index_accounts_on_family_id_and_status"
t.index ["family_id"], name: "index_accounts_on_family_id"
t.index ["import_id"], name: "index_accounts_on_import_id"
t.index ["plaid_account_id"], name: "index_accounts_on_plaid_account_id"
+ t.index ["status"], name: "index_accounts_on_status"
end
create_table "active_storage_attachments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -112,7 +115,22 @@
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0"
+ t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false
+ t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false
+ t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false
+ t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false
+ t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false
+ t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false
+ t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false
+ t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false
+ t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false
+ t.integer "flows_factor", default: 1, null: false
+ t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true
+ t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true
+ t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true
+ t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true
t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true
+ t.index ["account_id", "date"], name: "index_balances_on_account_id_and_date", order: { date: :desc }
t.index ["account_id"], name: "index_balances_on_account_id"
end
@@ -215,7 +233,11 @@
t.boolean "excluded", default: false
t.string "plaid_id"
t.jsonb "locked_attributes", default: {}
+ t.index "lower((name)::text)", name: "index_entries_on_lower_name"
+ t.index ["account_id", "date"], name: "index_entries_on_account_id_and_date"
t.index ["account_id"], name: "index_entries_on_account_id"
+ t.index ["date"], name: "index_entries_on_date"
+ t.index ["entryable_type"], name: "index_entries_on_entryable_type"
t.index ["import_id"], name: "index_entries_on_import_id"
end
@@ -248,6 +270,14 @@
t.datetime "latest_sync_completed_at", default: -> { "CURRENT_TIMESTAMP" }
end
+ create_table "family_exports", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "family_id", null: false
+ t.string "status", default: "pending", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["family_id"], name: "index_family_exports_on_family_id"
+ end
+
create_table "holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.uuid "security_id", null: false
@@ -741,6 +771,7 @@
t.index ["inflow_transaction_id", "outflow_transaction_id"], name: "idx_on_inflow_transaction_id_outflow_transaction_id_8cd07a28bd", unique: true
t.index ["inflow_transaction_id"], name: "index_transfers_on_inflow_transaction_id"
t.index ["outflow_transaction_id"], name: "index_transfers_on_outflow_transaction_id"
+ t.index ["status"], name: "index_transfers_on_status"
end
create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -779,6 +810,7 @@
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "locked_attributes", default: {}
+ t.string "kind", default: "reconciliation", null: false
end
create_table "vehicles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -806,6 +838,7 @@
add_foreign_key "chats", "users"
add_foreign_key "entries", "accounts"
add_foreign_key "entries", "imports"
+ add_foreign_key "family_exports", "families"
add_foreign_key "holdings", "accounts"
add_foreign_key "holdings", "securities"
add_foreign_key "impersonation_session_logs", "impersonation_sessions"
diff --git a/lib/tasks/data_migration.rake b/lib/tasks/data_migration.rake
index 509e033c866..febdcb3b00e 100644
--- a/lib/tasks/data_migration.rake
+++ b/lib/tasks/data_migration.rake
@@ -111,4 +111,62 @@ namespace :data_migration do
puts "✅ Duplicate security migration complete."
end
+
+ desc "Migrate account valuation anchors"
+ # 2025-07-10: Set opening_anchor kinds for valuations to support event-sourced ledger model.
+ # Manual accounts get their oldest valuation marked as opening_anchor, which acts as the
+ # starting balance for the account. Current anchors are only used for Plaid accounts.
+ task migrate_account_valuation_anchors: :environment do
+ puts "==> Migrating account valuation anchors..."
+
+ manual_accounts = Account.manual.includes(valuations: :entry)
+ total_accounts = manual_accounts.count
+ accounts_processed = 0
+ opening_anchors_set = 0
+
+ manual_accounts.find_each do |account|
+ accounts_processed += 1
+
+ # Find oldest account entry
+ oldest_entry = account.entries
+ .order("date ASC, created_at ASC")
+ .first
+
+ # Check if it's a valuation that isn't already an anchor
+ if oldest_entry && oldest_entry.valuation?
+ derived_valuation_name = Valuation.build_opening_anchor_name(account.accountable_type)
+
+ Account.transaction do
+ oldest_entry.valuation.update!(kind: "opening_anchor")
+ oldest_entry.update!(name: derived_valuation_name)
+ end
+ opening_anchors_set += 1
+ end
+
+ if accounts_processed % 100 == 0
+ puts "[#{accounts_processed}/#{total_accounts}] Processed #{accounts_processed} accounts..."
+ end
+ rescue => e
+ puts "ERROR processing account #{account.id}: #{e.message}"
+ end
+
+ puts "✅ Account valuation anchor migration complete."
+ puts " Processed: #{accounts_processed} accounts"
+ puts " Opening anchors set: #{opening_anchors_set}"
+ end
+
+ desc "Migrate balance components"
+ # 2025-07-20: Migrate balance components to support event-sourced ledger model.
+ # This task:
+ # 1. Sets the flows_factor for each account based on the account's classification
+ # 2. Sets the start_cash_balance, start_non_cash_balance, and start_balance for each balance
+ # 3. Sets the cash_inflows, cash_outflows, non_cash_inflows, non_cash_outflows, net_market_flows, cash_adjustments, and non_cash_adjustments for each balance
+ # 4. Sets the end_cash_balance, end_non_cash_balance, and end_balance for each balance
+ task migrate_balance_components: :environment do
+ puts "==> Migrating balance components..."
+
+ BalanceComponentMigrator.run
+
+ puts "✅ Balance component migration complete."
+ end
end
diff --git a/test/components/previews/alert_component_preview.rb b/test/components/previews/alert_component_preview.rb
index 34abcb3720c..ddd91183cd2 100644
--- a/test/components/previews/alert_component_preview.rb
+++ b/test/components/previews/alert_component_preview.rb
@@ -2,6 +2,6 @@ class AlertComponentPreview < Lookbook::Preview
# @param message text
# @param variant select [info, success, warning, error]
def default(message: "This is an alert message.", variant: :info)
- render AlertComponent.new(message: message, variant: variant.to_sym)
+ render DS::Alert.new(message: message, variant: variant.to_sym)
end
end
diff --git a/test/components/previews/button_component_preview.rb b/test/components/previews/button_component_preview.rb
index 499d36b3a55..e6045ab2069 100644
--- a/test/components/previews/button_component_preview.rb
+++ b/test/components/previews/button_component_preview.rb
@@ -1,10 +1,10 @@
class ButtonComponentPreview < ViewComponent::Preview
- # @param variant select {{ ButtonComponent::VARIANTS.keys }}
- # @param size select {{ ButtonComponent::SIZES.keys }}
+ # @param variant select {{ DS::Button::VARIANTS.keys }}
+ # @param size select {{ DS::Button::SIZES.keys }}
# @param disabled toggle
# @param icon select ["plus", "circle"]
def default(variant: "primary", size: "md", disabled: false, icon: "plus")
- render ButtonComponent.new(
+ render DS::Button.new(
text: "Sample button",
variant: variant,
size: size,
diff --git a/test/components/previews/dialog_component_preview.rb b/test/components/previews/dialog_component_preview.rb
index a1b81340aed..5f5c78d92f7 100644
--- a/test/components/previews/dialog_component_preview.rb
+++ b/test/components/previews/dialog_component_preview.rb
@@ -1,7 +1,7 @@
class DialogComponentPreview < ViewComponent::Preview
# @param show_overflow toggle
def modal(show_overflow: false)
- render DialogComponent.new(variant: "modal") do |dialog|
+ render DS::Dialog.new(variant: "modal") do |dialog|
dialog.with_header(title: "Sample modal title")
dialog.with_body do
@@ -21,7 +21,7 @@ def modal(show_overflow: false)
# @param show_overflow toggle
def drawer(show_overflow: false)
- render DialogComponent.new(variant: "drawer") do |dialog|
+ render DS::Dialog.new(variant: "drawer") do |dialog|
dialog.with_header(title: "Drawer title")
dialog.with_body do
diff --git a/test/components/previews/disclosure_component_preview.rb b/test/components/previews/disclosure_component_preview.rb
index ec6e6d1db54..51b9a8a0041 100644
--- a/test/components/previews/disclosure_component_preview.rb
+++ b/test/components/previews/disclosure_component_preview.rb
@@ -2,7 +2,7 @@ class DisclosureComponentPreview < ViewComponent::Preview
# @display container_classes max-w-[400px]
# @param align select ["left", "right"]
def default(align: "right")
- render DisclosureComponent.new(title: "Title", align: align, open: true) do |disclosure|
+ render DS::Disclosure.new(title: "Title", align: align, open: true) do |disclosure|
disclosure.with_summary_content do
content_tag(:p, "$200.25", class: "text-xs font-mono font-medium")
end
diff --git a/test/components/previews/filled_icon_component_preview.rb b/test/components/previews/filled_icon_component_preview.rb
index c2670308fc5..3b0ee022be1 100644
--- a/test/components/previews/filled_icon_component_preview.rb
+++ b/test/components/previews/filled_icon_component_preview.rb
@@ -1,11 +1,11 @@
class FilledIconComponentPreview < ViewComponent::Preview
# @param size select ["sm", "md", "lg"]
def default(size: "md")
- render FilledIconComponent.new(icon: "home", variant: :default, size: size)
+ render DS::FilledIcon.new(icon: "home", variant: :default, size: size)
end
# @param size select ["sm", "md", "lg"]
def text(size: "md")
- render FilledIconComponent.new(variant: :text, text: "Test", size: size, rounded: true)
+ render DS::FilledIcon.new(variant: :text, text: "Test", size: size, rounded: true)
end
end
diff --git a/test/components/previews/link_component_preview.rb b/test/components/previews/link_component_preview.rb
index 17150204995..d3600bf37bb 100644
--- a/test/components/previews/link_component_preview.rb
+++ b/test/components/previews/link_component_preview.rb
@@ -2,17 +2,17 @@ class LinkComponentPreview < ViewComponent::Preview
# Usage
# -------------
#
- # LinkComponent is a small abstraction on top of the `link_to` helper.
+ # DS::Link is a small abstraction on top of the `link_to` helper.
#
- # It can be used as a regular link or styled as a "Link button" using any of the available ButtonComponent variants.
+ # It can be used as a regular link or styled as a "Link button" using any of the available DS::Button variants.
#
- # @param variant select {{ LinkComponent::VARIANTS.keys }}
- # @param size select {{ LinkComponent::SIZES.keys }}
+ # @param variant select {{ DS::Link::VARIANTS.keys }}
+ # @param size select {{ DS::Link::SIZES.keys }}
# @param icon select ["", "plus", "arrow-right"]
# @param icon_position select ["left", "right"]
# @param full_width toggle
def default(variant: "default", size: "md", icon: "plus", icon_position: "left", full_width: false)
- render LinkComponent.new(
+ render DS::Link.new(
href: "#",
text: "Preview link",
variant: variant,
diff --git a/test/components/previews/menu_component_preview.rb b/test/components/previews/menu_component_preview.rb
index 6c210437d8d..592b276c171 100644
--- a/test/components/previews/menu_component_preview.rb
+++ b/test/components/previews/menu_component_preview.rb
@@ -1,19 +1,19 @@
class MenuComponentPreview < ViewComponent::Preview
def icon
- render MenuComponent.new(variant: "icon") do |menu|
+ render DS::Menu.new(variant: "icon") do |menu|
menu_contents(menu)
end
end
def button
- render MenuComponent.new(variant: "button") do |menu|
+ render DS::Menu.new(variant: "button") do |menu|
menu.with_button(text: "Open menu", variant: "secondary")
menu_contents(menu)
end
end
def avatar
- render MenuComponent.new(variant: "avatar") do |menu|
+ render DS::Menu.new(variant: "avatar") do |menu|
menu_contents(menu)
end
end
diff --git a/test/components/previews/toggle_component_preview.rb b/test/components/previews/toggle_component_preview.rb
index 9ab3d0143af..27eeff52234 100644
--- a/test/components/previews/toggle_component_preview.rb
+++ b/test/components/previews/toggle_component_preview.rb
@@ -2,7 +2,7 @@ class ToggleComponentPreview < ViewComponent::Preview
# @param disabled toggle
def default(disabled: false)
render(
- ToggleComponent.new(
+ DS::Toggle.new(
id: "toggle-component-id",
name: "toggle-component-name",
checked: false,
diff --git a/test/components/previews/tooltip_component_preview.rb b/test/components/previews/tooltip_component_preview.rb
new file mode 100644
index 00000000000..68bd6c320b1
--- /dev/null
+++ b/test/components/previews/tooltip_component_preview.rb
@@ -0,0 +1,32 @@
+class TooltipComponentPreview < ViewComponent::Preview
+ # @param text text
+ # @param placement select [top, right, bottom, left]
+ # @param offset number
+ # @param cross_axis number
+ # @param icon text
+ # @param size select [xs, sm, md, lg, xl, 2xl]
+ # @param color select [default, white, success, warning, destructive, current]
+ def default(text: "This is helpful information", placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default")
+ render DS::Tooltip.new(
+ text: text,
+ placement: placement,
+ offset: offset,
+ cross_axis: cross_axis,
+ icon: icon,
+ size: size,
+ color: color
+ )
+ end
+
+ def with_block_content
+ render DS::Tooltip.new(icon: "help-circle", color: "warning") do
+ tag.div do
+ tag.p("Custom content with formatting:", class: "font-medium mb-1") +
+ tag.ul(class: "list-disc list-inside text-xs") do
+ tag.li("First item") +
+ tag.li("Second item")
+ end
+ end
+ end
+ end
+end
diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb
index ba0b937e592..ec26ef49f00 100644
--- a/test/controllers/accounts_controller_test.rb
+++ b/test/controllers/accounts_controller_test.rb
@@ -11,18 +11,25 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
+ test "should get show" do
+ get account_url(@account)
+ assert_response :success
+ end
+
test "should sync account" do
post sync_account_url(@account)
assert_redirected_to account_url(@account)
end
- test "should get chart" do
- get chart_account_url(@account)
- assert_response :success
- end
-
test "should get sparkline" do
get sparkline_account_url(@account)
assert_response :success
end
+
+ test "destroys account" do
+ delete account_url(@account)
+ assert_redirected_to accounts_path
+ assert_enqueued_with job: DestroyJob
+ assert_equal "Account scheduled for deletion", flash[:notice]
+ end
end
diff --git a/test/controllers/credit_cards_controller_test.rb b/test/controllers/credit_cards_controller_test.rb
index 6a270156c03..d19db6512cb 100644
--- a/test/controllers/credit_cards_controller_test.rb
+++ b/test/controllers/credit_cards_controller_test.rb
@@ -11,8 +11,8 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest
test "creates with credit card details" do
assert_difference -> { Account.count } => 1,
-> { CreditCard.count } => 1,
- -> { Valuation.count } => 2,
- -> { Entry.count } => 2 do
+ -> { Valuation.count } => 1,
+ -> { Entry.count } => 1 do
post credit_cards_path, params: {
account: {
name: "New Credit Card",
@@ -48,7 +48,7 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest
test "updates with credit card details" do
assert_no_difference [ "Account.count", "CreditCard.count" ] do
- patch account_path(@account), params: {
+ patch credit_card_path(@account), params: {
account: {
name: "Updated Credit Card",
balance: 2000,
diff --git a/test/controllers/family_exports_controller_test.rb b/test/controllers/family_exports_controller_test.rb
new file mode 100644
index 00000000000..63adf7884bf
--- /dev/null
+++ b/test/controllers/family_exports_controller_test.rb
@@ -0,0 +1,73 @@
+require "test_helper"
+
+class FamilyExportsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @admin = users(:family_admin)
+ @non_admin = users(:family_member)
+ @family = @admin.family
+
+ sign_in @admin
+ end
+
+ test "non-admin cannot access exports" do
+ sign_in @non_admin
+
+ get new_family_export_path
+ assert_redirected_to root_path
+
+ post family_exports_path
+ assert_redirected_to root_path
+
+ get family_exports_path
+ assert_redirected_to root_path
+ end
+
+ test "admin can view export modal" do
+ get new_family_export_path
+ assert_response :success
+ assert_select "h2", text: "Export your data"
+ end
+
+ test "admin can create export" do
+ assert_enqueued_with(job: FamilyDataExportJob) do
+ post family_exports_path
+ end
+
+ assert_redirected_to settings_profile_path
+ assert_equal "Export started. You'll be able to download it shortly.", flash[:notice]
+
+ export = @family.family_exports.last
+ assert_equal "pending", export.status
+ end
+
+ test "admin can view export list" do
+ export1 = @family.family_exports.create!(status: "completed")
+ export2 = @family.family_exports.create!(status: "processing")
+
+ get family_exports_path
+ assert_response :success
+
+ assert_match export1.filename, response.body
+ assert_match "Exporting...", response.body
+ end
+
+ test "admin can download completed export" do
+ export = @family.family_exports.create!(status: "completed")
+ export.export_file.attach(
+ io: StringIO.new("test zip content"),
+ filename: "test.zip",
+ content_type: "application/zip"
+ )
+
+ get download_family_export_path(export)
+ assert_redirected_to(/rails\/active_storage/)
+ end
+
+ test "cannot download incomplete export" do
+ export = @family.family_exports.create!(status: "processing")
+
+ get download_family_export_path(export)
+ assert_redirected_to settings_profile_path
+ assert_equal "Export not ready for download", flash[:alert]
+ end
+end
diff --git a/test/controllers/loans_controller_test.rb b/test/controllers/loans_controller_test.rb
index ec590363cc9..47809400c03 100644
--- a/test/controllers/loans_controller_test.rb
+++ b/test/controllers/loans_controller_test.rb
@@ -11,8 +11,8 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
test "creates with loan details" do
assert_difference -> { Account.count } => 1,
-> { Loan.count } => 1,
- -> { Valuation.count } => 2,
- -> { Entry.count } => 2 do
+ -> { Valuation.count } => 1,
+ -> { Entry.count } => 1 do
post loans_path, params: {
account: {
name: "New Loan",
@@ -46,7 +46,7 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
test "updates with loan details" do
assert_no_difference [ "Account.count", "Loan.count" ] do
- patch account_path(@account), params: {
+ patch loan_path(@account), params: {
account: {
name: "Updated Loan",
balance: 45000,
diff --git a/test/controllers/properties_controller_test.rb b/test/controllers/properties_controller_test.rb
index b5f1305ff80..34f76734dba 100644
--- a/test/controllers/properties_controller_test.rb
+++ b/test/controllers/properties_controller_test.rb
@@ -71,10 +71,6 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
test "updates balances tab" do
original_balance = @account.balance
- # Mock the update_balance method to return a successful result
- Account::BalanceUpdater::Result.any_instance.stubs(:success?).returns(true)
- Account::BalanceUpdater::Result.any_instance.stubs(:updated?).returns(true)
-
patch update_balances_property_path(@account), params: {
account: {
balance: 600000,
@@ -116,9 +112,7 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
end
test "balances update handles validation errors" do
- # Mock update_balance to return a failure result
- Account::BalanceUpdater::Result.any_instance.stubs(:success?).returns(false)
- Account::BalanceUpdater::Result.any_instance.stubs(:error_message).returns("Invalid balance")
+ Account.any_instance.stubs(:set_current_balance).returns(OpenStruct.new(success?: false, error_message: "Invalid balance"))
patch update_balances_property_path(@account), params: {
account: {
diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb
index 2500615cbd6..c2cc94b7f8f 100644
--- a/test/controllers/transactions_controller_test.rb
+++ b/test/controllers/transactions_controller_test.rb
@@ -162,8 +162,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
income_money: Money.new(0, "USD")
)
- expected_filters = { "start_date" => 30.days.ago.to_date }
- Transaction::Search.expects(:new).with(family, filters: expected_filters).returns(search)
+ Transaction::Search.expects(:new).with(family, filters: {}).returns(search)
search.expects(:totals).once.returns(totals)
get transactions_url
diff --git a/test/controllers/valuations_controller_test.rb b/test/controllers/valuations_controller_test.rb
index 52c62ad4cde..7827906b8f5 100644
--- a/test/controllers/valuations_controller_test.rb
+++ b/test/controllers/valuations_controller_test.rb
@@ -8,14 +8,13 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest
@entry = entries(:valuation)
end
- test "creates entry with basic attributes" do
+ test "can create reconciliation" do
account = accounts(:investment)
assert_difference [ "Entry.count", "Valuation.count" ], 1 do
post valuations_url, params: {
entry: {
amount: account.balance + 100,
- currency: "USD",
date: Date.current.to_s,
account_id: account.id
}
@@ -36,9 +35,9 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest
assert_no_difference [ "Entry.count", "Valuation.count" ] do
patch valuation_url(@entry), params: {
entry: {
- amount: 20000,
- currency: "USD",
- date: Date.current
+ amount: 22000,
+ date: Date.current,
+ notes: "Test notes"
}
}
end
@@ -46,5 +45,9 @@ class ValuationsControllerTest < ActionDispatch::IntegrationTest
assert_enqueued_with job: SyncJob
assert_redirected_to account_url(@entry.account)
+
+ @entry.reload
+ assert_equal 22000, @entry.amount
+ assert_equal "Test notes", @entry.notes
end
end
diff --git a/test/controllers/vehicles_controller_test.rb b/test/controllers/vehicles_controller_test.rb
index bb7df9c68c6..55aa89cff76 100644
--- a/test/controllers/vehicles_controller_test.rb
+++ b/test/controllers/vehicles_controller_test.rb
@@ -11,8 +11,8 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest
test "creates with vehicle details" do
assert_difference -> { Account.count } => 1,
-> { Vehicle.count } => 1,
- -> { Valuation.count } => 2,
- -> { Entry.count } => 2 do
+ -> { Valuation.count } => 1,
+ -> { Entry.count } => 1 do
post vehicles_path, params: {
account: {
name: "Vehicle",
@@ -45,7 +45,7 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest
test "updates with vehicle details" do
assert_no_difference [ "Account.count", "Vehicle.count" ] do
- patch account_path(@account), params: {
+ patch vehicle_path(@account), params: {
account: {
name: "Updated Vehicle",
balance: 28000,
@@ -64,7 +64,7 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest
}
end
- assert_redirected_to @account
+ assert_redirected_to account_path(@account)
assert_equal "Vehicle account updated", flash[:notice]
assert_enqueued_with(job: SyncJob)
end
diff --git a/test/data_migrations/balance_component_migrator_test.rb b/test/data_migrations/balance_component_migrator_test.rb
new file mode 100644
index 00000000000..add8384c21d
--- /dev/null
+++ b/test/data_migrations/balance_component_migrator_test.rb
@@ -0,0 +1,160 @@
+require "test_helper"
+
+class BalanceComponentMigratorTest < ActiveSupport::TestCase
+ include EntriesTestHelper
+
+ setup do
+ @depository = accounts(:depository)
+ @investment = accounts(:investment)
+ @loan = accounts(:loan)
+
+ # Start fresh
+ Balance.delete_all
+ end
+
+ test "depository account with no gaps" do
+ create_balance_history(@depository, [
+ { date: 5.days.ago, cash_balance: 1000, balance: 1000 },
+ { date: 4.days.ago, cash_balance: 1100, balance: 1100 },
+ { date: 3.days.ago, cash_balance: 1050, balance: 1050 },
+ { date: 2.days.ago, cash_balance: 1200, balance: 1200 },
+ { date: 1.day.ago, cash_balance: 1150, balance: 1150 }
+ ])
+
+ BalanceComponentMigrator.run
+
+ assert_migrated_balances @depository, [
+ { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
+ { date: 4.days.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 100, non_cash_inflows: 0, end_cash: 1100, end_non_cash: 0, end: 1100 },
+ { date: 3.days.ago, start_cash: 1100, start_non_cash: 0, start: 1100, cash_inflows: -50, non_cash_inflows: 0, end_cash: 1050, end_non_cash: 0, end: 1050 },
+ { date: 2.days.ago, start_cash: 1050, start_non_cash: 0, start: 1050, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1200, end_non_cash: 0, end: 1200 },
+ { date: 1.day.ago, start_cash: 1200, start_non_cash: 0, start: 1200, cash_inflows: -50, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 }
+ ]
+ end
+
+ test "depository account with gaps" do
+ create_balance_history(@depository, [
+ { date: 5.days.ago, cash_balance: 1000, balance: 1000 },
+ { date: 1.day.ago, cash_balance: 1150, balance: 1150 }
+ ])
+
+ BalanceComponentMigrator.run
+
+ assert_migrated_balances @depository, [
+ { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
+ { date: 1.day.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 }
+ ]
+ end
+
+ test "investment account with no gaps" do
+ create_balance_history(@investment, [
+ { date: 3.days.ago, cash_balance: 100, balance: 200 },
+ { date: 2.days.ago, cash_balance: 200, balance: 300 },
+ { date: 1.day.ago, cash_balance: 0, balance: 300 }
+ ])
+
+ BalanceComponentMigrator.run
+
+ assert_migrated_balances @investment, [
+ { date: 3.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 100, non_cash_inflows: 100, end_cash: 100, end_non_cash: 100, end: 200 },
+ { date: 2.days.ago, start_cash: 100, start_non_cash: 100, start: 200, cash_inflows: 100, non_cash_inflows: 0, end_cash: 200, end_non_cash: 100, end: 300 },
+ { date: 1.day.ago, start_cash: 200, start_non_cash: 100, start: 300, cash_inflows: -200, non_cash_inflows: 200, end_cash: 0, end_non_cash: 300, end: 300 }
+ ]
+ end
+
+ test "investment account with gaps" do
+ create_balance_history(@investment, [
+ { date: 5.days.ago, cash_balance: 1000, balance: 1000 },
+ { date: 1.day.ago, cash_balance: 1150, balance: 1150 }
+ ])
+
+ BalanceComponentMigrator.run
+
+ assert_migrated_balances @investment, [
+ { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
+ { date: 1.day.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 }
+ ]
+ end
+
+ # Negative flows factor test
+ test "loan account with no gaps" do
+ create_balance_history(@loan, [
+ { date: 3.days.ago, cash_balance: 0, balance: 200 },
+ { date: 2.days.ago, cash_balance: 0, balance: 300 },
+ { date: 1.day.ago, cash_balance: 0, balance: 500 }
+ ])
+
+ BalanceComponentMigrator.run
+
+ assert_migrated_balances @loan, [
+ { date: 3.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 0, non_cash_inflows: -200, end_cash: 0, end_non_cash: 200, end: 200 },
+ { date: 2.days.ago, start_cash: 0, start_non_cash: 200, start: 200, cash_inflows: 0, non_cash_inflows: -100, end_cash: 0, end_non_cash: 300, end: 300 },
+ { date: 1.day.ago, start_cash: 0, start_non_cash: 300, start: 300, cash_inflows: 0, non_cash_inflows: -200, end_cash: 0, end_non_cash: 500, end: 500 }
+ ]
+ end
+
+ test "loan account with gaps" do
+ create_balance_history(@loan, [
+ { date: 5.days.ago, cash_balance: 0, balance: 1000 },
+ { date: 1.day.ago, cash_balance: 0, balance: 2000 }
+ ])
+
+ BalanceComponentMigrator.run
+
+ assert_migrated_balances @loan, [
+ { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 0, non_cash_inflows: -1000, end_cash: 0, end_non_cash: 1000, end: 1000 },
+ { date: 1.day.ago, start_cash: 0, start_non_cash: 1000, start: 1000, cash_inflows: 0, non_cash_inflows: -1000, end_cash: 0, end_non_cash: 2000, end: 2000 }
+ ]
+ end
+
+ private
+ def create_balance_history(account, balances)
+ balances.each do |balance|
+ account.balances.create!(
+ date: balance[:date].to_date,
+ balance: balance[:balance],
+ cash_balance: balance[:cash_balance],
+ currency: account.currency
+ )
+ end
+ end
+
+ def assert_migrated_balances(account, expected)
+ balances = account.balances.order(:date)
+
+ expected.each_with_index do |expected_values, index|
+ balance = balances.find { |b| b.date == expected_values[:date].to_date }
+ assert balance, "Expected balance for #{expected_values[:date].to_date} but none found"
+
+ # Assert expected values
+ assert_equal expected_values[:start_cash], balance.start_cash_balance,
+ "start_cash_balance mismatch for #{balance.date}"
+ assert_equal expected_values[:start_non_cash], balance.start_non_cash_balance,
+ "start_non_cash_balance mismatch for #{balance.date}"
+ assert_equal expected_values[:start], balance.start_balance,
+ "start_balance mismatch for #{balance.date}"
+ assert_equal expected_values[:cash_inflows], balance.cash_inflows,
+ "cash_inflows mismatch for #{balance.date}"
+ assert_equal expected_values[:non_cash_inflows], balance.non_cash_inflows,
+ "non_cash_inflows mismatch for #{balance.date}"
+ assert_equal expected_values[:end_cash], balance.end_cash_balance,
+ "end_cash_balance mismatch for #{balance.date}"
+ assert_equal expected_values[:end_non_cash], balance.end_non_cash_balance,
+ "end_non_cash_balance mismatch for #{balance.date}"
+ assert_equal expected_values[:end], balance.end_balance,
+ "end_balance mismatch for #{balance.date}"
+
+ # Assert zeros for other fields
+ assert_equal 0, balance.cash_outflows,
+ "cash_outflows should be zero for #{balance.date}"
+ assert_equal 0, balance.non_cash_outflows,
+ "non_cash_outflows should be zero for #{balance.date}"
+ assert_equal 0, balance.cash_adjustments,
+ "cash_adjustments should be zero for #{balance.date}"
+ assert_equal 0, balance.non_cash_adjustments,
+ "non_cash_adjustments should be zero for #{balance.date}"
+ assert_equal 0, balance.net_market_flows,
+ "net_market_flows should be zero for #{balance.date}"
+ end
+ end
+end
diff --git a/test/fixtures/family_exports.yml b/test/fixtures/family_exports.yml
new file mode 100644
index 00000000000..4d09645ec0f
--- /dev/null
+++ b/test/fixtures/family_exports.yml
@@ -0,0 +1,3 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+# Empty file - no fixtures needed, tests create them dynamically
diff --git a/test/fixtures/imports.yml b/test/fixtures/imports.yml
index 366bb6d9d4c..b017253272e 100644
--- a/test/fixtures/imports.yml
+++ b/test/fixtures/imports.yml
@@ -7,3 +7,8 @@ trade:
family: dylan_family
type: TradeImport
status: pending
+
+account:
+ family: dylan_family
+ type: AccountImport
+ status: pending
diff --git a/test/fixtures/valuations.yml b/test/fixtures/valuations.yml
index 21aeae24cb2..27891bd4eb4 100644
--- a/test/fixtures/valuations.yml
+++ b/test/fixtures/valuations.yml
@@ -1,2 +1,2 @@
-one: { }
-two: { }
\ No newline at end of file
+one:
+ kind: reconciliation
diff --git a/test/interfaces/accountable_resource_interface_test.rb b/test/interfaces/accountable_resource_interface_test.rb
index ad5f50796d8..5ad9598e120 100644
--- a/test/interfaces/accountable_resource_interface_test.rb
+++ b/test/interfaces/accountable_resource_interface_test.rb
@@ -14,16 +14,4 @@ module AccountableResourceInterfaceTest
get edit_account_url(@account)
assert_response :success
end
-
- test "renders accountable page" do
- get account_url(@account)
- assert_response :success
- end
-
- test "destroys account" do
- delete account_url(@account)
- assert_redirected_to accounts_path
- assert_enqueued_with job: DestroyJob
- assert_equal "#{@account.accountable_name.underscore.humanize} account scheduled for deletion", flash[:notice]
- end
end
diff --git a/test/jobs/family_data_export_job_test.rb b/test/jobs/family_data_export_job_test.rb
new file mode 100644
index 00000000000..6aae26e8131
--- /dev/null
+++ b/test/jobs/family_data_export_job_test.rb
@@ -0,0 +1,32 @@
+require "test_helper"
+
+class FamilyDataExportJobTest < ActiveJob::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @export = @family.family_exports.create!
+ end
+
+ test "marks export as processing then completed" do
+ assert_equal "pending", @export.status
+
+ perform_enqueued_jobs do
+ FamilyDataExportJob.perform_later(@export)
+ end
+
+ @export.reload
+ assert_equal "completed", @export.status
+ assert @export.export_file.attached?
+ end
+
+ test "marks export as failed on error" do
+ # Mock the exporter to raise an error
+ Family::DataExporter.any_instance.stubs(:generate_export).raises(StandardError, "Export failed")
+
+ perform_enqueued_jobs do
+ FamilyDataExportJob.perform_later(@export)
+ end
+
+ @export.reload
+ assert_equal "failed", @export.status
+ end
+end
diff --git a/test/models/account/activity_feed_data_test.rb b/test/models/account/activity_feed_data_test.rb
new file mode 100644
index 00000000000..ec093791bdc
--- /dev/null
+++ b/test/models/account/activity_feed_data_test.rb
@@ -0,0 +1,317 @@
+require "test_helper"
+
+class Account::ActivityFeedDataTest < ActiveSupport::TestCase
+ include EntriesTestHelper
+
+ setup do
+ @family = families(:empty)
+ @checking = @family.accounts.create!(name: "Test Checking", accountable: Depository.new, currency: "USD", balance: 0)
+ @savings = @family.accounts.create!(name: "Test Savings", accountable: Depository.new, currency: "USD", balance: 0)
+ @investment = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0)
+
+ @test_period_start = Date.current - 4.days
+
+ setup_test_data
+ end
+
+ test "returns balance for date with complete balance history" do
+ entries = @checking.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@checking, entries)
+
+ activities = feed_data.entries_by_date
+ day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
+
+ assert_not_nil day2_activity
+ assert_not_nil day2_activity.balance
+ assert_equal 1100, day2_activity.balance.end_balance # End of day 2
+ end
+
+ test "returns balance for first day" do
+ entries = @checking.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@checking, entries)
+
+ activities = feed_data.entries_by_date
+ day1_activity = find_activity_for_date(activities, @test_period_start)
+
+ assert_not_nil day1_activity
+ assert_not_nil day1_activity.balance
+ assert_equal 1000, day1_activity.balance.end_balance # End of first day
+ end
+
+ test "returns nil balance when no balance exists for date" do
+ @checking.balances.destroy_all
+
+ entries = @checking.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@checking, entries)
+
+ activities = feed_data.entries_by_date
+ day1_activity = find_activity_for_date(activities, @test_period_start)
+
+ assert_not_nil day1_activity
+ assert_nil day1_activity.balance
+ end
+
+ test "returns cash and holdings data for investment accounts" do
+ entries = @investment.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@investment, entries)
+
+ activities = feed_data.entries_by_date
+ day3_activity = find_activity_for_date(activities, @test_period_start + 2.days)
+
+ assert_not_nil day3_activity
+ assert_not_nil day3_activity.balance
+
+ # Balance should have the new schema fields
+ assert_equal 400, day3_activity.balance.end_cash_balance # End of day 3 cash balance
+ assert_equal 1500, day3_activity.balance.end_non_cash_balance # Holdings value
+ assert_equal 1900, day3_activity.balance.end_balance # Total balance
+ end
+
+ test "identifies transfers for a specific date" do
+ entries = @checking.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@checking, entries)
+
+ activities = feed_data.entries_by_date
+
+ # Day 2 has the transfer
+ day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
+ assert_not_nil day2_activity
+ assert_equal 1, day2_activity.transfers.size
+ assert_equal @transfer, day2_activity.transfers.first
+
+ # Other days have no transfers
+ day1_activity = find_activity_for_date(activities, @test_period_start)
+ assert_not_nil day1_activity
+ assert_empty day1_activity.transfers
+ end
+
+ test "returns complete ActivityDateData objects with all required fields" do
+ entries = @investment.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(@investment, entries)
+
+ activities = feed_data.entries_by_date
+
+ # Check that we get ActivityDateData objects
+ assert activities.all? { |a| a.is_a?(Account::ActivityFeedData::ActivityDateData) }
+
+ # Check that each ActivityDate has the required fields
+ activities.each do |activity|
+ assert_respond_to activity, :date
+ assert_respond_to activity, :entries
+ assert_respond_to activity, :balance
+ assert_respond_to activity, :transfers
+ end
+ end
+
+ test "handles valuations correctly with new balance schema" do
+ # Create account with known balances
+ account = @family.accounts.create!(name: "Test Investment", accountable: Investment.new, currency: "USD", balance: 0)
+
+ # Day 1: Starting balance
+ account.balances.create!(
+ date: @test_period_start,
+ balance: 7321.56, # Keep old field for now
+ cash_balance: 1000, # Keep old field for now
+ start_cash_balance: 0,
+ start_non_cash_balance: 0,
+ cash_inflows: 1000,
+ cash_outflows: 0,
+ non_cash_inflows: 6321.56,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ currency: "USD"
+ )
+
+ # Day 2: Add transactions, trades and a valuation
+ account.balances.create!(
+ date: @test_period_start + 1.day,
+ balance: 8500, # Keep old field for now
+ cash_balance: 1070, # Keep old field for now
+ start_cash_balance: 1000,
+ start_non_cash_balance: 6321.56,
+ cash_inflows: 70,
+ cash_outflows: 0,
+ non_cash_inflows: 750,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 358.44,
+ currency: "USD"
+ )
+
+ # Create transactions
+ create_transaction(
+ account: account,
+ date: @test_period_start + 1.day,
+ amount: -50,
+ name: "Interest payment"
+ )
+ create_transaction(
+ account: account,
+ date: @test_period_start + 1.day,
+ amount: -20,
+ name: "Interest payment"
+ )
+
+ # Create a trade
+ create_trade(
+ securities(:aapl),
+ account: account,
+ qty: 5,
+ date: @test_period_start + 1.day,
+ price: 150 # 5 * 150 = 750
+ )
+
+ # Create valuation
+ create_valuation(
+ account: account,
+ date: @test_period_start + 1.day,
+ amount: 8500
+ )
+
+ entries = account.entries.includes(:entryable).to_a
+ feed_data = Account::ActivityFeedData.new(account, entries)
+
+ activities = feed_data.entries_by_date
+ day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
+
+ assert_not_nil day2_activity
+ assert_not_nil day2_activity.balance
+
+ # Check new balance fields
+ assert_equal 1070, day2_activity.balance.end_cash_balance
+ assert_equal 7430, day2_activity.balance.end_non_cash_balance
+ assert_equal 8500, day2_activity.balance.end_balance
+ end
+
+ private
+ def find_activity_for_date(activities, date)
+ activities.find { |a| a.date == date }
+ end
+
+ def setup_test_data
+ # Create daily balances for checking account with new schema
+ 5.times do |i|
+ date = @test_period_start + i.days
+ prev_balance = i > 0 ? 1000 + ((i - 1) * 100) : 0
+
+ @checking.balances.create!(
+ date: date,
+ balance: 1000 + (i * 100), # Keep old field for now
+ cash_balance: 1000 + (i * 100), # Keep old field for now
+ start_balance: prev_balance,
+ start_cash_balance: prev_balance,
+ start_non_cash_balance: 0,
+ cash_inflows: i == 0 ? 1000 : 100,
+ cash_outflows: 0,
+ non_cash_inflows: 0,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ currency: "USD"
+ )
+ end
+
+ # Create daily balances for investment account with cash_balance
+ @investment.balances.create!(
+ date: @test_period_start,
+ balance: 500, # Keep old field for now
+ cash_balance: 500, # Keep old field for now
+ start_balance: 0,
+ start_cash_balance: 0,
+ start_non_cash_balance: 0,
+ cash_inflows: 500,
+ cash_outflows: 0,
+ non_cash_inflows: 0,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ currency: "USD"
+ )
+ @investment.balances.create!(
+ date: @test_period_start + 1.day,
+ balance: 500, # Keep old field for now
+ cash_balance: 500, # Keep old field for now
+ start_balance: 500,
+ start_cash_balance: 500,
+ start_non_cash_balance: 0,
+ cash_inflows: 0,
+ cash_outflows: 0,
+ non_cash_inflows: 0,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ currency: "USD"
+ )
+ @investment.balances.create!(
+ date: @test_period_start + 2.days,
+ balance: 1900, # Keep old field for now
+ cash_balance: 400, # Keep old field for now
+ start_balance: 500,
+ start_cash_balance: 500,
+ start_non_cash_balance: 0,
+ cash_inflows: 0,
+ cash_outflows: 100,
+ non_cash_inflows: 1500,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ currency: "USD"
+ )
+
+ # Day 1: Regular transaction
+ create_transaction(
+ account: @checking,
+ date: @test_period_start,
+ amount: -50,
+ name: "Grocery Store"
+ )
+
+ # Day 2: Transfer between accounts
+ @transfer = create_transfer(
+ from_account: @checking,
+ to_account: @savings,
+ amount: 200,
+ date: @test_period_start + 1.day
+ )
+
+ # Day 3: Trade in investment account
+ create_trade(
+ securities(:aapl),
+ account: @investment,
+ qty: 10,
+ date: @test_period_start + 2.days,
+ price: 150
+ )
+
+ # Day 3: Foreign currency transaction
+ create_transaction(
+ account: @investment,
+ date: @test_period_start + 2.days,
+ amount: -100,
+ currency: "EUR",
+ name: "International Wire"
+ )
+
+ # Create exchange rate for foreign currency
+ ExchangeRate.create!(
+ date: @test_period_start + 2.days,
+ from_currency: "EUR",
+ to_currency: "USD",
+ rate: 1.1
+ )
+
+ # Day 4: Valuation
+ create_valuation(
+ account: @investment,
+ date: @test_period_start + 3.days,
+ amount: 25
+ )
+ end
+end
diff --git a/test/models/account/current_balance_manager_test.rb b/test/models/account/current_balance_manager_test.rb
new file mode 100644
index 00000000000..0d7b914beca
--- /dev/null
+++ b/test/models/account/current_balance_manager_test.rb
@@ -0,0 +1,324 @@
+require "test_helper"
+
+class Account::CurrentBalanceManagerTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:empty)
+ @linked_account = accounts(:connected)
+ end
+
+ # -------------------------------------------------------------------------------------------------
+ # Manual account current balance management
+ #
+ # Manual accounts do not manage `current_anchor` valuations and have "auto-update strategies" to set the current balance.
+ # -------------------------------------------------------------------------------------------------
+
+ test "when one or more reconciliations exist, append new reconciliation to represent the current balance" do
+ account = @family.accounts.create!(
+ name: "Test",
+ balance: 1000,
+ cash_balance: 1000,
+ currency: "USD",
+ accountable: Depository.new
+ )
+
+ # A reconciliation tells us that the user is tracking this account's value with balance-only updates
+ account.entries.create!(
+ date: 30.days.ago.to_date,
+ name: "First manual recon valuation",
+ amount: 1200,
+ currency: "USD",
+ entryable: Valuation.new(kind: "reconciliation")
+ )
+
+ manager = Account::CurrentBalanceManager.new(account)
+
+ assert_equal 1, account.valuations.count
+
+ # Here, we assume user is once again "overriding" the balance to 1400
+ manager.set_current_balance(1400)
+
+ today_valuation = account.entries.valuations.find_by(date: Date.current)
+
+ assert_equal 2, account.valuations.count
+ assert_equal 1400, today_valuation.amount
+
+ assert_equal 1400, account.balance
+ end
+
+ test "all manual non cash accounts append reconciliations for current balance updates" do
+ [ Property, Vehicle, OtherAsset, Loan, OtherLiability ].each do |account_type|
+ account = @family.accounts.create!(
+ name: "Test",
+ balance: 1000,
+ cash_balance: 1000,
+ currency: "USD",
+ accountable: account_type.new
+ )
+
+ manager = Account::CurrentBalanceManager.new(account)
+
+ assert_equal 0, account.valuations.count
+
+ manager.set_current_balance(1400)
+
+ assert_equal 1, account.valuations.count
+
+ today_valuation = account.entries.valuations.find_by(date: Date.current)
+
+ assert_equal 1400, today_valuation.amount
+ assert_equal 1400, account.balance
+ end
+ end
+
+ # Scope: Depository, CreditCard only (i.e. all-cash accounts)
+ #
+ # If a user has an opening balance (valuation) for their manual *Depository* or *CreditCard* account and has 1+ transactions, the intent of
+ # "updating current balance" typically means that their start balance is incorrect. We follow that user intent
+ # by default and find the delta required, and update the opening balance so that the timeline reflects this current balance
+ #
+ # The purpose of this is so we're not cluttering up their timeline with "balance reconciliations" that reset the balance
+ # on the current date. Our goal is to keep the timeline with as few "Valuations" as possible.
+ #
+ # If we ever build a UI that gives user options, this test expectation may require some updates, but for now this
+ # is the least surprising outcome.
+ test "when no reconciliations exist on cash accounts, adjust opening balance with delta until it gets us to the desired balance" do
+ account = @family.accounts.create!(
+ name: "Test",
+ balance: 900, # the balance after opening valuation + transaction have "synced" (1000 - 100 = 900)
+ cash_balance: 900,
+ currency: "USD",
+ accountable: Depository.new
+ )
+
+ account.entries.create!(
+ date: 1.year.ago.to_date,
+ name: "Test opening valuation",
+ amount: 1000,
+ currency: "USD",
+ entryable: Valuation.new(kind: "opening_anchor")
+ )
+
+ account.entries.create!(
+ date: 10.days.ago.to_date,
+ name: "Test expense transaction",
+ amount: 100,
+ currency: "USD",
+ entryable: Transaction.new
+ )
+
+ # What we're asserting here:
+ # 1. User creates the account with an opening balance of 1000
+ # 2. User creates a transaction of 100, which then reduces the balance to 900 (the current balance value on account above)
+ # 3. User requests "current balance update" back to 1000, which was their intention
+ # 4. We adjust the opening balance by the delta (100) to 1100, which is the new opening balance, so that the transaction
+ # of 100 reduces it down to 1000, which is the current balance they intended.
+ assert_equal 1, account.valuations.count
+ assert_equal 1, account.transactions.count
+
+ # No new valuation is appended; we're just adjusting the opening valuation anchor
+ assert_no_difference "account.entries.count" do
+ manager = Account::CurrentBalanceManager.new(account)
+ manager.set_current_balance(1000)
+ end
+
+ opening_valuation = account.valuations.find_by(kind: "opening_anchor")
+
+ assert_equal 1100, opening_valuation.entry.amount
+ assert_equal 1000, account.balance
+ end
+
+ # (SEE ABOVE TEST FOR MORE DETAILED EXPLANATION)
+ # Same assertions as the test above, but Credit Card accounts are liabilities, which means expenses increase balance; not decrease
+ test "when no reconciliations exist on credit card accounts, adjust opening balance with delta until it gets us to the desired balance" do
+ account = @family.accounts.create!(
+ name: "Test",
+ balance: 1100, # the balance after opening valuation + transaction have "synced" (1000 + 100 = 1100) (expenses increase balance)
+ cash_balance: 1100,
+ currency: "USD",
+ accountable: CreditCard.new
+ )
+
+ account.entries.create!(
+ date: 1.year.ago.to_date,
+ name: "Test opening valuation",
+ amount: 1000,
+ currency: "USD",
+ entryable: Valuation.new(kind: "opening_anchor")
+ )
+
+ account.entries.create!(
+ date: 10.days.ago.to_date,
+ name: "Test expense transaction",
+ amount: 100,
+ currency: "USD",
+ entryable: Transaction.new
+ )
+
+ assert_equal 1, account.valuations.count
+ assert_equal 1, account.transactions.count
+
+ assert_no_difference "account.entries.count" do
+ manager = Account::CurrentBalanceManager.new(account)
+ manager.set_current_balance(1000)
+ end
+
+ opening_valuation = account.valuations.find_by(kind: "opening_anchor")
+
+ assert_equal 900, opening_valuation.entry.amount
+ assert_equal 1000, account.balance
+ end
+
+ # -------------------------------------------------------------------------------------------------
+ # Linked account current balance management
+ #
+ # Linked accounts manage "current balance" via the special `current_anchor` valuation.
+ # This is NOT a user-facing feature, and is primarily used in "processors" while syncing
+ # linked account data (e.g. via Plaid)
+ # -------------------------------------------------------------------------------------------------
+
+ test "when no existing anchor for linked account, creates new anchor" do
+ manager = Account::CurrentBalanceManager.new(@linked_account)
+
+ assert_difference -> { @linked_account.entries.count } => 1,
+ -> { @linked_account.valuations.count } => 1 do
+ result = manager.set_current_balance(1000)
+
+ assert result.success?
+ assert result.changes_made?
+ assert_nil result.error
+ end
+
+ current_anchor = @linked_account.valuations.current_anchor.first
+ assert_not_nil current_anchor
+ assert_equal 1000, current_anchor.entry.amount
+ assert_equal "current_anchor", current_anchor.kind
+
+ entry = current_anchor.entry
+ assert_equal 1000, entry.amount
+ assert_equal Date.current, entry.date
+ assert_equal "Current balance", entry.name # Depository type returns "Current balance"
+
+ assert_equal 1000, @linked_account.balance
+ end
+
+ test "updates existing anchor for linked account" do
+ # First create a current anchor
+ manager = Account::CurrentBalanceManager.new(@linked_account)
+ result = manager.set_current_balance(1000)
+ assert result.success?
+
+ current_anchor = @linked_account.valuations.current_anchor.first
+ original_id = current_anchor.id
+ original_entry_id = current_anchor.entry.id
+
+ # Travel to tomorrow to ensure date change
+ travel_to Date.current + 1.day do
+ # Now update it
+ assert_no_difference -> { @linked_account.entries.count } do
+ assert_no_difference -> { @linked_account.valuations.count } do
+ result = manager.set_current_balance(2000)
+ assert result.success?
+ assert result.changes_made?
+ end
+ end
+
+ current_anchor.reload
+ assert_equal original_id, current_anchor.id # Same valuation record
+ assert_equal original_entry_id, current_anchor.entry.id # Same entry record
+ assert_equal 2000, current_anchor.entry.amount
+ assert_equal Date.current, current_anchor.entry.date # Should be updated to current date
+ end
+
+ assert_equal 2000, @linked_account.balance
+ end
+
+ test "when no changes made, returns success with no changes made" do
+ # First create a current anchor
+ manager = Account::CurrentBalanceManager.new(@linked_account)
+ result = manager.set_current_balance(1000)
+ assert result.success?
+ assert result.changes_made?
+
+ # Try to set the same value on the same date
+ result = manager.set_current_balance(1000)
+
+ assert result.success?
+ assert_not result.changes_made?
+ assert_nil result.error
+
+ assert_equal 1000, @linked_account.balance
+ end
+
+ test "updates only amount when balance changes" do
+ manager = Account::CurrentBalanceManager.new(@linked_account)
+
+ # Create initial anchor
+ result = manager.set_current_balance(1000)
+ assert result.success?
+
+ current_anchor = @linked_account.valuations.current_anchor.first
+ original_date = current_anchor.entry.date
+
+ # Update only the balance
+ result = manager.set_current_balance(1500)
+ assert result.success?
+ assert result.changes_made?
+
+ current_anchor.reload
+ assert_equal 1500, current_anchor.entry.amount
+ assert_equal original_date, current_anchor.entry.date # Date should remain the same if on same day
+
+ assert_equal 1500, @linked_account.balance
+ end
+
+ test "updates date when called on different day" do
+ manager = Account::CurrentBalanceManager.new(@linked_account)
+
+ # Create initial anchor
+ result = manager.set_current_balance(1000)
+ assert result.success?
+
+ current_anchor = @linked_account.valuations.current_anchor.first
+ original_amount = current_anchor.entry.amount
+
+ # Travel to tomorrow and update with same balance
+ travel_to Date.current + 1.day do
+ result = manager.set_current_balance(1000)
+ assert result.success?
+ assert result.changes_made? # Should be true because date changed
+
+ current_anchor.reload
+ assert_equal original_amount, current_anchor.entry.amount
+ assert_equal Date.current, current_anchor.entry.date # Should be updated to new current date
+ end
+
+ assert_equal 1000, @linked_account.balance
+ end
+
+ test "current_balance returns balance from current anchor" do
+ manager = Account::CurrentBalanceManager.new(@linked_account)
+
+ # Create a current anchor
+ manager.set_current_balance(1500)
+
+ # Should return the anchor's balance
+ assert_equal 1500, manager.current_balance
+
+ # Update the anchor
+ manager.set_current_balance(2500)
+
+ # Should return the updated balance
+ assert_equal 2500, manager.current_balance
+
+ assert_equal 2500, @linked_account.balance
+ end
+
+ test "current_balance falls back to account balance when no anchor exists" do
+ manager = Account::CurrentBalanceManager.new(@linked_account)
+
+ # When no current anchor exists, should fall back to account.balance
+ assert_equal @linked_account.balance, manager.current_balance
+
+ assert_equal @linked_account.balance, @linked_account.balance
+ end
+end
diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb
index 1cc6b478fc1..dba43ba9bc7 100644
--- a/test/models/account/entry_test.rb
+++ b/test/models/account/entry_test.rb
@@ -17,7 +17,7 @@ class EntryTest < ActiveSupport::TestCase
existing_valuation = entries :valuation
new_valuation = Entry.new \
- entryable: Valuation.new,
+ entryable: Valuation.new(kind: "reconciliation"),
account: existing_valuation.account,
date: existing_valuation.date, # invalid
currency: existing_valuation.currency,
diff --git a/test/models/account/opening_balance_manager_test.rb b/test/models/account/opening_balance_manager_test.rb
new file mode 100644
index 00000000000..67becb60a22
--- /dev/null
+++ b/test/models/account/opening_balance_manager_test.rb
@@ -0,0 +1,252 @@
+require "test_helper"
+
+class Account::OpeningBalanceManagerTest < ActiveSupport::TestCase
+ setup do
+ @depository_account = accounts(:depository)
+ @investment_account = accounts(:investment)
+ end
+
+ test "when no existing anchor, creates new anchor" do
+ manager = Account::OpeningBalanceManager.new(@depository_account)
+
+ assert_difference -> { @depository_account.entries.count } => 1,
+ -> { @depository_account.valuations.count } => 1 do
+ result = manager.set_opening_balance(
+ balance: 1000,
+ date: 1.year.ago.to_date
+ )
+
+ assert result.success?
+ assert result.changes_made?
+ assert_nil result.error
+ end
+
+ opening_anchor = @depository_account.valuations.opening_anchor.first
+ assert_not_nil opening_anchor
+ assert_equal 1000, opening_anchor.entry.amount
+ assert_equal "opening_anchor", opening_anchor.kind
+
+ entry = opening_anchor.entry
+ assert_equal 1000, entry.amount
+ assert_equal 1.year.ago.to_date, entry.date
+ assert_equal "Opening balance", entry.name
+ end
+
+ test "when no existing anchor, creates with provided balance" do
+ # Test with Depository account (should default to balance)
+ depository_manager = Account::OpeningBalanceManager.new(@depository_account)
+
+ assert_difference -> { @depository_account.valuations.count } => 1 do
+ result = depository_manager.set_opening_balance(balance: 2000)
+ assert result.success?
+ assert result.changes_made?
+ end
+
+ depository_anchor = @depository_account.valuations.opening_anchor.first
+ assert_equal 2000, depository_anchor.entry.amount
+
+ # Test with Investment account (should default to 0)
+ investment_manager = Account::OpeningBalanceManager.new(@investment_account)
+
+ assert_difference -> { @investment_account.valuations.count } => 1 do
+ result = investment_manager.set_opening_balance(balance: 5000)
+ assert result.success?
+ assert result.changes_made?
+ end
+
+ investment_anchor = @investment_account.valuations.opening_anchor.first
+ assert_equal 5000, investment_anchor.entry.amount
+ end
+
+ test "when no existing anchor and no date provided, provides default based on account type" do
+ # Test with recent entry (less than 2 years ago)
+ @depository_account.entries.create!(
+ date: 30.days.ago.to_date,
+ name: "Test transaction",
+ amount: 100,
+ currency: "USD",
+ entryable: Transaction.new
+ )
+
+ manager = Account::OpeningBalanceManager.new(@depository_account)
+
+ assert_difference -> { @depository_account.valuations.count } => 1 do
+ result = manager.set_opening_balance(balance: 1500)
+ assert result.success?
+ assert result.changes_made?
+ end
+
+ opening_anchor = @depository_account.valuations.opening_anchor.first
+ # Default should be MIN(1 day before oldest entry, 2 years ago) = 2 years ago
+ assert_equal 2.years.ago.to_date, opening_anchor.entry.date
+
+ # Test with old entry (more than 2 years ago)
+ loan_account = accounts(:loan)
+ loan_account.entries.create!(
+ date: 3.years.ago.to_date,
+ name: "Old transaction",
+ amount: 100,
+ currency: "USD",
+ entryable: Transaction.new
+ )
+
+ loan_manager = Account::OpeningBalanceManager.new(loan_account)
+
+ assert_difference -> { loan_account.valuations.count } => 1 do
+ result = loan_manager.set_opening_balance(balance: 5000)
+ assert result.success?
+ assert result.changes_made?
+ end
+
+ loan_anchor = loan_account.valuations.opening_anchor.first
+ # Default should be MIN(3 years ago - 1 day, 2 years ago) = 3 years ago - 1 day
+ assert_equal (3.years.ago.to_date - 1.day), loan_anchor.entry.date
+
+ # Test with account that has no entries
+ property_account = accounts(:property)
+ manager_no_entries = Account::OpeningBalanceManager.new(property_account)
+
+ assert_difference -> { property_account.valuations.count } => 1 do
+ result = manager_no_entries.set_opening_balance(balance: 3000)
+ assert result.success?
+ assert result.changes_made?
+ end
+
+ opening_anchor_no_entries = property_account.valuations.opening_anchor.first
+ # Default should be 2 years ago when no entries exist
+ assert_equal 2.years.ago.to_date, opening_anchor_no_entries.entry.date
+ end
+
+ test "updates existing anchor" do
+ # First create an opening anchor
+ manager = Account::OpeningBalanceManager.new(@depository_account)
+ result = manager.set_opening_balance(
+ balance: 1000,
+ date: 6.months.ago.to_date
+ )
+ assert result.success?
+
+ opening_anchor = @depository_account.valuations.opening_anchor.first
+ original_id = opening_anchor.id
+ original_entry_id = opening_anchor.entry.id
+
+ # Now update it
+ assert_no_difference -> { @depository_account.entries.count } do
+ assert_no_difference -> { @depository_account.valuations.count } do
+ result = manager.set_opening_balance(
+ balance: 2000,
+ date: 8.months.ago.to_date
+ )
+ assert result.success?
+ assert result.changes_made?
+ end
+ end
+
+ opening_anchor.reload
+ assert_equal original_id, opening_anchor.id # Same valuation record
+ assert_equal original_entry_id, opening_anchor.entry.id # Same entry record
+ assert_equal 2000, opening_anchor.entry.amount
+ assert_equal 2000, opening_anchor.entry.amount
+ assert_equal 8.months.ago.to_date, opening_anchor.entry.date
+ end
+
+ test "when existing anchor and no date provided, only update balance" do
+ # First create an opening anchor
+ manager = Account::OpeningBalanceManager.new(@depository_account)
+ result = manager.set_opening_balance(
+ balance: 1000,
+ date: 3.months.ago.to_date
+ )
+ assert result.success?
+
+ opening_anchor = @depository_account.valuations.opening_anchor.first
+
+ # Update without providing date
+ result = manager.set_opening_balance(balance: 1500)
+ assert result.success?
+ assert result.changes_made?
+
+ opening_anchor.reload
+ assert_equal 1500, opening_anchor.entry.amount
+ end
+
+ test "when existing anchor and updating balance only, preserves original date" do
+ # First create an opening anchor with specific date
+ manager = Account::OpeningBalanceManager.new(@depository_account)
+ original_date = 4.months.ago.to_date
+ result = manager.set_opening_balance(
+ balance: 1000,
+ date: original_date
+ )
+ assert result.success?
+
+ opening_anchor = @depository_account.valuations.opening_anchor.first
+
+ # Update without providing date
+ result = manager.set_opening_balance(balance: 2500)
+ assert result.success?
+ assert result.changes_made?
+
+ opening_anchor.reload
+ assert_equal 2500, opening_anchor.entry.amount
+ assert_equal original_date, opening_anchor.entry.date # Should remain unchanged
+ end
+
+ test "when date is equal to or greater than account's oldest entry, returns error result" do
+ # Create an entry with a specific date
+ oldest_date = 60.days.ago.to_date
+ @depository_account.entries.create!(
+ date: oldest_date,
+ name: "Test transaction",
+ amount: 100,
+ currency: "USD",
+ entryable: Transaction.new
+ )
+
+ manager = Account::OpeningBalanceManager.new(@depository_account)
+
+ # Try to set opening balance on the same date as oldest entry
+ result = manager.set_opening_balance(
+ balance: 1000,
+ date: oldest_date
+ )
+
+ assert_not result.success?
+ assert_not result.changes_made?
+ assert_equal "Opening balance date must be before the oldest entry date", result.error
+
+ # Try to set opening balance after the oldest entry
+ result = manager.set_opening_balance(
+ balance: 1000,
+ date: oldest_date + 1.day
+ )
+
+ assert_not result.success?
+ assert_not result.changes_made?
+ assert_equal "Opening balance date must be before the oldest entry date", result.error
+
+ # Verify no opening anchor was created
+ assert_nil @depository_account.valuations.opening_anchor.first
+ end
+
+ test "when no changes made, returns success with no changes made" do
+ # First create an opening anchor
+ manager = Account::OpeningBalanceManager.new(@depository_account)
+ result = manager.set_opening_balance(
+ balance: 1000,
+ date: 2.months.ago.to_date
+ )
+ assert result.success?
+ assert result.changes_made?
+
+ # Try to set the same values
+ result = manager.set_opening_balance(
+ balance: 1000,
+ date: 2.months.ago.to_date
+ )
+
+ assert result.success?
+ assert_not result.changes_made?
+ assert_nil result.error
+ end
+end
diff --git a/test/models/account/reconciliation_manager_test.rb b/test/models/account/reconciliation_manager_test.rb
new file mode 100644
index 00000000000..fe5257e9d62
--- /dev/null
+++ b/test/models/account/reconciliation_manager_test.rb
@@ -0,0 +1,94 @@
+require "test_helper"
+
+class Account::ReconciliationManagerTest < ActiveSupport::TestCase
+ include BalanceTestHelper
+
+ setup do
+ @account = accounts(:investment)
+ @manager = Account::ReconciliationManager.new(@account)
+ end
+
+ test "new reconciliation" do
+ create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
+
+ result = @manager.reconcile_balance(balance: 1200, date: Date.current)
+
+ assert_equal 1200, result.new_balance
+ assert_equal 700, result.new_cash_balance # Non cash stays the same since user is valuing the entire account balance
+ assert_equal 1000, result.old_balance
+ assert_equal 500, result.old_cash_balance
+ assert_equal true, result.success?
+ end
+
+ test "updates existing reconciliation without date change" do
+ create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
+
+ # Existing reconciliation entry
+ existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: Date.current, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency)
+
+ result = @manager.reconcile_balance(balance: 1200, date: Date.current, existing_valuation_entry: existing_entry)
+
+ assert_equal 1200, result.new_balance
+ assert_equal 700, result.new_cash_balance # Non cash stays the same since user is valuing the entire account balance
+ assert_equal 1000, result.old_balance
+ assert_equal 500, result.old_cash_balance
+ assert_equal true, result.success?
+ end
+
+ test "updates existing reconciliation with date and amount change" do
+ create_balance(account: @account, date: 5.days.ago, balance: 1000, cash_balance: 500)
+ create_balance(account: @account, date: Date.current, balance: 1200, cash_balance: 700)
+
+ # Existing reconciliation entry (5 days ago)
+ existing_entry = @account.entries.create!(name: "Test", amount: 1000, date: 5.days.ago, entryable: Valuation.new(kind: "reconciliation"), currency: @account.currency)
+
+ # Should update and change date for existing entry; not create a new one
+ assert_no_difference "Valuation.count" do
+ # "Update valuation from 5 days ago to today, set balance from 1000 to 1500"
+ result = @manager.reconcile_balance(balance: 1500, date: Date.current, existing_valuation_entry: existing_entry)
+
+ assert_equal true, result.success?
+
+ # Reconciliation
+ assert_equal 1500, result.new_balance # Equal to new valuation amount
+ assert_equal 1000, result.new_cash_balance # Get non-cash balance today (1200 - 700 = 500). Then subtract this from new valuation (1500 - 500 = 1000)
+
+ # Prior valuation
+ assert_equal 1000, result.old_balance # This is the balance from the old valuation, NOT the date we're reconciling to
+ assert_equal 500, result.old_cash_balance
+ end
+ end
+
+ test "handles date conflicts" do
+ create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 1000)
+
+ # Existing reconciliation entry
+ @account.entries.create!(
+ name: "Test",
+ amount: 1000,
+ date: Date.current,
+ entryable: Valuation.new(kind: "reconciliation"),
+ currency: @account.currency
+ )
+
+ # Doesn't pass existing_valuation_entry, but reconciliation manager should recognize its the same date and update the existing entry
+ assert_no_difference "Valuation.count" do
+ result = @manager.reconcile_balance(balance: 1200, date: Date.current)
+
+ assert result.success?
+ assert_equal 1200, result.new_balance
+ end
+ end
+
+ test "dry run does not persist account" do
+ create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
+
+ assert_no_difference "Valuation.count" do
+ @manager.reconcile_balance(balance: 1200, date: Date.current, dry_run: true)
+ end
+
+ assert_difference "Valuation.count", 1 do
+ @manager.reconcile_balance(balance: 1200, date: Date.current)
+ end
+ end
+end
diff --git a/test/models/account_import_test.rb b/test/models/account_import_test.rb
new file mode 100644
index 00000000000..29204c0fd87
--- /dev/null
+++ b/test/models/account_import_test.rb
@@ -0,0 +1,92 @@
+require "test_helper"
+
+class AccountImportTest < ActiveSupport::TestCase
+ include ActiveJob::TestHelper, ImportInterfaceTest
+
+ setup do
+ @subject = @import = imports(:account)
+ end
+
+ test "import creates accounts with valuations" do
+ import_csv = <<~CSV
+ type,name,amount,currency
+ depository,Main Checking,1000.00,USD
+ depository,Savings Account,5000.00,USD
+ CSV
+
+ @import.update!(
+ raw_file_str: import_csv,
+ entity_type_col_label: "type",
+ name_col_label: "name",
+ amount_col_label: "amount",
+ currency_col_label: "currency"
+ )
+
+ @import.generate_rows_from_csv
+
+ # Create mappings for account types
+ @import.mappings.create! key: "depository", value: "Depository", type: "Import::AccountTypeMapping"
+
+ @import.reload
+
+ # Store initial counts
+ initial_account_count = Account.count
+ initial_entry_count = Entry.count
+ initial_valuation_count = Valuation.count
+
+ # Perform the import
+ @import.publish
+
+ # Check if import succeeded
+ if @import.failed?
+ fail "Import failed with error: #{@import.error}"
+ end
+
+ assert_equal "complete", @import.status
+
+ # Check the differences
+ assert_equal initial_account_count + 2, Account.count, "Expected 2 new accounts"
+ assert_equal initial_entry_count + 2, Entry.count, "Expected 2 new entries"
+ assert_equal initial_valuation_count + 2, Valuation.count, "Expected 2 new valuations"
+
+ # Verify accounts were created correctly
+ accounts = @import.accounts.order(:name)
+ assert_equal [ "Main Checking", "Savings Account" ], accounts.pluck(:name)
+ assert_equal [ 1000.00, 5000.00 ], accounts.map { |a| a.balance.to_f }
+
+ # Verify valuations were created with correct fields
+ accounts.each do |account|
+ valuation = account.valuations.last
+ assert_not_nil valuation
+ assert_equal "opening_anchor", valuation.kind
+ assert_equal account.balance, valuation.entry.amount
+ end
+ end
+
+ test "column_keys returns expected keys" do
+ assert_equal %i[entity_type name amount currency], @import.column_keys
+ end
+
+ test "required_column_keys returns expected keys" do
+ assert_equal %i[name amount], @import.required_column_keys
+ end
+
+ test "mapping_steps returns account type mapping" do
+ assert_equal [ Import::AccountTypeMapping ], @import.mapping_steps
+ end
+
+ test "dry_run returns expected counts" do
+ @import.rows.create!(
+ entity_type: "depository",
+ name: "Test Account",
+ amount: "1000.00",
+ currency: "USD"
+ )
+
+ assert_equal({ accounts: 1 }, @import.dry_run)
+ end
+
+ test "max_row_count is limited to 50" do
+ assert_equal 50, @import.max_row_count
+ end
+end
diff --git a/test/models/balance/chart_series_builder_test.rb b/test/models/balance/chart_series_builder_test.rb
index 80d2467f2d8..fbf5019fd8e 100644
--- a/test/models/balance/chart_series_builder_test.rb
+++ b/test/models/balance/chart_series_builder_test.rb
@@ -1,6 +1,8 @@
require "test_helper"
class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
+ include BalanceTestHelper
+
setup do
end
@@ -9,9 +11,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
account.balances.destroy_all
# With gaps
- account.balances.create!(date: 3.days.ago.to_date, balance: 1000, currency: "USD")
- account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD")
- account.balances.create!(date: Date.current, balance: 1200, currency: "USD")
+ create_balance(account: account, date: 3.days.ago.to_date, balance: 1000)
+ create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)
+ create_balance(account: account, date: Date.current, balance: 1200)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ account.id ],
@@ -38,9 +40,9 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
account = accounts(:depository)
account.balances.destroy_all
- account.balances.create!(date: 2.days.ago.to_date, balance: 1000, currency: "USD")
- account.balances.create!(date: 1.day.ago.to_date, balance: 1100, currency: "USD")
- account.balances.create!(date: Date.current, balance: 1200, currency: "USD")
+ create_balance(account: account, date: 2.days.ago.to_date, balance: 1000)
+ create_balance(account: account, date: 1.day.ago.to_date, balance: 1100)
+ create_balance(account: account, date: Date.current, balance: 1200)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ account.id ],
@@ -68,13 +70,13 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
Balance.destroy_all
- asset_account.balances.create!(date: 3.days.ago.to_date, balance: 500, currency: "USD")
- asset_account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD")
- asset_account.balances.create!(date: Date.current, balance: 1000, currency: "USD")
+ create_balance(account: asset_account, date: 3.days.ago.to_date, balance: 500)
+ create_balance(account: asset_account, date: 1.day.ago.to_date, balance: 1000)
+ create_balance(account: asset_account, date: Date.current, balance: 1000)
- liability_account.balances.create!(date: 3.days.ago.to_date, balance: 200, currency: "USD")
- liability_account.balances.create!(date: 2.days.ago.to_date, balance: 200, currency: "USD")
- liability_account.balances.create!(date: Date.current, balance: 100, currency: "USD")
+ create_balance(account: liability_account, date: 3.days.ago.to_date, balance: 200)
+ create_balance(account: liability_account, date: 2.days.ago.to_date, balance: 200)
+ create_balance(account: liability_account, date: Date.current, balance: 100)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ asset_account.id, liability_account.id ],
@@ -98,8 +100,8 @@ class Balance::ChartSeriesBuilderTest < ActiveSupport::TestCase
account = accounts(:credit_card)
account.balances.destroy_all
- account.balances.create!(date: 1.day.ago.to_date, balance: 1000, currency: "USD")
- account.balances.create!(date: Date.current, balance: 500, currency: "USD")
+ create_balance(account: account, date: 1.day.ago.to_date, balance: 1000)
+ create_balance(account: account, date: Date.current, balance: 500)
builder = Balance::ChartSeriesBuilder.new(
account_ids: [ account.id ],
diff --git a/test/models/balance/forward_calculator_test.rb b/test/models/balance/forward_calculator_test.rb
index 05215c259f7..b2462cb5804 100644
--- a/test/models/balance/forward_calculator_test.rb
+++ b/test/models/balance/forward_calculator_test.rb
@@ -1,129 +1,596 @@
require "test_helper"
+# The "forward calculator" is used for all **manual** accounts where balance tracking is done through entries and NOT from an external data provider.
class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
- include EntriesTestHelper
+ include LedgerTestingHelper
- setup do
- @account = families(:empty).accounts.create!(
- name: "Test",
- balance: 20000,
- cash_balance: 20000,
- currency: "USD",
- accountable: Investment.new
- )
- end
+ # ------------------------------------------------------------------------------------------------
+ # General tests for all account types
+ # ------------------------------------------------------------------------------------------------
- test "balance generation respects user timezone and last generated date is current user date" do
- # Simulate user in EST timezone
- Time.use_zone("America/New_York") do
- # Set current time to 1am UTC on Jan 5, 2025
- # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for)
- travel_to Time.utc(2025, 01, 05, 1, 0, 0)
+ # When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
+ test "no entries sync" do
+ account = create_account_with_ledger(
+ account: { type: Depository, currency: "USD" },
+ entries: []
+ )
- # Create a valuation for Jan 3, 2025
- create_valuation(account: @account, date: "2025-01-03", amount: 17000)
+ assert_equal 0, account.balances.count
+
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: Date.current,
+ legacy_balances: { balance: 0, cash_balance: 0 },
+ balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 },
+ flows: 0,
+ adjustments: 0
+ }
+ ]
+ )
+ end
- expected = [ [ "2025-01-02", 0 ], [ "2025-01-03", 17000 ], [ "2025-01-04", 17000 ] ]
- calculated = Balance::ForwardCalculator.new(@account).calculate
+ # Our system ensures all manual accounts have an opening anchor (for UX), but we should be able to handle a missing anchor by starting at 0 (i.e. "fresh account with no history")
+ test "account without opening anchor starts at zero balance" do
+ account = create_account_with_ledger(
+ account: { type: Depository, currency: "USD" },
+ entries: [
+ { type: "transaction", date: 2.days.ago.to_date, amount: -1000 }
+ ]
+ )
- assert_equal expected, calculated.map { |b| [ b.date.to_s, b.balance ] }
- end
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ # Since we start at 0, this transaction (inflow) simply increases balance from 0 -> 1000
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 3.days.ago.to_date,
+ legacy_balances: { balance: 0, cash_balance: 0 },
+ balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 1000, cash_balance: 1000 },
+ balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
+ flows: { cash_inflows: 1000, cash_outflows: 0 },
+ adjustments: 0
+ }
+ ]
+ )
end
- # When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
- test "no entries sync" do
- assert_equal 0, @account.balances.count
+ test "reconciliation valuation sets absolute balance before applying subsequent transactions" do
+ account = create_account_with_ledger(
+ account: { type: Depository, currency: "USD" },
+ entries: [
+ { type: "reconciliation", date: 3.days.ago.to_date, balance: 18000 },
+ { type: "transaction", date: 2.days.ago.to_date, amount: -1000 }
+ ]
+ )
- expected = [ 0, 0 ]
- calculated = Balance::ForwardCalculator.new(@account).calculate
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ # First valuation sets balance to 18000, then transaction increases balance to 19000
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 3.days.ago.to_date,
+ legacy_balances: { balance: 18000, cash_balance: 18000 },
+ balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 },
+ flows: 0,
+ adjustments: { cash_adjustments: 18000, non_cash_adjustments: 0 }
+ },
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 19000, cash_balance: 19000 },
+ balances: { start: 18000, start_cash: 18000, start_non_cash: 0, end_cash: 19000, end_non_cash: 0, end: 19000 },
+ flows: { cash_inflows: 1000, cash_outflows: 0 },
+ adjustments: 0
+ }
+ ]
+ )
+ end
- assert_equal expected, calculated.map(&:balance)
+ test "cash-only accounts (depository, credit card) use valuations where cash balance equals total balance" do
+ [ Depository, CreditCard ].each do |account_type|
+ account = create_account_with_ledger(
+ account: { type: account_type, currency: "USD" },
+ entries: [
+ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
+ { type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
+ ]
+ )
+
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 3.days.ago.to_date,
+ legacy_balances: { balance: 17000, cash_balance: 17000 },
+ balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 18000, cash_balance: 18000 },
+ balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 },
+ flows: 0,
+ adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 }
+ }
+ ]
+ )
+ end
end
- test "valuations sync" do
- create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
- create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
+ test "non-cash accounts (property, loan) use valuations where cash balance is always zero" do
+ [ Property, Loan ].each do |account_type|
+ account = create_account_with_ledger(
+ account: { type: account_type, currency: "USD" },
+ entries: [
+ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
+ { type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
+ ]
+ )
+
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 3.days.ago.to_date,
+ legacy_balances: { balance: 17000, cash_balance: 0.0 },
+ balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 17000, end: 17000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 18000, cash_balance: 0.0 },
+ balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 18000, end: 18000 },
+ flows: 0,
+ adjustments: { cash_adjustments: 0, non_cash_adjustments: 1000 }
+ }
+ ]
+ )
+ end
+ end
- expected = [ 0, 17000, 17000, 19000, 19000, 19000 ]
- calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+ test "mixed accounts (investment) use valuations where cash balance is total minus holdings" do
+ account = create_account_with_ledger(
+ account: { type: Investment, currency: "USD" },
+ entries: [
+ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 },
+ { type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 }
+ ]
+ )
- assert_equal expected, calculated
+ # Without holdings, cash balance equals total balance
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 3.days.ago.to_date,
+ legacy_balances: { balance: 17000, cash_balance: 17000 },
+ balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
+ flows: { market_flows: 0 },
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 18000, cash_balance: 18000 },
+ balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 },
+ flows: { market_flows: 0 },
+ adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash
+ }
+ ]
+ )
end
- test "transactions sync" do
- create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
- create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
-
- expected = [ 0, 500, 500, 400, 400, 400 ]
- calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+ # ------------------------------------------------------------------------------------------------
+ # All Cash accounts (Depository, CreditCard)
+ # ------------------------------------------------------------------------------------------------
+
+ test "transactions on depository accounts affect cash balance" do
+ account = create_account_with_ledger(
+ account: { type: Depository, currency: "USD" },
+ entries: [
+ { type: "opening_anchor", date: 5.days.ago.to_date, balance: 20000 },
+ { type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # income
+ { type: "transaction", date: 2.days.ago.to_date, amount: 100 } # expense
+ ]
+ )
- assert_equal expected, calculated
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 5.days.ago.to_date,
+ legacy_balances: { balance: 20000, cash_balance: 20000 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 4.days.ago.to_date,
+ legacy_balances: { balance: 20500, cash_balance: 20500 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 },
+ flows: { cash_inflows: 500, cash_outflows: 0 },
+ adjustments: 0
+ },
+ {
+ date: 3.days.ago.to_date,
+ legacy_balances: { balance: 20500, cash_balance: 20500 },
+ balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 20400, cash_balance: 20400 },
+ balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20400, end_non_cash: 0, end: 20400 },
+ flows: { cash_inflows: 0, cash_outflows: 100 },
+ adjustments: 0
+ }
+ ]
+ )
end
- test "multi-entry sync" do
- create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
- create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
- create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
- create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
- create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
- create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
- expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ]
- calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+ test "transactions on credit card accounts affect cash balance inversely" do
+ account = create_account_with_ledger(
+ account: { type: CreditCard, currency: "USD" },
+ entries: [
+ { type: "opening_anchor", date: 5.days.ago.to_date, balance: 1000 },
+ { type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # CC payment
+ { type: "transaction", date: 2.days.ago.to_date, amount: 100 } # expense
+ ]
+ )
- assert_equal expected, calculated
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 5.days.ago.to_date,
+ legacy_balances: { balance: 1000, cash_balance: 1000 },
+ balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 4.days.ago.to_date,
+ legacy_balances: { balance: 500, cash_balance: 500 },
+ balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },
+ flows: { cash_inflows: 500, cash_outflows: 0 },
+ adjustments: 0
+ },
+ {
+ date: 3.days.ago.to_date,
+ legacy_balances: { balance: 500, cash_balance: 500 },
+ balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 600, cash_balance: 600 },
+ balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 600, end_non_cash: 0, end: 600 },
+ flows: { cash_inflows: 0, cash_outflows: 100 },
+ adjustments: 0
+ }
+ ]
+ )
end
- test "multi-currency sync" do
- ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2
-
- create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD")
- create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD")
+ test "depository account with transactions and balance reconciliations" do
+ account = create_account_with_ledger(
+ account: { type: Depository, currency: "USD" },
+ entries: [
+ { type: "opening_anchor", date: 4.days.ago.to_date, balance: 20000 },
+ { type: "transaction", date: 3.days.ago.to_date, amount: -5000 },
+ { type: "reconciliation", date: 2.days.ago.to_date, balance: 17000 },
+ { type: "transaction", date: 1.day.ago.to_date, amount: -500 }
+ ]
+ )
- # Transaction in different currency than the account's main currency
- create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 4.days.ago.to_date,
+ legacy_balances: { balance: 20000, cash_balance: 20000 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 3.days.ago.to_date,
+ legacy_balances: { balance: 25000, cash_balance: 25000 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 25000, end_non_cash: 0, end: 25000 },
+ flows: { cash_inflows: 5000, cash_outflows: 0 },
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 17000, cash_balance: 17000 },
+ balances: { start: 25000, start_cash: 25000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 },
+ flows: 0,
+ adjustments: { cash_adjustments: -8000, non_cash_adjustments: 0 }
+ },
+ {
+ date: 1.day.ago.to_date,
+ legacy_balances: { balance: 17500, cash_balance: 17500 },
+ balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17500, end_non_cash: 0, end: 17500 },
+ flows: { cash_inflows: 500, cash_outflows: 0 },
+ adjustments: 0
+ }
+ ]
+ )
+ end
- expected = [ 0, 100, 400, 1000, 1000 ]
- calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+ test "accounts with transactions in multiple currencies convert to the account currency and flows are stored in account currency" do
+ account = create_account_with_ledger(
+ account: { type: Depository, currency: "USD" },
+ entries: [
+ { type: "opening_anchor", date: 4.days.ago.to_date, balance: 100 },
+ { type: "transaction", date: 3.days.ago.to_date, amount: -100 },
+ { type: "transaction", date: 2.days.ago.to_date, amount: -300 },
+ # Transaction in different currency than the account's main currency
+ { type: "transaction", date: 1.day.ago.to_date, amount: -500, currency: "EUR" } # €500 * 1.2 = $600
+ ],
+ exchange_rates: [
+ { date: 1.day.ago.to_date, from: "EUR", to: "USD", rate: 1.2 }
+ ]
+ )
- assert_equal expected, calculated
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 4.days.ago.to_date,
+ legacy_balances: { balance: 100, cash_balance: 100 },
+ balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 3.days.ago.to_date,
+ legacy_balances: { balance: 200, cash_balance: 200 },
+ balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 200, end_non_cash: 0, end: 200 },
+ flows: { cash_inflows: 100, cash_outflows: 0 },
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 500, cash_balance: 500 },
+ balances: { start: 200, start_cash: 200, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },
+ flows: { cash_inflows: 300, cash_outflows: 0 },
+ adjustments: 0
+ },
+ {
+ date: 1.day.ago.to_date,
+ legacy_balances: { balance: 1100, cash_balance: 1100 },
+ balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 1100, end_non_cash: 0, end: 1100 },
+ flows: { cash_inflows: 600, cash_outflows: 0 }, # Cash inflow is the USD equivalent of €500 (converted for balances table)
+ adjustments: 0
+ }
+ ]
+ )
end
- test "holdings and trades sync" do
- aapl = securities(:aapl)
+ # A loan is a special case where despite being a "non-cash" account, it is typical to have "payment" transactions that reduce the loan principal (non cash balance)
+ test "loan payment transactions affect non cash balance" do
+ account = create_account_with_ledger(
+ account: { type: Loan, currency: "USD" },
+ entries: [
+ { type: "opening_anchor", date: 2.days.ago.to_date, balance: 20000 },
+ # "Loan payment" of $2000, which reduces the principal
+ # TODO: We'll eventually need to calculate which portion of the txn was "interest" vs. "principal", but for now we'll just assume it's all principal
+ # since we don't have a first-class way to track interest payments yet.
+ { type: "transaction", date: 1.day.ago.to_date, amount: -2000 }
+ ]
+ )
- # Account starts at a value of $5000
- create_valuation(account: @account, date: 2.days.ago.to_date, amount: 5000)
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 20000, cash_balance: 0 },
+ balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 20000, end: 20000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 1.day.ago.to_date,
+ legacy_balances: { balance: 18000, cash_balance: 0 },
+ balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 18000, end: 18000 },
+ flows: { non_cash_inflows: 2000, non_cash_outflows: 0, cash_inflows: 0, cash_outflows: 0 }, # Loans are "special cases" where transactions do affect non-cash balance
+ adjustments: 0
+ }
+ ]
+ )
+ end
- # Share purchase reduces cash balance by $1000, but keeps overall balance same
- create_trade(aapl, account: @account, qty: 10, date: 1.day.ago.to_date, price: 100)
+ test "non cash accounts can only use valuations and transactions will be recorded but ignored for balance calculation" do
+ [ Property, Vehicle, OtherAsset, OtherLiability ].each do |account_type|
+ account = create_account_with_ledger(
+ account: { type: account_type, currency: "USD" },
+ entries: [
+ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 500000 },
+
+ # Will be ignored for balance calculation due to account type of non-cash
+ { type: "transaction", date: 2.days.ago.to_date, amount: -50000 }
+ ]
+ )
+
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 3.days.ago.to_date,
+ legacy_balances: { balance: 500000, cash_balance: 0 },
+ balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 500000, cash_balance: 0 },
+ balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 },
+ flows: 0, # Despite having a transaction, non-cash accounts ignore it for balance calculation
+ adjustments: 0
+ }
+ ]
+ )
+ end
+ end
- Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
- Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
+ # ------------------------------------------------------------------------------------------------
+ # Hybrid accounts (Investment, Crypto) - these have both cash and non-cash balance components
+ # ------------------------------------------------------------------------------------------------
+
+ # A transaction increases/decreases cash balance (i.e. "deposits" and "withdrawals")
+ # A trade increases/decreases cash balance (i.e. "buys" and "sells", which consume/add "brokerage cash" and create/destroy "holdings")
+ # A valuation can set both cash and non-cash balances to "override" investment account value.
+ # Holdings are calculated separately and fed into the balance calculator; treated as "non-cash"
+ test "investment account calculates balance from transactions and trades and treats holdings as non-cash, additive to balance" do
+ account = create_account_with_ledger(
+ account: { type: Investment, currency: "USD" },
+ entries: [
+ # Account starts with brokerage cash of $5000 and no holdings
+ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 5000 },
+ # Share purchase reduces cash balance by $1000, but keeps overall balance same
+ { type: "trade", date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100 }
+ ],
+ holdings: [
+ # Holdings calculator will calculate $1000 worth of holdings
+ { date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
+ { date: Date.current, ticker: "AAPL", qty: 10, price: 110, amount: 1100 } # Price increased by 10%, so holdings value goes up by $100 without a trade
+ ]
+ )
# Given constant prices, overall balance (account value) should be constant
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
- expected = [ 0, 5000, 5000, 5000 ]
- calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
-
- assert_equal expected, calculated
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 3.days.ago.to_date,
+ legacy_balances: { balance: 5000, cash_balance: 5000 },
+ balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 5000, cash_balance: 5000 },
+ balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 1.day.ago.to_date,
+ legacy_balances: { balance: 5000, cash_balance: 4000 },
+ balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 4000, end_non_cash: 1000, end: 5000 },
+ flows: { cash_inflows: 0, cash_outflows: 1000, non_cash_inflows: 1000, non_cash_outflows: 0, net_market_flows: 0 }, # Decrease cash by 1000, increase holdings by 1000 (i.e. "buy" of $1000 worth of AAPL)
+ adjustments: 0
+ },
+ {
+ date: Date.current,
+ legacy_balances: { balance: 5100, cash_balance: 4000 },
+ balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1100, end: 5100 },
+ flows: { net_market_flows: 100 }, # Holdings value increased by 100, despite no change in portfolio quantities
+ adjustments: 0
+ }
+ ]
+ )
end
- # Balance calculator is entirely reliant on HoldingCalculator and respects whatever holding records it creates.
- test "holdings are additive to total balance" do
- aapl = securities(:aapl)
+ test "investment account can have valuations that override balance" do
+ account = create_account_with_ledger(
+ account: { type: Investment, currency: "USD" },
+ entries: [
+ { type: "opening_anchor", date: 2.days.ago.to_date, balance: 5000 },
+ { type: "reconciliation", date: 1.day.ago.to_date, balance: 10000 }
+ ],
+ holdings: [
+ { date: 3.days.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
+ { date: 2.days.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
+ { date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 110, amount: 1100 },
+ { date: Date.current, ticker: "AAPL", qty: 10, price: 120, amount: 1200 }
+ ]
+ )
- # Account starts at a value of $5000
- create_valuation(account: @account, date: 2.days.ago.to_date, amount: 5000)
+ # Given constant prices, overall balance (account value) should be constant
+ # (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
+ calculated = Balance::ForwardCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 5000, cash_balance: 4000 },
+ balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1000, end: 5000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 1.day.ago.to_date,
+ legacy_balances: { balance: 10000, cash_balance: 8900 },
+ balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 8900, end_non_cash: 1100, end: 10000 },
+ flows: { net_market_flows: 100 },
+ adjustments: { cash_adjustments: 4900, non_cash_adjustments: 0 }
+ },
+ {
+ date: Date.current,
+ legacy_balances: { balance: 10100, cash_balance: 8900 },
+ balances: { start: 10000, start_cash: 8900, start_non_cash: 1100, end_cash: 8900, end_non_cash: 1200, end: 10100 },
+ flows: { net_market_flows: 100 },
+ adjustments: 0
+ }
+ ]
+ )
+ end
- # Even though there are no trades in the history, the calculator will still add the holdings to the total balance
- Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
- Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
+ private
+ def assert_balances(calculated_data:, expected_balances:)
+ # Sort calculated data by date to ensure consistent ordering
+ sorted_data = calculated_data.sort_by(&:date)
- # Start at zero, then valuation of $5000, then tack on $1000 of holdings for remaining 2 days
- expected = [ 0, 5000, 6000, 6000 ]
- calculated = Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+ # Extract actual values as [date, { balance:, cash_balance: }]
+ actual_balances = sorted_data.map do |b|
+ [ b.date, { balance: b.balance, cash_balance: b.cash_balance } ]
+ end
- assert_equal expected, calculated
- end
+ assert_equal expected_balances, actual_balances
+ end
end
diff --git a/test/models/balance/materializer_test.rb b/test/models/balance/materializer_test.rb
index 4a5ac439d0a..01d3476940b 100644
--- a/test/models/balance/materializer_test.rb
+++ b/test/models/balance/materializer_test.rb
@@ -2,6 +2,7 @@
class Balance::MaterializerTest < ActiveSupport::TestCase
include EntriesTestHelper
+ include BalanceTestHelper
setup do
@account = families(:empty).accounts.create!(
@@ -16,36 +17,143 @@ class Balance::MaterializerTest < ActiveSupport::TestCase
test "syncs balances" do
Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once
- @account.expects(:start_date).returns(2.days.ago.to_date)
+ expected_balances = [
+ Balance.new(
+ date: 1.day.ago.to_date,
+ balance: 1000,
+ cash_balance: 1000,
+ currency: "USD",
+ start_cash_balance: 500,
+ start_non_cash_balance: 0,
+ cash_inflows: 500,
+ cash_outflows: 0,
+ non_cash_inflows: 0,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ flows_factor: 1
+ ),
+ Balance.new(
+ date: Date.current,
+ balance: 1000,
+ cash_balance: 1000,
+ currency: "USD",
+ start_cash_balance: 1000,
+ start_non_cash_balance: 0,
+ cash_inflows: 0,
+ cash_outflows: 0,
+ non_cash_inflows: 0,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ flows_factor: 1
+ )
+ ]
- Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
- [
- Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
- Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
- ]
- )
+ Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances)
assert_difference "@account.balances.count", 2 do
Balance::Materializer.new(@account, strategy: :forward).materialize_balances
end
+
+ assert_balance_fields_persisted(expected_balances)
end
- test "purges stale balances and holdings" do
- # Balance before start date is stale
- @account.expects(:start_date).returns(2.days.ago.to_date).twice
- stale_balance = Balance.new(date: 3.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD")
-
- Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
- [
- stale_balance,
- Balance.new(date: 2.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD"),
- Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
- Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
- ]
- )
+ test "purges stale balances outside calculated range" do
+ # Create existing balances that will be stale
+ stale_old = create_balance(account: @account, date: 5.days.ago.to_date, balance: 5000)
+ stale_future = create_balance(account: @account, date: 2.days.from_now.to_date, balance: 15000)
+
+ # Calculator will return balances for only these dates
+ expected_balances = [
+ Balance.new(
+ date: 2.days.ago.to_date,
+ balance: 10000,
+ cash_balance: 10000,
+ currency: "USD",
+ start_cash_balance: 10000,
+ start_non_cash_balance: 0,
+ cash_inflows: 0,
+ cash_outflows: 0,
+ non_cash_inflows: 0,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ flows_factor: 1
+ ),
+ Balance.new(
+ date: 1.day.ago.to_date,
+ balance: 1000,
+ cash_balance: 1000,
+ currency: "USD",
+ start_cash_balance: 10000,
+ start_non_cash_balance: 0,
+ cash_inflows: 0,
+ cash_outflows: 9000,
+ non_cash_inflows: 0,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ flows_factor: 1
+ ),
+ Balance.new(
+ date: Date.current,
+ balance: 1000,
+ cash_balance: 1000,
+ currency: "USD",
+ start_cash_balance: 1000,
+ start_non_cash_balance: 0,
+ cash_inflows: 0,
+ cash_outflows: 0,
+ non_cash_inflows: 0,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ flows_factor: 1
+ )
+ ]
+
+ Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances)
+ Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once
- assert_difference "@account.balances.count", 3 do
+ # Should end up with 3 balances (stale ones deleted, new ones created)
+ assert_difference "@account.balances.count", 1 do
Balance::Materializer.new(@account, strategy: :forward).materialize_balances
end
+
+ # Verify stale balances were deleted
+ assert_nil @account.balances.find_by(id: stale_old.id)
+ assert_nil @account.balances.find_by(id: stale_future.id)
+
+ # Verify expected balances were persisted
+ assert_balance_fields_persisted(expected_balances)
end
+
+ private
+
+ def assert_balance_fields_persisted(expected_balances)
+ expected_balances.each do |expected|
+ persisted = @account.balances.find_by(date: expected.date)
+ assert_not_nil persisted, "Balance for #{expected.date} should be persisted"
+
+ # Check all balance component fields
+ assert_equal expected.balance, persisted.balance
+ assert_equal expected.cash_balance, persisted.cash_balance
+ assert_equal expected.start_cash_balance, persisted.start_cash_balance
+ assert_equal expected.start_non_cash_balance, persisted.start_non_cash_balance
+ assert_equal expected.cash_inflows, persisted.cash_inflows
+ assert_equal expected.cash_outflows, persisted.cash_outflows
+ assert_equal expected.non_cash_inflows, persisted.non_cash_inflows
+ assert_equal expected.non_cash_outflows, persisted.non_cash_outflows
+ assert_equal expected.net_market_flows, persisted.net_market_flows
+ assert_equal expected.cash_adjustments, persisted.cash_adjustments
+ assert_equal expected.non_cash_adjustments, persisted.non_cash_adjustments
+ assert_equal expected.flows_factor, persisted.flows_factor
+ end
+ end
end
diff --git a/test/models/balance/reverse_calculator_test.rb b/test/models/balance/reverse_calculator_test.rb
index 6d73aea84cb..c3ba12ba47e 100644
--- a/test/models/balance/reverse_calculator_test.rb
+++ b/test/models/balance/reverse_calculator_test.rb
@@ -1,142 +1,490 @@
require "test_helper"
class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
- include EntriesTestHelper
-
- setup do
- @account = families(:empty).accounts.create!(
- name: "Test",
- balance: 20000,
- cash_balance: 20000,
- currency: "USD",
- accountable: Investment.new
- )
- end
+ include LedgerTestingHelper
# When syncing backwards, we start with the account balance and generate everything from there.
- test "no entries sync" do
- assert_equal 0, @account.balances.count
-
- expected = [ @account.balance, @account.balance ]
- calculated = Balance::ReverseCalculator.new(@account).calculate
+ test "when missing anchor and no entries, falls back to cached account balance" do
+ account = create_account_with_ledger(
+ account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
+ entries: []
+ )
- assert_equal expected, calculated.map(&:balance)
+ assert_equal 20000, account.balance
+
+ calculated = Balance::ReverseCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: Date.current,
+ legacy_balances: { balance: 20000, cash_balance: 20000 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
+ flows: 0,
+ adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
+ }
+ ]
+ )
end
- test "balance generation respects user timezone and last generated date is current user date" do
- # Simulate user in EST timezone
- Time.use_zone("America/New_York") do
- # Set current time to 1am UTC on Jan 5, 2025
- # This would be 8pm EST on Jan 4, 2025 (user's time, and the last date we should generate balances for)
- travel_to Time.utc(2025, 01, 05, 1, 0, 0)
+ # An artificial constraint we put on the reverse sync because it's confusing in both the code and the UI
+ # to think about how an absolute "Valuation" affects balances when syncing backwards. Furthermore, since
+ # this is typically a Plaid sync, we expect Plaid to provide us the history.
+ # Note: while "reconciliation" valuations don't affect balance, `current_anchor` and `opening_anchor` do.
+ test "reconciliation valuations do not affect balance for reverse syncs" do
+ account = create_account_with_ledger(
+ account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
+ entries: [
+ { type: "current_anchor", date: Date.current, balance: 20000 },
+ { type: "reconciliation", date: 1.day.ago, balance: 17000 }, # Ignored
+ { type: "reconciliation", date: 2.days.ago, balance: 17000 }, # Ignored
+ { type: "opening_anchor", date: 4.days.ago, balance: 15000 }
+ ]
+ )
- create_valuation(account: @account, date: "2025-01-03", amount: 17000)
+ calculated = Balance::ReverseCalculator.new(account).calculate
+
+ # The "opening anchor" works slightly differently than most would expect. Since it's an artificial
+ # value provided by the user to set the date/balance of the start of the account, we must assume
+ # that there are "missing" entries following it. Because of this, we cannot "carry forward" this value
+ # like we do for a "forward sync". We simply sync backwards normally, then set the balance on opening
+ # date equal to this anchor. This is not "ideal", but is a constraint put on us since we cannot guarantee
+ # a 100% full entries history.
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: Date.current,
+ legacy_balances: { balance: 20000, cash_balance: 20000 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
+ flows: 0,
+ adjustments: 0
+ }, # Current anchor
+ {
+ date: 1.day.ago,
+ legacy_balances: { balance: 20000, cash_balance: 20000 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago,
+ legacy_balances: { balance: 20000, cash_balance: 20000 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 3.days.ago,
+ legacy_balances: { balance: 20000, cash_balance: 20000 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
+ flows: 0,
+ adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
+ },
+ {
+ date: 4.days.ago,
+ legacy_balances: { balance: 15000, cash_balance: 15000 },
+ balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 15000, end_non_cash: 0, end: 15000 },
+ flows: 0,
+ adjustments: 0
+ } # Opening anchor
+ ]
+ )
+ end
- expected = [ [ "2025-01-02", 17000 ], [ "2025-01-03", 17000 ], [ "2025-01-04", @account.balance ] ]
- calculated = Balance::ReverseCalculator.new(@account).calculate
+ # Investment account balances are made of two components: cash and holdings.
+ test "anchors on investment accounts calculate cash balance dynamically based on holdings value" do
+ account = create_account_with_ledger(
+ account: { type: Investment, balance: 20000, cash_balance: 10000, currency: "USD" },
+ entries: [
+ { type: "current_anchor", date: Date.current, balance: 20000 }, # "Total account value is $20,000 today"
+ { type: "opening_anchor", date: 1.day.ago, balance: 15000 } # "Total account value was $15,000 at the start of the account"
+ ],
+ holdings: [
+ { date: Date.current, ticker: "AAPL", qty: 100, price: 100, amount: 10000 },
+ { date: 1.day.ago, ticker: "AAPL", qty: 100, price: 100, amount: 10000 }
+ ]
+ )
- assert_equal expected, calculated.sort_by(&:date).map { |b| [ b.date.to_s, b.balance ] }
- end
+ calculated = Balance::ReverseCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: Date.current,
+ legacy_balances: { balance: 20000, cash_balance: 10000 },
+ balances: { start: 20000, start_cash: 10000, start_non_cash: 10000, end_cash: 10000, end_non_cash: 10000, end: 20000 },
+ flows: { market_flows: 0 },
+ adjustments: 0
+ }, # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value
+ {
+ date: 1.day.ago,
+ legacy_balances: { balance: 15000, cash_balance: 5000 },
+ balances: { start: 15000, start_cash: 5000, start_non_cash: 10000, end_cash: 5000, end_non_cash: 10000, end: 15000 },
+ flows: { market_flows: 0 },
+ adjustments: 0
+ } # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value
+ ]
+ )
end
- test "valuations sync" do
- create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
- create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
-
- expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ]
- calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+ test "transactions on depository accounts affect cash balance" do
+ account = create_account_with_ledger(
+ account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
+ entries: [
+ { type: "current_anchor", date: Date.current, balance: 20000 },
+ { type: "transaction", date: 2.days.ago, amount: 100 }, # expense
+ { type: "transaction", date: 4.days.ago, amount: -500 } # income
+ ]
+ )
- assert_equal expected, calculated
+ calculated = Balance::ReverseCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: Date.current,
+ legacy_balances: { balance: 20000, cash_balance: 20000 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
+ flows: 0,
+ adjustments: 0
+ }, # Current balance
+ {
+ date: 1.day.ago,
+ legacy_balances: { balance: 20000, cash_balance: 20000 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
+ flows: 0,
+ adjustments: 0
+ }, # No change
+ {
+ date: 2.days.ago,
+ legacy_balances: { balance: 20000, cash_balance: 20000 },
+ balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
+ flows: { cash_inflows: 0, cash_outflows: 100 },
+ adjustments: 0
+ }, # After expense (+100)
+ {
+ date: 3.days.ago,
+ legacy_balances: { balance: 20100, cash_balance: 20100 },
+ balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 },
+ flows: 0,
+ adjustments: 0
+ }, # Before expense
+ {
+ date: 4.days.ago,
+ legacy_balances: { balance: 20100, cash_balance: 20100 },
+ balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 },
+ flows: { cash_inflows: 500, cash_outflows: 0 },
+ adjustments: 0
+ }, # After income (-500)
+ {
+ date: 5.days.ago,
+ legacy_balances: { balance: 19600, cash_balance: 19600 },
+ balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 19600, end_non_cash: 0, end: 19600 },
+ flows: 0,
+ adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
+ } # After income (-500)
+ ]
+ )
end
- test "transactions sync" do
- create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
- create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
-
- expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ]
- calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+ test "transactions on credit card accounts affect cash balance inversely" do
+ account = create_account_with_ledger(
+ account: { type: CreditCard, balance: 2000, cash_balance: 2000, currency: "USD" },
+ entries: [
+ { type: "current_anchor", date: Date.current, balance: 2000 },
+ { type: "transaction", date: 2.days.ago, amount: 100 }, # expense (increases cash balance)
+ { type: "transaction", date: 4.days.ago, amount: -500 } # CC payment (reduces cash balance)
+ ]
+ )
- assert_equal expected, calculated
+ calculated = Balance::ReverseCalculator.new(account).calculate
+
+ # Reversed order: showing how we work backwards
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: Date.current,
+ legacy_balances: { balance: 2000, cash_balance: 2000 },
+ balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 },
+ flows: 0,
+ adjustments: 0
+ }, # Current balance
+ {
+ date: 1.day.ago,
+ legacy_balances: { balance: 2000, cash_balance: 2000 },
+ balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 },
+ flows: 0,
+ adjustments: 0
+ }, # No change
+ {
+ date: 2.days.ago,
+ legacy_balances: { balance: 2000, cash_balance: 2000 },
+ balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 },
+ flows: { cash_inflows: 0, cash_outflows: 100 },
+ adjustments: 0
+ }, # After expense (+100)
+ {
+ date: 3.days.ago,
+ legacy_balances: { balance: 1900, cash_balance: 1900 },
+ balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 },
+ flows: 0,
+ adjustments: 0
+ }, # Before expense
+ {
+ date: 4.days.ago,
+ legacy_balances: { balance: 1900, cash_balance: 1900 },
+ balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 },
+ flows: { cash_inflows: 500, cash_outflows: 0 },
+ adjustments: 0
+ }, # After CC payment (-500)
+ {
+ date: 5.days.ago,
+ legacy_balances: { balance: 2400, cash_balance: 2400 },
+ balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 2400, end_non_cash: 0, end: 2400 },
+ flows: 0,
+ adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
+ }
+ ]
+ )
end
- test "multi-entry sync" do
- create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
- create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
- create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
- create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
- create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
- create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
+ # A loan is a special case where despite being a "non-cash" account, it is typical to have "payment" transactions that reduce the loan principal (non cash balance)
+ test "loan payment transactions affect non cash balance" do
+ account = create_account_with_ledger(
+ account: { type: Loan, balance: 198000, cash_balance: 0, currency: "USD" },
+ entries: [
+ { type: "current_anchor", date: Date.current, balance: 198000 },
+ # "Loan payment" of $2000, which reduces the principal
+ # TODO: We'll eventually need to calculate which portion of the txn was "interest" vs. "principal", but for now we'll just assume it's all principal
+ # since we don't have a first-class way to track interest payments yet.
+ { type: "transaction", date: 1.day.ago.to_date, amount: -2000 }
+ ]
+ )
- expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ]
- calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+ calculated = Balance::ReverseCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: Date.current,
+ legacy_balances: { balance: 198000, cash_balance: 0 },
+ balances: { start: 198000, start_cash: 0, start_non_cash: 198000, end_cash: 0, end_non_cash: 198000, end: 198000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 1.day.ago,
+ legacy_balances: { balance: 198000, cash_balance: 0 },
+ balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 198000, end: 198000 },
+ flows: { non_cash_inflows: 2000, non_cash_outflows: 0, cash_inflows: 0, cash_outflows: 0 },
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago,
+ legacy_balances: { balance: 200000, cash_balance: 0 },
+ balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 200000, end: 200000 },
+ flows: 0,
+ adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
+ }
+ ]
+ )
+ end
- assert_equal expected, calculated
+ test "non cash accounts can only use valuations and transactions will be recorded but ignored for balance calculation" do
+ [ Property, Vehicle, OtherAsset, OtherLiability ].each do |account_type|
+ account = create_account_with_ledger(
+ account: { type: account_type, balance: 1000, cash_balance: 0, currency: "USD" },
+ entries: [
+ { type: "current_anchor", date: Date.current, balance: 1000 },
+
+ # Will be ignored for balance calculation due to account type of non-cash
+ { type: "transaction", date: 1.day.ago, amount: -100 }
+ ]
+ )
+
+ calculated = Balance::ReverseCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: Date.current,
+ legacy_balances: { balance: 1000, cash_balance: 0 },
+ balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 1.day.ago,
+ legacy_balances: { balance: 1000, cash_balance: 0 },
+ balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 },
+ flows: 0,
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago,
+ legacy_balances: { balance: 1000, cash_balance: 0 },
+ balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 },
+ flows: 0,
+ adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
+ }
+ ]
+ )
+ end
end
# When syncing backwards, trades from the past should NOT affect the current balance or previous balances.
# They should only affect the *cash* component of the historical balances
test "holdings and trades sync" do
- aapl = securities(:aapl)
-
# Account starts with $20,000 total value, $19,000 cash, $1,000 in holdings
- @account.update!(cash_balance: 19000, balance: 20000)
-
- # Bought 10 AAPL shares 1 day ago, so cash is $19,000, $1,000 in holdings, total value is $20,000
- create_trade(aapl, account: @account, qty: 10, date: 1.day.ago.to_date, price: 100)
+ account = create_account_with_ledger(
+ account: { type: Investment, balance: 20000, cash_balance: 19000, currency: "USD" },
+ entries: [
+ { type: "current_anchor", date: Date.current, balance: 20000 },
+ # Bought 10 AAPL shares 1 day ago, so cash is $19,000, $1,000 in holdings, total value is $20,000
+ { type: "trade", date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100 }
+ ],
+ holdings: [
+ { date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
+ { date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }
+ ]
+ )
- Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
- Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 1000, currency: "USD")
+ calculated = Balance::ReverseCalculator.new(account).calculate
# Given constant prices, overall balance (account value) should be constant
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
- expected = [ 20000, 20000, 20000 ]
- calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
-
- assert_equal expected, calculated
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: Date.current,
+ legacy_balances: { balance: 20000, cash_balance: 19000 },
+ balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },
+ flows: { market_flows: 0 },
+ adjustments: 0
+ }, # Current: $19k cash + $1k holdings (anchor)
+ {
+ date: 1.day.ago.to_date,
+ legacy_balances: { balance: 20000, cash_balance: 19000 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 19000, end_non_cash: 1000, end: 20000 },
+ flows: { cash_inflows: 0, cash_outflows: 1000, non_cash_inflows: 1000, non_cash_outflows: 0, net_market_flows: 0 },
+ adjustments: 0
+ }, # After trade: $19k cash + $1k holdings
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 20000, cash_balance: 20000 },
+ balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 },
+ flows: { market_flows: 0 },
+ adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 }
+ } # At first, account is 100% cash, no holdings (no trades)
+ ]
+ )
end
# A common scenario with Plaid is they'll give us holding records for today, but no trade history for some of them.
# This is because they only supply 2 years worth of historical data. Our system must properly handle this.
test "properly calculates balances when a holding has no trade history" do
- aapl = securities(:aapl)
- msft = securities(:msft)
-
# Account starts with $20,000 total value, $19,000 cash, $1,000 in holdings ($500 AAPL, $500 MSFT)
- @account.update!(cash_balance: 19000, balance: 20000)
-
- # A holding *with* trade history (5 shares of AAPL, purchased 1 day ago, results in 2 holdings)
- Holding.create!(date: Date.current, account: @account, security: aapl, qty: 5, price: 100, amount: 500, currency: "USD")
- Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 5, price: 100, amount: 500, currency: "USD")
- create_trade(aapl, account: @account, qty: 5, date: 1.day.ago.to_date, price: 100)
-
- # A holding *without* trade history (5 shares of MSFT, no trade history, results in 1 holding)
- # We assume if no history is provided, this holding has existed since beginning of account
- Holding.create!(date: Date.current, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD")
- Holding.create!(date: 1.day.ago.to_date, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD")
- Holding.create!(date: 2.days.ago.to_date, account: @account, security: msft, qty: 5, price: 100, amount: 500, currency: "USD")
-
- expected = [ 20000, 20000, 20000 ]
- calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+ account = create_account_with_ledger(
+ account: { type: Investment, balance: 20000, cash_balance: 19000, currency: "USD" },
+ entries: [
+ { type: "current_anchor", date: Date.current, balance: 20000 },
+ # A holding *with* trade history (5 shares of AAPL, purchased 1 day ago)
+ { type: "trade", date: 1.day.ago.to_date, ticker: "AAPL", qty: 5, price: 100 }
+ ],
+ holdings: [
+ # AAPL holdings
+ { date: Date.current, ticker: "AAPL", qty: 5, price: 100, amount: 500 },
+ { date: 1.day.ago.to_date, ticker: "AAPL", qty: 5, price: 100, amount: 500 },
+ # MSFT holdings without trade history - Balance calculator doesn't care how the holdings were created. It just reads them and assumes they are accurate.
+ { date: Date.current, ticker: "MSFT", qty: 5, price: 100, amount: 500 },
+ { date: 1.day.ago.to_date, ticker: "MSFT", qty: 5, price: 100, amount: 500 },
+ { date: 2.days.ago.to_date, ticker: "MSFT", qty: 5, price: 100, amount: 500 }
+ ]
+ )
- assert_equal expected, calculated
+ calculated = Balance::ReverseCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ {
+ date: Date.current,
+ legacy_balances: { balance: 20000, cash_balance: 19000 },
+ balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },
+ flows: { market_flows: 0 },
+ adjustments: 0
+ }, # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL)
+ {
+ date: 1.day.ago.to_date,
+ legacy_balances: { balance: 20000, cash_balance: 19000 },
+ balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19000, end_non_cash: 1000, end: 20000 },
+ flows: { cash_inflows: 0, cash_outflows: 500, non_cash_inflows: 500, non_cash_outflows: 0, market_flows: 0 },
+ adjustments: 0
+ }, # After AAPL trade: $19k cash + $1k holdings
+ {
+ date: 2.days.ago.to_date,
+ legacy_balances: { balance: 20000, cash_balance: 19500 },
+ balances: { start: 19500, start_cash: 19500, start_non_cash: 0, end_cash: 19500, end_non_cash: 500, end: 20000 },
+ flows: { market_flows: -500 },
+ adjustments: 0
+ } # Before AAPL trade: $19.5k cash + $500 MSFT
+ ]
+ )
end
test "uses provider reported holdings and cash value on current day" do
- aapl = securities(:aapl)
-
# Implied holdings value of $1,000 from provider
- @account.update!(cash_balance: 19000, balance: 20000)
-
- # Create a holding that differs in value from provider ($2,000 vs. the $1,000 reported by provider)
- Holding.create!(date: Date.current, account: @account, security: aapl, qty: 10, price: 100, amount: 2000, currency: "USD")
- Holding.create!(date: 1.day.ago.to_date, account: @account, security: aapl, qty: 10, price: 100, amount: 2000, currency: "USD")
-
- # Today reports the provider value. Yesterday, provider won't give us any data, so we MUST look at the generated holdings value
- # to calculate the end balance ($19,000 cash + $2,000 holdings = $21,000 total value)
- expected = [ 21000, 20000 ]
-
- calculated = Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+ account = create_account_with_ledger(
+ account: { type: Investment, balance: 20000, cash_balance: 19000, currency: "USD" },
+ entries: [
+ { type: "current_anchor", date: Date.current, balance: 20000 },
+ { type: "opening_anchor", date: 2.days.ago, balance: 15000 }
+ ],
+ holdings: [
+ # Create holdings that differ in value from provider ($2,000 vs. the $1,000 reported by provider)
+ { date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
+ { date: 1.day.ago, ticker: "AAPL", qty: 10, price: 100, amount: 1000 },
+ { date: 2.days.ago, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }
+ ]
+ )
- assert_equal expected, calculated
+ calculated = Balance::ReverseCalculator.new(account).calculate
+
+ assert_calculated_ledger_balances(
+ calculated_data: calculated,
+ expected_data: [
+ # No matter what, we force current day equal to the "anchor" balance (what provider gave us), and let "cash" float based on holdings value
+ # This ensures the user sees the same top-line number reported by the provider (even if it creates a discrepancy in the cash balance)
+ {
+ date: Date.current,
+ legacy_balances: { balance: 20000, cash_balance: 19000 },
+ balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },
+ flows: { market_flows: 0 },
+ adjustments: 0
+ },
+ {
+ date: 1.day.ago,
+ legacy_balances: { balance: 20000, cash_balance: 19000 },
+ balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 },
+ flows: { market_flows: 0 },
+ adjustments: 0
+ },
+ {
+ date: 2.days.ago,
+ legacy_balances: { balance: 15000, cash_balance: 14000 },
+ balances: { start: 15000, start_cash: 14000, start_non_cash: 1000, end_cash: 14000, end_non_cash: 1000, end: 15000 },
+ flows: { market_flows: 0 },
+ adjustments: 0
+ } # Opening anchor sets absolute balance
+ ]
+ )
end
end
diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb
new file mode 100644
index 00000000000..cff5cd6cc00
--- /dev/null
+++ b/test/models/budget_test.rb
@@ -0,0 +1,88 @@
+require "test_helper"
+
+class BudgetTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:empty)
+ end
+
+ test "budget_date_valid? allows going back 2 years even without entries" do
+ two_years_ago = 2.years.ago.beginning_of_month
+ assert Budget.budget_date_valid?(two_years_ago, family: @family)
+ end
+
+ test "budget_date_valid? allows going back to earliest entry date if more than 2 years ago" do
+ # Create an entry 3 years ago
+ old_account = Account.create!(
+ family: @family,
+ accountable: Depository.new,
+ name: "Old Account",
+ status: "active",
+ currency: "USD",
+ balance: 1000
+ )
+
+ old_entry = Entry.create!(
+ account: old_account,
+ entryable: Transaction.new(category: categories(:income)),
+ date: 3.years.ago,
+ name: "Old Transaction",
+ amount: 100,
+ currency: "USD"
+ )
+
+ # Should allow going back to the old entry date
+ assert Budget.budget_date_valid?(3.years.ago.beginning_of_month, family: @family)
+ end
+
+ test "budget_date_valid? does not allow dates before earliest entry or 2 years ago" do
+ # Create an entry 1 year ago
+ account = Account.create!(
+ family: @family,
+ accountable: Depository.new,
+ name: "Test Account",
+ status: "active",
+ currency: "USD",
+ balance: 500
+ )
+
+ Entry.create!(
+ account: account,
+ entryable: Transaction.new(category: categories(:income)),
+ date: 1.year.ago,
+ name: "Recent Transaction",
+ amount: 100,
+ currency: "USD"
+ )
+
+ # Should not allow going back more than 2 years
+ refute Budget.budget_date_valid?(3.years.ago.beginning_of_month, family: @family)
+ end
+
+ test "budget_date_valid? does not allow future dates beyond current month" do
+ refute Budget.budget_date_valid?(2.months.from_now, family: @family)
+ end
+
+ test "previous_budget_param returns nil when date is too old" do
+ # Create a budget at the oldest allowed date
+ two_years_ago = 2.years.ago.beginning_of_month
+ budget = Budget.create!(
+ family: @family,
+ start_date: two_years_ago,
+ end_date: two_years_ago.end_of_month,
+ currency: "USD"
+ )
+
+ assert_nil budget.previous_budget_param
+ end
+
+ test "previous_budget_param returns param when date is valid" do
+ budget = Budget.create!(
+ family: @family,
+ start_date: Date.current.beginning_of_month,
+ end_date: Date.current.end_of_month,
+ currency: "USD"
+ )
+
+ assert_not_nil budget.previous_budget_param
+ end
+end
diff --git a/test/models/family/data_exporter_test.rb b/test/models/family/data_exporter_test.rb
new file mode 100644
index 00000000000..fe56fb08adf
--- /dev/null
+++ b/test/models/family/data_exporter_test.rb
@@ -0,0 +1,115 @@
+require "test_helper"
+
+class Family::DataExporterTest < ActiveSupport::TestCase
+ setup do
+ @family = families(:dylan_family)
+ @other_family = families(:empty)
+ @exporter = Family::DataExporter.new(@family)
+
+ # Create some test data for the family
+ @account = @family.accounts.create!(
+ name: "Test Account",
+ accountable: Depository.new,
+ balance: 1000,
+ currency: "USD"
+ )
+
+ @category = @family.categories.create!(
+ name: "Test Category",
+ color: "#FF0000"
+ )
+
+ @tag = @family.tags.create!(
+ name: "Test Tag",
+ color: "#00FF00"
+ )
+ end
+
+ test "generates a zip file with all required files" do
+ zip_data = @exporter.generate_export
+
+ assert zip_data.is_a?(StringIO)
+
+ # Check that the zip contains all expected files
+ expected_files = [ "accounts.csv", "transactions.csv", "trades.csv", "categories.csv", "all.ndjson" ]
+
+ Zip::File.open_buffer(zip_data) do |zip|
+ actual_files = zip.entries.map(&:name)
+ assert_equal expected_files.sort, actual_files.sort
+ end
+ end
+
+ test "generates valid CSV files" do
+ zip_data = @exporter.generate_export
+
+ Zip::File.open_buffer(zip_data) do |zip|
+ # Check accounts.csv
+ accounts_csv = zip.read("accounts.csv")
+ assert accounts_csv.include?("id,name,type,subtype,balance,currency,created_at")
+
+ # Check transactions.csv
+ transactions_csv = zip.read("transactions.csv")
+ assert transactions_csv.include?("date,account_name,amount,name,category,tags,notes,currency")
+
+ # Check trades.csv
+ trades_csv = zip.read("trades.csv")
+ assert trades_csv.include?("date,account_name,ticker,quantity,price,amount,currency")
+
+ # Check categories.csv
+ categories_csv = zip.read("categories.csv")
+ assert categories_csv.include?("name,color,parent_category,classification")
+ end
+ end
+
+ test "generates valid NDJSON file" do
+ zip_data = @exporter.generate_export
+
+ Zip::File.open_buffer(zip_data) do |zip|
+ ndjson_content = zip.read("all.ndjson")
+ lines = ndjson_content.split("\n")
+
+ lines.each do |line|
+ assert_nothing_raised { JSON.parse(line) }
+ end
+
+ # Check that each line has expected structure
+ first_line = JSON.parse(lines.first)
+ assert first_line.key?("type")
+ assert first_line.key?("data")
+ end
+ end
+
+ test "only exports data from the specified family" do
+ # Create data for another family that should NOT be exported
+ other_account = @other_family.accounts.create!(
+ name: "Other Family Account",
+ accountable: Depository.new,
+ balance: 5000,
+ currency: "USD"
+ )
+
+ other_category = @other_family.categories.create!(
+ name: "Other Family Category",
+ color: "#0000FF"
+ )
+
+ zip_data = @exporter.generate_export
+
+ Zip::File.open_buffer(zip_data) do |zip|
+ # Check accounts.csv doesn't contain other family's data
+ accounts_csv = zip.read("accounts.csv")
+ assert accounts_csv.include?(@account.name)
+ refute accounts_csv.include?(other_account.name)
+
+ # Check categories.csv doesn't contain other family's data
+ categories_csv = zip.read("categories.csv")
+ assert categories_csv.include?(@category.name)
+ refute categories_csv.include?(other_category.name)
+
+ # Check NDJSON doesn't contain other family's data
+ ndjson_content = zip.read("all.ndjson")
+ refute ndjson_content.include?(other_account.id)
+ refute ndjson_content.include?(other_category.id)
+ end
+ end
+end
diff --git a/test/models/family_export_test.rb b/test/models/family_export_test.rb
new file mode 100644
index 00000000000..45420adf380
--- /dev/null
+++ b/test/models/family_export_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class FamilyExportTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/models/plaid_account/processor_test.rb b/test/models/plaid_account/processor_test.rb
index ec75296dbec..ba6a002f086 100644
--- a/test/models/plaid_account/processor_test.rb
+++ b/test/models/plaid_account/processor_test.rb
@@ -94,10 +94,21 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
test "calculates balance using BalanceCalculator for investment accounts" do
@plaid_account.update!(plaid_type: "investment")
- PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).returns(1000).once
+ # Balance is called twice: once for account.balance and once for set_current_balance
+ PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:balance).returns(1000).twice
PlaidAccount::Investments::BalanceCalculator.any_instance.expects(:cash_balance).returns(1000).once
PlaidAccount::Processor.new(@plaid_account).process
+
+ # Verify that the balance was set correctly
+ account = @plaid_account.account
+ assert_equal 1000, account.balance
+ assert_equal 1000, account.cash_balance
+
+ # Verify current balance anchor was created with correct value
+ current_anchor = account.valuations.current_anchor.first
+ assert_not_nil current_anchor
+ assert_equal 1000, current_anchor.entry.amount
end
test "processes credit liability data" do
@@ -142,6 +153,76 @@ class PlaidAccount::ProcessorTest < ActiveSupport::TestCase
PlaidAccount::Processor.new(@plaid_account).process
end
+ test "creates current balance anchor when processing account" do
+ expect_default_subprocessor_calls
+
+ # Clear out accounts to start fresh
+ Account.destroy_all
+
+ @plaid_account.update!(
+ plaid_id: "test_plaid_id",
+ plaid_type: "depository",
+ plaid_subtype: "checking",
+ current_balance: 1500,
+ available_balance: 1500,
+ currency: "USD",
+ name: "Test Account with Anchor",
+ mask: "1234"
+ )
+
+ assert_difference "Account.count", 1 do
+ assert_difference "Entry.count", 1 do
+ assert_difference "Valuation.count", 1 do
+ PlaidAccount::Processor.new(@plaid_account).process
+ end
+ end
+ end
+
+ account = Account.order(created_at: :desc).first
+ assert_equal 1500, account.balance
+
+ # Verify current balance anchor was created
+ current_anchor = account.valuations.current_anchor.first
+ assert_not_nil current_anchor
+ assert_equal "current_anchor", current_anchor.kind
+ assert_equal 1500, current_anchor.entry.amount
+ assert_equal Date.current, current_anchor.entry.date
+ assert_equal "Current balance", current_anchor.entry.name
+ end
+
+ test "updates existing current balance anchor when reprocessing" do
+ # First process creates the account and anchor
+ expect_default_subprocessor_calls
+ PlaidAccount::Processor.new(@plaid_account).process
+
+ account = @plaid_account.account
+ original_anchor = account.valuations.current_anchor.first
+ assert_not_nil original_anchor
+ original_anchor_id = original_anchor.id
+ original_entry_id = original_anchor.entry.id
+ original_balance = original_anchor.entry.amount
+
+ # Update the plaid account balance
+ @plaid_account.update!(current_balance: 2500)
+
+ # Expect subprocessor calls again for the second processing
+ expect_default_subprocessor_calls
+
+ # Reprocess should update the existing anchor
+ assert_no_difference "Valuation.count" do
+ assert_no_difference "Entry.count" do
+ PlaidAccount::Processor.new(@plaid_account).process
+ end
+ end
+
+ # Verify the anchor was updated
+ original_anchor.reload
+ assert_equal original_anchor_id, original_anchor.id
+ assert_equal original_entry_id, original_anchor.entry.id
+ assert_equal 2500, original_anchor.entry.amount
+ assert_not_equal original_balance, original_anchor.entry.amount
+ end
+
private
def expect_investment_product_processor_calls
PlaidAccount::Investments::TransactionsProcessor.any_instance.expects(:process).once
diff --git a/test/models/valuation/name_test.rb b/test/models/valuation/name_test.rb
index 7fa41ccac26..feed97eae28 100644
--- a/test/models/valuation/name_test.rb
+++ b/test/models/valuation/name_test.rb
@@ -17,6 +17,21 @@ class Valuation::NameTest < ActiveSupport::TestCase
assert_equal "Opening account value", name.to_s
end
+ test "generates opening anchor name for Vehicle" do
+ name = Valuation::Name.new("opening_anchor", "Vehicle")
+ assert_equal "Original purchase price", name.to_s
+ end
+
+ test "generates opening anchor name for Crypto" do
+ name = Valuation::Name.new("opening_anchor", "Crypto")
+ assert_equal "Opening account value", name.to_s
+ end
+
+ test "generates opening anchor name for OtherAsset" do
+ name = Valuation::Name.new("opening_anchor", "OtherAsset")
+ assert_equal "Opening account value", name.to_s
+ end
+
test "generates opening anchor name for other account types" do
name = Valuation::Name.new("opening_anchor", "Depository")
assert_equal "Opening balance", name.to_s
@@ -38,6 +53,21 @@ class Valuation::NameTest < ActiveSupport::TestCase
assert_equal "Current account value", name.to_s
end
+ test "generates current anchor name for Vehicle" do
+ name = Valuation::Name.new("current_anchor", "Vehicle")
+ assert_equal "Current market value", name.to_s
+ end
+
+ test "generates current anchor name for Crypto" do
+ name = Valuation::Name.new("current_anchor", "Crypto")
+ assert_equal "Current account value", name.to_s
+ end
+
+ test "generates current anchor name for OtherAsset" do
+ name = Valuation::Name.new("current_anchor", "OtherAsset")
+ assert_equal "Current account value", name.to_s
+ end
+
test "generates current anchor name for other account types" do
name = Valuation::Name.new("current_anchor", "Depository")
assert_equal "Current balance", name.to_s
@@ -54,6 +84,21 @@ class Valuation::NameTest < ActiveSupport::TestCase
assert_equal "Manual value update", name.to_s
end
+ test "generates recon name for Vehicle" do
+ name = Valuation::Name.new("reconciliation", "Vehicle")
+ assert_equal "Manual value update", name.to_s
+ end
+
+ test "generates recon name for Crypto" do
+ name = Valuation::Name.new("reconciliation", "Crypto")
+ assert_equal "Manual value update", name.to_s
+ end
+
+ test "generates recon name for OtherAsset" do
+ name = Valuation::Name.new("reconciliation", "OtherAsset")
+ assert_equal "Manual value update", name.to_s
+ end
+
test "generates recon name for Loan" do
name = Valuation::Name.new("reconciliation", "Loan")
assert_equal "Manual principal update", name.to_s
diff --git a/test/services/noop_api_rate_limiter_test.rb b/test/services/noop_api_rate_limiter_test.rb
new file mode 100644
index 00000000000..9c7105b12c8
--- /dev/null
+++ b/test/services/noop_api_rate_limiter_test.rb
@@ -0,0 +1,58 @@
+require "test_helper"
+
+class NoopApiRateLimiterTest < ActiveSupport::TestCase
+ setup do
+ @user = users(:family_admin)
+ # Clean up any existing API keys for this user to ensure tests start fresh
+ @user.api_keys.destroy_all
+
+ @api_key = ApiKey.create!(
+ user: @user,
+ name: "Noop Rate Limiter Test Key",
+ scopes: [ "read" ],
+ display_key: "noop_rate_limiter_test_#{SecureRandom.hex(8)}"
+ )
+ @rate_limiter = NoopApiRateLimiter.new(@api_key)
+ end
+
+ test "should never be rate limited" do
+ assert_not @rate_limiter.rate_limit_exceeded?
+ end
+
+ test "should not increment request count" do
+ @rate_limiter.increment_request_count!
+ assert_equal 0, @rate_limiter.current_count
+ end
+
+ test "should always have zero request count" do
+ assert_equal 0, @rate_limiter.current_count
+ end
+
+ test "should have infinite rate limit" do
+ assert_equal Float::INFINITY, @rate_limiter.rate_limit
+ end
+
+ test "should have zero reset time" do
+ assert_equal 0, @rate_limiter.reset_time
+ end
+
+ test "should provide correct usage info" do
+ usage_info = @rate_limiter.usage_info
+
+ assert_equal 0, usage_info[:current_count]
+ assert_equal Float::INFINITY, usage_info[:rate_limit]
+ assert_equal Float::INFINITY, usage_info[:remaining]
+ assert_equal 0, usage_info[:reset_time]
+ assert_equal :noop, usage_info[:tier]
+ end
+
+ test "class method usage_for should work" do
+ usage_info = NoopApiRateLimiter.usage_for(@api_key)
+
+ assert_equal 0, usage_info[:current_count]
+ assert_equal Float::INFINITY, usage_info[:rate_limit]
+ assert_equal Float::INFINITY, usage_info[:remaining]
+ assert_equal 0, usage_info[:reset_time]
+ assert_equal :noop, usage_info[:tier]
+ end
+end
diff --git a/test/support/balance_test_helper.rb b/test/support/balance_test_helper.rb
new file mode 100644
index 00000000000..e9eed0d7e07
--- /dev/null
+++ b/test/support/balance_test_helper.rb
@@ -0,0 +1,72 @@
+module BalanceTestHelper
+ def create_balance(account:, date:, balance:, cash_balance: nil, **attributes)
+ # If cash_balance is not provided, default to entire balance being cash
+ cash_balance ||= balance
+
+ # Calculate non-cash balance
+ non_cash_balance = balance - cash_balance
+
+ # Set default component values that will generate the desired end_balance
+ # flows_factor should be 1 for assets, -1 for liabilities
+ flows_factor = account.classification == "liability" ? -1 : 1
+
+ defaults = {
+ date: date,
+ balance: balance,
+ cash_balance: cash_balance,
+ currency: account.currency,
+ start_cash_balance: cash_balance,
+ start_non_cash_balance: non_cash_balance,
+ cash_inflows: 0,
+ cash_outflows: 0,
+ non_cash_inflows: 0,
+ non_cash_outflows: 0,
+ net_market_flows: 0,
+ cash_adjustments: 0,
+ non_cash_adjustments: 0,
+ flows_factor: flows_factor
+ }
+
+ account.balances.create!(defaults.merge(attributes))
+ end
+
+ def create_balance_with_flows(account:, date:, start_balance:, end_balance:,
+ cash_portion: 1.0, cash_flow: 0, non_cash_flow: 0,
+ market_flow: 0, **attributes)
+ # Calculate cash and non-cash portions
+ start_cash = start_balance * cash_portion
+ start_non_cash = start_balance * (1 - cash_portion)
+
+ # Calculate adjustments needed to reach end_balance
+ expected_end_cash = start_cash + cash_flow
+ expected_end_non_cash = start_non_cash + non_cash_flow + market_flow
+ expected_total = expected_end_cash + expected_end_non_cash
+
+ # Calculate adjustments if end_balance doesn't match expected
+ total_adjustment = end_balance - expected_total
+ cash_adjustment = cash_portion * total_adjustment
+ non_cash_adjustment = (1 - cash_portion) * total_adjustment
+
+ # flows_factor should be 1 for assets, -1 for liabilities
+ flows_factor = account.classification == "liability" ? -1 : 1
+
+ defaults = {
+ date: date,
+ balance: end_balance,
+ cash_balance: expected_end_cash + cash_adjustment,
+ currency: account.currency,
+ start_cash_balance: start_cash,
+ start_non_cash_balance: start_non_cash,
+ cash_inflows: cash_flow > 0 ? cash_flow : 0,
+ cash_outflows: cash_flow < 0 ? -cash_flow : 0,
+ non_cash_inflows: non_cash_flow > 0 ? non_cash_flow : 0,
+ non_cash_outflows: non_cash_flow < 0 ? -non_cash_flow : 0,
+ net_market_flows: market_flow,
+ cash_adjustments: cash_adjustment,
+ non_cash_adjustments: non_cash_adjustment,
+ flows_factor: flows_factor
+ }
+
+ account.balances.create!(defaults.merge(attributes))
+ end
+end
diff --git a/test/support/entries_test_helper.rb b/test/support/entries_test_helper.rb
index a4f2013f828..f586b148828 100644
--- a/test/support/entries_test_helper.rb
+++ b/test/support/entries_test_helper.rb
@@ -16,16 +16,22 @@ def create_transaction(attributes = {})
end
def create_valuation(attributes = {})
+ entry_attributes = attributes.except(:kind)
+ valuation_attributes = attributes.slice(:kind)
+
+ account = attributes[:account] || accounts(:depository)
+ amount = attributes[:amount] || 5000
+
entry_defaults = {
- account: accounts(:depository),
+ account: account,
name: "Valuation",
date: 1.day.ago.to_date,
currency: "USD",
- amount: 5000,
- entryable: Valuation.new
+ amount: amount,
+ entryable: Valuation.new({ kind: "reconciliation" }.merge(valuation_attributes))
}
- Entry.create! entry_defaults.merge(attributes)
+ Entry.create! entry_defaults.merge(entry_attributes)
end
def create_trade(security, account:, qty:, date:, price: nil, currency: "USD")
@@ -44,4 +50,33 @@ def create_trade(security, account:, qty:, date:, price: nil, currency: "USD")
currency: currency,
entryable: trade
end
+
+ def create_transfer(from_account:, to_account:, amount:, date: Date.current, currency: "USD")
+ outflow_transaction = Transaction.create!(kind: "funds_movement")
+ inflow_transaction = Transaction.create!(kind: "funds_movement")
+
+ transfer = Transfer.create!(
+ outflow_transaction: outflow_transaction,
+ inflow_transaction: inflow_transaction
+ )
+
+ # Create entries for both accounts
+ from_account.entries.create!(
+ name: "Transfer to #{to_account.name}",
+ date: date,
+ amount: -amount.abs,
+ currency: currency,
+ entryable: outflow_transaction
+ )
+
+ to_account.entries.create!(
+ name: "Transfer from #{from_account.name}",
+ date: date,
+ amount: amount.abs,
+ currency: currency,
+ entryable: inflow_transaction
+ )
+
+ transfer
+ end
end
diff --git a/test/support/ledger_testing_helper.rb b/test/support/ledger_testing_helper.rb
new file mode 100644
index 00000000000..d5e08aec48b
--- /dev/null
+++ b/test/support/ledger_testing_helper.rb
@@ -0,0 +1,309 @@
+module LedgerTestingHelper
+ def create_account_with_ledger(account:, entries: [], exchange_rates: [], security_prices: [], holdings: [])
+ # Clear all exchange rates and security prices to ensure clean test environment
+ ExchangeRate.destroy_all
+ Security::Price.destroy_all
+
+ # Create account with specified attributes
+ account_attrs = account.except(:type)
+ account_type = account[:type]
+
+ # Create the account
+ created_account = families(:empty).accounts.create!(
+ name: "Test Account",
+ accountable: account_type.new,
+ balance: account[:balance] || 0, # Doesn't matter, ledger derives this
+ cash_balance: account[:cash_balance] || 0, # Doesn't matter, ledger derives this
+ **account_attrs
+ )
+
+ # Set up exchange rates if provided
+ exchange_rates.each do |rate_data|
+ ExchangeRate.create!(
+ date: rate_data[:date],
+ from_currency: rate_data[:from],
+ to_currency: rate_data[:to],
+ rate: rate_data[:rate]
+ )
+ end
+
+ # Set up security prices if provided
+ security_prices.each do |price_data|
+ security = Security.find_or_create_by!(ticker: price_data[:ticker]) do |s|
+ s.name = price_data[:ticker]
+ end
+
+ Security::Price.create!(
+ security: security,
+ date: price_data[:date],
+ price: price_data[:price],
+ currency: created_account.currency
+ )
+ end
+
+ # Create entries in the order they were specified
+ entries.each do |entry_data|
+ case entry_data[:type]
+ when "current_anchor", "opening_anchor", "reconciliation"
+ # Create valuation entry
+ created_account.entries.create!(
+ name: "Valuation",
+ date: entry_data[:date],
+ amount: entry_data[:balance],
+ currency: entry_data[:currency] || created_account.currency,
+ entryable: Valuation.new(kind: entry_data[:type])
+ )
+ when "transaction"
+ # Use account currency if not specified
+ currency = entry_data[:currency] || created_account.currency
+
+ created_account.entries.create!(
+ name: "Transaction",
+ date: entry_data[:date],
+ amount: entry_data[:amount],
+ currency: currency,
+ entryable: Transaction.new
+ )
+ when "trade"
+ # Find or create security
+ security = Security.find_or_create_by!(ticker: entry_data[:ticker]) do |s|
+ s.name = entry_data[:ticker]
+ end
+
+ # Use account currency if not specified
+ currency = entry_data[:currency] || created_account.currency
+
+ trade = Trade.new(
+ qty: entry_data[:qty],
+ security: security,
+ price: entry_data[:price],
+ currency: currency
+ )
+
+ created_account.entries.create!(
+ name: "Trade",
+ date: entry_data[:date],
+ amount: entry_data[:qty] * entry_data[:price],
+ currency: currency,
+ entryable: trade
+ )
+ end
+ end
+
+ # Create holdings if provided
+ holdings.each do |holding_data|
+ # Find or create security
+ security = Security.find_or_create_by!(ticker: holding_data[:ticker]) do |s|
+ s.name = holding_data[:ticker]
+ end
+
+ Holding.create!(
+ account: created_account,
+ security: security,
+ date: holding_data[:date],
+ qty: holding_data[:qty],
+ price: holding_data[:price],
+ amount: holding_data[:amount],
+ currency: holding_data[:currency] || created_account.currency
+ )
+ end
+
+ created_account
+ end
+
+ def assert_calculated_ledger_balances(calculated_data:, expected_data:)
+ # Convert expected data to a hash for easier lookup
+ # Structure: [ { date:, legacy_balances: { balance:, cash_balance: }, balances: { start:, start_cash:, etc... }, flows: { ... }, adjustments: { ... } } ]
+ expected_hash = {}
+ expected_data.each do |data|
+ expected_hash[data[:date].to_date] = {
+ legacy_balances: data[:legacy_balances] || {},
+ balances: data[:balances] || {},
+ flows: data[:flows] || {},
+ adjustments: data[:adjustments] || {}
+ }
+ end
+
+ # Get all unique dates from all data sources
+ all_dates = (calculated_data.map(&:date) + expected_hash.keys).uniq.sort
+
+ # Check each date
+ all_dates.each do |date|
+ calculated_balance = calculated_data.find { |b| b.date == date }
+ expected = expected_hash[date]
+
+ if expected
+ assert calculated_balance, "Expected balance for #{date} but none was calculated"
+
+ # Always assert flows_factor is correct based on account classification
+ expected_flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
+ assert_equal expected_flows_factor, calculated_balance.flows_factor,
+ "Flows factor mismatch for #{date}: expected #{expected_flows_factor} for #{calculated_balance.account.classification} account"
+
+ legacy_balances = expected[:legacy_balances]
+ balances = expected[:balances]
+ flows = expected[:flows]
+ adjustments = expected[:adjustments]
+
+ # Legacy balance assertions
+ if legacy_balances.any?
+ assert_equal legacy_balances[:balance], calculated_balance.balance,
+ "Balance mismatch for #{date}"
+
+ assert_equal legacy_balances[:cash_balance], calculated_balance.cash_balance,
+ "Cash balance mismatch for #{date}"
+ end
+
+ # Balance assertions
+ if balances.any?
+ assert_equal balances[:start_cash], calculated_balance.start_cash_balance,
+ "Start cash balance mismatch for #{date}" if balances.key?(:start_cash)
+
+ assert_equal balances[:start_non_cash], calculated_balance.start_non_cash_balance,
+ "Start non-cash balance mismatch for #{date}" if balances.key?(:start_non_cash)
+
+ # Calculate end_cash_balance using the formula from the migration
+ if balances.key?(:end_cash)
+ # Determine flows_factor based on account classification
+ flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
+ expected_end_cash = calculated_balance.start_cash_balance +
+ ((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +
+ calculated_balance.cash_adjustments
+ assert_equal balances[:end_cash], expected_end_cash,
+ "End cash balance mismatch for #{date}"
+ end
+
+ # Calculate end_non_cash_balance using the formula from the migration
+ if balances.key?(:end_non_cash)
+ # Determine flows_factor based on account classification
+ flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
+ expected_end_non_cash = calculated_balance.start_non_cash_balance +
+ ((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +
+ calculated_balance.net_market_flows +
+ calculated_balance.non_cash_adjustments
+ assert_equal balances[:end_non_cash], expected_end_non_cash,
+ "End non-cash balance mismatch for #{date}"
+ end
+
+ # Calculate start_balance using the formula from the migration
+ if balances.key?(:start)
+ expected_start = calculated_balance.start_cash_balance + calculated_balance.start_non_cash_balance
+ assert_equal balances[:start], expected_start,
+ "Start balance mismatch for #{date}"
+ end
+
+ # Calculate end_balance using the formula from the migration since we're not persisting balances,
+ # and generated columns are not available until the record is persisted
+ if balances.key?(:end)
+ # Determine flows_factor based on account classification
+ flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
+ expected_end_cash_component = calculated_balance.start_cash_balance +
+ ((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +
+ calculated_balance.cash_adjustments
+ expected_end_non_cash_component = calculated_balance.start_non_cash_balance +
+ ((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +
+ calculated_balance.net_market_flows +
+ calculated_balance.non_cash_adjustments
+ expected_end = expected_end_cash_component + expected_end_non_cash_component
+ assert_equal balances[:end], expected_end,
+ "End balance mismatch for #{date}"
+ end
+ end
+
+ # Flow assertions
+ # If flows passed is 0, we assert all columns are 0
+ if flows.is_a?(Integer) && flows == 0
+ assert_equal 0, calculated_balance.cash_inflows,
+ "Cash inflows mismatch for #{date}"
+
+ assert_equal 0, calculated_balance.cash_outflows,
+ "Cash outflows mismatch for #{date}"
+
+ assert_equal 0, calculated_balance.non_cash_inflows,
+ "Non-cash inflows mismatch for #{date}"
+
+ assert_equal 0, calculated_balance.non_cash_outflows,
+ "Non-cash outflows mismatch for #{date}"
+
+ assert_equal 0, calculated_balance.net_market_flows,
+ "Net market flows mismatch for #{date}"
+ elsif flows.is_a?(Hash) && flows.any?
+ # Cash flows - must be asserted together
+ if flows.key?(:cash_inflows) || flows.key?(:cash_outflows)
+ assert flows.key?(:cash_inflows) && flows.key?(:cash_outflows),
+ "Cash inflows and outflows must be asserted together for #{date}"
+
+ assert_equal flows[:cash_inflows], calculated_balance.cash_inflows,
+ "Cash inflows mismatch for #{date}"
+
+ assert_equal flows[:cash_outflows], calculated_balance.cash_outflows,
+ "Cash outflows mismatch for #{date}"
+ end
+
+ # Non-cash flows - must be asserted together
+ if flows.key?(:non_cash_inflows) || flows.key?(:non_cash_outflows)
+ assert flows.key?(:non_cash_inflows) && flows.key?(:non_cash_outflows),
+ "Non-cash inflows and outflows must be asserted together for #{date}"
+
+ assert_equal flows[:non_cash_inflows], calculated_balance.non_cash_inflows,
+ "Non-cash inflows mismatch for #{date}"
+
+ assert_equal flows[:non_cash_outflows], calculated_balance.non_cash_outflows,
+ "Non-cash outflows mismatch for #{date}"
+ end
+
+ # Market flows - can be asserted independently
+ if flows.key?(:net_market_flows)
+ assert_equal flows[:net_market_flows], calculated_balance.net_market_flows,
+ "Net market flows mismatch for #{date}"
+ end
+ end
+
+ # Adjustment assertions
+ if adjustments.is_a?(Integer) && adjustments == 0
+ assert_equal 0, calculated_balance.cash_adjustments,
+ "Cash adjustments mismatch for #{date}"
+
+ assert_equal 0, calculated_balance.non_cash_adjustments,
+ "Non-cash adjustments mismatch for #{date}"
+ elsif adjustments.is_a?(Hash) && adjustments.any?
+ assert_equal adjustments[:cash_adjustments], calculated_balance.cash_adjustments,
+ "Cash adjustments mismatch for #{date}" if adjustments.key?(:cash_adjustments)
+
+ assert_equal adjustments[:non_cash_adjustments], calculated_balance.non_cash_adjustments,
+ "Non-cash adjustments mismatch for #{date}" if adjustments.key?(:non_cash_adjustments)
+ end
+
+ # Temporary assertions during migration (remove after migration complete)
+ # TODO: Remove these assertions after migration is complete
+ # Since we're not persisting balances, we calculate the end values
+ flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
+ expected_end_cash = calculated_balance.start_cash_balance +
+ ((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +
+ calculated_balance.cash_adjustments
+ expected_end_balance = expected_end_cash +
+ calculated_balance.start_non_cash_balance +
+ ((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +
+ calculated_balance.net_market_flows +
+ calculated_balance.non_cash_adjustments
+
+ assert_equal calculated_balance.cash_balance, expected_end_cash,
+ "Temporary assertion failed: end_cash_balance should equal cash_balance for #{date}"
+
+ assert_equal calculated_balance.balance, expected_end_balance,
+ "Temporary assertion failed: end_balance should equal balance for #{date}"
+ else
+ assert_nil calculated_balance, "Unexpected balance calculated for #{date}"
+ end
+ end
+
+ # Verify we got all expected dates
+ expected_dates = expected_hash.keys.sort
+ calculated_dates = calculated_data.map(&:date).sort
+
+ expected_dates.each do |date|
+ assert_includes calculated_dates, date,
+ "Expected balance for #{date} was not in calculated data"
+ end
+ end
+end
diff --git a/test/system/accounts_test.rb b/test/system/accounts_test.rb
index e910a3acf80..da1b5938452 100644
--- a/test/system/accounts_test.rb
+++ b/test/system/accounts_test.rb
@@ -98,7 +98,7 @@ class AccountsTest < ApplicationSystemTestCase
private
def open_new_account_modal
- within "[data-controller='tabs']" do
+ within "[data-controller='DS--tabs']" do
click_button "All"
click_link "New account"
end
diff --git a/test/system/settings/api_keys_test.rb b/test/system/settings/api_keys_test.rb
index 839068bbe4f..d6beeeee77d 100644
--- a/test/system/settings/api_keys_test.rb
+++ b/test/system/settings/api_keys_test.rb
@@ -124,17 +124,14 @@ class Settings::ApiKeysTest < ApplicationSystemTestCase
# Click the revoke button to open the modal
click_button "Revoke Key"
- # Wait for the modal to appear and click Confirm
- # The dialog might take a moment to appear
- sleep 0.5
-
+ # Wait for the dialog and then confirm
+ assert_selector "#confirm-dialog", visible: true
within "#confirm-dialog" do
- assert_text "Are you sure you want to revoke this API key?"
click_button "Confirm"
end
- # Wait for the page to update after revoke
- sleep 0.5
+ # Wait for redirect after revoke
+ assert_no_selector "#confirm-dialog"
assert_text "Create Your API Key"
assert_text "Get programmatic access to your Maybe data"
diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb
index ad9cc9269d9..632f6076ff4 100644
--- a/test/system/transactions_test.rb
+++ b/test/system/transactions_test.rb
@@ -34,6 +34,7 @@ class TransactionsTest < ApplicationSystemTestCase
within "form#transactions-search" do
fill_in "Search transactions ...", with: @transaction.name
+ find("#q_search").send_keys(:tab) # Trigger blur to submit form
end
assert_selector "#" + dom_id(@transaction), count: 1
@@ -118,22 +119,25 @@ class TransactionsTest < ApplicationSystemTestCase
assert_text "No entries found"
+ # Wait for Turbo to finish updating the DOM
+ sleep 0.5
+
# Page reload doesn't affect results
visit current_url
assert_text "No entries found"
- within "ul#transaction-search-filters" do
- find("li", text: account.name).first("button").click
- find("li", text: "on or after #{10.days.ago.to_date}").first("button").click
- find("li", text: "on or before #{1.day.ago.to_date}").first("button").click
- find("li", text: "Income").first("button").click
- find("li", text: "less than 200").first("button").click
- find("li", text: category.name).first("button").click
- find("li", text: merchant.name).first("button").click
+ # Remove all filters by clicking their X buttons
+ # Get all the filter buttons at once to avoid stale elements
+ filter_count = page.all("ul#transaction-search-filters li button").count
+
+ # Click each one with a small delay to let Turbo update
+ filter_count.times do
+ page.all("ul#transaction-search-filters li button").first.click
+ sleep 0.1
end
- assert_selector "#" + dom_id(@transaction), count: 1
+ assert_text @transaction.name
end
test "can select and deselect entire page of transactions" do
@@ -191,7 +195,7 @@ class TransactionsTest < ApplicationSystemTestCase
fill_in "Date", with: transfer_date
fill_in "model[amount]", with: 175.25
click_button "Add transaction"
- within "#entry-group-" + transfer_date.to_s do
+ within "#" + dom_id(investment_account, "entries_#{transfer_date}") do
assert_text "175.25"
end
end