diff --git a/app/components/DS/menu.html.erb b/app/components/DS/menu.html.erb index d4f0ea8d8..ed6490184 100644 --- a/app/components/DS/menu.html.erb +++ b/app/components/DS/menu.html.erb @@ -1,4 +1,4 @@ -<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, testid: testid } do %> +<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, DS__menu_mobile_fullwidth_value: mobile_fullwidth, testid: testid } do %> <% if variant == :icon %> <%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %> <% elsif variant == :button %> @@ -12,7 +12,7 @@ <% end %> <% end %> diff --git a/app/components/DS/menu.rb b/app/components/DS/menu.rb index 39ef35e97..32a14a472 100644 --- a/app/components/DS/menu.rb +++ b/app/components/DS/menu.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class DS::Menu < DesignSystemComponent - attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid + attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid, :mobile_fullwidth, :max_width renders_one :button, ->(**button_options, &block) do options_with_target = button_options.merge(data: { DS__menu_target: "button" }) @@ -23,7 +23,7 @@ class DS::Menu < DesignSystemComponent VARIANTS = %i[icon button avatar].freeze - def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil) + def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil, mobile_fullwidth: true, max_width: nil) @variant = variant.to_sym @avatar_url = avatar_url @initials = initials @@ -32,6 +32,8 @@ class DS::Menu < DesignSystemComponent @icon_vertical = icon_vertical @no_padding = no_padding @testid = testid + @mobile_fullwidth = mobile_fullwidth + @max_width = max_width raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant) end diff --git a/app/components/DS/menu_controller.js b/app/components/DS/menu_controller.js index 512358f6c..33d3714ef 100644 --- a/app/components/DS/menu_controller.js +++ b/app/components/DS/menu_controller.js @@ -16,6 +16,7 @@ export default class extends Controller { show: Boolean, placement: { type: String, default: "bottom-end" }, offset: { type: Number, default: 6 }, + mobileFullwidth: { type: Boolean, default: true }, }; connect() { @@ -105,13 +106,14 @@ export default class extends Controller { if (!this.buttonTarget || !this.contentTarget) return; const isSmallScreen = !window.matchMedia("(min-width: 768px)").matches; + const useMobileFullwidth = isSmallScreen && this.mobileFullwidthValue; computePosition(this.buttonTarget, this.contentTarget, { - placement: isSmallScreen ? "bottom" : this.placementValue, + placement: useMobileFullwidth ? "bottom" : this.placementValue, middleware: [offset(this.offsetValue), shift({ padding: 5 })], strategy: "fixed", }).then(({ x, y }) => { - if (isSmallScreen) { + if (useMobileFullwidth) { Object.assign(this.contentTarget.style, { position: "fixed", left: "0px", diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 6b9ba590c..5fcb3df66 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -1,5 +1,5 @@ class AccountsController < ApplicationController - before_action :set_account, only: %i[sync sparkline toggle_active show destroy unlink confirm_unlink select_provider] + before_action :set_account, only: %i[sync sparkline toggle_active set_default remove_default show destroy unlink confirm_unlink select_provider] include Periodable def index @@ -89,6 +89,21 @@ class AccountsController < ApplicationController redirect_to accounts_path end + def set_default + unless @account.eligible_for_transaction_default? + redirect_to accounts_path, alert: t("accounts.set_default.depository_only") + return + end + + Current.user.update!(default_account: @account) + redirect_to accounts_path + end + + def remove_default + Current.user.update!(default_account: nil) + redirect_to accounts_path + end + def destroy if @account.linked? redirect_to account_path(@account), alert: t("accounts.destroy.cannot_delete_linked") diff --git a/app/models/account.rb b/app/models/account.rb index 63afce7d7..45a4635e4 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -310,6 +310,14 @@ class Account < ApplicationRecord accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name end + def supports_default? + depository? || credit_card? + end + + def eligible_for_transaction_default? + supports_default? && active? && !linked? + end + # Determines if this account supports manual trade entry # Investment accounts always support trades; Crypto only if subtype is "exchange" def supports_trades? diff --git a/app/models/user.rb b/app/models/user.rb index 24ce74a0b..9c6a1882c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -24,6 +24,7 @@ class User < ApplicationRecord belongs_to :family belongs_to :last_viewed_chat, class_name: "Chat", optional: true + belongs_to :default_account, class_name: "Account", optional: true has_many :sessions, dependent: :destroy has_many :chats, dependent: :destroy has_many :api_keys, dependent: :destroy @@ -244,6 +245,15 @@ class User < ApplicationRecord AccountOrder.find(default_account_order) || AccountOrder.default end + def default_account_for_transactions + return nil unless default_account_id.present? + + account = default_account + return nil unless account&.eligible_for_transaction_default? && account.family_id == family_id + + account + end + # Dashboard preferences management def dashboard_section_collapsed?(section_key) preferences&.dig("collapsed_sections", section_key) == true diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index c9eadeb86..380bb53fd 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -1,7 +1,9 @@ <%# locals: (account:, return_to: nil) %> +<% is_default = Current.user&.default_account_id == account.id %> + <%= turbo_frame_tag dom_id(account) do %> -
+
<%= render "accounts/logo", account: account, size: "md" %> @@ -16,41 +18,26 @@

<% else %> -
+
<%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %> - <% if account.institution_name %> - • <%= account.institution_name %> + + <% if account.institution_name.present? %> + <% end %>
<% if account.long_subtype_label %>

<%= account.long_subtype_label %>

<% end %> + <% if account.supports_default? && is_default %> +

<%= t("accounts.account.default_label") %>

+ <% end %> + <% if account.institution_name.present? %> +

<%= account.institution_name %>

+ <% end %> <% end %>
- - <% unless account.pending_deletion? %> - <%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %> - <%= icon("pencil-line", size: "sm") %> - <% end %> - - <% if !account.linked? && ["Depository", "CreditCard", "Investment", "Crypto"].include?(account.accountable_type) %> - <%= link_to select_provider_account_path(account), - data: { turbo_frame: :modal }, - class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1", - title: t("accounts.account.link_provider") do %> - <%= icon("link", size: "sm") %> - <% end %> - <% elsif account.linked? %> - <%= link_to confirm_unlink_account_path(account), - data: { turbo_frame: :modal }, - class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center gap-1", - title: t("accounts.account.unlink_provider") do %> - <%= icon("unlink", size: "sm") %> - <% end %> - <% end %> - <% end %>
-
+
<% if account.draft? %> <% elsif account.syncing? %> @@ -68,14 +55,34 @@ variant: :outline, frame: :modal ) %> - <% elsif account.active? || account.disabled? %> - <%= form_with model: account, url: toggle_active_account_path(account), method: :patch, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %> - <%= render DS::Toggle.new( - id: "account_#{account.id}_active", - name: "active", - checked: account.active?, - data: { auto_submit_form_target: "auto" } - ) %> + <% elsif !account.pending_deletion? %> + <%= render DS::Menu.new(icon_vertical: true, mobile_fullwidth: false, max_width: "280px") do |menu| %> + <% menu.with_item(variant: "link", text: t("accounts.account.edit"), href: edit_account_path(account, return_to: return_to), icon: "pencil-line", data: { turbo_frame: :modal }) %> + + <% if !account.linked? && %w[Depository CreditCard Investment Crypto].include?(account.accountable_type) %> + <% menu.with_item(variant: "link", text: t("accounts.account.link_provider"), href: select_provider_account_path(account), icon: "link", data: { turbo_frame: :modal }) %> + <% elsif account.linked? %> + <% menu.with_item(variant: "link", text: t("accounts.account.unlink_provider"), href: confirm_unlink_account_path(account), icon: "unlink", data: { turbo_frame: :modal }) %> + <% end %> + + <% menu.with_item(variant: "divider") %> + + <% if account.active? %> + <% menu.with_item(variant: "button", text: t("accounts.account.disable"), href: toggle_active_account_path(account), method: :patch, icon: "toggle-right", data: { turbo_frame: :_top }) %> + <% elsif account.disabled? %> + <% menu.with_item(variant: "button", text: t("accounts.account.enable"), href: toggle_active_account_path(account), method: :patch, icon: "toggle-left", data: { turbo_frame: :_top }) %> + <% end %> + + <% if is_default %> + <% menu.with_item(variant: "button", text: t("accounts.account.remove_default"), href: remove_default_account_path(account), method: :patch, icon: "star-off", data: { turbo_frame: :_top }) %> + <% elsif account.eligible_for_transaction_default? %> + <% menu.with_item(variant: "button", text: t("accounts.account.set_default"), href: set_default_account_path(account), method: :patch, icon: "star", data: { turbo_frame: :_top }) %> + <% end %> + + <% unless account.linked? %> + <% menu.with_item(variant: "divider") %> + <% menu.with_item(variant: "button", text: t("accounts.account.delete"), href: account_path(account), method: :delete, icon: "trash-2", confirm: CustomConfirm.for_resource_deletion("account", high_severity: true), data: { turbo_frame: :_top }) %> + <% end %> <% end %> <% end %>
diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb index 2717dd57c..7bfde6e9a 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/transactions/_form.html.erb @@ -18,7 +18,7 @@ <% if @entry.account_id %> <%= f.hidden_field :account_id %> <% else %> - <%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account"), variant: :logo }, required: true, class: "form-field__input text-ellipsis" %> + <%= f.collection_select :account_id, Current.family.accounts.manual.active.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account"), selected: Current.user.default_account_for_transactions&.id, variant: :logo }, required: true, class: "form-field__input text-ellipsis" %> <% end %> <%= f.money_field :amount, label: t(".amount"), required: true %> diff --git a/config/locales/views/accounts/de.yml b/config/locales/views/accounts/de.yml index 1b3e4d27b..f163a31b1 100644 --- a/config/locales/views/accounts/de.yml +++ b/config/locales/views/accounts/de.yml @@ -2,14 +2,23 @@ de: accounts: account: + edit: Bearbeiten link_lunchflow: Mit Lunch Flow verknüpfen link_provider: Mit Provider verknüpfen unlink_provider: Von Provider trennen troubleshoot: Fehlerbehebung + enable: Konto aktivieren + disable: Konto deaktivieren + set_default: Als Standard festlegen + remove_default: Standard aufheben + default_label: Standard + delete: Konto löschen chart: data_not_available: Für den ausgewählten Zeitraum sind keine Daten verfügbar create: success: "%{type}-Konto erstellt" + set_default: + depository_only: "Nur Bargeld- und Kreditkartenkonten können als Standard festgelegt werden." destroy: success: "%{type}-Konto zur Löschung vorgemerkt" cannot_delete_linked: "Ein verknüpftes Konto kann nicht gelöscht werden. Bitte trennen Sie es zuerst." diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 4e2d61faa..1a721e19a 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -2,14 +2,23 @@ en: accounts: account: + edit: Edit link_lunchflow: Link with Lunch Flow link_provider: Link with provider unlink_provider: Unlink from provider troubleshoot: Troubleshoot + enable: Enable account + disable: Disable account + set_default: Set as default + remove_default: Unset default + default_label: Default + delete: Delete account chart: data_not_available: Data not available for the selected period create: success: "%{type} account created" + set_default: + depository_only: "Only cash and credit card accounts can be set as default." destroy: success: "%{type} account scheduled for deletion" cannot_delete_linked: "Cannot delete a linked account. Please unlink it first." diff --git a/config/locales/views/accounts/es.yml b/config/locales/views/accounts/es.yml index 2639652df..613bf93ce 100644 --- a/config/locales/views/accounts/es.yml +++ b/config/locales/views/accounts/es.yml @@ -2,14 +2,23 @@ es: accounts: account: + edit: Editar link_lunchflow: Vincular con Lunch Flow link_provider: Vincular con proveedor unlink_provider: Desvincular de proveedor troubleshoot: Solucionar problemas + enable: Activar cuenta + disable: Desactivar cuenta + set_default: Establecer como predeterminada + remove_default: Quitar predeterminada + default_label: Predeterminada + delete: Eliminar cuenta chart: data_not_available: Datos no disponibles para el período seleccionado create: success: "Cuenta %{type} creada" + set_default: + depository_only: "Solo las cuentas de efectivo y tarjeta de crédito pueden establecerse como predeterminadas." destroy: success: "Cuenta %{type} programada para eliminación" cannot_delete_linked: "No se puede eliminar una cuenta vinculada. Por favor, desvincúlela primero." diff --git a/config/locales/views/accounts/fr.yml b/config/locales/views/accounts/fr.yml index 3c9dae3bb..7d2eafcb8 100644 --- a/config/locales/views/accounts/fr.yml +++ b/config/locales/views/accounts/fr.yml @@ -2,14 +2,23 @@ fr: accounts: account: + edit: Modifier link_lunchflow: Lier avec Lunch Flow link_provider: Lier avec un fournisseur unlink_provider: Délier du fournisseur troubleshoot: Dépannage + enable: Activer le compte + disable: Désactiver le compte + set_default: Définir par défaut + remove_default: Retirer par défaut + default_label: Par défaut + delete: Supprimer le compte chart: data_not_available: Données non disponibles pour la période sélectionnée create: success: "Compte %{type} créé" + set_default: + depository_only: "Seuls les comptes de liquidités et de carte de crédit peuvent être définis par défaut." destroy: success: "Le compte %{type} a été préparé à la suppression" cannot_delete_linked: "Impossible de supprimer un compte lié. Veuillez d'abord le délier." diff --git a/config/routes.rb b/config/routes.rb index e3b494761..b4576b907 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -330,6 +330,8 @@ Rails.application.routes.draw do post :sync get :sparkline patch :toggle_active + patch :set_default + patch :remove_default get :select_provider get :confirm_unlink delete :unlink diff --git a/db/migrate/20260305120000_add_default_account_to_users.rb b/db/migrate/20260305120000_add_default_account_to_users.rb new file mode 100644 index 000000000..9bb6d0619 --- /dev/null +++ b/db/migrate/20260305120000_add_default_account_to_users.rb @@ -0,0 +1,5 @@ +class AddDefaultAccountToUsers < ActiveRecord::Migration[7.2] + def change + add_reference :users, :default_account, type: :uuid, foreign_key: { to_table: :accounts, on_delete: :nullify }, null: true + end +end diff --git a/db/schema.rb b/db/schema.rb index ae425569d..08a91cddd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1474,6 +1474,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_14_131357) do t.jsonb "preferences", default: {}, null: false t.string "locale" t.string "ui_layout" + t.uuid "default_account_id" + t.index ["default_account_id"], name: "index_users_on_default_account_id" t.index ["email"], name: "index_users_on_email", unique: true t.index ["family_id"], name: "index_users_on_family_id" t.index ["last_viewed_chat_id"], name: "index_users_on_last_viewed_chat_id" @@ -1584,6 +1586,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_03_14_131357) do add_foreign_key "transactions", "merchants" add_foreign_key "transfers", "transactions", column: "inflow_transaction_id", on_delete: :cascade add_foreign_key "transfers", "transactions", column: "outflow_transaction_id", on_delete: :cascade + add_foreign_key "users", "accounts", column: "default_account_id", on_delete: :nullify add_foreign_key "users", "chats", column: "last_viewed_chat_id" add_foreign_key "users", "families" end diff --git a/test/controllers/accounts_controller_test.rb b/test/controllers/accounts_controller_test.rb index 39e4dc2e1..0034360e0 100644 --- a/test/controllers/accounts_controller_test.rb +++ b/test/controllers/accounts_controller_test.rb @@ -158,7 +158,6 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_includes @response.body, @account.name - assert_includes @response.body, "account_#{@account.id}_active" end test "toggle_active disables and re-enables an account" do @@ -178,6 +177,34 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "set_default sets user default account" do + patch set_default_account_url(@account) + assert_redirected_to accounts_path + @user.reload + assert_equal @account.id, @user.default_account_id + end + + test "set_default rejects ineligible account type" do + investment = accounts(:investment) + + patch set_default_account_url(investment) + assert_redirected_to accounts_path + assert_equal I18n.t("accounts.set_default.depository_only"), flash[:alert] + + @user.reload + assert_not_equal investment.id, @user.default_account_id + end + + test "remove_default clears user default account" do + @user.update!(default_account: @account) + + patch remove_default_account_url(@account) + assert_redirected_to accounts_path + + @user.reload + assert_nil @user.default_account_id + end + test "select_provider redirects for already linked account" do plaid_account = plaid_accounts(:one) AccountProvider.create!(account: @account, provider: plaid_account) diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 0d2a09d58..46aa0637f 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -362,6 +362,33 @@ class UserTest < ActiveSupport::TestCase "Should return false when section key is missing from collapsed_sections" end + # Default account for transactions + test "default_account_for_transactions returns account when active and manual" do + account = accounts(:depository) + @user.update!(default_account: account) + assert_equal account, @user.default_account_for_transactions + end + + test "default_account_for_transactions returns nil when account is disabled" do + account = accounts(:depository) + @user.update!(default_account: account) + account.disable! + assert_nil @user.default_account_for_transactions + end + + test "default_account_for_transactions returns nil when account is linked" do + account = accounts(:depository) + @user.update!(default_account: account) + plaid_account = plaid_accounts(:one) + AccountProvider.create!(account: account, provider: plaid_account) + account.reload + assert_nil @user.default_account_for_transactions + end + + test "default_account_for_transactions returns nil when no default set" do + assert_nil @user.default_account_for_transactions + end + # SSO-only user security tests test "sso_only? returns true for user with OIDC identity and no password" do sso_user = users(:sso_only)