mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-24 15:49:39 +02:00
Multi-currency support (#425)
* Initial foundational pass at multi-currency * Default format currency * More work on currency and exchanging * Re-build currencies on change * Currency import/setup * Background job overhaul + cheaper OXR plan support * Lint fixes * Test fixes * Multi-currency setup instructions * Allow decimals in the balance field * Spacing fix for form --------- Signed-off-by: Josh Pigford <josh@joshpigford.com>
This commit is contained in:
parent
94f7b4ea8f
commit
aa351ae616
41 changed files with 634 additions and 176 deletions
|
@ -3,7 +3,7 @@ class AccountsController < ApplicationController
|
|||
|
||||
def new
|
||||
@account = Account.new(
|
||||
balance: nil,
|
||||
original_balance: nil,
|
||||
accountable: Accountable.from_type(params[:type])&.new
|
||||
)
|
||||
end
|
||||
|
@ -25,6 +25,6 @@ class AccountsController < ApplicationController
|
|||
private
|
||||
|
||||
def account_params
|
||||
params.require(:account).permit(:name, :accountable_type, :balance, :balance_cents, :subtype)
|
||||
params.require(:account).permit(:name, :accountable_type, :original_balance, :original_currency, :subtype)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,7 +5,19 @@ class SettingsController < ApplicationController
|
|||
end
|
||||
|
||||
def update
|
||||
if Current.user.update(user_params)
|
||||
user_params_with_family = user_params
|
||||
# Ensure we're only updating the family associated with the current user
|
||||
if Current.family
|
||||
family_attributes = user_params_with_family[:family_attributes].merge({ id: Current.family.id })
|
||||
user_params_with_family[:family_attributes] = family_attributes
|
||||
end
|
||||
|
||||
# If the family attribute for currency is changed, we need to convert all account balances to the new currency with the ConvertCurrencyJob job
|
||||
if user_params_with_family[:family_attributes][:currency] != Current.family.currency
|
||||
ConvertCurrencyJob.perform_later(Current.family)
|
||||
end
|
||||
|
||||
if Current.user.update(user_params_with_family)
|
||||
redirect_to root_path, notice: "Profile updated successfully."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
|
@ -16,6 +28,6 @@ class SettingsController < ApplicationController
|
|||
|
||||
def user_params
|
||||
params.require(:user).permit(:first_name, :last_name, :email, :password, :password_confirmation,
|
||||
family_attributes: [ :name, :id ])
|
||||
family_attributes: [ :name, :id, :currency ])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -17,4 +17,37 @@ module ApplicationHelper
|
|||
content = capture &block
|
||||
render partial: "shared/modal", locals: { content: content }
|
||||
end
|
||||
|
||||
def format_currency(number, options = {})
|
||||
user_currency_preference = Current.family.try(:currency) || "USD"
|
||||
|
||||
case user_currency_preference
|
||||
when "USD"
|
||||
options.reverse_merge!(unit: "$", precision: 2, delimiter: ",", separator: ".")
|
||||
when "EUR"
|
||||
options.reverse_merge!(unit: "€", precision: 2, delimiter: ".", separator: ",")
|
||||
when "GBP"
|
||||
options.reverse_merge!(unit: "£", precision: 2, delimiter: ",", separator: ".")
|
||||
when "CAD"
|
||||
options.reverse_merge!(unit: "C$", precision: 2, delimiter: ",", separator: ".")
|
||||
when "MXN"
|
||||
options.reverse_merge!(unit: "MX$", precision: 2, delimiter: ",", separator: ".")
|
||||
when "HKD"
|
||||
options.reverse_merge!(unit: "HK$", precision: 2, delimiter: ",", separator: ".")
|
||||
when "CHF"
|
||||
options.reverse_merge!(unit: "CHF", precision: 2, delimiter: ".", separator: ",")
|
||||
when "SGD"
|
||||
options.reverse_merge!(unit: "S$", precision: 2, delimiter: ",", separator: ".")
|
||||
when "NZD"
|
||||
options.reverse_merge!(unit: "NZ$", precision: 2, delimiter: ",", separator: ".")
|
||||
when "AUD"
|
||||
options.reverse_merge!(unit: "A$", precision: 2, delimiter: ",", separator: ".")
|
||||
when "KRW"
|
||||
options.reverse_merge!(unit: "₩", precision: 0, delimiter: ",", separator: ".")
|
||||
else
|
||||
options.reverse_merge!(unit: "$", precision: 2, delimiter: ",", separator: ".")
|
||||
end
|
||||
|
||||
number_to_currency(number, options)
|
||||
end
|
||||
end
|
||||
|
|
19
app/jobs/convert_currency_job.rb
Normal file
19
app/jobs/convert_currency_job.rb
Normal file
|
@ -0,0 +1,19 @@
|
|||
class ConvertCurrencyJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(family)
|
||||
family = Family.find(family.id)
|
||||
|
||||
# Convert all account balances to new currency
|
||||
family.accounts.each do |account|
|
||||
if account.original_currency == family.currency
|
||||
account.converted_balance = account.original_balance
|
||||
account.converted_currency = account.original_currency
|
||||
else
|
||||
account.converted_balance = ExchangeRate.convert(account.original_currency, family.currency, account.original_balance)
|
||||
account.converted_currency = family.currency
|
||||
end
|
||||
account.save!
|
||||
end
|
||||
end
|
||||
end
|
34
app/jobs/daily_exchange_rate_job.rb
Normal file
34
app/jobs/daily_exchange_rate_job.rb
Normal file
|
@ -0,0 +1,34 @@
|
|||
class DailyExchangeRateJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
app_id = ENV["OPEN_EXCHANGE_APP_ID"]
|
||||
|
||||
# Get the last date for which exchange rates were fetched for each currency
|
||||
last_fetched_dates = ExchangeRate.group(:base_currency).maximum(:date)
|
||||
|
||||
# Loop through each currency and fetch exchange rates for each
|
||||
Currency.all.each do |currency|
|
||||
last_fetched_date = last_fetched_dates[currency.iso_code] || Date.yesterday
|
||||
next_day = last_fetched_date + 1.day
|
||||
response = Faraday.get("https://openexchangerates.org/api/historical/#{next_day}.json") do |req|
|
||||
req.params["app_id"] = app_id
|
||||
req.params["base"] = currency.iso_code
|
||||
req.params["symbols"] = Currency.where.not(iso_code: currency.iso_code).pluck(:iso_code).join(",")
|
||||
end
|
||||
|
||||
if response.success?
|
||||
rates = JSON.parse(response.body)["rates"]
|
||||
|
||||
rates.each do |currency_iso_code, value|
|
||||
ExchangeRate.find_or_create_by(date: Date.today, base_currency: currency.iso_code, converted_currency: currency_iso_code) do |exchange_rate|
|
||||
exchange_rate.rate = value
|
||||
end
|
||||
puts "#{currency.iso_code} to #{currency_iso_code} on #{Date.today}: #{value}"
|
||||
end
|
||||
else
|
||||
puts "Failed to fetch exchange rates for #{currency.iso_code} on #{Date.today}: #{response.status}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -5,5 +5,15 @@ class Account < ApplicationRecord
|
|||
|
||||
delegate :type_name, to: :accountable
|
||||
|
||||
monetize :balance_cents
|
||||
before_create :check_currency
|
||||
|
||||
def check_currency
|
||||
if self.original_currency == self.family.currency
|
||||
self.converted_balance = self.original_balance
|
||||
self.converted_currency = self.original_currency
|
||||
else
|
||||
self.converted_balance = ExchangeRate.convert(self.original_currency, self.family.currency, self.original_balance)
|
||||
self.converted_currency = self.family.currency
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
2
app/models/currency.rb
Normal file
2
app/models/currency.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
class Currency < ApplicationRecord
|
||||
end
|
6
app/models/exchange_rate.rb
Normal file
6
app/models/exchange_rate.rb
Normal file
|
@ -0,0 +1,6 @@
|
|||
class ExchangeRate < ApplicationRecord
|
||||
def self.convert(from, to, amount)
|
||||
rate = ExchangeRate.find_by(base_currency: from, converted_currency: to)
|
||||
amount * rate.rate
|
||||
end
|
||||
end
|
|
@ -2,13 +2,13 @@
|
|||
|
||||
<% accounts = Current.family.accounts.where(accountable_type: type.name) %>
|
||||
|
||||
<% if accounts.sum(&:balance) > 0 %>
|
||||
<% if accounts.sum(&:converted_balance) > 0 %>
|
||||
<details class="mb-1 text-sm group">
|
||||
<summary class="flex gap-4 px-2 py-3 items-center w-full rounded-[10px] font-medium hover:bg-[#f2f2f2]">
|
||||
<%= lucide_icon("chevron-down", class: "hidden group-open:block text-[#737373] w-5 h-5") %>
|
||||
<%= lucide_icon("chevron-right", class: "group-open:hidden text-[#737373] w-5 h-5") %>
|
||||
<div class="text-left"><%= type.model_name.human %></div>
|
||||
<div class="ml-auto"><%= humanized_money_with_symbol accounts.sum(&:balance) %></div>
|
||||
<div class="ml-auto"><%= format_currency accounts.sum(&:converted_balance) %></div>
|
||||
</summary>
|
||||
|
||||
<% accounts.each do |account| %>
|
||||
|
@ -19,7 +19,7 @@
|
|||
<p class="text-xs text-[#737373]"><%= account.subtype&.humanize %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="ml-auto font-medium"><%= humanized_money_with_symbol account.balance %></p>
|
||||
<p class="ml-auto font-medium"><%= format_currency account.converted_balance %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
|
|
@ -4,14 +4,14 @@
|
|||
|
||||
<% Current.family.accounts.each do |account| %>
|
||||
<div class="flex flex-row items-center justify-between px-3 py-3 mb-2 bg-white shadow-sm rounded-xl">
|
||||
<div class="flex w-1/3 items-center text-sm">
|
||||
<div class="flex items-center w-1/3 text-sm">
|
||||
<%= account.name %>
|
||||
</div>
|
||||
<div class="flex w-1/3 items-center text-sm">
|
||||
<div class="flex items-center w-1/3 text-sm">
|
||||
<%= to_accountable_title(account.accountable) %>
|
||||
</div>
|
||||
<p class="flex w-1/3 text-sm text-right">
|
||||
<span class="block mb-1"><%= humanized_money_with_symbol account.balance %></span>
|
||||
<span class="block mb-1"><%= format_currency account.converted_balance %></span>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -69,16 +69,17 @@
|
|||
</div>
|
||||
|
||||
<%= form_with model: @account, url: accounts_path, scope: :account, html: { class: "m-5 mt-1 flex flex-col justify-between grow", data: { turbo: false } } do |f| %>
|
||||
<div class="space-y-4 grow">
|
||||
<%= f.hidden_field :accountable_type %>
|
||||
<div class="space-y-4 grow">
|
||||
<%= f.hidden_field :accountable_type %>
|
||||
|
||||
<%= f.text_field :name, placeholder: 'Example account name', required: 'required', label: 'Account name' %>
|
||||
<%= f.text_field :name, placeholder: 'Example account name', required: 'required', label: 'Account name' %>
|
||||
|
||||
<%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %>
|
||||
<%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %>
|
||||
|
||||
<%= f.number_field :balance, placeholder: number_to_currency(0), in: 0.00..100000000.00, required: 'required', label: true %>
|
||||
<%= f.number_field :original_balance, step: :any, placeholder: 0.00, in: 0.00..100000000.00, required: 'required', label: true %>
|
||||
|
||||
<%= f.collection_select :original_currency, Currency.all, :iso_code, :iso_code, { prompt: "Choose a currency", selected: Current.family.currency }, { required: 'required', label: "Base Currency" } %>
|
||||
</div>
|
||||
|
||||
<%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
<%= form_with model: Current.user, url: settings_path, html: { class: "space-y-4" } do |form| %>
|
||||
<%= form.fields_for :family_attributes do |family_fields| %>
|
||||
<%= family_fields.text_field :name, placeholder: "Family name", value: Current.family.name, label: "Family name" %>
|
||||
|
||||
<%= family_fields.select :currency, options_for_select(Currency.all.order(iso_code: :asc).map { |currency| ["#{currency.iso_code} (#{currency.name})", currency.iso_code] }, selected: Current.family.currency), { label: "Currency" } %>
|
||||
<% end %>
|
||||
|
||||
<%= form.text_field :first_name, placeholder: "First name", value: Current.user.first_name, label: true %>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue