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:) %>
"> - <% if entry.account_transaction.transfer? %> - <%= render "categories/badge", category: entry.account_transaction.transfer.payment? ? payment_category : transfer_category %> - <% else %> + <% if entry.account_transaction.transfer&.categorizable? || entry.account_transaction.transfer.nil? %> <%= render "categories/menu", transaction: entry.account_transaction %> + <% else %> + <%= render "categories/badge", category: entry.account_transaction.transfer&.payment? ? payment_category : transfer_category %> <% end %>
diff --git a/app/views/account/valuations/_valuation.html.erb b/app/views/account/valuations/_valuation.html.erb index fb74b750..a98d0ced 100644 --- a/app/views/account/valuations/_valuation.html.erb +++ b/app/views/account/valuations/_valuation.html.erb @@ -13,7 +13,7 @@
<%= tag.div class: "w-6 h-6 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %> - <%= lucide_icon icon, class: "w-4 h-4" %> + <%= lucide_icon icon, class: "w-4 h-4 shrink-0" %> <% end %>
diff --git a/app/views/budget_categories/_allocation_progress.erb b/app/views/budget_categories/_allocation_progress.erb new file mode 100644 index 00000000..56077ee9 --- /dev/null +++ b/app/views/budget_categories/_allocation_progress.erb @@ -0,0 +1,26 @@ +<%# locals: (budget:) %> + +
+
+
">
+ +

+ <%= number_to_percentage(budget.allocated_percent, precision: 0) %> set +

+ +

+ <%= format_money(budget.allocated_spending_money) %> + / + <%= format_money(budget.budgeted_spending_money) %> +

+
+ +
+
+
+ +
+ <%= format_money(budget.available_to_allocate_money) %> + left to allocate +
+
diff --git a/app/views/budget_categories/_allocation_progress_overage.html.erb b/app/views/budget_categories/_allocation_progress_overage.html.erb new file mode 100644 index 00000000..eaa485ae --- /dev/null +++ b/app/views/budget_categories/_allocation_progress_overage.html.erb @@ -0,0 +1,25 @@ +<%# locals: (budget:) %> + +
+
+
+ +

> 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) %> +

+
+
diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb new file mode 100644 index 00000000..e6c0e4cb --- /dev/null +++ b/app/views/budget_categories/_budget_category.html.erb @@ -0,0 +1,48 @@ +<%# locals: (budget_category:) %> + +<%= turbo_frame_tag dom_id(budget_category), class: "w-full" do %> + <%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group w-full p-4 flex items-center gap-3 bg-white", data: { turbo_frame: "drawer" } do %> + + <% if budget_category.initialized? %> +
+ <%= render "budget_categories/budget_category_donut", budget_category: budget_category %> +
+ <% else %> +
+ <% if budget_category.category.lucide_icon %> + <%= icon(budget_category.category.lucide_icon) %> + <% else %> + <%= render "shared/circle_logo", name: budget_category.category.name, hex: budget_category.category.color %> + <% end %> +
+ <% end %> + +
+

<%= 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 %> +
+ <% end %> +<% end %> diff --git a/app/views/budget_categories/_budget_category_donut.html.erb b/app/views/budget_categories/_budget_category_donut.html.erb new file mode 100644 index 00000000..517135fe --- /dev/null +++ b/app/views/budget_categories/_budget_category_donut.html.erb @@ -0,0 +1,22 @@ +<%# locals: (budget_category:) %> + +<%= tag.div data: { + controller: "donut-chart", + donut_chart_segments_value: budget_category.to_donut_segments_json, + donut_chart_segment_height_value: 5, + donut_chart_segment_opacity_value: 0.2 +}, class: "relative h-full" do %> +
+ +
+
+ <% if budget_category.category.lucide_icon %> + <%= lucide_icon budget_category.category.lucide_icon, class: "w-4 h-4 shrink-0", style: "color: #{budget_category.category.color}" %> + <% else %> + + <%= budget_category.category.name.first.upcase %> + + <% end %> +
+
+<% end %> diff --git a/app/views/budget_categories/_budget_category_form.html.erb b/app/views/budget_categories/_budget_category_form.html.erb new file mode 100644 index 00000000..9b7a1cad --- /dev/null +++ b/app/views/budget_categories/_budget_category_form.html.erb @@ -0,0 +1,29 @@ +<%# locals: (budget_category:) %> + +<% currency = Money::Currency.new(budget_category.budget.currency) %> + +
+
+ +
+

<%= budget_category.category.name %>

+ +

<%= format_money(budget_category.category.avg_monthly_total, precision: 0) %>/m average

+
+ +
+ <%= form_with model: [budget_category.budget, budget_category], data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "blur", turbo_frame: :_top } do |f| %> +
+
+ <%= currency.symbol %> + <%= f.number_field :budgeted_spending, + class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", + placeholder: "0", + step: currency.step, + min: 0, + data: { auto_submit_form_target: "auto" } %> +
+
+ <% end %> +
+
diff --git a/app/views/budget_categories/_no_categories.html.erb b/app/views/budget_categories/_no_categories.html.erb new file mode 100644 index 00000000..aaa0a865 --- /dev/null +++ b/app/views/budget_categories/_no_categories.html.erb @@ -0,0 +1,17 @@ +
+
+

Oops!

+

+ You have not created or assigned any expense categories to your transactions yet. +

+ +
+ <%= button_to "Use default categories", bootstrap_categories_path, class: "btn btn--primary" %> + + <%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %> + <%= lucide_icon("plus", class: "w-5 h-5") %> + New category + <% end %> +
+
+
diff --git a/app/views/budget_categories/_uncategorized_budget_category_form.html.erb b/app/views/budget_categories/_uncategorized_budget_category_form.html.erb new file mode 100644 index 00000000..da2b6997 --- /dev/null +++ b/app/views/budget_categories/_uncategorized_budget_category_form.html.erb @@ -0,0 +1,21 @@ +<%# locals: (budget:) %> + +<% budget_category = budget.uncategorized_budget_category %> + +
+
+ +
+

<%= budget_category.category.name %>

+

<%= format_money(budget_category.category.avg_monthly_total, precision: 0) %>/m average

+
+ +
+
+
+ $ + <%= text_field_tag :uncategorized, budget_category.budgeted_spending, autocomplete: "off", class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", disabled: true %> +
+
+
+
diff --git a/app/views/budget_categories/index.html.erb b/app/views/budget_categories/index.html.erb new file mode 100644 index 00000000..5adee7c6 --- /dev/null +++ b/app/views/budget_categories/index.html.erb @@ -0,0 +1,65 @@ +<%= content_for :header_nav do %> + <%= render "budgets/budget_nav", budget: @budget %> +<% end %> + +<%= content_for :previous_path, edit_budget_path(@budget) %> +<%= content_for :cancel_path, budget_path(@budget) %> + +
+
+
+

Edit your category budgets

+

+ Adjust category budgets to set spending limits. Unallocated funds will be automatically assigned as uncategorized. +

+
+ +
+ <% if @budget.family.categories.empty? %> +
+ <%= render "budget_categories/no_categories" %> +
+ <% else %> +
+ <% if @budget.available_to_allocate.negative? %> + <%= render "budget_categories/allocation_progress_overage", budget: @budget %> + <% else %> + <%= render "budget_categories/allocation_progress", budget: @budget %> + <% end %> + +
+ <% BudgetCategory::Group.for(@budget.budget_categories).sort_by(&:name).each do |group| %> +
+ <%= render "budget_categories/budget_category_form", budget_category: group.budget_category %> + +
+ <% group.budget_subcategories.each do |budget_subcategory| %> +
+
+ <%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %> +
+ + <%= render "budget_categories/budget_category_form", budget_category: budget_subcategory %> +
+ <% end %> +
+
+ <% end %> + + <%= render "budget_categories/uncategorized_budget_category_form", budget: @budget %> +
+ + <% if @budget.allocations_valid? %> + <%= link_to "Confirm", + budget_path(@budget), + class: "block btn btn--primary w-full text-center" %> + <% else %> + + Confirm + + <% end %> +
+ <% end %> +
+
+
diff --git a/app/views/budget_categories/show.html.erb b/app/views/budget_categories/show.html.erb new file mode 100644 index 00000000..e9d4b759 --- /dev/null +++ b/app/views/budget_categories/show.html.erb @@ -0,0 +1,150 @@ +<%= drawer do %> +
+
+
+

Category

+

+ <%= @budget_category.category.name %> +

+ + <% if @budget_category.budget.initialized? %> +

+ + <%= format_money(@budget_category.actual_spending) %> + + / + <%= format_money(@budget_category.budgeted_spending) %> +

+ <% end %> +
+ + <% if @budget_category.budget.initialized? %> +
+ <%= render "budget_categories/budget_category_donut", + budget_category: @budget_category %> +
+ <% end %> +
+ +
+ +

Overview

+ <%= lucide_icon "chevron-down", + class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+
+
+
+ <%= @budget_category.budget.start_date.strftime("%b %Y") %> spending +
+
+ <%= format_money @budget_category.actual_spending_money %> +
+
+ + <% if @budget_category.budget.initialized? %> +
+
Status
+ <% if @budget_category.available_to_spend.negative? %> +
+ <%= lucide_icon "alert-circle", class: "shrink-0 w-4 h-4 text-red-500" %> + <%= format_money @budget_category.available_to_spend_money.abs %> + overspent +
+ <% elsif @budget_category.available_to_spend.zero? %> +
+ <%= lucide_icon "x-circle", class: "shrink-0 w-4 h-4 text-orange-500" %> + <%= format_money @budget_category.available_to_spend_money %> + left +
+ <% else %> +
+ <%= lucide_icon "check-circle-2", class: "shrink-0 w-4 h-4 text-green-500" %> + <%= format_money @budget_category.available_to_spend_money %> + left +
+ <% end %> +
+ +
+
Budgeted
+
+ <%= format_money @budget_category.budgeted_spending %> +
+
+ <% end %> + +
+
Monthly average spending
+
+ <%= format_money @budget_category.category.avg_monthly_total %> +
+
+ +
+
Monthly median spending
+
+ <%= format_money @budget_category.category.median_monthly_total %> +
+
+
+
+
+ +
+ +

Recent Transactions

+ <%= lucide_icon "chevron-down", + class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %> +
+ +
+
+ <% if @recent_transactions.any? %> +
    + <% @recent_transactions.each_with_index do |entry, index| %> +
  • +
    +
    + <% unless index == @recent_transactions.length - 1 %> +
    + <% end %> +
    + +
    +
    +

    + <%= entry.date.strftime("%b %d") %> +

    +

    <%= entry.name %>

    +
    +

    + <%= format_money entry.amount_money %> +

    +
    +
  • + <% end %> +
+ + <%= link_to "View all category transactions", + transactions_path(q: { + categories: [@budget_category.category.name], + start_date: @budget.start_date, + end_date: @budget.end_date + }), + data: { turbo_frame: :_top }, + class: "block text-center btn btn--outline w-full" %> + <% else %> +

+ No transactions found for this budget period. +

+ <% end %> +
+
+
+
+<% end %> diff --git a/app/views/budgets/_actuals_summary.html.erb b/app/views/budgets/_actuals_summary.html.erb new file mode 100644 index 00000000..0cd5acf8 --- /dev/null +++ b/app/views/budgets/_actuals_summary.html.erb @@ -0,0 +1,62 @@ +<%# locals: (budget:) %> + +
+
+

Income

+ + <% income_totals = budget.income_categories_with_totals %> + <% income_categories = income_totals.category_totals.reject { |ct| ct.amount_money.zero? }.sort_by { |ct| ct.percentage }.reverse %> + + <%= format_money(income_totals.total_money) %> + + + <% if income_categories.any? %> +
+
+ <% income_categories.each do |item| %> +
+ <% end %> +
+ +
+ <% income_categories.each do |item| %> +
+
+ <%= item.category.name %> + <%= number_to_percentage(item.percentage, precision: 0) %> +
+ <% end %> +
+
+ <% end %> +
+ +
+

Expenses

+ + <% expense_totals = budget.expense_categories_with_totals %> + <% expense_categories = expense_totals.category_totals.reject { |ct| ct.amount_money.zero? || ct.category.subcategory? }.sort_by { |ct| ct.percentage }.reverse %> + + <%= format_money(expense_totals.total_money) %> + + <% if expense_categories.any? %> +
+
+ <% expense_categories.each do |item| %> +
+ <% end %> +
+ +
+ <% expense_categories.each do |item| %> +
+
+ <%= item.category.name %> + <%= number_to_percentage(item.percentage, precision: 0) %> +
+ <% end %> +
+
+ <% end %> +
+
diff --git a/app/views/budgets/_budget_categories.html.erb b/app/views/budgets/_budget_categories.html.erb new file mode 100644 index 00000000..86cc3012 --- /dev/null +++ b/app/views/budgets/_budget_categories.html.erb @@ -0,0 +1,45 @@ +<%# locals: (budget:) %> + +
+
+

Categories

+ · +

<%= budget.budget_categories.count %>

+ +

Amount

+
+ +
+ <% if budget.family.categories.expenses.empty? %> +
+ <%= render "budget_categories/no_categories" %> +
+ <% else %> + <% category_groups = BudgetCategory::Group.for(budget.budget_categories) %> + + <% category_groups.each_with_index do |group, index| %> +
+ <%= render "budget_categories/budget_category", budget_category: group.budget_category %> + +
+ <% group.budget_subcategories.each do |budget_subcategory| %> +
+
+ <%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %> +
+ + <%= render "budget_categories/budget_category", budget_category: budget_subcategory %> +
+ <% end %> +
+
+ +
+
+
+ <% end %> + + <%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %> + <% end %> +
+
diff --git a/app/views/budgets/_budget_donut.html.erb b/app/views/budgets/_budget_donut.html.erb new file mode 100644 index 00000000..2dc2fd72 --- /dev/null +++ b/app/views/budgets/_budget_donut.html.erb @@ -0,0 +1,61 @@ +<%= tag.div data: { controller: "donut-chart", donut_chart_segments_value: budget.to_donut_segments_json }, class: "relative h-full" do %> +
+ +
+
+ <% if budget.initialized? %> +
+ Spent +
+ +
"> + <%= format_money(budget.actual_spending) %> +
+ + <%= link_to edit_budget_path(budget), class: "btn btn--secondary flex items-center gap-1 mt-2" do %> + + of <%= format_money(budget.budgeted_spending_money) %> + + <%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 hover:text-gray-600" %> + <% end %> + <% else %> +
+ <%= format_money Money.new(0, budget.currency || budget.family.currency) %> +
+ <%= link_to edit_budget_path(budget), class: "flex items-center gap-2 btn btn--primary" do %> + <%= lucide_icon "plus", class: "w-4 h-4 text-white" %> + New budget + <% end %> + <% end %> +
+ + <% budget.budget_categories.each do |bc| %> + + <% end %> + + +
+<% end %> diff --git a/app/views/budgets/_budget_header.html.erb b/app/views/budgets/_budget_header.html.erb new file mode 100644 index 00000000..5dd041dc --- /dev/null +++ b/app/views/budgets/_budget_header.html.erb @@ -0,0 +1,40 @@ +<%# locals: (budget:, previous_budget:, next_budget:, latest_budget:) %> + +
+
+ <% if @previous_budget %> + <%= link_to budget_path(@previous_budget) do %> + <%= lucide_icon "chevron-left" %> + <% end %> + <% else %> + <%= lucide_icon "chevron-left", class: "text-gray-400" %> + <% end %> + + <% if @next_budget %> + <%= link_to budget_path(@next_budget) do %> + <%= lucide_icon "chevron-right" %> + <% end %> + <% else %> + <%= lucide_icon "chevron-right", class: "text-gray-400" %> + <% end %> +
+ +
+ <%= tag.button data: { menu_target: "button" }, class: "flex items-center gap-1 hover:bg-gray-50 rounded-md p-2" do %> + <%= @budget.name %> + <%= lucide_icon "chevron-down", class: "w-5 h-5 shrink-0 text-gray-500" %> + <% end %> + + +
+ +
+ <% if @budget.current? %> + Today + <% else %> + <%= link_to "Today", budget_path(@latest_budget), class: "btn btn--outline" %> + <% end %> +
+
diff --git a/app/views/budgets/_budget_nav.html.erb b/app/views/budgets/_budget_nav.html.erb new file mode 100644 index 00000000..2d336f4a --- /dev/null +++ b/app/views/budgets/_budget_nav.html.erb @@ -0,0 +1,37 @@ +<%# locals: (budget:) %> + +<% steps = [ + { name: "Setup", path: edit_budget_path(budget), is_complete: budget.initialized?, step_number: 1 }, + { name: "Categories", path: budget_budget_categories_path(budget), is_complete: budget.allocations_valid?, step_number: 2 }, +] %> + + diff --git a/app/views/budgets/_budgeted_summary.html.erb b/app/views/budgets/_budgeted_summary.html.erb new file mode 100644 index 00000000..a77f40b3 --- /dev/null +++ b/app/views/budgets/_budgeted_summary.html.erb @@ -0,0 +1,63 @@ +<%# locals: (budget:) %> + +
+
+

Expected income

+ + + <%= format_money(budget.expected_income_money) %> + + +
+
+ <% if budget.remaining_expected_income.negative? %> +
+
+ <% else %> +
+
+ <% end %> +
+
+

<%= 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 %> +

+
+
+
+ +
+

Budgeted

+ + + <%= format_money(budget.budgeted_spending_money) %> + + +
+
+ <% if budget.available_to_spend.negative? %> +
+
+ <% else %> +
+
+ <% 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 %> +

+
+
+
+
diff --git a/app/views/budgets/_over_allocation_warning.html.erb b/app/views/budgets/_over_allocation_warning.html.erb new file mode 100644 index 00000000..9999038c --- /dev/null +++ b/app/views/budgets/_over_allocation_warning.html.erb @@ -0,0 +1,13 @@ +<%# locals: (budget:) %> + +
+ <%= lucide_icon "alert-triangle", class: "w-6 h-6 text-red-500" %> +

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 %> +
diff --git a/app/views/budgets/_picker.html.erb b/app/views/budgets/_picker.html.erb new file mode 100644 index 00000000..53ad6c76 --- /dev/null +++ b/app/views/budgets/_picker.html.erb @@ -0,0 +1,49 @@ +<%# locals: (family:, year:) %> + +<%= turbo_frame_tag "budget_picker" do %> +
+
+ <% if year > family.oldest_entry_date.year %> + <%= link_to picker_budgets_path(year: year - 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %> + <%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-gray-500" %> + <% end %> + <% else %> + + <%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-gray-400" %> + + <% end %> + + + <%= year %> + + + <% if year < Date.current.year %> + <%= link_to picker_budgets_path(year: year + 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %> + <%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-gray-500" %> + <% end %> + <% else %> + + <%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-gray-400" %> + + <% end %> +
+ +
+ <% Date::ABBR_MONTHNAMES.compact.each_with_index do |month_name, index| %> + <% month_number = index + 1 %> + <% start_date = Date.new(year, month_number) %> + <% budget = family.budgets.for_date(start_date) %> + + <% if budget %> + <%= link_to month_name, budget_path(budget), data: { turbo_frame: "_top" }, class: "block px-3 py-2 text-sm text-gray-900 hover:bg-gray-100 rounded-md" %> + <% elsif start_date >= family.oldest_entry_date.beginning_of_month && start_date <= Date.current %> + <%= button_to budgets_path(budget: { start_date: start_date }), data: { turbo_frame: "_top" }, class: "block w-full px-3 py-2 text-gray-900 hover:bg-gray-100 rounded-md" do %> + <%= month_name %> + <% end %> + <% else %> + <%= month_name %> + <% end %> + <% end %> +
+
+<% end %> diff --git a/app/views/budgets/edit.html.erb b/app/views/budgets/edit.html.erb new file mode 100644 index 00000000..c43acc5b --- /dev/null +++ b/app/views/budgets/edit.html.erb @@ -0,0 +1,47 @@ +<%= content_for :header_nav do %> + <%= render "budgets/budget_nav", budget: @budget %> +<% end %> + +<%= content_for :previous_path, budget_path(@budget) %> +<%= content_for :cancel_path, budget_path(@budget) %> + +
+
+
+

Setup your budget

+

+ Enter your monthly earnings and planned spending below to setup your budget. +

+
+ +
+ <%= styled_form_with model: @budget, class: "space-y-3", data: { controller: "budget-form" } do |f| %> + <%= f.money_field :budgeted_spending, label: "Budgeted spending", required: true, disable_currency: true %> + <%= f.money_field :expected_income, label: "Expected income", required: true, disable_currency: true %> + + <% if @budget.estimated_income && @budget.estimated_spending %> +
+ <%= lucide_icon "sparkles", class: "w-5 h-5 text-gray-500 shrink-0" %> +
+

Autosuggest income & spending budget

+

+ This will be based on transaction history. AI can make mistakes, verify before continuing. +

+
+ +
+ <%= check_box_tag :auto_fill, "1", params[:auto_fill].present?, class: "sr-only peer", data: { + action: "change->budget-form#toggleAutoFill", + budget_form_income_param: { key: "budget_expected_income", value: @budget.estimated_income }, + budget_form_spending_param: { key: "budget_budgeted_spending", value: @budget.estimated_spending } + } %> + +
+
+ <% end %> + + <%= f.submit "Continue", class: "btn btn--primary w-full" %> + <% end %> +
+
+
diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb new file mode 100644 index 00000000..f9734976 --- /dev/null +++ b/app/views/budgets/show.html.erb @@ -0,0 +1,67 @@ +
+ <%= render "budgets/budget_header", + budget: @budget, + previous_budget: @previous_budget, + next_budget: @next_budget, + latest_budget: @latest_budget %> + +
+
+
+ <% if @budget.available_to_allocate.negative? %> + <%= render "budgets/over_allocation_warning", budget: @budget %> + <% else %> + <%= render "budgets/budget_donut", budget: @budget %> + <% end %> +
+ +
+ <% if @budget.initialized? && @budget.available_to_allocate.positive? %> +
+ <% base_classes = "rounded-md px-2 py-1 flex-1 text-center" %> + <% selected_tab = params[:tab].presence || "budgeted" %> + + <%= link_to "Budgeted", + budget_path(@budget, tab: "budgeted"), + class: class_names( + base_classes, + "bg-white shadow-xs text-gray-900": selected_tab == "budgeted", + "text-gray-500": selected_tab != "budgeted" + ) %> + + <%= link_to "Actual", + budget_path(@budget, tab: "actuals"), + class: class_names( + base_classes, + "bg-white shadow-xs text-gray-900": selected_tab == "actuals", + "text-gray-500": selected_tab != "actuals" + ) %> +
+ +
+ <%= render selected_tab == "budgeted" ? "budgets/budgeted_summary" : "budgets/actuals_summary", budget: @budget %> +
+ <% else %> +
+ <%= render "budgets/actuals_summary", budget: @budget %> +
+ <% end %> +
+
+ +
+
+

Categories

+ + <%= link_to budget_budget_categories_path(@budget), class: "btn btn--secondary flex items-center gap-2" do %> + <%= icon "settings-2", color: "gray" %> + Edit + <% end %> +
+ +
+ <%= render "budgets/budget_categories", budget: @budget %> +
+
+
+
diff --git a/app/views/categories/_badge.html.erb b/app/views/categories/_badge.html.erb index b6c6480c..67a42251 100644 --- a/app/views/categories/_badge.html.erb +++ b/app/views/categories/_badge.html.erb @@ -1,5 +1,5 @@ <%# locals: (category:) %> -<% category ||= null_category %> +<% category ||= Category.uncategorized %>
5%, white); border-color: color-mix(in srgb, <%= category.color %> 30%, white); color: <%= category.color %>;"> + <% if category.lucide_icon.present? %> + <%= lucide_icon category.lucide_icon, class: "w-4 h-4 shrink-0" %> + <% end %> <%= category.name %> diff --git a/app/views/categories/_category_list_group.html.erb b/app/views/categories/_category_list_group.html.erb new file mode 100644 index 00000000..671e10dd --- /dev/null +++ b/app/views/categories/_category_list_group.html.erb @@ -0,0 +1,25 @@ +<%# locals: (title:, categories:) %> + +
+
+

<%= title %>

+ · +

<%= categories.count %>

+
+ +
+
+ <% Category::Group.for(categories).each_with_index do |group, idx| %> + <%= render group.category %> + + <% group.subcategories.each do |subcategory| %> + <%= render subcategory %> + <% end %> + + <% unless idx == Category::Group.for(categories).count - 1 %> + <%= render "categories/ruler" %> + <% end %> + <% end %> +
+
+
diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 2bca2191..d0cda832 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -1,7 +1,7 @@ <%# locals: (category:, categories:) %>
- <%= styled_form_with model: category, class: "space-y-4", data: { turbo_frame: :_top } do |f| %> + <%= styled_form_with model: category, class: "space-y-4" do |f| %>
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %> @@ -20,7 +20,19 @@ <%= render "shared/form_errors", model: category %> <% end %> +
+ <% Category.icon_codes.each do |icon| %> + + <% end %> +
+
+ <%= f.select :classification, [["Income", "income"], ["Expense", "expense"]], { label: "Classification" }, required: true %> <%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %> <%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" } %>
diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb index c0cf8440..64e6acb9 100644 --- a/app/views/categories/index.html.erb +++ b/app/views/categories/index.html.erb @@ -14,28 +14,14 @@
<% if @categories.any? %> -
-
-

<%= t(".categories") %>

- · -

<%= @categories.count %>

-
+
+ <% if @categories.incomes.any? %> + <%= render "categories/category_list_group", title: t(".categories_incomes"), categories: @categories.incomes %> + <% end %> -
-
- <% Category::Group.for(@categories).each_with_index do |group, idx| %> - <%= render group.category %> - - <% group.subcategories.each do |subcategory| %> - <%= render subcategory %> - <% end %> - - <% unless idx == Category::Group.for(@categories).count - 1 %> - <%= render "categories/ruler" %> - <% end %> - <% end %> -
-
+ <% if @categories.expenses.any? %> + <%= render "categories/category_list_group", title: t(".categories_expenses"), categories: @categories.expenses %> + <% end %>
<% else %>
diff --git a/app/views/category/dropdowns/show.html.erb b/app/views/category/dropdowns/show.html.erb index 6df124ee..4a0047cb 100644 --- a/app/views/category/dropdowns/show.html.erb +++ b/app/views/category/dropdowns/show.html.erb @@ -41,12 +41,14 @@ <% end %> <% end %> - <%= link_to new_account_transaction_transfer_match_path(@transaction.entry), + <% unless @transaction.transfer? %> + <%= link_to new_account_transaction_transfer_match_path(@transaction.entry), class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100", data: { turbo_frame: "modal" } do %> - <%= lucide_icon "refresh-cw", class: "w-5 h-5" %> + <%= lucide_icon "refresh-cw", class: "w-5 h-5" %> -

Match transfer/payment

+

Match transfer/payment

+ <% end %> <% end %>
diff --git a/app/views/layouts/_sidebar.html.erb b/app/views/layouts/_sidebar.html.erb index ff168f2d..42ac543d 100644 --- a/app/views/layouts/_sidebar.html.erb +++ b/app/views/layouts/_sidebar.html.erb @@ -81,6 +81,9 @@
  • <%= sidebar_link_to t(".transactions"), transactions_path, icon: "credit-card" %>
  • +
  • + <%= sidebar_link_to t(".budgeting"), budgets_path, icon: "map" %> +
  • diff --git a/app/views/layouts/wizard.html.erb b/app/views/layouts/wizard.html.erb new file mode 100644 index 00000000..fc540a71 --- /dev/null +++ b/app/views/layouts/wizard.html.erb @@ -0,0 +1,23 @@ +<%= content_for :content do %> +
    +
    + <%= link_to content_for(:previous_path) || root_path do %> + <%= lucide_icon "arrow-left", class: "w-5 h-5 text-gray-500" %> + <% end %> + + + + <%= link_to content_for(:cancel_path) || root_path do %> + <%= lucide_icon "x", class: "text-gray-500 w-5 h-5" %> + <% end %> +
    + +
    + <%= yield %> +
    +
    +<% end %> + +<%= render template: "layouts/application" %> diff --git a/app/views/shared/_icon.html.erb b/app/views/shared/_icon.html.erb new file mode 100644 index 00000000..0e475933 --- /dev/null +++ b/app/views/shared/_icon.html.erb @@ -0,0 +1,6 @@ +<%# locals: (key:, size: "md", color: "current") %> + +<% size_class = case size when "sm" then "w-4 h-4" when "md" then "w-5 h-5" when "lg" then "w-6 h-6" end %> +<% color_class = case color when "current" then "text-current" when "gray" then "text-gray-500" end %> + +<%= lucide_icon key, class: class_names(size_class, color_class, "shrink-0") %> diff --git a/app/views/transfers/_transfer.html.erb b/app/views/transfers/_transfer.html.erb index 05b8db3b..ad0527db 100644 --- a/app/views/transfers/_transfer.html.erb +++ b/app/views/transfers/_transfer.html.erb @@ -52,7 +52,11 @@
    <%= link_to transfer.from_account.name, transfer.from_account, class: "hover:underline", data: { turbo_frame: "_top" } %> - <%= lucide_icon "arrow-left-right", class: "w-4 h-4" %> + <% if transfer.payment? %> + <%= lucide_icon "arrow-right", class: "w-4 h-4" %> + <% else %> + <%= lucide_icon "arrow-left-right", class: "w-4 h-4" %> + <% end %> <%= link_to transfer.to_account.name, transfer.to_account, class: "hover:underline", data: { turbo_frame: "_top" } %>
    @@ -63,7 +67,11 @@
    - <%= render "categories/badge", category: transfer.payment? ? payment_category : transfer_category %> + <% if transfer.categorizable? %> + <%= render "account/transactions/transaction_category", entry: transfer.outflow_transaction.entry %> + <% else %> + <%= render "categories/badge", category: transfer.payment? ? payment_category : transfer_category %> + <% end %>
    diff --git a/app/views/transfers/show.html.erb b/app/views/transfers/show.html.erb index f8307997..393c8ccf 100644 --- a/app/views/transfers/show.html.erb +++ b/app/views/transfers/show.html.erb @@ -70,7 +70,11 @@ <%= disclosure t(".details") do %> <%= styled_form_with model: @transfer, - data: { controller: "auto-submit-form" } do |f| %> + data: { controller: "auto-submit-form" }, class: "space-y-2" do |f| %> + <% if @transfer.categorizable? %> + <%= f.collection_select :category_id, @categories.alphabetically, :id, :name, { label: "Category", include_blank: "Uncategorized", selected: @transfer.outflow_transaction.category&.id }, "data-auto-submit-form-target": "auto" %> + <% end %> + <%= f.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), diff --git a/config/locales/views/categories/en.yml b/config/locales/views/categories/en.yml index 1d6aeb6e..9ff612e9 100644 --- a/config/locales/views/categories/en.yml +++ b/config/locales/views/categories/en.yml @@ -15,8 +15,10 @@ en: form: placeholder: Category name index: - bootstrap: Use default categories categories: Categories + bootstrap: Use default categories + categories_incomes: Income categories + categories_expenses: Expense categories empty: No categories found new: New category menu: diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml index 8adc4792..9054ee29 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -19,3 +19,4 @@ en: new_account: New account portfolio: Portfolio transactions: Transactions + budgeting: Budgeting diff --git a/config/routes.rb b/config/routes.rb index 440756ba..d49ca187 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,12 +38,18 @@ Rails.application.routes.draw do resource :dropdown, only: :show end - resources :categories do + resources :categories, except: :show do resources :deletions, only: %i[new create], module: :category post :bootstrap, on: :collection end + resources :budgets, only: %i[index show edit update create] do + get :picker, on: :collection + + resources :budget_categories, only: %i[index show update] + end + resources :merchants, only: %i[index new create edit update destroy] resources :transfers, only: %i[new create destroy show update] diff --git a/db/migrate/20250108182147_create_budgets.rb b/db/migrate/20250108182147_create_budgets.rb new file mode 100644 index 00000000..72a0f046 --- /dev/null +++ b/db/migrate/20250108182147_create_budgets.rb @@ -0,0 +1,15 @@ +class CreateBudgets < ActiveRecord::Migration[7.2] + def change + create_table :budgets, id: :uuid do |t| + t.references :family, null: false, foreign_key: true, type: :uuid + t.date :start_date, null: false + t.date :end_date, null: false + t.decimal :budgeted_spending, precision: 19, scale: 4 + t.decimal :expected_income, precision: 19, scale: 4 + t.string :currency, null: false + t.timestamps + end + + add_index :budgets, %i[family_id start_date end_date], unique: true + end +end diff --git a/db/migrate/20250108200055_create_budget_categories.rb b/db/migrate/20250108200055_create_budget_categories.rb new file mode 100644 index 00000000..d248884f --- /dev/null +++ b/db/migrate/20250108200055_create_budget_categories.rb @@ -0,0 +1,13 @@ +class CreateBudgetCategories < ActiveRecord::Migration[7.2] + def change + create_table :budget_categories, id: :uuid do |t| + t.references :budget, null: false, foreign_key: true, type: :uuid + t.references :category, null: false, foreign_key: true, type: :uuid + t.decimal :budgeted_spending, null: false, precision: 19, scale: 4 + t.string :currency, null: false + t.timestamps + end + + add_index :budget_categories, %i[budget_id category_id], unique: true + end +end diff --git a/db/migrate/20250110012347_category_classification.rb b/db/migrate/20250110012347_category_classification.rb new file mode 100644 index 00000000..a204bc42 --- /dev/null +++ b/db/migrate/20250110012347_category_classification.rb @@ -0,0 +1,17 @@ +class CategoryClassification < ActiveRecord::Migration[7.2] + def change + add_column :categories, :classification, :string, null: false, default: "expense" + add_column :categories, :lucide_icon, :string + + # Attempt to update existing user categories that are likely to be income + reversible do |dir| + dir.up do + execute <<-SQL + UPDATE categories + SET classification = 'income' + WHERE lower(name) in ('income', 'incomes', 'other income', 'other incomes'); + SQL + end + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 7c333cc3..dffd8081 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_12_31_140709) do +ActiveRecord::Schema[7.2].define(version: 2025_01_10_012347) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -160,6 +160,31 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_31_140709) do t.index ["addressable_type", "addressable_id"], name: "index_addresses_on_addressable" end + create_table "budget_categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "budget_id", null: false + t.uuid "category_id", null: false + t.decimal "budgeted_spending", precision: 19, scale: 4, null: false + t.string "currency", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["budget_id", "category_id"], name: "index_budget_categories_on_budget_id_and_category_id", unique: true + t.index ["budget_id"], name: "index_budget_categories_on_budget_id" + t.index ["category_id"], name: "index_budget_categories_on_category_id" + end + + create_table "budgets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "family_id", null: false + t.date "start_date", null: false + t.date "end_date", null: false + t.decimal "budgeted_spending", precision: 19, scale: 4 + t.decimal "expected_income", precision: 19, scale: 4 + t.string "currency", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["family_id", "start_date", "end_date"], name: "index_budgets_on_family_id_and_start_date_and_end_date", unique: true + t.index ["family_id"], name: "index_budgets_on_family_id" + end + create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name", null: false t.string "color", default: "#6172F3", null: false @@ -167,6 +192,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_31_140709) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.uuid "parent_id" + t.string "classification", default: "expense", null: false + t.string "lucide_icon" t.index ["family_id"], name: "index_categories_on_family_id" end @@ -650,6 +677,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_31_140709) do add_foreign_key "accounts", "plaid_accounts" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "budget_categories", "budgets" + add_foreign_key "budget_categories", "categories" + add_foreign_key "budgets", "families" add_foreign_key "categories", "families" add_foreign_key "impersonation_session_logs", "impersonation_sessions" add_foreign_key "impersonation_sessions", "users", column: "impersonated_id" diff --git a/test/controllers/account/transactions_controller_test.rb b/test/controllers/account/transactions_controller_test.rb index 1b077eb2..0a754834 100644 --- a/test/controllers/account/transactions_controller_test.rb +++ b/test/controllers/account/transactions_controller_test.rb @@ -74,7 +74,7 @@ class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest end test "can destroy many transactions at once" do - transactions = @user.family.entries.account_transactions.incomes_and_expenses + transactions = @user.family.entries.incomes_and_expenses delete_count = transactions.size assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do diff --git a/test/controllers/categories_controller_test.rb b/test/controllers/categories_controller_test.rb index 22105ba2..07f45b6a 100644 --- a/test/controllers/categories_controller_test.rb +++ b/test/controllers/categories_controller_test.rb @@ -84,7 +84,7 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest end test "bootstrap" do - assert_difference "Category.count", 16 do + assert_difference "Category.count", 10 do post bootstrap_categories_url end diff --git a/test/fixtures/budgets.yml b/test/fixtures/budgets.yml new file mode 100644 index 00000000..e95ef917 --- /dev/null +++ b/test/fixtures/budgets.yml @@ -0,0 +1,7 @@ +one: + family: dylan_family + start_date: <%= Date.current.beginning_of_month %> + end_date: <%= Date.current.end_of_month %> + budgeted_spending: 5000 + expected_income: 7000 + currency: USD