From 6f0e410684d1052cd4f41d9b1d78b6da1111a932 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 6 Mar 2024 09:56:59 -0500 Subject: [PATCH] Dashboard View and Calculations (#521) * Handle Turbo updates with tabs Fixes #491 * Add Filterable concern for controllers * Add trendline chart * Extract common UI to partials * Series refactor * Put placeholders for calculations in * Add classification generated column to account * Add basic net worth calculation * Add net worth tests * Get net worth graph working * Fix lint errors * Implement asset grouping query * Make trends and series more intuitive * Fully functional dashboard * Remove logging --- app/controllers/accounts_controller.rb | 5 +- app/controllers/pages_controller.rb | 5 + app/helpers/accounts_helper.rb | 27 ++++ app/helpers/application_helper.rb | 4 +- .../controllers/trendline_controller.js | 94 ++++++++++++++ app/models/account.rb | 119 +++++++++++++++--- app/models/account_balance.rb | 7 +- app/models/period.rb | 6 + app/models/trend.rb | 11 +- app/models/valuation.rb | 9 +- app/views/accounts/_account_history.html.erb | 4 +- .../accounts/_account_valuation_list.html.erb | 4 +- app/views/accounts/show.html.erb | 14 +-- app/views/pages/_account_group_disclosure.erb | 51 ++++++++ .../pages/_account_percentages_bar.html.erb | 17 +++ .../pages/_account_percentages_table.html.erb | 20 +++ app/views/pages/dashboard.html.erb | 91 +++++++++++++- app/views/shared/_balance_heading.html.erb | 10 +- app/views/shared/_line_chart.html.erb | 8 ++ app/views/shared/_progress_circle.html.erb | 10 ++ app/views/shared/_trend_change.html.erb | 10 ++ config/locales/views/pages/en.yml | 3 +- lib/tasks/demo_data.rake | 2 + test/fixtures/account/credits.yml | 3 +- test/fixtures/account/depositories.yml | 6 +- test/fixtures/account/investments.yml | 3 +- test/fixtures/account/loans.yml | 3 +- test/fixtures/account/other_assets.yml | 3 +- test/fixtures/account/other_liabilities.yml | 3 +- test/fixtures/account/properties.yml | 3 +- test/fixtures/account/vehicles.yml | 3 +- test/fixtures/accounts.yml | 4 + .../models/account/balance_calculator_test.rb | 4 - test/models/account/syncable_test.rb | 3 - test/models/account_test.rb | 2 - test/models/family_test.rb | 60 ++++++++- test/models/trend_test.rb | 37 ++++++ 37 files changed, 594 insertions(+), 74 deletions(-) create mode 100644 app/javascript/controllers/trendline_controller.js create mode 100644 app/views/pages/_account_group_disclosure.erb create mode 100644 app/views/pages/_account_percentages_bar.html.erb create mode 100644 app/views/pages/_account_percentages_table.html.erb create mode 100644 app/views/shared/_line_chart.html.erb create mode 100644 app/views/shared/_progress_circle.html.erb create mode 100644 app/views/shared/_trend_change.html.erb create mode 100644 test/models/trend_test.rb diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index cf00cc20..7a726dc6 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -1,6 +1,5 @@ class AccountsController < ApplicationController include Filterable - before_action :authenticate_user! def new @@ -12,8 +11,8 @@ class AccountsController < ApplicationController def show @account = Current.family.accounts.find(params[:id]) - @balance_series = @account.balance_series(@period) - @valuation_series = @account.valuation_series + @balance_series = @account.balances.to_series(@account, @period) + @valuation_series = @account.valuations.to_series(@account) end def create diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index e6dc79ea..25cee640 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -1,6 +1,11 @@ class PagesController < ApplicationController + include Filterable before_action :authenticate_user! def dashboard + @asset_series = Current.family.asset_series(@period) + @liability_series = Current.family.liability_series(@period) + @net_worth_series = Current.family.net_worth_series(@period) + @account_groups = Current.family.accounts.by_group(@period) end end diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 0673bf2a..f4686e2d 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -2,4 +2,31 @@ module AccountsHelper def to_accountable_title(accountable) accountable.model_name.human end + + def accountable_text_class(accountable_type) + class_mapping(accountable_type)[:text] + end + + def accountable_bg_class(accountable_type) + class_mapping(accountable_type)[:bg] + end + + def accountable_bg_transparent_class(accountable_type) + class_mapping(accountable_type)[:bg_transparent] + end + + private + + def class_mapping(accountable_type) + { + "Account::Credit" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10" }, + "Account::Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10" }, + "Account::OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10" }, + "Account::Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10" }, + "Account::Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10" }, + "Account::OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10" }, + "Account::Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10" }, + "Account::Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10" } + }.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10" }) + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0ef198be..3c755882 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -48,8 +48,10 @@ module ApplicationHelper end end - # Styles to use when displaying a change in value def trend_styles(trend) + fallback = { bg_class: "bg-gray-500/5", text_class: "text-gray-500", symbol: "", icon: "minus" } + return fallback if trend.nil? || trend.direction == "flat" + bg_class, text_class, symbol, icon = case trend.direction when "up" trend.type == "liability" ? [ "bg-red-500/5", "text-red-500", "+", "arrow-up" ] : [ "bg-green-500/5", "text-green-500", "+", "arrow-up" ] diff --git a/app/javascript/controllers/trendline_controller.js b/app/javascript/controllers/trendline_controller.js new file mode 100644 index 00000000..813f8ec9 --- /dev/null +++ b/app/javascript/controllers/trendline_controller.js @@ -0,0 +1,94 @@ +import { Controller } from "@hotwired/stimulus"; +import tailwindColors from "@maybe/tailwindcolors"; +import * as d3 from "d3"; + +export default class extends Controller { + static values = { series: Object, classification: String }; + + connect() { + this.renderChart(this.seriesValue); + document.addEventListener("turbo:load", this.renderChart.bind(this)); + } + + disconnect() { + document.removeEventListener("turbo:load", this.renderChart.bind(this)); + } + + renderChart() { + this.drawChart(this.seriesValue); + } + + drawChart(series) { + const data = series.data.map((d) => ({ + ...d, + date: new Date(d.date + "T00:00:00"), + amount: +d.amount, + })); + const chartContainer = d3.select(this.element); + chartContainer.selectAll("*").remove(); + const initialDimensions = { + width: chartContainer.node().clientWidth, + height: chartContainer.node().clientHeight, + }; + + const svg = chartContainer + .append("svg") + .attr("width", initialDimensions.width) + .attr("height", initialDimensions.height) + .attr("viewBox", [ + 0, + 0, + initialDimensions.width, + initialDimensions.height, + ]) + .attr("style", "max-width: 100%; height: auto; height: intrinsic;"); + + const margin = { top: 0, right: 0, bottom: 0, left: 0 }; + const width = initialDimensions.width - margin.left - margin.right; + const height = initialDimensions.height - margin.top - margin.bottom; + + const isLiability = this.classificationValue === "liability"; + const trendDirection = data[data.length - 1].amount - data[0].amount; + let lineColor; + + if (trendDirection > 0) { + lineColor = isLiability + ? tailwindColors.error + : tailwindColors.green[500]; + } else if (trendDirection < 0) { + lineColor = isLiability + ? tailwindColors.green[500] + : tailwindColors.error; + } else { + lineColor = tailwindColors.gray[500]; + } + + const xScale = d3 + .scaleTime() + .rangeRound([0, width]) + .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 padding = (dataMax - dataMin) * PADDING; + + const yScale = d3 + .scaleLinear() + .rangeRound([height, 0]) + .domain([dataMin - padding, dataMax + padding]); + + const line = d3 + .line() + .x((d) => xScale(d.date)) + .y((d) => yScale(d.amount)); + + svg + .append("path") + .datum(data) + .attr("fill", "none") + .attr("stroke", lineColor) + .attr("stroke-width", 2) + .attr("d", line); + } +} diff --git a/app/models/account.rb b/app/models/account.rb index ceabced7..3f4442a9 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -11,27 +11,110 @@ class Account < ApplicationRecord before_create :check_currency - def balance_series(period) - MoneySeries.new( - balances.in_period(period).order(:date), - { trend_type: classification } - ) + 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) end - def valuation_series - MoneySeries.new( - valuations.order(:date), - { trend_type: classification, amount_accessor: :value } - ) - 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 = 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 check_currency - if self.currency == self.family.currency - self.converted_balance = self.balance - self.converted_currency = self.currency - else - self.converted_balance = ExchangeRate.convert(self.currency, self.family.currency, self.balance) - self.converted_currency = self.family.currency + if period.date_range + ranked_balances_cte = ranked_balances_cte.where("account_balances.date BETWEEN ? AND ?", period.date_range.begin, period.date_range.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: { + total: total_assets, + groups: assets.group_by(&:accountable_type).transform_values do |rows| + end_balance = rows.sum(&:end_balance) + start_balance = rows.sum(&:start_balance) + { + start_balance: start_balance, + end_balance: end_balance, + allocation: (end_balance / total_assets * 100).round(2), + trend: Trend.new(current: end_balance, previous: start_balance, type: "asset"), + accounts: rows.map do |account| + { + name: account.name, + start_balance: account.start_balance, + end_balance: account.end_balance, + allocation: (account.end_balance / total_assets * 100).round(2), + trend: Trend.new(current: account.end_balance, previous: account.start_balance, type: "asset") + } + end + } + end + }, + liability: { + total: total_liabilities, + groups: liabilities.group_by(&:accountable_type).transform_values do |rows| + end_balance = rows.sum(&:end_balance) + start_balance = rows.sum(&:start_balance) + { + start_balance: start_balance, + end_balance: end_balance, + allocation: (end_balance / total_liabilities * 100).round(2), + trend: Trend.new(current: end_balance, previous: start_balance, type: "liability"), + accounts: rows.map do |account| + { + name: account.name, + start_balance: account.start_balance, + end_balance: account.end_balance, + allocation: (account.end_balance / total_liabilities * 100).round(2), + trend: Trend.new(current: account.end_balance, previous: account.start_balance, type: "liability") + } + end + } + end + } + } end + + private + + def check_currency + if self.currency == self.family.currency + self.converted_balance = self.balance + self.converted_currency = self.currency + else + self.converted_balance = ExchangeRate.convert(self.currency, self.family.currency, self.balance) + self.converted_currency = self.family.currency + end + end end diff --git a/app/models/account_balance.rb b/app/models/account_balance.rb index 5968c5d1..024cc30d 100644 --- a/app/models/account_balance.rb +++ b/app/models/account_balance.rb @@ -3,7 +3,10 @@ class AccountBalance < ApplicationRecord scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) } - def trend(previous) - Trend.new(balance, previous&.balance) + 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/period.rb b/app/models/period.rb index b4a81579..eada7555 100644 --- a/app/models/period.rb +++ b/app/models/period.rb @@ -22,4 +22,10 @@ class Period ] INDEX = BUILTIN.index_by(&:name) + + BUILTIN.each do |period| + define_singleton_method(period.name) do + period + end + end end diff --git a/app/models/trend.rb b/app/models/trend.rb index abb89036..0722f9ea 100644 --- a/app/models/trend.rb +++ b/app/models/trend.rb @@ -1,17 +1,16 @@ class Trend attr_reader :current, :previous, :type - def initialize(current:, previous: nil, type: "asset") + 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" unless @previous - return "up" if @current > @previous - return "down" if @current < @previous - "flat" + return "flat" if @current == @previous + return "up" if @previous.nil? || (@current && @current > @previous) + "down" end def amount @@ -22,6 +21,6 @@ class Trend def percent return 0 if @previous.nil? return Float::INFINITY if @previous == 0 - ((@current - @previous).abs / @previous.to_f * 100).round(1) + ((@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 f28e6dc4..34bbeb53 100644 --- a/app/models/valuation.rb +++ b/app/models/valuation.rb @@ -3,8 +3,13 @@ class Valuation < ApplicationRecord after_commit :sync_account - def trend(previous) - Trend.new(current: value, previous: previous&.value, type: account.classification) + 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 } + ) end private diff --git a/app/views/accounts/_account_history.html.erb b/app/views/accounts/_account_history.html.erb index 2fbad8de..19cb16ff 100644 --- a/app/views/accounts/_account_history.html.erb +++ b/app/views/accounts/_account_history.html.erb @@ -1,4 +1,4 @@ -<%# locals: (account:, valuation_series:) %> +<%# locals: (account:, valuations:) %>

History

@@ -21,7 +21,7 @@
<%= turbo_frame_tag dom_id(Valuation.new) %> <%= turbo_frame_tag "valuations_list" do %> - <%= render partial: "accounts/account_valuation_list", locals: { valuation_series: valuation_series, classification: account.classification } %> + <%= render partial: "accounts/account_valuation_list", locals: { valuation_series: valuations } %> <% end %>
diff --git a/app/views/accounts/_account_valuation_list.html.erb b/app/views/accounts/_account_valuation_list.html.erb index af4eca87..e9244e13 100644 --- a/app/views/accounts/_account_valuation_list.html.erb +++ b/app/views/accounts/_account_valuation_list.html.erb @@ -1,7 +1,7 @@ -<%# locals: (valuation_series:, classification:) %> +<%# 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(valuation_item[:trend]) %> + <% valuation_styles = trend_styles(trend) %> <%= turbo_frame_tag dom_id(valuation) do %>
diff --git a/app/views/accounts/show.html.erb b/app/views/accounts/show.html.erb index 366b9531..c7780643 100644 --- a/app/views/accounts/show.html.erb +++ b/app/views/accounts/show.html.erb @@ -34,18 +34,10 @@ } %>
- <%= form_with url: account_path(@account), method: :get, class: "flex items-center gap-4", html: { class: "" } do |f| %> - <%= f.select :period, options_for_select([['7D', 'last_7_days'], ['1M', 'last_30_days'], ["1Y", "last_365_days"], ['All', 'all']], selected: params[:period]), {}, { class: "block w-full border border-alpha-black-100 shadow-xs rounded-lg text-sm py-2 pr-8 pl-2 cursor-pointer", onchange: "this.form.submit();" } %> - <% end %> + <%= render partial: "shared/period_dropdown", locals: { period: @period, path: account_path(@account) } %>
- <% if @balance_series %> -
- <% else %> -
-

No data available for the selected period.

-
- <% end %> + <%= render partial: "shared/line_chart", locals: { series: @balance_series } %>
@@ -55,7 +47,7 @@
- <%= render partial: "accounts/account_history", locals: { account: @account, valuation_series: @valuation_series } %> + <%= render partial: "accounts/account_history", locals: { account: @account, valuations: @valuation_series } %>
\ No newline at end of file diff --git a/app/views/shared/_line_chart.html.erb b/app/views/shared/_line_chart.html.erb new file mode 100644 index 00000000..e02af5f6 --- /dev/null +++ b/app/views/shared/_line_chart.html.erb @@ -0,0 +1,8 @@ +<%# locals: (series:) %> +<% if series %> +
+<% else %> +
+

No data available for the selected period.

+
+<% end %> \ No newline at end of file diff --git a/app/views/shared/_progress_circle.html.erb b/app/views/shared/_progress_circle.html.erb new file mode 100644 index 00000000..64676b1a --- /dev/null +++ b/app/views/shared/_progress_circle.html.erb @@ -0,0 +1,10 @@ +<%# locals: (progress:, radius: 7, stroke: 2, text_class: "text-green-500") %> +<% circumference = Math::PI * 2 * radius %> +<% progress_percent = progress.clamp(0, 100) %> +<% stroke_dashoffset = ((100 - progress_percent) * circumference) / 100 %> + + + + + + \ No newline at end of file diff --git a/app/views/shared/_trend_change.html.erb b/app/views/shared/_trend_change.html.erb new file mode 100644 index 00000000..142f4e03 --- /dev/null +++ b/app/views/shared/_trend_change.html.erb @@ -0,0 +1,10 @@ +<%# locals: (trend:) %> +<% styles = trend_styles(trend) %> +

+ <% if trend.direction == "flat" %> + No change + <% else %> + <%= styles[:symbol] %><%= format_currency(trend.amount.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/locales/views/pages/en.yml b/config/locales/views/pages/en.yml index 18851e23..0660c46d 100644 --- a/config/locales/views/pages/en.yml +++ b/config/locales/views/pages/en.yml @@ -2,4 +2,5 @@ en: pages: dashboard: - title: Dashboard + greeting: Welcome back, %{name} + new: New diff --git a/lib/tasks/demo_data.rake b/lib/tasks/demo_data.rake index 54ff9294..c4bd0c05 100644 --- a/lib/tasks/demo_data.rake +++ b/lib/tasks/demo_data.rake @@ -5,6 +5,8 @@ namespace :demo_data do user = User.find_or_create_by(email: "user@maybe.local") do |u| u.password = "password" u.family = family + u.first_name = "User" + u.last_name = "Demo" end puts "Reset user: #{user.email} with family: #{family.name}" diff --git a/test/fixtures/account/credits.yml b/test/fixtures/account/credits.yml index 182a6978..4e67ab5c 100644 --- a/test/fixtures/account/credits.yml +++ b/test/fixtures/account/credits.yml @@ -1 +1,2 @@ -one: {} +one: + id: "123e4567-e89b-12d3-a456-426614174003" diff --git a/test/fixtures/account/depositories.yml b/test/fixtures/account/depositories.yml index 3207fdb6..bb41339c 100644 --- a/test/fixtures/account/depositories.yml +++ b/test/fixtures/account/depositories.yml @@ -1,2 +1,4 @@ -checking: {} -savings: {} +checking: + id: "123e4567-e89b-12d3-a456-426614174000" +savings: + id: "123e4567-e89b-12d3-a456-426614174001" diff --git a/test/fixtures/account/investments.yml b/test/fixtures/account/investments.yml index 182a6978..48c2657f 100644 --- a/test/fixtures/account/investments.yml +++ b/test/fixtures/account/investments.yml @@ -1 +1,2 @@ -one: {} +one: + id: "123e4567-e89b-12d3-a456-426614174004" diff --git a/test/fixtures/account/loans.yml b/test/fixtures/account/loans.yml index 182a6978..a49f101b 100644 --- a/test/fixtures/account/loans.yml +++ b/test/fixtures/account/loans.yml @@ -1 +1,2 @@ -one: {} +one: + id: "123e4567-e89b-12d3-a456-426614174005" diff --git a/test/fixtures/account/other_assets.yml b/test/fixtures/account/other_assets.yml index 182a6978..2734e1b0 100644 --- a/test/fixtures/account/other_assets.yml +++ b/test/fixtures/account/other_assets.yml @@ -1 +1,2 @@ -one: {} +one: + id: "123e4567-e89b-12d3-a456-426614174002" diff --git a/test/fixtures/account/other_liabilities.yml b/test/fixtures/account/other_liabilities.yml index 182a6978..2212ec20 100644 --- a/test/fixtures/account/other_liabilities.yml +++ b/test/fixtures/account/other_liabilities.yml @@ -1 +1,2 @@ -one: {} +one: + id: "123e4567-e89b-12d3-a456-426614174006" diff --git a/test/fixtures/account/properties.yml b/test/fixtures/account/properties.yml index 182a6978..1e3f9294 100644 --- a/test/fixtures/account/properties.yml +++ b/test/fixtures/account/properties.yml @@ -1 +1,2 @@ -one: {} +one: + id: "123e4567-e89b-12d3-a456-426614174007" diff --git a/test/fixtures/account/vehicles.yml b/test/fixtures/account/vehicles.yml index 182a6978..7a7f7e50 100644 --- a/test/fixtures/account/vehicles.yml +++ b/test/fixtures/account/vehicles.yml @@ -1 +1,2 @@ -one: {} +one: + id: "123e4567-e89b-12d3-a456-426614174008" diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index 358358c2..4b67bee2 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -4,6 +4,7 @@ collectable: name: Collectable Account balance: 550 accountable_type: Account::OtherAsset + accountable_id: "123e4567-e89b-12d3-a456-426614174002" # Account with only transactions checking: @@ -11,6 +12,7 @@ checking: name: Checking Account balance: 5000 accountable_type: Account::Depository + accountable_id: "123e4567-e89b-12d3-a456-426614174000" # Account with both transactions and valuations savings_with_valuation_overrides: @@ -18,6 +20,7 @@ savings_with_valuation_overrides: name: Savings account with valuation overrides balance: 20000 accountable_type: Account::Depository + accountable_id: "123e4567-e89b-12d3-a456-426614174001" # Liability account credit_card: @@ -25,3 +28,4 @@ credit_card: name: Credit Card balance: 1000 accountable_type: Account::Credit + accountable_id: "123e4567-e89b-12d3-a456-426614174003" diff --git a/test/models/account/balance_calculator_test.rb b/test/models/account/balance_calculator_test.rb index 81ea3d87..8baf1302 100644 --- a/test/models/account/balance_calculator_test.rb +++ b/test/models/account/balance_calculator_test.rb @@ -3,7 +3,6 @@ require "test_helper" class Account::BalanceCalculatorTest < ActiveSupport::TestCase test "syncs account with only valuations" do account = accounts(:collectable) - account.accountable = account_other_assets(:one) daily_balances = Account::BalanceCalculator.new(account).daily_balances @@ -19,7 +18,6 @@ class Account::BalanceCalculatorTest < ActiveSupport::TestCase test "syncs account with only transactions" do account = accounts(:checking) - account.accountable = account_depositories(:checking) daily_balances = Account::BalanceCalculator.new(account).daily_balances @@ -35,7 +33,6 @@ class Account::BalanceCalculatorTest < ActiveSupport::TestCase test "syncs account with both valuations and transactions" do account = accounts(:savings_with_valuation_overrides) - account.accountable = account_depositories(:savings) daily_balances = Account::BalanceCalculator.new(account).daily_balances expected_balances = [ @@ -50,7 +47,6 @@ class Account::BalanceCalculatorTest < ActiveSupport::TestCase test "syncs liability account" do account = accounts(:credit_card) - account.accountable = account_credits(:one) daily_balances = Account::BalanceCalculator.new(account).daily_balances expected_balances = [ diff --git a/test/models/account/syncable_test.rb b/test/models/account/syncable_test.rb index 40db9bfd..e69f5b1c 100644 --- a/test/models/account/syncable_test.rb +++ b/test/models/account/syncable_test.rb @@ -3,14 +3,12 @@ require "test_helper" class Account::SyncableTest < ActiveSupport::TestCase test "account has no balances until synced" do account = accounts(:savings_with_valuation_overrides) - account.accountable = account_depositories(:savings) assert_equal 0, account.balances.count end test "account has balances after syncing" do account = accounts(:savings_with_valuation_overrides) - account.accountable = account_depositories(:savings) account.sync assert_equal 31, account.balances.count @@ -18,7 +16,6 @@ class Account::SyncableTest < ActiveSupport::TestCase test "stale balances are purged after syncing" do account = accounts(:savings_with_valuation_overrides) - account.accountable = account_depositories(:savings) # Create old, stale balances that should be purged (since they are before account start date) account.balances.create!(date: 1.year.ago, balance: 1000) diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 5b0983c2..00abb708 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -2,9 +2,7 @@ require "test_helper" class AccountTest < ActiveSupport::TestCase def setup - depository = account_depositories(:checking) @account = accounts(:checking) - @account.accountable = depository end test "new account should be valid" do diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 83219a37..01a2144f 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -5,7 +5,6 @@ class FamilyTest < ActiveSupport::TestCase @family = families(:dylan_family) @family.accounts.each do |account| - account.accountable = account.classification == "asset" ? account_other_assets(:one) : account_other_liabilities(:one) account.sync end end @@ -78,4 +77,63 @@ class FamilyTest < ActiveSupport::TestCase assert_equal expected_balances, @family.net_worth_series.data.map { |b| b[:value].amount } 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 + + 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/trend_test.rb b/test/models/trend_test.rb new file mode 100644 index 00000000..ff07c793 --- /dev/null +++ b/test/models/trend_test.rb @@ -0,0 +1,37 @@ +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