1
0
Fork 0
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:
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

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

View file

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

View file

@ -26,7 +26,6 @@ en:
type_filter:
expense: Expense
income: Income
transfer: Transfer
menu:
account_filter: Account
amount_filter: Amount

View 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

View 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

View 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

View 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
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_12_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"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
require "test_helper"
class BudgetCategoryTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class BudgetTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

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

@ -0,0 +1,7 @@
require "test_helper"
class GoalTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View 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

View file

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