Add instituion details & notes to Account model (#481)

- Add institution name & domain, to allow fetching logos when no provider is configured
- Add free-form textarea for storing misc. notes (eg. sort codes, account numbers)
- Update account settings form to support these new fields
This commit is contained in:
Matthew Kilpatrick
2025-12-23 23:59:50 +00:00
committed by GitHub
parent 104324a82b
commit 68864b1fdb
14 changed files with 139 additions and 5 deletions

View File

@@ -86,6 +86,7 @@ module AccountableResource
def account_params def account_params
params.require(:account).permit( params.require(:account).permit(
:name, :balance, :subtype, :currency, :accountable_type, :return_to, :name, :balance, :subtype, :currency, :accountable_type, :return_to,
:institution_name, :institution_domain, :notes,
accountable_attributes: self.class.permitted_accountable_attributes accountable_attributes: self.class.permitted_accountable_attributes
) )
end end

View File

@@ -89,7 +89,14 @@ class PropertiesController < ApplicationController
def property_params def property_params
params.require(:account) params.require(:account)
.permit(:name, :accountable_type, accountable_attributes: [ :id, :subtype, :year_built, :area_unit, :area_value ]) .permit(
:name,
:accountable_type,
:institution_name,
:institution_domain,
:notes,
accountable_attributes: [ :id, :subtype, :year_built, :area_unit, :area_value ]
)
end end
def set_property def set_property

View File

@@ -188,8 +188,12 @@ class Account < ApplicationRecord
end end
end end
def institution_name
read_attribute(:institution_name).presence || provider&.institution_name
end
def institution_domain def institution_domain
provider&.institution_domain read_attribute(:institution_domain).presence || provider&.institution_domain
end end
def destroy_later def destroy_later

View File

@@ -18,8 +18,8 @@
<% else %> <% else %>
<div> <div>
<%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %> <%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
<% if account.simplefin_account&.org_data&.dig('name') %> <% if account.institution_name %>
<span class="text-secondary">• <%= account.simplefin_account.org_data["name"] %></span> <span class="text-secondary">• <%= account.institution_name %></span>
<% end %> <% end %>
</div> </div>
<% if account.long_subtype_label %> <% if account.long_subtype_label %>

View File

@@ -16,6 +16,28 @@
<% end %> <% end %>
<%= yield form %> <%= yield form %>
<details class="group">
<summary class="cursor-pointer text-sm text-secondary hover:text-primary flex items-center gap-1 py-2">
<%= icon "chevron-right", size: "sm", class: "group-open:rotate-90 transition-transform" %>
<%= t(".additional_details") %>
</summary>
<div class="space-y-2 mt-2 pl-4 border-l border-primary">
<%= form.text_field :institution_name,
label: t(".institution_name_label"),
placeholder: account.provider&.institution_name || t(".institution_name_placeholder") %>
<%= form.text_field :institution_domain,
label: t(".institution_domain_label"),
placeholder: account.provider&.institution_domain || t(".institution_domain_placeholder") %>
<%= form.text_area :notes,
label: t(".notes_label"),
placeholder: t(".notes_placeholder"),
rows: 4 %>
</div>
</details>
</div> </div>
<%= form.submit %> <%= form.submit %>

View File

@@ -7,7 +7,7 @@
"full" => "w-full h-full" "full" => "w-full h-full"
} %> } %>
<% if account.linked? && account.institution_domain.present? && Setting.brand_fetch_client_id.present? %> <% if account.institution_domain.present? && Setting.brand_fetch_client_id.present? %>
<%= image_tag "https://cdn.brandfetch.io/#{account.institution_domain}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}", class: "shrink-0 rounded-full #{size_classes[size]}" %> <%= image_tag "https://cdn.brandfetch.io/#{account.institution_domain}/icon/fallback/lettermark/w/40/h/40?c=#{Setting.brand_fetch_client_id}", class: "shrink-0 rounded-full #{size_classes[size]}" %>
<% elsif account.logo.attached? %> <% elsif account.logo.attached? %>
<%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %> <%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %>

View File

@@ -21,6 +21,13 @@ en:
balance: Current balance balance: Current balance
name_label: Account name name_label: Account name
name_placeholder: Example account name name_placeholder: Example account name
additional_details: Additional details
institution_name_label: Institution name
institution_name_placeholder: e.g., Chase Bank
institution_domain_label: Institution domain
institution_domain_placeholder: e.g., chase.com
notes_label: Notes
notes_placeholder: Store additional information like account numbers, sort codes, IBAN, routing numbers, etc.
index: index:
accounts: Accounts accounts: Accounts
manual_accounts: manual_accounts:

View File

@@ -0,0 +1,12 @@
class AddInstitutionFieldsToAccounts < ActiveRecord::Migration[7.2]
def change
add_column :accounts, :institution_name, :string
add_column :accounts, :institution_domain, :string
add_column :accounts, :notes, :text
# Touch all accounts to invalidate cached queries that depend on accounts.maximum(:updated_at)
# Without this, the following error would occur post-update and prevent page loads:
# "undefined method 'institution_domain' for an instance of BalanceSheet::AccountTotals::AccountRow"
Account.in_batches.update_all(updated_at: Time.current)
end
end

3
db/schema.rb generated
View File

@@ -46,6 +46,9 @@ ActiveRecord::Schema[7.2].define(version: 2025_12_15_100443) do
t.jsonb "locked_attributes", default: {} t.jsonb "locked_attributes", default: {}
t.string "status", default: "active" t.string "status", default: "active"
t.uuid "simplefin_account_id" t.uuid "simplefin_account_id"
t.string "institution_name"
t.string "institution_domain"
t.text "notes"
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type" t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type" t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["currency"], name: "index_accounts_on_currency" t.index ["currency"], name: "index_accounts_on_currency"

View File

@@ -18,6 +18,9 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest
name: "New Credit Card", name: "New Credit Card",
balance: 1000, balance: 1000,
currency: "USD", currency: "USD",
institution_name: "Amex",
institution_domain: "americanexpress.com",
notes: "Primary card",
accountable_type: "CreditCard", accountable_type: "CreditCard",
accountable_attributes: { accountable_attributes: {
available_credit: 5000, available_credit: 5000,
@@ -35,6 +38,9 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest
assert_equal "New Credit Card", created_account.name assert_equal "New Credit Card", created_account.name
assert_equal 1000, created_account.balance assert_equal 1000, created_account.balance
assert_equal "USD", created_account.currency assert_equal "USD", created_account.currency
assert_equal "Amex", created_account[:institution_name]
assert_equal "americanexpress.com", created_account[:institution_domain]
assert_equal "Primary card", created_account[:notes]
assert_equal 5000, created_account.accountable.available_credit assert_equal 5000, created_account.accountable.available_credit
assert_equal 25.51, created_account.accountable.minimum_payment assert_equal 25.51, created_account.accountable.minimum_payment
assert_equal 15.99, created_account.accountable.apr assert_equal 15.99, created_account.accountable.apr
@@ -53,6 +59,9 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest
name: "Updated Credit Card", name: "Updated Credit Card",
balance: 2000, balance: 2000,
currency: "USD", currency: "USD",
institution_name: "Chase",
institution_domain: "chase.com",
notes: "Updated notes",
accountable_type: "CreditCard", accountable_type: "CreditCard",
accountable_attributes: { accountable_attributes: {
id: @account.accountable_id, id: @account.accountable_id,
@@ -70,6 +79,9 @@ class CreditCardsControllerTest < ActionDispatch::IntegrationTest
assert_equal "Updated Credit Card", @account.name assert_equal "Updated Credit Card", @account.name
assert_equal 2000, @account.balance assert_equal 2000, @account.balance
assert_equal "Chase", @account[:institution_name]
assert_equal "chase.com", @account[:institution_domain]
assert_equal "Updated notes", @account[:notes]
assert_equal 6000, @account.accountable.available_credit assert_equal 6000, @account.accountable.available_credit
assert_equal 50, @account.accountable.minimum_payment assert_equal 50, @account.accountable.minimum_payment
assert_equal 14.99, @account.accountable.apr assert_equal 14.99, @account.accountable.apr

View File

@@ -18,6 +18,9 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
name: "New Loan", name: "New Loan",
balance: 50000, balance: 50000,
currency: "USD", currency: "USD",
institution_name: "Local Bank",
institution_domain: "localbank.example",
notes: "Mortgage notes",
accountable_type: "Loan", accountable_type: "Loan",
accountable_attributes: { accountable_attributes: {
interest_rate: 5.5, interest_rate: 5.5,
@@ -34,6 +37,9 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
assert_equal "New Loan", created_account.name assert_equal "New Loan", created_account.name
assert_equal 50000, created_account.balance assert_equal 50000, created_account.balance
assert_equal "USD", created_account.currency assert_equal "USD", created_account.currency
assert_equal "Local Bank", created_account[:institution_name]
assert_equal "localbank.example", created_account[:institution_domain]
assert_equal "Mortgage notes", created_account[:notes]
assert_equal 5.5, created_account.accountable.interest_rate assert_equal 5.5, created_account.accountable.interest_rate
assert_equal 60, created_account.accountable.term_months assert_equal 60, created_account.accountable.term_months
assert_equal "fixed", created_account.accountable.rate_type assert_equal "fixed", created_account.accountable.rate_type
@@ -51,6 +57,9 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
name: "Updated Loan", name: "Updated Loan",
balance: 45000, balance: 45000,
currency: "USD", currency: "USD",
institution_name: "Updated Bank",
institution_domain: "updatedbank.example",
notes: "Updated loan notes",
accountable_type: "Loan", accountable_type: "Loan",
accountable_attributes: { accountable_attributes: {
id: @account.accountable_id, id: @account.accountable_id,
@@ -67,6 +76,9 @@ class LoansControllerTest < ActionDispatch::IntegrationTest
assert_equal "Updated Loan", @account.name assert_equal "Updated Loan", @account.name
assert_equal 45000, @account.balance assert_equal 45000, @account.balance
assert_equal "Updated Bank", @account[:institution_name]
assert_equal "updatedbank.example", @account[:institution_domain]
assert_equal "Updated loan notes", @account[:notes]
assert_equal 4.5, @account.accountable.interest_rate assert_equal 4.5, @account.accountable.interest_rate
assert_equal 48, @account.accountable.term_months assert_equal 48, @account.accountable.term_months
assert_equal "fixed", @account.accountable.rate_type assert_equal "fixed", @account.accountable.rate_type

View File

@@ -14,6 +14,9 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
account: { account: {
name: "New Property", name: "New Property",
subtype: "house", subtype: "house",
institution_name: "Property Lender",
institution_domain: "propertylender.example",
notes: "Property notes",
accountable_type: "Property", accountable_type: "Property",
accountable_attributes: { accountable_attributes: {
year_built: 1990, year_built: 1990,
@@ -28,6 +31,9 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
assert created_account.accountable.is_a?(Property) assert created_account.accountable.is_a?(Property)
assert_equal "draft", created_account.status assert_equal "draft", created_account.status
assert_equal 0, created_account.balance assert_equal 0, created_account.balance
assert_equal "Property Lender", created_account[:institution_name]
assert_equal "propertylender.example", created_account[:institution_domain]
assert_equal "Property notes", created_account[:notes]
assert_equal 1990, created_account.accountable.year_built assert_equal 1990, created_account.accountable.year_built
assert_equal 1200, created_account.accountable.area_value assert_equal 1200, created_account.accountable.area_value
assert_equal "sqft", created_account.accountable.area_unit assert_equal "sqft", created_account.accountable.area_unit
@@ -39,6 +45,9 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
patch property_path(@account), params: { patch property_path(@account), params: {
account: { account: {
name: "Updated Property", name: "Updated Property",
institution_name: "Updated Lender",
institution_domain: "updatedlender.example",
notes: "Updated property notes",
accountable_attributes: { accountable_attributes: {
id: @account.accountable.id, id: @account.accountable.id,
subtype: "condominium" subtype: "condominium"
@@ -50,6 +59,9 @@ class PropertiesControllerTest < ActionDispatch::IntegrationTest
@account.reload @account.reload
assert_equal "Updated Property", @account.name assert_equal "Updated Property", @account.name
assert_equal "condominium", @account.subtype assert_equal "condominium", @account.subtype
assert_equal "Updated Lender", @account[:institution_name]
assert_equal "updatedlender.example", @account[:institution_domain]
assert_equal "Updated property notes", @account[:notes]
# If account is active, it renders edit view; otherwise redirects to balances # If account is active, it renders edit view; otherwise redirects to balances
if @account.active? if @account.active?

View File

@@ -18,6 +18,9 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest
name: "Vehicle", name: "Vehicle",
balance: 30000, balance: 30000,
currency: "USD", currency: "USD",
institution_name: "Auto Lender",
institution_domain: "autolender.example",
notes: "Lease notes",
accountable_type: "Vehicle", accountable_type: "Vehicle",
accountable_attributes: { accountable_attributes: {
make: "Toyota", make: "Toyota",
@@ -32,6 +35,12 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest
created_account = Account.order(:created_at).last created_account = Account.order(:created_at).last
assert_equal "Vehicle", created_account.name
assert_equal 30000, created_account.balance
assert_equal "USD", created_account.currency
assert_equal "Auto Lender", created_account[:institution_name]
assert_equal "autolender.example", created_account[:institution_domain]
assert_equal "Lease notes", created_account[:notes]
assert_equal "Toyota", created_account.accountable.make assert_equal "Toyota", created_account.accountable.make
assert_equal "Camry", created_account.accountable.model assert_equal "Camry", created_account.accountable.model
assert_equal 2020, created_account.accountable.year assert_equal 2020, created_account.accountable.year
@@ -50,6 +59,9 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest
name: "Updated Vehicle", name: "Updated Vehicle",
balance: 28000, balance: 28000,
currency: "USD", currency: "USD",
institution_name: "Updated Lender",
institution_domain: "updatedlender.example",
notes: "Updated lease notes",
accountable_type: "Vehicle", accountable_type: "Vehicle",
accountable_attributes: { accountable_attributes: {
id: @account.accountable_id, id: @account.accountable_id,
@@ -64,6 +76,13 @@ class VehiclesControllerTest < ActionDispatch::IntegrationTest
} }
end end
@account.reload
assert_equal "Updated Vehicle", @account.name
assert_equal 28000, @account.balance
assert_equal "Updated Lender", @account[:institution_name]
assert_equal "updatedlender.example", @account[:institution_domain]
assert_equal "Updated lease notes", @account[:notes]
assert_redirected_to account_path(@account) assert_redirected_to account_path(@account)
assert_equal "Vehicle account updated", flash[:notice] assert_equal "Vehicle account updated", flash[:notice]
assert_enqueued_with(job: SyncJob) assert_enqueued_with(job: SyncJob)

View File

@@ -109,9 +109,16 @@ class AccountsTest < ApplicationSystemTestCase
click_link "Enter account balance" if accountable_type.in?(%w[Depository Investment Crypto Loan CreditCard]) click_link "Enter account balance" if accountable_type.in?(%w[Depository Investment Crypto Loan CreditCard])
account_name = "[system test] #{accountable_type} Account" account_name = "[system test] #{accountable_type} Account"
institution_name = "[system test] Institution"
institution_domain = "example.com"
notes = "Test notes for #{accountable_type}"
fill_in "Account name*", with: account_name fill_in "Account name*", with: account_name
fill_in "account[balance]", with: 100.99 fill_in "account[balance]", with: 100.99
find("summary", text: "Additional details").click
fill_in "Institution name", with: institution_name
fill_in "Institution domain", with: institution_domain
fill_in "Notes", with: notes
yield if block_given? yield if block_given?
@@ -127,6 +134,9 @@ class AccountsTest < ApplicationSystemTestCase
assert_text account_name assert_text account_name
created_account = Account.order(:created_at).last created_account = Account.order(:created_at).last
assert_equal institution_name, created_account[:institution_name]
assert_equal institution_domain, created_account[:institution_domain]
assert_equal notes, created_account[:notes]
visit account_url(created_account) visit account_url(created_account)
@@ -135,9 +145,22 @@ class AccountsTest < ApplicationSystemTestCase
click_on "Edit" click_on "Edit"
end end
updated_institution_name = "[system test] Updated Institution"
updated_institution_domain = "updated.example.com"
updated_notes = "Updated notes for #{accountable_type}"
fill_in "Account name", with: "Updated account name" fill_in "Account name", with: "Updated account name"
find("summary", text: "Additional details").click
fill_in "Institution name", with: updated_institution_name
fill_in "Institution domain", with: updated_institution_domain
fill_in "Notes", with: updated_notes
click_button "Update Account" click_button "Update Account"
assert_selector "h2", text: "Updated account name" assert_selector "h2", text: "Updated account name"
created_account.reload
assert_equal updated_institution_name, created_account[:institution_name]
assert_equal updated_institution_domain, created_account[:institution_domain]
assert_equal updated_notes, created_account[:notes]
end end
def humanized_accountable(accountable_type) def humanized_accountable(accountable_type)