mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-05 05:25:24 +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
|
@ -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') %>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue