diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc index 2977dc33..17cee2e0 100644 --- a/.cursor/rules/project-conventions.mdc +++ b/.cursor/rules/project-conventions.mdc @@ -71,7 +71,7 @@ Due to the open-source nature of this project, we have chosen Minitest + Fixture - Always use Minitest and fixtures for testing. - Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed. -- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb) +- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb) - Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence #### Convention 5a: Write minimal, effective tests @@ -87,26 +87,26 @@ Below are examples of necessary vs. unnecessary tests: # GOOD!! # Necessary test - in this case, we're testing critical domain business logic test "syncs balances" do - Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once + Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once @account.expects(:start_date).returns(2.days.ago.to_date) - Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns( + Balance::ForwardCalculator.any_instance.expects(:calculate).returns( [ - Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), - Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") + Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"), + Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD") ] ) assert_difference "@account.balances.count", 2 do - Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances + Balance::Syncer.new(@account, strategy: :forward).sync_balances end end # BAD!! # Unnecessary test - in this case, this is simply testing ActiveRecord's functionality test "saves balance" do - balance_record = Account::Balance.new(balance: 100, currency: "USD") + balance_record = Balance.new(balance: 100, currency: "USD") assert balance_record.save end diff --git a/.cursor/rules/project-design.mdc b/.cursor/rules/project-design.mdc index 41fa2210..b2d13e9f 100644 --- a/.cursor/rules/project-design.mdc +++ b/.cursor/rules/project-design.mdc @@ -55,29 +55,29 @@ All balances are calculated daily by [balance_calculator.rb](mdc:app/models/acco ### Account Holdings -An account [holding.rb](mdc:app/models/account/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`. +An account [holding.rb](mdc:app/models/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`. -For investment accounts with holdings, [holding_calculator.rb](mdc:app/models/account/holding_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb). +For investment accounts with holdings, [base_calculator.rb](mdc:app/models/holding/base_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb). ### Account Entries -An account [entry.rb](mdc:app/models/account/entry.rb) is also a Rails "delegated type". `Account::Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/account/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`. +An account [entry.rb](mdc:app/models/entry.rb) is also a Rails "delegated type". `Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`. -The `amount` of an [entry.rb](mdc:app/models/account/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example: +The `amount` of an [entry.rb](mdc:app/models/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example: - A negative amount for a credit card account represents a "payment" to that account, which _reduces_ its balance (since it is a `liability`) - A negative amount for a checking account represents an "income" to that account, which _increases_ its balance (since it is an `asset`) - A negative amount for an investment/brokerage trade represents a "sell" transaction, which _increases_ the cash balance of the account -There are 3 entry types, defined as [entryable.rb](mdc:app/models/account/entryable.rb) records: +There are 3 entry types, defined as [entryable.rb](mdc:app/models/entryable.rb) records: -- `Account::Valuation` - an account [valuation.rb](mdc:app/models/account/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Account::Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today. -- `Account::Transaction` - an account [transaction.rb](mdc:app/models/account/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense". -- `Account::Trade` - an account [trade.rb](mdc:app/models/account/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`. +- `Valuation` - an account [valuation.rb](mdc:app/models/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today. +- `Transaction` - an account [transaction.rb](mdc:app/models/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense". +- `Trade` - an account [trade.rb](mdc:app/models/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`. ### Account Transfers -A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/account/transaction.rb) and an outflow [transaction.rb](mdc:app/models/account/transaction.rb). The Maybe system auto-matches transfers based on the following criteria: +A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/transaction.rb) and an outflow [transaction.rb](mdc:app/models/transaction.rb). The Maybe system auto-matches transfers based on the following criteria: - Must be from different accounts - Must be within 4 days of each other @@ -115,10 +115,10 @@ The most important type of sync is the account sync. It is orchestrated by the - Auto-matches transfer records for the account - Calculates daily [balance.rb](mdc:app/models/account/balance.rb) records for the account from `account.start_date` to `Date.current` using [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb) - - Balances are dependent on the calculation of [holding.rb](mdc:app/models/account/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb) + - Balances are dependent on the calculation of [holding.rb](mdc:app/models/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb) - Enriches transaction data if enabled by user -An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated. +An account sync happens every time an [entry.rb](mdc:app/models/entry.rb) is updated. ### Plaid Item Syncs @@ -126,7 +126,7 @@ A Plaid Item sync is an ETL (extract, transform, load) operation: 1. [plaid_item.rb](mdc:app/models/plaid_item.rb) fetches data from the external Plaid API 2. [plaid_item.rb](mdc:app/models/plaid_item.rb) creates and loads this data to [plaid_account.rb](mdc:app/models/plaid_account.rb) records -3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/account/entry.rb), the internal Maybe representations of the data. +3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/entry.rb), the internal Maybe representations of the data. ### Family Syncs diff --git a/app/controllers/account/trades_controller.rb b/app/controllers/account/trades_controller.rb deleted file mode 100644 index fd9b7d48..00000000 --- a/app/controllers/account/trades_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -class Account::TradesController < ApplicationController - include EntryableResource - - permitted_entryable_attributes :id, :qty, :price - - private - def build_entry - Account::TradeBuilder.new(create_entry_params) - end - - def create_entry_params - params.require(:account_entry).permit( - :account_id, :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id - ).tap do |params| - account_id = params.delete(:account_id) - params[:account] = Current.family.accounts.find(account_id) - end - end - - def update_entry_params - return entry_params unless entry_params[:entryable_attributes].present? - - update_params = entry_params - update_params = update_params.merge(entryable_type: "Account::Trade") - - qty = update_params[:entryable_attributes][:qty] - price = update_params[:entryable_attributes][:price] - - if qty.present? && price.present? - qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d - update_params[:entryable_attributes][:qty] = qty - update_params[:amount] = qty * price.to_d - end - - update_params.except(:nature) - end -end diff --git a/app/controllers/account/transaction_categories_controller.rb b/app/controllers/account/transaction_categories_controller.rb deleted file mode 100644 index 5920a0b3..00000000 --- a/app/controllers/account/transaction_categories_controller.rb +++ /dev/null @@ -1,22 +0,0 @@ -class Account::TransactionCategoriesController < ApplicationController - def update - @entry = Current.family.entries.account_transactions.find(params[:transaction_id]) - @entry.update!(entry_params) - - respond_to do |format| - format.html { redirect_back_or_to account_transaction_path(@entry) } - format.turbo_stream do - render turbo_stream: turbo_stream.replace( - "category_menu_account_transaction_#{@entry.account_transaction_id}", - partial: "categories/menu", - locals: { transaction: @entry.account_transaction } - ) - end - end - end - - private - def entry_params - params.require(:account_entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ]) - end -end diff --git a/app/controllers/account/transactions_controller.rb b/app/controllers/account/transactions_controller.rb deleted file mode 100644 index 8125565c..00000000 --- a/app/controllers/account/transactions_controller.rb +++ /dev/null @@ -1,37 +0,0 @@ -class Account::TransactionsController < ApplicationController - include EntryableResource - - permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] } - - def bulk_delete - destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids]) - destroyed.map(&:account).uniq.each(&:sync_later) - redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count) - end - - def bulk_edit - end - - def bulk_update - updated = Current.family - .entries - .where(id: bulk_update_params[:entry_ids]) - .bulk_update!(bulk_update_params) - - redirect_back_or_to transactions_url, notice: t(".success", count: updated) - end - - private - def bulk_delete_params - params.require(:bulk_delete).permit(entry_ids: []) - end - - def bulk_update_params - params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [], tag_ids: []) - end - - def search_params - params.fetch(:q, {}) - .permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: []) - end -end diff --git a/app/controllers/account/valuations_controller.rb b/app/controllers/account/valuations_controller.rb deleted file mode 100644 index 08f566f3..00000000 --- a/app/controllers/account/valuations_controller.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Account::ValuationsController < ApplicationController - include EntryableResource -end diff --git a/app/controllers/budget_categories_controller.rb b/app/controllers/budget_categories_controller.rb index dcd96262..e8cc83e6 100644 --- a/app/controllers/budget_categories_controller.rb +++ b/app/controllers/budget_categories_controller.rb @@ -11,14 +11,14 @@ class BudgetCategoriesController < ApplicationController if params[:id] == BudgetCategory.uncategorized.id @budget_category = @budget.uncategorized_budget_category - @recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil }) + @recent_transactions = @recent_transactions.where(transactions: { category_id: nil }) else @budget_category = Current.family.budget_categories.find(params[:id]) - @recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id") + @recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = transactions.category_id") .where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id) end - @recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3) + @recent_transactions = @recent_transactions.order("entries.date DESC, ABS(entries.amount) DESC").take(3) end def update diff --git a/app/controllers/concerns/entryable_resource.rb b/app/controllers/concerns/entryable_resource.rb index 58519725..443b0483 100644 --- a/app/controllers/concerns/entryable_resource.rb +++ b/app/controllers/concerns/entryable_resource.rb @@ -2,14 +2,9 @@ module EntryableResource extend ActiveSupport::Concern included do - before_action :set_entry, only: %i[show update destroy] - end + include StreamExtensions, ActionView::RecordIdentifier - class_methods do - def permitted_entryable_attributes(*attrs) - @permitted_entryable_attributes = attrs if attrs.any? - @permitted_entryable_attributes ||= [ :id ] - end + before_action :set_entry, only: %i[show update destroy] end def show @@ -21,49 +16,16 @@ module EntryableResource @entry = Current.family.entries.new( account: account, currency: account ? account.currency : Current.family.currency, - entryable: entryable_type.new + entryable: entryable ) end def create - @entry = build_entry - - if @entry.save - @entry.sync_account_later - - flash[:notice] = t("account.entries.create.success") - - respond_to do |format| - format.html { redirect_back_or_to account_path(@entry.account) } - - redirect_target_url = request.referer || account_path(@entry.account) - format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) } - end - else - render :new, status: :unprocessable_entity - end + raise NotImplementedError, "Entryable resources must implement #create" end def update - if @entry.update(update_entry_params) - @entry.sync_account_later - - respond_to do |format| - format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") } - format.turbo_stream do - render turbo_stream: [ - turbo_stream.replace( - "header_account_entry_#{@entry.id}", - partial: "#{entryable_type.name.underscore.pluralize}/header", - locals: { entry: @entry } - ), - turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry }) - ] - end - end - else - render :show, status: :unprocessable_entity - end + raise NotImplementedError, "Entryable resources must implement #update" end def destroy @@ -71,58 +33,15 @@ module EntryableResource @entry.destroy! @entry.sync_account_later - flash[:notice] = t("account.entries.destroy.success") - - respond_to do |format| - format.html { redirect_back_or_to account_path(account) } - - redirect_target_url = request.referer || account_path(@entry.account) - format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) } - end + redirect_back_or_to account_path(account), notice: t("account.entries.destroy.success") end private - def entryable_type - permitted_entryable_types = %w[Account::Transaction Account::Valuation Account::Trade] - klass = params[:entryable_type] || "Account::#{controller_name.classify}" - klass.constantize if permitted_entryable_types.include?(klass) + def entryable + controller_name.classify.constantize.new end def set_entry @entry = Current.family.entries.find(params[:id]) end - - def build_entry - Current.family.entries.new(create_entry_params) - end - - def update_entry_params - prepared_entry_params - end - - def create_entry_params - prepared_entry_params.merge({ - entryable_type: entryable_type.name, - entryable_attributes: entry_params[:entryable_attributes] || {} - }) - end - - def prepared_entry_params - default_params = entry_params.except(:nature) - default_params = default_params.merge(entryable_type: entryable_type.name) if entry_params[:entryable_attributes].present? - - if entry_params[:nature].present? && entry_params[:amount].present? - signed_amount = entry_params[:nature] == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d - default_params = default_params.merge(amount: signed_amount) - end - - default_params - end - - def entry_params - params.require(:account_entry).permit( - :account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, - entryable_attributes: self.class.permitted_entryable_attributes - ) - end end diff --git a/app/controllers/concerns/stream_extensions.rb b/app/controllers/concerns/stream_extensions.rb new file mode 100644 index 00000000..978d5bfb --- /dev/null +++ b/app/controllers/concerns/stream_extensions.rb @@ -0,0 +1,20 @@ +module StreamExtensions + extend ActiveSupport::Concern + + def stream_redirect_to(path, notice: nil, alert: nil) + custom_stream_redirect(path, notice: notice, alert: alert) + end + + def stream_redirect_back_or_to(path, notice: nil, alert: nil) + custom_stream_redirect(path, redirect_back: true, notice: notice, alert: alert) + end + + private + def custom_stream_redirect(path, redirect_back: false, notice: nil, alert: nil) + flash[:notice] = notice if notice.present? + flash[:alert] = alert if alert.present? + + redirect_target_url = redirect_back ? request.referer : path + render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) + end +end diff --git a/app/controllers/account/holdings_controller.rb b/app/controllers/holdings_controller.rb similarity index 92% rename from app/controllers/account/holdings_controller.rb rename to app/controllers/holdings_controller.rb index 9ded4165..db9d59b4 100644 --- a/app/controllers/account/holdings_controller.rb +++ b/app/controllers/holdings_controller.rb @@ -1,4 +1,4 @@ -class Account::HoldingsController < ApplicationController +class HoldingsController < ApplicationController before_action :set_holding, only: %i[show destroy] def index diff --git a/app/controllers/trades_controller.rb b/app/controllers/trades_controller.rb new file mode 100644 index 00000000..151d62c6 --- /dev/null +++ b/app/controllers/trades_controller.rb @@ -0,0 +1,79 @@ +class TradesController < ApplicationController + include EntryableResource + + def create + @entry = build_entry + + if @entry.save + @entry.sync_account_later + + flash[:notice] = t("entries.create.success") + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account) } + format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) } + end + else + render :new, status: :unprocessable_entity + end + end + + def update + if @entry.update(update_entry_params) + @entry.sync_account_later + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account), notice: t("entries.update.success") } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace( + "header_entry_#{@entry.id}", + partial: "trades/header", + locals: { entry: @entry } + ), + turbo_stream.replace("entry_#{@entry.id}", partial: "entries/entry", locals: { entry: @entry }) + ] + end + end + else + render :show, status: :unprocessable_entity + end + end + + private + def build_entry + account = Current.family.accounts.find(params.dig(:entry, :account_id)) + TradeBuilder.new(create_entry_params.merge(account: account)) + end + + def entry_params + params.require(:entry).permit( + :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, + entryable_attributes: [ :id, :qty, :price ] + ) + end + + def create_entry_params + params.require(:entry).permit( + :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id + ) + end + + def update_entry_params + return entry_params unless entry_params[:entryable_attributes].present? + + update_params = entry_params + update_params = update_params.merge(entryable_type: "Trade") + + qty = update_params[:entryable_attributes][:qty] + price = update_params[:entryable_attributes][:price] + + if qty.present? && price.present? + qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d + update_params[:entryable_attributes][:qty] = qty + update_params[:amount] = qty * price.to_d + end + + update_params.except(:nature) + end +end diff --git a/app/controllers/transaction_categories_controller.rb b/app/controllers/transaction_categories_controller.rb new file mode 100644 index 00000000..f70e0aa9 --- /dev/null +++ b/app/controllers/transaction_categories_controller.rb @@ -0,0 +1,22 @@ +class TransactionCategoriesController < ApplicationController + def update + @entry = Current.family.entries.transactions.find(params[:transaction_id]) + @entry.update!(entry_params) + + respond_to do |format| + format.html { redirect_back_or_to transaction_path(@entry) } + format.turbo_stream do + render turbo_stream: turbo_stream.replace( + "category_menu_transaction_#{@entry.transaction_id}", + partial: "categories/menu", + locals: { transaction: @entry.transaction } + ) + end + end + end + + private + def entry_params + params.require(:entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ]) + end +end diff --git a/app/controllers/transactions/bulk_deletions_controller.rb b/app/controllers/transactions/bulk_deletions_controller.rb new file mode 100644 index 00000000..fefaf389 --- /dev/null +++ b/app/controllers/transactions/bulk_deletions_controller.rb @@ -0,0 +1,12 @@ +class Transactions::BulkDeletionsController < ApplicationController + def create + destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids]) + destroyed.map(&:account).uniq.each(&:sync_later) + redirect_back_or_to transactions_url, notice: "#{destroyed.count} transaction#{destroyed.count == 1 ? "" : "s"} deleted" + end + + private + def bulk_delete_params + params.require(:bulk_delete).permit(entry_ids: []) + end +end diff --git a/app/controllers/transactions/bulk_updates_controller.rb b/app/controllers/transactions/bulk_updates_controller.rb new file mode 100644 index 00000000..08c4befe --- /dev/null +++ b/app/controllers/transactions/bulk_updates_controller.rb @@ -0,0 +1,19 @@ +class Transactions::BulkUpdatesController < ApplicationController + def new + end + + def create + updated = Current.family + .entries + .where(id: bulk_update_params[:entry_ids]) + .bulk_update!(bulk_update_params) + + redirect_back_or_to transactions_path, notice: "#{updated} transactions updated" + end + + private + def bulk_update_params + params.require(:bulk_update) + .permit(:date, :notes, :category_id, :merchant_id, entry_ids: [], tag_ids: []) + end +end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index 484c53a0..e4407ee3 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -1,5 +1,5 @@ class TransactionsController < ApplicationController - include ScrollFocusable + include ScrollFocusable, EntryableResource before_action :store_params!, only: :index @@ -48,7 +48,62 @@ class TransactionsController < ApplicationController redirect_to transactions_path(updated_params) end + def create + account = Current.family.accounts.find(params.dig(:entry, :account_id)) + @entry = account.entries.new(entry_params) + + if @entry.save + @entry.sync_account_later + + flash[:notice] = "Transaction created" + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account) } + format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) } + end + else + render :new, status: :unprocessable_entity + end + end + + def update + if @entry.update(entry_params) + @entry.sync_account_later + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace( + dom_id(@entry, :header), + partial: "transactions/header", + locals: { entry: @entry } + ), + turbo_stream.replace(@entry) + ] + end + end + else + render :show, status: :unprocessable_entity + end + end + private + def entry_params + entry_params = params.require(:entry).permit( + :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type, + entryable_attributes: [ :id, :category_id, :merchant_id, { tag_ids: [] } ] + ) + + nature = entry_params.delete(:nature) + + if nature.present? && entry_params[:amount].present? + signed_amount = nature == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d + entry_params = entry_params.merge(amount: signed_amount) + end + + entry_params + end def search_params cleaned_params = params.fetch(:q, {}) diff --git a/app/controllers/account/transfer_matches_controller.rb b/app/controllers/transfer_matches_controller.rb similarity index 73% rename from app/controllers/account/transfer_matches_controller.rb rename to app/controllers/transfer_matches_controller.rb index 851f3ac3..415d9377 100644 --- a/app/controllers/account/transfer_matches_controller.rb +++ b/app/controllers/transfer_matches_controller.rb @@ -1,9 +1,9 @@ -class Account::TransferMatchesController < ApplicationController +class TransferMatchesController < ApplicationController before_action :set_entry def new @accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id) - @transfer_match_candidates = @entry.account_transaction.transfer_match_candidates + @transfer_match_candidates = @entry.transaction.transfer_match_candidates end def create @@ -11,7 +11,7 @@ class Account::TransferMatchesController < ApplicationController @transfer.save! @transfer.sync_account_later - redirect_back_or_to transactions_path, notice: t(".success") + redirect_back_or_to transactions_path, notice: "Transfer created" end private @@ -27,7 +27,7 @@ class Account::TransferMatchesController < ApplicationController if transfer_match_params[:method] == "new" target_account = Current.family.accounts.find(transfer_match_params[:target_account_id]) - missing_transaction = Account::Transaction.new( + missing_transaction = Transaction.new( entry: target_account.entries.build( amount: @entry.amount * -1, currency: @entry.currency, @@ -37,8 +37,8 @@ class Account::TransferMatchesController < ApplicationController ) transfer = Transfer.find_or_initialize_by( - inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction, - outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction + inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.transaction, + outflow_transaction: @entry.amount.positive? ? @entry.transaction : missing_transaction ) transfer.status = "confirmed" transfer @@ -46,8 +46,8 @@ class Account::TransferMatchesController < ApplicationController target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id]) transfer = Transfer.find_or_initialize_by( - inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction, - outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction + inflow_transaction: @entry.amount.negative? ? @entry.transaction : target_transaction.transaction, + outflow_transaction: @entry.amount.negative? ? target_transaction.transaction : @entry.transaction ) transfer.status = "confirmed" transfer diff --git a/app/controllers/valuations_controller.rb b/app/controllers/valuations_controller.rb new file mode 100644 index 00000000..7d91a9a6 --- /dev/null +++ b/app/controllers/valuations_controller.rb @@ -0,0 +1,49 @@ +class ValuationsController < ApplicationController + include EntryableResource + + def create + account = Current.family.accounts.find(params.dig(:entry, :account_id)) + @entry = account.entries.new(entry_params.merge(entryable: Valuation.new)) + + if @entry.save + @entry.sync_account_later + + flash[:notice] = "Balance created" + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account) } + format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) } + end + else + render :new, status: :unprocessable_entity + end + end + + def update + if @entry.update(entry_params) + @entry.sync_account_later + + respond_to do |format| + format.html { redirect_back_or_to account_path(@entry.account), notice: "Balance updated" } + format.turbo_stream do + render turbo_stream: [ + turbo_stream.replace( + dom_id(@entry, :header), + partial: "valuations/header", + locals: { entry: @entry } + ), + turbo_stream.replace(@entry) + ] + end + end + else + render :show, status: :unprocessable_entity + end + end + + private + def entry_params + params.require(:entry) + .permit(:name, :enriched_name, :date, :amount, :currency, :notes) + end +end diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/entries_helper.rb similarity index 78% rename from app/helpers/account/entries_helper.rb rename to app/helpers/entries_helper.rb index 5fac75cc..e198e6ee 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/entries_helper.rb @@ -1,8 +1,8 @@ -module Account::EntriesHelper +module EntriesHelper def entries_by_date(entries, totals: false) transfer_groups = entries.group_by do |entry| # Only check for transfer if it's a transaction - next nil unless entry.entryable_type == "Account::Transaction" + next nil unless entry.entryable_type == "Transaction" entry.entryable.transfer&.id end @@ -12,7 +12,7 @@ module Account::EntriesHelper grouped_entries else grouped_entries.reject do |e| - e.entryable_type == "Account::Transaction" && + e.entryable_type == "Transaction" && e.entryable.transfer_as_inflow.present? end end @@ -25,7 +25,7 @@ module Account::EntriesHelper next if content.blank? - render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: } + render partial: "entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: } end.compact.join.html_safe end diff --git a/app/models/account.rb b/app/models/account.rb index b93a13e1..42a9d67f 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -7,11 +7,11 @@ class Account < ApplicationRecord belongs_to :import, optional: true has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" - has_many :entries, dependent: :destroy, class_name: "Account::Entry" - has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction" - has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation" - has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade" - has_many :holdings, dependent: :destroy, class_name: "Account::Holding" + has_many :entries, dependent: :destroy + has_many :transactions, through: :entries, source: :entryable, source_type: "Transaction" + has_many :valuations, through: :entries, source: :entryable, source_type: "Valuation" + has_many :trades, through: :entries, source: :entryable, source_type: "Trade" + has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy monetize :balance, :cash_balance @@ -43,14 +43,14 @@ class Account < ApplicationRecord date: Date.current, amount: account.balance, currency: account.currency, - entryable: Account::Valuation.new + entryable: Valuation.new ) account.entries.build( name: "Initial Balance", date: 1.day.ago.to_date, amount: initial_balance, currency: account.currency, - entryable: Account::Valuation.new + entryable: Valuation.new ) account.save! @@ -113,7 +113,7 @@ class Account < ApplicationRecord end def update_balance!(balance) - valuation = entries.account_valuations.find_by(date: Date.current) + valuation = entries.valuations.find_by(date: Date.current) if valuation valuation.update! amount: balance @@ -123,7 +123,7 @@ class Account < ApplicationRecord name: "Balance update", amount: balance, currency: currency, - entryable: Account::Valuation.new + entryable: Valuation.new end end @@ -148,7 +148,7 @@ class Account < ApplicationRecord end def first_valuation - entries.account_valuations.order(:date).first + entries.valuations.order(:date).first end def first_valuation_amount diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index bac6a50e..d9a6c44b 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -7,7 +7,7 @@ module Account::Chartable series_interval = interval || period.interval - balances = Account::Balance.find_by_sql([ + balances = Balance.find_by_sql([ balance_series_query, { start_date: period.start_date, @@ -61,7 +61,7 @@ module Account::Chartable COUNT(CASE WHEN accounts.currency <> :target_currency AND er.rate IS NULL THEN 1 END) as missing_rates FROM dates d LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql}) - LEFT JOIN account_balances ab ON ( + LEFT JOIN balances ab ON ( ab.date = d.date AND ab.currency = accounts.currency AND ab.account_id = accounts.id diff --git a/app/models/account/enrichable.rb b/app/models/account/enrichable.rb index 260aec5a..f5f175b4 100644 --- a/app/models/account/enrichable.rb +++ b/app/models/account/enrichable.rb @@ -2,9 +2,9 @@ module Account::Enrichable extend ActiveSupport::Concern def enrich_data - total_unenriched = entries.account_transactions - .joins("JOIN account_transactions at ON at.id = account_entries.entryable_id AND account_entries.entryable_type = 'Account::Transaction'") - .where("account_entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") + total_unenriched = entries.transactions + .joins("JOIN transactions at ON at.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where("entries.enriched_at IS NULL OR at.merchant_id IS NULL OR at.category_id IS NULL") .count if total_unenriched > 0 @@ -63,7 +63,7 @@ module Account::Enrichable transactions.active .includes(:merchant, :category) .where( - "account_entries.enriched_at IS NULL", + "entries.enriched_at IS NULL", "OR merchant_id IS NULL", "OR category_id IS NULL" ) diff --git a/app/models/account/valuation.rb b/app/models/account/valuation.rb deleted file mode 100644 index 219ecd90..00000000 --- a/app/models/account/valuation.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Account::Valuation < ApplicationRecord - include Account::Entryable -end diff --git a/app/models/account_import.rb b/app/models/account_import.rb index 98e7e0d0..96fdfd47 100644 --- a/app/models/account_import.rb +++ b/app/models/account_import.rb @@ -20,7 +20,7 @@ class AccountImport < Import currency: row.currency, date: Date.current, name: "Imported account value", - entryable: Account::Valuation.new + entryable: Valuation.new ) end end diff --git a/app/models/account/balance.rb b/app/models/balance.rb similarity index 85% rename from app/models/account/balance.rb rename to app/models/balance.rb index 5d4e3710..90c4df41 100644 --- a/app/models/account/balance.rb +++ b/app/models/balance.rb @@ -1,4 +1,4 @@ -class Account::Balance < ApplicationRecord +class Balance < ApplicationRecord include Monetizable belongs_to :account diff --git a/app/models/account/balance/base_calculator.rb b/app/models/balance/base_calculator.rb similarity index 84% rename from app/models/account/balance/base_calculator.rb rename to app/models/balance/base_calculator.rb index 7acb51e8..2d01dfe7 100644 --- a/app/models/account/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -1,4 +1,4 @@ -class Account::Balance::BaseCalculator +class Balance::BaseCalculator attr_reader :account def initialize(account) @@ -13,11 +13,11 @@ class Account::Balance::BaseCalculator private def sync_cache - @sync_cache ||= Account::Balance::SyncCache.new(account) + @sync_cache ||= Balance::SyncCache.new(account) end def build_balance(date, cash_balance, holdings_value) - Account::Balance.new( + Balance.new( account_id: account.id, date: date, balance: holdings_value + cash_balance, diff --git a/app/models/account/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb similarity index 90% rename from app/models/account/balance/forward_calculator.rb rename to app/models/balance/forward_calculator.rb index 503e5b79..d024d2c6 100644 --- a/app/models/account/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -1,4 +1,4 @@ -class Account::Balance::ForwardCalculator < Account::Balance::BaseCalculator +class Balance::ForwardCalculator < Balance::BaseCalculator private def calculate_balances current_cash_balance = 0 diff --git a/app/models/account/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb similarity index 92% rename from app/models/account/balance/reverse_calculator.rb rename to app/models/balance/reverse_calculator.rb index 151f4036..4c124ced 100644 --- a/app/models/account/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -1,4 +1,4 @@ -class Account::Balance::ReverseCalculator < Account::Balance::BaseCalculator +class Balance::ReverseCalculator < Balance::BaseCalculator private def calculate_balances current_cash_balance = account.cash_balance diff --git a/app/models/account/balance/sync_cache.rb b/app/models/balance/sync_cache.rb similarity index 83% rename from app/models/account/balance/sync_cache.rb rename to app/models/balance/sync_cache.rb index 1fb7ea7f..aed2b64e 100644 --- a/app/models/account/balance/sync_cache.rb +++ b/app/models/balance/sync_cache.rb @@ -1,10 +1,10 @@ -class Account::Balance::SyncCache +class Balance::SyncCache def initialize(account) @account = account end def get_valuation(date) - converted_entries.find { |e| e.date == date && e.account_valuation? } + converted_entries.find { |e| e.date == date && e.valuation? } end def get_holdings(date) @@ -12,7 +12,7 @@ class Account::Balance::SyncCache end def get_entries(date) - converted_entries.select { |e| e.date == date && (e.account_transaction? || e.account_trade?) } + converted_entries.select { |e| e.date == date && (e.transaction? || e.trade?) } end private diff --git a/app/models/account/balance/syncer.rb b/app/models/balance/syncer.rb similarity index 86% rename from app/models/account/balance/syncer.rb rename to app/models/balance/syncer.rb index 7aeaebda..362b87aa 100644 --- a/app/models/account/balance/syncer.rb +++ b/app/models/balance/syncer.rb @@ -1,4 +1,4 @@ -class Account::Balance::Syncer +class Balance::Syncer attr_reader :account, :strategy def initialize(account, strategy:) @@ -7,7 +7,7 @@ class Account::Balance::Syncer end def sync_balances - Account::Balance.transaction do + Balance.transaction do sync_holdings calculate_balances @@ -26,7 +26,7 @@ class Account::Balance::Syncer private def sync_holdings - @holdings = Account::Holding::Syncer.new(account, strategy: strategy).sync_holdings + @holdings = Holding::Syncer.new(account, strategy: strategy).sync_holdings end def update_account_info @@ -63,9 +63,9 @@ class Account::Balance::Syncer def calculator if strategy == :reverse - Account::Balance::ReverseCalculator.new(account) + Balance::ReverseCalculator.new(account) else - Account::Balance::ForwardCalculator.new(account) + Balance::ForwardCalculator.new(account) end end end diff --git a/app/models/account/balance_trend_calculator.rb b/app/models/balance/trend_calculator.rb similarity index 95% rename from app/models/account/balance_trend_calculator.rb rename to app/models/balance/trend_calculator.rb index a9bfeb30..5fb8b406 100644 --- a/app/models/account/balance_trend_calculator.rb +++ b/app/models/balance/trend_calculator.rb @@ -2,7 +2,7 @@ # In most cases, this is sufficient. However, for the "Activity View", we need to show intraday balances # to show users how each entry affects their balances. This class calculates intraday balances by # interpolating between end-of-day balances. -class Account::BalanceTrendCalculator +class Balance::TrendCalculator BalanceTrend = Struct.new(:trend, :cash, keyword_init: true) class << self @@ -48,12 +48,12 @@ class Account::BalanceTrendCalculator todays_entries = entries.select { |e| e.date == entry.date } todays_entries.each_with_index do |e, idx| - if e.account_valuation? + if e.valuation? current_balance = e.amount current_cash_balance = e.amount else multiplier = e.account.liability? ? 1 : -1 - balance_change = e.account_trade? ? 0 : multiplier * e.amount + balance_change = e.trade? ? 0 : multiplier * e.amount cash_change = multiplier * e.amount current_balance = prior_balance + balance_change diff --git a/app/models/category.rb b/app/models/category.rb index 2adc7788..56fd3a63 100644 --- a/app/models/category.rb +++ b/app/models/category.rb @@ -1,5 +1,5 @@ class Category < ApplicationRecord - has_many :transactions, dependent: :nullify, class_name: "Account::Transaction" + has_many :transactions, dependent: :nullify, class_name: "Transaction" has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" belongs_to :family diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 9d62e684..181b9806 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -361,7 +361,7 @@ class Demo::Generator unknown = Security.find_by(ticker: "UNKNOWN") # Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices - account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown, currency: "USD") + account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Trade.new(qty: 20, price: 5, security: unknown, currency: "USD") trades = [ { security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 }, @@ -382,7 +382,7 @@ class Demo::Generator amount: qty * price, currency: "USD", name: name_prefix + "#{qty} shares of #{security.ticker}", - entryable: Account::Trade.new(qty: qty, price: price, currency: "USD", security: security) + entryable: Trade.new(qty: qty, price: price, currency: "USD", security: security) end end @@ -450,20 +450,20 @@ class Demo::Generator entry_defaults = { date: Faker::Number.between(from: 0, to: 730).days.ago.to_date, currency: "USD", - entryable: Account::Transaction.new(transaction_attributes) + entryable: Transaction.new(transaction_attributes) } - Account::Entry.create! entry_defaults.merge(entry_attributes) + Entry.create! entry_defaults.merge(entry_attributes) end def create_valuation!(account, date, amount) - Account::Entry.create! \ + Entry.create! \ account: account, date: date, amount: amount, currency: "USD", name: "Balance update", - entryable: Account::Valuation.new + entryable: Valuation.new end def random_family_record(model, family) diff --git a/app/models/account/entry.rb b/app/models/entry.rb similarity index 78% rename from app/models/account/entry.rb rename to app/models/entry.rb index 4e6292a1..0f35cd39 100644 --- a/app/models/account/entry.rb +++ b/app/models/entry.rb @@ -1,4 +1,4 @@ -class Account::Entry < ApplicationRecord +class Entry < ApplicationRecord include Monetizable monetize :amount @@ -7,11 +7,11 @@ class Account::Entry < ApplicationRecord belongs_to :transfer, optional: true belongs_to :import, optional: true - delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy + delegated_type :entryable, types: Entryable::TYPES, dependent: :destroy accepts_nested_attributes_for :entryable validates :date, :name, :amount, :currency, presence: true - validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? } + validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { valuation? } validates :date, comparison: { greater_than: -> { min_supported_date } } scope :active, -> { @@ -21,7 +21,7 @@ class Account::Entry < ApplicationRecord scope :chronological, -> { order( date: :asc, - Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc, + Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :asc, created_at: :asc ) } @@ -29,7 +29,7 @@ class Account::Entry < ApplicationRecord scope :reverse_chronological, -> { order( date: :desc, - Arel.sql("CASE WHEN account_entries.entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc, + Arel.sql("CASE WHEN entries.entryable_type = 'Valuation' THEN 1 ELSE 0 END") => :desc, created_at: :desc ) } @@ -44,7 +44,7 @@ class Account::Entry < ApplicationRecord end def balance_trend(entries, balances) - Account::BalanceTrendCalculator.new(self, entries, balances).trend + Balance::TrendCalculator.new(self, entries, balances).trend end def display_name @@ -53,7 +53,7 @@ class Account::Entry < ApplicationRecord class << self def search(params) - Account::EntrySearch.new(params).build_query(all) + EntrySearch.new(params).build_query(all) end # arbitrary cutoff date to avoid expensive sync operations diff --git a/app/models/account/entry_search.rb b/app/models/entry_search.rb similarity index 74% rename from app/models/account/entry_search.rb rename to app/models/entry_search.rb index b08c338f..bed87613 100644 --- a/app/models/account/entry_search.rb +++ b/app/models/entry_search.rb @@ -1,4 +1,4 @@ -class Account::EntrySearch +class EntrySearch include ActiveModel::Model include ActiveModel::Attributes @@ -16,7 +16,7 @@ class Account::EntrySearch return scope if search.blank? query = scope - query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search", + query = query.where("entries.name ILIKE :search OR entries.enriched_name ILIKE :search", search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%" ) query @@ -26,8 +26,8 @@ class Account::EntrySearch return scope if start_date.blank? && end_date.blank? query = scope - query = query.where("account_entries.date >= ?", start_date) if start_date.present? - query = query.where("account_entries.date <= ?", end_date) if end_date.present? + query = query.where("entries.date >= ?", start_date) if start_date.present? + query = query.where("entries.date <= ?", end_date) if end_date.present? query end @@ -38,11 +38,11 @@ class Account::EntrySearch case amount_operator when "equal" - query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs) + query = query.where("ABS(ABS(entries.amount) - ?) <= 0.01", amount.to_f.abs) when "less" - query = query.where("ABS(account_entries.amount) < ?", amount.to_f.abs) + query = query.where("ABS(entries.amount) < ?", amount.to_f.abs) when "greater" - query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs) + query = query.where("ABS(entries.amount) > ?", amount.to_f.abs) end query diff --git a/app/models/account/entryable.rb b/app/models/entryable.rb similarity index 50% rename from app/models/account/entryable.rb rename to app/models/entryable.rb index 91df5521..84ab6c12 100644 --- a/app/models/account/entryable.rb +++ b/app/models/entryable.rb @@ -1,7 +1,7 @@ -module Account::Entryable +module Entryable extend ActiveSupport::Concern - TYPES = %w[Account::Valuation Account::Transaction Account::Trade] + TYPES = %w[Valuation Transaction Trade] def self.from_type(entryable_type) entryable_type.presence_in(TYPES).constantize @@ -12,18 +12,18 @@ module Account::Entryable scope :with_entry, -> { joins(:entry) } - scope :active, -> { with_entry.merge(Account::Entry.active) } + scope :active, -> { with_entry.merge(Entry.active) } scope :in_period, ->(period) { - with_entry.where(account_entries: { date: period.start_date..period.end_date }) + with_entry.where(entries: { date: period.start_date..period.end_date }) } scope :reverse_chronological, -> { - with_entry.merge(Account::Entry.reverse_chronological) + with_entry.merge(Entry.reverse_chronological) } scope :chronological, -> { - with_entry.merge(Account::Entry.chronological) + with_entry.merge(Entry.chronological) } end end diff --git a/app/models/family/auto_transfer_matchable.rb b/app/models/family/auto_transfer_matchable.rb index 32fe94b4..388ba5d6 100644 --- a/app/models/family/auto_transfer_matchable.rb +++ b/app/models/family/auto_transfer_matchable.rb @@ -1,12 +1,12 @@ module Family::AutoTransferMatchable def transfer_match_candidates - Account::Entry.select([ + Entry.select([ "inflow_candidates.entryable_id as inflow_transaction_id", "outflow_candidates.entryable_id as outflow_transaction_id", "ABS(inflow_candidates.date - outflow_candidates.date) as date_diff" - ]).from("account_entries inflow_candidates") + ]).from("entries inflow_candidates") .joins(" - JOIN account_entries outflow_candidates ON ( + JOIN entries outflow_candidates ON ( inflow_candidates.amount < 0 AND outflow_candidates.amount > 0 AND inflow_candidates.amount = -outflow_candidates.amount AND @@ -29,7 +29,7 @@ module Family::AutoTransferMatchable .where("inflow_accounts.family_id = ? AND outflow_accounts.family_id = ?", self.id, self.id) .where("inflow_accounts.is_active = true") .where("outflow_accounts.is_active = true") - .where("inflow_candidates.entryable_type = 'Account::Transaction' AND outflow_candidates.entryable_type = 'Account::Transaction'") + .where("inflow_candidates.entryable_type = 'Transaction' AND outflow_candidates.entryable_type = 'Transaction'") .where(existing_transfers: { id: nil }) .order("date_diff ASC") # Closest matches first end diff --git a/app/models/account/holding.rb b/app/models/holding.rb similarity index 79% rename from app/models/account/holding.rb rename to app/models/holding.rb index ba7a7e2d..fb9b001e 100644 --- a/app/models/account/holding.rb +++ b/app/models/holding.rb @@ -1,4 +1,4 @@ -class Account::Holding < ApplicationRecord +class Holding < ApplicationRecord include Monetizable, Gapfillable monetize :amount @@ -27,9 +27,9 @@ class Account::Holding < ApplicationRecord # Basic approximation of cost-basis def avg_cost - avg_cost = account.entries.account_trades - .joins("INNER JOIN account_trades ON account_trades.id = account_entries.entryable_id") - .where("account_trades.security_id = ? AND account_trades.qty > 0 AND account_entries.date <= ?", security.id, date) + avg_cost = account.entries.trades + .joins("INNER JOIN trades ON trades.id = entries.entryable_id") + .where("trades.security_id = ? AND trades.qty > 0 AND entries.date <= ?", security.id, date) .average(:price) Money.new(avg_cost || price, currency) diff --git a/app/models/account/holding/base_calculator.rb b/app/models/holding/base_calculator.rb similarity index 88% rename from app/models/account/holding/base_calculator.rb rename to app/models/holding/base_calculator.rb index 4359e9ab..47178d8b 100644 --- a/app/models/account/holding/base_calculator.rb +++ b/app/models/holding/base_calculator.rb @@ -1,4 +1,4 @@ -class Account::Holding::BaseCalculator +class Holding::BaseCalculator attr_reader :account def initialize(account) @@ -8,13 +8,13 @@ class Account::Holding::BaseCalculator def calculate Rails.logger.tagged(self.class.name) do holdings = calculate_holdings - Account::Holding.gapfill(holdings) + Holding.gapfill(holdings) end end private def portfolio_cache - @portfolio_cache ||= Account::Holding::PortfolioCache.new(account) + @portfolio_cache ||= Holding::PortfolioCache.new(account) end def empty_portfolio @@ -49,7 +49,7 @@ class Account::Holding::BaseCalculator next end - Account::Holding.new( + Holding.new( account_id: account.id, security_id: security_id, date: date, diff --git a/app/models/account/holding/forward_calculator.rb b/app/models/holding/forward_calculator.rb similarity index 77% rename from app/models/account/holding/forward_calculator.rb rename to app/models/holding/forward_calculator.rb index afb6b71f..d2f2e8d7 100644 --- a/app/models/account/holding/forward_calculator.rb +++ b/app/models/holding/forward_calculator.rb @@ -1,7 +1,7 @@ -class Account::Holding::ForwardCalculator < Account::Holding::BaseCalculator +class Holding::ForwardCalculator < Holding::BaseCalculator private def portfolio_cache - @portfolio_cache ||= Account::Holding::PortfolioCache.new(account) + @portfolio_cache ||= Holding::PortfolioCache.new(account) end def calculate_holdings diff --git a/app/models/account/holding/gapfillable.rb b/app/models/holding/gapfillable.rb similarity index 91% rename from app/models/account/holding/gapfillable.rb rename to app/models/holding/gapfillable.rb index e2462a6f..45c05089 100644 --- a/app/models/account/holding/gapfillable.rb +++ b/app/models/holding/gapfillable.rb @@ -1,4 +1,4 @@ -module Account::Holding::Gapfillable +module Holding::Gapfillable extend ActiveSupport::Concern class_methods do @@ -19,7 +19,7 @@ module Account::Holding::Gapfillable previous_holding = holding else # Create a new holding based on the previous day's data - filled_holdings << Account::Holding.new( + filled_holdings << Holding.new( account: previous_holding.account, security: previous_holding.security, date: date, diff --git a/app/models/account/holding/portfolio_cache.rb b/app/models/holding/portfolio_cache.rb similarity index 98% rename from app/models/account/holding/portfolio_cache.rb rename to app/models/holding/portfolio_cache.rb index 224d0b83..e8d3fcec 100644 --- a/app/models/account/holding/portfolio_cache.rb +++ b/app/models/holding/portfolio_cache.rb @@ -1,4 +1,4 @@ -class Account::Holding::PortfolioCache +class Holding::PortfolioCache attr_reader :account, :use_holdings class SecurityNotFound < StandardError @@ -49,7 +49,7 @@ class Account::Holding::PortfolioCache PriceWithPriority = Data.define(:price, :priority) def trades - @trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a + @trades ||= account.entries.includes(entryable: :security).trades.chronological.to_a end def holdings diff --git a/app/models/account/holding/reverse_calculator.rb b/app/models/holding/reverse_calculator.rb similarity index 87% rename from app/models/account/holding/reverse_calculator.rb rename to app/models/holding/reverse_calculator.rb index d3677c88..f3996e5f 100644 --- a/app/models/account/holding/reverse_calculator.rb +++ b/app/models/holding/reverse_calculator.rb @@ -1,10 +1,10 @@ -class Account::Holding::ReverseCalculator < Account::Holding::BaseCalculator +class Holding::ReverseCalculator < Holding::BaseCalculator private # Reverse calculators will use the existing holdings as a source of security ids and prices # since it is common for a provider to supply "current day" holdings but not all the historical # trades that make up those holdings. def portfolio_cache - @portfolio_cache ||= Account::Holding::PortfolioCache.new(account, use_holdings: true) + @portfolio_cache ||= Holding::PortfolioCache.new(account, use_holdings: true) end def calculate_holdings diff --git a/app/models/account/holding/syncer.rb b/app/models/holding/syncer.rb similarity index 84% rename from app/models/account/holding/syncer.rb rename to app/models/holding/syncer.rb index bfccd6f0..345f2a3f 100644 --- a/app/models/account/holding/syncer.rb +++ b/app/models/holding/syncer.rb @@ -1,4 +1,4 @@ -class Account::Holding::Syncer +class Holding::Syncer def initialize(account, strategy:) @account = account @strategy = strategy @@ -36,7 +36,7 @@ class Account::Holding::Syncer end def purge_stale_holdings - portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq + portfolio_security_ids = account.entries.trades.map { |entry| entry.entryable.security_id }.uniq # If there are no securities in the portfolio, delete all holdings if portfolio_security_ids.empty? @@ -50,9 +50,9 @@ class Account::Holding::Syncer def calculator if strategy == :reverse - Account::Holding::ReverseCalculator.new(account) + Holding::ReverseCalculator.new(account) else - Account::Holding::ForwardCalculator.new(account) + Holding::ForwardCalculator.new(account) end end end diff --git a/app/models/import.rb b/app/models/import.rb index 662b4cee..bb367cf9 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -34,7 +34,7 @@ class Import < ApplicationRecord has_many :rows, dependent: :destroy has_many :mappings, dependent: :destroy has_many :accounts, dependent: :destroy - has_many :entries, dependent: :destroy, class_name: "Account::Entry" + has_many :entries, dependent: :destroy class << self def parse_csv_str(csv_str, col_sep: ",") diff --git a/app/models/import/row.rb b/app/models/import/row.rb index 622a9d0a..350d8084 100644 --- a/app/models/import/row.rb +++ b/app/models/import/row.rb @@ -63,7 +63,7 @@ class Import::Row < ApplicationRecord return end - min_date = Account::Entry.min_supported_date + min_date = Entry.min_supported_date max_date = Date.current if parsed_date < min_date || parsed_date > max_date diff --git a/app/models/income_statement/base_query.rb b/app/models/income_statement/base_query.rb index d2b17b81..ef1c8a99 100644 --- a/app/models/income_statement/base_query.rb +++ b/app/models/income_statement/base_query.rb @@ -11,13 +11,13 @@ module IncomeStatement::BaseQuery COUNT(ae.id) as transactions_count, BOOL_OR(ae.currency <> :target_currency AND er.rate IS NULL) as missing_exchange_rates FROM (#{transactions_scope.to_sql}) at - JOIN account_entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Account::Transaction' + JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction' LEFT JOIN categories c ON c.id = at.category_id LEFT JOIN ( SELECT t.*, t.id as transfer_id, a.accountable_type FROM transfers t - JOIN account_entries ae ON ae.entryable_id = t.inflow_transaction_id - AND ae.entryable_type = 'Account::Transaction' + JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id + AND ae.entryable_type = 'Transaction' JOIN accounts a ON a.id = ae.account_id ) transfer_info ON ( transfer_info.inflow_transaction_id = at.id OR diff --git a/app/models/merchant.rb b/app/models/merchant.rb index e363f6aa..030d9409 100644 --- a/app/models/merchant.rb +++ b/app/models/merchant.rb @@ -1,5 +1,5 @@ class Merchant < ApplicationRecord - has_many :transactions, dependent: :nullify, class_name: "Account::Transaction" + has_many :transactions, dependent: :nullify, class_name: "Transaction" belongs_to :family validates :name, :color, :family, presence: true diff --git a/app/models/mint_import.rb b/app/models/mint_import.rb index 66e3bb69..da9ced2a 100644 --- a/app/models/mint_import.rb +++ b/app/models/mint_import.rb @@ -35,7 +35,7 @@ class MintImport < Import name: row.name, currency: row.currency, notes: row.notes, - entryable: Account::Transaction.new(category: category, tags: tags), + entryable: Transaction.new(category: category, tags: tags), import: self entry.save! diff --git a/app/models/plaid_account.rb b/app/models/plaid_account.rb index e0e71f67..4f2b923c 100644 --- a/app/models/plaid_account.rb +++ b/app/models/plaid_account.rb @@ -87,7 +87,7 @@ class PlaidAccount < ApplicationRecord t.amount = plaid_txn.amount t.currency = plaid_txn.iso_currency_code t.date = plaid_txn.date - t.entryable = Account::Transaction.new( + t.entryable = Transaction.new( category: get_category(plaid_txn.personal_finance_category.primary), merchant: get_merchant(plaid_txn.merchant_name) ) @@ -120,7 +120,7 @@ class PlaidAccount < ApplicationRecord e.amount = loan_data.origination_principal_amount e.currency = account.currency e.date = loan_data.origination_date - e.entryable = Account::Valuation.new + e.entryable = Valuation.new end end end diff --git a/app/models/plaid_investment_sync.rb b/app/models/plaid_investment_sync.rb index bcb1f330..489b0ca1 100644 --- a/app/models/plaid_investment_sync.rb +++ b/app/models/plaid_investment_sync.rb @@ -31,7 +31,7 @@ class PlaidInvestmentSync t.amount = transaction.amount t.currency = transaction.iso_currency_code t.date = transaction.date - t.entryable = Account::Transaction.new + t.entryable = Transaction.new end else new_transaction = plaid_account.account.entries.find_or_create_by!(plaid_id: transaction.investment_transaction_id) do |t| @@ -39,7 +39,7 @@ class PlaidInvestmentSync t.amount = transaction.quantity * transaction.price t.currency = transaction.iso_currency_code t.date = transaction.date - t.entryable = Account::Trade.new( + t.entryable = Trade.new( security: security, qty: transaction.quantity, price: transaction.price, diff --git a/app/models/property.rb b/app/models/property.rb index b30a1071..42d2979e 100644 --- a/app/models/property.rb +++ b/app/models/property.rb @@ -44,6 +44,6 @@ class Property < ApplicationRecord private def first_valuation_amount - account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money + account.entries.valuations.order(:date).first&.amount_money || account.balance_money end end diff --git a/app/models/rejected_transfer.rb b/app/models/rejected_transfer.rb index 9d1a1ce4..4c66d44d 100644 --- a/app/models/rejected_transfer.rb +++ b/app/models/rejected_transfer.rb @@ -1,4 +1,4 @@ class RejectedTransfer < ApplicationRecord - belongs_to :inflow_transaction, class_name: "Account::Transaction" - belongs_to :outflow_transaction, class_name: "Account::Transaction" + belongs_to :inflow_transaction, class_name: "Transaction" + belongs_to :outflow_transaction, class_name: "Transaction" end diff --git a/app/models/security.rb b/app/models/security.rb index 30abbe85..0adabd8a 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -3,7 +3,7 @@ class Security < ApplicationRecord before_save :upcase_ticker - has_many :trades, dependent: :nullify, class_name: "Account::Trade" + has_many :trades, dependent: :nullify, class_name: "Trade" has_many :prices, dependent: :destroy validates :ticker, presence: true diff --git a/app/models/tag.rb b/app/models/tag.rb index 6b2fb67b..c5bdc0bc 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -1,7 +1,7 @@ class Tag < ApplicationRecord belongs_to :family has_many :taggings, dependent: :destroy - has_many :transactions, through: :taggings, source: :taggable, source_type: "Account::Transaction" + has_many :transactions, through: :taggings, source: :taggable, source_type: "Transaction" has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping" validates :name, presence: true, uniqueness: { scope: :family } diff --git a/app/models/account/trade.rb b/app/models/trade.rb similarity index 83% rename from app/models/account/trade.rb rename to app/models/trade.rb index a683b2ca..5d71d978 100644 --- a/app/models/account/trade.rb +++ b/app/models/trade.rb @@ -1,5 +1,5 @@ -class Account::Trade < ApplicationRecord - include Account::Entryable, Monetizable +class Trade < ApplicationRecord + include Entryable, Monetizable monetize :price diff --git a/app/models/account/trade_builder.rb b/app/models/trade_builder.rb similarity index 94% rename from app/models/account/trade_builder.rb rename to app/models/trade_builder.rb index c632f272..9b7e0471 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/trade_builder.rb @@ -1,4 +1,4 @@ -class Account::TradeBuilder +class TradeBuilder include ActiveModel::Model attr_accessor :account, :date, :amount, :currency, :qty, @@ -46,7 +46,7 @@ class Account::TradeBuilder date: date, amount: signed_amount, currency: currency, - entryable: Account::Trade.new( + entryable: Trade.new( qty: signed_qty, price: price, currency: currency, @@ -74,7 +74,7 @@ class Account::TradeBuilder date: date, amount: signed_amount, currency: currency, - entryable: Account::Transaction.new + entryable: Transaction.new ) end end @@ -85,7 +85,7 @@ class Account::TradeBuilder date: date, amount: signed_amount, currency: currency, - entryable: Account::Transaction.new + entryable: Transaction.new ) end diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index db917f04..d126fec0 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -16,12 +16,12 @@ class TradeImport < Import exchange_operating_mic: row.exchange_operating_mic ) - Account::Trade.new( + Trade.new( security: security, qty: row.qty, currency: row.currency.presence || mapped_account.currency, price: row.price, - entry: Account::Entry.new( + entry: Entry.new( account: mapped_account, date: row.date_iso, amount: row.signed_amount, @@ -31,7 +31,7 @@ class TradeImport < Import ), ) end - Account::Trade.import!(trades, recursive: true) + Trade.import!(trades, recursive: true) end end diff --git a/app/models/account/transaction.rb b/app/models/transaction.rb similarity index 64% rename from app/models/account/transaction.rb rename to app/models/transaction.rb index e31a5607..fb5fb0df 100644 --- a/app/models/account/transaction.rb +++ b/app/models/transaction.rb @@ -1,5 +1,5 @@ -class Account::Transaction < ApplicationRecord - include Account::Entryable, Transferable, Provided +class Transaction < ApplicationRecord + include Entryable, Transferable, Provided belongs_to :category, optional: true belongs_to :merchant, optional: true @@ -11,7 +11,7 @@ class Account::Transaction < ApplicationRecord class << self def search(params) - Account::TransactionSearch.new(params).build_query(all) + Search.new(params).build_query(all) end end end diff --git a/app/models/account/transaction/provided.rb b/app/models/transaction/provided.rb similarity index 89% rename from app/models/account/transaction/provided.rb rename to app/models/transaction/provided.rb index 4bae0ab4..b4210e0a 100644 --- a/app/models/account/transaction/provided.rb +++ b/app/models/transaction/provided.rb @@ -1,4 +1,4 @@ -module Account::Transaction::Provided +module Transaction::Provided extend ActiveSupport::Concern def fetch_enrichment_info diff --git a/app/models/account/transaction_search.rb b/app/models/transaction/search.rb similarity index 77% rename from app/models/account/transaction_search.rb rename to app/models/transaction/search.rb index 215c6a98..067050f4 100644 --- a/app/models/account/transaction_search.rb +++ b/app/models/transaction/search.rb @@ -1,4 +1,4 @@ -class Account::TransactionSearch +class Transaction::Search include ActiveModel::Model include ActiveModel::Attributes @@ -22,10 +22,10 @@ class Account::TransactionSearch query = apply_type_filter(query, types) query = apply_merchant_filter(query, merchants) query = apply_tag_filter(query, tags) - query = Account::EntrySearch.apply_search_filter(query, search) - query = Account::EntrySearch.apply_date_filters(query, start_date, end_date) - query = Account::EntrySearch.apply_amount_filter(query, amount, amount_operator) - query = Account::EntrySearch.apply_accounts_filter(query, accounts, account_ids) + query = EntrySearch.apply_search_filter(query, search) + query = EntrySearch.apply_date_filters(query, start_date, end_date) + query = EntrySearch.apply_amount_filter(query, amount, amount_operator) + query = EntrySearch.apply_accounts_filter(query, accounts, account_ids) query end @@ -36,12 +36,12 @@ class Account::TransactionSearch LEFT JOIN ( SELECT t.*, t.id as transfer_id, a.accountable_type FROM transfers t - JOIN account_entries ae ON ae.entryable_id = t.inflow_transaction_id - AND ae.entryable_type = 'Account::Transaction' + JOIN entries ae ON ae.entryable_id = t.inflow_transaction_id + AND ae.entryable_type = 'Transaction' JOIN accounts a ON a.id = ae.account_id ) transfer_info ON ( - transfer_info.inflow_transaction_id = account_transactions.id OR - transfer_info.outflow_transaction_id = account_transactions.id + transfer_info.inflow_transaction_id = transactions.id OR + transfer_info.outflow_transaction_id = transactions.id ) SQL end @@ -68,8 +68,8 @@ class Account::TransactionSearch return query if types.sort == [ "expense", "income", "transfer" ] transfer_condition = "transfer_info.transfer_id IS NOT NULL" - expense_condition = "account_entries.amount >= 0" - income_condition = "account_entries.amount <= 0" + expense_condition = "entries.amount >= 0" + income_condition = "entries.amount <= 0" condition = case types.sort when [ "transfer" ] diff --git a/app/models/account/transaction/transferable.rb b/app/models/transaction/transferable.rb similarity index 96% rename from app/models/account/transaction/transferable.rb rename to app/models/transaction/transferable.rb index de0b70cb..d839047a 100644 --- a/app/models/account/transaction/transferable.rb +++ b/app/models/transaction/transferable.rb @@ -1,4 +1,4 @@ -module Account::Transaction::Transferable +module Transaction::Transferable extend ActiveSupport::Concern included do diff --git a/app/models/transaction_import.rb b/app/models/transaction_import.rb index cf3f6e12..dd44cca4 100644 --- a/app/models/transaction_import.rb +++ b/app/models/transaction_import.rb @@ -13,10 +13,10 @@ class TransactionImport < Import category = mappings.categories.mappable_for(row.category) tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact - Account::Transaction.new( + Transaction.new( category: category, tags: tags, - entry: Account::Entry.new( + entry: Entry.new( account: mapped_account, date: row.date_iso, amount: row.signed_amount, @@ -28,7 +28,7 @@ class TransactionImport < Import ) end - Account::Transaction.import!(transactions, recursive: true) + Transaction.import!(transactions, recursive: true) end end diff --git a/app/models/transfer.rb b/app/models/transfer.rb index 3cb1f07b..d681d581 100644 --- a/app/models/transfer.rb +++ b/app/models/transfer.rb @@ -1,6 +1,6 @@ class Transfer < ApplicationRecord - belongs_to :inflow_transaction, class_name: "Account::Transaction" - belongs_to :outflow_transaction, class_name: "Account::Transaction" + belongs_to :inflow_transaction, class_name: "Transaction" + belongs_to :outflow_transaction, class_name: "Transaction" enum :status, { pending: "pending", confirmed: "confirmed" } @@ -23,22 +23,22 @@ class Transfer < ApplicationRecord end new( - inflow_transaction: Account::Transaction.new( + inflow_transaction: Transaction.new( entry: to_account.entries.build( amount: converted_amount.amount.abs * -1, currency: converted_amount.currency.iso_code, date: date, name: "Transfer from #{from_account.name}", - entryable: Account::Transaction.new + entryable: Transaction.new ) ), - outflow_transaction: Account::Transaction.new( + outflow_transaction: Transaction.new( entry: from_account.entries.build( amount: amount.abs, currency: from_account.currency, date: date, name: "Transfer to #{to_account.name}", - entryable: Account::Transaction.new + entryable: Transaction.new ) ), status: "confirmed" diff --git a/app/models/valuation.rb b/app/models/valuation.rb new file mode 100644 index 00000000..6d1d2b4b --- /dev/null +++ b/app/models/valuation.rb @@ -0,0 +1,3 @@ +class Valuation < ApplicationRecord + include Entryable +end diff --git a/app/models/vehicle.rb b/app/models/vehicle.rb index 6ba19540..255e11d6 100644 --- a/app/models/vehicle.rb +++ b/app/models/vehicle.rb @@ -31,6 +31,6 @@ class Vehicle < ApplicationRecord private def first_valuation_amount - account.entries.account_valuations.order(:date).first&.amount_money || account.balance_money + account.entries.valuations.order(:date).first&.amount_money || account.balance_money end end diff --git a/app/views/account/transactions/_header.html.erb b/app/views/account/transactions/_header.html.erb deleted file mode 100644 index 83f9d1b6..00000000 --- a/app/views/account/transactions/_header.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -<%# locals: (entry:) %> - -<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %> -