Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/controllers/depositories_controller.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
class DepositoriesController < ApplicationController
include AccountableResource

permitted_accountable_attributes :subtype
end
2 changes: 1 addition & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ def user_params
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,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ],
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :cash_subgroup_enabled, :id ],
goals: []
)
end
Expand Down
6 changes: 4 additions & 2 deletions app/models/balance_sheet.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@ def assets
@assets ||= ClassificationGroup.new(
classification: "asset",
currency: family.currency,
accounts: sorted(account_totals.asset_accounts)
accounts: sorted(account_totals.asset_accounts),
family: family
)
end

def liabilities
@liabilities ||= ClassificationGroup.new(
classification: "liability",
currency: family.currency,
accounts: sorted(account_totals.liability_accounts)
accounts: sorted(account_totals.liability_accounts),
family: family
)
end

Expand Down
43 changes: 43 additions & 0 deletions app/models/balance_sheet/account_group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,38 @@ def total
accounts.sum(&:converted_balance)
end

def subgroups
return [] unless cash_subgroup_enabled? && accountable_type == Depository

grouped_accounts = accounts.group_by { |account| normalized_subtype(account.subtype) }

order = Depository::SUBTYPES.keys

grouped_accounts
.reject { |subtype, _| subtype.nil? }
.map do |subtype, rows|
BalanceSheet::SubtypeGroup.new(subtype: subtype, accounts: rows, account_group: self)
end
.sort_by do |subgroup|
idx = order.index(subgroup.subtype)
[ idx || order.length, subgroup.name ]
end
end

def uncategorized_accounts
return [] unless cash_subgroup_enabled? && accountable_type == Depository

accounts.select { |account| normalized_subtype(account.subtype).nil? }
end

def uncategorized_total
uncategorized_accounts.sum(&:converted_balance)
end

def uncategorized_total_money
Money.new((uncategorized_total * 100).to_i, currency)
end

def weight
return 0 if classification_group.total.zero?

Expand All @@ -56,6 +88,17 @@ def currency
classification_group.currency
end

def cash_subgroup_enabled?
classification_group.family.cash_subgroup_enabled != false
end

private
def normalized_subtype(subtype)
value = subtype&.to_s&.strip&.downcase
return nil if value.blank?

Depository::SUBTYPES.key?(value) ? value : nil
end

attr_reader :classification_group
end
5 changes: 3 additions & 2 deletions app/models/balance_sheet/classification_group.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ class BalanceSheet::ClassificationGroup

monetize :total, as: :total_money

attr_reader :classification, :currency
attr_reader :classification, :currency, :family

def initialize(classification:, currency:, accounts:)
def initialize(classification:, currency:, accounts:, family:)
@classification = normalize_classification!(classification)
@name = name
@currency = currency
@accounts = accounts
@family = family
end

def name
Expand Down
29 changes: 29 additions & 0 deletions app/models/balance_sheet/subtype_group.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
class BalanceSheet::SubtypeGroup
include Monetizable

monetize :total, as: :total_money

attr_reader :subtype, :accounts, :account_group

def initialize(subtype:, accounts:, account_group:)
@subtype = subtype
@accounts = accounts
@account_group = account_group
end

def name
account_group.accountable_type.short_subtype_label_for(subtype) || account_group.name
end

def key
subtype.presence || "other"
end

def total
accounts.sum(&:converted_balance)
end

def currency
account_group.currency
end
end
3 changes: 2 additions & 1 deletion app/models/concerns/accountable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ def subtype_label_for(subtype, format: :short)
return nil if subtype.nil?

label_type = format == :long ? :long : :short
self::SUBTYPES[subtype]&.fetch(label_type, nil)
subtype_key = subtype.to_s.downcase
self::SUBTYPES[subtype_key]&.fetch(label_type, nil)
end

# Convenience method for getting the short label
Expand Down
115 changes: 96 additions & 19 deletions app/views/accounts/_accountable_group.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -21,32 +21,109 @@
</div>
<% end %>

<div class="space-y-1">
<% account_group.accounts.each do |account| %>
<%= link_to account_path(account),
class: class_names(
"block flex items-center gap-2 px-3 py-2 rounded-lg",
page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover"
),
title: account.name do %>
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
<% subgroups = account_group.subgroups %>
<% uncategorized_accounts = account_group.uncategorized_accounts %>

<div class="min-w-0 grow">
<div class="flex items-center gap-2 mb-0.5">
<%= tag.p account.name, class: class_names("text-sm text-primary font-medium truncate", "animate-pulse" => account.syncing?) %>
<div class="space-y-1">
<% if subgroups.any? || uncategorized_accounts.any? %>
<% if uncategorized_accounts.any? %>
<div class="space-y-1">
<div class="flex items-center px-3 pt-2 text-xs font-medium text-secondary">
<p>Cash</p>
<p class="ml-auto text-sm text-primary whitespace-nowrap"><%= format_money(account_group.uncategorized_total_money) %></p>
Comment on lines +31 to +33
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hardcoded "Cash" string should use i18n.

Per coding guidelines, all user-facing strings must use localization via the t() helper.

 <div class="flex items-center px-3 pt-2 text-xs font-medium text-secondary">
-  <p>Cash</p>
+  <p><%= t("accounts.cash") %></p>
   <p class="ml-auto text-sm text-primary whitespace-nowrap"><%= format_money(account_group.uncategorized_total_money) %></p>
 </div>
🤖 Prompt for AI Agents
In app/views/accounts/_accountable_group.html.erb around lines 31 to 33, the
literal "Cash" must be localized; replace the hardcoded string with the Rails
i18n helper (e.g. t('accounts.accountable_group.cash') or a view-scoped key like
t('.cash')) and add the corresponding key and translation to the appropriate
locale YAML (e.g. config/locales/en.yml) so the view uses t('...') and has an
English entry for the "Cash" label.

</div>
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>

<% uncategorized_accounts.each do |account| %>
<%= link_to account_path(account),
class: class_names(
"block flex items-center gap-2 px-3 py-2 rounded-lg",
page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover"
),
title: account.name do %>
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>

<div class="min-w-0 grow">
<div class="flex items-center gap-2 mb-0.5">
<%= tag.p account.name, class: class_names("text-sm text-primary font-medium truncate", "animate-pulse" => account.syncing?) %>
</div>
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
</div>

<div class="ml-auto text-right grow h-10">
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>
<% end %>

<div class="ml-auto text-right grow h-10">
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<% subgroups.each do |subgroup| %>
<div class="space-y-1">
<div class="flex items-center px-3 pt-2 text-xs font-medium text-secondary">
<p><%= subgroup.name %></p>
<p class="ml-auto text-sm text-primary whitespace-nowrap"><%= format_money(subgroup.total_money) %></p>
</div>

<% subgroup.accounts.each do |account| %>
<%= link_to account_path(account),
class: class_names(
"block flex items-center gap-2 px-3 py-2 rounded-lg",
page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover"
),
title: account.name do %>
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>

<div class="min-w-0 grow">
<div class="flex items-center gap-2 mb-0.5">
<%= tag.p account.name, class: class_names("text-sm text-primary font-medium truncate", "animate-pulse" => account.syncing?) %>
</div>
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
</div>

<div class="ml-auto text-right grow h-10">
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div>
<% end %>
<% else %>
<% account_group.accounts.each do |account| %>
<%= link_to account_path(account),
class: class_names(
"block flex items-center gap-2 px-3 py-2 rounded-lg",
page_active?(account_path(account)) ? "bg-container" : "hover:bg-surface-hover"
),
title: account.name do %>
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>

<div class="min-w-0 grow">
<div class="flex items-center gap-2 mb-0.5">
<%= tag.p account.name, class: class_names("text-sm text-primary font-medium truncate", "animate-pulse" => account.syncing?) %>
</div>
<%= tag.p account.short_subtype_label, class: "text-sm text-secondary truncate" %>
</div>

<div class="ml-auto text-right grow h-10">
<%= tag.p format_money(account.balance_money), class: "text-sm font-medium text-primary whitespace-nowrap" %>
<%= turbo_frame_tag dom_id(account, :sparkline), src: sparkline_account_path(account), loading: "lazy", data: { controller: "turbo-frame-timeout", turbo_frame_timeout_timeout_value: 10000 } do %>
<div class="flex items-center w-8 h-4 ml-auto">
<div class="w-6 h-px bg-loader"></div>
</div>
<% end %>
</div>
<% end %>
<% end %>
<% end %>
</div>

Expand Down
8 changes: 5 additions & 3 deletions app/views/depositories/_form.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<%# locals: (account:, url:) %>

<%= render "accounts/form", account: account, url: url do |form| %>
<%= form.select :subtype,
Depository::SUBTYPES.map { |k, v| [v[:long], k] },
{ label: true, prompt: t("depositories.form.subtype_prompt"), include_blank: t("depositories.form.none") } %>
<%= form.fields_for :accountable do |accountable_fields| %>
<%= accountable_fields.select :subtype,
Depository::SUBTYPES.map { |k, v| [v[:long], k] },
{ label: true, prompt: t("depositories.form.subtype_prompt"), include_blank: t("depositories.form.none") } %>
<% end %>
<% end %>
13 changes: 12 additions & 1 deletion app/views/settings/preferences/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,18 @@
{ label: t(".country") },
{ data: { auto_submit_form_target: "auto" } } %>

<p class="text-xs italic pl-2 text-secondary">Please note, we are still working on translations for various languages.</p>
<p class="text-xs italic pl-2 text-secondary">Please note, we are still working on translations for various languages. Please see the <%= link_to "I18n issue", "https://github.com/maybe-finance/maybe/issues/1225", target: "_blank", class: "underline" %> for more information.</p>

<div class="mt-4">
<div class="flex items-center justify-between">
<div class="space-y-1">
<p class="text-sm text-primary"><%= t(".cash_subgroup_enabled_label") %></p>
<p class="text-secondary text-xs"><%= t(".cash_subgroup_enabled_hint") %></p>
</div>

<%= family_form.toggle :cash_subgroup_enabled, { data: { auto_submit_form_target: "auto" } } %>
</div>
</div>
<% end %>
<% end %>
</div>
Expand Down
2 changes: 2 additions & 0 deletions config/locales/views/settings/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ en:
general_title: General
default_period: Default Period
default_account_order: Default Account Order
cash_subgroup_enabled_label: Group cash accounts by subtype
cash_subgroup_enabled_hint: Show Savings/Checking sections under Cash; turn off for a flat list.
language: Language
page_title: Preferences
theme_dark: Dark
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddCashSubgroupEnabledToFamilies < ActiveRecord::Migration[7.2]
def change
add_column :families, :cash_subgroup_enabled, :boolean, null: false, default: true
end
end
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions test/models/balance_sheet_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,40 @@ class BalanceSheetTest < ActiveSupport::TestCase
assert_equal 3000 + 5000, liability_groups.find { |ag| ag.name == I18n.t("accounts.types.other_liability") }.total
end

test "cash subgroups render savings and uncategorized when enabled" do
create_account(balance: 100, accountable: Depository.new(subtype: "savings"))
create_account(balance: 200, accountable: Depository.new) # no subtype

cash_group = BalanceSheet.new(@family).assets.account_groups.find { |ag| ag.name == "Cash" }

assert_equal [ "Savings" ], cash_group.subgroups.map(&:name)
assert_equal 1, cash_group.uncategorized_accounts.size
assert_equal 20000.0, cash_group.uncategorized_total_money.to_f
end

test "cash subgroups flatten when preference disabled" do
@family.update!(cash_subgroup_enabled: false)

create_account(balance: 100, accountable: Depository.new(subtype: "savings"))
create_account(balance: 200, accountable: Depository.new)

cash_group = BalanceSheet.new(@family).assets.account_groups.find { |ag| ag.name == "Cash" }

assert_empty cash_group.subgroups
assert_empty cash_group.uncategorized_accounts
assert_equal 2, cash_group.accounts.size
end

test "unknown cash subtypes are treated as uncategorized" do
create_account(balance: 150, accountable: Depository.new(subtype: "mystery"))

cash_group = BalanceSheet.new(@family).assets.account_groups.find { |ag| ag.name == "Cash" }

assert_empty cash_group.subgroups
assert_equal 1, cash_group.uncategorized_accounts.size
assert_equal 15000.0, cash_group.uncategorized_total_money.to_f
end

private
def create_account(attributes = {})
account = @family.accounts.create! name: "Test", currency: "USD", **attributes
Expand Down
2 changes: 1 addition & 1 deletion test/system/chats_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class ChatsTest < ApplicationSystemTestCase

# After page refresh, we're still on the last chat we were viewing
within "#chat-container" do
assert_selector "h1", text: @user.chats.first.title
assert_selector "h1", text: @user.chats.first.title, visible: :all
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unrelated change: chat test modification in cash subgroup PR.

This change to the chat test visibility assertion appears unrelated to the PR's stated objectives of adding cash subgroup functionality. Consider moving unrelated test fixes to a separate commit or PR to maintain clear change history.

🤖 Prompt for AI Agents
In test/system/chats_test.rb around line 45, the visibility change to
assert_selector "h1", text: @user.chats.first.title, visible: :all is unrelated
to the cash subgroup work; revert this change or remove it from this branch and
move it into a separate commit/PR dedicated to test fixes. Restore the assertion
to its original form (undo the visible: :all modification) in this PR, or create
a new commit containing only the test adjustment and reference it separately so
the cash subgroup PR only contains scope-relevant changes.

end
end
end
Expand Down