From aa351ae616c235502e7ad0fa0fd5cbe428cf6ecf Mon Sep 17 00:00:00 2001 From: Josh Pigford Date: Sat, 10 Feb 2024 16:18:56 -0600 Subject: [PATCH] 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 --- .env.example | 10 ++ Gemfile | 5 +- Gemfile.lock | 32 +++-- Procfile.dev | 1 + README.md | 14 ++- app/controllers/accounts_controller.rb | 4 +- app/controllers/settings_controller.rb | 16 ++- app/helpers/application_helper.rb | 33 +++++ app/jobs/convert_currency_job.rb | 19 +++ app/jobs/daily_exchange_rate_job.rb | 34 ++++++ app/models/account.rb | 12 +- app/models/currency.rb | 2 + app/models/exchange_rate.rb | 6 + app/views/accounts/_account_list.html.erb | 6 +- app/views/accounts/index.html.erb | 6 +- app/views/accounts/new.html.erb | 13 +- app/views/settings/edit.html.erb | 2 + config/environments/development.rb | 3 + config/environments/production.rb | 2 +- config/initializers/good_job.rb | 9 ++ config/initializers/money.rb | 114 ------------------ config/locales/models/account/en.yml | 4 +- config/routes.rb | 2 + config/solid_queue.yml | 18 +++ ...20240209153232_add_currency_to_families.rb | 5 + .../20240209174912_redo_money_storage.rb | 12 ++ .../20240209200519_create_currencies.rb | 12 ++ .../20240209200924_create_exchange_rates.rb | 16 +++ db/migrate/20240210155058_create_good_jobs.rb | 91 ++++++++++++++ db/schema.rb | 109 ++++++++++++++++- db/seeds.rb | 12 +- lib/tasks/currencies.rake | 28 +++++ lib/tasks/exchange_rates.rake | 83 +++++++++++++ test/fixtures/accounts.yml | 6 +- test/fixtures/currencies.yml | 9 ++ test/fixtures/exchange_rates.yml | 13 ++ test/jobs/convert_currency_job_test.rb | 7 ++ test/jobs/daily_exchange_rate_job_test.rb | 7 ++ test/models/account_test.rb | 19 +-- test/models/currency_test.rb | 7 ++ test/models/exchange_rate_test.rb | 7 ++ 41 files changed, 634 insertions(+), 176 deletions(-) create mode 100644 app/jobs/convert_currency_job.rb create mode 100644 app/jobs/daily_exchange_rate_job.rb create mode 100644 app/models/currency.rb create mode 100644 app/models/exchange_rate.rb create mode 100644 config/initializers/good_job.rb delete mode 100644 config/initializers/money.rb create mode 100644 config/solid_queue.yml create mode 100644 db/migrate/20240209153232_add_currency_to_families.rb create mode 100644 db/migrate/20240209174912_redo_money_storage.rb create mode 100644 db/migrate/20240209200519_create_currencies.rb create mode 100644 db/migrate/20240209200924_create_exchange_rates.rb create mode 100644 db/migrate/20240210155058_create_good_jobs.rb create mode 100644 lib/tasks/currencies.rake create mode 100644 lib/tasks/exchange_rates.rake create mode 100644 test/fixtures/currencies.yml create mode 100644 test/fixtures/exchange_rates.yml create mode 100644 test/jobs/convert_currency_job_test.rb create mode 100644 test/jobs/daily_exchange_rate_job_test.rb create mode 100644 test/models/currency_test.rb create mode 100644 test/models/exchange_rate_test.rb diff --git a/.env.example b/.env.example index b144a286..e1a1548e 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,16 @@ HOSTED=false # This is the domain that your Maybe instance will be hosted at. It is used to generate links in emails and other places. APP_DOMAIN= +# Exchange Rate API +# This is used to convert between different currencies in the app. We're currently using Open Exchange Rates (openexchangerates.org) to sync exchange rate data and, at the moment, that requies a $12/mo subscription. This is NOT required to run Maybe with one currency, but it is required to sync exchange rate data if you need multiple currencies. In the future we'll be adding support for other exchange rate APIs that are cheaper/free. +OPEN_EXCHANGE_APP_ID= + +# Currency Configuration +# A list of currencies that you want to support. This is used to generate the list of currencies that users can select from when creating a new account. +# A free Open Exchange Rates API key is required if you want to support multiple currencies. +# Example: CURRENCIES=USD,EUR,GBP +CURRENCIES=USD + # SMTP Configuration # This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports). # Resend.com is a good option that offers a free tier for sending emails. diff --git a/Gemfile b/Gemfile index 7a2b13c0..d121daa8 100644 --- a/Gemfile +++ b/Gemfile @@ -23,12 +23,15 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails" gem "stimulus-rails" gem "turbo-rails" +# Background Jobs +gem "good_job" + # Other gem "bcrypt", "~> 3.1.7" gem "inline_svg" gem "jbuilder" gem "tzinfo-data", platforms: %i[ windows jruby ] -gem "money-rails", "~> 1.12" +gem "faraday" group :development, :test do gem "debug", platforms: %i[ mri windows ] diff --git a/Gemfile.lock b/Gemfile.lock index 4b5c5a95..127b8280 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -146,9 +146,25 @@ GEM drb (2.2.0) ruby2_keywords erubi (1.12.0) + et-orbi (1.2.7) + tzinfo + faraday (2.9.0) + faraday-net_http (>= 2.0, < 3.2) + faraday-net_http (3.1.0) + net-http ffi (1.16.3) + fugit (1.9.0) + et-orbi (~> 1, >= 1.2.7) + raabro (~> 1.4) globalid (1.2.1) activesupport (>= 6.1) + good_job (3.23.0) + activejob (>= 6.0.0) + activerecord (>= 6.0.0) + concurrent-ruby (>= 1.0.2) + fugit (>= 1.1) + railties (>= 6.0.0) + thor (>= 0.14.1) highline (3.0.1) hotwire-livereload (1.3.1) actioncable (>= 6.0.0) @@ -202,16 +218,9 @@ GEM matrix (0.4.2) mini_mime (1.1.5) minitest (5.21.2) - monetize (1.13.0) - money (~> 6.12) - money (6.16.0) - i18n (>= 0.6.4, <= 2) - money-rails (1.15.0) - activesupport (>= 3.0) - monetize (~> 1.9) - money (~> 6.13) - railties (>= 3.0) msgpack (1.7.2) + net-http (0.4.1) + uri net-imap (0.4.10) date net-protocol @@ -250,6 +259,7 @@ GEM public_suffix (5.0.4) puma (6.4.2) nio4r (~> 2.0) + raabro (1.4.0) racc (1.7.3) rack (3.0.9) rack-session (2.0.0) @@ -359,6 +369,7 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) + uri (0.13.0) useragent (0.16.10) web-console (4.2.1) actionview (>= 6.0.0) @@ -389,6 +400,8 @@ DEPENDENCIES capybara debug dotenv-rails + faraday + good_job hotwire-livereload i18n-tasks importmap-rails @@ -396,7 +409,6 @@ DEPENDENCIES jbuilder letter_opener lucide-rails! - money-rails (~> 1.12) pg (~> 1.1) propshaft puma (>= 5.0) diff --git a/Procfile.dev b/Procfile.dev index 7410150c..d04ee87d 100644 --- a/Procfile.dev +++ b/Procfile.dev @@ -1,2 +1,3 @@ web: bin/rails server -b 0.0.0.0 css: bin/rails tailwindcss:watch +worker: bundle exec good_job start diff --git a/README.md b/README.md index 7fc74352..23c7f61f 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,21 @@ bin/dev And visit http://localhost:3000 to see the app. You can use the following credentials to log in (generated by DB seed): -Email: user@maybe.local -Password: password +Email: `user@maybe.local` +Password: `password` For further instructions, see guides below. +### Multi-currency support + +If you'd like multi-currency support, there are a few extra steps to follow. + +1. Sign up for an API key at [Open Exchange Rates](https://openexchangerates.org/signup). For now, you'll need the Developer plan, which is $12/mo. +2. Add your API key to your `.env` file. +3. Set the currencies you'd like to support in the `.env` file. +4. Run `rake currencies:seed` +5. Run `rake exchange_rates:sync` + ### Setup Guides #### Dev Container (optional) diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 6fe6e98f..0dc0d460 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -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 diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index c7b4d73a..5751b952 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e2fe2b8a..18b88f95 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/jobs/convert_currency_job.rb b/app/jobs/convert_currency_job.rb new file mode 100644 index 00000000..fe8e82d2 --- /dev/null +++ b/app/jobs/convert_currency_job.rb @@ -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 diff --git a/app/jobs/daily_exchange_rate_job.rb b/app/jobs/daily_exchange_rate_job.rb new file mode 100644 index 00000000..46592530 --- /dev/null +++ b/app/jobs/daily_exchange_rate_job.rb @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb index 2159c192..218f59ed 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/models/currency.rb b/app/models/currency.rb new file mode 100644 index 00000000..ef898699 --- /dev/null +++ b/app/models/currency.rb @@ -0,0 +1,2 @@ +class Currency < ApplicationRecord +end diff --git a/app/models/exchange_rate.rb b/app/models/exchange_rate.rb new file mode 100644 index 00000000..993cb34f --- /dev/null +++ b/app/models/exchange_rate.rb @@ -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 diff --git a/app/views/accounts/_account_list.html.erb b/app/views/accounts/_account_list.html.erb index 0692bfa2..2e73247a 100644 --- a/app/views/accounts/_account_list.html.erb +++ b/app/views/accounts/_account_list.html.erb @@ -2,13 +2,13 @@ <% accounts = Current.family.accounts.where(accountable_type: type.name) %> -<% if accounts.sum(&:balance) > 0 %> +<% if accounts.sum(&:converted_balance) > 0 %>
<%= 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") %>
<%= type.model_name.human %>
-
<%= humanized_money_with_symbol accounts.sum(&:balance) %>
+
<%= format_currency accounts.sum(&:converted_balance) %>
<% accounts.each do |account| %> @@ -19,7 +19,7 @@

<%= account.subtype&.humanize %>

<% end %> -

<%= humanized_money_with_symbol account.balance %>

+

<%= format_currency account.converted_balance %>

<% end %> diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 34bc5672..31590101 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -4,14 +4,14 @@ <% Current.family.accounts.each do |account| %>
-
+
<%= account.name %>
-
+
<%= to_accountable_title(account.accountable) %>

- <%= humanized_money_with_symbol account.balance %> + <%= format_currency account.converted_balance %>

<% end %> diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb index ac2ef743..2ec58678 100644 --- a/app/views/accounts/new.html.erb +++ b/app/views/accounts/new.html.erb @@ -69,16 +69,17 @@
<%= 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| %> -
- <%= f.hidden_field :accountable_type %> +
+ <%= 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" } %>
- <%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %> <% end %> <% end %> diff --git a/app/views/settings/edit.html.erb b/app/views/settings/edit.html.erb index dd485ba0..12e5f308 100644 --- a/app/views/settings/edit.html.erb +++ b/app/views/settings/edit.html.erb @@ -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 %> diff --git a/config/environments/development.rb b/config/environments/development.rb index e24469ff..ef4f7037 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -64,6 +64,9 @@ Rails.application.configure do # Highlight code that enqueued background job in logs. config.active_job.verbose_enqueue_logs = true + # Set Active Job queue adapter + config.active_job.queue_adapter = :good_job + # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true diff --git a/config/environments/production.rb b/config/environments/production.rb index bf991d0a..baac7932 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -62,7 +62,7 @@ Rails.application.configure do # config.cache_store = :mem_cache_store # Use a real queuing backend for Active Job (and separate queues per environment). - # config.active_job.queue_adapter = :resque + config.active_job.queue_adapter = :good_job # config.active_job.queue_name_prefix = "maybe_production" config.action_mailer.perform_caching = false diff --git a/config/initializers/good_job.rb b/config/initializers/good_job.rb new file mode 100644 index 00000000..8e675fd9 --- /dev/null +++ b/config/initializers/good_job.rb @@ -0,0 +1,9 @@ +Rails.application.configure do + config.good_job.enable_cron = true + config.good_job.cron = { + maintenance: { + cron: "0 22 * * *", + class: "DailyExchangeRateJob" + } + } +end diff --git a/config/initializers/money.rb b/config/initializers/money.rb deleted file mode 100644 index 557b6bac..00000000 --- a/config/initializers/money.rb +++ /dev/null @@ -1,114 +0,0 @@ -# encoding : utf-8 - -MoneyRails.configure do |config| - # To set the default currency - # - config.default_currency = :usd - - # Set default bank object - # - # Example: - # config.default_bank = EuCentralBank.new - - # Add exchange rates to current money bank object. - # (The conversion rate refers to one direction only) - # - # Example: - # config.add_rate "USD", "CAD", 1.24515 - # config.add_rate "CAD", "USD", 0.803115 - - # To handle the inclusion of validations for monetized fields - # The default value is true - # - # config.include_validations = true - - # Default ActiveRecord migration configuration values for columns: - # - # config.amount_column = { prefix: '', # column name prefix - # postfix: '_cents', # column name postfix - # column_name: nil, # full column name (overrides prefix, postfix and accessor name) - # type: :integer, # column type - # present: true, # column will be created - # null: false, # other options will be treated as column options - # default: 0 - # } - # - # config.currency_column = { prefix: '', - # postfix: '_currency', - # column_name: nil, - # type: :string, - # present: true, - # null: false, - # default: 'USD' - # } - - # Register a custom currency - # - # Example: - # config.register_currency = { - # priority: 1, - # iso_code: "EU4", - # name: "Euro with subunit of 4 digits", - # symbol: "€", - # symbol_first: true, - # subunit: "Subcent", - # subunit_to_unit: 10000, - # thousands_separator: ".", - # decimal_mark: "," - # } - - # Specify a rounding mode - # Any one of: - # - # BigDecimal::ROUND_UP, - # BigDecimal::ROUND_DOWN, - # BigDecimal::ROUND_HALF_UP, - # BigDecimal::ROUND_HALF_DOWN, - # BigDecimal::ROUND_HALF_EVEN, - # BigDecimal::ROUND_CEILING, - # BigDecimal::ROUND_FLOOR - # - # set to BigDecimal::ROUND_HALF_EVEN by default - # - config.rounding_mode = BigDecimal::ROUND_HALF_UP - - # Set default money format globally. - # Default value is nil meaning "ignore this option". - # Example: - # - # config.default_format = { - # no_cents_if_whole: nil, - # symbol: nil, - # sign_before_symbol: nil - # } - - # If you would like to use I18n localization (formatting depends on the - # locale): - config.locale_backend = :i18n - # - # Example (using default localization from rails-i18n): - # - # I18n.locale = :en - # Money.new(10_000_00, 'USD').format # => $10,000.00 - # I18n.locale = :es - # Money.new(10_000_00, 'USD').format # => $10.000,00 - # - # For the legacy behaviour of "per currency" localization (formatting depends - # only on currency): - # config.locale_backend = :currency - # - # Example: - # Money.new(10_000_00, 'USD').format # => $10,000.00 - # Money.new(10_000_00, 'EUR').format # => €10.000,00 - # - # In case you don't need localization and would like to use default values - # (can be redefined using config.default_format): - # config.locale_backend = nil - - # Set default raise_error_on_money_parsing option - # It will be raise error if assigned different currency - # The default value is false - # - # Example: - # config.raise_error_on_money_parsing = false -end diff --git a/config/locales/models/account/en.yml b/config/locales/models/account/en.yml index 4b199653..c0f6a519 100644 --- a/config/locales/models/account/en.yml +++ b/config/locales/models/account/en.yml @@ -3,11 +3,11 @@ en: activerecord: attributes: account: - balance: Balance - currency: Currency family: Family family_id: Family name: Name + original_balance: Balance + original_currency: Currency subtype: Subtype models: account: Account diff --git a/config/routes.rb b/config/routes.rb index 7453b36c..5ba54a28 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + mount GoodJob::Engine => "jobs" + resource :registration resource :session resource :password_reset diff --git a/config/solid_queue.yml b/config/solid_queue.yml new file mode 100644 index 00000000..5a437350 --- /dev/null +++ b/config/solid_queue.yml @@ -0,0 +1,18 @@ +# default: &default +# dispatchers: +# - polling_interval: 1 +# batch_size: 500 +# workers: +# - queues: "*" +# threads: 5 +# processes: 1 +# polling_interval: 0.1 +# +# development: +# <<: *default +# +# test: +# <<: *default +# +# production: +# <<: *default diff --git a/db/migrate/20240209153232_add_currency_to_families.rb b/db/migrate/20240209153232_add_currency_to_families.rb new file mode 100644 index 00000000..9b4a1b76 --- /dev/null +++ b/db/migrate/20240209153232_add_currency_to_families.rb @@ -0,0 +1,5 @@ +class AddCurrencyToFamilies < ActiveRecord::Migration[7.2] + def change + add_column :families, :currency, :string, default: 'USD' + end +end diff --git a/db/migrate/20240209174912_redo_money_storage.rb b/db/migrate/20240209174912_redo_money_storage.rb new file mode 100644 index 00000000..8550373c --- /dev/null +++ b/db/migrate/20240209174912_redo_money_storage.rb @@ -0,0 +1,12 @@ +class RedoMoneyStorage < ActiveRecord::Migration[7.2] + def change + add_column :accounts, :original_balance, :decimal, precision: 19, scale: 4, default: 0.0 + add_column :accounts, :original_currency, :string, default: "USD" + add_column :accounts, :converted_balance, :decimal, precision: 19, scale: 4, default: 0.0 + add_column :accounts, :converted_currency, :string, default: "USD" + + remove_column :accounts, :balance_cents + remove_column :accounts, :balance_currency + remove_column :accounts, :currency + end +end diff --git a/db/migrate/20240209200519_create_currencies.rb b/db/migrate/20240209200519_create_currencies.rb new file mode 100644 index 00000000..4bda4f05 --- /dev/null +++ b/db/migrate/20240209200519_create_currencies.rb @@ -0,0 +1,12 @@ +class CreateCurrencies < ActiveRecord::Migration[7.2] + def change + create_table :currencies, id: :uuid do |t| + t.string :name + t.string :iso_code + + t.timestamps + end + + add_index :currencies, :iso_code, unique: true + end +end diff --git a/db/migrate/20240209200924_create_exchange_rates.rb b/db/migrate/20240209200924_create_exchange_rates.rb new file mode 100644 index 00000000..796438ea --- /dev/null +++ b/db/migrate/20240209200924_create_exchange_rates.rb @@ -0,0 +1,16 @@ +class CreateExchangeRates < ActiveRecord::Migration[7.2] + def change + create_table :exchange_rates, id: :uuid do |t| + t.string :base_currency, null: false + t.string :converted_currency, null: false + t.decimal :rate + t.date :date + + t.timestamps + end + + add_index :exchange_rates, :base_currency + add_index :exchange_rates, :converted_currency + add_index :exchange_rates, %i[base_currency converted_currency date], unique: true + end +end diff --git a/db/migrate/20240210155058_create_good_jobs.rb b/db/migrate/20240210155058_create_good_jobs.rb new file mode 100644 index 00000000..3a7f3070 --- /dev/null +++ b/db/migrate/20240210155058_create_good_jobs.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +class CreateGoodJobs < ActiveRecord::Migration[7.2] + def change + # Uncomment for Postgres v12 or earlier to enable gen_random_uuid() support + # enable_extension 'pgcrypto' + + create_table :good_jobs, id: :uuid do |t| + t.text :queue_name + t.integer :priority + t.jsonb :serialized_params + t.datetime :scheduled_at + t.datetime :performed_at + t.datetime :finished_at + t.text :error + + t.timestamps + + t.uuid :active_job_id + t.text :concurrency_key + t.text :cron_key + t.uuid :retried_good_job_id + t.datetime :cron_at + + t.uuid :batch_id + t.uuid :batch_callback_id + + t.boolean :is_discrete + t.integer :executions_count + t.text :job_class + t.integer :error_event, limit: 2 + t.text :labels, array: true + end + + create_table :good_job_batches, id: :uuid do |t| + t.timestamps + t.text :description + t.jsonb :serialized_properties + t.text :on_finish + t.text :on_success + t.text :on_discard + t.text :callback_queue_name + t.integer :callback_priority + t.datetime :enqueued_at + t.datetime :discarded_at + t.datetime :finished_at + end + + create_table :good_job_executions, id: :uuid do |t| + t.timestamps + + t.uuid :active_job_id, null: false + t.text :job_class + t.text :queue_name + t.jsonb :serialized_params + t.datetime :scheduled_at + t.datetime :finished_at + t.text :error + t.integer :error_event, limit: 2 + end + + create_table :good_job_processes, id: :uuid do |t| + t.timestamps + t.jsonb :state + end + + create_table :good_job_settings, id: :uuid do |t| + t.timestamps + t.text :key + t.jsonb :value + t.index :key, unique: true + end + + add_index :good_jobs, :scheduled_at, where: "(finished_at IS NULL)", name: :index_good_jobs_on_scheduled_at + add_index :good_jobs, [ :queue_name, :scheduled_at ], where: "(finished_at IS NULL)", name: :index_good_jobs_on_queue_name_and_scheduled_at + add_index :good_jobs, [ :active_job_id, :created_at ], name: :index_good_jobs_on_active_job_id_and_created_at + add_index :good_jobs, :concurrency_key, where: "(finished_at IS NULL)", name: :index_good_jobs_on_concurrency_key_when_unfinished + add_index :good_jobs, [ :cron_key, :created_at ], where: "(cron_key IS NOT NULL)", name: :index_good_jobs_on_cron_key_and_created_at_cond + add_index :good_jobs, [ :cron_key, :cron_at ], where: "(cron_key IS NOT NULL)", unique: true, name: :index_good_jobs_on_cron_key_and_cron_at_cond + add_index :good_jobs, [ :finished_at ], where: "retried_good_job_id IS NULL AND finished_at IS NOT NULL", name: :index_good_jobs_jobs_on_finished_at + add_index :good_jobs, [ :priority, :created_at ], order: { priority: "DESC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", name: :index_good_jobs_jobs_on_priority_created_at_when_unfinished + add_index :good_jobs, [ :priority, :created_at ], order: { priority: "ASC NULLS LAST", created_at: :asc }, + where: "finished_at IS NULL", name: :index_good_job_jobs_for_candidate_lookup + add_index :good_jobs, [ :batch_id ], where: "batch_id IS NOT NULL" + add_index :good_jobs, [ :batch_callback_id ], where: "batch_callback_id IS NOT NULL" + add_index :good_jobs, :labels, using: :gin, where: "(labels IS NOT NULL)", name: :index_good_jobs_on_labels + + add_index :good_job_executions, [ :active_job_id, :created_at ], name: :index_good_job_executions_on_active_job_id_and_created_at + end +end diff --git a/db/schema.rb b/db/schema.rb index 98483a6f..142dcdd0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_02_06_031739) do +ActiveRecord::Schema[7.2].define(version: 2024_02_10_155058) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -59,21 +59,122 @@ ActiveRecord::Schema[7.2].define(version: 2024_02_06_031739) do t.string "subtype" t.uuid "family_id", null: false t.string "name" - t.string "currency", default: "USD" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "accountable_type" t.uuid "accountable_id" - t.bigint "balance_cents", default: 0, null: false - t.string "balance_currency", default: "USD", null: false + t.decimal "original_balance", precision: 19, scale: 4, default: "0.0" + t.string "original_currency", default: "USD" + t.decimal "converted_balance", precision: 19, scale: 4, default: "0.0" + t.string "converted_currency", default: "USD" t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["family_id"], name: "index_accounts_on_family_id" end + create_table "currencies", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name" + t.string "iso_code" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["iso_code"], name: "index_currencies_on_iso_code", unique: true + end + + create_table "exchange_rates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "base_currency", null: false + t.string "converted_currency", null: false + t.decimal "rate" + t.date "date" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["base_currency", "converted_currency", "date"], name: "idx_on_base_currency_converted_currency_date_255be792be", unique: true + t.index ["base_currency"], name: "index_exchange_rates_on_base_currency" + t.index ["converted_currency"], name: "index_exchange_rates_on_converted_currency" + end + create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "currency", default: "USD" + end + + create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "description" + t.jsonb "serialized_properties" + t.text "on_finish" + t.text "on_success" + t.text "on_discard" + t.text "callback_queue_name" + t.integer "callback_priority" + t.datetime "enqueued_at" + t.datetime "discarded_at" + t.datetime "finished_at" + end + + create_table "good_job_executions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id", null: false + t.text "job_class" + t.text "queue_name" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.text "error" + t.integer "error_event", limit: 2 + t.index ["active_job_id", "created_at"], name: "index_good_job_executions_on_active_job_id_and_created_at" + end + + create_table "good_job_processes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.jsonb "state" + end + + create_table "good_job_settings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "key" + t.jsonb "value" + t.index ["key"], name: "index_good_job_settings_on_key", unique: true + end + + create_table "good_jobs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.text "queue_name" + t.integer "priority" + t.jsonb "serialized_params" + t.datetime "scheduled_at" + t.datetime "performed_at" + t.datetime "finished_at" + t.text "error" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.uuid "active_job_id" + t.text "concurrency_key" + t.text "cron_key" + t.uuid "retried_good_job_id" + t.datetime "cron_at" + t.uuid "batch_id" + t.uuid "batch_callback_id" + t.boolean "is_discrete" + t.integer "executions_count" + t.text "job_class" + t.integer "error_event", limit: 2 + t.text "labels", array: true + t.index ["active_job_id", "created_at"], name: "index_good_jobs_on_active_job_id_and_created_at" + t.index ["batch_callback_id"], name: "index_good_jobs_on_batch_callback_id", where: "(batch_callback_id IS NOT NULL)" + t.index ["batch_id"], name: "index_good_jobs_on_batch_id", where: "(batch_id IS NOT NULL)" + t.index ["concurrency_key"], name: "index_good_jobs_on_concurrency_key_when_unfinished", where: "(finished_at IS NULL)" + t.index ["cron_key", "created_at"], name: "index_good_jobs_on_cron_key_and_created_at_cond", where: "(cron_key IS NOT NULL)" + t.index ["cron_key", "cron_at"], name: "index_good_jobs_on_cron_key_and_cron_at_cond", unique: true, where: "(cron_key IS NOT NULL)" + t.index ["finished_at"], name: "index_good_jobs_jobs_on_finished_at", where: "((retried_good_job_id IS NULL) AND (finished_at IS NOT NULL))" + t.index ["labels"], name: "index_good_jobs_on_labels", where: "(labels IS NOT NULL)", using: :gin + t.index ["priority", "created_at"], name: "index_good_job_jobs_for_candidate_lookup", where: "(finished_at IS NULL)" + t.index ["priority", "created_at"], name: "index_good_jobs_jobs_on_priority_created_at_when_unfinished", order: { priority: "DESC NULLS LAST" }, where: "(finished_at IS NULL)" + t.index ["queue_name", "scheduled_at"], name: "index_good_jobs_on_queue_name_and_scheduled_at", where: "(finished_at IS NULL)" + t.index ["scheduled_at"], name: "index_good_jobs_on_scheduled_at", where: "(finished_at IS NULL)" end create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/db/seeds.rb b/db/seeds.rb index 396593bd..7d22963b 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -9,9 +9,13 @@ # end # Create the default user -family = Family.create_or_find_by!(name: "The Maybe Family") +family = Family.create_or_find_by(name: "The Maybe Family") puts "Family created: #{family.name}" -user = User.create_or_find_by!( - first_name: "Josh", last_name: "Maybe", email: "user@maybe.local", - password: "password", password_confirmation: "password", family_id: family.id) +user = User.create_or_find_by(email: "user@maybe.local") do |u| + u.first_name = "Josh" + u.last_name = "Maybe" + u.password = "password" + u.password_confirmation = "password" + u.family_id = family.id +end puts "User created: #{user.email} for family: #{family.name}" diff --git a/lib/tasks/currencies.rake b/lib/tasks/currencies.rake new file mode 100644 index 00000000..d9bbe12c --- /dev/null +++ b/lib/tasks/currencies.rake @@ -0,0 +1,28 @@ +namespace :currencies do + desc "Seed Currencies" + task seed: :environment do + currencies = ENV["CURRENCIES"].split(",") + + if currencies.count > 1 && ENV["OPEN_EXCHANGE_APP_ID"].present? + url = "https://openexchangerates.org/api/currencies.json" + + response = Faraday.get(url) do |req| + req.params["app_id"] = ENV["OPEN_EXCHANGE_APP_ID"] + end + + oxr_currencies = JSON.parse(response.body) + + currencies.each do |iso_code| + Currency.find_or_create_by(iso_code: iso_code) do |c| + c.name = oxr_currencies[iso_code] + end + end + + puts "Currencies created: #{Currency.count}" + elsif currencies.count == 1 + Currency.find_or_create_by(iso_code: currencies.first) + else + puts "No currencies found in ENV['CURRENCIES']" + end + end +end diff --git a/lib/tasks/exchange_rates.rake b/lib/tasks/exchange_rates.rake new file mode 100644 index 00000000..ce202ce3 --- /dev/null +++ b/lib/tasks/exchange_rates.rake @@ -0,0 +1,83 @@ +namespace :exchange_rates do + desc "Fetch exchange rates from openexchangerates.org" + task sync: :environment do + app_id = ENV["OPEN_EXCHANGE_APP_ID"] + MININUM_DATE_RANGE = 30.days + MAXIMUM_DATE_RANGE = 120.days + + # First, check what plan the user is on at OXR + # If the user is on the Developer plan, we can only fetch exchange rates for the past 30 days and must use the historical endpoint + account_check = Faraday.get("https://openexchangerates.org/api/usage.json") do |req| + req.params["app_id"] = app_id + end + account_details = JSON.parse(account_check.body) + quota_limit = account_details["data"]["usage"]["requests_quota"] + + if quota_limit <= 10000 + # First loop through all Currency records and fetch exchange rates for each, but use the historical endpoint, which only allows fetching one day at a time. For each currency, set the base currency to the currency's iso_code and the symbols to all other currencies' iso_codes + Currency.all.each do |currency| + (Date.today - MININUM_DATE_RANGE).upto(Date.today) do |date| + response = Faraday.get("https://openexchangerates.org/api/historical/#{date}.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, 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}: #{value}" + end + else + puts "Failed to fetch exchange rates for #{currency.iso_code} on #{date}: #{response.status}" + end + end + end + else + # Use Faraday to make a request openexchangerates.org time series endpoint + # Use the response to create or update exchange rates in the database + url = "https://openexchangerates.org/api/time-series.json" + start_date = (Date.today - MAXIMUM_DATE_RANGE).to_s + end_date = Date.today.to_s + + # Loop through all Currency records and fetch exchange rates for each + Currency.all.each do |currency| + start_period = Date.parse(start_date) + end_period = Date.parse(end_date) + + while start_period < end_period + current_end_date = [ start_period + 30.days, end_period ].min + + response = Faraday.get(url) do |req| + req.params["app_id"] = app_id + req.params["start"] = start_period.to_s + req.params["end"] = current_end_date.to_s + 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 |date, rate| + rate.each do |currency_iso_code, value| + ExchangeRate.find_or_create_by(date: date, 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}: #{value}" + end + end + else + puts "Failed to fetch exchange rates for period #{start_period} to #{current_end_date}: #{response.status}" + end + + start_period = current_end_date + 1.day + end + end + end + end +end diff --git a/test/fixtures/accounts.yml b/test/fixtures/accounts.yml index 3f31f421..8d181abb 100644 --- a/test/fixtures/accounts.yml +++ b/test/fixtures/accounts.yml @@ -1,14 +1,14 @@ dylan_checking: family: dylan_family name: Bob's Checking - balance_cents: 1200 + original_balance: 1200 dylan_roth: family: dylan_family name: Bob's Roth IRA - balance_cents: 1200 + original_balance: 1200 richards_savings: family: richards_family name: Keef's HYSA - balance_cents: 1500 + original_balance: 1500 diff --git a/test/fixtures/currencies.yml b/test/fixtures/currencies.yml new file mode 100644 index 00000000..acc50874 --- /dev/null +++ b/test/fixtures/currencies.yml @@ -0,0 +1,9 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +# one: +# name: MyString +# iso_code: MyString + +# two: +# name: MyString +# iso_code: MyString diff --git a/test/fixtures/exchange_rates.yml b/test/fixtures/exchange_rates.yml new file mode 100644 index 00000000..e08ad7fb --- /dev/null +++ b/test/fixtures/exchange_rates.yml @@ -0,0 +1,13 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + base_currency: one + converted_currency: one + rate: 9.99 + date: 2024-02-09 + +two: + base_currency: two + converted_currency: two + rate: 9.99 + date: 2024-02-09 diff --git a/test/jobs/convert_currency_job_test.rb b/test/jobs/convert_currency_job_test.rb new file mode 100644 index 00000000..a208d53e --- /dev/null +++ b/test/jobs/convert_currency_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ConvertCurrencyJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/jobs/daily_exchange_rate_job_test.rb b/test/jobs/daily_exchange_rate_job_test.rb new file mode 100644 index 00000000..7308046a --- /dev/null +++ b/test/jobs/daily_exchange_rate_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class DailyExchangeRateJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/account_test.rb b/test/models/account_test.rb index a67ed495..b211b4da 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -3,7 +3,7 @@ require "test_helper" class AccountTest < ActiveSupport::TestCase def setup depository = Account::Depository.create! - @account = Account.create!(family: families(:dylan_family), name: "Explicit Checking", balance_cents: 1200, accountable: depository) + @account = Account.create!(family: families(:dylan_family), name: "Explicit Checking", original_balance: 1200, accountable: depository) end test "new account should be valid" do @@ -11,21 +11,4 @@ class AccountTest < ActiveSupport::TestCase assert_not_nil @account.accountable_id assert_not_nil @account.accountable end - - test "balance returns Money object" do - @account.balance = 10 - assert_instance_of Money, @account.balance - assert_equal :usd, @account.balance.currency.id - end - - test "correctly assigns Money objects to the attribute" do - @account.balance = Money.new(2500, "USD") - assert_equal 2500, @account.balance_cents - end - - test "balance_cents can be updated" do - new_balance = Money.new(10000, "USD") - @account.balance = new_balance - assert_equal new_balance, @account.balance - end end diff --git a/test/models/currency_test.rb b/test/models/currency_test.rb new file mode 100644 index 00000000..924a6453 --- /dev/null +++ b/test/models/currency_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class CurrencyTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/exchange_rate_test.rb b/test/models/exchange_rate_test.rb new file mode 100644 index 00000000..23b5458a --- /dev/null +++ b/test/models/exchange_rate_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class ExchangeRateTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end