diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e9b92bf..5d0b4c30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,7 +97,6 @@ jobs: - name: System tests run: DISABLE_PARALLELIZATION=true bin/rails test:system - continue-on-error: true # TODO: Eventually we'll enforce for PRs - name: Keep screenshots from failed system tests uses: actions/upload-artifact@v4 diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 8544b611..f93c444b 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -71,19 +71,8 @@ class AccountsController < ApplicationController end def sync_all - synced_accounts_count = 0 - Current.family.accounts.each do |account| - next unless account.can_sync? - - account.sync_later - synced_accounts_count += 1 - end - - if synced_accounts_count > 0 - redirect_back_or_to accounts_path, notice: t(".success", count: synced_accounts_count) - else - redirect_back_or_to accounts_path, alert: t(".no_accounts_to_sync") - end + Current.family.accounts.active.sync + redirect_back_or_to accounts_path, notice: t(".success") end private diff --git a/app/jobs/account_sync_job.rb b/app/jobs/account_sync_job.rb index 4464ef4b..cdcefffc 100644 --- a/app/jobs/account_sync_job.rb +++ b/app/jobs/account_sync_job.rb @@ -1,7 +1,7 @@ class AccountSyncJob < ApplicationJob queue_as :default - def perform(account, start_date = nil) - account.sync(start_date) + def perform(account, start_date: nil) + account.sync(start_date: start_date) end end diff --git a/app/models/account.rb b/app/models/account.rb index 5440ad83..d1fcb02f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -14,10 +14,10 @@ class Account < ApplicationRecord has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation" has_many :balances, dependent: :destroy has_many :imports, dependent: :destroy + has_many :syncs, dependent: :destroy monetize :balance - enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } scope :active, -> { where(is_active: true) } @@ -73,24 +73,15 @@ class Account < ApplicationRecord end end - def balance_on(date) - balances.where("date <= ?", date).order(date: :desc).first&.balance + def alert + latest_sync = syncs.latest + [ latest_sync&.error, *latest_sync&.warnings ].compact.first end def favorable_direction classification == "asset" ? "up" : "down" end - # e.g. Wise, Revolut accounts that have transactions in multiple currencies - def multi_currency? - entries.select(:currency).distinct.count > 1 - end - - # e.g. Accounts denominated in currency other than family currency - def foreign_currency? - currency != family.currency - end - def series(period: Period.all, currency: self.currency) balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code) diff --git a/app/models/account/balance.rb b/app/models/account/balance.rb index 3e7adc24..1f2ace10 100644 --- a/app/models/account/balance.rb +++ b/app/models/account/balance.rb @@ -5,4 +5,5 @@ class Account::Balance < ApplicationRecord validates :account, :date, :balance, presence: true monetize :balance scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) } + scope :chronological, -> { order(:date) } end diff --git a/app/models/account/balance/calculator.rb b/app/models/account/balance/calculator.rb deleted file mode 100644 index 8b30d61f..00000000 --- a/app/models/account/balance/calculator.rb +++ /dev/null @@ -1,116 +0,0 @@ -class Account::Balance::Calculator - attr_reader :errors, :warnings - - def initialize(account, options = {}) - @errors = [] - @warnings = [] - @account = account - @calc_start_date = calculate_sync_start(options[:calc_start_date]) - end - - def daily_balances - @daily_balances ||= calculate_daily_balances - end - - private - - attr_reader :calc_start_date, :account - - def calculate_sync_start(provided_start_date = nil) - if account.balances.any? - [ provided_start_date, account.effective_start_date ].compact.max - else - account.effective_start_date - end - end - - def calculate_daily_balances - prior_balance = nil - - calculated_balances = (calc_start_date..Date.current).map do |date| - valuation_entry = find_valuation_entry(date) - - if valuation_entry - current_balance = valuation_entry.amount - elsif prior_balance.nil? - current_balance = implied_start_balance - else - txn_entries = syncable_transaction_entries.select { |e| e.date == date } - txn_flows = transaction_flows(txn_entries) - current_balance = prior_balance - txn_flows - end - - prior_balance = current_balance - - { date:, balance: current_balance, currency: account.currency, updated_at: Time.current } - end - - if account.foreign_currency? - calculated_balances.concat(convert_balances_to_family_currency(calculated_balances)) - end - - calculated_balances - end - - def syncable_entries - @entries ||= account.entries.where("date >= ?", calc_start_date).to_a - end - - def syncable_transaction_entries - @syncable_transaction_entries ||= syncable_entries.select { |e| e.account_transaction? } - end - - def find_valuation_entry(date) - syncable_entries.find { |entry| entry.date == date && entry.account_valuation? } - end - - def transaction_flows(transaction_entries) - converted_entries = transaction_entries.map { |entry| convert_entry_to_account_currency(entry) }.compact - flows = converted_entries.sum(&:amount) - flows *= -1 if account.liability? - flows - end - - def convert_balances_to_family_currency(balances) - rates = ExchangeRate.find_rates( - from: account.currency, - to: account.family.currency, - start_date: calc_start_date - ).to_a - - # Abort conversion if some required rates are missing - if rates.length != balances.length - @errors << :sync_message_missing_rates - return [] - end - - balances.map do |balance| - rate = rates.find { |r| r.date == balance[:date] } - converted_balance = balance[:balance] * rate&.rate - { date: balance[:date], balance: converted_balance, currency: account.family.currency, updated_at: Time.current } - end - end - - # Multi-currency accounts have transactions in many currencies - def convert_entry_to_account_currency(entry) - return entry if entry.currency == account.currency - - converted_entry = entry.dup - - rate = ExchangeRate.find_rate(from: entry.currency, to: account.currency, date: entry.date) - - unless rate - @errors << :sync_message_missing_rates - return nil - end - - converted_entry.currency = account.currency - converted_entry.amount = entry.amount * rate.rate - converted_entry - end - - def implied_start_balance - transaction_entries = syncable_transaction_entries.select { |e| e.date > calc_start_date } - account.balance.to_d + transaction_flows(transaction_entries) - end -end diff --git a/app/models/account/balance/syncer.rb b/app/models/account/balance/syncer.rb new file mode 100644 index 00000000..690c52b2 --- /dev/null +++ b/app/models/account/balance/syncer.rb @@ -0,0 +1,129 @@ +class Account::Balance::Syncer + attr_reader :warnings + + def initialize(account, start_date: nil) + @account = account + @warnings = [] + @sync_start_date = calculate_sync_start_date(start_date) + end + + def run + daily_balances = calculate_daily_balances + daily_balances += calculate_converted_balances(daily_balances) if account.currency != account.family.currency + + Account::Balance.transaction do + upsert_balances!(daily_balances) + purge_stale_balances! + end + end + + private + + attr_reader :sync_start_date, :account + + def upsert_balances!(balances) + balances_to_upsert = balances.map do |balance| + { + date: balance.date, + balance: balance.balance, + currency: balance.currency, + updated_at: Time.now + } + end + + account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency]) + end + + def purge_stale_balances! + account.balances.delete_by("date < ?", account_start_date) + end + + def calculate_balance_for_date(date, entries:, prior_balance:) + valuation = entries.find { |e| e.date == date && e.account_valuation? } + + return valuation.amount if valuation + return derived_sync_start_balance(entries) unless prior_balance + + transactions = entries.select { |e| e.date == date && e.account_transaction? } + + prior_balance - net_transaction_flows(transactions) + end + + def calculate_daily_balances + entries = account.entries.where("date >= ?", sync_start_date).to_a + prior_balance = find_prior_balance + + daily_balances = (sync_start_date...Date.current).map do |date| + current_balance = calculate_balance_for_date(date, entries:, prior_balance:) + + prior_balance = current_balance + + build_balance(date, current_balance) + end + + # Last balance of series is always equal to account balance + daily_balances << build_balance(Date.current, account.balance) + end + + def calculate_converted_balances(balances) + from_currency = account.currency + to_currency = account.family.currency + + exchange_rates = ExchangeRate.find_rates from: from_currency, + to: to_currency, + start_date: sync_start_date + + balances.map do |balance| + exchange_rate = exchange_rates.find { |er| er.date == balance.date } + + raise Money::ConversionError.new("missing exchange rate from #{from_currency} to #{to_currency} on date #{balance.date}") unless exchange_rate + + build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency) + end + rescue Money::ConversionError + @warnings << "missing exchange rates from #{from_currency} to #{to_currency}" + [] + end + + def build_balance(date, balance, currency = nil) + account.balances.build \ + date: date, + balance: balance, + currency: currency || account.currency + end + + def derived_sync_start_balance(entries) + transactions = entries.select { |e| e.account_transaction? && e.date > sync_start_date } + + account.balance + net_transaction_flows(transactions) + end + + def find_prior_balance + account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance + end + + def net_transaction_flows(transactions, target_currency = account.currency) + converted_transaction_amounts = transactions.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) } + + flows = converted_transaction_amounts.sum(&:amount) + + account.liability? ? flows * -1 : flows + end + + def account_start_date + @account_start_date ||= begin + oldest_entry_date = account.entries.chronological.first.try(:date) + + return Date.current unless oldest_entry_date + + oldest_entry_is_valuation = account.entries.account_valuations.where(date: oldest_entry_date).exists? + + oldest_entry_date -= 1 unless oldest_entry_is_valuation + oldest_entry_date + end + end + + def calculate_sync_start_date(provided_start_date) + [ provided_start_date, account_start_date ].compact.max + end +end diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 78487e5e..cdc6410c 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -33,7 +33,7 @@ class Account::Entry < ApplicationRecord sync_start_date = [ date_previously_was, date ].compact.min end - account.sync_later(sync_start_date) + account.sync_later(start_date: sync_start_date) end def inflow? @@ -122,19 +122,17 @@ class Account::Entry < ApplicationRecord end def income_total(currency = "USD") - account_transactions.includes(:entryable) + without_transfers.account_transactions.includes(:entryable) .where("account_entries.amount <= 0") - .where("account_entries.currency = ?", currency) - .reject { |e| e.marked_as_transfer? } - .sum(&:amount_money) + .map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) } + .sum end def expense_total(currency = "USD") - account_transactions.includes(:entryable) - .where("account_entries.amount > 0") - .where("account_entries.currency = ?", currency) - .reject { |e| e.marked_as_transfer? } - .sum(&:amount_money) + without_transfers.account_transactions.includes(:entryable) + .where("account_entries.amount > 0") + .map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) } + .sum end def search(params) diff --git a/app/models/account/sync.rb b/app/models/account/sync.rb new file mode 100644 index 00000000..90e18ebc --- /dev/null +++ b/app/models/account/sync.rb @@ -0,0 +1,51 @@ +class Account::Sync < ApplicationRecord + belongs_to :account + + enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" } + + class << self + def for(account, start_date: nil) + create! account: account, start_date: start_date + end + + def latest + order(created_at: :desc).first + end + end + + def run + start! + + sync_balances + + complete! + rescue StandardError => error + fail! error + end + + private + + def sync_balances + syncer = Account::Balance::Syncer.new(account, start_date: start_date) + + syncer.run + + append_warnings(syncer.warnings) + end + + def append_warnings(new_warnings) + update! warnings: warnings + new_warnings + end + + def start! + update! status: "syncing", last_ran_at: Time.now + end + + def complete! + update! status: "completed" + end + + def fail!(error) + update! status: "failed", error: error.message + end +end diff --git a/app/models/account/syncable.rb b/app/models/account/syncable.rb index 31ed1e7e..a0fe78cd 100644 --- a/app/models/account/syncable.rb +++ b/app/models/account/syncable.rb @@ -1,89 +1,21 @@ module Account::Syncable extend ActiveSupport::Concern - def sync_later(start_date = nil) - AccountSyncJob.perform_later(self, start_date) + class_methods do + def sync(start_date: nil) + all.each { |a| a.sync_later(start_date: start_date) } + end end - def sync(start_date = nil) - update!(status: "syncing") - - if multi_currency? || foreign_currency? - sync_exchange_rates - end - - calculator = Account::Balance::Calculator.new(self, { calc_start_date: start_date }) - - self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique) - self.balances.where("date < ?", effective_start_date).delete_all - new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance] - - update! \ - status: "ok", - last_sync_date: Date.current, - balance: new_balance, - sync_errors: calculator.errors, - sync_warnings: calculator.warnings - rescue => e - update!(status: "error", sync_errors: [ :sync_message_unknown_error ]) - logger.error("Failed to sync account #{id}: #{e.message}") + def syncing? + syncs.syncing.any? end - def can_sync? - # Skip account sync if account is not active or the sync process is already running - return false unless is_active - return false if syncing? - # If last_sync_date is blank (i.e. the account has never been synced before) allow syncing - return true if last_sync_date.blank? - - # If last_sync_date is not today, allow syncing - last_sync_date != Date.today + def sync_later(start_date: nil) + AccountSyncJob.perform_later(self, start_date: start_date) end - # The earliest date we can calculate a balance for - def effective_start_date - @effective_start_date ||= entries.order(:date).first.try(:date) || Date.current - end - - # Finds all the rate pairs that are required to calculate balances for an account and syncs them - def sync_exchange_rates - rate_candidates = [] - - if multi_currency? - transactions_in_foreign_currency = self.entries.where.not(currency: self.currency).pluck(:currency, :date).uniq - transactions_in_foreign_currency.each do |currency, date| - rate_candidates << { date: date, from_currency: currency, to_currency: self.currency } - end - end - - if foreign_currency? - (effective_start_date..Date.current).each do |date| - rate_candidates << { date: date, from_currency: self.currency, to_currency: self.family.currency } - end - end - - return if rate_candidates.blank? - - existing_rates = ExchangeRate.where( - from_currency: rate_candidates.map { |rc| rc[:from_currency] }, - to_currency: rate_candidates.map { |rc| rc[:to_currency] }, - date: rate_candidates.map { |rc| rc[:date] } - ).pluck(:from_currency, :to_currency, :date) - - # Convert to a set for faster lookup - existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set - - rate_candidates.each do |rate_candidate| - rc_from = rate_candidate[:from_currency] - rc_to = rate_candidate[:to_currency] - rc_date = rate_candidate[:date] - - next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ]) - - logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})" - ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date - end - - nil + def sync(start_date: nil) + Account::Sync.for(self, start_date: start_date).run end end diff --git a/app/models/family.rb b/app/models/family.rb index b0be8b1e..cdcf2e8b 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -104,7 +104,7 @@ class Family < ApplicationRecord Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency) end - def sync_accounts - accounts.each { |account| account.sync_later if account.can_sync? } + def sync(start_date: nil) + accounts.active.sync(start_date: start_date) end end diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index 1c74929e..9892794e 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -47,12 +47,11 @@ <%= turbo_frame_tag "sync_message" do %> <%= render partial: "accounts/sync_message", locals: { is_syncing: @account.syncing? } %> <% end %> - <% @account.sync_errors.each do |message| %> - <%= render partial: "shared/alert", locals: { type: "error", content: t("." + message) } %> - <% end %> - <% @account.sync_warnings.each do |message| %> - <%= render partial: "shared/alert", locals: { type: "warning", content: t("." + message) } %> + + <% if @account.alert %> + <%= render partial: "shared/alert", locals: { type: "error", content: t("." + @account.alert) } %> <% end %> +
diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 6a55b619..755b7b8e 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -66,7 +66,6 @@ en: sync: success: Account sync started sync_all: - no_accounts_to_sync: No accounts were eligible for syncing. - success: Successfully queued %{count} accounts for syncing. + success: Successfully queued accounts for syncing. update: success: Account updated diff --git a/db/migrate/20240707130331_create_account_syncs.rb b/db/migrate/20240707130331_create_account_syncs.rb new file mode 100644 index 00000000..474a98a0 --- /dev/null +++ b/db/migrate/20240707130331_create_account_syncs.rb @@ -0,0 +1,18 @@ +class CreateAccountSyncs < ActiveRecord::Migration[7.2] + def change + create_table :account_syncs, id: :uuid do |t| + t.references :account, null: false, foreign_key: true, type: :uuid + t.string :status, null: false, default: "pending" + t.date :start_date + t.datetime :last_ran_at + t.string :error + t.text :warnings, array: true, default: [] + + t.timestamps + end + + remove_column :accounts, :status, :string + remove_column :accounts, :sync_warnings, :jsonb + remove_column :accounts, :sync_errors, :jsonb + end +end diff --git a/db/schema.rb b/db/schema.rb index e13e7f6d..91cbbdbd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -48,6 +48,18 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do t.index ["transfer_id"], name: "index_account_entries_on_transfer_id" end + create_table "account_syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "account_id", null: false + t.string "status", default: "pending", null: false + t.date "start_date" + t.datetime "last_ran_at" + t.string "error" + t.text "warnings", default: [], array: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_account_syncs_on_account_id" + end + create_table "account_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -80,12 +92,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do t.decimal "balance", precision: 19, scale: 4, default: "0.0" t.string "currency", default: "USD" 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 - t.jsonb "sync_errors", default: [], null: false t.date "last_sync_date" t.uuid "institution_id" - t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('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['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["institution_id"], name: "index_accounts_on_institution_id" @@ -364,6 +373,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do add_foreign_key "account_balances", "accounts", on_delete: :cascade add_foreign_key "account_entries", "account_transfers", column: "transfer_id" add_foreign_key "account_entries", "accounts" + add_foreign_key "account_syncs", "accounts" add_foreign_key "account_transactions", "categories", on_delete: :nullify add_foreign_key "account_transactions", "merchants" add_foreign_key "accounts", "families" diff --git a/lib/tasks/demo_data.rake b/lib/tasks/demo_data.rake index 556ac270..3fce0461 100644 --- a/lib/tasks/demo_data.rake +++ b/lib/tasks/demo_data.rake @@ -323,9 +323,7 @@ namespace :demo_data do puts "Syncing accounts... This may take a few seconds." - family.accounts.each do |account| - account.sync - end + family.sync puts "Accounts synced. Demo data reset complete." end diff --git a/test/controllers/account/entries_controller_test.rb b/test/controllers/account/entries_controller_test.rb index 327d13f3..48e2c1c1 100644 --- a/test/controllers/account/entries_controller_test.rb +++ b/test/controllers/account/entries_controller_test.rb @@ -3,49 +3,48 @@ require "test_helper" class Account::EntriesControllerTest < ActionDispatch::IntegrationTest setup do sign_in @user = users(:family_admin) - @account = accounts(:savings) - @transaction_entry = @account.entries.account_transactions.first - @valuation_entry = @account.entries.account_valuations.first + @transaction = account_entries :transaction + @valuation = account_entries :valuation end test "should edit valuation entry" do - get edit_account_entry_url(@account, @valuation_entry) + get edit_account_entry_url(@valuation.account, @valuation) assert_response :success end test "should show transaction entry" do - get account_entry_url(@account, @transaction_entry) + get account_entry_url(@transaction.account, @transaction) assert_response :success end test "should show valuation entry" do - get account_entry_url(@account, @valuation_entry) + get account_entry_url(@valuation.account, @valuation) assert_response :success end test "should get list of transaction entries" do - get transaction_account_entries_url(@account) + get transaction_account_entries_url(@transaction.account) assert_response :success end test "should get list of valuation entries" do - get valuation_account_entries_url(@account) + get valuation_account_entries_url(@valuation.account) assert_response :success end test "gets new entry by type" do - get new_account_entry_url(@account, entryable_type: "Account::Valuation") + get new_account_entry_url(@valuation.account, entryable_type: "Account::Valuation") assert_response :success end test "should create valuation" do assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do - post account_entries_url(@account), params: { + post account_entries_url(@valuation.account), params: { account_entry: { name: "Manual valuation", amount: 19800, date: Date.current, - currency: @account.currency, + currency: @valuation.account.currency, entryable_type: "Account::Valuation", entryable_attributes: {} } @@ -54,16 +53,16 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest assert_equal "Valuation created", flash[:notice] assert_enqueued_with job: AccountSyncJob - assert_redirected_to account_path(@account) + assert_redirected_to account_path(@valuation.account) end test "error when valuation already exists for date" do assert_no_difference_in_entries do - post account_entries_url(@account), params: { + post account_entries_url(@valuation.account), params: { account_entry: { amount: 19800, - date: @valuation_entry.date, - currency: @valuation_entry.currency, + date: @valuation.date, + currency: @valuation.currency, entryable_type: "Account::Valuation", entryable_attributes: {} } @@ -71,33 +70,33 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest end assert_equal "Date has already been taken", flash[:error] - assert_redirected_to account_path(@account) + assert_redirected_to account_path(@valuation.account) end test "can update entry without entryable attributes" do assert_no_difference_in_entries do - patch account_entry_url(@account, @valuation_entry), params: { + patch account_entry_url(@valuation.account, @valuation), params: { account_entry: { name: "Updated name" } } end - assert_redirected_to account_entry_url(@account, @valuation_entry) + assert_redirected_to account_entry_url(@valuation.account, @valuation) assert_enqueued_with(job: AccountSyncJob) end test "should update transaction entry with entryable attributes" do assert_no_difference_in_entries do - patch account_entry_url(@account, @transaction_entry), params: { + patch account_entry_url(@transaction.account, @transaction), params: { account_entry: { name: "Updated name", date: Date.current, currency: "USD", amount: 20, - entryable_type: @transaction_entry.entryable_type, + entryable_type: @transaction.entryable_type, entryable_attributes: { - id: @transaction_entry.entryable_id, + id: @transaction.entryable_id, tag_ids: [ Tag.first.id, Tag.second.id ], category_id: Category.first.id, merchant_id: Merchant.first.id, @@ -108,17 +107,17 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest } end - assert_redirected_to account_entry_url(@account, @transaction_entry) + assert_redirected_to account_entry_url(@transaction.account, @transaction) assert_enqueued_with(job: AccountSyncJob) end test "should destroy transaction entry" do - [ @transaction_entry, @valuation_entry ].each do |entry| + [ @transaction, @valuation ].each do |entry| assert_difference -> { Account::Entry.count } => -1, -> { entry.entryable_class.count } => -1 do - delete account_entry_url(@account, entry) + delete account_entry_url(entry.account, entry) end - assert_redirected_to account_url(@account) + assert_redirected_to account_url(entry.account) assert_enqueued_with(job: AccountSyncJob) end end diff --git a/test/controllers/account/transfers_controller_test.rb b/test/controllers/account/transfers_controller_test.rb index 5b28c1b2..a41426bf 100644 --- a/test/controllers/account/transfers_controller_test.rb +++ b/test/controllers/account/transfers_controller_test.rb @@ -14,8 +14,8 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest assert_difference "Account::Transfer.count", 1 do post account_transfers_url, params: { account_transfer: { - from_account_id: accounts(:checking).id, - to_account_id: accounts(:savings).id, + from_account_id: accounts(:depository).id, + to_account_id: accounts(:credit_card).id, date: Date.current, amount: 100, currency: "USD", @@ -28,7 +28,7 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest test "can destroy transfer" do assert_difference -> { Account::Transfer.count } => -1, -> { Account::Transaction.count } => 0 do - delete account_transfer_url(account_transfers(:credit_card_payment)) + delete account_transfer_url(account_transfers(:one)) end end end diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index 629a19bd..a7b32d09 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -3,7 +3,7 @@ require "test_helper" class AccountsControllerTest < ActionDispatch::IntegrationTest setup do sign_in @user = users(:family_admin) - @account = accounts(:checking) + @account = accounts(:depository) end test "gets accounts list" do @@ -33,7 +33,7 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest test "can sync all accounts" do post sync_all_accounts_path assert_redirected_to accounts_url - assert_equal "Successfully queued #{ @user.family.accounts.size } accounts for syncing.", flash[:notice] + assert_equal "Successfully queued accounts for syncing.", flash[:notice] end test "should update account" do diff --git a/test/controllers/categories_controller_test.rb b/test/controllers/categories_controller_test.rb index b9fed5b0..cc71a340 100644 --- a/test/controllers/categories_controller_test.rb +++ b/test/controllers/categories_controller_test.rb @@ -3,6 +3,7 @@ require "test_helper" class CategoriesControllerTest < ActionDispatch::IntegrationTest setup do sign_in users(:family_admin) + @transaction = account_transactions :one end test "index" do @@ -37,7 +38,7 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest assert_difference "Category.count", +1 do post categories_url, params: { - transaction_id: account_transactions(:checking_one).id, + transaction_id: @transaction.id, category: { name: "New Category", color: color } } @@ -48,7 +49,7 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest assert_redirected_to transactions_url assert_equal "New Category", new_category.name assert_equal color, new_category.color - assert_equal account_transactions(:checking_one).reload.category, new_category + assert_equal @transaction.reload.category, new_category end test "edit" do diff --git a/test/controllers/tag/deletions_controller_test.rb b/test/controllers/tag/deletions_controller_test.rb index 57bf6df2..d51af30f 100644 --- a/test/controllers/tag/deletions_controller_test.rb +++ b/test/controllers/tag/deletions_controller_test.rb @@ -3,8 +3,7 @@ require "test_helper" class Tag::DeletionsControllerTest < ActionDispatch::IntegrationTest setup do sign_in @user = users(:family_admin) - @user_tags = @user.family.tags - @tag = tags(:hawaii_trip) + @tag = tags(:one) end test "should get new" do @@ -13,7 +12,7 @@ class Tag::DeletionsControllerTest < ActionDispatch::IntegrationTest end test "create with replacement" do - replacement_tag = tags(:trips) + replacement_tag = tags(:two) affected_transaction_count = @tag.transactions.count diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index 3f381261..5ef4d356 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -1,10 +1,11 @@ require "test_helper" class TransactionsControllerTest < ActionDispatch::IntegrationTest + include Account::EntriesTestHelper + setup do sign_in @user = users(:family_admin) - @transaction_entry = account_entries(:checking_one) - @recent_transaction_entries = @user.family.entries.account_transactions.reverse_chronological.limit(20).to_a + @transaction = account_entries(:transaction) end test "should get new" do @@ -13,9 +14,9 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest end test "prefills account_id" do - get new_transaction_url(account_id: @transaction_entry.account.id) + get new_transaction_url(account_id: @transaction.account.id) assert_response :success - assert_select "option[selected][value='#{@transaction_entry.account.id}']" + assert_select "option[selected][value='#{@transaction.account.id}']" end test "should create transaction" do @@ -45,11 +46,11 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest post transactions_url, params: { account_entry: { nature: "expense", - account_id: @transaction_entry.account_id, - amount: @transaction_entry.amount, - currency: @transaction_entry.currency, - date: @transaction_entry.date, - name: @transaction_entry.name, + account_id: @transaction.account_id, + amount: @transaction.amount, + currency: @transaction.currency, + date: @transaction.date, + name: @transaction.name, entryable_type: "Account::Transaction", entryable_attributes: {} } @@ -58,7 +59,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest created_entry = Account::Entry.order(created_at: :desc).first - assert_redirected_to account_url(@transaction_entry.account) + assert_redirected_to account_url(@transaction.account) assert created_entry.amount.positive?, "Amount should be positive" end @@ -67,11 +68,11 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest post transactions_url, params: { account_entry: { nature: "income", - account_id: @transaction_entry.account_id, - amount: @transaction_entry.amount, - currency: @transaction_entry.currency, - date: @transaction_entry.date, - name: @transaction_entry.name, + account_id: @transaction.account_id, + amount: @transaction.amount, + currency: @transaction.currency, + date: @transaction.date, + name: @transaction.name, entryable_type: "Account::Transaction", entryable_attributes: { category_id: categories(:food_and_drink).id } } @@ -80,83 +81,79 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest created_entry = Account::Entry.order(created_at: :desc).first - assert_redirected_to account_url(@transaction_entry.account) + assert_redirected_to account_url(@transaction.account) assert created_entry.amount.negative?, "Amount should be negative" end - test "should get paginated index with most recent transactions first" do - get transactions_url(per_page: 10) - assert_response :success - - @recent_transaction_entries.first(10).each do |transaction| - assert_dom "#" + dom_id(transaction), count: 1 - end - end - test "transaction count represents filtered total" do + family = families(:empty) + sign_in family.users.first + account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new + + 3.times do + create_transaction(account: account) + end + get transactions_url(per_page: 10) - assert_dom "#total-transactions", count: 1, text: @user.family.entries.account_transactions.select { |t| t.currency == "USD" }.count.to_s - new_transaction = @user.family.accounts.first.entries.create! \ - entryable: Account::Transaction.new, - name: "Transaction to search for", - date: Date.current, - amount: 0, - currency: "USD" + assert_dom "#total-transactions", count: 1, text: family.entries.account_transactions.size.to_s - get transactions_url(q: { search: new_transaction.name }) + searchable_transaction = create_transaction(account: account, name: "Unique test name") + + get transactions_url(q: { search: searchable_transaction.name }) # Only finds 1 transaction that matches filter - assert_dom "#" + dom_id(new_transaction), count: 1 + assert_dom "#" + dom_id(searchable_transaction), count: 1 assert_dom "#total-transactions", count: 1, text: "1" end - test "can navigate to paginated result" do - get transactions_url(page: 2, per_page: 10) + test "can paginate" do + family = families(:empty) + sign_in family.users.first + account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new + + 11.times do + create_transaction(account: account) + end + + sorted_transactions = family.entries.account_transactions.reverse_chronological.to_a + + assert_equal 11, sorted_transactions.count + + get transactions_url(page: 1, per_page: 10) + assert_response :success - - visible_transaction_entries = @recent_transaction_entries[10, 10].reject { |e| e.transfer.present? } - - visible_transaction_entries.each do |transaction| + sorted_transactions.first(10).each do |transaction| assert_dom "#" + dom_id(transaction), count: 1 end - end - test "loads last page when page is out of range" do - user_oldest_transaction_entry = @user.family.entries.account_transactions.chronological.first - get transactions_url(page: 9999999999) + get transactions_url(page: 2, per_page: 10) - assert_response :success - assert_dom "#" + dom_id(user_oldest_transaction_entry), count: 1 + assert_dom "#" + dom_id(sorted_transactions.last), count: 1 + + get transactions_url(page: 9999999, per_page: 10) # out of range loads last page + + assert_dom "#" + dom_id(sorted_transactions.last), count: 1 end test "can destroy many transactions at once" do - delete_count = 10 + transactions = @user.family.entries.account_transactions + delete_count = transactions.size + assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do post bulk_delete_transactions_url, params: { bulk_delete: { - entry_ids: @recent_transaction_entries.first(delete_count).pluck(:id) + entry_ids: transactions.pluck(:id) } } end assert_redirected_to transactions_url - assert_equal "10 transactions deleted", flash[:notice] + assert_equal "#{delete_count} transactions deleted", flash[:notice] end test "can update many transactions at once" do - transactions = @user.family.entries.account_transactions.reverse_chronological.limit(20) - - transactions.each do |transaction| - transaction.update! \ - date: Date.current, - entryable_attributes: { - id: transaction.account_transaction.id, - category_id: Category.first.id, - merchant_id: Merchant.first.id, - notes: "Starting note" - } - end + transactions = @user.family.entries.account_transactions assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do post bulk_update_transactions_url, params: { @@ -173,9 +170,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to transactions_url assert_equal "#{transactions.count} transactions updated", flash[:notice] - transactions.reload - - transactions.each do |transaction| + transactions.reload.each do |transaction| assert_equal 1.day.ago.to_date, transaction.date assert_equal Category.second, transaction.account_transaction.category assert_equal Merchant.second, transaction.account_transaction.merchant diff --git a/test/fixtures/account/balances.yml b/test/fixtures/account/balances.yml new file mode 100644 index 00000000..ff811a9a --- /dev/null +++ b/test/fixtures/account/balances.yml @@ -0,0 +1,11 @@ +one: + date: <%= 2.days.ago.to_date %> + balance: 4990 + currency: USD + account: depository + +two: + date: <%= 1.day.ago.to_date %> + balance: 4980 + currency: USD + account: depository \ No newline at end of file diff --git a/test/fixtures/account/entries.yml b/test/fixtures/account/entries.yml index bd45af76..315867aa 100644 --- a/test/fixtures/account/entries.yml +++ b/test/fixtures/account/entries.yml @@ -1,323 +1,39 @@ -# Checking account transactions -checking_one: +valuation: + name: Manual valuation + date: <%= 4.days.ago.to_date %> + amount: 4995 + currency: USD + account: depository + entryable_type: Account::Valuation + entryable: one + +transaction: name: Starbucks - date: <%= 5.days.ago.to_date %> + date: <%= 1.day.ago.to_date %> amount: 10 - account: checking currency: USD + account: depository entryable_type: Account::Transaction - entryable: checking_one + entryable: one -checking_two: - name: Chipotle - date: <%= 12.days.ago.to_date %> - amount: 30 - account: checking - currency: USD - entryable_type: Account::Transaction - entryable: checking_two - -checking_three: - name: Amazon - date: <%= 15.days.ago.to_date %> - amount: 20 - account: checking - currency: USD - entryable_type: Account::Transaction - entryable: checking_three - -checking_four: - name: Paycheck - date: <%= 22.days.ago.to_date %> - amount: -1075 - account: checking - currency: USD - entryable_type: Account::Transaction - entryable: checking_four - -checking_five: - name: Netflix - date: <%= 29.days.ago.to_date %> - amount: 15 - account: checking - currency: USD - entryable_type: Account::Transaction - entryable: checking_five - -checking_six_payment: - name: Payment to Credit Card - date: <%= 29.days.ago.to_date %> +transfer_out: + name: Payment to credit card account + date: <%= 3.days.ago.to_date %> amount: 100 - account: checking currency: USD - entryable_type: Account::Transaction - entryable: checking_six_payment + account: depository 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 + transfer: one entryable_type: Account::Transaction - entryable: checking_seven_transfer + entryable: transfer_out -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 - entryable_type: Account::Transaction - entryable: checking_eight_external_payment - -checking_nine_external_transfer: - name: Transfer FROM external investing account (owned by user but not known to app) - date: <%= 31.days.ago.to_date %> - amount: -200 - account: checking - currency: USD - marked_as_transfer: true - entryable_type: Account::Transaction - entryable: checking_nine_external_transfer - -savings_one: - name: Interest Received - date: <%= 5.days.ago.to_date %> - amount: -200 - account: savings - currency: USD - entryable_type: Account::Transaction - entryable: savings_one - -savings_two: - name: Check Deposit - date: <%= 12.days.ago.to_date %> - amount: -50 - account: savings - currency: USD - entryable_type: Account::Transaction - entryable: savings_two - -savings_three: - name: Withdrawal - date: <%= 18.days.ago.to_date %> - amount: 2000 - account: savings - currency: USD - entryable_type: Account::Transaction - entryable: savings_three - -savings_four: - name: Check Deposit - date: <%= 29.days.ago.to_date %> - amount: -500 - account: savings - currency: USD - entryable_type: Account::Transaction - entryable: savings_four - -savings_five_transfer: - name: Received Transfer from Checking Account - date: <%= 31.days.ago.to_date %> - amount: -250 - account: savings - currency: USD - marked_as_transfer: true - transfer: savings_transfer - entryable_type: Account::Transaction - entryable: savings_five_transfer - -credit_card_one: - name: Starbucks - date: <%= 5.days.ago.to_date %> - amount: 10 - account: credit_card - currency: USD - entryable_type: Account::Transaction - entryable: credit_card_one - -credit_card_two: - name: Chipotle - date: <%= 12.days.ago.to_date %> - amount: 30 - account: credit_card - currency: USD - entryable_type: Account::Transaction - entryable: credit_card_two - -credit_card_three: - name: Amazon - date: <%= 15.days.ago.to_date %> - amount: 20 - account: credit_card - currency: USD - entryable_type: Account::Transaction - entryable: credit_card_three - -credit_card_four_payment: - name: Received CC Payment from Checking Account - date: <%= 31.days.ago.to_date %> +transfer_in: + name: Payment received from checking account + date: <%= 3.days.ago.to_date %> amount: -100 + currency: USD account: credit_card - currency: USD marked_as_transfer: true - transfer: credit_card_payment + transfer: one entryable_type: Account::Transaction - entryable: credit_card_four_payment - -eur_checking_one: - name: Check - date: <%= 9.days.ago.to_date %> - amount: -50 - currency: EUR - account: eur_checking - entryable_type: Account::Transaction - entryable: eur_checking_one - -eur_checking_two: - name: Shopping trip - date: <%= 19.days.ago.to_date %> - amount: 100 - currency: EUR - account: eur_checking - entryable_type: Account::Transaction - entryable: eur_checking_two - -eur_checking_three: - name: Check - date: <%= 31.days.ago.to_date %> - amount: -200 - currency: EUR - account: eur_checking - entryable_type: Account::Transaction - entryable: eur_checking_three - -multi_currency_one: - name: Outflow 1 - date: <%= 4.days.ago.to_date %> - amount: 800 - currency: EUR - account: multi_currency - entryable_type: Account::Transaction - entryable: multi_currency_one - -multi_currency_two: - name: Inflow 1 - date: <%= 9.days.ago.to_date %> - amount: -50 - currency: USD - account: multi_currency - entryable_type: Account::Transaction - entryable: multi_currency_two - -multi_currency_three: - name: Outflow 2 - date: <%= 19.days.ago.to_date %> - amount: 110.85 - currency: EUR - account: multi_currency - entryable_type: Account::Transaction - entryable: multi_currency_three - -multi_currency_four: - name: Inflow 2 - date: <%= 29.days.ago.to_date %> - amount: -200 - currency: USD - account: multi_currency - entryable_type: Account::Transaction - entryable: multi_currency_four - -collectable_one_valuation: - amount: 550 - date: <%= 4.days.ago.to_date %> - account: collectable - currency: USD - entryable_type: Account::Valuation - entryable: collectable_one - -collectable_two_valuation: - amount: 700 - date: <%= 12.days.ago.to_date %> - account: collectable - currency: USD - entryable_type: Account::Valuation - entryable: collectable_two - -collectable_three_valuation: - amount: 400 - date: <%= 31.days.ago.to_date %> - account: collectable - currency: USD - entryable_type: Account::Valuation - entryable: collectable_three - -iou_one_valuation: - amount: 200 - date: <%= 31.days.ago.to_date %> - account: iou - currency: USD - entryable_type: Account::Valuation - entryable: iou_one - -multi_currency_one_valuation: - amount: 10200 - date: <%= 31.days.ago.to_date %> - account: multi_currency - currency: USD - entryable_type: Account::Valuation - entryable: multi_currency_one - -savings_one_valuation: - amount: 19500 - date: <%= 12.days.ago.to_date %> - account: savings - currency: USD - entryable_type: Account::Valuation - entryable: savings_one - -savings_two_valuation: - amount: 21000 - date: <%= 25.days.ago.to_date %> - account: savings - currency: USD - entryable_type: Account::Valuation - entryable: savings_two - -brokerage_one_valuation: - amount: 10000 - date: <%= 31.days.ago.to_date %> - account: brokerage - currency: USD - entryable_type: Account::Valuation - entryable: brokerage_one - -mortgage_loan_one_valuation: - amount: 500000 - date: <%= 31.days.ago.to_date %> - account: mortgage_loan - currency: USD - entryable_type: Account::Valuation - entryable: mortgage_loan_one - -house_one_valuation: - amount: 550000 - date: <%= 31.days.ago.to_date %> - account: house - currency: USD - entryable_type: Account::Valuation - entryable: house_one - -car_one_valuation: - amount: 18000 - date: <%= 31.days.ago.to_date %> - account: car - currency: USD - entryable_type: Account::Valuation - entryable: car_one \ No newline at end of file + entryable: transfer_in diff --git a/test/fixtures/account/syncs.yml b/test/fixtures/account/syncs.yml new file mode 100644 index 00000000..48c7fc4f --- /dev/null +++ b/test/fixtures/account/syncs.yml @@ -0,0 +1,13 @@ +one: + account: depository + status: failed + start_date: 2024-07-07 + last_ran_at: 2024-07-07 09:03:31 + error: test sync error + warnings: [ "test warning 1", "test warning 2" ] + +two: + account: investment + status: completed + start_date: 2024-07-07 + last_ran_at: 2024-07-07 09:03:32 diff --git a/test/fixtures/account/transactions.yml b/test/fixtures/account/transactions.yml index 8e897eb5..426d7d58 100644 --- a/test/fixtures/account/transactions.yml +++ b/test/fixtures/account/transactions.yml @@ -1,60 +1,6 @@ -# Checking account transactions -checking_one: +one: category: food_and_drink - -checking_two: - category: food_and_drink - -checking_three: merchant: amazon -checking_four: - category: income - -checking_five: - merchant: netflix - -checking_six_payment: { } - -checking_seven_transfer: { } - -checking_eight_external_payment: { } - -checking_nine_external_transfer: { } - -# Savings account that has transactions and valuation overrides -savings_one: - category: income - -savings_two: - category: income - -savings_three: { } - -savings_four: - category: income - -savings_five_transfer: { } - -# Credit card account transactions -credit_card_one: - category: food_and_drink - -credit_card_two: - category: food_and_drink - -credit_card_three: - merchant: amazon - -credit_card_four_payment: { } - -# eur_checking transactions -eur_checking_one: { } -eur_checking_two: { } -eur_checking_three: { } - -# multi_currency transactions -multi_currency_one: { } -multi_currency_two: { } -multi_currency_three: { } -multi_currency_four: { } +transfer_out: { } +transfer_in: { } \ No newline at end of file diff --git a/test/fixtures/account/transfers.yml b/test/fixtures/account/transfers.yml index 3c723a8e..6aab7788 100644 --- a/test/fixtures/account/transfers.yml +++ b/test/fixtures/account/transfers.yml @@ -1,2 +1 @@ -credit_card_payment: { } -savings_transfer: { } +one: { } diff --git a/test/fixtures/account/valuations.yml b/test/fixtures/account/valuations.yml index 94e83b2c..21aeae24 100644 --- a/test/fixtures/account/valuations.yml +++ b/test/fixtures/account/valuations.yml @@ -1,18 +1,2 @@ -collectable_one: { } -collectable_two: { } -collectable_three: { } - -iou_one: { } - -multi_currency_one: { } - -savings_one: { } -savings_two: { } - -brokerage_one: { } - -mortgage_loan_one: { } - -house_one: { } - -car_one: { } \ No newline at end of file +one: { } +two: { } \ No newline at end of file diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index 25633c4e..74e09ddb 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -1,31 +1,23 @@ -collectable: +other_asset: family: dylan_family name: Collectable Account balance: 550 accountable_type: OtherAsset - accountable: other_asset_collectable + accountable: one -iou: +other_liability: family: dylan_family name: IOU (personal debt to friend) balance: 200 accountable_type: OtherLiability - accountable: other_liability_iou + accountable: one -checking: +depository: family: dylan_family name: Checking Account balance: 5000 accountable_type: Depository - accountable: depository_checking - institution: chase - -savings: - family: dylan_family - name: Savings account - balance: 19700 - accountable_type: Depository - accountable: depository_savings + accountable: one institution: chase credit_card: @@ -33,56 +25,37 @@ credit_card: name: Credit Card balance: 1000 accountable_type: CreditCard - accountable: credit_one + accountable: one institution: chase -eur_checking: - family: dylan_family - name: Euro Checking Account - currency: EUR - balance: 12000 - accountable_type: Depository - accountable: depository_eur_checking - institution: revolut - -# Multi-currency account (e.g. Wise, Revolut, etc.) -multi_currency: - family: dylan_family - name: Multi Currency Account - currency: USD # multi-currency accounts still have a "primary" currency - balance: 9467 - accountable_type: Depository - accountable: depository_multi_currency - institution: revolut - -brokerage: +investment: family: dylan_family name: Robinhood Brokerage Account currency: USD balance: 10000 accountable_type: Investment - accountable: investment_brokerage + accountable: one -mortgage_loan: +loan: family: dylan_family name: Mortgage Loan currency: USD balance: 500000 accountable_type: Loan - accountable: loan_mortgage + accountable: one -house: +property: family: dylan_family name: 123 Maybe Court currency: USD balance: 550000 accountable_type: Property - accountable: property_house + accountable: one -car: +vehicle: family: dylan_family name: Honda Accord currency: USD balance: 18000 accountable_type: Vehicle - accountable: vehicle_honda_accord + accountable: one diff --git a/test/fixtures/categories.yml b/test/fixtures/categories.yml index ad3144d8..1c32bf80 100644 --- a/test/fixtures/categories.yml +++ b/test/fixtures/categories.yml @@ -1,3 +1,7 @@ +one: + name: Test + family: empty + income: name: Income internal_category: income diff --git a/test/fixtures/credit_cards.yml b/test/fixtures/credit_cards.yml index 1ad8c478..e0553ab0 100644 --- a/test/fixtures/credit_cards.yml +++ b/test/fixtures/credit_cards.yml @@ -1 +1 @@ -credit_one: { } +one: { } \ No newline at end of file diff --git a/test/fixtures/depositories.yml b/test/fixtures/depositories.yml index ff07aeab..e0553ab0 100644 --- a/test/fixtures/depositories.yml +++ b/test/fixtures/depositories.yml @@ -1,4 +1 @@ -depository_checking: { } -depository_savings: { } -depository_eur_checking: { } -depository_multi_currency: { } +one: { } \ No newline at end of file diff --git a/test/fixtures/families.yml b/test/fixtures/families.yml index 002da9c0..c1b0831a 100644 --- a/test/fixtures/families.yml +++ b/test/fixtures/families.yml @@ -1,2 +1,6 @@ +empty: + name: Family + dylan_family: name: The Dylan Family + diff --git a/test/fixtures/files/expected_family_snapshots.csv b/test/fixtures/files/expected_family_snapshots.csv deleted file mode 100644 index 62af170c..00000000 --- a/test/fixtures/files/expected_family_snapshots.csv +++ /dev/null @@ -1,33 +0,0 @@ -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,5150.00,940.00,20950.00,12050.00,13238.13,10200.00,10000.00,500000.00,550000.00,18000.00,126798.13,627938.13,501140.00,49538.13,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,219.72,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,0.00,0.00,219.72,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,919.72,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,919.72,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,919.72,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,919.72,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,919.72,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,919.72,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,919.72,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,1994.72,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,1994.72,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,1994.72,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,1994.72,0.8781 -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,1994.72,-0.1246 -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,1994.72,-0.1246 -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,1994.72,-0.1246 -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,1994.72,-0.1446 -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,1994.72,-0.1446 -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,1994.72,-0.1446 -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,2044.72,-0.1460 -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,2044.72,-0.1460 -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,2044.72,-0.1460 -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,2148.64,-0.0905 -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,2148.64,-0.0905 -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,2148.64,-0.0905 -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,2148.64,-0.0905 -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,2348.64,-0.0062 -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,2348.64,-0.3737 -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,2348.64,-0.3737 -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,2348.64,-0.3737 -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,2348.64,-0.3737 -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,2348.64,-0.3737 \ No newline at end of file diff --git a/test/fixtures/imports.yml b/test/fixtures/imports.yml index 0b7d1959..4ab16a08 100644 --- a/test/fixtures/imports.yml +++ b/test/fixtures/imports.yml @@ -1,9 +1,9 @@ empty_import: - account: checking + account: depository created_at: <%= 1.minute.ago %> completed_import: - account: checking + account: depository column_mappings: date: date name: name diff --git a/test/fixtures/investments.yml b/test/fixtures/investments.yml index 10ecb60d..e0553ab0 100644 --- a/test/fixtures/investments.yml +++ b/test/fixtures/investments.yml @@ -1 +1 @@ -investment_brokerage: { } +one: { } \ No newline at end of file diff --git a/test/fixtures/loans.yml b/test/fixtures/loans.yml index 6043e466..e0553ab0 100644 --- a/test/fixtures/loans.yml +++ b/test/fixtures/loans.yml @@ -1 +1 @@ -loan_mortgage: { } +one: { } \ No newline at end of file diff --git a/test/fixtures/merchants.yml b/test/fixtures/merchants.yml index ade78c09..3e3ca05a 100644 --- a/test/fixtures/merchants.yml +++ b/test/fixtures/merchants.yml @@ -1,3 +1,7 @@ +one: + name: Test + family: empty + netflix: name: Netflix color: "#fd7f6f" diff --git a/test/fixtures/other_assets.yml b/test/fixtures/other_assets.yml index 74674e8c..09e7865d 100644 --- a/test/fixtures/other_assets.yml +++ b/test/fixtures/other_assets.yml @@ -1,3 +1,2 @@ -other_asset_collectable: { } - +one: { } diff --git a/test/fixtures/other_liabilities.yml b/test/fixtures/other_liabilities.yml index 08028f97..e0553ab0 100644 --- a/test/fixtures/other_liabilities.yml +++ b/test/fixtures/other_liabilities.yml @@ -1 +1 @@ -other_asset_iou: { } +one: { } \ No newline at end of file diff --git a/test/fixtures/properties.yml b/test/fixtures/properties.yml index c6026df2..e0553ab0 100644 --- a/test/fixtures/properties.yml +++ b/test/fixtures/properties.yml @@ -1 +1 @@ -property_house: { } +one: { } \ No newline at end of file diff --git a/test/fixtures/taggings.yml b/test/fixtures/taggings.yml index caefa609..5039ba27 100644 --- a/test/fixtures/taggings.yml +++ b/test/fixtures/taggings.yml @@ -1,10 +1,10 @@ one: - tag: hawaii_trip - taggable: checking_one + tag: one + taggable: one taggable_type: Account::Transaction two: - tag: emergency_fund - taggable: checking_two + tag: two + taggable: one taggable_type: Account::Transaction diff --git a/test/fixtures/tags.yml b/test/fixtures/tags.yml index 600a7fd1..1c76d6cc 100644 --- a/test/fixtures/tags.yml +++ b/test/fixtures/tags.yml @@ -1,11 +1,11 @@ -trips: +one: name: Trips family: dylan_family -hawaii_trip: - name: Hawaii Trip +two: + name: Emergency fund family: dylan_family -emergency_fund: - name: Emergency Fund - family: dylan_family +three: + name: Test + family: empty \ No newline at end of file diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index a78a3942..3c9aa4ed 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,3 +1,10 @@ +empty: + family: empty + first_name: User + last_name: One + email: user1@email.com + password_digest: <%= BCrypt::Password.create('password') %> + family_admin: family: dylan_family first_name: Bob diff --git a/test/fixtures/vehicles.yml b/test/fixtures/vehicles.yml index a0ea340e..e0553ab0 100644 --- a/test/fixtures/vehicles.yml +++ b/test/fixtures/vehicles.yml @@ -1 +1 @@ -vehicle_honda_accord: { } +one: { } \ No newline at end of file diff --git a/test/models/account/balance/calculator_test.rb b/test/models/account/balance/calculator_test.rb deleted file mode 100644 index bac5d4c3..00000000 --- a/test/models/account/balance/calculator_test.rb +++ /dev/null @@ -1,101 +0,0 @@ -require "test_helper" -require "csv" - -class Account::Balance::CalculatorTest < ActiveSupport::TestCase - include FamilySnapshotTestHelper - - 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 - required_exchange_rates_for_sync = [ - 1.0834, 1.0845, 1.0819, 1.0872, 1.0788, 1.0743, 1.0755, 1.0774, - 1.0778, 1.0783, 1.0773, 1.0709, 1.0729, 1.0773, 1.0778, 1.078, - 1.0809, 1.0818, 1.0824, 1.0822, 1.0854, 1.0845, 1.0839, 1.0807, - 1.084, 1.0856, 1.0858, 1.0898, 1.095, 1.094, 1.0926, 1.0986 - ] - - required_exchange_rates_for_sync.each_with_index do |exchange_rate, idx| - ExchangeRate.create! date: idx.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: exchange_rate - end - - # 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 - required_exchange_rates_for_sync = [ - { from_currency: "EUR", to_currency: "USD", date: 4.days.ago.to_date, rate: 1.0788 }, - { from_currency: "EUR", to_currency: "USD", date: 19.days.ago.to_date, rate: 1.0822 } - ] - - ExchangeRate.insert_all(required_exchange_rates_for_sync) - - 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 - - def calculated_balances_for(account_key) - Account::Balance::Calculator.new(accounts(account_key)).daily_balances - end -end diff --git a/test/models/account/balance/syncer_test.rb b/test/models/account/balance/syncer_test.rb new file mode 100644 index 00000000..c70f52e9 --- /dev/null +++ b/test/models/account/balance/syncer_test.rb @@ -0,0 +1,133 @@ +require "test_helper" + +class Account::Balance::SyncerTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @account = families(:empty).accounts.create!(name: "Test", balance: 20000, currency: "USD", accountable: Depository.new) + end + + test "syncs account with no entries" do + assert_equal 0, @account.balances.count + + syncer = Account::Balance::Syncer.new(@account) + syncer.run + + assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance) + end + + test "syncs account with valuations only" do + create_valuation(account: @account, date: 2.days.ago.to_date, amount: 22000) + + syncer = Account::Balance::Syncer.new(@account) + syncer.run + + assert_equal [ 22000, 22000, @account.balance ], @account.balances.chronological.map(&:balance) + end + + test "syncs account with transactions only" do + create_transaction(account: @account, date: 4.days.ago.to_date, amount: 100) + create_transaction(account: @account, date: 2.days.ago.to_date, amount: -500) + + syncer = Account::Balance::Syncer.new(@account) + syncer.run + + assert_equal [ 19600, 19500, 19500, 20000, 20000, @account.balance ], @account.balances.chronological.map(&:balance) + end + + test "syncs account with valuations and transactions" do + create_valuation(account: @account, date: 5.days.ago.to_date, amount: 20000) + create_transaction(account: @account, date: 3.days.ago.to_date, amount: -500) + create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) + create_valuation(account: @account, date: 1.day.ago.to_date, amount: 25000) + + syncer = Account::Balance::Syncer.new(@account) + syncer.run + + assert_equal [ 20000, 20000, 20500, 20400, 25000, @account.balance ], @account.balances.chronological.map(&:balance) + end + + test "syncs account with transactions in multiple currencies" do + ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2 + + create_transaction(account: @account, date: 3.days.ago.to_date, amount: 100, currency: "USD") + create_transaction(account: @account, date: 2.days.ago.to_date, amount: 300, currency: "USD") + create_transaction(account: @account, date: 1.day.ago.to_date, amount: 500, currency: "EUR") # €500 * 1.2 = $600 + + syncer = Account::Balance::Syncer.new(@account) + syncer.run + + assert_equal [ 21000, 20900, 20600, 20000, @account.balance ], @account.balances.chronological.map(&:balance) + end + + test "converts foreign account balances to family currency" do + @account.update! currency: "EUR" + + create_transaction(date: 1.day.ago.to_date, amount: 1000, account: @account, currency: "EUR") + + create_exchange_rate(2.days.ago.to_date, from: "EUR", to: "USD", rate: 2) + create_exchange_rate(1.day.ago.to_date, from: "EUR", to: "USD", rate: 2) + create_exchange_rate(Date.current, from: "EUR", to: "USD", rate: 2) + + syncer = Account::Balance::Syncer.new(@account) + syncer.run + + usd_balances = @account.balances.where(currency: "USD").chronological.map(&:balance) + eur_balances = @account.balances.where(currency: "EUR").chronological.map(&:balance) + + assert_equal [ 21000, 20000, @account.balance ], eur_balances # native account balances + assert_equal [ 42000, 40000, @account.balance * 2 ], usd_balances # converted balances at rate of 2:1 + end + + test "fails with error if exchange rate not available for any entry" do + create_transaction(account: @account, currency: "EUR") + + syncer = Account::Balance::Syncer.new(@account) + + assert_raises Money::ConversionError do + syncer.run + end + end + + # Account is able to calculate balances in its own currency (i.e. can still show a historical graph), but + # doesn't have exchange rates available to convert those calculated balances to the family currency + test "completes with warning if exchange rates not available to convert to family currency" do + @account.update! currency: "EUR" + + syncer = Account::Balance::Syncer.new(@account) + syncer.run + + assert_equal 1, syncer.warnings.count + end + + test "overwrites existing balances and purges stale balances" do + assert_equal 0, @account.balances.size + + @account.balances.create! date: Date.current, currency: "USD", balance: 30000 # incorrect balance, will be updated + @account.balances.create! date: 10.years.ago.to_date, currency: "USD", balance: 35000 # Out of range balance, will be deleted + + assert_equal 2, @account.balances.size + + syncer = Account::Balance::Syncer.new(@account) + syncer.run + + assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance) + end + + test "partial sync does not affect balances prior to sync start date" do + existing_balance = @account.balances.create! date: 2.days.ago.to_date, currency: "USD", balance: 30000 + + transaction = create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100, currency: "USD") + + syncer = Account::Balance::Syncer.new(@account, start_date: 1.day.ago.to_date) + syncer.run + + assert_equal [ existing_balance.balance, existing_balance.balance - transaction.amount, @account.balance ], @account.balances.chronological.map(&:balance) + end + + private + + def create_exchange_rate(date, from:, to:, rate:) + ExchangeRate.create! date: date, from_currency: from, to_currency: to, rate: rate + end +end diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb index 25c6a09c..edea8464 100644 --- a/test/models/account/entry_test.rb +++ b/test/models/account/entry_test.rb @@ -1,26 +1,29 @@ require "test_helper" class Account::EntryTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + setup do - @entry = account_entries :checking_one - @family = families :dylan_family + @entry = account_entries :transaction end test "valuations cannot have more than one entry per day" do - new_entry = Account::Entry.new \ - entryable: Account::Valuation.new, - date: @entry.date, # invalid - currency: @entry.currency, - amount: @entry.amount + existing_valuation = account_entries :valuation - assert new_entry.invalid? + new_valuation = Account::Entry.new \ + entryable: Account::Valuation.new, + date: existing_valuation.date, # invalid + currency: existing_valuation.currency, + amount: existing_valuation.amount + + assert new_valuation.invalid? end test "triggers sync with correct start date when transaction is set to prior date" do prior_date = @entry.date - 1 @entry.update! date: prior_date - @entry.account.expects(:sync_later).with(prior_date) + @entry.account.expects(:sync_later).with(start_date: prior_date) @entry.sync_account_later end @@ -28,48 +31,62 @@ class Account::EntryTest < ActiveSupport::TestCase prior_date = @entry.date @entry.update! date: @entry.date + 1 - @entry.account.expects(:sync_later).with(prior_date) + @entry.account.expects(:sync_later).with(start_date: prior_date) @entry.sync_account_later end test "triggers sync with correct start date when transaction deleted" do - prior_entry = account_entries(:checking_two) # 12 days ago - current_entry = account_entries(:checking_one) # 5 days ago + current_entry = create_transaction(date: 1.day.ago.to_date) + prior_entry = create_transaction(date: current_entry.date - 1.day) + current_entry.destroy! - current_entry.account.expects(:sync_later).with(prior_entry.date) + current_entry.account.expects(:sync_later).with(start_date: prior_entry.date) current_entry.sync_account_later end test "can search entries" do + family = families(:empty) + account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new + category = family.categories.first + merchant = family.merchants.first + + create_transaction(account: account, name: "a transaction") + create_transaction(account: account, name: "ignored") + create_transaction(account: account, name: "third transaction", category: category, merchant: merchant) + params = { search: "a" } - assert_equal 12, Account::Entry.search(params).size + assert_equal 2, family.entries.search(params).size - params = params.merge(categories: [ "Food & Drink" ]) # transaction specific search param + params = params.merge(categories: [ category.name ], merchants: [ merchant.name ]) # transaction specific search param - assert_equal 2, Account::Entry.search(params).size + assert_equal 1, family.entries.search(params).size end test "can calculate total spending for a group of transactions" do - assert_equal Money.new(2135), @family.entries.expense_total("USD") - assert_equal Money.new(1010.85, "EUR"), @family.entries.expense_total("EUR") + family = families(:empty) + account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new + create_transaction(account: account, amount: 100) + create_transaction(account: account, amount: 100) + create_transaction(account: account, amount: -500) # income, will be ignored + + assert_equal Money.new(200), family.entries.expense_total("USD") end test "can calculate total income for a group of transactions" do - assert_equal -Money.new(2075), @family.entries.income_total("USD") - assert_equal -Money.new(250, "EUR"), @family.entries.income_total("EUR") + family = families(:empty) + account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new + create_transaction(account: account, amount: -100) + create_transaction(account: account, amount: -100) + create_transaction(account: account, amount: 500) # income, will be ignored + + assert_equal Money.new(-200), family.entries.income_total("USD") end # See: https://github.com/maybe-finance/maybe/wiki/vision#signage-of-money test "transactions with negative amounts are inflows, positive amounts are outflows to an account" do - inflow_transaction = account_entries(:checking_four) - outflow_transaction = account_entries(:checking_five) - - assert inflow_transaction.amount < 0 - assert inflow_transaction.inflow? - - assert outflow_transaction.amount >= 0 - assert outflow_transaction.outflow? + assert create_transaction(amount: -10).inflow? + assert create_transaction(amount: 10).outflow? end end diff --git a/test/models/account/sync_test.rb b/test/models/account/sync_test.rb new file mode 100644 index 00000000..335dabe9 --- /dev/null +++ b/test/models/account/sync_test.rb @@ -0,0 +1,36 @@ +require "test_helper" + +class Account::SyncTest < ActiveSupport::TestCase + setup do + @account = accounts(:depository) + + @sync = Account::Sync.for(@account) + @balance_syncer = mock("Account::Balance::Syncer") + Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once + end + + test "runs sync" do + @balance_syncer.expects(:run).once + @balance_syncer.expects(:warnings).returns([ "test sync warning" ]).once + + assert_equal "pending", @sync.status + assert_equal [], @sync.warnings + assert_nil @sync.last_ran_at + + @sync.run + + assert_equal "completed", @sync.status + assert_equal [ "test sync warning" ], @sync.warnings + assert @sync.last_ran_at + end + + test "handles sync errors" do + @balance_syncer.expects(:run).raises(StandardError.new("test sync error")) + + @sync.run + + assert @sync.last_ran_at + assert_equal "failed", @sync.status + assert_equal "test sync error", @sync.error + end +end diff --git a/test/models/account/syncable_test.rb b/test/models/account/syncable_test.rb deleted file mode 100644 index 6eb8c808..00000000 --- a/test/models/account/syncable_test.rb +++ /dev/null @@ -1,124 +0,0 @@ -require "test_helper" - -class Account::SyncableTest < ActiveSupport::TestCase - include ActiveJob::TestHelper - - setup do - @account = accounts(:savings) - end - - test "calculates effective start date of an account" do - assert_equal 31.days.ago.to_date, accounts(:collectable).effective_start_date - assert_equal 31.days.ago.to_date, @account.effective_start_date - end - - test "syncs regular account" do - @account.sync - assert_equal "ok", @account.status - assert_equal 32, @account.balances.count - end - - test "syncs multi currency account" do - required_exchange_rates_for_sync = [ - { from_currency: "EUR", to_currency: "USD", date: 4.days.ago.to_date, rate: 1.0788 }, - { from_currency: "EUR", to_currency: "USD", date: 19.days.ago.to_date, rate: 1.0822 } - ] - - ExchangeRate.insert_all(required_exchange_rates_for_sync) - - account = accounts(:multi_currency) - account.sync - assert_equal "ok", account.status - assert_equal 32, account.balances.where(currency: "USD").count - end - - test "triggers sync job" do - assert_enqueued_with(job: AccountSyncJob, args: [ @account, Date.current ]) do - @account.sync_later(Date.current) - end - end - - test "account has no balances until synced" do - account = accounts(:savings) - - assert_equal 0, account.balances.count - end - - test "account has balances after syncing" do - account = accounts(:savings) - account.sync - - assert_equal 32, account.balances.count - end - - test "partial sync with missing historical balances performs a full sync" do - account = accounts(:savings) - account.sync 10.days.ago.to_date - - assert_equal 32, account.balances.count - end - - test "balances are updated after syncing" do - account = accounts(:savings) - balance_date = 10.days.ago - account.balances.create!(date: balance_date, balance: 1000) - account.sync - - assert_equal 19500, account.balances.find_by(date: balance_date)[:balance] - end - - test "can perform a partial sync with a given sync start date" do - # Perform a full sync to populate all balances - @account.sync - - # Perform partial sync - sync_start_date = 5.days.ago.to_date - balances_before_sync = @account.balances.to_a - @account.sync sync_start_date - balances_after_sync = @account.reload.balances.to_a - - # Balances on or after should be updated - balances_after_sync.each do |balance_after_sync| - balance_before_sync = balances_before_sync.find { |b| b.date == balance_after_sync.date } - - if balance_after_sync.date >= sync_start_date - assert balance_before_sync.updated_at < balance_after_sync.updated_at - else - assert_equal balance_before_sync.updated_at, balance_after_sync.updated_at - end - end - end - - test "foreign currency account has balances in each currency after syncing" do - required_exchange_rates_for_sync = [ - 1.0834, 1.0845, 1.0819, 1.0872, 1.0788, 1.0743, 1.0755, 1.0774, - 1.0778, 1.0783, 1.0773, 1.0709, 1.0729, 1.0773, 1.0778, 1.078, - 1.0809, 1.0818, 1.0824, 1.0822, 1.0854, 1.0845, 1.0839, 1.0807, - 1.084, 1.0856, 1.0858, 1.0898, 1.095, 1.094, 1.0926, 1.0986 - ] - - required_exchange_rates_for_sync.each_with_index do |exchange_rate, idx| - ExchangeRate.create! date: idx.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: exchange_rate - end - - account = accounts(:eur_checking) - account.sync - - 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) - - # Create old, stale balances that should be purged (since they are before account start date) - account.balances.create!(date: 1.year.ago, balance: 1000) - account.balances.create!(date: 2.years.ago, balance: 2000) - account.balances.create!(date: 3.years.ago, balance: 3000) - - account.sync - - assert_equal 32, account.balances.count - end -end diff --git a/test/models/account/transfer_test.rb b/test/models/account/transfer_test.rb index 8c380c0d..2c9265c6 100644 --- a/test/models/account/transfer_test.rb +++ b/test/models/account/transfer_test.rb @@ -2,22 +2,8 @@ require "test_helper" class Account::TransferTest < ActiveSupport::TestCase setup do - # Transfers can be posted on different dates - @outflow = accounts(:checking).entries.create! \ - date: 1.day.ago.to_date, - name: "Transfer to Savings", - amount: 100, - currency: "USD", - marked_as_transfer: true, - entryable: Account::Transaction.new - - @inflow = accounts(:savings).entries.create! \ - date: Date.current, - name: "Transfer from Savings", - amount: -100, - currency: "USD", - marked_as_transfer: true, - entryable: Account::Transaction.new + @outflow = account_entries(:transfer_out) + @inflow = account_entries(:transfer_in) end test "transfer valid if it has inflow and outflow from different accounts for the same amount" do @@ -28,14 +14,15 @@ class Account::TransferTest < ActiveSupport::TestCase test "transfer must have 2 transactions" do invalid_transfer_1 = Account::Transfer.new entries: [ @outflow ] - invalid_transfer_2 = Account::Transfer.new entries: [ @inflow, @outflow, account_entries(:savings_four) ] + invalid_transfer_2 = Account::Transfer.new entries: [ @inflow, @outflow, account_entries(:transaction) ] 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) + account = accounts(:depository) + inflow = account.entries.create! \ date: Date.current, name: "Inflow", diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 472ff059..b0c45a7d 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -1,38 +1,31 @@ require "test_helper" -require "csv" class AccountTest < ActiveSupport::TestCase - def setup - @account = accounts(:checking) + include ActiveJob::TestHelper + + setup do + @account = accounts(:depository) @family = families(:dylan_family) end - test "recognizes foreign currency account" do - regular_account = accounts(:checking) - foreign_account = accounts(:eur_checking) - assert_not regular_account.foreign_currency? - assert foreign_account.foreign_currency? + test "can sync later" do + assert_enqueued_with(job: AccountSyncJob, args: [ @account, start_date: Date.current ]) do + @account.sync_later start_date: Date.current + end end - test "recognizes multi currency account" do - regular_account = accounts(:checking) - multi_currency_account = accounts(:multi_currency) - assert_not regular_account.multi_currency? - assert multi_currency_account.multi_currency? - end + test "can sync" do + start_date = 10.days.ago.to_date - test "multi currency and foreign currency are different concepts" do - multi_currency_account = accounts(:multi_currency) - assert_equal multi_currency_account.family.currency, multi_currency_account.currency - assert multi_currency_account.multi_currency? - assert_not multi_currency_account.foreign_currency? + mock_sync = mock("Account::Sync") + mock_sync.expects(:run).once + + Account::Sync.expects(:for).with(@account, start_date: start_date).returns(mock_sync).once + + @account.sync start_date: start_date end test "groups accounts by type" do - @family.accounts.each do |account| - account.sync - end - result = @family.accounts.by_group(period: Period.all) assets = result[:assets] liabilities = result[:liabilities] @@ -50,7 +43,7 @@ class AccountTest < ActiveSupport::TestCase loans = liabilities.children.find { |group| group.name == "Loan" } other_liabilities = liabilities.children.find { |group| group.name == "OtherLiability" } - assert_equal 4, depositories.children.count + assert_equal 1, depositories.children.count assert_equal 1, properties.children.count assert_equal 1, vehicles.children.count assert_equal 1, investments.children.count @@ -61,38 +54,24 @@ class AccountTest < ActiveSupport::TestCase assert_equal 1, other_liabilities.children.count end - test "generates series with last balance equal to current account balance" do - # If account hasn't been synced, series falls back to a single point with the current balance - assert_equal @account.balance_money, @account.series.last.value - - @account.sync - - # Synced series will always have final balance equal to the current account balance - assert_equal @account.balance_money, @account.series.last.value + test "generates balance series" do + assert_equal 2, @account.series.values.count end - test "generates empty series for foreign currency if no exchange rate" do - account = accounts(:eur_checking) - - # We know EUR -> NZD exchange rate is not available in fixtures - assert_equal 0, account.series(currency: "NZD").values.count + test "generates balance series with single value if no balances" do + @account.balances.delete_all + assert_equal 1, @account.series.values.count end - test "should destroy dependent transactions" do - assert_difference("Account::Transaction.count", -@account.transactions.count) do - @account.destroy - end + test "generates balance series in period" do + @account.balances.delete_all + @account.balances.create! date: 31.days.ago.to_date, balance: 5000, currency: "USD" # out of period range + @account.balances.create! date: 30.days.ago.to_date, balance: 5000, currency: "USD" # in range + + assert_equal 1, @account.series(period: Period.last_30_days).values.count end - test "should destroy dependent balances" do - assert_difference("Account::Balance.count", -@account.balances.count) do - @account.destroy - end - end - - test "should destroy dependent valuations" do - assert_difference("Account::Valuation.count", -@account.valuations.count) do - @account.destroy - end + test "generates empty series if no balances and no exchange rate" do + assert_equal 0, @account.series(currency: "NZD").values.count end end diff --git a/test/models/category_test.rb b/test/models/category_test.rb index 4020747b..b7e60b20 100644 --- a/test/models/category_test.rb +++ b/test/models/category_test.rb @@ -20,7 +20,7 @@ class CategoryTest < ActiveSupport::TestCase end test "updating name should clear the internal_category field" do - category = Category.take + category = categories(:income) assert_changes "category.reload.internal_category", to: nil do category.update_attribute(:name, "new name") end diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 9efc06ed..de4b4b55 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -2,144 +2,148 @@ require "test_helper" require "csv" class FamilyTest < ActiveSupport::TestCase - include FamilySnapshotTestHelper + include Account::EntriesTestHelper def setup - @family = families(:dylan_family) - - required_exchange_rates_for_family = [ - 1.0834, 1.0845, 1.0819, 1.0872, 1.0788, 1.0743, 1.0755, 1.0774, - 1.0778, 1.0783, 1.0773, 1.0709, 1.0729, 1.0773, 1.0778, 1.078, - 1.0809, 1.0818, 1.0824, 1.0822, 1.0854, 1.0845, 1.0839, 1.0807, - 1.084, 1.0856, 1.0858, 1.0898, 1.095, 1.094, 1.0926, 1.0986 - ] - - required_exchange_rates_for_family.each_with_index do |exchange_rate, idx| - ExchangeRate.create! date: idx.days.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: exchange_rate - end - - @family.accounts.each do |account| - account.sync - end + @family = families :empty end - test "should have many users" do - assert @family.users.size > 0 - assert @family.users.include?(users(:family_admin)) + test "calculates assets" do + assert_equal Money.new(0, @family.currency), @family.assets + + @family.accounts.create!(balance: 1000, accountable: Depository.new) + @family.accounts.create!(balance: 5000, accountable: OtherAsset.new) + @family.accounts.create!(balance: 10000, accountable: CreditCard.new) # ignored + + assert_equal Money.new(1000 + 5000, @family.currency), @family.assets end - test "should have many accounts" do - assert @family.accounts.size > 0 + test "calculates liabilities" do + assert_equal Money.new(0, @family.currency), @family.liabilities + + @family.accounts.create!(balance: 1000, accountable: CreditCard.new) + @family.accounts.create!(balance: 5000, accountable: OtherLiability.new) + @family.accounts.create!(balance: 10000, accountable: Depository.new) # ignored + + assert_equal Money.new(1000 + 5000, @family.currency), @family.liabilities end - test "should destroy dependent users" do - assert_difference("User.count", -@family.users.count) do - @family.destroy - end - end + test "calculates net worth" do + assert_equal Money.new(0, @family.currency), @family.net_worth - test "should destroy dependent accounts" do - assert_difference("Account.count", -@family.accounts.count) do - @family.destroy - end - end + @family.accounts.create!(balance: 1000, accountable: CreditCard.new) + @family.accounts.create!(balance: 50000, accountable: Depository.new) - test "should destroy dependent transaction categories" do - assert_difference("Category.count", -@family.categories.count) do - @family.destroy - end - end - - test "should destroy dependent merchants" do - assert_difference("Merchant.count", -@family.merchants.count) do - @family.destroy - end - end - - test "should calculate total assets" do - expected = get_today_snapshot_value_for :assets - assert_in_delta expected, @family.assets.amount, 0.01 - end - - test "should calculate total liabilities" do - expected = get_today_snapshot_value_for :liabilities - assert_in_delta expected, @family.liabilities.amount, 0.01 - end - - test "should calculate net worth" do - expected = get_today_snapshot_value_for :net_worth - assert_in_delta expected, @family.net_worth.amount, 0.01 - end - - test "calculates asset time series" do - series = @family.snapshot[:asset_series] - expected_series = get_expected_balances_for :assets - - assert_time_series_balances series, expected_series - end - - test "calculates liability time series" do - series = @family.snapshot[:liability_series] - expected_series = get_expected_balances_for :liabilities - - assert_time_series_balances series, expected_series - end - - test "calculates net worth time series" do - series = @family.snapshot[:net_worth_series] - expected_series = get_expected_balances_for :net_worth - - 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 + assert_equal Money.new(50000 - 1000, @family.currency), @family.net_worth end test "should exclude disabled accounts from calculations" do - assets_before = @family.assets - liabilities_before = @family.liabilities - net_worth_before = @family.net_worth + cc = @family.accounts.create!(balance: 1000, accountable: CreditCard.new) + @family.accounts.create!(balance: 50000, accountable: Depository.new) - disabled_checking = accounts(:checking) - disabled_cc = accounts(:credit_card) + assert_equal Money.new(50000 - 1000, @family.currency), @family.net_worth - disabled_checking.update!(is_active: false) - disabled_cc.update!(is_active: false) + cc.update! is_active: false - assert_equal assets_before - disabled_checking.balance, @family.assets - assert_equal liabilities_before - disabled_cc.balance, @family.liabilities - assert_equal net_worth_before - disabled_checking.balance + disabled_cc.balance, @family.net_worth + assert_equal Money.new(50000, @family.currency), @family.net_worth end - private + test "syncs active accounts" do + account = @family.accounts.create!(balance: 1000, accountable: CreditCard.new, is_active: false) - 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 + Account.any_instance.expects(:sync_later).never - 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 + @family.sync + + account.update! is_active: true + + Account.any_instance.expects(:sync_later).with(start_date: nil).once + + @family.sync + end + + test "calculates snapshot" do + asset = @family.accounts.create!(balance: 500, accountable: Depository.new) + liability = @family.accounts.create!(balance: 100, accountable: CreditCard.new) + + asset.balances.create! date: 1.day.ago.to_date, currency: "USD", balance: 450 + asset.balances.create! date: Date.current, currency: "USD", balance: 500 + + liability.balances.create! date: 1.day.ago.to_date, currency: "USD", balance: 50 + liability.balances.create! date: Date.current, currency: "USD", balance: 100 + + expected_asset_series = [ + { date: 1.day.ago.to_date, value: Money.new(450) }, + { date: Date.current, value: Money.new(500) } + ] + + expected_liability_series = [ + { date: 1.day.ago.to_date, value: Money.new(50) }, + { date: Date.current, value: Money.new(100) } + ] + + expected_net_worth_series = [ + { date: 1.day.ago.to_date, value: Money.new(450 - 50) }, + { date: Date.current, value: Money.new(500 - 100) } + ] + + assert_equal expected_asset_series, @family.snapshot[:asset_series].values.map { |v| { date: v.date, value: v.value } } + assert_equal expected_liability_series, @family.snapshot[:liability_series].values.map { |v| { date: v.date, value: v.value } } + assert_equal expected_net_worth_series, @family.snapshot[:net_worth_series].values.map { |v| { date: v.date, value: v.value } } + end + + test "calculates top movers" do + checking_account = @family.accounts.create!(balance: 500, accountable: Depository.new) + savings_account = @family.accounts.create!(balance: 1000, accountable: Depository.new) + + create_transaction(account: checking_account, date: 2.days.ago.to_date, amount: -1000) + create_transaction(account: checking_account, date: 1.day.ago.to_date, amount: 10) + create_transaction(account: savings_account, date: 2.days.ago.to_date, amount: -5000) + + snapshot = @family.snapshot_account_transactions + top_spenders = snapshot[:top_spenders] + top_earners = snapshot[:top_earners] + top_savers = snapshot[:top_savers] + + assert_equal 10, top_spenders.first.spending + + assert_equal 5000, top_earners.first.income + assert_equal 1000, top_earners.second.income + + assert_equal 1, top_savers.first.savings_rate + assert_equal ((1000 - 10).to_f / 1000), top_savers.second.savings_rate + end + + test "calculates rolling transaction totals" do + account = @family.accounts.create!(balance: 1000, accountable: Depository.new) + create_transaction(account: account, date: 2.days.ago.to_date, amount: -500) + create_transaction(account: account, date: 1.day.ago.to_date, amount: 100) + create_transaction(account: account, date: Date.current, amount: 20) + + snapshot = @family.snapshot_transactions + + expected_income_series = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 500, 500, 500 + ] + + assert_equal expected_income_series, snapshot[:income_series].values.map(&:value).map(&:amount) + + expected_spending_series = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 100, 120 + ] + + assert_equal expected_spending_series, snapshot[:spending_series].values.map(&:value).map(&:amount) + + expected_savings_rate_series = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 1, 0.8, 0.76 + ] + + assert_equal expected_savings_rate_series, snapshot[:savings_rate_series].values.map(&:value).map { |v| v.round(2) } + end end diff --git a/test/models/tag_test.rb b/test/models/tag_test.rb index 26582fc6..2d4cba2d 100644 --- a/test/models/tag_test.rb +++ b/test/models/tag_test.rb @@ -2,8 +2,8 @@ require "test_helper" class TagTest < ActiveSupport::TestCase test "replace and destroy" do - old_tag = tags(:hawaii_trip) - new_tag = tags(:trips) + old_tag = tags(:one) + new_tag = tags(:two) assert_difference "Tag.count", -1 do old_tag.replace_and_destroy!(new_tag) diff --git a/test/models/value_group_test.rb b/test/models/value_group_test.rb index 3b0c676d..e27010a1 100644 --- a/test/models/value_group_test.rb +++ b/test/models/value_group_test.rb @@ -2,10 +2,6 @@ require "test_helper" require "ostruct" class ValueGroupTest < ActiveSupport::TestCase setup do - checking = accounts(:checking) - savings = accounts(:savings) - collectable = accounts(:collectable) - # Level 1 @assets = ValueGroup.new("Assets", :usd) diff --git a/test/support/account/entries_test_helper.rb b/test/support/account/entries_test_helper.rb new file mode 100644 index 00000000..4a4dd339 --- /dev/null +++ b/test/support/account/entries_test_helper.rb @@ -0,0 +1,30 @@ +module Account::EntriesTestHelper + def create_transaction(attributes = {}) + entry_attributes = attributes.except(:category, :tags, :merchant) + transaction_attributes = attributes.slice(:category, :tags, :merchant) + + entry_defaults = { + account: accounts(:depository), + name: "Transaction", + date: Date.current, + currency: "USD", + amount: 100, + entryable: Account::Transaction.new(transaction_attributes) + } + + Account::Entry.create! entry_defaults.merge(entry_attributes) + end + + def create_valuation(attributes = {}) + entry_defaults = { + account: accounts(:depository), + name: "Valuation", + date: 1.day.ago.to_date, + currency: "USD", + amount: 5000, + entryable: Account::Valuation.new + } + + Account::Entry.create! entry_defaults.merge(attributes) + end +end diff --git a/test/support/family_snapshot_test_helper.rb b/test/support/family_snapshot_test_helper.rb deleted file mode 100644 index f691f34f..00000000 --- a/test/support/family_snapshot_test_helper.rb +++ /dev/null @@ -1,21 +0,0 @@ -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 6516e3ed..25a75223 100644 --- a/test/system/transactions_test.rb +++ b/test/system/transactions_test.rb @@ -4,24 +4,28 @@ class TransactionsTest < ApplicationSystemTestCase setup do sign_in @user = users(:family_admin) + Account::Entry.delete_all # clean slate + + create_transaction("one", 12.days.ago.to_date, 100) + create_transaction("two", 10.days.ago.to_date, 100) + create_transaction("three", 9.days.ago.to_date, 100) + create_transaction("four", 8.days.ago.to_date, 100) + create_transaction("five", 7.days.ago.to_date, 100) + create_transaction("six", 7.days.ago.to_date, 100) + create_transaction("seven", 4.days.ago.to_date, 100) + create_transaction("eight", 3.days.ago.to_date, 100) + create_transaction("nine", 1.days.ago.to_date, 100) + create_transaction("ten", 1.days.ago.to_date, 100) + create_transaction("eleven", Date.current, 100, category: categories(:food_and_drink), tags: [ tags(:one) ], merchant: merchants(:amazon)) + + @transactions = @user.family.entries + .account_transactions + .reverse_chronological + + @transaction = @transactions.first + @page_size = 10 - @latest_transactions = @user.family.entries - .account_transactions - .without_transfers - .reverse_chronological - .limit(20).to_a - @test_category = @user.family.categories.create! name: "System Test Category" - @test_merchant = @user.family.merchants.create! name: "System Test Merchant" - - @target_txn = @user.family.accounts.first.entries.create! \ - name: "Oldest transaction", - date: 10.years.ago.to_date, - currency: @user.family.currency, - amount: 100, - entryable: Account::Transaction.new(category: @test_category, - merchant: @test_merchant) - visit transactions_url(per_page: @page_size) end @@ -29,13 +33,13 @@ class TransactionsTest < ApplicationSystemTestCase assert_selector "h1", text: "Transactions" within "form#transactions-search" do - fill_in "Search transactions by name", with: @target_txn.name + fill_in "Search transactions by name", with: @transaction.name end - assert_selector "#" + dom_id(@target_txn), count: 1 + assert_selector "#" + dom_id(@transaction), count: 1 within "#transaction-search-filters" do - assert_text @target_txn.name + assert_text @transaction.name end end @@ -43,30 +47,34 @@ class TransactionsTest < ApplicationSystemTestCase find("#transaction-filters-button").click within "#transaction-filters-menu" do - check(@target_txn.account.name) + check(@transaction.account.name) click_button "Category" - check(@test_category.name) + check(@transaction.account_transaction.category.name) click_button "Apply" end - assert_selector "#" + dom_id(@target_txn), count: 1 + assert_selector "#" + dom_id(@transaction), count: 1 within "#transaction-search-filters" do - assert_text @target_txn.account.name - assert_text @target_txn.account_transaction.category.name + assert_text @transaction.account.name + assert_text @transaction.account_transaction.category.name end end test "all filters work and empty state shows if no match" do find("#transaction-filters-button").click + account = @transaction.account + category = @transaction.account_transaction.category + merchant = @transaction.account_transaction.merchant + within "#transaction-filters-menu" do click_button "Account" - check(@target_txn.account.name) + check(account.name) click_button "Date" fill_in "q_start_date", with: 10.days.ago.to_date - fill_in "q_end_date", with: Date.current + fill_in "q_end_date", with: 1.day.ago.to_date click_button "Type" assert_text "Filter by type coming soon..." @@ -75,10 +83,10 @@ class TransactionsTest < ApplicationSystemTestCase assert_text "Filter by amount coming soon..." click_button "Category" - check(@test_category.name) + check(category.name) click_button "Merchant" - check(@test_merchant.name) + check(merchant.name) click_button "Apply" end @@ -91,14 +99,14 @@ class TransactionsTest < ApplicationSystemTestCase assert_text "No entries found" within "ul#transaction-search-filters" do - find("li", text: @target_txn.account.name).first("a").click + find("li", text: account.name).first("a").click find("li", text: "on or after #{10.days.ago.to_date}").first("a").click - find("li", text: "on or before #{Date.current}").first("a").click - find("li", text: @target_txn.account_transaction.category.name).first("a").click - find("li", text: @target_txn.account_transaction.merchant.name).first("a").click + find("li", text: "on or before #{1.day.ago.to_date}").first("a").click + find("li", text: category.name).first("a").click + find("li", text: merchant.name).first("a").click end - assert_selector "#" + dom_id(@user.family.entries.reverse_chronological.first), count: 1 + assert_selector "#" + dom_id(@transaction), count: 1 end test "can select and deselect entire page of transactions" do @@ -109,36 +117,52 @@ class TransactionsTest < ApplicationSystemTestCase end test "can select and deselect groups of transactions" do - date_transactions_checkbox(12.days.ago.to_date).check - assert_selection_count(3) - date_transactions_checkbox(12.days.ago.to_date).uncheck + date_transactions_checkbox(1.day.ago.to_date).check + assert_selection_count(2) + + date_transactions_checkbox(1.day.ago.to_date).uncheck assert_selection_count(0) end test "can select and deselect individual transactions" do - transaction_checkbox(@latest_transactions.first).check + transaction_checkbox(@transactions.first).check assert_selection_count(1) - transaction_checkbox(@latest_transactions.second).check + transaction_checkbox(@transactions.second).check assert_selection_count(2) - transaction_checkbox(@latest_transactions.second).uncheck + transaction_checkbox(@transactions.second).uncheck assert_selection_count(1) end test "outermost group always overrides inner selections" do - transaction_checkbox(@latest_transactions.first).check + transaction_checkbox(@transactions.first).check assert_selection_count(1) + all_transactions_checkbox.check assert_selection_count(number_of_transactions_on_page) - transaction_checkbox(@latest_transactions.first).uncheck + + transaction_checkbox(@transactions.first).uncheck assert_selection_count(number_of_transactions_on_page - 1) - date_transactions_checkbox(12.days.ago.to_date).uncheck - assert_selection_count(number_of_transactions_on_page - 4) + + date_transactions_checkbox(1.day.ago.to_date).uncheck + assert_selection_count(number_of_transactions_on_page - 3) + all_transactions_checkbox.uncheck assert_selection_count(0) end private + def create_transaction(name, date, amount, category: nil, merchant: nil, tags: []) + account = accounts(:depository) + + account.entries.create! \ + name: name, + date: date, + amount: amount, + currency: "USD", + entryable: Account::Transaction.new(category: category, merchant: merchant, tags: tags) + end + def number_of_transactions_on_page [ @user.family.entries.without_transfers.count, @page_size ].min end diff --git a/test/system/transfers_test.rb b/test/system/transfers_test.rb index 48d51b5c..702ca81f 100644 --- a/test/system/transfers_test.rb +++ b/test/system/transfers_test.rb @@ -7,8 +7,8 @@ class TransfersTest < ApplicationSystemTestCase end test "can create a transfer" do - checking_name = accounts(:checking).name - savings_name = accounts(:savings).name + checking_name = accounts(:depository).name + savings_name = accounts(:credit_card).name transfer_date = Date.current click_on "New transaction" @@ -32,15 +32,15 @@ class TransfersTest < ApplicationSystemTestCase test "can match 2 transactions and create a transfer" do transfer_date = Date.current - outflow = accounts(:savings).entries.create! \ - name: "Outflow from savings account", + outflow = accounts(:depository).entries.create! \ + name: "Outflow from checking account", date: transfer_date, amount: 100, currency: "USD", entryable: Account::Transaction.new - inflow = accounts(:checking).entries.create! \ - name: "Inflow to checking account", + inflow = accounts(:credit_card).entries.create! \ + name: "Inflow to cc account", date: transfer_date, amount: -100, currency: "USD", @@ -64,7 +64,7 @@ class TransfersTest < ApplicationSystemTestCase txn = @user.family.entries.reverse_chronological.first within "#" + dom_id(txn) do - assert_text "Uncategorized" + assert_text txn.account_transaction.category.name || "Uncategorized" end transaction_entry_checkbox(txn).check