mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Multi-Currency Part 2 (#543)
* Support all currencies, handle outside DB * Remove currencies from seed * Fix account balance namespace * Set default currency on authentication * Cache currency instances * Implement multi-currency syncs with tests * Series fallback, passing tests * Fix conflicts * Make value group concrete class that works with currency values * Fix migration conflict * Update tests to expect multi-currency results * Update account list to use group method * Namespace updates * Fetch unknown exchange rates from API * Fix date range bug * Ensure demo data works without external API * Enforce cascades only at DB level
This commit is contained in:
parent
de0cba9fed
commit
110855d077
55 changed files with 1226 additions and 714 deletions
|
@ -2,12 +2,6 @@
|
|||
# This is used to convert between different currencies in the app. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
|
||||
SYNTH_API_KEY=
|
||||
|
||||
# Currency Configuration
|
||||
# A list of currencies that you want to support. This is used to generate the list of currencies that users can select from when creating a new account.
|
||||
# A free Open Exchange Rates API key is required if you want to support multiple currencies.
|
||||
# Example: CURRENCIES=USD,EUR,GBP
|
||||
CURRENCIES=USD
|
||||
|
||||
# SMTP Configuration
|
||||
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
|
||||
# Resend.com is a good option that offers a free tier for sending emails.
|
||||
|
|
|
@ -47,11 +47,8 @@ For further instructions, see guides below.
|
|||
|
||||
If you'd like multi-currency support, there are a few extra steps to follow.
|
||||
|
||||
1. Sign up for an API key at [Synth](https://synthfinance.com). It's a Maybe product and the free plan is sufficient for multi-currency support.
|
||||
1. Sign up for an API key at [Synth](https://synthfinance.com). It's a Maybe product and the free plan is sufficient for basic multi-currency support.
|
||||
2. Add your API key to your `.env` file.
|
||||
3. Set the currencies you'd like to support in the `.env` file.
|
||||
4. Run `rake currencies:seed`
|
||||
5. Run `rake exchange_rates:sync`
|
||||
|
||||
### Setup Guides
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ class AccountsController < ApplicationController
|
|||
|
||||
def show
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
@balance_series = @account.series(@period)
|
||||
@balance_series = @account.series(period: @period)
|
||||
@valuation_series = @account.valuations.to_series
|
||||
end
|
||||
|
||||
|
@ -50,6 +50,21 @@ class AccountsController < ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def sync
|
||||
@account = Current.family.accounts.find(params[:id])
|
||||
@account.sync_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_to account_path(@account), notice: t(".success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.append("notification-tray", partial: "shared/notification", locals: { type: "success", content: t(".success") }),
|
||||
turbo_stream.replace("sync_message", partial: "accounts/sync_message", locals: { is_syncing: true })
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account_params
|
||||
|
|
|
@ -7,6 +7,6 @@ class PagesController < ApplicationController
|
|||
@net_worth_series = snapshot[:net_worth_series]
|
||||
@asset_series = snapshot[:asset_series]
|
||||
@liability_series = snapshot[:liability_series]
|
||||
@account_groups = Current.family.accounts.by_group(@period)
|
||||
@account_groups = Current.family.accounts.by_group(period: @period, currency: Current.family.currency)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -27,8 +27,10 @@ module ApplicationHelper
|
|||
render partial: "shared/modal", locals: { content: content }
|
||||
end
|
||||
|
||||
|
||||
|
||||
def account_groups
|
||||
assets, liabilities = Current.family.accounts.by_group(currency: Current.family.currency, period: Period.last_30_days).values_at(:assets, :liabilities)
|
||||
[ assets.children, liabilities.children ].flatten
|
||||
end
|
||||
|
||||
def sidebar_modal(&block)
|
||||
content = capture &block
|
||||
|
@ -74,7 +76,7 @@ module ApplicationHelper
|
|||
end
|
||||
|
||||
def period_label(period)
|
||||
return "since account creation" if period.date_range.nil?
|
||||
return "since account creation" if period.date_range.begin.nil?
|
||||
start_date, end_date = period.date_range.first, period.date_range.last
|
||||
|
||||
return "Starting from #{start_date.strftime('%b %d, %Y')}" if end_date.nil?
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
class ConvertCurrencyJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(family)
|
||||
family = Family.find(family.id)
|
||||
|
||||
# Convert all account balances to new currency
|
||||
family.accounts.each do |account|
|
||||
if account.currency == family.currency
|
||||
account.converted_balance = account.balance
|
||||
account.converted_currency = account.currency
|
||||
else
|
||||
account.converted_balance = ExchangeRate.convert(account.currency, family.currency, account.balance)
|
||||
account.converted_currency = family.currency
|
||||
end
|
||||
account.save!
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,34 +0,0 @@
|
|||
class DailyExchangeRateJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
# Get the last date for which exchange rates were fetched for each currency
|
||||
last_fetched_dates = ExchangeRate.group(:base_currency).maximum(:date)
|
||||
|
||||
# Loop through each currency and fetch exchange rates for each
|
||||
Currency.all.each do |currency|
|
||||
last_fetched_date = last_fetched_dates[currency.iso_code] || Date.yesterday
|
||||
next_day = last_fetched_date + 1.day
|
||||
|
||||
response = Faraday.get("https://api.synthfinance.com/rates/historical") do |req|
|
||||
req.headers["Authorization"] = "Bearer #{ENV["SYNTH_API_KEY"]}"
|
||||
req.params["date"] = next_day.to_s
|
||||
req.params["from"] = currency.iso_code
|
||||
req.params["to"] = Currency.where.not(iso_code: currency.iso_code).pluck(:iso_code).join(",")
|
||||
end
|
||||
|
||||
if response.success?
|
||||
rates = JSON.parse(response.body)["rates"]
|
||||
|
||||
rates.each do |currency_iso_code, value|
|
||||
ExchangeRate.find_or_create_by(date: Date.today, base_currency: currency.iso_code, converted_currency: currency_iso_code) do |exchange_rate|
|
||||
exchange_rate.rate = value
|
||||
end
|
||||
puts "#{currency.iso_code} to #{currency_iso_code} on #{Date.today}: #{value}"
|
||||
end
|
||||
else
|
||||
puts "Failed to fetch exchange rates for #{currency.iso_code} on #{Date.today}: #{response.status}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -6,7 +6,7 @@ class Account < ApplicationRecord
|
|||
|
||||
broadcasts_refreshes
|
||||
belongs_to :family
|
||||
has_many :balances, class_name: "AccountBalance"
|
||||
has_many :balances
|
||||
has_many :valuations
|
||||
has_many :transactions
|
||||
|
||||
|
@ -20,8 +20,6 @@ class Account < ApplicationRecord
|
|||
|
||||
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
|
||||
|
||||
before_create :check_currency
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
%w[name]
|
||||
end
|
||||
|
@ -30,6 +28,17 @@ class Account < ApplicationRecord
|
|||
balances.where("date <= ?", date).order(date: :desc).first&.balance
|
||||
end
|
||||
|
||||
# e.g. Wise, Revolut accounts that have transactions in multiple currencies
|
||||
def multi_currency?
|
||||
currencies = [ valuations.pluck(:currency), transactions.pluck(:currency) ].flatten.uniq
|
||||
currencies.count > 1
|
||||
end
|
||||
|
||||
# e.g. Accounts denominated in currency other than family currency
|
||||
def foreign_currency?
|
||||
currency != family.currency
|
||||
end
|
||||
|
||||
def self.by_provider
|
||||
# TODO: When 3rd party providers are supported, dynamically load all providers and their accounts
|
||||
[ { name: "Manual accounts", accounts: all.order(balance: :desc).group_by(&:accountable_type) } ]
|
||||
|
@ -39,35 +48,41 @@ class Account < ApplicationRecord
|
|||
exists?(status: "syncing")
|
||||
end
|
||||
|
||||
def series(period = Period.all)
|
||||
TimeSeries.from_collection(balances.in_period(period), :balance_money)
|
||||
|
||||
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
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money)
|
||||
end
|
||||
end
|
||||
|
||||
def self.by_group(period = Period.all)
|
||||
grouped_accounts = { assets: ValueGroup.new("Assets"), liabilities: ValueGroup.new("Liabilities") }
|
||||
def self.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_node(type)
|
||||
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
|
||||
Accountable.from_type(type).includes(:account).each do |accountable|
|
||||
account = accountable.account
|
||||
value_node = group.add_value_node(account)
|
||||
value_node.attach_series(account.series(period))
|
||||
next unless 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
|
||||
|
||||
private
|
||||
def check_currency
|
||||
if self.currency == self.family.currency
|
||||
self.converted_balance = self.balance
|
||||
self.converted_currency = self.currency
|
||||
else
|
||||
self.converted_balance = ExchangeRate.convert(self.currency, self.family.currency, self.balance)
|
||||
self.converted_currency = self.family.currency
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
8
app/models/account/balance.rb
Normal file
8
app/models/account/balance.rb
Normal file
|
@ -0,0 +1,8 @@
|
|||
class Account::Balance < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :account
|
||||
validates :account, :date, :balance, presence: true
|
||||
monetize :balance
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
end
|
108
app/models/account/balance/calculator.rb
Normal file
108
app/models/account/balance/calculator.rb
Normal file
|
@ -0,0 +1,108 @@
|
|||
class Account::Balance::Calculator
|
||||
attr_reader :daily_balances, :errors, :warnings
|
||||
|
||||
@daily_balances = []
|
||||
@errors = []
|
||||
@warnings = []
|
||||
|
||||
def initialize(account, options = {})
|
||||
@account = account
|
||||
@calc_start_date = [ options[:calc_start_date], @account.effective_start_date ].compact.max
|
||||
end
|
||||
|
||||
def calculate
|
||||
prior_balance = implied_start_balance
|
||||
|
||||
calculated_balances = ((@calc_start_date + 1.day)...Date.current).map do |date|
|
||||
valuation = normalized_valuations.find { |v| v["date"] == date }
|
||||
|
||||
if valuation
|
||||
current_balance = valuation["value"]
|
||||
else
|
||||
txn_flows = transaction_flows(date)
|
||||
current_balance = prior_balance - txn_flows
|
||||
end
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
{ date: date, balance: current_balance, currency: @account.currency, updated_at: Time.current }
|
||||
end
|
||||
|
||||
@daily_balances = [
|
||||
{ date: @calc_start_date, balance: implied_start_balance, currency: @account.currency, updated_at: Time.current },
|
||||
*calculated_balances,
|
||||
{ date: Date.current, balance: @account.balance, currency: @account.currency, updated_at: Time.current } # Last balance must always match "source of truth"
|
||||
]
|
||||
|
||||
if @account.foreign_currency?
|
||||
converted_balances = convert_balances_to_family_currency
|
||||
@daily_balances.concat(converted_balances)
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
private
|
||||
def convert_balances_to_family_currency
|
||||
rates = ExchangeRate.get_rate_series(
|
||||
@account.currency,
|
||||
@account.family.currency,
|
||||
@calc_start_date..Date.current
|
||||
).to_a
|
||||
|
||||
@daily_balances.map do |balance|
|
||||
rate = rates.find { |rate| rate.date == balance[:date] }
|
||||
raise "Rate for #{@account.currency} to #{@account.family.currency} on #{balance[:date]} not found" if rate.nil?
|
||||
converted_balance = balance[:balance] * rate.rate
|
||||
{ date: balance[:date], balance: converted_balance, currency: @account.family.currency, updated_at: Time.current }
|
||||
end
|
||||
end
|
||||
|
||||
# For calculation, all transactions and valuations need to be normalized to the same currency (the account's primary currency)
|
||||
def normalize_entries_to_account_currency(entries, value_key)
|
||||
entries.map do |entry|
|
||||
currency = entry.currency
|
||||
date = entry.date
|
||||
value = entry.send(value_key)
|
||||
|
||||
if currency != @account.currency
|
||||
rate = ExchangeRate.find_by(base_currency: currency, converted_currency: @account.currency, date: date)
|
||||
raise "Rate for #{currency} to #{@account.currency} not found" unless rate
|
||||
|
||||
value *= rate.rate
|
||||
currency = @account.currency
|
||||
end
|
||||
|
||||
entry.attributes.merge(value_key.to_s => value, "currency" => currency)
|
||||
end
|
||||
end
|
||||
|
||||
def normalized_valuations
|
||||
@normalized_valuations ||= normalize_entries_to_account_currency(@account.valuations.where("date >= ?", @calc_start_date).order(:date).select(:date, :value, :currency), :value)
|
||||
end
|
||||
|
||||
def normalized_transactions
|
||||
@normalized_transactions ||= normalize_entries_to_account_currency(@account.transactions.where("date >= ?", @calc_start_date).order(:date).select(:date, :amount, :currency), :amount)
|
||||
end
|
||||
|
||||
def transaction_flows(date)
|
||||
flows = normalized_transactions.select { |t| t["date"] == date }.sum { |t| t["amount"] }
|
||||
flows *= -1 if @account.classification == "liability"
|
||||
flows
|
||||
end
|
||||
|
||||
def implied_start_balance
|
||||
oldest_valuation_date = normalized_valuations.first&.dig("date")
|
||||
oldest_transaction_date = normalized_transactions.first&.dig("date")
|
||||
oldest_entry_date = [ oldest_valuation_date, oldest_transaction_date ].compact.min
|
||||
|
||||
if oldest_entry_date == oldest_valuation_date
|
||||
oldest_valuation = normalized_valuations.find { |v| v["date"] == oldest_valuation_date }
|
||||
oldest_valuation["value"].to_d
|
||||
else
|
||||
net_transaction_flows = normalized_transactions.sum { |t| t["amount"].to_d }
|
||||
net_transaction_flows *= -1 if @account.classification == "liability"
|
||||
@account.balance.to_d + net_transaction_flows
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,40 +0,0 @@
|
|||
class Account::BalanceCalculator
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def daily_balances(start_date = nil)
|
||||
calc_start_date = [ start_date, @account.effective_start_date ].compact.max
|
||||
|
||||
valuations = @account.valuations.where("date >= ?", calc_start_date).order(:date).select(:date, :value, :currency)
|
||||
transactions = @account.transactions.where("date > ?", calc_start_date).order(:date).select(:date, :amount, :currency)
|
||||
oldest_entry = [ valuations.first, transactions.first ].compact.min_by(&:date)
|
||||
|
||||
net_transaction_flows = transactions.sum(&:amount)
|
||||
net_transaction_flows *= -1 if @account.classification == "liability"
|
||||
implied_start_balance = oldest_entry.is_a?(Valuation) ? oldest_entry.value : @account.balance + net_transaction_flows
|
||||
|
||||
prior_balance = implied_start_balance
|
||||
calculated_balances = ((calc_start_date + 1.day)...Date.current).map do |date|
|
||||
valuation = valuations.find { |v| v.date == date }
|
||||
|
||||
if valuation
|
||||
current_balance = valuation.value
|
||||
else
|
||||
current_day_net_transaction_flows = transactions.select { |t| t.date == date }.sum(&:amount)
|
||||
current_day_net_transaction_flows *= -1 if @account.classification == "liability"
|
||||
current_balance = prior_balance - current_day_net_transaction_flows
|
||||
end
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
{ date: date, balance: current_balance, updated_at: Time.current }
|
||||
end
|
||||
|
||||
[
|
||||
{ date: calc_start_date, balance: implied_start_balance, updated_at: Time.current },
|
||||
*calculated_balances,
|
||||
{ date: Date.current, balance: @account.balance, updated_at: Time.current } # Last balance must always match "source of truth"
|
||||
]
|
||||
end
|
||||
end
|
|
@ -7,8 +7,12 @@ module Account::Syncable
|
|||
|
||||
def sync
|
||||
update!(status: "syncing")
|
||||
synced_daily_balances = Account::BalanceCalculator.new(self).daily_balances
|
||||
self.balances.upsert_all(synced_daily_balances, unique_by: :index_account_balances_on_account_id_and_date)
|
||||
|
||||
sync_exchange_rates
|
||||
|
||||
calculator = Account::Balance::Calculator.new(self)
|
||||
calculator.calculate
|
||||
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
|
||||
update!(status: "ok")
|
||||
rescue => e
|
||||
|
@ -23,4 +27,45 @@ module Account::Syncable
|
|||
|
||||
[ first_valuation_date, first_transaction_date&.prev_day ].compact.min || 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.transactions.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
|
||||
|
||||
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})"
|
||||
rate = ExchangeRate.fetch_rate_from_provider(rc_from, rc_to, rc_date)
|
||||
ExchangeRate.create! base_currency: rc_from, converted_currency: rc_to, date: rc_date, rate: rate if rate
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
class AccountBalance < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :account
|
||||
validates :account, :date, :balance, presence: true
|
||||
monetize :balance
|
||||
|
||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||
end
|
|
@ -1,2 +0,0 @@
|
|||
class Currency < ApplicationRecord
|
||||
end
|
|
@ -1,16 +1,42 @@
|
|||
class ExchangeRate < ApplicationRecord
|
||||
validates :base_currency, :converted_currency, presence: true
|
||||
|
||||
def self.convert(from, to, amount)
|
||||
return amount unless EXCHANGE_RATE_ENABLED
|
||||
|
||||
rate = ExchangeRate.find_by(base_currency: from, converted_currency: to)
|
||||
|
||||
# TODO: Handle the case where the rate is not found
|
||||
if rate.nil?
|
||||
amount # Silently handle the error by returning the original amount
|
||||
else
|
||||
class << self
|
||||
def convert(from, to, amount)
|
||||
rate = ExchangeRate.find_by(base_currency: from, converted_currency: to, date: Date.current)
|
||||
return nil if rate.nil?
|
||||
amount * rate.rate
|
||||
end
|
||||
|
||||
def get_rate(from, to, date)
|
||||
_from = Money::Currency.new(from)
|
||||
_to = Money::Currency.new(to)
|
||||
find_by! base_currency: _from.iso_code, converted_currency: _to.iso_code, date: date
|
||||
rescue
|
||||
logger.warn "Exchange rate not found for #{_from.iso_code} to #{_to.iso_code} on #{date}"
|
||||
nil
|
||||
end
|
||||
|
||||
def get_rate_series(from, to, date_range)
|
||||
where(base_currency: from, converted_currency: to, date: date_range).order(:date)
|
||||
end
|
||||
|
||||
# TODO: Replace with generic provider
|
||||
# See https://github.com/maybe-finance/maybe/pull/556
|
||||
def fetch_rate_from_provider(from, to, date)
|
||||
response = Faraday.get("https://api.synthfinance.com/rates/historical") do |req|
|
||||
req.headers["Authorization"] = "Bearer #{ENV["SYNTH_API_KEY"]}"
|
||||
req.params["date"] = date.to_s
|
||||
req.params["from"] = from
|
||||
req.params["to"] = to
|
||||
end
|
||||
|
||||
if response.success?
|
||||
rates = JSON.parse(response.body)
|
||||
rates.dig("data", "rates", to)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
class Family < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
has_many :users, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :transactions, through: :accounts
|
||||
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
|
||||
|
||||
monetize :net_worth, :assets, :liabilities
|
||||
|
||||
def snapshot(period = Period.all)
|
||||
query = accounts.active.joins(:balances)
|
||||
.where("account_balances.currency = ?", self.currency)
|
||||
|
@ -21,8 +17,8 @@ class Family < ApplicationRecord
|
|||
.group("account_balances.date, account_balances.currency")
|
||||
.order("account_balances.date")
|
||||
|
||||
query = query.where("account_balances.date BETWEEN ? AND ?", period.date_range.begin, period.date_range.end) if period.date_range
|
||||
|
||||
query = query.where("account_balances.date >= ?", period.date_range.begin) if period.date_range.begin
|
||||
query = query.where("account_balances.date <= ?", period.date_range.end) if period.date_range.end
|
||||
result = query.to_a
|
||||
|
||||
{
|
||||
|
@ -37,14 +33,14 @@ class Family < ApplicationRecord
|
|||
end
|
||||
|
||||
def net_worth
|
||||
Money.new(accounts.active.sum("CASE WHEN classification = 'asset' THEN balance ELSE -balance END"), currency)
|
||||
assets - liabilities
|
||||
end
|
||||
|
||||
def assets
|
||||
accounts.active.assets.sum(:balance)
|
||||
Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency) || 0 }.sum, currency)
|
||||
end
|
||||
|
||||
def liabilities
|
||||
Money.new(accounts.active.liabilities.sum(:balance), currency)
|
||||
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency) || 0 }.sum, currency)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -9,13 +9,13 @@ class Period
|
|||
INDEX.keys.sort
|
||||
end
|
||||
|
||||
def initialize(name:, date_range:)
|
||||
def initialize(name: "custom", date_range:)
|
||||
@name = name
|
||||
@date_range = date_range
|
||||
end
|
||||
|
||||
BUILTIN = [
|
||||
new(name: "all", date_range: nil),
|
||||
new(name: "all", date_range: nil..Date.current),
|
||||
new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current),
|
||||
new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current),
|
||||
new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current)
|
||||
|
|
|
@ -1,29 +1,29 @@
|
|||
class ValueGroup
|
||||
attr_accessor :parent
|
||||
attr_reader :name, :children, :value, :original
|
||||
attr_accessor :parent, :original
|
||||
attr_reader :name, :children, :value, :currency
|
||||
|
||||
def initialize(name = "Root", value: nil, original: nil)
|
||||
def initialize(name, currency = Money.default_currency)
|
||||
@name = name
|
||||
@value = value
|
||||
@currency = Money::Currency.new(currency)
|
||||
@children = []
|
||||
@original = original
|
||||
end
|
||||
|
||||
def sum
|
||||
return value if is_value_node?
|
||||
return 0 if children.empty? && value.nil?
|
||||
return Money.new(0, currency) if children.empty? && value.nil?
|
||||
children.sum(&:sum)
|
||||
end
|
||||
|
||||
def avg
|
||||
return value if is_value_node?
|
||||
return 0 if children.empty? && value.nil?
|
||||
return Money.new(0, currency) if children.empty? && value.nil?
|
||||
leaf_values = value_nodes.map(&:value)
|
||||
leaf_values.compact.sum.to_f / leaf_values.compact.size
|
||||
leaf_values.compact.sum / leaf_values.compact.size
|
||||
end
|
||||
|
||||
def series
|
||||
return @raw_series || TimeSeries.new([]) if is_value_node?
|
||||
return @series if is_value_node?
|
||||
|
||||
summed_by_date = children.each_with_object(Hash.new(0)) do |child, acc|
|
||||
child.series.values.each do |series_value|
|
||||
acc[series_value.date] += series_value.value
|
||||
|
@ -31,43 +31,63 @@
|
|||
end
|
||||
|
||||
summed_series = summed_by_date.map { |date, value| { date: date, value: value } }
|
||||
|
||||
TimeSeries.new(summed_series)
|
||||
end
|
||||
|
||||
def series=(series)
|
||||
raise "Cannot set series on a non-leaf node" unless is_value_node?
|
||||
|
||||
_series = series || TimeSeries.new([])
|
||||
|
||||
raise "Series must be an instance of TimeSeries" unless _series.is_a?(TimeSeries)
|
||||
raise "Series must contain money values in the node's currency" unless _series.values.all? { |v| v.value.currency == currency }
|
||||
@series = _series
|
||||
end
|
||||
|
||||
def value_nodes
|
||||
return [ self ] unless value.nil?
|
||||
children.flat_map { |child| child.value_nodes }
|
||||
end
|
||||
|
||||
def empty?
|
||||
value_nodes.empty?
|
||||
end
|
||||
|
||||
def percent_of_total
|
||||
return 100 if parent.nil? || parent.sum.zero?
|
||||
|
||||
((sum / parent.sum) * 100).round(1)
|
||||
end
|
||||
|
||||
def leaf?
|
||||
children.empty?
|
||||
end
|
||||
|
||||
def add_child_node(name)
|
||||
def add_child_group(name, currency = Money.default_currency)
|
||||
raise "Cannot add subgroup to node with a value" if is_value_node?
|
||||
child = self.class.new(name)
|
||||
child = self.class.new(name, currency)
|
||||
child.parent = self
|
||||
@children << child
|
||||
child
|
||||
end
|
||||
|
||||
def add_value_node(obj)
|
||||
def add_value_node(original, value, series = nil)
|
||||
raise "Cannot add value node to a non-leaf node" unless can_add_value_node?
|
||||
child = create_value_node(obj)
|
||||
child = self.class.new(original.name)
|
||||
child.original = original
|
||||
child.value = value
|
||||
child.series = series
|
||||
child.parent = self
|
||||
@children << child
|
||||
child
|
||||
end
|
||||
|
||||
def attach_series(raw_series)
|
||||
validate_attached_series(raw_series)
|
||||
@raw_series = raw_series
|
||||
def value=(value)
|
||||
raise "Cannot set value on a non-leaf node" unless is_leaf_node?
|
||||
raise "Value must be an instance of Money" unless value.is_a?(Money)
|
||||
@value = value
|
||||
@currency = value.currency
|
||||
end
|
||||
|
||||
def is_leaf_node?
|
||||
children.empty?
|
||||
end
|
||||
|
||||
def is_value_node?
|
||||
|
@ -79,23 +99,4 @@
|
|||
return false if is_value_node?
|
||||
children.empty? || children.all?(&:is_value_node?)
|
||||
end
|
||||
|
||||
def create_value_node(obj)
|
||||
value = if obj.respond_to?(:value)
|
||||
obj.value
|
||||
elsif obj.respond_to?(:balance)
|
||||
obj.balance
|
||||
elsif obj.respond_to?(:amount)
|
||||
obj.amount
|
||||
else
|
||||
raise ArgumentError, "Object must have a value, balance, or amount"
|
||||
end
|
||||
|
||||
self.class.new(obj.name, value: value, original: obj)
|
||||
end
|
||||
|
||||
def validate_attached_series(series)
|
||||
raise "Cannot add series to a node without a value" unless is_value_node?
|
||||
raise "Attached series must be a TimeSeries" unless series.is_a?(TimeSeries)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,22 +1,24 @@
|
|||
<%# locals: (type:) -%>
|
||||
<% accounts = Current.family.accounts.where(accountable_type: type.name) %>
|
||||
<% if accounts.sum(&:converted_balance) > 0 %>
|
||||
<%# locals: (group:) -%>
|
||||
<% type = Accountable.from_type(group.name) %>
|
||||
<% if group %>
|
||||
<details class="mb-1 text-sm group" data-controller="account-collapse" data-account-collapse-type-value="<%= type %>">
|
||||
<summary class="flex gap-4 px-2 py-3 items-center w-full rounded-[10px] font-medium hover:bg-gray-100">
|
||||
<summary class="flex gap-4 px-3 py-2 items-center w-full rounded-[10px] font-medium hover:bg-gray-100">
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||
<div class="text-left"><%= type.model_name.human %></div>
|
||||
<div class="ml-auto"><%= format_money accounts.sum(&:converted_balance) %></div>
|
||||
<div class="ml-auto flex flex-col items-end">
|
||||
<p class="text-right"><%= format_money group.sum %></p>
|
||||
</div>
|
||||
</summary>
|
||||
<% accounts.each do |account| %>
|
||||
<%= link_to account_path(account), class: "flex items-center w-full gap-3 px-2 py-3 mb-1 hover:bg-gray-100 rounded-[10px]" do %>
|
||||
<% group.children.each do |account_value_node| %>
|
||||
<%= link_to account_path(account_value_node.original), class: "flex items-center w-full gap-3 px-2 py-3 mb-1 hover:bg-gray-100 rounded-[10px]" do %>
|
||||
<div>
|
||||
<p class="font-medium"><%= account.name %></p>
|
||||
<% if account.subtype %>
|
||||
<p class="text-xs text-gray-500"><%= account.subtype&.humanize %></p>
|
||||
<p class="font-medium"><%= account_value_node.name %></p>
|
||||
<% if account_value_node.original.subtype %>
|
||||
<p class="text-xs text-gray-500"><%= account_value_node.original.subtype&.humanize %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="ml-auto font-medium"><%= format_money account.converted_balance %></p>
|
||||
<p class="ml-auto font-medium"><%= format_money account_value_node.original.balance_money %></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= link_to new_account_path(step: 'method', type: type.name.demodulize), class: "flex items-center gap-4 px-2 py-3 mb-1 text-gray-500 text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %>
|
||||
|
|
|
@ -8,6 +8,9 @@
|
|||
<h2 class="font-medium text-xl"><%= @account.name %></h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= button_to sync_account_path(@account), method: :post, class: "flex items-center gap-2", title: "Sync Account" do %>
|
||||
<%= lucide_icon "refresh-cw", class: "w-4 h-4 text-gray-900 hover:text-gray-500" %>
|
||||
<% end %>
|
||||
<div class="relative cursor-not-allowed">
|
||||
<div class="flex items-center gap-2 px-3 py-2">
|
||||
<span class="text-gray-900"><%= @account.balance_money.currency.iso_code %> <%= @account.balance_money.currency.symbol %></span>
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
<meta name="apple-mobile-web-app-title" content="Maybe">
|
||||
<%= csrf_meta_tags %>
|
||||
<%= csp_meta_tag %>
|
||||
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
|
@ -16,7 +15,6 @@
|
|||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#ffffff">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
|
||||
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
<%= javascript_importmap_tags %>
|
||||
|
@ -71,8 +69,8 @@
|
|||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<p><%= t('.new_account') %></p>
|
||||
<% end %>
|
||||
<% Accountable.types.each do |type| %>
|
||||
<%= render 'accounts/account_list', type: Accountable.from_type(type) %>
|
||||
<% account_groups.each do |group| %>
|
||||
<%= render 'accounts/account_list', group: group %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,47 +1,47 @@
|
|||
<%# locals: (account_group:) %>
|
||||
<% text_class = accountable_text_class(account_group.name) %>
|
||||
<%# locals: (accountable_group:) %>
|
||||
<% text_class = accountable_text_class(accountable_group.name) %>
|
||||
<details class="open:bg-gray-25 group">
|
||||
<summary class="flex p-4 items-center w-full rounded-lg font-medium hover:bg-gray-50 text-gray-500 text-sm font-medium cursor-pointer">
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden w-5 h-5") %>
|
||||
<div class="ml-4 h-2.5 w-2.5 rounded-full <%= accountable_bg_class(account_group.name) %>"></div>
|
||||
<p class="text-gray-900 ml-2"><%= to_accountable_title(Accountable.from_type(account_group.name)) %></p>
|
||||
<div class="ml-4 h-2.5 w-2.5 rounded-full <%= accountable_bg_class(accountable_group.name) %>"></div>
|
||||
<p class="text-gray-900 ml-2"><%= to_accountable_title(Accountable.from_type(accountable_group.name)) %></p>
|
||||
<span class="mx-1">·</span>
|
||||
<div ><%= account_group.children.count %></div>
|
||||
<div ><%= accountable_group.children.count %></div>
|
||||
<div class="ml-auto text-right flex items-center gap-10 text-sm font-medium text-gray-900">
|
||||
<div class="flex items-center justify-end gap-2 w-24">
|
||||
<%= render partial: "shared/progress_circle", locals: { progress: account_group.percent_of_total, text_class: text_class } %>
|
||||
<p><%= account_group.percent_of_total.round(1) %>%</p>
|
||||
<%= render partial: "shared/progress_circle", locals: { progress: accountable_group.percent_of_total, text_class: text_class } %>
|
||||
<p><%= accountable_group.percent_of_total.round(1) %>%</p>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<p><%= format_money account_group.sum %></p>
|
||||
<p><%= format_money accountable_group.sum %></p>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<%= render partial: "shared/trend_change", locals: { trend: account_group.series.trend } %>
|
||||
<%= render partial: "shared/trend_change", locals: { trend: accountable_group.series.trend } %>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="px-4 py-3 space-y-4">
|
||||
<% account_group.children.map do |account| %>
|
||||
<% accountable_group.children.map do |account_value_node| %>
|
||||
<div class="flex items-center justify-between text-sm font-medium text-gray-900">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full <%= text_class %> <%= accountable_bg_transparent_class(account_group.name) %>">
|
||||
<%= account.name[0].upcase %>
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full <%= text_class %> <%= accountable_bg_transparent_class(account_value_node.name) %>">
|
||||
<%= account_value_node.name[0].upcase %>
|
||||
</div>
|
||||
<div>
|
||||
<p><%= account.name %></p>
|
||||
<p><%= account_value_node.name %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-10 items-center text-right">
|
||||
<div class="flex items-center justify-end gap-2 w-24">
|
||||
<%= render partial: "shared/progress_circle", locals: { progress: account.percent_of_total, text_class: text_class } %>
|
||||
<p><%= account.percent_of_total %>%</p>
|
||||
<%= render partial: "shared/progress_circle", locals: { progress: account_value_node.percent_of_total, text_class: text_class } %>
|
||||
<p><%= account_value_node.percent_of_total %>%</p>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<p><%= format_money account.sum %></p>
|
||||
<p><%= format_money account_value_node.original.balance_money %></p>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<%= render partial: "shared/trend_change", locals: { trend: account.series.trend } %>
|
||||
<%= render partial: "shared/trend_change", locals: { trend: account_value_node.original.series.trend } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -15,6 +15,6 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="bg-white border border-alpha-black-25 shadow-xs rounded-lg divide-y divide-alpha-black-50">
|
||||
<%= render partial: "account_group_disclosure", collection: account_groups, as: :account_group %>
|
||||
<%= render partial: "account_group_disclosure", collection: account_groups, as: :accountable_group %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
<%= render partial: "shared/balance_heading", locals: {
|
||||
label: "Net Worth",
|
||||
period: @period,
|
||||
balance: Current.family.net_worth_money,
|
||||
balance: Current.family.net_worth,
|
||||
trend: @net_worth_series.trend
|
||||
}
|
||||
%>
|
||||
|
@ -26,7 +26,7 @@
|
|||
<%= render partial: "shared/balance_heading", locals: {
|
||||
label: "Assets",
|
||||
period: @period,
|
||||
balance: Current.family.assets_money,
|
||||
balance: Current.family.assets,
|
||||
trend: @asset_series.trend
|
||||
} %>
|
||||
</div>
|
||||
|
@ -44,7 +44,7 @@
|
|||
label: "Liabilities",
|
||||
period: @period,
|
||||
size: "md",
|
||||
balance: Current.family.liabilities_money,
|
||||
balance: Current.family.liabilities,
|
||||
trend: @liability_series.trend
|
||||
} %>
|
||||
</div>
|
||||
|
|
|
@ -1,22 +1,14 @@
|
|||
<h1 class="text-3xl font-semibold font-display">Update settings</h1>
|
||||
|
||||
<%= form_with model: Current.user, url: settings_path, html: { class: "space-y-4" } do |form| %>
|
||||
<%= form.fields_for :family_attributes do |family_fields| %>
|
||||
<%= family_fields.text_field :name, placeholder: "Family name", value: Current.family.name, label: "Family name" %>
|
||||
|
||||
<%= family_fields.select :currency, options_for_select(Currency.all.order(iso_code: :asc).map { |currency| ["#{currency.iso_code} (#{currency.name})", currency.iso_code] }, selected: Current.family.currency), { label: "Currency" } %>
|
||||
<%= family_fields.select :currency, options_for_select(Money::Currency.popular.map { |currency| ["#{currency.iso_code} (#{currency.name})", currency.iso_code] }, selected: Current.family.currency), { label: "Currency" } %>
|
||||
<% end %>
|
||||
|
||||
<%= form.text_field :first_name, placeholder: "First name", value: Current.user.first_name, label: true %>
|
||||
|
||||
<%= form.text_field :last_name, placeholder: "Last name", value: Current.user.last_name, label: true %>
|
||||
|
||||
<%= form.email_field :email, placeholder: "Email", value: Current.user.email, label: true %>
|
||||
|
||||
<%= form.password_field :password, label: true %>
|
||||
|
||||
<%= form.password_field :password_confirmation, label: true %>
|
||||
|
||||
<div class="fixed right-5 bottom-5">
|
||||
<button type="submit" class="flex items-center justify-center w-12 h-12 mb-2 bg-black rounded-full shrink-0 grow-0 hover:bg-gray-600">
|
||||
<%= inline_svg_tag('icn-check.svg', class: 'text-white fill-current') %>
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
EXCHANGE_RATE_ENABLED = ENV["OPEN_EXCHANGE_APP_ID"].present?
|
||||
|
||||
BALANCE_SHEET_CLASSIFICATIONS = {
|
||||
asset: "asset",
|
||||
liability: "liability",
|
||||
equity: "equity"
|
||||
}.freeze
|
|
@ -11,5 +11,7 @@ en:
|
|||
placeholder: Example account name
|
||||
select_accountable_type: What would you like to add?
|
||||
title: Add an account
|
||||
sync:
|
||||
success: Account sync started
|
||||
update:
|
||||
success: Account updated successfully
|
||||
|
|
|
@ -9,6 +9,7 @@ Rails.application.routes.draw do
|
|||
|
||||
resources :transactions
|
||||
resources :accounts, shallow: true do
|
||||
post :sync, on: :member
|
||||
resources :valuations
|
||||
end
|
||||
|
||||
|
|
5
db/migrate/20240308121431_remove_currency_table.rb
Normal file
5
db/migrate/20240308121431_remove_currency_table.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class RemoveCurrencyTable < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
drop_table :currencies
|
||||
end
|
||||
end
|
|
@ -0,0 +1,7 @@
|
|||
class UpdateUniqueIndexesForAccountBalanceAndExchangeRate < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
rename_index :exchange_rates, 'idx_on_base_currency_converted_currency_date_255be792be', 'index_exchange_rates_on_base_converted_date_unique'
|
||||
remove_index :account_balances, name: "index_account_balances_on_account_id_and_date"
|
||||
add_index :account_balances, [ :account_id, :date, :currency ], unique: true, name: "index_account_balances_on_account_id_date_currency_unique"
|
||||
end
|
||||
end
|
|
@ -0,0 +1,6 @@
|
|||
class RemoveConvertedBalanceFromAccount < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
remove_column :accounts, :converted_balance, :decimal
|
||||
remove_column :accounts, :converted_currency, :string
|
||||
end
|
||||
end
|
14
db/schema.rb
generated
14
db/schema.rb
generated
|
@ -26,7 +26,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_03_19_154732) do
|
|||
t.string "currency", default: "USD", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "date"], name: "index_account_balances_on_account_id_and_date", unique: true
|
||||
t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true
|
||||
t.index ["account_id"], name: "index_account_balances_on_account_id"
|
||||
end
|
||||
|
||||
|
@ -85,8 +85,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_03_19_154732) do
|
|||
t.uuid "accountable_id"
|
||||
t.decimal "balance", precision: 19, scale: 4, default: "0.0"
|
||||
t.string "currency", default: "USD"
|
||||
t.decimal "converted_balance", precision: 19, scale: 4, default: "0.0"
|
||||
t.string "converted_currency", default: "USD"
|
||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Account::Loan'::character varying, 'Account::Credit'::character varying, 'Account::OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.boolean "is_active", default: true, null: false
|
||||
t.enum "status", default: "ok", null: false, enum_type: "account_status"
|
||||
|
@ -96,14 +94,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_03_19_154732) do
|
|||
t.index ["family_id"], name: "index_accounts_on_family_id"
|
||||
end
|
||||
|
||||
create_table "currencies", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.string "iso_code"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["iso_code"], name: "index_currencies_on_iso_code", unique: true
|
||||
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
|
||||
|
@ -111,7 +101,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_03_19_154732) do
|
|||
t.date "date"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["base_currency", "converted_currency", "date"], name: "idx_on_base_currency_converted_currency_date_255be792be", unique: true
|
||||
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"
|
||||
end
|
||||
|
|
|
@ -2,7 +2,4 @@
|
|||
# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
|
||||
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
|
||||
|
||||
# Create default currency
|
||||
Currency.find_or_create_by(iso_code: "USD", name: "United States Dollar")
|
||||
|
||||
puts 'Run the following command to create demo data: `rake demo_data:reset`' if Rails.env.development?
|
||||
|
|
|
@ -23,6 +23,14 @@ class Money
|
|||
@currency = obj.is_a?(Money) ? obj.currency : Money::Currency.new(currency)
|
||||
end
|
||||
|
||||
# TODO: Replace with injected rate store
|
||||
def exchange_to(other_currency, date = Date.current)
|
||||
return self if @currency == Money::Currency.new(other_currency)
|
||||
rate = ExchangeRate.get_rate(@currency, other_currency, date)
|
||||
return nil if rate.nil?
|
||||
Money.new(@amount * rate.rate, other_currency)
|
||||
end
|
||||
|
||||
def cents_str(precision = @currency.default_precision)
|
||||
format_str = "%.#{precision}f"
|
||||
amount_str = format_str % @amount
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
namespace :currencies do
|
||||
desc "Seed Currencies"
|
||||
task seed: :environment do
|
||||
currencies = ENV["CURRENCIES"].split(",")
|
||||
|
||||
if currencies.count > 1 && ENV["SYNTH_API_KEY"].present?
|
||||
url = "https://api.synthfinance.com/currencies"
|
||||
|
||||
response = Faraday.get(url) do |req|
|
||||
req.headers["Authorization"] = "Bearer #{ENV["SYNTH_API_KEY"]}"
|
||||
end
|
||||
|
||||
synth_currencies = JSON.parse(response.body)
|
||||
|
||||
currencies.each do |iso_code|
|
||||
Currency.find_or_create_by(iso_code: iso_code) do |c|
|
||||
c.name = synth_currencies["data"].find { |currency| currency["iso_code"] == iso_code.downcase }["name"]
|
||||
end
|
||||
end
|
||||
|
||||
puts "Currencies created: #{Currency.count}"
|
||||
elsif currencies.count == 1
|
||||
Currency.find_or_create_by(iso_code: currencies.first)
|
||||
else
|
||||
puts "No currencies found in ENV['CURRENCIES']"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -3,7 +3,8 @@ namespace :demo_data do
|
|||
task reset: :environment do
|
||||
family = Family.find_or_create_by(name: "Demo Family")
|
||||
|
||||
family.accounts.destroy_all
|
||||
family.accounts.delete_all
|
||||
ExchangeRate.delete_all
|
||||
|
||||
user = User.find_or_create_by(email: "user@maybe.local") do |u|
|
||||
u.password = "password"
|
||||
|
@ -14,40 +15,72 @@ namespace :demo_data do
|
|||
|
||||
puts "Reset user: #{user.email} with family: #{family.name}"
|
||||
|
||||
Transaction::Category.create_default_categories(family) if family.transaction_categories.empty?
|
||||
|
||||
multi_currency_checking = Account.find_or_create_by(name: "Demo Multi-Currency Checking") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Depository.new
|
||||
a.balance = 4000
|
||||
a.currency = "EUR"
|
||||
# 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"
|
||||
|
||||
Transaction::Category.create_default_categories(family) if family.transaction_categories.empty?
|
||||
|
||||
# ========== Accounts ================
|
||||
empty_account = Account.create(name: "Demo Empty Account", family: family, accountable: Account::Depository.new, balance: 500, currency: "USD")
|
||||
multi_currency_checking = Account.create(name: "Demo Multi-Currency Checking", family: family, accountable: Account::Depository.new, balance: 4000, currency: "EUR")
|
||||
checking = Account.create(name: "Demo Checking", family: family, accountable: Account::Depository.new, balance: 5000, currency: "USD")
|
||||
savings = Account.create(name: "Demo Savings", family: family, accountable: Account::Depository.new, balance: 20000, currency: "USD")
|
||||
credit_card = Account.create(name: "Demo Credit Card", family: family, accountable: Account::Credit.new, balance: 1500, currency: "USD")
|
||||
retirement = Account.create(name: "Demo 401k", family: family, accountable: Account::Investment.new, balance: 100000, currency: "USD")
|
||||
euro_savings = Account.create(name: "Demo Euro Savings", family: family, accountable: Account::Depository.new, balance: 10000, currency: "EUR")
|
||||
brokerage = Account.create(name: "Demo Brokerage Account", family: family, accountable: Account::Investment.new, balance: 10000, currency: "USD")
|
||||
crypto = Account.create(name: "Bitcoin Account", family: family, accountable: Account::Crypto.new, balance: 0.1, currency: "BTC")
|
||||
mortgage = Account.create(name: "Demo Mortgage", family: family, accountable: Account::Loan.new, balance: 450000, currency: "USD")
|
||||
main_car = Account.create(name: "Demo Main Car", family: family, accountable: Account::Vehicle.new, balance: 25000, currency: "USD")
|
||||
cash = Account.create(name: "Demo Physical Cash", family: family, accountable: Account::OtherAsset.new, balance: 500, currency: "USD")
|
||||
car_loan = Account.create(name: "Demo Car Loan", family: family, accountable: Account::Loan.new, balance: 10000, currency: "USD")
|
||||
house = Account.create(name: "Demo Primary Residence", family: family, accountable: Account::Property.new, balance: 2500000, currency: "USD")
|
||||
personal_iou = Account.create(name: "Demo Personal IOU", family: family, accountable: Account::OtherLiability.new, balance: 1000, currency: "USD")
|
||||
second_car = Account.create(name: "Demo Secondary Car", family: family, accountable: Account::Vehicle.new, balance: 12000, currency: "USD")
|
||||
|
||||
|
||||
# ========== Transactions ================
|
||||
multi_currency_checking_transactions = [
|
||||
{ date: Date.today - 84, amount: 3000, name: "Paycheck", currency: "USD" },
|
||||
{ date: Date.today - 70, amount: -1500, name: "Rent Payment", currency: "EUR" },
|
||||
{ date: Date.today - 70, amount: -200, name: "Groceries", currency: "EUR" },
|
||||
{ date: Date.today - 56, amount: 3000, name: "Paycheck", currency: "USD" },
|
||||
{ date: Date.today - 42, amount: -1500, name: "Rent Payment", currency: "EUR" },
|
||||
{ date: Date.today - 42, amount: -100, name: "Utilities", currency: "EUR" },
|
||||
{ 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" }
|
||||
]
|
||||
|
||||
multi_currency_checking_transactions.each do |t|
|
||||
multi_currency_checking.transactions.find_or_create_by(date: t[:date], amount: t[:amount], name: t[:name], currency: t[:currency])
|
||||
end
|
||||
|
||||
multi_currency_checking.sync
|
||||
|
||||
checking = Account.find_or_create_by(name: "Demo Checking") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Depository.new
|
||||
a.balance = 5000
|
||||
end
|
||||
|
||||
checking_transactions = [
|
||||
{ date: Date.today - 84, amount: -3000, name: "Direct Deposit" },
|
||||
{ date: Date.today - 70, amount: 1500, name: "Credit Card Payment" },
|
||||
|
@ -65,18 +98,6 @@ namespace :demo_data do
|
|||
{ date: Date.today - 2, amount: 100, name: "Gym Membership" }
|
||||
]
|
||||
|
||||
checking_transactions.each do |t|
|
||||
checking.transactions.find_or_create_by(date: t[:date], amount: t[:amount], name: t[:name])
|
||||
end
|
||||
|
||||
checking.sync
|
||||
|
||||
savings = Account.find_or_create_by(name: "Demo Savings") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Depository.new
|
||||
a.balance = 20000
|
||||
end
|
||||
|
||||
savings_transactions = [
|
||||
{ date: Date.today - 360, amount: -1000, name: "Initial Deposit" },
|
||||
{ date: Date.today - 330, amount: -200, name: "Monthly Savings" },
|
||||
|
@ -92,46 +113,16 @@ namespace :demo_data do
|
|||
{ date: Date.today - 30, amount: -200, name: "Monthly Savings" }
|
||||
]
|
||||
|
||||
savings_transactions.each do |t|
|
||||
savings.transactions.find_or_create_by(date: t[:date], amount: t[:amount], name: t[:name])
|
||||
end
|
||||
|
||||
savings.sync
|
||||
|
||||
euro_savings = Account.find_or_create_by(name: "Demo Euro Savings") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Depository.new
|
||||
a.balance = 10000
|
||||
a.currency = "EUR"
|
||||
end
|
||||
|
||||
euro_savings_transactions = [
|
||||
{ date: Date.today - 360, amount: -500, name: "Initial Deposit", currency: "EUR" },
|
||||
{ date: Date.today - 330, amount: -100, name: "Monthly Savings", currency: "EUR" },
|
||||
{ date: Date.today - 300, amount: -100, name: "Monthly Savings", currency: "EUR" },
|
||||
{ date: Date.today - 270, amount: -100, name: "Monthly Savings", currency: "EUR" },
|
||||
{ date: Date.today - 240, amount: -100, name: "Monthly Savings", currency: "EUR" },
|
||||
{ date: Date.today - 210, amount: -100, name: "Monthly Savings", currency: "EUR" },
|
||||
{ date: Date.today - 180, amount: -100, name: "Monthly Savings", currency: "EUR" },
|
||||
{ date: Date.today - 150, amount: -100, name: "Monthly Savings", currency: "EUR" },
|
||||
{ date: Date.today - 120, amount: -100, name: "Monthly Savings", currency: "EUR" },
|
||||
{ date: Date.today - 90, amount: 500, name: "Withdrawal", currency: "EUR" },
|
||||
{ date: Date.today - 60, amount: -100, name: "Monthly Savings", currency: "EUR" },
|
||||
{ date: Date.today - 30, amount: -100, name: "Monthly Savings", currency: "EUR" }
|
||||
{ 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" }
|
||||
]
|
||||
|
||||
euro_savings_transactions.each do |t|
|
||||
euro_savings.transactions.find_or_create_by(date: t[:date], amount: t[:amount], name: t[:name], currency: t[:currency])
|
||||
end
|
||||
|
||||
euro_savings.sync
|
||||
|
||||
credit_card = Account.find_or_create_by(name: "Demo Credit Card") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Credit.new
|
||||
a.balance = 1500
|
||||
end
|
||||
|
||||
credit_card_transactions = [
|
||||
{ date: Date.today - 90, amount: 75, name: "Grocery Store" },
|
||||
{ date: Date.today - 89, amount: 30, name: "Gas Station" },
|
||||
|
@ -179,95 +170,12 @@ namespace :demo_data do
|
|||
{ date: Date.today, amount: -1000, name: "Credit Card Payment" }
|
||||
]
|
||||
|
||||
credit_card_transactions.each do |t|
|
||||
credit_card.transactions.find_or_create_by(date: t[:date], amount: t[:amount], name: t[:name])
|
||||
end
|
||||
|
||||
credit_card.sync
|
||||
|
||||
retirement = Account.find_or_create_by(name: "Demo 401k") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Investment.new
|
||||
a.balance = 100000
|
||||
end
|
||||
|
||||
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 }
|
||||
]
|
||||
|
||||
retirement.valuations.upsert_all(retirement_valuations, unique_by: :index_valuations_on_account_id_and_date)
|
||||
|
||||
retirement.sync
|
||||
|
||||
brokerage = Account.find_or_create_by(name: "Demo Brokerage Account") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Investment.new
|
||||
a.balance = 10000
|
||||
end
|
||||
|
||||
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 }
|
||||
]
|
||||
|
||||
brokerage.valuations.upsert_all(brokerage_valuations, unique_by: :index_valuations_on_account_id_and_date)
|
||||
|
||||
brokerage.sync
|
||||
|
||||
crypto = Account.find_or_create_by(name: "Bitcoin Account") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Crypto.new
|
||||
a.currency = "BTC"
|
||||
a.balance = 0.1
|
||||
end
|
||||
|
||||
crypto_valuations = [
|
||||
{ date: 1.year.ago.to_date, value: 0.05, currency: "BTC" },
|
||||
{ date: 200.days.ago.to_date, value: 0.06, currency: "BTC" },
|
||||
{ date: 100.days.ago.to_date, value: 0.08, currency: "BTC" },
|
||||
{ date: 20.days.ago.to_date, value: 0.1, currency: "BTC" }
|
||||
]
|
||||
|
||||
crypto.valuations.upsert_all(crypto_valuations, unique_by: :index_valuations_on_account_id_and_date)
|
||||
|
||||
crypto.sync
|
||||
|
||||
mortgage = Account.find_or_create_by(name: "Demo Mortgage") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Loan.new
|
||||
a.balance = 450000
|
||||
end
|
||||
|
||||
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" }
|
||||
]
|
||||
|
||||
mortgage_transactions.each do |t|
|
||||
mortgage.transactions.find_or_create_by(date: t[:date], amount: t[:amount], name: t[:name])
|
||||
end
|
||||
|
||||
mortgage_valuations = [
|
||||
{ date: 2.years.ago.to_date, value: 500000 },
|
||||
{ date: 6.months.ago.to_date, value: 455000 }
|
||||
]
|
||||
|
||||
mortgage.valuations.upsert_all(mortgage_valuations, unique_by: :index_valuations_on_account_id_and_date)
|
||||
|
||||
mortgage.sync
|
||||
|
||||
car_loan = Account.find_or_create_by(name: "Demo Car Loan") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Loan.new
|
||||
a.balance = 10000
|
||||
end
|
||||
|
||||
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" },
|
||||
|
@ -283,17 +191,30 @@ namespace :demo_data do
|
|||
{ date: 1.month.ago.to_date, amount: -1250, name: "Car Loan Payment" }
|
||||
]
|
||||
|
||||
car_loan_transactions.each do |t|
|
||||
car_loan.transactions.find_or_create_by(date: t[:date], amount: t[:amount], name: t[:name])
|
||||
end
|
||||
# ========== 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 }
|
||||
]
|
||||
|
||||
car_loan.sync
|
||||
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 }
|
||||
]
|
||||
|
||||
house = Account.find_or_create_by(name: "Demo Primary Residence") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Property.new
|
||||
a.balance = 2500000
|
||||
end
|
||||
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 },
|
||||
|
@ -303,67 +224,51 @@ namespace :demo_data do
|
|||
{ date: 1.year.ago.to_date, value: 2500000 }
|
||||
]
|
||||
|
||||
house.valuations.upsert_all(house_valuations, unique_by: :index_valuations_on_account_id_and_date)
|
||||
|
||||
house.sync
|
||||
|
||||
main_car = Account.find_or_create_by(name: "Demo Main Car") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Vehicle.new
|
||||
a.balance = 25000
|
||||
end
|
||||
|
||||
main_car_valuations = [
|
||||
{ date: 1.year.ago.to_date, value: 25000 }
|
||||
]
|
||||
|
||||
main_car.valuations.upsert_all(main_car_valuations, unique_by: :index_valuations_on_account_id_and_date)
|
||||
|
||||
main_car.sync
|
||||
|
||||
second_car = Account.find_or_create_by(name: "Demo Secondary Car") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::Vehicle.new
|
||||
a.balance = 12000
|
||||
end
|
||||
|
||||
second_car_valuations = [
|
||||
{ date: 2.years.ago.to_date, value: 11000 },
|
||||
{ date: 1.year.ago.to_date, value: 12000 }
|
||||
]
|
||||
|
||||
second_car.valuations.upsert_all(second_car_valuations, unique_by: :index_valuations_on_account_id_and_date)
|
||||
|
||||
second_car.sync
|
||||
|
||||
cash = Account.find_or_create_by(name: "Demo Physical Cash") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::OtherAsset.new
|
||||
a.balance = 500
|
||||
end
|
||||
|
||||
cash_valuations = [
|
||||
{ date: 1.month.ago.to_date, value: 500 }
|
||||
]
|
||||
|
||||
cash.valuations.upsert_all(cash_valuations, unique_by: :index_valuations_on_account_id_and_date)
|
||||
|
||||
cash.sync
|
||||
|
||||
personal_iou = Account.find_or_create_by(name: "Demo Personal IOU") do |a|
|
||||
a.family = family
|
||||
a.accountable = Account::OtherLiability.new
|
||||
a.balance = 1000
|
||||
end
|
||||
|
||||
personal_iou_valuations = [
|
||||
{ date: 1.month.ago.to_date, value: 1000 }
|
||||
]
|
||||
|
||||
personal_iou.valuations.upsert_all(personal_iou_valuations, unique_by: :index_valuations_on_account_id_and_date)
|
||||
# Insert valuations
|
||||
retirement.valuations.insert_all(retirement_valuations)
|
||||
brokerage.valuations.insert_all(brokerage_valuations)
|
||||
crypto.valuations.insert_all(crypto_valuations)
|
||||
mortgage.valuations.insert_all(mortgage_valuations)
|
||||
house.valuations.insert_all(house_valuations)
|
||||
main_car.valuations.insert_all(main_car_valuations)
|
||||
second_car.valuations.insert_all(second_car_valuations)
|
||||
cash.valuations.insert_all(cash_valuations)
|
||||
personal_iou.valuations.insert_all(personal_iou_valuations)
|
||||
|
||||
personal_iou.sync
|
||||
# Insert transactions
|
||||
multi_currency_checking.transactions.insert_all(multi_currency_checking_transactions)
|
||||
checking.transactions.insert_all(checking_transactions)
|
||||
savings.transactions.insert_all(savings_transactions)
|
||||
euro_savings.transactions.insert_all(euro_savings_transactions)
|
||||
credit_card.transactions.insert_all(credit_card_transactions)
|
||||
mortgage.transactions.insert_all(mortgage_transactions)
|
||||
car_loan.transactions.insert_all(car_loan_transactions)
|
||||
|
||||
puts "Demo data reset complete"
|
||||
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."
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
namespace :exchange_rates do
|
||||
desc "Fetch exchange rates from Synth API"
|
||||
task sync: :environment do
|
||||
Currency.all.each do |currency|
|
||||
(Date.today - 30.days).upto(Date.today) do |date|
|
||||
response = Faraday.get("https://api.synthfinance.com/rates/historical") do |req|
|
||||
req.headers["Authorization"] = "Bearer #{ENV["SYNTH_API_KEY"]}"
|
||||
req.params["date"] = date.to_s
|
||||
req.params["from"] = currency.iso_code
|
||||
req.params["to"] = Currency.where.not(iso_code: currency.iso_code).pluck(:iso_code).join(",")
|
||||
end
|
||||
|
||||
if response.success?
|
||||
rates = JSON.parse(response.body)["data"]["rates"]
|
||||
|
||||
rates.each do |currency_iso_code, value|
|
||||
ExchangeRate.find_or_create_by(date: date, base_currency: currency.iso_code, converted_currency: currency_iso_code) do |exchange_rate|
|
||||
exchange_rate.rate = value
|
||||
end
|
||||
puts "#{currency.iso_code} to #{currency_iso_code} on #{date}: #{value}"
|
||||
end
|
||||
else
|
||||
puts "Failed to fetch exchange rates for #{currency.iso_code} on #{date}: #{response.status}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -2,7 +2,7 @@ require "test_helper"
|
|||
|
||||
class PagesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in users(:family_admin)
|
||||
sign_in @user = users(:family_admin)
|
||||
end
|
||||
|
||||
test "dashboard" do
|
||||
|
|
4
test/fixtures/account/depositories.yml
vendored
4
test/fixtures/account/depositories.yml
vendored
|
@ -2,3 +2,7 @@ checking:
|
|||
id: "123e4567-e89b-12d3-a456-426614174000"
|
||||
savings:
|
||||
id: "123e4567-e89b-12d3-a456-426614174001"
|
||||
eur_checking:
|
||||
id: "123e4567-e89b-12d3-a456-426614174004"
|
||||
multi_currency:
|
||||
id: "123e4567-e89b-12d3-a456-426614174005"
|
||||
|
|
32
test/fixtures/account/expected_balances.csv
vendored
Normal file
32
test/fixtures/account/expected_balances.csv
vendored
Normal file
|
@ -0,0 +1,32 @@
|
|||
date_offset,collectable,checking,savings_with_valuation_overrides,credit_card,eur_checking_eur,eur_checking_usd,multi_currency
|
||||
-30,400,4000,21250,1040,11850,12947.31,10721.26
|
||||
-29,400,3985,21750,940,12050,13182.7,10921.26
|
||||
-28,400,3985,21750,940,12050,13194.75,10921.26
|
||||
-27,400,3985,21750,940,12050,13132.09,10921.26
|
||||
-26,400,3985,21750,940,12050,13083.89,10921.26
|
||||
-25,400,3985,21000,940,12050,13081.48,10921.26
|
||||
-24,400,3985,21000,940,12050,13062.2,10921.26
|
||||
-23,400,3985,21000,940,12050,13022.435,10921.26
|
||||
-22,400,5060,21000,940,12050,13060.995,10921.26
|
||||
-21,400,5060,21000,940,12050,13068.225,10921.26
|
||||
-20,400,5060,21000,940,12050,13079.07,10921.26
|
||||
-19,400,5060,21000,940,11950,12932.29,10813.04
|
||||
-18,400,5060,19000,940,11950,12934.68,10813.04
|
||||
-17,400,5060,19000,940,11950,12927.51,10813.04
|
||||
-16,400,5060,19000,940,11950,12916.755,10813.04
|
||||
-15,400,5040,19000,960,11950,12882.1,10813.04
|
||||
-14,400,5040,19000,960,11950,12879.71,10813.04
|
||||
-13,400,5040,19000,960,11950,12873.735,10813.04
|
||||
-12,700,5010,19500,990,11950,12821.155,10813.04
|
||||
-11,700,5010,19500,990,11950,12797.255,10813.04
|
||||
-10,700,5010,19500,990,11950,12873.735,10813.04
|
||||
-9,700,5010,19500,990,12000,12939.6,10863.04
|
||||
-8,700,5010,19500,990,12000,12933.6,10863.04
|
||||
-7,700,5010,19500,990,12000,12928.8,10863.04
|
||||
-6,700,5010,19500,990,12000,12906,10863.04
|
||||
-5,700,5000,19700,1000,12000,12891.6,10863.04
|
||||
-4,550,5000,19700,1000,12000,12945.6,10000
|
||||
-3,550,5000,20500,1000,12000,13046.4,10000
|
||||
-2,550,5000,20500,1000,12000,12982.8,10000
|
||||
-1,550,5000,20500,1000,12000,13014,10000
|
||||
0,550,5000,20000,1000,12000,13000.8,10000
|
|
17
test/fixtures/accounts.yml
vendored
17
test/fixtures/accounts.yml
vendored
|
@ -29,3 +29,20 @@ credit_card:
|
|||
balance: 1000
|
||||
accountable_type: Account::Credit
|
||||
accountable_id: "123e4567-e89b-12d3-a456-426614174003"
|
||||
|
||||
eur_checking:
|
||||
family: dylan_family
|
||||
name: Euro Checking Account
|
||||
currency: EUR
|
||||
balance: 12000
|
||||
accountable_type: Account::Depository
|
||||
accountable_id: "123e4567-e89b-12d3-a456-426614174004"
|
||||
|
||||
# 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: 10000
|
||||
accountable_type: Account::Depository
|
||||
accountable_id: "123e4567-e89b-12d3-a456-426614174005"
|
||||
|
|
9
test/fixtures/currencies.yml
vendored
9
test/fixtures/currencies.yml
vendored
|
@ -1,9 +0,0 @@
|
|||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
# one:
|
||||
# name: MyString
|
||||
# iso_code: MyString
|
||||
|
||||
# two:
|
||||
# name: MyString
|
||||
# iso_code: MyString
|
323
test/fixtures/exchange_rates.yml
vendored
323
test/fixtures/exchange_rates.yml
vendored
|
@ -1,13 +1,310 @@
|
|||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
# one:
|
||||
# base_currency: one
|
||||
# converted_currency: one
|
||||
# rate: 9.99
|
||||
# date: 2024-02-09
|
||||
|
||||
# two:
|
||||
# base_currency: two
|
||||
# converted_currency: two
|
||||
# rate: 9.99
|
||||
# date: 2024-02-09
|
||||
day_30_ago_eur_to_usd:
|
||||
base_currency: EUR
|
||||
converted_currency: USD
|
||||
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_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 %>
|
||||
|
|
62
test/fixtures/family/expected_snapshots.csv
vendored
62
test/fixtures/family/expected_snapshots.csv
vendored
|
@ -1,32 +1,32 @@
|
|||
date_offset,net_worth,assets,liabilities,depositories,investments,loans,credits,properties,vehicles,other_assets,other_liabilities
|
||||
-30,24610,25650,1040,25250,0,0,1040,0,0,400,0
|
||||
-29,25195,26135,940,25735,0,0,940,0,0,400,0
|
||||
-28,25195,26135,940,25735,0,0,940,0,0,400,0
|
||||
-27,25195,26135,940,25735,0,0,940,0,0,400,0
|
||||
-26,25195,26135,940,25735,0,0,940,0,0,400,0
|
||||
-25,24445,25385,940,24985,0,0,940,0,0,400,0
|
||||
-24,24445,25385,940,24985,0,0,940,0,0,400,0
|
||||
-23,24445,25385,940,24985,0,0,940,0,0,400,0
|
||||
-22,25520,26460,940,26060,0,0,940,0,0,400,0
|
||||
-21,25520,26460,940,26060,0,0,940,0,0,400,0
|
||||
-20,25520,26460,940,26060,0,0,940,0,0,400,0
|
||||
-19,25520,26460,940,26060,0,0,940,0,0,400,0
|
||||
-18,23520,24460,940,24060,0,0,940,0,0,400,0
|
||||
-17,23520,24460,940,24060,0,0,940,0,0,400,0
|
||||
-16,23520,24460,940,24060,0,0,940,0,0,400,0
|
||||
-15,23480,24440,960,24040,0,0,960,0,0,400,0
|
||||
-14,23480,24440,960,24040,0,0,960,0,0,400,0
|
||||
-13,23480,24440,960,24040,0,0,960,0,0,400,0
|
||||
-12,24220,25210,990,24510,0,0,990,0,0,700,0
|
||||
-11,24220,25210,990,24510,0,0,990,0,0,700,0
|
||||
-10,24220,25210,990,24510,0,0,990,0,0,700,0
|
||||
-9,24220,25210,990,24510,0,0,990,0,0,700,0
|
||||
-8,24220,25210,990,24510,0,0,990,0,0,700,0
|
||||
-7,24220,25210,990,24510,0,0,990,0,0,700,0
|
||||
-6,24220,25210,990,24510,0,0,990,0,0,700,0
|
||||
-5,24400,25400,1000,24700,0,0,1000,0,0,700,0
|
||||
-4,24250,25250,1000,24700,0,0,1000,0,0,550,0
|
||||
-3,25050,26050,1000,25500,0,0,1000,0,0,550,0
|
||||
-2,25050,26050,1000,25500,0,0,1000,0,0,550,0
|
||||
-1,25050,26050,1000,25500,0,0,1000,0,0,550,0
|
||||
0,24550,25550,1000,25000,0,0,1000,0,0,550,0
|
||||
-30,48278.57,49318.57,1040.00,48918.57,0.00,0.00,1040.00,0.00,0.00,400.00,0.00
|
||||
-29,49298.96,50238.96,940.00,49838.96,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-28,49311.01,50251.01,940.00,49851.01,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-27,49248.35,50188.35,940.00,49788.35,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-26,49200.15,50140.15,940.00,49740.15,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-25,48447.74,49387.74,940.00,48987.74,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-24,48428.46,49368.46,940.00,48968.46,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-23,48388.70,49328.70,940.00,48928.70,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-22,49502.26,50442.26,940.00,50042.26,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-21,49509.49,50449.49,940.00,50049.49,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-20,49520.33,50460.33,940.00,50060.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-19,49265.33,50205.33,940.00,49805.33,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-18,47267.72,48207.72,940.00,47807.72,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-17,47260.55,48200.55,940.00,47800.55,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-16,47249.80,48189.80,940.00,47789.80,0.00,0.00,940.00,0.00,0.00,400.00,0.00
|
||||
-15,47175.14,48135.14,960.00,47735.14,0.00,0.00,960.00,0.00,0.00,400.00,0.00
|
||||
-14,47172.75,48132.75,960.00,47732.75,0.00,0.00,960.00,0.00,0.00,400.00,0.00
|
||||
-13,47166.78,48126.78,960.00,47726.78,0.00,0.00,960.00,0.00,0.00,400.00,0.00
|
||||
-12,47854.20,48844.20,990.00,48144.20,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-11,47830.30,48820.30,990.00,48120.30,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-10,47906.78,48896.78,990.00,48196.78,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-9,48022.64,49012.64,990.00,48312.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-8,48016.64,49006.64,990.00,48306.64,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-7,48011.84,49001.84,990.00,48301.84,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-6,47989.04,48979.04,990.00,48279.04,0.00,0.00,990.00,0.00,0.00,700.00,0.00
|
||||
-5,48154.64,49154.64,1000.00,48454.64,0.00,0.00,1000.00,0.00,0.00,700.00,0.00
|
||||
-4,47195.60,48195.60,1000.00,47645.60,0.00,0.00,1000.00,0.00,0.00,550.00,0.00
|
||||
-3,48096.40,49096.40,1000.00,48546.40,0.00,0.00,1000.00,0.00,0.00,550.00,0.00
|
||||
-2,48032.80,49032.80,1000.00,48482.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00
|
||||
-1,48064.00,49064.00,1000.00,48514.00,0.00,0.00,1000.00,0.00,0.00,550.00,0.00
|
||||
0,47550.80,48550.80,1000.00,48000.80,0.00,0.00,1000.00,0.00,0.00,550.00,0.00
|
|
64
test/fixtures/transactions.yml
vendored
64
test/fixtures/transactions.yml
vendored
|
@ -5,6 +5,7 @@ checking_one:
|
|||
amount: 10
|
||||
account: checking
|
||||
category: food_and_drink
|
||||
currency: USD
|
||||
|
||||
checking_two:
|
||||
name: Chipotle
|
||||
|
@ -12,12 +13,14 @@ checking_two:
|
|||
amount: 30
|
||||
account: checking
|
||||
category: food_and_drink
|
||||
currency: USD
|
||||
|
||||
checking_three:
|
||||
name: Amazon
|
||||
date: <%= 15.days.ago.to_date %>
|
||||
amount: 20
|
||||
account: checking
|
||||
currency: USD
|
||||
|
||||
checking_four:
|
||||
name: Paycheck
|
||||
|
@ -25,12 +28,14 @@ checking_four:
|
|||
amount: -1075
|
||||
account: checking
|
||||
category: income
|
||||
currency: USD
|
||||
|
||||
checking_five:
|
||||
name: Netflix
|
||||
date: <%= 29.days.ago.to_date %>
|
||||
amount: 15
|
||||
account: checking
|
||||
currency: USD
|
||||
|
||||
# Savings account that has these transactions and valuation overrides
|
||||
savings_one:
|
||||
|
@ -39,6 +44,7 @@ savings_one:
|
|||
amount: -200
|
||||
account: savings_with_valuation_overrides
|
||||
category: income
|
||||
currency: USD
|
||||
|
||||
savings_two:
|
||||
name: Check Deposit
|
||||
|
@ -46,12 +52,14 @@ savings_two:
|
|||
amount: -50
|
||||
account: savings_with_valuation_overrides
|
||||
category: income
|
||||
currency: USD
|
||||
|
||||
savings_three:
|
||||
name: Withdrawal
|
||||
date: <%= 18.days.ago.to_date %>
|
||||
amount: 2000
|
||||
account: savings_with_valuation_overrides
|
||||
currency: USD
|
||||
|
||||
savings_four:
|
||||
name: Check Deposit
|
||||
|
@ -59,6 +67,7 @@ savings_four:
|
|||
amount: -500
|
||||
account: savings_with_valuation_overrides
|
||||
category: income
|
||||
currency: USD
|
||||
|
||||
# Credit card account transactions
|
||||
credit_card_one:
|
||||
|
@ -67,6 +76,7 @@ credit_card_one:
|
|||
amount: 10
|
||||
account: credit_card
|
||||
category: food_and_drink
|
||||
currency: USD
|
||||
|
||||
credit_card_two:
|
||||
name: Chipotle
|
||||
|
@ -74,15 +84,69 @@ credit_card_two:
|
|||
amount: 30
|
||||
account: credit_card
|
||||
category: food_and_drink
|
||||
currency: USD
|
||||
|
||||
credit_card_three:
|
||||
name: Amazon
|
||||
date: <%= 15.days.ago.to_date %>
|
||||
amount: 20
|
||||
account: credit_card
|
||||
currency: USD
|
||||
|
||||
credit_card_four:
|
||||
name: CC Payment
|
||||
date: <%= 29.days.ago.to_date %>
|
||||
amount: -100
|
||||
account: credit_card
|
||||
currency: USD
|
||||
|
||||
# eur_checking transactions
|
||||
eur_checking_one:
|
||||
name: Check
|
||||
date: <%= 9.days.ago.to_date %>
|
||||
amount: -50
|
||||
currency: EUR
|
||||
account: eur_checking
|
||||
|
||||
eur_checking_two:
|
||||
name: Shopping trip
|
||||
date: <%= 19.days.ago.to_date %>
|
||||
amount: 100
|
||||
currency: EUR
|
||||
account: eur_checking
|
||||
|
||||
eur_checking_three:
|
||||
name: Check
|
||||
date: <%= 29.days.ago.to_date %>
|
||||
amount: -200
|
||||
currency: EUR
|
||||
account: eur_checking
|
||||
|
||||
# multi_currency transactions
|
||||
multi_currency_one:
|
||||
name: Outflow 1
|
||||
date: <%= 4.days.ago.to_date %>
|
||||
amount: 800
|
||||
currency: EUR
|
||||
account: multi_currency
|
||||
|
||||
multi_currency_two:
|
||||
name: Inflow 1
|
||||
date: <%= 9.days.ago.to_date %>
|
||||
amount: -50
|
||||
currency: USD
|
||||
account: multi_currency
|
||||
|
||||
multi_currency_three:
|
||||
name: Outflow 2
|
||||
date: <%= 19.days.ago.to_date %>
|
||||
amount: 100
|
||||
currency: EUR
|
||||
account: multi_currency
|
||||
|
||||
multi_currency_four:
|
||||
name: Inflow 2
|
||||
date: <%= 29.days.ago.to_date %>
|
||||
amount: -200
|
||||
currency: USD
|
||||
account: multi_currency
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
require "test_helper"
|
||||
|
||||
class AccountBalanceSyncJobTest < ActiveJob::TestCase
|
||||
class Account::BalanceSyncJobTest < ActiveJob::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
|
|
|
@ -87,4 +87,13 @@ class MoneyTest < ActiveSupport::TestCase
|
|||
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 "returns nil if exchange rate not available" do
|
||||
assert_nil Money.new(1000).exchange_to(:jpy)
|
||||
end
|
||||
end
|
||||
|
|
98
test/models/account/balance/calculator_test.rb
Normal file
98
test/models/account/balance/calculator_test.rb
Normal file
|
@ -0,0 +1,98 @@
|
|||
require "test_helper"
|
||||
require "csv"
|
||||
|
||||
class Account::Balance::CalculatorTest < ActiveSupport::TestCase
|
||||
# See: https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
|
||||
setup do
|
||||
@expected_balances = CSV.read("test/fixtures/account/expected_balances.csv", headers: true).map do |row|
|
||||
{
|
||||
"date" => (Date.current + row["date_offset"].to_i.days).to_date,
|
||||
"collectable" => row["collectable"],
|
||||
"checking" => row["checking"],
|
||||
"savings_with_valuation_overrides" => row["savings_with_valuation_overrides"],
|
||||
"credit_card" => row["credit_card"],
|
||||
"multi_currency" => row["multi_currency"],
|
||||
|
||||
# Balances should be calculated for all currencies of an account
|
||||
"eur_checking_eur" => row["eur_checking_eur"],
|
||||
"eur_checking_usd" => row["eur_checking_usd"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
test "syncs account with only valuations" do
|
||||
account = accounts(:collectable)
|
||||
|
||||
calculator = Account::Balance::Calculator.new(account)
|
||||
calculator.calculate
|
||||
|
||||
expected = @expected_balances.map { |row| row["collectable"].to_d }
|
||||
actual = calculator.daily_balances.map { |b| b[:balance] }
|
||||
|
||||
assert_equal expected, actual
|
||||
end
|
||||
|
||||
test "syncs account with only transactions" do
|
||||
account = accounts(:checking)
|
||||
|
||||
calculator = Account::Balance::Calculator.new(account)
|
||||
calculator.calculate
|
||||
|
||||
expected = @expected_balances.map { |row| row["checking"].to_d }
|
||||
actual = calculator.daily_balances.map { |b| b[:balance] }
|
||||
|
||||
assert_equal expected, actual
|
||||
end
|
||||
|
||||
test "syncs account with both valuations and transactions" do
|
||||
account = accounts(:savings_with_valuation_overrides)
|
||||
|
||||
calculator = Account::Balance::Calculator.new(account)
|
||||
calculator.calculate
|
||||
|
||||
expected = @expected_balances.map { |row| row["savings_with_valuation_overrides"].to_d }
|
||||
actual = calculator.daily_balances.map { |b| b[:balance] }
|
||||
|
||||
assert_equal expected, actual
|
||||
end
|
||||
|
||||
test "syncs liability account" do
|
||||
account = accounts(:credit_card)
|
||||
|
||||
calculator = Account::Balance::Calculator.new(account)
|
||||
calculator.calculate
|
||||
|
||||
expected = @expected_balances.map { |row| row["credit_card"].to_d }
|
||||
actual = calculator.daily_balances.map { |b| b[:balance] }
|
||||
|
||||
assert_equal expected, actual
|
||||
end
|
||||
|
||||
test "syncs foreign currency account" do
|
||||
account = accounts(:eur_checking)
|
||||
calculator = Account::Balance::Calculator.new(account)
|
||||
calculator.calculate
|
||||
|
||||
# Calculator should calculate balances in both account and family currency
|
||||
expected_eur_balances = @expected_balances.map { |row| row["eur_checking_eur"].to_d }
|
||||
expected_usd_balances = @expected_balances.map { |row| row["eur_checking_usd"].to_d }
|
||||
|
||||
actual_eur_balances = calculator.daily_balances.select { |b| b[:currency] == "EUR" }.sort_by { |b| b[:date] }.map { |b| b[:balance] }
|
||||
actual_usd_balances = calculator.daily_balances.select { |b| b[:currency] == "USD" }.sort_by { |b| b[:date] }.map { |b| b[:balance] }
|
||||
|
||||
assert_equal expected_eur_balances, actual_eur_balances
|
||||
assert_equal expected_usd_balances, actual_usd_balances
|
||||
end
|
||||
|
||||
test "syncs multi currency account" do
|
||||
account = accounts(:multi_currency)
|
||||
calculator = Account::Balance::Calculator.new(account)
|
||||
calculator.calculate
|
||||
|
||||
expected_balances = @expected_balances.map { |row| row["multi_currency"].to_d }
|
||||
|
||||
actual_balances = calculator.daily_balances.map { |b| b[:balance] }
|
||||
|
||||
assert_equal expected_balances, actual_balances
|
||||
end
|
||||
end
|
|
@ -1,61 +0,0 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::BalanceCalculatorTest < ActiveSupport::TestCase
|
||||
test "syncs account with only valuations" do
|
||||
account = accounts(:collectable)
|
||||
|
||||
daily_balances = Account::BalanceCalculator.new(account).daily_balances
|
||||
|
||||
expected_balances = [
|
||||
400, 400, 400, 400, 400, 400, 400, 400, 400, 400,
|
||||
400, 400, 400, 400, 400, 400, 400, 400, 700, 700,
|
||||
700, 700, 700, 700, 700, 700, 550, 550, 550, 550,
|
||||
550
|
||||
].map(&:to_d)
|
||||
|
||||
assert_equal expected_balances, daily_balances.map { |b| b[:balance] }
|
||||
end
|
||||
|
||||
test "syncs account with only transactions" do
|
||||
account = accounts(:checking)
|
||||
|
||||
daily_balances = Account::BalanceCalculator.new(account).daily_balances
|
||||
|
||||
expected_balances = [
|
||||
4000, 3985, 3985, 3985, 3985, 3985, 3985, 3985, 5060, 5060,
|
||||
5060, 5060, 5060, 5060, 5060, 5040, 5040, 5040, 5010, 5010,
|
||||
5010, 5010, 5010, 5010, 5010, 5000, 5000, 5000, 5000, 5000,
|
||||
5000
|
||||
].map(&:to_d)
|
||||
|
||||
assert_equal expected_balances, daily_balances.map { |b| b[:balance] }
|
||||
end
|
||||
|
||||
test "syncs account with both valuations and transactions" do
|
||||
account = accounts(:savings_with_valuation_overrides)
|
||||
daily_balances = Account::BalanceCalculator.new(account).daily_balances
|
||||
|
||||
expected_balances = [
|
||||
21250, 21750, 21750, 21750, 21750, 21000, 21000, 21000, 21000, 21000,
|
||||
21000, 21000, 19000, 19000, 19000, 19000, 19000, 19000, 19500, 19500,
|
||||
19500, 19500, 19500, 19500, 19500, 19700, 19700, 20500, 20500, 20500,
|
||||
20000
|
||||
].map(&:to_d)
|
||||
|
||||
assert_equal expected_balances, daily_balances.map { |b| b[:balance] }
|
||||
end
|
||||
|
||||
test "syncs liability account" do
|
||||
account = accounts(:credit_card)
|
||||
daily_balances = Account::BalanceCalculator.new(account).daily_balances
|
||||
|
||||
expected_balances = [
|
||||
1040, 940, 940, 940, 940, 940, 940, 940, 940, 940,
|
||||
940, 940, 940, 940, 940, 960, 960, 960, 990, 990,
|
||||
990, 990, 990, 990, 990, 1000, 1000, 1000, 1000, 1000,
|
||||
1000
|
||||
].map(&:to_d)
|
||||
|
||||
assert_equal expected_balances, daily_balances.map { |b| b[:balance] }
|
||||
end
|
||||
end
|
|
@ -14,6 +14,15 @@ class Account::SyncableTest < ActiveSupport::TestCase
|
|||
assert_equal 31, account.balances.count
|
||||
end
|
||||
|
||||
test "foreign currency account has balances in each currency after syncing" do
|
||||
account = accounts(:eur_checking)
|
||||
account.sync
|
||||
|
||||
assert_equal 62, account.balances.count
|
||||
assert_equal 31, account.balances.where(currency: "EUR").count
|
||||
assert_equal 31, account.balances.where(currency: "USD").count
|
||||
end
|
||||
|
||||
test "stale balances are purged after syncing" do
|
||||
account = accounts(:savings_with_valuation_overrides)
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
require "test_helper"
|
||||
|
||||
class AccountBalanceTest < ActiveSupport::TestCase
|
||||
class Account::BalanceTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
|
|
|
@ -1,8 +1,20 @@
|
|||
require "test_helper"
|
||||
require "csv"
|
||||
|
||||
class AccountTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@account = accounts(:checking)
|
||||
@family = families(:dylan_family)
|
||||
@snapshots = CSV.read("test/fixtures/family/expected_snapshots.csv", headers: true).map do |row|
|
||||
{
|
||||
"date" => (Date.current + row["date_offset"].to_i.days).to_date,
|
||||
"assets" => row["assets"],
|
||||
"liabilities" => row["liabilities"],
|
||||
"Account::Depository" => row["depositories"],
|
||||
"Account::Credit" => row["credits"],
|
||||
"Account::OtherAsset" => row["other_assets"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
test "new account should be valid" do
|
||||
|
@ -10,4 +22,92 @@ class AccountTest < ActiveSupport::TestCase
|
|||
assert_not_nil @account.accountable_id
|
||||
assert_not_nil @account.accountable
|
||||
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?
|
||||
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 "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?
|
||||
end
|
||||
|
||||
test "syncs regular account" do
|
||||
@account.sync
|
||||
assert_equal "ok", @account.status
|
||||
assert_equal 31, @account.balances.count
|
||||
end
|
||||
|
||||
test "syncs foreign currency account" do
|
||||
account = accounts(:eur_checking)
|
||||
account.sync
|
||||
assert_equal "ok", account.status
|
||||
assert_equal 31, account.balances.where(currency: "USD").count
|
||||
assert_equal 31, account.balances.where(currency: "EUR").count
|
||||
end
|
||||
test "groups accounts by type" do
|
||||
@family.accounts.each do |account|
|
||||
account.sync
|
||||
end
|
||||
|
||||
result = @family.accounts.by_group(period: Period.all)
|
||||
|
||||
expected_assets = @snapshots.last["assets"].to_d
|
||||
expected_liabilities = @snapshots.last["liabilities"].to_d
|
||||
|
||||
assets = result[:assets]
|
||||
liabilities = result[:liabilities]
|
||||
|
||||
assert_equal @family.assets, assets.sum
|
||||
assert_equal @family.liabilities, liabilities.sum
|
||||
|
||||
depositories = assets.children.find { |group| group.name == "Account::Depository" }
|
||||
properties = assets.children.find { |group| group.name == "Account::Property" }
|
||||
vehicles = assets.children.find { |group| group.name == "Account::Vehicle" }
|
||||
investments = assets.children.find { |group| group.name == "Account::Investment" }
|
||||
other_assets = assets.children.find { |group| group.name == "Account::OtherAsset" }
|
||||
|
||||
credits = liabilities.children.find { |group| group.name == "Account::Credit" }
|
||||
loans = liabilities.children.find { |group| group.name == "Account::Loan" }
|
||||
other_liabilities = liabilities.children.find { |group| group.name == "Account::OtherLiability" }
|
||||
|
||||
assert_equal 4, depositories.children.count
|
||||
assert_equal 0, properties.children.count
|
||||
assert_equal 0, vehicles.children.count
|
||||
assert_equal 0, investments.children.count
|
||||
assert_equal 1, other_assets.children.count
|
||||
|
||||
assert_equal 1, credits.children.count
|
||||
assert_equal 0, loans.children.count
|
||||
assert_equal 0, 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
|
||||
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
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
require "test_helper"
|
||||
|
||||
class CurrencyTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
|
@ -8,6 +8,17 @@ class FamilyTest < ActiveSupport::TestCase
|
|||
@family.accounts.each do |account|
|
||||
account.sync
|
||||
end
|
||||
|
||||
# See this Google Sheet for calculations and expected results for dylan_family:
|
||||
# https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
|
||||
@expected_snapshots = CSV.read("test/fixtures/family/expected_snapshots.csv", headers: true).map do |row|
|
||||
{
|
||||
"date" => (Date.current + row["date_offset"].to_i.days).to_date,
|
||||
"net_worth" => row["net_worth"],
|
||||
"assets" => row["assets"],
|
||||
"liabilities" => row["liabilities"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
test "should have many users" do
|
||||
|
@ -38,45 +49,37 @@ class FamilyTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
test "should calculate total assets" do
|
||||
assert_equal Money.new(25550), @family.assets_money
|
||||
expected = @expected_snapshots.last["assets"].to_d
|
||||
assert_equal Money.new(expected), @family.assets
|
||||
end
|
||||
|
||||
test "should calculate total liabilities" do
|
||||
assert_equal Money.new(1000), @family.liabilities_money
|
||||
expected = @expected_snapshots.last["liabilities"].to_d
|
||||
assert_equal Money.new(expected), @family.liabilities
|
||||
end
|
||||
|
||||
test "should calculate net worth" do
|
||||
assert_equal Money.new(24550), @family.net_worth_money
|
||||
expected = @expected_snapshots.last["net_worth"].to_d
|
||||
assert_equal Money.new(expected), @family.net_worth
|
||||
end
|
||||
|
||||
test "should calculate snapshot correctly" do
|
||||
# See this Google Sheet for calculations and expected results for dylan_family:
|
||||
# https://docs.google.com/spreadsheets/d/18LN5N-VLq4b49Mq1fNwF7_eBiHSQB46qQduRtdAEN98/edit?usp=sharing
|
||||
expected_snapshots = CSV.read("test/fixtures/family/expected_snapshots.csv", headers: true).map do |row|
|
||||
{
|
||||
"date" => (Date.current + row["date_offset"].to_i.days).to_date,
|
||||
"net_worth" => row["net_worth"],
|
||||
"assets" => row["assets"],
|
||||
"liabilities" => row["liabilities"]
|
||||
}
|
||||
end
|
||||
|
||||
asset_series = @family.snapshot[:asset_series]
|
||||
liability_series = @family.snapshot[:liability_series]
|
||||
net_worth_series = @family.snapshot[:net_worth_series]
|
||||
|
||||
assert_equal expected_snapshots.count, asset_series.values.count
|
||||
assert_equal expected_snapshots.count, liability_series.values.count
|
||||
assert_equal expected_snapshots.count, net_worth_series.values.count
|
||||
assert_equal @expected_snapshots.count, asset_series.values.count
|
||||
assert_equal @expected_snapshots.count, liability_series.values.count
|
||||
assert_equal @expected_snapshots.count, net_worth_series.values.count
|
||||
|
||||
expected_snapshots.each_with_index do |row, index|
|
||||
@expected_snapshots.each_with_index do |row, index|
|
||||
expected_assets = TimeSeries::Value.new(date: row["date"], value: Money.new(row["assets"].to_d))
|
||||
expected_liabilities = TimeSeries::Value.new(date: row["date"], value: Money.new(row["liabilities"].to_d))
|
||||
expected_net_worth = TimeSeries::Value.new(date: row["date"], value: Money.new(row["net_worth"].to_d))
|
||||
|
||||
assert_equal expected_assets, asset_series.values[index]
|
||||
assert_equal expected_liabilities, liability_series.values[index]
|
||||
assert_equal expected_net_worth, net_worth_series.values[index]
|
||||
assert_in_delta expected_assets.value.amount, Money.new(asset_series.values[index].value).amount, 0.01
|
||||
assert_in_delta expected_liabilities.value.amount, Money.new(liability_series.values[index].value).amount, 0.01
|
||||
assert_in_delta expected_net_worth.value.amount, Money.new(net_worth_series.values[index].value).amount, 0.01
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -7,20 +7,20 @@ class ValueGroupTest < ActiveSupport::TestCase
|
|||
collectable = accounts(:collectable)
|
||||
|
||||
# Level 1
|
||||
@assets = ValueGroup.new("Assets")
|
||||
@assets = ValueGroup.new("Assets", :usd)
|
||||
|
||||
# Level 2
|
||||
@depositories = @assets.add_child_node("Depositories")
|
||||
@other_assets = @assets.add_child_node("Other Assets")
|
||||
@depositories = @assets.add_child_group("Depositories", :usd)
|
||||
@other_assets = @assets.add_child_group("Other Assets", :usd)
|
||||
|
||||
# Level 3 (leaf/value nodes)
|
||||
@checking_node = @depositories.add_value_node(checking)
|
||||
@savings_node = @depositories.add_value_node(savings)
|
||||
@collectable_node = @other_assets.add_value_node(collectable)
|
||||
@checking_node = @depositories.add_value_node(OpenStruct.new({ name: "Checking", value: Money.new(5000) }), Money.new(5000))
|
||||
@savings_node = @depositories.add_value_node(OpenStruct.new({ name: "Savings", value: Money.new(20000) }), Money.new(20000))
|
||||
@collectable_node = @other_assets.add_value_node(OpenStruct.new({ name: "Collectable", value: Money.new(550) }), Money.new(550))
|
||||
end
|
||||
|
||||
test "empty group works" do
|
||||
group = ValueGroup.new
|
||||
group = ValueGroup.new("Root", :usd)
|
||||
|
||||
assert_equal "Root", group.name
|
||||
assert_equal [], group.children
|
||||
|
@ -32,7 +32,7 @@ class ValueGroupTest < ActiveSupport::TestCase
|
|||
|
||||
test "group without value nodes has no value" do
|
||||
assets = ValueGroup.new("Assets")
|
||||
depositories = assets.add_child_node("Depositories")
|
||||
depositories = assets.add_child_group("Depositories")
|
||||
|
||||
assert_equal 0, assets.sum
|
||||
assert_equal 0, depositories.sum
|
||||
|
@ -57,24 +57,24 @@ class ValueGroupTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
test "group with value nodes aggregates totals correctly" do
|
||||
assert_equal 5000, @checking_node.sum
|
||||
assert_equal 20000, @savings_node.sum
|
||||
assert_equal 550, @collectable_node.sum
|
||||
assert_equal Money.new(5000), @checking_node.sum
|
||||
assert_equal Money.new(20000), @savings_node.sum
|
||||
assert_equal Money.new(550), @collectable_node.sum
|
||||
|
||||
assert_equal 25000, @depositories.sum
|
||||
assert_equal 550, @other_assets.sum
|
||||
assert_equal Money.new(25000), @depositories.sum
|
||||
assert_equal Money.new(550), @other_assets.sum
|
||||
|
||||
assert_equal 25550, @assets.sum
|
||||
assert_equal Money.new(25550), @assets.sum
|
||||
end
|
||||
|
||||
test "group averages leaf nodes" do
|
||||
assert_equal 5000, @checking_node.avg
|
||||
assert_equal 20000, @savings_node.avg
|
||||
assert_equal 550, @collectable_node.avg
|
||||
assert_equal Money.new(5000), @checking_node.avg
|
||||
assert_equal Money.new(20000), @savings_node.avg
|
||||
assert_equal Money.new(550), @collectable_node.avg
|
||||
|
||||
assert_in_delta 12500, @depositories.avg, 0.01
|
||||
assert_in_delta 550, @other_assets.avg, 0.01
|
||||
assert_in_delta 8516.67, @assets.avg, 0.01
|
||||
assert_in_delta 12500, @depositories.avg.amount, 0.01
|
||||
assert_in_delta 550, @other_assets.avg.amount, 0.01
|
||||
assert_in_delta 8516.67, @assets.avg.amount, 0.01
|
||||
end
|
||||
|
||||
# Percentage of parent group (i.e. collectable is 100% of "Other Assets" group)
|
||||
|
@ -88,19 +88,19 @@ class ValueGroupTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
test "handles unbalanced tree" do
|
||||
vehicles = @assets.add_child_node("Vehicles")
|
||||
vehicles = @assets.add_child_group("Vehicles")
|
||||
|
||||
# Since we didn't add any value nodes to vehicles, shouldn't affect rollups
|
||||
assert_equal 25550, @assets.sum
|
||||
assert_equal Money.new(25550), @assets.sum
|
||||
end
|
||||
|
||||
|
||||
test "can attach and aggregate time series" do
|
||||
checking_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 4000 }, { date: Date.current, value: 5000 } ])
|
||||
savings_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 19000 }, { date: Date.current, value: 20000 } ])
|
||||
checking_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(4000) }, { date: Date.current, value: Money.new(5000) } ])
|
||||
savings_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(19000) }, { date: Date.current, value: Money.new(20000) } ])
|
||||
|
||||
@checking_node.attach_series(checking_series)
|
||||
@savings_node.attach_series(savings_series)
|
||||
@checking_node.series = checking_series
|
||||
@savings_node.series = savings_series
|
||||
|
||||
assert_not_nil @checking_node.series
|
||||
assert_not_nil @savings_node.series
|
||||
|
@ -108,8 +108,8 @@ class ValueGroupTest < ActiveSupport::TestCase
|
|||
assert_equal @checking_node.sum, @checking_node.series.last.value
|
||||
assert_equal @savings_node.sum, @savings_node.series.last.value
|
||||
|
||||
aggregated_depository_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 23000 }, { date: Date.current, value: 25000 } ])
|
||||
aggregated_assets_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 23000 }, { date: Date.current, value: 25000 } ])
|
||||
aggregated_depository_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(23000) }, { date: Date.current, value: Money.new(25000) } ])
|
||||
aggregated_assets_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(23000) }, { date: Date.current, value: Money.new(25000) } ])
|
||||
|
||||
assert_equal aggregated_depository_series.values, @depositories.series.values
|
||||
assert_equal aggregated_assets_series.values, @assets.series.values
|
||||
|
@ -117,29 +117,29 @@ class ValueGroupTest < ActiveSupport::TestCase
|
|||
|
||||
test "attached series must be a TimeSeries" do
|
||||
assert_raises(RuntimeError) do
|
||||
@checking_node.attach_series([])
|
||||
@checking_node.series = []
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot add time series to non-leaf node" do
|
||||
assert_raises(RuntimeError) do
|
||||
@assets.attach_series(TimeSeries.new([]))
|
||||
@assets.series = TimeSeries.new([])
|
||||
end
|
||||
end
|
||||
|
||||
test "can only add value node at leaf level of tree" do
|
||||
root = ValueGroup.new("Root Level")
|
||||
grandparent = root.add_child_node("Grandparent")
|
||||
parent = grandparent.add_child_node("Parent")
|
||||
grandparent = root.add_child_group("Grandparent")
|
||||
parent = grandparent.add_child_group("Parent")
|
||||
|
||||
value_node = parent.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 }))
|
||||
value_node = parent.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
|
||||
|
||||
assert_raises(RuntimeError) do
|
||||
value_node.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 }))
|
||||
value_node.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
|
||||
end
|
||||
|
||||
assert_raises(RuntimeError) do
|
||||
grandparent.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 }))
|
||||
grandparent.add_value_node(OpenStruct.new({ name: "Value Node", value: Money.new(100) }), Money.new(100))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue