+ <%= icon(
+ "refresh-cw",
+ as_button: true,
+ size: "sm",
+ href: sync_all_accounts_path,
+ disabled: Current.family.syncing?,
+ frame: :_top
+ ) %>
<%= render DS::Link.new(
text: "New account",
href: new_account_path(return_to: accounts_path),
diff --git a/app/views/family_exports/_list.html.erb b/app/views/family_exports/_list.html.erb
new file mode 100644
index 00000000..f4e979d8
--- /dev/null
+++ b/app/views/family_exports/_list.html.erb
@@ -0,0 +1,39 @@
+<%= turbo_frame_tag "family_exports",
+ data: exports.any? { |e| e.pending? || e.processing? } ? {
+ turbo_refresh_url: family_exports_path,
+ turbo_refresh_interval: 3000
+ } : {} do %>
+
+ <% if exports.any? %>
+ <% exports.each do |export| %>
+
+
+
Export from <%= export.created_at.strftime("%B %d, %Y at %I:%M %p") %>
+
<%= export.filename %>
+
+
+ <% if export.processing? || export.pending? %>
+
+ <% elsif export.completed? %>
+ <%= link_to download_family_export_path(export),
+ class: "flex items-center gap-2 text-primary hover:text-primary-hover",
+ data: { turbo_frame: "_top" } do %>
+ <%= icon "download", class: "w-5 h-5" %>
+
Download
+ <% end %>
+ <% elsif export.failed? %>
+
+ <%= icon "alert-circle", class: "w-4 h-4" %>
+ Failed
+
+ <% end %>
+
+ <% end %>
+ <% else %>
+
No exports yet
+ <% end %>
+
+<% end %>
diff --git a/app/views/family_exports/index.html.erb b/app/views/family_exports/index.html.erb
new file mode 100644
index 00000000..530b8151
--- /dev/null
+++ b/app/views/family_exports/index.html.erb
@@ -0,0 +1 @@
+<%= render "list", exports: @exports %>
diff --git a/app/views/family_exports/new.html.erb b/app/views/family_exports/new.html.erb
new file mode 100644
index 00000000..5bf02352
--- /dev/null
+++ b/app/views/family_exports/new.html.erb
@@ -0,0 +1,42 @@
+<%= render DS::Dialog.new do |dialog| %>
+ <% dialog.with_header(title: "Export your data", subtitle: "Download all your financial data") %>
+
+ <% dialog.with_body do %>
+
+
+
What's included:
+
+ -
+ <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
+ All accounts and balances
+
+ -
+ <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
+ Transaction history
+
+ -
+ <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
+ Investment trades
+
+ -
+ <%= icon "check", class: "shrink-0 mt-0.5 text-positive" %>
+ Categories and tags
+
+
+
+
+
+
+ Note: This export includes all of your data, but only some of the data can be imported back into Maybe via the CSV import feature. We support account, transaction (with category and tags), and trade imports. Other account data cannot be imported and is for your records only.
+
+
+
+ <%= form_with url: family_exports_path, method: :post, class: "space-y-4" do |form| %>
+
+ <%= link_to "Cancel", "#", class: "flex-1 text-center px-4 py-2 border border-primary rounded-lg hover:bg-surface-hover", data: { action: "click->modal#close" } %>
+ <%= form.submit "Export data", class: "flex-1 bg-inverse fg-inverse rounded-lg px-4 py-2 cursor-pointer" %>
+
+ <% end %>
+
+ <% end %>
+<% end %>
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb
index 2444f6cf..1cf92451 100644
--- a/app/views/pages/dashboard.html.erb
+++ b/app/views/pages/dashboard.html.erb
@@ -1,5 +1,20 @@
<% content_for :page_header do %>
+ <% unless Current.family.self_hoster? %>
+
+ <%= icon "triangle-alert", color: "warning" %>
+
+
We've made a tough decision to shut down the hosted version of Maybe. Here's what's happening next:
+
+ - <%= link_to "Read why we're doing this here", "https://x.com/Shpigford/status/1947725345244709240", class: "underline" %>
+ - You will be refunded in full.
+ - You have until July 31, 2025 to export your data. You can do that <%= link_to "here", settings_profile_path, class: "underline" %>.
+
+
+
+ <% end %>
+
+
Welcome back, <%= Current.user.first_name %>
Here's what's happening with your finances
diff --git a/app/views/rules/_rule.html.erb b/app/views/rules/_rule.html.erb
index e562cb95..68630b70 100644
--- a/app/views/rules/_rule.html.erb
+++ b/app/views/rules/_rule.html.erb
@@ -57,7 +57,7 @@
- <%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %>
+ <%= styled_form_with model: rule, namespace: "rule_#{rule.id}", data: { controller: "auto-submit-form" } do |f| %>
<%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %>
<% end %>
<%= render DS::Menu.new do |menu| %>
diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb
index e1f0d653..1dfe2c15 100644
--- a/app/views/settings/profiles/show.html.erb
+++ b/app/views/settings/profiles/show.html.erb
@@ -122,6 +122,29 @@
<% end %>
+<% if Current.user.admin? %>
+ <%= settings_section title: "Data Import/Export" do %>
+
+
+ <%= render DS::Link.new(
+ text: "Export data",
+ icon: "database",
+ href: new_family_export_path,
+ variant: "secondary",
+ full_width: true,
+ data: { turbo_frame: :modal }
+ ) %>
+
+
+ <%= turbo_frame_tag "family_exports", src: family_exports_path, loading: :lazy do %>
+
+ <% end %>
+
+ <% end %>
+<% end %>
+
<%= settings_section title: t(".danger_zone_title") do %>
<% if Current.user.admin? %>
diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb
index 46fd4ac1..3760f7dd 100644
--- a/app/views/transactions/_form.html.erb
+++ b/app/views/transactions/_form.html.erb
@@ -18,7 +18,7 @@
<% if @entry.account_id %>
<%= f.hidden_field :account_id %>
<% else %>
- <%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %>
+ <%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %>
<% end %>
<%= f.money_field :amount, label: t(".amount"), required: true %>
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index 5ca90443..05172fd2 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 93f55288..3d225e58 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 134b959a..3aefc6ba 100644
--- a/config/initializers/version.rb
+++ b/config/initializers/version.rb
@@ -14,7 +14,7 @@ module Maybe
private
def semver
- "0.5.0"
+ "0.6.0"
end
end
end
diff --git a/config/routes.rb b/config/routes.rb
index e2817432..d6c2bc7a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -24,6 +24,12 @@ Rails.application.routes.draw do
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"
@@ -156,6 +162,10 @@ Rails.application.routes.draw do
get :sparkline
patch :toggle_active
end
+
+ collection do
+ post :sync_all
+ end
end
# Convenience routes for polymorphic paths
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 00000000..1c864439
--- /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 00000000..d432d48d
--- /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 3b839c95..5984a8f2 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_18_120146) 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"
@@ -115,6 +115,20 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_18_120146) do
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"
@@ -256,6 +270,14 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_18_120146) do
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
@@ -816,6 +838,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_18_120146) do
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 6d3a14fd..febdcb3b 100644
--- a/lib/tasks/data_migration.rake
+++ b/lib/tasks/data_migration.rake
@@ -154,4 +154,19 @@ namespace :data_migration do
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/controllers/family_exports_controller_test.rb b/test/controllers/family_exports_controller_test.rb
new file mode 100644
index 00000000..63adf788
--- /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/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb
index 2500615c..c2cc94b7 100644
--- a/test/controllers/transactions_controller_test.rb
+++ b/test/controllers/transactions_controller_test.rb
@@ -162,8 +162,7 @@ end
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/data_migrations/balance_component_migrator_test.rb b/test/data_migrations/balance_component_migrator_test.rb
new file mode 100644
index 00000000..add8384c
--- /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 00000000..4d09645e
--- /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/jobs/family_data_export_job_test.rb b/test/jobs/family_data_export_job_test.rb
new file mode 100644
index 00000000..6aae26e8
--- /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
index 2139076c..ec093791 100644
--- a/test/models/account/activity_feed_data_test.rb
+++ b/test/models/account/activity_feed_data_test.rb
@@ -14,7 +14,7 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
setup_test_data
end
- test "calculates balance trend with complete balance history" do
+ test "returns balance for date with complete balance history" do
entries = @checking.entries.includes(:entryable).to_a
feed_data = Account::ActivityFeedData.new(@checking, entries)
@@ -22,14 +22,11 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
assert_not_nil day2_activity
- trend = day2_activity.balance_trend
- assert_equal 1100, trend.current.amount.to_i # End of day 2
- assert_equal 1000, trend.previous.amount.to_i # End of day 1
- assert_equal 100, trend.value.amount.to_i
- assert_equal "up", trend.direction.to_s
+ assert_not_nil day2_activity.balance
+ assert_equal 1100, day2_activity.balance.end_balance # End of day 2
end
- test "calculates balance trend for first day with zero starting balance" do
+ test "returns balance for first day" do
entries = @checking.entries.includes(:entryable).to_a
feed_data = Account::ActivityFeedData.new(@checking, entries)
@@ -37,49 +34,24 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
day1_activity = find_activity_for_date(activities, @test_period_start)
assert_not_nil day1_activity
- trend = day1_activity.balance_trend
- assert_equal 1000, trend.current.amount.to_i # End of first day
- assert_equal 0, trend.previous.amount.to_i # Fallback to 0
- assert_equal 1000, trend.value.amount.to_i
+ assert_not_nil day1_activity.balance
+ assert_equal 1000, day1_activity.balance.end_balance # End of first day
end
- test "uses last observed balance when intermediate balances are missing" do
- @checking.balances.where(date: [ @test_period_start + 1.day, @test_period_start + 3.days ]).destroy_all
-
- entries = @checking.entries.includes(:entryable).to_a
- feed_data = Account::ActivityFeedData.new(@checking, entries)
-
- activities = feed_data.entries_by_date
-
- # When day 2 balance is missing, both start and end use day 1 balance
- day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
- assert_not_nil day2_activity
- trend = day2_activity.balance_trend
- assert_equal 1000, trend.current.amount.to_i # LOCF from day 1
- assert_equal 1000, trend.previous.amount.to_i # LOCF from day 1
- assert_equal 0, trend.value.amount.to_i
- assert_equal "flat", trend.direction.to_s
- end
-
- test "returns zero balance when no balance history exists" do
+ 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
- # Use first day which has a transaction
day1_activity = find_activity_for_date(activities, @test_period_start)
assert_not_nil day1_activity
- trend = day1_activity.balance_trend
- assert_equal 0, trend.current.amount.to_i # Fallback to 0
- assert_equal 0, trend.previous.amount.to_i # Fallback to 0
- assert_equal 0, trend.value.amount.to_i
- assert_equal "flat", trend.direction.to_s
+ assert_nil day1_activity.balance
end
- test "calculates cash and holdings trends for investment accounts" do
+ test "returns cash and holdings data for investment accounts" do
entries = @investment.entries.includes(:entryable).to_a
feed_data = Account::ActivityFeedData.new(@investment, entries)
@@ -87,20 +59,12 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
day3_activity = find_activity_for_date(activities, @test_period_start + 2.days)
assert_not_nil day3_activity
+ assert_not_nil day3_activity.balance
- # Cash trend for day 3 (after foreign currency transaction)
- cash_trend = day3_activity.cash_balance_trend
- assert_equal 400, cash_trend.current.amount.to_i # End of day 3 cash balance
- assert_equal 500, cash_trend.previous.amount.to_i # End of day 2 cash balance
- assert_equal(-100, cash_trend.value.amount.to_i)
- assert_equal "down", cash_trend.direction.to_s
-
- # Holdings trend for day 3 (after trade)
- holdings_trend = day3_activity.holdings_value_trend
- assert_equal 1500, holdings_trend.current.amount.to_i # Total balance - cash balance
- assert_equal 0, holdings_trend.previous.amount.to_i # No holdings before trade
- assert_equal 1500, holdings_trend.value.amount.to_i
- assert_equal "up", holdings_trend.direction.to_s
+ # 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
@@ -134,30 +98,46 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
activities.each do |activity|
assert_respond_to activity, :date
assert_respond_to activity, :entries
- assert_respond_to activity, :balance_trend
- assert_respond_to activity, :cash_balance_trend
- assert_respond_to activity, :holdings_value_trend
+ assert_respond_to activity, :balance
assert_respond_to activity, :transfers
end
end
- test "handles valuations correctly by summing entry changes" do
+ 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,
- cash_balance: 1000,
+ 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, # Valuation sets this
- cash_balance: 1070, # Cash increased by transactions
+ 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"
)
@@ -198,73 +178,12 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
day2_activity = find_activity_for_date(activities, @test_period_start + 1.day)
assert_not_nil day2_activity
+ assert_not_nil day2_activity.balance
- # Cash change should be $70 (50 + 20 from transactions only, not trades)
- assert_equal 70, day2_activity.cash_balance_trend.value.amount.to_i
-
- # Holdings change should be 750 (from the trade)
- assert_equal 750, day2_activity.holdings_value_trend.value.amount.to_i
-
- # Total balance change
- assert_in_delta 1178.44, day2_activity.balance_trend.value.amount.to_f, 0.01
- end
-
- test "normalizes multi-currency entries on valuation days" do
- # Create EUR account
- eur_account = @family.accounts.create!(name: "EUR Investment", accountable: Investment.new, currency: "EUR", balance: 0)
-
- # Day 1: Starting balance
- eur_account.balances.create!(
- date: @test_period_start,
- balance: 1000,
- cash_balance: 500,
- currency: "EUR"
- )
-
- # Day 2: Multi-currency transactions and valuation
- eur_account.balances.create!(
- date: @test_period_start + 1.day,
- balance: 2000,
- cash_balance: 600,
- currency: "EUR"
- )
-
- # Create USD transaction (should be converted to EUR)
- create_transaction(
- account: eur_account,
- date: @test_period_start + 1.day,
- amount: -100,
- currency: "USD",
- name: "USD Payment"
- )
-
- # Create exchange rate: 1 USD = 0.9 EUR
- ExchangeRate.create!(
- date: @test_period_start + 1.day,
- from_currency: "USD",
- to_currency: "EUR",
- rate: 0.9
- )
-
- # Create valuation
- create_valuation(
- account: eur_account,
- date: @test_period_start + 1.day,
- amount: 2000
- )
-
- entries = eur_account.entries.includes(:entryable).to_a
- feed_data = Account::ActivityFeedData.new(eur_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
-
- # Cash change should be 90 EUR (100 USD * 0.9)
- # The transaction is -100 USD, which becomes +100 when inverted, then 100 * 0.9 = 90 EUR
- assert_equal 90, day2_activity.cash_balance_trend.value.amount.to_i
- assert_equal "EUR", day2_activity.cash_balance_trend.value.currency.iso_code
+ # 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
@@ -273,12 +192,25 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
end
def setup_test_data
- # Create daily balances for checking account
+ # 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),
+ 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
@@ -286,20 +218,50 @@ class Account::ActivityFeedDataTest < ActiveSupport::TestCase
# Create daily balances for investment account with cash_balance
@investment.balances.create!(
date: @test_period_start,
- balance: 500,
- cash_balance: 500,
+ 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,
- cash_balance: 500,
+ 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, # 1500 holdings + 400 cash
- cash_balance: 400, # After -100 EUR transaction
+ 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"
)
diff --git a/test/models/account/reconciliation_manager_test.rb b/test/models/account/reconciliation_manager_test.rb
index 794c2cc5..fe5257e9 100644
--- a/test/models/account/reconciliation_manager_test.rb
+++ b/test/models/account/reconciliation_manager_test.rb
@@ -1,18 +1,15 @@
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
- @account.balances.create!(
- date: Date.current,
- balance: 1000,
- cash_balance: 500,
- currency: @account.currency
- )
+ create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 500)
result = @manager.reconcile_balance(balance: 1200, date: Date.current)
@@ -24,7 +21,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
end
test "updates existing reconciliation without date change" do
- @account.balances.create!(date: Date.current, balance: 1000, cash_balance: 500, currency: @account.currency)
+ 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)
@@ -39,8 +36,8 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
end
test "updates existing reconciliation with date and amount change" do
- @account.balances.create!(date: 5.days.ago, balance: 1000, cash_balance: 500, currency: @account.currency)
- @account.balances.create!(date: Date.current, balance: 1200, cash_balance: 700, currency: @account.currency)
+ 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)
@@ -63,12 +60,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
end
test "handles date conflicts" do
- @account.balances.create!(
- date: Date.current,
- balance: 1000,
- cash_balance: 1000,
- currency: @account.currency
- )
+ create_balance(account: @account, date: Date.current, balance: 1000, cash_balance: 1000)
# Existing reconciliation entry
@account.entries.create!(
@@ -89,7 +81,7 @@ class Account::ReconciliationManagerTest < ActiveSupport::TestCase
end
test "dry run does not persist account" do
- @account.balances.create!(date: Date.current, balance: 1000, cash_balance: 500, currency: @account.currency)
+ 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)
diff --git a/test/models/balance/chart_series_builder_test.rb b/test/models/balance/chart_series_builder_test.rb
index 80d2467f..fbf5019f 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 b6eb2d11..b2462cb5 100644
--- a/test/models/balance/forward_calculator_test.rb
+++ b/test/models/balance/forward_calculator_test.rb
@@ -11,7 +11,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# 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, balance: 20000, cash_balance: 20000, currency: "USD" },
+ account: { type: Depository, currency: "USD" },
entries: []
)
@@ -21,8 +21,14 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ Date.current, { balance: 0, cash_balance: 0 } ]
+ 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
@@ -30,7 +36,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# 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, balance: 20000, cash_balance: 20000, currency: "USD" },
+ account: { type: Depository, currency: "USD" },
entries: [
{ type: "transaction", date: 2.days.ago.to_date, amount: -1000 }
]
@@ -41,16 +47,28 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# Since we start at 0, this transaction (inflow) simply increases balance from 0 -> 1000
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ 3.days.ago.to_date, { balance: 0, cash_balance: 0 } ],
- [ 2.days.ago.to_date, { balance: 1000, cash_balance: 1000 } ]
+ 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
test "reconciliation valuation sets absolute balance before applying subsequent transactions" do
account = create_account_with_ledger(
- account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
+ 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 }
@@ -62,9 +80,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# First valuation sets balance to 18000, then transaction increases balance to 19000
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ 3.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ],
- [ 2.days.ago.to_date, { balance: 19000, cash_balance: 19000 } ]
+ 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
@@ -72,7 +102,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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, balance: 10000, cash_balance: 10000, currency: "USD" },
+ 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 }
@@ -83,9 +113,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
- [ 2.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ]
+ 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
@@ -94,7 +136,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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, balance: 10000, cash_balance: 10000, currency: "USD" },
+ 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 }
@@ -105,9 +147,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ 3.days.ago.to_date, { balance: 17000, cash_balance: 0.0 } ],
- [ 2.days.ago.to_date, { balance: 18000, cash_balance: 0.0 } ]
+ 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
@@ -115,7 +169,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
test "mixed accounts (investment) use valuations where cash balance is total minus holdings" do
account = create_account_with_ledger(
- account: { type: Investment, balance: 10000, cash_balance: 10000, currency: "USD" },
+ 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 }
@@ -127,9 +181,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
- [ 2.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ]
+ 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
@@ -140,7 +206,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
test "transactions on depository accounts affect cash balance" do
account = create_account_with_ledger(
- account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
+ 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
@@ -152,11 +218,35 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ 5.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ],
- [ 4.days.ago.to_date, { balance: 20500, cash_balance: 20500 } ],
- [ 3.days.ago.to_date, { balance: 20500, cash_balance: 20500 } ],
- [ 2.days.ago.to_date, { balance: 20400, cash_balance: 20400 } ]
+ 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
@@ -164,7 +254,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
test "transactions on credit card accounts affect cash balance inversely" do
account = create_account_with_ledger(
- account: { type: CreditCard, balance: 10000, cash_balance: 10000, currency: "USD" },
+ 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
@@ -176,26 +266,47 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ 5.days.ago.to_date, { balance: 1000, cash_balance: 1000 } ],
- [ 4.days.ago.to_date, { balance: 500, cash_balance: 500 } ],
- [ 3.days.ago.to_date, { balance: 500, cash_balance: 500 } ],
- [ 2.days.ago.to_date, { balance: 600, cash_balance: 600 } ]
+ 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 "depository account with transactions and balance reconciliations" do
account = create_account_with_ledger(
- account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
+ account: { type: Depository, currency: "USD" },
entries: [
- { type: "opening_anchor", date: 10.days.ago.to_date, balance: 20000 },
- { type: "transaction", date: 8.days.ago.to_date, amount: -5000 },
- { type: "reconciliation", date: 6.days.ago.to_date, balance: 17000 },
- { type: "transaction", date: 6.days.ago.to_date, amount: -500 },
- { type: "transaction", date: 4.days.ago.to_date, amount: -500 },
- { type: "reconciliation", date: 3.days.ago.to_date, balance: 17000 },
- { type: "transaction", date: 1.day.ago.to_date, amount: 100 }
+ { 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 }
]
)
@@ -203,24 +314,42 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ 10.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ],
- [ 9.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ],
- [ 8.days.ago.to_date, { balance: 25000, cash_balance: 25000 } ],
- [ 7.days.ago.to_date, { balance: 25000, cash_balance: 25000 } ],
- [ 6.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
- [ 5.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
- [ 4.days.ago.to_date, { balance: 17500, cash_balance: 17500 } ],
- [ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
- [ 2.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ],
- [ 1.day.ago.to_date, { balance: 16900, cash_balance: 16900 } ]
+ 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
- test "accounts with transactions in multiple currencies convert to the account currency" do
+ 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, balance: 20000, cash_balance: 20000, currency: "USD" },
+ 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 },
@@ -237,11 +366,35 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ 4.days.ago.to_date, { balance: 100, cash_balance: 100 } ],
- [ 3.days.ago.to_date, { balance: 200, cash_balance: 200 } ],
- [ 2.days.ago.to_date, { balance: 500, cash_balance: 500 } ],
- [ 1.day.ago.to_date, { balance: 1100, cash_balance: 1100 } ]
+ 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
@@ -249,7 +402,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# 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: 10000, cash_balance: 0, currency: "USD" },
+ 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
@@ -263,9 +416,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ 2.days.ago.to_date, { balance: 20000, cash_balance: 0 } ],
- [ 1.day.ago.to_date, { balance: 18000, cash_balance: 0 } ]
+ 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
@@ -273,7 +438,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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: 10000, cash_balance: 10000, currency: "USD" },
+ account: { type: account_type, currency: "USD" },
entries: [
{ type: "opening_anchor", date: 3.days.ago.to_date, balance: 500000 },
@@ -286,9 +451,21 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ 3.days.ago.to_date, { balance: 500000, cash_balance: 0 } ],
- [ 2.days.ago.to_date, { balance: 500000, cash_balance: 0 } ]
+ 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
@@ -304,7 +481,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
# 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, balance: 10000, cash_balance: 10000, currency: "USD" },
+ 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 },
@@ -314,7 +491,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
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: 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
]
)
@@ -324,17 +501,87 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ 3.days.ago.to_date, { balance: 5000, cash_balance: 5000 } ],
- [ 2.days.ago.to_date, { balance: 5000, cash_balance: 5000 } ],
- [ 1.day.ago.to_date, { balance: 5000, cash_balance: 4000 } ],
- [ Date.current, { balance: 5000, cash_balance: 4000 } ]
+ 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
+
+ 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 }
+ ]
+ )
+
+ # 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
private
-
def assert_balances(calculated_data:, expected_balances:)
# Sort calculated data by date to ensure consistent ordering
sorted_data = calculated_data.sort_by(&:date)
diff --git a/test/models/balance/materializer_test.rb b/test/models/balance/materializer_test.rb
index 4a5ac439..01d34769 100644
--- a/test/models/balance/materializer_test.rb
+++ b/test/models/balance/materializer_test.rb
@@ -2,6 +2,7 @@ require "test_helper"
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")
+ 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)
- 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")
- ]
- )
+ # 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
+ )
+ ]
- assert_difference "@account.balances.count", 3 do
+ Balance::ForwardCalculator.any_instance.expects(:calculate).returns(expected_balances)
+ Holding::Materializer.any_instance.expects(:materialize_holdings).returns([]).once
+
+ # 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 a9348220..c3ba12ba 100644
--- a/test/models/balance/reverse_calculator_test.rb
+++ b/test/models/balance/reverse_calculator_test.rb
@@ -16,8 +16,14 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ Date.current, { balance: 20000, cash_balance: 20000 } ]
+ 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
@@ -47,12 +53,42 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
# a 100% full entries history.
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ Date.current, { balance: 20000, cash_balance: 20000 } ], # Current anchor
- [ 1.day.ago, { balance: 20000, cash_balance: 20000 } ],
- [ 2.days.ago, { balance: 20000, cash_balance: 20000 } ],
- [ 3.days.ago, { balance: 20000, cash_balance: 20000 } ],
- [ 4.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor
+ 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
@@ -75,9 +111,21 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ Date.current, { balance: 20000, cash_balance: 10000 } ], # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value
- [ 1.day.ago, { balance: 15000, cash_balance: 5000 } ] # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value
+ 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
@@ -87,8 +135,8 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" },
entries: [
{ type: "current_anchor", date: Date.current, balance: 20000 },
- { type: "transaction", date: 4.days.ago, amount: -500 }, # income
- { type: "transaction", date: 2.days.ago, amount: 100 } # expense
+ { type: "transaction", date: 2.days.ago, amount: 100 }, # expense
+ { type: "transaction", date: 4.days.ago, amount: -500 } # income
]
)
@@ -96,13 +144,49 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ Date.current, { balance: 20000, cash_balance: 20000 } ], # Current balance
- [ 1.day.ago, { balance: 20000, cash_balance: 20000 } ], # No change
- [ 2.days.ago, { balance: 20000, cash_balance: 20000 } ], # After expense (+100)
- [ 3.days.ago, { balance: 20100, cash_balance: 20100 } ], # Before expense
- [ 4.days.ago, { balance: 20100, cash_balance: 20100 } ], # After income (-500)
- [ 5.days.ago, { balance: 19600, cash_balance: 19600 } ] # After income (-500)
+ 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
@@ -122,13 +206,49 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
# Reversed order: showing how we work backwards
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ Date.current, { balance: 2000, cash_balance: 2000 } ], # Current balance
- [ 1.day.ago, { balance: 2000, cash_balance: 2000 } ], # No change
- [ 2.days.ago, { balance: 2000, cash_balance: 2000 } ], # After expense (+100)
- [ 3.days.ago, { balance: 1900, cash_balance: 1900 } ], # Before expense
- [ 4.days.ago, { balance: 1900, cash_balance: 1900 } ], # After CC payment (-500)
- [ 5.days.ago, { balance: 2400, cash_balance: 2400 } ]
+ 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
@@ -150,10 +270,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ Date.current, { balance: 198000, cash_balance: 0 } ],
- [ 1.day.ago, { balance: 198000, cash_balance: 0 } ],
- [ 2.days.ago, { balance: 200000, cash_balance: 0 } ]
+ 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
@@ -174,10 +312,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ Date.current, { balance: 1000, cash_balance: 0 } ],
- [ 1.day.ago, { balance: 1000, cash_balance: 0 } ],
- [ 2.days.ago, { balance: 1000, cash_balance: 0 } ]
+ 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
@@ -206,10 +362,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
# (the single trade doesn't affect balance; it just alters cash vs. holdings composition)
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings (anchor)
- [ 1.day.ago.to_date, { balance: 20000, cash_balance: 19000 } ], # After trade: $19k cash + $1k holdings
- [ 2.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ] # At first, account is 100% cash, no holdings (no trades)
+ 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
@@ -240,10 +414,28 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
- [ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL)
- [ 1.day.ago.to_date, { balance: 20000, cash_balance: 19000 } ], # After AAPL trade: $19k cash + $1k holdings
- [ 2.days.ago.to_date, { balance: 20000, cash_balance: 19500 } ] # Before AAPL trade: $19.5k cash + $500 MSFT
+ 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
@@ -258,8 +450,9 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
],
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: 2000 },
- { date: 1.day.ago, ticker: "AAPL", qty: 10, price: 100, amount: 2000 }
+ { 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 }
]
)
@@ -267,12 +460,30 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase
assert_calculated_ledger_balances(
calculated_data: calculated,
- expected_balances: [
+ 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.current, { balance: 20000, cash_balance: 18000 } ],
- [ 1.day.ago, { balance: 20000, cash_balance: 18000 } ],
- [ 2.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor sets absolute 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
diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb
new file mode 100644
index 00000000..cff5cd6c
--- /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 00000000..fe56fb08
--- /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 00000000..45420adf
--- /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/services/noop_api_rate_limiter_test.rb b/test/services/noop_api_rate_limiter_test.rb
new file mode 100644
index 00000000..9c7105b1
--- /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 00000000..e9eed0d7
--- /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/ledger_testing_helper.rb b/test/support/ledger_testing_helper.rb
index 6ae71678..d5e08aec 100644
--- a/test/support/ledger_testing_helper.rb
+++ b/test/support/ledger_testing_helper.rb
@@ -12,6 +12,8 @@ module LedgerTestingHelper
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
)
@@ -109,13 +111,20 @@ module LedgerTestingHelper
created_account
end
- def assert_calculated_ledger_balances(calculated_data:, expected_balances:)
- # Convert expected balances to a hash for easier lookup
- expected_hash = expected_balances.to_h do |date, balance_data|
- [ date.to_date, balance_data ]
+ 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 both calculated and expected data
+ # Get all unique dates from all data sources
all_dates = (calculated_data.map(&:date) + expected_hash.keys).uniq.sort
# Check each date
@@ -126,15 +135,163 @@ module LedgerTestingHelper
if expected
assert calculated_balance, "Expected balance for #{date} but none was calculated"
- if expected[:balance]
- assert_equal expected[:balance], calculated_balance.balance.to_d,
- "Balance mismatch for #{date}"
- end
+ # 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"
- if expected[:cash_balance]
- assert_equal expected[:cash_balance], calculated_balance.cash_balance.to_d,
+ 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
diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb
index be1f9b54..632f6076 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