diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 392c8bd5..b67928a6 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -11,8 +11,8 @@ class AccountsController < ApplicationController def show @account = Current.family.accounts.find(params[:id]) - @balance_series = @account.balances.to_series(@account, @period) - @valuation_series = @account.valuations.to_series(@account) + @balance_series = @account.series(@period) + @valuation_series = @account.valuations.to_series end def edit diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 43e6f831..f98fb42f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -29,6 +29,7 @@ module ApplicationHelper + def sidebar_modal(&block) content = capture &block render partial: "shared/sidebar_modal", locals: { content: content } diff --git a/app/javascript/controllers/line_chart_controller.js b/app/javascript/controllers/line_chart_controller.js index 9fbb70ba..e182f3f2 100644 --- a/app/javascript/controllers/line_chart_controller.js +++ b/app/javascript/controllers/line_chart_controller.js @@ -16,7 +16,8 @@ export default class extends Controller { } renderChart = () => { - this.drawChart(this.seriesValue); + const data = this.prepareData(this.seriesValue); + this.drawChart(data); }; trendStyles(trendDirection) { @@ -36,25 +37,27 @@ export default class extends Controller { }[trendDirection]; } - drawChart(series) { - const data = series.data.map((b) => ({ + prepareData(series) { + return series.values.map((b) => ({ date: new Date(b.date + "T00:00:00"), - value: +b.amount, + value: +b.value.amount, styles: this.trendStyles(b.trend.direction), trend: b.trend, formatted: { - value: Intl.NumberFormat("en-US", { + value: Intl.NumberFormat(undefined, { style: "currency", - currency: b.currency.iso_code || "USD", - }).format(b.amount), - change: Intl.NumberFormat("en-US", { + currency: b.value.currency || "USD", + }).format(b.value.amount), + change: Intl.NumberFormat(undefined, { style: "currency", - currency: b.currency.iso_code || "USD", + currency: b.value.currency || "USD", signDisplay: "always", - }).format(b.trend.amount), + }).format(b.trend.value.amount), }, })); + } + drawChart(data) { const chartContainer = d3.select(this.element); // Clear any existing chart @@ -77,6 +80,11 @@ export default class extends Controller { ]) .attr("style", "max-width: 100%; height: auto; height: intrinsic;"); + if (data.length === 1) { + this.renderEmpty(svg, initialDimensions); + return; + } + const margin = { top: 20, right: 1, bottom: 30, left: 1 }, width = +svg.attr("width") - margin.left - margin.right, height = +svg.attr("height") - margin.top - margin.bottom, @@ -237,4 +245,26 @@ export default class extends Controller { tooltip.style("opacity", 0); }); } + + // Dot in middle of chart as placeholder for empty chart + renderEmpty(svg, { width, height }) { + svg + .append("line") + .attr("x1", width / 2) + .attr("y1", 0) + .attr("x2", width / 2) + .attr("y2", height) + .attr("stroke", tailwindColors.gray[300]) + .attr("stroke-dasharray", "4, 4"); + + svg + .append("circle") + .attr("cx", width / 2) + .attr("cy", height / 2) + .attr("r", 4) + .style("fill", tailwindColors.gray[400]); + + svg.selectAll(".tick").remove(); + svg.selectAll(".domain").remove(); + } } diff --git a/app/javascript/controllers/trendline_controller.js b/app/javascript/controllers/trendline_controller.js index 2127fb10..dba9d15a 100644 --- a/app/javascript/controllers/trendline_controller.js +++ b/app/javascript/controllers/trendline_controller.js @@ -3,7 +3,7 @@ import tailwindColors from "@maybe/tailwindcolors"; import * as d3 from "d3"; export default class extends Controller { - static values = { series: Object, classification: String }; + static values = { series: Object }; connect() { this.renderChart(this.seriesValue); @@ -15,15 +15,18 @@ export default class extends Controller { } renderChart = () => { - this.drawChart(this.seriesValue); + const data = this.prepareData(this.seriesValue); + this.drawChart(data); + }; + + prepareData(series) { + return series.values.map((d) => ({ + date: new Date(d.date + "T00:00:00"), + value: +d.value.amount, + })); } - drawChart(series) { - const data = series.data.map((d) => ({ - ...d, - date: new Date(d.date + "T00:00:00"), - amount: +d.amount, - })); + drawChart(data) { const chartContainer = d3.select(this.element); chartContainer.selectAll("*").remove(); const initialDimensions = { @@ -48,7 +51,7 @@ export default class extends Controller { const height = initialDimensions.height - margin.top - margin.bottom; const isLiability = this.classificationValue === "liability"; - const trendDirection = data[data.length - 1].amount - data[0].amount; + const trendDirection = data[data.length - 1].value - data[0].value; let lineColor; if (trendDirection > 0) { @@ -69,8 +72,8 @@ export default class extends Controller { .domain(d3.extent(data, (d) => d.date)); const PADDING = 0.05; - const dataMin = d3.min(data, (d) => d.amount); - const dataMax = d3.max(data, (d) => d.amount); + const dataMin = d3.min(data, (d) => d.value); + const dataMax = d3.max(data, (d) => d.value); const padding = (dataMax - dataMin) * PADDING; const yScale = d3 @@ -81,7 +84,7 @@ export default class extends Controller { const line = d3 .line() .x((d) => xScale(d.date)) - .y((d) => yScale(d.amount)); + .y((d) => yScale(d.value)); svg .append("path") diff --git a/app/models/account.rb b/app/models/account.rb index fb5f7037..cb5881cb 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -26,10 +26,8 @@ class Account < ApplicationRecord %w[name] end - def trend(period = Period.all) - first = balances.in_period(period).order(:date).first - last = balances.in_period(period).order(date: :desc).first - Trend.new(current: last.balance, previous: first.balance, type: classification) + def balance_on(date) + balances.where("date <= ?", date).order(date: :desc).first&.balance end def self.by_provider @@ -41,55 +39,28 @@ class Account < ApplicationRecord exists?(status: "syncing") end - # TODO: We will need a better way to encapsulate large queries & transformation logic, but leaving all in one spot until - # we have a better understanding of the requirements - def self.by_group(period = Period.all) - ranked_balances_cte = active.joins(:balances) - .select(" - account_balances.account_id, - account_balances.balance, - account_balances.date, - ROW_NUMBER() OVER (PARTITION BY account_balances.account_id ORDER BY date ASC) AS rn_asc, - ROW_NUMBER() OVER (PARTITION BY account_balances.account_id ORDER BY date DESC) AS rn_desc - ") + def series(period = Period.all) + TimeSeries.from_collection(balances.in_period(period), :balance_money) + end - if period.date_range - ranked_balances_cte = ranked_balances_cte.where("account_balances.date BETWEEN ? AND ?", period.date_range.begin, period.date_range.end) + def self.by_group(period = Period.all) + grouped_accounts = { assets: ValueGroup.new("Assets"), liabilities: ValueGroup.new("Liabilities") } + + Accountable.by_classification.each do |classification, types| + types.each do |type| + group = grouped_accounts[classification.to_sym].add_child_node(type) + 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)) + end + end end - accounts_with_period_balances = AccountBalance.with( - ranked_balances: ranked_balances_cte - ) - .from("ranked_balances AS rb") - .joins("JOIN accounts a ON a.id = rb.account_id") - .select(" - a.name, - a.accountable_type, - a.classification, - SUM(CASE WHEN rb.rn_asc = 1 THEN rb.balance ELSE 0 END) AS start_balance, - MAX(CASE WHEN rb.rn_asc = 1 THEN rb.date ELSE NULL END) as start_date, - SUM(CASE WHEN rb.rn_desc = 1 THEN rb.balance ELSE 0 END) AS end_balance, - MAX(CASE WHEN rb.rn_desc = 1 THEN rb.date ELSE NULL END) as end_date - ") - .where("rb.rn_asc = 1 OR rb.rn_desc = 1") - .group("a.id") - .order("end_balance") - .to_a - - assets = accounts_with_period_balances.select { |row| row.classification == "asset" } - liabilities = accounts_with_period_balances.select { |row| row.classification == "liability" } - - total_assets = assets.sum(&:end_balance) - total_liabilities = liabilities.sum(&:end_balance) - - { - asset: build_group_summary(assets, "asset"), - liability: build_group_summary(liabilities, "liability") - } + grouped_accounts end private - def check_currency if self.currency == self.family.currency self.converted_balance = self.balance @@ -99,34 +70,4 @@ class Account < ApplicationRecord self.converted_currency = self.family.currency end end - - def self.build_group_summary(accounts, classification) - total_balance = accounts.sum(&:end_balance) - { - total: total_balance, - groups: accounts.group_by(&:accountable_type).transform_values do |rows| - build_account_summary(rows, total_balance, classification) - end - } - end - - def self.build_account_summary(accounts, total_balance, classification) - end_balance = accounts.sum(&:end_balance) - start_balance = accounts.sum(&:start_balance) - { - start_balance: start_balance, - end_balance: end_balance, - allocation: (end_balance / total_balance * 100).round(2), - trend: Trend.new(current: end_balance, previous: start_balance, type: classification), - accounts: accounts.map do |account| - { - name: account.name, - start_balance: account.start_balance, - end_balance: account.end_balance, - allocation: (account.end_balance / total_balance * 100).round(2), - trend: Trend.new(current: account.end_balance, previous: account.start_balance, type: classification) - } - end - } - end end diff --git a/app/models/account_balance.rb b/app/models/account_balance.rb index 6a47d368..a4e48f21 100644 --- a/app/models/account_balance.rb +++ b/app/models/account_balance.rb @@ -6,11 +6,4 @@ class AccountBalance < ApplicationRecord monetize :balance scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) } - - def self.to_series(account, period = Period.all) - MoneySeries.new( - in_period(period).order(:date), - { trend_type: account.classification } - ) - end end diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index e23d856d..befc16fb 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -1,15 +1,26 @@ module Accountable extend ActiveSupport::Concern - TYPES = %w[ Account::Credit Account::Depository Account::Investment Account::Loan Account::OtherAsset Account::OtherLiability Account::Property Account::Vehicle ] + ASSET_TYPES = %w[ Account::Depository Account::Investment Account::OtherAsset Account::Property Account::Vehicle ] + LIABILITY_TYPES = %w[ Account::Credit Account::Loan Account::OtherLiability ] + TYPES = ASSET_TYPES + LIABILITY_TYPES def self.from_type(type) return nil unless types.include?(type) || TYPES.include?(type) "Account::#{type.demodulize}".constantize end - def self.types - TYPES.map { |type| type.demodulize } + def self.by_classification + { assets: ASSET_TYPES, liabilities: LIABILITY_TYPES } + end + + def self.types(classification = nil) + types = classification ? (classification.to_sym == :asset ? ASSET_TYPES : LIABILITY_TYPES) : TYPES + types.map { |type| type.demodulize } + end + + def self.classification(type) + ASSET_TYPES.include?(type) ? :asset : :liability end included do diff --git a/app/models/family.rb b/app/models/family.rb index a04522e1..9b01b77f 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -23,10 +23,12 @@ class Family < ApplicationRecord query = query.where("account_balances.date BETWEEN ? AND ?", period.date_range.begin, period.date_range.end) if period.date_range + result = query.to_a + { - asset_series: MoneySeries.new(query, { trend_type: :asset, amount_accessor: "assets" }), - liability_series: MoneySeries.new(query, { trend_type: :liability, amount_accessor: "liabilities" }), - net_worth_series: MoneySeries.new(query, { trend_type: :asset, amount_accessor: "net_worth" }) + 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) } }), + net_worth_series: TimeSeries.new(result.map { |r| { date: r.date, value: Money.new(r.net_worth, r.currency) } }) } end @@ -35,7 +37,7 @@ class Family < ApplicationRecord end def net_worth - accounts.active.sum("CASE WHEN classification = 'asset' THEN balance ELSE -balance END") + Money.new(accounts.active.sum("CASE WHEN classification = 'asset' THEN balance ELSE -balance END"), currency) end def assets @@ -43,6 +45,6 @@ class Family < ApplicationRecord end def liabilities - accounts.active.liabilities.sum(:balance) + Money.new(accounts.active.liabilities.sum(:balance), currency) end end diff --git a/app/models/money_series.rb b/app/models/money_series.rb deleted file mode 100644 index c55d095f..00000000 --- a/app/models/money_series.rb +++ /dev/null @@ -1,60 +0,0 @@ -class MoneySeries - def initialize(series, options = {}) - @trend_type = options[:trend_type] || "asset" # Defines whether a positive trend is good or bad - @accessor = options[:amount_accessor] || :balance - @series = series - end - - def valid? - @series.length > 1 - end - - def data - [ nil, *@series ].each_cons(2).map do |previous, current| - { - raw: current, - date: current.date, - value: Money.new(current.send(@accessor), current.currency), - trend: Trend.new( - current: current.send(@accessor), - previous: previous&.send(@accessor), - type: @trend_type - ) - } - end - end - - def trend - return Trend.new(current: 0, type: @trend_type) unless valid? - - Trend.new( - current: @series.last.send(@accessor), - previous: @series.first&.send(@accessor), - type: @trend_type - ) - end - - def serialize_for_d3_chart - { - data: data.map do |datum| - { - date: datum[:date], - amount: datum[:value].amount, - currency: datum[:value].currency, - trend: { - amount: datum[:trend].amount, - percent: datum[:trend].percent, - direction: datum[:trend].direction, - type: datum[:trend].type - } - } - end, - trend: { - amount: trend.amount, - percent: trend.percent, - direction: trend.direction, - type: trend.type - } - }.to_json - end -end diff --git a/app/models/time_series.rb b/app/models/time_series.rb new file mode 100644 index 00000000..001c7bdd --- /dev/null +++ b/app/models/time_series.rb @@ -0,0 +1,83 @@ + +class TimeSeries + attr_reader :type + + def self.from_collection(collection, value_method, options = {}) + data = collection.map do |obj| + { date: obj.date, value: obj.public_send(value_method), original: obj } + end + new(data, options) + end + + def initialize(data, options = {}) + @type = options[:type] || :normal + initialize_series_data(data) + end + + def values + @values ||= add_trends_to_series + 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, + type: @type + ) + end + + # Data shape that frontend expects for D3 charts + def to_json(*_args) + { + values: values.map do |v| + { + date: v.date, + value: JSON.parse(v.value.to_json), + trend: { + type: v.trend.type, + direction: v.trend.direction, + value: JSON.parse(v.trend.value.to_json), + percent: v.trend.percent + } + } + end, + trend: { + type: @type, + direction: trend.direction, + value: JSON.parse(trend.value.to_json), + percent: trend.percent + }, + type: @type + }.to_json + end + + private + def initialize_series_data(data) + @series_data = data.nil? || data.empty? ? [] : data.map { |d| TimeSeries::Value.new(d) }.sort_by(&:date) + end + + def add_trends_to_series + [ nil, *@series_data ].each_cons(2).map do |previous, current| + unless current.trend + current.trend = TimeSeries::Trend.new( + current: current.value, + previous: previous&.value, + type: @type + ) + end + current + end + end +end diff --git a/app/models/time_series/trend.rb b/app/models/time_series/trend.rb new file mode 100644 index 00000000..6a748056 --- /dev/null +++ b/app/models/time_series/trend.rb @@ -0,0 +1,48 @@ +class TimeSeries::Trend + attr_reader :type + + # Tells us whether an increasing/decreasing trend is good or bad (i.e. a liability decreasing is good) + TYPES = %i[normal inverse].freeze + + def initialize(current: nil, previous: nil, type: :normal) + validate_data_types(current, previous) + validate_type(type) + @current = current + @previous = previous + @type = type + end + + def direction + return "flat" if @previous.nil? || @current == @previous + return "up" if @current && @current > @previous + "down" + end + + def value + return Money.new(0) if @previous.nil? && @current.is_a?(Money) + return 0 if @previous.nil? + @current - @previous + end + + def percent + return 0.0 if @previous.nil? + return Float::INFINITY if @previous == 0 + ((extract_numeric(@current) - extract_numeric(@previous)).abs / extract_numeric(@previous).abs.to_f * 100).round(1).to_f + end + + private + def validate_type(type) + raise ArgumentError, "Invalid type" unless TYPES.include?(type) + end + + def validate_data_types(current, previous) + return if previous.nil? || current.nil? + raise ArgumentError, "Current and previous values must be of the same type" unless current.class == previous.class + raise ArgumentError, "Current and previous values must be of type Money or Numeric" unless current.is_a?(Money) || current.is_a?(Numeric) + end + + def extract_numeric(obj) + return obj.amount if obj.is_a? Money + obj + end +end diff --git a/app/models/time_series/value.rb b/app/models/time_series/value.rb new file mode 100644 index 00000000..0b435f87 --- /dev/null +++ b/app/models/time_series/value.rb @@ -0,0 +1,32 @@ +class TimeSeries::Value + include Comparable + + attr_accessor :trend + attr_reader :value, :date, :original + + def initialize(obj) + @original = obj[:original] || obj + + if obj.is_a?(Hash) + @date = obj[:date] + @value = obj[:value] + else + @date = obj.date + @value = obj.value + end + + validate_input + end + + def <=>(other) + result = date <=> other.date + result = value <=> other.value if result == 0 + result + end + + private + def validate_input + raise ArgumentError, "Date is required" unless @date.is_a?(Date) + raise ArgumentError, "Money or Numeric value is required" unless @value.is_a?(Money) || @value.is_a?(Numeric) + end +end diff --git a/app/models/trend.rb b/app/models/trend.rb deleted file mode 100644 index 0722f9ea..00000000 --- a/app/models/trend.rb +++ /dev/null @@ -1,26 +0,0 @@ -class Trend - attr_reader :current, :previous, :type - - def initialize(current: nil, previous: nil, type: "asset") - @current = current - @previous = previous - @type = type # asset means positive trend is good, liability means negative trend is good - end - - def direction - return "flat" if @current == @previous - return "up" if @previous.nil? || (@current && @current > @previous) - "down" - end - - def amount - return 0 if @previous.nil? - @current - @previous - end - - def percent - return 0 if @previous.nil? - return Float::INFINITY if @previous == 0 - ((@current - @previous).abs / @previous.abs.to_f * 100).round(1) - end -end diff --git a/app/models/valuation.rb b/app/models/valuation.rb index 2f8dc919..b19329ab 100644 --- a/app/models/valuation.rb +++ b/app/models/valuation.rb @@ -8,11 +8,8 @@ class Valuation < ApplicationRecord scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) } - def self.to_series(account, period = Period.all) - MoneySeries.new( - in_period(period).order(:date), - { trend_type: account.classification, amount_accessor: :value } - ) + def self.to_series + TimeSeries.from_collection all, :value_money end private diff --git a/app/models/value_group.rb b/app/models/value_group.rb new file mode 100644 index 00000000..0e65779d --- /dev/null +++ b/app/models/value_group.rb @@ -0,0 +1,100 @@ + class ValueGroup + attr_accessor :parent + attr_reader :name, :children, :value, :original + + def initialize(name = "Root", value: nil, original: nil) + @name = name + @value = value + @children = [] + @original = original + end + + def sum + return value if is_value_node? + return 0 if children.empty? && value.nil? + children.sum(&:sum) + end + + def avg + return value if is_value_node? + return 0 if children.empty? && value.nil? + leaf_values = value_nodes.map(&:value) + leaf_values.compact.sum.to_f / leaf_values.compact.size + end + + def series + return @raw_series || TimeSeries.new([]) 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 + + summed_series = summed_by_date.map { |date, value| { date: date, value: value } } + TimeSeries.new(summed_series) + end + + def value_nodes + return [ self ] unless value.nil? + children.flat_map { |child| child.value_nodes } + end + + def percent_of_total + return 100 if parent.nil? + ((sum / parent.sum) * 100).round(1) + end + + def leaf? + children.empty? + end + + def add_child_node(name) + raise "Cannot add subgroup to node with a value" if is_value_node? + child = self.class.new(name) + child.parent = self + @children << child + child + end + + def add_value_node(obj) + raise "Cannot add value node to a non-leaf node" unless can_add_value_node? + child = create_value_node(obj) + child.parent = self + @children << child + child + end + + def attach_series(raw_series) + validate_attached_series(raw_series) + @raw_series = raw_series + 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 + + def create_value_node(obj) + value = if obj.respond_to?(:value) + obj.value + elsif obj.respond_to?(:balance) + obj.balance + elsif obj.respond_to?(:amount) + obj.amount + else + raise ArgumentError, "Object must have a value, balance, or amount" + end + + self.class.new(obj.name, value: value, original: obj) + end + + def validate_attached_series(series) + raise "Cannot add series to a node without a value" unless is_value_node? + raise "Attached series must be a TimeSeries" unless series.is_a?(TimeSeries) + end + end diff --git a/app/views/accounts/_account_valuation_list.html.erb b/app/views/accounts/_account_valuation_list.html.erb index f6ddd597..78f621d4 100644 --- a/app/views/accounts/_account_valuation_list.html.erb +++ b/app/views/accounts/_account_valuation_list.html.erb @@ -1,8 +1,7 @@ <%# locals: (valuation_series:) %> -<% valuation_series.data.reverse_each.with_index do |valuation_item, index| %> - <% valuation, trend = valuation_item.values_at(:raw, :trend) %> - <% valuation_styles = trend_styles(trend) %> - <%= turbo_frame_tag dom_id(valuation) do %> +<% valuation_series.values.reverse_each.with_index do |valuation, index| %> + <% valuation_styles = trend_styles(valuation.trend) %> + <%= turbo_frame_tag dom_id(valuation.original) do %>
Manually entered
<%= to_accountable_title(Accountable.from_type(accountable_type)) %>
+ +<%= to_accountable_title(Accountable.from_type(account_group.name)) %>
· -<%= account_details[:allocation] %>%
+ <%= render partial: "shared/progress_circle", locals: { progress: account_group.percent_of_total, text_class: text_class } %> +<%= account_group.percent_of_total.round(1) %>%
<%= format_money account_details[:end_balance] %>
+<%= format_money account_group.sum %>
<%= account[:name] %>
+<%= account.name %>
<%= account[:allocation] %>%
+ <%= render partial: "shared/progress_circle", locals: { progress: account.percent_of_total, text_class: text_class } %> +<%= account.percent_of_total %>%
<%= format_money account[:end_balance] %>
+<%= format_money account.sum %>
<%= to_accountable_title(Accountable.from_type(type)) %>
-<%= value[:allocation] %>%
+ +<%= to_accountable_title(Accountable.from_type(group.name)) %>
+<%= group.percent_of_total.round(1) %>%
No data available for the selected period.
diff --git a/app/views/shared/_trend_change.html.erb b/app/views/shared/_trend_change.html.erb index f0bbc0ec..7e8af716 100644 --- a/app/views/shared/_trend_change.html.erb +++ b/app/views/shared/_trend_change.html.erb @@ -4,7 +4,7 @@ <% if trend.direction == "flat" %> No change <% else %> - <%= styles[:symbol] %><%= format_money trend.amount.abs %> + <%= styles[:symbol] %><%= format_money trend.value.abs %> (<%= lucide_icon(styles[:icon], class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent %>%) <% end %> \ No newline at end of file diff --git a/config/initializers/constants.rb b/config/initializers/constants.rb index f6a58af6..878d5a6d 100644 --- a/config/initializers/constants.rb +++ b/config/initializers/constants.rb @@ -1 +1,7 @@ EXCHANGE_RATE_ENABLED = ENV["OPEN_EXCHANGE_APP_ID"].present? + +BALANCE_SHEET_CLASSIFICATIONS = { + asset: "asset", + liability: "liability", + equity: "equity" +}.freeze diff --git a/lib/money.rb b/lib/money.rb index 2c9581cf..b3274c17 100644 --- a/lib/money.rb +++ b/lib/money.rb @@ -42,6 +42,10 @@ class Money @currency.default_format.gsub("%n", formatted_amount).gsub("%u", @currency.symbol) end + def to_json(*_args) + { amount: @amount, currency: @currency.iso_code }.to_json + end + def <=>(other) raise TypeError, "Money can only be compared with other Money objects except for 0" unless other.is_a?(Money) || other.eql?(0) return @amount <=> other if other.is_a?(Numeric) diff --git a/test/fixtures/account/investments.yml b/test/fixtures/account/investments.yml index 48c2657f..e69de29b 100644 --- a/test/fixtures/account/investments.yml +++ b/test/fixtures/account/investments.yml @@ -1,2 +0,0 @@ -one: - id: "123e4567-e89b-12d3-a456-426614174004" diff --git a/test/fixtures/account/loans.yml b/test/fixtures/account/loans.yml index a49f101b..e69de29b 100644 --- a/test/fixtures/account/loans.yml +++ b/test/fixtures/account/loans.yml @@ -1,2 +0,0 @@ -one: - id: "123e4567-e89b-12d3-a456-426614174005" diff --git a/test/fixtures/account/other_liabilities.yml b/test/fixtures/account/other_liabilities.yml index 2212ec20..e69de29b 100644 --- a/test/fixtures/account/other_liabilities.yml +++ b/test/fixtures/account/other_liabilities.yml @@ -1,2 +0,0 @@ -one: - id: "123e4567-e89b-12d3-a456-426614174006" diff --git a/test/fixtures/account/properties.yml b/test/fixtures/account/properties.yml index 1e3f9294..e69de29b 100644 --- a/test/fixtures/account/properties.yml +++ b/test/fixtures/account/properties.yml @@ -1,2 +0,0 @@ -one: - id: "123e4567-e89b-12d3-a456-426614174007" diff --git a/test/fixtures/account/vehicles.yml b/test/fixtures/account/vehicles.yml index 7a7f7e50..e69de29b 100644 --- a/test/fixtures/account/vehicles.yml +++ b/test/fixtures/account/vehicles.yml @@ -1,2 +0,0 @@ -one: - id: "123e4567-e89b-12d3-a456-426614174008" diff --git a/test/models/family_test.rb b/test/models/family_test.rb index be7351b8..93a95b29 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -65,26 +65,18 @@ class FamilyTest < ActiveSupport::TestCase liability_series = @family.snapshot[:liability_series] net_worth_series = @family.snapshot[:net_worth_series] - assert_equal expected_snapshots.count, asset_series.data.count - assert_equal expected_snapshots.count, liability_series.data.count - assert_equal expected_snapshots.count, net_worth_series.data.count + assert_equal expected_snapshots.count, asset_series.values.count + assert_equal expected_snapshots.count, liability_series.values.count + assert_equal expected_snapshots.count, net_worth_series.values.count expected_snapshots.each_with_index do |row, index| - expected = { - date: row["date"], - assets: row["assets"].to_d, - liabilities: row["liabilities"].to_d, - net_worth: row["net_worth"].to_d - } + expected_assets = TimeSeries::Value.new(date: row["date"], value: Money.new(row["assets"].to_d)) + expected_liabilities = TimeSeries::Value.new(date: row["date"], value: Money.new(row["liabilities"].to_d)) + expected_net_worth = TimeSeries::Value.new(date: row["date"], value: Money.new(row["net_worth"].to_d)) - actual = { - date: asset_series.data[index][:date], - assets: asset_series.data[index][:value].amount, - liabilities: liability_series.data[index][:value].amount, - net_worth: net_worth_series.data[index][:value].amount - } - - assert_equal expected, actual + assert_equal expected_assets, asset_series.values[index] + assert_equal expected_liabilities, liability_series.values[index] + assert_equal expected_net_worth, net_worth_series.values[index] end end @@ -103,80 +95,4 @@ class FamilyTest < ActiveSupport::TestCase assert_equal liabilities_before - disabled_cc.balance, @family.liabilities assert_equal net_worth_before - disabled_checking.balance + disabled_cc.balance, @family.net_worth end - - test "calculates balances by type" do - verify_balances_by_type( - period: Period.all, - expected_asset_total: BigDecimal("25550"), - expected_liability_total: BigDecimal("1000"), - expected_asset_groups: { - "Account::OtherAsset" => { end_balance: BigDecimal("550"), start_balance: BigDecimal("400"), allocation: 2.15 }, - "Account::Depository" => { end_balance: BigDecimal("25000"), start_balance: BigDecimal("25250"), allocation: 97.85 } - }, - expected_liability_groups: { - "Account::Credit" => { end_balance: BigDecimal("1000"), start_balance: BigDecimal("1040"), allocation: 100 } - } - ) - end - - test "calculates balances by type with a date range filter" do - verify_balances_by_type( - period: Period.new(name: "custom", date_range: 7.days.ago.to_date..2.days.ago.to_date), - expected_asset_total: BigDecimal("26050"), - expected_liability_total: BigDecimal("1000"), - expected_asset_groups: { - "Account::OtherAsset" => { end_balance: BigDecimal("550"), start_balance: BigDecimal("700"), allocation: 2.11 }, - "Account::Depository" => { end_balance: BigDecimal("25500"), start_balance: BigDecimal("24510"), allocation: 97.89 } - }, - expected_liability_groups: { - "Account::Credit" => { end_balance: BigDecimal("1000"), start_balance: BigDecimal("990"), allocation: 100 } - } - ) - end - - test "calculates balances by type with disabled account" do - disabled_checking = accounts(:checking).update!(is_active: false) - - verify_balances_by_type( - period: Period.all, - expected_asset_total: BigDecimal("20550"), - expected_liability_total: BigDecimal("1000"), - expected_asset_groups: { - "Account::OtherAsset" => { end_balance: BigDecimal("550"), start_balance: BigDecimal("400"), allocation: 2.68 }, - "Account::Depository" => { end_balance: BigDecimal("20000"), start_balance: BigDecimal("21250"), allocation: 97.32 } - }, - expected_liability_groups: { - "Account::Credit" => { end_balance: BigDecimal("1000"), start_balance: BigDecimal("1040"), allocation: 100 } - } - ) - end - - private - - def verify_balances_by_type(period:, expected_asset_total:, expected_liability_total:, expected_asset_groups:, expected_liability_groups:) - result = @family.accounts.by_group(period) - - asset_total = result[:asset][:total] - liability_total = result[:liability][:total] - - assert_equal expected_asset_total, asset_total - assert_equal expected_liability_total, liability_total - - asset_groups = result[:asset][:groups] - liability_groups = result[:liability][:groups] - - assert_equal expected_asset_groups.keys, asset_groups.keys - expected_asset_groups.each do |type, expected_values| - assert_equal expected_values[:end_balance], asset_groups[type][:end_balance] - assert_equal expected_values[:start_balance], asset_groups[type][:start_balance] - assert_equal expected_values[:allocation], asset_groups[type][:allocation] - end - - assert_equal expected_liability_groups.keys, liability_groups.keys - expected_liability_groups.each do |type, expected_values| - assert_equal expected_values[:end_balance], liability_groups[type][:end_balance] - assert_equal expected_values[:start_balance], liability_groups[type][:start_balance] - assert_equal expected_values[:allocation], liability_groups[type][:allocation] - end - end end diff --git a/test/models/time_series/trend_test.rb b/test/models/time_series/trend_test.rb new file mode 100644 index 00000000..eb79913d --- /dev/null +++ b/test/models/time_series/trend_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class TimeSeries::TrendTest < ActiveSupport::TestCase + test "handles money trend" do + trend = TimeSeries::Trend.new(current: Money.new(100), previous: Money.new(50)) + assert_equal "up", trend.direction + assert_equal Money.new(50), trend.value + assert_equal 100.0, trend.percent + end + test "up" do + trend = TimeSeries::Trend.new(current: 100, previous: 50) + assert_equal "up", trend.direction + end + + test "down" do + trend = TimeSeries::Trend.new(current: 50, previous: 100) + assert_equal "down", trend.direction + end + + test "flat" do + trend1 = TimeSeries::Trend.new(current: 100, previous: 100) + trend3 = TimeSeries::Trend.new(current: 100, previous: nil) + trend2 = TimeSeries::Trend.new(current: nil, previous: nil) + assert_equal "flat", trend1.direction + assert_equal "flat", trend2.direction + assert_equal "flat", trend3.direction + end + + test "infinitely up" do + trend = TimeSeries::Trend.new(current: 100, previous: 0) + assert_equal "up", trend.direction + end + + test "infinitely down" do + trend1 = TimeSeries::Trend.new(current: nil, previous: 100) + trend2 = TimeSeries::Trend.new(current: 0, previous: 100) + assert_equal "down", trend1.direction + assert_equal "down", trend2.direction + end + + test "empty" do + trend =TimeSeries::Trend.new + assert_equal "flat", trend.direction + end +end diff --git a/test/models/time_series_test.rb b/test/models/time_series_test.rb new file mode 100644 index 00000000..4b611666 --- /dev/null +++ b/test/models/time_series_test.rb @@ -0,0 +1,75 @@ +require "test_helper" + +class TimeSeriesTest < ActiveSupport::TestCase + test "it can accept array of money values" do + series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(100) }, { date: Date.current, value: Money.new(200) } ]) + + assert_equal Money.new(100), series.first.value + assert_equal Money.new(200), series.last.value + assert_equal :normal, series.type + assert_equal "up", series.trend.direction + assert_equal Money.new(100), series.trend.value + assert_equal 100.0, series.trend.percent + end + + test "it can accept array of numeric values" do + series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 100 }, { date: Date.current, value: 200 } ]) + + assert_equal 100, series.first.value + assert_equal 200, series.last.value + assert_equal 100, series.on(1.day.ago.to_date).value + assert_equal :normal, series.type + assert_equal "up", series.trend.direction + assert_equal 100, series.trend.value + assert_equal 100.0, series.trend.percent + end + + test "when nil or empty array passed, it returns empty series" do + series = TimeSeries.new(nil) + assert_equal [], series.values + + series = TimeSeries.new([]) + + assert_nil series.first + assert_nil series.last + assert_equal({ values: [], trend: { type: "normal", direction: "flat", value: 0, percent: 0.0 }, type: "normal" }.to_json, series.to_json) + end + + test "money series can be serialized to json" do + expected_values = { + values: [ + { + date: "2024-03-17", + value: { amount: "100.0", currency: "USD" }, + trend: { type: "normal", direction: "flat", value: { amount: "0.0", currency: "USD" }, percent: 0.0 } + }, + { + date: "2024-03-18", + value: { amount: "200.0", currency: "USD" }, + trend: { type: "normal", direction: "up", value: { amount: "100.0", currency: "USD" }, percent: 100.0 } + } + ], + trend: { type: "normal", direction: "up", value: { amount: "100.0", currency: "USD" }, percent: 100.0 }, + type: "normal" + }.to_json + + series = TimeSeries.new([ { date: 1.day.ago.to_date, value: Money.new(100) }, { date: Date.current, value: Money.new(200) } ]) + + assert_equal expected_values, series.to_json + end + + test "numeric series can be serialized to json" do + expected_values = { + values: [ + { date: 1.day.ago.to_date, value: 100, trend: { type: "normal", direction: "flat", value: 0, percent: 0.0 } }, + { date: Date.current, value: 200, trend: { type: "normal", direction: "up", value: 100, percent: 100.0 } } + ], + trend: { type: "normal", direction: "up", value: 100, percent: 100.0 }, + type: "normal" + }.to_json + + series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 100 }, { date: Date.current, value: 200 } ]) + + assert_equal expected_values, series.to_json + end +end diff --git a/test/models/trend_test.rb b/test/models/trend_test.rb deleted file mode 100644 index ff07c793..00000000 --- a/test/models/trend_test.rb +++ /dev/null @@ -1,37 +0,0 @@ -require "test_helper" - -class TrendTest < ActiveSupport::TestCase - test "up" do - trend = Trend.new(current: 100, previous: 50) - assert_equal "up", trend.direction - end - - test "down" do - trend = Trend.new(current: 50, previous: 100) - assert_equal "down", trend.direction - end - - test "flat" do - trend = Trend.new(current: 100, previous: 100) - assert_equal "flat", trend.direction - end - - test "infinitely up" do - trend1 = Trend.new(current: 100, previous: nil) - trend2 = Trend.new(current: 100, previous: 0) - assert_equal "up", trend1.direction - assert_equal "up", trend2.direction - end - - test "infinitely down" do - trend1 = Trend.new(current: nil, previous: 100) - trend2 = Trend.new(current: 0, previous: 100) - assert_equal "down", trend1.direction - assert_equal "down", trend2.direction - end - - test "empty" do - trend = Trend.new - assert_equal "flat", trend.direction - end -end diff --git a/test/models/value_group_test.rb b/test/models/value_group_test.rb new file mode 100644 index 00000000..c04f226b --- /dev/null +++ b/test/models/value_group_test.rb @@ -0,0 +1,145 @@ +require "test_helper" + +class ValueGroupTest < ActiveSupport::TestCase + setup do + checking = accounts(:checking) + savings = accounts(:savings_with_valuation_overrides) + collectable = accounts(:collectable) + + # Level 1 + @assets = ValueGroup.new("Assets") + + # Level 2 + @depositories = @assets.add_child_node("Depositories") + @other_assets = @assets.add_child_node("Other Assets") + + # Level 3 (leaf/value nodes) + @checking_node = @depositories.add_value_node(checking) + @savings_node = @depositories.add_value_node(savings) + @collectable_node = @other_assets.add_value_node(collectable) + end + + test "empty group works" do + group = ValueGroup.new + + assert_equal "Root", group.name + assert_equal [], group.children + assert_equal 0, group.sum + assert_equal 0, group.avg + assert_equal 100, group.percent_of_total + assert_nil group.parent + end + + test "group without value nodes has no value" do + assets = ValueGroup.new("Assets") + depositories = assets.add_child_node("Depositories") + + assert_equal 0, assets.sum + assert_equal 0, depositories.sum + end + + test "sum equals value at leaf level" do + assert_equal @checking_node.value, @checking_node.sum + assert_equal @savings_node.value, @savings_node.sum + assert_equal @collectable_node.value, @collectable_node.sum + end + + test "value is nil at rollup levels" do + assert_not_equal @depositories.value, @depositories.sum + assert_nil @depositories.value + assert_nil @other_assets.value + end + + test "generates list of value nodes regardless of level in hierarchy" do + assert_equal [ @checking_node, @savings_node, @collectable_node ], @assets.value_nodes + assert_equal [ @checking_node, @savings_node ], @depositories.value_nodes + assert_equal [ @collectable_node ], @other_assets.value_nodes + end + + test "group with value nodes aggregates totals correctly" do + assert_equal 5000, @checking_node.sum + assert_equal 20000, @savings_node.sum + assert_equal 550, @collectable_node.sum + + assert_equal 25000, @depositories.sum + assert_equal 550, @other_assets.sum + + assert_equal 25550, @assets.sum + end + + test "group averages leaf nodes" do + assert_equal 5000, @checking_node.avg + assert_equal 20000, @savings_node.avg + assert_equal 550, @collectable_node.avg + + assert_in_delta 12500, @depositories.avg, 0.01 + assert_in_delta 550, @other_assets.avg, 0.01 + assert_in_delta 8516.67, @assets.avg, 0.01 + end + + # Percentage of parent group (i.e. collectable is 100% of "Other Assets" group) + test "group calculates percent of parent total" do + assert_equal 100, @assets.percent_of_total + assert_in_delta 97.85, @depositories.percent_of_total, 0.1 + assert_in_delta 2.15, @other_assets.percent_of_total, 0.1 + assert_in_delta 80.0, @savings_node.percent_of_total, 0.1 + assert_in_delta 20.0, @checking_node.percent_of_total, 0.1 + assert_equal 100, @collectable_node.percent_of_total + end + + test "handles unbalanced tree" do + vehicles = @assets.add_child_node("Vehicles") + + # Since we didn't add any value nodes to vehicles, shouldn't affect rollups + assert_equal 25550, @assets.sum + end + + + test "can attach and aggregate time series" do + checking_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 4000 }, { date: Date.current, value: 5000 } ]) + savings_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 19000 }, { date: Date.current, value: 20000 } ]) + + @checking_node.attach_series(checking_series) + @savings_node.attach_series(savings_series) + + assert_not_nil @checking_node.series + assert_not_nil @savings_node.series + + assert_equal @checking_node.sum, @checking_node.series.last.value + assert_equal @savings_node.sum, @savings_node.series.last.value + + aggregated_depository_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 23000 }, { date: Date.current, value: 25000 } ]) + aggregated_assets_series = TimeSeries.new([ { date: 1.day.ago.to_date, value: 23000 }, { date: Date.current, value: 25000 } ]) + + assert_equal aggregated_depository_series.values, @depositories.series.values + assert_equal aggregated_assets_series.values, @assets.series.values + end + + test "attached series must be a TimeSeries" do + assert_raises(RuntimeError) do + @checking_node.attach_series([]) + end + end + + test "cannot add time series to non-leaf node" do + assert_raises(RuntimeError) do + @assets.attach_series(TimeSeries.new([])) + end + end + + test "can only add value node at leaf level of tree" do + root = ValueGroup.new("Root Level") + grandparent = root.add_child_node("Grandparent") + parent = grandparent.add_child_node("Parent") + + value_node = parent.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 })) + + assert_raises(RuntimeError) do + value_node.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 })) + end + + assert_raises(RuntimeError) do + grandparent.add_value_node(OpenStruct.new({ name: "Value Node", value: 100 })) + end + end +end