Compare commits

..

15 Commits

Author SHA1 Message Date
Zach Gollwitzer
744ffb68aa Bump to v0.1.0-alpha.10
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-07-12 18:38:17 -04:00
Zach Gollwitzer
34e03c2d6a Make balance editing easier (#976)
* Make balance editing easier

* Translations

* Fix money input option

* Fix balance sync logic

* Rework balance update flow
2024-07-12 13:47:39 -04:00
Zach Gollwitzer
b002a41b35 New demo user data (#972) 2024-07-11 08:37:21 -04:00
Zach Gollwitzer
c6bdf49f10 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
2024-07-10 11:22:59 -04:00
Tony Vincent
de5a2e55b3 Add missing migrations for good_job 4x (#967)
* Goodjob 3.99

* Properly bump to good_job 4.0.0

* Remove accidently tracked .tool-versions
2024-07-09 14:23:19 -04:00
dependabot[bot]
538b00712c Bump rails from df02832 to 8075866 (#962)
Bumps [rails](https://github.com/rails/rails) from `df02832` to `8075866`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](df02832784...8075866ae8)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-08 13:35:30 -04:00
dependabot[bot]
2e56f5726e Bump sentry-rails from 5.18.0 to 5.18.1 (#964)
Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.18.0 to 5.18.1.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/5.18.0...5.18.1)

---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-08 09:15:11 -04:00
dependabot[bot]
3c9cdb16f9 Bump pagy from 8.6.1 to 8.6.3 (#963)
Bumps [pagy](https://github.com/ddnexus/pagy) from 8.6.1 to 8.6.3.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/8.6.1...8.6.3)

---
updated-dependencies:
- dependency-name: pagy
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-08 09:14:46 -04:00
dependabot[bot]
6d4c871f85 Bump ruby-lsp-rails from 0.3.7 to 0.3.8 (#960)
Bumps [ruby-lsp-rails](https://github.com/Shopify/ruby-lsp-rails) from 0.3.7 to 0.3.8.
- [Release notes](https://github.com/Shopify/ruby-lsp-rails/releases)
- [Commits](https://github.com/Shopify/ruby-lsp-rails/compare/v0.3.7...v0.3.8)

---
updated-dependencies:
- dependency-name: ruby-lsp-rails
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-08 09:14:31 -04:00
dependabot[bot]
dd915c42ed Bump aws-sdk-s3 from 1.155.0 to 1.156.0 (#961)
Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.155.0 to 1.156.0.
- [Release notes](https://github.com/aws/aws-sdk-ruby/releases)
- [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-ruby/commits)

---
updated-dependencies:
- dependency-name: aws-sdk-s3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-08 09:07:52 -04:00
dependabot[bot]
0447d47a53 Bump good_job from 3.29.5 to 4.0.0 (#959)
Bumps [good_job](https://github.com/bensheldon/good_job) from 3.29.5 to 4.0.0.
- [Release notes](https://github.com/bensheldon/good_job/releases)
- [Changelog](https://github.com/bensheldon/good_job/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bensheldon/good_job/compare/v3.29.5...v4.0.0)

---
updated-dependencies:
- dependency-name: good_job
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-08 09:07:12 -04:00
dependabot[bot]
42dec4014e Bump sentry-ruby from 5.18.0 to 5.18.1 (#958)
Bumps [sentry-ruby](https://github.com/getsentry/sentry-ruby) from 5.18.0 to 5.18.1.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/5.18.0...5.18.1)

---
updated-dependencies:
- dependency-name: sentry-ruby
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-08 09:05:30 -04:00
Zach Gollwitzer
6767aaed1d Handle missing exchange rate provider, allow fallback for missing rates (#955)
* Clean up exchange rate logic

* Remove stale method
2024-07-08 09:04:59 -04:00
Magnus Jensen
bef335c631 fix: #951 pointer cursor and bg hover for import flow buttons (#954)
* set cursor to pointer on buttons in imports flow

* add hover bg change on buttons to indicate action

* add rounded hover background to x
2024-07-08 08:56:08 -04:00
Tony Vincent
3ffb6cb62b Add error handling for AccountsController#create (#957) 2024-07-08 08:53:45 -04:00
87 changed files with 1749 additions and 2268 deletions

View File

@@ -97,7 +97,6 @@ jobs:
- name: System tests
run: DISABLE_PARALLELIZATION=true bin/rails test:system
continue-on-error: true # TODO: Eventually we'll enforce for PRs
- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4

View File

@@ -59,6 +59,7 @@ group :development do
gem "letter_opener"
gem "ruby-lsp-rails"
gem "web-console"
gem "faker"
end
group :test do

View File

@@ -7,7 +7,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: df028327844b6564be8d09075b52288260d4da42
revision: 8075866ae8dfee76e1c6099b9eea6dcb7df70803
branch: 7-2-stable
specs:
actioncable (7.2.0.beta2)
@@ -109,19 +109,19 @@ GEM
public_suffix (>= 2.0.2, < 6.0)
ast (2.4.2)
aws-eventstream (1.3.0)
aws-partitions (1.949.0)
aws-sdk-core (3.200.0)
aws-partitions (1.952.0)
aws-sdk-core (3.201.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.87.0)
aws-sdk-core (~> 3, >= 3.199.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.155.0)
aws-sdk-core (~> 3, >= 3.199.0)
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.156.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0)
@@ -178,6 +178,8 @@ GEM
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
faker (3.4.1)
i18n (>= 1.8.11, < 2)
faraday (2.9.2)
faraday-net_http (>= 2.0, < 3.2)
faraday-net_http (3.1.0)
@@ -190,13 +192,13 @@ GEM
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
good_job (3.29.5)
activejob (>= 6.0.0)
activerecord (>= 6.0.0)
concurrent-ruby (>= 1.0.2)
fugit (>= 1.1)
railties (>= 6.0.0)
thor (>= 0.14.1)
good_job (4.0.0)
activejob (>= 6.1.0)
activerecord (>= 6.1.0)
concurrent-ruby (>= 1.3.1)
fugit (>= 1.11.0)
railties (>= 6.1.0)
thor (>= 1.0.0)
hashdiff (1.1.0)
highline (3.0.1)
hotwire-livereload (1.4.0)
@@ -226,7 +228,7 @@ GEM
activesupport (>= 3.0)
nokogiri (>= 1.6)
io-console (0.7.2)
irb (1.13.2)
irb (1.14.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
@@ -284,13 +286,13 @@ GEM
octokit (9.1.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (8.6.1)
pagy (8.6.3)
parallel (1.24.0)
parser (3.3.1.0)
ast (~> 2.4.1)
racc
pg (1.5.6)
prism (0.29.0)
prism (0.30.0)
propshaft (0.9.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@@ -303,7 +305,7 @@ GEM
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.0)
rack (3.1.4)
rack (3.1.6)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.1.0)
@@ -329,6 +331,8 @@ GEM
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
ffi (~> 1.0)
rbs (3.5.1)
logger
rdoc (6.7.0)
psych (>= 4.0.0)
regexp_parser (2.9.2)
@@ -365,12 +369,13 @@ GEM
rubocop-minitest
rubocop-performance
rubocop-rails
ruby-lsp (0.17.1)
ruby-lsp (0.17.4)
language_server-protocol (~> 3.17.0)
prism (>= 0.29.0, < 0.30)
prism (>= 0.29.0, < 0.31)
rbs (>= 3, < 4)
sorbet-runtime (>= 0.5.10782)
ruby-lsp-rails (0.3.7)
ruby-lsp (>= 0.17.0, < 0.18.0)
ruby-lsp-rails (0.3.8)
ruby-lsp (>= 0.17.2, < 0.18.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.1)
ffi (~> 1.12)
@@ -385,10 +390,10 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.18.0)
sentry-rails (5.18.1)
railties (>= 5.0)
sentry-ruby (~> 5.18.0)
sentry-ruby (5.18.0)
sentry-ruby (~> 5.18.1)
sentry-ruby (5.18.1)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
@@ -398,7 +403,7 @@ GEM
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sorbet-runtime (0.5.11406)
sorbet-runtime (0.5.11473)
stackprof (0.2.26)
stimulus-rails (1.3.3)
railties (>= 6.0.0)
@@ -467,6 +472,7 @@ DEPENDENCIES
debug
dotenv-rails
erb_lint
faker
faraday
faraday-retry
good_job

View File

@@ -3,7 +3,6 @@ class AccountsController < ApplicationController
include Filterable
before_action :set_account, only: %i[ edit show destroy sync update ]
after_action :sync_account, only: :create
def index
@institutions = Current.family.institutions
@@ -41,7 +40,9 @@ class AccountsController < ApplicationController
end
def update
@account.update! account_params.except(:accountable_type)
@account.update! account_params.except(:accountable_type, :balance)
@account.update_balance!(account_params[:balance]) if account_params[:balance]
@account.sync_later
redirect_back_or_to account_path(@account), notice: t(".success")
end
@@ -52,8 +53,10 @@ class AccountsController < ApplicationController
attributes: account_params.except(:start_date, :start_balance),
start_date: account_params[:start_date],
start_balance: account_params[:start_balance]
@account.sync_later
redirect_back_or_to account_path(@account), notice: t(".success")
rescue ActiveRecord::RecordInvalid => e
redirect_back_or_to accounts_path, alert: e.record.errors.full_messages.to_sentence
end
def destroy
@@ -70,19 +73,8 @@ class AccountsController < ApplicationController
end
def sync_all
synced_accounts_count = 0
Current.family.accounts.each do |account|
next unless account.can_sync?
account.sync_later
synced_accounts_count += 1
end
if synced_accounts_count > 0
redirect_back_or_to accounts_path, notice: t(".success", count: synced_accounts_count)
else
redirect_back_or_to accounts_path, alert: t(".no_accounts_to_sync")
end
Current.family.accounts.active.sync
redirect_back_or_to accounts_path, notice: t(".success")
end
private
@@ -94,8 +86,4 @@ class AccountsController < ApplicationController
def account_params
params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id)
end
def sync_account
@account.sync_later
end
end

View File

@@ -53,7 +53,7 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
(label(method, *label_args(options)).to_s if options[:label]) +
@template.tag.div(class: "flex items-center") do
number_field(money_amount_method, merged_options.except(:label)) +
grouped_select(money_currency_method, grouped_options, { selected: selected_currency, disabled: readonly_currency }, class: "ml-auto form-field__input w-fit pr-8", data: { "money-field-target" => "currency", action: "change->money-field#handleCurrencyChange" })
grouped_select(money_currency_method, grouped_options, { selected: selected_currency }, disabled: readonly_currency, class: "ml-auto form-field__input w-fit pr-8", data: { "money-field-target" => "currency", action: "change->money-field#handleCurrencyChange" })
end
end
end

View File

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

View File

@@ -14,10 +14,10 @@ class Account < ApplicationRecord
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
has_many :balances, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :syncs, dependent: :destroy
monetize :balance
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
scope :active, -> { where(is_active: true) }
@@ -28,79 +28,83 @@ class Account < ApplicationRecord
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
def balance_on(date)
balances.where("date <= ?", date).order(date: :desc).first&.balance
class << self
def by_group(period: Period.all, currency: Money.default_currency)
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
Accountable.by_classification.each do |classification, types|
types.each do |type|
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
self.where(accountable_type: type).each do |account|
group.add_value_node(
account,
account.balance_money.exchange_to(currency, fallback_rate: 0),
account.series(period: period, currency: currency)
)
end
end
end
grouped_accounts
end
def create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
account = self.new(attributes.except(:accountable_type))
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
# Always build the initial valuation
account.entries.build \
date: Date.current,
amount: attributes[:balance],
currency: account.currency,
entryable: Account::Valuation.new
# Conditionally build the optional start valuation
if start_date.present? && start_balance.present?
account.entries.build \
date: start_date,
amount: start_balance,
currency: account.currency,
entryable: Account::Valuation.new
end
account.save!
account
end
end
def alert
latest_sync = syncs.latest
[ latest_sync&.error, *latest_sync&.warnings ].compact.first
end
def favorable_direction
classification == "asset" ? "up" : "down"
end
# e.g. Wise, Revolut accounts that have transactions in multiple currencies
def multi_currency?
entries.select(:currency).distinct.count > 1
end
# e.g. Accounts denominated in currency other than family currency
def foreign_currency?
currency != family.currency
end
def series(period: Period.all, currency: self.currency)
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
if balance_series.empty? && period.date_range.end == Date.current
converted_balance = balance_money.exchange_to(currency)
if converted_balance
TimeSeries.new([ { date: Date.current, value: converted_balance } ])
else
TimeSeries.new([])
end
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency) } ])
else
TimeSeries.from_collection(balance_series, :balance_money)
end
rescue Money::ConversionError
TimeSeries.new([])
end
def self.by_group(period: Period.all, currency: Money.default_currency)
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
def update_balance!(balance)
valuation = entries.account_valuations.find_by(date: Date.current)
Accountable.by_classification.each do |classification, types|
types.each do |type|
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
self.where(accountable_type: type).each do |account|
value_node = group.add_value_node(
account,
account.balance_money.exchange_to(currency) || Money.new(0, currency),
account.series(period: period, currency: currency)
)
end
end
end
grouped_accounts
end
def self.create_with_optional_start_balance!(attributes:, start_date: nil, start_balance: nil)
account = self.new(attributes.except(:accountable_type))
account.accountable = Accountable.from_type(attributes[:accountable_type])&.new
# Always build the initial valuation
account.entries.build \
date: Date.current,
amount: attributes[:balance],
currency: account.currency,
entryable: Account::Valuation.new
# Conditionally build the optional start valuation
if start_date.present? && start_balance.present?
account.entries.build \
date: start_date,
amount: start_balance,
currency: account.currency,
if valuation
valuation.update! amount: balance
else
entries.create! \
date: Date.current,
amount: balance,
currency: currency,
entryable: Account::Valuation.new
end
account.save!
account
end
end

View File

@@ -5,4 +5,5 @@ class Account::Balance < ApplicationRecord
validates :account, :date, :balance, presence: true
monetize :balance
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
scope :chronological, -> { order(:date) }
end

View File

@@ -1,115 +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.get_rates(
account.currency,
account.family.currency,
calc_start_date..Date.current
).to_a
# Abort conversion if some required rates are missing
if rates.length != balances.length
@errors << :sync_message_missing_rates
return []
end
balances.map.with_index do |balance, index|
converted_balance = balance[:balance] * rates[index].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,131 @@
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!
if daily_balances.any?
account.reload
account.update! balance: daily_balances.select { |db| db.currency == account.currency }.last&.balance
end
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
(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
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

@@ -22,7 +22,7 @@ class Account::Entry < ApplicationRecord
"account_entries.*",
"account_entries.amount * COALESCE(er.rate, 1) AS converted_amount"
)
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.base_currency AND er.converted_currency = ?", currency ]))
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ]))
.where("er.rate IS NOT NULL OR account_entries.currency = ?", currency)
}
@@ -33,7 +33,7 @@ class Account::Entry < ApplicationRecord
sync_start_date = [ date_previously_was, date ].compact.min
end
account.sync_later(sync_start_date)
account.sync_later(start_date: sync_start_date)
end
def inflow?
@@ -122,19 +122,17 @@ class Account::Entry < ApplicationRecord
end
def income_total(currency = "USD")
account_transactions.includes(:entryable)
without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount <= 0")
.where("account_entries.currency = ?", currency)
.reject { |e| e.marked_as_transfer? }
.sum(&:amount_money)
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
end
def expense_total(currency = "USD")
account_transactions.includes(:entryable)
.where("account_entries.amount > 0")
.where("account_entries.currency = ?", currency)
.reject { |e| e.marked_as_transfer? }
.sum(&:amount_money)
without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount > 0")
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
end
def search(params)

View File

@@ -0,0 +1,51 @@
class Account::Sync < ApplicationRecord
belongs_to :account
enum :status, { pending: "pending", syncing: "syncing", completed: "completed", failed: "failed" }
class << self
def for(account, start_date: nil)
create! account: account, start_date: start_date
end
def latest
order(created_at: :desc).first
end
end
def run
start!
sync_balances
complete!
rescue StandardError => error
fail! error
end
private
def sync_balances
syncer = Account::Balance::Syncer.new(account, start_date: start_date)
syncer.run
append_warnings(syncer.warnings)
end
def append_warnings(new_warnings)
update! warnings: warnings + new_warnings
end
def start!
update! status: "syncing", last_ran_at: Time.now
end
def complete!
update! status: "completed"
end
def fail!(error)
update! status: "failed", error: error.message
end
end

View File

@@ -1,89 +1,21 @@
module Account::Syncable
extend ActiveSupport::Concern
def sync_later(start_date = nil)
AccountSyncJob.perform_later(self, start_date)
class_methods do
def sync(start_date: nil)
all.each { |a| a.sync_later(start_date: start_date) }
end
end
def sync(start_date = nil)
update!(status: "syncing")
if multi_currency? || foreign_currency?
sync_exchange_rates
end
calculator = Account::Balance::Calculator.new(self, { calc_start_date: start_date })
self.balances.upsert_all(calculator.daily_balances, unique_by: :index_account_balances_on_account_id_date_currency_unique)
self.balances.where("date < ?", effective_start_date).delete_all
new_balance = calculator.daily_balances.select { |b| b[:currency] == self.currency }.last[:balance]
update! \
status: "ok",
last_sync_date: Date.current,
balance: new_balance,
sync_errors: calculator.errors,
sync_warnings: calculator.warnings
rescue => e
update!(status: "error", sync_errors: [ :sync_message_unknown_error ])
logger.error("Failed to sync account #{id}: #{e.message}")
def syncing?
syncs.syncing.any?
end
def can_sync?
# Skip account sync if account is not active or the sync process is already running
return false unless is_active
return false if syncing?
# If last_sync_date is blank (i.e. the account has never been synced before) allow syncing
return true if last_sync_date.blank?
# If last_sync_date is not today, allow syncing
last_sync_date != Date.today
def sync_later(start_date: nil)
AccountSyncJob.perform_later(self, start_date: start_date)
end
# The earliest date we can calculate a balance for
def effective_start_date
@effective_start_date ||= entries.order(:date).first.try(:date) || Date.current
end
# Finds all the rate pairs that are required to calculate balances for an account and syncs them
def sync_exchange_rates
rate_candidates = []
if multi_currency?
transactions_in_foreign_currency = self.entries.where.not(currency: self.currency).pluck(:currency, :date).uniq
transactions_in_foreign_currency.each do |currency, date|
rate_candidates << { date: date, from_currency: currency, to_currency: self.currency }
end
end
if foreign_currency?
(effective_start_date..Date.current).each do |date|
rate_candidates << { date: date, from_currency: self.currency, to_currency: self.family.currency }
end
end
return if rate_candidates.blank?
existing_rates = ExchangeRate.where(
base_currency: rate_candidates.map { |rc| rc[:from_currency] },
converted_currency: rate_candidates.map { |rc| rc[:to_currency] },
date: rate_candidates.map { |rc| rc[:date] }
).pluck(:base_currency, :converted_currency, :date)
# Convert to a set for faster lookup
existing_rates_set = existing_rates.map { |er| [ er[0], er[1], er[2].to_s ] }.to_set
rate_candidates.each do |rate_candidate|
rc_from = rate_candidate[:from_currency]
rc_to = rate_candidate[:to_currency]
rc_date = rate_candidate[:date]
next if existing_rates_set.include?([ rc_from, rc_to, rc_date.to_s ])
logger.info "Fetching exchange rate from provider for account #{self.name}: #{self.id} (#{rc_from} to #{rc_to} on #{rc_date})"
ExchangeRate.find_rate_or_fetch from: rc_from, to: rc_to, date: rc_date
end
nil
def sync(start_date: nil)
Account::Sync.for(self, start_date: start_date).run
end
end

View File

@@ -7,7 +7,13 @@ module Providable
class_methods do
def exchange_rates_provider
Provider::Synth.new
api_key = ENV["SYNTH_API_KEY"]
if api_key.present?
Provider::Synth.new api_key
else
nil
end
end
def git_repository_provider

View File

@@ -0,0 +1,284 @@
class Demo::Generator
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
def initialize
@family = reset_family!
end
def reset_and_clear_data!
clear_data!
create_user!
puts "user reset"
end
def reset_data!
clear_data!
create_user!
puts "user reset"
create_tags!
create_categories!
create_merchants!
puts "tags, categories, merchants created"
create_credit_card_account!
create_checking_account!
create_savings_account!
create_investment_account!
create_house_and_mortgage!
create_car_and_loan!
puts "accounts created"
family.sync
puts "balances synced"
puts "Demo data loaded successfully!"
end
private
attr_reader :family
def reset_family!
family_id = "d99e3c6e-d513-4452-8f24-dc263f8528c0" # deterministic demo id
family = Family.find_by(id: family_id)
family.destroy! if family
Family.create!(id: family_id, name: "Demo Family").tap(&:reload)
end
def clear_data!
ExchangeRate.destroy_all
end
def create_user!
family.users.create! \
email: "user@maybe.local",
first_name: "Demo",
last_name: "User",
password: "password"
end
def create_tags!
[ "Trips", "Emergency Fund", "Demo Tag" ].each do |tag|
family.tags.create!(name: tag)
end
end
def create_categories!
categories = [ "Income", "Food & Drink", "Entertainment", "Travel",
"Personal Care", "General Services", "Auto & Transport",
"Rent & Utilities", "Home Improvement", "Shopping" ]
categories.each do |category|
family.categories.create!(name: category, color: COLORS.sample)
end
end
def create_merchants!
merchants = [ "Amazon", "Starbucks", "McDonald's", "Target", "Costco",
"Home Depot", "Shell", "Whole Foods", "Walgreens", "Nike",
"Uber", "Netflix", "Spotify", "Delta Airlines", "Airbnb", "Sephora" ]
merchants.each do |merchant|
family.merchants.create!(name: merchant, color: COLORS.sample)
end
end
def create_credit_card_account!
cc = family.accounts.create! \
accountable: CreditCard.new,
name: "Chase Credit Card",
balance: 2300,
institution: family.institutions.find_or_create_by(name: "Chase")
50.times do
merchant = random_family_record(Merchant)
create_transaction! \
account: cc,
name: merchant.name,
amount: Faker::Number.positive(to: 200),
tags: [ tag_for_merchant(merchant) ],
category: category_for_merchant(merchant),
merchant: merchant
end
5.times do
create_transaction! \
account: cc,
amount: Faker::Number.negative(from: -1000),
name: "CC Payment"
end
end
def create_checking_account!
checking = family.accounts.create! \
accountable: Depository.new,
name: "Chase Checking",
balance: 15000,
institution: family.institutions.find_or_create_by(name: "Chase")
10.times do
create_transaction! \
account: checking,
name: "Expense",
amount: Faker::Number.positive(from: 100, to: 1000)
end
10.times do
create_transaction! \
account: checking,
amount: Faker::Number.negative(from: -2000),
name: "Income",
category: income_category
end
end
def create_savings_account!
savings = family.accounts.create! \
accountable: Depository.new,
name: "Demo Savings",
balance: 40000,
subtype: "savings",
institution: family.institutions.find_or_create_by(name: "Chase")
income_category = categories.find { |c| c.name == "Income" }
income_tag = tags.find { |t| t.name == "Emergency Fund" }
20.times do
create_transaction! \
account: savings,
amount: Faker::Number.negative(from: -2000),
tags: [ income_tag ],
category: income_category,
name: "Income"
end
end
def create_investment_account!
account = family.accounts.create! \
accountable: Investment.new,
name: "Robinhood",
balance: 100000,
institution: family.institutions.find_or_create_by(name: "Robinhood")
create_valuation!(account, 2.years.ago.to_date, 60000)
create_valuation!(account, 1.year.ago.to_date, 70000)
create_valuation!(account, 3.months.ago.to_date, 92000)
end
def create_house_and_mortgage!
house = family.accounts.create! \
accountable: Property.new,
name: "123 Maybe Way",
balance: 560000
create_valuation!(house, 3.years.ago.to_date, 520000)
create_valuation!(house, 2.years.ago.to_date, 540000)
create_valuation!(house, 1.years.ago.to_date, 550000)
family.accounts.create! \
accountable: Loan.new,
name: "Mortgage",
balance: 495000
end
def create_car_and_loan!
family.accounts.create! \
accountable: Vehicle.new,
name: "Honda Accord",
balance: 18000
family.accounts.create! \
accountable: Loan.new,
name: "Car Loan",
balance: 8000
end
def create_transaction!(attributes = {})
entry_attributes = attributes.except(:category, :tags, :merchant)
transaction_attributes = attributes.slice(:category, :tags, :merchant)
entry_defaults = {
date: Faker::Number.between(from: 0, to: 90).days.ago.to_date,
currency: "USD",
entryable: Account::Transaction.new(transaction_attributes)
}
Account::Entry.create! entry_defaults.merge(entry_attributes)
end
def create_valuation!(account, date, amount)
Account::Entry.create! \
account: account,
date: date,
amount: amount,
currency: "USD",
entryable: Account::Valuation.new
end
def random_family_record(model)
family_records = model.where(family_id: family.id)
model.offset(rand(family_records.count)).first
end
def category_for_merchant(merchant)
mapping = {
"Amazon" => "Shopping",
"Starbucks" => "Food & Drink",
"McDonald's" => "Food & Drink",
"Target" => "Shopping",
"Costco" => "Food & Drink",
"Home Depot" => "Home Improvement",
"Shell" => "Auto & Transport",
"Whole Foods" => "Food & Drink",
"Walgreens" => "Personal Care",
"Nike" => "Shopping",
"Uber" => "Auto & Transport",
"Netflix" => "Entertainment",
"Spotify" => "Entertainment",
"Delta Airlines" => "Travel",
"Airbnb" => "Travel",
"Sephora" => "Personal Care"
}
categories.find { |c| c.name == mapping[merchant.name] }
end
def tag_for_merchant(merchant)
mapping = {
"Delta Airlines" => "Trips",
"Airbnb" => "Trips"
}
tag_from_merchant = tags.find { |t| t.name == mapping[merchant.name] }
tag_from_merchant || tags.find { |t| t.name == "Demo Tag" }
end
def merchants
@merchants ||= family.merchants
end
def categories
@categories ||= family.categories
end
def tags
@tags ||= family.tags
end
def income_tag
@income_tag ||= tags.find { |t| t.name == "Emergency Fund" }
end
def income_category
@income_category ||= categories.find { |c| c.name == "Income" }
end
end

View File

@@ -1,29 +1,29 @@
class ExchangeRate < ApplicationRecord
include Provided
validates :base_currency, :converted_currency, presence: true
validates :from_currency, :to_currency, :date, :rate, presence: true
class << self
def find_rate(from:, to:, date:)
find_by \
base_currency: Money::Currency.new(from).iso_code,
converted_currency: Money::Currency.new(to).iso_code,
def find_rate(from:, to:, date:, cache: true)
result = find_by \
from_currency: from,
to_currency: to,
date: date
result || fetch_rate_from_provider(from:, to:, date:, cache:)
end
def find_rate_or_fetch(from:, to:, date:)
find_rate(from:, to:, date:) || fetch_rate_from_provider(from:, to:, date:)&.tap(&:save!)
end
def find_rates(from:, to:, start_date:, end_date: Date.current, cache: true)
rates = self.where(from_currency: from, to_currency: to, date: start_date..end_date).to_a
all_dates = (start_date..end_date).to_a.to_set
existing_dates = rates.map(&:date).to_set
missing_dates = all_dates - existing_dates
def get_rates(from, to, dates)
where(base_currency: from, converted_currency: to, date: dates).order(:date)
end
if missing_dates.any?
rates += fetch_rates_from_provider(from:, to:, dates: missing_dates, cache:)
end
def convert(value:, from:, to:, date:)
rate = ExchangeRate.find_by(base_currency: from, converted_currency: to, date:)
raise "Conversion from: #{from} to: #{to} on: #{date} not found" unless rate
value * rate.rate
rates
end
end
end

View File

@@ -1,25 +1,38 @@
module ExchangeRate::Provided
extend ActiveSupport::Concern
include Providable
class_methods do
private
def fetch_rate_from_provider(from:, to:, date:)
return nil unless exchange_rates_provider.configured?
def fetch_rates_from_provider(from:, to:, dates:, cache: false)
return [] unless exchange_rates_provider.present?
dates.map do |date|
fetch_rate_from_provider from:, to:, date:, cache:
end.compact
end
def fetch_rate_from_provider(from:, to:, date:, cache: false)
return nil unless exchange_rates_provider.present?
response = exchange_rates_provider.fetch_exchange_rate \
from: Money::Currency.new(from).iso_code,
to: Money::Currency.new(to).iso_code,
from: from,
to: to,
date: date
if response.success?
ExchangeRate.new \
base_currency: from,
converted_currency: to,
rate = ExchangeRate.new \
from_currency: from,
to_currency: to,
rate: response.rate,
date: date
rate.save! if cache
rate
else
raise response.error
nil
end
end
end

View File

@@ -97,14 +97,14 @@ class Family < ApplicationRecord
end
def assets
Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency) || 0 }.sum, currency)
Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
end
def liabilities
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency) || 0 }.sum, currency)
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
end
def sync_accounts
accounts.each { |account| account.sync_later if account.can_sync? }
def sync(start_date: nil)
accounts.active.sync(start_date: start_date)
end
end

View File

@@ -1,12 +1,8 @@
class Provider::Synth
include Retryable
def initialize(api_key = ENV["SYNTH_API_KEY"])
@api_key = api_key || ENV["SYNTH_API_KEY"]
end
def configured?
@api_key.present?
def initialize(api_key)
@api_key = api_key
end
def fetch_exchange_rate(from:, to:, date:)

View File

@@ -6,7 +6,8 @@
</header>
<%= form_with model: @account, data: { turbo_frame: "_top" } do |f| %>
<%= f.text_field :name, label: "Name" %>
<%= f.text_field :name, label: t(".name") %>
<%= f.money_field :balance_money, label: t(".balance"), readonly_currency: true %>
<div class="relative">
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>

View File

@@ -86,7 +86,7 @@
<%= label_tag :add_start_values, t(".optional_start_balance_message"), class: "pl-1 text-sm text-gray-500" %>
<div class="hidden peer-checked:flex items-center gap-2 mt-3 mb-6">
<div class="w-1/2"><%= f.date_field :start_date, label: t(".start_date"), max: Date.current %></div>
<div class="w-1/2"><%= f.date_field :start_date, label: t(".start_date"), max: Date.yesterday %></div>
<div class="w-1/2"><%= f.number_field :start_balance, label: t(".start_balance") %></div>
</div>
</div>

View File

@@ -44,24 +44,30 @@
<% end %>
</div>
</div>
<%= turbo_frame_tag "sync_message" do %>
<%= render partial: "accounts/sync_message", locals: { is_syncing: @account.syncing? } %>
<% end %>
<% @account.sync_errors.each do |message| %>
<%= render partial: "shared/alert", locals: { type: "error", content: t("." + message) } %>
<% end %>
<% @account.sync_warnings.each do |message| %>
<%= render partial: "shared/alert", locals: { type: "warning", content: t("." + message) } %>
<% if @account.alert %>
<%= render partial: "shared/alert", locals: { type: "error", content: t("." + @account.alert) } %>
<% end %>
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
<div class="p-4 flex justify-between">
<div class="space-y-2">
<%= render partial: "shared/value_heading", locals: {
label: "Total Value",
period: @period,
value: @account.balance_money,
trend: @balance_series.trend
} %>
<%= tag.p t(".total_value"), class: "text-sm font-medium text-gray-500" %>
<%= tag.p format_money(@account.balance_money, precision: 0), class: "text-gray-900 text-3xl font-medium" %>
<div>
<% if @balance_series.trend.direction.flat? %>
<%= tag.span t(".no_change"), class: "text-gray-500" %>
<% else %>
<%= tag.span format_money(@balance_series.trend.value), style: "color: #{@balance_series.trend.color}" %>
<%= tag.span "(#{@balance_series.trend.percent}%)", style: "color: #{@balance_series.trend.color}" %>
<% end %>
<%= tag.span period_label(@period), class: "text-gray-500" %>
</div>
</div>
<%= form_with url: account_path(@account), method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do %>
<%= render partial: "shared/period_select", locals: { value: @period.name } %>
@@ -72,11 +78,11 @@
</div>
</div>
<% selected_tab = params[:tab] || "history" %>
<% selected_tab = params[:tab] || "value" %>
<div class="flex gap-1 text-sm text-gray-900 font-medium mb-4">
<%= link_to "History", account_path(tab: "history"), class: ["p-2 rounded-lg", "bg-gray-100": selected_tab == "history"] %>
<%= link_to "Transactions", account_path(tab: "transactions"), class: ["p-2 rounded-lg", "bg-gray-100": selected_tab == "transactions"] %>
<div class="flex gap-2 text-sm text-gray-900 font-medium mb-4">
<%= link_to t(".value"), account_path(tab: "value"), class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab == "value"] %>
<%= link_to t(".transactions"), account_path(tab: "transactions"), class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab == "transactions"] %>
</div>
<div class="min-h-[800px]">

View File

@@ -3,5 +3,5 @@
<%= form.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".account"), required: true } %>
</div>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium" %>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium cursor-pointer hover:bg-gray-700" %>
<% end %>

View File

@@ -44,6 +44,6 @@
</div>
<% if @import.csv.valid? %>
<%= link_to "Next", confirm_import_path(@import), class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %>
<%= link_to "Next", confirm_import_path(@import), class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium hover:bg-gray-700", data: { turbo: false } %>
<% end %>
</div>

View File

@@ -20,6 +20,6 @@
<% end %>
</div>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.column_mappings? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium cursor-pointer hover:bg-gray-700", data: { turbo_confirm: (@import.column_mappings? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
<% end %>
</div>

View File

@@ -20,5 +20,5 @@
<% end %>
</div>
<%= button_to "Import " + @import.csv.table.size.to_s + " transactions", confirm_import_path(@import), method: :patch, class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo: false } %>
<%= button_to "Import " + @import.csv.table.size.to_s + " transactions", confirm_import_path(@import), method: :patch, class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium hover:bg-gray-700", data: { turbo: false } %>
</div>

View File

@@ -17,7 +17,7 @@
class: "rounded-md w-full border text-sm border-alpha-black-100 bg-white placeholder:text-gray-400 p-4" %>
</div>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.raw_csv_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium cursor-pointer hover:bg-gray-700", data: { turbo_confirm: (@import.raw_csv_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
<% end %>
<div class="bg-alpha-black-25 rounded-xl p-1">

View File

@@ -24,7 +24,7 @@
</div>
</nav>
<%= link_to content_for(:return_to_path) do %>
<%= lucide_icon("x", class: "text-gray-500 w-5 h-5") %>
<%= lucide_icon("x", class: "text-gray-500 w-8 h-8 hover:bg-gray-100 rounded-full p-2") %>
<% end %>
</div>

View File

@@ -10,7 +10,7 @@ module Maybe
private
def semver
"0.1.0-alpha.9"
"0.1.0-alpha.10"
end
end
end

View File

@@ -6,8 +6,10 @@ en:
destroy:
success: Account deleted successfully
edit:
balance: Balance
edit: Edit %{account}
institution: Financial institution
name: Name
ungrouped: "(none)"
empty:
empty_message: Add an account either via connection, importing or entering manually.
@@ -58,15 +60,18 @@ en:
confirm_title: Delete account?
edit: Edit
import: Import transactions
no_change: No change
sync_message_missing_rates: Since exchange rates haven't been synced, balance
graphs may not reflect accurate values.
sync_message_unknown_error: An error has occurred during the sync.
total_value: Total Value
transactions: Transactions
value: Value
summary:
new: New account
sync:
success: Account sync started
sync_all:
no_accounts_to_sync: No accounts were eligible for syncing.
success: Successfully queued %{count} accounts for syncing.
success: Successfully queued accounts for syncing.
update:
success: Account updated

View File

@@ -0,0 +1,6 @@
class RenameRateFields < ActiveRecord::Migration[7.2]
def change
rename_column :exchange_rates, :base_currency, :from_currency
rename_column :exchange_rates, :converted_currency, :to_currency
end
end

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

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateGoodJobExecutionErrorBacktrace < ActiveRecord::Migration[7.2]
def change
reversible do |dir|
dir.up do
# Ensure this incremental update migration is idempotent
# with monolithic install migration.
return if connection.column_exists?(:good_job_executions, :error_backtrace)
end
end
add_column :good_job_executions, :error_backtrace, :text, array: true
end
end

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
class CreateGoodJobProcessLockIds < ActiveRecord::Migration[7.2]
def change
reversible do |dir|
dir.up do
# Ensure this incremental update migration is idempotent
# with monolithic install migration.
return if connection.column_exists?(:good_jobs, :locked_by_id)
end
end
add_column :good_jobs, :locked_by_id, :uuid
add_column :good_jobs, :locked_at, :datetime
add_column :good_job_executions, :process_id, :uuid
add_column :good_job_processes, :lock_type, :integer, limit: 2
end
end

View File

@@ -0,0 +1,38 @@
# frozen_string_literal: true
class CreateGoodJobProcessLockIndexes < ActiveRecord::Migration[7.2]
disable_ddl_transaction!
def change
reversible do |dir|
dir.up do
unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked)
add_index :good_jobs, [ :priority, :scheduled_at ],
order: { priority: "ASC NULLS LAST", scheduled_at: :asc },
where: "finished_at IS NULL AND locked_by_id IS NULL",
name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked,
algorithm: :concurrently
end
unless connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id)
add_index :good_jobs, :locked_by_id,
where: "locked_by_id IS NOT NULL",
name: :index_good_jobs_on_locked_by_id,
algorithm: :concurrently
end
unless connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at)
add_index :good_job_executions, [ :process_id, :created_at ],
name: :index_good_job_executions_on_process_id_and_created_at,
algorithm: :concurrently
end
end
dir.down do
remove_index(:good_jobs, name: :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked) if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_priority_scheduled_at_unfinished_unlocked)
remove_index(:good_jobs, name: :index_good_jobs_on_locked_by_id) if connection.index_name_exists?(:good_jobs, :index_good_jobs_on_locked_by_id)
remove_index(:good_job_executions, name: :index_good_job_executions_on_process_id_and_created_at) if connection.index_name_exists?(:good_job_executions, :index_good_job_executions_on_process_id_and_created_at)
end
end
end
end

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateGoodJobExecutionDuration < ActiveRecord::Migration[7.2]
def change
reversible do |dir|
dir.up do
# Ensure this incremental update migration is idempotent
# with monolithic install migration.
return if connection.column_exists?(:good_job_executions, :duration)
end
end
add_column :good_job_executions, :duration, :interval
end
end

39
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_06_28_104551) do
ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -48,6 +48,18 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_28_104551) do
t.index ["transfer_id"], name: "index_account_entries_on_transfer_id"
end
create_table "account_syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
t.string "status", default: "pending", null: false
t.date "start_date"
t.datetime "last_ran_at"
t.string "error"
t.text "warnings", default: [], array: true
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_account_syncs_on_account_id"
end
create_table "account_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@@ -80,12 +92,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_28_104551) do
t.decimal "balance", precision: 19, scale: 4, default: "0.0"
t.string "currency", default: "USD"
t.boolean "is_active", default: true, null: false
t.enum "status", default: "ok", null: false, enum_type: "account_status"
t.jsonb "sync_warnings", default: [], null: false
t.jsonb "sync_errors", default: [], null: false
t.date "last_sync_date"
t.uuid "institution_id"
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["family_id"], name: "index_accounts_on_family_id"
t.index ["institution_id"], name: "index_accounts_on_institution_id"
@@ -145,15 +154,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_28_104551) do
end
create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "base_currency", null: false
t.string "converted_currency", null: false
t.string "from_currency", null: false
t.string "to_currency", null: false
t.decimal "rate"
t.date "date"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["base_currency", "converted_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true
t.index ["base_currency"], name: "index_exchange_rates_on_base_currency"
t.index ["converted_currency"], name: "index_exchange_rates_on_converted_currency"
t.index ["from_currency", "to_currency", "date"], name: "index_exchange_rates_on_base_converted_date_unique", unique: true
t.index ["from_currency"], name: "index_exchange_rates_on_from_currency"
t.index ["to_currency"], name: "index_exchange_rates_on_to_currency"
end
create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -189,13 +198,18 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_28_104551) do
t.datetime "finished_at"
t.text "error"
t.integer "error_event", limit: 2
t.text "error_backtrace", array: true
t.uuid "process_id"
t.interval "duration"
t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at"
t.index ["process_id", "created_at"], name: "index_good_job_executions_on_process_id_and_created_at"
end
create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.jsonb "state"
t.integer "lock_type", limit: 2
end
create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -228,6 +242,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_28_104551) do
t.text "job_class"
t.integer "error_event", limit: 2
t.text "labels", array: true
t.uuid "locked_by_id"
t.datetime "locked_at"
t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at"
t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)"
t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)"
@@ -236,8 +252,10 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_28_104551) do
t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)"
t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))"
t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin
t.index ["locked_by_id"], name: "index_good_jobs_on_locked_by_id", where: "(locked_by_id IS NOT NULL)"
t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)"
t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)"
t.index ["priority", "scheduled_at"], name: "index_good_jobs_on_priority_scheduled_at_unfinished_unlocked", where: "((finished_at IS NULL) AND (locked_by_id IS NULL))"
t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)"
t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)"
end
@@ -355,6 +373,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_06_28_104551) do
add_foreign_key "account_balances", "accounts", on_delete: :cascade
add_foreign_key "account_entries", "account_transfers", column: "transfer_id"
add_foreign_key "account_entries", "accounts"
add_foreign_key "account_syncs", "accounts"
add_foreign_key "account_transactions", "categories", on_delete: :nullify
add_foreign_key "account_transactions", "merchants"
add_foreign_key "accounts", "families"

View File

@@ -2,7 +2,10 @@ class Money
include Comparable, Arithmetic
include ActiveModel::Validations
attr_reader :amount, :currency
class ConversionError < StandardError
end
attr_reader :amount, :currency, :store
validate :source_must_be_of_known_type
@@ -16,20 +19,27 @@ class Money
end
end
def initialize(obj, currency = Money.default_currency)
def initialize(obj, currency = Money.default_currency, store: ExchangeRate)
@source = obj
@amount = obj.is_a?(Money) ? obj.amount : BigDecimal(obj.to_s)
@currency = obj.is_a?(Money) ? obj.currency : Money::Currency.new(currency)
@store = store
validate!
end
# TODO: Replace with injected rate store
def exchange_to(other_currency, date = Date.current)
if currency == Money::Currency.new(other_currency)
def exchange_to(other_currency, date: Date.current, fallback_rate: nil)
iso_code = currency.iso_code
other_iso_code = Money::Currency.new(other_currency).iso_code
if iso_code == other_iso_code
self
elsif rate = ExchangeRate.find_rate(from: currency, to: other_currency, date: date)
Money.new(amount * rate.rate, other_currency)
else
exchange_rate = store.find_rate(from: iso_code, to: other_iso_code, date: date)&.rate || fallback_rate
raise ConversionError.new("Couldn't find exchange rate from #{iso_code} to #{other_iso_code} on #{date}") unless exchange_rate
Money.new(amount * exchange_rate, other_iso_code)
end
end

View File

@@ -1,332 +1,10 @@
namespace :demo_data do
desc "Creates or resets demo data used in development environment"
task reset_empty: :environment do
Family.all.each do |family|
family.destroy
end
family = Family.create(name: "Demo Family")
family.users.create! \
email: "user@maybe.local",
password: "password",
first_name: "Demo",
last_name: "User"
task empty: :environment do
Demo::Generator.new.reset_and_clear_data!
end
task reset: :environment do
family = Family.find_or_create_by(name: "Demo Family")
family.accounts.destroy_all
ExchangeRate.delete_all
family.categories.destroy_all
Tagging.delete_all
family.tags.destroy_all
Category.create_default_categories(family)
user = User.find_or_create_by(email: "user@maybe.local") do |u|
u.password = "password"
u.family = family
u.first_name = "User"
u.last_name = "Demo"
end
puts "Reset user: #{user.email} with family: #{family.name}"
# Tags
tags = [
{ name: "Hawaii Trip", color: "#e99537" },
{ name: "Trips", color: "#4da568" },
{ name: "Emergency Fund", color: "#db5a54" }
]
family.tags.insert_all(tags)
# Mock exchange rates for last 60 days (these rates are reasonable for EUR:USD, but not exact)
exchange_rates = (0..60).map do |days_ago|
{
date: Date.current - days_ago.days,
base_currency: "EUR",
converted_currency: "USD",
rate: rand(1.0840..1.0924).round(4)
}
end
exchange_rates += (0..20).map do |days_ago|
{
date: Date.current - days_ago.days,
base_currency: "BTC",
converted_currency: "USD",
rate: rand(60000..65000).round(2)
}
end
# Multi-currency account needs a few USD:EUR rates
exchange_rates += [
{ date: Date.current - 45.days, base_currency: "USD", converted_currency: "EUR", rate: 0.89 },
{ date: Date.current - 34.days, base_currency: "USD", converted_currency: "EUR", rate: 0.87 },
{ date: Date.current - 28.days, base_currency: "USD", converted_currency: "EUR", rate: 0.88 },
{ date: Date.current - 14.days, base_currency: "USD", converted_currency: "EUR", rate: 0.86 }
]
ExchangeRate.insert_all(exchange_rates)
puts "Loaded mock exchange rates for last 60 days"
# ========== Accounts ================
empty_account = Account.create(name: "Demo Empty Account", family: family, accountable: Depository.new, balance: 500, currency: "USD")
multi_currency_checking = Account.create(name: "Demo Multi-Currency Checking", family: family, accountable: Depository.new, balance: 4000, currency: "EUR")
checking = Account.create(name: "Demo Checking", family: family, accountable: Depository.new, balance: 5000, currency: "USD")
savings = Account.create(name: "Demo Savings", family: family, accountable: Depository.new, balance: 20000, currency: "USD")
credit_card = Account.create(name: "Demo Credit Card", family: family, accountable: CreditCard.new, balance: 1500, currency: "USD")
retirement = Account.create(name: "Demo 401k", family: family, accountable: Investment.new, balance: 100000, currency: "USD")
euro_savings = Account.create(name: "Demo Euro Savings", family: family, accountable: Depository.new, balance: 10000, currency: "EUR")
brokerage = Account.create(name: "Demo Brokerage Account", family: family, accountable: Investment.new, balance: 10000, currency: "USD")
crypto = Account.create(name: "Bitcoin Account", family: family, accountable: Crypto.new, balance: 0.1, currency: "BTC")
mortgage = Account.create(name: "Demo Mortgage", family: family, accountable: Loan.new, balance: 450000, currency: "USD")
main_car = Account.create(name: "Demo Main Car", family: family, accountable: Vehicle.new, balance: 25000, currency: "USD")
cash = Account.create(name: "Demo Physical Cash", family: family, accountable: OtherAsset.new, balance: 500, currency: "USD")
car_loan = Account.create(name: "Demo Car Loan", family: family, accountable: Loan.new, balance: 10000, currency: "USD")
house = Account.create(name: "Demo Primary Residence", family: family, accountable: Property.new, balance: 2500000, currency: "USD")
personal_iou = Account.create(name: "Demo Personal IOU", family: family, accountable: OtherLiability.new, balance: 1000, currency: "USD")
second_car = Account.create(name: "Demo Secondary Car", family: family, accountable: Vehicle.new, balance: 12000, currency: "USD")
# ========== Transactions ================
multi_currency_checking_transactions = [
{ date: Date.today - 45, amount: -3000, name: "Paycheck", currency: "USD" },
{ date: Date.today - 41, amount: 1500, name: "Rent Payment", currency: "EUR" },
{ date: Date.today - 39, amount: 200, name: "Groceries", currency: "EUR" },
{ date: Date.today - 34, amount: -3000, name: "Paycheck", currency: "USD" },
{ date: Date.today - 31, amount: 1500, name: "Rent Payment", currency: "EUR" },
{ date: Date.today - 28, amount: 100, name: "Utilities", currency: "EUR" },
{ date: Date.today - 28, amount: -3000, name: "Paycheck", currency: "USD" },
{ date: Date.today - 28, amount: 1500, name: "Rent Payment", currency: "EUR" },
{ date: Date.today - 28, amount: 50, name: "Internet Bill", currency: "EUR" },
{ date: Date.today - 14, amount: -3000, name: "Paycheck", currency: "USD" }
]
checking_transactions = [
{ date: Date.today - 84, amount: -3000, name: "Direct Deposit" },
{ date: Date.today - 70, amount: 1500, name: "Credit Card Payment" },
{ date: Date.today - 70, amount: 200, name: "Utility Bill" },
{ date: Date.today - 56, amount: -3000, name: "Direct Deposit" },
{ date: Date.today - 42, amount: 1500, name: "Credit Card Payment" },
{ date: Date.today - 42, amount: 100, name: "Internet Bill" },
{ date: Date.today - 28, amount: -3000, name: "Direct Deposit" },
{ date: Date.today - 28, amount: 1500, name: "Credit Card Payment" },
{ date: Date.today - 28, amount: 50, name: "Mobile Bill" },
{ date: Date.today - 14, amount: -3000, name: "Direct Deposit" },
{ date: Date.today - 14, amount: 1500, name: "Credit Card Payment" },
{ date: Date.today - 14, amount: 200, name: "Car Loan Payment" },
{ date: Date.today - 7, amount: 150, name: "Insurance" },
{ date: Date.today - 2, amount: 100, name: "Gym Membership" }
]
savings_transactions = [
{ date: Date.today - 360, amount: -1000, name: "Initial Deposit" },
{ date: Date.today - 330, amount: -200, name: "Monthly Savings" },
{ date: Date.today - 300, amount: -200, name: "Monthly Savings" },
{ date: Date.today - 270, amount: -200, name: "Monthly Savings" },
{ date: Date.today - 240, amount: -200, name: "Monthly Savings" },
{ date: Date.today - 210, amount: -200, name: "Monthly Savings" },
{ date: Date.today - 180, amount: -200, name: "Monthly Savings" },
{ date: Date.today - 150, amount: -200, name: "Monthly Savings" },
{ date: Date.today - 120, amount: -200, name: "Monthly Savings" },
{ date: Date.today - 90, amount: 1000, name: "Withdrawal" },
{ date: Date.today - 60, amount: -200, name: "Monthly Savings" },
{ date: Date.today - 30, amount: -200, name: "Monthly Savings" }
]
euro_savings_transactions = [
{ date: Date.today - 55, amount: -500, name: "Initial Deposit", currency: "EUR" },
{ date: Date.today - 40, amount: -100, name: "Savings", currency: "EUR" },
{ date: Date.today - 15, amount: -100, name: "Savings", currency: "EUR" },
{ date: Date.today - 10, amount: -100, name: "Savings", currency: "EUR" },
{ date: Date.today - 9, amount: 500, name: "Withdrawal", currency: "EUR" },
{ date: Date.today - 5, amount: -100, name: "Savings", currency: "EUR" },
{ date: Date.today - 2, amount: -100, name: "Savings", currency: "EUR" }
]
credit_card_transactions = [
{ date: Date.today - 90, amount: 75, name: "Grocery Store" },
{ date: Date.today - 89, amount: 30, name: "Gas Station" },
{ date: Date.today - 88, amount: 12, name: "Coffee Shop" },
{ date: Date.today - 85, amount: 50, name: "Restaurant" },
{ date: Date.today - 84, amount: 25, name: "Online Subscription" },
{ date: Date.today - 82, amount: 100, name: "Clothing Store" },
{ date: Date.today - 80, amount: 60, name: "Pharmacy" },
{ date: Date.today - 78, amount: 40, name: "Utility Bill" },
{ date: Date.today - 75, amount: 90, name: "Home Improvement Store" },
{ date: Date.today - 74, amount: 20, name: "Book Store" },
{ date: Date.today - 72, amount: 15, name: "Movie Theater" },
{ date: Date.today - 70, amount: 200, name: "Electronics Store" },
{ date: Date.today - 68, amount: 35, name: "Pet Store" },
{ date: Date.today - 65, amount: 80, name: "Sporting Goods Store" },
{ date: Date.today - 63, amount: 55, name: "Department Store" },
{ date: Date.today - 60, amount: 110, name: "Auto Repair Shop" },
{ date: Date.today - 58, amount: 45, name: "Beauty Salon" },
{ date: Date.today - 55, amount: 95, name: "Furniture Store" },
{ date: Date.today - 53, amount: 22, name: "Fast Food" },
{ date: Date.today - 50, amount: 120, name: "Airline Ticket" },
{ date: Date.today - 48, amount: 65, name: "Hotel" },
{ date: Date.today - 45, amount: 30, name: "Car Rental" },
{ date: Date.today - 43, amount: 18, name: "Music Store" },
{ date: Date.today - 40, amount: 70, name: "Grocery Store" },
{ date: Date.today - 38, amount: 32, name: "Gas Station" },
{ date: Date.today - 36, amount: 14, name: "Coffee Shop" },
{ date: Date.today - 33, amount: 52, name: "Restaurant" },
{ date: Date.today - 31, amount: 28, name: "Online Subscription" },
{ date: Date.today - 29, amount: 105, name: "Clothing Store" },
{ date: Date.today - 27, amount: 62, name: "Pharmacy" },
{ date: Date.today - 25, amount: 42, name: "Utility Bill" },
{ date: Date.today - 22, amount: 92, name: "Home Improvement Store" },
{ date: Date.today - 20, amount: 23, name: "Book Store" },
{ date: Date.today - 18, amount: 17, name: "Movie Theater" },
{ date: Date.today - 15, amount: 205, name: "Electronics Store" },
{ date: Date.today - 13, amount: 37, name: "Pet Store" },
{ date: Date.today - 10, amount: 83, name: "Sporting Goods Store" },
{ date: Date.today - 8, amount: 57, name: "Department Store" },
{ date: Date.today - 5, amount: 115, name: "Auto Repair Shop" },
{ date: Date.today - 3, amount: 47, name: "Beauty Salon" },
{ date: Date.today - 1, amount: 98, name: "Furniture Store" },
{ date: Date.today - 60, amount: -800, name: "Credit Card Payment" },
{ date: Date.today - 30, amount: -900, name: "Credit Card Payment" },
{ date: Date.today, amount: -1000, name: "Credit Card Payment" }
]
mortgage_transactions = [
{ date: Date.today - 90, amount: 1500, name: "Mortgage Payment" },
{ date: Date.today - 60, amount: 1500, name: "Mortgage Payment" },
{ date: Date.today - 30, amount: 1500, name: "Mortgage Payment" }
]
car_loan_transactions = [
{ date: 12.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 11.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 10.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 9.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 8.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 7.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 6.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 5.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 4.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 3.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 2.months.ago.to_date, amount: 1250, name: "Car Loan Payment" },
{ date: 1.month.ago.to_date, amount: 1250, name: "Car Loan Payment" }
]
# ========== Valuations ================
retirement_valuations = [
{ date: 1.year.ago.to_date, value: 90000 },
{ date: 200.days.ago.to_date, value: 95000 },
{ date: 100.days.ago.to_date, value: 94444.96 },
{ date: 20.days.ago.to_date, value: 100000 }
]
brokerage_valuations = [
{ date: 1.year.ago.to_date, value: 9000 },
{ date: 200.days.ago.to_date, value: 9500 },
{ date: 100.days.ago.to_date, value: 9444.96 },
{ date: 20.days.ago.to_date, value: 10000 }
]
crypto_valuations = [
{ date: 1.week.ago.to_date, value: 0.08, currency: "BTC" },
{ date: 2.days.ago.to_date, value: 0.1, currency: "BTC" }
]
mortgage_valuations = [
{ date: 2.years.ago.to_date, value: 500000 },
{ date: 6.months.ago.to_date, value: 455000 }
]
house_valuations = [
{ date: 5.years.ago.to_date, value: 3000000 },
{ date: 4.years.ago.to_date, value: 2800000 },
{ date: 3.years.ago.to_date, value: 2700000 },
{ date: 2.years.ago.to_date, value: 2600000 },
{ date: 1.year.ago.to_date, value: 2500000 }
]
main_car_valuations = [
{ date: 1.year.ago.to_date, value: 25000 }
]
second_car_valuations = [
{ date: 2.years.ago.to_date, value: 11000 },
{ date: 1.year.ago.to_date, value: 12000 }
]
cash_valuations = [
{ date: 1.month.ago.to_date, value: 500 }
]
personal_iou_valuations = [
{ date: 1.month.ago.to_date, value: 1000 }
]
accounts = [
[ empty_account, [], [] ],
[ multi_currency_checking, multi_currency_checking_transactions, [] ],
[ checking, checking_transactions, [] ],
[ savings, savings_transactions, [] ],
[ credit_card, credit_card_transactions, [] ],
[ retirement, [], retirement_valuations ],
[ euro_savings, euro_savings_transactions, [] ],
[ brokerage, [], brokerage_valuations ],
[ crypto, [], crypto_valuations ],
[ mortgage, mortgage_transactions, mortgage_valuations ],
[ main_car, [], main_car_valuations ],
[ cash, [], cash_valuations ],
[ car_loan, car_loan_transactions, [] ],
[ house, [], house_valuations ],
[ personal_iou, [], personal_iou_valuations ],
[ second_car, [], second_car_valuations ]
]
accounts.each do |account, transactions, valuations|
transactions.each do |transaction|
account.entries.create! \
name: transaction[:name],
date: transaction[:date],
amount: transaction[:amount],
currency: transaction[:currency] || "USD",
entryable: Account::Transaction.new(category: family.categories.first, tags: [ Tag.first ])
end
valuations.each do |valuation|
account.entries.create! \
name: "Manual valuation",
date: valuation[:date],
amount: valuation[:value],
currency: valuation[:currency] || "USD",
entryable: Account::Valuation.new
end
end
# Tag a few transactions
emergency_fund_tag = Tag.find_by(name: "Emergency Fund")
trips_tag = Tag.find_by(name: "Trips")
hawaii_trip_tag = Tag.find_by(name: "Hawaii Trip")
savings.transactions.order(date: :desc).limit(5).each do |txn|
txn.tags << emergency_fund_tag
txn.save!
end
checking.transactions.order(date: :desc).limit(5).each do |txn|
txn.tags = [ trips_tag, hawaii_trip_tag ]
txn.save!
end
puts "Created demo accounts, transactions, and valuations for family: #{family.name}"
puts "Syncing accounts... This may take a few seconds."
family.accounts.each do |account|
account.sync
end
puts "Accounts synced. Demo data reset complete."
Demo::Generator.new.reset_data!
end
end

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ require "test_helper"
class AccountsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in @user = users(:family_admin)
@account = accounts(:checking)
@account = accounts(:depository)
end
test "gets accounts list" do
@@ -33,7 +33,7 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
test "can sync all accounts" do
post sync_all_accounts_path
assert_redirected_to accounts_url
assert_equal "Successfully queued #{ @user.family.accounts.size } accounts for syncing.", flash[:notice]
assert_equal "Successfully queued accounts for syncing.", flash[:notice]
end
test "should update account" do
@@ -46,6 +46,37 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
}
assert_redirected_to account_url(@account)
assert_enqueued_with job: AccountSyncJob
assert_equal "Account updated", flash[:notice]
end
test "updates account balance by creating new valuation" do
assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do
patch account_url(@account), params: {
account: {
balance: 10000
}
}
end
assert_redirected_to account_url(@account)
assert_enqueued_with job: AccountSyncJob
assert_equal "Account updated", flash[:notice]
end
test "updates account balance by editing existing valuation for today" do
@account.entries.create! date: Date.current, amount: 6000, currency: "USD", entryable: Account::Valuation.new
assert_no_difference [ "Account::Entry.count", "Account::Valuation.count" ] do
patch account_url(@account), params: {
account: {
balance: 10000
}
}
end
assert_redirected_to account_url(@account)
assert_enqueued_with job: AccountSyncJob
assert_equal "Account updated", flash[:notice]
end

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
require "test_helper"
class TransactionsControllerTest < ActionDispatch::IntegrationTest
include Account::EntriesTestHelper
setup do
sign_in @user = users(:family_admin)
@transaction_entry = account_entries(:checking_one)
@recent_transaction_entries = @user.family.entries.account_transactions.reverse_chronological.limit(20).to_a
@transaction = account_entries(:transaction)
end
test "should get new" do
@@ -13,9 +14,9 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
end
test "prefills account_id" do
get new_transaction_url(account_id: @transaction_entry.account.id)
get new_transaction_url(account_id: @transaction.account.id)
assert_response :success
assert_select "option[selected][value='#{@transaction_entry.account.id}']"
assert_select "option[selected][value='#{@transaction.account.id}']"
end
test "should create transaction" do
@@ -45,11 +46,11 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
post transactions_url, params: {
account_entry: {
nature: "expense",
account_id: @transaction_entry.account_id,
amount: @transaction_entry.amount,
currency: @transaction_entry.currency,
date: @transaction_entry.date,
name: @transaction_entry.name,
account_id: @transaction.account_id,
amount: @transaction.amount,
currency: @transaction.currency,
date: @transaction.date,
name: @transaction.name,
entryable_type: "Account::Transaction",
entryable_attributes: {}
}
@@ -58,7 +59,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
created_entry = Account::Entry.order(created_at: :desc).first
assert_redirected_to account_url(@transaction_entry.account)
assert_redirected_to account_url(@transaction.account)
assert created_entry.amount.positive?, "Amount should be positive"
end
@@ -67,11 +68,11 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
post transactions_url, params: {
account_entry: {
nature: "income",
account_id: @transaction_entry.account_id,
amount: @transaction_entry.amount,
currency: @transaction_entry.currency,
date: @transaction_entry.date,
name: @transaction_entry.name,
account_id: @transaction.account_id,
amount: @transaction.amount,
currency: @transaction.currency,
date: @transaction.date,
name: @transaction.name,
entryable_type: "Account::Transaction",
entryable_attributes: { category_id: categories(:food_and_drink).id }
}
@@ -80,83 +81,79 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
created_entry = Account::Entry.order(created_at: :desc).first
assert_redirected_to account_url(@transaction_entry.account)
assert_redirected_to account_url(@transaction.account)
assert created_entry.amount.negative?, "Amount should be negative"
end
test "should get paginated index with most recent transactions first" do
get transactions_url(per_page: 10)
assert_response :success
@recent_transaction_entries.first(10).each do |transaction|
assert_dom "#" + dom_id(transaction), count: 1
end
end
test "transaction count represents filtered total" do
family = families(:empty)
sign_in family.users.first
account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new
3.times do
create_transaction(account: account)
end
get transactions_url(per_page: 10)
assert_dom "#total-transactions", count: 1, text: @user.family.entries.account_transactions.select { |t| t.currency == "USD" }.count.to_s
new_transaction = @user.family.accounts.first.entries.create! \
entryable: Account::Transaction.new,
name: "Transaction to search for",
date: Date.current,
amount: 0,
currency: "USD"
assert_dom "#total-transactions", count: 1, text: family.entries.account_transactions.size.to_s
get transactions_url(q: { search: new_transaction.name })
searchable_transaction = create_transaction(account: account, name: "Unique test name")
get transactions_url(q: { search: searchable_transaction.name })
# Only finds 1 transaction that matches filter
assert_dom "#" + dom_id(new_transaction), count: 1
assert_dom "#" + dom_id(searchable_transaction), count: 1
assert_dom "#total-transactions", count: 1, text: "1"
end
test "can navigate to paginated result" do
get transactions_url(page: 2, per_page: 10)
test "can paginate" do
family = families(:empty)
sign_in family.users.first
account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new
11.times do
create_transaction(account: account)
end
sorted_transactions = family.entries.account_transactions.reverse_chronological.to_a
assert_equal 11, sorted_transactions.count
get transactions_url(page: 1, per_page: 10)
assert_response :success
visible_transaction_entries = @recent_transaction_entries[10, 10].reject { |e| e.transfer.present? }
visible_transaction_entries.each do |transaction|
sorted_transactions.first(10).each do |transaction|
assert_dom "#" + dom_id(transaction), count: 1
end
end
test "loads last page when page is out of range" do
user_oldest_transaction_entry = @user.family.entries.account_transactions.chronological.first
get transactions_url(page: 9999999999)
get transactions_url(page: 2, per_page: 10)
assert_response :success
assert_dom "#" + dom_id(user_oldest_transaction_entry), count: 1
assert_dom "#" + dom_id(sorted_transactions.last), count: 1
get transactions_url(page: 9999999, per_page: 10) # out of range loads last page
assert_dom "#" + dom_id(sorted_transactions.last), count: 1
end
test "can destroy many transactions at once" do
delete_count = 10
transactions = @user.family.entries.account_transactions
delete_count = transactions.size
assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do
post bulk_delete_transactions_url, params: {
bulk_delete: {
entry_ids: @recent_transaction_entries.first(delete_count).pluck(:id)
entry_ids: transactions.pluck(:id)
}
}
end
assert_redirected_to transactions_url
assert_equal "10 transactions deleted", flash[:notice]
assert_equal "#{delete_count} transactions deleted", flash[:notice]
end
test "can update many transactions at once" do
transactions = @user.family.entries.account_transactions.reverse_chronological.limit(20)
transactions.each do |transaction|
transaction.update! \
date: Date.current,
entryable_attributes: {
id: transaction.account_transaction.id,
category_id: Category.first.id,
merchant_id: Merchant.first.id,
notes: "Starting note"
}
end
transactions = @user.family.entries.account_transactions
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do
post bulk_update_transactions_url, params: {
@@ -173,9 +170,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to transactions_url
assert_equal "#{transactions.count} transactions updated", flash[:notice]
transactions.reload
transactions.each do |transaction|
transactions.reload.each do |transaction|
assert_equal 1.day.ago.to_date, transaction.date
assert_equal Category.second, transaction.account_transaction.category
assert_equal Merchant.second, transaction.account_transaction.merchant

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

@@ -0,0 +1,11 @@
one:
date: <%= 2.days.ago.to_date %>
balance: 4990
currency: USD
account: depository
two:
date: <%= 1.day.ago.to_date %>
balance: 4980
currency: USD
account: depository

View File

@@ -1,323 +1,39 @@
# Checking account transactions
checking_one:
valuation:
name: Manual valuation
date: <%= 4.days.ago.to_date %>
amount: 4995
currency: USD
account: depository
entryable_type: Account::Valuation
entryable: one
transaction:
name: Starbucks
date: <%= 5.days.ago.to_date %>
date: <%= 1.day.ago.to_date %>
amount: 10
account: checking
currency: USD
account: depository
entryable_type: Account::Transaction
entryable: checking_one
entryable: one
checking_two:
name: Chipotle
date: <%= 12.days.ago.to_date %>
amount: 30
account: checking
currency: USD
entryable_type: Account::Transaction
entryable: checking_two
checking_three:
name: Amazon
date: <%= 15.days.ago.to_date %>
amount: 20
account: checking
currency: USD
entryable_type: Account::Transaction
entryable: checking_three
checking_four:
name: Paycheck
date: <%= 22.days.ago.to_date %>
amount: -1075
account: checking
currency: USD
entryable_type: Account::Transaction
entryable: checking_four
checking_five:
name: Netflix
date: <%= 29.days.ago.to_date %>
amount: 15
account: checking
currency: USD
entryable_type: Account::Transaction
entryable: checking_five
checking_six_payment:
name: Payment to Credit Card
date: <%= 29.days.ago.to_date %>
transfer_out:
name: Payment to credit card account
date: <%= 3.days.ago.to_date %>
amount: 100
account: checking
currency: USD
entryable_type: Account::Transaction
entryable: checking_six_payment
account: depository
marked_as_transfer: true
transfer: credit_card_payment
checking_seven_transfer:
name: Transfer to Savings
date: <%= 30.days.ago.to_date %>
amount: 250
account: checking
currency: USD
marked_as_transfer: true
transfer: savings_transfer
transfer: one
entryable_type: Account::Transaction
entryable: checking_seven_transfer
entryable: transfer_out
checking_eight_external_payment:
name: Transfer TO external CC account (owned by user but not known to app)
date: <%= 30.days.ago.to_date %>
amount: 800
account: checking
currency: USD
marked_as_transfer: true
entryable_type: Account::Transaction
entryable: checking_eight_external_payment
checking_nine_external_transfer:
name: Transfer FROM external investing account (owned by user but not known to app)
date: <%= 31.days.ago.to_date %>
amount: -200
account: checking
currency: USD
marked_as_transfer: true
entryable_type: Account::Transaction
entryable: checking_nine_external_transfer
savings_one:
name: Interest Received
date: <%= 5.days.ago.to_date %>
amount: -200
account: savings
currency: USD
entryable_type: Account::Transaction
entryable: savings_one
savings_two:
name: Check Deposit
date: <%= 12.days.ago.to_date %>
amount: -50
account: savings
currency: USD
entryable_type: Account::Transaction
entryable: savings_two
savings_three:
name: Withdrawal
date: <%= 18.days.ago.to_date %>
amount: 2000
account: savings
currency: USD
entryable_type: Account::Transaction
entryable: savings_three
savings_four:
name: Check Deposit
date: <%= 29.days.ago.to_date %>
amount: -500
account: savings
currency: USD
entryable_type: Account::Transaction
entryable: savings_four
savings_five_transfer:
name: Received Transfer from Checking Account
date: <%= 31.days.ago.to_date %>
amount: -250
account: savings
currency: USD
marked_as_transfer: true
transfer: savings_transfer
entryable_type: Account::Transaction
entryable: savings_five_transfer
credit_card_one:
name: Starbucks
date: <%= 5.days.ago.to_date %>
amount: 10
account: credit_card
currency: USD
entryable_type: Account::Transaction
entryable: credit_card_one
credit_card_two:
name: Chipotle
date: <%= 12.days.ago.to_date %>
amount: 30
account: credit_card
currency: USD
entryable_type: Account::Transaction
entryable: credit_card_two
credit_card_three:
name: Amazon
date: <%= 15.days.ago.to_date %>
amount: 20
account: credit_card
currency: USD
entryable_type: Account::Transaction
entryable: credit_card_three
credit_card_four_payment:
name: Received CC Payment from Checking Account
date: <%= 31.days.ago.to_date %>
transfer_in:
name: Payment received from checking account
date: <%= 3.days.ago.to_date %>
amount: -100
currency: USD
account: credit_card
currency: USD
marked_as_transfer: true
transfer: credit_card_payment
transfer: one
entryable_type: Account::Transaction
entryable: credit_card_four_payment
eur_checking_one:
name: Check
date: <%= 9.days.ago.to_date %>
amount: -50
currency: EUR
account: eur_checking
entryable_type: Account::Transaction
entryable: eur_checking_one
eur_checking_two:
name: Shopping trip
date: <%= 19.days.ago.to_date %>
amount: 100
currency: EUR
account: eur_checking
entryable_type: Account::Transaction
entryable: eur_checking_two
eur_checking_three:
name: Check
date: <%= 31.days.ago.to_date %>
amount: -200
currency: EUR
account: eur_checking
entryable_type: Account::Transaction
entryable: eur_checking_three
multi_currency_one:
name: Outflow 1
date: <%= 4.days.ago.to_date %>
amount: 800
currency: EUR
account: multi_currency
entryable_type: Account::Transaction
entryable: multi_currency_one
multi_currency_two:
name: Inflow 1
date: <%= 9.days.ago.to_date %>
amount: -50
currency: USD
account: multi_currency
entryable_type: Account::Transaction
entryable: multi_currency_two
multi_currency_three:
name: Outflow 2
date: <%= 19.days.ago.to_date %>
amount: 110.85
currency: EUR
account: multi_currency
entryable_type: Account::Transaction
entryable: multi_currency_three
multi_currency_four:
name: Inflow 2
date: <%= 29.days.ago.to_date %>
amount: -200
currency: USD
account: multi_currency
entryable_type: Account::Transaction
entryable: multi_currency_four
collectable_one_valuation:
amount: 550
date: <%= 4.days.ago.to_date %>
account: collectable
currency: USD
entryable_type: Account::Valuation
entryable: collectable_one
collectable_two_valuation:
amount: 700
date: <%= 12.days.ago.to_date %>
account: collectable
currency: USD
entryable_type: Account::Valuation
entryable: collectable_two
collectable_three_valuation:
amount: 400
date: <%= 31.days.ago.to_date %>
account: collectable
currency: USD
entryable_type: Account::Valuation
entryable: collectable_three
iou_one_valuation:
amount: 200
date: <%= 31.days.ago.to_date %>
account: iou
currency: USD
entryable_type: Account::Valuation
entryable: iou_one
multi_currency_one_valuation:
amount: 10200
date: <%= 31.days.ago.to_date %>
account: multi_currency
currency: USD
entryable_type: Account::Valuation
entryable: multi_currency_one
savings_one_valuation:
amount: 19500
date: <%= 12.days.ago.to_date %>
account: savings
currency: USD
entryable_type: Account::Valuation
entryable: savings_one
savings_two_valuation:
amount: 21000
date: <%= 25.days.ago.to_date %>
account: savings
currency: USD
entryable_type: Account::Valuation
entryable: savings_two
brokerage_one_valuation:
amount: 10000
date: <%= 31.days.ago.to_date %>
account: brokerage
currency: USD
entryable_type: Account::Valuation
entryable: brokerage_one
mortgage_loan_one_valuation:
amount: 500000
date: <%= 31.days.ago.to_date %>
account: mortgage_loan
currency: USD
entryable_type: Account::Valuation
entryable: mortgage_loan_one
house_one_valuation:
amount: 550000
date: <%= 31.days.ago.to_date %>
account: house
currency: USD
entryable_type: Account::Valuation
entryable: house_one
car_one_valuation:
amount: 18000
date: <%= 31.days.ago.to_date %>
account: car
currency: USD
entryable_type: Account::Valuation
entryable: car_one
entryable: transfer_in

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

@@ -0,0 +1,13 @@
one:
account: depository
status: failed
start_date: 2024-07-07
last_ran_at: 2024-07-07 09:03:31
error: test sync error
warnings: [ "test warning 1", "test warning 2" ]
two:
account: investment
status: completed
start_date: 2024-07-07
last_ran_at: 2024-07-07 09:03:32

View File

@@ -1,60 +1,6 @@
# Checking account transactions
checking_one:
one:
category: food_and_drink
checking_two:
category: food_and_drink
checking_three:
merchant: amazon
checking_four:
category: income
checking_five:
merchant: netflix
checking_six_payment: { }
checking_seven_transfer: { }
checking_eight_external_payment: { }
checking_nine_external_transfer: { }
# Savings account that has transactions and valuation overrides
savings_one:
category: income
savings_two:
category: income
savings_three: { }
savings_four:
category: income
savings_five_transfer: { }
# Credit card account transactions
credit_card_one:
category: food_and_drink
credit_card_two:
category: food_and_drink
credit_card_three:
merchant: amazon
credit_card_four_payment: { }
# eur_checking transactions
eur_checking_one: { }
eur_checking_two: { }
eur_checking_three: { }
# multi_currency transactions
multi_currency_one: { }
multi_currency_two: { }
multi_currency_three: { }
multi_currency_four: { }
transfer_out: { }
transfer_in: { }

View File

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

View File

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

View File

@@ -1,31 +1,23 @@
collectable:
other_asset:
family: dylan_family
name: Collectable Account
balance: 550
accountable_type: OtherAsset
accountable: other_asset_collectable
accountable: one
iou:
other_liability:
family: dylan_family
name: IOU (personal debt to friend)
balance: 200
accountable_type: OtherLiability
accountable: other_liability_iou
accountable: one
checking:
depository:
family: dylan_family
name: Checking Account
balance: 5000
accountable_type: Depository
accountable: depository_checking
institution: chase
savings:
family: dylan_family
name: Savings account
balance: 19700
accountable_type: Depository
accountable: depository_savings
accountable: one
institution: chase
credit_card:
@@ -33,56 +25,37 @@ credit_card:
name: Credit Card
balance: 1000
accountable_type: CreditCard
accountable: credit_one
accountable: one
institution: chase
eur_checking:
family: dylan_family
name: Euro Checking Account
currency: EUR
balance: 12000
accountable_type: Depository
accountable: depository_eur_checking
institution: revolut
# Multi-currency account (e.g. Wise, Revolut, etc.)
multi_currency:
family: dylan_family
name: Multi Currency Account
currency: USD # multi-currency accounts still have a "primary" currency
balance: 9467
accountable_type: Depository
accountable: depository_multi_currency
institution: revolut
brokerage:
investment:
family: dylan_family
name: Robinhood Brokerage Account
currency: USD
balance: 10000
accountable_type: Investment
accountable: investment_brokerage
accountable: one
mortgage_loan:
loan:
family: dylan_family
name: Mortgage Loan
currency: USD
balance: 500000
accountable_type: Loan
accountable: loan_mortgage
accountable: one
house:
property:
family: dylan_family
name: 123 Maybe Court
currency: USD
balance: 550000
accountable_type: Property
accountable: property_house
accountable: one
car:
vehicle:
family: dylan_family
name: Honda Accord
currency: USD
balance: 18000
accountable_type: Vehicle
accountable: vehicle_honda_accord
accountable: one

View File

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

View File

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

View File

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

View File

@@ -1,383 +1,11 @@
day_31_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
one:
from_currency: EUR
to_currency: GBP
rate: 1.0986
date: <%= 31.days.ago.to_date %>
date: <%= Date.current %>
day_30_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
two:
from_currency: EUR
to_currency: GBP
rate: 1.0926
date: <%= 30.days.ago.to_date %>
day_29_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.094
date: <%= 29.days.ago.to_date %>
day_28_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.095
date: <%= 28.days.ago.to_date %>
day_27_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0898
date: <%= 27.days.ago.to_date %>
day_26_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0858
date: <%= 26.days.ago.to_date %>
day_25_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0856
date: <%= 25.days.ago.to_date %>
day_24_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.084
date: <%= 24.days.ago.to_date %>
day_23_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0807
date: <%= 23.days.ago.to_date %>
day_22_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0839
date: <%= 22.days.ago.to_date %>
day_21_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0845
date: <%= 21.days.ago.to_date %>
day_20_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0854
date: <%= 20.days.ago.to_date %>
day_19_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0822
date: <%= 19.days.ago.to_date %>
day_18_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0824
date: <%= 18.days.ago.to_date %>
day_17_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0818
date: <%= 17.days.ago.to_date %>
day_16_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0809
date: <%= 16.days.ago.to_date %>
day_15_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.078
date: <%= 15.days.ago.to_date %>
day_14_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0778
date: <%= 14.days.ago.to_date %>
day_13_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0773
date: <%= 13.days.ago.to_date %>
day_12_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0729
date: <%= 12.days.ago.to_date %>
day_11_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0709
date: <%= 11.days.ago.to_date %>
day_10_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0773
date: <%= 10.days.ago.to_date %>
day_9_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0783
date: <%= 9.days.ago.to_date %>
day_8_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0778
date: <%= 8.days.ago.to_date %>
day_7_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0774
date: <%= 7.days.ago.to_date %>
day_6_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0755
date: <%= 6.days.ago.to_date %>
day_5_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0743
date: <%= 5.days.ago.to_date %>
day_4_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0788
date: <%= 4.days.ago.to_date %>
day_3_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0872
date: <%= 3.days.ago.to_date %>
day_2_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0819
date: <%= 2.days.ago.to_date %>
day_1_ago_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0845
date: <%= 1.days.ago.to_date %>
today_eur_to_usd:
base_currency: EUR
converted_currency: USD
rate: 1.0834
date: <%= Date.current %>
day_31_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9279
date: <%= 31.days.ago.to_date %>
day_30_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9179
date: <%= 30.days.ago.to_date %>
day_29_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9154
date: <%= 29.days.ago.to_date %>
day_28_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9107
date: <%= 28.days.ago.to_date %>
day_27_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9139
date: <%= 27.days.ago.to_date %>
day_26_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9082
date: <%= 26.days.ago.to_date %>
day_25_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9077
date: <%= 25.days.ago.to_date %>
day_24_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9054
date: <%= 24.days.ago.to_date %>
day_23_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9004
date: <%= 23.days.ago.to_date %>
day_22_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9040
date: <%= 22.days.ago.to_date %>
day_21_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9060
date: <%= 21.days.ago.to_date %>
day_20_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9052
date: <%= 20.days.ago.to_date %>
day_19_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9139
date: <%= 19.days.ago.to_date %>
day_18_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9155
date: <%= 18.days.ago.to_date %>
day_17_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9135
date: <%= 17.days.ago.to_date %>
day_16_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9141
date: <%= 16.days.ago.to_date %>
day_15_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9131
date: <%= 15.days.ago.to_date %>
day_14_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9147
date: <%= 14.days.ago.to_date %>
day_13_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9112
date: <%= 13.days.ago.to_date %>
day_12_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9115
date: <%= 12.days.ago.to_date %>
day_11_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9132
date: <%= 11.days.ago.to_date %>
day_10_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9130
date: <%= 10.days.ago.to_date %>
day_9_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9192
date: <%= 9.days.ago.to_date %>
day_8_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9188
date: <%= 8.days.ago.to_date %>
day_7_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9194
date: <%= 7.days.ago.to_date %>
day_6_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9177
date: <%= 6.days.ago.to_date %>
day_5_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9187
date: <%= 5.days.ago.to_date %>
day_4_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9213
date: <%= 4.days.ago.to_date %>
day_3_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9186
date: <%= 3.days.ago.to_date %>
day_2_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9218
date: <%= 2.days.ago.to_date %>
day_1_ago_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9213
date: <%= 1.days.ago.to_date %>
today_usd_to_eur:
base_currency: USD
converted_currency: EUR
rate: 0.9141
date: <%= Date.current %>
date: <%= 1.day.ago.to_date %>

View File

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

View File

@@ -1,33 +0,0 @@
date_offset,collectable,iou,checking,credit_card,savings,eur_checking_eur,eur_checking_usd,multi_currency,brokerage,mortgage_loan,house,car,net_worth,assets,liabilities,depositories,investments,loans,credits,properties,vehicles,other_assets,other_liabilities,spending,income,rolling_spend,rolling_income,savings_rate
31,400.00,200.00,5150.00,940.00,20950.00,12050.00,13238.13,10200.00,10000.00,500000.00,550000.00,18000.00,126798.13,627938.13,501140.00,49538.13,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,219.72,0.00,0.00,0.0000
30,400.00,200.00,4100.00,940.00,20950.00,12050.00,13165.83,10200.00,10000.00,500000.00,550000.00,18000.00,125675.83,626815.83,501140.00,48415.83,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,0.00,219.72,1.0000
29,400.00,200.00,3985.00,940.00,21450.00,12050.00,13182.70,10400.00,10000.00,500000.00,550000.00,18000.00,126277.70,627417.70,501140.00,49017.70,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,15.00,700.00,15.00,919.72,0.9837
28,400.00,200.00,3985.00,940.00,21450.00,12050.00,13194.75,10400.00,10000.00,500000.00,550000.00,18000.00,126289.75,627429.75,501140.00,49029.75,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,919.72,0.9837
27,400.00,200.00,3985.00,940.00,21450.00,12050.00,13132.09,10400.00,10000.00,500000.00,550000.00,18000.00,126227.09,627367.09,501140.00,48967.09,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,919.72,0.9837
26,400.00,200.00,3985.00,940.00,21450.00,12050.00,13083.89,10400.00,10000.00,500000.00,550000.00,18000.00,126178.89,627318.89,501140.00,48918.89,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,919.72,0.9837
25,400.00,200.00,3985.00,940.00,21000.00,12050.00,13081.48,10400.00,10000.00,500000.00,550000.00,18000.00,125726.48,626866.48,501140.00,48466.48,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,919.72,0.9837
24,400.00,200.00,3985.00,940.00,21000.00,12050.00,13062.20,10400.00,10000.00,500000.00,550000.00,18000.00,125707.20,626847.20,501140.00,48447.20,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,919.72,0.9837
23,400.00,200.00,3985.00,940.00,21000.00,12050.00,13022.44,10400.00,10000.00,500000.00,550000.00,18000.00,125667.44,626807.44,501140.00,48407.44,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,919.72,0.9837
22,400.00,200.00,5060.00,940.00,21000.00,12050.00,13061.00,10400.00,10000.00,500000.00,550000.00,18000.00,126781.00,627921.00,501140.00,49521.00,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,1075.00,15.00,1994.72,0.9925
21,400.00,200.00,5060.00,940.00,21000.00,12050.00,13068.23,10400.00,10000.00,500000.00,550000.00,18000.00,126788.23,627928.23,501140.00,49528.23,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,1994.72,0.9925
20,400.00,200.00,5060.00,940.00,21000.00,12050.00,13079.07,10400.00,10000.00,500000.00,550000.00,18000.00,126799.07,627939.07,501140.00,49539.07,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,15.00,1994.72,0.9925
19,400.00,200.00,5060.00,940.00,21000.00,11950.00,12932.29,10280.04,10000.00,500000.00,550000.00,18000.00,126532.33,627672.33,501140.00,49272.33,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,228.18,0.00,243.18,1994.72,0.8781
18,400.00,200.00,5060.00,940.00,19000.00,11950.00,12934.68,10280.04,10000.00,500000.00,550000.00,18000.00,124534.72,625674.72,501140.00,47274.72,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,2000.00,0.00,2243.18,1994.72,-0.1246
17,400.00,200.00,5060.00,940.00,19000.00,11950.00,12927.51,10280.04,10000.00,500000.00,550000.00,18000.00,124527.55,625667.55,501140.00,47267.55,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,2243.18,1994.72,-0.1246
16,400.00,200.00,5060.00,940.00,19000.00,11950.00,12916.76,10280.04,10000.00,500000.00,550000.00,18000.00,124516.79,625656.79,501140.00,47256.79,10000.00,500000.00,940.00,550000.00,18000.00,400.00,200.00,0.00,0.00,2243.18,1994.72,-0.1246
15,400.00,200.00,5040.00,960.00,19000.00,11950.00,12882.10,10280.04,10000.00,500000.00,550000.00,18000.00,124442.14,625602.14,501160.00,47202.14,10000.00,500000.00,960.00,550000.00,18000.00,400.00,200.00,40.00,0.00,2283.18,1994.72,-0.1446
14,400.00,200.00,5040.00,960.00,19000.00,11950.00,12879.71,10280.04,10000.00,500000.00,550000.00,18000.00,124439.75,625599.75,501160.00,47199.75,10000.00,500000.00,960.00,550000.00,18000.00,400.00,200.00,0.00,0.00,2283.18,1994.72,-0.1446
13,400.00,200.00,5040.00,960.00,19000.00,11950.00,12873.74,10280.04,10000.00,500000.00,550000.00,18000.00,124433.77,625593.77,501160.00,47193.77,10000.00,500000.00,960.00,550000.00,18000.00,400.00,200.00,0.00,0.00,2283.18,1994.72,-0.1446
12,700.00,200.00,5010.00,990.00,19500.00,11950.00,12821.16,10280.04,10000.00,500000.00,550000.00,18000.00,125121.19,626311.19,501190.00,47611.19,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,60.00,50.00,2343.18,2044.72,-0.1460
11,700.00,200.00,5010.00,990.00,19500.00,11950.00,12797.26,10280.04,10000.00,500000.00,550000.00,18000.00,125097.29,626287.29,501190.00,47587.29,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2044.72,-0.1460
10,700.00,200.00,5010.00,990.00,19500.00,11950.00,12873.74,10280.04,10000.00,500000.00,550000.00,18000.00,125173.77,626363.77,501190.00,47663.77,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2044.72,-0.1460
9,700.00,200.00,5010.00,990.00,19500.00,12000.00,12939.60,10330.04,10000.00,500000.00,550000.00,18000.00,125289.64,626479.64,501190.00,47779.64,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,103.92,2343.18,2148.64,-0.0905
8,700.00,200.00,5010.00,990.00,19500.00,12000.00,12933.60,10330.04,10000.00,500000.00,550000.00,18000.00,125283.64,626473.64,501190.00,47773.64,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2148.64,-0.0905
7,700.00,200.00,5010.00,990.00,19500.00,12000.00,12928.80,10330.04,10000.00,500000.00,550000.00,18000.00,125278.84,626468.84,501190.00,47768.84,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2148.64,-0.0905
6,700.00,200.00,5010.00,990.00,19500.00,12000.00,12906.00,10330.04,10000.00,500000.00,550000.00,18000.00,125256.04,626446.04,501190.00,47746.04,10000.00,500000.00,990.00,550000.00,18000.00,700.00,200.00,0.00,0.00,2343.18,2148.64,-0.0905
5,700.00,200.00,5000.00,1000.00,19700.00,12000.00,12891.60,10330.04,10000.00,500000.00,550000.00,18000.00,125421.64,626621.64,501200.00,47921.64,10000.00,500000.00,1000.00,550000.00,18000.00,700.00,200.00,20.00,200.00,2363.18,2348.64,-0.0062
4,550.00,200.00,5000.00,1000.00,19700.00,12000.00,12945.60,9467.00,10000.00,500000.00,550000.00,18000.00,124462.60,625662.60,501200.00,47112.60,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,863.04,0.00,3226.22,2348.64,-0.3737
3,550.00,200.00,5000.00,1000.00,19700.00,12000.00,13046.40,9467.00,10000.00,500000.00,550000.00,18000.00,124563.40,625763.40,501200.00,47213.40,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,0.00,0.00,3226.22,2348.64,-0.3737
2,550.00,200.00,5000.00,1000.00,19700.00,12000.00,12982.80,9467.00,10000.00,500000.00,550000.00,18000.00,124499.80,625699.80,501200.00,47149.80,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,0.00,0.00,3226.22,2348.64,-0.3737
1,550.00,200.00,5000.00,1000.00,19700.00,12000.00,13014.00,9467.00,10000.00,500000.00,550000.00,18000.00,124531.00,625731.00,501200.00,47181.00,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,0.00,0.00,3226.22,2348.64,-0.3737
0,550.00,200.00,5000.00,1000.00,19700.00,12000.00,13000.80,9467.00,10000.00,500000.00,550000.00,18000.00,124517.80,625717.80,501200.00,47167.80,10000.00,500000.00,1000.00,550000.00,18000.00,550.00,200.00,0.00,0.00,3226.22,2348.64,-0.3737
1 date_offset collectable iou checking credit_card savings eur_checking_eur eur_checking_usd multi_currency brokerage mortgage_loan house car net_worth assets liabilities depositories investments loans credits properties vehicles other_assets other_liabilities spending income rolling_spend rolling_income savings_rate
2 31 400.00 200.00 5150.00 940.00 20950.00 12050.00 13238.13 10200.00 10000.00 500000.00 550000.00 18000.00 126798.13 627938.13 501140.00 49538.13 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 219.72 0.00 0.00 0.0000
3 30 400.00 200.00 4100.00 940.00 20950.00 12050.00 13165.83 10200.00 10000.00 500000.00 550000.00 18000.00 125675.83 626815.83 501140.00 48415.83 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 0.00 0.00 219.72 1.0000
4 29 400.00 200.00 3985.00 940.00 21450.00 12050.00 13182.70 10400.00 10000.00 500000.00 550000.00 18000.00 126277.70 627417.70 501140.00 49017.70 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 15.00 700.00 15.00 919.72 0.9837
5 28 400.00 200.00 3985.00 940.00 21450.00 12050.00 13194.75 10400.00 10000.00 500000.00 550000.00 18000.00 126289.75 627429.75 501140.00 49029.75 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 0.00 15.00 919.72 0.9837
6 27 400.00 200.00 3985.00 940.00 21450.00 12050.00 13132.09 10400.00 10000.00 500000.00 550000.00 18000.00 126227.09 627367.09 501140.00 48967.09 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 0.00 15.00 919.72 0.9837
7 26 400.00 200.00 3985.00 940.00 21450.00 12050.00 13083.89 10400.00 10000.00 500000.00 550000.00 18000.00 126178.89 627318.89 501140.00 48918.89 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 0.00 15.00 919.72 0.9837
8 25 400.00 200.00 3985.00 940.00 21000.00 12050.00 13081.48 10400.00 10000.00 500000.00 550000.00 18000.00 125726.48 626866.48 501140.00 48466.48 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 0.00 15.00 919.72 0.9837
9 24 400.00 200.00 3985.00 940.00 21000.00 12050.00 13062.20 10400.00 10000.00 500000.00 550000.00 18000.00 125707.20 626847.20 501140.00 48447.20 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 0.00 15.00 919.72 0.9837
10 23 400.00 200.00 3985.00 940.00 21000.00 12050.00 13022.44 10400.00 10000.00 500000.00 550000.00 18000.00 125667.44 626807.44 501140.00 48407.44 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 0.00 15.00 919.72 0.9837
11 22 400.00 200.00 5060.00 940.00 21000.00 12050.00 13061.00 10400.00 10000.00 500000.00 550000.00 18000.00 126781.00 627921.00 501140.00 49521.00 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 1075.00 15.00 1994.72 0.9925
12 21 400.00 200.00 5060.00 940.00 21000.00 12050.00 13068.23 10400.00 10000.00 500000.00 550000.00 18000.00 126788.23 627928.23 501140.00 49528.23 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 0.00 15.00 1994.72 0.9925
13 20 400.00 200.00 5060.00 940.00 21000.00 12050.00 13079.07 10400.00 10000.00 500000.00 550000.00 18000.00 126799.07 627939.07 501140.00 49539.07 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 0.00 15.00 1994.72 0.9925
14 19 400.00 200.00 5060.00 940.00 21000.00 11950.00 12932.29 10280.04 10000.00 500000.00 550000.00 18000.00 126532.33 627672.33 501140.00 49272.33 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 228.18 0.00 243.18 1994.72 0.8781
15 18 400.00 200.00 5060.00 940.00 19000.00 11950.00 12934.68 10280.04 10000.00 500000.00 550000.00 18000.00 124534.72 625674.72 501140.00 47274.72 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 2000.00 0.00 2243.18 1994.72 -0.1246
16 17 400.00 200.00 5060.00 940.00 19000.00 11950.00 12927.51 10280.04 10000.00 500000.00 550000.00 18000.00 124527.55 625667.55 501140.00 47267.55 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 0.00 2243.18 1994.72 -0.1246
17 16 400.00 200.00 5060.00 940.00 19000.00 11950.00 12916.76 10280.04 10000.00 500000.00 550000.00 18000.00 124516.79 625656.79 501140.00 47256.79 10000.00 500000.00 940.00 550000.00 18000.00 400.00 200.00 0.00 0.00 2243.18 1994.72 -0.1246
18 15 400.00 200.00 5040.00 960.00 19000.00 11950.00 12882.10 10280.04 10000.00 500000.00 550000.00 18000.00 124442.14 625602.14 501160.00 47202.14 10000.00 500000.00 960.00 550000.00 18000.00 400.00 200.00 40.00 0.00 2283.18 1994.72 -0.1446
19 14 400.00 200.00 5040.00 960.00 19000.00 11950.00 12879.71 10280.04 10000.00 500000.00 550000.00 18000.00 124439.75 625599.75 501160.00 47199.75 10000.00 500000.00 960.00 550000.00 18000.00 400.00 200.00 0.00 0.00 2283.18 1994.72 -0.1446
20 13 400.00 200.00 5040.00 960.00 19000.00 11950.00 12873.74 10280.04 10000.00 500000.00 550000.00 18000.00 124433.77 625593.77 501160.00 47193.77 10000.00 500000.00 960.00 550000.00 18000.00 400.00 200.00 0.00 0.00 2283.18 1994.72 -0.1446
21 12 700.00 200.00 5010.00 990.00 19500.00 11950.00 12821.16 10280.04 10000.00 500000.00 550000.00 18000.00 125121.19 626311.19 501190.00 47611.19 10000.00 500000.00 990.00 550000.00 18000.00 700.00 200.00 60.00 50.00 2343.18 2044.72 -0.1460
22 11 700.00 200.00 5010.00 990.00 19500.00 11950.00 12797.26 10280.04 10000.00 500000.00 550000.00 18000.00 125097.29 626287.29 501190.00 47587.29 10000.00 500000.00 990.00 550000.00 18000.00 700.00 200.00 0.00 0.00 2343.18 2044.72 -0.1460
23 10 700.00 200.00 5010.00 990.00 19500.00 11950.00 12873.74 10280.04 10000.00 500000.00 550000.00 18000.00 125173.77 626363.77 501190.00 47663.77 10000.00 500000.00 990.00 550000.00 18000.00 700.00 200.00 0.00 0.00 2343.18 2044.72 -0.1460
24 9 700.00 200.00 5010.00 990.00 19500.00 12000.00 12939.60 10330.04 10000.00 500000.00 550000.00 18000.00 125289.64 626479.64 501190.00 47779.64 10000.00 500000.00 990.00 550000.00 18000.00 700.00 200.00 0.00 103.92 2343.18 2148.64 -0.0905
25 8 700.00 200.00 5010.00 990.00 19500.00 12000.00 12933.60 10330.04 10000.00 500000.00 550000.00 18000.00 125283.64 626473.64 501190.00 47773.64 10000.00 500000.00 990.00 550000.00 18000.00 700.00 200.00 0.00 0.00 2343.18 2148.64 -0.0905
26 7 700.00 200.00 5010.00 990.00 19500.00 12000.00 12928.80 10330.04 10000.00 500000.00 550000.00 18000.00 125278.84 626468.84 501190.00 47768.84 10000.00 500000.00 990.00 550000.00 18000.00 700.00 200.00 0.00 0.00 2343.18 2148.64 -0.0905
27 6 700.00 200.00 5010.00 990.00 19500.00 12000.00 12906.00 10330.04 10000.00 500000.00 550000.00 18000.00 125256.04 626446.04 501190.00 47746.04 10000.00 500000.00 990.00 550000.00 18000.00 700.00 200.00 0.00 0.00 2343.18 2148.64 -0.0905
28 5 700.00 200.00 5000.00 1000.00 19700.00 12000.00 12891.60 10330.04 10000.00 500000.00 550000.00 18000.00 125421.64 626621.64 501200.00 47921.64 10000.00 500000.00 1000.00 550000.00 18000.00 700.00 200.00 20.00 200.00 2363.18 2348.64 -0.0062
29 4 550.00 200.00 5000.00 1000.00 19700.00 12000.00 12945.60 9467.00 10000.00 500000.00 550000.00 18000.00 124462.60 625662.60 501200.00 47112.60 10000.00 500000.00 1000.00 550000.00 18000.00 550.00 200.00 863.04 0.00 3226.22 2348.64 -0.3737
30 3 550.00 200.00 5000.00 1000.00 19700.00 12000.00 13046.40 9467.00 10000.00 500000.00 550000.00 18000.00 124563.40 625763.40 501200.00 47213.40 10000.00 500000.00 1000.00 550000.00 18000.00 550.00 200.00 0.00 0.00 3226.22 2348.64 -0.3737
31 2 550.00 200.00 5000.00 1000.00 19700.00 12000.00 12982.80 9467.00 10000.00 500000.00 550000.00 18000.00 124499.80 625699.80 501200.00 47149.80 10000.00 500000.00 1000.00 550000.00 18000.00 550.00 200.00 0.00 0.00 3226.22 2348.64 -0.3737
32 1 550.00 200.00 5000.00 1000.00 19700.00 12000.00 13014.00 9467.00 10000.00 500000.00 550000.00 18000.00 124531.00 625731.00 501200.00 47181.00 10000.00 500000.00 1000.00 550000.00 18000.00 550.00 200.00 0.00 0.00 3226.22 2348.64 -0.3737
33 0 550.00 200.00 5000.00 1000.00 19700.00 12000.00 13000.80 9467.00 10000.00 500000.00 550000.00 18000.00 124517.80 625717.80 501200.00 47167.80 10000.00 500000.00 1000.00 550000.00 18000.00 550.00 200.00 0.00 0.00 3226.22 2348.64 -0.3737

View File

@@ -1,9 +1,9 @@
empty_import:
account: checking
account: depository
created_at: <%= 1.minute.ago %>
completed_import:
account: checking
account: depository
column_mappings:
date: date
name: name

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
one:
tag: hawaii_trip
taggable: checking_one
tag: one
taggable: one
taggable_type: Account::Transaction
two:
tag: emergency_fund
taggable: checking_two
tag: two
taggable: one
taggable_type: Account::Transaction

View File

@@ -1,11 +1,11 @@
trips:
one:
name: Trips
family: dylan_family
hawaii_trip:
name: Hawaii Trip
two:
name: Emergency fund
family: dylan_family
emergency_fund:
name: Emergency Fund
family: dylan_family
three:
name: Test
family: empty

View File

@@ -1,3 +1,10 @@
empty:
family: empty
first_name: User
last_name: One
email: user1@email.com
password_digest: <%= BCrypt::Password.create('password') %>
family_admin:
family: dylan_family
first_name: Bob

View File

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

View File

@@ -1,99 +1,112 @@
require "test_helper"
require "ostruct"
class MoneyTest < ActiveSupport::TestCase
test "can create with default currency" do
value = Money.new(1000)
assert_equal 1000, value.amount
end
test "can create with default currency" do
value = Money.new(1000)
assert_equal 1000, value.amount
end
test "can create with custom currency" do
value1 = Money.new(1000, :EUR)
value2 = Money.new(1000, :eur)
value3 = Money.new(1000, "eur")
value4 = Money.new(1000, "EUR")
test "can create with custom currency" do
value1 = Money.new(1000, :EUR)
value2 = Money.new(1000, :eur)
value3 = Money.new(1000, "eur")
value4 = Money.new(1000, "EUR")
assert_equal value1.currency.iso_code, value2.currency.iso_code
assert_equal value2.currency.iso_code, value3.currency.iso_code
assert_equal value3.currency.iso_code, value4.currency.iso_code
end
assert_equal value1.currency.iso_code, value2.currency.iso_code
assert_equal value2.currency.iso_code, value3.currency.iso_code
assert_equal value3.currency.iso_code, value4.currency.iso_code
end
test "equality tests amount and currency" do
assert_equal Money.new(1000), Money.new(1000)
assert_not_equal Money.new(1000), Money.new(1001)
assert_not_equal Money.new(1000, :usd), Money.new(1000, :eur)
end
test "equality tests amount and currency" do
assert_equal Money.new(1000), Money.new(1000)
assert_not_equal Money.new(1000), Money.new(1001)
assert_not_equal Money.new(1000, :usd), Money.new(1000, :eur)
end
test "can compare with zero Numeric" do
assert_equal Money.new(0), 0
assert_raises(TypeError) { Money.new(1) == 1 }
end
test "can compare with zero Numeric" do
assert_equal Money.new(0), 0
assert_raises(TypeError) { Money.new(1) == 1 }
end
test "can negate" do
assert_equal (-Money.new(1000)), Money.new(-1000)
end
test "can negate" do
assert_equal (-Money.new(1000)), Money.new(-1000)
end
test "can use comparison operators" do
assert_operator Money.new(1000), :>, Money.new(999)
assert_operator Money.new(1000), :>=, Money.new(1000)
assert_operator Money.new(1000), :<, Money.new(1001)
assert_operator Money.new(1000), :<=, Money.new(1000)
end
test "can use comparison operators" do
assert_operator Money.new(1000), :>, Money.new(999)
assert_operator Money.new(1000), :>=, Money.new(1000)
assert_operator Money.new(1000), :<, Money.new(1001)
assert_operator Money.new(1000), :<=, Money.new(1000)
end
test "can add and subtract" do
assert_equal Money.new(1000) + Money.new(1000), Money.new(2000)
assert_equal Money.new(1000) + 1000, Money.new(2000)
assert_equal Money.new(1000) - Money.new(1000), Money.new(0)
assert_equal Money.new(1000) - 1000, Money.new(0)
end
test "can add and subtract" do
assert_equal Money.new(1000) + Money.new(1000), Money.new(2000)
assert_equal Money.new(1000) + 1000, Money.new(2000)
assert_equal Money.new(1000) - Money.new(1000), Money.new(0)
assert_equal Money.new(1000) - 1000, Money.new(0)
end
test "can multiply" do
assert_equal Money.new(1000) * 2, Money.new(2000)
assert_raises(TypeError) { Money.new(1000) * Money.new(2) }
end
test "can multiply" do
assert_equal Money.new(1000) * 2, Money.new(2000)
assert_raises(TypeError) { Money.new(1000) * Money.new(2) }
end
test "can divide" do
assert_equal Money.new(1000) / 2, Money.new(500)
assert_equal Money.new(1000) / Money.new(500), 2
assert_raise(TypeError) { 1000 / Money.new(2) }
end
test "can divide" do
assert_equal Money.new(1000) / 2, Money.new(500)
assert_equal Money.new(1000) / Money.new(500), 2
assert_raise(TypeError) { 1000 / Money.new(2) }
end
test "operator order does not matter" do
assert_equal Money.new(1000) + 1000, 1000 + Money.new(1000)
assert_equal Money.new(1000) - 1000, 1000 - Money.new(1000)
assert_equal Money.new(1000) * 2, 2 * Money.new(1000)
end
test "operator order does not matter" do
assert_equal Money.new(1000) + 1000, 1000 + Money.new(1000)
assert_equal Money.new(1000) - 1000, 1000 - Money.new(1000)
assert_equal Money.new(1000) * 2, 2 * Money.new(1000)
end
test "can get absolute value" do
assert_equal Money.new(1000).abs, Money.new(1000)
assert_equal Money.new(-1000).abs, Money.new(1000)
end
test "can get absolute value" do
assert_equal Money.new(1000).abs, Money.new(1000)
assert_equal Money.new(-1000).abs, Money.new(1000)
end
test "can test if zero" do
assert Money.new(0).zero?
assert_not Money.new(1000).zero?
end
test "can test if zero" do
assert Money.new(0).zero?
assert_not Money.new(1000).zero?
end
test "can test if negative" do
assert Money.new(-1000).negative?
assert_not Money.new(1000).negative?
end
test "can test if negative" do
assert Money.new(-1000).negative?
assert_not Money.new(1000).negative?
end
test "can test if positive" do
assert Money.new(1000).positive?
assert_not Money.new(-1000).positive?
end
test "can test if positive" do
assert Money.new(1000).positive?
assert_not Money.new(-1000).positive?
end
test "can cast to string with basic formatting" do
assert_equal "$1,000.90", Money.new(1000.899).format
assert_equal "€1.000,12", Money.new(1000.12, :eur).format
end
test "can cast to string with basic formatting" do
assert_equal "$1,000.90", Money.new(1000.899).format
assert_equal "€1.000,12", Money.new(1000.12, :eur).format
end
test "can exchange to another currency" do
er = exchange_rates(:today_usd_to_eur)
assert_equal Money.new(1000).exchange_to(:eur), Money.new(1000 * er.rate, :eur)
end
test "converts currency when rate available" do
ExchangeRate.expects(:find_rate).returns(OpenStruct.new(rate: 1.2))
test "returns nil if exchange rate not available" do
assert_nil Money.new(1000).exchange_to(:jpy)
assert_equal Money.new(1000).exchange_to(:eur), Money.new(1000 * 1.2, :eur)
end
test "raises when no conversion rate available and no fallback rate provided" do
ExchangeRate.expects(:find_rate).returns(nil)
assert_raises Money::ConversionError do
Money.new(1000).exchange_to(:jpy)
end
end
test "converts currency with a fallback rate" do
ExchangeRate.expects(:find_rate).returns(nil).twice
assert_equal 0, Money.new(1000).exchange_to(:jpy, fallback_rate: 0)
assert_equal Money.new(1000, :jpy), Money.new(1000, :usd).exchange_to(:jpy, fallback_rate: 1)
end
end

View File

@@ -1,83 +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
# Foreign accounts will generate balances for all currencies
expected_usd_balances = get_expected_balances_for(:eur_checking_usd)
expected_eur_balances = get_expected_balances_for(:eur_checking_eur)
calculated_balances = calculated_balances_for(:eur_checking)
calculated_usd_balances = calculated_balances.select { |b| b[:currency] == "USD" }
calculated_eur_balances = calculated_balances.select { |b| b[:currency] == "EUR" }
assert_account_balances calculated_usd_balances, expected_usd_balances
assert_account_balances calculated_eur_balances, expected_eur_balances
end
test "syncs multi-currency checking account balances" do
expected_balances = get_expected_balances_for(:multi_currency)
assert_account_balances calculated_balances_for(:multi_currency), expected_balances
end
test "syncs savings accounts balances" do
expected_balances = get_expected_balances_for(:savings)
assert_account_balances calculated_balances_for(:savings), expected_balances
end
test "syncs investment account balances" do
expected_balances = get_expected_balances_for(:brokerage)
assert_account_balances calculated_balances_for(:brokerage), expected_balances
end
test "syncs loan account balances" do
expected_balances = get_expected_balances_for(:mortgage_loan)
assert_account_balances calculated_balances_for(:mortgage_loan), expected_balances
end
test "syncs property account balances" do
expected_balances = get_expected_balances_for(:house)
assert_account_balances calculated_balances_for(:house), expected_balances
end
test "syncs vehicle account balances" do
expected_balances = get_expected_balances_for(:car)
assert_account_balances calculated_balances_for(:car), expected_balances
end
private
def assert_account_balances(actual_balances, expected_balances)
assert_equal expected_balances.count, actual_balances.count
actual_balances.each do |ab|
expected_balance = expected_balances.find { |eb| eb[:date] == ab[:date] }
assert_in_delta expected_balance[:balance], ab[:balance], 0.01, "Balance incorrect on date: #{ab[:date]}"
end
end
def calculated_balances_for(account_key)
Account::Balance::Calculator.new(accounts(account_key)).daily_balances
end
end

View File

@@ -0,0 +1,138 @@
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, @account.balance
assert_equal [ 22000, 22000, 22000 ], @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 20000, @account.balance
assert_equal [ 19600, 19500, 19500, 20000, 20000, 20000 ], @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 25000, @account.balance
assert_equal [ 20000, 20000, 20500, 20400, 25000, 25000 ], @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 20000, @account.balance
assert_equal [ 21000, 20900, 20600, 20000, 20000 ], @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 20000, @account.balance
assert_equal [ 21000, 20000, 20000 ], eur_balances # native account balances
assert_equal [ 42000, 40000, 40000 ], usd_balances # converted balances at rate of 2:1
end
test "fails with error if exchange rate not available for any entry" do
create_transaction(account: @account, currency: "EUR")
syncer = Account::Balance::Syncer.new(@account)
assert_raises Money::ConversionError do
syncer.run
end
end
# Account is able to calculate balances in its own currency (i.e. can still show a historical graph), but
# doesn't have exchange rates available to convert those calculated balances to the family currency
test "completes with warning if exchange rates not available to convert to family currency" do
@account.update! currency: "EUR"
syncer = Account::Balance::Syncer.new(@account)
syncer.run
assert_equal 1, syncer.warnings.count
end
test "overwrites existing balances and purges stale balances" do
assert_equal 0, @account.balances.size
@account.balances.create! date: Date.current, currency: "USD", balance: 30000 # incorrect balance, will be updated
@account.balances.create! date: 10.years.ago.to_date, currency: "USD", balance: 35000 # Out of range balance, will be deleted
assert_equal 2, @account.balances.size
syncer = Account::Balance::Syncer.new(@account)
syncer.run
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
end
test "partial sync does not affect balances prior to sync start date" do
existing_balance = @account.balances.create! date: 2.days.ago.to_date, currency: "USD", balance: 30000
transaction = create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100, currency: "USD")
syncer = Account::Balance::Syncer.new(@account, start_date: 1.day.ago.to_date)
syncer.run
assert_equal [ existing_balance.balance, existing_balance.balance - transaction.amount, @account.balance ], @account.balances.chronological.map(&:balance)
end
private
def create_exchange_rate(date, from:, to:, rate:)
ExchangeRate.create! date: date, from_currency: from, to_currency: to, rate: rate
end
end

View File

@@ -1,26 +1,29 @@
require "test_helper"
class Account::EntryTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do
@entry = account_entries :checking_one
@family = families :dylan_family
@entry = account_entries :transaction
end
test "valuations cannot have more than one entry per day" do
new_entry = Account::Entry.new \
entryable: Account::Valuation.new,
date: @entry.date, # invalid
currency: @entry.currency,
amount: @entry.amount
existing_valuation = account_entries :valuation
assert new_entry.invalid?
new_valuation = Account::Entry.new \
entryable: Account::Valuation.new,
date: existing_valuation.date, # invalid
currency: existing_valuation.currency,
amount: existing_valuation.amount
assert new_valuation.invalid?
end
test "triggers sync with correct start date when transaction is set to prior date" do
prior_date = @entry.date - 1
@entry.update! date: prior_date
@entry.account.expects(:sync_later).with(prior_date)
@entry.account.expects(:sync_later).with(start_date: prior_date)
@entry.sync_account_later
end
@@ -28,48 +31,62 @@ class Account::EntryTest < ActiveSupport::TestCase
prior_date = @entry.date
@entry.update! date: @entry.date + 1
@entry.account.expects(:sync_later).with(prior_date)
@entry.account.expects(:sync_later).with(start_date: prior_date)
@entry.sync_account_later
end
test "triggers sync with correct start date when transaction deleted" do
prior_entry = account_entries(:checking_two) # 12 days ago
current_entry = account_entries(:checking_one) # 5 days ago
current_entry = create_transaction(date: 1.day.ago.to_date)
prior_entry = create_transaction(date: current_entry.date - 1.day)
current_entry.destroy!
current_entry.account.expects(:sync_later).with(prior_entry.date)
current_entry.account.expects(:sync_later).with(start_date: prior_entry.date)
current_entry.sync_account_later
end
test "can search entries" do
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new
category = family.categories.first
merchant = family.merchants.first
create_transaction(account: account, name: "a transaction")
create_transaction(account: account, name: "ignored")
create_transaction(account: account, name: "third transaction", category: category, merchant: merchant)
params = { search: "a" }
assert_equal 12, Account::Entry.search(params).size
assert_equal 2, family.entries.search(params).size
params = params.merge(categories: [ "Food & Drink" ]) # transaction specific search param
params = params.merge(categories: [ category.name ], merchants: [ merchant.name ]) # transaction specific search param
assert_equal 2, Account::Entry.search(params).size
assert_equal 1, family.entries.search(params).size
end
test "can calculate total spending for a group of transactions" do
assert_equal Money.new(2135), @family.entries.expense_total("USD")
assert_equal Money.new(1010.85, "EUR"), @family.entries.expense_total("EUR")
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new
create_transaction(account: account, amount: 100)
create_transaction(account: account, amount: 100)
create_transaction(account: account, amount: -500) # income, will be ignored
assert_equal Money.new(200), family.entries.expense_total("USD")
end
test "can calculate total income for a group of transactions" do
assert_equal -Money.new(2075), @family.entries.income_total("USD")
assert_equal -Money.new(250, "EUR"), @family.entries.income_total("EUR")
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new
create_transaction(account: account, amount: -100)
create_transaction(account: account, amount: -100)
create_transaction(account: account, amount: 500) # income, will be ignored
assert_equal Money.new(-200), family.entries.income_total("USD")
end
# See: https://github.com/maybe-finance/maybe/wiki/vision#signage-of-money
test "transactions with negative amounts are inflows, positive amounts are outflows to an account" do
inflow_transaction = account_entries(:checking_four)
outflow_transaction = account_entries(:checking_five)
assert inflow_transaction.amount < 0
assert inflow_transaction.inflow?
assert outflow_transaction.amount >= 0
assert outflow_transaction.outflow?
assert create_transaction(amount: -10).inflow?
assert create_transaction(amount: 10).outflow?
end
end

View File

@@ -0,0 +1,36 @@
require "test_helper"
class Account::SyncTest < ActiveSupport::TestCase
setup do
@account = accounts(:depository)
@sync = Account::Sync.for(@account)
@balance_syncer = mock("Account::Balance::Syncer")
Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once
end
test "runs sync" do
@balance_syncer.expects(:run).once
@balance_syncer.expects(:warnings).returns([ "test sync warning" ]).once
assert_equal "pending", @sync.status
assert_equal [], @sync.warnings
assert_nil @sync.last_ran_at
@sync.run
assert_equal "completed", @sync.status
assert_equal [ "test sync warning" ], @sync.warnings
assert @sync.last_ran_at
end
test "handles sync errors" do
@balance_syncer.expects(:run).raises(StandardError.new("test sync error"))
@sync.run
assert @sync.last_ran_at
assert_equal "failed", @sync.status
assert_equal "test sync error", @sync.error
end
end

View File

@@ -1,114 +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 foreign currency account" do
account = accounts(:eur_checking)
account.sync
assert_equal "ok", account.status
assert_equal 32, account.balances.where(currency: "USD").count
assert_equal 32, account.balances.where(currency: "EUR").count
end
test "syncs multi currency account" do
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
account = accounts(:eur_checking)
account.sync
assert_equal 64, account.balances.count
assert_equal 32, account.balances.where(currency: "EUR").count
assert_equal 32, account.balances.where(currency: "USD").count
end
test "stale balances are purged after syncing" do
account = accounts(:savings)
# Create old, stale balances that should be purged (since they are before account start date)
account.balances.create!(date: 1.year.ago, balance: 1000)
account.balances.create!(date: 2.years.ago, balance: 2000)
account.balances.create!(date: 3.years.ago, balance: 3000)
account.sync
assert_equal 32, account.balances.count
end
end

View File

@@ -2,22 +2,8 @@ require "test_helper"
class Account::TransferTest < ActiveSupport::TestCase
setup do
# Transfers can be posted on different dates
@outflow = accounts(:checking).entries.create! \
date: 1.day.ago.to_date,
name: "Transfer to Savings",
amount: 100,
currency: "USD",
marked_as_transfer: true,
entryable: Account::Transaction.new
@inflow = accounts(:savings).entries.create! \
date: Date.current,
name: "Transfer from Savings",
amount: -100,
currency: "USD",
marked_as_transfer: true,
entryable: Account::Transaction.new
@outflow = account_entries(:transfer_out)
@inflow = account_entries(:transfer_in)
end
test "transfer valid if it has inflow and outflow from different accounts for the same amount" do
@@ -28,14 +14,15 @@ class Account::TransferTest < ActiveSupport::TestCase
test "transfer must have 2 transactions" do
invalid_transfer_1 = Account::Transfer.new entries: [ @outflow ]
invalid_transfer_2 = Account::Transfer.new entries: [ @inflow, @outflow, account_entries(:savings_four) ]
invalid_transfer_2 = Account::Transfer.new entries: [ @inflow, @outflow, account_entries(:transaction) ]
assert invalid_transfer_1.invalid?
assert invalid_transfer_2.invalid?
end
test "transfer cannot have 2 transactions from the same account" do
account = accounts(:checking)
account = accounts(:depository)
inflow = account.entries.create! \
date: Date.current,
name: "Inflow",

View File

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

View File

@@ -20,7 +20,7 @@ class CategoryTest < ActiveSupport::TestCase
end
test "updating name should clear the internal_category field" do
category = Category.take
category = categories(:income)
assert_changes "category.reload.internal_category", to: nil do
category.update_attribute(:name, "new name")
end

View File

@@ -1,50 +1,95 @@
require "test_helper"
require "ostruct"
class ExchangeRateTest < ActiveSupport::TestCase
test "find rate in db" do
assert_equal exchange_rates(:day_29_ago_eur_to_usd),
ExchangeRate.find_rate_or_fetch(from: "EUR", to: "USD", date: 29.days.ago.to_date)
setup do
@provider = mock
ExchangeRate.stubs(:exchange_rates_provider).returns(@provider)
end
test "fetch rate from provider when it's not found in db" do
with_env_overrides SYNTH_API_KEY: "true" do
ExchangeRate
.expects(:fetch_rate_from_provider)
.returns(ExchangeRate.new(base_currency: "USD", converted_currency: "MXN", rate: 1.0, date: Date.current))
test "exchange rate provider nil if no api key configured" do
ExchangeRate.unstub(:exchange_rates_provider)
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
with_env_overrides SYNTH_API_KEY: nil do
assert_nil ExchangeRate.exchange_rates_provider
end
end
test "provided rates are saved to the db" do
with_env_overrides SYNTH_API_KEY: "true" do
VCR.use_cassette "synth_exchange_rate" do
assert_difference "ExchangeRate.count", 1 do
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
end
end
test "finds single rate in DB" do
@provider.expects(:fetch_exchange_rate).never
rate = exchange_rates(:one)
assert_equal exchange_rates(:one), ExchangeRate.find_rate(from: rate.from_currency, to: rate.to_currency, date: rate.date)
end
test "finds single rate from provider and caches to DB" do
expected_rate = 1.21
@provider.expects(:fetch_exchange_rate).once.returns(OpenStruct.new(success?: true, rate: expected_rate))
fetched_rate = ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current, cache: true)
refetched_rate = ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current, cache: true)
assert_equal expected_rate, fetched_rate.rate
assert_equal expected_rate, refetched_rate.rate
end
test "nil if rate is not found in DB and provider throws an error" do
@provider.expects(:fetch_exchange_rate).with(from: "USD", to: "EUR", date: Date.current).once.returns(OpenStruct.new(success?: false))
assert_nil ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current)
end
test "nil if rate is not found in DB and provider is disabled" do
ExchangeRate.unstub(:exchange_rates_provider)
with_env_overrides SYNTH_API_KEY: nil do
assert_nil ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current)
end
end
test "retrying, then raising on provider error" do
with_env_overrides SYNTH_API_KEY: "true" do
Faraday.expects(:get).returns(OpenStruct.new(success?: false)).times(3)
test "finds multiple rates in DB" do
@provider.expects(:fetch_exchange_rate).never
error = assert_raises Provider::Base::ProviderError do
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
end
rate1 = exchange_rates(:one) # EUR -> GBP, today
rate2 = exchange_rates(:two) # EUR -> GBP, yesterday
assert_match "Failed to fetch exchange rate from Provider::Synth", error.message
end
fetched_rates = ExchangeRate.find_rates(from: rate1.from_currency, to: rate1.to_currency, start_date: 1.day.ago.to_date).sort_by(&:date)
assert_equal rate1, fetched_rates[1]
assert_equal rate2, fetched_rates[0]
end
test "retrying, then raising on network error" do
with_env_overrides SYNTH_API_KEY: "true" do
Faraday.expects(:get).raises(Faraday::TimeoutError).times(3)
test "finds multiple rates from provider and caches to DB" do
@provider.expects(:fetch_exchange_rate).with(from: "EUR", to: "USD", date: 1.day.ago.to_date).returns(OpenStruct.new(success?: true, rate: 1.1)).once
@provider.expects(:fetch_exchange_rate).with(from: "EUR", to: "USD", date: Date.current).returns(OpenStruct.new(success?: true, rate: 1.2)).once
assert_raises Faraday::TimeoutError do
ExchangeRate.find_rate_or_fetch from: "USD", to: "MXN", date: Date.current
end
fetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date, cache: true)
refetched_rates = ExchangeRate.find_rates(from: "EUR", to: "USD", start_date: 1.day.ago.to_date)
assert_equal [ 1.1, 1.2 ], fetched_rates.sort_by(&:date).map(&:rate)
assert_equal [ 1.1, 1.2 ], refetched_rates.sort_by(&:date).map(&:rate)
end
test "finds missing db rates from provider and appends to results" do
@provider.expects(:fetch_exchange_rate).with(from: "EUR", to: "GBP", date: 2.days.ago.to_date).returns(OpenStruct.new(success?: true, rate: 1.1)).once
rate1 = exchange_rates(:one) # EUR -> GBP, today
rate2 = exchange_rates(:two) # EUR -> GBP, yesterday
fetched_rates = ExchangeRate.find_rates(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date, cache: true)
refetched_rates = ExchangeRate.find_rates(from: "EUR", to: "GBP", start_date: 2.days.ago.to_date)
assert_equal [ 1.1, rate2.rate, rate1.rate ], fetched_rates.sort_by(&:date).map(&:rate)
assert_equal [ 1.1, rate2.rate, rate1.rate ], refetched_rates.sort_by(&:date).map(&:rate)
end
test "returns empty array if no rates found in DB or provider" do
ExchangeRate.unstub(:exchange_rates_provider)
with_env_overrides SYNTH_API_KEY: nil do
assert_equal [], ExchangeRate.find_rates(from: "USD", to: "JPY", start_date: 10.days.ago.to_date)
end
end
end

View File

@@ -2,132 +2,148 @@ require "test_helper"
require "csv"
class FamilyTest < ActiveSupport::TestCase
include FamilySnapshotTestHelper
include Account::EntriesTestHelper
def setup
@family = families(:dylan_family)
@family.accounts.each do |account|
account.sync
end
@family = families :empty
end
test "should have many users" do
assert @family.users.size > 0
assert @family.users.include?(users(:family_admin))
test "calculates assets" do
assert_equal Money.new(0, @family.currency), @family.assets
@family.accounts.create!(balance: 1000, accountable: Depository.new)
@family.accounts.create!(balance: 5000, accountable: OtherAsset.new)
@family.accounts.create!(balance: 10000, accountable: CreditCard.new) # ignored
assert_equal Money.new(1000 + 5000, @family.currency), @family.assets
end
test "should have many accounts" do
assert @family.accounts.size > 0
test "calculates liabilities" do
assert_equal Money.new(0, @family.currency), @family.liabilities
@family.accounts.create!(balance: 1000, accountable: CreditCard.new)
@family.accounts.create!(balance: 5000, accountable: OtherLiability.new)
@family.accounts.create!(balance: 10000, accountable: Depository.new) # ignored
assert_equal Money.new(1000 + 5000, @family.currency), @family.liabilities
end
test "should destroy dependent users" do
assert_difference("User.count", -@family.users.count) do
@family.destroy
end
end
test "calculates net worth" do
assert_equal Money.new(0, @family.currency), @family.net_worth
test "should destroy dependent accounts" do
assert_difference("Account.count", -@family.accounts.count) do
@family.destroy
end
end
@family.accounts.create!(balance: 1000, accountable: CreditCard.new)
@family.accounts.create!(balance: 50000, accountable: Depository.new)
test "should destroy dependent transaction categories" do
assert_difference("Category.count", -@family.categories.count) do
@family.destroy
end
end
test "should destroy dependent merchants" do
assert_difference("Merchant.count", -@family.merchants.count) do
@family.destroy
end
end
test "should calculate total assets" do
expected = get_today_snapshot_value_for :assets
assert_in_delta expected, @family.assets.amount, 0.01
end
test "should calculate total liabilities" do
expected = get_today_snapshot_value_for :liabilities
assert_in_delta expected, @family.liabilities.amount, 0.01
end
test "should calculate net worth" do
expected = get_today_snapshot_value_for :net_worth
assert_in_delta expected, @family.net_worth.amount, 0.01
end
test "calculates asset time series" do
series = @family.snapshot[:asset_series]
expected_series = get_expected_balances_for :assets
assert_time_series_balances series, expected_series
end
test "calculates liability time series" do
series = @family.snapshot[:liability_series]
expected_series = get_expected_balances_for :liabilities
assert_time_series_balances series, expected_series
end
test "calculates net worth time series" do
series = @family.snapshot[:net_worth_series]
expected_series = get_expected_balances_for :net_worth
assert_time_series_balances series, expected_series
end
test "calculates rolling expenses" do
series = @family.snapshot_transactions[:spending_series]
expected_series = get_expected_balances_for :rolling_spend
assert_time_series_balances series, expected_series, ignore_count: true
end
test "calculates rolling income" do
series = @family.snapshot_transactions[:income_series]
expected_series = get_expected_balances_for :rolling_income
assert_time_series_balances series, expected_series, ignore_count: true
end
test "calculates savings rate series" do
series = @family.snapshot_transactions[:savings_rate_series]
expected_series = get_expected_balances_for :savings_rate
series.values.each do |tsb|
expected_balance = expected_series.find { |eb| eb[:date] == tsb.date }
assert_in_delta expected_balance[:balance], tsb.value, 0.0001, "Balance incorrect on date: #{tsb.date}"
end
assert_equal Money.new(50000 - 1000, @family.currency), @family.net_worth
end
test "should exclude disabled accounts from calculations" do
assets_before = @family.assets
liabilities_before = @family.liabilities
net_worth_before = @family.net_worth
cc = @family.accounts.create!(balance: 1000, accountable: CreditCard.new)
@family.accounts.create!(balance: 50000, accountable: Depository.new)
disabled_checking = accounts(:checking)
disabled_cc = accounts(:credit_card)
assert_equal Money.new(50000 - 1000, @family.currency), @family.net_worth
disabled_checking.update!(is_active: false)
disabled_cc.update!(is_active: false)
cc.update! is_active: false
assert_equal assets_before - disabled_checking.balance, @family.assets
assert_equal liabilities_before - disabled_cc.balance, @family.liabilities
assert_equal net_worth_before - disabled_checking.balance + disabled_cc.balance, @family.net_worth
assert_equal Money.new(50000, @family.currency), @family.net_worth
end
private
test "syncs active accounts" do
account = @family.accounts.create!(balance: 1000, accountable: CreditCard.new, is_active: false)
def assert_time_series_balances(time_series_balances, expected_balances, ignore_count: false)
assert_equal time_series_balances.values.count, expected_balances.count unless ignore_count
Account.any_instance.expects(:sync_later).never
time_series_balances.values.each do |tsb|
expected_balance = expected_balances.find { |eb| eb[:date] == tsb.date }
assert_in_delta expected_balance[:balance], tsb.value.amount, 0.01, "Balance incorrect on date: #{tsb.date}"
end
end
@family.sync
account.update! is_active: true
Account.any_instance.expects(:sync_later).with(start_date: nil).once
@family.sync
end
test "calculates snapshot" do
asset = @family.accounts.create!(balance: 500, accountable: Depository.new)
liability = @family.accounts.create!(balance: 100, accountable: CreditCard.new)
asset.balances.create! date: 1.day.ago.to_date, currency: "USD", balance: 450
asset.balances.create! date: Date.current, currency: "USD", balance: 500
liability.balances.create! date: 1.day.ago.to_date, currency: "USD", balance: 50
liability.balances.create! date: Date.current, currency: "USD", balance: 100
expected_asset_series = [
{ date: 1.day.ago.to_date, value: Money.new(450) },
{ date: Date.current, value: Money.new(500) }
]
expected_liability_series = [
{ date: 1.day.ago.to_date, value: Money.new(50) },
{ date: Date.current, value: Money.new(100) }
]
expected_net_worth_series = [
{ date: 1.day.ago.to_date, value: Money.new(450 - 50) },
{ date: Date.current, value: Money.new(500 - 100) }
]
assert_equal expected_asset_series, @family.snapshot[:asset_series].values.map { |v| { date: v.date, value: v.value } }
assert_equal expected_liability_series, @family.snapshot[:liability_series].values.map { |v| { date: v.date, value: v.value } }
assert_equal expected_net_worth_series, @family.snapshot[:net_worth_series].values.map { |v| { date: v.date, value: v.value } }
end
test "calculates top movers" do
checking_account = @family.accounts.create!(balance: 500, accountable: Depository.new)
savings_account = @family.accounts.create!(balance: 1000, accountable: Depository.new)
create_transaction(account: checking_account, date: 2.days.ago.to_date, amount: -1000)
create_transaction(account: checking_account, date: 1.day.ago.to_date, amount: 10)
create_transaction(account: savings_account, date: 2.days.ago.to_date, amount: -5000)
snapshot = @family.snapshot_account_transactions
top_spenders = snapshot[:top_spenders]
top_earners = snapshot[:top_earners]
top_savers = snapshot[:top_savers]
assert_equal 10, top_spenders.first.spending
assert_equal 5000, top_earners.first.income
assert_equal 1000, top_earners.second.income
assert_equal 1, top_savers.first.savings_rate
assert_equal ((1000 - 10).to_f / 1000), top_savers.second.savings_rate
end
test "calculates rolling transaction totals" do
account = @family.accounts.create!(balance: 1000, accountable: Depository.new)
create_transaction(account: account, date: 2.days.ago.to_date, amount: -500)
create_transaction(account: account, date: 1.day.ago.to_date, amount: 100)
create_transaction(account: account, date: Date.current, amount: 20)
snapshot = @family.snapshot_transactions
expected_income_series = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 500, 500, 500
]
assert_equal expected_income_series, snapshot[:income_series].values.map(&:value).map(&:amount)
expected_spending_series = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 100, 120
]
assert_equal expected_spending_series, snapshot[:spending_series].values.map(&:value).map(&:amount)
expected_savings_rate_series = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1, 0.8, 0.76
]
assert_equal expected_savings_rate_series, snapshot[:savings_rate_series].values.map(&:value).map { |v| v.round(2) }
end
end

View File

@@ -1,9 +1,26 @@
require "test_helper"
require "ostruct"
class Provider::SynthTest < ActiveSupport::TestCase
include ExchangeRateProviderInterfaceTest
setup do
@subject = Provider::Synth.new
@subject = @synth = Provider::Synth.new("fookey")
end
test "retries then provides failed response" do
Faraday.expects(:get).returns(OpenStruct.new(success?: false)).times(3)
response = @synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current
assert_match "Failed to fetch exchange rate from Provider::Synth", response.error.message
end
test "retrying, then raising on network error" do
Faraday.expects(:get).raises(Faraday::TimeoutError).times(3)
assert_raises Faraday::TimeoutError do
@synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current
end
end
end

View File

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

View File

@@ -2,10 +2,6 @@ require "test_helper"
require "ostruct"
class ValueGroupTest < ActiveSupport::TestCase
setup do
checking = accounts(:checking)
savings = accounts(:savings)
collectable = accounts(:collectable)
# Level 1
@assets = ValueGroup.new("Assets", :usd)

View File

@@ -0,0 +1,30 @@
module Account::EntriesTestHelper
def create_transaction(attributes = {})
entry_attributes = attributes.except(:category, :tags, :merchant)
transaction_attributes = attributes.slice(:category, :tags, :merchant)
entry_defaults = {
account: accounts(:depository),
name: "Transaction",
date: Date.current,
currency: "USD",
amount: 100,
entryable: Account::Transaction.new(transaction_attributes)
}
Account::Entry.create! entry_defaults.merge(entry_attributes)
end
def create_valuation(attributes = {})
entry_defaults = {
account: accounts(:depository),
name: "Valuation",
date: 1.day.ago.to_date,
currency: "USD",
amount: 5000,
entryable: Account::Valuation.new
}
Account::Entry.create! entry_defaults.merge(attributes)
end
end

View File

@@ -1,21 +0,0 @@
module FamilySnapshotTestHelper
# See: https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
def get_expected_balances_for(key)
expected_results_file.map do |row|
{
date: (Date.current - row["date_offset"].to_i.days).to_date,
balance: row[key.to_s].to_d
}
end
end
def get_today_snapshot_value_for(metric)
expected_results_file[-1][metric.to_s].to_d
end
private
def expected_results_file
CSV.read("test/fixtures/files/expected_family_snapshots.csv", headers: true)
end
end

View File

@@ -4,24 +4,28 @@ class TransactionsTest < ApplicationSystemTestCase
setup do
sign_in @user = users(:family_admin)
Account::Entry.delete_all # clean slate
create_transaction("one", 12.days.ago.to_date, 100)
create_transaction("two", 10.days.ago.to_date, 100)
create_transaction("three", 9.days.ago.to_date, 100)
create_transaction("four", 8.days.ago.to_date, 100)
create_transaction("five", 7.days.ago.to_date, 100)
create_transaction("six", 7.days.ago.to_date, 100)
create_transaction("seven", 4.days.ago.to_date, 100)
create_transaction("eight", 3.days.ago.to_date, 100)
create_transaction("nine", 1.days.ago.to_date, 100)
create_transaction("ten", 1.days.ago.to_date, 100)
create_transaction("eleven", Date.current, 100, category: categories(:food_and_drink), tags: [ tags(:one) ], merchant: merchants(:amazon))
@transactions = @user.family.entries
.account_transactions
.reverse_chronological
@transaction = @transactions.first
@page_size = 10
@latest_transactions = @user.family.entries
.account_transactions
.without_transfers
.reverse_chronological
.limit(20).to_a
@test_category = @user.family.categories.create! name: "System Test Category"
@test_merchant = @user.family.merchants.create! name: "System Test Merchant"
@target_txn = @user.family.accounts.first.entries.create! \
name: "Oldest transaction",
date: 10.years.ago.to_date,
currency: @user.family.currency,
amount: 100,
entryable: Account::Transaction.new(category: @test_category,
merchant: @test_merchant)
visit transactions_url(per_page: @page_size)
end
@@ -29,13 +33,13 @@ class TransactionsTest < ApplicationSystemTestCase
assert_selector "h1", text: "Transactions"
within "form#transactions-search" do
fill_in "Search transactions by name", with: @target_txn.name
fill_in "Search transactions by name", with: @transaction.name
end
assert_selector "#" + dom_id(@target_txn), count: 1
assert_selector "#" + dom_id(@transaction), count: 1
within "#transaction-search-filters" do
assert_text @target_txn.name
assert_text @transaction.name
end
end
@@ -43,30 +47,34 @@ class TransactionsTest < ApplicationSystemTestCase
find("#transaction-filters-button").click
within "#transaction-filters-menu" do
check(@target_txn.account.name)
check(@transaction.account.name)
click_button "Category"
check(@test_category.name)
check(@transaction.account_transaction.category.name)
click_button "Apply"
end
assert_selector "#" + dom_id(@target_txn), count: 1
assert_selector "#" + dom_id(@transaction), count: 1
within "#transaction-search-filters" do
assert_text @target_txn.account.name
assert_text @target_txn.account_transaction.category.name
assert_text @transaction.account.name
assert_text @transaction.account_transaction.category.name
end
end
test "all filters work and empty state shows if no match" do
find("#transaction-filters-button").click
account = @transaction.account
category = @transaction.account_transaction.category
merchant = @transaction.account_transaction.merchant
within "#transaction-filters-menu" do
click_button "Account"
check(@target_txn.account.name)
check(account.name)
click_button "Date"
fill_in "q_start_date", with: 10.days.ago.to_date
fill_in "q_end_date", with: Date.current
fill_in "q_end_date", with: 1.day.ago.to_date
click_button "Type"
assert_text "Filter by type coming soon..."
@@ -75,10 +83,10 @@ class TransactionsTest < ApplicationSystemTestCase
assert_text "Filter by amount coming soon..."
click_button "Category"
check(@test_category.name)
check(category.name)
click_button "Merchant"
check(@test_merchant.name)
check(merchant.name)
click_button "Apply"
end
@@ -91,14 +99,14 @@ class TransactionsTest < ApplicationSystemTestCase
assert_text "No entries found"
within "ul#transaction-search-filters" do
find("li", text: @target_txn.account.name).first("a").click
find("li", text: account.name).first("a").click
find("li", text: "on or after #{10.days.ago.to_date}").first("a").click
find("li", text: "on or before #{Date.current}").first("a").click
find("li", text: @target_txn.account_transaction.category.name).first("a").click
find("li", text: @target_txn.account_transaction.merchant.name).first("a").click
find("li", text: "on or before #{1.day.ago.to_date}").first("a").click
find("li", text: category.name).first("a").click
find("li", text: merchant.name).first("a").click
end
assert_selector "#" + dom_id(@user.family.entries.reverse_chronological.first), count: 1
assert_selector "#" + dom_id(@transaction), count: 1
end
test "can select and deselect entire page of transactions" do
@@ -109,36 +117,52 @@ class TransactionsTest < ApplicationSystemTestCase
end
test "can select and deselect groups of transactions" do
date_transactions_checkbox(12.days.ago.to_date).check
assert_selection_count(3)
date_transactions_checkbox(12.days.ago.to_date).uncheck
date_transactions_checkbox(1.day.ago.to_date).check
assert_selection_count(2)
date_transactions_checkbox(1.day.ago.to_date).uncheck
assert_selection_count(0)
end
test "can select and deselect individual transactions" do
transaction_checkbox(@latest_transactions.first).check
transaction_checkbox(@transactions.first).check
assert_selection_count(1)
transaction_checkbox(@latest_transactions.second).check
transaction_checkbox(@transactions.second).check
assert_selection_count(2)
transaction_checkbox(@latest_transactions.second).uncheck
transaction_checkbox(@transactions.second).uncheck
assert_selection_count(1)
end
test "outermost group always overrides inner selections" do
transaction_checkbox(@latest_transactions.first).check
transaction_checkbox(@transactions.first).check
assert_selection_count(1)
all_transactions_checkbox.check
assert_selection_count(number_of_transactions_on_page)
transaction_checkbox(@latest_transactions.first).uncheck
transaction_checkbox(@transactions.first).uncheck
assert_selection_count(number_of_transactions_on_page - 1)
date_transactions_checkbox(12.days.ago.to_date).uncheck
assert_selection_count(number_of_transactions_on_page - 4)
date_transactions_checkbox(1.day.ago.to_date).uncheck
assert_selection_count(number_of_transactions_on_page - 3)
all_transactions_checkbox.uncheck
assert_selection_count(0)
end
private
def create_transaction(name, date, amount, category: nil, merchant: nil, tags: [])
account = accounts(:depository)
account.entries.create! \
name: name,
date: date,
amount: amount,
currency: "USD",
entryable: Account::Transaction.new(category: category, merchant: merchant, tags: tags)
end
def number_of_transactions_on_page
[ @user.family.entries.without_transfers.count, @page_size ].min
end

View File

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