diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index c08078e0..5cb8dd62 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -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; } diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 484fd7c8..88ebb2a9 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -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 diff --git a/app/helpers/application_form_builder.rb b/app/helpers/application_form_builder.rb index e5c1f24a..c7b96857 100644 --- a/app/helpers/application_form_builder.rb +++ b/app/helpers/application_form_builder.rb @@ -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) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c7051ed2..43e6f831 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index ac138976..4228b7bf 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -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 diff --git a/app/javascript/controllers/line_chart_controller.js b/app/javascript/controllers/line_chart_controller.js index 2d16079a..9fbb70ba 100644 --- a/app/javascript/controllers/line_chart_controller.js +++ b/app/javascript/controllers/line_chart_controller.js @@ -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), }, diff --git a/app/models/account.rb b/app/models/account.rb index 5c77a16c..fb5f7037 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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) } diff --git a/app/models/account/balance_calculator.rb b/app/models/account/balance_calculator.rb index 640ec640..9dbf2f55 100644 --- a/app/models/account/balance_calculator.rb +++ b/app/models/account/balance_calculator.rb @@ -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) diff --git a/app/models/account_balance.rb b/app/models/account_balance.rb index 9915ddde..6a47d368 100644 --- a/app/models/account_balance.rb +++ b/app/models/account_balance.rb @@ -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) } diff --git a/app/models/concerns/monetizable.rb b/app/models/concerns/monetizable.rb new file mode 100644 index 00000000..909c7210 --- /dev/null +++ b/app/models/concerns/monetizable.rb @@ -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 diff --git a/app/models/family.rb b/app/models/family.rb index 3f81b93b..a04522e1 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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 diff --git a/app/models/money.rb b/app/models/money.rb deleted file mode 100644 index b61c1fb3..00000000 --- a/app/models/money.rb +++ /dev/null @@ -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 diff --git a/app/models/money_series.rb b/app/models/money_series.rb index f9cea5d9..c55d095f 100644 --- a/app/models/money_series.rb +++ b/app/models/money_series.rb @@ -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), diff --git a/app/models/transaction.rb b/app/models/transaction.rb index 43ea6480..6e8cdbca 100644 --- a/app/models/transaction.rb +++ b/app/models/transaction.rb @@ -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 diff --git a/app/models/valuation.rb b/app/models/valuation.rb index 70c520bf..2f8dc919 100644 --- a/app/models/valuation.rb +++ b/app/models/valuation.rb @@ -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) } diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index 745c9549..ac7c27bc 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -10,7 +10,7 @@
"> - <%= format_currency account.balance %> + <%= format_money account.balance_money %>
<%= account.name %>
<%= account.subtype&.humanize %>
<%= format_currency account.converted_balance %>
<%= format_money account.converted_balance %>
New <%= type.model_name.human.downcase %>
Manually entered
<%= to_accountable_title(Accountable.from_type(group)) %>
<%= accounts.count %>
<%= format_currency accounts.sum(&:balance) %>
<%= format_money accounts.sum(&:balance_money) %>
<%= account_details[:allocation] %>%
<%= format_currency account_details[:end_balance] %>
<%= format_money account_details[:end_balance] %>
<%= account[:allocation] %>%
<%= format_currency account[:end_balance] %>
<%= format_money account[:end_balance] %>
<%= label %>
- <%= balance.symbol %> - font-medium"><%= format_currency(balance.amount, precision: 0, unit: '') %> - <%- if balance.precision.positive? -%> + <%= balance.currency.symbol %> + font-medium"><%= format_money_without_symbol balance, precision: 0 %> + <%- if balance.currency.default_precision.positive? -%> - <%= balance.separator %><%= balance.cents %> + <%= balance.currency.separator %><%= balance.cents_str %> <% end %>
<%= transaction.account.name %>
"><%= number_to_currency(-transaction.amount, { precision: 2 }) %>
"><%= format_money -transaction.amount_money %>
Income
- <%= number_to_currency(@transactions.select { |t| t.amount < 0 }.sum(&:amount).abs, precision: 2) %> + <%= format_money @transactions.inflows.sum(&:amount_money).abs %>
Expenses
- <%= number_to_currency(@transactions.select { |t| t.amount >= 0 }.sum(&:amount), precision: 2) %> + <%= format_money @transactions.outflows.sum(&:amount_money) %>
amount