Skip to content
This repository was archived by the owner on Jul 27, 2025. It is now read-only.

Commit 77def1d

Browse files
authored
Nested Categories (#1561)
* Prepare entry search for nested categories * Subcategory implementation * Remove caching for test stability
1 parent a4d1009 commit 77def1d

31 files changed

+298
-235
lines changed

app/controllers/categories_controller.rb

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,26 +10,29 @@ def index
1010

1111
def new
1212
@category = Current.family.categories.new color: Category::COLORS.sample
13+
@categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id)
1314
end
1415

1516
def create
1617
@category = Current.family.categories.new(category_params)
1718

1819
if @category.save
1920
@transaction.update(category_id: @category.id) if @transaction
20-
redirect_back_or_to transactions_path, notice: t(".success")
21+
22+
redirect_back_or_to categories_path, notice: t(".success")
2123
else
22-
redirect_back_or_to transactions_path, alert: t(".failure", error: @category.errors.full_messages.to_sentence)
24+
render :new, status: :unprocessable_entity
2325
end
2426
end
2527

2628
def edit
29+
@categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id)
2730
end
2831

2932
def update
3033
@category.update! category_params
3134

32-
redirect_back_or_to transactions_path, notice: t(".success")
35+
redirect_back_or_to categories_path, notice: t(".success")
3336
end
3437

3538
def destroy
@@ -38,6 +41,12 @@ def destroy
3841
redirect_back_or_to categories_path, notice: t(".success")
3942
end
4043

44+
def bootstrap
45+
Current.family.categories.bootstrap_defaults
46+
47+
redirect_back_or_to categories_path, notice: t(".success")
48+
end
49+
4150
private
4251
def set_category
4352
@category = Current.family.categories.find(params[:id])
@@ -50,6 +59,6 @@ def set_transaction
5059
end
5160

5261
def category_params
53-
params.require(:category).permit(:name, :color)
62+
params.require(:category).permit(:name, :color, :parent_id)
5463
end
5564
end

app/controllers/registrations_controller.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ def create
2424

2525
if @user.save
2626
@invitation&.update!(accepted_at: Time.current)
27-
Category.create_default_categories(@user.family) unless @invitation
2827
@session = create_session_for(@user)
2928
redirect_to root_path, notice: t(".success")
3029
else

app/controllers/transactions_controller.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ class TransactionsController < ApplicationController
33

44
def index
55
@q = search_params
6-
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
7-
@pagy, @transaction_entries = pagy(result, limit: params[:per_page] || "50")
6+
search_query = Current.family.transactions.search(@q).includes(:entryable).reverse_chronological
7+
@pagy, @transaction_entries = pagy(search_query, limit: params[:per_page] || "50")
88

99
@totals = {
10-
count: result.select { |t| t.currency == Current.family.currency }.count,
11-
income: result.income_total(Current.family.currency).abs,
12-
expense: result.expense_total(Current.family.currency)
10+
count: search_query.select { |t| t.currency == Current.family.currency }.count,
11+
income: search_query.income_total(Current.family.currency).abs,
12+
expense: search_query.expense_total(Current.family.currency)
1313
}
1414
end
1515

app/models/account/entry.rb

Lines changed: 4 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ def display_name
6060
end
6161

6262
class << self
63+
def search(params)
64+
Account::EntrySearch.new(params).build_query(all)
65+
end
66+
6367
# arbitrary cutoff date to avoid expensive sync operations
6468
def min_supported_date
6569
30.years.ago.to_date
@@ -141,49 +145,7 @@ def expense_total(currency = "USD")
141145
Money.new(total, currency)
142146
end
143147

144-
def search(params)
145-
query = all
146-
query = query.where("account_entries.name ILIKE ?", "%#{sanitize_sql_like(params[:search])}%") if params[:search].present?
147-
query = query.where("account_entries.date >= ?", params[:start_date]) if params[:start_date].present?
148-
query = query.where("account_entries.date <= ?", params[:end_date]) if params[:end_date].present?
149-
150-
if params[:types].present?
151-
query = query.where(marked_as_transfer: false) unless params[:types].include?("transfer")
152-
153-
if params[:types].include?("income") && !params[:types].include?("expense")
154-
query = query.where("account_entries.amount < 0")
155-
elsif params[:types].include?("expense") && !params[:types].include?("income")
156-
query = query.where("account_entries.amount >= 0")
157-
end
158-
end
159-
160-
if params[:amount].present? && params[:amount_operator].present?
161-
case params[:amount_operator]
162-
when "equal"
163-
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", params[:amount].to_f.abs)
164-
when "less"
165-
query = query.where("ABS(account_entries.amount) < ?", params[:amount].to_f.abs)
166-
when "greater"
167-
query = query.where("ABS(account_entries.amount) > ?", params[:amount].to_f.abs)
168-
end
169-
end
170-
171-
if params[:accounts].present? || params[:account_ids].present?
172-
query = query.joins(:account)
173-
end
174-
175-
query = query.where(accounts: { name: params[:accounts] }) if params[:accounts].present?
176-
query = query.where(accounts: { id: params[:account_ids] }) if params[:account_ids].present?
177-
178-
# Search attributes on each entryable to further refine results
179-
entryable_ids = entryable_search(params)
180-
query = query.where(entryable_id: entryable_ids) unless entryable_ids.nil?
181-
182-
query
183-
end
184-
185148
private
186-
187149
def entryable_search(params)
188150
entryable_ids = []
189151
entryable_search_performed = false

app/models/account/entry_search.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
class Account::EntrySearch
2+
include ActiveModel::Model
3+
include ActiveModel::Attributes
4+
5+
attribute :search, :string
6+
attribute :amount, :string
7+
attribute :amount_operator, :string
8+
attribute :types, :string
9+
attribute :accounts, :string
10+
attribute :account_ids, :string
11+
attribute :start_date, :string
12+
attribute :end_date, :string
13+
14+
class << self
15+
def from_entryable_search(entryable_search)
16+
new(entryable_search.attributes.slice(*attribute_names))
17+
end
18+
end
19+
20+
def build_query(scope)
21+
query = scope
22+
23+
query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search",
24+
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
25+
) if search.present?
26+
query = query.where("account_entries.date >= ?", start_date) if start_date.present?
27+
query = query.where("account_entries.date <= ?", end_date) if end_date.present?
28+
29+
if types.present?
30+
query = query.where(marked_as_transfer: false) unless types.include?("transfer")
31+
32+
if types.include?("income") && !types.include?("expense")
33+
query = query.where("account_entries.amount < 0")
34+
elsif types.include?("expense") && !types.include?("income")
35+
query = query.where("account_entries.amount >= 0")
36+
end
37+
end
38+
39+
if amount.present? && amount_operator.present?
40+
case amount_operator
41+
when "equal"
42+
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs)
43+
when "less"
44+
query = query.where("ABS(account_entries.amount) < ?", amount.to_f.abs)
45+
when "greater"
46+
query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs)
47+
end
48+
end
49+
50+
if accounts.present? || account_ids.present?
51+
query = query.joins(:account)
52+
end
53+
54+
query = query.where(accounts: { name: accounts }) if accounts.present?
55+
query = query.where(accounts: { id: account_ids }) if account_ids.present?
56+
57+
query
58+
end
59+
end

app/models/account/trade.rb

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,8 @@ class Account::Trade < ApplicationRecord
88
validates :qty, presence: true
99
validates :price, :currency, presence: true
1010

11-
class << self
12-
def search(_params)
13-
all
14-
end
15-
16-
def requires_search?(_params)
17-
false
18-
end
19-
end
20-
21-
def sell?
22-
qty < 0
23-
end
24-
25-
def buy?
26-
qty > 0
27-
end
28-
2911
def unrealized_gain_loss
30-
return nil if sell?
12+
return nil if qty.negative?
3113
current_price = security.current_price
3214
return nil if current_price.nil?
3315

app/models/account/transaction.rb

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,52 +12,7 @@ class Account::Transaction < ApplicationRecord
1212

1313
class << self
1414
def search(params)
15-
query = all
16-
if params[:categories].present?
17-
if params[:categories].exclude?("Uncategorized")
18-
query = query
19-
.joins(:category)
20-
.where(categories: { name: params[:categories] })
21-
else
22-
query = query
23-
.left_joins(:category)
24-
.where(categories: { name: params[:categories] })
25-
.or(query.where(category_id: nil))
26-
end
27-
end
28-
29-
query = query.joins(:merchant).where(merchants: { name: params[:merchants] }) if params[:merchants].present?
30-
31-
if params[:tags].present?
32-
query = query.joins(:tags)
33-
.where(tags: { name: params[:tags] })
34-
.distinct
35-
end
36-
37-
query
15+
Account::TransactionSearch.new(params).build_query(all)
3816
end
39-
40-
def requires_search?(params)
41-
searchable_keys.any? { |key| params.key?(key) }
42-
end
43-
44-
private
45-
46-
def searchable_keys
47-
%i[categories merchants tags]
48-
end
4917
end
50-
51-
def eod_balance
52-
entry.amount_money
53-
end
54-
55-
private
56-
def account
57-
entry.account
58-
end
59-
60-
def daily_transactions
61-
account.entries.account_transactions
62-
end
6318
end
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
class Account::TransactionSearch
2+
include ActiveModel::Model
3+
include ActiveModel::Attributes
4+
5+
attribute :search, :string
6+
attribute :amount, :string
7+
attribute :amount_operator, :string
8+
attribute :types, array: true
9+
attribute :accounts, array: true
10+
attribute :account_ids, array: true
11+
attribute :start_date, :string
12+
attribute :end_date, :string
13+
attribute :categories, array: true
14+
attribute :merchants, array: true
15+
attribute :tags, array: true
16+
17+
# Returns array of Account::Entry objects to stay consistent with partials, which only deal with Account::Entry
18+
def build_query(scope)
19+
query = scope
20+
21+
if categories.present?
22+
if categories.exclude?("Uncategorized")
23+
query = query
24+
.joins(:category)
25+
.where(categories: { name: categories })
26+
else
27+
query = query
28+
.left_joins(:category)
29+
.where(categories: { name: categories })
30+
.or(query.where(category_id: nil))
31+
end
32+
end
33+
34+
query = query.joins(:merchant).where(merchants: { name: merchants }) if merchants.present?
35+
36+
query = query.joins(:tags).where(tags: { name: tags }) if tags.present?
37+
38+
entries_scope = Account::Entry.account_transactions.where(entryable_id: query.select(:id))
39+
40+
Account::EntrySearch.from_entryable_search(self).build_query(entries_scope)
41+
end
42+
end

app/models/account/valuation.rb

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,3 @@
11
class Account::Valuation < ApplicationRecord
22
include Account::Entryable
3-
4-
class << self
5-
def search(_params)
6-
all
7-
end
8-
9-
def requires_search?(_params)
10-
false
11-
end
12-
end
133
end

0 commit comments

Comments
 (0)