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:
parent
e5750d1a13
commit
fe2fa0eac1
43 changed files with 2982 additions and 196 deletions
|
@ -42,7 +42,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-field__input {
|
.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;
|
@apply focus:outline-none focus:ring-0 focus:opacity-100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,7 +35,7 @@ class TransactionsController < ApplicationController
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
if @transaction.save
|
if @transaction.save
|
||||||
format.html { redirect_to transaction_url(@transaction), notice: t(".success") }
|
format.html { redirect_to transactions_url, notice: t(".success") }
|
||||||
else
|
else
|
||||||
format.html { render :new, status: :unprocessable_entity }
|
format.html { render :new, status: :unprocessable_entity }
|
||||||
end
|
end
|
||||||
|
|
|
@ -22,6 +22,34 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
|
||||||
RUBY_EVAL
|
RUBY_EVAL
|
||||||
end
|
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 = {})
|
def select(method, choices, options = {}, html_options = {})
|
||||||
default_options = { class: "form-field__input" }
|
default_options = { class: "form-field__input" }
|
||||||
merged_options = default_options.merge(html_options)
|
merged_options = default_options.merge(html_options)
|
||||||
|
|
|
@ -27,9 +27,7 @@ module ApplicationHelper
|
||||||
render partial: "shared/modal", locals: { content: content }
|
render partial: "shared/modal", locals: { content: content }
|
||||||
end
|
end
|
||||||
|
|
||||||
def currency_dropdown(f: nil, options: [])
|
|
||||||
render partial: "shared/currency_dropdown", locals: { f: f, options: options }
|
|
||||||
end
|
|
||||||
|
|
||||||
def sidebar_modal(&block)
|
def sidebar_modal(&block)
|
||||||
content = capture &block
|
content = capture &block
|
||||||
|
@ -97,12 +95,15 @@ module ApplicationHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_currency(number, options = {})
|
def format_money(number_or_money, options = {})
|
||||||
user_currency_preference = Current.family.try(:currency) || "USD"
|
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]
|
def format_money_without_symbol(number_or_money, options = {})
|
||||||
options.reverse_merge!(currency_options)
|
money = Money.new(number_or_money)
|
||||||
|
options.reverse_merge!(money.default_format_options)
|
||||||
number_to_currency(number, options)
|
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,4 +2,8 @@ module FormsHelper
|
||||||
def form_field_tag(&)
|
def form_field_tag(&)
|
||||||
tag.div class: "form-field", &
|
tag.div class: "form-field", &
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def currency_dropdown(f: nil, options: [])
|
||||||
|
render partial: "shared/currency_dropdown", locals: { f: f, options: options }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,7 +17,7 @@ export default class extends Controller {
|
||||||
|
|
||||||
renderChart = () => {
|
renderChart = () => {
|
||||||
this.drawChart(this.seriesValue);
|
this.drawChart(this.seriesValue);
|
||||||
}
|
};
|
||||||
|
|
||||||
trendStyles(trendDirection) {
|
trendStyles(trendDirection) {
|
||||||
return {
|
return {
|
||||||
|
@ -45,11 +45,11 @@ export default class extends Controller {
|
||||||
formatted: {
|
formatted: {
|
||||||
value: Intl.NumberFormat("en-US", {
|
value: Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: b.currency || "USD",
|
currency: b.currency.iso_code || "USD",
|
||||||
}).format(b.amount),
|
}).format(b.amount),
|
||||||
change: Intl.NumberFormat("en-US", {
|
change: Intl.NumberFormat("en-US", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: b.currency || "USD",
|
currency: b.currency.iso_code || "USD",
|
||||||
signDisplay: "always",
|
signDisplay: "always",
|
||||||
}).format(b.trend.amount),
|
}).format(b.trend.amount),
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
class Account < ApplicationRecord
|
class Account < ApplicationRecord
|
||||||
include Syncable
|
include Syncable
|
||||||
|
include Monetizable
|
||||||
|
|
||||||
validates :family, presence: true
|
validates :family, presence: true
|
||||||
|
|
||||||
|
@ -9,6 +10,8 @@ class Account < ApplicationRecord
|
||||||
has_many :valuations
|
has_many :valuations
|
||||||
has_many :transactions
|
has_many :transactions
|
||||||
|
|
||||||
|
monetize :balance
|
||||||
|
|
||||||
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true
|
enum :status, { ok: "ok", syncing: "syncing", error: "error" }, validate: true
|
||||||
|
|
||||||
scope :active, -> { where(is_active: true) }
|
scope :active, -> { where(is_active: true) }
|
||||||
|
|
|
@ -6,8 +6,8 @@ class Account::BalanceCalculator
|
||||||
def daily_balances(start_date = nil)
|
def daily_balances(start_date = nil)
|
||||||
calc_start_date = [ start_date, @account.effective_start_date ].compact.max
|
calc_start_date = [ start_date, @account.effective_start_date ].compact.max
|
||||||
|
|
||||||
valuations = @account.valuations.where("date >= ?", calc_start_date).order(:date).select(:date, :value)
|
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)
|
transactions = @account.transactions.where("date > ?", calc_start_date).order(:date).select(:date, :amount, :currency)
|
||||||
oldest_entry = [ valuations.first, transactions.first ].compact.min_by(&:date)
|
oldest_entry = [ valuations.first, transactions.first ].compact.min_by(&:date)
|
||||||
|
|
||||||
net_transaction_flows = transactions.sum(&:amount)
|
net_transaction_flows = transactions.sum(&:amount)
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
class AccountBalance < ApplicationRecord
|
class AccountBalance < ApplicationRecord
|
||||||
belongs_to :account
|
include Monetizable
|
||||||
|
|
||||||
|
belongs_to :account
|
||||||
validates :account, :date, :balance, presence: true
|
validates :account, :date, :balance, presence: true
|
||||||
|
monetize :balance
|
||||||
|
|
||||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||||
|
|
||||||
|
|
14
app/models/concerns/monetizable.rb
Normal file
14
app/models/concerns/monetizable.rb
Normal 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
|
|
@ -1,9 +1,13 @@
|
||||||
class Family < ApplicationRecord
|
class Family < ApplicationRecord
|
||||||
|
include Monetizable
|
||||||
|
|
||||||
has_many :users, dependent: :destroy
|
has_many :users, dependent: :destroy
|
||||||
has_many :accounts, dependent: :destroy
|
has_many :accounts, dependent: :destroy
|
||||||
has_many :transactions, through: :accounts
|
has_many :transactions, through: :accounts
|
||||||
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
|
has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category"
|
||||||
|
|
||||||
|
monetize :net_worth, :assets, :liabilities
|
||||||
|
|
||||||
def snapshot(period = Period.all)
|
def snapshot(period = Period.all)
|
||||||
query = accounts.active.joins(:balances)
|
query = accounts.active.joins(:balances)
|
||||||
.where("account_balances.currency = ?", self.currency)
|
.where("account_balances.currency = ?", self.currency)
|
||||||
|
@ -35,7 +39,7 @@ class Family < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def assets
|
def assets
|
||||||
accounts.active.assets.sum(:balance)
|
accounts.active.assets.sum(:balance)
|
||||||
end
|
end
|
||||||
|
|
||||||
def liabilities
|
def liabilities
|
||||||
|
|
|
@ -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
|
|
|
@ -14,7 +14,7 @@ class MoneySeries
|
||||||
{
|
{
|
||||||
raw: current,
|
raw: current,
|
||||||
date: current.date,
|
date: current.date,
|
||||||
value: Money.from_amount(current.send(@accessor), current.currency),
|
value: Money.new(current.send(@accessor), current.currency),
|
||||||
trend: Trend.new(
|
trend: Trend.new(
|
||||||
current: current.send(@accessor),
|
current: current.send(@accessor),
|
||||||
previous: previous&.send(@accessor),
|
previous: previous&.send(@accessor),
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
class Transaction < ApplicationRecord
|
class Transaction < ApplicationRecord
|
||||||
|
include Monetizable
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :category, optional: true
|
belongs_to :category, optional: true
|
||||||
|
|
||||||
|
@ -6,6 +8,12 @@ class Transaction < ApplicationRecord
|
||||||
|
|
||||||
after_commit :sync_account
|
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)
|
def self.ransackable_attributes(auth_object = nil)
|
||||||
%w[name amount date]
|
%w[name amount date]
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
class Valuation < ApplicationRecord
|
class Valuation < ApplicationRecord
|
||||||
|
include Monetizable
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
|
|
||||||
validates :account, :date, :value, presence: true
|
validates :account, :date, :value, presence: true
|
||||||
|
|
||||||
after_commit :sync_account
|
after_commit :sync_account
|
||||||
|
monetize :value
|
||||||
|
|
||||||
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-8">
|
<div class="flex items-center gap-8">
|
||||||
<p class="text-sm font-medium <%= account.is_active ? "text-gray-900" : "text-gray-400" %>">
|
<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>
|
</p>
|
||||||
<%= form_with model: account, method: :patch, html: { class: "flex items-center", data: { turbo_frame: "_top" } } do |form| %>
|
<%= form_with model: account, method: :patch, html: { class: "flex items-center", data: { turbo_frame: "_top" } } do |form| %>
|
||||||
<div class="relative inline-block select-none">
|
<div class="relative inline-block select-none">
|
||||||
|
|
|
@ -1,31 +1,27 @@
|
||||||
<%# locals: (type:) -%>
|
<%# locals: (type:) -%>
|
||||||
|
|
||||||
<% accounts = Current.family.accounts.where(accountable_type: type.name) %>
|
<% accounts = Current.family.accounts.where(accountable_type: type.name) %>
|
||||||
|
|
||||||
<% if accounts.sum(&:converted_balance) > 0 %>
|
<% if accounts.sum(&:converted_balance) > 0 %>
|
||||||
<details class="mb-1 text-sm group" data-controller="account-collapse" data-account-collapse-type-value="<%= type %>">
|
<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">
|
<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-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") %>
|
<%= 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="text-left"><%= type.model_name.human %></div>
|
||||||
<div class="ml-auto"><%= format_currency accounts.sum(&:converted_balance) %></div>
|
<div class="ml-auto"><%= format_money accounts.sum(&:converted_balance) %></div>
|
||||||
</summary>
|
</summary>
|
||||||
|
<% accounts.each do |account| %>
|
||||||
<% 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 %>
|
||||||
<%= 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>
|
||||||
<div>
|
<p class="font-medium"><%= account.name %></p>
|
||||||
<p class="font-medium"><%= account.name %></p>
|
<% if account.subtype %>
|
||||||
<% if account.subtype %>
|
<p class="text-xs text-gray-500"><%= account.subtype&.humanize %></p>
|
||||||
<p class="text-xs text-gray-500"><%= account.subtype&.humanize %></p>
|
<% end %>
|
||||||
<% end %>
|
</div>
|
||||||
</div>
|
<p class="ml-auto font-medium"><%= format_money account.converted_balance %></p>
|
||||||
<p class="ml-auto font-medium"><%= format_currency account.converted_balance %></p>
|
<% end %>
|
||||||
<% 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") %>
|
||||||
<%= 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 %>
|
<p>New <%= type.model_name.human.downcase %></p>
|
||||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
<% end %>
|
||||||
<p>New <%= type.model_name.human.downcase %></p>
|
</details>
|
||||||
<% end %>
|
|
||||||
</details>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -15,13 +15,13 @@
|
||||||
<%# TODO: Add descriptive name of valuation %>
|
<%# TODO: Add descriptive name of valuation %>
|
||||||
<p class="text-gray-500">Manually entered</p>
|
<p class="text-gray-500">Manually entered</p>
|
||||||
</div>
|
</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>
|
||||||
<div class="flex w-56 justify-end text-right text-sm font-medium">
|
<div class="flex w-56 justify-end text-right text-sm font-medium">
|
||||||
<% if trend.amount == 0 %>
|
<% if trend.amount == 0 %>
|
||||||
<span class="text-gray-500">No change</span>
|
<span class="text-gray-500">No change</span>
|
||||||
<% else %>
|
<% 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>
|
<span class="<%= valuation_styles[:text_class] %>">(<%= lucide_icon(valuation_styles[:icon], class: "w-4 h-4 align-text-bottom inline") %> <%= trend.percent %>%)</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -40,7 +40,7 @@
|
||||||
<p><%= to_accountable_title(Accountable.from_type(group)) %></p>
|
<p><%= to_accountable_title(Accountable.from_type(group)) %></p>
|
||||||
<span class="text-gray-400 mx-2">·</span>
|
<span class="text-gray-400 mx-2">·</span>
|
||||||
<p><%= accounts.count %></p>
|
<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>
|
||||||
<div class="bg-white">
|
<div class="bg-white">
|
||||||
<% accounts.each do |account| %>
|
<% accounts.each do |account| %>
|
||||||
|
|
|
@ -69,11 +69,7 @@
|
||||||
<%= f.hidden_field :accountable_type %>
|
<%= 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 %>
|
<%= 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 %>
|
<%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %>
|
||||||
<%= form_field_tag do %>
|
<%= f.money_field :balance_money, label: "Balance", required: 'required' %>
|
||||||
<%= 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 %>
|
|
||||||
</div>
|
</div>
|
||||||
<%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %>
|
<%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<%= turbo_stream_from @account %>
|
<%= 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="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 +11,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"><%= 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") %>
|
<%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -29,7 +29,7 @@
|
||||||
<%= render partial: "shared/balance_heading", locals: {
|
<%= render partial: "shared/balance_heading", locals: {
|
||||||
label: "Total Value",
|
label: "Total Value",
|
||||||
period: @period,
|
period: @period,
|
||||||
balance: Money.from_amount(@account.balance, @account.currency),
|
balance: @account.balance_money,
|
||||||
trend: @balance_series.trend
|
trend: @balance_series.trend
|
||||||
}
|
}
|
||||||
%>
|
%>
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
<p><%= account_details[:allocation] %>%</p>
|
<p><%= account_details[:allocation] %>%</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
<div class="w-24">
|
||||||
<p><%= format_currency account_details[:end_balance] %></p>
|
<p><%= format_money account_details[:end_balance] %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-40">
|
<div class="w-40">
|
||||||
<%= render partial: "shared/trend_change", locals: { trend: account_details[:trend] } %>
|
<%= render partial: "shared/trend_change", locals: { trend: account_details[:trend] } %>
|
||||||
|
@ -39,7 +39,7 @@
|
||||||
<p><%= account[:allocation] %>%</p>
|
<p><%= account[:allocation] %>%</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
<div class="w-24">
|
||||||
<p><%= format_currency account[:end_balance] %></p>
|
<p><%= format_money account[:end_balance] %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-40">
|
<div class="w-40">
|
||||||
<%= render partial: "shared/trend_change", locals: { trend: account[:trend] } %>
|
<%= render partial: "shared/trend_change", locals: { trend: account[:trend] } %>
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
<%= render partial: "shared/balance_heading", locals: {
|
<%= render partial: "shared/balance_heading", locals: {
|
||||||
label: "Net Worth",
|
label: "Net Worth",
|
||||||
period: @period,
|
period: @period,
|
||||||
balance: Money.from_amount(Current.family.net_worth, Current.family.currency),
|
balance: Current.family.net_worth_money,
|
||||||
trend: @net_worth_series.trend
|
trend: @net_worth_series.trend
|
||||||
}
|
}
|
||||||
%>
|
%>
|
||||||
|
@ -26,7 +26,7 @@
|
||||||
<%= render partial: "shared/balance_heading", locals: {
|
<%= render partial: "shared/balance_heading", locals: {
|
||||||
label: "Assets",
|
label: "Assets",
|
||||||
period: @period,
|
period: @period,
|
||||||
balance: Money.from_amount(Current.family.assets, Current.family.currency),
|
balance: Current.family.assets_money,
|
||||||
trend: @asset_series.trend
|
trend: @asset_series.trend
|
||||||
} %>
|
} %>
|
||||||
</div>
|
</div>
|
||||||
|
@ -44,7 +44,7 @@
|
||||||
label: "Liabilities",
|
label: "Liabilities",
|
||||||
period: @period,
|
period: @period,
|
||||||
size: "md",
|
size: "md",
|
||||||
balance: Money.from_amount(Current.family.liabilities, Current.family.currency),
|
balance: Current.family.liabilities_money,
|
||||||
trend: @liability_series.trend
|
trend: @liability_series.trend
|
||||||
} %>
|
} %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,11 +2,11 @@
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="text-sm text-gray-500"><%= label %></p>
|
<p class="text-sm text-gray-500"><%= label %></p>
|
||||||
<p class="text-gray-900 -space-x-0.5">
|
<p class="text-gray-900 -space-x-0.5">
|
||||||
<span class="text-gray-500"><%= balance.symbol %></span>
|
<span class="text-gray-500"><%= balance.currency.symbol %></span>
|
||||||
<span class="<%= size == "lg" ? "text-xl" : "text-lg" %> font-medium"><%= format_currency(balance.amount, precision: 0, unit: '') %></span>
|
<span class="<%= size == "lg" ? "text-xl" : "text-lg" %> font-medium"><%= format_money_without_symbol balance, precision: 0 %></span>
|
||||||
<%- if balance.precision.positive? -%>
|
<%- if balance.currency.default_precision.positive? -%>
|
||||||
<span class="text-gray-500">
|
<span class="text-gray-500">
|
||||||
<%= balance.separator %><%= balance.cents %>
|
<%= balance.currency.separator %><%= balance.cents_str %>
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
<% if trend.direction == "flat" %>
|
<% if trend.direction == "flat" %>
|
||||||
<span>No change</span>
|
<span>No change</span>
|
||||||
<% else %>
|
<% 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>
|
<span>(<%= lucide_icon(styles[:icon], class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent %>%)</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</p>
|
|
@ -1,7 +1,7 @@
|
||||||
<%= form_with model: @transaction do |f| %>
|
<%= form_with model: @transaction do |f| %>
|
||||||
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account" } %>
|
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account" } %>
|
||||||
<%= f.date_field :date, label: "Date" %>
|
<%= f.date_field :date, label: "Date" %>
|
||||||
<%= f.text_field :name, label: "Name" %>
|
<%= f.text_field :name, label: "Name", placeholder: "Groceries" %>
|
||||||
<%= f.number_field :amount, label: "Amount", step: :any, placeholder: number_to_currency(0), in: 0.00..100000000.00 %>
|
<%= f.money_field :amount_money, label: "Amount" %>
|
||||||
<%= f.submit %>
|
<%= f.submit %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -12,6 +12,6 @@
|
||||||
<p><%= transaction.account.name %></p>
|
<p><%= transaction.account.name %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-auto">
|
<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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="bg-gray-25 rounded-xl p-1">
|
<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">
|
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||||
<h4><%= date.strftime('%b %d, %Y') %> · <%= transactions.size %></h4>
|
<h4><%= date.strftime('%b %d, %Y') %> · <%= transactions.size %></h4>
|
||||||
<span><%= number_to_currency(-transactions.sum(&:amount)) %></span>
|
<span><%= format_money -transactions.sum(&:amount_money) %></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
<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 %>
|
<%= render partial: "transactions/transaction", collection: transactions %>
|
||||||
|
|
|
@ -23,13 +23,13 @@
|
||||||
<div class="p-4 space-y-2">
|
<div class="p-4 space-y-2">
|
||||||
<p class="text-sm text-gray-500">Income</p>
|
<p class="text-sm text-gray-500">Income</p>
|
||||||
<p class="text-gray-900 font-medium text-xl">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 space-y-2">
|
<div class="p-4 space-y-2">
|
||||||
<p class="text-sm text-gray-500">Expenses</p>
|
<p class="text-sm text-gray-500">Expenses</p>
|
||||||
<p class="text-gray-900 font-medium text-xl">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -50,7 +50,6 @@
|
||||||
</div>
|
</div>
|
||||||
<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" } %>
|
<%= 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 -->
|
<!-- Hidden fields for date range -->
|
||||||
<%= form.hidden_field :date_gteq, value: '', "data-transactions-search-target": "dateGteq" %>
|
<%= form.hidden_field :date_gteq, value: '', "data-transactions-search-target": "dateGteq" %>
|
||||||
<%= form.hidden_field :date_lteq, value: '', "data-transactions-search-target": "dateLteq" %>
|
<%= form.hidden_field :date_lteq, value: '', "data-transactions-search-target": "dateLteq" %>
|
||||||
|
@ -76,14 +75,12 @@
|
||||||
<p>amount</p>
|
<p>amount</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<% @transactions.group_by { |transaction| transaction.date }.each do |date, grouped_transactions| %>
|
<% @transactions.group_by { |transaction| transaction.date }.each do |date, grouped_transactions| %>
|
||||||
<%= render partial: "transactions/transaction_group", locals: { date: date, transactions: grouped_transactions } %>
|
<%= render partial: "transactions/transaction_group", locals: { date: date, transactions: grouped_transactions } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if @pagy.pages > 1 %>
|
<% if @pagy.pages > 1 %>
|
||||||
<nav class="flex items-center justify-center px-4 mt-4 sm:px-0">
|
<nav class="flex items-center justify-center px-4 mt-4 sm:px-0">
|
||||||
<%= render partial: "transactions/pagination", locals: { pagy: @pagy } %>
|
<%= render partial: "transactions/pagination", locals: { pagy: @pagy } %>
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
<%= sidebar_modal do %>
|
<%= sidebar_modal do %>
|
||||||
<h3 class="font-medium mb-1">
|
<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>
|
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
|
||||||
</h3>
|
</h3>
|
||||||
<span class="text-sm text-gray-500"><%= @transaction.date.strftime("%A %d %B") %></span>
|
<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| %>
|
<%= form_with model: @transaction, html: {data: {controller: "auto-submit-form"}} do |f| %>
|
||||||
<details class="group" open>
|
<details class="group" open>
|
||||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-4 group-open:mb-2">
|
<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") %>
|
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<%= f.date_field :date, label: "Date" %>
|
<%= f.date_field :date, label: "Date" %>
|
||||||
<div class="h-2"></div>
|
<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"} %>
|
<%= 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>
|
||||||
|
|
||||||
<details class="group" open>
|
<details class="group" open>
|
||||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
<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">
|
<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") %>
|
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<%= f.text_field :name, label: "Name" %>
|
<%= f.text_field :name, label: "Name" %>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="group" open>
|
<details class="group" open>
|
||||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 group-open:mb-2">
|
<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">
|
<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") %>
|
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<label class="flex items-center cursor-pointer justify-between mx-3">
|
<label class="flex items-center cursor-pointer justify-between mx-3">
|
||||||
<%= f.check_box :excluded, class: "sr-only peer" %>
|
<%= f.check_box :excluded, class: "sr-only peer" %>
|
||||||
<div class="flex flex-col justify-center text-sm w-[340px] py-3">
|
<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>
|
<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>
|
</label>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="group" open>
|
<details class="group" open>
|
||||||
<summary class="list-none bg-gray-25 rounded-xl py-1 mt-6 mb-2">
|
<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">
|
<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") %>
|
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<%= f.text_area :notes, label: "Notes", placeholder: "Enter a note" %>
|
<%= f.text_area :notes, label: "Notes", placeholder: "Enter a note" %>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between grow">
|
<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.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>
|
||||||
<div class="w-[296px] flex gap-2 justify-end items-center">
|
<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" %>
|
<%= 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
2520
config/currencies.yml
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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?
|
EXCHANGE_RATE_ENABLED = ENV["OPEN_EXCHANGE_APP_ID"].present?
|
||||||
|
|
|
@ -2,13 +2,6 @@ class ReplaceMoneyField < ActiveRecord::Migration[7.2]
|
||||||
def change
|
def change
|
||||||
add_column :accounts, :balance_cents, :integer
|
add_column :accounts, :balance_cents, :integer
|
||||||
change_column :accounts, :balance_cents, :integer, limit: 8
|
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
|
remove_column :accounts, :balance
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
2
db/schema.rb
generated
2
db/schema.rb
generated
|
@ -82,7 +82,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_03_09_180636) do
|
||||||
t.string "currency", default: "USD"
|
t.string "currency", default: "USD"
|
||||||
t.decimal "converted_balance", precision: 19, scale: 4, default: "0.0"
|
t.decimal "converted_balance", precision: 19, scale: 4, default: "0.0"
|
||||||
t.string "converted_currency", default: "USD"
|
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.boolean "is_active", default: true, null: false
|
||||||
t.enum "status", default: "ok", null: false, enum_type: "account_status"
|
t.enum "status", default: "ok", null: false, enum_type: "account_status"
|
||||||
t.jsonb "sync_warnings", default: "[]", null: false
|
t.jsonb "sync_warnings", default: "[]", null: false
|
||||||
|
|
61
lib/money.rb
Normal file
61
lib/money.rb
Normal 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
62
lib/money/arithmetic.rb
Normal 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
61
lib/money/currency.rb
Normal 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
|
|
@ -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: } }
|
post transactions_url, params: { transaction: { account_id: @transaction.account_id, amount: @transaction.amount, currency: @transaction.currency, date: @transaction.date, name: } }
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_redirected_to transaction_url(Transaction.find_by(name:))
|
assert_redirected_to transactions_url
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should show transaction" do
|
test "should show transaction" do
|
||||||
|
|
49
test/lib/money/currency_test.rb
Normal file
49
test/lib/money/currency_test.rb
Normal 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
90
test/lib/money_test.rb
Normal 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
|
|
@ -38,15 +38,15 @@ class FamilyTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should calculate total assets" do
|
test "should calculate total assets" do
|
||||||
assert_equal BigDecimal("25550"), @family.assets
|
assert_equal Money.new(25550), @family.assets_money
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should calculate total liabilities" do
|
test "should calculate total liabilities" do
|
||||||
assert_equal BigDecimal("1000"), @family.liabilities
|
assert_equal Money.new(1000), @family.liabilities_money
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should calculate net worth" do
|
test "should calculate net worth" do
|
||||||
assert_equal BigDecimal("24550"), @family.net_worth
|
assert_equal Money.new(24550), @family.net_worth_money
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should calculate snapshot correctly" do
|
test "should calculate snapshot correctly" do
|
||||||
|
|
|
@ -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
|
|
Loading…
Add table
Add a link
Reference in a new issue