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:) %>
<%= t(".no_trades") %>
<% else %>