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

Multi-currency support (#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:
Josh Pigford 2024-02-10 16:18:56 -06:00 committed by GitHub
parent 94f7b4ea8f
commit aa351ae616
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 634 additions and 176 deletions

View file

@ -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.

View file

@ -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 ]

View file

@ -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)

View file

@ -1,2 +1,3 @@
web: bin/rails server -b 0.0.0.0
css: bin/rails tailwindcss:watch
worker: bundle exec good_job start

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View 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

View file

@ -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
View file

@ -0,0 +1,2 @@
class Currency < ApplicationRecord
end

View 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

View file

@ -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 %>

View file

@ -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 %>

View file

@ -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 %>

View file

@ -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 %>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,4 +1,6 @@
Rails.application.routes.draw do
mount GoodJob::Engine => "jobs"
resource :registration
resource :session
resource :password_reset

18
config/solid_queue.yml Normal file
View file

@ -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

View file

@ -0,0 +1,5 @@
class AddCurrencyToFamilies < ActiveRecord::Migration[7.2]
def change
add_column :families, :currency, :string, default: 'USD'
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

109
db/schema.rb generated
View file

@ -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|

View file

@ -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}"

28
lib/tasks/currencies.rake Normal file
View file

@ -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

View file

@ -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

View file

@ -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

9
test/fixtures/currencies.yml vendored Normal file
View file

@ -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

13
test/fixtures/exchange_rates.yml vendored Normal file
View file

@ -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

View file

@ -0,0 +1,7 @@
require "test_helper"
class ConvertCurrencyJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class DailyExchangeRateJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -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

View file

@ -0,0 +1,7 @@
require "test_helper"
class CurrencyTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class ExchangeRateTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end