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? %>