From ca39b260704b97abb1297633b6b36bdfca5b9a10 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 19 Jun 2024 06:52:08 -0400 Subject: [PATCH] Transaction transfers, payments, and matching (#883) * Add transfer model and clean up family snapshot fixtures * Ignore transfers in income and expense snapshots * Add transfer validations * Implement basic transfer matching UI * Fix merge conflicts * Add missing translations * Tweak selection states for transfer types * Add missing i18n translation --- .../stylesheets/application.tailwind.css | 4 + app/controllers/transactions_controller.rb | 28 ++- app/controllers/transfers_controller.rb | 41 ++++ app/helpers/application_form_builder.rb | 2 +- app/helpers/transactions_helper.rb | 23 +++ app/helpers/transfers_helper.rb | 2 + .../controllers/bulk_select_controller.js | 2 +- app/models/family.rb | 39 ++-- app/models/transaction.rb | 25 ++- app/models/transfer.rb | 57 ++++++ .../transactions/_transaction.html.erb | 9 +- app/views/transactions/_date_group.html.erb | 19 +- app/views/transactions/_form.html.erb | 13 +- app/views/transactions/_name.html.erb | 2 +- .../transactions/_selection_bar.html.erb | 15 ++ app/views/transactions/_transaction.html.erb | 39 +++- app/views/transactions/index.html.erb | 4 +- app/views/transactions/show.html.erb | 64 +++---- app/views/transfers/_form.html.erb | 32 ++++ app/views/transfers/_transfer.html.erb | 37 ++++ app/views/transfers/new.html.erb | 17 ++ config/locales/views/transactions/en.yml | 14 +- config/locales/views/transfers/en.yml | 27 +++ config/routes.rb | 4 + db/migrate/20240614120946_create_transfers.rb | 7 + ...1110_add_transfer_fields_to_transaction.rb | 8 + db/schema.rb | 13 +- .../transactions_controller_test.rb | 6 +- test/controllers/transfers_controller_test.rb | 33 ++++ test/fixtures/account/credits.yml | 3 +- test/fixtures/account/cryptos.yml | 12 +- test/fixtures/account/depositories.yml | 12 +- test/fixtures/account/expected_balances.csv | 32 ---- test/fixtures/account/investments.yml | 1 + test/fixtures/account/loans.yml | 1 + test/fixtures/account/other_assets.yml | 5 +- test/fixtures/account/other_liabilities.yml | 1 + test/fixtures/account/properties.yml | 1 + test/fixtures/account/vehicles.yml | 1 + test/fixtures/account_balances.yml | 13 -- test/fixtures/accounts.yml | 61 ++++-- test/fixtures/exchange_rates.yml | 73 +++++++ test/fixtures/family/expected_snapshots.csv | 32 ---- test/fixtures/files/.keep | 0 .../files/expected_family_snapshots.csv | 33 ++++ test/fixtures/transactions.yml | 65 ++++++- test/fixtures/transfers.yml | 2 + test/fixtures/valuations.yml | 44 ++++- .../models/account/balance/calculator_test.rb | 179 ++++++++---------- test/models/account/syncable_test.rb | 38 ++-- test/models/account_test.rb | 31 +-- test/models/family_test.rb | 107 ++++++----- test/models/transaction_test.rb | 11 ++ test/models/transfer_test.rb | 49 +++++ test/models/value_group_test.rb | 2 +- test/support/family_snapshot_test_helper.rb | 21 ++ test/system/transactions_test.rb | 2 +- 57 files changed, 991 insertions(+), 427 deletions(-) create mode 100644 app/controllers/transfers_controller.rb create mode 100644 app/helpers/transfers_helper.rb create mode 100644 app/models/transfer.rb create mode 100644 app/views/transfers/_form.html.erb create mode 100644 app/views/transfers/_transfer.html.erb create mode 100644 app/views/transfers/new.html.erb create mode 100644 config/locales/views/transfers/en.yml create mode 100644 db/migrate/20240614120946_create_transfers.rb create mode 100644 db/migrate/20240614121110_add_transfer_fields_to_transaction.rb create mode 100644 test/controllers/transfers_controller_test.rb delete mode 100644 test/fixtures/account/expected_balances.csv delete mode 100644 test/fixtures/account_balances.yml delete mode 100644 test/fixtures/family/expected_snapshots.csv delete mode 100644 test/fixtures/files/.keep create mode 100644 test/fixtures/files/expected_family_snapshots.csv create mode 100644 test/fixtures/transfers.yml create mode 100644 test/models/transfer_test.rb create mode 100644 test/support/family_snapshot_test_helper.rb diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 2ffebf12..00fb5d95 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -7,6 +7,10 @@ details > summary::-webkit-details-marker { @apply hidden; } + + details > summary { + @apply list-none; + } } @layer components { diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index bc2a9592..0c569d96 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -9,9 +9,9 @@ class TransactionsController < ApplicationController @pagy, @transactions = pagy(result, items: 50) @totals = { - count: result.count, - income: result.inflows.sum(&:amount_money).abs, - expense: result.outflows.sum(&:amount_money).abs + count: result.select { |t| t.currency == Current.family.currency }.count, + income: result.income_total(Current.family.currency).abs, + expense: result.expense_total(Current.family.currency) } end @@ -54,7 +54,7 @@ class TransactionsController < ApplicationController def bulk_delete destroyed = Current.family.transactions.destroy_by(id: bulk_delete_params[:transaction_ids]) - redirect_to transactions_url, notice: t(".success", count: destroyed.count) + redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count) end def bulk_edit @@ -63,13 +63,31 @@ class TransactionsController < ApplicationController def bulk_update transactions = Current.family.transactions.where(id: bulk_update_params[:transaction_ids]) if transactions.update_all(bulk_update_params.except(:transaction_ids).to_h.compact_blank!) - redirect_to transactions_url, notice: t(".success", count: transactions.count) + redirect_back_or_to transactions_url, notice: t(".success", count: transactions.count) else flash.now[:error] = t(".failure") render :index, status: :unprocessable_entity end end + def mark_transfers + Current.family + .transactions + .where(id: bulk_update_params[:transaction_ids]) + .mark_transfers! + + redirect_back_or_to transactions_url, notice: t(".success") + end + + def unmark_transfers + Current.family + .transactions + .where(id: bulk_update_params[:transaction_ids]) + .update_all marked_as_transfer: false + + redirect_back_or_to transactions_url, notice: t(".success") + end + private def set_transaction diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb new file mode 100644 index 00000000..6e231a88 --- /dev/null +++ b/app/controllers/transfers_controller.rb @@ -0,0 +1,41 @@ +class TransfersController < ApplicationController + layout "with_sidebar" + + before_action :set_transfer, only: :destroy + + def new + @transfer = Transfer.new + end + + def create + from_account = Current.family.accounts.find(transfer_params[:from_account_id]) + to_account = Current.family.accounts.find(transfer_params[:to_account_id]) + + @transfer = Transfer.build_from_accounts from_account, to_account, \ + date: transfer_params[:date], + amount: transfer_params[:amount].to_d, + currency: transfer_params[:currency], + name: transfer_params[:name] + + if @transfer.save + redirect_to transactions_path, notice: t(".success") + else + render :new, status: :unprocessable_entity + end + end + + def destroy + @transfer.destroy_and_remove_marks! + redirect_back_or_to transactions_url, notice: t(".success") + end + + private + + def set_transfer + @transfer = Transfer.find(params[:id]) + end + + def transfer_params + params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :currency, :date, :name) + end +end diff --git a/app/helpers/application_form_builder.rb b/app/helpers/application_form_builder.rb index dc3df84b..0e33c222 100644 --- a/app/helpers/application_form_builder.rb +++ b/app/helpers/application_form_builder.rb @@ -25,7 +25,7 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder # See `Monetizable` concern, which adds a _money suffix to the attribute name # For a monetized field, the setter will always be the attribute name without the _money suffix def money_field(method, options = {}) - money = @object.send(method) + money = @object && @object.respond_to?(method) ? @object.send(method) : nil raise ArgumentError, "The value of #{method} is not a Money object" unless money.is_a?(Money) || money.nil? money_amount_method = method.to_s.chomp("_money").to_sym diff --git a/app/helpers/transactions_helper.rb b/app/helpers/transactions_helper.rb index f5f4e9c4..67758978 100644 --- a/app/helpers/transactions_helper.rb +++ b/app/helpers/transactions_helper.rb @@ -17,4 +17,27 @@ module TransactionsHelper content: content } end + + def unconfirmed_transfer?(transaction) + transaction.marked_as_transfer && transaction.transfer.nil? + end + + def group_transactions_by_date(transactions) + grouped_by_date = {} + + transactions.each do |transaction| + if transaction.transfer + transfer_date = transaction.transfer.inflow_transaction.date + grouped_by_date[transfer_date] ||= { transactions: [], transfers: [] } + unless grouped_by_date[transfer_date][:transfers].include?(transaction.transfer) + grouped_by_date[transfer_date][:transfers] << transaction.transfer + end + else + grouped_by_date[transaction.date] ||= { transactions: [], transfers: [] } + grouped_by_date[transaction.date][:transactions] << transaction + end + end + + grouped_by_date + end end diff --git a/app/helpers/transfers_helper.rb b/app/helpers/transfers_helper.rb new file mode 100644 index 00000000..98355baf --- /dev/null +++ b/app/helpers/transfers_helper.rb @@ -0,0 +1,2 @@ +module TransfersHelper +end diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js index a1aff137..71a82149 100644 --- a/app/javascript/controllers/bulk_select_controller.js +++ b/app/javascript/controllers/bulk_select_controller.js @@ -113,7 +113,7 @@ export default class extends Controller { #updateGroups() { this.groupTargets.forEach(group => { const rows = this.rowTargets.filter(row => group.contains(row)) - const groupSelected = rows.every(row => this.selectedIdsValue.includes(row.dataset.id)) + const groupSelected = rows.length > 0 && rows.every(row => this.selectedIdsValue.includes(row.dataset.id)) group.querySelector("input[type='checkbox']").checked = groupSelected }) } diff --git a/app/models/family.rb b/app/models/family.rb index 3b644bac..d7ccafd9 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -10,16 +10,16 @@ class Family < ApplicationRecord def snapshot(period = Period.all) query = accounts.active.joins(:balances) - .where("account_balances.currency = ?", self.currency) - .select( - "account_balances.currency", - "account_balances.date", - "SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities", - "SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets", - "SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth", - ) - .group("account_balances.date, account_balances.currency") - .order("account_balances.date") + .where("account_balances.currency = ?", self.currency) + .select( + "account_balances.currency", + "account_balances.date", + "SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities", + "SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets", + "SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth", + ) + .group("account_balances.date, account_balances.currency") + .order("account_balances.date") query = query.where("account_balances.date >= ?", period.date_range.begin) if period.date_range.begin query = query.where("account_balances.date <= ?", period.date_range.end) if period.date_range.end @@ -35,15 +35,16 @@ class Family < ApplicationRecord def snapshot_account_transactions period = Period.last_30_days results = accounts.active.joins(:transactions) - .select( - "accounts.*", - "COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending", - "COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income" - ) - .where("transactions.date >= ?", period.date_range.begin) - .where("transactions.date <= ?", period.date_range.end) - .group("id") - .to_a + .select( + "accounts.*", + "COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending", + "COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income" + ) + .where("transactions.date >= ?", period.date_range.begin) + .where("transactions.date <= ?", period.date_range.end) + .where("transactions.marked_as_transfer = ?", false) + .group("id") + .to_a results.each do |r| r.define_singleton_method(:savings_rate) do diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 4ffdc137..1d6b304e 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -4,6 +4,7 @@ class Transaction < ApplicationRecord monetize :amount belongs_to :account + belongs_to :transfer, optional: true belongs_to :category, optional: true belongs_to :merchant, optional: true has_many :taggings, as: :taggable, dependent: :destroy @@ -42,6 +43,10 @@ class Transaction < ApplicationRecord amount > 0 end + def transfer? + marked_as_transfer + end + def sync_account_later if destroyed? sync_start_date = previous_transaction_date @@ -53,6 +58,21 @@ class Transaction < ApplicationRecord end class << self + def income_total(currency = "USD") + inflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money) + end + + def expense_total(currency = "USD") + outflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money) + end + + def mark_transfers! + update_all marked_as_transfer: true + + # Attempt to "auto match" and save a transfer if 2 transactions selected + Transfer.new(transactions: all).save if all.count == 2 + end + def daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency) # Sum spending and income for each day in the period with the given currency select( @@ -60,7 +80,7 @@ class Transaction < ApplicationRecord "COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending", "COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income" ) - .from(transactions.with_converted_amount(currency), :t) + .from(transactions.with_converted_amount(currency).where(marked_as_transfer: false), :t) .joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ])) .group("gs.date") end @@ -83,7 +103,7 @@ class Transaction < ApplicationRecord end def search(params) - query = all + query = all.includes(:transfer) query = query.by_name(params[:search]) if params[:search].present? query = query.with_categories(params[:categories]) if params[:categories].present? query = query.with_accounts(params[:accounts]) if params[:accounts].present? @@ -96,7 +116,6 @@ class Transaction < ApplicationRecord end private - def previous_transaction_date self.account .transactions diff --git a/app/models/transfer.rb b/app/models/transfer.rb new file mode 100644 index 00000000..bb3778fb --- /dev/null +++ b/app/models/transfer.rb @@ -0,0 +1,57 @@ +class Transfer < ApplicationRecord + has_many :transactions, dependent: :nullify + + validate :transaction_count, :from_different_accounts, :net_zero_flows, :all_transactions_marked + + def inflow_transaction + transactions.find { |t| t.inflow? } + end + + def outflow_transaction + transactions.find { |t| t.outflow? } + end + + def destroy_and_remove_marks! + transaction do + transactions.each do |t| + t.update! marked_as_transfer: false + end + + destroy! + end + end + + class << self + def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:) + outflow = from_account.transactions.build(amount: amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true) + inflow = to_account.transactions.build(amount: -amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true) + + new transactions: [ outflow, inflow ] + end + end + + private + + def transaction_count + unless transactions.size == 2 + errors.add :transactions, "must have exactly 2 transactions" + end + end + + def from_different_accounts + accounts = transactions.map(&:account_id).uniq + errors.add :transactions, "must be from different accounts" if accounts.size < transactions.size + end + + def net_zero_flows + unless transactions.sum(&:amount).zero? + errors.add :transactions, "must have an inflow and outflow that net to zero" + end + end + + def all_transactions_marked + unless transactions.all?(&:marked_as_transfer) + errors.add :transactions, "must be marked as transfer" + end + end +end diff --git a/app/views/accounts/transactions/_transaction.html.erb b/app/views/accounts/transactions/_transaction.html.erb index c117eb3b..5adc5817 100644 --- a/app/views/accounts/transactions/_transaction.html.erb +++ b/app/views/accounts/transactions/_transaction.html.erb @@ -4,7 +4,14 @@
- <%= render "transactions/categories/badge", category: transaction.category %> + <% if transaction.marked_as_transfer %> +
+ <%= lucide_icon "arrow-right-left", class: "w-4 h-4 text-gray-500" %> +

Transfer

+
+ <% else %> + <%= render "transactions/categories/badge", category: transaction.category %> + <% end %>
<%= link_to transaction.account.name, diff --git a/app/views/transactions/_date_group.html.erb b/app/views/transactions/_date_group.html.erb index c54d5d97..7ec9be4b 100644 --- a/app/views/transactions/_date_group.html.erb +++ b/app/views/transactions/_date_group.html.erb @@ -1,17 +1,26 @@ -<%# locals: (date:, transactions:) %> +<%# locals: (date:, group:) %>
<%= check_box_tag "#{date}_transactions_selection", - class: "maybe-checkbox maybe-checkbox--light", + class: ["maybe-checkbox maybe-checkbox--light", "hidden": group[:transactions].count == 0], id: "selection_transaction_#{date}", data: { action: "bulk-select#toggleGroupSelection" } %> - <%= tag.span "#{date.strftime('%b %d, %Y')} · #{transactions.size}" %> + + <%= tag.span "#{date.strftime('%b %d, %Y')} · #{group[:transactions].size + (group[:transfers].size * 2)}" %> +
+ +
+ <% transactions_by_currency = group[:transactions].group_by(&:currency) %> + <% transactions_by_currency.each_with_index do |(_currency, transactions), idx| %> + <%= tag.span format_money(-transactions.sum(&:amount_money)) %> + <%= tag.span "|", class: "mx-2" if idx < transactions_by_currency.count - 1 %> + <% end %>
- <%= tag.span format_money(-transactions.sum(&:amount_money)) %>
- <%= render transactions %> + <%= render group[:transactions] %> + <%= render group[:transfers] %>
diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index 1741b150..2cd52e18 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -1,9 +1,12 @@ -<%= form_with model: @transaction, data: { turbo: false } do |f| %> +<%= form_with model: @transaction, data: { turbo_frame: "_top" } do |f| %>
- <%= radio_tab_tag form: f, name: :nature, value: :expense, label: t(".expense"), icon: "minus-circle", checked: true %> - <%= radio_tab_tag form: f, name: :nature, value: :income, label: t(".income"), icon: "plus-circle" %> - <%= radio_tab_tag form: f, name: :nature, value: :transfer, label: t(".transfer"), icon: "arrow-right-left", disabled: true %> + <%= radio_tab_tag form: f, name: :nature, value: :expense, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "expense" || params[:nature].nil? %> + <%= radio_tab_tag form: f, name: :nature, value: :income, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "income" %> + <%= link_to new_transfer_path, data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm" do %> + <%= lucide_icon "arrow-right-left", class: "w-5 h-5" %> + <%= tag.span t(".transfer") %> + <% end %>
@@ -11,7 +14,7 @@ <%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %> <%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true %> <%= f.money_field :amount_money, label: t(".amount"), required: true %> - <%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") }, required: true %> + <%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %> <%= f.date_field :date, label: t(".date"), required: true, max: Date.today %> diff --git a/app/views/transactions/_name.html.erb b/app/views/transactions/_name.html.erb index 09bc09dd..32891964 100644 --- a/app/views/transactions/_name.html.erb +++ b/app/views/transactions/_name.html.erb @@ -9,7 +9,7 @@ <% else %> <%= link_to transaction.name, transaction_path(transaction), - data: { turbo_frame: "drawer" }, + data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> <% end %> diff --git a/app/views/transactions/_selection_bar.html.erb b/app/views/transactions/_selection_bar.html.erb index 34cc0a1f..d03ca5f3 100644 --- a/app/views/transactions/_selection_bar.html.erb +++ b/app/views/transactions/_selection_bar.html.erb @@ -8,6 +8,21 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %> + <%= form_with url: mark_transfers_transactions_path, + builder: ActionView::Helpers::FormBuilder, + scope: "bulk_update", + data: { + turbo_confirm: { + title: t(".mark_transfers"), + body: t(".mark_transfers_message"), + accept: t(".mark_transfers_confirm"), + } + } do |f| %> + + <% end %> + <%= link_to bulk_edit_transactions_path, class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md", title: "Edit", diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb index 3e04a625..c824cee0 100644 --- a/app/views/transactions/_transaction.html.erb +++ b/app/views/transactions/_transaction.html.erb @@ -1,22 +1,41 @@ -<%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 py-4 text-sm font-medium px-4" do %> -
+<%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4" do %> +
"> <%= check_box_tag dom_id(transaction, "selection"), class: "maybe-checkbox maybe-checkbox--light", data: { id: transaction.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %> -
+
<%= render "transactions/name", transaction: transaction %>
+ + <% if unconfirmed_transfer?(transaction) %> + <%= form_with url: unmark_transfers_transactions_path, builder: ActionView::Helpers::FormBuilder, class: "flex items-center", data: { + turbo_confirm: { + title: t(".remove_transfer"), + body: t(".remove_transfer_body"), + accept: t(".remove_transfer_confirm"), + }, + turbo_frame: "_top" + } do |f| %> + <%= f.hidden_field "bulk_update[transaction_ids][]", value: transaction.id %> + <%= f.button class: "flex items-center justify-center group", title: "Remove transfer" do %> + <%= lucide_icon "arrow-left-right", class: "group-hover:hidden text-gray-500 w-4 h-4" %> + <%= lucide_icon "unlink", class: "hidden group-hover:inline-block text-gray-900 w-4 h-4" %> + <% end %> + <% end %> + <% end %>
-
- <%= render "transactions/categories/menu", transaction: transaction %> -
+ <% unless unconfirmed_transfer?(transaction) %> +
+ <%= render "transactions/categories/menu", transaction: transaction %> +
- <%= link_to transaction.account.name, - account_path(transaction.account), - data: { turbo_frame: "_top" }, - class: ["col-span-3 hover:underline"] %> + <%= link_to transaction.account.name, + account_path(transaction.account), + data: { turbo_frame: "_top" }, + class: ["col-span-3 hover:underline"] %> + <% end %>
<%= render "transactions/amount", transaction: transaction %> diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 61d8115d..23fff412 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -25,8 +25,8 @@

amount

- <% @transactions.group_by(&:date).each do |date, transactions| %> - <%= render partial: "date_group", locals: { date:, transactions: } %> + <% group_transactions_by_date(@transactions).each do |date, group| %> + <%= render partial: "date_group", locals: { date:, group: } %> <% end %>
<% else %> diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb index 53a6a657..6fc355d1 100644 --- a/app/views/transactions/show.html.erb +++ b/app/views/transactions/show.html.erb @@ -1,10 +1,16 @@ <%= drawer do %>
-

- <%= format_money @transaction.amount_money %> - <%= @transaction.currency %> -

+
+

+ <%= format_money -@transaction.amount_money %> + <%= @transaction.currency %> +

+ + <% if @transaction.marked_as_transfer %> + <%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %> + <% end %> +
<%= @transaction.date.strftime("%A %d %B") %>
@@ -19,28 +25,20 @@
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
+ <%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %> <%= f.date_field :date, label: t(".date_label"), max: Date.today, "data-auto-submit-form-target": "auto" %> - <%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %> - <%= f.collection_select :merchant_id, Current.family.transaction_merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %> + + <% unless @transaction.marked_as_transfer %> + <%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %> + <%= f.collection_select :merchant_id, Current.family.transaction_merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %> + <% end %> + <%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_placeholder"), label: t(".account_label"), class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
<% end %>
-
- -

<%= t(".description") %>

- <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> -
- -
- <%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %> - <%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %> - <% end %> -
-
-

<%= t(".additional") %>

@@ -70,8 +68,8 @@
- <%= 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| %> +
+
+ <%= link_to new_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %> + <%= lucide_icon "minus-circle", class: "w-5 h-5" %> + <%= tag.span t(".expense") %> + <% end %> + + <%= link_to new_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %> + <%= lucide_icon "plus-circle", class: "w-5 h-5" %> + <%= tag.span t(".income") %> + <% end %> + + <%= tag.div class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 bg-white text-gray-800 shadow-sm" do %> + <%= lucide_icon "arrow-right-left", class: "w-5 h-5" %> + <%= tag.span t(".transfer") %> + <% end %> +
+
+ +
+ <%= 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