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:
parent
94f7b4ea8f
commit
aa351ae616
41 changed files with 634 additions and 176 deletions
10
.env.example
10
.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.
|
# 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=
|
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
|
# 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).
|
# 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.
|
# Resend.com is a good option that offers a free tier for sending emails.
|
||||||
|
|
5
Gemfile
5
Gemfile
|
@ -23,12 +23,15 @@ gem "lucide-rails", github: "maybe-finance/lucide-rails"
|
||||||
gem "stimulus-rails"
|
gem "stimulus-rails"
|
||||||
gem "turbo-rails"
|
gem "turbo-rails"
|
||||||
|
|
||||||
|
# Background Jobs
|
||||||
|
gem "good_job"
|
||||||
|
|
||||||
# Other
|
# Other
|
||||||
gem "bcrypt", "~> 3.1.7"
|
gem "bcrypt", "~> 3.1.7"
|
||||||
gem "inline_svg"
|
gem "inline_svg"
|
||||||
gem "jbuilder"
|
gem "jbuilder"
|
||||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
gem "money-rails", "~> 1.12"
|
gem "faraday"
|
||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
gem "debug", platforms: %i[ mri windows ]
|
gem "debug", platforms: %i[ mri windows ]
|
||||||
|
|
32
Gemfile.lock
32
Gemfile.lock
|
@ -146,9 +146,25 @@ GEM
|
||||||
drb (2.2.0)
|
drb (2.2.0)
|
||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
erubi (1.12.0)
|
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)
|
ffi (1.16.3)
|
||||||
|
fugit (1.9.0)
|
||||||
|
et-orbi (~> 1, >= 1.2.7)
|
||||||
|
raabro (~> 1.4)
|
||||||
globalid (1.2.1)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.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)
|
highline (3.0.1)
|
||||||
hotwire-livereload (1.3.1)
|
hotwire-livereload (1.3.1)
|
||||||
actioncable (>= 6.0.0)
|
actioncable (>= 6.0.0)
|
||||||
|
@ -202,16 +218,9 @@ GEM
|
||||||
matrix (0.4.2)
|
matrix (0.4.2)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.21.2)
|
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)
|
msgpack (1.7.2)
|
||||||
|
net-http (0.4.1)
|
||||||
|
uri
|
||||||
net-imap (0.4.10)
|
net-imap (0.4.10)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
|
@ -250,6 +259,7 @@ GEM
|
||||||
public_suffix (5.0.4)
|
public_suffix (5.0.4)
|
||||||
puma (6.4.2)
|
puma (6.4.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
|
raabro (1.4.0)
|
||||||
racc (1.7.3)
|
racc (1.7.3)
|
||||||
rack (3.0.9)
|
rack (3.0.9)
|
||||||
rack-session (2.0.0)
|
rack-session (2.0.0)
|
||||||
|
@ -359,6 +369,7 @@ GEM
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
unicode-display_width (2.5.0)
|
unicode-display_width (2.5.0)
|
||||||
|
uri (0.13.0)
|
||||||
useragent (0.16.10)
|
useragent (0.16.10)
|
||||||
web-console (4.2.1)
|
web-console (4.2.1)
|
||||||
actionview (>= 6.0.0)
|
actionview (>= 6.0.0)
|
||||||
|
@ -389,6 +400,8 @@ DEPENDENCIES
|
||||||
capybara
|
capybara
|
||||||
debug
|
debug
|
||||||
dotenv-rails
|
dotenv-rails
|
||||||
|
faraday
|
||||||
|
good_job
|
||||||
hotwire-livereload
|
hotwire-livereload
|
||||||
i18n-tasks
|
i18n-tasks
|
||||||
importmap-rails
|
importmap-rails
|
||||||
|
@ -396,7 +409,6 @@ DEPENDENCIES
|
||||||
jbuilder
|
jbuilder
|
||||||
letter_opener
|
letter_opener
|
||||||
lucide-rails!
|
lucide-rails!
|
||||||
money-rails (~> 1.12)
|
|
||||||
pg (~> 1.1)
|
pg (~> 1.1)
|
||||||
propshaft
|
propshaft
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
web: bin/rails server -b 0.0.0.0
|
web: bin/rails server -b 0.0.0.0
|
||||||
css: bin/rails tailwindcss:watch
|
css: bin/rails tailwindcss:watch
|
||||||
|
worker: bundle exec good_job start
|
||||||
|
|
14
README.md
14
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):
|
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
|
Email: `user@maybe.local`
|
||||||
Password: password
|
Password: `password`
|
||||||
|
|
||||||
For further instructions, see guides below.
|
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
|
### Setup Guides
|
||||||
|
|
||||||
#### Dev Container (optional)
|
#### Dev Container (optional)
|
||||||
|
|
|
@ -3,7 +3,7 @@ class AccountsController < ApplicationController
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@account = Account.new(
|
@account = Account.new(
|
||||||
balance: nil,
|
original_balance: nil,
|
||||||
accountable: Accountable.from_type(params[:type])&.new
|
accountable: Accountable.from_type(params[:type])&.new
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
@ -25,6 +25,6 @@ class AccountsController < ApplicationController
|
||||||
private
|
private
|
||||||
|
|
||||||
def account_params
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,7 +5,19 @@ class SettingsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
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."
|
redirect_to root_path, notice: "Profile updated successfully."
|
||||||
else
|
else
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
|
@ -16,6 +28,6 @@ class SettingsController < ApplicationController
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:first_name, :last_name, :email, :password, :password_confirmation,
|
params.require(:user).permit(:first_name, :last_name, :email, :password, :password_confirmation,
|
||||||
family_attributes: [ :name, :id ])
|
family_attributes: [ :name, :id, :currency ])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,4 +17,37 @@ module ApplicationHelper
|
||||||
content = capture &block
|
content = capture &block
|
||||||
render partial: "shared/modal", locals: { content: content }
|
render partial: "shared/modal", locals: { content: content }
|
||||||
end
|
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
|
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
|
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
|
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) %>
|
<% 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">
|
<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]">
|
<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-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") %>
|
<%= 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="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>
|
</summary>
|
||||||
|
|
||||||
<% accounts.each do |account| %>
|
<% accounts.each do |account| %>
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
<p class="text-xs text-[#737373]"><%= account.subtype&.humanize %></p>
|
<p class="text-xs text-[#737373]"><%= account.subtype&.humanize %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|
|
@ -4,14 +4,14 @@
|
||||||
|
|
||||||
<% Current.family.accounts.each do |account| %>
|
<% 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 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 %>
|
<%= account.name %>
|
||||||
</div>
|
</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) %>
|
<%= to_accountable_title(account.accountable) %>
|
||||||
</div>
|
</div>
|
||||||
<p class="flex w-1/3 text-sm text-right">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -69,16 +69,17 @@
|
||||||
</div>
|
</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| %>
|
<%= 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">
|
<div class="space-y-4 grow">
|
||||||
<%= 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" } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %>
|
<%= f.submit "Add #{@account.accountable.model_name.human.downcase}" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
<%= form_with model: Current.user, url: settings_path, html: { class: "space-y-4" } do |form| %>
|
<%= form_with model: Current.user, url: settings_path, html: { class: "space-y-4" } do |form| %>
|
||||||
<%= form.fields_for :family_attributes do |family_fields| %>
|
<%= 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.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 %>
|
<% end %>
|
||||||
|
|
||||||
<%= form.text_field :first_name, placeholder: "First name", value: Current.user.first_name, label: true %>
|
<%= form.text_field :first_name, placeholder: "First name", value: Current.user.first_name, label: true %>
|
||||||
|
|
|
@ -64,6 +64,9 @@ Rails.application.configure do
|
||||||
# Highlight code that enqueued background job in logs.
|
# Highlight code that enqueued background job in logs.
|
||||||
config.active_job.verbose_enqueue_logs = true
|
config.active_job.verbose_enqueue_logs = true
|
||||||
|
|
||||||
|
# Set Active Job queue adapter
|
||||||
|
config.active_job.queue_adapter = :good_job
|
||||||
|
|
||||||
|
|
||||||
# Raises error for missing translations.
|
# Raises error for missing translations.
|
||||||
# config.i18n.raise_on_missing_translations = true
|
# config.i18n.raise_on_missing_translations = true
|
||||||
|
|
|
@ -62,7 +62,7 @@ Rails.application.configure do
|
||||||
# config.cache_store = :mem_cache_store
|
# config.cache_store = :mem_cache_store
|
||||||
|
|
||||||
# Use a real queuing backend for Active Job (and separate queues per environment).
|
# 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.active_job.queue_name_prefix = "maybe_production"
|
||||||
|
|
||||||
config.action_mailer.perform_caching = false
|
config.action_mailer.perform_caching = false
|
||||||
|
|
9
config/initializers/good_job.rb
Normal file
9
config/initializers/good_job.rb
Normal 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
|
|
@ -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
|
|
|
@ -3,11 +3,11 @@ en:
|
||||||
activerecord:
|
activerecord:
|
||||||
attributes:
|
attributes:
|
||||||
account:
|
account:
|
||||||
balance: Balance
|
|
||||||
currency: Currency
|
|
||||||
family: Family
|
family: Family
|
||||||
family_id: Family
|
family_id: Family
|
||||||
name: Name
|
name: Name
|
||||||
|
original_balance: Balance
|
||||||
|
original_currency: Currency
|
||||||
subtype: Subtype
|
subtype: Subtype
|
||||||
models:
|
models:
|
||||||
account: Account
|
account: Account
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
|
mount GoodJob::Engine => "jobs"
|
||||||
|
|
||||||
resource :registration
|
resource :registration
|
||||||
resource :session
|
resource :session
|
||||||
resource :password_reset
|
resource :password_reset
|
||||||
|
|
18
config/solid_queue.yml
Normal file
18
config/solid_queue.yml
Normal 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
|
5
db/migrate/20240209153232_add_currency_to_families.rb
Normal file
5
db/migrate/20240209153232_add_currency_to_families.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class AddCurrencyToFamilies < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :families, :currency, :string, default: 'USD'
|
||||||
|
end
|
||||||
|
end
|
12
db/migrate/20240209174912_redo_money_storage.rb
Normal file
12
db/migrate/20240209174912_redo_money_storage.rb
Normal 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
|
12
db/migrate/20240209200519_create_currencies.rb
Normal file
12
db/migrate/20240209200519_create_currencies.rb
Normal 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
|
16
db/migrate/20240209200924_create_exchange_rates.rb
Normal file
16
db/migrate/20240209200924_create_exchange_rates.rb
Normal 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
|
91
db/migrate/20240210155058_create_good_jobs.rb
Normal file
91
db/migrate/20240210155058_create_good_jobs.rb
Normal 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
109
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -59,21 +59,122 @@ ActiveRecord::Schema[7.2].define(version: 2024_02_06_031739) do
|
||||||
t.string "subtype"
|
t.string "subtype"
|
||||||
t.uuid "family_id", null: false
|
t.uuid "family_id", null: false
|
||||||
t.string "name"
|
t.string "name"
|
||||||
t.string "currency", default: "USD"
|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "accountable_type"
|
t.string "accountable_type"
|
||||||
t.uuid "accountable_id"
|
t.uuid "accountable_id"
|
||||||
t.bigint "balance_cents", default: 0, null: false
|
t.decimal "original_balance", precision: 19, scale: 4, default: "0.0"
|
||||||
t.string "balance_currency", default: "USD", null: false
|
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 ["accountable_type"], name: "index_accounts_on_accountable_type"
|
||||||
t.index ["family_id"], name: "index_accounts_on_family_id"
|
t.index ["family_id"], name: "index_accounts_on_family_id"
|
||||||
end
|
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|
|
create_table "families", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "name"
|
t.string "name"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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
|
end
|
||||||
|
|
||||||
create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "invite_codes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
|
12
db/seeds.rb
12
db/seeds.rb
|
@ -9,9 +9,13 @@
|
||||||
# end
|
# end
|
||||||
|
|
||||||
# Create the default user
|
# 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}"
|
puts "Family created: #{family.name}"
|
||||||
user = User.create_or_find_by!(
|
user = User.create_or_find_by(email: "user@maybe.local") do |u|
|
||||||
first_name: "Josh", last_name: "Maybe", email: "user@maybe.local",
|
u.first_name = "Josh"
|
||||||
password: "password", password_confirmation: "password", family_id: family.id)
|
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}"
|
puts "User created: #{user.email} for family: #{family.name}"
|
||||||
|
|
28
lib/tasks/currencies.rake
Normal file
28
lib/tasks/currencies.rake
Normal 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
|
83
lib/tasks/exchange_rates.rake
Normal file
83
lib/tasks/exchange_rates.rake
Normal 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
|
6
test/fixtures/accounts.yml
vendored
6
test/fixtures/accounts.yml
vendored
|
@ -1,14 +1,14 @@
|
||||||
dylan_checking:
|
dylan_checking:
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
name: Bob's Checking
|
name: Bob's Checking
|
||||||
balance_cents: 1200
|
original_balance: 1200
|
||||||
|
|
||||||
dylan_roth:
|
dylan_roth:
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
name: Bob's Roth IRA
|
name: Bob's Roth IRA
|
||||||
balance_cents: 1200
|
original_balance: 1200
|
||||||
|
|
||||||
richards_savings:
|
richards_savings:
|
||||||
family: richards_family
|
family: richards_family
|
||||||
name: Keef's HYSA
|
name: Keef's HYSA
|
||||||
balance_cents: 1500
|
original_balance: 1500
|
||||||
|
|
9
test/fixtures/currencies.yml
vendored
Normal file
9
test/fixtures/currencies.yml
vendored
Normal 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
13
test/fixtures/exchange_rates.yml
vendored
Normal 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
|
7
test/jobs/convert_currency_job_test.rb
Normal file
7
test/jobs/convert_currency_job_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ConvertCurrencyJobTest < ActiveJob::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
7
test/jobs/daily_exchange_rate_job_test.rb
Normal file
7
test/jobs/daily_exchange_rate_job_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class DailyExchangeRateJobTest < ActiveJob::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
|
@ -3,7 +3,7 @@ require "test_helper"
|
||||||
class AccountTest < ActiveSupport::TestCase
|
class AccountTest < ActiveSupport::TestCase
|
||||||
def setup
|
def setup
|
||||||
depository = Account::Depository.create!
|
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
|
end
|
||||||
|
|
||||||
test "new account should be valid" do
|
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_id
|
||||||
assert_not_nil @account.accountable
|
assert_not_nil @account.accountable
|
||||||
end
|
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
|
end
|
||||||
|
|
7
test/models/currency_test.rb
Normal file
7
test/models/currency_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class CurrencyTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
7
test/models/exchange_rate_test.rb
Normal file
7
test/models/exchange_rate_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ExchangeRateTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
Loading…
Add table
Add a link
Reference in a new issue