diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index f74c7e6e1..252cd114d 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -12,6 +12,7 @@ class UsersController < ApplicationController def update @user = Current.user + return if moniker_change_requested? && !ensure_admin if email_changed? if @user.initiate_email_change(user_params[:email]) @@ -106,7 +107,7 @@ class UsersController < ApplicationController params.require(:user).permit( :first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :default_account_order, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at, :locale, - family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :id ], + family_attributes: [ :name, :currency, :country, :date_format, :timezone, :locale, :month_start_day, :moniker, :id ], goals: [] ) end @@ -115,7 +116,17 @@ class UsersController < ApplicationController @user = Current.user end + def moniker_change_requested? + requested_moniker = params.dig(:user, :family_attributes, :moniker) + return false if requested_moniker.blank? + + requested_moniker != Current.family.moniker + end + def ensure_admin - redirect_to settings_profile_path, alert: I18n.t("users.reset.unauthorized") unless Current.user.admin? + return true if Current.user.admin? + + redirect_to settings_profile_path, alert: I18n.t("users.reset.unauthorized") + false end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f6010503e..15777b223 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -70,6 +70,23 @@ module ApplicationHelper end end + + def family_moniker + Current.family&.moniker_label || "Family" + end + + def family_moniker_downcase + family_moniker.downcase + end + + def family_moniker_plural + Current.family&.moniker_label_plural || "Families" + end + + def family_moniker_plural_downcase + family_moniker_plural.downcase + end + def format_money(number_or_money, options = {}) return nil unless number_or_money diff --git a/app/javascript/controllers/onboarding_controller.js b/app/javascript/controllers/onboarding_controller.js index 5712ab308..7e410d916 100644 --- a/app/javascript/controllers/onboarding_controller.js +++ b/app/javascript/controllers/onboarding_controller.js @@ -2,6 +2,18 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="onboarding" export default class extends Controller { + static targets = ["nameField", "monikerRadio"] + static values = { + householdNameLabel: String, + householdNamePlaceholder: String, + groupNameLabel: String, + groupNamePlaceholder: String + } + + connect() { + this.updateNameFieldForCurrentMoniker(); + } + setLocale(event) { this.refreshWithParam("locale", event.target.value); } @@ -18,6 +30,30 @@ export default class extends Controller { document.documentElement.setAttribute("data-theme", event.target.value); } + updateNameFieldForCurrentMoniker(event = null) { + if (!this.hasNameFieldTarget) { + return; + } + + const selectedMonikerRadio = event?.target?.dataset?.onboardingMoniker ? event.target : this.monikerRadioTargets.find((radio) => radio.checked); + const selectedMoniker = selectedMonikerRadio?.dataset?.onboardingMoniker; + const isGroup = selectedMoniker === "Group"; + + this.nameFieldTarget.placeholder = isGroup ? this.groupNamePlaceholderValue : this.householdNamePlaceholderValue; + + const label = this.nameFieldTarget.closest(".form-field")?.querySelector(".form-field__label"); + if (!label) { + return; + } + + if (isGroup) { + label.textContent = this.groupNameLabelValue; + return; + } + + label.textContent = this.householdNameLabelValue; + } + refreshWithParam(key, value) { const url = new URL(window.location); url.searchParams.set(key, value); diff --git a/app/models/account.rb b/app/models/account.rb index d2a86eaf2..cfb6b4478 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -74,6 +74,11 @@ class Account < ApplicationRecord end class << self + def human_attribute_name(attribute, options = {}) + options = { moniker: Current.family&.moniker_label || "Family" }.merge(options) + super(attribute, options) + end + def create_and_sync(attributes, skip_initial_sync: false) attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty # Default cash_balance to balance unless explicitly provided (e.g., Crypto sets it to 0) diff --git a/app/models/family.rb b/app/models/family.rb index 6bc4ad471..10cc39f72 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -17,6 +17,9 @@ class Family < ApplicationRecord [ "YYYYMMDD", "%Y%m%d" ] ].freeze + + MONIKERS = [ "Family", "Group" ].freeze + has_many :users, dependent: :destroy has_many :accounts, dependent: :destroy has_many :invitations, dependent: :destroy @@ -43,6 +46,16 @@ class Family < ApplicationRecord validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) } validates :month_start_day, inclusion: { in: 1..28 } + validates :moniker, inclusion: { in: MONIKERS } + + + def moniker_label + moniker.presence || "Family" + end + + def moniker_label_plural + moniker_label == "Group" ? "Groups" : "Families" + end def uses_custom_month_start? month_start_day != 1 diff --git a/app/views/entries/_protection_indicator.html.erb b/app/views/entries/_protection_indicator.html.erb index 96243f953..96502f420 100644 --- a/app/views/entries/_protection_indicator.html.erb +++ b/app/views/entries/_protection_indicator.html.erb @@ -22,7 +22,7 @@

<%= t("entries.protection.locked_fields_label") %>

<% entry.locked_fields_with_timestamps.each do |field, timestamp| %>
- <%= entry.class.human_attribute_name(field) %> + <%= entry.class.human_attribute_name(field, moniker: Current.family&.moniker_label || "Family") %>
<%= timestamp.respond_to?(:strftime) ? l(timestamp.to_date, format: :long) : timestamp %>
diff --git a/app/views/family_merchants/index.html.erb b/app/views/family_merchants/index.html.erb index 648c8b39c..0aee42762 100644 --- a/app/views/family_merchants/index.html.erb +++ b/app/views/family_merchants/index.html.erb @@ -17,7 +17,7 @@
-

<%= t(".family_title") %>

+

<%= t(".family_title", moniker: family_moniker) %>

·

<%= @family_merchants.count %>

@@ -41,7 +41,7 @@ <% else %>
-

<%= t(".family_empty") %>

+

<%= t(".family_empty", moniker: family_moniker_downcase) %>

<%= render DS::Link.new( text: t(".new"), @@ -87,7 +87,7 @@
<% else %>
-

<%= t(".provider_empty") %>

+

<%= t(".provider_empty", moniker: family_moniker_downcase) %>

<% end %>
diff --git a/app/views/invitation_mailer/invite_email.html.erb b/app/views/invitation_mailer/invite_email.html.erb index dafb379ee..6e7d6841f 100644 --- a/app/views/invitation_mailer/invite_email.html.erb +++ b/app/views/invitation_mailer/invite_email.html.erb @@ -5,6 +5,7 @@ ".body", inviter: @invitation.inviter.display_name, family: @invitation.family.name, + moniker: @invitation.family.moniker_label.downcase, product_name: product_name ).html_safe %>

diff --git a/app/views/invitations/new.html.erb b/app/views/invitations/new.html.erb index a45c680b2..f74b6a9d5 100644 --- a/app/views/invitations/new.html.erb +++ b/app/views/invitations/new.html.erb @@ -1,5 +1,5 @@ <%= render DS::Dialog.new do |dialog| %> - <% dialog.with_header(title: t(".title"), subtitle: t(".subtitle", product_name: product_name)) %> + <% dialog.with_header(title: t(".title"), subtitle: t(".subtitle", product_name: product_name, moniker: family_moniker_downcase)) %> <% dialog.with_body do %> <%= styled_form_with model: @invitation, class: "space-y-4", data: { turbo: false } do |form| %> diff --git a/app/views/onboardings/show.html.erb b/app/views/onboardings/show.html.erb index 29c55444f..694bc97e8 100644 --- a/app/views/onboardings/show.html.erb +++ b/app/views/onboardings/show.html.erb @@ -10,14 +10,14 @@ <%= render "onboardings/logout" %> <% end %> -
+

<%= t(".title") %>

<%= t(".subtitle") %>

- <%= styled_form_with model: @user do |form| %> + <%= styled_form_with model: @user do |form| %> <%= form.hidden_field :redirect_to, value: @invitation ? "home" : "onboarding_preferences" %> <%= form.hidden_field :onboarded_at, value: Time.current if @invitation %> @@ -33,7 +33,37 @@ <% unless @invitation %>
<%= form.fields_for :family do |family_form| %> - <%= family_form.text_field :name, placeholder: t(".household_name_placeholder"), label: t(".household_name") %> +
+
" + data-onboarding-household-name-placeholder-value="<%= t(".household_name_placeholder") %>" + data-onboarding-group-name-label-value="<%= t(".group_name") %>" + data-onboarding-group-name-placeholder-value="<%= t(".group_name_placeholder") %>" + > +

<%= t(".moniker_prompt", product_name: product_name) %>

+ + + + + + <%= family_form.text_field :name, placeholder: t(".household_name_placeholder"), label: t(".household_name"), data: { onboarding_target: "nameField" } %> +
+
<%= family_form.select :country, country_options, diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb index daebaaabd..08aa1c720 100644 --- a/app/views/registrations/new.html.erb +++ b/app/views/registrations/new.html.erb @@ -1,5 +1,5 @@ <% - header_title @invitation ? t(".join_family_title", family: @invitation.family.name) : t(".title") + header_title @invitation ? t(".join_family_title", family: @invitation.family.name, moniker: @invitation.family.moniker_label) : t(".title") %> <% if self_hosted_first_login? %> diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index 8bcc34842..c2f2dc158 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -26,13 +26,15 @@ <% end %> <% unless Current.user.ui_layout_intro? %> - <%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %> + <%= settings_section title: family_moniker == "Group" ? t(".group_title", default: "Group") : t(".household_title"), subtitle: t(".household_subtitle", moniker_plural: family_moniker_plural_downcase, moniker: family_moniker_downcase) do %>
<%= styled_form_with model: Current.user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> <%= form.fields_for :family do |family_fields| %> + <% name_label = family_moniker == "Group" ? t(".group_form_label", default: "Group name") : t(".household_form_label") %> + <% name_placeholder = family_moniker == "Group" ? t(".group_form_input_placeholder", default: "Enter group name") : t(".household_form_input_placeholder") %> <%= family_fields.text_field :name, - placeholder: t(".household_form_input_placeholder"), - label: t(".household_form_label"), + placeholder: name_placeholder, + label: name_label, disabled: !Current.user.admin?, "data-auto-submit-form-target": "auto" %> <% end %> diff --git a/app/views/simplefin_items/select_existing_account.html.erb b/app/views/simplefin_items/select_existing_account.html.erb index 41db812dd..b47727556 100644 --- a/app/views/simplefin_items/select_existing_account.html.erb +++ b/app/views/simplefin_items/select_existing_account.html.erb @@ -6,7 +6,7 @@ <% dialog.with_body do %> <% if @available_simplefin_accounts.blank? %>
-

<%= t("simplefin_items.select_existing_account.no_accounts_found") %>

+

<%= t("simplefin_items.select_existing_account.no_accounts_found", moniker: family_moniker_downcase) %>

  • <%= t("simplefin_items.select_existing_account.wait_for_sync") %>
  • <%= t("simplefin_items.select_existing_account.check_provider_health") %>
  • diff --git a/config/locales/models/account/en.yml b/config/locales/models/account/en.yml index 4b199653c..e4c882b9c 100644 --- a/config/locales/models/account/en.yml +++ b/config/locales/models/account/en.yml @@ -5,8 +5,8 @@ en: account: balance: Balance currency: Currency - family: Family - family_id: Family + family: "%{moniker}" + family_id: "%{moniker}" name: Name subtype: Subtype models: diff --git a/config/locales/models/user/en.yml b/config/locales/models/user/en.yml index 9b66de68d..c94bbad77 100644 --- a/config/locales/models/user/en.yml +++ b/config/locales/models/user/en.yml @@ -4,8 +4,8 @@ en: attributes: user: email: Email - family: Family - family_id: Family + family: "%{moniker}" + family_id: "%{moniker}" first_name: First Name last_name: Last Name password: Password diff --git a/config/locales/views/invitation_mailer/en.yml b/config/locales/views/invitation_mailer/en.yml index abd6d22e5..02e7036d3 100644 --- a/config/locales/views/invitation_mailer/en.yml +++ b/config/locales/views/invitation_mailer/en.yml @@ -3,6 +3,6 @@ en: invitation_mailer: invite_email: accept_button: Accept Invitation - body: "%{inviter} has invited you to join the %{family} family on %{product_name}!" + body: "%{inviter} has invited you to join the %{family} %{moniker} on %{product_name}!" expiry_notice: This invitation will expire in %{days} days greeting: Welcome to %{product_name}! diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml index 3a2cde81c..45c2d51a5 100644 --- a/config/locales/views/invitations/en.yml +++ b/config/locales/views/invitations/en.yml @@ -23,5 +23,5 @@ en: role_label: Role role_member: Member submit: Send Invitation - subtitle: Send an invitation to join your family account on %{product_name} + subtitle: Send an invitation to join your %{moniker} account on %{product_name} title: Invite Someone diff --git a/config/locales/views/merchants/en.yml b/config/locales/views/merchants/en.yml index aeb0fead5..efd51ba76 100644 --- a/config/locales/views/merchants/en.yml +++ b/config/locales/views/merchants/en.yml @@ -18,10 +18,10 @@ en: new: New merchant merge: Merge merchants title: Merchants - family_title: Family merchants - family_empty: No family merchants yet + family_title: "%{moniker} merchants" + family_empty: "No %{moniker} merchants yet" provider_title: Provider merchants - provider_empty: No provider merchants linked to this family yet + provider_empty: "No provider merchants linked to this %{moniker} yet" provider_read_only: Provider merchants are synced from your connected institutions. They cannot be edited here. provider_info: These merchants were automatically detected by your bank connections or AI. You can edit them to create your own copy, or remove them to unlink from your transactions. unlinked_title: Recently unlinked diff --git a/config/locales/views/onboardings/en.yml b/config/locales/views/onboardings/en.yml index 296c0b80c..c265f8c64 100644 --- a/config/locales/views/onboardings/en.yml +++ b/config/locales/views/onboardings/en.yml @@ -16,8 +16,13 @@ en: first_name_placeholder: First name last_name: Last name last_name_placeholder: Last name + group_name: Group name + group_name_placeholder: Group name household_name: Household name household_name_placeholder: Household name + moniker_prompt: "Will be using %{product_name} with ..." + moniker_family: Family members (just yourself or with partner, teens, etc.) + moniker_group: Group of people (company, club, association, any other type) country: Country submit: Continue preferences: @@ -58,4 +63,4 @@ en: in_40_days: In 40 days (%{date}) in_40_days_description: We'll notify you to remind you to export your data. in_45_days: In 45 days (%{date}) - in_45_days_description: We delete your data — contribute to continue using Sure here! \ No newline at end of file + in_45_days_description: We delete your data — contribute to continue using Sure here! diff --git a/config/locales/views/registrations/en.yml b/config/locales/views/registrations/en.yml index d8f86dfea..7f61905f9 100644 --- a/config/locales/views/registrations/en.yml +++ b/config/locales/views/registrations/en.yml @@ -15,7 +15,7 @@ en: success: You have signed up successfully. new: invitation_message: "%{inviter} has invited you to join as a %{role}" - join_family_title: Join %{family} + join_family_title: Join %{family} %{moniker} role_admin: administrator role_guest: guest role_member: member diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml index 27d8cee29..55730d2b6 100644 --- a/config/locales/views/settings/en.yml +++ b/config/locales/views/settings/en.yml @@ -80,10 +80,12 @@ en: reset_account_with_sample_data_warning: Delete all your existing data and then load fresh sample data so you can explore with a pre-filled environment. email: Email first_name: First Name + group_form_input_placeholder: Enter group name + group_form_label: Group name + group_title: Group Members household_form_input_placeholder: Enter household name household_form_label: Household name - household_subtitle: Invite family members, partners and other inviduals. Invitees - can login to your household and access your shared accounts. + household_subtitle: Invitees can login to your %{moniker} account and access shared resources. household_title: Household invitation_link: Invitation link invite_member: Add member diff --git a/config/locales/views/simplefin_items/en.yml b/config/locales/views/simplefin_items/en.yml index 216f9cc2d..9929eb64c 100644 --- a/config/locales/views/simplefin_items/en.yml +++ b/config/locales/views/simplefin_items/en.yml @@ -87,7 +87,7 @@ en: description: Select a SimpleFIN account to link to your existing account cancel: Cancel link_account: Link account - no_accounts_found: No SimpleFIN accounts found for this family. + no_accounts_found: "No SimpleFIN accounts found for this %{moniker}." wait_for_sync: If you just connected or synced, try again after the sync completes. unlink_to_move: To move a link, first unlink it from the account’s actions menu. all_accounts_already_linked: All SimpleFIN accounts appear to be linked already. diff --git a/db/migrate/20260211101500_add_moniker_to_families.rb b/db/migrate/20260211101500_add_moniker_to_families.rb new file mode 100644 index 000000000..fa79636a4 --- /dev/null +++ b/db/migrate/20260211101500_add_moniker_to_families.rb @@ -0,0 +1,5 @@ +class AddMonikerToFamilies < ActiveRecord::Migration[7.2] + def change + add_column :families, :moniker, :string, null: false, default: "Family" + end +end diff --git a/db/schema.rb b/db/schema.rb index 785ba1fa3..fe8f6523b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -501,6 +501,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_11_120001) do t.boolean "recurring_transactions_disabled", default: false, null: false t.integer "month_start_day", default: 1, null: false t.string "vector_store_id" + t.string "moniker", default: "Family", null: false t.check_constraint "month_start_day >= 1 AND month_start_day <= 28", name: "month_start_day_range" end diff --git a/test/controllers/onboardings_controller_test.rb b/test/controllers/onboardings_controller_test.rb index a0b886c38..bb4641371 100644 --- a/test/controllers/onboardings_controller_test.rb +++ b/test/controllers/onboardings_controller_test.rb @@ -17,6 +17,16 @@ class OnboardingsControllerTest < ActionDispatch::IntegrationTest assert_select "h1", text: /set up your account/i end + + test "onboarding setup includes required moniker selection" do + get onboarding_url + assert_response :success + + assert_select "input[name='user[family_attributes][moniker]'][value='Family'][required]" + assert_select "input[name='user[family_attributes][moniker]'][value='Group'][required]" + assert_select "p", text: /Will be using Sure with/i + end + test "should get preferences" do get preferences_onboarding_url assert_response :success diff --git a/test/models/family_test.rb b/test/models/family_test.rb index b0fb3632e..240547c6e 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -36,6 +36,19 @@ class FamilyTest < ActiveSupport::TestCase end end + + test "moniker helpers return expected singular and plural labels" do + family = families(:dylan_family) + + family.update!(moniker: "Family") + assert_equal "Family", family.moniker_label + assert_equal "Families", family.moniker_label_plural + + family.update!(moniker: "Group") + assert_equal "Group", family.moniker_label + assert_equal "Groups", family.moniker_label_plural + end + test "available_merchants includes family merchants without transactions" do family = families(:dylan_family)