1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00

Account::Sync model and test fixture simplifications (#968)

* Add sync model

* Fresh fixtures for sync tests

* Sync tests overhaul

* Fix entry tests

* Complete remaining model test updates

* Update system tests

* Update demo data task

* Add system tests back to PR checks

* More simplifications, add empty family to fixtures for easier testing
This commit is contained in:
Zach Gollwitzer 2024-07-10 11:22:59 -04:00 committed by GitHub
parent de5a2e55b3
commit c6bdf49f10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 929 additions and 1353 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 %>
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
<div class="p-4 flex justify-between">
<div class="space-y-2">

View file

@ -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

View file

@ -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

18
db/schema.rb generated
View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

11
test/fixtures/account/balances.yml vendored Normal file
View file

@ -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

View file

@ -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
entryable: transfer_in

13
test/fixtures/account/syncs.yml vendored Normal file
View file

@ -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

View file

@ -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: { }

View file

@ -1,2 +1 @@
credit_card_payment: { }
savings_transfer: { }
one: { }

View file

@ -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: { }
one: { }
two: { }

View file

@ -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

View file

@ -1,3 +1,7 @@
one:
name: Test
family: empty
income:
name: Income
internal_category: income

View file

@ -1 +1 @@
credit_one: { }
one: { }

View file

@ -1,4 +1 @@
depository_checking: { }
depository_savings: { }
depository_eur_checking: { }
depository_multi_currency: { }
one: { }

View file

@ -1,2 +1,6 @@
empty:
name: Family
dylan_family:
name: The Dylan Family

View file

@ -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
1 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
2 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
3 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
4 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
5 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
6 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
7 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
8 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
9 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
10 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
11 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
12 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
13 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
14 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
15 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
16 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
17 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
18 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
19 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
20 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
21 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
22 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
23 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
24 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
25 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
26 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
27 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
28 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
29 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
30 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
31 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
32 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
33 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

View file

@ -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

View file

@ -1 +1 @@
investment_brokerage: { }
one: { }

View file

@ -1 +1 @@
loan_mortgage: { }
one: { }

View file

@ -1,3 +1,7 @@
one:
name: Test
family: empty
netflix:
name: Netflix
color: "#fd7f6f"

View file

@ -1,3 +1,2 @@
other_asset_collectable: { }
one: { }

View file

@ -1 +1 @@
other_asset_iou: { }
one: { }

View file

@ -1 +1 @@
property_house: { }
one: { }

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1 +1 @@
vehicle_honda_accord: { }
one: { }

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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