1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-22 06:39:39 +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 - name: System tests
run: DISABLE_PARALLELIZATION=true bin/rails test:system 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 - name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View file

@ -71,19 +71,8 @@ class AccountsController < ApplicationController
end end
def sync_all def sync_all
synced_accounts_count = 0 Current.family.accounts.active.sync
Current.family.accounts.each do |account| redirect_back_or_to accounts_path, notice: t(".success")
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
end end
private private

View file

@ -1,7 +1,7 @@
class AccountSyncJob < ApplicationJob class AccountSyncJob < ApplicationJob
queue_as :default queue_as :default
def perform(account, start_date = nil) def perform(account, start_date: nil)
account.sync(start_date) account.sync(start_date: start_date)
end end
end end

View file

@ -14,10 +14,10 @@ class Account < ApplicationRecord
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation" has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
has_many :balances, dependent: :destroy has_many :balances, dependent: :destroy
has_many :imports, dependent: :destroy has_many :imports, dependent: :destroy
has_many :syncs, dependent: :destroy
monetize :balance monetize :balance
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true } enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
scope :active, -> { where(is_active: true) } scope :active, -> { where(is_active: true) }
@ -73,24 +73,15 @@ class Account < ApplicationRecord
end end
end end
def balance_on(date) def alert
balances.where("date <= ?", date).order(date: :desc).first&.balance latest_sync = syncs.latest
[ latest_sync&.error, *latest_sync&.warnings ].compact.first
end end
def favorable_direction def favorable_direction
classification == "asset" ? "up" : "down" classification == "asset" ? "up" : "down"
end 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) def series(period: Period.all, currency: self.currency)
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code) 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 validates :account, :date, :balance, presence: true
monetize :balance monetize :balance
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) } scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
scope :chronological, -> { order(:date) }
end 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 sync_start_date = [ date_previously_was, date ].compact.min
end end
account.sync_later(sync_start_date) account.sync_later(start_date: sync_start_date)
end end
def inflow? def inflow?
@ -122,19 +122,17 @@ class Account::Entry < ApplicationRecord
end end
def income_total(currency = "USD") def income_total(currency = "USD")
account_transactions.includes(:entryable) without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount <= 0") .where("account_entries.amount <= 0")
.where("account_entries.currency = ?", currency) .map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.reject { |e| e.marked_as_transfer? } .sum
.sum(&:amount_money)
end end
def expense_total(currency = "USD") def expense_total(currency = "USD")
account_transactions.includes(:entryable) without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount > 0") .where("account_entries.amount > 0")
.where("account_entries.currency = ?", currency) .map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.reject { |e| e.marked_as_transfer? } .sum
.sum(&:amount_money)
end end
def search(params) 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 module Account::Syncable
extend ActiveSupport::Concern extend ActiveSupport::Concern
def sync_later(start_date = nil) class_methods do
AccountSyncJob.perform_later(self, start_date) def sync(start_date: nil)
end all.each { |a| a.sync_later(start_date: start_date) }
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}")
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
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
end end
if foreign_currency? def syncing?
(effective_start_date..Date.current).each do |date| syncs.syncing.any?
rate_candidates << { date: date, from_currency: self.currency, to_currency: self.family.currency }
end
end end
return if rate_candidates.blank? def sync_later(start_date: nil)
AccountSyncJob.perform_later(self, start_date: start_date)
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 end
nil def sync(start_date: nil)
Account::Sync.for(self, start_date: start_date).run
end end
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) Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
end end
def sync_accounts def sync(start_date: nil)
accounts.each { |account| account.sync_later if account.can_sync? } accounts.active.sync(start_date: start_date)
end end
end end

View file

@ -47,12 +47,11 @@
<%= turbo_frame_tag "sync_message" do %> <%= turbo_frame_tag "sync_message" do %>
<%= render partial: "accounts/sync_message", locals: { is_syncing: @account.syncing? } %> <%= render partial: "accounts/sync_message", locals: { is_syncing: @account.syncing? } %>
<% end %> <% end %>
<% @account.sync_errors.each do |message| %>
<%= render partial: "shared/alert", locals: { type: "error", content: t("." + message) } %> <% if @account.alert %>
<% end %> <%= render partial: "shared/alert", locals: { type: "error", content: t("." + @account.alert) } %>
<% @account.sync_warnings.each do |message| %>
<%= render partial: "shared/alert", locals: { type: "warning", content: t("." + message) } %>
<% end %> <% end %>
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg"> <div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
<div class="p-4 flex justify-between"> <div class="p-4 flex justify-between">
<div class="space-y-2"> <div class="space-y-2">

View file

@ -66,7 +66,6 @@ en:
sync: sync:
success: Account sync started success: Account sync started
sync_all: sync_all:
no_accounts_to_sync: No accounts were eligible for syncing. success: Successfully queued accounts for syncing.
success: Successfully queued %{count} accounts for syncing.
update: update:
success: Account updated 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" t.index ["transfer_id"], name: "index_account_entries_on_transfer_id"
end 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| create_table "account_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_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.decimal "balance", precision: 19, scale: 4, default: "0.0"
t.string "currency", default: "USD" t.string "currency", default: "USD"
t.boolean "is_active", default: true, null: false 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.date "last_sync_date"
t.uuid "institution_id" 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 ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["family_id"], name: "index_accounts_on_family_id"
t.index ["institution_id"], name: "index_accounts_on_institution_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_balances", "accounts", on_delete: :cascade
add_foreign_key "account_entries", "account_transfers", column: "transfer_id" add_foreign_key "account_entries", "account_transfers", column: "transfer_id"
add_foreign_key "account_entries", "accounts" 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", "categories", on_delete: :nullify
add_foreign_key "account_transactions", "merchants" add_foreign_key "account_transactions", "merchants"
add_foreign_key "accounts", "families" add_foreign_key "accounts", "families"

View file

@ -323,9 +323,7 @@ namespace :demo_data do
puts "Syncing accounts... This may take a few seconds." puts "Syncing accounts... This may take a few seconds."
family.accounts.each do |account| family.sync
account.sync
end
puts "Accounts synced. Demo data reset complete." puts "Accounts synced. Demo data reset complete."
end end

View file

@ -3,49 +3,48 @@ require "test_helper"
class Account::EntriesControllerTest < ActionDispatch::IntegrationTest class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
setup do setup do
sign_in @user = users(:family_admin) sign_in @user = users(:family_admin)
@account = accounts(:savings) @transaction = account_entries :transaction
@transaction_entry = @account.entries.account_transactions.first @valuation = account_entries :valuation
@valuation_entry = @account.entries.account_valuations.first
end end
test "should edit valuation entry" do 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 assert_response :success
end end
test "should show transaction entry" do test "should show transaction entry" do
get account_entry_url(@account, @transaction_entry) get account_entry_url(@transaction.account, @transaction)
assert_response :success assert_response :success
end end
test "should show valuation entry" do test "should show valuation entry" do
get account_entry_url(@account, @valuation_entry) get account_entry_url(@valuation.account, @valuation)
assert_response :success assert_response :success
end end
test "should get list of transaction entries" do test "should get list of transaction entries" do
get transaction_account_entries_url(@account) get transaction_account_entries_url(@transaction.account)
assert_response :success assert_response :success
end end
test "should get list of valuation entries" do test "should get list of valuation entries" do
get valuation_account_entries_url(@account) get valuation_account_entries_url(@valuation.account)
assert_response :success assert_response :success
end end
test "gets new entry by type" do 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 assert_response :success
end end
test "should create valuation" do test "should create valuation" do
assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 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: { account_entry: {
name: "Manual valuation", name: "Manual valuation",
amount: 19800, amount: 19800,
date: Date.current, date: Date.current,
currency: @account.currency, currency: @valuation.account.currency,
entryable_type: "Account::Valuation", entryable_type: "Account::Valuation",
entryable_attributes: {} entryable_attributes: {}
} }
@ -54,16 +53,16 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
assert_equal "Valuation created", flash[:notice] assert_equal "Valuation created", flash[:notice]
assert_enqueued_with job: AccountSyncJob assert_enqueued_with job: AccountSyncJob
assert_redirected_to account_path(@account) assert_redirected_to account_path(@valuation.account)
end end
test "error when valuation already exists for date" do test "error when valuation already exists for date" do
assert_no_difference_in_entries do assert_no_difference_in_entries do
post account_entries_url(@account), params: { post account_entries_url(@valuation.account), params: {
account_entry: { account_entry: {
amount: 19800, amount: 19800,
date: @valuation_entry.date, date: @valuation.date,
currency: @valuation_entry.currency, currency: @valuation.currency,
entryable_type: "Account::Valuation", entryable_type: "Account::Valuation",
entryable_attributes: {} entryable_attributes: {}
} }
@ -71,33 +70,33 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
end end
assert_equal "Date has already been taken", flash[:error] assert_equal "Date has already been taken", flash[:error]
assert_redirected_to account_path(@account) assert_redirected_to account_path(@valuation.account)
end end
test "can update entry without entryable attributes" do test "can update entry without entryable attributes" do
assert_no_difference_in_entries 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: { account_entry: {
name: "Updated name" name: "Updated name"
} }
} }
end end
assert_redirected_to account_entry_url(@account, @valuation_entry) assert_redirected_to account_entry_url(@valuation.account, @valuation)
assert_enqueued_with(job: AccountSyncJob) assert_enqueued_with(job: AccountSyncJob)
end end
test "should update transaction entry with entryable attributes" do test "should update transaction entry with entryable attributes" do
assert_no_difference_in_entries 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: { account_entry: {
name: "Updated name", name: "Updated name",
date: Date.current, date: Date.current,
currency: "USD", currency: "USD",
amount: 20, amount: 20,
entryable_type: @transaction_entry.entryable_type, entryable_type: @transaction.entryable_type,
entryable_attributes: { entryable_attributes: {
id: @transaction_entry.entryable_id, id: @transaction.entryable_id,
tag_ids: [ Tag.first.id, Tag.second.id ], tag_ids: [ Tag.first.id, Tag.second.id ],
category_id: Category.first.id, category_id: Category.first.id,
merchant_id: Merchant.first.id, merchant_id: Merchant.first.id,
@ -108,17 +107,17 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
} }
end end
assert_redirected_to account_entry_url(@account, @transaction_entry) assert_redirected_to account_entry_url(@transaction.account, @transaction)
assert_enqueued_with(job: AccountSyncJob) assert_enqueued_with(job: AccountSyncJob)
end end
test "should destroy transaction entry" do 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 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 end
assert_redirected_to account_url(@account) assert_redirected_to account_url(entry.account)
assert_enqueued_with(job: AccountSyncJob) assert_enqueued_with(job: AccountSyncJob)
end end
end end

View file

@ -14,8 +14,8 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest
assert_difference "Account::Transfer.count", 1 do assert_difference "Account::Transfer.count", 1 do
post account_transfers_url, params: { post account_transfers_url, params: {
account_transfer: { account_transfer: {
from_account_id: accounts(:checking).id, from_account_id: accounts(:depository).id,
to_account_id: accounts(:savings).id, to_account_id: accounts(:credit_card).id,
date: Date.current, date: Date.current,
amount: 100, amount: 100,
currency: "USD", currency: "USD",
@ -28,7 +28,7 @@ class Account::TransfersControllerTest < ActionDispatch::IntegrationTest
test "can destroy transfer" do test "can destroy transfer" do
assert_difference -> { Account::Transfer.count } => -1, -> { Account::Transaction.count } => 0 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 end
end end

View file

@ -3,7 +3,7 @@ require "test_helper"
class AccountsControllerTest < ActionDispatch::IntegrationTest class AccountsControllerTest < ActionDispatch::IntegrationTest
setup do setup do
sign_in @user = users(:family_admin) sign_in @user = users(:family_admin)
@account = accounts(:checking) @account = accounts(:depository)
end end
test "gets accounts list" do test "gets accounts list" do
@ -33,7 +33,7 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
test "can sync all accounts" do test "can sync all accounts" do
post sync_all_accounts_path post sync_all_accounts_path
assert_redirected_to accounts_url 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 end
test "should update account" do test "should update account" do

View file

@ -3,6 +3,7 @@ require "test_helper"
class CategoriesControllerTest < ActionDispatch::IntegrationTest class CategoriesControllerTest < ActionDispatch::IntegrationTest
setup do setup do
sign_in users(:family_admin) sign_in users(:family_admin)
@transaction = account_transactions :one
end end
test "index" do test "index" do
@ -37,7 +38,7 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
assert_difference "Category.count", +1 do assert_difference "Category.count", +1 do
post categories_url, params: { post categories_url, params: {
transaction_id: account_transactions(:checking_one).id, transaction_id: @transaction.id,
category: { category: {
name: "New Category", name: "New Category",
color: color } } color: color } }
@ -48,7 +49,7 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to transactions_url assert_redirected_to transactions_url
assert_equal "New Category", new_category.name assert_equal "New Category", new_category.name
assert_equal color, new_category.color assert_equal color, new_category.color
assert_equal account_transactions(:checking_one).reload.category, new_category assert_equal @transaction.reload.category, new_category
end end
test "edit" do test "edit" do

View file

@ -3,8 +3,7 @@ require "test_helper"
class Tag::DeletionsControllerTest < ActionDispatch::IntegrationTest class Tag::DeletionsControllerTest < ActionDispatch::IntegrationTest
setup do setup do
sign_in @user = users(:family_admin) sign_in @user = users(:family_admin)
@user_tags = @user.family.tags @tag = tags(:one)
@tag = tags(:hawaii_trip)
end end
test "should get new" do test "should get new" do
@ -13,7 +12,7 @@ class Tag::DeletionsControllerTest < ActionDispatch::IntegrationTest
end end
test "create with replacement" do test "create with replacement" do
replacement_tag = tags(:trips) replacement_tag = tags(:two)
affected_transaction_count = @tag.transactions.count affected_transaction_count = @tag.transactions.count

View file

@ -1,10 +1,11 @@
require "test_helper" require "test_helper"
class TransactionsControllerTest < ActionDispatch::IntegrationTest class TransactionsControllerTest < ActionDispatch::IntegrationTest
include Account::EntriesTestHelper
setup do setup do
sign_in @user = users(:family_admin) sign_in @user = users(:family_admin)
@transaction_entry = account_entries(:checking_one) @transaction = account_entries(:transaction)
@recent_transaction_entries = @user.family.entries.account_transactions.reverse_chronological.limit(20).to_a
end end
test "should get new" do test "should get new" do
@ -13,9 +14,9 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
end end
test "prefills account_id" do 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_response :success
assert_select "option[selected][value='#{@transaction_entry.account.id}']" assert_select "option[selected][value='#{@transaction.account.id}']"
end end
test "should create transaction" do test "should create transaction" do
@ -45,11 +46,11 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
post transactions_url, params: { post transactions_url, params: {
account_entry: { account_entry: {
nature: "expense", nature: "expense",
account_id: @transaction_entry.account_id, account_id: @transaction.account_id,
amount: @transaction_entry.amount, amount: @transaction.amount,
currency: @transaction_entry.currency, currency: @transaction.currency,
date: @transaction_entry.date, date: @transaction.date,
name: @transaction_entry.name, name: @transaction.name,
entryable_type: "Account::Transaction", entryable_type: "Account::Transaction",
entryable_attributes: {} entryable_attributes: {}
} }
@ -58,7 +59,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
created_entry = Account::Entry.order(created_at: :desc).first 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" assert created_entry.amount.positive?, "Amount should be positive"
end end
@ -67,11 +68,11 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
post transactions_url, params: { post transactions_url, params: {
account_entry: { account_entry: {
nature: "income", nature: "income",
account_id: @transaction_entry.account_id, account_id: @transaction.account_id,
amount: @transaction_entry.amount, amount: @transaction.amount,
currency: @transaction_entry.currency, currency: @transaction.currency,
date: @transaction_entry.date, date: @transaction.date,
name: @transaction_entry.name, name: @transaction.name,
entryable_type: "Account::Transaction", entryable_type: "Account::Transaction",
entryable_attributes: { category_id: categories(:food_and_drink).id } 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 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" assert created_entry.amount.negative?, "Amount should be negative"
end 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 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) 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! \ assert_dom "#total-transactions", count: 1, text: family.entries.account_transactions.size.to_s
entryable: Account::Transaction.new,
name: "Transaction to search for",
date: Date.current,
amount: 0,
currency: "USD"
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 # 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" assert_dom "#total-transactions", count: 1, text: "1"
end end
test "can navigate to paginated result" do test "can paginate" do
get transactions_url(page: 2, per_page: 10) 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 assert_response :success
sorted_transactions.first(10).each do |transaction|
visible_transaction_entries = @recent_transaction_entries[10, 10].reject { |e| e.transfer.present? }
visible_transaction_entries.each do |transaction|
assert_dom "#" + dom_id(transaction), count: 1 assert_dom "#" + dom_id(transaction), count: 1
end end
end
test "loads last page when page is out of range" do get transactions_url(page: 2, per_page: 10)
user_oldest_transaction_entry = @user.family.entries.account_transactions.chronological.first
get transactions_url(page: 9999999999)
assert_response :success assert_dom "#" + dom_id(sorted_transactions.last), count: 1
assert_dom "#" + dom_id(user_oldest_transaction_entry), 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 end
test "can destroy many transactions at once" do 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 assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do
post bulk_delete_transactions_url, params: { post bulk_delete_transactions_url, params: {
bulk_delete: { bulk_delete: {
entry_ids: @recent_transaction_entries.first(delete_count).pluck(:id) entry_ids: transactions.pluck(:id)
} }
} }
end end
assert_redirected_to transactions_url assert_redirected_to transactions_url
assert_equal "10 transactions deleted", flash[:notice] assert_equal "#{delete_count} transactions deleted", flash[:notice]
end end
test "can update many transactions at once" do test "can update many transactions at once" do
transactions = @user.family.entries.account_transactions.reverse_chronological.limit(20) transactions = @user.family.entries.account_transactions
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
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do
post bulk_update_transactions_url, params: { post bulk_update_transactions_url, params: {
@ -173,9 +170,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to transactions_url assert_redirected_to transactions_url
assert_equal "#{transactions.count} transactions updated", flash[:notice] assert_equal "#{transactions.count} transactions updated", flash[:notice]
transactions.reload transactions.reload.each do |transaction|
transactions.each do |transaction|
assert_equal 1.day.ago.to_date, transaction.date assert_equal 1.day.ago.to_date, transaction.date
assert_equal Category.second, transaction.account_transaction.category assert_equal Category.second, transaction.account_transaction.category
assert_equal Merchant.second, transaction.account_transaction.merchant 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 valuation:
checking_one: name: Manual valuation
date: <%= 4.days.ago.to_date %>
amount: 4995
currency: USD
account: depository
entryable_type: Account::Valuation
entryable: one
transaction:
name: Starbucks name: Starbucks
date: <%= 5.days.ago.to_date %> date: <%= 1.day.ago.to_date %>
amount: 10 amount: 10
account: checking
currency: USD currency: USD
account: depository
entryable_type: Account::Transaction entryable_type: Account::Transaction
entryable: checking_one entryable: one
checking_two: transfer_out:
name: Chipotle name: Payment to credit card account
date: <%= 12.days.ago.to_date %> date: <%= 3.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 %>
amount: 100 amount: 100
account: checking
currency: USD currency: USD
entryable_type: Account::Transaction account: depository
entryable: checking_six_payment
marked_as_transfer: true marked_as_transfer: true
transfer: credit_card_payment transfer: one
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
entryable_type: Account::Transaction entryable_type: Account::Transaction
entryable: checking_seven_transfer entryable: transfer_out
checking_eight_external_payment: transfer_in:
name: Transfer TO external CC account (owned by user but not known to app) name: Payment received from checking account
date: <%= 30.days.ago.to_date %> date: <%= 3.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 %>
amount: -100 amount: -100
currency: USD
account: credit_card account: credit_card
currency: USD
marked_as_transfer: true marked_as_transfer: true
transfer: credit_card_payment transfer: one
entryable_type: Account::Transaction entryable_type: Account::Transaction
entryable: credit_card_four_payment entryable: transfer_in
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

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 one:
checking_one:
category: food_and_drink category: food_and_drink
checking_two:
category: food_and_drink
checking_three:
merchant: amazon merchant: amazon
checking_four: transfer_out: { }
category: income transfer_in: { }
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: { }

View file

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

View file

@ -1,18 +1,2 @@
collectable_one: { } one: { }
collectable_two: { } two: { }
collectable_three: { }
iou_one: { }
multi_currency_one: { }
savings_one: { }
savings_two: { }
brokerage_one: { }
mortgage_loan_one: { }
house_one: { }
car_one: { }

View file

@ -1,31 +1,23 @@
collectable: other_asset:
family: dylan_family family: dylan_family
name: Collectable Account name: Collectable Account
balance: 550 balance: 550
accountable_type: OtherAsset accountable_type: OtherAsset
accountable: other_asset_collectable accountable: one
iou: other_liability:
family: dylan_family family: dylan_family
name: IOU (personal debt to friend) name: IOU (personal debt to friend)
balance: 200 balance: 200
accountable_type: OtherLiability accountable_type: OtherLiability
accountable: other_liability_iou accountable: one
checking: depository:
family: dylan_family family: dylan_family
name: Checking Account name: Checking Account
balance: 5000 balance: 5000
accountable_type: Depository accountable_type: Depository
accountable: depository_checking accountable: one
institution: chase
savings:
family: dylan_family
name: Savings account
balance: 19700
accountable_type: Depository
accountable: depository_savings
institution: chase institution: chase
credit_card: credit_card:
@ -33,56 +25,37 @@ credit_card:
name: Credit Card name: Credit Card
balance: 1000 balance: 1000
accountable_type: CreditCard accountable_type: CreditCard
accountable: credit_one accountable: one
institution: chase institution: chase
eur_checking: investment:
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:
family: dylan_family family: dylan_family
name: Robinhood Brokerage Account name: Robinhood Brokerage Account
currency: USD currency: USD
balance: 10000 balance: 10000
accountable_type: Investment accountable_type: Investment
accountable: investment_brokerage accountable: one
mortgage_loan: loan:
family: dylan_family family: dylan_family
name: Mortgage Loan name: Mortgage Loan
currency: USD currency: USD
balance: 500000 balance: 500000
accountable_type: Loan accountable_type: Loan
accountable: loan_mortgage accountable: one
house: property:
family: dylan_family family: dylan_family
name: 123 Maybe Court name: 123 Maybe Court
currency: USD currency: USD
balance: 550000 balance: 550000
accountable_type: Property accountable_type: Property
accountable: property_house accountable: one
car: vehicle:
family: dylan_family family: dylan_family
name: Honda Accord name: Honda Accord
currency: USD currency: USD
balance: 18000 balance: 18000
accountable_type: Vehicle accountable_type: Vehicle
accountable: vehicle_honda_accord accountable: one

View file

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

View file

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

View file

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

View file

@ -1,2 +1,6 @@
empty:
name: Family
dylan_family: dylan_family:
name: The 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: empty_import:
account: checking account: depository
created_at: <%= 1.minute.ago %> created_at: <%= 1.minute.ago %>
completed_import: completed_import:
account: checking account: depository
column_mappings: column_mappings:
date: date date: date
name: name 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: netflix:
name: Netflix name: Netflix
color: "#fd7f6f" 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: one:
tag: hawaii_trip tag: one
taggable: checking_one taggable: one
taggable_type: Account::Transaction taggable_type: Account::Transaction
two: two:
tag: emergency_fund tag: two
taggable: checking_two taggable: one
taggable_type: Account::Transaction taggable_type: Account::Transaction

View file

@ -1,11 +1,11 @@
trips: one:
name: Trips name: Trips
family: dylan_family family: dylan_family
hawaii_trip: two:
name: Hawaii Trip name: Emergency fund
family: dylan_family family: dylan_family
emergency_fund: three:
name: Emergency Fund name: Test
family: dylan_family 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_admin:
family: dylan_family family: dylan_family
first_name: Bob 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" require "test_helper"
class Account::EntryTest < ActiveSupport::TestCase class Account::EntryTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do setup do
@entry = account_entries :checking_one @entry = account_entries :transaction
@family = families :dylan_family
end end
test "valuations cannot have more than one entry per day" do test "valuations cannot have more than one entry per day" do
new_entry = Account::Entry.new \ existing_valuation = account_entries :valuation
entryable: Account::Valuation.new,
date: @entry.date, # invalid
currency: @entry.currency,
amount: @entry.amount
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 end
test "triggers sync with correct start date when transaction is set to prior date" do test "triggers sync with correct start date when transaction is set to prior date" do
prior_date = @entry.date - 1 prior_date = @entry.date - 1
@entry.update! date: prior_date @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 @entry.sync_account_later
end end
@ -28,48 +31,62 @@ class Account::EntryTest < ActiveSupport::TestCase
prior_date = @entry.date prior_date = @entry.date
@entry.update! date: @entry.date + 1 @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 @entry.sync_account_later
end end
test "triggers sync with correct start date when transaction deleted" do test "triggers sync with correct start date when transaction deleted" do
prior_entry = account_entries(:checking_two) # 12 days ago current_entry = create_transaction(date: 1.day.ago.to_date)
current_entry = account_entries(:checking_one) # 5 days ago prior_entry = create_transaction(date: current_entry.date - 1.day)
current_entry.destroy! 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 current_entry.sync_account_later
end end
test "can search entries" do 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" } 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 end
test "can calculate total spending for a group of transactions" do test "can calculate total spending for a group of transactions" do
assert_equal Money.new(2135), @family.entries.expense_total("USD") family = families(:empty)
assert_equal Money.new(1010.85, "EUR"), @family.entries.expense_total("EUR") 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 end
test "can calculate total income for a group of transactions" do test "can calculate total income for a group of transactions" do
assert_equal -Money.new(2075), @family.entries.income_total("USD") family = families(:empty)
assert_equal -Money.new(250, "EUR"), @family.entries.income_total("EUR") 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 end
# See: https://github.com/maybe-finance/maybe/wiki/vision#signage-of-money # 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 test "transactions with negative amounts are inflows, positive amounts are outflows to an account" do
inflow_transaction = account_entries(:checking_four) assert create_transaction(amount: -10).inflow?
outflow_transaction = account_entries(:checking_five) assert create_transaction(amount: 10).outflow?
assert inflow_transaction.amount < 0
assert inflow_transaction.inflow?
assert outflow_transaction.amount >= 0
assert outflow_transaction.outflow?
end end
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 class Account::TransferTest < ActiveSupport::TestCase
setup do setup do
# Transfers can be posted on different dates @outflow = account_entries(:transfer_out)
@outflow = accounts(:checking).entries.create! \ @inflow = account_entries(:transfer_in)
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
end end
test "transfer valid if it has inflow and outflow from different accounts for the same amount" do 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 test "transfer must have 2 transactions" do
invalid_transfer_1 = Account::Transfer.new entries: [ @outflow ] 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_1.invalid?
assert invalid_transfer_2.invalid? assert invalid_transfer_2.invalid?
end end
test "transfer cannot have 2 transactions from the same account" do test "transfer cannot have 2 transactions from the same account" do
account = accounts(:checking) account = accounts(:depository)
inflow = account.entries.create! \ inflow = account.entries.create! \
date: Date.current, date: Date.current,
name: "Inflow", name: "Inflow",

View file

@ -1,38 +1,31 @@
require "test_helper" require "test_helper"
require "csv"
class AccountTest < ActiveSupport::TestCase class AccountTest < ActiveSupport::TestCase
def setup include ActiveJob::TestHelper
@account = accounts(:checking)
setup do
@account = accounts(:depository)
@family = families(:dylan_family) @family = families(:dylan_family)
end end
test "recognizes foreign currency account" do test "can sync later" do
regular_account = accounts(:checking) assert_enqueued_with(job: AccountSyncJob, args: [ @account, start_date: Date.current ]) do
foreign_account = accounts(:eur_checking) @account.sync_later start_date: Date.current
assert_not regular_account.foreign_currency? end
assert foreign_account.foreign_currency?
end end
test "recognizes multi currency account" do test "can sync" do
regular_account = accounts(:checking) start_date = 10.days.ago.to_date
multi_currency_account = accounts(:multi_currency)
assert_not regular_account.multi_currency?
assert multi_currency_account.multi_currency?
end
test "multi currency and foreign currency are different concepts" do mock_sync = mock("Account::Sync")
multi_currency_account = accounts(:multi_currency) mock_sync.expects(:run).once
assert_equal multi_currency_account.family.currency, multi_currency_account.currency
assert multi_currency_account.multi_currency? Account::Sync.expects(:for).with(@account, start_date: start_date).returns(mock_sync).once
assert_not multi_currency_account.foreign_currency?
@account.sync start_date: start_date
end end
test "groups accounts by type" do test "groups accounts by type" do
@family.accounts.each do |account|
account.sync
end
result = @family.accounts.by_group(period: Period.all) result = @family.accounts.by_group(period: Period.all)
assets = result[:assets] assets = result[:assets]
liabilities = result[:liabilities] liabilities = result[:liabilities]
@ -50,7 +43,7 @@ class AccountTest < ActiveSupport::TestCase
loans = liabilities.children.find { |group| group.name == "Loan" } loans = liabilities.children.find { |group| group.name == "Loan" }
other_liabilities = liabilities.children.find { |group| group.name == "OtherLiability" } 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, properties.children.count
assert_equal 1, vehicles.children.count assert_equal 1, vehicles.children.count
assert_equal 1, investments.children.count assert_equal 1, investments.children.count
@ -61,38 +54,24 @@ class AccountTest < ActiveSupport::TestCase
assert_equal 1, other_liabilities.children.count assert_equal 1, other_liabilities.children.count
end end
test "generates series with last balance equal to current account balance" do test "generates balance series" do
# If account hasn't been synced, series falls back to a single point with the current balance assert_equal 2, @account.series.values.count
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
end end
test "generates empty series for foreign currency if no exchange rate" do test "generates balance series with single value if no balances" do
account = accounts(:eur_checking) @account.balances.delete_all
assert_equal 1, @account.series.values.count
# We know EUR -> NZD exchange rate is not available in fixtures
assert_equal 0, account.series(currency: "NZD").values.count
end end
test "should destroy dependent transactions" do test "generates balance series in period" do
assert_difference("Account::Transaction.count", -@account.transactions.count) do @account.balances.delete_all
@account.destroy @account.balances.create! date: 31.days.ago.to_date, balance: 5000, currency: "USD" # out of period range
end @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 end
test "should destroy dependent balances" do test "generates empty series if no balances and no exchange rate" do
assert_difference("Account::Balance.count", -@account.balances.count) do assert_equal 0, @account.series(currency: "NZD").values.count
@account.destroy
end
end
test "should destroy dependent valuations" do
assert_difference("Account::Valuation.count", -@account.valuations.count) do
@account.destroy
end
end end
end end

View file

@ -20,7 +20,7 @@ class CategoryTest < ActiveSupport::TestCase
end end
test "updating name should clear the internal_category field" do 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 assert_changes "category.reload.internal_category", to: nil do
category.update_attribute(:name, "new name") category.update_attribute(:name, "new name")
end end

View file

@ -2,144 +2,148 @@ require "test_helper"
require "csv" require "csv"
class FamilyTest < ActiveSupport::TestCase class FamilyTest < ActiveSupport::TestCase
include FamilySnapshotTestHelper include Account::EntriesTestHelper
def setup def setup
@family = families(:dylan_family) @family = families :empty
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 end
@family.accounts.each do |account| test "calculates assets" do
account.sync assert_equal Money.new(0, @family.currency), @family.assets
end
@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 end
test "should have many users" do test "calculates liabilities" do
assert @family.users.size > 0 assert_equal Money.new(0, @family.currency), @family.liabilities
assert @family.users.include?(users(:family_admin))
@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 end
test "should have many accounts" do test "calculates net worth" do
assert @family.accounts.size > 0 assert_equal Money.new(0, @family.currency), @family.net_worth
end
test "should destroy dependent users" do @family.accounts.create!(balance: 1000, accountable: CreditCard.new)
assert_difference("User.count", -@family.users.count) do @family.accounts.create!(balance: 50000, accountable: Depository.new)
@family.destroy
end
end
test "should destroy dependent accounts" do assert_equal Money.new(50000 - 1000, @family.currency), @family.net_worth
assert_difference("Account.count", -@family.accounts.count) do
@family.destroy
end
end
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
end end
test "should exclude disabled accounts from calculations" do test "should exclude disabled accounts from calculations" do
assets_before = @family.assets cc = @family.accounts.create!(balance: 1000, accountable: CreditCard.new)
liabilities_before = @family.liabilities @family.accounts.create!(balance: 50000, accountable: Depository.new)
net_worth_before = @family.net_worth
disabled_checking = accounts(:checking) assert_equal Money.new(50000 - 1000, @family.currency), @family.net_worth
disabled_cc = accounts(:credit_card)
disabled_checking.update!(is_active: false) cc.update! is_active: false
disabled_cc.update!(is_active: false)
assert_equal assets_before - disabled_checking.balance, @family.assets assert_equal Money.new(50000, @family.currency), @family.net_worth
assert_equal liabilities_before - disabled_cc.balance, @family.liabilities
assert_equal net_worth_before - disabled_checking.balance + disabled_cc.balance, @family.net_worth
end 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) Account.any_instance.expects(:sync_later).never
assert_equal time_series_balances.values.count, expected_balances.count unless ignore_count
time_series_balances.values.each do |tsb| @family.sync
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}" account.update! is_active: true
end
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
end end

View file

@ -2,8 +2,8 @@ require "test_helper"
class TagTest < ActiveSupport::TestCase class TagTest < ActiveSupport::TestCase
test "replace and destroy" do test "replace and destroy" do
old_tag = tags(:hawaii_trip) old_tag = tags(:one)
new_tag = tags(:trips) new_tag = tags(:two)
assert_difference "Tag.count", -1 do assert_difference "Tag.count", -1 do
old_tag.replace_and_destroy!(new_tag) old_tag.replace_and_destroy!(new_tag)

View file

@ -2,10 +2,6 @@ require "test_helper"
require "ostruct" require "ostruct"
class ValueGroupTest < ActiveSupport::TestCase class ValueGroupTest < ActiveSupport::TestCase
setup do setup do
checking = accounts(:checking)
savings = accounts(:savings)
collectable = accounts(:collectable)
# Level 1 # Level 1
@assets = ValueGroup.new("Assets", :usd) @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,23 +4,27 @@ class TransactionsTest < ApplicationSystemTestCase
setup do setup do
sign_in @user = users(:family_admin) sign_in @user = users(:family_admin)
@page_size = 10 Account::Entry.delete_all # clean slate
@latest_transactions = @user.family.entries 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 .account_transactions
.without_transfers
.reverse_chronological .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! \ @transaction = @transactions.first
name: "Oldest transaction",
date: 10.years.ago.to_date, @page_size = 10
currency: @user.family.currency,
amount: 100,
entryable: Account::Transaction.new(category: @test_category,
merchant: @test_merchant)
visit transactions_url(per_page: @page_size) visit transactions_url(per_page: @page_size)
end end
@ -29,13 +33,13 @@ class TransactionsTest < ApplicationSystemTestCase
assert_selector "h1", text: "Transactions" assert_selector "h1", text: "Transactions"
within "form#transactions-search" do 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 end
assert_selector "#" + dom_id(@target_txn), count: 1 assert_selector "#" + dom_id(@transaction), count: 1
within "#transaction-search-filters" do within "#transaction-search-filters" do
assert_text @target_txn.name assert_text @transaction.name
end end
end end
@ -43,30 +47,34 @@ class TransactionsTest < ApplicationSystemTestCase
find("#transaction-filters-button").click find("#transaction-filters-button").click
within "#transaction-filters-menu" do within "#transaction-filters-menu" do
check(@target_txn.account.name) check(@transaction.account.name)
click_button "Category" click_button "Category"
check(@test_category.name) check(@transaction.account_transaction.category.name)
click_button "Apply" click_button "Apply"
end end
assert_selector "#" + dom_id(@target_txn), count: 1 assert_selector "#" + dom_id(@transaction), count: 1
within "#transaction-search-filters" do within "#transaction-search-filters" do
assert_text @target_txn.account.name assert_text @transaction.account.name
assert_text @target_txn.account_transaction.category.name assert_text @transaction.account_transaction.category.name
end end
end end
test "all filters work and empty state shows if no match" do test "all filters work and empty state shows if no match" do
find("#transaction-filters-button").click find("#transaction-filters-button").click
account = @transaction.account
category = @transaction.account_transaction.category
merchant = @transaction.account_transaction.merchant
within "#transaction-filters-menu" do within "#transaction-filters-menu" do
click_button "Account" click_button "Account"
check(@target_txn.account.name) check(account.name)
click_button "Date" click_button "Date"
fill_in "q_start_date", with: 10.days.ago.to_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" click_button "Type"
assert_text "Filter by type coming soon..." assert_text "Filter by type coming soon..."
@ -75,10 +83,10 @@ class TransactionsTest < ApplicationSystemTestCase
assert_text "Filter by amount coming soon..." assert_text "Filter by amount coming soon..."
click_button "Category" click_button "Category"
check(@test_category.name) check(category.name)
click_button "Merchant" click_button "Merchant"
check(@test_merchant.name) check(merchant.name)
click_button "Apply" click_button "Apply"
end end
@ -91,14 +99,14 @@ class TransactionsTest < ApplicationSystemTestCase
assert_text "No entries found" assert_text "No entries found"
within "ul#transaction-search-filters" do 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 after #{10.days.ago.to_date}").first("a").click
find("li", text: "on or before #{Date.current}").first("a").click find("li", text: "on or before #{1.day.ago.to_date}").first("a").click
find("li", text: @target_txn.account_transaction.category.name).first("a").click find("li", text: category.name).first("a").click
find("li", text: @target_txn.account_transaction.merchant.name).first("a").click find("li", text: merchant.name).first("a").click
end end
assert_selector "#" + dom_id(@user.family.entries.reverse_chronological.first), count: 1 assert_selector "#" + dom_id(@transaction), count: 1
end end
test "can select and deselect entire page of transactions" do test "can select and deselect entire page of transactions" do
@ -109,36 +117,52 @@ class TransactionsTest < ApplicationSystemTestCase
end end
test "can select and deselect groups of transactions" do test "can select and deselect groups of transactions" do
date_transactions_checkbox(12.days.ago.to_date).check date_transactions_checkbox(1.day.ago.to_date).check
assert_selection_count(3) assert_selection_count(2)
date_transactions_checkbox(12.days.ago.to_date).uncheck
date_transactions_checkbox(1.day.ago.to_date).uncheck
assert_selection_count(0) assert_selection_count(0)
end end
test "can select and deselect individual transactions" do test "can select and deselect individual transactions" do
transaction_checkbox(@latest_transactions.first).check transaction_checkbox(@transactions.first).check
assert_selection_count(1) assert_selection_count(1)
transaction_checkbox(@latest_transactions.second).check transaction_checkbox(@transactions.second).check
assert_selection_count(2) assert_selection_count(2)
transaction_checkbox(@latest_transactions.second).uncheck transaction_checkbox(@transactions.second).uncheck
assert_selection_count(1) assert_selection_count(1)
end end
test "outermost group always overrides inner selections" do test "outermost group always overrides inner selections" do
transaction_checkbox(@latest_transactions.first).check transaction_checkbox(@transactions.first).check
assert_selection_count(1) assert_selection_count(1)
all_transactions_checkbox.check all_transactions_checkbox.check
assert_selection_count(number_of_transactions_on_page) 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) 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 all_transactions_checkbox.uncheck
assert_selection_count(0) assert_selection_count(0)
end end
private 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 def number_of_transactions_on_page
[ @user.family.entries.without_transfers.count, @page_size ].min [ @user.family.entries.without_transfers.count, @page_size ].min
end end

View file

@ -7,8 +7,8 @@ class TransfersTest < ApplicationSystemTestCase
end end
test "can create a transfer" do test "can create a transfer" do
checking_name = accounts(:checking).name checking_name = accounts(:depository).name
savings_name = accounts(:savings).name savings_name = accounts(:credit_card).name
transfer_date = Date.current transfer_date = Date.current
click_on "New transaction" click_on "New transaction"
@ -32,15 +32,15 @@ class TransfersTest < ApplicationSystemTestCase
test "can match 2 transactions and create a transfer" do test "can match 2 transactions and create a transfer" do
transfer_date = Date.current transfer_date = Date.current
outflow = accounts(:savings).entries.create! \ outflow = accounts(:depository).entries.create! \
name: "Outflow from savings account", name: "Outflow from checking account",
date: transfer_date, date: transfer_date,
amount: 100, amount: 100,
currency: "USD", currency: "USD",
entryable: Account::Transaction.new entryable: Account::Transaction.new
inflow = accounts(:checking).entries.create! \ inflow = accounts(:credit_card).entries.create! \
name: "Inflow to checking account", name: "Inflow to cc account",
date: transfer_date, date: transfer_date,
amount: -100, amount: -100,
currency: "USD", currency: "USD",
@ -64,7 +64,7 @@ class TransfersTest < ApplicationSystemTestCase
txn = @user.family.entries.reverse_chronological.first txn = @user.family.entries.reverse_chronological.first
within "#" + dom_id(txn) do within "#" + dom_id(txn) do
assert_text "Uncategorized" assert_text txn.account_transaction.category.name || "Uncategorized"
end end
transaction_entry_checkbox(txn).check transaction_entry_checkbox(txn).check