mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-18 20:59:39 +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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -11,7 +11,8 @@ en:
|
|||
attributes:
|
||||
entries:
|
||||
must_be_from_different_accounts: must be from different accounts
|
||||
must_be_marked_as_transfer: must be marked as transfer
|
||||
must_have_transfer_category: must have transfer category
|
||||
must_have_an_inflow_and_outflow_that_net_to_zero: must have an inflow
|
||||
and outflow that net to zero
|
||||
must_have_exactly_2_entries: must have exactly 2 entries
|
||||
|
||||
|
|
|
@ -26,7 +26,6 @@ en:
|
|||
type_filter:
|
||||
expense: Expense
|
||||
income: Income
|
||||
transfer: Transfer
|
||||
menu:
|
||||
account_filter: Account
|
||||
amount_filter: Amount
|
||||
|
|
13
db/migrate/20241230154019_create_budgets.rb
Normal file
13
db/migrate/20241230154019_create_budgets.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
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_amount, null: false, precision: 19, scale: 4
|
||||
t.decimal :expected_income, null: false, precision: 19, scale: 4
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
11
db/migrate/20241230155132_create_budget_categories.rb
Normal file
11
db/migrate/20241230155132_create_budget_categories.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
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_amount, null: false, precision: 19, scale: 4
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
14
db/migrate/20241230162744_create_goals.rb
Normal file
14
db/migrate/20241230162744_create_goals.rb
Normal file
|
@ -0,0 +1,14 @@
|
|||
class CreateGoals < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :goals, id: :uuid do |t|
|
||||
t.references :family, null: false, foreign_key: true, type: :uuid
|
||||
t.string :name, null: false
|
||||
t.string :type, null: false
|
||||
t.decimal :target_amount, null: false, precision: 19, scale: 4
|
||||
t.decimal :starting_amount, null: false, precision: 19, scale: 4
|
||||
t.date :start_date, null: false
|
||||
t.date :target_date, null: false
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
34
db/migrate/20241230164615_income_category.rb
Normal file
34
db/migrate/20241230164615_income_category.rb
Normal file
|
@ -0,0 +1,34 @@
|
|||
class IncomeCategory < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :categories, :classification, :string, null: false, default: "expense"
|
||||
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
execute <<-SQL
|
||||
UPDATE categories
|
||||
SET classification = 'income'
|
||||
WHERE LOWER(name) = 'income'
|
||||
SQL
|
||||
|
||||
# Assign the transfer classification for any entries marked as transfer
|
||||
execute <<-SQL
|
||||
UPDATE categories
|
||||
SET classification = 'transfer'
|
||||
WHERE id IN (
|
||||
SELECT DISTINCT t.category_id
|
||||
FROM account_entries e
|
||||
INNER JOIN account_transactions t ON t.id = e.entryable_id AND e.entryable_type = 'Account::Transaction'
|
||||
WHERE e.marked_as_transfer = true AND t.category_id IS NOT NULL
|
||||
)
|
||||
SQL
|
||||
|
||||
# We will now use categories to identify one-way transfers, and Account::Transfer for two-way transfers
|
||||
remove_column :account_entries, :marked_as_transfer
|
||||
end
|
||||
|
||||
dir.down do
|
||||
add_column :account_entries, :marked_as_transfer, :boolean, null: false, default: false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
42
db/schema.rb
generated
42
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_27_142333) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_12_30_164615) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -43,7 +43,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
|||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.uuid "transfer_id"
|
||||
t.boolean "marked_as_transfer", default: false, null: false
|
||||
t.uuid "import_id"
|
||||
t.text "notes"
|
||||
t.boolean "excluded", default: false
|
||||
|
@ -168,6 +167,27 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) 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_amount", precision: 19, scale: 4, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
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_amount", precision: 19, scale: 4, null: false
|
||||
t.decimal "expected_income", precision: 19, scale: 4, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
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
|
||||
|
@ -175,6 +195,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) 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.index ["family_id"], name: "index_categories_on_family_id"
|
||||
end
|
||||
|
||||
|
@ -226,6 +247,19 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) do
|
|||
t.boolean "data_enrichment_enabled", default: false
|
||||
end
|
||||
|
||||
create_table "goals", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "family_id", null: false
|
||||
t.string "name", null: false
|
||||
t.string "type", null: false
|
||||
t.decimal "target_amount", precision: 19, scale: 4, null: false
|
||||
t.decimal "starting_amount", precision: 19, scale: 4, null: false
|
||||
t.date "start_date", null: false
|
||||
t.date "target_date", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["family_id"], name: "index_goals_on_family_id"
|
||||
end
|
||||
|
||||
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
|
@ -647,7 +681,11 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_27_142333) 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 "goals", "families"
|
||||
add_foreign_key "impersonation_session_logs", "impersonation_sessions"
|
||||
add_foreign_key "impersonation_sessions", "users", column: "impersonated_id"
|
||||
add_foreign_key "impersonation_sessions", "users", column: "impersonator_id"
|
||||
|
|
|
@ -93,7 +93,7 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
created_entry = Account::Entry.order(created_at: :desc).first
|
||||
|
||||
assert created_entry.amount.positive?
|
||||
assert created_entry.marked_as_transfer
|
||||
assert created_entry.entryable.category.transfer?
|
||||
assert_redirected_to @entry.account
|
||||
end
|
||||
|
||||
|
|
2
test/fixtures/account/entries.yml
vendored
2
test/fixtures/account/entries.yml
vendored
|
@ -31,7 +31,6 @@ transfer_out:
|
|||
amount: 100
|
||||
currency: USD
|
||||
account: depository
|
||||
marked_as_transfer: true
|
||||
transfer: one
|
||||
entryable_type: Account::Transaction
|
||||
entryable: transfer_out
|
||||
|
@ -42,7 +41,6 @@ transfer_in:
|
|||
amount: -100
|
||||
currency: USD
|
||||
account: credit_card
|
||||
marked_as_transfer: true
|
||||
transfer: one
|
||||
entryable_type: Account::Transaction
|
||||
entryable: transfer_in
|
||||
|
|
7
test/fixtures/account/transactions.yml
vendored
7
test/fixtures/account/transactions.yml
vendored
|
@ -2,5 +2,8 @@ one:
|
|||
category: food_and_drink
|
||||
merchant: amazon
|
||||
|
||||
transfer_out: { }
|
||||
transfer_in: { }
|
||||
transfer_out:
|
||||
category: transfer
|
||||
|
||||
transfer_in:
|
||||
category: transfer
|
4
test/fixtures/budget_categories.yml
vendored
Normal file
4
test/fixtures/budget_categories.yml
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
food_and_drink:
|
||||
budget: one
|
||||
category: food_and_drink
|
||||
budgeted_amount: 800
|
6
test/fixtures/budgets.yml
vendored
Normal file
6
test/fixtures/budgets.yml
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
one:
|
||||
start_date: <%= 1.month.ago.to_date %>
|
||||
end_date: <%= Date.current %>
|
||||
family: dylan_family
|
||||
budgeted_amount: 5000
|
||||
expected_income: 8000
|
16
test/fixtures/categories.yml
vendored
16
test/fixtures/categories.yml
vendored
|
@ -1,17 +1,33 @@
|
|||
one:
|
||||
name: Test
|
||||
classification: expense
|
||||
family: empty
|
||||
|
||||
income:
|
||||
name: Income
|
||||
classification: income
|
||||
color: "#fd7f6f"
|
||||
family: dylan_family
|
||||
|
||||
transfer:
|
||||
name: Transfer
|
||||
classification: transfer
|
||||
color: "#fd7f6f"
|
||||
family: dylan_family
|
||||
|
||||
payment:
|
||||
name: Payment
|
||||
classification: payment
|
||||
color: "#fd7f6f"
|
||||
family: dylan_family
|
||||
|
||||
food_and_drink:
|
||||
name: Food & Drink
|
||||
classification: expense
|
||||
family: dylan_family
|
||||
|
||||
subcategory:
|
||||
name: Restaurants
|
||||
classification: expense
|
||||
parent: food_and_drink
|
||||
family: dylan_family
|
||||
|
|
8
test/fixtures/goals.yml
vendored
Normal file
8
test/fixtures/goals.yml
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
saving:
|
||||
name: Vacation savings
|
||||
type: saving
|
||||
start_date: <%= 1.month.ago.to_date %>
|
||||
target_date: <%= 1.year.from_now.to_date %>
|
||||
target_amount: 10000
|
||||
starting_amount: 2000
|
||||
family: dylan_family
|
|
@ -74,7 +74,7 @@ class Account::EntryTest < ActiveSupport::TestCase
|
|||
create_transaction(account: account, amount: 100)
|
||||
create_transaction(account: account, amount: -500) # income, will be ignored
|
||||
|
||||
assert_equal Money.new(200), family.entries.expense_total("USD")
|
||||
assert_equal Money.new(200), account.entries.expense_total("USD")
|
||||
end
|
||||
|
||||
test "can calculate total income for a group of transactions" do
|
||||
|
@ -82,8 +82,8 @@ class Account::EntryTest < ActiveSupport::TestCase
|
|||
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||
create_transaction(account: account, amount: -100)
|
||||
create_transaction(account: account, amount: -100)
|
||||
create_transaction(account: account, amount: 500) # income, will be ignored
|
||||
create_transaction(account: account, amount: 500) # expense, will be ignored
|
||||
|
||||
assert_equal Money.new(-200), family.entries.income_total("USD")
|
||||
assert_equal Money.new(-200), account.entries.income_total("USD")
|
||||
end
|
||||
end
|
||||
|
|
|
@ -28,28 +28,31 @@ class Account::TransferTest < ActiveSupport::TestCase
|
|||
name: "Inflow",
|
||||
amount: -100,
|
||||
currency: "USD",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
entryable: Account::Transaction.new(
|
||||
category: account.family.default_transfer_category
|
||||
)
|
||||
|
||||
outflow = account.entries.create! \
|
||||
date: Date.current,
|
||||
name: "Outflow",
|
||||
amount: 100,
|
||||
currency: "USD",
|
||||
marked_as_transfer: true,
|
||||
entryable: Account::Transaction.new
|
||||
entryable: Account::Transaction.new(
|
||||
category: account.family.default_transfer_category
|
||||
)
|
||||
|
||||
assert_raise ActiveRecord::RecordInvalid do
|
||||
Account::Transfer.create! entries: [ inflow, outflow ]
|
||||
end
|
||||
end
|
||||
|
||||
test "all transfer transactions must be marked as transfers" do
|
||||
@inflow.update! marked_as_transfer: false
|
||||
test "all transfer transactions must have transfer category" do
|
||||
@inflow.entryable.update! category: nil
|
||||
|
||||
assert_raise ActiveRecord::RecordInvalid do
|
||||
Account::Transfer.create! entries: [ @inflow, @outflow ]
|
||||
end
|
||||
transfer = Account::Transfer.new entries: [ @inflow, @outflow ]
|
||||
|
||||
assert_not transfer.valid?
|
||||
assert_equal "Entries must have transfer category", transfer.errors.full_messages.first
|
||||
end
|
||||
|
||||
test "single-currency transfer transactions must net to zero" do
|
||||
|
|
7
test/models/budget_category_test.rb
Normal file
7
test/models/budget_category_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class BudgetCategoryTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
7
test/models/budget_test.rb
Normal file
7
test/models/budget_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class BudgetTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
|
@ -124,7 +124,7 @@ class FamilyTest < ActiveSupport::TestCase
|
|||
create_transaction(account: account, date: 2.days.ago.to_date, amount: -500)
|
||||
create_transaction(account: account, date: 1.day.ago.to_date, amount: 100)
|
||||
create_transaction(account: account, date: Date.current, amount: 20)
|
||||
create_transaction(account: liability_account, date: 2.days.ago.to_date, amount: -333)
|
||||
create_transaction(account: liability_account, date: 2.days.ago.to_date, amount: -333, category: categories(:payment))
|
||||
|
||||
snapshot = @family.snapshot_transactions
|
||||
|
||||
|
|
7
test/models/goal_test.rb
Normal file
7
test/models/goal_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class GoalTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
28
test/models/transfer_matcher_test.rb
Normal file
28
test/models/transfer_matcher_test.rb
Normal file
|
@ -0,0 +1,28 @@
|
|||
require "test_helper"
|
||||
|
||||
class TransferMatcherTest < ActiveSupport::TestCase
|
||||
include Account::EntriesTestHelper
|
||||
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@matcher = TransferMatcher.new(@family)
|
||||
end
|
||||
|
||||
test "matches entries with opposite amounts and different accounts within 4 days" do
|
||||
entry1 = create_transaction(account: accounts(:depository), amount: 100, date: Date.current)
|
||||
entry2 = create_transaction(account: accounts(:credit_card), amount: -100, date: 2.days.ago.to_date)
|
||||
|
||||
assert_difference "Account::Transfer.count", 1 do
|
||||
@matcher.match!([ entry1, entry2 ])
|
||||
end
|
||||
end
|
||||
|
||||
test "doesn't match entries more than 4 days apart" do
|
||||
entry1 = create_transaction(account: accounts(:depository), amount: 100, date: Date.current)
|
||||
entry2 = create_transaction(account: accounts(:credit_card), amount: -100, date: Date.current + 5.days)
|
||||
|
||||
assert_no_difference "Account::Transfer.count" do
|
||||
@matcher.match!([ entry1, entry2 ])
|
||||
end
|
||||
end
|
||||
end
|
|
@ -210,7 +210,7 @@ class TransactionsTest < ApplicationSystemTestCase
|
|||
end
|
||||
|
||||
def number_of_transactions_on_page
|
||||
[ @user.family.entries.without_transfers.count, @page_size ].min
|
||||
[ @user.family.entries.count, @page_size ].min
|
||||
end
|
||||
|
||||
def all_transactions_checkbox
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue