1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00

Budgeting V1 (#1609)

* Budgeting V1

* Basic UI template

* Fully scaffolded budgeting v1

* Basic working budget

* Finalize donut chart for budgets

* Allow categorization of loan payments for budget

* Include loan payments in incomes_and_expenses scope

* Add budget allocations progress

* Empty states

* Clean up budget methods

* Category aggregation queries

* Handle overage scenarios in form

* Finalize budget donut chart controller

* Passing tests

* Fix allocation naming

* Add income category migration

* Native support for uncategorized budget category

* Formatting

* Fix subcategory sort order, padding

* Fix calculation for category rollups in budget
This commit is contained in:
Zach Gollwitzer 2025-01-16 14:36:37 -05:00 committed by GitHub
parent 413ec6cbed
commit 195ec85d96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 2044 additions and 140 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 = "";
}
}

View file

@ -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");
}
}
};
}

View file

@ -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,

View file

@ -17,7 +17,7 @@ class Account::Entry < ApplicationRecord
scope :chronological, -> {
order(
date: :asc,
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
created_at: :asc
)
}
@ -25,18 +25,27 @@ class Account::Entry < ApplicationRecord
scope :reverse_chronological, -> {
order(
date: :desc,
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
created_at: :desc
)
}
# All entries that are not part of a pending/approved transfer (rejected transfers count as normal entries, so are included)
# All non-transfer entries, rejected transfers, and the outflow of a loan payment transfer are incomes/expenses
scope :incomes_and_expenses, -> {
joins(
'LEFT JOIN transfers AS inflow_transfers ON inflow_transfers.inflow_transaction_id = account_entries.entryable_id
LEFT JOIN transfers AS outflow_transfers ON outflow_transfers.outflow_transaction_id = account_entries.entryable_id'
)
.where("(inflow_transfers.id IS NULL AND outflow_transfers.id IS NULL) OR inflow_transfers.status = 'rejected' OR outflow_transfers.status = 'rejected'")
joins("INNER JOIN account_transactions ON account_transactions.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'")
.joins("LEFT JOIN transfers ON transfers.inflow_transaction_id = account_transactions.id OR transfers.outflow_transaction_id = account_transactions.id")
.joins("LEFT JOIN account_transactions inflow_txns ON inflow_txns.id = transfers.inflow_transaction_id")
.joins("LEFT JOIN account_entries inflow_entries ON inflow_entries.entryable_id = inflow_txns.id AND inflow_entries.entryable_type = 'Account::Transaction'")
.joins("LEFT JOIN accounts inflow_accounts ON inflow_accounts.id = inflow_entries.account_id")
.where("transfers.id IS NULL OR transfers.status = 'rejected' OR (account_entries.amount > 0 AND inflow_accounts.accountable_type = 'Loan')")
}
scope :incomes, -> {
incomes_and_expenses.where("account_entries.amount <= 0")
}
scope :expenses, -> {
incomes_and_expenses.where("account_entries.amount > 0")
}
scope :with_converted_amount, ->(currency) {
@ -137,18 +146,16 @@ class Account::Entry < ApplicationRecord
all.size
end
def income_total(currency = "USD")
total = account_transactions.includes(:entryable).incomes_and_expenses
.where("account_entries.amount <= 0")
def income_total(currency = "USD", start_date: nil, end_date: nil)
total = incomes.where(date: start_date..end_date)
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
Money.new(total, currency)
end
def expense_total(currency = "USD")
total = account_transactions.includes(:entryable).incomes_and_expenses
.where("account_entries.amount > 0")
def expense_total(currency = "USD", start_date: nil, end_date: nil)
total = expenses.where(date: start_date..end_date)
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum

181
app/models/budget.rb Normal file
View file

@ -0,0 +1,181 @@
class Budget < ApplicationRecord
include Monetizable
belongs_to :family
has_many :budget_categories, dependent: :destroy
validates :start_date, :end_date, presence: true
validates :start_date, :end_date, uniqueness: { scope: :family_id }
monetize :budgeted_spending, :expected_income, :allocated_spending,
:actual_spending, :available_to_spend, :available_to_allocate,
:estimated_spending, :estimated_income, :actual_income
class << self
def for_date(date)
find_by(start_date: date.beginning_of_month, end_date: date.end_of_month)
end
def find_or_bootstrap(family, date: Date.current)
Budget.transaction do
budget = Budget.find_or_create_by(
family: family,
start_date: date.beginning_of_month,
end_date: date.end_of_month,
currency: family.currency
)
budget.sync_budget_categories
budget
end
end
end
def sync_budget_categories
family.categories.expenses.each do |category|
budget_categories.find_or_create_by(
category: category,
) do |bc|
bc.budgeted_spending = 0
bc.currency = family.currency
end
end
end
def uncategorized_budget_category
budget_categories.uncategorized.tap do |bc|
bc.budgeted_spending = [ available_to_allocate, 0 ].max
bc.currency = family.currency
end
end
def entries
family.entries.incomes_and_expenses.where(date: start_date..end_date)
end
def name
start_date.strftime("%B %Y")
end
def initialized?
budgeted_spending.present?
end
def income_categories_with_totals
family.income_categories_with_totals(date: start_date)
end
def expense_categories_with_totals
family.expense_categories_with_totals(date: start_date)
end
def current?
start_date == Date.today.beginning_of_month && end_date == Date.today.end_of_month
end
def previous_budget
prev_month_end_date = end_date - 1.month
return nil if prev_month_end_date < family.oldest_entry_date
family.budgets.find_or_bootstrap(family, date: prev_month_end_date)
end
def next_budget
return nil if current?
next_start_date = start_date + 1.month
family.budgets.find_or_bootstrap(family, date: next_start_date)
end
def to_donut_segments_json
unused_segment_id = "unused"
# Continuous gray segment for empty budgets
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless allocations_valid?
segments = budget_categories.map do |bc|
{ color: bc.category.color, amount: bc.actual_spending, id: bc.id }
end
if available_to_spend.positive?
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
end
segments
end
# =============================================================================
# Actuals: How much user has spent on each budget category
# =============================================================================
def estimated_spending
family.budgeting_stats.avg_monthly_expenses&.abs
end
def actual_spending
budget_categories.reject(&:subcategory?).sum(&:actual_spending)
end
def available_to_spend
(budgeted_spending || 0) - actual_spending
end
def percent_of_budget_spent
return 0 unless budgeted_spending > 0
(actual_spending / budgeted_spending.to_f) * 100
end
def overage_percent
return 0 unless available_to_spend.negative?
available_to_spend.abs / actual_spending.to_f * 100
end
# =============================================================================
# Budget allocations: How much user has budgeted for all categories combined
# =============================================================================
def allocated_spending
budget_categories.sum(:budgeted_spending)
end
def allocated_percent
return 0 unless budgeted_spending > 0
(allocated_spending / budgeted_spending.to_f) * 100
end
def available_to_allocate
(budgeted_spending || 0) - allocated_spending
end
def allocations_valid?
initialized? && available_to_allocate.positive? && allocated_spending > 0
end
# =============================================================================
# Income: How much user earned relative to what they expected to earn
# =============================================================================
def estimated_income
family.budgeting_stats.avg_monthly_income&.abs
end
def actual_income
family.entries.incomes.where(date: start_date..end_date).sum(:amount).abs
end
def actual_income_percent
return 0 unless expected_income > 0
(actual_income / expected_income.to_f) * 100
end
def remaining_expected_income
expected_income - actual_income
end
def surplus_percent
return 0 unless remaining_expected_income.negative?
remaining_expected_income.abs / expected_income.to_f * 100
end
end

View file

@ -0,0 +1,82 @@
class BudgetCategory < ApplicationRecord
include Monetizable
belongs_to :budget
belongs_to :category
validates :budget_id, uniqueness: { scope: :category_id }
monetize :budgeted_spending, :actual_spending, :available_to_spend
class Group
attr_reader :budget_category, :budget_subcategories
delegate :category, to: :budget_category
delegate :name, :color, to: :category
def self.for(budget_categories)
top_level_categories = budget_categories.select { |budget_category| budget_category.category.parent_id.nil? }
top_level_categories.map do |top_level_category|
subcategories = budget_categories.select { |bc| bc.category.parent_id == top_level_category.category_id && top_level_category.category_id.present? }
new(top_level_category, subcategories.sort_by { |subcategory| subcategory.category.name })
end.sort_by { |group| group.category.name }
end
def initialize(budget_category, budget_subcategories = [])
@budget_category = budget_category
@budget_subcategories = budget_subcategories
end
end
class << self
def uncategorized
new(
id: Digest::UUID.uuid_v5(Digest::UUID::URL_NAMESPACE, "uncategorized"),
category: nil,
)
end
end
def initialized?
budget.initialized?
end
def category
super || budget.family.categories.uncategorized
end
def subcategory?
category.parent_id.present?
end
def actual_spending
category.month_total(date: budget.start_date)
end
def available_to_spend
(budgeted_spending || 0) - actual_spending
end
def percent_of_budget_spent
return 0 unless budgeted_spending > 0
(actual_spending / budgeted_spending) * 100
end
def to_donut_segments_json
unused_segment_id = "unused"
overage_segment_id = "overage"
return [ { color: "#F0F0F0", amount: 1, id: unused_segment_id } ] unless actual_spending > 0
segments = [ { color: category.color, amount: actual_spending, id: id } ]
if available_to_spend.negative?
segments.push({ color: "#EF4444", amount: available_to_spend.abs, id: overage_segment_id })
else
segments.push({ color: "#F0F0F0", amount: available_to_spend, id: unused_segment_id })
end
segments
end
end

View file

@ -0,0 +1,29 @@
class BudgetingStats
attr_reader :family
def initialize(family)
@family = family
end
def avg_monthly_income
income_expense_totals_query(Account::Entry.incomes)
end
def avg_monthly_expenses
income_expense_totals_query(Account::Entry.expenses)
end
private
def income_expense_totals_query(type_scope)
monthly_totals = family.entries
.merge(type_scope)
.select("SUM(account_entries.amount) as total")
.group(Arel.sql("date_trunc('month', account_entries.date)"))
result = Family.select("AVG(mt.total)")
.from(monthly_totals, :mt)
.pick("AVG(mt.total)")
result
end
end

View file

@ -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

View file

@ -0,0 +1,179 @@
class CategoryStats
attr_reader :family
def initialize(family)
@family = family
end
def avg_monthly_total_for(category)
statistics_data[category.id]&.avg || 0
end
def median_monthly_total_for(category)
statistics_data[category.id]&.median || 0
end
def month_total_for(category, date: Date.current)
monthly_totals = totals_data[category.id]
category_total = monthly_totals&.find { |mt| mt.month == date.month && mt.year == date.year }
category_total&.amount || 0
end
def month_category_totals(date: Date.current)
by_classification = Hash.new { |h, k| h[k] = {} }
totals_data.each_with_object(by_classification) do |(category_id, totals), result|
totals.each do |t|
next unless t.month == date.month && t.year == date.year
result[t.classification][category_id] ||= { amount: 0, subcategory: t.subcategory? }
result[t.classification][category_id][:amount] += t.amount.abs
end
end
# Calculate percentages for each group
category_totals = []
[ "income", "expense" ].each do |classification|
totals = by_classification[classification]
# Only include non-subcategory amounts in the total for percentage calculations
total_amount = totals.sum do |_, data|
data[:subcategory] ? 0 : data[:amount]
end
next if total_amount.zero?
totals.each do |category_id, data|
percentage = (data[:amount].to_f / total_amount * 100).round(1)
category_totals << CategoryTotal.new(
category_id: category_id,
amount: data[:amount],
percentage: percentage,
classification: classification,
currency: family.currency,
subcategory?: data[:subcategory]
)
end
end
# Calculate totals based on non-subcategory amounts only
total_income = category_totals
.select { |ct| ct.classification == "income" && !ct.subcategory? }
.sum(&:amount)
total_expense = category_totals
.select { |ct| ct.classification == "expense" && !ct.subcategory? }
.sum(&:amount)
CategoryTotals.new(
total_income: total_income,
total_expense: total_expense,
category_totals: category_totals
)
end
private
Totals = Struct.new(:month, :year, :amount, :classification, :currency, :subcategory?, keyword_init: true)
Stats = Struct.new(:avg, :median, :currency, keyword_init: true)
CategoryTotals = Struct.new(:total_income, :total_expense, :category_totals, keyword_init: true)
CategoryTotal = Struct.new(:category_id, :amount, :percentage, :classification, :currency, :subcategory?, keyword_init: true)
def statistics_data
@statistics_data ||= begin
stats = totals_data.each_with_object({ nil => Stats.new(avg: 0, median: 0) }) do |(category_id, totals), hash|
next if totals.empty?
amounts = totals.map(&:amount)
hash[category_id] = Stats.new(
avg: (amounts.sum.to_f / amounts.size).round,
median: calculate_median(amounts),
currency: family.currency
)
end
end
end
def totals_data
@totals_data ||= begin
totals = monthly_totals_query.each_with_object({ nil => [] }) do |row, hash|
hash[row.category_id] ||= []
existing_total = hash[row.category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
if existing_total
existing_total.amount += row.total.to_i
else
hash[row.category_id] << Totals.new(
month: row.date.month,
year: row.date.year,
amount: row.total.to_i,
classification: row.classification,
currency: family.currency,
subcategory?: row.parent_category_id.present?
)
end
# If category is a parent, its total includes its own transactions + sum(child category transactions)
if row.parent_category_id
hash[row.parent_category_id] ||= []
existing_parent_total = hash[row.parent_category_id].find { |t| t.month == row.date.month && t.year == row.date.year }
if existing_parent_total
existing_parent_total.amount += row.total.to_i
else
hash[row.parent_category_id] << Totals.new(
month: row.date.month,
year: row.date.year,
amount: row.total.to_i,
classification: row.classification,
currency: family.currency,
subcategory?: false
)
end
end
end
# Ensure we have a default empty array for nil category, which represents "Uncategorized"
totals[nil] ||= []
totals
end
end
def monthly_totals_query
income_expense_classification = Arel.sql("
CASE WHEN categories.id IS NULL THEN
CASE WHEN account_entries.amount < 0 THEN 'income' ELSE 'expense' END
ELSE categories.classification
END
")
family.entries
.incomes_and_expenses
.select(
"categories.id as category_id",
"categories.parent_id as parent_category_id",
income_expense_classification,
"date_trunc('month', account_entries.date) as date",
"SUM(account_entries.amount) as total"
)
.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
.group(Arel.sql("categories.id, categories.parent_id, #{income_expense_classification}, date_trunc('month', account_entries.date)"))
.order(Arel.sql("date_trunc('month', account_entries.date) DESC"))
end
def calculate_median(numbers)
return 0 if numbers.empty?
sorted = numbers.sort
mid = sorted.size / 2
if sorted.size.odd?
sorted[mid]
else
((sorted[mid-1] + sorted[mid]) / 2.0).round
end
end
end

View file

@ -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] }

View file

@ -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

View file

@ -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?

View file

@ -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" %>

View file

@ -1,9 +1,9 @@
<%# locals: (entry:) %>
<div id="<%= dom_id(entry, "category_menu") %>">
<% 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 %>
</div>

View file

@ -13,7 +13,7 @@
<div class="flex items-center gap-3">
<%= 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 %>
<div class="truncate text-gray-900">

View file

@ -0,0 +1,26 @@
<%# locals: (budget:) %>
<div class="space-y-2 mb-6">
<div class="flex items-center gap-2">
<div class="rounded-full w-1.5 h-1.5 <%= budget.allocated_spending > 0 ? "bg-gray-900" : "bg-gray-100" %>"></div>
<p class="text-gray-500 text-sm">
<%= number_to_percentage(budget.allocated_percent, precision: 0) %> set
</p>
<p class="ml-auto text-sm space-x-1">
<span class="text-gray-900"><%= format_money(budget.allocated_spending_money) %></span>
<span class="text-gray-500"> / </span>
<span class="text-gray-500"><%= format_money(budget.budgeted_spending_money) %></span>
</p>
</div>
<div class="relative h-1.5 rounded-2xl bg-gray-100">
<div class="absolute inset-0 bg-gray-900 rounded-2xl" style="width: <%= budget.allocated_percent %>%;"></div>
</div>
<div class="text-sm">
<span class="text-gray-900"><%= format_money(budget.available_to_allocate_money) %></span>
<span class="text-gray-500">left to allocate</span>
</div>
</div>

View file

@ -0,0 +1,25 @@
<%# locals: (budget:) %>
<div class="space-y-2 mb-6">
<div class="flex items-center gap-2">
<div class="rounded-full w-1.5 h-1.5 bg-red-500"></div>
<p class="text-gray-900 text-sm">&gt; 100% set</p>
<p class="ml-auto text-sm space-x-1">
<span class="text-red-500"><%= format_money(budget.allocated_spending_money) %></span>
<span class="text-gray-500"> / </span>
<span class="text-gray-500"><%= format_money(budget.budgeted_spending_money) %></span>
</p>
</div>
<div class="relative h-1.5 rounded-2xl bg-gray-100">
<div class="absolute inset-0 bg-red-500 rounded-2xl" style="width: 100%;"></div>
</div>
<div class="text-sm">
<p class="text-gray-500">
Budget exceeded by <span class="text-red-500"><%= format_money(budget.available_to_allocate_money.abs) %></span>
</p>
</div>
</div>

View file

@ -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? %>
<div class="w-10 h-10 group-hover:scale-105 transition-all duration-300">
<%= render "budget_categories/budget_category_donut", budget_category: budget_category %>
</div>
<% else %>
<div class="w-8 h-8 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center" style="<%= mixed_hex_styles(budget_category.category.color) %>">
<% 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 %>
</div>
<% end %>
<div>
<p class="text-sm font-medium text-gray-900"><%= budget_category.category.name %></p>
<% if budget_category.initialized? %>
<% if budget_category.available_to_spend.negative? %>
<p class="text-sm font-medium text-red-500"><%= format_money(budget_category.available_to_spend_money.abs) %> over</p>
<% elsif budget_category.available_to_spend.zero? %>
<p class="text-sm font-medium <%= budget_category.budgeted_spending.positive? ? "text-orange-500" : "text-gray-500" %>">
<%= format_money(budget_category.available_to_spend_money) %> left
</p>
<% else %>
<p class="text-sm text-gray-500 font-medium"><%= format_money(budget_category.available_to_spend_money) %> left</p>
<% end %>
<% else %>
<p class="text-sm text-gray-500 font-medium">
<%= format_money(budget_category.category.avg_monthly_total) %> avg
</p>
<% end %>
</div>
<div class="ml-auto text-right">
<p class="text-sm font-medium text-gray-900"><%= format_money(budget_category.actual_spending_money) %></p>
<% if budget_category.initialized? %>
<p class="text-sm text-gray-500">from <%= format_money(budget_category.budgeted_spending_money) %></p>
<% end %>
</div>
<% end %>
<% end %>

View file

@ -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 %>
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
<div data-donut-chart-target="contentContainer" class="flex justify-center items-center h-full p-1">
<div data-donut-chart-target="defaultContent" class="h-full w-full rounded-full flex flex-col items-center justify-center" style="background-color: <%= hex_with_alpha(budget_category.category.color, 0.05) %>">
<% 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 %>
<span class="text-sm uppercase" style="color: <%= budget_category.category.color %>">
<%= budget_category.category.name.first.upcase %>
</span>
<% end %>
</div>
</div>
<% end %>

View file

@ -0,0 +1,29 @@
<%# locals: (budget_category:) %>
<% currency = Money::Currency.new(budget_category.budget.currency) %>
<div class="w-full flex gap-3">
<div class="w-1 h-3 rounded-xl mt-1" style="background-color: <%= budget_category.category.color %>"></div>
<div class="text-sm mr-3">
<p class="text-gray-900 font-medium mb-0.5"><%= budget_category.category.name %></p>
<p class="text-gray-500"><%= format_money(budget_category.category.avg_monthly_total, precision: 0) %>/m average</p>
</div>
<div class="ml-auto">
<%= 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| %>
<div class="form-field w-[120px]">
<div class="flex items-center">
<span class="text-gray-500 text-sm mr-2"><%= currency.symbol %></span>
<%= 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" } %>
</div>
</div>
<% end %>
</div>
</div>

View file

@ -0,0 +1,17 @@
<div class="flex justify-center items-center">
<div class="text-center flex flex-col items-center max-w-[500px]">
<h2 class="text-lg text-gray-900 font-medium">Oops!</h2>
<p class="text-gray-500 text-sm max-w-sm mx-auto mb-4">
You have not created or assigned any expense categories to your transactions yet.
</p>
<div class="flex items-center gap-2">
<%= 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") %>
<span>New category</span>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,21 @@
<%# locals: (budget:) %>
<% budget_category = budget.uncategorized_budget_category %>
<div class="flex gap-3">
<div class="w-1 h-3 rounded-xl mt-1" style="background-color: <%= budget_category.category.color %>"></div>
<div class="text-sm mr-3">
<p class="text-gray-900 font-medium mb-0.5"><%= budget_category.category.name %></p>
<p class="text-gray-500"><%= format_money(budget_category.category.avg_monthly_total, precision: 0) %>/m average</p>
</div>
<div class="ml-auto">
<div class="form-field w-[120px]">
<div class="flex items-center">
<span class="text-gray-400 text-sm mr-2">$</span>
<%= 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 %>
</div>
</div>
</div>
</div>

View file

@ -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) %>
<div>
<div class="space-y-6">
<div class="text-center space-y-2">
<h1 class="text-3xl text-gray-900 font-medium">Edit your category budgets</h1>
<p class="text-gray-500 text-sm max-w-md mx-auto">
Adjust category budgets to set spending limits. Unallocated funds will be automatically assigned as uncategorized.
</p>
</div>
<div class="mx-auto max-w-lg">
<% if @budget.family.categories.empty? %>
<div class="bg-white shadow-xs border border-gray-200 rounded-lg p-4">
<%= render "budget_categories/no_categories" %>
</div>
<% else %>
<div class="max-w-md mx-auto">
<% if @budget.available_to_allocate.negative? %>
<%= render "budget_categories/allocation_progress_overage", budget: @budget %>
<% else %>
<%= render "budget_categories/allocation_progress", budget: @budget %>
<% end %>
<div class="space-y-4 mb-4">
<% BudgetCategory::Group.for(@budget.budget_categories).sort_by(&:name).each do |group| %>
<div class="space-y-4">
<%= render "budget_categories/budget_category_form", budget_category: group.budget_category %>
<div class="space-y-4">
<% group.budget_subcategories.each do |budget_subcategory| %>
<div class="w-full flex items-center gap-4">
<div class="ml-4 flex items-center justify-center text-gray-400">
<%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %>
</div>
<%= render "budget_categories/budget_category_form", budget_category: budget_subcategory %>
</div>
<% end %>
</div>
</div>
<% end %>
<%= render "budget_categories/uncategorized_budget_category_form", budget: @budget %>
</div>
<% if @budget.allocations_valid? %>
<%= link_to "Confirm",
budget_path(@budget),
class: "block btn btn--primary w-full text-center" %>
<% else %>
<span class="block btn btn--secondary w-full text-center text-gray-400 cursor-not-allowed">
Confirm
</span>
<% end %>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,150 @@
<%= drawer do %>
<div class="space-y-4">
<header class="flex justify-between">
<div>
<p class="text-sm text-gray-500">Category</p>
<h3 class="text-2xl font-medium text-gray-900">
<%= @budget_category.category.name %>
</h3>
<% if @budget_category.budget.initialized? %>
<p class="text-sm text-gray-500">
<span class="text-gray-900">
<%= format_money(@budget_category.actual_spending) %>
</span>
<span>/</span>
<span><%= format_money(@budget_category.budgeted_spending) %></span>
</p>
<% end %>
</div>
<% if @budget_category.budget.initialized? %>
<div class="ml-auto w-10 h-10">
<%= render "budget_categories/budget_category_donut",
budget_category: @budget_category %>
</div>
<% end %>
</header>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2
text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4>Overview</h4>
<%= lucide_icon "chevron-down",
class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-4">
<dl class="space-y-3 px-3 py-2">
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">
<%= @budget_category.budget.start_date.strftime("%b %Y") %> spending
</dt>
<dd class="text-gray-900 font-medium">
<%= format_money @budget_category.actual_spending_money %>
</dd>
</div>
<% if @budget_category.budget.initialized? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">Status</dt>
<% if @budget_category.available_to_spend.negative? %>
<dd class="text-red-500 flex items-center gap-1 text-red-500 font-medium">
<%= lucide_icon "alert-circle", class: "shrink-0 w-4 h-4 text-red-500" %>
<%= format_money @budget_category.available_to_spend_money.abs %>
<span>overspent</span>
</dd>
<% elsif @budget_category.available_to_spend.zero? %>
<dd class="text-orange-500 flex items-center gap-1 text-orange-500 font-medium">
<%= lucide_icon "x-circle", class: "shrink-0 w-4 h-4 text-orange-500" %>
<%= format_money @budget_category.available_to_spend_money %>
<span>left</span>
</dd>
<% else %>
<dd class="text-gray-900 flex items-center gap-1 text-green-500 font-medium">
<%= lucide_icon "check-circle-2", class: "shrink-0 w-4 h-4 text-green-500" %>
<%= format_money @budget_category.available_to_spend_money %>
<span>left</span>
</dd>
<% end %>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">Budgeted</dt>
<dd class="text-gray-900 font-medium">
<%= format_money @budget_category.budgeted_spending %>
</dd>
</div>
<% end %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">Monthly average spending</dt>
<dd class="text-gray-900 font-medium">
<%= format_money @budget_category.category.avg_monthly_total %>
</dd>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">Monthly median spending</dt>
<dd class="text-gray-900 font-medium">
<%= format_money @budget_category.category.median_monthly_total %>
</dd>
</div>
</dl>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2
text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4>Recent Transactions</h4>
<%= lucide_icon "chevron-down",
class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="space-y-2">
<div class="px-3 py-4 space-y-2">
<% if @recent_transactions.any? %>
<ul class="space-y-2 mb-4">
<% @recent_transactions.each_with_index do |entry, index| %>
<li class="flex gap-4 text-sm space-y-1">
<div class="flex flex-col items-center gap-1.5 pt-2">
<div class="rounded-full h-1.5 w-1.5 bg-gray-300"></div>
<% unless index == @recent_transactions.length - 1 %>
<div class="h-12 w-px bg-alpha-black-200"></div>
<% end %>
</div>
<div class="flex justify-between w-full">
<div>
<p class="text-gray-500 text-xs uppercase">
<%= entry.date.strftime("%b %d") %>
</p>
<p class="text-gray-900"><%= entry.name %></p>
</div>
<p class="text-gray-900 font-medium">
<%= format_money entry.amount_money %>
</p>
</div>
</li>
<% end %>
</ul>
<%= 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 %>
<p class="text-gray-500 text-sm mb-4">
No transactions found for this budget period.
</p>
<% end %>
</div>
</div>
</details>
</div>
<% end %>

View file

@ -0,0 +1,62 @@
<%# locals: (budget:) %>
<div>
<div class="p-4 border-b border-gray-100">
<h3 class="text-sm text-gray-500 mb-2">Income</h3>
<% 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 %>
<span class="inline-block mb-2 text-xl font-medium text-gray-900">
<%= format_money(income_totals.total_money) %>
</span>
<% if income_categories.any? %>
<div>
<div class="flex h-1.5 mb-3 gap-1">
<% income_categories.each do |item| %>
<div class="h-full rounded-xs" style="background-color: <%= item.category.color %>; width: <%= item.percentage %>%"></div>
<% end %>
</div>
<div class="flex flex-wrap gap-x-2.5 gap-y-1 text-xs">
<% income_categories.each do |item| %>
<div class="flex items-center gap-1.5">
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color: <%= item.category.color %>"></div>
<span class="text-gray-500"><%= item.category.name %></span>
<span class="text-gray-900"><%= number_to_percentage(item.percentage, precision: 0) %></span>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<div class="p-4">
<h3 class="text-sm text-gray-500 mb-2">Expenses</h3>
<% 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 %>
<span class="inline-block mb-2 text-xl font-medium text-gray-900"><%= format_money(expense_totals.total_money) %></span>
<% if expense_categories.any? %>
<div>
<div class="flex h-1.5 mb-3 gap-1">
<% expense_categories.each do |item| %>
<div class="h-full rounded-xs" style="background-color: <%= item.category.color %>; width: <%= item.percentage %>%"></div>
<% end %>
</div>
<div class="flex flex-wrap gap-x-2.5 gap-y-1 text-xs">
<% expense_categories.each do |item| %>
<div class="flex items-center gap-1.5">
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color: <%= item.category.color %>"></div>
<span class="text-gray-500"><%= item.category.name %></span>
<span class="text-gray-900"><%= number_to_percentage(item.percentage, precision: 0) %></span>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</div>

View file

@ -0,0 +1,45 @@
<%# locals: (budget:) %>
<div>
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
<p>Categories</p>
<span class="text-gray-400">&middot;</span>
<p><%= budget.budget_categories.count %></p>
<p class="ml-auto">Amount</p>
</div>
<div class="bg-white py-1 shadow-xs border border-gray-100 rounded-md">
<% if budget.family.categories.expenses.empty? %>
<div class="py-8">
<%= render "budget_categories/no_categories" %>
</div>
<% else %>
<% category_groups = BudgetCategory::Group.for(budget.budget_categories) %>
<% category_groups.each_with_index do |group, index| %>
<div>
<%= render "budget_categories/budget_category", budget_category: group.budget_category %>
<div>
<% group.budget_subcategories.each do |budget_subcategory| %>
<div class="w-full flex items-center -mt-4">
<div class="ml-8 flex items-center justify-center text-gray-400">
<%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %>
</div>
<%= render "budget_categories/budget_category", budget_category: budget_subcategory %>
</div>
<% end %>
</div>
</div>
<div class="px-4">
<div class="h-px w-full bg-alpha-black-50"></div>
</div>
<% end %>
<%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %>
<% end %>
</div>
</div>

View file

@ -0,0 +1,61 @@
<%= tag.div data: { controller: "donut-chart", donut_chart_segments_value: budget.to_donut_segments_json }, class: "relative h-full" do %>
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
<div data-donut-chart-target="contentContainer" class="flex justify-center items-center h-full">
<div data-donut-chart-target="defaultContent" class="flex flex-col items-center">
<% if budget.initialized? %>
<div class="text-gray-600 text-sm mb-2">
<span>Spent</span>
</div>
<div class="text-3xl font-medium <%= budget.available_to_spend.negative? ? "text-red-500" : "text-gray-900" %>">
<%= format_money(budget.actual_spending) %>
</div>
<%= link_to edit_budget_path(budget), class: "btn btn--secondary flex items-center gap-1 mt-2" do %>
<span class="text-gray-900 font-medium">
of <%= format_money(budget.budgeted_spending_money) %>
</span>
<%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 hover:text-gray-600" %>
<% end %>
<% else %>
<div class="text-gray-400 text-3xl mb-2">
<span><%= format_money Money.new(0, budget.currency || budget.family.currency) %></span>
</div>
<%= 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 %>
</div>
<% budget.budget_categories.each do |bc| %>
<div id="segment_<%= bc.id %>" class="hidden">
<div class="flex flex-col gap-2 items-center">
<div class="flex items-center gap-3">
<div class="w-1 h-3 rounded-xl" style="background-color: <%= bc.category.color %>"></div>
<p class="text-sm text-gray-500"><%= bc.category.name %></p>
</div>
<p class="text-3xl font-medium <%= bc.available_to_spend.negative? ? "text-red-500" : "text-gray-900" %>">
<%= format_money(bc.actual_spending_money) %>
</p>
<%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %>
<span>of <%= format_money(bc.budgeted_spending_money, precision: 0) %></span>
<%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 shrink-0" %>
<% end %>
</div>
</div>
<% end %>
<div id="segment_unused" class="hidden">
<p class="text-sm text-gray-500 text-center mb-2">Unused</p>
<p class="text-3xl font-medium text-gray-900">
<%= format_money(budget.available_to_spend_money) %>
</p>
</div>
</div>
<% end %>

View file

@ -0,0 +1,40 @@
<%# locals: (budget:, previous_budget:, next_budget:, latest_budget:) %>
<div class="flex items-center gap-1 mb-4">
<div class="flex items-center gap-2">
<% 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 %>
</div>
<div data-controller="menu" data-menu-placement-value="bottom-start">
<%= tag.button data: { menu_target: "button" }, class: "flex items-center gap-1 hover:bg-gray-50 rounded-md p-2" do %>
<span class="text-gray-900 font-medium"><%= @budget.name %></span>
<%= lucide_icon "chevron-down", class: "w-5 h-5 shrink-0 text-gray-500" %>
<% end %>
<div data-menu-target="content" class="hidden z-10">
<%= render "budgets/picker", family: Current.family, year: Date.current.year %>
</div>
</div>
<div class="ml-auto">
<% if @budget.current? %>
<span class="border border-alpha-black-200 text-gray-900 text-sm font-medium px-3 py-2 rounded-lg">Today</span>
<% else %>
<%= link_to "Today", budget_path(@latest_budget), class: "btn btn--outline" %>
<% end %>
</div>
</div>

View file

@ -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 },
] %>
<ul class="flex items-center gap-2">
<% steps.each_with_index do |step, idx| %>
<li class="flex items-center gap-2 group">
<% is_current = request.path == step[:path] %>
<% text_class = if is_current
"text-gray-900"
else
step[:is_complete] ? "text-green-600" : "text-gray-500"
end %>
<% step_class = if is_current
"bg-gray-900 text-white"
else
step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50"
end %>
<%= link_to step[:path], class: "flex items-center gap-3" do %>
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
<%= step[:is_complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : idx + 1 %>
</span>
<span><%= step[:name] %></span>
</div>
<% end %>
<div class="h-px bg-alpha-black-200 w-12 group-last:hidden"></div>
</li>
<% end %>
</ul>

View file

@ -0,0 +1,63 @@
<%# locals: (budget:) %>
<div>
<div class="p-4 border-b border-gray-100">
<h3 class="text-sm text-gray-500 mb-2">Expected income</h3>
<span class="inline-block mb-2 text-xl font-medium text-gray-900">
<%= format_money(budget.expected_income_money) %>
</span>
<div>
<div class="flex h-1.5 mb-3 gap-1">
<% if budget.remaining_expected_income.negative? %>
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= 100 - budget.surplus_percent %>%"></div>
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= budget.surplus_percent %>%"></div>
<% else %>
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= budget.actual_income_percent %>%"></div>
<div class="rounded-md h-1.5 bg-gray-100" style="width: <%= 100 - budget.actual_income_percent %>%"></div>
<% end %>
</div>
<div class="flex justify-between text-sm">
<p class="text-gray-500"><%= format_money(budget.actual_income_money) %> earned</p>
<p class="font-medium">
<% if budget.remaining_expected_income.negative? %>
<span class="text-green-500"><%= format_money(budget.remaining_expected_income.abs) %> over</span>
<% else %>
<span class="text-gray-900"><%= format_money(budget.remaining_expected_income) %> left</span>
<% end %>
</p>
</div>
</div>
</div>
<div class="p-4">
<h3 class="text-sm text-gray-500 mb-2">Budgeted</h3>
<span class="inline-block mb-2 text-xl font-medium text-gray-900">
<%= format_money(budget.budgeted_spending_money) %>
</span>
<div>
<div class="flex h-1.5 mb-3 gap-1">
<% if budget.available_to_spend.negative? %>
<div class="rounded-md h-1.5 bg-gray-900" style="width: <%= 100 - budget.overage_percent %>%"></div>
<div class="rounded-md h-1.5 bg-red-500" style="width: <%= budget.overage_percent %>%"></div>
<% else %>
<div class="rounded-md h-1.5 bg-gray-900" style="width: <%= budget.percent_of_budget_spent %>%"></div>
<div class="rounded-md h-1.5 bg-gray-100" style="width: <%= 100 - budget.percent_of_budget_spent %>%"></div>
<% end %>
</div>
<div class="flex justify-between text-sm">
<p class="text-gray-500"><%= format_money(budget.actual_spending_money) %> spent</p>
<p class="font-medium">
<% if budget.available_to_spend.negative? %>
<span class="text-red-500"><%= format_money(budget.available_to_spend_money.abs) %> over</span>
<% else %>
<span class="text-gray-900"><%= format_money(budget.available_to_spend_money) %> left</span>
<% end %>
</p>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,13 @@
<%# locals: (budget:) %>
<div class="flex flex-col gap-4 items-center justify-center h-full">
<%= lucide_icon "alert-triangle", class: "w-6 h-6 text-red-500" %>
<p class="text-gray-500 text-sm text-center">You have over-allocated your budget. Please fix your allocations.</p>
<%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %>
<span class="text-gray-900 font-medium">
Fix allocations
</span>
<%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 hover:text-gray-600" %>
<% end %>
</div>

View file

@ -0,0 +1,49 @@
<%# locals: (family:, year:) %>
<%= turbo_frame_tag "budget_picker" do %>
<div class="bg-white shadow-md border border-alpha-black-25 p-3 rounded-xl space-y-4">
<div class="flex items-center gap-2 justify-between">
<% 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 %>
<span class="p-2 flex items-center justify-center text-gray-300 rounded-md">
<%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-gray-400" %>
</span>
<% end %>
<span class="w-40 text-center px-3 py-2 border border-alpha-black-100 rounded-md" data-budget-picker-target="year">
<%= year %>
</span>
<% 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 %>
<span class="p-2 flex items-center justify-center text-gray-300 rounded-md">
<%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-gray-400" %>
</span>
<% end %>
</div>
<div class="grid grid-cols-3 gap-2 text-sm text-center font-medium">
<% 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 %>
<span class="px-3 py-2 text-gray-400 rounded-md"><%= month_name %></span>
<% end %>
<% end %>
</div>
</div>
<% end %>

View file

@ -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) %>
<div>
<div class="space-y-4">
<div class="text-center space-y-2">
<h1 class="text-3xl text-gray-900 font-medium">Setup your budget</h1>
<p class="text-gray-500 text-sm max-w-sm mx-auto">
Enter your monthly earnings and planned spending below to setup your budget.
</p>
</div>
<div class="mx-auto max-w-lg">
<%= 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 %>
<div class="border border-alpha-black-100 rounded-lg p-3 flex">
<%= lucide_icon "sparkles", class: "w-5 h-5 text-gray-500 shrink-0" %>
<div class="ml-2 space-y-1 text-sm">
<h4 class="text-gray-900">Autosuggest income & spending budget</h4>
<p class="text-gray-500">
This will be based on transaction history. AI can make mistakes, verify before continuing.
</p>
</div>
<div class="relative inline-block select-none ml-6">
<%= 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 }
} %>
<label for="auto_fill" class="maybe-switch"></label>
</div>
</div>
<% end %>
<%= f.submit "Continue", class: "btn btn--primary w-full" %>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,67 @@
<div class="pb-12">
<%= render "budgets/budget_header",
budget: @budget,
previous_budget: @previous_budget,
next_budget: @next_budget,
latest_budget: @latest_budget %>
<div class="flex items-start gap-4">
<div class="w-[300px] space-y-4">
<div class="h-[300px] bg-white rounded-xl shadow-xs p-8 border border-gray-100">
<% if @budget.available_to_allocate.negative? %>
<%= render "budgets/over_allocation_warning", budget: @budget %>
<% else %>
<%= render "budgets/budget_donut", budget: @budget %>
<% end %>
</div>
<div>
<% if @budget.initialized? && @budget.available_to_allocate.positive? %>
<div class="flex gap-2 mb-2 rounded-lg bg-alpha-black-25 p-1">
<% 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"
) %>
</div>
<div class="bg-white rounded-xl shadow-xs border border-gray-100">
<%= render selected_tab == "budgeted" ? "budgets/budgeted_summary" : "budgets/actuals_summary", budget: @budget %>
</div>
<% else %>
<div class="bg-white rounded-xl shadow-xs border border-gray-100">
<%= render "budgets/actuals_summary", budget: @budget %>
</div>
<% end %>
</div>
</div>
<div class="grow bg-white rounded-xl shadow-xs p-4 border border-gray-100">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium">Categories</h2>
<%= link_to budget_budget_categories_path(@budget), class: "btn btn--secondary flex items-center gap-2" do %>
<%= icon "settings-2", color: "gray" %>
<span>Edit</span>
<% end %>
</div>
<div class="bg-gray-25 rounded-xl p-1">
<%= render "budgets/budget_categories", budget: @budget %>
</div>
</div>
</div>
</div>

View file

@ -1,5 +1,5 @@
<%# locals: (category:) %>
<% category ||= null_category %>
<% category ||= Category.uncategorized %>
<div>
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border truncate"
@ -7,6 +7,9 @@
background-color: color-mix(in srgb, <%= category.color %> 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 %>
</span>

View file

@ -0,0 +1,25 @@
<%# locals: (title:, categories:) %>
<div class="rounded-xl bg-gray-25 space-y-1 p-1">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
<p><%= title %></p>
<span class="text-gray-400">&middot;</span>
<p><%= categories.count %></p>
</div>
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
<div class="overflow-hidden rounded-md">
<% 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 %>
</div>
</div>
</div>

View file

@ -1,7 +1,7 @@
<%# locals: (category:, categories:) %>
<div data-controller="color-avatar">
<%= 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| %>
<section class="space-y-4">
<div class="w-fit m-auto">
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
@ -20,7 +20,19 @@
<%= render "shared/form_errors", model: category %>
<% end %>
<div class="flex flex-wrap gap-2 justify-center mb-4">
<% Category.icon_codes.each do |icon| %>
<label class="relative">
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer" %>
<div class="p-1 rounded cursor-pointer hover:bg-gray-100 peer-checked:bg-gray-100 border-1 border-transparent peer-checked:border-gray-500">
<%= lucide_icon icon, class: "w-5 h-5" %>
</div>
</label>
<% end %>
</div>
<div class="space-y-2">
<%= 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)" } %>
</div>

View file

@ -14,28 +14,14 @@
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
<% if @categories.any? %>
<div class="rounded-xl bg-gray-25 space-y-1 p-1">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
<p><%= t(".categories") %></p>
<span class="text-gray-400">&middot;</span>
<p><%= @categories.count %></p>
</div>
<div class="space-y-4">
<% if @categories.incomes.any? %>
<%= render "categories/category_list_group", title: t(".categories_incomes"), categories: @categories.incomes %>
<% end %>
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
<div class="overflow-hidden rounded-md">
<% 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 %>
</div>
</div>
<% if @categories.expenses.any? %>
<%= render "categories/category_list_group", title: t(".categories_expenses"), categories: @categories.expenses %>
<% end %>
</div>
<% else %>
<div class="flex justify-center items-center py-20">

View file

@ -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" %>
<p>Match transfer/payment</p>
<p>Match transfer/payment</p>
<% end %>
<% end %>
<div class="flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2">

View file

@ -81,6 +81,9 @@
<li>
<%= sidebar_link_to t(".transactions"), transactions_path, icon: "credit-card" %>
</li>
<li>
<%= sidebar_link_to t(".budgeting"), budgets_path, icon: "map" %>
</li>
</ul>
</nav>
<div class="flex flex-col mt-6">

View file

@ -0,0 +1,23 @@
<%= content_for :content do %>
<div class="flex flex-col h-dvh">
<header class="flex items-center justify-between p-8">
<%= link_to content_for(:previous_path) || root_path do %>
<%= lucide_icon "arrow-left", class: "w-5 h-5 text-gray-500" %>
<% end %>
<nav>
<%= yield :header_nav %>
</nav>
<%= link_to content_for(:cancel_path) || root_path do %>
<%= lucide_icon "x", class: "text-gray-500 w-5 h-5" %>
<% end %>
</header>
<main class="flex-grow px-8 pt-12 pb-32 overflow-y-auto">
<%= yield %>
</main>
</div>
<% end %>
<%= render template: "layouts/application" %>

View file

@ -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") %>

View file

@ -52,7 +52,11 @@
<div class="text-gray-500 text-xs font-normal">
<div class="flex items-center gap-1">
<%= 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" } %>
</div>
</div>
@ -63,7 +67,11 @@
</div>
<div class="flex items-center gap-1 col-span-2">
<%= 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 %>
</div>
<div class="col-span-2 ml-auto">

View file

@ -70,7 +70,11 @@
<!-- Details Section -->
<%= 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"),

View file

@ -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:

View file

@ -19,3 +19,4 @@ en:
new_account: New account
portfolio: Portfolio
transactions: Transactions
budgeting: Budgeting

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -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

32
db/schema.rb generated
View file

@ -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"

View file

@ -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

View file

@ -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

7
test/fixtures/budgets.yml vendored Normal file
View file

@ -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