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