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:
parent
5d1a2937bb
commit
9a2a7b31d4
45 changed files with 342 additions and 84 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
4
app/models/budget.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
class Budget < ApplicationRecord
|
||||
belongs_to :family
|
||||
has_many :budget_categories, dependent: :destroy
|
||||
end
|
4
app/models/budget_category.rb
Normal file
4
app/models/budget_category.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
class BudgetCategory < ApplicationRecord
|
||||
belongs_to :budget
|
||||
belongs_to :category
|
||||
end
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
5
app/models/goal.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class Goal < ApplicationRecord
|
||||
belongs_to :family
|
||||
|
||||
enum :type, { saving: "saving" }
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -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|
|
||||
|
|
2
app/models/saving_goal.rb
Normal file
2
app/models/saving_goal.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
class SavingGoal < Goal
|
||||
end
|
36
app/models/transfer_matcher.rb
Normal file
36
app/models/transfer_matcher.rb
Normal 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
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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") %>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue