diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index ebf250d03..1ee9ee95b 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -107,7 +107,10 @@ class UsersController < ApplicationController def user_params family_attrs = [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :id ] - family_attrs.push(:moniker, :default_account_sharing) if Current.user.admin? + if Current.user.admin? + family_attrs.push(:moniker, :default_account_sharing) + family_attrs << { enabled_currencies: [] } + end params.require(:user).permit( :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, @@ -127,8 +130,9 @@ class UsersController < ApplicationController moniker_changed = family_attrs[:moniker].present? && family_attrs[:moniker] != Current.family.moniker sharing_changed = family_attrs[:default_account_sharing].present? && family_attrs[:default_account_sharing] != Current.family.default_account_sharing + enabled_currencies_changed = family_attrs.key?(:enabled_currencies) - moniker_changed || sharing_changed + moniker_changed || sharing_changed || enabled_currencies_changed end def ensure_admin diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 7f0aad0bd..e8a7f008b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -100,6 +100,17 @@ module ApplicationHelper .join(separator) end + def currency_picker_options_for_family(family = Current.family, extra: []) + return Money::Currency.as_options.map(&:iso_code) unless family + + family.enabled_currency_codes(extra:) + end + + def currency_label(currency_or_code) + currency = currency_or_code.is_a?(Money::Currency) ? currency_or_code : Money::Currency.new(currency_or_code) + "#{currency.name} (#{currency.iso_code})" + end + def show_super_admin_bar? if params[:admin].present? cookies.permanent[:admin] = params[:admin] diff --git a/app/javascript/controllers/currency_preferences_controller.js b/app/javascript/controllers/currency_preferences_controller.js new file mode 100644 index 000000000..04cf31b71 --- /dev/null +++ b/app/javascript/controllers/currency_preferences_controller.js @@ -0,0 +1,31 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["dialog", "checkbox"]; + static values = { + baseCurrency: String, + }; + + open() { + this.dialogTarget.showModal(); + } + + selectAll() { + this.checkboxTargets.forEach((checkbox) => { + checkbox.checked = true; + }); + } + + selectBaseOnly() { + this.checkboxTargets.forEach((checkbox) => { + checkbox.checked = checkbox.value === this.baseCurrencyValue; + }); + } + + handleSubmitEnd(event) { + if (!event.detail.success) return; + if (!this.dialogTarget.open) return; + + this.dialogTarget.close(); + } +} diff --git a/app/models/family.rb b/app/models/family.rb index c141ec968..6156f6861 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -52,6 +52,34 @@ class Family < ApplicationRecord validates :assistant_type, inclusion: { in: ASSISTANT_TYPES } validates :default_account_sharing, inclusion: { in: SHARING_DEFAULTS } + before_validation :normalize_enabled_currencies! + + def primary_currency_code + normalize_currency_code(currency) || "USD" + end + + def custom_enabled_currencies? + enabled_currencies.present? + end + + def enabled_currency_codes(extra: []) + selected_codes = if custom_enabled_currencies? + [ primary_currency_code, *Array(enabled_currencies) ] + else + Money::Currency.as_options.map(&:iso_code) + end + + normalize_currency_codes([ *selected_codes, *Array(extra) ]) + end + + def enabled_currency_objects(extra: []) + enabled_currency_codes(extra:).map { |code| Money::Currency.new(code) } + end + + def secondary_enabled_currency_objects(extra: []) + enabled_currency_objects(extra:).reject { |currency| currency.iso_code == primary_currency_code } + end + def moniker_label moniker.presence || "Family" @@ -299,4 +327,29 @@ class Family < ApplicationRecord def self_hoster? Rails.application.config.app_mode.self_hosted? end + + private + def normalize_enabled_currencies! + if enabled_currencies.blank? + self.enabled_currencies = nil + return + end + + normalized_codes = normalize_currency_codes([ primary_currency_code, *Array(enabled_currencies) ]) + all_codes = Money::Currency.as_options.map(&:iso_code) + all_selected = normalized_codes.size == all_codes.size && (normalized_codes - all_codes).empty? + self.enabled_currencies = all_selected ? nil : normalized_codes + end + + def normalize_currency_codes(values) + Array(values).filter_map { |value| normalize_currency_code(value) }.uniq + end + + def normalize_currency_code(value) + return if value.blank? + + Money::Currency.new(value).iso_code + rescue Money::Currency::UnknownCurrencyError, ArgumentError + nil + end end diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb index 22faaf010..199ad36e0 100644 --- a/app/views/settings/preferences/show.html.erb +++ b/app/views/settings/preferences/show.html.erb @@ -1,63 +1,158 @@ <%= content_for :page_title, t(".page_title") %> - <%= settings_section title: t(".general_title"), subtitle: t(".general_subtitle") do %>
<%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> <%= form.hidden_field :redirect_to, value: "preferences" %> - <%= form.select :locale, language_options, { label: t(".language"), include_blank: t(".language_auto") }, { data: { auto_submit_form_target: "auto" } } %> - <%= form.fields_for :family do |family_form| %> - <%= family_form.select :currency, - Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }, - { label: t(".currency") }, disabled: true %> - <%= family_form.select :timezone, timezone_options, { label: t(".timezone") }, { data: { auto_submit_form_target: "auto" } } %> - <%= family_form.select :date_format, Family::DATE_FORMATS, { label: t(".date_format") }, { data: { auto_submit_form_target: "auto" } } %> - <%= form.select :default_period, Period.all.map { |period| [ period.label, period.key ] }, { label: t(".default_period") }, { data: { auto_submit_form_target: "auto" } } %> - <%= form.select :default_account_order, AccountOrder.all.map { |order| [ order.label, order.key ] }, { label: t(".default_account_order") }, { data: { auto_submit_form_target: "auto" } } %> - <%= family_form.select :country, country_options, { label: t(".country") }, { data: { auto_submit_form_target: "auto" } } %> - <%= family_form.select :month_start_day, (1..28).map { |day| [day.ordinalize, day] }, { label: t(".month_start_day"), hint: t(".month_start_day_hint") }, { data: { auto_submit_form_target: "auto" } } %> - <% if @user.family.uses_custom_month_start? %>
<%= t(".month_start_day_warning") %>
<% end %> -

Please note, we are still working on translations for various languages.

<% end %> <% end %>
<% end %> - <% if Current.user.admin? %> + <%= settings_section title: t(".currencies_title", moniker: family_moniker), subtitle: t(".currencies_subtitle", moniker: family_moniker_downcase) do %> +
+ <% preview_currencies = @user.family.enabled_currency_objects.first(6) %> +
+
+
+

<%= @user.family.primary_currency_code %>

+

<%= currency_label(@user.family.primary_currency_code) %>

+
+ + <%= t(".base_currency_badge") %> + +
+
+ <% preview_currencies.each do |currency| %> + + <%= currency.iso_code %> + + <% end %> + <% if @user.family.enabled_currency_codes.count > preview_currencies.count %> + + <%= t(".currencies_more", count: @user.family.enabled_currency_codes.count - preview_currencies.count) %> + + <% end %> +
+
+ <%= render DS::Button.new( + text: t(".manage_currencies"), + type: :button, + class: "md:w-auto w-full justify-center", + data: { action: "currency-preferences#open" } + ) %> + <%= render DS::Dialog.new( + id: "currency-preferences-dialog", + auto_open: false, + disable_frame: true, + width: "md", + data: { currency_preferences_target: "dialog" } + ) do |dialog| %> + <% dialog.with_header(title: t(".manage_currencies"), subtitle: t(".manage_currencies_subtitle")) %> + <% dialog.with_body do %> + <%= styled_form_with model: @user, url: user_path(@user), class: "space-y-4", data: { action: "turbo:submit-end->currency-preferences#handleSubmitEnd" } do |form| %> + <%= form.hidden_field :redirect_to, value: "preferences" %> + <%= hidden_field_tag "user[family_attributes][id]", @user.family.id %> + <%= hidden_field_tag "user[family_attributes][enabled_currencies][]", @user.family.primary_currency_code, id: nil %> +
+ <%= render DS::Button.new( + text: t(".select_all_currencies"), + type: :button, + variant: :ghost, + data: { action: "currency-preferences#selectAll" } + ) %> + <%= render DS::Button.new( + text: t(".select_base_only"), + type: :button, + variant: :ghost, + data: { action: "currency-preferences#selectBaseOnly" } + ) %> +
+
+
+ " + aria-label="<%= t(".currency_search_placeholder") %>" + data-list-filter-target="input" + data-action="input->list-filter#filter" + class="block w-full border border-secondary rounded-md py-2 pl-10 pr-3 bg-container focus:ring-gray-500 sm:text-sm"> +
+ <%= icon("search", class: "text-secondary") %> +
+
+
+ <% Money::Currency.as_options.each do |currency| %> + <% checked = @user.family.enabled_currency_codes.include?(currency.iso_code) %> + <% base_currency = currency.iso_code == @user.family.primary_currency_code %> + + <% end %> +
+
+
+ <%= render DS::Button.new(text: t("shared.cancel"), type: :button, variant: :ghost, data: { action: "DS--dialog#close" }) %> + <%= render DS::Button.new(text: t(".save_currencies")) %> +
+ <% end %> + <% end %> + <% end %> +
+ <% end %> <%= settings_section title: t(".sharing_title", moniker: family_moniker), subtitle: t(".sharing_subtitle", moniker: family_moniker_downcase) do %>
<%= styled_form_with model: @user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index 386b7ebe6..c4f34f542 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -78,7 +78,7 @@ currency_data["action"] = ["change->money-field#handleCurrencyChange", existing_action].compact.join(" ") %> <%= form.select currency_method, - Money::Currency.as_options.map(&:iso_code), + currency_picker_options_for_family(extra: currency.iso_code), { inline: true, selected: currency.iso_code }, { class: "w-fit pr-5 disabled:text-subdued form-field__input", diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 02eda5228..46e834d1a 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -58,6 +58,16 @@ en: month_start_day: Budget month starts on month_start_day_hint: Set when your budget month starts (e.g., payday) month_start_day_warning: Your budgets and MTD calculations will use this custom start day instead of the 1st of each month. + currencies_title: "%{moniker} Currencies" + currencies_subtitle: Choose which currencies appear in money fields for your %{moniker} + base_currency_badge: Base currency + currencies_more: "+%{count} more" + manage_currencies: Manage currencies + manage_currencies_subtitle: Deselect the currencies you never use, or reduce the list down to just a few. + select_all_currencies: Select all + select_base_only: Only base currency + currency_search_placeholder: Search currencies + save_currencies: Save currencies sharing_title: "%{moniker} Sharing" sharing_subtitle: "Control how accounts are shared in your %{moniker}" sharing_default_label: Default sharing for new accounts diff --git a/db/migrate/20260410100000_add_enabled_currencies_to_families.rb b/db/migrate/20260410100000_add_enabled_currencies_to_families.rb new file mode 100644 index 000000000..028d14c1b --- /dev/null +++ b/db/migrate/20260410100000_add_enabled_currencies_to_families.rb @@ -0,0 +1,5 @@ +class AddEnabledCurrenciesToFamilies < ActiveRecord::Migration[7.2] + def change + add_column :families, :enabled_currencies, :string, array: true + end +end diff --git a/db/schema.rb b/db/schema.rb index d81f73e2d..e5255b267 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -594,6 +594,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_04_12_120000) do t.string "moniker", default: "Family", null: false t.string "assistant_type", default: "builtin", null: false t.string "default_account_sharing", default: "shared", null: false + t.string "enabled_currencies", array: true t.check_constraint "default_account_sharing::text = ANY (ARRAY['shared'::character varying::text, 'private'::character varying::text])", name: "chk_families_default_account_sharing" t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end diff --git a/test/controllers/settings/preferences_controller_test.rb b/test/controllers/settings/preferences_controller_test.rb index c17b03f6e..d2f5142fc 100644 --- a/test/controllers/settings/preferences_controller_test.rb +++ b/test/controllers/settings/preferences_controller_test.rb @@ -4,8 +4,21 @@ class Settings::PreferencesControllerTest < ActionDispatch::IntegrationTest setup do sign_in users(:family_admin) end + test "get" do get settings_preferences_url + assert_response :success end + + test "group moniker uses group currencies copy and hides legacy currency field" do + users(:family_admin).family.update!(moniker: "Group") + + get settings_preferences_url + + assert_response :success + assert_includes response.body, "Group Currencies" + assert_includes response.body, "your group" + assert_select "select[name='user[family_attributes][currency]']", count: 0 + end end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 5ca659669..63c00b478 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -32,6 +32,40 @@ class UsersControllerTest < ActionDispatch::IntegrationTest assert_equal "es", @user.reload.locale end + test "admin can update enabled family currencies" do + patch user_url(@user), params: { + user: { + redirect_to: "preferences", + family_attributes: { + id: @user.family.id, + enabled_currencies: [ "USD", "SGD" ] + } + } + } + + assert_redirected_to settings_preferences_url + assert_equal [ "USD", "SGD" ], @user.family.reload.enabled_currency_codes + end + + test "non-admin cannot update enabled family currencies" do + sign_in @member = users(:family_member) + original_codes = @member.family.enabled_currency_codes + + patch user_url(@member), params: { + user: { + redirect_to: "preferences", + family_attributes: { + id: @member.family.id, + enabled_currencies: [ "USD", "SGD" ] + } + } + } + + assert_redirected_to settings_profile_url + assert_equal I18n.t("users.reset.unauthorized"), flash[:alert] + assert_equal original_codes, @member.family.reload.enabled_currency_codes + end + test "admin can reset family data" do account = accounts(:investment) category = categories(:income) diff --git a/test/helpers/application_helper_test.rb b/test/helpers/application_helper_test.rb index e4c3bf361..d946c5afd 100644 --- a/test/helpers/application_helper_test.rb +++ b/test/helpers/application_helper_test.rb @@ -24,4 +24,18 @@ class ApplicationHelperTest < ActionView::TestCase assert_equal "$0.00", totals_by_currency(collection: [ Account.new(currency: "USD", balance: 0) ], money_method: :balance_money) assert_equal "-$3.00 | €7.00", totals_by_currency(collection: [ @account1, @account2, @account3 ], money_method: :balance_money, negate: true) end + + test "#currency_picker_options_for_family returns enabled family currencies" do + family = families(:dylan_family) + family.update!(currency: "SGD", enabled_currencies: [ "USD" ]) + + assert_equal [ "SGD", "USD" ], currency_picker_options_for_family(family) + end + + test "#currency_picker_options_for_family keeps selected legacy currency visible" do + family = families(:dylan_family) + family.update!(currency: "SGD", enabled_currencies: [ "USD" ]) + + assert_equal [ "SGD", "USD", "EUR" ], currency_picker_options_for_family(family, extra: "EUR") + end end diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 69a530316..f026ff6ab 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -173,6 +173,39 @@ class FamilyTest < ActiveSupport::TestCase assert_includes family.available_merchants, new_merchant end + test "enabled currencies always include the base currency" do + family = families(:dylan_family) + family.update!(currency: "SGD", enabled_currencies: [ "USD" ]) + + family.update!(enabled_currencies: [ "USD" ]) + + assert_equal [ "SGD", "USD" ], family.reload.enabled_currency_codes + end + + test "empty enabled currencies keeps all currencies available" do + family = families(:dylan_family) + family.update!(enabled_currencies: []) + + assert_nil family.reload.enabled_currencies + assert_equal Money::Currency.as_options.map(&:iso_code), family.reload.enabled_currency_codes + end + + test "enabled currencies are normalized and deduplicated" do + family = families(:dylan_family) + family.update!(currency: "SGD", enabled_currencies: [ "USD", "usd", "SGD" ]) + + assert_equal [ "SGD", "USD" ], family.reload.enabled_currencies + assert_equal [ "SGD", "USD" ], family.reload.enabled_currency_codes + end + + test "all selected currencies collapse to default behavior" do + family = families(:dylan_family) + family.update!(enabled_currencies: Money::Currency.as_options.map(&:iso_code)) + + assert_nil family.reload.enabled_currencies + assert_equal Money::Currency.as_options.map(&:iso_code), family.reload.enabled_currency_codes + end + test "upload_document stores provided metadata on family document" do family = families(:dylan_family) family.update!(vector_store_id: nil)