1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-10 07:55:21 +02:00

First sketch of budgeting module

This commit is contained in:
Zach Gollwitzer 2024-12-30 17:29:59 -05:00
parent 5d1a2937bb
commit 9a2a7b31d4
45 changed files with 342 additions and 84 deletions

View file

@ -22,10 +22,9 @@ class Account::TransactionsController < ApplicationController
end
def mark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.mark_transfers!
selected_entries = Current.family.entries.account_transactions.where(id: bulk_update_params[:entry_ids])
TransferMatcher.new(Current.family).match!(selected_entries)
redirect_back_or_to transactions_url, notice: t(".success")
end
@ -33,8 +32,12 @@ class Account::TransactionsController < ApplicationController
def unmark_transfers
Current.family
.entries
.account_transactions
.includes(:entryable)
.where(id: bulk_update_params[:entry_ids])
.update_all marked_as_transfer: false
.each do |entry|
entry.entryable.update!(category_id: nil)
end
redirect_back_or_to transactions_url, notice: t(".success")
end

View file

@ -4,7 +4,7 @@ module Account::EntriesHelper
end
def unconfirmed_transfer?(entry)
entry.marked_as_transfer? && entry.transfer.nil?
entry.transfer.nil? && entry.entryable.category&.classification == "transfer"
end
def transfer_entries(entries)

View file

@ -30,7 +30,20 @@ class Account::Entry < ApplicationRecord
)
}
scope :without_transfers, -> { where(marked_as_transfer: false) }
scope :incomes_and_expenses, -> {
joins("INNER JOIN account_transactions ON account_transactions.id = account_entries.entryable_id")
.joins(:account)
.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
# All transfers excluded from income/expenses, outflow payments are expenses, inflow payments are NOT income
.where(<<~SQL.squish)
categories.id IS NULL OR
(
categories.classification != 'transfer' AND
(categories.classification != 'payment' OR account_entries.amount > 0)
)
SQL
}
scope :with_converted_amount, ->(currency) {
# Join with exchange rates to convert the amount to the given currency
# If no rate is available, exclude the transaction from the results
@ -98,13 +111,6 @@ class Account::Entry < ApplicationRecord
select("*").from(rolling_totals).where("date >= ?", period.date_range.first)
end
def mark_transfers!
update_all marked_as_transfer: true
# Attempt to "auto match" and save a transfer if 2 transactions selected
Account::Transfer.new(entries: all).save if all.count == 2
end
def bulk_update!(bulk_update_params)
bulk_attributes = {
date: bulk_update_params[:date],
@ -128,8 +134,7 @@ class Account::Entry < ApplicationRecord
end
def income_total(currency = "USD")
total = without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount <= 0")
total = incomes_and_expenses.where("account_entries.amount <= 0")
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum
@ -137,8 +142,7 @@ class Account::Entry < ApplicationRecord
end
def expense_total(currency = "USD")
total = without_transfers.account_transactions.includes(:entryable)
.where("account_entries.amount > 0")
total = incomes_and_expenses.where("account_entries.amount > 0")
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
.sum

View file

@ -27,8 +27,6 @@ class Account::EntrySearch
query = query.where("account_entries.date <= ?", end_date) if end_date.present?
if types.present?
query = query.where(marked_as_transfer: false) unless types.include?("transfer")
if types.include?("income") && !types.include?("expense")
query = query.where("account_entries.amount < 0")
elsif types.include?("expense") && !types.include?("income")

View file

@ -67,8 +67,9 @@ class Account::TradeBuilder
date: date,
amount: signed_amount,
currency: currency,
marked_as_transfer: true,
entryable: Account::Transaction.new
entryable: Account::Transaction.new(
category: account.family.default_transfer_category
)
)
end
end

View file

@ -59,8 +59,9 @@ class Account::Transfer < ApplicationRecord
currency: from_account.currency,
date: date,
name: "Transfer to #{to_account.name}",
marked_as_transfer: true,
entryable: Account::Transaction.new
entryable: Account::Transaction.new(
category: from_account.family.default_transfer_category
)
# Attempt to convert the amount to the to_account's currency. If the conversion fails,
# use the original amount.
@ -75,8 +76,9 @@ class Account::Transfer < ApplicationRecord
currency: converted_amount.currency.iso_code,
date: date,
name: "Transfer from #{from_account.name}",
marked_as_transfer: true,
entryable: Account::Transaction.new
entryable: Account::Transaction.new(
category: to_account.family.default_transfer_category
)
new entries: [ outflow, inflow ]
end
@ -106,8 +108,8 @@ class Account::Transfer < ApplicationRecord
end
def all_transactions_marked
unless entries.all?(&:marked_as_transfer)
errors.add :entries, :must_be_marked_as_transfer
unless entries.all? { |e| e.entryable.category == from_account.family.default_transfer_category }
errors.add :entries, :must_have_transfer_category
end
end
end

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

@ -0,0 +1,4 @@
class Budget < ApplicationRecord
belongs_to :family
has_many :budget_categories, dependent: :destroy
end

View file

@ -0,0 +1,4 @@
class BudgetCategory < ApplicationRecord
belongs_to :budget
belongs_to :category
end

View file

@ -4,9 +4,12 @@ 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
enum :classification, { expense: "expense", income: "income", transfer: "transfer", payment: "payment" }
validates :name, :color, :family, presence: true
validates :name, uniqueness: { scope: :family_id }

View file

@ -88,7 +88,7 @@ class Demo::Generator
"Rent & Utilities", "Home Improvement", "Shopping" ]
categories.each do |category|
family.categories.create!(name: category, color: COLORS.sample)
family.categories.create!(name: category, color: COLORS.sample, classification: category == "Income" ? "income" : "expense")
end
food = family.categories.find_by(name: "Food & Drink")

View file

@ -21,6 +21,18 @@ class Family < ApplicationRecord
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
validates :date_format, inclusion: { in: DATE_FORMATS }
def default_transfer_category
@default_transfer_category ||= categories.find_or_create_by!(classification: "transfer") do |c|
c.name = "Transfer"
end
end
def default_payment_category
@default_payment_category ||= categories.find_or_create_by!(classification: "payment") do |c|
c.name = "Payment"
end
end
def sync_data(start_date: nil)
update!(last_synced_at: Time.current)
@ -82,7 +94,10 @@ class Family < ApplicationRecord
def snapshot_account_transactions
period = Period.last_30_days
results = accounts.active.joins(:entries)
results = accounts.active
.joins("INNER JOIN account_entries ON account_entries.account_id = accounts.id")
.joins("INNER JOIN account_transactions ON account_entries.entryable_id = account_transactions.id AND account_entries.entryable_type = 'Account::Transaction'")
.joins("LEFT JOIN categories ON account_transactions.category_id = categories.id")
.select(
"accounts.*",
"COALESCE(SUM(account_entries.amount) FILTER (WHERE account_entries.amount > 0), 0) AS spending",
@ -90,8 +105,7 @@ class Family < ApplicationRecord
)
.where("account_entries.date >= ?", period.date_range.begin)
.where("account_entries.date <= ?", period.date_range.end)
.where("account_entries.marked_as_transfer = ?", false)
.where("account_entries.entryable_type = ?", "Account::Transaction")
.where("categories.classification IS NULL OR categories.classification != ?", "transfer")
.group("accounts.id")
.having("SUM(ABS(account_entries.amount)) > 0")
.to_a
@ -110,9 +124,7 @@ class Family < ApplicationRecord
end
def snapshot_transactions
candidate_entries = entries.account_transactions.without_transfers.excluding(
entries.joins(:account).where(amount: ..0, accounts: { classification: Account.classifications[:liability] })
)
candidate_entries = entries.incomes_and_expenses
rolling_totals = Account::Entry.daily_rolling_totals(candidate_entries, self.currency, period: Period.last_30_days)
spending = []

5
app/models/goal.rb Normal file
View file

@ -0,0 +1,5 @@
class Goal < ApplicationRecord
belongs_to :family
enum :type, { saving: "saving" }
end

View file

@ -89,7 +89,6 @@ class PlaidAccount < ApplicationRecord
t.amount = plaid_txn.amount
t.currency = plaid_txn.iso_currency_code
t.date = plaid_txn.date
t.marked_as_transfer = transfer?(plaid_txn)
t.entryable = Account::Transaction.new(
category: get_category(plaid_txn.personal_finance_category.primary),
merchant: get_merchant(plaid_txn.merchant_name)
@ -136,9 +135,9 @@ class PlaidAccount < ApplicationRecord
# See https://plaid.com/documents/transactions-personal-finance-category-taxonomy.csv
def get_category(plaid_category)
ignored_categories = [ "BANK_FEES", "TRANSFER_IN", "TRANSFER_OUT", "LOAN_PAYMENTS", "OTHER" ]
return nil if ignored_categories.include?(plaid_category)
return family.default_transfer_category if [ "TRANSFER_IN", "TRANSFER_OUT" ].include?(plaid_category)
return family.default_payment_category if [ "LOAN_PAYMENTS" ].include?(plaid_category)
return nil if [ "BANK_FEES", "OTHER" ].include?(plaid_category)
family.categories.find_or_create_by!(name: plaid_category.titleize)
end

View file

@ -26,13 +26,13 @@ class PlaidInvestmentSync
next if security.nil? && plaid_security.nil?
if transaction.type == "cash" || plaid_security.ticker_symbol == "CUR:USD"
category = plaid_account.account.family.default_transfer_category if transaction.subtype.in?(%w[deposit withdrawal])
new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t|
t.name = transaction.name
t.amount = transaction.amount
t.currency = transaction.iso_currency_code
t.date = transaction.date
t.marked_as_transfer = transaction.subtype.in?(%w[deposit withdrawal])
t.entryable = Account::Transaction.new
t.entryable = Account::Transaction.new(category: category)
end
else
new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t|

View file

@ -0,0 +1,2 @@
class SavingGoal < Goal
end

View file

@ -0,0 +1,36 @@
class TransferMatcher
attr_reader :family
def initialize(family)
@family = family
end
def match!(transaction_entries)
ActiveRecord::Base.transaction do
transaction_entries.each do |entry|
entry.entryable.update!(category_id: transfer_category.id)
end
create_transfers(transaction_entries)
end
end
private
def create_transfers(entries)
matches = entries.to_a.combination(2).select do |entry1, entry2|
entry1.amount == -entry2.amount &&
entry1.account_id != entry2.account_id &&
(entry1.date - entry2.date).abs <= 4
end
matches.each do |match|
Account::Transfer.create!(entries: match)
end
end
def transfer_category
@transfer_category ||= family.categories.find_or_create_by!(classification: "transfer") do |category|
category.name = "Transfer"
end
end
end

View file

@ -12,7 +12,7 @@
</span>
</h3>
<% if entry.marked_as_transfer? %>
<% if entry.entryable.category&.transfer? %>
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
<% end %>
</div>

View file

@ -19,7 +19,7 @@
max: Date.current,
"data-auto-submit-form-target": "auto" %>
<% unless @entry.marked_as_transfer? %>
<% unless @entry.entryable.category&.transfer? %>
<div class="flex items-center gap-2">
<%= f.select :nature,
[["Expense", "outflow"], ["Income", "inflow"]],
@ -52,7 +52,7 @@
url: account_transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<% unless @entry.marked_as_transfer? %>
<% unless @entry.entryable.category&.transfer? %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id,
Current.family.categories.alphabetically,

View file

@ -3,7 +3,7 @@
<div class="flex items-center gap-1">
<div>
<% if pagy.prev %>
<%= link_to custom_pagy_url_for(pagy, pagy.prev, current_path: current_path),
<%= link_to custom_pagy_url_for(pagy, pagy.prev, current_path: current_path),
class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700",
data: (current_path ? { turbo_frame: "_top" } : {}) do %>
<%= lucide_icon("chevron-left", class: "w-5 h-5 text-gray-500") %>
@ -17,13 +17,13 @@
<div class="rounded-xl p-1 bg-gray-25">
<% pagy.series.each do |series_item| %>
<% if series_item.is_a?(Integer) %>
<%= link_to custom_pagy_url_for(pagy, series_item, current_path: current_path),
<%= link_to custom_pagy_url_for(pagy, series_item, current_path: current_path),
class: "rounded-md px-2 py-1 inline-flex items-center text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700",
data: (current_path ? { turbo_frame: "_top" } : {}) do %>
<%= series_item %>
<% end %>
<% elsif series_item.is_a?(String) %>
<%= link_to custom_pagy_url_for(pagy, series_item, current_path: current_path),
<%= link_to custom_pagy_url_for(pagy, series_item, current_path: current_path),
class: "rounded-md px-2 py-1 bg-white border border-alpha-black-25 shadow-xs inline-flex items-center text-sm font-medium text-gray-900",
data: (current_path ? { turbo_frame: "_top" } : {}) do %>
<%= series_item %>
@ -35,7 +35,7 @@
</div>
<div>
<% if pagy.next %>
<%= link_to custom_pagy_url_for(pagy, pagy.next, current_path: current_path),
<%= link_to custom_pagy_url_for(pagy, pagy.next, current_path: current_path),
class: "inline-flex items-center p-2 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700",
data: (current_path ? { turbo_frame: "_top" } : {}) do %>
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>

View file

@ -22,6 +22,7 @@
<div class="space-y-2">
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
<%= f.select :classification, Category.classifications.keys.map { |c| [c.humanize, c] }, required: true, label: true %>
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" } %>
</div>
</section>

View file

@ -13,7 +13,6 @@
<%= combobox_style_tag %>
<%= javascript_importmap_tags %>
<%= hotwire_livereload_tags if Rails.env.development? %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<meta name="viewport"

View file

@ -10,7 +10,6 @@
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<%= hotwire_livereload_tags if Rails.env.development? %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">

View file

@ -23,15 +23,4 @@
nil %>
<%= form.label :types, t(".expense"), value: "expense", class: "text-sm text-gray-900" %>
</div>
<div class="flex items-center gap-3" data-filter-name="transfer">
<%= form.check_box :types,
{
multiple: true,
checked: @q[:types]&.include?("transfer"),
class: "maybe-checkbox maybe-checkbox--light"
},
"transfer",
nil %>
<%= form.label :types, t(".transfer"), value: "transfer", class: "text-sm text-gray-900" %>
</div>
</div>