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:
parent
89ea12e9a1
commit
0fe9b6d34a
16 changed files with 228 additions and 161 deletions
|
@ -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
|
||||||
|
|
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"
|
// 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();
|
||||||
|
|
|
@ -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
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,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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 %>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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?
|
||||||
|
|
|
@ -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