Skip to content

Commit

Permalink
Budgeting V1 (#1609)
Browse files Browse the repository at this point in the history
* Budgeting V1

* Basic UI template

* Fully scaffolded budgeting v1

* Basic working budget

* Finalize donut chart for budgets

* Allow categorization of loan payments for budget

* Include loan payments in incomes_and_expenses scope

* Add budget allocations progress

* Empty states

* Clean up budget methods

* Category aggregation queries

* Handle overage scenarios in form

* Finalize budget donut chart controller

* Passing tests

* Fix allocation naming

* Add income category migration

* Native support for uncategorized budget category

* Formatting

* Fix subcategory sort order, padding

* Fix calculation for category rollups in budget
  • Loading branch information
zachgoll authored Jan 16, 2025
1 parent 413ec6c commit 195ec85
Show file tree
Hide file tree
Showing 61 changed files with 2,045 additions and 141 deletions.
35 changes: 35 additions & 0 deletions app/controllers/budget_categories_controller.rb
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions app/controllers/budgets_controller.rb
Original file line number Diff line number Diff line change
@@ -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
12 changes: 9 additions & 3 deletions app/controllers/categories_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@ def create
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
Expand Down Expand Up @@ -60,6 +66,6 @@ def set_transaction
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
2 changes: 1 addition & 1 deletion app/controllers/transactions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions app/controllers/transfers_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ def new
end

def show
@categories = Current.family.categories.expenses
end

def create
Expand Down Expand Up @@ -37,7 +38,11 @@ def create
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
Expand All @@ -61,6 +66,6 @@ def transfer_params
end

def transfer_update_params
params.require(:transfer).permit(:notes, :status)
params.require(:transfer).permit(:notes, :status, :category_id)
end
end
10 changes: 10 additions & 0 deletions app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ def date_format_options
]
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
Expand Down
18 changes: 7 additions & 11 deletions app/helpers/categories_helper.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,6 +20,6 @@ def trade_category
end

def family_categories
[ null_category ].concat(Current.family.categories.alphabetically)
[ Category.uncategorized ].concat(Current.family.categories.alphabetically)
end
end
25 changes: 25 additions & 0 deletions app/javascript/controllers/budget_form_controller.js
Original file line number Diff line number Diff line change
@@ -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 = "";
}
}
Loading

0 comments on commit 195ec85

Please sign in to comment.