- <%= form_with model: @transaction, html: { class: "p-3", data: { controller: "auto-submit-form" } } do |f| %>
-
+ <%= form_with model: @transaction, html: { class: "p-3 space-y-3", data: { controller: "auto-submit-form" } } do |f| %>
+
<%= t(".exclude_title") %>
<%= t(".exclude_subtitle") %>
@@ -84,18 +82,20 @@
<% end %>
-
-
-
<%= t(".delete_title") %>
-
<%= t(".delete_subtitle") %>
-
+ <% unless @transaction.transfer? %>
+
+
+
<%= t(".delete_title") %>
+
<%= t(".delete_subtitle") %>
+
- <%= button_to t(".delete"),
- transaction_path(@transaction),
- method: :delete,
- class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
- data: { turbo_confirm: true, turbo_frame: "_top" } %>
-
+ <%= button_to t(".delete"),
+ transaction_path(@transaction),
+ method: :delete,
+ class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
+ data: { turbo_confirm: true, turbo_frame: "_top" } %>
+
+ <% end %>
diff --git a/app/views/transfers/_form.html.erb b/app/views/transfers/_form.html.erb
new file mode 100644
index 00000000..0a8cfcd1
--- /dev/null
+++ b/app/views/transfers/_form.html.erb
@@ -0,0 +1,32 @@
+<%= form_with model: transfer do |f| %>
+
+
+
+
+
+ <%= f.text_field :name, value: transfer.transactions.first&.name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
+ <%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %>
+ <%= f.collection_select :to_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %>
+ <%= f.money_field :amount_money, label: t(".amount"), required: true %>
+ <%= f.date_field :date, value: transfer.transactions.first&.date, label: t(".date"), required: true, max: Date.current %>
+
+
+
+ <%= f.submit t(".submit") %>
+
+<% end %>
diff --git a/app/views/transfers/_transfer.html.erb b/app/views/transfers/_transfer.html.erb
new file mode 100644
index 00000000..fae63dae
--- /dev/null
+++ b/app/views/transfers/_transfer.html.erb
@@ -0,0 +1,37 @@
+<%= turbo_frame_tag dom_id(transfer), class: "block" do %>
+
+
+
+ <%= button_to transfer_path(transfer),
+ method: :delete,
+ class: "flex items-center group/transfer",
+ data: {
+ turbo_frame: "_top",
+ turbo_confirm: {
+ title: t(".remove_title"),
+ body: t(".remove_body"),
+ confirm: t(".remove_confirm")
+ }
+ } do %>
+ <%= lucide_icon "arrow-left-right", class: "group-hover/transfer:hidden w-5 h-5 text-gray-500" %>
+ <%= lucide_icon "unlink", class: "group-hover/transfer:inline-block hidden w-5 h-5 text-gray-500" %>
+ <% end %>
+
+
+ <%= tag.p t(".transfer_name", from_account: transfer.outflow_transaction&.account&.name, to_account: transfer.inflow_transaction&.account&.name) %>
+
+
+
+ <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
+
+
+
+ <% transfer.transactions.each do |transaction| %>
+
+ <%= render "transactions/name", transaction: transaction %>
+ <%= render "transactions/amount", transaction: transaction %>
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/transfers/new.html.erb b/app/views/transfers/new.html.erb
new file mode 100644
index 00000000..fc399358
--- /dev/null
+++ b/app/views/transfers/new.html.erb
@@ -0,0 +1,17 @@
+<%= modal do %>
+
+
+ <%= tag.h2 t(".title"), class: "font-medium text-xl" %>
+ <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
+
+
+ <% if @transfer.errors.present? %>
+
+ <%= lucide_icon "circle-alert", class: "w-5 h-5" %>
+
<%= @transfer.errors.full_messages.to_sentence %>
+
+ <% end %>
+
+ <%= render "form", transfer: @transfer %>
+
+<% end %>
diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml
index b021bc00..8b5e7e53 100644
--- a/config/locales/views/transactions/en.yml
+++ b/config/locales/views/transactions/en.yml
@@ -89,6 +89,8 @@ en:
import: Import
index:
transaction: transaction
+ mark_transfers:
+ success: Marked as transfer
merchants:
create:
success: New merchant created successfully
@@ -116,6 +118,11 @@ en:
title: New merchant
update:
success: Merchant updated successfully
+ selection_bar:
+ mark_transfers: Mark as transfers?
+ mark_transfers_confirm: Mark as transfers
+ mark_transfers_message: By marking transactions as transfers, they will no longer
+ be included in income or spending calculations.
show:
account_label: Account
account_placeholder: Select an account
@@ -127,7 +134,6 @@ en:
delete_subtitle: This permanently deletes the transaction, affects your historical
balances, and cannot be undone.
delete_title: Delete transaction
- description: Description
exclude_subtitle: This excludes the transaction from any in-app features or
analytics.
exclude_title: Exclude transaction
@@ -139,5 +145,11 @@ en:
overview: Overview
settings: Settings
tags_label: Select one or more tags
+ transaction:
+ remove_transfer: Remove transfer
+ remove_transfer_body: This will remove the transfer from this transaction
+ remove_transfer_confirm: Confirm
+ unmark_transfers:
+ success: Transfer removed
update:
success: Transaction updated successfully
diff --git a/config/locales/views/transfers/en.yml b/config/locales/views/transfers/en.yml
new file mode 100644
index 00000000..79b842a2
--- /dev/null
+++ b/config/locales/views/transfers/en.yml
@@ -0,0 +1,27 @@
+---
+en:
+ transfers:
+ create:
+ success: Transfer created
+ destroy:
+ success: Transfer removed
+ form:
+ amount: Amount
+ date: Date
+ description: Description
+ description_placeholder: Transfer from Checking to Savings
+ expense: Expense
+ from: From
+ income: Income
+ select_account: Select account
+ submit: Create transfer
+ to: To
+ transfer: Transfer
+ new:
+ title: New transfer
+ transfer:
+ remove_body: This will NOT delete the underlying transactions. It will just
+ remove the transfer.
+ remove_confirm: Confirm
+ remove_title: Remove transfer?
+ transfer_name: Transfer from %{from_account} to %{to_account}
diff --git a/config/routes.rb b/config/routes.rb
index feb262e6..b38ddbb6 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -46,6 +46,8 @@ Rails.application.routes.draw do
post "bulk_delete"
get "bulk_edit"
post "bulk_update"
+ post "mark_transfers"
+ post "unmark_transfers"
scope module: :transactions, as: :transaction do
resources :rows, only: %i[ show update ]
@@ -63,6 +65,8 @@ Rails.application.routes.draw do
end
end
+ resources :transfers, only: %i[ new create destroy ]
+
resources :accounts, shallow: true do
get :summary, on: :collection
get :list, on: :collection
diff --git a/db/migrate/20240614120946_create_transfers.rb b/db/migrate/20240614120946_create_transfers.rb
new file mode 100644
index 00000000..d53ad290
--- /dev/null
+++ b/db/migrate/20240614120946_create_transfers.rb
@@ -0,0 +1,7 @@
+class CreateTransfers < ActiveRecord::Migration[7.2]
+ def change
+ create_table :transfers, id: :uuid do |t|
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20240614121110_add_transfer_fields_to_transaction.rb b/db/migrate/20240614121110_add_transfer_fields_to_transaction.rb
new file mode 100644
index 00000000..ec91f908
--- /dev/null
+++ b/db/migrate/20240614121110_add_transfer_fields_to_transaction.rb
@@ -0,0 +1,8 @@
+class AddTransferFieldsToTransaction < ActiveRecord::Migration[7.2]
+ def change
+ change_table :transactions do |t|
+ t.references :transfer, foreign_key: true, type: :uuid
+ t.boolean :marked_as_transfer, default: false, null: false
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 56a0d4c0..be01a8cc 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: 2024_06_12_164944) do
+ActiveRecord::Schema[7.2].define(version: 2024_06_14_121110) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -87,7 +87,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_12_164944) do
t.uuid "accountable_id"
t.decimal "balance", precision: 19, scale: 4, default: "0.0"
t.string "currency", default: "USD"
- t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Account::Loan'::character varying)::text, ('Account::Credit'::character varying)::text, ('Account::OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
+ t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Account::Loan'::character varying, 'Account::Credit'::character varying, 'Account::OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.boolean "is_active", default: true, null: false
t.enum "status", default: "ok", null: false, enum_type: "account_status"
t.jsonb "sync_warnings", default: [], null: false
@@ -310,9 +310,17 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_12_164944) do
t.boolean "excluded", default: false
t.text "notes"
t.uuid "merchant_id"
+ t.uuid "transfer_id"
+ t.boolean "marked_as_transfer", default: false, null: false
t.index ["account_id"], name: "index_transactions_on_account_id"
t.index ["category_id"], name: "index_transactions_on_category_id"
t.index ["merchant_id"], name: "index_transactions_on_merchant_id"
+ t.index ["transfer_id"], name: "index_transactions_on_transfer_id"
+ end
+
+ create_table "transfers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
end
create_table "users", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -357,6 +365,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_12_164944) do
add_foreign_key "transactions", "accounts", on_delete: :cascade
add_foreign_key "transactions", "transaction_categories", column: "category_id", on_delete: :nullify
add_foreign_key "transactions", "transaction_merchants", column: "merchant_id"
+ add_foreign_key "transactions", "transfers"
add_foreign_key "users", "families"
add_foreign_key "valuations", "accounts", on_delete: :cascade
end
diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb
index 6b36ab52..57feedcc 100644
--- a/test/controllers/transactions_controller_test.rb
+++ b/test/controllers/transactions_controller_test.rb
@@ -4,7 +4,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@transaction = transactions(:checking_one)
- @recent_transactions = @user.family.transactions.ordered.limit(20).to_a
+ @recent_transactions = @user.family.transactions.ordered.where(transfer_id: nil).limit(20).to_a
end
test "should get paginated index with most recent transactions first" do
@@ -18,7 +18,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
test "transaction count represents filtered total" do
get transactions_url
- assert_dom "#total-transactions", count: 1, text: @user.family.transactions.count.to_s
+ assert_dom "#total-transactions", count: 1, text: @user.family.transactions.select { |t| t.currency == "USD" }.count.to_s
new_transaction = @user.family.accounts.first.transactions.create! \
name: "Transaction to search for",
@@ -42,7 +42,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
end
test "loads last page when page is out of range" do
- user_oldest_transaction = @user.family.transactions.ordered.last
+ user_oldest_transaction = @user.family.transactions.ordered.reject(&:transfer?).last
get transactions_url(page: 9999999999)
assert_response :success
diff --git a/test/controllers/transfers_controller_test.rb b/test/controllers/transfers_controller_test.rb
new file mode 100644
index 00000000..4a139f67
--- /dev/null
+++ b/test/controllers/transfers_controller_test.rb
@@ -0,0 +1,33 @@
+require "test_helper"
+
+class TransfersControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in users(:family_admin)
+ end
+
+ test "should get new" do
+ get new_transfer_url
+ assert_response :success
+ end
+
+ test "can create transfers" do
+ assert_difference "Transfer.count", 1 do
+ post transfers_url, params: {
+ transfer: {
+ from_account_id: accounts(:checking).id,
+ to_account_id: accounts(:savings).id,
+ date: Date.current,
+ amount: 100,
+ currency: "USD",
+ name: "Test Transfer"
+ }
+ }
+ end
+ end
+
+ test "can destroy transfer" do
+ assert_difference -> { Transfer.count } => -1, -> { Transaction.count } => 0 do
+ delete transfer_url(transfers(:credit_card_payment))
+ end
+ end
+end
diff --git a/test/fixtures/account/credits.yml b/test/fixtures/account/credits.yml
index 4e67ab5c..1ad8c478 100644
--- a/test/fixtures/account/credits.yml
+++ b/test/fixtures/account/credits.yml
@@ -1,2 +1 @@
-one:
- id: "123e4567-e89b-12d3-a456-426614174003"
+credit_one: { }
diff --git a/test/fixtures/account/cryptos.yml b/test/fixtures/account/cryptos.yml
index 1f0df1da..e0553ab0 100644
--- a/test/fixtures/account/cryptos.yml
+++ b/test/fixtures/account/cryptos.yml
@@ -1,11 +1 @@
-# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
-
-# This model initially had no columns defined. If you add columns to the
-# model remove the "{}" from the fixture names and add the columns immediately
-# below each fixture, per the syntax in the comments below
-#
-# one: {}
-# column: value
-#
-# two: {}
-# column: value
+one: { }
\ No newline at end of file
diff --git a/test/fixtures/account/depositories.yml b/test/fixtures/account/depositories.yml
index d24bbbb9..ff07aeab 100644
--- a/test/fixtures/account/depositories.yml
+++ b/test/fixtures/account/depositories.yml
@@ -1,8 +1,4 @@
-checking:
- id: "123e4567-e89b-12d3-a456-426614174000"
-savings:
- id: "123e4567-e89b-12d3-a456-426614174001"
-eur_checking:
- id: "123e4567-e89b-12d3-a456-426614174004"
-multi_currency:
- id: "123e4567-e89b-12d3-a456-426614174005"
+depository_checking: { }
+depository_savings: { }
+depository_eur_checking: { }
+depository_multi_currency: { }
diff --git a/test/fixtures/account/expected_balances.csv b/test/fixtures/account/expected_balances.csv
deleted file mode 100644
index c72f02c4..00000000
--- a/test/fixtures/account/expected_balances.csv
+++ /dev/null
@@ -1,32 +0,0 @@
-date_offset,collectable,checking,savings_with_valuation_overrides,credit_card,eur_checking_eur,eur_checking_usd,multi_currency
--30,400,4000,21250,1040,11850,12947.31,10721.26
--29,400,3985,21750,940,12050,13182.7,10921.26
--28,400,3985,21750,940,12050,13194.75,10921.26
--27,400,3985,21750,940,12050,13132.09,10921.26
--26,400,3985,21750,940,12050,13083.89,10921.26
--25,400,3985,21000,940,12050,13081.48,10921.26
--24,400,3985,21000,940,12050,13062.2,10921.26
--23,400,3985,21000,940,12050,13022.435,10921.26
--22,400,5060,21000,940,12050,13060.995,10921.26
--21,400,5060,21000,940,12050,13068.225,10921.26
--20,400,5060,21000,940,12050,13079.07,10921.26
--19,400,5060,21000,940,11950,12932.29,10813.04
--18,400,5060,19000,940,11950,12934.68,10813.04
--17,400,5060,19000,940,11950,12927.51,10813.04
--16,400,5060,19000,940,11950,12916.755,10813.04
--15,400,5040,19000,960,11950,12882.1,10813.04
--14,400,5040,19000,960,11950,12879.71,10813.04
--13,400,5040,19000,960,11950,12873.735,10813.04
--12,700,5010,19500,990,11950,12821.155,10813.04
--11,700,5010,19500,990,11950,12797.255,10813.04
--10,700,5010,19500,990,11950,12873.735,10813.04
--9,700,5010,19500,990,12000,12939.6,10863.04
--8,700,5010,19500,990,12000,12933.6,10863.04
--7,700,5010,19500,990,12000,12928.8,10863.04
--6,700,5010,19500,990,12000,12906,10863.04
--5,700,5000,19700,1000,12000,12891.6,10863.04
--4,550,5000,19700,1000,12000,12945.6,10000
--3,550,5000,20500,1000,12000,13046.4,10000
--2,550,5000,20500,1000,12000,12982.8,10000
--1,550,5000,20500,1000,12000,13014,10000
-0,550,5000,20500,1000,12000,13000.8,10000
\ No newline at end of file
diff --git a/test/fixtures/account/investments.yml b/test/fixtures/account/investments.yml
index e69de29b..10ecb60d 100644
--- a/test/fixtures/account/investments.yml
+++ b/test/fixtures/account/investments.yml
@@ -0,0 +1 @@
+investment_brokerage: { }
diff --git a/test/fixtures/account/loans.yml b/test/fixtures/account/loans.yml
index e69de29b..6043e466 100644
--- a/test/fixtures/account/loans.yml
+++ b/test/fixtures/account/loans.yml
@@ -0,0 +1 @@
+loan_mortgage: { }
diff --git a/test/fixtures/account/other_assets.yml b/test/fixtures/account/other_assets.yml
index 2734e1b0..74674e8c 100644
--- a/test/fixtures/account/other_assets.yml
+++ b/test/fixtures/account/other_assets.yml
@@ -1,2 +1,3 @@
-one:
- id: "123e4567-e89b-12d3-a456-426614174002"
+other_asset_collectable: { }
+
+
diff --git a/test/fixtures/account/other_liabilities.yml b/test/fixtures/account/other_liabilities.yml
index e69de29b..08028f97 100644
--- a/test/fixtures/account/other_liabilities.yml
+++ b/test/fixtures/account/other_liabilities.yml
@@ -0,0 +1 @@
+other_asset_iou: { }
diff --git a/test/fixtures/account/properties.yml b/test/fixtures/account/properties.yml
index e69de29b..c6026df2 100644
--- a/test/fixtures/account/properties.yml
+++ b/test/fixtures/account/properties.yml
@@ -0,0 +1 @@
+property_house: { }
diff --git a/test/fixtures/account/vehicles.yml b/test/fixtures/account/vehicles.yml
index e69de29b..a0ea340e 100644
--- a/test/fixtures/account/vehicles.yml
+++ b/test/fixtures/account/vehicles.yml
@@ -0,0 +1 @@
+vehicle_honda_accord: { }
diff --git a/test/fixtures/account_balances.yml b/test/fixtures/account_balances.yml
deleted file mode 100644
index 99798f29..00000000
--- a/test/fixtures/account_balances.yml
+++ /dev/null
@@ -1,13 +0,0 @@
-# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
-
-# one:
-# account: generic
-# date: 2024-02-12
-# balance: 9.99
-# currency: MyString
-
-# two:
-# account: generic
-# date: 2024-02-13
-# balance: 9.99
-# currency: MyString
diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml
index 1859f8d5..bbb96c0f 100644
--- a/test/fixtures/accounts.yml
+++ b/test/fixtures/accounts.yml
@@ -1,36 +1,39 @@
-# Account with only valuations
collectable:
family: dylan_family
name: Collectable Account
balance: 550
accountable_type: Account::OtherAsset
- accountable_id: "123e4567-e89b-12d3-a456-426614174002"
+ accountable: other_asset_collectable
+
+iou:
+ family: dylan_family
+ name: IOU (personal debt to friend)
+ balance: 200
+ accountable_type: Account::OtherLiability
+ accountable: other_liability_iou
-# Account with only transactions
checking:
family: dylan_family
name: Checking Account
balance: 5000
accountable_type: Account::Depository
- accountable_id: "123e4567-e89b-12d3-a456-426614174000"
+ accountable: depository_checking
institution: chase
-# Account with both transactions and valuations
-savings_with_valuation_overrides:
+savings:
family: dylan_family
name: Savings account with valuation overrides
- balance: 20000
+ balance: 19700
accountable_type: Account::Depository
- accountable_id: "123e4567-e89b-12d3-a456-426614174001"
+ accountable: depository_savings
institution: chase
-# Liability account
credit_card:
family: dylan_family
name: Credit Card
balance: 1000
accountable_type: Account::Credit
- accountable_id: "123e4567-e89b-12d3-a456-426614174003"
+ accountable: credit_one
institution: chase
eur_checking:
@@ -39,7 +42,7 @@ eur_checking:
currency: EUR
balance: 12000
accountable_type: Account::Depository
- accountable_id: "123e4567-e89b-12d3-a456-426614174004"
+ accountable: depository_eur_checking
institution: revolut
# Multi-currency account (e.g. Wise, Revolut, etc.)
@@ -47,7 +50,39 @@ multi_currency:
family: dylan_family
name: Multi Currency Account
currency: USD # multi-currency accounts still have a "primary" currency
- balance: 10000
+ balance: 9467
accountable_type: Account::Depository
- accountable_id: "123e4567-e89b-12d3-a456-426614174005"
+ accountable: depository_multi_currency
institution: revolut
+
+brokerage:
+ family: dylan_family
+ name: Robinhood Brokerage Account
+ currency: USD
+ balance: 10000
+ accountable_type: Account::Investment
+ accountable: investment_brokerage
+
+mortgage_loan:
+ family: dylan_family
+ name: Mortgage Loan
+ currency: USD
+ balance: 500000
+ accountable_type: Account::Loan
+ accountable: loan_mortgage
+
+house:
+ family: dylan_family
+ name: 123 Maybe Court
+ currency: USD
+ balance: 550000
+ accountable_type: Account::Property
+ accountable: property_house
+
+car:
+ family: dylan_family
+ name: Honda Accord
+ currency: USD
+ balance: 18000
+ accountable_type: Account::Vehicle
+ accountable: vehicle_honda_accord
diff --git a/test/fixtures/exchange_rates.yml b/test/fixtures/exchange_rates.yml
index 9b367421..533d16cf 100644
--- a/test/fixtures/exchange_rates.yml
+++ b/test/fixtures/exchange_rates.yml
@@ -1,308 +1,381 @@
+day_31_ago_eur_to_usd:
+ base_currency: EUR
+ converted_currency: USD
+ rate: 1.0986
+ date: <%= 31.days.ago.to_date %>
+
day_30_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0926
date: <%= 30.days.ago.to_date %>
+
day_29_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.094
date: <%= 29.days.ago.to_date %>
+
day_28_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.095
date: <%= 28.days.ago.to_date %>
+
day_27_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0898
date: <%= 27.days.ago.to_date %>
+
day_26_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0858
date: <%= 26.days.ago.to_date %>
+
day_25_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0856
date: <%= 25.days.ago.to_date %>
+
day_24_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.084
date: <%= 24.days.ago.to_date %>
+
day_23_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0807
date: <%= 23.days.ago.to_date %>
+
day_22_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0839
date: <%= 22.days.ago.to_date %>
+
day_21_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0845
date: <%= 21.days.ago.to_date %>
+
day_20_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0854
date: <%= 20.days.ago.to_date %>
+
day_19_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0822
date: <%= 19.days.ago.to_date %>
+
day_18_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0824
date: <%= 18.days.ago.to_date %>
+
day_17_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0818
date: <%= 17.days.ago.to_date %>
+
day_16_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0809
date: <%= 16.days.ago.to_date %>
+
day_15_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.078
date: <%= 15.days.ago.to_date %>
+
day_14_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0778
date: <%= 14.days.ago.to_date %>
+
day_13_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0773
date: <%= 13.days.ago.to_date %>
+
day_12_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0729
date: <%= 12.days.ago.to_date %>
+
day_11_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0709
date: <%= 11.days.ago.to_date %>
+
day_10_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0773
date: <%= 10.days.ago.to_date %>
+
day_9_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0783
date: <%= 9.days.ago.to_date %>
+
day_8_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0778
date: <%= 8.days.ago.to_date %>
+
day_7_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0774
date: <%= 7.days.ago.to_date %>
+
day_6_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0755
date: <%= 6.days.ago.to_date %>
+
day_5_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0743
date: <%= 5.days.ago.to_date %>
+
day_4_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0788
date: <%= 4.days.ago.to_date %>
+
day_3_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0872
date: <%= 3.days.ago.to_date %>
+
day_2_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0819
date: <%= 2.days.ago.to_date %>
+
day_1_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0845
date: <%= 1.days.ago.to_date %>
+
today_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0834
date: <%= Date.current %>
+
+day_31_ago_usd_to_eur:
+ base_currency: USD
+ converted_currency: EUR
+ rate: 0.9279
+ date: <%= 31.days.ago.to_date %>
+
day_30_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9179
date: <%= 30.days.ago.to_date %>
+
day_29_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9154
date: <%= 29.days.ago.to_date %>
+
day_28_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9107
date: <%= 28.days.ago.to_date %>
+
day_27_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9139
date: <%= 27.days.ago.to_date %>
+
day_26_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9082
date: <%= 26.days.ago.to_date %>
+
day_25_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9077
date: <%= 25.days.ago.to_date %>
+
day_24_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9054
date: <%= 24.days.ago.to_date %>
+
day_23_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9004
date: <%= 23.days.ago.to_date %>
+
day_22_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9040
date: <%= 22.days.ago.to_date %>
+
day_21_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9060
date: <%= 21.days.ago.to_date %>
+
day_20_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9052
date: <%= 20.days.ago.to_date %>
+
day_19_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9139
date: <%= 19.days.ago.to_date %>
+
day_18_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9155
date: <%= 18.days.ago.to_date %>
+
day_17_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9135
date: <%= 17.days.ago.to_date %>
+
day_16_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9141
date: <%= 16.days.ago.to_date %>
+
day_15_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9131
date: <%= 15.days.ago.to_date %>
+
day_14_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9147
date: <%= 14.days.ago.to_date %>
+
day_13_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9112
date: <%= 13.days.ago.to_date %>
+
day_12_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9115
date: <%= 12.days.ago.to_date %>
+
day_11_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9132
date: <%= 11.days.ago.to_date %>
+
day_10_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9130
date: <%= 10.days.ago.to_date %>
+
day_9_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9192
date: <%= 9.days.ago.to_date %>
+
day_8_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9188
date: <%= 8.days.ago.to_date %>
+
day_7_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9194
date: <%= 7.days.ago.to_date %>
+
day_6_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9177
date: <%= 6.days.ago.to_date %>
+
day_5_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9187
date: <%= 5.days.ago.to_date %>
+
day_4_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9213
date: <%= 4.days.ago.to_date %>
+
day_3_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9186
date: <%= 3.days.ago.to_date %>
+
day_2_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9218
date: <%= 2.days.ago.to_date %>
+
day_1_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9213
date: <%= 1.days.ago.to_date %>
+
today_usd_to_eur:
base_currency: USD
converted_currency: EUR
diff --git a/test/fixtures/family/expected_snapshots.csv b/test/fixtures/family/expected_snapshots.csv
deleted file mode 100644
index fb4a6b01..00000000
--- a/test/fixtures/family/expected_snapshots.csv
+++ /dev/null
@@ -1,32 +0,0 @@
-date_offset,net_worth,assets,liabilities,depositories,investments,loans,credits,properties,vehicles,other_assets,other_liabilities,spending,income,rolling_spend,rolling_income,savings_rate
--30,48278.57,49318.57,1040.00,48918.57,0.00,0.00,1040.00,0.00,0.00,400.00,0.00,0,0,0,0,0
--29,49298.96,50238.96,940.00,49838.96,0.00,0.00,940.00,0.00,0.00,400.00,0.00,15,1018.8,15,1018.8,0.9852767962
--28,49311.01,50251.01,940.00,49851.01,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
--27,49248.35,50188.35,940.00,49788.35,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
--26,49200.15,50140.15,940.00,49740.15,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
--25,48447.74,49387.74,940.00,48987.74,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
--24,48428.46,49368.46,940.00,48968.46,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
--23,48388.70,49328.70,940.00,48928.70,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,1018.8,0.9852767962
--22,49502.26,50442.26,940.00,50042.26,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,1075,15,2093.8,0.992835992
--21,49509.49,50449.49,940.00,50049.49,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,2093.8,0.992835992
--20,49520.33,50460.33,940.00,50060.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,15,2093.8,0.992835992
--19,49265.33,50205.33,940.00,49805.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00,216.44,0,231.44,2093.8,0.8894641322
--18,47267.72,48207.72,940.00,47807.72,0.00,0.00,940.00,0.00,0.00,400.00,0.00,2000,0,2231.44,2093.8,-0.06573693763
--17,47260.55,48200.55,940.00,47800.55,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,2231.44,2093.8,-0.06573693763
--16,47249.80,48189.80,940.00,47789.80,0.00,0.00,940.00,0.00,0.00,400.00,0.00,0,0,2231.44,2093.8,-0.06573693763
--15,47175.14,48135.14,960.00,47735.14,0.00,0.00,960.00,0.00,0.00,400.00,0.00,40,0,2271.44,2093.8,-0.08484095902
--14,47172.75,48132.75,960.00,47732.75,0.00,0.00,960.00,0.00,0.00,400.00,0.00,0,0,2271.44,2093.8,-0.08484095902
--13,47166.78,48126.78,960.00,47726.78,0.00,0.00,960.00,0.00,0.00,400.00,0.00,0,0,2271.44,2093.8,-0.08484095902
--12,47854.20,48844.20,990.00,48144.20,0.00,0.00,990.00,0.00,0.00,700.00,0.00,60,50,2331.44,2143.8,-0.08752682153
--11,47830.30,48820.30,990.00,48120.30,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2143.8,-0.08752682153
--10,47906.78,48896.78,990.00,48196.78,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2143.8,-0.08752682153
--9,48022.64,49012.64,990.00,48312.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,103.915,2331.44,2247.715,-0.03724893948
--8,48016.64,49006.64,990.00,48306.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715,-0.03724893948
--7,48011.84,49001.84,990.00,48301.84,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715,-0.03724893948
--6,47989.04,48979.04,990.00,48279.04,0.00,0.00,990.00,0.00,0.00,700.00,0.00,0,0,2331.44,2247.715,-0.03724893948
--5,48154.64,49154.64,1000.00,48454.64,0.00,0.00,1000.00,0.00,0.00,700.00,0.00,20,200,2351.44,2447.715,0.03933260204
--4,47195.60,48195.60,1000.00,47645.60,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,863.04,0,3214.48,2447.715,-0.3132574667
--3,48096.40,49096.40,1000.00,48546.40,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
--2,48032.80,49032.80,1000.00,48482.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
--1,48064.00,49064.00,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
-0,48050.80,49050.80,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00,0,0,3214.48,2447.715,-0.3132574667
\ No newline at end of file
diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep
deleted file mode 100644
index e69de29b..00000000
diff --git a/test/fixtures/files/expected_family_snapshots.csv b/test/fixtures/files/expected_family_snapshots.csv
new file mode 100644
index 00000000..6b0667e2
--- /dev/null
+++ b/test/fixtures/files/expected_family_snapshots.csv
@@ -0,0 +1,33 @@
+date_offset,collectable,iou,checking,credit_card,savings,eur_checking_eur,eur_checking_usd,multi_currency,brokerage,mortgage_loan,house,car,net_worth,assets,liabilities,depositories,investments,loans,credits,properties,vehicles,other_assets,other_liabilities,spending,income,rolling_spend,rolling_income,savings_rate
+31,400.00,200.00,4950.00,1040.00,20700.00,11850.00,13018.41,10200.00,10000.00,500000.00,550000.00,18000.00,126028.41,627268.41,501240.00,48868.41,10000.00,500000.00,1040.00,550000.00,18000.00,400.00,200.00,0.00,0.00,0.00,0.00,0.0000
+30,400.00,200.00,4100.00,940.00,20950.00,12050.00,13165.83,10200.00,10000.00,500000.00,550000.00,18000.00,125675.83,626815.83,501140.00,48415.83,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,218.52,0.00,218.52,1.0000
+29,400.00,200.00,3985.00,940.00,21450.00,12050.00,13182.70,10400.00,10000.00,500000.00,550000.00,18000.00,126277.70,627417.70,501140.00,49017.70,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,15.00,700.00,15.00,918.52,0.9837
+28,400.00,200.00,3985.00,940.00,21450.00,12050.00,13194.75,10400.00,10000.00,500000.00,550000.00,18000.00,126289.75,627429.75,501140.00,49029.75,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,918.52,0.9837
+27,400.00,200.00,3985.00,940.00,21450.00,12050.00,13132.09,10400.00,10000.00,500000.00,550000.00,18000.00,126227.09,627367.09,501140.00,48967.09,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,918.52,0.9837
+26,400.00,200.00,3985.00,940.00,21450.00,12050.00,13083.89,10400.00,10000.00,500000.00,550000.00,18000.00,126178.89,627318.89,501140.00,48918.89,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,918.52,0.9837
+25,400.00,200.00,3985.00,940.00,21000.00,12050.00,13081.48,10400.00,10000.00,500000.00,550000.00,18000.00,125726.48,626866.48,501140.00,48466.48,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,918.52,0.9837
+24,400.00,200.00,3985.00,940.00,21000.00,12050.00,13062.20,10400.00,10000.00,500000.00,550000.00,18000.00,125707.20,626847.20,501140.00,48447.20,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,918.52,0.9837
+23,400.00,200.00,3985.00,940.00,21000.00,12050.00,13022.44,10400.00,10000.00,500000.00,550000.00,18000.00,125667.44,626807.44,501140.00,48407.44,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,918.52,0.9837
+22,400.00,200.00,5060.00,940.00,21000.00,12050.00,13061.00,10400.00,10000.00,500000.00,550000.00,18000.00,126781.00,627921.00,501140.00,49521.00,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,1075.00,15.00,1993.52,0.9925
+21,400.00,200.00,5060.00,940.00,21000.00,12050.00,13068.23,10400.00,10000.00,500000.00,550000.00,18000.00,126788.23,627928.23,501140.00,49528.23,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,1993.52,0.9925
+20,400.00,200.00,5060.00,940.00,21000.00,12050.00,13079.07,10400.00,10000.00,500000.00,550000.00,18000.00,126799.07,627939.07,501140.00,49539.07,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,1993.52,0.9925
+19,400.00,200.00,5060.00,940.00,21000.00,11950.00,12932.29,10280.04,10000.00,500000.00,550000.00,18000.00,126532.33,627672.33,501140.00,49272.33,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,228.18,0.00,243.18,1993.52,0.8780
+18,400.00,200.00,5060.00,940.00,19000.00,11950.00,12934.68,10280.04,10000.00,500000.00,550000.00,18000.00,124534.72,625674.72,501140.00,47274.72,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,2000.00,0.00,2243.18,1993.52,-0.1252
+17,400.00,200.00,5060.00,940.00,19000.00,11950.00,12927.51,10280.04,10000.00,500000.00,550000.00,18000.00,124527.55,625667.55,501140.00,47267.55,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,2243.18,1993.52,-0.1252
+16,400.00,200.00,5060.00,940.00,19000.00,11950.00,12916.76,10280.04,10000.00,500000.00,550000.00,18000.00,124516.79,625656.79,501140.00,47256.79,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,2243.18,1993.52,-0.1252
+15,400.00,200.00,5040.00,960.00,19000.00,11950.00,12882.10,10280.04,10000.00,500000.00,550000.00,18000.00,124442.14,625602.14,501160.00,47202.14,10000.00,500000.00,960.00,550000.00,18000.00,400.00,200.00,40.00,0.00,2283.18,1993.52,-0.1453
+14,400.00,200.00,5040.00,960.00,19000.00,11950.00,12879.71,10280.04,10000.00,500000.00,550000.00,18000.00,124439.75,625599.75,501160.00,47199.75,10000.00,500000.00,960.00,550000.00,18000.00,400.00,200.00,0.00,0.00,2283.18,1993.52,-0.1453
+13,400.00,200.00,5040.00,960.00,19000.00,11950.00,12873.74,10280.04,10000.00,500000.00,550000.00,18000.00,124433.77,625593.77,501160.00,47193.77,10000.00,500000.00,960.00,550000.00,18000.00,400.00,200.00,0.00,0.00,2283.18,1993.52,-0.1453
+12,700.00,200.00,5010.00,990.00,19500.00,11950.00,12821.16,10280.04,10000.00,500000.00,550000.00,18000.00,125121.19,626311.19,501190.00,47611.19,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,60.00,50.00,2343.18,2043.52,-0.1466
+11,700.00,200.00,5010.00,990.00,19500.00,11950.00,12797.26,10280.04,10000.00,500000.00,550000.00,18000.00,125097.29,626287.29,501190.00,47587.29,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2043.52,-0.1466
+10,700.00,200.00,5010.00,990.00,19500.00,11950.00,12873.74,10280.04,10000.00,500000.00,550000.00,18000.00,125173.77,626363.77,501190.00,47663.77,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2043.52,-0.1466
+9,700.00,200.00,5010.00,990.00,19500.00,12000.00,12939.60,10330.04,10000.00,500000.00,550000.00,18000.00,125289.64,626479.64,501190.00,47779.64,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,103.92,2343.18,2147.44,-0.0912
+8,700.00,200.00,5010.00,990.00,19500.00,12000.00,12933.60,10330.04,10000.00,500000.00,550000.00,18000.00,125283.64,626473.64,501190.00,47773.64,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2147.44,-0.0912
+7,700.00,200.00,5010.00,990.00,19500.00,12000.00,12928.80,10330.04,10000.00,500000.00,550000.00,18000.00,125278.84,626468.84,501190.00,47768.84,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2147.44,-0.0912
+6,700.00,200.00,5010.00,990.00,19500.00,12000.00,12906.00,10330.04,10000.00,500000.00,550000.00,18000.00,125256.04,626446.04,501190.00,47746.04,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2147.44,-0.0912
+5,700.00,200.00,5000.00,1000.00,19700.00,12000.00,12891.60,10330.04,10000.00,500000.00,550000.00,18000.00,125421.64,626621.64,501200.00,47921.64,10000.00,500000.00,1000.00,550000.00,18000.00,700.00,200.00,20.00,200.00,2363.18,2347.44,-0.0067
+4,550.00,200.00,5000.00,1000.00,19700.00,12000.00,12945.60,9467.00,10000.00,500000.00,550000.00,18000.00,124462.60,625662.60,501200.00,47112.60,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,863.04,0.00,3226.22,2347.44,-0.3744
+3,550.00,200.00,5000.00,1000.00,19700.00,12000.00,13046.40,9467.00,10000.00,500000.00,550000.00,18000.00,124563.40,625763.40,501200.00,47213.40,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,0.00,0.00,3226.22,2347.44,-0.3744
+2,550.00,200.00,5000.00,1000.00,19700.00,12000.00,12982.80,9467.00,10000.00,500000.00,550000.00,18000.00,124499.80,625699.80,501200.00,47149.80,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,0.00,0.00,3226.22,2347.44,-0.3744
+1,550.00,200.00,5000.00,1000.00,19700.00,12000.00,13014.00,9467.00,10000.00,500000.00,550000.00,18000.00,124531.00,625731.00,501200.00,47181.00,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,0.00,0.00,3226.22,2347.44,-0.3744
+0,550.00,200.00,5000.00,1000.00,19700.00,12000.00,13000.80,9467.00,10000.00,500000.00,550000.00,18000.00,124517.80,625717.80,501200.00,47167.80,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,0.00,0.00,3226.22,2347.44,-0.3744
\ No newline at end of file
diff --git a/test/fixtures/transactions.yml b/test/fixtures/transactions.yml
index 410a4265..89576991 100644
--- a/test/fixtures/transactions.yml
+++ b/test/fixtures/transactions.yml
@@ -39,12 +39,46 @@ checking_five:
currency: USD
merchant: netflix
-# Savings account that has these transactions and valuation overrides
+checking_six_payment:
+ name: Payment to Credit Card
+ date: <%= 29.days.ago.to_date %>
+ amount: 100
+ account: checking
+ currency: USD
+ marked_as_transfer: true
+ transfer: credit_card_payment
+
+checking_seven_transfer:
+ name: Transfer to Savings
+ date: <%= 30.days.ago.to_date %>
+ amount: 250
+ account: checking
+ currency: USD
+ marked_as_transfer: true
+ transfer: savings_transfer
+
+checking_eight_external_payment:
+ name: Transfer TO external CC account (owned by user but not known to app)
+ date: <%= 30.days.ago.to_date %>
+ amount: 800
+ account: checking
+ currency: USD
+ marked_as_transfer: true
+
+checking_nine_external_transfer:
+ name: Transfer FROM external investing account (owned by user but not known to app)
+ date: <%= 30.days.ago.to_date %>
+ amount: -200
+ account: checking
+ currency: USD
+ marked_as_transfer: true
+
+# Savings account that has transactions and valuation overrides
savings_one:
name: Interest Received
date: <%= 5.days.ago.to_date %>
amount: -200
- account: savings_with_valuation_overrides
+ account: savings
category: income
currency: USD
@@ -52,7 +86,7 @@ savings_two:
name: Check Deposit
date: <%= 12.days.ago.to_date %>
amount: -50
- account: savings_with_valuation_overrides
+ account: savings
category: income
currency: USD
@@ -60,17 +94,26 @@ savings_three:
name: Withdrawal
date: <%= 18.days.ago.to_date %>
amount: 2000
- account: savings_with_valuation_overrides
+ account: savings
currency: USD
savings_four:
name: Check Deposit
date: <%= 29.days.ago.to_date %>
amount: -500
- account: savings_with_valuation_overrides
+ account: savings
category: income
currency: USD
+savings_five_transfer:
+ name: Received Transfer from Checking Account
+ date: <%= 30.days.ago.to_date %>
+ amount: -250
+ account: savings
+ currency: USD
+ marked_as_transfer: true
+ transfer: savings_transfer
+
# Credit card account transactions
credit_card_one:
name: Starbucks
@@ -96,12 +139,14 @@ credit_card_three:
currency: USD
merchant: amazon
-credit_card_four:
- name: CC Payment
- date: <%= 29.days.ago.to_date %>
+credit_card_four_payment:
+ name: Received CC Payment from Checking Account
+ date: <%= 30.days.ago.to_date %>
amount: -100
account: credit_card
currency: USD
+ marked_as_transfer: true
+ transfer: credit_card_payment
# eur_checking transactions
eur_checking_one:
@@ -120,7 +165,7 @@ eur_checking_two:
eur_checking_three:
name: Check
- date: <%= 29.days.ago.to_date %>
+ date: <%= 30.days.ago.to_date %>
amount: -200
currency: EUR
account: eur_checking
@@ -143,7 +188,7 @@ multi_currency_two:
multi_currency_three:
name: Outflow 2
date: <%= 19.days.ago.to_date %>
- amount: 100
+ amount: 110.85
currency: EUR
account: multi_currency
diff --git a/test/fixtures/transfers.yml b/test/fixtures/transfers.yml
new file mode 100644
index 00000000..3c723a8e
--- /dev/null
+++ b/test/fixtures/transfers.yml
@@ -0,0 +1,2 @@
+credit_card_payment: { }
+savings_transfer: { }
diff --git a/test/fixtures/valuations.yml b/test/fixtures/valuations.yml
index eef16227..51bb3374 100644
--- a/test/fixtures/valuations.yml
+++ b/test/fixtures/valuations.yml
@@ -11,21 +11,45 @@ collectable_two:
collectable_three:
value: 400
- date: <%= 30.days.ago.to_date %>
+ date: <%= 31.days.ago.to_date %>
account: collectable
-# For checking account that has valuations and transactions
-savings_one:
- value: 20500
- date: <%= 3.days.ago.to_date %>
- account: savings_with_valuation_overrides
+iou_one:
+ value: 200
+ date: <%= 31.days.ago.to_date %>
+ account: iou
-savings_two:
+multi_currency_one:
+ value: 10200
+ date: <%= 31.days.ago.to_date %>
+ account: multi_currency
+
+savings_one:
value: 19500
date: <%= 12.days.ago.to_date %>
- account: savings_with_valuation_overrides
+ account: savings
-savings_three:
+savings_two:
value: 21000
date: <%= 25.days.ago.to_date %>
- account: savings_with_valuation_overrides
+ account: savings
+
+brokerage_one:
+ value: 10000
+ date: <%= 31.days.ago.to_date %>
+ account: brokerage
+
+mortgage_loan_one:
+ value: 500000
+ date: <%= 31.days.ago.to_date %>
+ account: mortgage_loan
+
+house_one:
+ value: 550000
+ date: <%= 31.days.ago.to_date %>
+ account: house
+
+car_one:
+ value: 18000
+ date: <%= 31.days.ago.to_date %>
+ account: car
\ No newline at end of file
diff --git a/test/models/account/balance/calculator_test.rb b/test/models/account/balance/calculator_test.rb
index 05a9abaf..19ad1bf9 100644
--- a/test/models/account/balance/calculator_test.rb
+++ b/test/models/account/balance/calculator_test.rb
@@ -2,111 +2,82 @@ require "test_helper"
require "csv"
class Account::Balance::CalculatorTest < ActiveSupport::TestCase
- # See: https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
- setup do
- @expected_balances = CSV.read("test/fixtures/account/expected_balances.csv", headers: true).map do |row|
- {
- "date" => (Date.current + row["date_offset"].to_i.days).to_date,
- "collectable" => row["collectable"],
- "checking" => row["checking"],
- "savings_with_valuation_overrides" => row["savings_with_valuation_overrides"],
- "credit_card" => row["credit_card"],
- "multi_currency" => row["multi_currency"],
+ include FamilySnapshotTestHelper
- # Balances should be calculated for all currencies of an account
- "eur_checking_eur" => row["eur_checking_eur"],
- "eur_checking_usd" => row["eur_checking_usd"]
- }
+ test "syncs other asset balances" do
+ expected_balances = get_expected_balances_for(:collectable)
+ assert_account_balances calculated_balances_for(:collectable), expected_balances
+ end
+
+ test "syncs other liability balances" do
+ expected_balances = get_expected_balances_for(:iou)
+ assert_account_balances calculated_balances_for(:iou), expected_balances
+ end
+
+ test "syncs credit balances" do
+ expected_balances = get_expected_balances_for :credit_card
+ assert_account_balances calculated_balances_for(:credit_card), expected_balances
+ end
+
+ test "syncs checking account balances" do
+ expected_balances = get_expected_balances_for(:checking)
+ assert_account_balances calculated_balances_for(:checking), expected_balances
+ end
+
+ test "syncs foreign checking account balances" do
+ # Foreign accounts will generate balances for all currencies
+ expected_usd_balances = get_expected_balances_for(:eur_checking_usd)
+ expected_eur_balances = get_expected_balances_for(:eur_checking_eur)
+
+ calculated_balances = calculated_balances_for(:eur_checking)
+ calculated_usd_balances = calculated_balances.select { |b| b[:currency] == "USD" }
+ calculated_eur_balances = calculated_balances.select { |b| b[:currency] == "EUR" }
+
+ assert_account_balances calculated_usd_balances, expected_usd_balances
+ assert_account_balances calculated_eur_balances, expected_eur_balances
+ end
+
+ test "syncs multi-currency checking account balances" do
+ expected_balances = get_expected_balances_for(:multi_currency)
+ assert_account_balances calculated_balances_for(:multi_currency), expected_balances
+ end
+
+ test "syncs savings accounts balances" do
+ expected_balances = get_expected_balances_for(:savings)
+ assert_account_balances calculated_balances_for(:savings), expected_balances
+ end
+
+ test "syncs investment account balances" do
+ expected_balances = get_expected_balances_for(:brokerage)
+ assert_account_balances calculated_balances_for(:brokerage), expected_balances
+ end
+
+ test "syncs loan account balances" do
+ expected_balances = get_expected_balances_for(:mortgage_loan)
+ assert_account_balances calculated_balances_for(:mortgage_loan), expected_balances
+ end
+
+ test "syncs property account balances" do
+ expected_balances = get_expected_balances_for(:house)
+ assert_account_balances calculated_balances_for(:house), expected_balances
+ end
+
+ test "syncs vehicle account balances" do
+ expected_balances = get_expected_balances_for(:car)
+ assert_account_balances calculated_balances_for(:car), expected_balances
+ end
+
+ private
+ def assert_account_balances(actual_balances, expected_balances)
+ assert_equal expected_balances.count, actual_balances.count
+
+ actual_balances.each do |ab|
+ expected_balance = expected_balances.find { |eb| eb[:date] == ab[:date] }
+ assert_in_delta expected_balance[:balance], ab[:balance], 0.01, "Balance incorrect on date: #{ab[:date]}"
+ end
end
- end
- test "syncs account with only valuations" do
- account = accounts(:collectable)
-
- calculator = Account::Balance::Calculator.new(account)
- calculator.calculate
-
- expected = @expected_balances.map { |row| row["collectable"].to_d }
- actual = calculator.daily_balances.map { |b| b[:balance] }
-
- assert_equal expected, actual
- end
-
- test "syncs account with only transactions" do
- account = accounts(:checking)
-
- calculator = Account::Balance::Calculator.new(account)
- calculator.calculate
-
- expected = @expected_balances.map { |row| row["checking"].to_d }
- actual = calculator.daily_balances.map { |b| b[:balance] }
-
- assert_equal expected, actual
- end
-
- test "syncs account with both valuations and transactions" do
- account = accounts(:savings_with_valuation_overrides)
-
- calculator = Account::Balance::Calculator.new(account)
- calculator.calculate
-
- expected = @expected_balances.map { |row| row["savings_with_valuation_overrides"].to_d }
- actual = calculator.daily_balances.map { |b| b[:balance] }
-
- assert_equal expected, actual
- end
-
- test "syncs liability account" do
- account = accounts(:credit_card)
-
- calculator = Account::Balance::Calculator.new(account)
- calculator.calculate
-
- expected = @expected_balances.map { |row| row["credit_card"].to_d }
- actual = calculator.daily_balances.map { |b| b[:balance] }
-
- assert_equal expected, actual
- end
-
- test "syncs foreign currency account" do
- account = accounts(:eur_checking)
- calculator = Account::Balance::Calculator.new(account)
- calculator.calculate
-
- # Calculator should calculate balances in both account and family currency
- expected_eur_balances = @expected_balances.map { |row| row["eur_checking_eur"].to_d }
- expected_usd_balances = @expected_balances.map { |row| row["eur_checking_usd"].to_d }
-
- actual_eur_balances = calculator.daily_balances.select { |b| b[:currency] == "EUR" }.sort_by { |b| b[:date] }.map { |b| b[:balance] }
- actual_usd_balances = calculator.daily_balances.select { |b| b[:currency] == "USD" }.sort_by { |b| b[:date] }.map { |b| b[:balance] }
-
- assert_equal expected_eur_balances, actual_eur_balances
- assert_equal expected_usd_balances, actual_usd_balances
- end
-
- test "syncs multi currency account" do
- account = accounts(:multi_currency)
- calculator = Account::Balance::Calculator.new(account)
- calculator.calculate
-
- expected_balances = @expected_balances.map { |row| row["multi_currency"].to_d }
-
- actual_balances = calculator.daily_balances.map { |b| b[:balance] }
-
- assert_equal expected_balances, actual_balances
- end
-
- test "syncs with overridden start date" do
- account = accounts(:multi_currency)
- account.sync
- calc_start_date = 10.days.ago.to_date
- calculator = Account::Balance::Calculator.new(account, { calc_start_date: })
- calculator.calculate
-
- expected_balances = @expected_balances.filter { |row| row["date"] >= calc_start_date }.map { |row| row["multi_currency"].to_d }
-
- actual_balances = calculator.daily_balances.map { |b| b[:balance] }
-
- assert_equal expected_balances, actual_balances
- end
+ def calculated_balances_for(account_key)
+ Account::Balance::Calculator.new(accounts(account_key)).calculate.daily_balances
+ end
end
diff --git a/test/models/account/syncable_test.rb b/test/models/account/syncable_test.rb
index cb9d2655..e6afdf5f 100644
--- a/test/models/account/syncable_test.rb
+++ b/test/models/account/syncable_test.rb
@@ -4,7 +4,7 @@ class Account::SyncableTest < ActiveSupport::TestCase
include ActiveJob::TestHelper
setup do
- @account = accounts(:savings_with_valuation_overrides)
+ @account = accounts(:savings)
end
test "triggers sync job" do
@@ -14,27 +14,27 @@ class Account::SyncableTest < ActiveSupport::TestCase
end
test "account has no balances until synced" do
- account = accounts(:savings_with_valuation_overrides)
+ account = accounts(:savings)
assert_equal 0, account.balances.count
end
test "account has balances after syncing" do
- account = accounts(:savings_with_valuation_overrides)
+ account = accounts(:savings)
account.sync
- assert_equal 31, account.balances.count
+ assert_equal 32, account.balances.count
end
test "partial sync with missing historical balances performs a full sync" do
- account = accounts(:savings_with_valuation_overrides)
+ account = accounts(:savings)
account.sync 10.days.ago.to_date
- assert_equal 31, account.balances.count
+ assert_equal 32, account.balances.count
end
test "balances are updated after syncing" do
- account = accounts(:savings_with_valuation_overrides)
+ account = accounts(:savings)
balance_date = 10.days.ago
account.balances.create!(date: balance_date, balance: 1000)
account.sync
@@ -43,7 +43,7 @@ class Account::SyncableTest < ActiveSupport::TestCase
end
test "balances before sync start date are not updated after syncing" do
- account = accounts(:savings_with_valuation_overrides)
+ account = accounts(:savings)
balance_date = 10.days.ago
account.balances.create!(date: balance_date, balance: 1000)
account.sync 5.days.ago.to_date
@@ -52,7 +52,7 @@ class Account::SyncableTest < ActiveSupport::TestCase
end
test "balances after sync start date are updated after syncing" do
- account = accounts(:savings_with_valuation_overrides)
+ account = accounts(:savings)
balance_date = 10.days.ago
account.balances.create!(date: balance_date, balance: 1000)
account.sync 20.days.ago.to_date
@@ -61,7 +61,7 @@ class Account::SyncableTest < ActiveSupport::TestCase
end
test "balance on the sync date is updated after syncing" do
- account = accounts(:savings_with_valuation_overrides)
+ account = accounts(:savings)
balance_date = 5.days.ago
account.balances.create!(date: balance_date, balance: 1000)
account.sync balance_date.to_date
@@ -73,13 +73,13 @@ class Account::SyncableTest < ActiveSupport::TestCase
account = accounts(:eur_checking)
account.sync
- assert_equal 62, account.balances.count
- assert_equal 31, account.balances.where(currency: "EUR").count
- assert_equal 31, account.balances.where(currency: "USD").count
+ assert_equal 64, account.balances.count
+ assert_equal 32, account.balances.where(currency: "EUR").count
+ assert_equal 32, account.balances.where(currency: "USD").count
end
test "stale balances are purged after syncing" do
- account = accounts(:savings_with_valuation_overrides)
+ account = accounts(:savings)
# Create old, stale balances that should be purged (since they are before account start date)
account.balances.create!(date: 1.year.ago, balance: 1000)
@@ -88,14 +88,6 @@ class Account::SyncableTest < ActiveSupport::TestCase
account.sync
- assert_equal 31, account.balances.count
- end
-
- test "account balance is updated after sync" do
- account = accounts(:savings_with_valuation_overrides)
-
- assert_changes -> { account.balance }, to: 20500 do
- account.sync
- end
+ assert_equal 32, account.balances.count
end
end
diff --git a/test/models/account_test.rb b/test/models/account_test.rb
index 949335c6..f0894354 100644
--- a/test/models/account_test.rb
+++ b/test/models/account_test.rb
@@ -5,16 +5,6 @@ class AccountTest < ActiveSupport::TestCase
def setup
@account = accounts(:checking)
@family = families(:dylan_family)
- @snapshots = CSV.read("test/fixtures/family/expected_snapshots.csv", headers: true).map do |row|
- {
- "date" => (Date.current + row["date_offset"].to_i.days).to_date,
- "assets" => row["assets"],
- "liabilities" => row["liabilities"],
- "Account::Depository" => row["depositories"],
- "Account::Credit" => row["credits"],
- "Account::OtherAsset" => row["other_assets"]
- }
- end
end
test "new account should be valid" do
@@ -47,26 +37,23 @@ class AccountTest < ActiveSupport::TestCase
test "syncs regular account" do
@account.sync
assert_equal "ok", @account.status
- assert_equal 31, @account.balances.count
+ assert_equal 32, @account.balances.count
end
test "syncs foreign currency account" do
account = accounts(:eur_checking)
account.sync
assert_equal "ok", account.status
- assert_equal 31, account.balances.where(currency: "USD").count
- assert_equal 31, account.balances.where(currency: "EUR").count
+ assert_equal 32, account.balances.where(currency: "USD").count
+ assert_equal 32, account.balances.where(currency: "EUR").count
end
+
test "groups accounts by type" do
@family.accounts.each do |account|
account.sync
end
result = @family.accounts.by_group(period: Period.all)
-
- expected_assets = @snapshots.last["assets"].to_d
- expected_liabilities = @snapshots.last["liabilities"].to_d
-
assets = result[:assets]
liabilities = result[:liabilities]
@@ -84,14 +71,14 @@ class AccountTest < ActiveSupport::TestCase
other_liabilities = liabilities.children.find { |group| group.name == "Account::OtherLiability" }
assert_equal 4, depositories.children.count
- assert_equal 0, properties.children.count
- assert_equal 0, vehicles.children.count
- assert_equal 0, investments.children.count
+ assert_equal 1, properties.children.count
+ assert_equal 1, vehicles.children.count
+ assert_equal 1, investments.children.count
assert_equal 1, other_assets.children.count
assert_equal 1, credits.children.count
- assert_equal 0, loans.children.count
- assert_equal 0, other_liabilities.children.count
+ assert_equal 1, loans.children.count
+ assert_equal 1, other_liabilities.children.count
end
test "generates series with last balance equal to current account balance" do
diff --git a/test/models/family_test.rb b/test/models/family_test.rb
index ba57279b..fc9ff284 100644
--- a/test/models/family_test.rb
+++ b/test/models/family_test.rb
@@ -2,26 +2,13 @@ require "test_helper"
require "csv"
class FamilyTest < ActiveSupport::TestCase
+ include FamilySnapshotTestHelper
+
def setup
@family = families(:dylan_family)
-
@family.accounts.each do |account|
account.sync
end
-
- # See this Google Sheet for calculations and expected results for dylan_family:
- # https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
- @expected_snapshots = CSV.read("test/fixtures/family/expected_snapshots.csv", headers: true).map do |row|
- {
- "date" => (Date.current + row["date_offset"].to_i.days).to_date,
- "net_worth" => row["net_worth"],
- "assets" => row["assets"],
- "liabilities" => row["liabilities"],
- "rolling_spend" => row["rolling_spend"],
- "rolling_income" => row["rolling_income"],
- "savings_rate" => row["savings_rate"]
- }
- end
end
test "should have many users" do
@@ -58,57 +45,62 @@ class FamilyTest < ActiveSupport::TestCase
end
test "should calculate total assets" do
- expected = @expected_snapshots.last["assets"].to_d
- assert_equal Money.new(expected), @family.assets
+ expected = get_today_snapshot_value_for :assets
+ assert_in_delta expected, @family.assets.amount, 0.01
end
test "should calculate total liabilities" do
- expected = @expected_snapshots.last["liabilities"].to_d
- assert_equal Money.new(expected), @family.liabilities
+ expected = get_today_snapshot_value_for :liabilities
+ assert_in_delta expected, @family.liabilities.amount, 0.01
end
test "should calculate net worth" do
- expected = @expected_snapshots.last["net_worth"].to_d
- assert_equal Money.new(expected), @family.net_worth
+ expected = get_today_snapshot_value_for :net_worth
+ assert_in_delta expected, @family.net_worth.amount, 0.01
end
- test "should calculate snapshot correctly" do
- asset_series = @family.snapshot[:asset_series]
- liability_series = @family.snapshot[:liability_series]
- net_worth_series = @family.snapshot[:net_worth_series]
+ test "calculates asset time series" do
+ series = @family.snapshot[:asset_series]
+ expected_series = get_expected_balances_for :assets
- assert_equal @expected_snapshots.count, asset_series.values.count
- assert_equal @expected_snapshots.count, liability_series.values.count
- assert_equal @expected_snapshots.count, net_worth_series.values.count
-
- @expected_snapshots.each_with_index do |row, index|
- expected_assets = TimeSeries::Value.new(date: row["date"], value: Money.new(row["assets"].to_d))
- expected_liabilities = TimeSeries::Value.new(date: row["date"], value: Money.new(row["liabilities"].to_d))
- expected_net_worth = TimeSeries::Value.new(date: row["date"], value: Money.new(row["net_worth"].to_d))
-
- assert_in_delta expected_assets.value.amount, Money.new(asset_series.values[index].value).amount, 0.01
- assert_in_delta expected_liabilities.value.amount, Money.new(liability_series.values[index].value).amount, 0.01
- assert_in_delta expected_net_worth.value.amount, Money.new(net_worth_series.values[index].value).amount, 0.01
- end
+ assert_time_series_balances series, expected_series
end
- test "should calculate transaction snapshot correctly" do
- spending_series = @family.snapshot_transactions[:spending_series]
- income_series = @family.snapshot_transactions[:income_series]
- savings_rate_series = @family.snapshot_transactions[:savings_rate_series]
+ test "calculates liability time series" do
+ series = @family.snapshot[:liability_series]
+ expected_series = get_expected_balances_for :liabilities
- assert_equal @expected_snapshots.count, spending_series.values.count
- assert_equal @expected_snapshots.count, income_series.values.count
- assert_equal @expected_snapshots.count, savings_rate_series.values.count
+ assert_time_series_balances series, expected_series
+ end
- @expected_snapshots.each_with_index do |row, index|
- expected_spending = TimeSeries::Value.new(date: row["date"], value: Money.new(row["rolling_spend"].to_d))
- expected_income = TimeSeries::Value.new(date: row["date"], value: Money.new(row["rolling_income"].to_d))
- expected_savings_rate = TimeSeries::Value.new(date: row["date"], value: Money.new(row["savings_rate"].to_d))
+ test "calculates net worth time series" do
+ series = @family.snapshot[:net_worth_series]
+ expected_series = get_expected_balances_for :net_worth
- assert_in_delta expected_spending.value.amount, Money.new(spending_series.values[index].value).amount, 0.01
- assert_in_delta expected_income.value.amount, Money.new(income_series.values[index].value).amount, 0.01
- assert_in_delta expected_savings_rate.value.amount, savings_rate_series.values[index].value, 0.01
+ assert_time_series_balances series, expected_series
+ end
+
+ test "calculates rolling expenses" do
+ series = @family.snapshot_transactions[:spending_series]
+ expected_series = get_expected_balances_for :rolling_spend
+
+ assert_time_series_balances series, expected_series, ignore_count: true
+ end
+
+ test "calculates rolling income" do
+ series = @family.snapshot_transactions[:income_series]
+ expected_series = get_expected_balances_for :rolling_income
+
+ assert_time_series_balances series, expected_series, ignore_count: true
+ end
+
+ test "calculates savings rate series" do
+ series = @family.snapshot_transactions[:savings_rate_series]
+ expected_series = get_expected_balances_for :savings_rate
+
+ series.values.each do |tsb|
+ expected_balance = expected_series.find { |eb| eb[:date] == tsb.date }
+ assert_in_delta expected_balance[:balance], tsb.value, 0.0001, "Balance incorrect on date: #{tsb.date}"
end
end
@@ -127,4 +119,15 @@ class FamilyTest < ActiveSupport::TestCase
assert_equal liabilities_before - disabled_cc.balance, @family.liabilities
assert_equal net_worth_before - disabled_checking.balance + disabled_cc.balance, @family.net_worth
end
+
+ private
+
+ def assert_time_series_balances(time_series_balances, expected_balances, ignore_count: false)
+ assert_equal time_series_balances.values.count, expected_balances.count unless ignore_count
+
+ time_series_balances.values.each do |tsb|
+ expected_balance = expected_balances.find { |eb| eb[:date] == tsb.date }
+ assert_in_delta expected_balance[:balance], tsb.value.amount, 0.01, "Balance incorrect on date: #{tsb.date}"
+ end
+ end
end
diff --git a/test/models/transaction_test.rb b/test/models/transaction_test.rb
index 5e21f065..141b294c 100644
--- a/test/models/transaction_test.rb
+++ b/test/models/transaction_test.rb
@@ -3,6 +3,7 @@ require "test_helper"
class TransactionTest < ActiveSupport::TestCase
setup do
@transaction = transactions(:checking_one)
+ @family = families(:dylan_family)
end
# See: https://github.com/maybe-finance/maybe/wiki/vision#signage-of-money
@@ -41,4 +42,14 @@ class TransactionTest < ActiveSupport::TestCase
current_transaction.account.expects(:sync_later).with(prior_transaction.date)
current_transaction.sync_account_later
end
+
+ test "can calculate total spending for a group of transactions" do
+ assert_equal Money.new(2135), @family.transactions.expense_total("USD")
+ assert_equal Money.new(1010.85, "EUR"), @family.transactions.expense_total("EUR")
+ end
+
+ test "can calculate total income for a group of transactions" do
+ assert_equal -Money.new(2075), @family.transactions.income_total("USD")
+ assert_equal -Money.new(250, "EUR"), @family.transactions.income_total("EUR")
+ end
end
diff --git a/test/models/transfer_test.rb b/test/models/transfer_test.rb
new file mode 100644
index 00000000..2d1b26cd
--- /dev/null
+++ b/test/models/transfer_test.rb
@@ -0,0 +1,49 @@
+require "test_helper"
+
+class TransferTest < ActiveSupport::TestCase
+ setup do
+ # Transfers can be posted on different dates
+ @outflow = accounts(:checking).transactions.create! date: 1.day.ago.to_date, name: "Transfer to Savings", amount: 100, marked_as_transfer: true
+ @inflow = accounts(:savings).transactions.create! date: Date.current, name: "Transfer from Savings", amount: -100, marked_as_transfer: true
+ end
+
+ test "transfer valid if it has inflow and outflow from different accounts for the same amount" do
+ transfer = Transfer.create! transactions: [ @inflow, @outflow ]
+
+ assert transfer.valid?
+ end
+
+ test "transfer must have 2 transactions" do
+ invalid_transfer_1 = Transfer.new transactions: [ @outflow ]
+ invalid_transfer_2 = Transfer.new transactions: [ @inflow, @outflow, transactions(:savings_four) ]
+
+ assert invalid_transfer_1.invalid?
+ assert invalid_transfer_2.invalid?
+ end
+
+ test "transfer cannot have 2 transactions from the same account" do
+ account = accounts(:checking)
+ inflow = account.transactions.create! date: Date.current, name: "Inflow", amount: -100
+ outflow = account.transactions.create! date: Date.current, name: "Outflow", amount: 100
+
+ assert_raise ActiveRecord::RecordInvalid do
+ Transfer.create! transactions: [ inflow, outflow ]
+ end
+ end
+
+ test "all transfer transactions must be marked as transfers" do
+ @inflow.update! marked_as_transfer: false
+
+ assert_raise ActiveRecord::RecordInvalid do
+ Transfer.create! transactions: [ @inflow, @outflow ]
+ end
+ end
+
+ test "transfer transactions must net to zero" do
+ @outflow.update! amount: 105
+
+ assert_raises ActiveRecord::RecordInvalid do
+ Transfer.create! transactions: [ @inflow, @outflow ]
+ end
+ end
+end
diff --git a/test/models/value_group_test.rb b/test/models/value_group_test.rb
index c571e78d..3b0c676d 100644
--- a/test/models/value_group_test.rb
+++ b/test/models/value_group_test.rb
@@ -3,7 +3,7 @@ require "ostruct"
class ValueGroupTest < ActiveSupport::TestCase
setup do
checking = accounts(:checking)
- savings = accounts(:savings_with_valuation_overrides)
+ savings = accounts(:savings)
collectable = accounts(:collectable)
# Level 1
diff --git a/test/support/family_snapshot_test_helper.rb b/test/support/family_snapshot_test_helper.rb
new file mode 100644
index 00000000..f691f34f
--- /dev/null
+++ b/test/support/family_snapshot_test_helper.rb
@@ -0,0 +1,21 @@
+module FamilySnapshotTestHelper
+ # See: https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
+ def get_expected_balances_for(key)
+ expected_results_file.map do |row|
+ {
+ date: (Date.current - row["date_offset"].to_i.days).to_date,
+ balance: row[key.to_s].to_d
+ }
+ end
+ end
+
+ def get_today_snapshot_value_for(metric)
+ expected_results_file[-1][metric.to_s].to_d
+ end
+
+ private
+
+ def expected_results_file
+ CSV.read("test/fixtures/files/expected_family_snapshots.csv", headers: true)
+ end
+end
diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb
index fa3d8c89..3dd2d585 100644
--- a/test/system/transactions_test.rb
+++ b/test/system/transactions_test.rb
@@ -134,7 +134,7 @@ class TransactionsTest < ApplicationSystemTestCase
def number_of_transactions_on_page
page_size = 50
- [ @user.family.transactions.count, page_size ].min
+ [ @user.family.transactions.where(transfer_id: nil).count, page_size ].min
end
def all_transactions_checkbox