mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +02:00
Budgeting V1 (#1609)
* Budgeting V1 * Basic UI template * Fully scaffolded budgeting v1 * Basic working budget * Finalize donut chart for budgets * Allow categorization of loan payments for budget * Include loan payments in incomes_and_expenses scope * Add budget allocations progress * Empty states * Clean up budget methods * Category aggregation queries * Handle overage scenarios in form * Finalize budget donut chart controller * Passing tests * Fix allocation naming * Add income category migration * Native support for uncategorized budget category * Formatting * Fix subcategory sort order, padding * Fix calculation for category rollups in budget
This commit is contained in:
parent
413ec6cbed
commit
195ec85d96
61 changed files with 2044 additions and 140 deletions
|
@ -18,7 +18,6 @@ class Account::DataEnricher
|
|||
Rails.logger.info("Enriching #{candidates.count} transactions for account #{account.id}")
|
||||
|
||||
merchants = {}
|
||||
categories = {}
|
||||
|
||||
candidates.each do |entry|
|
||||
if entry.enriched_at.nil? || entry.entryable.merchant_id.nil? || entry.entryable.category_id.nil?
|
||||
|
@ -37,17 +36,11 @@ class Account::DataEnricher
|
|||
end
|
||||
end
|
||||
|
||||
if info.category.present?
|
||||
category = categories[info.category] ||= account.family.categories.find_or_create_by(name: info.category)
|
||||
end
|
||||
|
||||
entryable_attributes = { id: entry.entryable_id }
|
||||
entryable_attributes[:merchant_id] = merchant.id if merchant.present? && entry.entryable.merchant_id.nil?
|
||||
entryable_attributes[:category_id] = category.id if category.present? && entry.entryable.category_id.nil?
|
||||
|
||||
Account.transaction do
|
||||
merchant.save! if merchant.present?
|
||||
category.save! if category.present?
|
||||
entry.update!(
|
||||
enriched_at: Time.current,
|
||||
enriched_name: info.name,
|
||||
|
|
|
@ -17,7 +17,7 @@ class Account::Entry < ApplicationRecord
|
|||
scope :chronological, -> {
|
||||
order(
|
||||
date: :asc,
|
||||
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
|
||||
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
|
||||
created_at: :asc
|
||||
)
|
||||
}
|
||||
|
@ -25,18 +25,27 @@ class Account::Entry < ApplicationRecord
|
|||
scope :reverse_chronological, -> {
|
||||
order(
|
||||
date: :desc,
|
||||
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
|
||||
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
|
||||
created_at: :desc
|
||||
)
|
||||
}
|
||||
|
||||
# All entries that are not part of a pending/approved transfer (rejected transfers count as normal entries, so are included)
|
||||
# All non-transfer entries, rejected transfers, and the outflow of a loan payment transfer are incomes/expenses
|
||||
scope :incomes_and_expenses, -> {
|
||||
joins(
|
||||
'LEFT JOIN transfers AS inflow_transfers ON inflow_transfers.inflow_transaction_id = account_entries.entryable_id
|
||||
LEFT JOIN transfers AS outflow_transfers ON outflow_transfers.outflow_transaction_id = account_entries.entryable_id'
|
||||
)
|
||||
.where("(inflow_transfers.id IS NULL AND outflow_transfers.id IS NULL) OR inflow_transfers.status = 'rejected' OR outflow_transfers.status = 'rejected'")
|
||||
joins("INNER JOIN account_transactions ON account_transactions.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
|
||||
.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_transactions.id OR transfers.outflow_transaction_id = account_transactions.id")
|
||||
.joins("LEFT JOIN account_transactions inflow_txns ON inflow_txns.id = transfers.inflow_transaction_id")
|
||||
.joins("LEFT JOIN account_entries inflow_entries ON inflow_entries.entryable_id = inflow_txns.id AND inflow_entries.entryable_type = 'Account::Transaction'")
|
||||
.joins("LEFT JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_entries.account_id")
|
||||
.where("transfers.id IS NULL OR transfers.status = 'rejected' OR (account_entries.amount > 0 AND inflow_accounts.accountable_type = 'Loan')")
|
||||
}
|
||||
|
||||
scope :incomes, -> {
|
||||
incomes_and_expenses.where("account_entries.amount <= 0")
|
||||
}
|
||||
|
||||
scope :expenses, -> {
|
||||
incomes_and_expenses.where("account_entries.amount > 0")
|
||||
}
|
||||
|
||||
scope :with_converted_amount, ->(currency) {
|
||||
|
@ -137,18 +146,16 @@ class Account::Entry < ApplicationRecord
|
|||
all.size
|
||||
end
|
||||
|
||||
def income_total(currency = "USD")
|
||||
total = account_transactions.includes(:entryable).incomes_and_expenses
|
||||
.where("account_entries.amount <= 0")
|
||||
def income_total(currency = "USD", start_date: nil, end_date: nil)
|
||||
total = incomes.where(date: start_date..end_date)
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
|
||||
Money.new(total, currency)
|
||||
end
|
||||
|
||||
def expense_total(currency = "USD")
|
||||
total = account_transactions.includes(:entryable).incomes_and_expenses
|
||||
.where("account_entries.amount > 0")
|
||||
def expense_total(currency = "USD", start_date: nil, end_date: nil)
|
||||
total = expenses.where(date: start_date..end_date)
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
|
||||
|
|
181
app/models/budget.rb
Normal file
181
app/models/budget.rb
Normal file
|
@ -0,0 +1,181 @@
|
|||
class Budget < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :family
|
||||
|
||||
has_many :budget_categories, dependent: :destroy
|
||||
|
||||
validates :start_date, :end_date, presence: true
|
||||
validates :start_date, :end_date, uniqueness: { scope: :family_id }
|
||||
|
||||
monetize :budgeted_spending, :expected_income, :allocated_spending,
|
||||
:actual_spending, :available_to_spend, :available_to_allocate,
|
||||
:estimated_spending, :estimated_income, :actual_income
|
||||
|
||||
class << self
|
||||
def for_date(date)
|
||||
find_by(start_date: date.beginning_of_month, end_date: date.end_of_month)
|
||||
end
|
||||
|
||||
def find_or_bootstrap(family, date: Date.current)
|
||||
Budget.transaction do
|
||||
budget = Budget.find_or_create_by(
|
||||
family: family,
|
||||
start_date: date.beginning_of_month,
|
||||
end_date: date.end_of_month,
|
||||
currency: family.currency
|
||||
)
|
||||
|
||||
budget.sync_budget_categories
|
||||
|
||||
budget
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sync_budget_categories
|
||||
family.categories.expenses.each do |category|
|
||||
budget_categories.find_or_create_by(
|
||||
category: category,
|
||||
) do |bc|
|
||||
bc.budgeted_spending = 0
|
||||
bc.currency = family.currency
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def uncategorized_budget_category
|
||||
budget_categories.uncategorized.tap do |bc|
|
||||
bc.budgeted_spending = [ available_to_allocate, 0 ].max
|
||||
bc.currency = family.currency
|
||||
end
|
||||
end
|
||||
|
||||
def entries
|
||||
family.entries.incomes_and_expenses.where(date: start_date..end_date)
|
||||
end
|
||||
|
||||
def name
|
||||
start_date.strftime("%B %Y")
|
||||
end
|
||||
|
||||
def initialized?
|
||||
budgeted_spending.present?
|
||||
end
|
||||
|
||||
def income_categories_with_totals
|
||||
family.income_categories_with_totals(date: start_date)
|
||||
end
|
||||
|
||||
def expense_categories_with_totals
|
||||
family.expense_categories_with_totals(date: start_date)
|
||||
end
|
||||
|
||||
def current?
|
||||
start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month
|
||||
end
|
||||
|
||||
def previous_budget
|
||||
prev_month_end_date = end_date - 1.month
|
||||
return nil if prev_month_end_date < family.oldest_entry_date
|
||||
family.budgets.find_or_bootstrap(family, date: prev_month_end_date)
|
||||
end
|
||||
|
||||
def next_budget
|
||||
return nil if current?
|
||||
next_start_date = start_date + 1.month
|
||||
family.budgets.find_or_bootstrap(family, date: next_start_date)
|
||||
end
|
||||
|
||||
def to_donut_segments_json
|
||||
unused_segment_id = "unused"
|
||||
|
||||
# Continuous gray segment for empty budgets
|
||||
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid?
|
||||
|
||||
segments = budget_categories.map do |bc|
|
||||
{ color: bc.category.color, amount: bc.actual_spending, id: bc.id }
|
||||
end
|
||||
|
||||
if available_to_spend.positive?
|
||||
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
|
||||
end
|
||||
|
||||
segments
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Actuals: How much user has spent on each budget category
|
||||
# =============================================================================
|
||||
def estimated_spending
|
||||
family.budgeting_stats.avg_monthly_expenses&.abs
|
||||
end
|
||||
|
||||
def actual_spending
|
||||
budget_categories.reject(&:subcategory?).sum(&:actual_spending)
|
||||
end
|
||||
|
||||
def available_to_spend
|
||||
(budgeted_spending || 0) - actual_spending
|
||||
end
|
||||
|
||||
def percent_of_budget_spent
|
||||
return 0 unless budgeted_spending > 0
|
||||
|
||||
(actual_spending / budgeted_spending.to_f) * 100
|
||||
end
|
||||
|
||||
def overage_percent
|
||||
return 0 unless available_to_spend.negative?
|
||||
|
||||
available_to_spend.abs / actual_spending.to_f * 100
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Budget allocations: How much user has budgeted for all categories combined
|
||||
# =============================================================================
|
||||
def allocated_spending
|
||||
budget_categories.sum(:budgeted_spending)
|
||||
end
|
||||
|
||||
def allocated_percent
|
||||
return 0 unless budgeted_spending > 0
|
||||
|
||||
(allocated_spending / budgeted_spending.to_f) * 100
|
||||
end
|
||||
|
||||
def available_to_allocate
|
||||
(budgeted_spending || 0) - allocated_spending
|
||||
end
|
||||
|
||||
def allocations_valid?
|
||||
initialized? && available_to_allocate.positive? && allocated_spending > 0
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Income: How much user earned relative to what they expected to earn
|
||||
# =============================================================================
|
||||
def estimated_income
|
||||
family.budgeting_stats.avg_monthly_income&.abs
|
||||
end
|
||||
|
||||
def actual_income
|
||||
family.entries.incomes.where(date: start_date..end_date).sum(:amount).abs
|
||||
end
|
||||
|
||||
def actual_income_percent
|
||||
return 0 unless expected_income > 0
|
||||
|
||||
(actual_income / expected_income.to_f) * 100
|
||||
end
|
||||
|
||||
def remaining_expected_income
|
||||
expected_income - actual_income
|
||||
end
|
||||
|
||||
def surplus_percent
|
||||
return 0 unless remaining_expected_income.negative?
|
||||
|
||||
remaining_expected_income.abs / expected_income.to_f * 100
|
||||
end
|
||||
end
|
82
app/models/budget_category.rb
Normal file
82
app/models/budget_category.rb
Normal file
|
@ -0,0 +1,82 @@
|
|||
class BudgetCategory < ApplicationRecord
|
||||
include Monetizable
|
||||
|
||||
belongs_to :budget
|
||||
belongs_to :category
|
||||
|
||||
validates :budget_id, uniqueness: { scope: :category_id }
|
||||
|
||||
monetize :budgeted_spending, :actual_spending, :available_to_spend
|
||||
|
||||
class Group
|
||||
attr_reader :budget_category, :budget_subcategories
|
||||
|
||||
delegate :category, to: :budget_category
|
||||
delegate :name, :color, to: :category
|
||||
|
||||
def self.for(budget_categories)
|
||||
top_level_categories = budget_categories.select { |budget_category| budget_category.category.parent_id.nil? }
|
||||
top_level_categories.map do |top_level_category|
|
||||
subcategories = budget_categories.select { |bc| bc.category.parent_id == top_level_category.category_id && top_level_category.category_id.present? }
|
||||
new(top_level_category, subcategories.sort_by { |subcategory| subcategory.category.name })
|
||||
end.sort_by { |group| group.category.name }
|
||||
end
|
||||
|
||||
def initialize(budget_category, budget_subcategories = [])
|
||||
@budget_category = budget_category
|
||||
@budget_subcategories = budget_subcategories
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def uncategorized
|
||||
new(
|
||||
id: Digest::UUID.uuid_v5(Digest::UUID::URL_NAMESPACE, "uncategorized"),
|
||||
category: nil,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def initialized?
|
||||
budget.initialized?
|
||||
end
|
||||
|
||||
def category
|
||||
super || budget.family.categories.uncategorized
|
||||
end
|
||||
|
||||
def subcategory?
|
||||
category.parent_id.present?
|
||||
end
|
||||
|
||||
def actual_spending
|
||||
category.month_total(date: budget.start_date)
|
||||
end
|
||||
|
||||
def available_to_spend
|
||||
(budgeted_spending || 0) - actual_spending
|
||||
end
|
||||
|
||||
def percent_of_budget_spent
|
||||
return 0 unless budgeted_spending > 0
|
||||
|
||||
(actual_spending / budgeted_spending) * 100
|
||||
end
|
||||
|
||||
def to_donut_segments_json
|
||||
unused_segment_id = "unused"
|
||||
overage_segment_id = "overage"
|
||||
|
||||
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless actual_spending > 0
|
||||
|
||||
segments = [ { color: category.color, amount: actual_spending, id: id } ]
|
||||
|
||||
if available_to_spend.negative?
|
||||
segments.push({ color: "#EF4444", amount: available_to_spend.abs, id: overage_segment_id })
|
||||
else
|
||||
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
|
||||
end
|
||||
|
||||
segments
|
||||
end
|
||||
end
|
29
app/models/budgeting_stats.rb
Normal file
29
app/models/budgeting_stats.rb
Normal file
|
@ -0,0 +1,29 @@
|
|||
class BudgetingStats
|
||||
attr_reader :family
|
||||
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
def avg_monthly_income
|
||||
income_expense_totals_query(Account::Entry.incomes)
|
||||
end
|
||||
|
||||
def avg_monthly_expenses
|
||||
income_expense_totals_query(Account::Entry.expenses)
|
||||
end
|
||||
|
||||
private
|
||||
def income_expense_totals_query(type_scope)
|
||||
monthly_totals = family.entries
|
||||
.merge(type_scope)
|
||||
.select("SUM(account_entries.amount) as total")
|
||||
.group(Arel.sql("date_trunc('month', account_entries.date)"))
|
||||
|
||||
result = Family.select("AVG(mt.total)")
|
||||
.from(monthly_totals, :mt)
|
||||
.pick("AVG(mt.total)")
|
||||
|
||||
result
|
||||
end
|
||||
end
|
|
@ -4,6 +4,7 @@ class Category < ApplicationRecord
|
|||
|
||||
belongs_to :family
|
||||
|
||||
has_many :budget_categories, dependent: :destroy
|
||||
has_many :subcategories, class_name: "Category", foreign_key: :parent_id
|
||||
belongs_to :parent, class_name: "Category", optional: true
|
||||
|
||||
|
@ -11,8 +12,11 @@ class Category < ApplicationRecord
|
|||
validates :name, uniqueness: { scope: :family_id }
|
||||
|
||||
validate :category_level_limit
|
||||
validate :nested_category_matches_parent_classification
|
||||
|
||||
scope :alphabetically, -> { order(:name) }
|
||||
scope :incomes, -> { where(classification: "income") }
|
||||
scope :expenses, -> { where(classification: "expense") }
|
||||
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
|
@ -39,35 +43,43 @@ class Category < ApplicationRecord
|
|||
end
|
||||
|
||||
class << self
|
||||
def icon_codes
|
||||
%w[bus circle-dollar-sign ambulance apple award baby battery lightbulb bed-single beer bluetooth book briefcase building credit-card camera utensils cooking-pot cookie dices drama dog drill drum dumbbell gamepad-2 graduation-cap house hand-helping ice-cream-cone phone piggy-bank pill pizza printer puzzle ribbon shopping-cart shield-plus ticket trees]
|
||||
end
|
||||
|
||||
def bootstrap_defaults
|
||||
default_categories.each do |name, color|
|
||||
default_categories.each do |name, color, icon|
|
||||
find_or_create_by!(name: name) do |category|
|
||||
category.color = color
|
||||
category.classification = "income" if name == "Income"
|
||||
category.lucide_icon = icon
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def uncategorized
|
||||
new(
|
||||
name: "Uncategorized",
|
||||
color: UNCATEGORIZED_COLOR,
|
||||
lucide_icon: "circle-dashed"
|
||||
)
|
||||
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" ]
|
||||
[ "Income", "#e99537", "circle-dollar-sign" ],
|
||||
[ "Housing", "#6471eb", "house" ],
|
||||
[ "Entertainment", "#df4e92", "drama" ],
|
||||
[ "Food & Drink", "#eb5429", "utensils" ],
|
||||
[ "Shopping", "#e99537", "shopping-cart" ],
|
||||
[ "Healthcare", "#4da568", "pill" ],
|
||||
[ "Insurance", "#6471eb", "piggy-bank" ],
|
||||
[ "Utilities", "#db5a54", "lightbulb" ],
|
||||
[ "Transportation", "#df4e92", "bus" ],
|
||||
[ "Education", "#eb5429", "book" ],
|
||||
[ "Gifts & Donations", "#61c9ea", "hand-helping" ],
|
||||
[ "Subscriptions", "#805dee", "credit-card" ]
|
||||
]
|
||||
end
|
||||
end
|
||||
|
@ -83,10 +95,28 @@ class Category < ApplicationRecord
|
|||
parent.present?
|
||||
end
|
||||
|
||||
def avg_monthly_total
|
||||
family.category_stats.avg_monthly_total_for(self)
|
||||
end
|
||||
|
||||
def median_monthly_total
|
||||
family.category_stats.median_monthly_total_for(self)
|
||||
end
|
||||
|
||||
def month_total(date: Date.current)
|
||||
family.category_stats.month_total_for(self, date: date)
|
||||
end
|
||||
|
||||
private
|
||||
def category_level_limit
|
||||
if subcategory? && parent.subcategory?
|
||||
errors.add(:parent, "can't have more than 2 levels of subcategories")
|
||||
end
|
||||
end
|
||||
|
||||
def nested_category_matches_parent_classification
|
||||
if subcategory? && parent.classification != classification
|
||||
errors.add(:parent, "must have the same classification as its parent")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
179
app/models/category_stats.rb
Normal file
179
app/models/category_stats.rb
Normal file
|
@ -0,0 +1,179 @@
|
|||
class CategoryStats
|
||||
attr_reader :family
|
||||
|
||||
def initialize(family)
|
||||
@family = family
|
||||
end
|
||||
|
||||
def avg_monthly_total_for(category)
|
||||
statistics_data[category.id]&.avg || 0
|
||||
end
|
||||
|
||||
def median_monthly_total_for(category)
|
||||
statistics_data[category.id]&.median || 0
|
||||
end
|
||||
|
||||
def month_total_for(category, date: Date.current)
|
||||
monthly_totals = totals_data[category.id]
|
||||
|
||||
category_total = monthly_totals&.find { |mt| mt.month == date.month && mt.year == date.year }
|
||||
|
||||
category_total&.amount || 0
|
||||
end
|
||||
|
||||
def month_category_totals(date: Date.current)
|
||||
by_classification = Hash.new { |h, k| h[k] = {} }
|
||||
|
||||
totals_data.each_with_object(by_classification) do |(category_id, totals), result|
|
||||
totals.each do |t|
|
||||
next unless t.month == date.month && t.year == date.year
|
||||
result[t.classification][category_id] ||= { amount: 0, subcategory: t.subcategory? }
|
||||
result[t.classification][category_id][:amount] += t.amount.abs
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate percentages for each group
|
||||
category_totals = []
|
||||
|
||||
[ "income", "expense" ].each do |classification|
|
||||
totals = by_classification[classification]
|
||||
|
||||
# Only include non-subcategory amounts in the total for percentage calculations
|
||||
total_amount = totals.sum do |_, data|
|
||||
data[:subcategory] ? 0 : data[:amount]
|
||||
end
|
||||
|
||||
next if total_amount.zero?
|
||||
|
||||
totals.each do |category_id, data|
|
||||
percentage = (data[:amount].to_f / total_amount * 100).round(1)
|
||||
|
||||
category_totals << CategoryTotal.new(
|
||||
category_id: category_id,
|
||||
amount: data[:amount],
|
||||
percentage: percentage,
|
||||
classification: classification,
|
||||
currency: family.currency,
|
||||
subcategory?: data[:subcategory]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate totals based on non-subcategory amounts only
|
||||
total_income = category_totals
|
||||
.select { |ct| ct.classification == "income" && !ct.subcategory? }
|
||||
.sum(&:amount)
|
||||
|
||||
total_expense = category_totals
|
||||
.select { |ct| ct.classification == "expense" && !ct.subcategory? }
|
||||
.sum(&:amount)
|
||||
|
||||
CategoryTotals.new(
|
||||
total_income: total_income,
|
||||
total_expense: total_expense,
|
||||
category_totals: category_totals
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
Totals = Struct.new(:month, :year, :amount, :classification, :currency, :subcategory?, keyword_init: true)
|
||||
Stats = Struct.new(:avg, :median, :currency, keyword_init: true)
|
||||
CategoryTotals = Struct.new(:total_income, :total_expense, :category_totals, keyword_init: true)
|
||||
CategoryTotal = Struct.new(:category_id, :amount, :percentage, :classification, :currency, :subcategory?, keyword_init: true)
|
||||
|
||||
def statistics_data
|
||||
@statistics_data ||= begin
|
||||
stats = totals_data.each_with_object({ nil => Stats.new(avg: 0, median: 0) }) do |(category_id, totals), hash|
|
||||
next if totals.empty?
|
||||
|
||||
amounts = totals.map(&:amount)
|
||||
hash[category_id] = Stats.new(
|
||||
avg: (amounts.sum.to_f / amounts.size).round,
|
||||
median: calculate_median(amounts),
|
||||
currency: family.currency
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def totals_data
|
||||
@totals_data ||= begin
|
||||
totals = monthly_totals_query.each_with_object({ nil => [] }) do |row, hash|
|
||||
hash[row.category_id] ||= []
|
||||
existing_total = hash[row.category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
|
||||
|
||||
if existing_total
|
||||
existing_total.amount += row.total.to_i
|
||||
else
|
||||
hash[row.category_id] << Totals.new(
|
||||
month: row.date.month,
|
||||
year: row.date.year,
|
||||
amount: row.total.to_i,
|
||||
classification: row.classification,
|
||||
currency: family.currency,
|
||||
subcategory?: row.parent_category_id.present?
|
||||
)
|
||||
end
|
||||
|
||||
# If category is a parent, its total includes its own transactions + sum(child category transactions)
|
||||
if row.parent_category_id
|
||||
hash[row.parent_category_id] ||= []
|
||||
|
||||
existing_parent_total = hash[row.parent_category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
|
||||
|
||||
if existing_parent_total
|
||||
existing_parent_total.amount += row.total.to_i
|
||||
else
|
||||
hash[row.parent_category_id] << Totals.new(
|
||||
month: row.date.month,
|
||||
year: row.date.year,
|
||||
amount: row.total.to_i,
|
||||
classification: row.classification,
|
||||
currency: family.currency,
|
||||
subcategory?: false
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Ensure we have a default empty array for nil category, which represents "Uncategorized"
|
||||
totals[nil] ||= []
|
||||
totals
|
||||
end
|
||||
end
|
||||
|
||||
def monthly_totals_query
|
||||
income_expense_classification = Arel.sql("
|
||||
CASE WHEN categories.id IS NULL THEN
|
||||
CASE WHEN account_entries.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
ELSE categories.classification
|
||||
END
|
||||
")
|
||||
|
||||
family.entries
|
||||
.incomes_and_expenses
|
||||
.select(
|
||||
"categories.id as category_id",
|
||||
"categories.parent_id as parent_category_id",
|
||||
income_expense_classification,
|
||||
"date_trunc('month', account_entries.date) as date",
|
||||
"SUM(account_entries.amount) as total"
|
||||
)
|
||||
.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
|
||||
.group(Arel.sql("categories.id, categories.parent_id, #{income_expense_classification}, date_trunc('month', account_entries.date)"))
|
||||
.order(Arel.sql("date_trunc('month', account_entries.date) DESC"))
|
||||
end
|
||||
|
||||
|
||||
def calculate_median(numbers)
|
||||
return 0 if numbers.empty?
|
||||
|
||||
sorted = numbers.sort
|
||||
mid = sorted.size / 2
|
||||
if sorted.size.odd?
|
||||
sorted[mid]
|
||||
else
|
||||
((sorted[mid-1] + sorted[mid]) / 2.0).round
|
||||
end
|
||||
end
|
||||
end
|
|
@ -87,18 +87,12 @@ class Demo::Generator
|
|||
end
|
||||
|
||||
def create_categories!
|
||||
categories = [ "Income", "Food & Drink", "Entertainment", "Travel",
|
||||
"Personal Care", "General Services", "Auto & Transport",
|
||||
"Rent & Utilities", "Home Improvement", "Shopping" ]
|
||||
|
||||
categories.each do |category|
|
||||
family.categories.create!(name: category, color: COLORS.sample)
|
||||
end
|
||||
family.categories.bootstrap_defaults
|
||||
|
||||
food = family.categories.find_by(name: "Food & Drink")
|
||||
family.categories.create!(name: "Restaurants", parent: food)
|
||||
family.categories.create!(name: "Groceries", parent: food)
|
||||
family.categories.create!(name: "Alcohol & Bars", parent: food)
|
||||
family.categories.create!(name: "Restaurants", parent: food, color: COLORS.sample, classification: "expense")
|
||||
family.categories.create!(name: "Groceries", parent: food, color: COLORS.sample, classification: "expense")
|
||||
family.categories.create!(name: "Alcohol & Bars", parent: food, color: COLORS.sample, classification: "expense")
|
||||
end
|
||||
|
||||
def create_merchants!
|
||||
|
@ -362,17 +356,17 @@ class Demo::Generator
|
|||
"McDonald's" => "Food & Drink",
|
||||
"Target" => "Shopping",
|
||||
"Costco" => "Food & Drink",
|
||||
"Home Depot" => "Home Improvement",
|
||||
"Shell" => "Auto & Transport",
|
||||
"Home Depot" => "Housing",
|
||||
"Shell" => "Transportation",
|
||||
"Whole Foods" => "Food & Drink",
|
||||
"Walgreens" => "Personal Care",
|
||||
"Walgreens" => "Healthcare",
|
||||
"Nike" => "Shopping",
|
||||
"Uber" => "Auto & Transport",
|
||||
"Netflix" => "Entertainment",
|
||||
"Spotify" => "Entertainment",
|
||||
"Delta Airlines" => "Travel",
|
||||
"Airbnb" => "Travel",
|
||||
"Sephora" => "Personal Care"
|
||||
"Uber" => "Transportation",
|
||||
"Netflix" => "Subscriptions",
|
||||
"Spotify" => "Subscriptions",
|
||||
"Delta Airlines" => "Transportation",
|
||||
"Airbnb" => "Housing",
|
||||
"Sephora" => "Shopping"
|
||||
}
|
||||
|
||||
categories.find { |c| c.name == mapping[merchant.name] }
|
||||
|
|
|
@ -17,6 +17,8 @@ class Family < ApplicationRecord
|
|||
has_many :issues, through: :accounts
|
||||
has_many :holdings, through: :accounts
|
||||
has_many :plaid_items, dependent: :destroy
|
||||
has_many :budgets, dependent: :destroy
|
||||
has_many :budget_categories, through: :budgets
|
||||
|
||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||
validates :date_format, inclusion: { in: DATE_FORMATS }
|
||||
|
@ -56,6 +58,22 @@ class Family < ApplicationRecord
|
|||
).link_token
|
||||
end
|
||||
|
||||
def income_categories_with_totals(date: Date.current)
|
||||
categories_with_stats(classification: "income", date: date)
|
||||
end
|
||||
|
||||
def expense_categories_with_totals(date: Date.current)
|
||||
categories_with_stats(classification: "expense", date: date)
|
||||
end
|
||||
|
||||
def category_stats
|
||||
CategoryStats.new(self)
|
||||
end
|
||||
|
||||
def budgeting_stats
|
||||
BudgetingStats.new(self)
|
||||
end
|
||||
|
||||
def snapshot(period = Period.all)
|
||||
query = accounts.active.joins(:balances)
|
||||
.where("account_balances.currency = ?", self.currency)
|
||||
|
@ -172,4 +190,41 @@ class Family < ApplicationRecord
|
|||
def primary_user
|
||||
users.order(:created_at).first
|
||||
end
|
||||
|
||||
def oldest_entry_date
|
||||
entries.order(:date).first&.date || Date.current
|
||||
end
|
||||
|
||||
private
|
||||
CategoriesWithTotals = Struct.new(:total_money, :category_totals, keyword_init: true)
|
||||
CategoryWithStats = Struct.new(:category, :amount_money, :percentage, keyword_init: true)
|
||||
|
||||
def categories_with_stats(classification:, date: Date.current)
|
||||
totals = category_stats.month_category_totals(date: date)
|
||||
|
||||
classified_totals = totals.category_totals.select { |t| t.classification == classification }
|
||||
|
||||
if classification == "income"
|
||||
total = totals.total_income
|
||||
categories_scope = categories.incomes
|
||||
else
|
||||
total = totals.total_expense
|
||||
categories_scope = categories.expenses
|
||||
end
|
||||
|
||||
categories_with_uncategorized = categories_scope + [ categories_scope.uncategorized ]
|
||||
|
||||
CategoriesWithTotals.new(
|
||||
total_money: Money.new(total, currency),
|
||||
category_totals: categories_with_uncategorized.map do |category|
|
||||
ct = classified_totals.find { |ct| ct.category_id == category&.id }
|
||||
|
||||
CategoryWithStats.new(
|
||||
category: category,
|
||||
amount_money: Money.new(ct&.amount || 0, currency),
|
||||
percentage: ct&.percentage || 0
|
||||
)
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -42,34 +42,34 @@ class Transfer < ApplicationRecord
|
|||
end
|
||||
|
||||
def auto_match_for_account(account)
|
||||
matches = account.entries.account_transactions.joins("
|
||||
JOIN account_entries ae2 ON
|
||||
account_entries.amount = -ae2.amount AND
|
||||
account_entries.currency = ae2.currency AND
|
||||
account_entries.account_id <> ae2.account_id AND
|
||||
ABS(account_entries.date - ae2.date) <= 4
|
||||
").select(
|
||||
"account_entries.id",
|
||||
"account_entries.entryable_id AS e1_entryable_id",
|
||||
"ae2.entryable_id AS e2_entryable_id",
|
||||
"account_entries.amount AS e1_amount",
|
||||
"ae2.amount AS e2_amount"
|
||||
)
|
||||
matches = Account::Entry.from("account_entries inflow_candidates")
|
||||
.joins("
|
||||
JOIN account_entries outflow_candidates ON (
|
||||
inflow_candidates.amount < 0 AND
|
||||
outflow_candidates.amount > 0 AND
|
||||
inflow_candidates.amount = -outflow_candidates.amount AND
|
||||
inflow_candidates.currency = outflow_candidates.currency AND
|
||||
inflow_candidates.account_id <> outflow_candidates.account_id AND
|
||||
inflow_candidates.date BETWEEN outflow_candidates.date - 4 AND outflow_candidates.date + 4 AND
|
||||
inflow_candidates.date >= outflow_candidates.date
|
||||
)
|
||||
").joins("
|
||||
LEFT JOIN transfers existing_transfers ON (
|
||||
(existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id AND
|
||||
existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id) OR
|
||||
(existing_transfers.inflow_transaction_id = inflow_candidates.entryable_id) OR
|
||||
(existing_transfers.outflow_transaction_id = outflow_candidates.entryable_id)
|
||||
)
|
||||
")
|
||||
.where(existing_transfers: { id: nil })
|
||||
.where("inflow_candidates.account_id = ? AND outflow_candidates.account_id = ?", account.id, account.id)
|
||||
.pluck(:inflow_transaction_id, :outflow_transaction_id)
|
||||
|
||||
Transfer.transaction do
|
||||
matches.each do |match|
|
||||
inflow = match.e1_amount.negative? ? match.e1_entryable_id : match.e2_entryable_id
|
||||
outflow = match.e1_amount.negative? ? match.e2_entryable_id : match.e1_entryable_id
|
||||
|
||||
# Skip all rejected, or already matched transfers
|
||||
next if Transfer.exists?(
|
||||
inflow_transaction_id: inflow,
|
||||
outflow_transaction_id: outflow
|
||||
)
|
||||
|
||||
matches.each do |inflow_transaction_id, outflow_transaction_id|
|
||||
Transfer.create!(
|
||||
inflow_transaction_id: inflow,
|
||||
outflow_transaction_id: outflow
|
||||
inflow_transaction_id: inflow_transaction_id,
|
||||
outflow_transaction_id: outflow_transaction_id,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
@ -109,6 +109,10 @@ class Transfer < ApplicationRecord
|
|||
to_account.liability?
|
||||
end
|
||||
|
||||
def categorizable?
|
||||
to_account.accountable_type == "Loan"
|
||||
end
|
||||
|
||||
private
|
||||
def transfer_has_different_accounts
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue