From e342ac4ad10171b2682f9e95da4ecd5e288b81a7 Mon Sep 17 00:00:00 2001 From: Artem Danilov <42946292+vlnd0@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:57:35 +0300 Subject: [PATCH] fix(accounts): persist subtype when creating an account (#2356) The Account#subtype= writer delegates to accountable&.subtype=, which is a silent no-op while the accountable is nil. On create the accountable is built from accountable_attributes via accepts_nested_attributes_for, but the form submits subtype as a top-level account attribute. Mass-assignment applies subtype before accountable_attributes, so the selected subtype was dropped on create (update worked because the accountable already exists). Build the accountable from the delegated type inside the writer when it is not yet present, so the value is preserved; the later accountable_attributes assignment (update_only) updates the same record. Add regression tests covering the create flow and the assignment ordering. --- app/models/account.rb | 13 +++++++++++-- test/models/account_test.rb | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/app/models/account.rb b/app/models/account.rb index a2c144b8e..77e9285c5 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -95,9 +95,18 @@ class Account < ApplicationRecord delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy delegate :subtype, to: :accountable, allow_nil: true - # Writer for subtype that delegates to the accountable - # This allows forms to set subtype directly on the account + # Writer for subtype that delegates to the accountable. + # This allows forms to set subtype directly on the account. + # + # On create the accountable may not be built yet: mass-assignment can apply + # `subtype` before `accountable_attributes` (which is what builds the + # accountable via accepts_nested_attributes_for). With no accountable in place + # `accountable&.subtype = value` is a silent no-op and the chosen subtype is + # dropped. Build the accountable from the delegated type first so the value is + # preserved; the later `accountable_attributes` assignment (update_only) then + # updates this same record instead of building a new one. def subtype=(value) + self.accountable = accountable_class.new if accountable.nil? && accountable_type.present? accountable&.subtype = value end diff --git a/test/models/account_test.rb b/test/models/account_test.rb index 0d7013354..373e1057e 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -99,6 +99,36 @@ class AccountTest < ActiveSupport::TestCase assert_equal opening_date, opening_anchor.entry.date end + test "subtype set as a top-level account attribute persists on create" do + Account.any_instance.stubs(:sync_later) + + # Mirrors the create flow: the form submits `account[subtype]` as a + # top-level attribute (not nested under accountable_attributes). The + # accountable does not exist yet, so the delegating writer must build it. + account = Account.create_and_sync({ + family: @family, + owner: @admin, + name: "Savings Account", + balance: 100, + currency: "USD", + accountable_type: "Depository", + subtype: "savings" + }) + + assert account.persisted? + assert_equal "savings", account.reload.subtype + assert_equal "savings", account.accountable.subtype + end + + test "subtype assigned before accountable is built is not dropped" do + account = Account.new + account.accountable_type = "Depository" + account.subtype = "checking" + + assert_not_nil account.accountable + assert_equal "checking", account.subtype + end + test "accountable display names expose singular and group contexts" do assert_equal "Investment", Investment.singular_display_name assert_equal "Investments", Investment.display_name