mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Add Money and Money Series (#505)
* Add Money class * Standardize creation of money series * Formatting * Fix test
This commit is contained in:
parent
89ea12e9a1
commit
0fe9b6d34a
16 changed files with 228 additions and 161 deletions
|
@ -1,4 +1,6 @@
|
|||
class AccountsController < ApplicationController
|
||||
include Filterable
|
||||
|
||||
before_action :authenticate_user!
|
||||
|
||||
def new
|
||||
|
@ -10,19 +12,6 @@ class AccountsController < ApplicationController
|
|||
|
||||
def show
|
||||
@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)
|
||||
@valuation_series = @account.valuation_series
|
||||
end
|
||||
|
|
23
app/controllers/concerns/filterable.rb
Normal file
23
app/controllers/concerns/filterable.rb
Normal 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
|
|
@ -4,7 +4,7 @@ import * as d3 from "d3";
|
|||
|
||||
// Connects to data-controller="line-chart"
|
||||
export default class extends Controller {
|
||||
static values = { series: Array };
|
||||
static values = { series: Object };
|
||||
|
||||
connect() {
|
||||
this.renderChart(this.seriesValue);
|
||||
|
@ -36,26 +36,26 @@ export default class extends Controller {
|
|||
}[trendDirection];
|
||||
}
|
||||
|
||||
drawChart(balances) {
|
||||
const data = balances.map((b) => ({
|
||||
date: new Date(b.data.date + "T00:00:00"),
|
||||
value: +b.data.balance,
|
||||
drawChart(series) {
|
||||
const data = series.data.map((b) => ({
|
||||
date: new Date(b.date + "T00:00:00"),
|
||||
value: +b.amount,
|
||||
styles: this.trendStyles(b.trend.direction),
|
||||
trend: b.trend,
|
||||
formatted: {
|
||||
value: Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: b.data.currency || "USD",
|
||||
}).format(b.data.balance),
|
||||
currency: b.currency || "USD",
|
||||
}).format(b.amount),
|
||||
change: Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: b.data.currency || "USD",
|
||||
currency: b.currency || "USD",
|
||||
signDisplay: "always",
|
||||
}).format(b.trend.amount),
|
||||
},
|
||||
}));
|
||||
|
||||
const chartContainer = d3.select("#lineChart");
|
||||
const chartContainer = d3.select(this.element);
|
||||
|
||||
// Clear any existing chart
|
||||
chartContainer.selectAll("svg").remove();
|
||||
|
|
|
@ -28,29 +28,17 @@ class Account < ApplicationRecord
|
|||
end
|
||||
|
||||
def balance_series(period)
|
||||
filtered_balances = balances.in_period(period).order(:date)
|
||||
return nil if filtered_balances.empty?
|
||||
|
||||
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])
|
||||
}
|
||||
MoneySeries.new(
|
||||
balances.in_period(period).order(:date),
|
||||
{ trend_type: classification }
|
||||
)
|
||||
end
|
||||
|
||||
def valuation_series
|
||||
series_data = [ nil, *valuations.order(:date) ].each_cons(2).map do |previous, current|
|
||||
{ value: current, trend: current&.trend(previous) }
|
||||
end
|
||||
|
||||
series_data.reverse_each
|
||||
MoneySeries.new(
|
||||
valuations.order(:date),
|
||||
{ trend_type: classification, amount_accessor: :value }
|
||||
)
|
||||
end
|
||||
|
||||
def check_currency
|
||||
|
|
32
app/models/money.rb
Normal file
32
app/models/money.rb
Normal 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
|
60
app/models/money_series.rb
Normal file
60
app/models/money_series.rb
Normal 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
|
|
@ -1,26 +1,27 @@
|
|||
class Trend
|
||||
attr_reader :current, :previous
|
||||
attr_reader :current, :previous, :type
|
||||
|
||||
def initialize(current, previous)
|
||||
@current = current
|
||||
@previous = previous
|
||||
end
|
||||
def initialize(current:, 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"
|
||||
end
|
||||
def direction
|
||||
return "flat" unless @previous
|
||||
return "up" if @current > @previous
|
||||
return "down" if @current < @previous
|
||||
"flat"
|
||||
end
|
||||
|
||||
def amount
|
||||
return 0 if @previous.nil?
|
||||
@current - @previous
|
||||
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.to_f * 100).round(1)
|
||||
end
|
||||
def percent
|
||||
return 0 if @previous.nil?
|
||||
return Float::INFINITY if @previous == 0
|
||||
((@current - @previous).abs / @previous.to_f * 100).round(1)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,7 +4,7 @@ class Valuation < ApplicationRecord
|
|||
after_commit :sync_account
|
||||
|
||||
def trend(previous)
|
||||
Trend.new(value, previous&.value)
|
||||
Trend.new(current: value, previous: previous&.value, type: account.classification)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<%# locals: (valuation_series:, classification:) %>
|
||||
<% valuation_series.with_index do |valuation_item, index| %>
|
||||
<% valuation, trend = valuation_item.values_at(:value, :trend) %>
|
||||
<% 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], mode: classification) %>
|
||||
<%= turbo_frame_tag dom_id(valuation) do %>
|
||||
<div class="p-4 flex items-center">
|
||||
|
@ -41,7 +41,7 @@
|
|||
</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>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<%= 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="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
|
@ -11,7 +12,7 @@
|
|||
<div class="flex items-center gap-3">
|
||||
<div class="relative cursor-not-allowed">
|
||||
<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") %>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -27,22 +28,21 @@
|
|||
<div class="p-4 flex justify-between">
|
||||
<div class="space-y-2">
|
||||
<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">
|
||||
<span class="text-gray-500"><%= @account.currency.unit %></span>
|
||||
<span class="text-xl font-medium"><%= format_currency(@account.balance, precision: 0, unit: '') %></span>
|
||||
<%- if @account.currency.precision.positive? -%>
|
||||
<span class="text-gray-500"><%= @account.currency.separator %><%= @account.balance.cents(precision: @account.currency.precision) %></span>
|
||||
<span class="text-gray-500"><%= balance.symbol %></span>
|
||||
<span class="text-xl font-medium"><%= format_currency(balance.amount, precision: 0, unit: '') %></span>
|
||||
<%- if balance.precision.positive? -%>
|
||||
<span class="text-gray-500"><%= balance.separator %><%= balance.cents %></span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% if @balance_series.nil? %>
|
||||
<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>
|
||||
<% else %>
|
||||
<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>(<%= 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><%= 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 class="text-gray-500"><%= trend_label(@period) %></span>
|
||||
</p>
|
||||
<% end %>
|
||||
|
@ -53,7 +53,7 @@
|
|||
</div>
|
||||
<div class="h-96 flex items-center justify-center text-2xl font-bold">
|
||||
<% 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 %>
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-gray-500">No data available for the selected period.</p>
|
||||
|
|
|
@ -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(
|
||||
"USD": { unit: "$", precision: 2, delimiter: ",", separator: "." },
|
||||
"EUR": { unit: "€", precision: 2, delimiter: ".", separator: "," },
|
||||
"GBP": { unit: "£", precision: 2, delimiter: ",", separator: "." },
|
||||
"CAD": { unit: "C$", precision: 2, delimiter: ",", separator: "." },
|
||||
"MXN": { unit: "MX$", precision: 2, delimiter: ",", separator: "." },
|
||||
"HKD": { unit: "HK$", precision: 2, delimiter: ",", separator: "." },
|
||||
"CHF": { unit: "CHF", precision: 2, delimiter: ".", separator: "," },
|
||||
"SGD": { unit: "S$", precision: 2, delimiter: ",", separator: "." },
|
||||
"NZD": { unit: "NZ$", precision: 2, delimiter: ",", separator: "." },
|
||||
"AUD": { unit: "A$", precision: 2, delimiter: ",", separator: "." },
|
||||
"KRW": { unit: "₩", precision: 0, delimiter: ",", separator: "." },
|
||||
"INR": { unit: "₹", precision: 2, delimiter: ",", separator: "." }
|
||||
"USD": { symbol: "$", precision: 2, delimiter: ",", separator: "." },
|
||||
"EUR": { symbol: "€", precision: 2, delimiter: ".", separator: "," },
|
||||
"GBP": { symbol: "£", precision: 2, delimiter: ",", separator: "." },
|
||||
"CAD": { symbol: "C$", precision: 2, delimiter: ",", separator: "." },
|
||||
"MXN": { symbol: "MX$", precision: 2, delimiter: ",", separator: "." },
|
||||
"HKD": { symbol: "HK$", precision: 2, delimiter: ",", separator: "." },
|
||||
"CHF": { symbol: "CHF", precision: 2, delimiter: ".", separator: "," },
|
||||
"SGD": { symbol: "S$", precision: 2, delimiter: ",", separator: "." },
|
||||
"NZD": { symbol: "NZ$", precision: 2, delimiter: ",", separator: "." },
|
||||
"AUD": { symbol: "A$", precision: 2, delimiter: ",", separator: "." },
|
||||
"KRW": { symbol: "₩", precision: 0, delimiter: ",", separator: "." },
|
||||
"INR": { symbol: "₹", precision: 2, delimiter: ",", separator: "." }
|
||||
)
|
||||
|
||||
EXCHANGE_RATE_ENABLED = ENV["OPEN_EXCHANGE_APP_ID"].present?
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
45
test/models/money_test.rb
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue