1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +02:00

Add Money and Money Series (#505)

* Add Money class

* Standardize creation of money series

* Formatting

* Fix test
This commit is contained in:
Zach Gollwitzer 2024-03-01 17:17:34 -05:00 committed by GitHub
parent 89ea12e9a1
commit 0fe9b6d34a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 228 additions and 161 deletions

View file

@ -1,4 +1,6 @@
class AccountsController < ApplicationController class AccountsController < ApplicationController
include Filterable
before_action :authenticate_user! before_action :authenticate_user!
def new def new
@ -10,19 +12,6 @@ class AccountsController < ApplicationController
def show def show
@account = Current.family.accounts.find(params[:id]) @account = Current.family.accounts.find(params[:id])
@period = Period.find_by_name(params[:period])
if @period.nil?
start_date = params[:start_date].presence&.to_date
end_date = params[:end_date].presence&.to_date
if start_date.is_a?(Date) && end_date.is_a?(Date) && start_date <= end_date
@period = Period.new(name: "custom", date_range: start_date..end_date)
else
params[:period] = "last_30_days"
@period = Period.find_by_name(params[:period])
end
end
@balance_series = @account.balance_series(@period) @balance_series = @account.balance_series(@period)
@valuation_series = @account.valuation_series @valuation_series = @account.valuation_series
end end

View file

@ -0,0 +1,23 @@
module Filterable
extend ActiveSupport::Concern
included do
before_action :set_period
end
private
def set_period
@period = Period.find_by_name(params[:period])
if @period.nil?
start_date = params[:start_date].presence&.to_date
end_date = params[:end_date].presence&.to_date
if start_date.is_a?(Date) && end_date.is_a?(Date) && start_date <= end_date
@period = Period.new(name: "custom", date_range: start_date..end_date)
else
params[:period] = "last_30_days"
@period = Period.find_by_name(params[:period])
end
end
end
end

View file

@ -4,7 +4,7 @@ import * as d3 from "d3";
// Connects to data-controller="line-chart" // Connects to data-controller="line-chart"
export default class extends Controller { export default class extends Controller {
static values = { series: Array }; static values = { series: Object };
connect() { connect() {
this.renderChart(this.seriesValue); this.renderChart(this.seriesValue);
@ -36,26 +36,26 @@ export default class extends Controller {
}[trendDirection]; }[trendDirection];
} }
drawChart(balances) { drawChart(series) {
const data = balances.map((b) => ({ const data = series.data.map((b) => ({
date: new Date(b.data.date + "T00:00:00"), date: new Date(b.date + "T00:00:00"),
value: +b.data.balance, value: +b.amount,
styles: this.trendStyles(b.trend.direction), styles: this.trendStyles(b.trend.direction),
trend: b.trend, trend: b.trend,
formatted: { formatted: {
value: Intl.NumberFormat("en-US", { value: Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: b.data.currency || "USD", currency: b.currency || "USD",
}).format(b.data.balance), }).format(b.amount),
change: Intl.NumberFormat("en-US", { change: Intl.NumberFormat("en-US", {
style: "currency", style: "currency",
currency: b.data.currency || "USD", currency: b.currency || "USD",
signDisplay: "always", signDisplay: "always",
}).format(b.trend.amount), }).format(b.trend.amount),
}, },
})); }));
const chartContainer = d3.select("#lineChart"); const chartContainer = d3.select(this.element);
// Clear any existing chart // Clear any existing chart
chartContainer.selectAll("svg").remove(); chartContainer.selectAll("svg").remove();

View file

@ -28,29 +28,17 @@ class Account < ApplicationRecord
end end
def balance_series(period) def balance_series(period)
filtered_balances = balances.in_period(period).order(:date) MoneySeries.new(
return nil if filtered_balances.empty? balances.in_period(period).order(:date),
{ trend_type: classification }
series_data = [ nil, *filtered_balances ].each_cons(2).map do |previous, current| )
trend = current&.trend(previous)
{ data: current, trend: { amount: trend&.amount, direction: trend&.direction, percent: trend&.percent } }
end
last_balance = series_data.last[:data]
{
series_data: series_data,
last_balance: last_balance.balance,
trend: last_balance.trend(series_data.first[:data])
}
end end
def valuation_series def valuation_series
series_data = [ nil, *valuations.order(:date) ].each_cons(2).map do |previous, current| MoneySeries.new(
{ value: current, trend: current&.trend(previous) } valuations.order(:date),
end { trend_type: classification, amount_accessor: :value }
)
series_data.reverse_each
end end
def check_currency def check_currency

32
app/models/money.rb Normal file
View file

@ -0,0 +1,32 @@
class Money
attr_reader :amount, :currency
def self.from_amount(amount, currency = "USD")
Money.new(amount, currency)
end
def initialize(amount, currency = :USD)
@amount = amount
@currency = currency
end
def cents(precision: nil)
_precision = precision || CURRENCY_OPTIONS[@currency.to_sym][:precision]
return "" unless _precision.positive?
fractional_part = @amount.to_s.split(".")[1] || ""
fractional_part = fractional_part[0, _precision].ljust(_precision, "0")
end
def symbol
CURRENCY_OPTIONS[@currency.to_sym][:symbol]
end
def separator
CURRENCY_OPTIONS[@currency.to_sym][:separator]
end
def precision
CURRENCY_OPTIONS[@currency.to_sym][:precision]
end
end

View file

@ -0,0 +1,60 @@
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.from_amount(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

View file

@ -1,9 +1,10 @@
class Trend class Trend
attr_reader :current, :previous attr_reader :current, :previous, :type
def initialize(current, previous) def initialize(current:, previous: nil, type: :asset)
@current = current @current = current
@previous = previous @previous = previous
@type = type # :asset means positive trend is good, :liability means negative trend is good
end end
def direction def direction

View file

@ -4,7 +4,7 @@ class Valuation < ApplicationRecord
after_commit :sync_account after_commit :sync_account
def trend(previous) def trend(previous)
Trend.new(value, previous&.value) Trend.new(current: value, previous: previous&.value, type: account.classification)
end end
private private

View file

@ -1,6 +1,6 @@
<%# locals: (valuation_series:, classification:) %> <%# locals: (valuation_series:, classification:) %>
<% valuation_series.with_index do |valuation_item, index| %> <% valuation_series.data.reverse_each.with_index do |valuation_item, index| %>
<% valuation, trend = valuation_item.values_at(:value, :trend) %> <% valuation, trend = valuation_item.values_at(:raw, :trend) %>
<% valuation_styles = trend_styles(valuation_item[:trend], mode: classification) %> <% valuation_styles = trend_styles(valuation_item[:trend], mode: classification) %>
<%= turbo_frame_tag dom_id(valuation) do %> <%= turbo_frame_tag dom_id(valuation) do %>
<div class="p-4 flex items-center"> <div class="p-4 flex items-center">
@ -41,7 +41,7 @@
</div> </div>
</div> </div>
</div> </div>
<% unless index == valuation_series.size - 1 %> <% unless index == valuation_series.data.size - 1 %>
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div> <div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
<% end %> <% end %>
<% end %> <% end %>

View file

@ -1,5 +1,6 @@
<%= turbo_stream_from @account %> <%= turbo_stream_from @account %>
<% balance_trend_styles = @balance_series.nil? ? {} : trend_styles(@balance_series[:trend], mode: @account.classification) %> <% balance_trend_styles = trend_styles(@balance_series.trend, mode: @account.classification) %>
<% balance = Money.from_amount(@account.balance, @account.currency) %>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@ -11,7 +12,7 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="relative cursor-not-allowed"> <div class="relative cursor-not-allowed">
<div class="flex items-center gap-2 px-3 py-2"> <div class="flex items-center gap-2 px-3 py-2">
<span class="text-gray-900"><%= @account.currency %> <%= @account.currency.unit %></span> <span class="text-gray-900"><%= balance.currency %> <%= balance.symbol %></span>
<%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %> <%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
</div> </div>
</div> </div>
@ -27,22 +28,21 @@
<div class="p-4 flex justify-between"> <div class="p-4 flex justify-between">
<div class="space-y-2"> <div class="space-y-2">
<p class="text-sm text-gray-500">Total Value</p> <p class="text-sm text-gray-500">Total Value</p>
<%# TODO: Will need a better way to split a formatted monetary value into these 3 parts %>
<p class="text-gray-900"> <p class="text-gray-900">
<span class="text-gray-500"><%= @account.currency.unit %></span> <span class="text-gray-500"><%= balance.symbol %></span>
<span class="text-xl font-medium"><%= format_currency(@account.balance, precision: 0, unit: '') %></span> <span class="text-xl font-medium"><%= format_currency(balance.amount, precision: 0, unit: '') %></span>
<%- if @account.currency.precision.positive? -%> <%- if balance.precision.positive? -%>
<span class="text-gray-500"><%= @account.currency.separator %><%= @account.balance.cents(precision: @account.currency.precision) %></span> <span class="text-gray-500"><%= balance.separator %><%= balance.cents %></span>
<% end %> <% end %>
</p> </p>
<% if @balance_series.nil? %> <% if @balance_series.nil? %>
<p class="text-sm text-gray-500">Data not available for the selected period</p> <p class="text-sm text-gray-500">Data not available for the selected period</p>
<% elsif @balance_series[:trend].amount == 0 %> <% elsif @balance_series.trend.amount == 0 %>
<p class="text-sm text-gray-500">No change vs. prior period</p> <p class="text-sm text-gray-500">No change vs. prior period</p>
<% else %> <% else %>
<p class="text-sm <%= balance_trend_styles[:text_class] %>"> <p class="text-sm <%= balance_trend_styles[:text_class] %>">
<span><%= balance_trend_styles[:symbol] %><%= number_to_currency(@balance_series[:trend].amount.abs, precision: 2) %></span> <span><%= balance_trend_styles[:symbol] %><%= number_to_currency(@balance_series.trend.amount.abs, precision: 2) %></span>
<span>(<%= lucide_icon(@balance_series[:trend].amount > 0 ? 'arrow-up' : 'arrow-down', class: "w-4 h-4 align-text-bottom inline") %> <%= @balance_series[:trend].percent %>%)</span> <span>(<%= lucide_icon(@balance_series.trend.amount > 0 ? 'arrow-up' : 'arrow-down', class: "w-4 h-4 align-text-bottom inline") %> <%= @balance_series.trend.percent %>%)</span>
<span class="text-gray-500"><%= trend_label(@period) %></span> <span class="text-gray-500"><%= trend_label(@period) %></span>
</p> </p>
<% end %> <% end %>
@ -53,7 +53,7 @@
</div> </div>
<div class="h-96 flex items-center justify-center text-2xl font-bold"> <div class="h-96 flex items-center justify-center text-2xl font-bold">
<% if @balance_series %> <% if @balance_series %>
<div data-controller="line-chart" id="lineChart" class="w-full h-full" data-line-chart-series-value="<%= @balance_series[:series_data].to_json %>"></div> <div data-controller="line-chart" id="lineChart" class="w-full h-full" data-line-chart-series-value="<%= @balance_series.serialize_for_d3_chart %>"></div>
<% else %> <% else %>
<div class="w-full h-full flex items-center justify-center"> <div class="w-full h-full flex items-center justify-center">
<p class="text-gray-500">No data available for the selected period.</p> <p class="text-gray-500">No data available for the selected period.</p>

View file

@ -1,18 +1,18 @@
default_currency_options = { unit: "$", precision: 2, delimiter: ",", separator: "." } default_currency_options = { symbol: "$", precision: 2, delimiter: ",", separator: "." }
CURRENCY_OPTIONS = Hash.new { |hash, key| hash[key] = default_currency_options.dup }.merge( CURRENCY_OPTIONS = Hash.new { |hash, key| hash[key] = default_currency_options.dup }.merge(
"USD": { unit: "$", precision: 2, delimiter: ",", separator: "." }, "USD": { symbol: "$", precision: 2, delimiter: ",", separator: "." },
"EUR": { unit: "", precision: 2, delimiter: ".", separator: "," }, "EUR": { symbol: "", precision: 2, delimiter: ".", separator: "," },
"GBP": { unit: "£", precision: 2, delimiter: ",", separator: "." }, "GBP": { symbol: "£", precision: 2, delimiter: ",", separator: "." },
"CAD": { unit: "C$", precision: 2, delimiter: ",", separator: "." }, "CAD": { symbol: "C$", precision: 2, delimiter: ",", separator: "." },
"MXN": { unit: "MX$", precision: 2, delimiter: ",", separator: "." }, "MXN": { symbol: "MX$", precision: 2, delimiter: ",", separator: "." },
"HKD": { unit: "HK$", precision: 2, delimiter: ",", separator: "." }, "HKD": { symbol: "HK$", precision: 2, delimiter: ",", separator: "." },
"CHF": { unit: "CHF", precision: 2, delimiter: ".", separator: "," }, "CHF": { symbol: "CHF", precision: 2, delimiter: ".", separator: "," },
"SGD": { unit: "S$", precision: 2, delimiter: ",", separator: "." }, "SGD": { symbol: "S$", precision: 2, delimiter: ",", separator: "." },
"NZD": { unit: "NZ$", precision: 2, delimiter: ",", separator: "." }, "NZD": { symbol: "NZ$", precision: 2, delimiter: ",", separator: "." },
"AUD": { unit: "A$", precision: 2, delimiter: ",", separator: "." }, "AUD": { symbol: "A$", precision: 2, delimiter: ",", separator: "." },
"KRW": { unit: "", precision: 0, delimiter: ",", separator: "." }, "KRW": { symbol: "", precision: 0, delimiter: ",", separator: "." },
"INR": { unit: "", precision: 2, delimiter: ",", separator: "." } "INR": { symbol: "", precision: 2, delimiter: ",", separator: "." }
) )
EXCHANGE_RATE_ENABLED = ENV["OPEN_EXCHANGE_APP_ID"].present? EXCHANGE_RATE_ENABLED = ENV["OPEN_EXCHANGE_APP_ID"].present?

View file

@ -1,11 +0,0 @@
class Numeric
def cents(precision: 2)
return "" unless precision.positive?
cents = self.to_s.split(".")[1]
cents = "" unless cents.to_i.positive?
zero_padded_cents = cents.ljust(precision, "0")
zero_padded_cents[0..precision - 1]
end
end

View file

@ -1,13 +0,0 @@
class String
def unit
CURRENCY_OPTIONS[self.to_sym][:unit]
end
def separator
CURRENCY_OPTIONS[self.to_sym][:separator]
end
def precision
CURRENCY_OPTIONS[self.to_sym][:precision]
end
end

View file

@ -1,28 +0,0 @@
# test/initializers/big_decimal_extensions_test.rb
require "test_helper"
class NumericExtensionsTest < ActiveSupport::TestCase
test "#cents returns the cents part with 2 precisions by default" do
amount = 123.45
assert_equal "45", amount.cents
end
test "#cents returns empty when precision is 0" do
amount = 123.45
assert_equal "", amount.cents(precision: 0)
end
test "#cents returns the cents part of the string with given precision" do
amount = 123.4862
assert_equal "4", amount.cents(precision: 1)
assert_equal "486", amount.cents(precision: 3)
end
test "#cents pads the cents part with zeros up to the specified precision" do
amount_without_decimal = 123
amount_with_decimal = 123.4
assert_equal "00", amount_without_decimal.cents
assert_equal "40", amount_with_decimal.cents
end
end

View file

@ -1,19 +0,0 @@
# test/string_extensions_test.rb
require "test_helper"
class StringExtensionsTest < ActiveSupport::TestCase
test "#unit returns the currency unit for a given currency code" do
assert_equal "$", "USD".unit
assert_equal "", "EUR".unit
end
test "#separator returns the currency separator for a given currency code" do
assert_equal ".", "USD".separator
assert_equal ",", "EUR".separator
end
test "#precision returns the currency's precision for a given currency code" do
assert_equal 2, "USD".precision
assert_equal 0, "KRW".precision
end
end

45
test/models/money_test.rb Normal file
View file

@ -0,0 +1,45 @@
require "test_helper"
class MoneyTest < ActiveSupport::TestCase
test "#symbol returns the currency symbol for a given currency code" do
assert_equal "$", Money.from_amount(0, "USD").symbol
assert_equal "", Money.from_amount(0, "EUR").symbol
end
test "#separator returns the currency separator for a given currency code" do
assert_equal ".", Money.from_amount(0, "USD").separator
assert_equal ",", Money.from_amount(0, "EUR").separator
end
test "#precision returns the currency's precision for a given currency code" do
assert_equal 2, Money.from_amount(0, "USD").precision
assert_equal 0, Money.from_amount(123.45, "KRW").precision
end
test "#cents returns the cents part with 2 precisions by default" do
assert_equal "45", Money.from_amount(123.45, "USD").cents
end
test "#cents returns empty when precision is 0" do
assert_equal "", Money.from_amount(123.45, "USD").cents(precision: 0)
end
test "#cents returns the cents part of the string with given precision" do
amount = Money.from_amount(123.4862, "USD")
assert_equal "4", amount.cents(precision: 1)
assert_equal "486", amount.cents(precision: 3)
end
test "#cents pads the cents part with zeros up to the specified precision" do
amount_without_decimal = Money.from_amount(123, "USD")
amount_with_decimal = Money.from_amount(123.4, "USD")
assert_equal "00", amount_without_decimal.cents
assert_equal "40", amount_with_decimal.cents
end
test "works with BigDecimal" do
amount = Money.from_amount(BigDecimal("123.45"), "USD")
assert_equal "45", amount.cents
end
end