1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-02 20:15:22 +02:00

New Design System + Codebase Refresh (#1823)

Since the very first 0.1.0-alpha.1 release, we've been moving quickly to add new features to the Maybe app. In doing so, some parts of the codebase have become outdated, unnecessary, or overly-complex as a natural result of this feature prioritization.

Now that "core" Maybe is complete, we're moving into a second phase of development where we'll be working hard to improve the accuracy of existing features and build additional features on top of "core". This PR is a quick overhaul of the existing codebase aimed to:

- Establish the brand new and simplified dashboard view (pictured above)
- Establish and move towards the conventions introduced in Cursor rules and project design overview #1788
- Consolidate layouts and improve the performance of layout queries
- Organize the core models of the Maybe domain (i.e. Account::Entry, Account::Transaction, etc.) and break out specific traits of each model into dedicated concerns for better readability
- Remove stale / dead code from codebase
- Remove overly complex code paths in favor of simpler ones
This commit is contained in:
Zach Gollwitzer 2025-02-21 11:57:59 -05:00 committed by GitHub
parent 8539ac7dec
commit d75be2282b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
278 changed files with 3428 additions and 4354 deletions

View file

@ -1,5 +1,5 @@
class Account < ApplicationRecord
include Syncable, Monetizable, Issuable
include Syncable, Monetizable, Issuable, Chartable
validates :name, :balance, :currency, presence: true
@ -20,7 +20,7 @@ class Account < ApplicationRecord
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
scope :active, -> { where(is_active: true, scheduled_for_deletion: false) }
scope :active, -> { where(is_active: true) }
scope :assets, -> { where(classification: "asset") }
scope :liabilities, -> { where(classification: "liability") }
scope :alphabetically, -> { order(:name) }
@ -32,34 +32,7 @@ class Account < ApplicationRecord
accepts_nested_attributes_for :accountable, update_only: true
def institution_domain
return nil unless plaid_account&.plaid_item&.institution_url.present?
URI.parse(plaid_account.plaid_item.institution_url).host.gsub(/^www\./, "")
end
class << self
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
Accountable.by_classification.each do |classification, types|
types.each do |type|
accounts = self.where(accountable_type: type)
if accounts.any?
group = grouped_accounts[classification.to_sym].add_child_group(type, currency)
accounts.each do |account|
group.add_value_node(
account,
account.balance_money.exchange_to(currency, fallback_rate: 0),
account.series(period: period, currency: currency)
)
end
end
end
end
grouped_accounts
end
def create_and_sync(attributes)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
account = new(attributes.merge(cash_balance: attributes[:balance]))
@ -89,8 +62,13 @@ class Account < ApplicationRecord
end
end
def institution_domain
return nil unless plaid_account&.plaid_item&.institution_url.present?
URI.parse(plaid_account.plaid_item.institution_url).host.gsub(/^www\./, "")
end
def destroy_later
update!(scheduled_for_deletion: true)
update!(scheduled_for_deletion: true, is_active: false)
DestroyJob.perform_later(self)
end
@ -106,18 +84,6 @@ class Account < ApplicationRecord
accountable.post_sync
end
def series(period: Period.last_30_days, currency: nil)
balance_series = balances.in_period(period).where(currency: currency || self.currency)
if balance_series.empty? && period.date_range.end == Date.current
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency || self.currency) } ])
else
TimeSeries.from_collection(balance_series, :balance_money, favorable_direction: asset? ? "up" : "down")
end
rescue Money::ConversionError
TimeSeries.new([])
end
def original_balance
balance_amount = balances.chronological.first&.balance || balance
Money.new(balance_amount, currency)
@ -127,10 +93,6 @@ class Account < ApplicationRecord
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
end
def favorable_direction
classification == "asset" ? "up" : "down"
end
def enrich_data
DataEnricher.new(self).run
end
@ -161,63 +123,11 @@ class Account < ApplicationRecord
end
end
def transfer_match_candidates
Account::Entry.select([
"inflow_candidates.entryable_id as inflow_transaction_id",
"outflow_candidates.entryable_id as outflow_transaction_id",
"ABS(inflow_candidates.date - outflow_candidates.date) as date_diff"
]).from("account_entries inflow_candidates")
.joins("
JOIN account_entries outflow_candidates ON (
inflow_candidates.amount < 0 AND
outflow_candidates.amount > 0 AND
inflow_candidates.amount = -outflow_candidates.amount AND
inflow_candidates.currency = outflow_candidates.currency AND
inflow_candidates.account_id <> outflow_candidates.account_id AND
inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4
)
").joins("
LEFT JOIN transfers existing_transfers ON (
existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id OR
existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id
)
")
.joins("LEFT JOIN rejected_transfers ON (
rejected_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND
rejected_transfers.outflow_transaction_id = outflow_candidates.entryable_id
)")
.joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id")
.joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id")
.where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.family_id, self.family_id)
.where("inflow_accounts.is_active = true AND inflow_accounts.scheduled_for_deletion = false")
.where("outflow_accounts.is_active = true AND outflow_accounts.scheduled_for_deletion = false")
.where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'")
.where(existing_transfers: { id: nil })
.order("date_diff ASC") # Closest matches first
end
def sparkline_series
cache_key = family.build_cache_key("#{id}_sparkline")
def auto_match_transfers!
# Exclude already matched transfers
candidates_scope = transfer_match_candidates.where(rejected_transfers: { id: nil })
# Track which transactions we've already matched to avoid duplicates
used_transaction_ids = Set.new
candidates = []
Transfer.transaction do
candidates_scope.each do |match|
next if used_transaction_ids.include?(match.inflow_transaction_id) ||
used_transaction_ids.include?(match.outflow_transaction_id)
Transfer.create!(
inflow_transaction_id: match.inflow_transaction_id,
outflow_transaction_id: match.outflow_transaction_id,
)
used_transaction_ids << match.inflow_transaction_id
used_transaction_ids << match.outflow_transaction_id
end
Rails.cache.fetch(cache_key) do
balance_series
end
end
end

View file

@ -4,6 +4,6 @@ class Account::Balance < ApplicationRecord
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) }
scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }
scope :chronological, -> { order(:date) }
end

View file

@ -80,7 +80,7 @@ class Account::BalanceTrendCalculator
return BalanceTrend.new(trend: nil) unless intraday_balance.present?
BalanceTrend.new(
trend: TimeSeries::Trend.new(
trend: Trend.new(
current: Money.new(intraday_balance, entry.currency),
previous: Money.new(prior_balance, entry.currency),
favorable_direction: entry.account.favorable_direction

View file

@ -0,0 +1,102 @@
module Account::Chartable
extend ActiveSupport::Concern
class_methods do
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up")
balances = Account::Balance.find_by_sql([
balance_series_query,
{
start_date: period.start_date,
end_date: period.end_date,
interval: period.interval,
target_currency: currency
}
])
balances = gapfill_balances(balances)
values = [ nil, *balances ].each_cons(2).map do |prev, curr|
Series::Value.new(
date: curr.date,
date_formatted: I18n.l(curr.date, format: :long),
trend: Trend.new(
current: Money.new(curr.balance, currency),
previous: prev.nil? ? nil : Money.new(prev.balance, currency),
favorable_direction: favorable_direction
)
)
end
Series.new(
start_date: period.start_date,
end_date: period.end_date,
interval: period.interval,
trend: Trend.new(
current: Money.new(balances.last&.balance || 0, currency),
previous: Money.new(balances.first&.balance || 0, currency),
favorable_direction: favorable_direction
),
values: values
)
end
private
def balance_series_query
<<~SQL
WITH dates as (
SELECT generate_series(DATE :start_date, DATE :end_date, :interval::interval)::date as date
UNION DISTINCT
SELECT CURRENT_DATE -- Ensures we always end on current date, regardless of interval
)
SELECT
d.date,
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance ELSE -ab.balance END * COALESCE(er.rate, 1)) as balance,
COUNT(CASE WHEN accounts.currency <> :target_currency AND er.rate IS NULL THEN 1 END) as missing_rates
FROM dates d
LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql})
LEFT JOIN account_balances ab ON (
ab.date = d.date AND
ab.currency = accounts.currency AND
ab.account_id = accounts.id
)
LEFT JOIN exchange_rates er ON (
er.date = ab.date AND
er.from_currency = accounts.currency AND
er.to_currency = :target_currency
)
GROUP BY d.date
ORDER BY d.date
SQL
end
def gapfill_balances(balances)
gapfilled = []
prev_balance = nil
[ nil, *balances ].each_cons(2).each_with_index do |(prev, curr), index|
if index == 0 && curr.balance.nil?
curr.balance = 0 # Ensure all series start with a non-nil balance
elsif curr.balance.nil?
curr.balance = prev.balance
end
gapfilled << curr
end
gapfilled
end
end
def favorable_direction
classification == "asset" ? "up" : "down"
end
def balance_series(period: Period.last_30_days)
self.class.where(id: self.id).balance_series(
currency: currency,
period: period,
favorable_direction: favorable_direction
)
end
end

View file

@ -1,8 +1,6 @@
class Account::Entry < ApplicationRecord
include Monetizable
Stats = Struct.new(:currency, :count, :income_total, :expense_total, keyword_init: true)
monetize :amount
belongs_to :account
@ -16,6 +14,10 @@ class Account::Entry < ApplicationRecord
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
validates :date, comparison: { greater_than: -> { min_supported_date } }
scope :active, -> {
joins(:account).where(accounts: { is_active: true })
}
scope :chronological, -> {
order(
date: :asc,
@ -24,10 +26,6 @@ class Account::Entry < ApplicationRecord
)
}
scope :active, -> {
joins(:account).where(accounts: { is_active: true, scheduled_for_deletion: false })
}
scope :reverse_chronological, -> {
order(
date: :desc,
@ -36,35 +34,6 @@ class Account::Entry < ApplicationRecord
)
}
# All non-transfer entries, rejected transfers, and the outflow of a loan payment transfer are incomes/expenses
scope :incomes_and_expenses, -> {
joins("INNER JOIN account_transactions ON account_transactions.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_transactions.id OR transfers.outflow_transaction_id = account_transactions.id")
.joins("LEFT JOIN account_transactions inflow_txns ON inflow_txns.id = transfers.inflow_transaction_id")
.joins("LEFT JOIN account_entries inflow_entries ON inflow_entries.entryable_id = inflow_txns.id AND inflow_entries.entryable_type = 'Account::Transaction'")
.joins("LEFT JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_entries.account_id")
.where("transfers.id IS NULL OR transfers.status = 'rejected' OR (account_entries.amount > 0 AND inflow_accounts.accountable_type = 'Loan')")
}
scope :incomes, -> {
incomes_and_expenses.where("account_entries.amount <= 0")
}
scope :expenses, -> {
incomes_and_expenses.where("account_entries.amount > 0")
}
scope :with_converted_amount, ->(currency) {
# Join with exchange rates to convert the amount to the given currency
# If no rate is available, exclude the transaction from the results
select(
"account_entries.*",
"account_entries.amount * COALESCE(er.rate, 1) AS converted_amount"
)
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ]))
.where("er.rate IS NOT NULL OR account_entries.currency = ?", currency)
}
def sync_account_later
sync_start_date = [ date_previously_was, date ].compact.min unless destroyed?
account.sync_later(start_date: sync_start_date)
@ -82,23 +51,6 @@ class Account::Entry < ApplicationRecord
enriched_name.presence || name
end
def transfer_match_candidates
candidates_scope = account.transfer_match_candidates
candidates_scope = if amount.negative?
candidates_scope.where("inflow_candidates.entryable_id = ?", entryable_id)
else
candidates_scope.where("outflow_candidates.entryable_id = ?", entryable_id)
end
candidates_scope.map do |pm|
Transfer.new(
inflow_transaction_id: pm.inflow_transaction_id,
outflow_transaction_id: pm.outflow_transaction_id,
)
end
end
class << self
def search(params)
Account::EntrySearch.new(params).build_query(all)
@ -109,35 +61,6 @@ class Account::Entry < ApplicationRecord
30.years.ago.to_date
end
def daily_totals(entries, currency, period: Period.last_30_days)
# Sum spending and income for each day in the period with the given currency
select(
"gs.date",
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
)
.from(entries.with_converted_amount(currency), :e)
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON e.date = gs.date", period.date_range.first, period.date_range.last ]))
.group("gs.date")
end
def daily_rolling_totals(entries, currency, period: Period.last_30_days)
# Extend the period to include the rolling window
period_with_rolling = period.extend_backward(period.date_range.count.days)
# Aggregate the rolling sum of spending and income based on daily totals
rolling_totals = from(daily_totals(entries, currency, period: period_with_rolling))
.select(
"*",
sanitize_sql_array([ "SUM(spending) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_spend", "#{period.date_range.count} days" ]),
sanitize_sql_array([ "SUM(income) OVER (ORDER BY date RANGE BETWEEN INTERVAL ? PRECEDING AND CURRENT ROW) as rolling_income", "#{period.date_range.count} days" ])
)
.order(:date)
# Trim the results to the original period
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
end
def bulk_update!(bulk_update_params)
bulk_attributes = {
date: bulk_update_params[:date],
@ -159,25 +82,5 @@ class Account::Entry < ApplicationRecord
all.size
end
def stats(currency = "USD")
result = all
.incomes_and_expenses
.joins(sanitize_sql_array([ "LEFT JOIN exchange_rates er ON account_entries.date = er.date AND account_entries.currency = er.from_currency AND er.to_currency = ?", currency ]))
.select(
"COUNT(*) AS count",
"SUM(CASE WHEN account_entries.amount < 0 THEN (account_entries.amount * COALESCE(er.rate, 1)) ELSE 0 END) AS income_total",
"SUM(CASE WHEN account_entries.amount > 0 THEN (account_entries.amount * COALESCE(er.rate, 1)) ELSE 0 END) AS expense_total"
)
.to_a
.first
Stats.new(
currency: currency,
count: result.count,
income_total: result.income_total ? result.income_total * -1 : 0,
expense_total: result.expense_total || 0
)
end
end
end

View file

@ -12,29 +12,44 @@ class Account::EntrySearch
attribute :end_date, :string
class << self
def from_entryable_search(entryable_search)
new(entryable_search.attributes.slice(*attribute_names))
def apply_search_filter(scope, search)
return scope if search.blank?
query = scope
query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search",
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
)
query
end
end
def build_query(scope)
query = scope
def apply_date_filters(scope, start_date, end_date)
return scope if start_date.blank? && end_date.blank?
query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search",
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
) if search.present?
query = query.where("account_entries.date >= ?", start_date) if start_date.present?
query = query.where("account_entries.date <= ?", end_date) if end_date.present?
query = scope
query = query.where("account_entries.date >= ?", start_date) if start_date.present?
query = query.where("account_entries.date <= ?", end_date) if end_date.present?
query
end
def apply_type_filter(scope, types)
return scope if types.blank?
query = scope
if types.present?
if types.include?("income") && !types.include?("expense")
query = query.where("account_entries.amount < 0")
elsif types.include?("expense") && !types.include?("income")
query = query.where("account_entries.amount >= 0")
end
query
end
if amount.present? && amount_operator.present?
def apply_amount_filter(scope, amount, amount_operator)
return scope if amount.blank? || amount_operator.blank?
query = scope
case amount_operator
when "equal"
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs)
@ -43,15 +58,27 @@ class Account::EntrySearch
when "greater"
query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs)
end
query
end
if accounts.present? || account_ids.present?
query = query.joins(:account)
def apply_accounts_filter(scope, accounts, account_ids)
return scope if accounts.blank? && account_ids.blank?
query = scope
query = query.where(accounts: { name: accounts }) if accounts.present?
query = query.where(accounts: { id: account_ids }) if account_ids.present?
query
end
end
query = query.where(accounts: { name: accounts }) if accounts.present?
query = query.where(accounts: { id: account_ids }) if account_ids.present?
def build_query(scope)
query = scope.joins(:account)
query = self.class.apply_search_filter(query, search)
query = self.class.apply_date_filters(query, start_date, end_date)
query = self.class.apply_type_filter(query, types)
query = self.class.apply_amount_filter(query, amount, amount_operator)
query = self.class.apply_accounts_filter(query, accounts, account_ids)
query
end
end

View file

@ -9,5 +9,21 @@ module Account::Entryable
included do
has_one :entry, as: :entryable, touch: true
scope :with_entry, -> { joins(:entry) }
scope :active, -> { with_entry.merge(Account::Entry.active) }
scope :in_period, ->(period) {
with_entry.where(account_entries: { date: period.start_date..period.end_date })
}
scope :reverse_chronological, -> {
with_entry.merge(Account::Entry.reverse_chronological)
}
scope :chronological, -> {
with_entry.merge(Account::Entry.chronological)
}
end
end

View file

@ -53,13 +53,12 @@ class Account::Holding < ApplicationRecord
end
private
def calculate_trend
return nil unless amount_money
start_amount = qty * avg_cost
TimeSeries::Trend.new \
Trend.new \
current: amount_money,
previous: start_amount
end

View file

@ -5,7 +5,7 @@ class Account::Syncer
end
def run
account.auto_match_transfers!
account.family.auto_match_transfers!
holdings = sync_holdings
balances = sync_balances(holdings)

View file

@ -16,6 +16,6 @@ class Account::Trade < ApplicationRecord
current_value = current_price * qty.abs
cost_basis = price_money * qty.abs
TimeSeries::Trend.new(current: current_value, previous: cost_basis)
Trend.new(current: current_value, previous: cost_basis)
end
end

View file

@ -1,33 +1,17 @@
class Account::Transaction < ApplicationRecord
include Account::Entryable
include Account::Entryable, Transferable
belongs_to :category, optional: true
belongs_to :merchant, optional: true
has_many :taggings, as: :taggable, dependent: :destroy
has_many :tags, through: :taggings
has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :destroy
has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :destroy
# We keep track of rejected transfers to avoid auto-matching them again
has_one :rejected_transfer_as_inflow, class_name: "RejectedTransfer", foreign_key: "inflow_transaction_id", dependent: :destroy
has_one :rejected_transfer_as_outflow, class_name: "RejectedTransfer", foreign_key: "outflow_transaction_id", dependent: :destroy
accepts_nested_attributes_for :taggings, allow_destroy: true
scope :active, -> { where(excluded: false) }
class << self
def search(params)
Account::TransactionSearch.new(params).build_query(all)
end
end
def transfer
transfer_as_inflow || transfer_as_outflow
end
def transfer?
transfer.present?
end
end

View file

@ -0,0 +1,40 @@
module Account::Transaction::Transferable
extend ActiveSupport::Concern
included do
has_one :transfer_as_inflow, class_name: "Transfer", foreign_key: "inflow_transaction_id", dependent: :destroy
has_one :transfer_as_outflow, class_name: "Transfer", foreign_key: "outflow_transaction_id", dependent: :destroy
# We keep track of rejected transfers to avoid auto-matching them again
has_one :rejected_transfer_as_inflow, class_name: "RejectedTransfer", foreign_key: "inflow_transaction_id", dependent: :destroy
has_one :rejected_transfer_as_outflow, class_name: "RejectedTransfer", foreign_key: "outflow_transaction_id", dependent: :destroy
end
def transfer
transfer_as_inflow || transfer_as_outflow
end
def transfer?
transfer.present?
end
def transfer_match_candidates
candidates_scope = if self.entry.amount.negative?
family_matches_scope.where("inflow_candidates.entryable_id = ?", self.id)
else
family_matches_scope.where("outflow_candidates.entryable_id = ?", self.id)
end
candidates_scope.map do |match|
Transfer.new(
inflow_transaction_id: match.inflow_transaction_id,
outflow_transaction_id: match.outflow_transaction_id,
)
end
end
private
def family_matches_scope
self.entry.account.family.transfer_match_candidates
end
end

View file

@ -16,7 +16,7 @@ class Account::TransactionSearch
# Returns array of Account::Entry objects to stay consistent with partials, which only deal with Account::Entry
def build_query(scope)
query = scope
query = scope.joins(entry: :account)
if types.present? && types.exclude?("transfer")
query = query.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_entries.id OR transfers.outflow_transaction_id = account_entries.id")
@ -40,8 +40,13 @@ class Account::TransactionSearch
query = query.joins(:tags).where(tags: { name: tags }) if tags.present?
entries_scope = Account::Entry.account_transactions.where(entryable_id: query.select(:id))
# Apply common entry search filters
query = Account::EntrySearch.apply_search_filter(query, search)
query = Account::EntrySearch.apply_date_filters(query, start_date, end_date)
query = Account::EntrySearch.apply_type_filter(query, types)
query = Account::EntrySearch.apply_amount_filter(query, amount, amount_operator)
query = Account::EntrySearch.apply_accounts_filter(query, accounts, account_ids)
Account::EntrySearch.from_entryable_search(self).build_query(entries_scope)
query
end
end

View file

@ -0,0 +1,95 @@
class BalanceSheet
include Monetizable
monetize :total_assets, :total_liabilities, :net_worth
attr_reader :family
def initialize(family)
@family = family
end
def total_assets
totals_query.filter { |t| t.classification == "asset" }.sum(&:converted_balance)
end
def total_liabilities
totals_query.filter { |t| t.classification == "liability" }.sum(&:converted_balance)
end
def net_worth
total_assets - total_liabilities
end
def classification_groups
[
ClassificationGroup.new(
key: "asset",
display_name: "Assets",
icon: "blocks",
account_groups: account_groups("asset")
),
ClassificationGroup.new(
key: "liability",
display_name: "Debts",
icon: "scale",
account_groups: account_groups("liability")
)
]
end
def account_groups(classification = nil)
classification_accounts = classification ? totals_query.filter { |t| t.classification == classification } : totals_query
classification_total = classification_accounts.sum(&:converted_balance)
account_groups = classification_accounts.group_by(&:accountable_type).transform_keys { |k| Accountable.from_type(k) }
account_groups.map do |accountable, accounts|
group_total = accounts.sum(&:converted_balance)
AccountGroup.new(
key: accountable.model_name.param_key,
name: accountable.display_name,
classification: accountable.classification,
total: group_total,
total_money: Money.new(group_total, currency),
weight: classification_total.zero? ? 0 : group_total / classification_total.to_d * 100,
missing_rates?: accounts.any? { |a| a.missing_rates? },
color: accountable.color,
accounts: accounts.map do |account|
account.define_singleton_method(:weight) do
classification_total.zero? ? 0 : account.converted_balance / classification_total.to_d * 100
end
account
end.sort_by(&:weight).reverse
)
end.sort_by(&:weight).reverse
end
def net_worth_series(period: Period.last_30_days)
active_accounts.balance_series(currency: currency, period: period, favorable_direction: "up")
end
def currency
family.currency
end
private
ClassificationGroup = Struct.new(:key, :display_name, :icon, :account_groups, keyword_init: true)
AccountGroup = Struct.new(:key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, keyword_init: true)
def active_accounts
family.accounts.active.with_attached_logo
end
def totals_query
@totals_query ||= active_accounts
.joins(ActiveRecord::Base.sanitize_sql_array([ "LEFT JOIN exchange_rates ON exchange_rates.date = CURRENT_DATE AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = ?", currency ]))
.select(
"accounts.*",
"SUM(accounts.balance * COALESCE(exchange_rates.rate, 1)) as converted_balance",
ActiveRecord::Base.sanitize_sql_array([ "COUNT(CASE WHEN accounts.currency <> ? AND exchange_rates.rate IS NULL THEN 1 END) as missing_rates", currency ])
)
.group(:classification, :accountable_type, :id)
.to_a
end
end

View file

@ -1,9 +1,11 @@
class Budget < ApplicationRecord
include Monetizable
PARAM_DATE_FORMAT = "%b-%Y"
belongs_to :family
has_many :budget_categories, dependent: :destroy
has_many :budget_categories, -> { includes(:category) }, dependent: :destroy
validates :start_date, :end_date, presence: true
validates :start_date, :end_date, uniqueness: { scope: :family_id }
@ -13,16 +15,28 @@ class Budget < ApplicationRecord
:estimated_spending, :estimated_income, :actual_income, :remaining_expected_income
class << self
def for_date(date)
find_by(start_date: date.beginning_of_month, end_date: date.end_of_month)
def date_to_param(date)
date.strftime(PARAM_DATE_FORMAT).downcase
end
def find_or_bootstrap(family, date: Date.current)
def param_to_date(param)
Date.strptime(param, PARAM_DATE_FORMAT).beginning_of_month
end
def budget_date_valid?(date, family:)
beginning_of_month = date.beginning_of_month
beginning_of_month >= oldest_valid_budget_date(family) && beginning_of_month <= Date.current.end_of_month
end
def find_or_bootstrap(family, start_date:)
return nil unless budget_date_valid?(start_date, family: family)
Budget.transaction do
budget = Budget.find_or_create_by!(
family: family,
start_date: date.beginning_of_month,
end_date: date.end_of_month
start_date: start_date.beginning_of_month,
end_date: start_date.end_of_month
) do |b|
b.currency = family.currency
end
@ -32,17 +46,38 @@ class Budget < ApplicationRecord
budget
end
end
private
def oldest_valid_budget_date(family)
@oldest_valid_budget_date ||= family.oldest_entry_date.beginning_of_month
end
end
def period
Period.new(start_date: start_date, end_date: end_date)
end
def to_param
self.class.date_to_param(start_date)
end
def sync_budget_categories
family.categories.expenses.each do |category|
budget_categories.find_or_create_by(
category: category,
) do |bc|
bc.budgeted_spending = 0
bc.currency = family.currency
end
current_category_ids = family.categories.expenses.pluck(:id).to_set
existing_budget_category_ids = budget_categories.pluck(:category_id).to_set
categories_to_add = current_category_ids - existing_budget_category_ids
categories_to_remove = existing_budget_category_ids - current_category_ids
# Create missing categories
categories_to_add.each do |category_id|
budget_categories.create!(
category_id: category_id,
budgeted_spending: 0,
currency: family.currency
)
end
# Remove old categories
budget_categories.where(category_id: categories_to_remove).destroy_all if categories_to_remove.any?
end
def uncategorized_budget_category
@ -52,8 +87,8 @@ class Budget < ApplicationRecord
end
end
def entries
family.entries.incomes_and_expenses.where(date: start_date..end_date)
def transactions
family.transactions.active.in_period(period)
end
def name
@ -64,28 +99,32 @@ class Budget < ApplicationRecord
budgeted_spending.present?
end
def income_categories_with_totals
family.income_categories_with_totals(date: start_date)
def income_category_totals
income_totals.category_totals.reject { |ct| ct.category.subcategory? }.sort_by(&:weight).reverse
end
def expense_categories_with_totals
family.expense_categories_with_totals(date: start_date)
def expense_category_totals
expense_totals.category_totals.reject { |ct| ct.category.subcategory? }.sort_by(&:weight).reverse
end
def current?
start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month
end
def previous_budget
prev_month_end_date = end_date - 1.month
return nil if prev_month_end_date < family.oldest_entry_date
family.budgets.find_or_bootstrap(family, date: prev_month_end_date)
def previous_budget_param
previous_date = start_date - 1.month
return nil unless self.class.budget_date_valid?(previous_date, family: family)
self.class.date_to_param(previous_date)
end
def next_budget
def next_budget_param
return nil if current?
next_start_date = start_date + 1.month
family.budgets.find_or_bootstrap(family, date: next_start_date)
next_date = start_date + 1.month
return nil unless self.class.budget_date_valid?(next_date, family: family)
self.class.date_to_param(next_date)
end
def to_donut_segments_json
@ -94,8 +133,8 @@ class Budget < ApplicationRecord
# Continuous gray segment for empty budgets
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid?
segments = budget_categories.includes(:category).map do |bc|
{ color: bc.category.color, amount: bc.actual_spending, id: bc.id }
segments = budget_categories.map do |bc|
{ color: bc.category.color, amount: budget_category_actual_spending(bc), id: bc.id }
end
if available_to_spend.positive?
@ -109,11 +148,23 @@ class Budget < ApplicationRecord
# Actuals: How much user has spent on each budget category
# =============================================================================
def estimated_spending
family.budgeting_stats.avg_monthly_expenses&.abs
income_statement.median_expense(interval: "month")
end
def actual_spending
expense_categories_with_totals.total_money.amount
expense_totals.total
end
def budget_category_actual_spending(budget_category)
expense_totals.category_totals.find { |ct| ct.category.id == budget_category.category.id }&.total || 0
end
def category_median_monthly_expense(category)
income_statement.median_expense(category: category)
end
def category_avg_monthly_expense(category)
income_statement.avg_expense(category: category)
end
def available_to_spend
@ -157,11 +208,11 @@ class Budget < ApplicationRecord
# Income: How much user earned relative to what they expected to earn
# =============================================================================
def estimated_income
family.budgeting_stats.avg_monthly_income&.abs
family.income_statement.median_income(interval: "month")
end
def actual_income
family.entries.incomes.where(date: start_date..end_date).sum(:amount).abs
family.income_statement.income_totals(period: self.period).total
end
def actual_income_percent
@ -179,4 +230,17 @@ class Budget < ApplicationRecord
remaining_expected_income.abs / expected_income.to_f * 100
end
private
def income_statement
@income_statement ||= family.income_statement
end
def expense_totals
@expense_totals ||= income_statement.expense_totals(period: period)
end
def income_totals
@income_totals ||= family.income_statement.income_totals(period: period)
end
end

View file

@ -6,7 +6,7 @@ class BudgetCategory < ApplicationRecord
validates :budget_id, uniqueness: { scope: :category_id }
monetize :budgeted_spending, :actual_spending, :available_to_spend
monetize :budgeted_spending, :available_to_spend, :avg_monthly_expense, :median_monthly_expense, :actual_spending
class Group
attr_reader :budget_category, :budget_subcategories
@ -45,12 +45,24 @@ class BudgetCategory < ApplicationRecord
super || budget.family.categories.uncategorized
end
def subcategory?
category.parent_id.present?
def name
category.name
end
def actual_spending
category.month_total(date: budget.start_date)
budget.budget_category_actual_spending(self)
end
def avg_monthly_expense
budget.category_avg_monthly_expense(category)
end
def median_monthly_expense
budget.category_median_monthly_expense(category)
end
def subcategory?
category.parent_id.present?
end
def available_to_spend

View file

@ -1,29 +0,0 @@
class BudgetingStats
attr_reader :family
def initialize(family)
@family = family
end
def avg_monthly_income
income_expense_totals_query(Account::Entry.incomes)
end
def avg_monthly_expenses
income_expense_totals_query(Account::Entry.expenses)
end
private
def income_expense_totals_query(type_scope)
monthly_totals = family.entries
.merge(type_scope)
.select("SUM(account_entries.amount) as total")
.group(Arel.sql("date_trunc('month', account_entries.date)"))
result = Family.select("AVG(mt.total)")
.from(monthly_totals, :mt)
.pick("AVG(mt.total)")
result&.round(2)
end
end

View file

@ -108,30 +108,6 @@ class Category < ApplicationRecord
parent.present?
end
def avg_monthly_total
family.category_stats.avg_monthly_total_for(self)
end
def median_monthly_total
family.category_stats.median_monthly_total_for(self)
end
def month_total(date: Date.current)
family.category_stats.month_total_for(self, date: date)
end
def avg_monthly_total_money
Money.new(avg_monthly_total, family.currency)
end
def median_monthly_total_money
Money.new(median_monthly_total, family.currency)
end
def month_total_money(date: Date.current)
Money.new(month_total(date: date), family.currency)
end
private
def category_level_limit
if (subcategory? && parent.subcategory?) || (parent? && subcategory?)
@ -144,4 +120,8 @@ class Category < ApplicationRecord
errors.add(:parent, "must have the same classification as its parent")
end
end
def monetizable_currency
family.currency
end
end

View file

@ -1,179 +0,0 @@
class CategoryStats
attr_reader :family
def initialize(family)
@family = family
end
def avg_monthly_total_for(category)
statistics_data[category.id]&.avg || 0
end
def median_monthly_total_for(category)
statistics_data[category.id]&.median || 0
end
def month_total_for(category, date: Date.current)
monthly_totals = totals_data[category.id]
category_total = monthly_totals&.find { |mt| mt.month == date.month && mt.year == date.year }
category_total&.amount || 0
end
def month_category_totals(date: Date.current)
by_classification = Hash.new { |h, k| h[k] = {} }
totals_data.each_with_object(by_classification) do |(category_id, totals), result|
totals.each do |t|
next unless t.month == date.month && t.year == date.year
result[t.classification][category_id] ||= { amount: 0, subcategory: t.subcategory? }
result[t.classification][category_id][:amount] += t.amount.abs
end
end
# Calculate percentages for each group
category_totals = []
[ "income", "expense" ].each do |classification|
totals = by_classification[classification]
# Only include non-subcategory amounts in the total for percentage calculations
total_amount = totals.sum do |_, data|
data[:subcategory] ? 0 : data[:amount]
end
next if total_amount.zero?
totals.each do |category_id, data|
percentage = (data[:amount].to_f / total_amount * 100).round(1)
category_totals << CategoryTotal.new(
category_id: category_id,
amount: data[:amount],
percentage: percentage,
classification: classification,
currency: family.currency,
subcategory?: data[:subcategory]
)
end
end
# Calculate totals based on non-subcategory amounts only
total_income = category_totals
.select { |ct| ct.classification == "income" && !ct.subcategory? }
.sum(&:amount)
total_expense = category_totals
.select { |ct| ct.classification == "expense" && !ct.subcategory? }
.sum(&:amount)
CategoryTotals.new(
total_income: total_income,
total_expense: total_expense,
category_totals: category_totals
)
end
private
Totals = Struct.new(:month, :year, :amount, :classification, :currency, :subcategory?, keyword_init: true)
Stats = Struct.new(:avg, :median, :currency, keyword_init: true)
CategoryTotals = Struct.new(:total_income, :total_expense, :category_totals, keyword_init: true)
CategoryTotal = Struct.new(:category_id, :amount, :percentage, :classification, :currency, :subcategory?, keyword_init: true)
def statistics_data
@statistics_data ||= begin
stats = totals_data.each_with_object({ nil => Stats.new(avg: 0, median: 0) }) do |(category_id, totals), hash|
next if totals.empty?
amounts = totals.map(&:amount)
hash[category_id] = Stats.new(
avg: (amounts.sum.to_f / amounts.size).round,
median: calculate_median(amounts),
currency: family.currency
)
end
end
end
def totals_data
@totals_data ||= begin
totals = monthly_totals_query.each_with_object({ nil => [] }) do |row, hash|
hash[row.category_id] ||= []
existing_total = hash[row.category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
if existing_total
existing_total.amount += row.total.to_i
else
hash[row.category_id] << Totals.new(
month: row.date.month,
year: row.date.year,
amount: row.total.to_i,
classification: row.classification,
currency: family.currency,
subcategory?: row.parent_category_id.present?
)
end
# If category is a parent, its total includes its own transactions + sum(child category transactions)
if row.parent_category_id
hash[row.parent_category_id] ||= []
existing_parent_total = hash[row.parent_category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
if existing_parent_total
existing_parent_total.amount += row.total.to_i
else
hash[row.parent_category_id] << Totals.new(
month: row.date.month,
year: row.date.year,
amount: row.total.to_i,
classification: row.classification,
currency: family.currency,
subcategory?: false
)
end
end
end
# Ensure we have a default empty array for nil category, which represents "Uncategorized"
totals[nil] ||= []
totals
end
end
def monthly_totals_query
income_expense_classification = Arel.sql("
CASE WHEN categories.id IS NULL THEN
CASE WHEN account_entries.amount < 0 THEN 'income' ELSE 'expense' END
ELSE categories.classification
END
")
family.entries
.incomes_and_expenses
.select(
"categories.id as category_id",
"categories.parent_id as parent_category_id",
income_expense_classification,
"date_trunc('month', account_entries.date) as date",
"SUM(account_entries.amount) as total"
)
.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
.group(Arel.sql("categories.id, categories.parent_id, #{income_expense_classification}, date_trunc('month', account_entries.date)"))
.order(Arel.sql("date_trunc('month', account_entries.date) DESC"))
end
def calculate_median(numbers)
return 0 if numbers.empty?
sorted = numbers.sort
mid = sorted.size / 2
if sorted.size.odd?
sorted[mid]
else
((sorted[mid-1] + sorted[mid]) / 2.0).round
end
end
end

View file

@ -1,23 +1,50 @@
module Accountable
extend ActiveSupport::Concern
ASSET_TYPES = %w[Depository Investment Crypto Property Vehicle OtherAsset]
LIABILITY_TYPES = %w[CreditCard Loan OtherLiability]
TYPES = ASSET_TYPES + LIABILITY_TYPES
TYPES = %w[Depository Investment Crypto Property Vehicle OtherAsset CreditCard Loan OtherLiability]
def self.from_type(type)
return nil unless TYPES.include?(type)
type.constantize
end
def self.by_classification
{ assets: ASSET_TYPES, liabilities: LIABILITY_TYPES }
end
included do
has_one :account, as: :accountable, touch: true
end
class_methods do
def classification
raise NotImplementedError, "Accountable must implement #classification"
end
def icon
raise NotImplementedError, "Accountable must implement #icon"
end
def color
raise NotImplementedError, "Accountable must implement #color"
end
def favorable_direction
classification == "asset" ? "up" : "down"
end
def display_name
self.name.pluralize.titleize
end
def balance_money(family)
family.accounts
.active
.joins(sanitize_sql_array([
"LEFT JOIN exchange_rates ON exchange_rates.date = :current_date AND accounts.currency = exchange_rates.from_currency AND exchange_rates.to_currency = :family_currency",
{ current_date: Date.current.to_s, family_currency: family.currency }
]))
.where(accountable_type: self.name)
.sum("accounts.balance * COALESCE(exchange_rates.rate, 1)")
end
end
def post_sync
broadcast_replace_to(
account,
@ -26,4 +53,20 @@ module Accountable
locals: { account: account }
)
end
def display_name
self.class.display_name
end
def icon
self.class.icon
end
def color
self.class.color
end
def classification
self.class.classification
end
end

View file

@ -4,11 +4,19 @@ module Monetizable
class_methods do
def monetize(*fields)
fields.each do |field|
define_method("#{field}_money") do
value = self.send(field)
value.nil? ? nil : Money.new(value, currency || Money.default_currency)
define_method("#{field}_money") do |**args|
value = self.send(field, **args)
return nil if value.nil? || monetizable_currency.nil?
Money.new(value, monetizable_currency)
end
end
end
end
private
def monetizable_currency
currency
end
end

View file

@ -1,6 +1,20 @@
class CreditCard < ApplicationRecord
include Accountable
class << self
def color
"#F13636"
end
def icon
"credit-card"
end
def classification
"liability"
end
end
def available_credit_money
available_credit ? Money.new(available_credit, account.currency) : nil
end
@ -12,12 +26,4 @@ class CreditCard < ApplicationRecord
def annual_fee_money
annual_fee ? Money.new(annual_fee, account.currency) : nil
end
def color
"#F13636"
end
def icon
"credit-card"
end
end

View file

@ -1,11 +1,21 @@
class Crypto < ApplicationRecord
include Accountable
def color
"#737373"
end
class << self
def color
"#737373"
end
def icon
"bitcoin"
def classification
"asset"
end
def icon
"bitcoin"
end
def display_name
"Crypto"
end
end
end

View file

@ -61,6 +61,34 @@ class Demo::Generator
puts "Demo data loaded successfully!"
end
def generate_multi_currency_data!
puts "Clearing existing data..."
destroy_everything!
puts "Data cleared"
create_family_and_user!("Demo Family 1", "user@maybe.local", currency: "EUR")
family = Family.find_by(name: "Demo Family 1")
puts "Users reset"
usd_checking = family.accounts.create!(name: "USD Checking", currency: "USD", balance: 10000, accountable: Depository.new)
eur_checking = family.accounts.create!(name: "EUR Checking", currency: "EUR", balance: 4900, accountable: Depository.new)
puts "Accounts created"
create_transaction!(account: usd_checking, amount: -11000, currency: "USD", name: "USD income Transaction")
create_transaction!(account: usd_checking, amount: 1000, currency: "USD", name: "USD expense Transaction")
create_transaction!(account: eur_checking, amount: -5000, currency: "EUR", name: "EUR income Transaction")
create_transaction!(account: eur_checking, amount: 100, currency: "EUR", name: "EUR expense Transaction")
puts "Transactions created"
puts "Demo data loaded successfully!"
end
private
def destroy_everything!
Family.destroy_all
@ -71,13 +99,14 @@ class Demo::Generator
Security::Price.destroy_all
end
def create_family_and_user!(family_name, user_email, data_enrichment_enabled: false)
def create_family_and_user!(family_name, user_email, data_enrichment_enabled: false, currency: "USD")
base_uuid = "d99e3c6e-d513-4452-8f24-dc263f8528c0"
id = Digest::UUID.uuid_v5(base_uuid, family_name)
family = Family.create!(
id: id,
name: family_name,
currency: currency,
stripe_subscription_status: "active",
data_enrichment_enabled: data_enrichment_enabled,
locale: "en",
@ -161,19 +190,21 @@ class Demo::Generator
balance: 15000,
currency: "USD"
# First create income transactions to ensure positive balance
50.times do
create_transaction! \
account: checking,
amount: Faker::Number.negative(from: -2000, to: -500),
name: "Income",
category: family.categories.find_by(name: "Income")
end
# Then create expenses that won't exceed the income
200.times do
create_transaction! \
account: checking,
name: "Expense",
amount: Faker::Number.positive(from: 100, to: 1000)
end
50.times do
create_transaction! \
account: checking,
amount: Faker::Number.negative(from: -2000),
name: "Income",
category: family.categories.find_by(name: "Income")
amount: Faker::Number.positive(from: 8, to: 500)
end
end
@ -185,14 +216,23 @@ class Demo::Generator
currency: "USD",
subtype: "savings"
# Create larger income deposits first
100.times do
create_transaction! \
account: savings,
amount: Faker::Number.negative(from: -2000),
amount: Faker::Number.negative(from: -3000, to: -1000),
tags: [ family.tags.find_by(name: "Emergency Fund") ],
category: family.categories.find_by(name: "Income"),
name: "Income"
end
# Add some smaller withdrawals that won't exceed the deposits
50.times do
create_transaction! \
account: savings,
amount: Faker::Number.positive(from: 100, to: 1000),
name: "Savings Withdrawal"
end
end
def create_transfer_transactions!(family)
@ -304,39 +344,50 @@ class Demo::Generator
create_valuation!(house, 2.years.ago.to_date, 540000)
create_valuation!(house, 1.years.ago.to_date, 550000)
family.accounts.create! \
mortgage = family.accounts.create! \
accountable: Loan.new,
name: "Mortgage",
balance: 495000,
currency: "USD"
create_valuation!(mortgage, 3.years.ago.to_date, 495000)
create_valuation!(mortgage, 2.years.ago.to_date, 490000)
create_valuation!(mortgage, 1.years.ago.to_date, 485000)
end
def create_car_and_loan!(family)
family.accounts.create! \
vehicle = family.accounts.create! \
accountable: Vehicle.new,
name: "Honda Accord",
balance: 18000,
currency: "USD"
family.accounts.create! \
create_valuation!(vehicle, 1.year.ago.to_date, 18000)
loan = family.accounts.create! \
accountable: Loan.new,
name: "Car Loan",
balance: 8000,
currency: "USD"
create_valuation!(loan, 1.year.ago.to_date, 8000)
end
def create_other_accounts!(family)
family.accounts.create! \
other_asset = family.accounts.create! \
accountable: OtherAsset.new,
name: "Other Asset",
balance: 10000,
currency: "USD"
family.accounts.create! \
other_liability = family.accounts.create! \
accountable: OtherLiability.new,
name: "Other Liability",
balance: 5000,
currency: "USD"
create_valuation!(other_asset, 1.year.ago.to_date, 10000)
create_valuation!(other_liability, 1.year.ago.to_date, 5000)
end
def create_transaction!(attributes = {})

View file

@ -6,11 +6,21 @@ class Depository < ApplicationRecord
[ "Savings", "savings" ]
].freeze
def color
"#875BF7"
end
class << self
def display_name
"Cash"
end
def icon
"landmark"
def color
"#875BF7"
end
def classification
"asset"
end
def icon
"landmark"
end
end
end

View file

@ -1,5 +1,5 @@
class Family < ApplicationRecord
include Plaidable, Syncable
include Providable, Plaidable, Syncable, AutoTransferMatchable
DATE_FORMATS = [
[ "MM-DD-YYYY", "%m-%d-%Y" ],
@ -13,26 +13,37 @@ class Family < ApplicationRecord
[ "YYYY.MM.DD", "%Y.%m.%d" ]
].freeze
include Providable
has_many :users, dependent: :destroy
has_many :invitations, dependent: :destroy
has_many :tags, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :plaid_items, dependent: :destroy
has_many :invitations, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :transactions, through: :accounts
has_many :issues, through: :accounts
has_many :entries, through: :accounts
has_many :transactions, through: :accounts
has_many :trades, through: :accounts
has_many :holdings, through: :accounts
has_many :tags, dependent: :destroy
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy
has_many :issues, through: :accounts
has_many :holdings, through: :accounts
has_many :plaid_items, dependent: :destroy
has_many :budgets, dependent: :destroy
has_many :budget_categories, through: :budgets
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
def balance_sheet
@balance_sheet ||= BalanceSheet.new(self)
end
def income_statement
@income_statement ||= IncomeStatement.new(self)
end
def sync_data(start_date: nil)
update!(last_synced_at: Time.current)
@ -81,120 +92,6 @@ class Family < ApplicationRecord
).link_token
end
def income_categories_with_totals(date: Date.current)
categories_with_stats(classification: "income", date: date)
end
def expense_categories_with_totals(date: Date.current)
categories_with_stats(classification: "expense", date: date)
end
def category_stats
CategoryStats.new(self)
end
def budgeting_stats
BudgetingStats.new(self)
end
def snapshot(period = Period.all)
query = accounts.active.joins(:balances)
.where("account_balances.currency = ?", self.currency)
.select(
"account_balances.currency",
"account_balances.date",
"SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities",
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets",
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth",
)
.group("account_balances.date, account_balances.currency")
.order("account_balances.date")
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
{
asset_series: TimeSeries.new(result.map { |r| { date: r.date, value: Money.new(r.assets, r.currency) } }),
liability_series: TimeSeries.new(result.map { |r| { date: r.date, value: Money.new(r.liabilities, r.currency) } }, favorable_direction: "down"),
net_worth_series: TimeSeries.new(result.map { |r| { date: r.date, value: Money.new(r.net_worth, r.currency) } })
}
end
def snapshot_account_transactions
period = Period.last_30_days
results = accounts.active
.joins(:entries)
.joins("LEFT JOIN transfers ON (transfers.inflow_transaction_id = account_entries.entryable_id OR transfers.outflow_transaction_id = account_entries.entryable_id)")
.select(
"accounts.*",
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
"COALESCE(SUM(-account_entries.amount) FILTER (WHERE account_entries.amount < 0), 0) AS income"
)
.where("account_entries.date >= ?", period.date_range.begin)
.where("account_entries.date <= ?", period.date_range.end)
.where("account_entries.entryable_type = 'Account::Transaction'")
.where("transfers.id IS NULL")
.group("accounts.id")
.having("SUM(ABS(account_entries.amount)) > 0")
.to_a
results.each do |r|
r.define_singleton_method(:savings_rate) do
(income - spending) / income
end
end
{
top_spenders: results.sort_by(&:spending).select { |a| a.spending > 0 }.reverse,
top_earners: results.sort_by(&:income).select { |a| a.income > 0 }.reverse,
top_savers: results.sort_by { |a| a.savings_rate }.reverse
}
end
def snapshot_transactions
candidate_entries = entries.account_transactions.incomes_and_expenses
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
spending = []
income = []
savings = []
rolling_totals.each do |r|
spending << {
date: r.date,
value: Money.new(r.rolling_spend, self.currency)
}
income << {
date: r.date,
value: Money.new(r.rolling_income, self.currency)
}
savings << {
date: r.date,
value: r.rolling_income != 0 ? ((r.rolling_income - r.rolling_spend) / r.rolling_income) : 0.to_d
}
end
{
income_series: TimeSeries.new(income, favorable_direction: "up"),
spending_series: TimeSeries.new(spending, favorable_direction: "down"),
savings_rate_series: TimeSeries.new(savings, favorable_direction: "up")
}
end
def net_worth
assets - liabilities
end
def assets
Money.new(accounts.active.assets.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
end
def liabilities
Money.new(accounts.active.liabilities.map { |account| account.balance_money.exchange_to(currency, fallback_rate: 0) }.sum, currency)
end
def synth_usage
self.class.synth_provider&.usage
end
@ -223,36 +120,13 @@ class Family < ApplicationRecord
accounts.active.count
end
private
CategoriesWithTotals = Struct.new(:total_money, :category_totals, keyword_init: true)
CategoryWithStats = Struct.new(:category, :amount_money, :percentage, keyword_init: true)
def categories_with_stats(classification:, date: Date.current)
totals = category_stats.month_category_totals(date: date)
classified_totals = totals.category_totals.select { |t| t.classification == classification }
if classification == "income"
total = totals.total_income
categories_scope = categories.incomes
else
total = totals.total_expense
categories_scope = categories.expenses
end
categories_with_uncategorized = categories_scope + [ categories_scope.uncategorized ]
CategoriesWithTotals.new(
total_money: Money.new(total, currency),
category_totals: categories_with_uncategorized.map do |category|
ct = classified_totals.find { |ct| ct.category_id == category&.id }
CategoryWithStats.new(
category: category,
amount_money: Money.new(ct&.amount || 0, currency),
percentage: ct&.percentage || 0
)
end
)
end
# Cache key that is invalidated when any of the family's entries are updated (which affect rollups and other calculations)
def build_cache_key(key)
[
"family",
id,
key,
entries.maximum(:updated_at)
].compact.join("_")
end
end

View file

@ -0,0 +1,61 @@
module Family::AutoTransferMatchable
def transfer_match_candidates
Account::Entry.select([
"inflow_candidates.entryable_id as inflow_transaction_id",
"outflow_candidates.entryable_id as outflow_transaction_id",
"ABS(inflow_candidates.date - outflow_candidates.date) as date_diff"
]).from("account_entries inflow_candidates")
.joins("
JOIN account_entries outflow_candidates ON (
inflow_candidates.amount < 0 AND
outflow_candidates.amount > 0 AND
inflow_candidates.amount = -outflow_candidates.amount AND
inflow_candidates.currency = outflow_candidates.currency AND
inflow_candidates.account_id <> outflow_candidates.account_id AND
inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4
)
").joins("
LEFT JOIN transfers existing_transfers ON (
existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id OR
existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id
)
")
.joins("LEFT JOIN rejected_transfers ON (
rejected_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND
rejected_transfers.outflow_transaction_id = outflow_candidates.entryable_id
)")
.joins("JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_candidates.account_id")
.joins("JOIN accounts outflow_accounts ON outflow_accounts.id = outflow_candidates.account_id")
.where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.id, self.id)
.where("inflow_accounts.is_active = true")
.where("outflow_accounts.is_active = true")
.where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'")
.where(existing_transfers: { id: nil })
.order("date_diff ASC") # Closest matches first
end
def auto_match_transfers!
# Exclude already matched transfers
candidates_scope = transfer_match_candidates.where(rejected_transfers: { id: nil })
# Track which transactions we've already matched to avoid duplicates
used_transaction_ids = Set.new
candidates = []
Transfer.transaction do
candidates_scope.each do |match|
next if used_transaction_ids.include?(match.inflow_transaction_id) ||
used_transaction_ids.include?(match.outflow_transaction_id)
Transfer.create!(
inflow_transaction_id: match.inflow_transaction_id,
outflow_transaction_id: match.outflow_transaction_id,
)
used_transaction_ids << match.inflow_transaction_id
used_transaction_ids << match.outflow_transaction_id
end
end
end
end

View file

@ -0,0 +1,118 @@
class IncomeStatement
include Monetizable
monetize :median_expense, :median_income
attr_reader :family
def initialize(family)
@family = family
end
def totals(transactions_scope: nil)
transactions_scope ||= family.transactions.active
result = totals_query(transactions_scope: transactions_scope)
total_income = result.select { |t| t.classification == "income" }.sum(&:total)
total_expense = result.select { |t| t.classification == "expense" }.sum(&:total)
ScopeTotals.new(
transactions_count: transactions_scope.count,
income_money: Money.new(total_income, family.currency),
expense_money: Money.new(total_expense, family.currency),
missing_exchange_rates?: result.any?(&:missing_exchange_rates?)
)
end
def expense_totals(period: Period.current_month)
build_period_total(classification: "expense", period: period)
end
def income_totals(period: Period.current_month)
build_period_total(classification: "income", period: period)
end
def median_expense(interval: "month", category: nil)
if category.present?
category_stats(interval: interval).find { |stat| stat.classification == "expense" && stat.category_id == category.id }&.median || 0
else
family_stats(interval: interval).find { |stat| stat.classification == "expense" }&.median || 0
end
end
def avg_expense(interval: "month", category: nil)
if category.present?
category_stats(interval: interval).find { |stat| stat.classification == "expense" && stat.category_id == category.id }&.avg || 0
else
family_stats(interval: interval).find { |stat| stat.classification == "expense" }&.avg || 0
end
end
def median_income(interval: "month")
family_stats(interval: interval).find { |stat| stat.classification == "income" }&.median || 0
end
private
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money, :missing_exchange_rates?)
PeriodTotal = Data.define(:classification, :total, :currency, :missing_exchange_rates?, :category_totals)
CategoryTotal = Data.define(:category, :total, :currency, :weight)
def categories
@categories ||= family.categories.all.to_a
end
def build_period_total(classification:, period:)
totals = totals_query(transactions_scope: family.transactions.active.in_period(period)).select { |t| t.classification == classification }
classification_total = totals.sum(&:total)
category_totals = totals.map do |ct|
# If parent category is nil, it's a top-level category. This means we need to
# sum itself + SUM(children) to get the overall category total
children_totals = if ct.parent_category_id.nil? && ct.category_id.present?
totals.select { |t| t.parent_category_id == ct.category_id }.sum(&:total)
else
0
end
category_total = ct.total + children_totals
weight = (category_total.zero? ? 0 : category_total.to_f / classification_total) * 100
CategoryTotal.new(
category: categories.find { |c| c.id == ct.category_id } || family.categories.uncategorized,
total: category_total,
currency: family.currency,
weight: weight,
)
end
PeriodTotal.new(
classification: classification,
total: category_totals.reject { |ct| ct.category.subcategory? }.sum(&:total),
currency: family.currency,
missing_exchange_rates?: totals.any?(&:missing_exchange_rates?),
category_totals: category_totals
)
end
def family_stats(interval: "month")
@family_stats ||= {}
@family_stats[interval] ||= FamilyStats.new(family, interval:).call
end
def category_stats(interval: "month")
@category_stats ||= {}
@category_stats[interval] ||= CategoryStats.new(family, interval:).call
end
def totals_query(transactions_scope:)
@totals_query_cache ||= {}
cache_key = Digest::MD5.hexdigest(transactions_scope.to_sql)
@totals_query_cache[cache_key] ||= Totals.new(family, transactions_scope: transactions_scope).call
end
def monetizable_currency
family.currency
end
end

View file

@ -0,0 +1,42 @@
module IncomeStatement::BaseQuery
private
def base_query_sql(family:, interval:, transactions_scope:)
sql = <<~SQL
SELECT
c.id as category_id,
c.parent_id as parent_category_id,
date_trunc(:interval, ae.date) as date,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
SUM(ae.amount * COALESCE(er.rate, 1)) as total,
BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates
FROM (#{transactions_scope.to_sql}) at
JOIN account_entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Account::Transaction'
LEFT JOIN categories c ON c.id = at.category_id
LEFT JOIN (
SELECT t.*, t.id as transfer_id, a.accountable_type
FROM transfers t
JOIN account_entries ae ON ae.entryable_id = t.inflow_transaction_id
AND ae.entryable_type = 'Account::Transaction'
JOIN accounts a ON a.id = ae.account_id
) transfer_info ON (
transfer_info.inflow_transaction_id = at.id OR
transfer_info.outflow_transaction_id = at.id
)
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE (
transfer_info.transfer_id IS NULL OR
(ae.amount < 0 AND transfer_info.accountable_type = 'Loan')
)
GROUP BY 1, 2, 3, 4
SQL
ActiveRecord::Base.sanitize_sql_array([
sql,
{ target_currency: family.currency, interval: interval }
])
end
end

View file

@ -0,0 +1,41 @@
class IncomeStatement::CategoryStats
include IncomeStatement::BaseQuery
def initialize(family, interval: "month")
@family = family
@interval = interval
end
def call
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
StatRow.new(
category_id: row["category_id"],
classification: row["classification"],
median: row["median"],
avg: row["avg"],
missing_exchange_rates?: row["missing_exchange_rates"]
)
end
end
private
StatRow = Data.define(:category_id, :classification, :median, :avg, :missing_exchange_rates?)
def query_sql
base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
<<~SQL
WITH base_totals AS (
#{base_sql}
)
SELECT
category_id,
classification,
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
ABS(AVG(total)) as avg,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
FROM base_totals
GROUP BY category_id, classification;
SQL
end
end

View file

@ -0,0 +1,47 @@
class IncomeStatement::FamilyStats
include IncomeStatement::BaseQuery
def initialize(family, interval: "month")
@family = family
@interval = interval
end
def call
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
StatRow.new(
classification: row["classification"],
median: row["median"],
avg: row["avg"],
missing_exchange_rates?: row["missing_exchange_rates"]
)
end
end
private
StatRow = Data.define(:classification, :median, :avg, :missing_exchange_rates?)
def query_sql
base_sql = base_query_sql(family: @family, interval: @interval, transactions_scope: @family.transactions.active)
<<~SQL
WITH base_totals AS (
#{base_sql}
), aggregated_totals AS (
SELECT
date,
classification,
SUM(total) as total,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
FROM base_totals
GROUP BY date, classification
)
SELECT
classification,
ABS(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total)) as median,
ABS(AVG(total)) as avg,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
FROM aggregated_totals
GROUP BY classification;
SQL
end
end

View file

@ -0,0 +1,41 @@
class IncomeStatement::Totals
include IncomeStatement::BaseQuery
def initialize(family, transactions_scope:)
@family = family
@transactions_scope = transactions_scope
end
def call
ActiveRecord::Base.connection.select_all(query_sql).map do |row|
TotalsRow.new(
parent_category_id: row["parent_category_id"],
category_id: row["category_id"],
classification: row["classification"],
total: row["total"],
missing_exchange_rates?: row["missing_exchange_rates"]
)
end
end
private
TotalsRow = Data.define(:parent_category_id, :category_id, :classification, :total, :missing_exchange_rates?)
def query_sql
base_sql = base_query_sql(family: @family, interval: "day", transactions_scope: @transactions_scope)
<<~SQL
WITH base_totals AS (
#{base_sql}
)
SELECT
parent_category_id,
category_id,
classification,
ABS(SUM(total)) as total,
BOOL_OR(missing_exchange_rates) as missing_exchange_rates
FROM base_totals
GROUP BY 1, 2, 3;
SQL
end
end

View file

@ -16,11 +16,17 @@ class Investment < ApplicationRecord
[ "Angel", "angel" ]
].freeze
def color
"#1570EF"
end
class << self
def color
"#1570EF"
end
def icon
"line-chart"
def classification
"asset"
end
def icon
"line-chart"
end
end
end

View file

@ -17,11 +17,17 @@ class Loan < ApplicationRecord
Money.new(payment.round, account.currency)
end
def color
"#D444F1"
end
class << self
def color
"#D444F1"
end
def icon
"hand-coins"
def icon
"hand-coins"
end
def classification
"liability"
end
end
end

View file

@ -1,11 +1,17 @@
class OtherAsset < ApplicationRecord
include Accountable
def color
"#12B76A"
end
class << self
def color
"#12B76A"
end
def icon
"plus"
def icon
"plus"
end
def classification
"asset"
end
end
end

View file

@ -1,11 +1,17 @@
class OtherLiability < ApplicationRecord
include Accountable
def color
"#737373"
end
class << self
def color
"#737373"
end
def icon
"minus"
def icon
"minus"
end
def classification
"liability"
end
end
end

View file

@ -1,46 +1,167 @@
class Period
attr_reader :name, :date_range
include ActiveModel::Validations, Comparable
attr_reader :start_date, :end_date
validates :start_date, :end_date, presence: true
validate :must_be_valid_date_range
PERIODS = {
"last_day" => {
date_range: [ 1.day.ago.to_date, Date.current ],
label_short: "1D",
label: "Last Day",
comparison_label: "vs. yesterday"
},
"current_week" => {
date_range: [ Date.current.beginning_of_week, Date.current ],
label_short: "WTD",
label: "Current Week",
comparison_label: "vs. start of week"
},
"last_7_days" => {
date_range: [ 7.days.ago.to_date, Date.current ],
label_short: "7D",
label: "Last 7 Days",
comparison_label: "vs. last week"
},
"current_month" => {
date_range: [ Date.current.beginning_of_month, Date.current ],
label_short: "MTD",
label: "Current Month",
comparison_label: "vs. start of month"
},
"last_30_days" => {
date_range: [ 30.days.ago.to_date, Date.current ],
label_short: "30D",
label: "Last 30 Days",
comparison_label: "vs. last month"
},
"last_90_days" => {
date_range: [ 90.days.ago.to_date, Date.current ],
label_short: "90D",
label: "Last 90 Days",
comparison_label: "vs. last quarter"
},
"current_year" => {
date_range: [ Date.current.beginning_of_year, Date.current ],
label_short: "YTD",
label: "Current Year",
comparison_label: "vs. start of year"
},
"last_365_days" => {
date_range: [ 365.days.ago.to_date, Date.current ],
label_short: "365D",
label: "Last 365 Days",
comparison_label: "vs. 1 year ago"
},
"last_5_years" => {
date_range: [ 5.years.ago.to_date, Date.current ],
label_short: "5Y",
label: "Last 5 Years",
comparison_label: "vs. 5 years ago"
}
}
class << self
def from_param(param)
find_by_name(param) || self.last_30_days
def default
from_key("last_30_days")
end
def find_by_name(name)
INDEX[name]
def from_key(key, fallback: false)
if PERIODS[key].present?
start_date, end_date = PERIODS[key].fetch(:date_range)
new(start_date: start_date, end_date: end_date)
else
return default if fallback
raise ArgumentError, "Invalid period key: #{key}"
end
end
def names
INDEX.keys.sort
def all
PERIODS.map { |key, period| from_key(key) }
end
end
def initialize(name: "custom", date_range:)
@name = name
@date_range = date_range
end
def extend_backward(duration)
Period.new(name: name + "_extended", date_range: (date_range.first - duration)..date_range.last)
end
BUILTIN = [
new(name: "all", date_range: nil..Date.current),
new(name: "current_week", date_range: Date.current.beginning_of_week..Date.current),
new(name: "last_7_days", date_range: 7.days.ago.to_date..Date.current),
new(name: "current_month", date_range: Date.current.beginning_of_month..Date.current),
new(name: "last_30_days", date_range: 30.days.ago.to_date..Date.current),
new(name: "current_quarter", date_range: Date.current.beginning_of_quarter..Date.current),
new(name: "last_90_days", date_range: 90.days.ago.to_date..Date.current),
new(name: "current_year", date_range: Date.current.beginning_of_year..Date.current),
new(name: "last_365_days", date_range: 365.days.ago.to_date..Date.current)
]
INDEX = BUILTIN.index_by(&:name)
BUILTIN.each do |period|
define_singleton_method(period.name) do
period
PERIODS.each do |key, period|
define_singleton_method(key) do
start_date, end_date = period.fetch(:date_range)
new(start_date: start_date, end_date: end_date)
end
end
def initialize(start_date:, end_date:, date_format: "%b %d, %Y")
@start_date = start_date
@end_date = end_date
@date_format = date_format
validate!
end
def <=>(other)
[ start_date, end_date ] <=> [ other.start_date, other.end_date ]
end
def date_range
start_date..end_date
end
def days
(end_date - start_date).to_i + 1
end
def within?(other)
start_date >= other.start_date && end_date <= other.end_date
end
def interval
if days > 90
"1 month"
else
"1 day"
end
end
def key
PERIODS.find { |_, period| period.fetch(:date_range) == [ start_date, end_date ] }&.first
end
def label
if known?
PERIODS[key].fetch(:label)
else
"Custom Period"
end
end
def label_short
if known?
PERIODS[key].fetch(:label_short)
else
"CP"
end
end
def comparison_label
if known?
PERIODS[key].fetch(:comparison_label)
else
"#{start_date.strftime(@date_format)} to #{end_date.strftime(@date_format)}"
end
end
private
def known?
key.present?
end
def must_be_valid_date_range
return if start_date.nil? || end_date.nil?
unless start_date.is_a?(Date) && end_date.is_a?(Date)
errors.add(:start_date, "must be a valid date")
errors.add(:end_date, "must be a valid date")
return
end
errors.add(:start_date, "must be before end date") if start_date > end_date
end
end

View file

@ -115,12 +115,6 @@ class PlaidAccount < ApplicationRecord
plaid_item.family
end
def transfer?(plaid_txn)
transfer_categories = [ "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS" ]
transfer_categories.include?(plaid_txn.personal_finance_category.primary)
end
def create_initial_loan_balance(loan_data)
if loan_data.origination_principal_amount.present? && loan_data.origination_date.present?
account.entries.find_or_create_by!(plaid_id: loan_data.account_id) do |e|

View file

@ -16,6 +16,20 @@ class Property < ApplicationRecord
attribute :area_unit, :string, default: "sqft"
class << self
def icon
"home"
end
def color
"#06AED4"
end
def classification
"asset"
end
end
def area
Measurement.new(area_value, area_unit) if area_value.present?
end
@ -25,15 +39,7 @@ class Property < ApplicationRecord
end
def trend
TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
end
def color
"#06AED4"
end
def icon
"home"
Trend.new(current: account.balance_money, previous: first_valuation_amount)
end
private

57
app/models/series.rb Normal file
View file

@ -0,0 +1,57 @@
class Series
attr_reader :start_date, :end_date, :interval, :trend, :values
Value = Struct.new(
:date,
:date_formatted,
:trend,
keyword_init: true
)
class << self
def from_raw_values(values, interval: "1 day")
raise ArgumentError, "Must be an array of at least 2 values" unless values.size >= 2
raise ArgumentError, "Must have date and value properties" unless values.all? { |value| value.has_key?(:date) && value.has_key?(:value) }
ordered = values.sort_by { |value| value[:date] }
start_date = ordered.first[:date]
end_date = ordered.last[:date]
new(
start_date: start_date,
end_date: end_date,
interval: interval,
trend: Trend.new(
current: ordered.last[:value],
previous: ordered.first[:value]
),
values: [ nil, *ordered ].each_cons(2).map do |prev_value, curr_value|
Value.new(
date: curr_value[:date],
date_formatted: I18n.l(curr_value[:date], format: :long),
trend: Trend.new(
current: curr_value[:value],
previous: prev_value&.[](:value)
)
)
end
)
end
end
def initialize(start_date:, end_date:, interval:, trend:, values:)
@start_date = start_date
@end_date = end_date
@interval = interval
@trend = trend
@values = values
end
def current
values.last.trend.current
end
def any?
values.any?
end
end

View file

@ -1,65 +0,0 @@
class TimeSeries
DIRECTIONS = %w[up down].freeze
attr_reader :values, :favorable_direction
def self.from_collection(collection, value_method, favorable_direction: "up")
collection.map do |obj|
{
date: obj.date,
value: obj.public_send(value_method),
original: obj
}
end.then { |data| new(data, favorable_direction: favorable_direction) }
end
def initialize(data, favorable_direction: "up")
@favorable_direction = (favorable_direction.presence_in(DIRECTIONS) || "up").inquiry
@values = initialize_values data.sort_by { |d| d[:date] }
end
def first
values.first
end
def last
values.last
end
def on(date)
values.find { |v| v.date == date }
end
def trend
TimeSeries::Trend.new \
current: last&.value,
previous: first&.value,
series: self
end
def empty?
values.empty?
end
def has_current_day_value?
values.any? { |v| v.date == Date.current }
end
# `as_json` returns the data shape used by D3 charts
def as_json
{
values: values.map(&:as_json),
trend: trend.as_json,
favorable_direction: favorable_direction
}.as_json
end
private
def initialize_values(data)
[ nil, *data ].each_cons(2).map do |previous, current|
TimeSeries::Value.new **current,
previous_value: previous.try(:[], :value),
series: self
end
end
end

View file

@ -1,131 +0,0 @@
class TimeSeries::Trend
include ActiveModel::Validations
attr_reader :current, :previous, :favorable_direction
validate :values_must_be_of_same_type, :values_must_be_of_known_type
def initialize(current:, previous:, series: nil, favorable_direction: nil)
@current = current
@previous = previous
@series = series
@favorable_direction = get_favorable_direction(favorable_direction)
validate!
end
def direction
if previous.nil? || current == previous
"flat"
elsif current && current > previous
"up"
else
"down"
end.inquiry
end
def color
case direction
when "up"
favorable_direction.down? ? red_hex : green_hex
when "down"
favorable_direction.down? ? green_hex : red_hex
else
gray_hex
end
end
def icon
if direction.flat?
"minus"
elsif direction.up?
"arrow-up"
else
"arrow-down"
end
end
def value
if previous.nil?
current.is_a?(Money) ? Money.new(0, current.currency) : 0
else
current - previous
end
end
def percent
if previous.nil? || (previous.zero? && current.zero?)
0.0
elsif previous.zero?
Float::INFINITY
else
change = (current_amount - previous_amount)
base = previous_amount.to_f
(change / base * 100).round(1).to_f
end
end
def as_json
{
favorable_direction: favorable_direction,
direction: direction,
value: value,
percent: percent
}.as_json
end
private
attr_reader :series
def red_hex
"#F13636" # red-500
end
def green_hex
"#10A861" # green-600
end
def gray_hex
"#737373" # gray-500
end
def values_must_be_of_same_type
unless current.class == previous.class || [ previous, current ].any?(&:nil?)
errors.add :current, :must_be_of_the_same_type_as_previous
errors.add :previous, :must_be_of_the_same_type_as_current
end
end
def values_must_be_of_known_type
unless current.is_a?(Money) || current.is_a?(Numeric) || current.nil?
errors.add :current, :must_be_of_type_money_numeric_or_nil
end
unless previous.is_a?(Money) || previous.is_a?(Numeric) || previous.nil?
errors.add :previous, :must_be_of_type_money_numeric_or_nil
end
end
def current_amount
extract_numeric current
end
def previous_amount
extract_numeric previous
end
def extract_numeric(obj)
if obj.is_a? Money
obj.amount
else
obj
end
end
def get_favorable_direction(favorable_direction)
direction = favorable_direction.presence || series&.favorable_direction
(direction.presence_in(TimeSeries::DIRECTIONS) || "up").inquiry
end
end

View file

@ -1,46 +0,0 @@
class TimeSeries::Value
include Comparable
include ActiveModel::Validations
attr_reader :value, :date, :original, :trend
validates :date, presence: true
validate :value_must_be_of_known_type
def initialize(date:, value:, original: nil, series: nil, previous_value: nil)
@date, @value, @original, @series = date, value, original, series
@trend = create_trend previous_value
validate!
end
def <=>(other)
result = date <=> other.date
result = value <=> other.value if result == 0
result
end
def as_json
{
date: date.iso8601,
value: value.as_json,
trend: trend.as_json
}
end
private
attr_reader :series
def create_trend(previous_value)
TimeSeries::Trend.new \
current: value,
previous: previous_value,
series: series
end
def value_must_be_of_known_type
unless value.is_a?(Money) || value.is_a?(Numeric)
errors.add :value, :must_be_a_money_or_numeric
end
end
end

94
app/models/trend.rb Normal file
View file

@ -0,0 +1,94 @@
class Trend
include ActiveModel::Validations
DIRECTIONS = %w[up down].freeze
attr_reader :current, :previous, :favorable_direction
validates :current, presence: true
def initialize(current:, previous:, favorable_direction: nil)
@current = current
@previous = previous || 0
@favorable_direction = (favorable_direction.presence_in(DIRECTIONS) || "up").inquiry
validate!
end
def direction
if current == previous
"flat"
elsif current > previous
"up"
else
"down"
end.inquiry
end
def color
case direction
when "up"
favorable_direction.down? ? red_hex : green_hex
when "down"
favorable_direction.down? ? green_hex : red_hex
else
gray_hex
end
end
def icon
if direction.flat?
"minus"
elsif direction.up?
"arrow-up"
else
"arrow-down"
end
end
def value
current - previous
end
def percent
return 0.0 if previous.zero? && current.zero?
return Float::INFINITY if previous.zero?
change = (current - previous).to_f
(change / previous.to_f * 100).round(1)
end
def percent_formatted
if percent.finite?
"#{percent.round(1)}%"
else
percent > 0 ? "+∞" : "-∞"
end
end
def as_json
{
value: value,
percent: percent,
percent_formatted: percent_formatted,
current: current,
previous: previous,
color: color,
icon: icon
}
end
private
def red_hex
"var(--color-destructive)"
end
def green_hex
"var(--color-success)"
end
def gray_hex
"var(--color-gray)"
end
end

View file

@ -1,104 +0,0 @@
class ValueGroup
attr_accessor :parent, :original
attr_reader :name, :children, :value, :currency
def initialize(name, currency = Money.default_currency)
@name = name
@currency = Money::Currency.new(currency)
@children = []
end
def sum
return value if is_value_node?
return Money.new(0, currency) if children.empty? && value.nil?
children.sum(&:sum)
end
def avg
return value if is_value_node?
return Money.new(0, currency) if children.empty? && value.nil?
leaf_values = value_nodes.map(&:value)
leaf_values.compact.sum / leaf_values.compact.size
end
def series
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
end
end
first_child = children.first
summed_series = summed_by_date.map { |date, value| { date: date, value: value } }
TimeSeries.new(summed_series, favorable_direction: first_child&.series&.favorable_direction || "up")
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 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, currency)
child.parent = self
@children << child
child
end
def add_value_node(original, value, series = nil)
raise "Cannot add value node to a non-leaf node" unless can_add_value_node?
child = self.class.new(original.name)
child.original = original
child.value = value
child.series = series
child.parent = self
@children << child
child
end
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?
value.present?
end
private
def can_add_value_node?
return false if is_value_node?
children.empty? || children.all?(&:is_value_node?)
end
end

View file

@ -12,15 +12,21 @@ class Vehicle < ApplicationRecord
end
def trend
TimeSeries::Trend.new(current: account.balance_money, previous: first_valuation_amount)
Trend.new(current: account.balance_money, previous: first_valuation_amount)
end
def color
"#F23E94"
end
class << self
def color
"#F23E94"
end
def icon
"car-front"
def icon
"car-front"
end
def classification
"asset"
end
end
private