diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb new file mode 100644 index 00000000..fa6e22e4 --- /dev/null +++ b/app/controllers/budget_categories_controller.rb @@ -0,0 +1,35 @@ +class BudgetCategoriesController < ApplicationController + def index + @budget = Current.family.budgets.find(params[:budget_id]) + render layout: "wizard" + end + + def show + @budget = Current.family.budgets.find(params[:budget_id]) + + @recent_transactions = @budget.entries + + if params[:id] == BudgetCategory.uncategorized.id + @budget_category = @budget.uncategorized_budget_category + @recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil }) + else + @budget_category = Current.family.budget_categories.find(params[:id]) + @recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id") + .where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id) + end + + @recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3) + end + + def update + @budget_category = Current.family.budget_categories.find(params[:id]) + @budget_category.update!(budget_category_params) + + redirect_to budget_budget_categories_path(@budget_category.budget) + end + + private + def budget_category_params + params.require(:budget_category).permit(:budgeted_spending) + end +end diff --git a/app/controllers/budgets_controller.rb b/app/controllers/budgets_controller.rb new file mode 100644 index 00000000..4ea71169 --- /dev/null +++ b/app/controllers/budgets_controller.rb @@ -0,0 +1,55 @@ +class BudgetsController < ApplicationController + before_action :set_budget, only: %i[show edit update] + + def index + redirect_to_current_month_budget + end + + def show + @next_budget = @budget.next_budget + @previous_budget = @budget.previous_budget + @latest_budget = Budget.find_or_bootstrap(Current.family) + render layout: with_sidebar + end + + def edit + render layout: "wizard" + end + + def update + @budget.update!(budget_params) + redirect_to budget_budget_categories_path(@budget) + end + + def create + start_date = Date.parse(budget_create_params[:start_date]) + @budget = Budget.find_or_bootstrap(Current.family, date: start_date) + redirect_to budget_path(@budget) + end + + def picker + render partial: "budgets/picker", locals: { + family: Current.family, + year: params[:year].to_i || Date.current.year + } + end + + private + def budget_create_params + params.require(:budget).permit(:start_date) + end + + def budget_params + params.require(:budget).permit(:budgeted_spending, :expected_income) + end + + def set_budget + @budget = Current.family.budgets.find(params[:id]) + @budget.sync_budget_categories + end + + def redirect_to_current_month_budget + current_budget = Budget.find_or_bootstrap(Current.family) + redirect_to budget_path(current_budget) + end +end diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index cbd468ea..03752869 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -19,9 +19,15 @@ class CategoriesController < ApplicationController if @category.save @transaction.update(category_id: @category.id) if @transaction - redirect_back_or_to categories_path, notice: t(".success") + flash[:notice] = t(".success") + + redirect_target_url = request.referer || categories_path + respond_to do |format| + format.html { redirect_back_or_to categories_path, notice: t(".success") } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) } + end else - @categories = Current.family.categories.alphabetically.where(parent_id: nil) + @categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id) render :new, status: :unprocessable_entity end end @@ -60,6 +66,6 @@ class CategoriesController < ApplicationController end def category_params - params.require(:category).permit(:name, :color, :parent_id) + params.require(:category).permit(:name, :color, :parent_id, :classification, :lucide_icon) end end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index f20a3304..80248ef2 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -3,7 +3,7 @@ class TransactionsController < ApplicationController def index @q = search_params - search_query = Current.family.transactions.search(@q).includes(:entryable).reverse_chronological + search_query = Current.family.transactions.search(@q).reverse_chronological @pagy, @transaction_entries = pagy(search_query, limit: params[:per_page] || "50") totals_query = search_query.incomes_and_expenses diff --git a/app/controllers/transfers_controller.rb b/app/controllers/transfers_controller.rb index 14c64422..a7d04bad 100644 --- a/app/controllers/transfers_controller.rb +++ b/app/controllers/transfers_controller.rb @@ -8,6 +8,7 @@ class TransfersController < ApplicationController end def show + @categories = Current.family.categories.expenses end def create @@ -37,7 +38,11 @@ class TransfersController < ApplicationController end def update - @transfer.update!(transfer_update_params) + Transfer.transaction do + @transfer.update!(transfer_update_params.except(:category_id)) + @transfer.outflow_transaction.update!(category_id: transfer_update_params[:category_id]) + end + respond_to do |format| format.html { redirect_back_or_to transactions_url, notice: t(".success") } format.turbo_stream @@ -61,6 +66,6 @@ class TransfersController < ApplicationController end def transfer_update_params - params.require(:transfer).permit(:notes, :status) + params.require(:transfer).permit(:notes, :status, :category_id) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0bead33e..4f1c9499 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -15,6 +15,16 @@ module ApplicationHelper ] end + def icon(key, size: "md", color: "current") + render partial: "shared/icon", locals: { key:, size:, color: } + end + + # Convert alpha (0-1) to 8-digit hex (00-FF) + def hex_with_alpha(hex, alpha) + alpha_hex = (alpha * 255).round.to_s(16).rjust(2, "0") + "#{hex}#{alpha_hex}" + end + def title(page_title) content_for(:title) { page_title } end diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb index 9250f1d7..2d586dee 100644 --- a/app/helpers/categories_helper.rb +++ b/app/helpers/categories_helper.rb @@ -1,20 +1,16 @@ module CategoriesHelper - def null_category - Category.new \ - name: "Uncategorized", - color: Category::UNCATEGORIZED_COLOR - end - def transfer_category Category.new \ - name: "⇄ Transfer", - color: Category::TRANSFER_COLOR + name: "Transfer", + color: Category::TRANSFER_COLOR, + lucide_icon: "arrow-right-left" end def payment_category Category.new \ - name: "→ Payment", - color: Category::PAYMENT_COLOR + name: "Payment", + color: Category::PAYMENT_COLOR, + lucide_icon: "arrow-right" end def trade_category @@ -24,6 +20,6 @@ module CategoriesHelper end def family_categories - [ null_category ].concat(Current.family.categories.alphabetically) + [ Category.uncategorized ].concat(Current.family.categories.alphabetically) end end diff --git a/app/javascript/controllers/budget_form_controller.js b/app/javascript/controllers/budget_form_controller.js new file mode 100644 index 00000000..0647736e --- /dev/null +++ b/app/javascript/controllers/budget_form_controller.js @@ -0,0 +1,25 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="budget-form" +export default class extends Controller { + toggleAutoFill(e) { + const expectedIncome = e.params.income; + const budgetedSpending = e.params.spending; + + if (e.target.checked) { + this.#fillField(expectedIncome.key, expectedIncome.value); + this.#fillField(budgetedSpending.key, budgetedSpending.value); + } else { + this.#clearField(expectedIncome.key); + this.#clearField(budgetedSpending.key); + } + } + + #fillField(id, value) { + this.element.querySelector(`input[id="${id}"]`).value = value; + } + + #clearField(id) { + this.element.querySelector(`input[id="${id}"]`).value = ""; + } +} diff --git a/app/javascript/controllers/donut_chart_controller.js b/app/javascript/controllers/donut_chart_controller.js new file mode 100644 index 00000000..55c7cbb3 --- /dev/null +++ b/app/javascript/controllers/donut_chart_controller.js @@ -0,0 +1,168 @@ +import { Controller } from "@hotwired/stimulus"; +import * as d3 from "d3"; + +// Connects to data-controller="donut-chart" +export default class extends Controller { + static targets = ["chartContainer", "contentContainer", "defaultContent"]; + static values = { + segments: { type: Array, default: [] }, + unusedSegmentId: { type: String, default: "unused" }, + overageSegmentId: { type: String, default: "overage" }, + segmentHeight: { type: Number, default: 3 }, + segmentOpacity: { type: Number, default: 1 }, + }; + + #viewBoxSize = 100; + #minSegmentAngle = this.segmentHeightValue * 0.01; + + connect() { + this.#draw(); + document.addEventListener("turbo:load", this.#redraw); + this.element.addEventListener("mouseleave", this.#clearSegmentHover); + } + + disconnect() { + this.#teardown(); + document.removeEventListener("turbo:load", this.#redraw); + this.element.removeEventListener("mouseleave", this.#clearSegmentHover); + } + + get #data() { + const totalPieValue = this.segmentsValue.reduce( + (acc, s) => acc + Number(s.amount), + 0, + ); + + // Overage is always first segment, unused is always last segment + return this.segmentsValue + .filter((s) => s.amount > 0) + .map((s) => ({ + ...s, + amount: Math.max( + Number(s.amount), + totalPieValue * this.#minSegmentAngle, + ), + })) + .sort((a, b) => { + if (a.id === this.overageSegmentIdValue) return -1; + if (b.id === this.overageSegmentIdValue) return 1; + if (a.id === this.unusedSegmentIdValue) return 1; + if (b.id === this.unusedSegmentIdValue) return -1; + return b.amount - a.amount; + }); + } + + #redraw = () => { + this.#teardown(); + this.#draw(); + }; + + #teardown() { + d3.select(this.chartContainerTarget).selectAll("*").remove(); + } + + #draw() { + const svg = d3 + .select(this.chartContainerTarget) + .append("svg") + .attr("viewBox", `0 0 ${this.#viewBoxSize} ${this.#viewBoxSize}`) // Square aspect ratio + .attr("preserveAspectRatio", "xMidYMid meet") + .attr("class", "w-full h-full"); + + const pie = d3 + .pie() + .sortValues(null) // Preserve order of segments + .value((d) => d.amount); + + const mainArc = d3 + .arc() + .innerRadius(this.#viewBoxSize / 2 - this.segmentHeightValue) + .outerRadius(this.#viewBoxSize / 2) + .cornerRadius(this.segmentHeightValue) + .padAngle(this.#minSegmentAngle); + + const segmentArcs = svg + .append("g") + .attr( + "transform", + `translate(${this.#viewBoxSize / 2}, ${this.#viewBoxSize / 2})`, + ) + .selectAll("arc") + .data(pie(this.#data)) + .enter() + .append("g") + .attr("class", "arc pointer-events-auto") + .append("path") + .attr("data-segment-id", (d) => d.data.id) + .attr("data-original-color", this.#transformRingColor) + .attr("fill", this.#transformRingColor) + .attr("d", mainArc); + + // Ensures that user can click on default content without triggering hover on a segment if that is their intent + let hoverTimeout = null; + + segmentArcs + .on("mouseover", (event) => { + hoverTimeout = setTimeout(() => { + this.#clearSegmentHover(); + this.#handleSegmentHover(event); + }, 150); + }) + .on("mouseleave", () => { + clearTimeout(hoverTimeout); + }); + } + + #transformRingColor = ({ data: { id, color } }) => { + if (id === this.unusedSegmentIdValue || id === this.overageSegmentIdValue) { + return color; + } + + const reducedOpacityColor = d3.color(color); + reducedOpacityColor.opacity = this.segmentOpacityValue; + return reducedOpacityColor; + }; + + // Highlights segment and shows segment specific content (all other segments are grayed out) + #handleSegmentHover(event) { + const segmentId = event.target.dataset.segmentId; + const template = this.element.querySelector(`#segment_${segmentId}`); + const unusedSegmentId = this.unusedSegmentIdValue; + + if (!template) return; + + d3.select(this.chartContainerTarget) + .selectAll("path") + .attr("fill", function () { + if (this.dataset.segmentId === segmentId) { + if (this.dataset.segmentId === unusedSegmentId) { + return "#A3A3A3"; + } + + return this.dataset.originalColor; + } + + return "#F0F0F0"; + }); + + this.defaultContentTarget.classList.add("hidden"); + template.classList.remove("hidden"); + } + + // Restores original segment colors and hides segment specific content + #clearSegmentHover = () => { + this.defaultContentTarget.classList.remove("hidden"); + + d3.select(this.chartContainerTarget) + .selectAll("path") + .attr("fill", function () { + return this.dataset.originalColor; + }); + + for (const child of this.contentContainerTarget.children) { + if (child !== this.defaultContentTarget) { + child.classList.add("hidden"); + } + } + }; +} diff --git a/app/models/account/data_enricher.rb b/app/models/account/data_enricher.rb index 0be57dc1..924d5894 100644 --- a/app/models/account/data_enricher.rb +++ b/app/models/account/data_enricher.rb @@ -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, diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index b9fd5534..9cbfb32d 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -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 diff --git a/app/models/budget.rb b/app/models/budget.rb new file mode 100644 index 00000000..637ff50b --- /dev/null +++ b/app/models/budget.rb @@ -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 diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb new file mode 100644 index 00000000..a57a3a97 --- /dev/null +++ b/app/models/budget_category.rb @@ -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 diff --git a/app/models/budgeting_stats.rb b/app/models/budgeting_stats.rb new file mode 100644 index 00000000..43fbd80f --- /dev/null +++ b/app/models/budgeting_stats.rb @@ -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 diff --git a/app/models/category.rb b/app/models/category.rb index 8d0c24b6..90d2ce92 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -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 diff --git a/app/models/category_stats.rb b/app/models/category_stats.rb new file mode 100644 index 00000000..631b95ee --- /dev/null +++ b/app/models/category_stats.rb @@ -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 diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 2f65b9da..c008e623 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -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] } diff --git a/app/models/family.rb b/app/models/family.rb index 601692ed..8649cea1 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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 diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 265c516b..3f86fe94 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -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? diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index 6b963050..03b01e1d 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -24,7 +24,7 @@ <% if entry.new_record? %> <%= content_tag :p, entry.display_name %> <% else %> - <%= link_to entry.display_name, + <%= link_to entry.account_transaction.transfer? ? entry.account_transaction.transfer.name : entry.display_name, entry.account_transaction.transfer? ? transfer_path(entry.account_transaction.transfer) : account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> diff --git a/app/views/account/transactions/_transaction_category.html.erb b/app/views/account/transactions/_transaction_category.html.erb index ab0b33c8..5489d310 100644 --- a/app/views/account/transactions/_transaction_category.html.erb +++ b/app/views/account/transactions/_transaction_category.html.erb @@ -1,9 +1,9 @@ <%# locals: (entry:) %>
+ <%= number_to_percentage(budget.allocated_percent, precision: 0) %> set +
+ ++ <%= format_money(budget.allocated_spending_money) %> + / + <%= format_money(budget.budgeted_spending_money) %> +
+> 100% set
+ ++ <%= format_money(budget.allocated_spending_money) %> + / + <%= format_money(budget.budgeted_spending_money) %> +
++ Budget exceeded by <%= format_money(budget.available_to_allocate_money.abs) %> +
+<%= budget_category.category.name %>
+ + <% if budget_category.initialized? %> + <% if budget_category.available_to_spend.negative? %> +<%= format_money(budget_category.available_to_spend_money.abs) %> over
+ <% elsif budget_category.available_to_spend.zero? %> +"> + <%= format_money(budget_category.available_to_spend_money) %> left +
+ <% else %> +<%= format_money(budget_category.available_to_spend_money) %> left
+ <% end %> + <% else %> ++ <%= format_money(budget_category.category.avg_monthly_total) %> avg +
+ <% end %> +<%= format_money(budget_category.actual_spending_money) %>
+ + <% if budget_category.initialized? %> +from <%= format_money(budget_category.budgeted_spending_money) %>
+ <% end %> +<%= budget_category.category.name %>
+ +<%= format_money(budget_category.category.avg_monthly_total, precision: 0) %>/m average
++ You have not created or assigned any expense categories to your transactions yet. +
+ +<%= budget_category.category.name %>
+<%= format_money(budget_category.category.avg_monthly_total, precision: 0) %>/m average
++ Adjust category budgets to set spending limits. Unallocated funds will be automatically assigned as uncategorized. +
+Category
++ + <%= format_money(@budget_category.actual_spending) %> + + / + <%= format_money(@budget_category.budgeted_spending) %> +
+ <% end %> ++ <%= entry.date.strftime("%b %d") %> +
+<%= entry.name %>
++ <%= format_money entry.amount_money %> +
++ No transactions found for this budget period. +
+ <% end %> +Categories
+ · +<%= budget.budget_categories.count %>
+ +Amount
+<%= bc.category.name %>
+"> + <%= format_money(bc.actual_spending_money) %> +
+ + <%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %> + of <%= format_money(bc.budgeted_spending_money, precision: 0) %> + + <%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 shrink-0" %> + <% end %> +Unused
+ ++ <%= format_money(budget.available_to_spend_money) %> +
+<%= format_money(budget.actual_income_money) %> earned
++ <% if budget.remaining_expected_income.negative? %> + <%= format_money(budget.remaining_expected_income.abs) %> over + <% else %> + <%= format_money(budget.remaining_expected_income) %> left + <% end %> +
+<%= format_money(budget.actual_spending_money) %> spent
++ <% if budget.available_to_spend.negative? %> + <%= format_money(budget.available_to_spend_money.abs) %> over + <% else %> + <%= format_money(budget.available_to_spend_money) %> left + <% end %> +
+You have over-allocated your budget. Please fix your allocations.
+ + <%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %> + + Fix allocations + + <%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 hover:text-gray-600" %> + <% end %> ++ Enter your monthly earnings and planned spending below to setup your budget. +
++ This will be based on transaction history. AI can make mistakes, verify before continuing. +
+<%= title %>
+ · +<%= categories.count %>
+<%= t(".categories") %>
- · -<%= @categories.count %>
-Match transfer/payment
+Match transfer/payment
+ <% end %> <% end %>