From c3248cd796b3052e72502e7b18ee75d9467770c1 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 27 Nov 2024 16:01:50 -0500 Subject: [PATCH] Improve account transaction, trade, and valuation editing and sync experience (#1506) * Consolidate entry controller logic * Transaction builder * Update trades controller to use new params * Load account charts in turbo frames, fix PG overflow * Consolidate tests * Tests passing * Remove unused code * Add client side trade form validations --- app/controllers/account/cashes_controller.rb | 9 +- app/controllers/account/entries_controller.rb | 45 +------ .../account/holdings_controller.rb | 20 +-- app/controllers/account/trades_controller.rb | 86 ++++-------- .../transaction_categories_controller.rb | 22 +++ .../account/transactions_controller.rb | 97 ++++++-------- .../account/valuations_controller.rb | 37 +---- app/controllers/accounts_controller.rb | 5 + .../concerns/entryable_resource.rb | 126 ++++++++++++++++++ app/controllers/securities_controller.rb | 17 ++- app/controllers/transactions_controller.rb | 91 +------------ app/helpers/application_helper.rb | 4 +- app/javascript/application.js | 4 + app/javascript/controllers/application.js | 4 +- .../controllers/modal_controller.js | 10 +- .../controllers/trade_form_controller.js | 70 +--------- app/models/account.rb | 2 +- app/models/account/entry.rb | 6 +- app/models/account/entry_builder.rb | 46 ------- app/models/account/trade.rb | 3 +- app/models/account/trade_builder.rb | 116 ++++++++++++---- app/models/account/transaction_builder.rb | 64 --------- app/models/account/transfer.rb | 4 + app/models/concerns/accountable.rb | 3 +- app/models/family.rb | 1 + app/models/investment.rb | 2 +- app/models/provider/synth.rb | 4 +- app/models/security.rb | 22 ++- app/models/security/synth_combobox_option.rb | 14 -- app/views/account/entries/_entry.html.erb | 4 +- .../account/entries/_selection_bar.html.erb | 2 +- app/views/account/entries/index.html.erb | 6 +- app/views/account/holdings/_holding.html.erb | 2 +- app/views/account/holdings/show.html.erb | 4 +- app/views/account/trades/_form.html.erb | 58 +++++--- app/views/account/trades/_header.html.erb | 68 ++++++++++ .../account/trades/_security.turbo_stream.erb | 11 -- .../account/trades/_selection_bar.html.erb | 2 +- app/views/account/trades/_trade.html.erb | 12 +- .../trades/securities.turbo_stream.erb | 2 - app/views/account/trades/show.html.erb | 94 ++++--------- .../{ => account}/transactions/_form.html.erb | 20 ++- .../account/transactions/_header.html.erb | 23 ++++ .../transactions/_selection_bar.html.erb | 6 +- .../transactions/_transaction.html.erb | 6 +- .../transactions/bulk_edit.html.erb | 4 +- app/views/account/transactions/index.html.erb | 2 +- .../{ => account}/transactions/new.html.erb | 2 +- app/views/account/transactions/show.html.erb | 98 +++++--------- app/views/account/transfers/_form.html.erb | 4 +- .../transfers/_transfer_toggle.html.erb | 2 +- app/views/account/valuations/_form.html.erb | 11 +- app/views/account/valuations/_header.html.erb | 19 +++ .../account/valuations/_valuation.html.erb | 4 +- app/views/account/valuations/show.html.erb | 30 +---- app/views/accounts/_chart_loader.html.erb | 5 + app/views/accounts/chart.html.erb | 32 +++++ app/views/accounts/show/_activity.html.erb | 2 +- app/views/accounts/show/_chart.html.erb | 25 +--- app/views/accounts/show/_tab.html.erb | 1 + app/views/categories/_menu.html.erb | 7 +- app/views/category/dropdowns/_row.html.erb | 13 +- app/views/category/dropdowns/show.html.erb | 2 +- app/views/investments/_cash_tab.html.erb | 2 +- app/views/investments/_chart.html.erb | 4 - app/views/investments/_holdings_tab.html.erb | 2 +- app/views/layouts/application.html.erb | 2 +- .../_combobox_security.turbo_stream.erb | 11 ++ app/views/securities/index.turbo_stream.erb | 2 + app/views/shared/_drawer.html.erb | 7 +- app/views/shared/_form_errors.html.erb | 6 + app/views/shared/_notification.html.erb | 5 +- app/views/shared/_syncing_notice.html.erb | 7 + app/views/transactions/_header.html.erb | 2 +- app/views/transactions/index.html.erb | 2 +- app/views/transactions/rules.html.erb | 16 --- config/brakeman.ignore | 25 +++- config/locales/views/account/entries/en.yml | 2 + config/locales/views/account/holdings/en.yml | 2 + config/locales/views/account/trades/en.yml | 20 ++- .../locales/views/account/transactions/en.yml | 41 +++++- .../locales/views/account/valuations/en.yml | 5 +- config/locales/views/accounts/en.yml | 3 +- config/locales/views/layout/en.yml | 2 - config/locales/views/shared/en.yml | 2 + config/locales/views/transactions/en.yml | 37 ----- config/routes.rb | 56 +++++--- ...20241126211249_add_logo_url_to_security.rb | 5 + db/schema.rb | 3 +- .../account/entries_controller_test.rb | 60 +-------- .../account/holdings_controller_test.rb | 10 +- .../account/trades_controller_test.rb | 68 +++++++--- .../account/transactions_controller_test.rb | 113 +++++++++++++--- .../account/valuations_controller_test.rb | 71 +++++----- .../transactions_controller_test.rb | 119 ----------------- .../entryable_resource_interface_test.rb | 25 ++++ test/system/trades_test.rb | 8 +- 97 files changed, 1103 insertions(+), 1159 deletions(-) create mode 100644 app/controllers/account/transaction_categories_controller.rb create mode 100644 app/controllers/concerns/entryable_resource.rb delete mode 100644 app/models/account/entry_builder.rb delete mode 100644 app/models/account/transaction_builder.rb create mode 100644 app/views/account/trades/_header.html.erb delete mode 100644 app/views/account/trades/_security.turbo_stream.erb delete mode 100644 app/views/account/trades/securities.turbo_stream.erb rename app/views/{ => account}/transactions/_form.html.erb (65%) create mode 100644 app/views/account/transactions/_header.html.erb rename app/views/{ => account}/transactions/bulk_edit.html.erb (91%) rename app/views/{ => account}/transactions/new.html.erb (51%) create mode 100644 app/views/account/valuations/_header.html.erb create mode 100644 app/views/accounts/_chart_loader.html.erb create mode 100644 app/views/accounts/chart.html.erb create mode 100644 app/views/securities/_combobox_security.turbo_stream.erb create mode 100644 app/views/securities/index.turbo_stream.erb create mode 100644 app/views/shared/_form_errors.html.erb create mode 100644 app/views/shared/_syncing_notice.html.erb delete mode 100644 app/views/transactions/rules.html.erb create mode 100644 db/migrate/20241126211249_add_logo_url_to_security.rb create mode 100644 test/interfaces/entryable_resource_interface_test.rb diff --git a/app/controllers/account/cashes_controller.rb b/app/controllers/account/cashes_controller.rb index 6afa3241..f94582ce 100644 --- a/app/controllers/account/cashes_controller.rb +++ b/app/controllers/account/cashes_controller.rb @@ -1,14 +1,7 @@ class Account::CashesController < ApplicationController layout :with_sidebar - before_action :set_account - def index + @account = Current.family.accounts.find(params[:account_id]) end - - private - - def set_account - @account = Current.family.accounts.find(params[:account_id]) - end end diff --git a/app/controllers/account/entries_controller.rb b/app/controllers/account/entries_controller.rb index d78cb62c..b36cdbc6 100644 --- a/app/controllers/account/entries_controller.rb +++ b/app/controllers/account/entries_controller.rb @@ -2,56 +2,21 @@ class Account::EntriesController < ApplicationController layout :with_sidebar before_action :set_account - before_action :set_entry, only: %i[edit update show destroy] def index @q = search_params - @pagy, @entries = pagy(@account.entries.search(@q).reverse_chronological, limit: params[:per_page] || "10") - end - - def edit - render entryable_view_path(:edit) - end - - def update - prev_amount = @entry.amount - prev_date = @entry.date - - @entry.update!(entry_params) - @entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date - - respond_to do |format| - format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") } - format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) } - end - end - - def show - render entryable_view_path(:show) - end - - def destroy - @entry.destroy! - @entry.sync_account_later - redirect_to account_url(@entry.account), notice: t(".success") + @pagy, @entries = pagy(entries_scope.search(@q).reverse_chronological, limit: params[:per_page] || "10") end private - - def entryable_view_path(action) - @entry.entryable_type.underscore.pluralize + "/" + action.to_s - end - def set_account @account = Current.family.accounts.find(params[:account_id]) end - def set_entry - @entry = @account.entries.find(params[:id]) - end - - def entry_params - params.require(:account_entry).permit(:name, :date, :amount, :currency, :notes) + def entries_scope + scope = Current.family.entries + scope = scope.where(account: @account) if @account + scope end def search_params diff --git a/app/controllers/account/holdings_controller.rb b/app/controllers/account/holdings_controller.rb index af0d3e6a..c316b854 100644 --- a/app/controllers/account/holdings_controller.rb +++ b/app/controllers/account/holdings_controller.rb @@ -1,11 +1,12 @@ class Account::HoldingsController < ApplicationController layout :with_sidebar - before_action :set_account before_action :set_holding, only: %i[show destroy] def index - @holdings = @account.holdings.current + @account = Current.family.accounts.find(params[:account_id]) + @holdings = Current.family.holdings.current + @holdings = @holdings.where(account: @account) if @account end def show @@ -13,16 +14,17 @@ class Account::HoldingsController < ApplicationController def destroy @holding.destroy_holding_and_entries! - redirect_back_or_to account_holdings_path(@account) + + flash[:notice] = t(".success") + + respond_to do |format| + format.html { redirect_back_or_to account_path(@holding.account) } + format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, account_path(@holding.account)) } + end end private - - def set_account - @account = Current.family.accounts.find(params[:account_id]) - end - def set_holding - @holding = @account.holdings.current.find(params[:id]) + @holding = Current.family.holdings.current.find(params[:id]) end end diff --git a/app/controllers/account/trades_controller.rb b/app/controllers/account/trades_controller.rb index f57c3089..6ace6538 100644 --- a/app/controllers/account/trades_controller.rb +++ b/app/controllers/account/trades_controller.rb @@ -1,69 +1,37 @@ class Account::TradesController < ApplicationController - layout :with_sidebar + include EntryableResource - before_action :set_account - before_action :set_entry, only: :update - - def new - @entry = @account.entries.account_trades.new( - currency: @account.currency, - entryable_attributes: {} - ) - end - - def index - @entries = @account.entries.reverse_chronological.where(entryable_type: %w[Account::Trade Account::Transaction]) - end - - def create - @builder = Account::EntryBuilder.new(entry_params) - - if entry = @builder.save - entry.sync_account_later - redirect_to @account, notice: t(".success") - else - flash[:alert] = t(".failure") - redirect_back_or_to @account - end - end - - def update - @entry.update!(entry_params) - - respond_to do |format| - format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") } - format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) } - end - end - - def securities - query = params[:q] - return render json: [] if query.blank? || query.length < 2 || query.length > 100 - - @securities = Security::SynthComboboxOption.find_in_synth(query) - end + permitted_entryable_attributes :id, :qty, :price private - - def set_account - @account = Current.family.accounts.find(params[:account_id]) + def build_entry + Account::TradeBuilder.new(create_entry_params) end - def set_entry - @entry = @account.entries.find(params[:id]) + def create_entry_params + params.require(:account_entry).permit( + :account_id, :date, :amount, :currency, :qty, :price, :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 entry_params - params.require(:account_entry) - .permit( - :type, :date, :qty, :ticker, :price, :amount, :notes, :excluded, :currency, :transfer_account_id, :entryable_type, - entryable_attributes: [ - :id, - :qty, - :ticker, - :price - ] - ) - .merge(account: @account) + 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 new file mode 100644 index 00000000..5920a0b3 --- /dev/null +++ b/app/controllers/account/transaction_categories_controller.rb @@ -0,0 +1,22 @@ +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 index 26c9e62d..6784aac6 100644 --- a/app/controllers/account/transactions_controller.rb +++ b/app/controllers/account/transactions_controller.rb @@ -1,74 +1,55 @@ class Account::TransactionsController < ApplicationController - layout :with_sidebar + include EntryableResource - before_action :set_account - before_action :set_entry, only: :update + permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] } - def index - @pagy, @entries = pagy( - @account.entries.account_transactions.reverse_chronological, - limit: params[:per_page] || "10" - ) + 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 update - prev_amount = @entry.amount - prev_date = @entry.date + def bulk_edit + end - @entry.update!(entry_params.except(:origin)) - @entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date + def bulk_update + updated = Current.family + .entries + .where(id: bulk_update_params[:entry_ids]) + .bulk_update!(bulk_update_params) - respond_to do |format| - format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") } - format.turbo_stream do - render turbo_stream: turbo_stream.replace( - @entry, - partial: "account/entries/entry", - locals: entry_locals.merge(entry: @entry) - ) - end - end + redirect_back_or_to transactions_url, notice: t(".success", count: updated) + end + + def mark_transfers + Current.family + .entries + .where(id: bulk_update_params[:entry_ids]) + .mark_transfers! + + redirect_back_or_to transactions_url, notice: t(".success") + end + + def unmark_transfers + Current.family + .entries + .where(id: bulk_update_params[:entry_ids]) + .update_all marked_as_transfer: false + + redirect_back_or_to transactions_url, notice: t(".success") end private - def set_account - @account = Current.family.accounts.find(params[:account_id]) + def bulk_delete_params + params.require(:bulk_delete).permit(entry_ids: []) end - def set_entry - @entry = @account.entries.find(params[:id]) + def bulk_update_params + params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: []) end - def entry_locals - { - selectable: entry_params[:origin].present?, - show_balance: entry_params[:origin] == "account", - origin: entry_params[:origin] - } - end - - def entry_params - params.require(:account_entry) - .permit( - :name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature, :origin, - entryable_attributes: [ - :id, - :category_id, - :merchant_id, - { tag_ids: [] } - ] - ).tap do |permitted_params| - nature = permitted_params.delete(:nature) - - if permitted_params[:amount] - amount_value = permitted_params[:amount].to_d - - if nature == "income" - amount_value *= -1 - end - - permitted_params[:amount] = amount_value - end - 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 index 35b83b90..08f566f3 100644 --- a/app/controllers/account/valuations_controller.rb +++ b/app/controllers/account/valuations_controller.rb @@ -1,38 +1,3 @@ class Account::ValuationsController < ApplicationController - layout :with_sidebar - - before_action :set_account - - def new - @entry = @account.entries.account_valuations.new( - currency: @account.currency, - entryable_attributes: {} - ) - end - - def create - @entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {})) - - if @entry.save - @entry.sync_account_later - redirect_back_or_to account_valuations_path(@account), notice: t(".success") - else - flash[:alert] = @entry.errors.full_messages.to_sentence - redirect_to @account - end - end - - def index - @entries = @account.entries.account_valuations.reverse_chronological - end - - private - - def set_account - @account = Current.family.accounts.find(params[:account_id]) - end - - def entry_params - params.require(:account_entry).permit(:name, :date, :amount, :currency) - end + include EntryableResource end diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 56c29d26..8d0c27c9 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -31,6 +31,11 @@ class AccountsController < ApplicationController redirect_to account_path(@account) end + def chart + @account = Current.family.accounts.find(params[:id]) + render layout: "application" + end + def sync_all unless Current.family.syncing? Current.family.sync_later diff --git a/app/controllers/concerns/entryable_resource.rb b/app/controllers/concerns/entryable_resource.rb new file mode 100644 index 00000000..84aac1d4 --- /dev/null +++ b/app/controllers/concerns/entryable_resource.rb @@ -0,0 +1,126 @@ +module EntryableResource + extend ActiveSupport::Concern + + included do + layout :with_sidebar + before_action :set_entry, only: %i[show update destroy] + end + + class_methods do + def permitted_entryable_attributes(*attrs) + @permitted_entryable_attributes = attrs if attrs.any? + @permitted_entryable_attributes ||= [ :id ] + end + end + + def show + end + + def new + account = Current.family.accounts.find_by(id: params[:account_id]) + + @entry = Current.family.entries.new( + account: account, + currency: account ? account.currency : Current.family.currency, + entryable: entryable_type.new + ) + 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 + 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 } + ) + end + end + else + render :show, status: :unprocessable_entity + end + end + + def destroy + account = @entry.account + @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 + 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) + 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, :date, :amount, :currency, :excluded, :notes, :nature, + entryable_attributes: self.class.permitted_entryable_attributes + ) + end +end diff --git a/app/controllers/securities_controller.rb b/app/controllers/securities_controller.rb index 24356118..4a3c65c4 100644 --- a/app/controllers/securities_controller.rb +++ b/app/controllers/securities_controller.rb @@ -1,5 +1,18 @@ class SecuritiesController < ApplicationController - def import - SecuritiesImportJob.perform_later(params[:exchange_mic]) + def index + query = params[:q] + return render json: [] if query.blank? || query.length < 2 || query.length > 100 + + @securities = Security.search({ + search: query, + country: country_code_filter + }) end + + private + def country_code_filter + filter = params[:country_code] + filter = "#{filter},US" unless filter == "US" + filter + end end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index b1733585..acceab79 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -13,94 +13,13 @@ class TransactionsController < ApplicationController } end - def new - @entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e| - if params[:account_id] - e.account = Current.family.accounts.find(params[:account_id]) - e.currency = e.account.currency - else - e.currency = Current.family.currency - end - end - end - - def create - @entry = Current.family - .accounts - .find(params[:account_entry][:account_id]) - .entries - .create!(transaction_entry_params.merge(amount: amount)) - - @entry.sync_account_later - redirect_back_or_to @entry.account, notice: t(".success") - end - - 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 - - def mark_transfers - Current.family - .entries - .where(id: bulk_update_params[:entry_ids]) - .mark_transfers! - - redirect_back_or_to transactions_url, notice: t(".success") - end - - def unmark_transfers - Current.family - .entries - .where(id: bulk_update_params[:entry_ids]) - .update_all marked_as_transfer: false - - redirect_back_or_to transactions_url, notice: t(".success") - end - private - - def amount - if nature.income? - transaction_entry_params[:amount].to_d * -1 - else - transaction_entry_params[:amount].to_d - end - end - - def nature - params[:account_entry][:nature].to_s.inquiry - end - - 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: []) - end - def search_params params.fetch(:q, {}) - .permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: []) - end - - def transaction_entry_params - params.require(:account_entry) - .permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ]) - .with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {}) + .permit( + :start_date, :end_date, :search, :amount, + :amount_operator, accounts: [], account_ids: [], + categories: [], merchants: [], types: [], tags: [] + ) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 3871a7d9..8bf3cf28 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -62,9 +62,9 @@ module ApplicationHelper #
Content here
# <% end %> # - def drawer(&block) + def drawer(reload_on_close: false, &block) content = capture &block - render partial: "shared/drawer", locals: { content: content } + render partial: "shared/drawer", locals: { content:, reload_on_close: } end def disclosure(title, &block) diff --git a/app/javascript/application.js b/app/javascript/application.js index 874eae81..12751637 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,3 +1,7 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails"; import "controllers"; + +Turbo.StreamActions.redirect = function () { + Turbo.visit(this.target); +}; diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js index 4b55996d..f898dcad 100644 --- a/app/javascript/controllers/application.js +++ b/app/javascript/controllers/application.js @@ -6,7 +6,7 @@ const application = Application.start(); application.debug = false; window.Stimulus = application; -Turbo.setConfirmMethod((message) => { +Turbo.config.forms.confirm = (message) => { const dialog = document.getElementById("turbo-confirm"); try { @@ -52,6 +52,6 @@ Turbo.setConfirmMethod((message) => { { once: true }, ); }); -}); +}; export { application }; diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js index a988dbb8..8c9d6c50 100644 --- a/app/javascript/controllers/modal_controller.js +++ b/app/javascript/controllers/modal_controller.js @@ -2,6 +2,10 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="modal" export default class extends Controller { + static values = { + reloadOnClose: { type: Boolean, default: false }, + }; + connect() { if (this.element.open) return; this.element.showModal(); @@ -10,11 +14,15 @@ export default class extends Controller { // Hide the dialog when the user clicks outside of it clickOutside(e) { if (e.target === this.element) { - this.element.close(); + this.close(); } } close() { this.element.close(); + + if (this.reloadOnCloseValue) { + window.location.reload(); + } } } diff --git a/app/javascript/controllers/trade_form_controller.js b/app/javascript/controllers/trade_form_controller.js index cd435d55..bff84364 100644 --- a/app/javascript/controllers/trade_form_controller.js +++ b/app/javascript/controllers/trade_form_controller.js @@ -1,71 +1,11 @@ import { Controller } from "@hotwired/stimulus"; -const TRADE_TYPES = { - BUY: "buy", - SELL: "sell", - TRANSFER_IN: "transfer_in", - TRANSFER_OUT: "transfer_out", - INTEREST: "interest", -}; - -const FIELD_VISIBILITY = { - [TRADE_TYPES.BUY]: { ticker: true, qty: true, price: true }, - [TRADE_TYPES.SELL]: { ticker: true, qty: true, price: true }, - [TRADE_TYPES.TRANSFER_IN]: { amount: true, transferAccount: true }, - [TRADE_TYPES.TRANSFER_OUT]: { amount: true, transferAccount: true }, - [TRADE_TYPES.INTEREST]: { amount: true }, -}; - // Connects to data-controller="trade-form" export default class extends Controller { - static targets = [ - "typeInput", - "tickerInput", - "amountInput", - "transferAccountInput", - "qtyInput", - "priceInput", - ]; - - connect() { - this.handleTypeChange = this.handleTypeChange.bind(this); - this.typeInputTarget.addEventListener("change", this.handleTypeChange); - this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY); - } - - disconnect() { - this.typeInputTarget.removeEventListener("change", this.handleTypeChange); - } - - handleTypeChange(event) { - this.updateFields(event.target.value); - } - - updateFields(type) { - const visibleFields = FIELD_VISIBILITY[type] || {}; - - Object.entries(this.fieldTargets).forEach(([field, target]) => { - const isVisible = visibleFields[field] || false; - - // Update visibility - target.hidden = !isVisible; - - // Update required status based on visibility - if (isVisible) { - target.setAttribute("required", ""); - } else { - target.removeAttribute("required"); - } - }); - } - - get fieldTargets() { - return { - ticker: this.tickerInputTarget, - amount: this.amountInputTarget, - transferAccount: this.transferAccountInputTarget, - qty: this.qtyInputTarget, - price: this.priceInputTarget, - }; + // Reloads the page with a new type without closing the modal + async changeType(event) { + const url = new URL(event.params.url, window.location.origin); + url.searchParams.set(event.params.key, event.target.value); + Turbo.visit(url, { frame: "modal" }); } } diff --git a/app/models/account.rb b/app/models/account.rb index e9a6160f..50fa6f56 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -12,7 +12,7 @@ class Account < ApplicationRecord 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 + has_many :holdings, dependent: :destroy, class_name: "Account::Holding" has_many :balances, dependent: :destroy has_many :issues, as: :issuable, dependent: :destroy diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index 2e77b19c..2addf3be 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -30,10 +30,10 @@ class Account::Entry < ApplicationRecord } def sync_account_later - if destroyed? - sync_start_date = previous_entry&.date + sync_start_date = if destroyed? + previous_entry&.date else - sync_start_date = [ date_previously_was, date ].compact.min + [ date_previously_was, date ].compact.min end account.sync_later(start_date: sync_start_date) diff --git a/app/models/account/entry_builder.rb b/app/models/account/entry_builder.rb deleted file mode 100644 index 189acfdd..00000000 --- a/app/models/account/entry_builder.rb +++ /dev/null @@ -1,46 +0,0 @@ -class Account::EntryBuilder - include ActiveModel::Model - - TYPES = %w[income expense buy sell interest transfer_in transfer_out].freeze - - attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id - - validates :type, inclusion: { in: TYPES } - - def save - if valid? - create_builder.save - end - end - - private - - def create_builder - case type - when "buy", "sell" - create_trade_builder - else - create_transaction_builder - end - end - - def create_trade_builder - Account::TradeBuilder.new \ - type: type, - date: date, - qty: qty, - ticker: ticker, - price: price, - account: account - end - - def create_transaction_builder - Account::TransactionBuilder.new \ - type: type, - date: date, - amount: amount, - account: account, - currency: currency, - transfer_account_id: transfer_account_id - end -end diff --git a/app/models/account/trade.rb b/app/models/account/trade.rb index 3ab2241e..b8ebd7b8 100644 --- a/app/models/account/trade.rb +++ b/app/models/account/trade.rb @@ -28,8 +28,7 @@ class Account::Trade < ApplicationRecord def name prefix = sell? ? "Sell " : "Buy " - generated = prefix + "#{qty.abs} shares of #{security.ticker}" - entry.name || generated + prefix + "#{qty.abs} shares of #{security.ticker}" end def unrealized_gain_loss diff --git a/app/models/account/trade_builder.rb b/app/models/account/trade_builder.rb index ec252897..dd6b966c 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/account/trade_builder.rb @@ -1,33 +1,103 @@ -class Account::TradeBuilder < Account::EntryBuilder +class Account::TradeBuilder include ActiveModel::Model - TYPES = %w[buy sell].freeze - - attr_accessor :type, :qty, :price, :ticker, :date, :account - - validates :type, :qty, :price, :ticker, :date, presence: true - validates :price, numericality: { greater_than: 0 } - validates :type, inclusion: { in: TYPES } + attr_accessor :account, :date, :amount, :currency, :qty, + :price, :ticker, :type, :transfer_account_id def save - if valid? - create_entry - end + buildable.save + end + + def errors + buildable.errors + end + + def sync_account_later + buildable.sync_account_later end private + def buildable + case type + when "buy", "sell" + build_trade + when "deposit", "withdrawal" + build_transfer + when "interest" + build_interest + else + raise "Unknown trade type: #{type}" + end + end - def create_entry - account.entries.account_trades.create! \ + def build_trade + account.entries.new( date: date, - amount: amount, - currency: account.currency, + amount: signed_amount, + currency: currency, entryable: Account::Trade.new( - security: security, qty: signed_qty, - price: price.to_d, - currency: account.currency + price: price, + currency: currency, + security: security ) + ) + end + + def build_transfer + transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present? + + if transfer_account + from_account = type == "withdrawal" ? account : transfer_account + to_account = type == "withdrawal" ? transfer_account : account + + Account::Transfer.build_from_accounts( + from_account, + to_account, + date: date, + amount: signed_amount + ) + else + account.entries.build( + name: signed_amount < 0 ? "Deposit from #{account.name}" : "Withdrawal to #{account.name}", + date: date, + amount: signed_amount, + currency: currency, + marked_as_transfer: true, + entryable: Account::Transaction.new + ) + end + end + + def build_interest + account.entries.build( + name: "Interest payment", + date: date, + amount: signed_amount, + currency: currency, + entryable: Account::Transaction.new + ) + end + + def signed_qty + return nil unless type.in?([ "buy", "sell" ]) + + type == "sell" ? -qty.to_d : qty.to_d + end + + def signed_amount + case type + when "buy", "sell" + signed_qty * price.to_d + when "deposit", "withdrawal" + type == "deposit" ? -amount.to_d : amount.to_d + when "interest" + amount.to_d * -1 + end + end + + def family + account.family end def security @@ -40,14 +110,4 @@ class Account::TradeBuilder < Account::EntryBuilder security end - - def amount - price.to_d * signed_qty - end - - def signed_qty - _qty = qty.to_d - _qty = _qty * -1 if type == "sell" - _qty - end end diff --git a/app/models/account/transaction_builder.rb b/app/models/account/transaction_builder.rb deleted file mode 100644 index 6c87d6a4..00000000 --- a/app/models/account/transaction_builder.rb +++ /dev/null @@ -1,64 +0,0 @@ -class Account::TransactionBuilder - include ActiveModel::Model - - TYPES = %w[income expense interest transfer_in transfer_out].freeze - - attr_accessor :type, :amount, :date, :account, :currency, :transfer_account_id - - validates :type, :amount, :date, presence: true - validates :type, inclusion: { in: TYPES } - - def save - if valid? - transfer? ? create_transfer : create_transaction - end - end - - private - - def transfer? - %w[transfer_in transfer_out].include?(type) - end - - def create_transfer - return create_unlinked_transfer(account.id, signed_amount) if transfer_account_id.blank? - - from_account_id = type == "transfer_in" ? transfer_account_id : account.id - to_account_id = type == "transfer_in" ? account.id : transfer_account_id - - outflow = create_unlinked_transfer(from_account_id, signed_amount.abs) - inflow = create_unlinked_transfer(to_account_id, signed_amount.abs * -1) - - Account::Transfer.create! entries: [ outflow, inflow ] - - inflow - end - - def create_unlinked_transfer(account_id, amount) - build_entry(account_id, amount, marked_as_transfer: true).tap(&:save!) - end - - def create_transaction - build_entry(account.id, signed_amount).tap(&:save!) - end - - def build_entry(account_id, amount, marked_as_transfer: false) - Account::Entry.new \ - account_id: account_id, - name: marked_as_transfer ? (amount < 0 ? "Deposit" : "Withdrawal") : "Interest", - amount: amount, - currency: currency, - date: date, - marked_as_transfer: marked_as_transfer, - entryable: Account::Transaction.new - end - - def signed_amount - case type - when "expense", "transfer_out" - amount.to_d - else - amount.to_d * -1 - end - end -end diff --git a/app/models/account/transfer.rb b/app/models/account/transfer.rb index b919c4b7..174576e8 100644 --- a/app/models/account/transfer.rb +++ b/app/models/account/transfer.rb @@ -48,6 +48,10 @@ class Account::Transfer < ApplicationRecord end end + def sync_account_later + entries.each(&:sync_account_later) + end + class << self def build_from_accounts(from_account, to_account, date:, amount:) outflow = from_account.entries.build \ diff --git a/app/models/concerns/accountable.rb b/app/models/concerns/accountable.rb index 84515374..6c93a8f8 100644 --- a/app/models/concerns/accountable.rb +++ b/app/models/concerns/accountable.rb @@ -35,8 +35,9 @@ module Accountable end def post_sync - broadcast_remove_to(account, target: "syncing-notification") + broadcast_remove_to(account.family, target: "syncing-notice") + # Broadcast a simple replace event that the controller can handle broadcast_replace_to( account, target: "chart_account_#{account.id}", diff --git a/app/models/family.rb b/app/models/family.rb index a618220c..e32c0d78 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -15,6 +15,7 @@ class Family < ApplicationRecord has_many :categories, dependent: :destroy has_many :merchants, dependent: :destroy has_many :issues, through: :accounts + has_many :holdings, through: :accounts has_many :plaid_items, dependent: :destroy validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) } diff --git a/app/models/investment.rb b/app/models/investment.rb index ced62765..90519da3 100644 --- a/app/models/investment.rb +++ b/app/models/investment.rb @@ -56,7 +56,7 @@ class Investment < ApplicationRecord end def post_sync - broadcast_remove_to(account, target: "syncing-notification") + broadcast_remove_to(account, target: "syncing-notice") broadcast_replace_to( account, diff --git a/app/models/provider/synth.rb b/app/models/provider/synth.rb index 8247ab34..5044f00a 100644 --- a/app/models/provider/synth.rb +++ b/app/models/provider/synth.rb @@ -134,12 +134,12 @@ class Provider::Synth securities = parsed.dig("data").map do |security| { - symbol: security.dig("symbol"), + ticker: security.dig("symbol"), name: security.dig("name"), logo_url: security.dig("logo_url"), exchange_acronym: security.dig("exchange", "acronym"), exchange_mic: security.dig("exchange", "mic_code"), - exchange_country_code: security.dig("exchange", "country_code") + country_code: security.dig("exchange", "country_code") } end diff --git a/app/models/security.rb b/app/models/security.rb index 732599ce..d2ce6387 100644 --- a/app/models/security.rb +++ b/app/models/security.rb @@ -8,17 +8,33 @@ class Security < ApplicationRecord validates :ticker, presence: true validates :ticker, uniqueness: { scope: :exchange_mic, case_sensitive: false } + class << self + def search(query) + security_prices_provider.search_securities( + query: query[:search], + dataset: "limited", + country_code: query[:country] + ).securities.map { |attrs| new(**attrs) } + end + end + def current_price @current_price ||= Security::Price.find_price(security: self, date: Date.current) return nil if @current_price.nil? Money.new(@current_price.price, @current_price.currency) end - def to_combobox_display - "#{ticker} (#{exchange_acronym})" + def to_combobox_option + SynthComboboxOption.new( + symbol: ticker, + name: name, + logo_url: logo_url, + exchange_acronym: exchange_acronym, + exchange_mic: exchange_mic, + exchange_country_code: country_code + ) end - private def upcase_ticker diff --git a/app/models/security/synth_combobox_option.rb b/app/models/security/synth_combobox_option.rb index efd81db6..d3b4437d 100644 --- a/app/models/security/synth_combobox_option.rb +++ b/app/models/security/synth_combobox_option.rb @@ -1,22 +1,8 @@ class Security::SynthComboboxOption include ActiveModel::Model - include Providable attr_accessor :symbol, :name, :logo_url, :exchange_acronym, :exchange_mic, :exchange_country_code - class << self - def find_in_synth(query) - country = Current.family.country - country = "#{country},US" unless country == "US" - - security_prices_provider.search_securities( - query:, - dataset: "limited", - country_code: country - ).securities.map { |attrs| new(**attrs) } - end - end - def id "#{symbol}|#{exchange_mic}|#{exchange_acronym}|#{exchange_country_code}" # submitted by combobox as value end diff --git a/app/views/account/entries/_entry.html.erb b/app/views/account/entries/_entry.html.erb index 4b839916..9bfe063a 100644 --- a/app/views/account/entries/_entry.html.erb +++ b/app/views/account/entries/_entry.html.erb @@ -1,5 +1,5 @@ -<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %> +<%# locals: (entry:, selectable: true, show_balance: false) %> <%= turbo_frame_tag dom_id(entry) do %> - <%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance:, origin: } %> + <%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance: } %> <% end %> diff --git a/app/views/account/entries/_selection_bar.html.erb b/app/views/account/entries/_selection_bar.html.erb index f4f7208e..3bbf39d1 100644 --- a/app/views/account/entries/_selection_bar.html.erb +++ b/app/views/account/entries/_selection_bar.html.erb @@ -6,7 +6,7 @@
- <%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> + <%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> diff --git a/app/views/account/entries/index.html.erb b/app/views/account/entries/index.html.erb index 9e6d5d42..c66d3767 100644 --- a/app/views/account/entries/index.html.erb +++ b/app/views/account/entries/index.html.erb @@ -9,13 +9,13 @@ <%= tag.span t(".new") %>
diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb index a3330927..fbb288df 100644 --- a/app/views/account/trades/_form.html.erb +++ b/app/views/account/trades/_form.html.erb @@ -1,35 +1,51 @@ <%# locals: (entry:) %> -<%= styled_form_with data: { turbo_frame: "_top", controller: "trade-form" }, - model: entry, - scope: :account_entry, - url: account_trades_path(entry.account) do |form| %> +<% type = params[:type] || "buy" %> + +<%= styled_form_with model: entry, url: account_trades_path, data: { controller: "trade-form" } do |form| %> + + <%= form.hidden_field :account_id %> +
+ <% if entry.errors.any? %> + <%= render "shared/form_errors", model: entry %> + <% end %> +
- <%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %> -
+ <%= form.select :type, [ + ["Buy", "buy"], + ["Sell", "sell"], + ["Deposit", "deposit"], + ["Withdrawal", "withdrawal"], + ["Interest", "interest"] + ], + { label: t(".type"), selected: type }, + { data: { + action: "trade-form#changeType", + trade_form_url_param: new_account_trade_path(account_id: entry.account_id), + trade_form_key_param: "type", + }} %> + + <% if %w[buy sell].include?(type) %>
- <%= form.combobox :ticker, securities_account_trades_path(entry.account), label: t(".holding"), placeholder: t(".ticker_placeholder") %> + <%= form.combobox :ticker, securities_path(country_code: Current.family.country), label: t(".holding"), placeholder: t(".ticker_placeholder"), required: true %>
-
+ <% end %> - <%= form.date_field :date, label: true, value: Date.today %> + <%= form.date_field :date, label: true, value: Date.today, required: true %> - + <% unless %w[buy sell].include?(type) %> + <%= form.money_field :amount, label: t(".amount"), required: true %> + <% end %> - + <% end %> -
- <%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any" %> -
- -
- <%= form.money_field :price, label: t(".price"), currency_value_override: "USD", disable_currency: true %> -
+ <% if %w[buy sell].include?(type) %> + <%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any", required: true %> + <%= form.money_field :price, label: t(".price"), required: true %> + <% end %>
<%= form.submit t(".submit") %> diff --git a/app/views/account/trades/_header.html.erb b/app/views/account/trades/_header.html.erb new file mode 100644 index 00000000..7ccadfa4 --- /dev/null +++ b/app/views/account/trades/_header.html.erb @@ -0,0 +1,68 @@ +<%# locals: (entry:) %> + +
+ <%= tag.header class: "mb-4 space-y-1" do %> + + <%= entry.amount.negative? ? t(".sell") : t(".buy") %> + + +
+

+ + <%= format_money entry.amount_money %> + + + + <%= entry.currency %> + +

+
+ + + <%= I18n.l(entry.date, format: :long) %> + + <% end %> + + <% trade = entry.account_trade %> + +
+ <%= disclosure t(".overview") do %> +
+
+
+
<%= t(".symbol_label") %>
+
<%= trade.security.ticker %>
+
+ + <% if trade.buy? %> +
+
<%= t(".purchase_qty_label") %>
+
<%= trade.qty.abs %>
+
+ +
+
<%= t(".purchase_price_label") %>
+
<%= format_money trade.price_money %>
+
+ <% end %> + + <% if trade.security.current_price.present? %> +
+
<%= t(".current_market_price_label") %>
+
<%= format_money trade.security.current_price %>
+
+ <% end %> + + <% if trade.buy? && trade.unrealized_gain_loss.present? %> +
+
<%= t(".total_return_label") %>
+
+ <%= render "shared/trend_change", trend: trade.unrealized_gain_loss %> +
+
+ <% end %> +
+
+ <% end %> +
+
diff --git a/app/views/account/trades/_security.turbo_stream.erb b/app/views/account/trades/_security.turbo_stream.erb deleted file mode 100644 index 34bdebd3..00000000 --- a/app/views/account/trades/_security.turbo_stream.erb +++ /dev/null @@ -1,11 +0,0 @@ -
- <%= image_tag(security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %> -
- - <%= security.name.presence || security.symbol %> - - - <%= "#{security.symbol} (#{security.exchange_acronym})" %> - -
-
diff --git a/app/views/account/trades/_selection_bar.html.erb b/app/views/account/trades/_selection_bar.html.erb index f4f7208e..3bbf39d1 100644 --- a/app/views/account/trades/_selection_bar.html.erb +++ b/app/views/account/trades/_selection_bar.html.erb @@ -6,7 +6,7 @@
- <%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> + <%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> diff --git a/app/views/account/trades/_trade.html.erb b/app/views/account/trades/_trade.html.erb index 29cd40f4..bd91d147 100644 --- a/app/views/account/trades/_trade.html.erb +++ b/app/views/account/trades/_trade.html.erb @@ -1,8 +1,8 @@ -<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %> +<%# locals: (entry:, selectable: true, show_balance: false) %> <% trade, account = entry.account_trade, entry.account %> -
+
text-sm font-medium p-4">
<% if selectable %> <%= check_box_tag dom_id(entry, "selection"), @@ -16,12 +16,12 @@ <%= trade.name.first.upcase %>
-
+
<% if entry.new_record? %> <%= content_tag :p, trade.name %> <% else %> <%= link_to trade.name, - account_entry_path(account, entry), + account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> <% end %> @@ -31,7 +31,9 @@
- <%= tag.span format_money(entry.amount_money) %> + <%= content_tag :p, + format_money(-entry.amount_money), + class: ["text-green-600": entry.amount.negative?] %>
diff --git a/app/views/account/trades/securities.turbo_stream.erb b/app/views/account/trades/securities.turbo_stream.erb deleted file mode 100644 index a3225939..00000000 --- a/app/views/account/trades/securities.turbo_stream.erb +++ /dev/null @@ -1,2 +0,0 @@ -<%= async_combobox_options @securities, - render_in: { partial: "account/trades/security" } %> diff --git a/app/views/account/trades/show.html.erb b/app/views/account/trades/show.html.erb index 0f07e9ad..1a1d8cf6 100644 --- a/app/views/account/trades/show.html.erb +++ b/app/views/account/trades/show.html.erb @@ -1,83 +1,37 @@ -<% entry, trade, account = @entry, @entry.account_trade, @entry.account %> +<%= drawer(reload_on_close: true) do %> + <%= render "account/trades/header", entry: @entry %> -<%= drawer do %> -
-
-

- - <%= format_money -entry.amount_money %> - - - - <%= entry.currency %> - -

-
- - - <%= I18n.l(entry.date, format: :long) %> - -
+ <% trade = @entry.account_trade %>
- - <%= disclosure t(".overview") do %> -
-
-
-
<%= t(".symbol_label") %>
-
<%= trade.security.ticker %>
-
- - <% if trade.buy? %> -
-
<%= t(".purchase_qty_label") %>
-
<%= trade.qty.abs %>
-
- -
-
<%= t(".purchase_price_label") %>
-
<%= format_money trade.price_money %>
-
- <% end %> - - <% if trade.security.current_price.present? %> -
-
<%= t(".current_market_price_label") %>
-
<%= format_money trade.security.current_price %>
-
- <% end %> - - <% if trade.buy? && trade.unrealized_gain_loss.present? %> -
-
<%= t(".total_return_label") %>
-
- <%= render "shared/trend_change", trend: trade.unrealized_gain_loss %> -
-
- <% end %> -
-
- <% end %> - <%= disclosure t(".details") do %>
- <%= styled_form_with model: [account, entry], - url: account_trade_path(account, entry), + <%= styled_form_with model: @entry, + url: account_trade_path(@entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> <%= f.date_field :date, label: t(".date_label"), - max: Date.current, + max: Date.today, "data-auto-submit-form-target": "auto" %> - <%= f.fields_for :entryable do |ef| %> - <%= ef.number_field :qty, +
+ <%= f.select :nature, + [["Buy", "outflow"], ["Sell", "inflow"]], + { container_class: "w-1/3", label: "Type", selected: @entry.amount.negative? ? "inflow" : "outflow" }, + { data: { "auto-submit-form-target": "auto" } } %> + + <%= f.fields_for :entryable do |ef| %> + <%= ef.number_field :qty, label: t(".quantity_label"), step: "any", + value: trade.qty.abs, "data-auto-submit-form-target": "auto" %> + <% end %> +
+ <%= f.fields_for :entryable do |ef| %> <%= ef.money_field :price, label: t(".cost_per_share_label"), disable_currency: true, @@ -91,8 +45,8 @@ <%= disclosure t(".additional") do %>
- <%= styled_form_with model: [account, entry], - url: account_trade_path(account, entry), + <%= styled_form_with model: @entry, + url: account_trade_path(@entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> <%= f.text_area :notes, @@ -108,8 +62,8 @@ <%= disclosure t(".settings") do %>
- <%= styled_form_with model: [account, entry], - url: account_trade_path(account, entry), + <%= styled_form_with model: @entry, + url: account_trade_path(@entry), class: "p-3", data: { controller: "auto-submit-form" } do |f| %>
@@ -136,11 +90,11 @@
<%= button_to t(".delete"), - account_entry_path(account, entry), + account_entry_path(@entry), method: :delete, class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200", - data: { turbo_confirm: true, turbo_frame: "_top" } %> + data: { turbo_confirm: true } %>
<% end %> diff --git a/app/views/transactions/_form.html.erb b/app/views/account/transactions/_form.html.erb similarity index 65% rename from app/views/transactions/_form.html.erb rename to app/views/account/transactions/_form.html.erb index dad6157b..10c09d30 100644 --- a/app/views/transactions/_form.html.erb +++ b/app/views/account/transactions/_form.html.erb @@ -1,8 +1,13 @@ -<%= styled_form_with model: @entry, url: transactions_path, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %> +<%= styled_form_with model: @entry, url: account_transactions_path, class: "space-y-4" do |f| %> + + <% if entry.errors.any? %> + <%= render "shared/form_errors", model: entry %> + <% end %> +
- <%= radio_tab_tag form: f, name: :nature, value: :expense, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "expense" || params[:nature].nil? %> - <%= radio_tab_tag form: f, name: :nature, value: :income, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "income" %> + <%= radio_tab_tag form: f, name: :nature, value: :outflow, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "outflow" || params[:nature].nil? %> + <%= radio_tab_tag form: f, name: :nature, value: :inflow, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "inflow" %> <%= link_to new_account_transfer_path, data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm" do %> <%= lucide_icon "arrow-right-left", class: "w-5 h-5" %> <%= tag.span t(".transfer") %> @@ -12,9 +17,14 @@
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %> - <%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %> + + <% if @entry.account_id %> + <%= f.hidden_field :account_id %> + <% else %> + <%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %> + <% end %> + <%= f.money_field :amount, label: t(".amount"), required: true %> - <%= f.hidden_field :entryable_type, value: "Account::Transaction" %> <%= f.fields_for :entryable do |ef| %> <%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %> <% end %> diff --git a/app/views/account/transactions/_header.html.erb b/app/views/account/transactions/_header.html.erb new file mode 100644 index 00000000..af819326 --- /dev/null +++ b/app/views/account/transactions/_header.html.erb @@ -0,0 +1,23 @@ +<%# locals: (entry:) %> + +<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %> +
+

+ + <%= format_money -entry.amount_money %> + + + + <%= entry.currency %> + +

+ + <% if entry.marked_as_transfer? %> + <%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %> + <% end %> +
+ + + <%= I18n.l(entry.date, format: :long) %> + +<% end %> diff --git a/app/views/account/transactions/_selection_bar.html.erb b/app/views/account/transactions/_selection_bar.html.erb index 3f72eaab..4fae27fa 100644 --- a/app/views/account/transactions/_selection_bar.html.erb +++ b/app/views/account/transactions/_selection_bar.html.erb @@ -8,7 +8,7 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %> - <%= form_with url: mark_transfers_transactions_path, + <%= form_with url: mark_transfers_account_transactions_path, scope: "bulk_update", data: { turbo_frame: "_top", @@ -28,14 +28,14 @@ <% end %> - <%= link_to bulk_edit_transactions_path, + <%= link_to bulk_edit_account_transactions_path, class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md", title: "Edit", data: { turbo_frame: "bulk_transaction_edit_drawer" } do %> <%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %> <% end %> - <%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> + <%= form_with url: bulk_delete_account_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %> diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index ebdda70d..9a300440 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -1,4 +1,4 @@ -<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %> +<%# locals: (entry:, selectable: true, show_balance: false) %> <% transaction, account = entry.account_transaction, entry.account %>
text-sm font-medium p-4"> @@ -20,7 +20,7 @@ <%= content_tag :p, transaction.name %> <% else %> <%= link_to transaction.name, - entry.transfer.present? ? account_transfer_path(entry.transfer, origin:) : account_entry_path(account, entry, origin:), + entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> <% end %> @@ -43,7 +43,7 @@
<% else %>
- <%= render "categories/menu", transaction: transaction, origin: origin %> + <%= render "categories/menu", transaction: transaction %>
<% unless show_balance %> diff --git a/app/views/transactions/bulk_edit.html.erb b/app/views/account/transactions/bulk_edit.html.erb similarity index 91% rename from app/views/transactions/bulk_edit.html.erb rename to app/views/account/transactions/bulk_edit.html.erb index dc4ec2a1..0fa23ae6 100644 --- a/app/views/transactions/bulk_edit.html.erb +++ b/app/views/account/transactions/bulk_edit.html.erb @@ -1,8 +1,8 @@ <%= turbo_frame_tag "bulk_transaction_edit_drawer" do %> - <%= styled_form_with url: bulk_update_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %> + class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4"> + <%= styled_form_with url: bulk_update_account_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
diff --git a/app/views/account/transactions/index.html.erb b/app/views/account/transactions/index.html.erb index a2fa3f27..f30fcfd6 100644 --- a/app/views/account/transactions/index.html.erb +++ b/app/views/account/transactions/index.html.erb @@ -2,7 +2,7 @@

<%= t(".transactions") %>

- <%= link_to new_transaction_path(account_id: @account), + <%= link_to new_account_transaction_path(account_id: @account), class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg", data: { turbo_frame: :modal } do %> <%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %> diff --git a/app/views/transactions/new.html.erb b/app/views/account/transactions/new.html.erb similarity index 51% rename from app/views/transactions/new.html.erb rename to app/views/account/transactions/new.html.erb index 0079185d..12c8f2f2 100644 --- a/app/views/transactions/new.html.erb +++ b/app/views/account/transactions/new.html.erb @@ -1,3 +1,3 @@ <%= modal_form_wrapper title: t(".new_transaction") do %> - <%= render "form", transaction: @transaction, entry: @entry %> + <%= render "form", entry: @entry %> <% end %> diff --git a/app/views/account/transactions/show.html.erb b/app/views/account/transactions/show.html.erb index fe75a2f0..ecce8921 100644 --- a/app/views/account/transactions/show.html.erb +++ b/app/views/account/transactions/show.html.erb @@ -1,39 +1,14 @@ -<% entry, transaction, account = @entry, @entry.account_transaction, @entry.account %> - -<% origin = params[:origin] %> - -<%= drawer do %> -
-
-

- - <%= format_money -entry.amount_money %> - - - - <%= entry.currency %> - -

- - <% if entry.marked_as_transfer? %> - <%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %> - <% end %> -
- - - <%= I18n.l(entry.date, format: :long) %> - -
+<%= drawer(reload_on_close: true) do %> + <%= render "account/transactions/header", entry: @entry %>
<%= disclosure t(".overview") do %>
- <%= styled_form_with model: [account, entry], - url: account_transaction_path(account, entry), + <%= styled_form_with model: @entry, + url: account_transaction_path(@entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> - <%= f.hidden_field :origin, value: origin %> <%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %> @@ -43,25 +18,25 @@ max: Date.current, "data-auto-submit-form-target": "auto" %> - <% unless entry.marked_as_transfer? %> + <% unless @entry.marked_as_transfer? %>
<%= f.select :nature, - [["Expense", "expense"], ["Income", "income"]], - { container_class: "w-1/3", label: t(".nature"), selected: entry.amount.negative? ? "income" : "expense" }, + [["Expense", "outflow"], ["Income", "inflow"]], + { container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" }, { data: { "auto-submit-form-target": "auto" } } %> <%= f.money_field :amount, label: t(".amount"), container_class: "w-2/3", auto_submit: true, min: 0, - value: entry.amount.abs %> + value: @entry.amount.abs %>
<% end %> <%= f.select :account, options_for_select( Current.family.accounts.alphabetically.pluck(:name, :id), - entry.account_id + @entry.account_id ), { label: t(".account_label") }, { disabled: true } %> @@ -72,55 +47,45 @@ <%= disclosure t(".details") do %>
- <%= styled_form_with model: [account, entry], - url: account_transaction_path(account, entry), + <%= styled_form_with model: @entry, + url: account_transaction_path(@entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> - <%= f.hidden_field :origin, value: origin %> - <%= f.fields_for :entryable do |ef| %> - <% unless entry.marked_as_transfer? %> + <% unless @entry.marked_as_transfer? %> + <%= f.fields_for :entryable do |ef| %> <%= ef.collection_select :category_id, - Current.family.categories.alphabetically, + Current.family.categories.alphabetically, :id, :name, - { prompt: t(".category_placeholder"), - label: t(".category_label"), - class: "text-gray-400" }, + { label: t(".category_label"), + class: "text-gray-400", include_blank: t(".uncategorized") }, "data-auto-submit-form-target": "auto" %> <%= ef.collection_select :merchant_id, - Current.family.merchants.alphabetically, + Current.family.merchants.alphabetically, :id, :name, - { prompt: t(".merchant_placeholder"), + { include_blank: t(".none"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %> - <% end %> - <%= ef.select :tag_ids, - options_for_select( + <%= ef.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), - transaction.tag_ids - ), - { - multiple: true, - label: t(".tags_label"), - container_class: "h-40" - }, + { + include_blank: t(".none"), + multiple: true, + label: t(".tags_label"), + container_class: "h-40" + }, { "data-auto-submit-form-target": "auto" } %> - + <% end %> <% end %> - <%= styled_form_with model: [account, entry], - url: account_transaction_path(account, entry), - class: "space-y-2", - data: { controller: "auto-submit-form" } do |f| %> - <%= f.hidden_field :origin, value: origin %> - <%= f.text_area :notes, + <%= f.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), rows: 5, "data-auto-submit-form-target": "auto" %> - <% end %> + <% end %>
<% end %> @@ -129,11 +94,10 @@ <%= disclosure t(".settings") do %>
- <%= styled_form_with model: [account, entry], - url: account_transaction_path(account, entry), + <%= styled_form_with model: @entry, + url: account_transaction_path(@entry), class: "p-3", data: { controller: "auto-submit-form" } do |f| %> - <%= f.hidden_field :origin, value: origin %>

<%= t(".exclude_title") %>

@@ -158,7 +122,7 @@
<%= button_to t(".delete"), - account_entry_path(account, entry), + account_entry_path(@entry), method: :delete, class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200", diff --git a/app/views/account/transfers/_form.html.erb b/app/views/account/transfers/_form.html.erb index 2d9e5741..0124d3ea 100644 --- a/app/views/account/transfers/_form.html.erb +++ b/app/views/account/transfers/_form.html.erb @@ -8,12 +8,12 @@
- <%= link_to new_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %> + <%= link_to new_account_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %> <%= lucide_icon "minus-circle", class: "w-5 h-5" %> <%= tag.span t(".expense") %> <% end %> - <%= link_to new_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %> + <%= link_to new_account_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %> <%= lucide_icon "plus-circle", class: "w-5 h-5" %> <%= tag.span t(".income") %> <% end %> diff --git a/app/views/account/transfers/_transfer_toggle.html.erb b/app/views/account/transfers/_transfer_toggle.html.erb index 4b3b5e5a..233b11e7 100644 --- a/app/views/account/transfers/_transfer_toggle.html.erb +++ b/app/views/account/transfers/_transfer_toggle.html.erb @@ -1,6 +1,6 @@ <%# locals: (entry:) %> -<%= form_with url: unmark_transfers_transactions_path, class: "flex items-center", data: { +<%= form_with url: unmark_transfers_account_transactions_path, class: "flex items-center", data: { turbo_confirm: { title: t(".remove_transfer"), body: t(".remove_transfer_body"), diff --git a/app/views/account/valuations/_form.html.erb b/app/views/account/valuations/_form.html.erb index 0ceef3c3..68345c73 100644 --- a/app/views/account/valuations/_form.html.erb +++ b/app/views/account/valuations/_form.html.erb @@ -1,9 +1,12 @@ <%# locals: (entry:) %> -<%= styled_form_with model: [entry.account, entry], - url: entry.new_record? ? account_valuations_path(entry.account) : account_entry_path(entry.account, entry), - class: "space-y-4", - data: { turbo: false } do |form| %> +<%= styled_form_with model: entry, url: account_valuations_path, class: "space-y-4" do |form| %> + <%= form.hidden_field :account_id %> + + <% if entry.errors.any? %> + <%= render "shared/form_errors", model: entry %> + <% end %> +
<%= form.date_field :date, label: true, required: true, value: Date.today, min: Account::Entry.min_supported_date, max: Date.today %> <%= form.money_field :amount, label: t(".amount"), required: true %> diff --git a/app/views/account/valuations/_header.html.erb b/app/views/account/valuations/_header.html.erb new file mode 100644 index 00000000..ca224bb7 --- /dev/null +++ b/app/views/account/valuations/_header.html.erb @@ -0,0 +1,19 @@ +<%# locals: (entry:) %> + +<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %> + + <%= t(".balance") %> + + +
+

+ + <%= format_money entry.amount_money %> + +

+
+ + + <%= I18n.l(entry.date, format: :long) %> + +<% end %> diff --git a/app/views/account/valuations/_valuation.html.erb b/app/views/account/valuations/_valuation.html.erb index a0402be5..b3e9caf4 100644 --- a/app/views/account/valuations/_valuation.html.erb +++ b/app/views/account/valuations/_valuation.html.erb @@ -1,4 +1,4 @@ -<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %> +<%# locals: (entry:, selectable: true, show_balance: false) %> <% account = entry.account %> <% valuation = entry.account_valuation %> @@ -21,7 +21,7 @@ <%= content_tag :p, entry.name %> <% else %> <%= link_to valuation.name, - account_entry_path(account, entry), + account_entry_path(entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> <% end %> diff --git a/app/views/account/valuations/show.html.erb b/app/views/account/valuations/show.html.erb index d6684795..89b40d8b 100644 --- a/app/views/account/valuations/show.html.erb +++ b/app/views/account/valuations/show.html.erb @@ -1,30 +1,14 @@ <% entry, account = @entry, @entry.account %> -<%= drawer do %> -
- - <%= t(".balance") %> - - -
-

- - <%= format_money entry.amount_money %> - -

-
- - - <%= I18n.l(entry.date, format: :long) %> - -
+<%= drawer(reload_on_close: true) do %> + <%= render "account/valuations/header", entry: %>
<%= disclosure t(".overview") do %>
- <%= styled_form_with model: [account, entry], - url: account_entry_path(account, entry), + <%= styled_form_with model: entry, + url: account_entry_path(entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> <%= f.text_field :name, @@ -48,8 +32,8 @@ <%= disclosure t(".details") do %>
- <%= styled_form_with model: [account, entry], - url: account_entry_path(account, entry), + <%= styled_form_with model: entry, + url: account_entry_path(entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> <%= f.text_area :notes, @@ -72,7 +56,7 @@
<%= button_to t(".delete"), - account_entry_path(account, entry), + account_entry_path(entry), method: :delete, class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200", data: { turbo_confirm: true, turbo_frame: "_top" } %> diff --git a/app/views/accounts/_chart_loader.html.erb b/app/views/accounts/_chart_loader.html.erb new file mode 100644 index 00000000..56e244d4 --- /dev/null +++ b/app/views/accounts/_chart_loader.html.erb @@ -0,0 +1,5 @@ +
+
+
+

Loading...

+
diff --git a/app/views/accounts/chart.html.erb b/app/views/accounts/chart.html.erb new file mode 100644 index 00000000..67eee6fb --- /dev/null +++ b/app/views/accounts/chart.html.erb @@ -0,0 +1,32 @@ +<% period = Period.from_param(params[:period]) %> +<% series = @account.series(period: period) %> +<% trend = series.trend %> + +<%= turbo_frame_tag dom_id(@account, :chart_details) do %> +
+ <% if trend.direction.flat? %> + <%= tag.span t(".no_change"), class: "text-gray-500" %> + <% else %> + <%= tag.span "#{trend.value.positive? ? "+" : ""}#{format_money(trend.value)}", style: "color: #{trend.color}" %> + <% unless trend.percent.infinite? %> + <%= tag.span "(#{trend.percent}%)", style: "color: #{trend.color}" %> + <% end %> + <% end %> + + <%= tag.span period_label(period), class: "text-gray-500" %> +
+ +
+ <% if series %> +
+ <% else %> +
+

No data available for the selected period.

+
+ <% end %> +
+<% end %> diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb index 4bf4e2ed..c041b652 100644 --- a/app/views/accounts/show/_activity.html.erb +++ b/app/views/accounts/show/_activity.html.erb @@ -1,5 +1,5 @@ <%# locals: (account:) %> -<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account) do %> +<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id) do %> <%= render "account/entries/loading" %> <% end %> diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index 6b825b48..7546748d 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -1,12 +1,10 @@ <%# locals: (account:, title: nil, tooltip: nil, **args) %> <% period = Period.from_param(params[:period]) %> -<% series = account.series(period: period) %> -<% trend = series.trend %> <% default_value_title = account.asset? ? t(".balance") : t(".owed") %> -
-
+
+
<%= tag.p title || default_value_title, class: "text-sm font-medium text-gray-500" %> @@ -14,19 +12,6 @@
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %> - -
- <% if trend.direction.flat? %> - <%= tag.span t(".no_change"), class: "text-gray-500" %> - <% else %> - <%= tag.span "#{trend.value.positive? ? "+" : ""}#{format_money(trend.value)}", style: "color: #{trend.color}" %> - <% unless trend.percent.infinite? %> - <%= tag.span "(#{trend.percent}%)", style: "color: #{trend.color}" %> - <% end %> - <% end %> - - <%= tag.span period_label(period), class: "text-gray-500" %> -
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %> @@ -34,7 +19,7 @@ <% end %>
-
- <%= render "shared/line_chart", series: series %> -
+ <%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.name) do %> + <%= render "accounts/chart_loader" %> + <% end %>
diff --git a/app/views/accounts/show/_tab.html.erb b/app/views/accounts/show/_tab.html.erb index 4ddd0e6e..36b63fcc 100644 --- a/app/views/accounts/show/_tab.html.erb +++ b/app/views/accounts/show/_tab.html.erb @@ -2,6 +2,7 @@ <%= link_to key.titleize, account_path(account, tab: key), + data: { turbo: false }, class: [ "px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": is_selected diff --git a/app/views/categories/_menu.html.erb b/app/views/categories/_menu.html.erb index 9235a581..746bcb45 100644 --- a/app/views/categories/_menu.html.erb +++ b/app/views/categories/_menu.html.erb @@ -1,11 +1,12 @@ -<%# locals: (transaction:, origin: nil) %> -
+<%# locals: (transaction:) %> + +

diff --git a/app/views/investments/_cash_tab.html.erb b/app/views/investments/_cash_tab.html.erb index f5e51f28..2ebd3126 100644 --- a/app/views/investments/_cash_tab.html.erb +++ b/app/views/investments/_cash_tab.html.erb @@ -1,5 +1,5 @@ <%# locals: (account:) %> -<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account) do %> +<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account_id: account.id) do %> <%= render "account/entries/loading" %> <% end %> diff --git a/app/views/investments/_chart.html.erb b/app/views/investments/_chart.html.erb index ceb7f32b..9ac9868d 100644 --- a/app/views/investments/_chart.html.erb +++ b/app/views/investments/_chart.html.erb @@ -1,9 +1,5 @@ <%# locals: (account:, **args) %> -<% period = Period.from_param(params[:period]) %> -<% series = account.series(period: period) %> -<% trend = series.trend %> -
diff --git a/app/views/investments/_holdings_tab.html.erb b/app/views/investments/_holdings_tab.html.erb index c61c3a17..9b68eb66 100644 --- a/app/views/investments/_holdings_tab.html.erb +++ b/app/views/investments/_holdings_tab.html.erb @@ -1,5 +1,5 @@ <%# locals: (account:) %> -<%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account) do %> +<%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account_id: account.id) do %> <%= render "account/entries/loading" %> <% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 8c8abc9c..8c8f1d15 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -36,7 +36,7 @@ <%= render_flash_notifications %> <% if Current.family&.syncing? %> - <%= render "shared/notification", id: "syncing-notification", type: :processing, message: t(".syncing") %> + <%= render "shared/syncing_notice" %> <% end %>
diff --git a/app/views/securities/_combobox_security.turbo_stream.erb b/app/views/securities/_combobox_security.turbo_stream.erb new file mode 100644 index 00000000..cc0667a3 --- /dev/null +++ b/app/views/securities/_combobox_security.turbo_stream.erb @@ -0,0 +1,11 @@ +
+ <%= image_tag(combobox_security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %> +
+ + <%= combobox_security.name.presence || combobox_security.symbol %> + + + <%= "#{combobox_security.symbol} (#{combobox_security.exchange_acronym})" %> + +
+
diff --git a/app/views/securities/index.turbo_stream.erb b/app/views/securities/index.turbo_stream.erb new file mode 100644 index 00000000..43b41c2b --- /dev/null +++ b/app/views/securities/index.turbo_stream.erb @@ -0,0 +1,2 @@ +<%= async_combobox_options @securities.map(&:to_combobox_option), + render_in: { partial: "securities/combobox_security" } %> diff --git a/app/views/shared/_drawer.html.erb b/app/views/shared/_drawer.html.erb index de796424..050bff21 100644 --- a/app/views/shared/_drawer.html.erb +++ b/app/views/shared/_drawer.html.erb @@ -1,5 +1,10 @@ +<%# locals: (content:, reload_on_close: false) %> + <%= turbo_frame_tag "drawer" do %> - +
diff --git a/app/views/shared/_form_errors.html.erb b/app/views/shared/_form_errors.html.erb new file mode 100644 index 00000000..30e37a97 --- /dev/null +++ b/app/views/shared/_form_errors.html.erb @@ -0,0 +1,6 @@ +<%# locals: (model:) %> + +
+ <%= lucide_icon("alert-circle", class: "text-red-500 w-4 h-4 shrink-0") %> +

<%= model.errors.full_messages.to_sentence %>

+
diff --git a/app/views/shared/_notification.html.erb b/app/views/shared/_notification.html.erb index 6f6ed438..2ee020bc 100644 --- a/app/views/shared/_notification.html.erb +++ b/app/views/shared/_notification.html.erb @@ -1,10 +1,9 @@ -<%# locals: (message:, type: "notice", id: nil, **_opts) %> +<%# locals: (message:, type: "notice", **_opts) %> <% type = type.to_sym %> <% action = "animationend->element-removal#remove" if type == :notice %> <%= tag.div class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25", - id: type == :processing ? "syncing-notification" : id, data: { controller: "element-removal", action: action @@ -20,8 +19,6 @@
<%= lucide_icon "x", class: "w-3 h-3" %>
- <% when :processing %> - <%= lucide_icon "loader", class: "w-5 h-5 text-gray-500 animate-pulse" %> <% end %>
diff --git a/app/views/shared/_syncing_notice.html.erb b/app/views/shared/_syncing_notice.html.erb new file mode 100644 index 00000000..5500dbc8 --- /dev/null +++ b/app/views/shared/_syncing_notice.html.erb @@ -0,0 +1,7 @@ +<%= tag.div id: "syncing-notice", class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25" do %> +
+ <%= lucide_icon "loader", class: "w-5 h-5 text-gray-500 animate-pulse" %> +
+ + <%= tag.p t(".syncing"), class: "text-gray-900 text-sm font-medium" %> +<% end %> diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb index 3e532fad..9577395c 100644 --- a/app/views/transactions/_header.html.erb +++ b/app/views/transactions/_header.html.erb @@ -16,7 +16,7 @@

<%= t(".import") %>

<% end %> - <%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %> + <%= link_to new_account_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %> <%= lucide_icon("plus", class: "w-5 h-5") %>

New transaction

<% end %> diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index c2663da3..61558049 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -29,7 +29,7 @@
<%= entries_by_date(@transaction_entries, totals: true) do |entries| %> - <%= render entries, origin: "transactions" %> + <%= render entries %> <% end %>
diff --git a/app/views/transactions/rules.html.erb b/app/views/transactions/rules.html.erb deleted file mode 100644 index 62222550..00000000 --- a/app/views/transactions/rules.html.erb +++ /dev/null @@ -1,16 +0,0 @@ -<% content_for :sidebar do %> - <%= render "settings/nav" %> -<% end %> - -
-

Rules

-
-
-

Transaction rules coming soon...

-
-
-
- <%= previous_setting("Merchants", merchants_path) %> - <%= next_setting("Imports", imports_path) %> -
-
diff --git a/config/brakeman.ignore b/config/brakeman.ignore index b854cf00..6ebccac2 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -80,6 +80,29 @@ ], "note": "" }, + { + "warning_type": "Mass Assignment", + "warning_code": 105, + "fingerprint": "f158202dcc66f2273ddea5e5296bad7146a50ca6667f49c77372b5b234542334", + "check_name": "PermitAttributes", + "message": "Potentially dangerous key allowed for mass assignment", + "file": "app/controllers/concerns/entryable_resource.rb", + "line": 122, + "link": "https://brakemanscanner.org/docs/warning_types/mass_assignment/", + "code": "params.require(:account_entry).permit(:account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_attributes => self.class.permitted_entryable_attributes)", + "render_path": null, + "location": { + "type": "method", + "class": "EntryableResource", + "method": "entry_params" + }, + "user_input": ":account_id", + "confidence": "High", + "cwe_id": [ + 915 + ], + "note": "" + }, { "warning_type": "Dynamic Render Path", "warning_code": 15, @@ -115,6 +138,6 @@ "note": "" } ], - "updated": "2024-11-02 15:02:28 -0400", + "updated": "2024-11-27 15:33:53 -0500", "brakeman_version": "6.2.2" } diff --git a/config/locales/views/account/entries/en.yml b/config/locales/views/account/entries/en.yml index b742a731..395ac91f 100644 --- a/config/locales/views/account/entries/en.yml +++ b/config/locales/views/account/entries/en.yml @@ -2,6 +2,8 @@ en: account: entries: + create: + success: Entry created destroy: success: Entry deleted empty: diff --git a/config/locales/views/account/holdings/en.yml b/config/locales/views/account/holdings/en.yml index 99fb3a42..b2e3fe25 100644 --- a/config/locales/views/account/holdings/en.yml +++ b/config/locales/views/account/holdings/en.yml @@ -2,6 +2,8 @@ en: account: holdings: + destroy: + success: Holding deleted holding: per_share: per share shares: "%{qty} shares" diff --git a/config/locales/views/account/trades/en.yml b/config/locales/views/account/trades/en.yml index 15b83280..01fd53fa 100644 --- a/config/locales/views/account/trades/en.yml +++ b/config/locales/views/account/trades/en.yml @@ -2,9 +2,6 @@ en: account: trades: - create: - failure: Something went wrong - success: Transaction created successfully. form: account: Transfer account (optional) account_prompt: Search account @@ -15,6 +12,15 @@ en: submit: Add transaction ticker_placeholder: AAPL type: Type + header: + buy: Buy + current_market_price_label: Current Market Price + overview: Overview + purchase_price_label: Purchase Price + purchase_qty_label: Purchase Quantity + sell: Sell + symbol_label: Symbol + total_return_label: Unrealized gain/loss index: amount: Amount new: New transaction @@ -27,7 +33,6 @@ en: show: additional: Additional cost_per_share_label: Cost per Share - current_market_price_label: Current Market Price date_label: Date delete: Delete delete_subtitle: This action cannot be undone @@ -37,12 +42,5 @@ en: exclude_title: Exclude from analytics note_label: Note note_placeholder: Add any additional notes here... - overview: Overview - purchase_price_label: Purchase Price - purchase_qty_label: Purchase Quantity quantity_label: Quantity settings: Settings - symbol_label: Symbol - total_return_label: Unrealized gain/loss - update: - success: Trade updated successfully. diff --git a/config/locales/views/account/transactions/en.yml b/config/locales/views/account/transactions/en.yml index 81a88210..af05bcdf 100644 --- a/config/locales/views/account/transactions/en.yml +++ b/config/locales/views/account/transactions/en.yml @@ -2,11 +2,44 @@ en: account: transactions: + bulk_delete: + success: "%{count} transactions deleted" + bulk_edit: + cancel: Cancel + category_label: Category + category_placeholder: Select a category + date_label: Date + details: Details + merchant_label: Merchant + merchant_placeholder: Select a merchant + note_label: Notes + note_placeholder: Enter a note that will be applied to selected transactions + overview: Overview + save: Save + bulk_update: + success: "%{count} transactions updated" + form: + account: Account + account_prompt: Select an Account + amount: Amount + category: Category + category_prompt: Select a Category + date: Date + description: Description + description_placeholder: Describe transaction + expense: Expense + income: Income + submit: Add transaction + transfer: Transfer index: new: New transaction no_transactions: No transactions for this account yet. transaction: transaction transactions: Transactions + mark_transfers: + success: Marked as transfers + new: + new_transaction: New transaction selection_bar: mark_transfers: Mark as transfers? mark_transfers_confirm: Mark as transfers @@ -16,7 +49,6 @@ en: account_label: Account amount: Amount category_label: Category - category_placeholder: Select a category date_label: Date delete: Delete delete_subtitle: This permanently deletes the transaction, affects your historical @@ -27,13 +59,14 @@ en: analytics. exclude_title: Exclude transaction merchant_label: Merchant - merchant_placeholder: Select a merchant name_label: Name nature: Type + none: "(none)" note_label: Notes note_placeholder: Enter a note overview: Overview settings: Settings tags_label: Tags - update: - success: Transaction updated successfully. + uncategorized: "(uncategorized)" + unmark_transfers: + success: Transfer removed diff --git a/config/locales/views/account/valuations/en.yml b/config/locales/views/account/valuations/en.yml index 5c542802..c157b6d6 100644 --- a/config/locales/views/account/valuations/en.yml +++ b/config/locales/views/account/valuations/en.yml @@ -2,11 +2,11 @@ en: account: valuations: - create: - success: Valuation created successfully. form: amount: Amount submit: Add balance update + header: + balance: Balance index: change: change date: date @@ -18,7 +18,6 @@ en: title: New balance show: amount: Amount - balance: Balance date_label: Date delete: Delete delete_subtitle: This action cannot be undone diff --git a/config/locales/views/accounts/en.yml b/config/locales/views/accounts/en.yml index 585cb37d..8e7359f5 100644 --- a/config/locales/views/accounts/en.yml +++ b/config/locales/views/accounts/en.yml @@ -31,10 +31,11 @@ en: manual_entry: Enter account balance title: How would you like to add it? title: What would you like to add? + chart: + no_change: no change show: chart: balance: Balance - no_change: no change owed: Amount owed menu: confirm_accept: Delete "%{name}" diff --git a/config/locales/views/layout/en.yml b/config/locales/views/layout/en.yml index c0598db9..8adc4792 100644 --- a/config/locales/views/layout/en.yml +++ b/config/locales/views/layout/en.yml @@ -1,8 +1,6 @@ --- en: layouts: - application: - syncing: Syncing account data... auth: existing_account: Already have an account? no_account: New to Maybe? diff --git a/config/locales/views/shared/en.yml b/config/locales/views/shared/en.yml index 80f4fc3d..0e020f42 100644 --- a/config/locales/views/shared/en.yml +++ b/config/locales/views/shared/en.yml @@ -1,6 +1,8 @@ --- en: shared: + syncing_notice: + syncing: Syncing accounts data... confirm_modal: accept: Confirm body_html: "

You will not be able to undo this decision

" diff --git a/config/locales/views/transactions/en.yml b/config/locales/views/transactions/en.yml index c0533bb4..5e6ab631 100644 --- a/config/locales/views/transactions/en.yml +++ b/config/locales/views/transactions/en.yml @@ -1,37 +1,6 @@ --- en: transactions: - bulk_delete: - success: "%{count} transactions deleted" - bulk_edit: - cancel: Cancel - category_label: Category - category_placeholder: Select a category - date_label: Date - details: Details - merchant_label: Merchant - merchant_placeholder: Select a merchant - note_label: Notes - note_placeholder: Enter a note that will be applied to selected transactions - overview: Overview - save: Save - bulk_update: - success: "%{count} transactions updated" - create: - success: New transaction created successfully - form: - account: Account - account_prompt: Select an Account - amount: Amount - category: Category - category_prompt: Select a Category - date: Date - description: Description - description_placeholder: Describe transaction - expense: Expense - income: Income - submit: Add transaction - transfer: Transfer header: edit_categories: Edit categories edit_imports: Edit imports @@ -41,10 +10,6 @@ en: index: transaction: transaction transactions: transactions - mark_transfers: - success: Marked as transfer - new: - new_transaction: New transaction searches: filters: amount_filter: @@ -77,5 +42,3 @@ en: equal_to: equal to greater_than: greater than less_than: less than - unmark_transfers: - success: Transfer removed diff --git a/config/routes.rb b/config/routes.rb index 1462b71a..c7c14d91 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -69,22 +69,42 @@ Rails.application.routes.draw do member do post :sync - end - - scope module: :account do - resources :holdings, only: %i[index new show destroy] - resources :cashes, only: :index - - resources :transactions, only: %i[index update] - resources :valuations, only: %i[index new create] - resources :trades, only: %i[index new create update] do - get :securities, on: :collection - end - - resources :entries, only: %i[index edit update show destroy] + get :chart end end + namespace :account do + resources :holdings, only: %i[index new show destroy] + resources :cashes, only: :index + + resources :entries, only: :index + + resources :transactions, only: %i[show new create update destroy] do + resource :category, only: :update, controller: :transaction_categories + + collection do + post "bulk_delete" + get "bulk_edit" + post "bulk_update" + post "mark_transfers" + post "unmark_transfers" + end + end + + resources :valuations, only: %i[show new create update destroy] + resources :trades, only: %i[show new create update destroy] + end + + direct :account_entry do |entry, options| + if entry.new_record? + route_for "account_#{entry.entryable_name.pluralize}", options + else + route_for entry.entryable_name, entry, options + end + end + + resources :transactions, only: :index + # Convenience routes for polymorphic paths # Example: account_path(Account.new(accountable: Depository.new)) => /depositories/123 direct :account do |model, options| @@ -104,15 +124,7 @@ Rails.application.routes.draw do resources :other_assets, except: :index resources :other_liabilities, except: :index - resources :transactions, only: %i[index new create] do - collection do - post "bulk_delete" - get "bulk_edit" - post "bulk_update" - post "mark_transfers" - post "unmark_transfers" - end - end + resources :securities, only: :index resources :invite_codes, only: %i[index create] diff --git a/db/migrate/20241126211249_add_logo_url_to_security.rb b/db/migrate/20241126211249_add_logo_url_to_security.rb new file mode 100644 index 00000000..e265424d --- /dev/null +++ b/db/migrate/20241126211249_add_logo_url_to_security.rb @@ -0,0 +1,5 @@ +class AddLogoUrlToSecurity < ActiveRecord::Migration[7.2] + def change + add_column :securities, :logo_url, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index e662e3be..945f18b1 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_11_22_183828) do +ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -508,6 +508,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_22_183828) do t.string "country_code" t.string "exchange_mic" t.string "exchange_acronym" + t.string "logo_url" t.index ["country_code"], name: "index_securities_on_country_code" t.index ["ticker", "exchange_mic"], name: "index_securities_on_ticker_and_exchange_mic", unique: true end diff --git a/test/controllers/account/entries_controller_test.rb b/test/controllers/account/entries_controller_test.rb index d735eb01..c0ce72c4 100644 --- a/test/controllers/account/entries_controller_test.rb +++ b/test/controllers/account/entries_controller_test.rb @@ -3,63 +3,11 @@ require "test_helper" class Account::EntriesControllerTest < ActionDispatch::IntegrationTest setup do sign_in @user = users(:family_admin) - @transaction = account_entries :transaction - @valuation = account_entries :valuation - @trade = account_entries :trade + @entry = account_entries(:transaction) end - # ================= - # Shared - # ================= - - test "should destroy entry" do - [ @transaction, @valuation, @trade ].each do |entry| - assert_difference -> { Account::Entry.count } => -1, -> { entry.entryable_class.count } => -1 do - delete account_entry_url(entry.account, entry) - end - - assert_redirected_to account_url(entry.account) - assert_enqueued_with(job: SyncJob) - end + test "gets index" do + get account_entries_path(account_id: @entry.account.id) + assert_response :success end - - test "gets show" do - [ @transaction, @valuation, @trade ].each do |entry| - get account_entry_url(entry.account, entry) - assert_response :success - end - end - - test "gets edit" do - [ @valuation ].each do |entry| - get edit_account_entry_url(entry.account, entry) - assert_response :success - end - end - - test "can update generic entry" do - [ @transaction, @valuation, @trade ].each do |entry| - assert_no_difference_in_entries do - patch account_entry_url(entry.account, entry), params: { - account_entry: { - name: "Name", - date: Date.current, - currency: "USD", - amount: 100 - } - } - end - - assert_redirected_to account_entry_url(entry.account, entry) - assert_enqueued_with(job: SyncJob) - end - end - - private - - # Simple guard to verify that nested attributes are passed the record ID to avoid new creation of record - # See `update_only` option of accepts_nested_attributes_for - def assert_no_difference_in_entries(&block) - assert_no_difference [ "Account::Entry.count", "Account::Transaction.count", "Account::Valuation.count" ], &block - end end diff --git a/test/controllers/account/holdings_controller_test.rb b/test/controllers/account/holdings_controller_test.rb index 3a556908..7bca9671 100644 --- a/test/controllers/account/holdings_controller_test.rb +++ b/test/controllers/account/holdings_controller_test.rb @@ -8,12 +8,12 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest end test "gets holdings" do - get account_holdings_url(@account) + get account_holdings_url(account_id: @account.id) assert_response :success end test "gets holding" do - get account_holding_path(@account, @holding) + get account_holding_path(@holding) assert_response :success end @@ -21,10 +21,10 @@ class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest test "destroys holding and associated entries" do assert_difference -> { Account::Holding.count } => -1, -> { Account::Entry.count } => -1 do - delete account_holding_path(@account, @holding) + delete account_holding_path(@holding) end - assert_redirected_to account_holdings_path(@account) - assert_empty @account.entries.where(entryable: @account.trades.where(security: @holding.security)) + assert_redirected_to account_path(@holding.account) + assert_empty @holding.account.entries.where(entryable: @holding.account.trades.where(security: @holding.security)) end end diff --git a/test/controllers/account/trades_controller_test.rb b/test/controllers/account/trades_controller_test.rb index eaa4f4c5..cdfd6add 100644 --- a/test/controllers/account/trades_controller_test.rb +++ b/test/controllers/account/trades_controller_test.rb @@ -1,19 +1,36 @@ require "test_helper" class Account::TradesControllerTest < ActionDispatch::IntegrationTest + include EntryableResourceInterfaceTest + setup do sign_in @user = users(:family_admin) - @entry = account_entries :trade + @entry = account_entries(:trade) end - test "should get index" do - get account_trades_url(@entry.account) - assert_response :success - end + test "updates trade entry" do + assert_no_difference [ "Account::Entry.count", "Account::Trade.count" ] do + patch account_trade_url(@entry), params: { + account_entry: { + currency: "USD", + entryable_attributes: { + id: @entry.entryable_id, + qty: 20, + price: 20 + } + } + } + end - test "should get new" do - get new_account_trade_url(@entry.account) - assert_response :success + @entry.reload + + assert_enqueued_with job: SyncJob + + assert_equal 20, @entry.account_trade.qty + assert_equal 20, @entry.account_trade.price + assert_equal "USD", @entry.currency + + assert_redirected_to account_url(@entry.account) end test "creates deposit entry" do @@ -22,9 +39,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert_difference -> { Account::Entry.count } => 2, -> { Account::Transaction.count } => 2, -> { Account::Transfer.count } => 1 do - post account_trades_url(@entry.account), params: { + post account_trades_url, params: { account_entry: { - type: "transfer_in", + account_id: @entry.account_id, + type: "deposit", date: Date.current, amount: 10, currency: "USD", @@ -42,9 +60,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert_difference -> { Account::Entry.count } => 2, -> { Account::Transaction.count } => 2, -> { Account::Transfer.count } => 1 do - post account_trades_url(@entry.account), params: { + post account_trades_url, params: { account_entry: { - type: "transfer_out", + account_id: @entry.account_id, + type: "withdrawal", date: Date.current, amount: 10, currency: "USD", @@ -60,9 +79,10 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert_difference -> { Account::Entry.count } => 1, -> { Account::Transaction.count } => 1, -> { Account::Transfer.count } => 0 do - post account_trades_url(@entry.account), params: { + post account_trades_url, params: { account_entry: { - type: "transfer_out", + account_id: @entry.account_id, + type: "withdrawal", date: Date.current, amount: 10, currency: "USD" @@ -79,8 +99,9 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest test "creates interest entry" do assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do - post account_trades_url(@entry.account), params: { + post account_trades_url, params: { account_entry: { + account_id: @entry.account_id, type: "interest", date: Date.current, amount: 10, @@ -97,13 +118,15 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest test "creates trade buy entry" do assert_difference [ "Account::Entry.count", "Account::Trade.count", "Security.count" ], 1 do - post account_trades_url(@entry.account), params: { + post account_trades_url, params: { account_entry: { + account_id: @entry.account_id, type: "buy", date: Date.current, ticker: "NVDA (NASDAQ)", qty: 10, - price: 10 + price: 10, + currency: "USD" } } end @@ -112,15 +135,16 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert created_entry.amount.positive? assert created_entry.account_trade.qty.positive? - assert_equal "Transaction created successfully.", flash[:notice] + assert_equal "Entry created", flash[:notice] assert_enqueued_with job: SyncJob - assert_redirected_to @entry.account + assert_redirected_to account_url(created_entry.account) end test "creates trade sell entry" do assert_difference [ "Account::Entry.count", "Account::Trade.count" ], 1 do - post account_trades_url(@entry.account), params: { + post account_trades_url, params: { account_entry: { + account_id: @entry.account_id, type: "sell", ticker: "AAPL (NYSE)", date: Date.current, @@ -135,8 +159,8 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest assert created_entry.amount.negative? assert created_entry.account_trade.qty.negative? - assert_equal "Transaction created successfully.", flash[:notice] + assert_equal "Entry created", flash[:notice] assert_enqueued_with job: SyncJob - assert_redirected_to @entry.account + assert_redirected_to account_url(created_entry.account) end end diff --git a/test/controllers/account/transactions_controller_test.rb b/test/controllers/account/transactions_controller_test.rb index ddda4677..d490bfa7 100644 --- a/test/controllers/account/transactions_controller_test.rb +++ b/test/controllers/account/transactions_controller_test.rb @@ -1,40 +1,117 @@ require "test_helper" class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest + include EntryableResourceInterfaceTest + setup do sign_in @user = users(:family_admin) - @entry = account_entries :transaction + @entry = account_entries(:transaction) end - test "should get index" do - get account_transactions_url(@entry.account) - assert_response :success - end - - test "update" do - assert_no_difference [ "Account::Entry.count", "Account::Transaction.count" ] do - patch account_transaction_url(@entry.account, @entry), params: { + test "creates with transaction details" do + assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do + post account_transactions_url, params: { account_entry: { - name: "Name", + account_id: @entry.account_id, + name: "New transaction", date: Date.current, currency: "USD", amount: 100, - nature: "income", - entryable_type: @entry.entryable_type, + nature: "inflow", entryable_attributes: { - id: @entry.entryable_id, tag_ids: [ Tag.first.id, Tag.second.id ], category_id: Category.first.id, - merchant_id: Merchant.first.id, - notes: "test notes", - excluded: false + merchant_id: Merchant.first.id } } } end - assert_equal "Transaction updated successfully.", flash[:notice] - assert_redirected_to account_entry_url(@entry.account, @entry) + created_entry = Account::Entry.order(:created_at).last + + assert_redirected_to account_url(created_entry.account) + assert_equal "Entry created", flash[:notice] assert_enqueued_with(job: SyncJob) end + + test "updates with transaction details" do + assert_no_difference [ "Account::Entry.count", "Account::Transaction.count" ] do + patch account_transaction_url(@entry), params: { + account_entry: { + name: "Updated name", + date: Date.current, + currency: "USD", + amount: 100, + nature: "inflow", + entryable_type: @entry.entryable_type, + notes: "test notes", + excluded: false, + entryable_attributes: { + id: @entry.entryable_id, + tag_ids: [ Tag.first.id, Tag.second.id ], + category_id: Category.first.id, + merchant_id: Merchant.first.id + } + } + } + end + + @entry.reload + + assert_equal "Updated name", @entry.name + assert_equal Date.current, @entry.date + assert_equal "USD", @entry.currency + assert_equal -100, @entry.amount + assert_equal [ Tag.first.id, Tag.second.id ], @entry.entryable.tag_ids.sort + assert_equal Category.first.id, @entry.entryable.category_id + assert_equal Merchant.first.id, @entry.entryable.merchant_id + assert_equal "test notes", @entry.notes + assert_equal false, @entry.excluded + + assert_equal "Entry updated", flash[:notice] + assert_redirected_to account_url(@entry.account) + assert_enqueued_with(job: SyncJob) + end + + test "can destroy many transactions at once" do + transactions = @user.family.entries.account_transactions + delete_count = transactions.size + + assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do + post bulk_delete_account_transactions_url, params: { + bulk_delete: { + entry_ids: transactions.pluck(:id) + } + } + end + + assert_redirected_to transactions_url + assert_equal "#{delete_count} transactions deleted", flash[:notice] + end + + test "can update many transactions at once" do + transactions = @user.family.entries.account_transactions + + assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do + post bulk_update_account_transactions_url, params: { + bulk_update: { + entry_ids: transactions.map(&:id), + date: 1.day.ago.to_date, + category_id: Category.second.id, + merchant_id: Merchant.second.id, + notes: "Updated note" + } + } + end + + assert_redirected_to transactions_url + assert_equal "#{transactions.count} transactions updated", flash[:notice] + + transactions.reload.each do |transaction| + assert_equal 1.day.ago.to_date, transaction.date + assert_equal Category.second, transaction.account_transaction.category + assert_equal Merchant.second, transaction.account_transaction.merchant + assert_equal "Updated note", transaction.notes + end + end end diff --git a/test/controllers/account/valuations_controller_test.rb b/test/controllers/account/valuations_controller_test.rb index 1d3daeb7..432c2cd4 100644 --- a/test/controllers/account/valuations_controller_test.rb +++ b/test/controllers/account/valuations_controller_test.rb @@ -1,36 +1,11 @@ require "test_helper" class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest + include EntryableResourceInterfaceTest + setup do sign_in @user = users(:family_admin) - @entry = account_entries :valuation - end - - test "should get index" do - get account_valuations_url(@entry.account) - assert_response :success - end - - test "should get new" do - get new_account_valuation_url(@entry.account) - assert_response :success - end - - test "create" do - assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do - post account_valuations_url(@entry.account), params: { - account_entry: { - name: "Manual valuation", - amount: 19800, - date: Date.current, - currency: "USD" - } - } - end - - assert_equal "Valuation created successfully.", flash[:notice] - assert_enqueued_with job: SyncJob - assert_redirected_to account_valuations_path(@entry.account) + @entry = account_entries(:valuation) end test "error when valuation already exists for date" do @@ -44,7 +19,43 @@ class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest } end - assert_equal "Date has already been taken", flash[:alert] - assert_redirected_to @entry.account + assert_response :unprocessable_entity + end + + test "creates entry with basic attributes" do + assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do + post account_valuations_url, params: { + account_entry: { + name: "New entry", + amount: 10000, + currency: "USD", + date: Date.current, + account_id: @entry.account_id + } + } + end + + created_entry = Account::Entry.order(created_at: :desc).first + + assert_enqueued_with job: SyncJob + + assert_redirected_to account_url(created_entry.account) + end + + test "updates entry with basic attributes" do + assert_no_difference [ "Account::Entry.count", "Account::Valuation.count" ] do + patch account_valuation_url(@entry), params: { + account_entry: { + name: "Updated entry", + amount: 20000, + currency: "USD", + date: Date.current + } + } + end + + assert_enqueued_with job: SyncJob + + assert_redirected_to account_url(@entry.account) end end diff --git a/test/controllers/transactions_controller_test.rb b/test/controllers/transactions_controller_test.rb index 5b73c114..e4920819 100644 --- a/test/controllers/transactions_controller_test.rb +++ b/test/controllers/transactions_controller_test.rb @@ -8,83 +8,6 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest @transaction = account_entries(:transaction) end - test "should get new" do - get new_transaction_url - assert_response :success - end - - test "prefills account_id" do - get new_transaction_url(account_id: @transaction.account.id) - assert_response :success - assert_select "option[selected][value='#{@transaction.account.id}']" - end - - test "should create transaction" do - account = @user.family.accounts.first - entry_params = { - account_id: account.id, - amount: 100.45, - currency: "USD", - date: Date.current, - name: "Test transaction", - entryable_type: "Account::Transaction", - entryable_attributes: { category_id: categories(:food_and_drink).id } - } - - assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do - post transactions_url, params: { account_entry: entry_params } - end - - assert_equal entry_params[:amount].to_d, Account::Transaction.order(created_at: :desc).first.entry.amount - assert_equal "New transaction created successfully", flash[:notice] - assert_enqueued_with(job: SyncJob) - assert_redirected_to account_url(account) - end - - test "expenses are positive" do - assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], 1) do - post transactions_url, params: { - account_entry: { - nature: "expense", - account_id: @transaction.account_id, - amount: @transaction.amount, - currency: @transaction.currency, - date: @transaction.date, - name: @transaction.name, - entryable_type: "Account::Transaction", - entryable_attributes: {} - } - } - end - - created_entry = Account::Entry.order(created_at: :desc).first - - assert_redirected_to account_url(@transaction.account) - assert created_entry.amount.positive?, "Amount should be positive" - end - - test "incomes are negative" do - assert_difference("Account::Transaction.count") do - post transactions_url, params: { - account_entry: { - nature: "income", - account_id: @transaction.account_id, - amount: @transaction.amount, - currency: @transaction.currency, - date: @transaction.date, - name: @transaction.name, - entryable_type: "Account::Transaction", - entryable_attributes: { category_id: categories(:food_and_drink).id } - } - } - end - - created_entry = Account::Entry.order(created_at: :desc).first - - assert_redirected_to account_url(@transaction.account) - assert created_entry.amount.negative?, "Amount should be negative" - end - test "transaction count represents filtered total" do family = families(:empty) sign_in family.users.first @@ -135,46 +58,4 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest assert_dom "#" + dom_id(sorted_transactions.last), count: 1 end - - test "can destroy many transactions at once" do - transactions = @user.family.entries.account_transactions - delete_count = transactions.size - - assert_difference([ "Account::Transaction.count", "Account::Entry.count" ], -delete_count) do - post bulk_delete_transactions_url, params: { - bulk_delete: { - entry_ids: transactions.pluck(:id) - } - } - end - - assert_redirected_to transactions_url - assert_equal "#{delete_count} transactions deleted", flash[:notice] - end - - test "can update many transactions at once" do - transactions = @user.family.entries.account_transactions - - assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 0 do - post bulk_update_transactions_url, params: { - bulk_update: { - entry_ids: transactions.map(&:id), - date: 1.day.ago.to_date, - category_id: Category.second.id, - merchant_id: Merchant.second.id, - notes: "Updated note" - } - } - end - - assert_redirected_to transactions_url - assert_equal "#{transactions.count} transactions updated", flash[:notice] - - transactions.reload.each do |transaction| - assert_equal 1.day.ago.to_date, transaction.date - assert_equal Category.second, transaction.account_transaction.category - assert_equal Merchant.second, transaction.account_transaction.merchant - assert_equal "Updated note", transaction.notes - end - end end diff --git a/test/interfaces/entryable_resource_interface_test.rb b/test/interfaces/entryable_resource_interface_test.rb new file mode 100644 index 00000000..28ba3e75 --- /dev/null +++ b/test/interfaces/entryable_resource_interface_test.rb @@ -0,0 +1,25 @@ +require "test_helper" + +module EntryableResourceInterfaceTest + extend ActiveSupport::Testing::Declarative + + test "shows new form" do + get new_polymorphic_url(@entry.entryable) + assert_response :success + end + + test "shows editing drawer" do + get account_entry_url(@entry) + assert_response :success + end + + test "destroys entry" do + assert_difference "Account::Entry.count", -1 do + delete account_entry_url(@entry) + end + + assert_enqueued_with job: SyncJob + + assert_redirected_to account_url(@entry.account) + end +end diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index 87855e0d..bc8d3965 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -10,9 +10,9 @@ class TradesTest < ApplicationSystemTestCase visit_account_trades - Security::SynthComboboxOption.stubs(:find_in_synth).returns([ - Security::SynthComboboxOption.new( - symbol: "AAPL", + Security.stubs(:search).returns([ + Security.new( + ticker: "AAPL", name: "Apple Inc.", logo_url: "https://logo.synthfinance.com/ticker/AAPL", exchange_acronym: "NASDAQ", @@ -37,7 +37,7 @@ class TradesTest < ApplicationSystemTestCase visit_account_trades within_trades do - assert_text "Purchase 10 shares of AAPL" + assert_text "Buy 10.0 shares of AAPL" assert_text "Buy #{shares_qty} shares of AAPL" end end