1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-05 13:35:21 +02:00

Nested Categories (#1561)
Some checks failed
Publish Docker image / ci (push) Has been cancelled
Publish Docker image / Build docker image (push) Has been cancelled

* Prepare entry search for nested categories

* Subcategory implementation

* Remove caching for test stability
This commit is contained in:
Zach Gollwitzer 2024-12-20 11:37:26 -05:00 committed by GitHub
parent a4d10097d5
commit 77def1db40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 297 additions and 234 deletions

View file

@ -60,6 +60,10 @@ class Account::Entry < ApplicationRecord
end
class << self
def search(params)
Account::EntrySearch.new(params).build_query(all)
end
# arbitrary cutoff date to avoid expensive sync operations
def min_supported_date
30.years.ago.to_date
@ -141,49 +145,7 @@ class Account::Entry < ApplicationRecord
Money.new(total, currency)
end
def search(params)
query = all
query = query.where("account_entries.name ILIKE ?", "%#{sanitize_sql_like(params[:search])}%") if params[:search].present?
query = query.where("account_entries.date >= ?", params[:start_date]) if params[:start_date].present?
query = query.where("account_entries.date <= ?", params[:end_date]) if params[:end_date].present?
if params[:types].present?
query = query.where(marked_as_transfer: false) unless params[:types].include?("transfer")
if params[:types].include?("income") && !params[:types].include?("expense")
query = query.where("account_entries.amount < 0")
elsif params[:types].include?("expense") && !params[:types].include?("income")
query = query.where("account_entries.amount >= 0")
end
end
if params[:amount].present? && params[:amount_operator].present?
case params[:amount_operator]
when "equal"
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", params[:amount].to_f.abs)
when "less"
query = query.where("ABS(account_entries.amount) < ?", params[:amount].to_f.abs)
when "greater"
query = query.where("ABS(account_entries.amount) > ?", params[:amount].to_f.abs)
end
end
if params[:accounts].present? || params[:account_ids].present?
query = query.joins(:account)
end
query = query.where(accounts: { name: params[:accounts] }) if params[:accounts].present?
query = query.where(accounts: { id: params[:account_ids] }) if params[:account_ids].present?
# Search attributes on each entryable to further refine results
entryable_ids = entryable_search(params)
query = query.where(entryable_id: entryable_ids) unless entryable_ids.nil?
query
end
private
def entryable_search(params)
entryable_ids = []
entryable_search_performed = false

View file

@ -0,0 +1,59 @@
class Account::EntrySearch
include ActiveModel::Model
include ActiveModel::Attributes
attribute :search, :string
attribute :amount, :string
attribute :amount_operator, :string
attribute :types, :string
attribute :accounts, :string
attribute :account_ids, :string
attribute :start_date, :string
attribute :end_date, :string
class << self
def from_entryable_search(entryable_search)
new(entryable_search.attributes.slice(*attribute_names))
end
end
def build_query(scope)
query = scope
query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search",
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
) if search.present?
query = query.where("account_entries.date >= ?", start_date) if start_date.present?
query = query.where("account_entries.date <= ?", end_date) if end_date.present?
if types.present?
query = query.where(marked_as_transfer: false) unless types.include?("transfer")
if types.include?("income") && !types.include?("expense")
query = query.where("account_entries.amount < 0")
elsif types.include?("expense") && !types.include?("income")
query = query.where("account_entries.amount >= 0")
end
end
if amount.present? && amount_operator.present?
case amount_operator
when "equal"
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs)
when "less"
query = query.where("ABS(account_entries.amount) < ?", amount.to_f.abs)
when "greater"
query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs)
end
end
if accounts.present? || account_ids.present?
query = query.joins(:account)
end
query = query.where(accounts: { name: accounts }) if accounts.present?
query = query.where(accounts: { id: account_ids }) if account_ids.present?
query
end
end

View file

@ -8,26 +8,8 @@ class Account::Trade < ApplicationRecord
validates :qty, presence: true
validates :price, :currency, presence: true
class << self
def search(_params)
all
end
def requires_search?(_params)
false
end
end
def sell?
qty < 0
end
def buy?
qty > 0
end
def unrealized_gain_loss
return nil if sell?
return nil if qty.negative?
current_price = security.current_price
return nil if current_price.nil?

View file

@ -12,52 +12,7 @@ class Account::Transaction < ApplicationRecord
class << self
def search(params)
query = all
if params[:categories].present?
if params[:categories].exclude?("Uncategorized")
query = query
.joins(:category)
.where(categories: { name: params[:categories] })
else
query = query
.left_joins(:category)
.where(categories: { name: params[:categories] })
.or(query.where(category_id: nil))
end
end
query = query.joins(:merchant).where(merchants: { name: params[:merchants] }) if params[:merchants].present?
if params[:tags].present?
query = query.joins(:tags)
.where(tags: { name: params[:tags] })
.distinct
end
query
Account::TransactionSearch.new(params).build_query(all)
end
def requires_search?(params)
searchable_keys.any? { |key| params.key?(key) }
end
private
def searchable_keys
%i[categories merchants tags]
end
end
def eod_balance
entry.amount_money
end
private
def account
entry.account
end
def daily_transactions
account.entries.account_transactions
end
end

View file

@ -0,0 +1,42 @@
class Account::TransactionSearch
include ActiveModel::Model
include ActiveModel::Attributes
attribute :search, :string
attribute :amount, :string
attribute :amount_operator, :string
attribute :types, array: true
attribute :accounts, array: true
attribute :account_ids, array: true
attribute :start_date, :string
attribute :end_date, :string
attribute :categories, array: true
attribute :merchants, array: true
attribute :tags, array: true
# Returns array of Account::Entry objects to stay consistent with partials, which only deal with Account::Entry
def build_query(scope)
query = scope
if categories.present?
if categories.exclude?("Uncategorized")
query = query
.joins(:category)
.where(categories: { name: categories })
else
query = query
.left_joins(:category)
.where(categories: { name: categories })
.or(query.where(category_id: nil))
end
end
query = query.joins(:merchant).where(merchants: { name: merchants }) if merchants.present?
query = query.joins(:tags).where(tags: { name: tags }) if tags.present?
entries_scope = Account::Entry.account_transactions.where(entryable_id: query.select(:id))
Account::EntrySearch.from_entryable_search(self).build_query(entries_scope)
end
end

View file

@ -1,13 +1,3 @@
class Account::Valuation < ApplicationRecord
include Account::Entryable
class << self
def search(_params)
all
end
def requires_search?(_params)
false
end
end
end

View file

@ -1,12 +1,16 @@
class Category < ApplicationRecord
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
belongs_to :family
has_many :subcategories, class_name: "Category", foreign_key: :parent_id
belongs_to :parent, class_name: "Category", optional: true
validates :name, :color, :family, presence: true
validates :name, uniqueness: { scope: :family_id }
before_update :clear_internal_category, if: :name_changed?
validate :category_level_limit
scope :alphabetically, -> { order(:name) }
@ -14,30 +18,55 @@ class Category < ApplicationRecord
UNCATEGORIZED_COLOR = "#737373"
DEFAULT_CATEGORIES = [
{ internal_category: "income", color: COLORS[0] },
{ internal_category: "food_and_drink", color: COLORS[1] },
{ internal_category: "entertainment", color: COLORS[2] },
{ internal_category: "personal_care", color: COLORS[3] },
{ internal_category: "general_services", color: COLORS[4] },
{ internal_category: "auto_and_transport", color: COLORS[5] },
{ internal_category: "rent_and_utilities", color: COLORS[6] },
{ internal_category: "home_improvement", color: COLORS[7] }
]
class Group
attr_reader :category, :subcategories
def self.create_default_categories(family)
if family.categories.size > 0
raise ArgumentError, "Family already has some categories"
delegate :name, :color, to: :category
def self.for(categories)
categories.select { |category| category.parent_id.nil? }.map do |category|
new(category, category.subcategories)
end
end
family_id = family.id
categories = self::DEFAULT_CATEGORIES.map { |c| {
name: I18n.t("transaction.default_category.#{c[:internal_category]}"),
internal_category: c[:internal_category],
color: c[:color],
family_id:
} }
self.insert_all(categories)
def initialize(category, subcategories = nil)
@category = category
@subcategories = subcategories || []
end
end
class << self
def bootstrap_defaults
default_categories.each do |name, color|
find_or_create_by!(name: name) do |category|
category.color = color
end
end
end
private
def default_categories
[
[ "Income", "#e99537" ],
[ "Loan Payments", "#6471eb" ],
[ "Bank Fees", "#db5a54" ],
[ "Entertainment", "#df4e92" ],
[ "Food & Drink", "#c44fe9" ],
[ "Groceries", "#eb5429" ],
[ "Dining Out", "#61c9ea" ],
[ "General Merchandise", "#805dee" ],
[ "Clothing & Accessories", "#6ad28a" ],
[ "Electronics", "#e99537" ],
[ "Healthcare", "#4da568" ],
[ "Insurance", "#6471eb" ],
[ "Utilities", "#db5a54" ],
[ "Transportation", "#df4e92" ],
[ "Gas & Fuel", "#c44fe9" ],
[ "Education", "#eb5429" ],
[ "Charitable Donations", "#61c9ea" ],
[ "Subscriptions", "#805dee" ]
]
end
end
def replace_and_destroy!(replacement)
@ -47,9 +76,14 @@ class Category < ApplicationRecord
end
end
private
def subcategory?
parent.present?
end
def clear_internal_category
self.internal_category = nil
private
def category_level_limit
if subcategory? && parent.subcategory?
errors.add(:parent, "can't have more than 2 levels of subcategories")
end
end
end

View file

@ -90,6 +90,11 @@ class Demo::Generator
categories.each do |category|
family.categories.create!(name: category, color: COLORS.sample)
end
food = family.categories.find_by(name: "Food & Drink")
family.categories.create!(name: "Restaurants", parent_category: food)
family.categories.create!(name: "Groceries", parent_category: food)
family.categories.create!(name: "Alcohol & Bars", parent_category: food)
end
def create_merchants!