From 110855d077a0a501674d5866fe43844df93c3159 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 21 Mar 2024 13:39:10 -0400 Subject: [PATCH] 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 --- .env.example | 6 - README.md | 5 +- app/controllers/accounts_controller.rb | 17 +- app/controllers/pages_controller.rb | 2 +- app/helpers/application_helper.rb | 8 +- app/jobs/convert_currency_job.rb | 19 - app/jobs/daily_exchange_rate_job.rb | 34 -- app/models/account.rb | 57 +-- app/models/account/balance.rb | 8 + app/models/account/balance/calculator.rb | 108 ++++++ app/models/account/balance_calculator.rb | 40 --- app/models/account/syncable.rb | 49 ++- app/models/account_balance.rb | 9 - app/models/currency.rb | 2 - app/models/exchange_rate.rb | 44 ++- app/models/family.rb | 14 +- app/models/period.rb | 4 +- app/models/value_group.rb | 79 ++--- app/views/accounts/_account_list.html.erb | 24 +- app/views/accounts/show.html.erb | 3 + app/views/layouts/application.html.erb | 6 +- app/views/pages/_account_group_disclosure.erb | 34 +- .../pages/_account_percentages_table.html.erb | 2 +- app/views/pages/dashboard.html.erb | 6 +- app/views/settings/edit.html.erb | 10 +- config/initializers/constants.rb | 7 - config/locales/views/account/en.yml | 2 + config/routes.rb | 1 + .../20240308121431_remove_currency_table.rb | 5 + ...s_for_account_balance_and_exchange_rate.rb | 7 + ...2_remove_converted_balance_from_account.rb | 6 + db/schema.rb | 14 +- db/seeds.rb | 3 - lib/money.rb | 8 + lib/tasks/currencies.rake | 28 -- lib/tasks/demo_data.rake | 325 +++++++----------- lib/tasks/exchange_rates.rake | 28 -- test/controllers/pages_controller_test.rb | 2 +- test/fixtures/account/depositories.yml | 4 + test/fixtures/account/expected_balances.csv | 32 ++ test/fixtures/accounts.yml | 17 + test/fixtures/currencies.yml | 9 - test/fixtures/exchange_rates.yml | 323 ++++++++++++++++- test/fixtures/family/expected_snapshots.csv | 62 ++-- test/fixtures/transactions.yml | 64 ++++ test/jobs/account_balance_sync_job_test.rb | 2 +- test/lib/money_test.rb | 9 + .../models/account/balance/calculator_test.rb | 98 ++++++ .../models/account/balance_calculator_test.rb | 61 ---- test/models/account/syncable_test.rb | 9 + test/models/account_balance_test.rb | 2 +- test/models/account_test.rb | 100 ++++++ test/models/currency_test.rb | 7 - test/models/family_test.rb | 45 +-- test/models/value_group_test.rb | 70 ++-- 55 files changed, 1226 insertions(+), 714 deletions(-) delete mode 100644 app/jobs/convert_currency_job.rb delete mode 100644 app/jobs/daily_exchange_rate_job.rb create mode 100644 app/models/account/balance.rb create mode 100644 app/models/account/balance/calculator.rb delete mode 100644 app/models/account/balance_calculator.rb delete mode 100644 app/models/account_balance.rb delete mode 100644 app/models/currency.rb delete mode 100644 config/initializers/constants.rb create mode 100644 db/migrate/20240308121431_remove_currency_table.rb create mode 100644 db/migrate/20240313141813_update_unique_indexes_for_account_balance_and_exchange_rate.rb create mode 100644 db/migrate/20240313203622_remove_converted_balance_from_account.rb delete mode 100644 lib/tasks/currencies.rake delete mode 100644 lib/tasks/exchange_rates.rake create mode 100644 test/fixtures/account/expected_balances.csv delete mode 100644 test/fixtures/currencies.yml create mode 100644 test/models/account/balance/calculator_test.rb delete mode 100644 test/models/account/balance_calculator_test.rb delete mode 100644 test/models/currency_test.rb diff --git a/.env.example b/.env.example index a579df8b..3e5719d3 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index 8991808a..3ed41481 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index b67928a6..86d579ec 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -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 diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index b2a3bb98..92754f5b 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f98fb42f..17b3be1f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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? diff --git a/app/jobs/convert_currency_job.rb b/app/jobs/convert_currency_job.rb deleted file mode 100644 index 2a657b69..00000000 --- a/app/jobs/convert_currency_job.rb +++ /dev/null @@ -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 diff --git a/app/jobs/daily_exchange_rate_job.rb b/app/jobs/daily_exchange_rate_job.rb deleted file mode 100644 index 8f71afa2..00000000 --- a/app/jobs/daily_exchange_rate_job.rb +++ /dev/null @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb index cb5881cb..5682214e 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/account/balance.rb b/app/models/account/balance.rb new file mode 100644 index 00000000..3e7adc24 --- /dev/null +++ b/app/models/account/balance.rb @@ -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 diff --git a/app/models/account/balance/calculator.rb b/app/models/account/balance/calculator.rb new file mode 100644 index 00000000..2f7e0d26 --- /dev/null +++ b/app/models/account/balance/calculator.rb @@ -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 diff --git a/app/models/account/balance_calculator.rb b/app/models/account/balance_calculator.rb deleted file mode 100644 index 9dbf2f55..00000000 --- a/app/models/account/balance_calculator.rb +++ /dev/null @@ -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 diff --git a/app/models/account/syncable.rb b/app/models/account/syncable.rb index 7e0b0d8b..5e4d3780 100644 --- a/app/models/account/syncable.rb +++ b/app/models/account/syncable.rb @@ -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 diff --git a/app/models/account_balance.rb b/app/models/account_balance.rb deleted file mode 100644 index a4e48f21..00000000 --- a/app/models/account_balance.rb +++ /dev/null @@ -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 diff --git a/app/models/currency.rb b/app/models/currency.rb deleted file mode 100644 index ef898699..00000000 --- a/app/models/currency.rb +++ /dev/null @@ -1,2 +0,0 @@ -class Currency < ApplicationRecord -end diff --git a/app/models/exchange_rate.rb b/app/models/exchange_rate.rb index 5f5827c4..3a5aee8c 100644 --- a/app/models/exchange_rate.rb +++ b/app/models/exchange_rate.rb @@ -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 diff --git a/app/models/family.rb b/app/models/family.rb index 9b01b77f..3cf10dbb 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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 diff --git a/app/models/period.rb b/app/models/period.rb index eada7555..e5413843 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -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) diff --git a/app/models/value_group.rb b/app/models/value_group.rb index 707f8a26..e9d2d5b8 100644 --- a/app/models/value_group.rb +++ b/app/models/value_group.rb @@ -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 diff --git a/app/views/accounts/_account_list.html.erb b/app/views/accounts/_account_list.html.erb index fb7f42aa..7f8319b9 100644 --- a/app/views/accounts/_account_list.html.erb +++ b/app/views/accounts/_account_list.html.erb @@ -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 %>
- + <%= 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") %>
<%= type.model_name.human %>
-
<%= format_money accounts.sum(&:converted_balance) %>
+
+

<%= format_money group.sum %>

+
- <% 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 %>
-

<%= account.name %>

- <% if account.subtype %> -

<%= account.subtype&.humanize %>

+

<%= account_value_node.name %>

+ <% if account_value_node.original.subtype %> +

<%= account_value_node.original.subtype&.humanize %>

<% end %>
-

<%= format_money account.converted_balance %>

+

<%= format_money account_value_node.original.balance_money %>

<% 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 %> diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index 0e5c760e..e315c3c5 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -8,6 +8,9 @@

<%= @account.name %>

+ <%= 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 %>
<%= @account.balance_money.currency.iso_code %> <%= @account.balance_money.currency.symbol %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 27afebb9..16bc6069 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -8,7 +8,6 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> - @@ -16,7 +15,6 @@ - <%= 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") %>

<%= t('.new_account') %>

<% 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 %>
diff --git a/app/views/pages/_account_group_disclosure.erb b/app/views/pages/_account_group_disclosure.erb index 2e29f3f4..bea0ab88 100644 --- a/app/views/pages/_account_group_disclosure.erb +++ b/app/views/pages/_account_group_disclosure.erb @@ -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) %>
<%= 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") %> -
-

<%= to_accountable_title(Accountable.from_type(account_group.name)) %>

+
+

<%= to_accountable_title(Accountable.from_type(accountable_group.name)) %>

· -
<%= account_group.children.count %>
+
<%= accountable_group.children.count %>
- <%= render partial: "shared/progress_circle", locals: { progress: account_group.percent_of_total, text_class: text_class } %> -

<%= account_group.percent_of_total.round(1) %>%

+ <%= render partial: "shared/progress_circle", locals: { progress: accountable_group.percent_of_total, text_class: text_class } %> +

<%= accountable_group.percent_of_total.round(1) %>%

-

<%= format_money account_group.sum %>

+

<%= format_money accountable_group.sum %>

- <%= render partial: "shared/trend_change", locals: { trend: account_group.series.trend } %> + <%= render partial: "shared/trend_change", locals: { trend: accountable_group.series.trend } %>
- <% account_group.children.map do |account| %> + <% accountable_group.children.map do |account_value_node| %>
-
- <%= account.name[0].upcase %> +
+ <%= account_value_node.name[0].upcase %>
-

<%= account.name %>

+

<%= account_value_node.name %>

- <%= render partial: "shared/progress_circle", locals: { progress: account.percent_of_total, text_class: text_class } %> -

<%= account.percent_of_total %>%

+ <%= render partial: "shared/progress_circle", locals: { progress: account_value_node.percent_of_total, text_class: text_class } %> +

<%= account_value_node.percent_of_total %>%

-

<%= format_money account.sum %>

+

<%= format_money account_value_node.original.balance_money %>

- <%= render partial: "shared/trend_change", locals: { trend: account.series.trend } %> + <%= render partial: "shared/trend_change", locals: { trend: account_value_node.original.series.trend } %>
diff --git a/app/views/pages/_account_percentages_table.html.erb b/app/views/pages/_account_percentages_table.html.erb index 44acca60..68f9409e 100644 --- a/app/views/pages/_account_percentages_table.html.erb +++ b/app/views/pages/_account_percentages_table.html.erb @@ -15,6 +15,6 @@
- <%= render partial: "account_group_disclosure", collection: account_groups, as: :account_group %> + <%= render partial: "account_group_disclosure", collection: account_groups, as: :accountable_group %>
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 97ad397c..af8a19b3 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -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 } %> @@ -44,7 +44,7 @@ label: "Liabilities", period: @period, size: "md", - balance: Current.family.liabilities_money, + balance: Current.family.liabilities, trend: @liability_series.trend } %> diff --git a/app/views/settings/edit.html.erb b/app/views/settings/edit.html.erb index 12e5f308..8b69a8cc 100644 --- a/app/views/settings/edit.html.erb +++ b/app/views/settings/edit.html.erb @@ -1,22 +1,14 @@

Update settings

- <%= 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 %> -