diff --git a/app/controllers/account/transactions_controller.rb b/app/controllers/account/transactions_controller.rb index 6784aac6..98c1a140 100644 --- a/app/controllers/account/transactions_controller.rb +++ b/app/controllers/account/transactions_controller.rb @@ -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 diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb index 359a241a..c2c79bc9 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/account/entries_helper.rb @@ -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) diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 8d6a8f40..e4ba1fc6 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -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 diff --git a/app/models/account/entry_search.rb b/app/models/account/entry_search.rb index c561765b..1c82fe52 100644 --- a/app/models/account/entry_search.rb +++ b/app/models/account/entry_search.rb @@ -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") diff --git a/app/models/account/trade_builder.rb b/app/models/account/trade_builder.rb index e62947f7..5f256d54 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/account/trade_builder.rb @@ -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 diff --git a/app/models/account/transfer.rb b/app/models/account/transfer.rb index ea908413..5272fa43 100644 --- a/app/models/account/transfer.rb +++ b/app/models/account/transfer.rb @@ -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 diff --git a/app/models/budget.rb b/app/models/budget.rb new file mode 100644 index 00000000..4f98ee61 --- /dev/null +++ b/app/models/budget.rb @@ -0,0 +1,4 @@ +class Budget < ApplicationRecord + belongs_to :family + has_many :budget_categories, dependent: :destroy +end diff --git a/app/models/budget_category.rb b/app/models/budget_category.rb new file mode 100644 index 00000000..1893254f --- /dev/null +++ b/app/models/budget_category.rb @@ -0,0 +1,4 @@ +class BudgetCategory < ApplicationRecord + belongs_to :budget + belongs_to :category +end diff --git a/app/models/category.rb b/app/models/category.rb index 6f50070b..7b20c30f 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -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 } diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 8041dea6..79f56b77 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -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") diff --git a/app/models/family.rb b/app/models/family.rb index 69ac5eb7..e01f6a45 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -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 = [] diff --git a/app/models/goal.rb b/app/models/goal.rb new file mode 100644 index 00000000..d0117031 --- /dev/null +++ b/app/models/goal.rb @@ -0,0 +1,5 @@ +class Goal < ApplicationRecord + belongs_to :family + + enum :type, { saving: "saving" } +end diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index 5772f821..2bdfb6af 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -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 diff --git a/app/models/plaid_investment_sync.rb b/app/models/plaid_investment_sync.rb index fd207116..4d06aaea 100644 --- a/app/models/plaid_investment_sync.rb +++ b/app/models/plaid_investment_sync.rb @@ -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| diff --git a/app/models/saving_goal.rb b/app/models/saving_goal.rb new file mode 100644 index 00000000..0d041e15 --- /dev/null +++ b/app/models/saving_goal.rb @@ -0,0 +1,2 @@ +class SavingGoal < Goal +end diff --git a/app/models/transfer_matcher.rb b/app/models/transfer_matcher.rb new file mode 100644 index 00000000..158b1555 --- /dev/null +++ b/app/models/transfer_matcher.rb @@ -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 diff --git a/app/views/account/transactions/_header.html.erb b/app/views/account/transactions/_header.html.erb index af819326..a18dba98 100644 --- a/app/views/account/transactions/_header.html.erb +++ b/app/views/account/transactions/_header.html.erb @@ -12,7 +12,7 @@ - <% 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 %> diff --git a/app/views/account/transactions/show.html.erb b/app/views/account/transactions/show.html.erb index b512b43a..d3060594 100644 --- a/app/views/account/transactions/show.html.erb +++ b/app/views/account/transactions/show.html.erb @@ -19,7 +19,7 @@ max: Date.current, "data-auto-submit-form-target": "auto" %> - <% unless @entry.marked_as_transfer? %> + <% unless @entry.entryable.category&.transfer? %>
<%= 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, diff --git a/app/views/application/_pagination.html.erb b/app/views/application/_pagination.html.erb index f4548512..13da2cd9 100644 --- a/app/views/application/_pagination.html.erb +++ b/app/views/application/_pagination.html.erb @@ -3,7 +3,7 @@
<% 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 @@
<% 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 @@
<% 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") %> diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb index 2bca2191..f217e45e 100644 --- a/app/views/categories/_form.html.erb +++ b/app/views/categories/_form.html.erb @@ -22,6 +22,7 @@
<%= 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)" } %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 8c8f1d15..ca2da86d 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -13,7 +13,6 @@ <%= combobox_style_tag %> <%= javascript_importmap_tags %> - <%= hotwire_livereload_tags if Rails.env.development? %> <%= turbo_refreshes_with method: :morph, scroll: :preserve %> <%= javascript_importmap_tags %> - <%= hotwire_livereload_tags if Rails.env.development? %> <%= turbo_refreshes_with method: :morph, scroll: :preserve %> diff --git a/app/views/transactions/searches/filters/_type_filter.html.erb b/app/views/transactions/searches/filters/_type_filter.html.erb index 5effc7e9..dafb939d 100644 --- a/app/views/transactions/searches/filters/_type_filter.html.erb +++ b/app/views/transactions/searches/filters/_type_filter.html.erb @@ -23,15 +23,4 @@ nil %> <%= form.label :types, t(".expense"), value: "expense", class: "text-sm text-gray-900" %>
-
- <%= 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" %> -
diff --git a/config/locales/models/account/transfer/en.yml b/config/locales/models/account/transfer/en.yml index c0fe38d5..a6888595 100644 --- a/config/locales/models/account/transfer/en.yml +++ b/config/locales/models/account/transfer/en.yml @@ -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 + diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index 5e6ab631..e4535ec3 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -26,7 +26,6 @@ en: type_filter: expense: Expense income: Income - transfer: Transfer menu: account_filter: Account amount_filter: Amount diff --git a/db/migrate/20241230154019_create_budgets.rb b/db/migrate/20241230154019_create_budgets.rb new file mode 100644 index 00000000..8edbe815 --- /dev/null +++ b/db/migrate/20241230154019_create_budgets.rb @@ -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 diff --git a/db/migrate/20241230155132_create_budget_categories.rb b/db/migrate/20241230155132_create_budget_categories.rb new file mode 100644 index 00000000..e610f684 --- /dev/null +++ b/db/migrate/20241230155132_create_budget_categories.rb @@ -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 diff --git a/db/migrate/20241230162744_create_goals.rb b/db/migrate/20241230162744_create_goals.rb new file mode 100644 index 00000000..3cfad28f --- /dev/null +++ b/db/migrate/20241230162744_create_goals.rb @@ -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 diff --git a/db/migrate/20241230164615_income_category.rb b/db/migrate/20241230164615_income_category.rb new file mode 100644 index 00000000..1a10bc47 --- /dev/null +++ b/db/migrate/20241230164615_income_category.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 0bdce796..0667bfae 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/test/controllers/account/trades_controller_test.rb b/test/controllers/account/trades_controller_test.rb index cdfd6add..c5ccc109 100644 --- a/test/controllers/account/trades_controller_test.rb +++ b/test/controllers/account/trades_controller_test.rb @@ -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 diff --git a/test/fixtures/account/entries.yml b/test/fixtures/account/entries.yml index 680710be..8ccf5fb7 100644 --- a/test/fixtures/account/entries.yml +++ b/test/fixtures/account/entries.yml @@ -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 diff --git a/test/fixtures/account/transactions.yml b/test/fixtures/account/transactions.yml index 426d7d58..8c320653 100644 --- a/test/fixtures/account/transactions.yml +++ b/test/fixtures/account/transactions.yml @@ -2,5 +2,8 @@ one: category: food_and_drink merchant: amazon -transfer_out: { } -transfer_in: { } \ No newline at end of file +transfer_out: + category: transfer + +transfer_in: + category: transfer \ No newline at end of file diff --git a/test/fixtures/budget_categories.yml b/test/fixtures/budget_categories.yml new file mode 100644 index 00000000..4fb4ed01 --- /dev/null +++ b/test/fixtures/budget_categories.yml @@ -0,0 +1,4 @@ +food_and_drink: + budget: one + category: food_and_drink + budgeted_amount: 800 \ No newline at end of file diff --git a/test/fixtures/budgets.yml b/test/fixtures/budgets.yml new file mode 100644 index 00000000..e1d6b7a7 --- /dev/null +++ b/test/fixtures/budgets.yml @@ -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 diff --git a/test/fixtures/categories.yml b/test/fixtures/categories.yml index 958e450e..ba65e506 100644 --- a/test/fixtures/categories.yml +++ b/test/fixtures/categories.yml @@ -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 diff --git a/test/fixtures/goals.yml b/test/fixtures/goals.yml new file mode 100644 index 00000000..de954377 --- /dev/null +++ b/test/fixtures/goals.yml @@ -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 diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb index 6d22179c..7bb32f92 100644 --- a/test/models/account/entry_test.rb +++ b/test/models/account/entry_test.rb @@ -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 diff --git a/test/models/account/transfer_test.rb b/test/models/account/transfer_test.rb index 2c9265c6..fd317205 100644 --- a/test/models/account/transfer_test.rb +++ b/test/models/account/transfer_test.rb @@ -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 diff --git a/test/models/budget_category_test.rb b/test/models/budget_category_test.rb new file mode 100644 index 00000000..897f8fa8 --- /dev/null +++ b/test/models/budget_category_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class BudgetCategoryTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/budget_test.rb b/test/models/budget_test.rb new file mode 100644 index 00000000..9d870970 --- /dev/null +++ b/test/models/budget_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class BudgetTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/family_test.rb b/test/models/family_test.rb index 74376a7e..24c71b33 100644 --- a/test/models/family_test.rb +++ b/test/models/family_test.rb @@ -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 diff --git a/test/models/goal_test.rb b/test/models/goal_test.rb new file mode 100644 index 00000000..6046e883 --- /dev/null +++ b/test/models/goal_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class GoalTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/transfer_matcher_test.rb b/test/models/transfer_matcher_test.rb new file mode 100644 index 00000000..f8ac6cd0 --- /dev/null +++ b/test/models/transfer_matcher_test.rb @@ -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 diff --git a/test/system/transactions_test.rb b/test/system/transactions_test.rb index 26db48c3..76ab1363 100644 --- a/test/system/transactions_test.rb +++ b/test/system/transactions_test.rb @@ -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