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)