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

Multi-currency support: Money + Currency class improvements (#553)

* Money improvements

* Replace all old money usage
This commit is contained in:
Zach Gollwitzer 2024-03-18 11:21:00 -04:00 committed by GitHub
parent e5750d1a13
commit fe2fa0eac1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 2982 additions and 196 deletions

View file

@ -42,7 +42,7 @@
}
.form-field__input {
@apply p-3 pt-1 w-full bg-transparent border-none opacity-50;
@apply p-3 w-full bg-transparent border-none opacity-50;
@apply focus:outline-none focus:ring-0 focus:opacity-100;
}

View file

@ -35,7 +35,7 @@ class TransactionsController < ApplicationController
respond_to do |format|
if @transaction.save
format.html { redirect_to transaction_url(@transaction), notice: t(".success") }
format.html { redirect_to transactions_url, notice: t(".success") }
else
format.html { render :new, status: :unprocessable_entity }
end

View file

@ -22,6 +22,34 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
RUBY_EVAL
end
# See `Monetizable` concern, which adds a _money suffix to the attribute name
# For a monetized field, the setter will always be the attribute name without the _money suffix
def money_field(method, options = {})
money = @object.send(method)
raise ArgumentError, "The value of #{method} is not a Money object" unless money.is_a?(Money) || money.nil?
money_amount_method = method.to_s.chomp("_money").to_sym
money_currency_method = :currency
readonly_currency = options[:readonly_currency] || false
default_options = {
class: "form-field__input",
value: money&.amount,
placeholder: Money.new(0, money&.currency || Money.default_currency).format
}
merged_options = default_options.merge(options)
@template.form_field_tag do
(label(method, *label_args(options)).to_s if options[:label]) +
@template.tag.div(class: "flex items-center") do
number_field(money_amount_method, merged_options.except(:label)) +
select(money_currency_method, Money::Currency.popular.map(&:iso_code), { selected: money&.currency&.iso_code }, { disabled: readonly_currency, class: "ml-auto form-field__input w-fit pr-8" })
end
end
end
def select(method, choices, options = {}, html_options = {})
default_options = { class: "form-field__input" }
merged_options = default_options.merge(html_options)

View file

@ -27,9 +27,7 @@ module ApplicationHelper
render partial: "shared/modal", locals: { content: content }
end
def currency_dropdown(f: nil, options: [])
render partial: "shared/currency_dropdown", locals: { f: f, options: options }
end
def sidebar_modal(&block)
content = capture &block
@ -97,12 +95,15 @@ module ApplicationHelper
end
end
def format_currency(number, options = {})
user_currency_preference = Current.family.try(:currency) || "USD"
def format_money(number_or_money, options = {})
money = Money.new(number_or_money)
options.reverse_merge!(money.default_format_options)
number_to_currency(money.amount, options)
end
currency_options = CURRENCY_OPTIONS[user_currency_preference.to_sym]
options.reverse_merge!(currency_options)
number_to_currency(number, options)
def format_money_without_symbol(number_or_money, options = {})
money = Money.new(number_or_money)
options.reverse_merge!(money.default_format_options)
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
end
end

View file

@ -2,4 +2,8 @@ module FormsHelper
def form_field_tag(&)
tag.div class: "form-field", &
end
def currency_dropdown(f: nil, options: [])
render partial: "shared/currency_dropdown", locals: { f: f, options: options }
end
end

View file

@ -17,7 +17,7 @@ export default class extends Controller {
renderChart = () => {
this.drawChart(this.seriesValue);
}
};
trendStyles(trendDirection) {
return {
@ -45,11 +45,11 @@ export default class extends Controller {
formatted: {
value: Intl.NumberFormat("en-US", {
style: "currency",
currency: b.currency || "USD",
currency: b.currency.iso_code || "USD",
}).format(b.amount),
change: Intl.NumberFormat("en-US", {
style: "currency",
currency: b.currency || "USD",
currency: b.currency.iso_code || "USD",
signDisplay: "always",
}).format(b.trend.amount),
},

View file

@ -1,5 +1,6 @@
class Account < ApplicationRecord
include Syncable
include Monetizable
validates :family, presence: true
@ -9,6 +10,8 @@ class Account < ApplicationRecord
has_many :valuations
has_many :transactions
monetize :balance
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true
scope :active, -> { where(is_active: true) }

View file

@ -6,8 +6,8 @@ class Account::BalanceCalculator
def daily_balances(start_date = nil)
calc_start_date = [ start_date, @account.effective_start_date ].compact.max
valuations = @account.valuations.where("date >= ?", calc_start_date).order(:date).select(:date, :value)
transactions = @account.transactions.where("date > ?", calc_start_date).order(:date).select(:date, :amount)
valuations = @account.valuations.where("date >= ?", calc_start_date).order(:date).select(:date, :value, :currency)
transactions = @account.transactions.where("date > ?", calc_start_date).order(:date).select(:date, :amount, :currency)
oldest_entry = [ valuations.first, transactions.first ].compact.min_by(&:date)
net_transaction_flows = transactions.sum(&:amount)

View file

@ -1,7 +1,9 @@
class AccountBalance < ApplicationRecord
belongs_to :account
include Monetizable
belongs_to :account
validates :account, :date, :balance, presence: true
monetize :balance
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }

View file

@ -0,0 +1,14 @@
module Monetizable
extend ActiveSupport::Concern
class_methods do
def monetize(*fields)
fields.each do |field|
define_method("#{field}_money") do
value = self.send(field)
value.nil? ? nil : Money.new(value, currency)
end
end
end
end
end

View file

@ -1,9 +1,13 @@
class Family < ApplicationRecord
include Monetizable
has_many :users, dependent: :destroy
has_many :accounts, dependent: :destroy
has_many :transactions, through: :accounts
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
monetize :net_worth, :assets, :liabilities
def snapshot(period = Period.all)
query = accounts.active.joins(:balances)
.where("account_balances.currency = ?", self.currency)
@ -35,7 +39,7 @@ class Family < ApplicationRecord
end
def assets
accounts.active.assets.sum(:balance)
accounts.active.assets.sum(:balance)
end
def liabilities

View file

@ -1,32 +0,0 @@
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

@ -14,7 +14,7 @@ class MoneySeries
{
raw: current,
date: current.date,
value: Money.from_amount(current.send(@accessor), current.currency),
value: Money.new(current.send(@accessor), current.currency),
trend: Trend.new(
current: current.send(@accessor),
previous: previous&.send(@accessor),

View file

@ -1,4 +1,6 @@
class Transaction < ApplicationRecord
include Monetizable
belongs_to :account
belongs_to :category, optional: true
@ -6,6 +8,12 @@ class Transaction < ApplicationRecord
after_commit :sync_account
monetize :amount
scope :inflows, -> { where("amount > 0") }
scope :outflows, -> { where("amount < 0") }
scope :active, -> { where(excluded: false) }
def self.ransackable_attributes(auth_object = nil)
%w[name amount date]
end

View file

@ -1,9 +1,10 @@
class Valuation < ApplicationRecord
include Monetizable
belongs_to :account
validates :account, :date, :value, presence: true
after_commit :sync_account
monetize :value
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }

View file

@ -10,7 +10,7 @@
</div>
<div class="flex items-center gap-8">
<p class="text-sm font-medium <%= account.is_active ? "text-gray-900" : "text-gray-400" %>">
<%= format_currency account.balance %>
<%= format_money account.balance_money %>
</p>
<%= form_with model: account, method: :patch, html: { class: "flex items-center", data: { turbo_frame: "_top" } } do |form| %>
<div class="relative inline-block select-none">

View file

@ -1,31 +1,27 @@
<%# locals: (type:) -%>
<% accounts = Current.family.accounts.where(accountable_type: type.name) %>
<% if accounts.sum(&:converted_balance) > 0 %>
<details class="mb-1 text-sm group" data-controller="account-collapse" data-account-collapse-type-value="<%= type %>">
<summary class="flex gap-4 px-2 py-3 items-center w-full rounded-[10px] font-medium hover:bg-gray-100">
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
<div class="text-left"><%= type.model_name.human %></div>
<div class="ml-auto"><%= format_currency accounts.sum(&:converted_balance) %></div>
</summary>
<% accounts.each do |account| %>
<%= link_to account_path(account), class: "flex items-center w-full gap-3 px-2 py-3 mb-1 hover:bg-gray-100 rounded-[10px]" do %>
<div>
<p class="font-medium"><%= account.name %></p>
<% if account.subtype %>
<p class="text-xs text-gray-500"><%= account.subtype&.humanize %></p>
<% end %>
</div>
<p class="ml-auto font-medium"><%= format_currency account.converted_balance %></p>
<details class="mb-1 text-sm group" data-controller="account-collapse" data-account-collapse-type-value="<%= type %>">
<summary class="flex gap-4 px-2 py-3 items-center w-full rounded-[10px] font-medium hover:bg-gray-100">
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-gray-500 w-5 h-5") %>
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
<div class="text-left"><%= type.model_name.human %></div>
<div class="ml-auto"><%= format_money accounts.sum(&:converted_balance) %></div>
</summary>
<% accounts.each do |account| %>
<%= link_to account_path(account), class: "flex items-center w-full gap-3 px-2 py-3 mb-1 hover:bg-gray-100 rounded-[10px]" do %>
<div>
<p class="font-medium"><%= account.name %></p>
<% if account.subtype %>
<p class="text-xs text-gray-500"><%= account.subtype&.humanize %></p>
<% end %>
</div>
<p class="ml-auto font-medium"><%= format_money account.converted_balance %></p>
<% end %>
<% end %>
<% end %>
<%= link_to new_account_path(step: 'method', type: type.name.demodulize), class: "flex items-center gap-4 px-2 py-3 mb-1 text-gray-500 text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p>New <%= type.model_name.human.downcase %></p>
<% end %>
</details>
<%= link_to new_account_path(step: 'method', type: type.name.demodulize), class: "flex items-center gap-4 px-2 py-3 mb-1 text-gray-500 text-sm font-medium rounded-[10px] hover:bg-gray-100", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p>New <%= type.model_name.human.downcase %></p>
<% end %>
</details>
<% end %>

View file

@ -15,13 +15,13 @@
<%# TODO: Add descriptive name of valuation %>
<p class="text-gray-500">Manually entered</p>
</div>
<div class="flex text-sm font-medium text-right"><%= format_currency(valuation.value) %></div>
<div class="flex text-sm font-medium text-right"><%= format_money valuation.value_money %></div>
</div>
<div class="flex w-56 justify-end text-right text-sm font-medium">
<% if trend.amount == 0 %>
<span class="text-gray-500">No change</span>
<% else %>
<span class="<%= valuation_styles[:text_class] %>"><%= valuation_styles[:symbol] %><%= format_currency(trend.amount.abs) %></span>
<span class="<%= valuation_styles[:text_class] %>"><%= valuation_styles[:symbol] %><%= format_money trend.amount.abs %></span>
<span class="<%= valuation_styles[:text_class] %>">(<%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 align-text-bottom inline") %> <%= trend.percent %>%)</span>
<% end %>
</div>

View file

@ -40,7 +40,7 @@
<p><%= to_accountable_title(Accountable.from_type(group)) %></p>
<span class="text-gray-400 mx-2">&middot;</span>
<p><%= accounts.count %></p>
<p class="ml-auto"><%= format_currency accounts.sum(&:balance) %></p>
<p class="ml-auto"><%= format_money accounts.sum(&:balance_money) %></p>
</div>
<div class="bg-white">
<% accounts.each do |account| %>

View file

@ -69,11 +69,7 @@
<%= f.hidden_field :accountable_type %>
<%= f.text_field :name, placeholder: t('accounts.new.name.placeholder'), required: 'required', label: t('accounts.new.name.label'), autofocus: true %>
<%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %>
<%= form_field_tag do %>
<%= f.label :balance, class: "form-field__label" %>
<%= f.number_field :balance, step: :any, placeholder: number_to_currency(0), in: 0.00..100000000.00, required: 'required', class: 'form-field__input max-w-[80%]' %>
<%= currency_dropdown(f: f, options: Currency.all.order(:iso_code).pluck(:iso_code)) if Currency.count > 1 %>
<% end %>
<%= f.money_field :balance_money, label: "Balance", required: 'required' %>
</div>
<%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %>
<% end %>

View file

@ -1,5 +1,5 @@
<%= turbo_stream_from @account %>
<% balance = Money.from_amount(@account.balance, @account.currency) %>
<% balance = Money.new(@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 +11,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"><%= balance.currency %> <%= balance.symbol %></span>
<span class="text-gray-900"><%= balance.currency.iso_code %> <%= balance.currency.symbol %></span>
<%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
</div>
</div>
@ -29,7 +29,7 @@
<%= render partial: "shared/balance_heading", locals: {
label: "Total Value",
period: @period,
balance: Money.from_amount(@account.balance, @account.currency),
balance: @account.balance_money,
trend: @balance_series.trend
}
%>

View file

@ -15,7 +15,7 @@
<p><%= account_details[:allocation] %>%</p>
</div>
<div class="w-24">
<p><%= format_currency account_details[:end_balance] %></p>
<p><%= format_money account_details[:end_balance] %></p>
</div>
<div class="w-40">
<%= render partial: "shared/trend_change", locals: { trend: account_details[:trend] } %>
@ -39,7 +39,7 @@
<p><%= account[:allocation] %>%</p>
</div>
<div class="w-24">
<p><%= format_currency account[:end_balance] %></p>
<p><%= format_money account[:end_balance] %></p>
</div>
<div class="w-40">
<%= render partial: "shared/trend_change", locals: { trend: account[:trend] } %>

View file

@ -10,7 +10,7 @@
<%= render partial: "shared/balance_heading", locals: {
label: "Net Worth",
period: @period,
balance: Money.from_amount(Current.family.net_worth, Current.family.currency),
balance: Current.family.net_worth_money,
trend: @net_worth_series.trend
}
%>
@ -26,7 +26,7 @@
<%= render partial: "shared/balance_heading", locals: {
label: "Assets",
period: @period,
balance: Money.from_amount(Current.family.assets, Current.family.currency),
balance: Current.family.assets_money,
trend: @asset_series.trend
} %>
</div>
@ -44,7 +44,7 @@
label: "Liabilities",
period: @period,
size: "md",
balance: Money.from_amount(Current.family.liabilities, Current.family.currency),
balance: Current.family.liabilities_money,
trend: @liability_series.trend
} %>
</div>

View file

@ -2,11 +2,11 @@
<div class="space-y-2">
<p class="text-sm text-gray-500"><%= label %></p>
<p class="text-gray-900 -space-x-0.5">
<span class="text-gray-500"><%= balance.symbol %></span>
<span class="<%= size == "lg" ? "text-xl" : "text-lg" %> font-medium"><%= format_currency(balance.amount, precision: 0, unit: '') %></span>
<%- if balance.precision.positive? -%>
<span class="text-gray-500"><%= balance.currency.symbol %></span>
<span class="<%= size == "lg" ? "text-xl" : "text-lg" %> font-medium"><%= format_money_without_symbol balance, precision: 0 %></span>
<%- if balance.currency.default_precision.positive? -%>
<span class="text-gray-500">
<%= balance.separator %><%= balance.cents %>
<%= balance.currency.separator %><%= balance.cents_str %>
</span>
<% end %>
</p>

View file

@ -4,7 +4,7 @@
<% if trend.direction == "flat" %>
<span>No change</span>
<% else %>
<span><%= styles[:symbol] %><%= format_currency(trend.amount.abs) %></span>
<span><%= styles[:symbol] %><%= format_money trend.amount.abs %></span>
<span>(<%= lucide_icon(styles[:icon], class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent %>%)</span>
<% end %>
</p>

View file

@ -1,7 +1,7 @@
<%= form_with model: @transaction do |f| %>
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account" } %>
<%= f.date_field :date, label: "Date" %>
<%= f.text_field :name, label: "Name" %>
<%= f.number_field :amount, label: "Amount", step: :any, placeholder: number_to_currency(0), in: 0.00..100000000.00 %>
<%= f.text_field :name, label: "Name", placeholder: "Groceries" %>
<%= f.money_field :amount_money, label: "Amount" %>
<%= f.submit %>
<% end %>

View file

@ -12,6 +12,6 @@
<p><%= transaction.account.name %></p>
</div>
<div class="ml-auto">
<p class="<%= transaction.amount < 0 ? "text-green-600" : "" %>"><%= number_to_currency(-transaction.amount, { precision: 2 }) %></p>
<p class="<%= transaction.amount < 0 ? "text-green-600" : "" %>"><%= format_money -transaction.amount_money %></p>
</div>
<% end %>

View file

@ -2,7 +2,7 @@
<div class="bg-gray-25 rounded-xl p-1">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<h4><%= date.strftime('%b %d, %Y') %> &middot; <%= transactions.size %></h4>
<span><%= number_to_currency(-transactions.sum(&:amount)) %></span>
<span><%= format_money -transactions.sum(&:amount_money) %></span>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<%= render partial: "transactions/transaction", collection: transactions %>

View file

@ -23,13 +23,13 @@
<div class="p-4 space-y-2">
<p class="text-sm text-gray-500">Income</p>
<p class="text-gray-900 font-medium text-xl">
<%= number_to_currency(@transactions.select { |t| t.amount < 0 }.sum(&:amount).abs, precision: 2) %>
<%= format_money @transactions.inflows.sum(&:amount_money).abs %>
</p>
</div>
<div class="p-4 space-y-2">
<p class="text-sm text-gray-500">Expenses</p>
<p class="text-gray-900 font-medium text-xl">
<%= number_to_currency(@transactions.select { |t| t.amount >= 0 }.sum(&:amount), precision: 2) %>
<%= format_money @transactions.outflows.sum(&:amount_money) %>
</p>
</div>
</div>
@ -50,7 +50,6 @@
</div>
<div>
<%= form.select :date, options_for_select([['All', 'all'], ['7D', 'last_7_days'], ['1M', 'last_30_days'], ["1Y", "last_365_days"]], selected: params.dig(:q, :date)), {}, { class: "block h-full w-full border border-gray-200 rounded-lg text-sm py-2 pr-8 pl-2", "data-transactions-search-target": "date" } %>
<!-- Hidden fields for date range -->
<%= form.hidden_field :date_gteq, value: '', "data-transactions-search-target": "dateGteq" %>
<%= form.hidden_field :date_lteq, value: '', "data-transactions-search-target": "dateLteq" %>
@ -76,14 +75,12 @@
<p>amount</p>
</div>
</div>
<div class="space-y-6">
<% @transactions.group_by { |transaction| transaction.date }.each do |date, grouped_transactions| %>
<%= render partial: "transactions/transaction_group", locals: { date: date, transactions: grouped_transactions } %>
<% end %>
</div>
<% end %>
<% if @pagy.pages > 1 %>
<nav class="flex items-center justify-center px-4 mt-4 sm:px-0">
<%= render partial: "transactions/pagination", locals: { pagy: @pagy } %>

View file

@ -1,10 +1,9 @@
<%= sidebar_modal do %>
<h3 class="font-medium mb-1">
<span class="text-2xl"><%=format_currency @transaction.amount %></span>
<span class="text-2xl"><%=format_money @transaction.amount_money %></span>
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
</h3>
<span class="text-sm text-gray-500"><%= @transaction.date.strftime("%A %d %B") %></span>
<%= form_with model: @transaction, html: {data: {controller: "auto-submit-form"}} do |f| %>
<details class="group" open>
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-4 group-open:mb-2">
@ -14,12 +13,10 @@
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
</div>
</summary>
<%= f.date_field :date, label: "Date" %>
<div class="h-2"></div>
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account", class: "text-gray-400" }, {class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled"} %>
</details>
<details class="group" open>
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
@ -28,10 +25,8 @@
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
</div>
</summary>
<%= f.text_field :name, label: "Name" %>
</details>
<details class="group" open>
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
@ -40,7 +35,6 @@
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
</div>
</summary>
<label class="flex items-center cursor-pointer justify-between mx-3">
<%= f.check_box :excluded, class: "sr-only peer" %>
<div class="flex flex-col justify-center text-sm w-[340px] py-3">
@ -50,7 +44,6 @@
<div class="relative w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-100 peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</details>
<details class="group" open>
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 mb-2">
<div class="py-2 px-[11px] flex items-center justify-between font-medium text-xs text-gray-500">
@ -59,10 +52,7 @@
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
</div>
</summary>
<%= f.text_area :notes, label: "Notes", placeholder: "Enter a note" %>
</details>
<% end %>
<% end %>

View file

@ -7,7 +7,7 @@
</div>
<div class="flex items-center justify-between grow">
<%= f.date_field :date, required: 'required', class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %>
<%= f.number_field :value, step: :any, placeholder: number_to_currency(0), in: 0.00..100000000.00, required: 'required', class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %>
<%= f.number_field :value, required: 'required', placeholder: "0.00", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %>
</div>
<div class="w-[296px] flex gap-2 justify-end items-center">
<%= link_to "Cancel", account_path(@valuation.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %>

2520
config/currencies.yml Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1 @@
default_currency_options = { symbol: "$", precision: 2, delimiter: ",", separator: "." }
CURRENCY_OPTIONS = Hash.new { |hash, key| hash[key] = default_currency_options.dup }.merge(
"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?

View file

@ -2,13 +2,6 @@ class ReplaceMoneyField < ActiveRecord::Migration[7.2]
def change
add_column :accounts, :balance_cents, :integer
change_column :accounts, :balance_cents, :integer, limit: 8
Account.reset_column_information
Account.find_each do |account|
account.update_columns(balance_cents: Money.from_amount(account.balance_in_database, account.currency).cents)
end
remove_column :accounts, :balance
end
end

2
db/schema.rb generated
View file

@ -82,7 +82,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_03_09_180636) do
t.string "currency", default: "USD"
t.decimal "converted_balance", precision: 19, scale: 4, default: "0.0"
t.string "converted_currency", default: "USD"
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Account::Loan'::character varying)::text, ('Account::Credit'::character varying)::text, ('Account::OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Account::Loan'::character varying, 'Account::Credit'::character varying, 'Account::OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.boolean "is_active", default: true, null: false
t.enum "status", default: "ok", null: false, enum_type: "account_status"
t.jsonb "sync_warnings", default: "[]", null: false

61
lib/money.rb Normal file
View file

@ -0,0 +1,61 @@
class Money
include Comparable
include Arithmetic
attr_reader :amount, :currency
class << self
def default_currency
@default ||= Money::Currency.new(:usd)
end
def default_currency=(object)
@default = Money::Currency.new(object)
end
end
def initialize(obj, currency = Money.default_currency)
unless obj.is_a?(Money) || obj.is_a?(Numeric) || obj.is_a?(BigDecimal)
raise ArgumentError, "obj must be an instance of Money, Numeric, or BigDecimal"
end
@amount = obj.is_a?(Money) ? obj.amount : BigDecimal(obj.to_s)
@currency = obj.is_a?(Money) ? obj.currency : Money::Currency.new(currency)
end
def cents_str(precision = @currency.default_precision)
format_str = "%.#{precision}f"
amount_str = format_str % @amount
parts = amount_str.split(@currency.separator)
return "" if parts.length < 2
parts.last.ljust(precision, "0")
end
# Basic formatting only. Use the Rails number_to_currency helper for more advanced formatting.
alias to_s format
def format
whole_part, fractional_part = sprintf("%.#{@currency.default_precision}f", @amount).split(".")
whole_with_delimiters = whole_part.chars.to_a.reverse.each_slice(3).map(&:join).join(@currency.delimiter).reverse
formatted_amount = "#{whole_with_delimiters}#{@currency.separator}#{fractional_part}"
@currency.default_format.gsub("%n", formatted_amount).gsub("%u", @currency.symbol)
end
def <=>(other)
raise TypeError, "Money can only be compared with other Money objects except for 0" unless other.is_a?(Money) || other.eql?(0)
return @amount <=> other if other.is_a?(Numeric)
amount_comparison = @amount <=> other.amount
return amount_comparison unless amount_comparison == 0
@currency <=> other.currency
end
def default_format_options
{
unit: @currency.symbol,
precision: @currency.default_precision,
delimiter: @currency.delimiter,
separator: @currency.separator
}
end
end

62
lib/money/arithmetic.rb Normal file
View file

@ -0,0 +1,62 @@
module Money::Arithmetic
CoercedNumeric = Struct.new(:value)
def +(other)
if other.is_a?(Money)
self.class.new(amount + other.amount, currency)
else
value = other.is_a?(CoercedNumeric) ? other.value : other
self.class.new(amount + value, currency)
end
end
def -(other)
if other.is_a?(Money)
self.class.new(amount - other.amount, currency)
else
value = other.is_a?(CoercedNumeric) ? other.value : other
self.class.new(amount - value, currency)
end
end
def -@
self.class.new(-amount, currency)
end
def *(other)
raise TypeError, "Can't multiply Money by Money, use Numeric instead" if other.is_a?(self.class)
value = other.is_a?(CoercedNumeric) ? other.value : other
self.class.new(amount * value, currency)
end
def /(other)
if other.is_a?(self.class)
amount / other.amount
else
raise TypeError, "can't divide Numeric by Money" if other.is_a?(CoercedNumeric)
self.class.new(amount / other, currency)
end
end
def abs
self.class.new(amount.abs, currency)
end
def zero?
amount.zero?
end
def negative?
amount.negative?
end
def positive?
amount.positive?
end
# Override Ruby's coerce method so the order of operands doesn't matter
# Wrap in Coerced so we can distinguish between Money and other types
def coerce(other)
[ self, CoercedNumeric.new(other) ]
end
end

61
lib/money/currency.rb Normal file
View file

@ -0,0 +1,61 @@
class Money::Currency
include Comparable
class UnknownCurrencyError < ArgumentError; end
CURRENCIES_FILE_PATH = Rails.root.join("config", "currencies.yml")
# Cached instances by iso code
@@instances = {}
class << self
def new(object)
iso_code = case object
when String, Symbol
object.to_s.downcase
when Money::Currency
object.iso_code.downcase
else
raise ArgumentError, "Invalid argument type"
end
@@instances[iso_code] ||= super(iso_code)
end
def all
@all ||= YAML.load_file(CURRENCIES_FILE_PATH)
end
def popular
all.values.sort_by { |currency| currency["priority"] }.first(12).map { |currency_data| new(currency_data["iso_code"]) }
end
end
attr_reader :name, :priority, :iso_code, :iso_numeric, :html_code,
:symbol, :minor_unit, :minor_unit_conversion, :smallest_denomination,
:separator, :delimiter, :default_format, :default_precision
def initialize(iso_code)
currency_data = self.class.all[iso_code]
raise UnknownCurrencyError if currency_data.nil?
@name = currency_data["name"]
@priority = currency_data["priority"]
@iso_code = currency_data["iso_code"]
@iso_numeric = currency_data["iso_numeric"]
@html_code = currency_data["html_code"]
@symbol = currency_data["symbol"]
@minor_unit = currency_data["minor_unit"]
@minor_unit_conversion = currency_data["minor_unit_conversion"]
@smallest_denomination = currency_data["smallest_denomination"]
@separator = currency_data["separator"]
@delimiter = currency_data["delimiter"]
@default_format = currency_data["default_format"]
@default_precision = currency_data["default_precision"]
end
def <=>(other)
return nil unless other.is_a?(Money::Currency)
@iso_code <=> other.iso_code
end
end

View file

@ -22,7 +22,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
post transactions_url, params: { transaction: { account_id: @transaction.account_id, amount: @transaction.amount, currency: @transaction.currency, date: @transaction.date, name: } }
end
assert_redirected_to transaction_url(Transaction.find_by(name:))
assert_redirected_to transactions_url
end
test "should show transaction" do

View file

@ -0,0 +1,49 @@
require "test_helper"
class Money::CurrencyTest < ActiveSupport::TestCase
setup do
@currency = Money::Currency.new(:usd)
end
test "has many currencies" do
assert_operator Money::Currency.all.count, :>, 100
end
test "can test equality of currencies" do
assert_equal Money::Currency.new(:usd), Money::Currency.new(:usd)
assert_not_equal Money::Currency.new(:usd), Money::Currency.new(:eur)
end
test "can get metadata about a currency" do
assert_equal "USD", @currency.iso_code
assert_equal "United States Dollar", @currency.name
assert_equal "$", @currency.symbol
assert_equal 1, @currency.priority
assert_equal "Cent", @currency.minor_unit
assert_equal 100, @currency.minor_unit_conversion
assert_equal 1, @currency.smallest_denomination
assert_equal ".", @currency.separator
assert_equal ",", @currency.delimiter
assert_equal "%u%n", @currency.default_format
assert_equal 2, @currency.default_precision
end
test "can extract cents string from amount" do
value1 = Money.new(100)
value2 = Money.new(100.1)
value3 = Money.new(100.12)
value4 = Money.new(100.123)
value5 = Money.new(200, :jpy)
assert_equal "00", value1.cents_str
assert_equal "10", value2.cents_str
assert_equal "12", value3.cents_str
assert_equal "12", value4.cents_str
assert_equal "", value5.cents_str
assert_equal "", value4.cents_str(0)
assert_equal "1", value4.cents_str(1)
assert_equal "12", value4.cents_str(2)
assert_equal "123", value4.cents_str(3)
end
end

90
test/lib/money_test.rb Normal file
View file

@ -0,0 +1,90 @@
require "test_helper"
class MoneyTest < ActiveSupport::TestCase
test "can create with default currency" do
value = Money.new(1000)
assert_equal 1000, value.amount
end
test "can create with custom currency" do
value1 = Money.new(1000, :EUR)
value2 = Money.new(1000, :eur)
value3 = Money.new(1000, "eur")
value4 = Money.new(1000, "EUR")
assert_equal value1.currency.iso_code, value2.currency.iso_code
assert_equal value2.currency.iso_code, value3.currency.iso_code
assert_equal value3.currency.iso_code, value4.currency.iso_code
end
test "equality tests amount and currency" do
assert_equal Money.new(1000), Money.new(1000)
assert_not_equal Money.new(1000), Money.new(1001)
assert_not_equal Money.new(1000, :usd), Money.new(1000, :eur)
end
test "can compare with zero Numeric" do
assert_equal Money.new(0), 0
assert_raises(TypeError) { Money.new(1) == 1 }
end
test "can negate" do
assert_equal (-Money.new(1000)), Money.new(-1000)
end
test "can use comparison operators" do
assert_operator Money.new(1000), :>, Money.new(999)
assert_operator Money.new(1000), :>=, Money.new(1000)
assert_operator Money.new(1000), :<, Money.new(1001)
assert_operator Money.new(1000), :<=, Money.new(1000)
end
test "can add and subtract" do
assert_equal Money.new(1000) + Money.new(1000), Money.new(2000)
assert_equal Money.new(1000) + 1000, Money.new(2000)
assert_equal Money.new(1000) - Money.new(1000), Money.new(0)
assert_equal Money.new(1000) - 1000, Money.new(0)
end
test "can multiply" do
assert_equal Money.new(1000) * 2, Money.new(2000)
assert_raises(TypeError) { Money.new(1000) * Money.new(2) }
end
test "can divide" do
assert_equal Money.new(1000) / 2, Money.new(500)
assert_equal Money.new(1000) / Money.new(500), 2
assert_raise(TypeError) { 1000 / Money.new(2) }
end
test "operator order does not matter" do
assert_equal Money.new(1000) + 1000, 1000 + Money.new(1000)
assert_equal Money.new(1000) - 1000, 1000 - Money.new(1000)
assert_equal Money.new(1000) * 2, 2 * Money.new(1000)
end
test "can get absolute value" do
assert_equal Money.new(1000).abs, Money.new(1000)
assert_equal Money.new(-1000).abs, Money.new(1000)
end
test "can test if zero" do
assert Money.new(0).zero?
assert_not Money.new(1000).zero?
end
test "can test if negative" do
assert Money.new(-1000).negative?
assert_not Money.new(1000).negative?
end
test "can test if positive" do
assert Money.new(1000).positive?
assert_not Money.new(-1000).positive?
end
test "can cast to string with basic formatting" do
assert_equal "$1,000.90", Money.new(1000.899).format
assert_equal "€1.000,12", Money.new(1000.12, :eur).format
end
end

View file

@ -38,15 +38,15 @@ class FamilyTest < ActiveSupport::TestCase
end
test "should calculate total assets" do
assert_equal BigDecimal("25550"), @family.assets
assert_equal Money.new(25550), @family.assets_money
end
test "should calculate total liabilities" do
assert_equal BigDecimal("1000"), @family.liabilities
assert_equal Money.new(1000), @family.liabilities_money
end
test "should calculate net worth" do
assert_equal BigDecimal("24550"), @family.net_worth
assert_equal Money.new(24550), @family.net_worth_money
end
test "should calculate snapshot correctly" do

View file

@ -1,45 +0,0 @@
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