diff --git a/.rubocop.yml b/.rubocop.yml index f9d86d4a..990e2b9c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -6,3 +6,7 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml } # # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` # Layout/SpaceInsideArrayLiteralBrackets: # Enabled: false +Layout/ElseAlignment: + Enabled: false +Layout/EndAlignment: + Enabled: false \ No newline at end of file diff --git a/app/controllers/account/entries_controller.rb b/app/controllers/account/entries_controller.rb index 70de7c62..1d81d1e5 100644 --- a/app/controllers/account/entries_controller.rb +++ b/app/controllers/account/entries_controller.rb @@ -4,49 +4,12 @@ class Account::EntriesController < ApplicationController before_action :set_account before_action :set_entry, only: %i[ edit update show destroy ] - def transactions - @transaction_entries = @account.entries.account_transactions.reverse_chronological - end - - def valuations - @valuation_entries = @account.entries.account_valuations.reverse_chronological - end - - def trades - @trades = @account.entries.where(entryable_type: [ "Account::Transaction", "Account::Trade" ]).reverse_chronological - end - - def new - @entry = @account.entries.build.tap do |entry| - if params[:entryable_type] - entry.entryable = Account::Entryable.from_type(params[:entryable_type]).new - else - entry.entryable = Account::Valuation.new - end - end - end - - def create - @entry = @account.entries.build(entry_params_with_defaults(entry_params)) - - if @entry.save - @entry.sync_account_later - redirect_to account_path(@account), notice: t(".success", name: @entry.entryable_name_short.upcase_first) - else - # TODO: this is not an ideal way to handle errors and should eventually be improved. - # See: https://github.com/hotwired/turbo-rails/pull/367 - flash[:alert] = @entry.errors.full_messages.to_sentence - redirect_to account_path(@account) - end - end - def edit + render entryable_view_path(:edit) end def update - @entry.assign_attributes entry_params - @entry.amount = amount if nature.present? - @entry.save! + @entry.update!(entry_params) @entry.sync_account_later respond_to do |format| @@ -56,6 +19,7 @@ class Account::EntriesController < ApplicationController end def show + render entryable_view_path(:show) end def destroy @@ -66,6 +30,10 @@ class Account::EntriesController < ApplicationController 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 @@ -74,36 +42,7 @@ class Account::EntriesController < ApplicationController @entry = @account.entries.find(params[:id]) end - def permitted_entryable_attributes - entryable_type = @entry ? @entry.entryable_class.to_s : params[:account_entry][:entryable_type] - - case entryable_type - when "Account::Transaction" - [ :id, :notes, :excluded, :category_id, :merchant_id, tag_ids: [] ] - else - [ :id ] - end - end - def entry_params - params.require(:account_entry) - .permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: permitted_entryable_attributes) - end - - def amount - if nature.income? - entry_params[:amount].to_d.abs * -1 - else - entry_params[:amount].to_d.abs - end - end - - def nature - params[:account_entry][:nature].to_s.inquiry - end - - # entryable_type is required here because Rails expects both of these params in this exact order (potential upstream bug) - def entry_params_with_defaults(params) - params.with_defaults(entryable_type: params[:entryable_type], entryable_attributes: {}) + params.require(:account_entry).permit(:name, :date, :amount, :currency) end end diff --git a/app/controllers/account/trades_controller.rb b/app/controllers/account/trades_controller.rb new file mode 100644 index 00000000..d93e5a21 --- /dev/null +++ b/app/controllers/account/trades_controller.rb @@ -0,0 +1,34 @@ +class Account::TradesController < ApplicationController + layout :with_sidebar + + before_action :set_account + + def new + @entry = @account.entries.account_trades.new(entryable_attributes: {}) + end + + def index + @entries = @account.entries.reverse_chronological.where(entryable_type: %w[ Account::Trade Account::Transaction ]) + end + + def create + @builder = Account::TradeBuilder.new(entry_params) + + if entry = @builder.save + entry.sync_account_later + redirect_to account_path(@account), notice: t(".success") + else + render :new, status: :unprocessable_entity + end + end + + private + + def set_account + @account = Current.family.accounts.find(params[:account_id]) + end + + def entry_params + params.require(:account_entry).permit(:type, :date, :qty, :ticker, :price).merge(account: @account) + end +end diff --git a/app/controllers/account/transactions_controller.rb b/app/controllers/account/transactions_controller.rb new file mode 100644 index 00000000..9103c803 --- /dev/null +++ b/app/controllers/account/transactions_controller.rb @@ -0,0 +1,53 @@ +class Account::TransactionsController < ApplicationController + layout :with_sidebar + + before_action :set_account + before_action :set_entry, only: :update + + def index + @entries = @account.entries.account_transactions.reverse_chronological + end + + def update + @entry.update!(entry_params.merge(amount: amount)) + @entry.sync_account_later + + 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 + + private + + 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, :entryable_type, + entryable_attributes: [ + :id, + :notes, + :excluded, + :category_id, + :merchant_id, + { tag_ids: [] } + ] + ) + end + + def amount + if params[:account_entry][:nature] == "income" + entry_params[:amount].to_d * -1 + else + entry_params[:amount].to_d + end + end +end diff --git a/app/controllers/account/valuations_controller.rb b/app/controllers/account/valuations_controller.rb new file mode 100644 index 00000000..7d975012 --- /dev/null +++ b/app/controllers/account/valuations_controller.rb @@ -0,0 +1,35 @@ +class Account::ValuationsController < ApplicationController + layout :with_sidebar + + before_action :set_account + + def new + @entry = @account.entries.account_valuations.new(entryable_attributes: {}) + end + + def create + @entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {})) + + if @entry.save + @entry.sync_account_later + redirect_to account_valuations_path(@account), notice: t(".success") + else + flash[:alert] = @entry.errors.full_messages.to_sentence + redirect_to account_path(@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 +end diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb index 86b07a80..c3d3b97d 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/account/entries_helper.rb @@ -30,6 +30,28 @@ module Account::EntriesHelper mixed_hex_styles(color) end + def entry_name(entry) + if entry.account_trade? + trade = entry.account_trade + prefix = trade.sell? ? "Sell " : "Buy " + generated = prefix + "#{trade.qty.abs} shares of #{trade.security.ticker}" + name = entry.name || generated + name + else + entry.name + end + end + + def entries_by_date(entries, selectable: true) + entries.group_by(&:date).map do |date, grouped_entries| + content = capture do + yield grouped_entries + end + + render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable: } + end.join.html_safe + end + private def permitted_entryable_key(entry) diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb index 6994578f..7436de02 100644 --- a/app/helpers/accounts_helper.rb +++ b/app/helpers/accounts_helper.rb @@ -26,9 +26,9 @@ module AccountsHelper def account_tabs(account) holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), content_path: account_holdings_path(account) } cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), content_path: account_cashes_path(account) } - value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: valuation_account_entries_path(account) } - transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: transaction_account_entries_path(account) } - trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: trade_account_entries_path(account) } + value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: account_valuations_path(account) } + transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: account_transactions_path(account) } + trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: account_trades_path(account) } return [ holdings_tab, cash_tab, trades_tab ] if account.investment? diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 0d2fe24c..19f75b29 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -4,6 +4,12 @@ module FormsHelper form_with(**options, &block) end + def modal_form_wrapper(title:, subtitle: nil, &block) + content = capture &block + + render partial: "shared/modal_form", locals: { title:, subtitle:, content: } + end + def form_field_tag(options = {}, &block) options[:class] = [ "form-field", options[:class] ].compact.join(" ") tag.div(**options, &block) @@ -23,17 +29,17 @@ module FormsHelper def money_with_currency_field(form, money_method, options = {}) render partial: "shared/money_field", locals: { - form: form, - money_method: money_method, + form: form, + money_method: money_method, default_currency: options[:default_currency] || "USD", disable_currency: options[:disable_currency] || false, hide_currency: options[:hide_currency] || false, - label: options[:label] || "Amount" + label: options[:label] || "Amount" } end def money_field(form, method, options = {}) - value = form.object.send(method) + value = form.object ? form.object.send(method) : nil currency = value&.currency || Money::Currency.new(options[:default_currency] || "USD") @@ -42,10 +48,10 @@ module FormsHelper money_options = { value: value&.amount, - placeholder: 100, - min: -99999999999999, - max: 99999999999999, - step: currency.step + placeholder: "100", + min: -99999999999999, + max: 99999999999999, + step: currency.step } merged_options = options.merge(money_options) diff --git a/app/models/account/trade.rb b/app/models/account/trade.rb index 35cafe19..c6232b21 100644 --- a/app/models/account/trade.rb +++ b/app/models/account/trade.rb @@ -1,10 +1,12 @@ class Account::Trade < ApplicationRecord - include Account::Entryable + include Account::Entryable, Monetizable + + monetize :price belongs_to :security validates :qty, presence: true, numericality: { other_than: 0 } - validates :price, presence: true + validates :price, :currency, presence: true class << self def search(_params) diff --git a/app/models/account/trade_builder.rb b/app/models/account/trade_builder.rb new file mode 100644 index 00000000..f460a486 --- /dev/null +++ b/app/models/account/trade_builder.rb @@ -0,0 +1,46 @@ +class Account::TradeBuilder + TYPES = %w[ buy sell ].freeze + + include ActiveModel::Model + + 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 } + + def save + if valid? + create_entry + end + end + + private + + def create_entry + account.entries.account_trades.create! \ + date: date, + amount: amount, + currency: account.currency, + entryable: Account::Trade.new( + security: security, + qty: signed_qty, + price: price.to_d, + currency: account.currency + ) + end + + def security + Security.find_or_create_by(ticker: ticker) + 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/demo/generator.rb b/app/models/demo/generator.rb index 12528416..952aca95 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -207,7 +207,7 @@ class Demo::Generator unknown = Security.find_by(ticker: "UNKNOWN") # Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices - account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown) + account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown, currency: "USD") trades = [ { security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 }, @@ -228,7 +228,7 @@ class Demo::Generator amount: qty * price, currency: "USD", name: name_prefix + "#{qty} shares of #{security.ticker}", - entryable: Account::Trade.new(qty: qty, price: price, security: security) + entryable: Account::Trade.new(qty: qty, price: price, currency: "USD", security: security) end end diff --git a/app/views/account/entries/_entry.html.erb b/app/views/account/entries/_entry.html.erb index 47965508..ab081dff 100644 --- a/app/views/account/entries/_entry.html.erb +++ b/app/views/account/entries/_entry.html.erb @@ -1,4 +1,5 @@ <%# locals: (entry:, **opts) %> + <%= turbo_frame_tag dom_id(entry) do %> - <%= render permitted_entryable_partial_path(entry, entry.entryable_name_short), entry: entry, **opts %> + <%= render partial: entry.entryable.to_partial_path, locals: { entry: entry, **opts } %> <% end %> diff --git a/app/views/account/entries/_entry_group.html.erb b/app/views/account/entries/_entry_group.html.erb index 2399dd4b..6ad2d936 100644 --- a/app/views/account/entries/_entry_group.html.erb +++ b/app/views/account/entries/_entry_group.html.erb @@ -1,4 +1,4 @@ -<%# locals: (date:, entries:, selectable: true, combine_transfers: false, **opts) %> +<%# locals: (date:, entries:, content:, selectable:) %>
@@ -15,11 +15,6 @@ <%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
- <% if combine_transfers %> - <%= render entries.reject { |e| e.transfer_id.present? }, selectable:, **opts %> - <%= render transfer_entries(entries), selectable: false, **opts %> - <% else %> - <%= render entries, selectable:, **opts %> - <% end %> + <%= content %>
diff --git a/app/views/account/entries/edit.html.erb b/app/views/account/entries/edit.html.erb deleted file mode 100644 index 435f4564..00000000 --- a/app/views/account/entries/edit.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= turbo_frame_tag dom_id(@entry) do %> - <%= render permitted_entryable_partial_path(@entry, "edit"), entry: @entry %> -<% end %> diff --git a/app/views/account/entries/entryables/valuation/_edit.html.erb b/app/views/account/entries/entryables/valuation/_edit.html.erb deleted file mode 100644 index dfd8dd2f..00000000 --- a/app/views/account/entries/entryables/valuation/_edit.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %> diff --git a/app/views/account/entries/entryables/valuation/_new.html.erb b/app/views/account/entries/entryables/valuation/_new.html.erb deleted file mode 100644 index 2ea0290e..00000000 --- a/app/views/account/entries/entryables/valuation/_new.html.erb +++ /dev/null @@ -1,2 +0,0 @@ -<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %> -
diff --git a/app/views/account/entries/entryables/valuation/_show.html.erb b/app/views/account/entries/entryables/valuation/_show.html.erb deleted file mode 100644 index 6ba1e419..00000000 --- a/app/views/account/entries/entryables/valuation/_show.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= render permitted_entryable_partial_path(@entry, "valuation"), entry: @entry %> diff --git a/app/views/account/entries/new.html.erb b/app/views/account/entries/new.html.erb deleted file mode 100644 index e35beb75..00000000 --- a/app/views/account/entries/new.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= turbo_frame_tag dom_id(@entry) do %> - <%= render permitted_entryable_partial_path(@entry, "new"), entry: @entry %> -<% end %> diff --git a/app/views/account/entries/show.html.erb b/app/views/account/entries/show.html.erb deleted file mode 100644 index 71c695ef..00000000 --- a/app/views/account/entries/show.html.erb +++ /dev/null @@ -1 +0,0 @@ -<%= render partial: permitted_entryable_partial_path(@entry, "show"), locals: { entry: @entry } %> diff --git a/app/views/account/holdings/_holding.html.erb b/app/views/account/holdings/_holding.html.erb index 90ad1d54..fc4db7f5 100644 --- a/app/views/account/holdings/_holding.html.erb +++ b/app/views/account/holdings/_holding.html.erb @@ -3,9 +3,9 @@ <%= turbo_frame_tag dom_id(holding) do %>
- <%= render "shared/circle_logo", name: holding.name %> + <%= render "shared/circle_logo", name: holding.name || "H" %>
- <%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %> + <%= link_to holding.name || holding.ticker, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %> <%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
diff --git a/app/views/account/holdings/index.html.erb b/app/views/account/holdings/index.html.erb index e58a0626..993e6b15 100644 --- a/app/views/account/holdings/index.html.erb +++ b/app/views/account/holdings/index.html.erb @@ -2,10 +2,10 @@
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %> - <%= link_to new_account_holding_path(@account), - disabled: true, + <%= link_to new_account_trade_path(@account), + id: dom_id(@account, "new_trade"), data: { turbo_frame: :modal }, - class: "cursor-not-allowed flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %> + class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %> <%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %> <%= tag.span t(".new_holding"), class: "text-sm" %> <% end %> diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb new file mode 100644 index 00000000..75a1e748 --- /dev/null +++ b/app/views/account/trades/_form.html.erb @@ -0,0 +1,19 @@ +<%# locals: (entry:) %> + +<%= styled_form_with data: { turbo_frame: "_top" }, + scope: :account_entry, + url: entry.new_record? ? account_trades_path(entry.account) : account_entry_path(entry.account, entry) do |form| %> +
+
+ <%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell]], "buy"), label: t(".type") %> + <%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %> + <%= form.date_field :date, label: true %> + <%= form.hidden_field :currency, value: entry.account.currency %> + <%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0 %> + <%= money_with_currency_field form, :price_money, label: t(".price"), disable_currency: true %> + <%= form.hidden_field :currency, value: entry.account.currency %> +
+ + <%= form.submit t(".submit") %> +
+<% end %> diff --git a/app/views/account/entries/entryables/trade/_selection_bar.html.erb b/app/views/account/trades/_selection_bar.html.erb similarity index 100% rename from app/views/account/entries/entryables/trade/_selection_bar.html.erb rename to app/views/account/trades/_selection_bar.html.erb diff --git a/app/views/account/entries/entryables/trade/_trade.html.erb b/app/views/account/trades/_trade.html.erb similarity index 63% rename from app/views/account/entries/entryables/trade/_trade.html.erb rename to app/views/account/trades/_trade.html.erb index 5c01d83c..1e878677 100644 --- a/app/views/account/entries/entryables/trade/_trade.html.erb +++ b/app/views/account/trades/_trade.html.erb @@ -13,14 +13,14 @@
<%= tag.div class: ["flex items-center gap-2"] do %>
- <%= entry.name[0].upcase %> + <%= entry_name(entry).first.upcase %>
<% if entry.new_record? %> - <%= content_tag :p, entry.name %> + <%= content_tag :p, entry_name(entry) %> <% else %> - <%= link_to entry.name, + <%= link_to entry_name(entry), account_entry_path(account, entry), data: { turbo_frame: "drawer", turbo_prefetch: false }, class: "hover:underline hover:text-gray-800" %> @@ -31,11 +31,20 @@
- <%= tag.p trade.buy? ? t(".buy") : t(".sell") %> + <% if entry.account_transaction? && entry.marked_as_transfer? %> + <%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %> + <% elsif entry.account_transaction? %> + <%= tag.p entry.inflow? ? t(".inflow") : t(".outflow") %> + <% else %> + <%= tag.p trade.buy? ? t(".buy") : t(".sell") %> + <% end %>
- <%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %> + <% if entry.account_transaction? %> + <%= tag.p format_money(entry.amount_money), class: { "text-green-500": entry.inflow? } %> + <% else %> + <%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %> + <% end %>
-
diff --git a/app/views/account/entries/trades.html.erb b/app/views/account/trades/index.html.erb similarity index 69% rename from app/views/account/entries/trades.html.erb rename to app/views/account/trades/index.html.erb index 2c02dd91..3ff7cd0d 100644 --- a/app/views/account/entries/trades.html.erb +++ b/app/views/account/trades/index.html.erb @@ -2,10 +2,10 @@
" class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">

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

- <%= link_to new_account_entry_path(@account), - disabled: true, - class: "cursor-not-allowed flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg", - data: { turbo_frame: :modal } do %> + <%= link_to new_account_trade_path(@account), + id: dom_id(@account, "new_trade"), + 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") %> <%= t(".new") %> <% end %> @@ -15,7 +15,7 @@
<%= check_box_tag "selection_entry", class: "maybe-checkbox maybe-checkbox--light", - data: { action: "bulk-select#togglePageSelection" } %> + data: { action: "bulk-select#togglePageSelection" } %> <%= tag.p t(".trade") %>
@@ -25,15 +25,15 @@
- <% if @trades.empty? %> + <% if @entries.empty? %>

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

<% else %>
- <% @trades.group_by(&:date).each do |date, entries| %> - <%= render "entry_group", date:, entries: entries %> + <%= entries_by_date(@entries) do |entries| %> + <%= render partial: "account/trades/trade", collection: entries, as: :entry %> <% end %>
<% end %> diff --git a/app/views/account/trades/new.html.erb b/app/views/account/trades/new.html.erb new file mode 100644 index 00000000..795e49d0 --- /dev/null +++ b/app/views/account/trades/new.html.erb @@ -0,0 +1,3 @@ +<%= modal_form_wrapper title: t(".title") do %> + <%= render "account/trades/form", entry: @entry %> +<% end %> diff --git a/app/views/account/entries/entryables/trade/_show.html.erb b/app/views/account/trades/show.html.erb similarity index 92% rename from app/views/account/entries/entryables/trade/_show.html.erb rename to app/views/account/trades/show.html.erb index ccd64727..1bbea909 100644 --- a/app/views/account/entries/entryables/trade/_show.html.erb +++ b/app/views/account/trades/show.html.erb @@ -1,6 +1,4 @@ -<%# locals: (entry:) %> - -<% trade, account = entry.account_trade, entry.account %> +<% entry = @entry %> <%= drawer do %>
diff --git a/app/views/account/entries/entryables/transaction/_selection_bar.html.erb b/app/views/account/transactions/_selection_bar.html.erb similarity index 100% rename from app/views/account/entries/entryables/transaction/_selection_bar.html.erb rename to app/views/account/transactions/_selection_bar.html.erb diff --git a/app/views/account/entries/entryables/transaction/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb similarity index 91% rename from app/views/account/entries/entryables/transaction/_transaction.html.erb rename to app/views/account/transactions/_transaction.html.erb index 0e7f7ebe..faba41fa 100644 --- a/app/views/account/entries/entryables/transaction/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -1,6 +1,5 @@ <%# locals: (entry:, selectable: true, editable: true, short: false, show_tags: false, **opts) %> <% transaction, account = entry.account_transaction, entry.account %> -<% is_investment_transfer = entry.account.investment? && entry.transfer.present? %>
<% name_col_span = unconfirmed_transfer?(entry) ? "col-span-10" : short ? "col-span-6" : "col-span-4" %> @@ -52,12 +51,6 @@ <% end %>
- <% if is_investment_transfer %> -
- <%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %> -
- <% end %> - <% unless entry.marked_as_transfer? %> <% unless short %>
"> @@ -89,7 +82,7 @@ <% end %> <% end %> -
ml-auto"> +
<%= content_tag :p, format_money(-entry.amount_money), class: ["text-green-600": entry.inflow?] %> diff --git a/app/views/account/entries/transactions.html.erb b/app/views/account/transactions/index.html.erb similarity index 76% rename from app/views/account/entries/transactions.html.erb rename to app/views/account/transactions/index.html.erb index 623ad9c0..c89ceba8 100644 --- a/app/views/account/entries/transactions.html.erb +++ b/app/views/account/transactions/index.html.erb @@ -4,7 +4,7 @@

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

<%= link_to new_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 %> + data: { turbo_frame: :modal } do %> <%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %> <%= t(".new") %> <% end %> @@ -12,15 +12,15 @@
"> - <% if @transaction_entries.empty? %> + <% if @entries.empty? %>

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

<% else %>
- <% @transaction_entries.group_by(&:date).each do |date, entries| %> - <%= render "entry_group", date:, entries: entries %> + <%= entries_by_date(@entries) do |entries| %> + <%= render entries %> <% end %>
<% end %> diff --git a/app/views/account/entries/entryables/transaction/_show.html.erb b/app/views/account/transactions/show.html.erb similarity index 88% rename from app/views/account/entries/entryables/transaction/_show.html.erb rename to app/views/account/transactions/show.html.erb index ebf603ba..d47db5ad 100644 --- a/app/views/account/entries/entryables/transaction/_show.html.erb +++ b/app/views/account/transactions/show.html.erb @@ -1,6 +1,4 @@ -<%# locals: (entry:) %> - -<% transaction, account = entry.account_transaction, entry.account %> +<% entry, transaction, account = @entry, @entry.account_transaction, @entry.account %> <%= drawer do %>
@@ -27,7 +25,7 @@
- <%= styled_form_with model: [account, entry], url: account_entry_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> + <%= styled_form_with model: [account, entry], url: account_transaction_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> <%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %> <% unless entry.marked_as_transfer? %>
@@ -60,15 +58,15 @@
- <%= styled_form_with model: [account, entry], url: account_entry_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> + <%= styled_form_with model: [account, entry], url: account_transaction_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %> <%= f.fields_for :entryable do |ef| %> <%= ef.select :tag_ids, options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), transaction.tag_ids), { multiple: true, - label: t(".tags_label"), - class: "placeholder:text-gray-500" + label: t(".tags_label"), + class: "placeholder:text-gray-500" }, "data-auto-submit-form-target": "auto" %> <%= ef.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), "data-auto-submit-form-target": "auto" %> @@ -84,7 +82,7 @@
- <%= styled_form_with model: [account, entry], url: account_entry_path(account, entry), class: "p-3 space-y-3", data: { controller: "auto-submit-form" } do |f| %> + <%= styled_form_with model: [account, entry], url: account_transaction_path(account, entry), class: "p-3 space-y-3", data: { controller: "auto-submit-form" } do |f| %> <%= f.fields_for :entryable do |ef| %>
@@ -110,8 +108,8 @@ <%= button_to t(".delete"), account_entry_path(account, 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" } %> + 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" } %>
<% end %>
diff --git a/app/views/account/transfers/_form.html.erb b/app/views/account/transfers/_form.html.erb index 2f58b08e..84e0b914 100644 --- a/app/views/account/transfers/_form.html.erb +++ b/app/views/account/transfers/_form.html.erb @@ -1,4 +1,11 @@ <%= styled_form_with model: transfer, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %> + <% if transfer.errors.present? %> +
+ <%= lucide_icon "circle-alert", class: "w-5 h-5" %> +

<%= @transfer.errors.full_messages.to_sentence %>

+
+ <% end %> +
<%= 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 %> diff --git a/app/views/account/transfers/new.html.erb b/app/views/account/transfers/new.html.erb index fc399358..f1a7ae0d 100644 --- a/app/views/account/transfers/new.html.erb +++ b/app/views/account/transfers/new.html.erb @@ -1,17 +1,3 @@ -<%= modal do %> -
-
- <%= tag.h2 t(".title"), class: "font-medium text-xl" %> - <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
- - <% if @transfer.errors.present? %> -
- <%= lucide_icon "circle-alert", class: "w-5 h-5" %> -

<%= @transfer.errors.full_messages.to_sentence %>

-
- <% end %> - - <%= render "form", transfer: @transfer %> -
+<%= modal_form_wrapper title: t(".title") do %> + <%= render "form", transfer: @transfer %> <% end %> diff --git a/app/views/account/entries/entryables/valuation/_form.html.erb b/app/views/account/valuations/_form.html.erb similarity index 77% rename from app/views/account/entries/entryables/valuation/_form.html.erb rename to app/views/account/valuations/_form.html.erb index 3d3f1025..b069c90b 100644 --- a/app/views/account/entries/entryables/valuation/_form.html.erb +++ b/app/views/account/valuations/_form.html.erb @@ -1,7 +1,8 @@ <%# locals: (entry:) %> + <%= form_with model: [entry.account, entry], data: { turbo_frame: "_top" }, - url: entry.new_record? ? account_entries_path(entry.account) : account_entry_path(entry.account, entry) do |f| %> + url: entry.new_record? ? account_valuations_path(entry.account) : account_entry_path(entry.account, entry) do |f| %>
@@ -11,12 +12,11 @@ <%= f.date_field :date, required: "required", min: Account::Entry.min_supported_date, max: Date.current, class: "border border-alpha-black-200 bg-white rounded-lg shadow-xs min-w-[200px] px-3 py-1.5 text-gray-900 text-sm" %> <%= f.number_field :amount, required: "required", placeholder: "0.00", step: "0.01", class: "bg-white border border-alpha-black-200 rounded-lg shadow-xs text-gray-900 text-sm px-3 py-1.5 text-right" %> <%= f.hidden_field :currency, value: entry.account.currency %> - <%= f.hidden_field :entryable_type, value: entry.entryable_type %>
- <%= link_to t(".cancel"), valuation_account_entries_path(entry.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %> + <%= link_to t(".cancel"), account_valuations_path(entry.account), class: "text-sm text-gray-900 hover:text-gray-800 font-medium px-3 py-1.5" %> <%= f.submit class: "bg-gray-50 rounded-lg font-medium px-3 py-1.5 cursor-pointer hover:bg-gray-100 text-sm" %>
diff --git a/app/views/account/entries/entryables/valuation/_valuation.html.erb b/app/views/account/valuations/_valuation.html.erb similarity index 100% rename from app/views/account/entries/entryables/valuation/_valuation.html.erb rename to app/views/account/valuations/_valuation.html.erb diff --git a/app/views/account/valuations/edit.html.erb b/app/views/account/valuations/edit.html.erb new file mode 100644 index 00000000..1e94f516 --- /dev/null +++ b/app/views/account/valuations/edit.html.erb @@ -0,0 +1,3 @@ +<%= turbo_frame_tag dom_id(@entry) do %> + <%= render "account/valuations/form", entry: @entry %> +<% end %> diff --git a/app/views/account/entries/valuations.html.erb b/app/views/account/valuations/index.html.erb similarity index 73% rename from app/views/account/entries/valuations.html.erb rename to app/views/account/valuations/index.html.erb index f993d037..41b7ada8 100644 --- a/app/views/account/entries/valuations.html.erb +++ b/app/views/account/valuations/index.html.erb @@ -2,8 +2,8 @@
<%= tag.h2 t(".valuations"), class: "font-medium text-lg" %> - <%= link_to new_account_entry_path(@account, entryable_type: "Account::Valuation"), - data: { turbo_frame: dom_id(@account.entries.account_valuations.new) }, + <%= link_to new_account_valuation_path(@account), + data: { turbo_frame: dom_id(@account.entries.account_valuations.new) }, class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %> <%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %> <%= tag.span t(".new_entry"), class: "text-sm" %> @@ -21,11 +21,11 @@
<%= turbo_frame_tag dom_id(@account.entries.account_valuations.new) %> - <% if @valuation_entries.any? %> - <%= render partial: "account/entries/entryables/valuation/valuation", - collection: @valuation_entries, - as: :entry, - spacer_template: "ruler" %> + <% if @entries.any? %> + <%= render partial: "account/valuations/valuation", + collection: @entries, + as: :entry, + spacer_template: "account/entries/ruler" %> <% else %>

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

<% end %> diff --git a/app/views/account/valuations/new.html.erb b/app/views/account/valuations/new.html.erb new file mode 100644 index 00000000..b74a04c7 --- /dev/null +++ b/app/views/account/valuations/new.html.erb @@ -0,0 +1,4 @@ +<%= turbo_frame_tag dom_id(@entry) do %> + <%= render "account/valuations/form", entry: @entry %> +
+<% end %> diff --git a/app/views/account/valuations/show.html.erb b/app/views/account/valuations/show.html.erb new file mode 100644 index 00000000..d8efac24 --- /dev/null +++ b/app/views/account/valuations/show.html.erb @@ -0,0 +1,3 @@ +<% entry = @entry %> + +<%= render "account/valuations/valuation", entry: entry %> diff --git a/app/views/accounts/edit.html.erb b/app/views/accounts/edit.html.erb index 77b03030..8767c4ad 100644 --- a/app/views/accounts/edit.html.erb +++ b/app/views/accounts/edit.html.erb @@ -1,22 +1,15 @@ -<%= modal do %> -
-
-

<%= t(".edit", account: @account.name) %>

- <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
+<%= modal_form_wrapper title: t(".edit", account: @account.name) do %> + <%= styled_form_with model: @account, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %> + <%= f.text_field :name, label: t(".name") %> + <%= money_with_currency_field f, :balance_money, label: t(".balance"), default_currency: @account.currency, disable_currency: true %> - <%= styled_form_with model: @account, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %> - <%= f.text_field :name, label: t(".name") %> - <%= money_with_currency_field f, :balance_money, label: t(".balance"), default_currency: @account.currency, disable_currency: true %> +
+ <%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %> + <%= link_to new_institution_path do %> + <%= lucide_icon "plus", class: "text-gray-700 hover:text-gray-500 w-4 h-4 absolute right-3 top-2" %> + <% end %> +
-
- <%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %> - <%= link_to new_institution_path do %> - <%= lucide_icon "plus", class: "text-gray-700 hover:text-gray-500 w-4 h-4 absolute right-3 top-2" %> - <% end %> -
- - <%= f.submit %> - <% end %> -
+ <%= f.submit %> + <% end %> <% end %> diff --git a/app/views/categories/edit.html.erb b/app/views/categories/edit.html.erb index d835e76f..69c52c6d 100644 --- a/app/views/categories/edit.html.erb +++ b/app/views/categories/edit.html.erb @@ -1,10 +1,3 @@ -<%= modal do %> -
-
-

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

- <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
- - <%= render "form", category: @category %> -
+<%= modal_form_wrapper title: t(".edit") do %> + <%= render "form", category: @category %> <% end %> diff --git a/app/views/categories/new.html.erb b/app/views/categories/new.html.erb index aad6f15b..de8e68d3 100644 --- a/app/views/categories/new.html.erb +++ b/app/views/categories/new.html.erb @@ -1,10 +1,3 @@ -<%= modal do %> -
-
-

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

- <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
- - <%= render "form", category: @category %> -
+<%= modal_form_wrapper title: t(".new_category") do %> + <%= render "form", category: @category %> <% end %> diff --git a/app/views/category/deletions/new.html.erb b/app/views/category/deletions/new.html.erb index de800518..67575498 100644 --- a/app/views/category/deletions/new.html.erb +++ b/app/views/category/deletions/new.html.erb @@ -1,34 +1,21 @@ -<%= modal do %> -
-
-
-

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

- <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
+<%= modal_form_wrapper title: t(".delete_category"), subtitle: t(".explanation", category_name: @category.name) do %> + <%= styled_form_with url: category_deletions_path(@category), + class: "space-y-4", + data: { + turbo: false, + controller: "deletion", + deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50", + deletion_safe_action_class: "form-field__submit border border-transparent", + deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name), + deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %> + <%= f.collection_select :replacement_category_id, + Current.family.categories.alphabetically.without(@category), + :id, :name, + { prompt: t(".replacement_category_prompt"), label: t(".category") }, + { data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %> -

- <%= t(".explanation", category_name: @category.name) %> -

-
- - <%= styled_form_with url: category_deletions_path(@category), - class: "space-y-4", - data: { - turbo: false, - controller: "deletion", - deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50", - deletion_safe_action_class: "form-field__submit border border-transparent", - deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name), - deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %> - <%= f.collection_select :replacement_category_id, - Current.family.categories.alphabetically.without(@category), - :id, :name, - { prompt: t(".replacement_category_prompt"), label: t(".category") }, - { data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %> - - <%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name), - class: "form-field__submit bg-white text-red-600 border hover:bg-red-50", - data: { deletion_target: "submitButton" } %> - <% end %> -
+ <%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name), + class: "form-field__submit bg-white text-red-600 border hover:bg-red-50", + data: { deletion_target: "submitButton" } %> + <% end %> <% end %> diff --git a/app/views/category/dropdowns/_row.html.erb b/app/views/category/dropdowns/_row.html.erb index eb75cf91..b4b6971b 100644 --- a/app/views/category/dropdowns/_row.html.erb +++ b/app/views/category/dropdowns/_row.html.erb @@ -2,7 +2,7 @@ <% is_selected = category.id === @selected_category&.id %> <%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %> - <%= button_to account_entry_path(@transaction.entry.account, @transaction.entry, account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: category.id } }), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %> + <%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry, account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: category.id } }), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %> <%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %> diff --git a/app/views/category/dropdowns/show.html.erb b/app/views/category/dropdowns/show.html.erb index b567b690..4f3baa63 100644 --- a/app/views/category/dropdowns/show.html.erb +++ b/app/views/category/dropdowns/show.html.erb @@ -25,7 +25,7 @@ <% end %> <% if @transaction.category %> - <%= button_to account_entry_path(@transaction.entry.account, @transaction.entry), + <%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } }, diff --git a/app/views/imports/confirm.html.erb b/app/views/imports/confirm.html.erb index 70c6a3f5..7b806a62 100644 --- a/app/views/imports/confirm.html.erb +++ b/app/views/imports/confirm.html.erb @@ -9,14 +9,8 @@
- <% transaction_entries = @import.dry_run %> - <% transaction_entries.group_by(&:date).each do |date, transactions| %> - <%= render "account/entries/entry_group", - date: date, - entries: transactions, - show_tags: true, - selectable: false, - editable: false %> + <%= entries_by_date(@import.dry_run, selectable: false) do |entries| %> + <%= render entries, show_tags: true, selectable: false, editable: false %> <% end %>
diff --git a/app/views/institutions/edit.html.erb b/app/views/institutions/edit.html.erb index fbcb813c..75c81579 100644 --- a/app/views/institutions/edit.html.erb +++ b/app/views/institutions/edit.html.erb @@ -1,10 +1,3 @@ -<%= modal do %> -
-
-

<%= t(".edit", institution: @institution.name) %>

- <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
- - <%= render "form", institution: @institution %> -
+<%= modal_form_wrapper title: t(".edit", institution: @institution.name) do %> + <%= render "form", institution: @institution %> <% end %> diff --git a/app/views/institutions/new.html.erb b/app/views/institutions/new.html.erb index 7f74dac2..94c36193 100644 --- a/app/views/institutions/new.html.erb +++ b/app/views/institutions/new.html.erb @@ -1,10 +1,3 @@ -<%= modal do %> -
-
-

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

- <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
- - <%= render "form", institution: @institution %> -
+<%= modal_form_wrapper title: t(".new_institution") do %> + <%= render "form", institution: @institution %> <% end %> diff --git a/app/views/merchants/edit.html.erb b/app/views/merchants/edit.html.erb index 713a3c88..a8776d3a 100644 --- a/app/views/merchants/edit.html.erb +++ b/app/views/merchants/edit.html.erb @@ -1,10 +1,3 @@ -<%= modal classes: "max-w-fit" do %> -
-
-

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

- <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
- - <%= render "form", merchant: @merchant %> -
+<%= modal_form_wrapper title: t(".title") do %> + <%= render "form", merchant: @merchant %> <% end %> diff --git a/app/views/merchants/new.html.erb b/app/views/merchants/new.html.erb index 713a3c88..a8776d3a 100644 --- a/app/views/merchants/new.html.erb +++ b/app/views/merchants/new.html.erb @@ -1,10 +1,3 @@ -<%= modal classes: "max-w-fit" do %> -
-
-

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

- <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
- - <%= render "form", merchant: @merchant %> -
+<%= modal_form_wrapper title: t(".title") do %> + <%= render "form", merchant: @merchant %> <% end %> diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 27bf0c57..8a7a6b60 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -162,13 +162,8 @@
<% else %>
- <% @transaction_entries.group_by(&:date).each do |date, transactions| %> - <%= render "account/entries/entry_group", - date: date, - entries: transactions, - selectable: false, - editable: false, - short: true %> + <%= entries_by_date(@transaction_entries, selectable: false) do |entries| %> + <%= render entries, selectable: false, editable: false, short: true %> <% end %>

<%= link_to t(".view_all"), transactions_path %>

diff --git a/app/views/shared/_modal_form.html.erb b/app/views/shared/_modal_form.html.erb new file mode 100644 index 00000000..68a462ee --- /dev/null +++ b/app/views/shared/_modal_form.html.erb @@ -0,0 +1,18 @@ +<%# locals: (title:, content:, subtitle: nil) %> + +<%= modal do %> +
+
+
+

<%= title %>

+ <%= lucide_icon("x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" }) %> +
+ + <% if subtitle.present? %> + <%= tag.p subtitle, class: "text-gray-500 font-light" %> + <% end %> +
+ + <%= content %> +
+<% end %> diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb index fc662791..14fe7e7b 100644 --- a/app/views/shared/_money_field.html.erb +++ b/app/views/shared/_money_field.html.erb @@ -1,7 +1,7 @@ <%# locals: (form:, money_method:, default_currency:, disable_currency: false, hide_currency: false, label: nil) %> <% fallback_label = t(".money-label") %> -<% currency = form.object.send(money_method)&.currency || Money::Currency.new(default_currency) %> +<% currency = form.object ? (form.object.send(money_method)&.currency || Money::Currency.new(default_currency)) : Money::Currency.new(default_currency) %>
<%= form.label label || fallback_label, { class: "form-field__label" } %> diff --git a/app/views/tag/deletions/new.html.erb b/app/views/tag/deletions/new.html.erb index 48c3562d..8d6dd067 100644 --- a/app/views/tag/deletions/new.html.erb +++ b/app/views/tag/deletions/new.html.erb @@ -1,34 +1,21 @@ -<%= modal do %> -
-
-
-

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

- <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
+<%= modal_form_wrapper title: t(".delete_tag"), subtitle: t(".explanation", tag_name: @tag.name) do %> + <%= styled_form_with url: tag_deletions_path(@tag), + class: "space-y-4", + data: { + turbo: false, + controller: "deletion", + deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50", + deletion_safe_action_class: "form-field__submit border border-transparent", + deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", tag_name: @tag.name), + deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", tag_name: @tag.name) } do |f| %> + <%= f.collection_select :replacement_tag_id, + Current.family.tags.alphabetically.without(@tag), + :id, :name, + { prompt: t(".replacement_tag_prompt"), label: t(".tag") }, + { data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %> -

- <%= t(".explanation", tag_name: @tag.name) %> -

-
- - <%= styled_form_with url: tag_deletions_path(@tag), - class: "space-y-4", - data: { - turbo: false, - controller: "deletion", - deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50", - deletion_safe_action_class: "form-field__submit border border-transparent", - deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", tag_name: @tag.name), - deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", tag_name: @tag.name) } do |f| %> - <%= f.collection_select :replacement_tag_id, - Current.family.tags.alphabetically.without(@tag), - :id, :name, - { prompt: t(".replacement_tag_prompt"), label: t(".tag") }, - { data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %> - - <%= f.submit t(".delete_and_leave_uncategorized", tag_name: @tag.name), - class: "form-field__submit bg-white text-red-600 border hover:bg-red-50", - data: { deletion_target: "submitButton" } %> - <% end %> -
+ <%= f.submit t(".delete_and_leave_uncategorized", tag_name: @tag.name), + class: "form-field__submit bg-white text-red-600 border hover:bg-red-50", + data: { deletion_target: "submitButton" } %> + <% end %> <% end %> diff --git a/app/views/tags/edit.html.erb b/app/views/tags/edit.html.erb index ee601c4d..75ad4a0f 100644 --- a/app/views/tags/edit.html.erb +++ b/app/views/tags/edit.html.erb @@ -1,10 +1,3 @@ -<%= modal do %> -
-
-

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

- <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
- - <%= render "form", tag: @tag %> -
+<%= modal_form_wrapper title: t(".edit") do %> + <%= render "form", tag: @tag %> <% end %> diff --git a/app/views/tags/new.html.erb b/app/views/tags/new.html.erb index 2ac767b3..ad97c79d 100644 --- a/app/views/tags/new.html.erb +++ b/app/views/tags/new.html.erb @@ -1,10 +1,3 @@ -<%= modal do %> -
-
-

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

- <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
- - <%= render "form", tag: @tag %> -
+<%= modal_form_wrapper title: t(".new") do %> + <%= render "form", tag: @tag %> <% end %> diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 695be9c8..8626da49 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -11,7 +11,7 @@ <% if @transaction_entries.present? %>
@@ -27,8 +27,9 @@

amount

- <% @transaction_entries.group_by(&:date).each do |date, entries| %> - <%= render "account/entries/entry_group", date:, combine_transfers: true, entries: %> + <%= entries_by_date(@transaction_entries) do |entries| %> + <%= render entries.reject { |e| e.transfer_id.present? }, selectable: true %> + <%= render transfer_entries(entries), selectable: false %> <% end %>
diff --git a/app/views/transactions/new.html.erb b/app/views/transactions/new.html.erb index cd86807c..0079185d 100644 --- a/app/views/transactions/new.html.erb +++ b/app/views/transactions/new.html.erb @@ -1,10 +1,3 @@ -<%= modal do %> -
-
-

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

- <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> -
- - <%= render "form", transaction: @transaction, entry: @entry %> -
+<%= modal_form_wrapper title: t(".new_transaction") do %> + <%= render "form", transaction: @transaction, entry: @entry %> <% end %> diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 421df93e..b6e8ef70 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,74 +1,6 @@ { "ignored_warnings": [ - { - "warning_type": "Dynamic Render Path", - "warning_code": 15, - "fingerprint": "6179565a9eb1786348e6bbaf5d838b77f9075551930a6ca8ba33fbbf6d2adf26", - "check_name": "Render", - "message": "Render path contains parameter value", - "file": "app/views/account/entries/show.html.erb", - "line": 1, - "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(partial => permitted_entryable_partial_path(Current.family.accounts.find(params[:account_id]).entries.find(params[:id]), \"show\"), { :locals => ({ :entry => Current.family.accounts.find(params[:account_id]).entries.find(params[:id]) }) })", - "render_path": [ - { - "type": "controller", - "class": "Account::EntriesController", - "method": "show", - "line": 42, - "file": "app/controllers/account/entries_controller.rb", - "rendered": { - "name": "account/entries/show", - "file": "app/views/account/entries/show.html.erb" - } - } - ], - "location": { - "type": "template", - "template": "account/entries/show" - }, - "user_input": "params[:id]", - "confidence": "Weak", - "cwe_id": [ - 22 - ], - "note": "" - }, - { - "warning_type": "Dynamic Render Path", - "warning_code": 15, - "fingerprint": "7a182d062523a7fe890fbe5945c0004aeec1044ac764430f1d464326e5fa2710", - "check_name": "Render", - "message": "Render path contains parameter value", - "file": "app/views/account/entries/edit.html.erb", - "line": 2, - "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/", - "code": "render(action => permitted_entryable_partial_path(Current.family.accounts.find(params[:account_id]).entries.find(params[:id]), \"edit\"), { :entry => Current.family.accounts.find(params[:account_id]).entries.find(params[:id]) })", - "render_path": [ - { - "type": "controller", - "class": "Account::EntriesController", - "method": "edit", - "line": 29, - "file": "app/controllers/account/entries_controller.rb", - "rendered": { - "name": "account/entries/edit", - "file": "app/views/account/entries/edit.html.erb" - } - } - ], - "location": { - "type": "template", - "template": "account/entries/edit" - }, - "user_input": "params[:id]", - "confidence": "Weak", - "cwe_id": [ - 22 - ], - "note": "" - } ], - "updated": "2024-06-30 12:52:10 -0400", + "updated": "2024-08-09 10:16:00 -0400", "brakeman_version": "6.1.2" } diff --git a/config/locales/views/account/entries/en.yml b/config/locales/views/account/entries/en.yml index c4c26303..ca0d6e8c 100644 --- a/config/locales/views/account/entries/en.yml +++ b/config/locales/views/account/entries/en.yml @@ -2,92 +2,12 @@ en: account: entries: - create: - success: "%{name} created" destroy: success: Entry deleted empty: description: Try adding an entry, editing filters or refining your search title: No entries found - entryables: - trade: - show: - overview: Overview - trade: - buy: Buy - sell: Sell - transaction: - selection_bar: - mark_transfers: Mark as transfers? - mark_transfers_confirm: Mark as transfers - mark_transfers_message: By marking transactions as transfers, they will - no longer be included in income or spending calculations. - show: - account_label: Account - account_placeholder: Select an account - additional: Additional - 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 balances, and cannot be undone. - delete_title: Delete transaction - exclude_subtitle: This excludes the transaction from any in-app features - or analytics. - exclude_title: Exclude transaction - merchant_label: Merchant - merchant_placeholder: Select a merchant - name_label: Name - nature: Transaction type - note_label: Notes - note_placeholder: Enter a note - overview: Overview - settings: Settings - tags_label: Select one or more tags - transaction: - deposit: Deposit - remove_transfer: Remove transfer - remove_transfer_body: This will remove the transfer from this transaction - remove_transfer_confirm: Confirm - withdrawal: Withdrawal - valuation: - form: - cancel: Cancel - valuation: - confirm_accept: Delete entry - confirm_body_html: "

Deleting this entry will remove it from the account’s - history which will impact different parts of your account. This includes - the net worth and account graphs.


The only way you’ll be - able to add this entry back is by re-entering it manually via a new - entry

" - confirm_title: Delete Entry? - delete_entry: Delete entry - edit_entry: Edit entry - no_change: No change - start_balance: Starting balance - value_update: Value update loading: loading: Loading entries... - trades: - amount: Amount - new: New transaction - no_trades: No transactions for this account yet. - trade: transaction - trades: Transactions - type: Type - transactions: - new: New transaction - no_transactions: No transactions for this account yet. - transaction: transaction - transactions: Transactions update: success: Entry updated - valuations: - change: change - date: date - new_entry: New entry - no_valuations: No valuations for this account yet - valuations: Value history - value: value diff --git a/config/locales/views/account/holdings/en.yml b/config/locales/views/account/holdings/en.yml index 6c931874..35a7b59a 100644 --- a/config/locales/views/account/holdings/en.yml +++ b/config/locales/views/account/holdings/en.yml @@ -11,7 +11,7 @@ en: name: name needs_sync: Your account needs to sync the latest prices to calculate this portfolio - new_holding: New holding + new_holding: New transaction no_holdings: No holdings to show. return: total return weight: weight diff --git a/config/locales/views/account/trades/en.yml b/config/locales/views/account/trades/en.yml new file mode 100644 index 00000000..9cf195ce --- /dev/null +++ b/config/locales/views/account/trades/en.yml @@ -0,0 +1,31 @@ +--- +en: + account: + trades: + create: + success: Transaction created successfully. + form: + holding: Ticker symbol + price: Price per share + qty: Quantity + submit: Add transaction + ticker_placeholder: AAPL + type: Type + index: + amount: Amount + new: New transaction + no_trades: No transactions for this account yet. + trade: transaction + trades: Transactions + type: Type + new: + title: New transaction + show: + overview: Overview + trade: + buy: Buy + deposit: Deposit + inflow: Inflow + outflow: Outflow + sell: Sell + withdrawal: Withdrawal diff --git a/config/locales/views/account/transactions/en.yml b/config/locales/views/account/transactions/en.yml new file mode 100644 index 00000000..28f089dd --- /dev/null +++ b/config/locales/views/account/transactions/en.yml @@ -0,0 +1,44 @@ +--- +en: + account: + transactions: + index: + new: New transaction + no_transactions: No transactions for this account yet. + transaction: transaction + transactions: Transactions + selection_bar: + mark_transfers: Mark as transfers? + mark_transfers_confirm: Mark as transfers + mark_transfers_message: By marking transactions as transfers, they will no + longer be included in income or spending calculations. + show: + account_label: Account + account_placeholder: Select an account + additional: Additional + 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 + balances, and cannot be undone. + delete_title: Delete transaction + exclude_subtitle: This excludes the transaction from any in-app features or + analytics. + exclude_title: Exclude transaction + merchant_label: Merchant + merchant_placeholder: Select a merchant + name_label: Name + nature: Transaction type + note_label: Notes + note_placeholder: Enter a note + overview: Overview + settings: Settings + tags_label: Select one or more tags + transaction: + remove_transfer: Remove transfer + remove_transfer_body: This will remove the transfer from this transaction + remove_transfer_confirm: Confirm + update: + success: Transaction updated successfully. diff --git a/config/locales/views/account/valuations/en.yml b/config/locales/views/account/valuations/en.yml new file mode 100644 index 00000000..71de8c1d --- /dev/null +++ b/config/locales/views/account/valuations/en.yml @@ -0,0 +1,27 @@ +--- +en: + account: + valuations: + create: + success: Valuation created successfully. + form: + cancel: Cancel + index: + change: change + date: date + new_entry: New entry + no_valuations: No valuations for this account yet + valuations: Value history + value: value + valuation: + confirm_accept: Delete entry + confirm_body_html: "

Deleting this entry will remove it from the account’s + history which will impact different parts of your account. This includes + the net worth and account graphs.


The only way you’ll be able + to add this entry back is by re-entering it manually via a new entry

" + confirm_title: Delete Entry? + delete_entry: Delete entry + edit_entry: Edit entry + no_change: No change + start_balance: Starting balance + value_update: Value update diff --git a/config/routes.rb b/config/routes.rb index e2963712..15c00c9f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,13 +81,11 @@ Rails.application.routes.draw do resources :holdings, only: %i[ index new show ] resources :cashes, only: :index - resources :entries, except: :index do - collection do - get "transactions", as: :transaction - get "valuations", as: :valuation - get "trades", as: :trade - end - end + resources :transactions, only: %i[ index update ] + resources :valuations, only: %i[ index new create ] + resources :trades, only: %i[ index new create ] + + resources :entries, only: %i[ edit update show destroy ] end end diff --git a/db/migrate/20240807153618_add_currency_field_to_trade.rb b/db/migrate/20240807153618_add_currency_field_to_trade.rb new file mode 100644 index 00000000..af18123a --- /dev/null +++ b/db/migrate/20240807153618_add_currency_field_to_trade.rb @@ -0,0 +1,5 @@ +class AddCurrencyFieldToTrade < ActiveRecord::Migration[7.2] + def change + add_column :account_trades, :currency, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index a80382f5..81da1415 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_07_31_191344) do +ActiveRecord::Schema[7.2].define(version: 2024_08_07_153618) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -81,6 +81,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_31_191344) do t.decimal "price", precision: 19, scale: 4 t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "currency" t.index ["security_id"], name: "index_account_trades_on_security_id" end diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index 83b45e4f..80b2711b 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -3,6 +3,9 @@ require "test_helper" class ApplicationSystemTestCase < ActionDispatch::SystemTestCase setup do Capybara.default_max_wait_time = 5 + + # Prevent "auto sync" from running when tests execute enqueued jobs + families(:dylan_family).update! last_synced_at: Time.now end driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : :chrome, screen_size: [ 1400, 1400 ] diff --git a/test/controllers/account/entries_controller_test.rb b/test/controllers/account/entries_controller_test.rb index 5442fae5..b8b38357 100644 --- a/test/controllers/account/entries_controller_test.rb +++ b/test/controllers/account/entries_controller_test.rb @@ -5,114 +5,15 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest sign_in @user = users(:family_admin) @transaction = account_entries :transaction @valuation = account_entries :valuation + @trade = account_entries :trade end - test "should edit valuation entry" do - get edit_account_entry_url(@valuation.account, @valuation) - assert_response :success - end + # ================= + # Shared + # ================= - test "should show transaction entry" do - get account_entry_url(@transaction.account, @transaction) - assert_response :success - end - - test "should show valuation entry" do - get account_entry_url(@valuation.account, @valuation) - assert_response :success - end - - test "should get list of transaction entries" do - get transaction_account_entries_url(@transaction.account) - assert_response :success - end - - test "should get list of valuation entries" do - get valuation_account_entries_url(@valuation.account) - assert_response :success - end - - test "gets new entry by type" do - get new_account_entry_url(@valuation.account, entryable_type: "Account::Valuation") - assert_response :success - end - - test "should create valuation" do - assert_difference [ "Account::Entry.count", "Account::Valuation.count" ], 1 do - post account_entries_url(@valuation.account), params: { - account_entry: { - name: "Manual valuation", - amount: 19800, - date: Date.current, - currency: @valuation.account.currency, - entryable_type: "Account::Valuation", - entryable_attributes: {} - } - } - end - - assert_equal "Valuation created", flash[:notice] - assert_enqueued_with job: AccountSyncJob - assert_redirected_to account_path(@valuation.account) - end - - test "error when valuation already exists for date" do - assert_no_difference_in_entries do - post account_entries_url(@valuation.account), params: { - account_entry: { - amount: 19800, - date: @valuation.date, - currency: @valuation.currency, - entryable_type: "Account::Valuation", - entryable_attributes: {} - } - } - end - - assert_equal "Date has already been taken", flash[:alert] - assert_redirected_to account_path(@valuation.account) - end - - test "can update entry without entryable attributes" do - assert_no_difference_in_entries do - patch account_entry_url(@valuation.account, @valuation), params: { - account_entry: { - name: "Updated name" - } - } - end - - assert_redirected_to account_entry_url(@valuation.account, @valuation) - assert_enqueued_with(job: AccountSyncJob) - end - - test "should update transaction entry with entryable attributes" do - assert_no_difference_in_entries do - patch account_entry_url(@transaction.account, @transaction), params: { - account_entry: { - name: "Updated name", - date: Date.current, - currency: "USD", - amount: 20, - entryable_type: @transaction.entryable_type, - entryable_attributes: { - id: @transaction.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 - } - } - } - end - - assert_redirected_to account_entry_url(@transaction.account, @transaction) - assert_enqueued_with(job: AccountSyncJob) - end - - test "should destroy transaction entry" do - [ @transaction, @valuation ].each do |entry| + 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 @@ -122,6 +23,38 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest end 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: AccountSyncJob) + end + end + private # Simple guard to verify that nested attributes are passed the record ID to avoid new creation of record diff --git a/test/controllers/account/trades_controller_test.rb b/test/controllers/account/trades_controller_test.rb new file mode 100644 index 00000000..b01089b6 --- /dev/null +++ b/test/controllers/account/trades_controller_test.rb @@ -0,0 +1,63 @@ +require "test_helper" + +class Account::TradesControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @entry = account_entries :trade + end + + test "should get index" do + get account_trades_url(@entry.account) + assert_response :success + end + + test "should get new" do + get new_account_trade_url(@entry.account) + assert_response :success + end + + 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: { + account_entry: { + type: "buy", + date: Date.current, + ticker: "NVDA", + qty: 10, + price: 10 + } + } + end + + created_entry = Account::Entry.order(created_at: :desc).first + + assert created_entry.amount.positive? + assert created_entry.account_trade.qty.positive? + assert_equal "Transaction created successfully.", flash[:notice] + assert_enqueued_with job: AccountSyncJob + assert_redirected_to account_path(@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: { + account_entry: { + type: "sell", + ticker: "AAPL", + date: Date.current, + currency: "USD", + qty: 10, + price: 10 + } + } + end + + created_entry = Account::Entry.order(created_at: :desc).first + + assert created_entry.amount.negative? + assert created_entry.account_trade.qty.negative? + assert_equal "Transaction created successfully.", flash[:notice] + assert_enqueued_with job: AccountSyncJob + assert_redirected_to account_path(@entry.account) + end +end diff --git a/test/controllers/account/transactions_controller_test.rb b/test/controllers/account/transactions_controller_test.rb new file mode 100644 index 00000000..f5290229 --- /dev/null +++ b/test/controllers/account/transactions_controller_test.rb @@ -0,0 +1,40 @@ +require "test_helper" + +class Account::TransactionsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in @user = users(:family_admin) + @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: { + account_entry: { + name: "Name", + date: Date.current, + currency: "USD", + amount: 100, + nature: "income", + entryable_type: @entry.entryable_type, + 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 + } + } + } + end + + assert_equal "Transaction updated successfully.", flash[:notice] + assert_redirected_to account_entry_url(@entry.account, @entry) + assert_enqueued_with(job: AccountSyncJob) + end +end diff --git a/test/controllers/account/valuations_controller_test.rb b/test/controllers/account/valuations_controller_test.rb new file mode 100644 index 00000000..0523a19e --- /dev/null +++ b/test/controllers/account/valuations_controller_test.rb @@ -0,0 +1,50 @@ +require "test_helper" + +class Account::ValuationsControllerTest < ActionDispatch::IntegrationTest + 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: AccountSyncJob + assert_redirected_to account_valuations_path(@entry.account) + end + + test "error when valuation already exists for date" do + assert_no_difference [ "Account::Entry.count", "Account::Valuation.count" ] do + post account_valuations_url(@entry.account), params: { + account_entry: { + amount: 19800, + date: @entry.date, + currency: "USD" + } + } + end + + assert_equal "Date has already been taken", flash[:alert] + assert_redirected_to account_path(@entry.account) + end +end diff --git a/test/fixtures/account/trades.yml b/test/fixtures/account/trades.yml index b782ec63..bf1acd71 100644 --- a/test/fixtures/account/trades.yml +++ b/test/fixtures/account/trades.yml @@ -2,3 +2,4 @@ one: security: aapl qty: 10 price: 214 + currency: USD diff --git a/test/support/account/entries_test_helper.rb b/test/support/account/entries_test_helper.rb index ac68574d..fb0356f4 100644 --- a/test/support/account/entries_test_helper.rb +++ b/test/support/account/entries_test_helper.rb @@ -34,7 +34,8 @@ module Account::EntriesTestHelper trade = Account::Trade.new \ qty: qty, security: security, - price: trade_price + price: trade_price, + currency: "USD" account.entries.create! \ name: "Trade", diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb new file mode 100644 index 00000000..541a445c --- /dev/null +++ b/test/system/trades_test.rb @@ -0,0 +1,67 @@ +require "application_system_test_case" + +class TradesTest < ApplicationSystemTestCase + include ActiveJob::TestHelper + + setup do + sign_in @user = users(:family_admin) + + @account = accounts(:investment) + + visit_account_trades + end + + test "can create buy transaction" do + shares_qty = 25.0 + + open_new_trade_modal + + fill_in "Ticker symbol", with: "NVDA" + fill_in "Date", with: Date.current + fill_in "Quantity", with: shares_qty + fill_in "account_entry[price]", with: 214.23 + + click_button "Add transaction" + + visit_account_trades + + within_trades do + assert_text "Purchase 10 shares of AAPL" + assert_text "Buy #{shares_qty} shares of NVDA" + end + end + + test "can create sell transaction" do + aapl = @account.holdings.current.find { |h| h.security.ticker == "AAPL" } + + open_new_trade_modal + + select "Sell", from: "Type" + fill_in "Ticker symbol", with: aapl.ticker + fill_in "Date", with: Date.current + fill_in "Quantity", with: aapl.qty + fill_in "account_entry[price]", with: 215.33 + + click_button "Add transaction" + + visit_account_trades + + within_trades do + assert_text "Sell #{aapl.qty} shares of AAPL" + end + end + + private + + def open_new_trade_modal + click_link "new_trade_account_#{@account.id}" + end + + def within_trades(&block) + within "#" + dom_id(@account, "trades"), &block + end + + def visit_account_trades + visit account_url(@account, tab: "trades") + end +end