Compare commits
15 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744ffb68aa | ||
|
|
34e03c2d6a | ||
|
|
b002a41b35 | ||
|
|
c6bdf49f10 | ||
|
|
de5a2e55b3 | ||
|
|
538b00712c | ||
|
|
2e56f5726e | ||
|
|
3c9cdb16f9 | ||
|
|
6d4c871f85 | ||
|
|
dd915c42ed | ||
|
|
0447d47a53 | ||
|
|
42dec4014e | ||
|
|
6767aaed1d | ||
|
|
bef335c631 | ||
|
|
3ffb6cb62b |
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
1
Gemfile
1
Gemfile
@@ -59,6 +59,7 @@ group :development do
|
||||
gem "letter_opener"
|
||||
gem "ruby-lsp-rails"
|
||||
gem "web-console"
|
||||
gem "faker"
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
62
Gemfile.lock
62
Gemfile.lock
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
131
app/models/account/balance/syncer.rb
Normal file
131
app/models/account/balance/syncer.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
51
app/models/account/sync.rb
Normal file
51
app/models/account/sync.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
284
app/models/demo/generator.rb
Normal file
284
app/models/demo/generator.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:)
|
||||
|
||||
@@ -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") } %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ module Maybe
|
||||
|
||||
private
|
||||
def semver
|
||||
"0.1.0-alpha.9"
|
||||
"0.1.0-alpha.10"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
6
db/migrate/20240706151026_rename_rate_fields.rb
Normal file
6
db/migrate/20240706151026_rename_rate_fields.rb
Normal 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
|
||||
18
db/migrate/20240707130331_create_account_syncs.rb
Normal file
18
db/migrate/20240707130331_create_account_syncs.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
39
db/schema.rb
generated
@@ -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"
|
||||
|
||||
24
lib/money.rb
24
lib/money.rb
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
11
test/fixtures/account/balances.yml
vendored
Normal 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
|
||||
334
test/fixtures/account/entries.yml
vendored
334
test/fixtures/account/entries.yml
vendored
@@ -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
13
test/fixtures/account/syncs.yml
vendored
Normal 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
|
||||
60
test/fixtures/account/transactions.yml
vendored
60
test/fixtures/account/transactions.yml
vendored
@@ -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: { }
|
||||
3
test/fixtures/account/transfers.yml
vendored
3
test/fixtures/account/transfers.yml
vendored
@@ -1,2 +1 @@
|
||||
credit_card_payment: { }
|
||||
savings_transfer: { }
|
||||
one: { }
|
||||
|
||||
20
test/fixtures/account/valuations.yml
vendored
20
test/fixtures/account/valuations.yml
vendored
@@ -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: { }
|
||||
57
test/fixtures/accounts.yml
vendored
57
test/fixtures/accounts.yml
vendored
@@ -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
|
||||
|
||||
4
test/fixtures/categories.yml
vendored
4
test/fixtures/categories.yml
vendored
@@ -1,3 +1,7 @@
|
||||
one:
|
||||
name: Test
|
||||
family: empty
|
||||
|
||||
income:
|
||||
name: Income
|
||||
internal_category: income
|
||||
|
||||
2
test/fixtures/credit_cards.yml
vendored
2
test/fixtures/credit_cards.yml
vendored
@@ -1 +1 @@
|
||||
credit_one: { }
|
||||
one: { }
|
||||
5
test/fixtures/depositories.yml
vendored
5
test/fixtures/depositories.yml
vendored
@@ -1,4 +1 @@
|
||||
depository_checking: { }
|
||||
depository_savings: { }
|
||||
depository_eur_checking: { }
|
||||
depository_multi_currency: { }
|
||||
one: { }
|
||||
388
test/fixtures/exchange_rates.yml
vendored
388
test/fixtures/exchange_rates.yml
vendored
@@ -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 %>
|
||||
|
||||
4
test/fixtures/families.yml
vendored
4
test/fixtures/families.yml
vendored
@@ -1,2 +1,6 @@
|
||||
empty:
|
||||
name: Family
|
||||
|
||||
dylan_family:
|
||||
name: The Dylan Family
|
||||
|
||||
|
||||
@@ -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
|
||||
|
4
test/fixtures/imports.yml
vendored
4
test/fixtures/imports.yml
vendored
@@ -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
|
||||
|
||||
2
test/fixtures/investments.yml
vendored
2
test/fixtures/investments.yml
vendored
@@ -1 +1 @@
|
||||
investment_brokerage: { }
|
||||
one: { }
|
||||
2
test/fixtures/loans.yml
vendored
2
test/fixtures/loans.yml
vendored
@@ -1 +1 @@
|
||||
loan_mortgage: { }
|
||||
one: { }
|
||||
4
test/fixtures/merchants.yml
vendored
4
test/fixtures/merchants.yml
vendored
@@ -1,3 +1,7 @@
|
||||
one:
|
||||
name: Test
|
||||
family: empty
|
||||
|
||||
netflix:
|
||||
name: Netflix
|
||||
color: "#fd7f6f"
|
||||
|
||||
3
test/fixtures/other_assets.yml
vendored
3
test/fixtures/other_assets.yml
vendored
@@ -1,3 +1,2 @@
|
||||
other_asset_collectable: { }
|
||||
|
||||
one: { }
|
||||
|
||||
|
||||
2
test/fixtures/other_liabilities.yml
vendored
2
test/fixtures/other_liabilities.yml
vendored
@@ -1 +1 @@
|
||||
other_asset_iou: { }
|
||||
one: { }
|
||||
2
test/fixtures/properties.yml
vendored
2
test/fixtures/properties.yml
vendored
@@ -1 +1 @@
|
||||
property_house: { }
|
||||
one: { }
|
||||
8
test/fixtures/taggings.yml
vendored
8
test/fixtures/taggings.yml
vendored
@@ -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
|
||||
|
||||
|
||||
12
test/fixtures/tags.yml
vendored
12
test/fixtures/tags.yml
vendored
@@ -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
|
||||
7
test/fixtures/users.yml
vendored
7
test/fixtures/users.yml
vendored
@@ -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
|
||||
|
||||
2
test/fixtures/vehicles.yml
vendored
2
test/fixtures/vehicles.yml
vendored
@@ -1 +1 @@
|
||||
vehicle_honda_accord: { }
|
||||
one: { }
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
138
test/models/account/balance/syncer_test.rb
Normal file
138
test/models/account/balance/syncer_test.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
36
test/models/account/sync_test.rb
Normal file
36
test/models/account/sync_test.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
30
test/support/account/entries_test_helper.rb
Normal file
30
test/support/account/entries_test_helper.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user