mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Allow user to add buy and sell trade transactions for investment accounts (#1066)
* Consolidate modal form structure into partial + helper * Scaffold out trade transaction form * Normalize translations * Add buy and sell trade form with tests * Move entryable lists to dedicated controllers * Delegate entry group contents rendering * More cleanup * Extract transaction and valuation update logic from entries controller * Delegate edit and show actions to entryables * Trade builder * Update paths for transaction updates
This commit is contained in:
parent
6bca35fa22
commit
e05f03b314
75 changed files with 801 additions and 624 deletions
|
@ -6,3 +6,7 @@ inherit_gem: { rubocop-rails-omakase: rubocop.yml }
|
||||||
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
|
# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
|
||||||
# Layout/SpaceInsideArrayLiteralBrackets:
|
# Layout/SpaceInsideArrayLiteralBrackets:
|
||||||
# Enabled: false
|
# Enabled: false
|
||||||
|
Layout/ElseAlignment:
|
||||||
|
Enabled: false
|
||||||
|
Layout/EndAlignment:
|
||||||
|
Enabled: false
|
|
@ -4,49 +4,12 @@ class Account::EntriesController < ApplicationController
|
||||||
before_action :set_account
|
before_action :set_account
|
||||||
before_action :set_entry, only: %i[ edit update show destroy ]
|
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
|
def edit
|
||||||
|
render entryable_view_path(:edit)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@entry.assign_attributes entry_params
|
@entry.update!(entry_params)
|
||||||
@entry.amount = amount if nature.present?
|
|
||||||
@entry.save!
|
|
||||||
@entry.sync_account_later
|
@entry.sync_account_later
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
|
@ -56,6 +19,7 @@ class Account::EntriesController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
render entryable_view_path(:show)
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
@ -66,6 +30,10 @@ class Account::EntriesController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def entryable_view_path(action)
|
||||||
|
@entry.entryable_type.underscore.pluralize + "/" + action.to_s
|
||||||
|
end
|
||||||
|
|
||||||
def set_account
|
def set_account
|
||||||
@account = Current.family.accounts.find(params[:account_id])
|
@account = Current.family.accounts.find(params[:account_id])
|
||||||
end
|
end
|
||||||
|
@ -74,36 +42,7 @@ class Account::EntriesController < ApplicationController
|
||||||
@entry = @account.entries.find(params[:id])
|
@entry = @account.entries.find(params[:id])
|
||||||
end
|
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
|
def entry_params
|
||||||
params.require(:account_entry)
|
params.require(:account_entry).permit(:name, :date, :amount, :currency)
|
||||||
.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: {})
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
34
app/controllers/account/trades_controller.rb
Normal file
34
app/controllers/account/trades_controller.rb
Normal file
|
@ -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
|
53
app/controllers/account/transactions_controller.rb
Normal file
53
app/controllers/account/transactions_controller.rb
Normal file
|
@ -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
|
35
app/controllers/account/valuations_controller.rb
Normal file
35
app/controllers/account/valuations_controller.rb
Normal file
|
@ -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
|
|
@ -30,6 +30,28 @@ module Account::EntriesHelper
|
||||||
mixed_hex_styles(color)
|
mixed_hex_styles(color)
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def permitted_entryable_key(entry)
|
def permitted_entryable_key(entry)
|
||||||
|
|
|
@ -26,9 +26,9 @@ module AccountsHelper
|
||||||
def account_tabs(account)
|
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) }
|
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) }
|
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) }
|
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: transaction_account_entries_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: trade_account_entries_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?
|
return [ holdings_tab, cash_tab, trades_tab ] if account.investment?
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,12 @@ module FormsHelper
|
||||||
form_with(**options, &block)
|
form_with(**options, &block)
|
||||||
end
|
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)
|
def form_field_tag(options = {}, &block)
|
||||||
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
|
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
|
||||||
tag.div(**options, &block)
|
tag.div(**options, &block)
|
||||||
|
@ -23,17 +29,17 @@ module FormsHelper
|
||||||
|
|
||||||
def money_with_currency_field(form, money_method, options = {})
|
def money_with_currency_field(form, money_method, options = {})
|
||||||
render partial: "shared/money_field", locals: {
|
render partial: "shared/money_field", locals: {
|
||||||
form: form,
|
form: form,
|
||||||
money_method: money_method,
|
money_method: money_method,
|
||||||
default_currency: options[:default_currency] || "USD",
|
default_currency: options[:default_currency] || "USD",
|
||||||
disable_currency: options[:disable_currency] || false,
|
disable_currency: options[:disable_currency] || false,
|
||||||
hide_currency: options[:hide_currency] || false,
|
hide_currency: options[:hide_currency] || false,
|
||||||
label: options[:label] || "Amount"
|
label: options[:label] || "Amount"
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def money_field(form, method, options = {})
|
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")
|
currency = value&.currency || Money::Currency.new(options[:default_currency] || "USD")
|
||||||
|
|
||||||
|
@ -42,10 +48,10 @@ module FormsHelper
|
||||||
|
|
||||||
money_options = {
|
money_options = {
|
||||||
value: value&.amount,
|
value: value&.amount,
|
||||||
placeholder: 100,
|
placeholder: "100",
|
||||||
min: -99999999999999,
|
min: -99999999999999,
|
||||||
max: 99999999999999,
|
max: 99999999999999,
|
||||||
step: currency.step
|
step: currency.step
|
||||||
}
|
}
|
||||||
|
|
||||||
merged_options = options.merge(money_options)
|
merged_options = options.merge(money_options)
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
class Account::Trade < ApplicationRecord
|
class Account::Trade < ApplicationRecord
|
||||||
include Account::Entryable
|
include Account::Entryable, Monetizable
|
||||||
|
|
||||||
|
monetize :price
|
||||||
|
|
||||||
belongs_to :security
|
belongs_to :security
|
||||||
|
|
||||||
validates :qty, presence: true, numericality: { other_than: 0 }
|
validates :qty, presence: true, numericality: { other_than: 0 }
|
||||||
validates :price, presence: true
|
validates :price, :currency, presence: true
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def search(_params)
|
def search(_params)
|
||||||
|
|
46
app/models/account/trade_builder.rb
Normal file
46
app/models/account/trade_builder.rb
Normal file
|
@ -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
|
|
@ -207,7 +207,7 @@ class Demo::Generator
|
||||||
unknown = Security.find_by(ticker: "UNKNOWN")
|
unknown = Security.find_by(ticker: "UNKNOWN")
|
||||||
|
|
||||||
# Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices
|
# 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 = [
|
trades = [
|
||||||
{ security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 },
|
{ security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 },
|
||||||
|
@ -228,7 +228,7 @@ class Demo::Generator
|
||||||
amount: qty * price,
|
amount: qty * price,
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
name: name_prefix + "#{qty} shares of #{security.ticker}",
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<%# locals: (entry:, **opts) %>
|
<%# locals: (entry:, **opts) %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(entry) do %>
|
<%= 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 %>
|
<% end %>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<%# locals: (date:, entries:, selectable: true, combine_transfers: false, **opts) %>
|
<%# locals: (date:, entries:, content:, selectable:) %>
|
||||||
<div id="entry-group-<%= date %>" class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
|
<div id="entry-group-<%= date %>" class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
|
||||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||||
<div class="flex pl-0.5 items-center gap-4">
|
<div class="flex pl-0.5 items-center gap-4">
|
||||||
|
@ -15,11 +15,6 @@
|
||||||
<%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
|
<%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
||||||
<% if combine_transfers %>
|
<%= content %>
|
||||||
<%= render entries.reject { |e| e.transfer_id.present? }, selectable:, **opts %>
|
|
||||||
<%= render transfer_entries(entries), selectable: false, **opts %>
|
|
||||||
<% else %>
|
|
||||||
<%= render entries, selectable:, **opts %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
<%= turbo_frame_tag dom_id(@entry) do %>
|
|
||||||
<%= render permitted_entryable_partial_path(@entry, "edit"), entry: @entry %>
|
|
||||||
<% end %>
|
|
|
@ -1 +0,0 @@
|
||||||
<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %>
|
|
|
@ -1,2 +0,0 @@
|
||||||
<%= render permitted_entryable_partial_path(entry, "form"), entry: entry %>
|
|
||||||
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
|
|
|
@ -1 +0,0 @@
|
||||||
<%= render permitted_entryable_partial_path(@entry, "valuation"), entry: @entry %>
|
|
|
@ -1,3 +0,0 @@
|
||||||
<%= turbo_frame_tag dom_id(@entry) do %>
|
|
||||||
<%= render permitted_entryable_partial_path(@entry, "new"), entry: @entry %>
|
|
||||||
<% end %>
|
|
|
@ -1 +0,0 @@
|
||||||
<%= render partial: permitted_entryable_partial_path(@entry, "show"), locals: { entry: @entry } %>
|
|
|
@ -3,9 +3,9 @@
|
||||||
<%= turbo_frame_tag dom_id(holding) do %>
|
<%= turbo_frame_tag dom_id(holding) do %>
|
||||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||||
<div class="col-span-4 flex items-center gap-4">
|
<div class="col-span-4 flex items-center gap-4">
|
||||||
<%= render "shared/circle_logo", name: holding.name %>
|
<%= render "shared/circle_logo", name: holding.name || "H" %>
|
||||||
<div>
|
<div>
|
||||||
<%= 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" %>
|
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -2,10 +2,10 @@
|
||||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %>
|
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %>
|
||||||
<%= link_to new_account_holding_path(@account),
|
<%= link_to new_account_trade_path(@account),
|
||||||
disabled: true,
|
id: dom_id(@account, "new_trade"),
|
||||||
data: { turbo_frame: :modal },
|
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") %>
|
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||||
<%= tag.span t(".new_holding"), class: "text-sm" %>
|
<%= tag.span t(".new_holding"), class: "text-sm" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
19
app/views/account/trades/_form.html.erb
Normal file
19
app/views/account/trades/_form.html.erb
Normal file
|
@ -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| %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<%= 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 %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= form.submit t(".submit") %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
|
@ -13,14 +13,14 @@
|
||||||
<div class="max-w-full">
|
<div class="max-w-full">
|
||||||
<%= tag.div class: ["flex items-center gap-2"] do %>
|
<%= tag.div class: ["flex items-center gap-2"] do %>
|
||||||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
|
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
|
||||||
<%= entry.name[0].upcase %>
|
<%= entry_name(entry).first.upcase %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="truncate text-gray-900">
|
<div class="truncate text-gray-900">
|
||||||
<% if entry.new_record? %>
|
<% if entry.new_record? %>
|
||||||
<%= content_tag :p, entry.name %>
|
<%= content_tag :p, entry_name(entry) %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to entry.name,
|
<%= link_to entry_name(entry),
|
||||||
account_entry_path(account, entry),
|
account_entry_path(account, entry),
|
||||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||||
class: "hover:underline hover:text-gray-800" %>
|
class: "hover:underline hover:text-gray-800" %>
|
||||||
|
@ -31,11 +31,20 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-1 col-span-3">
|
<div class="flex items-center justify-end gap-1 col-span-3">
|
||||||
<%= 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 %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-3 flex items-center justify-end">
|
<div class="col-span-3 flex items-center justify-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 %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
|
@ -2,10 +2,10 @@
|
||||||
<div id="trades" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".trade") %>" class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
<div id="trades" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".trade") %>" class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h3 class="font-medium text-lg"><%= t(".trades") %></h3>
|
<h3 class="font-medium text-lg"><%= t(".trades") %></h3>
|
||||||
<%= link_to new_account_entry_path(@account),
|
<%= link_to new_account_trade_path(@account),
|
||||||
disabled: true,
|
id: dom_id(@account, "new_trade"),
|
||||||
class: "cursor-not-allowed flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
|
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") %>
|
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||||
<span class="text-sm"><%= t(".new") %></span>
|
<span class="text-sm"><%= t(".new") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
@ -15,7 +15,7 @@
|
||||||
<div class="pl-0.5 col-span-6 flex items-center gap-4">
|
<div class="pl-0.5 col-span-6 flex items-center gap-4">
|
||||||
<%= check_box_tag "selection_entry",
|
<%= check_box_tag "selection_entry",
|
||||||
class: "maybe-checkbox maybe-checkbox--light",
|
class: "maybe-checkbox maybe-checkbox--light",
|
||||||
data: { action: "bulk-select#togglePageSelection" } %>
|
data: { action: "bulk-select#togglePageSelection" } %>
|
||||||
<%= tag.p t(".trade") %>
|
<%= tag.p t(".trade") %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -25,15 +25,15 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
|
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
|
||||||
<%= render "account/entries/entryables/trade/selection_bar" %>
|
<%= render "selection_bar" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @trades.empty? %>
|
<% if @entries.empty? %>
|
||||||
<p class="text-gray-500 py-4"><%= t(".no_trades") %></p>
|
<p class="text-gray-500 py-4"><%= t(".no_trades") %></p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<% @trades.group_by(&:date).each do |date, entries| %>
|
<%= entries_by_date(@entries) do |entries| %>
|
||||||
<%= render "entry_group", date:, entries: entries %>
|
<%= render partial: "account/trades/trade", collection: entries, as: :entry %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
3
app/views/account/trades/new.html.erb
Normal file
3
app/views/account/trades/new.html.erb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<%= modal_form_wrapper title: t(".title") do %>
|
||||||
|
<%= render "account/trades/form", entry: @entry %>
|
||||||
|
<% end %>
|
|
@ -1,6 +1,4 @@
|
||||||
<%# locals: (entry:) %>
|
<% entry = @entry %>
|
||||||
|
|
||||||
<% trade, account = entry.account_trade, entry.account %>
|
|
||||||
|
|
||||||
<%= drawer do %>
|
<%= drawer do %>
|
||||||
<div>
|
<div>
|
|
@ -1,6 +1,5 @@
|
||||||
<%# locals: (entry:, selectable: true, editable: true, short: false, show_tags: false, **opts) %>
|
<%# locals: (entry:, selectable: true, editable: true, short: false, show_tags: false, **opts) %>
|
||||||
<% transaction, account = entry.account_transaction, entry.account %>
|
<% transaction, account = entry.account_transaction, entry.account %>
|
||||||
<% is_investment_transfer = entry.account.investment? && entry.transfer.present? %>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
|
||||||
<% name_col_span = unconfirmed_transfer?(entry) ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
|
<% name_col_span = unconfirmed_transfer?(entry) ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
|
||||||
|
@ -52,12 +51,6 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if is_investment_transfer %>
|
|
||||||
<div class="col-span-5 text-right">
|
|
||||||
<%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% unless entry.marked_as_transfer? %>
|
<% unless entry.marked_as_transfer? %>
|
||||||
<% unless short %>
|
<% unless short %>
|
||||||
<div class="flex items-center gap-1 <%= show_tags ? "col-span-6" : "col-span-3" %>">
|
<div class="flex items-center gap-1 <%= show_tags ? "col-span-6" : "col-span-3" %>">
|
||||||
|
@ -89,7 +82,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="<%= is_investment_transfer ? "col-span-3" : "col-span-2" %> ml-auto">
|
<div class="col-span-2 ml-auto">
|
||||||
<%= content_tag :p,
|
<%= content_tag :p,
|
||||||
format_money(-entry.amount_money),
|
format_money(-entry.amount_money),
|
||||||
class: ["text-green-600": entry.inflow?] %>
|
class: ["text-green-600": entry.inflow?] %>
|
|
@ -4,7 +4,7 @@
|
||||||
<h3 class="font-medium text-lg"><%= t(".transactions") %></h3>
|
<h3 class="font-medium text-lg"><%= t(".transactions") %></h3>
|
||||||
<%= link_to new_transaction_path(account_id: @account),
|
<%= 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",
|
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") %>
|
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||||
<span class="text-sm"><%= t(".new") %></span>
|
<span class="text-sm"><%= t(".new") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
@ -12,15 +12,15 @@
|
||||||
|
|
||||||
<div id="transactions" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".transaction") %>">
|
<div id="transactions" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".transaction") %>">
|
||||||
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
|
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
|
||||||
<%= render "account/entries/entryables/transaction/selection_bar" %>
|
<%= render "selection_bar" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @transaction_entries.empty? %>
|
<% if @entries.empty? %>
|
||||||
<p class="text-gray-500 py-4"><%= t(".no_transactions") %></p>
|
<p class="text-gray-500 py-4"><%= t(".no_transactions") %></p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<% @transaction_entries.group_by(&:date).each do |date, entries| %>
|
<%= entries_by_date(@entries) do |entries| %>
|
||||||
<%= render "entry_group", date:, entries: entries %>
|
<%= render entries %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
|
@ -1,6 +1,4 @@
|
||||||
<%# locals: (entry:) %>
|
<% entry, transaction, account = @entry, @entry.account_transaction, @entry.account %>
|
||||||
|
|
||||||
<% transaction, account = entry.account_transaction, entry.account %>
|
|
||||||
|
|
||||||
<%= drawer do %>
|
<%= drawer do %>
|
||||||
<div>
|
<div>
|
||||||
|
@ -27,7 +25,7 @@
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
<%= 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" %>
|
<%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %>
|
||||||
<% unless entry.marked_as_transfer? %>
|
<% unless entry.marked_as_transfer? %>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
|
@ -60,15 +58,15 @@
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
<%= 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| %>
|
<%= f.fields_for :entryable do |ef| %>
|
||||||
<%= ef.select :tag_ids,
|
<%= ef.select :tag_ids,
|
||||||
options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), transaction.tag_ids),
|
options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), transaction.tag_ids),
|
||||||
{
|
{
|
||||||
multiple: true,
|
multiple: true,
|
||||||
label: t(".tags_label"),
|
label: t(".tags_label"),
|
||||||
class: "placeholder:text-gray-500"
|
class: "placeholder:text-gray-500"
|
||||||
},
|
},
|
||||||
"data-auto-submit-form-target": "auto" %>
|
"data-auto-submit-form-target": "auto" %>
|
||||||
<%= ef.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), "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 @@
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
<%= 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| %>
|
<%= f.fields_for :entryable do |ef| %>
|
||||||
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
||||||
<div class="text-sm space-y-1">
|
<div class="text-sm space-y-1">
|
||||||
|
@ -110,8 +108,8 @@
|
||||||
<%= button_to t(".delete"),
|
<%= button_to t(".delete"),
|
||||||
account_entry_path(account, entry),
|
account_entry_path(account, entry),
|
||||||
method: :delete,
|
method: :delete,
|
||||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
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, turbo_frame: "_top" } %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
|
@ -1,4 +1,11 @@
|
||||||
<%= styled_form_with model: transfer, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
|
<%= styled_form_with model: transfer, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
|
||||||
|
<% if transfer.errors.present? %>
|
||||||
|
<div class="text-red-600 flex items-center gap-2">
|
||||||
|
<%= lucide_icon "circle-alert", class: "w-5 h-5" %>
|
||||||
|
<p class="text-sm"><%= @transfer.errors.full_messages.to_sentence %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
|
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
|
||||||
<%= 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_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 %>
|
||||||
|
|
|
@ -1,17 +1,3 @@
|
||||||
<%= modal do %>
|
<%= modal_form_wrapper title: t(".title") do %>
|
||||||
<article class="mx-auto p-4 space-y-4 w-screen max-w-xl">
|
<%= render "form", transfer: @transfer %>
|
||||||
<header class="flex justify-between">
|
|
||||||
<%= 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" } %>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<% if @transfer.errors.present? %>
|
|
||||||
<div class="text-red-600 flex items-center gap-2">
|
|
||||||
<%= lucide_icon "circle-alert", class: "w-5 h-5" %>
|
|
||||||
<p class="text-sm"><%= @transfer.errors.full_messages.to_sentence %></p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= render "form", transfer: @transfer %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
<%# locals: (entry:) %>
|
<%# locals: (entry:) %>
|
||||||
|
|
||||||
<%= form_with model: [entry.account, entry],
|
<%= form_with model: [entry.account, entry],
|
||||||
data: { turbo_frame: "_top" },
|
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| %>
|
||||||
<div class="grid grid-cols-10 p-4 items-center">
|
<div class="grid grid-cols-10 p-4 items-center">
|
||||||
<div class="col-span-7 flex items-center gap-4">
|
<div class="col-span-7 flex items-center gap-4">
|
||||||
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center bg-gray-500/5">
|
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center bg-gray-500/5">
|
||||||
|
@ -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.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.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 :currency, value: entry.account.currency %>
|
||||||
<%= f.hidden_field :entryable_type, value: entry.entryable_type %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-3 flex gap-2 justify-end items-center">
|
<div class="col-span-3 flex gap-2 justify-end items-center">
|
||||||
<%= 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" %>
|
<%= f.submit class: "bg-gray-50 rounded-lg font-medium px-3 py-1.5 cursor-pointer hover:bg-gray-100 text-sm" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
3
app/views/account/valuations/edit.html.erb
Normal file
3
app/views/account/valuations/edit.html.erb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<%= turbo_frame_tag dom_id(@entry) do %>
|
||||||
|
<%= render "account/valuations/form", entry: @entry %>
|
||||||
|
<% end %>
|
|
@ -2,8 +2,8 @@
|
||||||
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<%= tag.h2 t(".valuations"), class: "font-medium text-lg" %>
|
<%= tag.h2 t(".valuations"), class: "font-medium text-lg" %>
|
||||||
<%= link_to new_account_entry_path(@account, entryable_type: "Account::Valuation"),
|
<%= link_to new_account_valuation_path(@account),
|
||||||
data: { turbo_frame: dom_id(@account.entries.account_valuations.new) },
|
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 %>
|
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") %>
|
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
|
||||||
<%= tag.span t(".new_entry"), class: "text-sm" %>
|
<%= tag.span t(".new_entry"), class: "text-sm" %>
|
||||||
|
@ -21,11 +21,11 @@
|
||||||
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
|
||||||
<%= turbo_frame_tag dom_id(@account.entries.account_valuations.new) %>
|
<%= turbo_frame_tag dom_id(@account.entries.account_valuations.new) %>
|
||||||
|
|
||||||
<% if @valuation_entries.any? %>
|
<% if @entries.any? %>
|
||||||
<%= render partial: "account/entries/entryables/valuation/valuation",
|
<%= render partial: "account/valuations/valuation",
|
||||||
collection: @valuation_entries,
|
collection: @entries,
|
||||||
as: :entry,
|
as: :entry,
|
||||||
spacer_template: "ruler" %>
|
spacer_template: "account/entries/ruler" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="text-gray-500 text-sm p-4"><%= t(".no_valuations") %></p>
|
<p class="text-gray-500 text-sm p-4"><%= t(".no_valuations") %></p>
|
||||||
<% end %>
|
<% end %>
|
4
app/views/account/valuations/new.html.erb
Normal file
4
app/views/account/valuations/new.html.erb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<%= turbo_frame_tag dom_id(@entry) do %>
|
||||||
|
<%= render "account/valuations/form", entry: @entry %>
|
||||||
|
<div class="h-px bg-alpha-black-50 ml-20 mr-4"></div>
|
||||||
|
<% end %>
|
3
app/views/account/valuations/show.html.erb
Normal file
3
app/views/account/valuations/show.html.erb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
<% entry = @entry %>
|
||||||
|
|
||||||
|
<%= render "account/valuations/valuation", entry: entry %>
|
|
@ -1,22 +1,15 @@
|
||||||
<%= modal do %>
|
<%= modal_form_wrapper title: t(".edit", account: @account.name) do %>
|
||||||
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
|
<%= styled_form_with model: @account, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
|
||||||
<header class="flex justify-between">
|
<%= f.text_field :name, label: t(".name") %>
|
||||||
<h2 class="font-medium text-xl"><%= t(".edit", account: @account.name) %></h2>
|
<%= money_with_currency_field f, :balance_money, label: t(".balance"), default_currency: @account.currency, disable_currency: true %>
|
||||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<%= styled_form_with model: @account, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
|
<div class="relative">
|
||||||
<%= f.text_field :name, label: t(".name") %>
|
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
|
||||||
<%= money_with_currency_field f, :balance_money, label: t(".balance"), default_currency: @account.currency, disable_currency: true %>
|
<%= 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 %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="relative">
|
<%= f.submit %>
|
||||||
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
|
<% end %>
|
||||||
<%= 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 %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= f.submit %>
|
|
||||||
<% end %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,10 +1,3 @@
|
||||||
<%= modal do %>
|
<%= modal_form_wrapper title: t(".edit") do %>
|
||||||
<article class="mx-auto w-full p-4 space-y-4">
|
<%= render "form", category: @category %>
|
||||||
<header class="flex justify-between">
|
|
||||||
<h2 class="font-medium text-xl"><%= t(".edit") %></h2>
|
|
||||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<%= render "form", category: @category %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,10 +1,3 @@
|
||||||
<%= modal do %>
|
<%= modal_form_wrapper title: t(".new_category") do %>
|
||||||
<article class="mx-auto w-full p-4 space-y-4">
|
<%= render "form", category: @category %>
|
||||||
<header class="flex justify-between">
|
|
||||||
<h2 class="font-medium text-xl"><%= t(".new_category") %></h2>
|
|
||||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<%= render "form", category: @category %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,34 +1,21 @@
|
||||||
<%= modal do %>
|
<%= modal_form_wrapper title: t(".delete_category"), subtitle: t(".explanation", category_name: @category.name) do %>
|
||||||
<article class="mx-auto p-4 w-screen max-w-md">
|
<%= styled_form_with url: category_deletions_path(@category),
|
||||||
<div class="space-y-2">
|
class: "space-y-4",
|
||||||
<header class="flex justify-between">
|
data: {
|
||||||
<h2 class="font-medium text-xl"><%= t(".delete_category") %></h2>
|
turbo: false,
|
||||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
controller: "deletion",
|
||||||
</header>
|
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" } } %>
|
||||||
|
|
||||||
<p class="text-gray-500 font-light">
|
<%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||||
<%= t(".explanation", category_name: @category.name) %>
|
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||||
</p>
|
data: { deletion_target: "submitButton" } %>
|
||||||
</div>
|
<% end %>
|
||||||
|
|
||||||
<%= 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 %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<% is_selected = category.id === @selected_category&.id %>
|
<% 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 %>
|
<%= 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 %>
|
||||||
<span class="w-5 h-5">
|
<span class="w-5 h-5">
|
||||||
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
||||||
</span>
|
</span>
|
||||||
|
|
|
@ -25,7 +25,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if @transaction.category %>
|
<% 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,
|
method: :patch,
|
||||||
data: { turbo_frame: dom_id(@transaction.entry) },
|
data: { turbo_frame: dom_id(@transaction.entry) },
|
||||||
params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
|
params: { account_entry: { entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: nil } } },
|
||||||
|
|
|
@ -9,14 +9,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-8 space-y-4">
|
<div class="mb-8 space-y-4">
|
||||||
<% transaction_entries = @import.dry_run %>
|
<%= entries_by_date(@import.dry_run, selectable: false) do |entries| %>
|
||||||
<% transaction_entries.group_by(&:date).each do |date, transactions| %>
|
<%= render entries, show_tags: true, selectable: false, editable: false %>
|
||||||
<%= render "account/entries/entry_group",
|
|
||||||
date: date,
|
|
||||||
entries: transactions,
|
|
||||||
show_tags: true,
|
|
||||||
selectable: false,
|
|
||||||
editable: false %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,3 @@
|
||||||
<%= modal do %>
|
<%= modal_form_wrapper title: t(".edit", institution: @institution.name) do %>
|
||||||
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
|
<%= render "form", institution: @institution %>
|
||||||
<header class="flex justify-between">
|
|
||||||
<h2 class="font-medium text-xl"><%= t(".edit", institution: @institution.name) %></h2>
|
|
||||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<%= render "form", institution: @institution %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,10 +1,3 @@
|
||||||
<%= modal do %>
|
<%= modal_form_wrapper title: t(".new_institution") do %>
|
||||||
<article class="mx-auto w-full p-4 space-y-4 min-w-[350px]">
|
<%= render "form", institution: @institution %>
|
||||||
<header class="flex justify-between">
|
|
||||||
<h2 class="font-medium text-xl"><%= t(".new_institution") %></h2>
|
|
||||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<%= render "form", institution: @institution %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,10 +1,3 @@
|
||||||
<%= modal classes: "max-w-fit" do %>
|
<%= modal_form_wrapper title: t(".title") do %>
|
||||||
<article class="mx-auto w-full p-4 space-y-4">
|
<%= render "form", merchant: @merchant %>
|
||||||
<header class="flex justify-between">
|
|
||||||
<h2 class="font-medium text-xl"><%= t(".title") %></h2>
|
|
||||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<%= render "form", merchant: @merchant %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,10 +1,3 @@
|
||||||
<%= modal classes: "max-w-fit" do %>
|
<%= modal_form_wrapper title: t(".title") do %>
|
||||||
<article class="mx-auto w-full p-4 space-y-4">
|
<%= render "form", merchant: @merchant %>
|
||||||
<header class="flex justify-between">
|
|
||||||
<h2 class="font-medium text-xl"><%= t(".title") %></h2>
|
|
||||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<%= render "form", merchant: @merchant %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -162,13 +162,8 @@
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="text-gray-500 p-1 space-y-1 bg-gray-25 rounded-xl">
|
<div class="text-gray-500 p-1 space-y-1 bg-gray-25 rounded-xl">
|
||||||
<% @transaction_entries.group_by(&:date).each do |date, transactions| %>
|
<%= entries_by_date(@transaction_entries, selectable: false) do |entries| %>
|
||||||
<%= render "account/entries/entry_group",
|
<%= render entries, selectable: false, editable: false, short: true %>
|
||||||
date: date,
|
|
||||||
entries: transactions,
|
|
||||||
selectable: false,
|
|
||||||
editable: false,
|
|
||||||
short: true %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<p class="py-2 text-sm text-center"><%= link_to t(".view_all"), transactions_path %></p>
|
<p class="py-2 text-sm text-center"><%= link_to t(".view_all"), transactions_path %></p>
|
||||||
|
|
18
app/views/shared/_modal_form.html.erb
Normal file
18
app/views/shared/_modal_form.html.erb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<%# locals: (title:, content:, subtitle: nil) %>
|
||||||
|
|
||||||
|
<%= modal do %>
|
||||||
|
<article class="mx-auto w-full p-4 space-y-4 min-w-[450px] max-w-xl">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<header class="flex justify-between">
|
||||||
|
<h2 class="font-medium text-xl"><%= title %></h2>
|
||||||
|
<%= lucide_icon("x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" }) %>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<% if subtitle.present? %>
|
||||||
|
<%= tag.p subtitle, class: "text-gray-500 font-light" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= content %>
|
||||||
|
</article>
|
||||||
|
<% end %>
|
|
@ -1,7 +1,7 @@
|
||||||
<%# locals: (form:, money_method:, default_currency:, disable_currency: false, hide_currency: false, label: nil) %>
|
<%# locals: (form:, money_method:, default_currency:, disable_currency: false, hide_currency: false, label: nil) %>
|
||||||
|
|
||||||
<% fallback_label = t(".money-label") %>
|
<% 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) %>
|
||||||
|
|
||||||
<div class="form-field pr-0" data-controller="money-field">
|
<div class="form-field pr-0" data-controller="money-field">
|
||||||
<%= form.label label || fallback_label, { class: "form-field__label" } %>
|
<%= form.label label || fallback_label, { class: "form-field__label" } %>
|
||||||
|
|
|
@ -1,34 +1,21 @@
|
||||||
<%= modal do %>
|
<%= modal_form_wrapper title: t(".delete_tag"), subtitle: t(".explanation", tag_name: @tag.name) do %>
|
||||||
<article class="mx-auto p-4 w-screen max-w-md">
|
<%= styled_form_with url: tag_deletions_path(@tag),
|
||||||
<div class="space-y-2">
|
class: "space-y-4",
|
||||||
<header class="flex justify-between">
|
data: {
|
||||||
<h2 class="font-medium text-xl"><%= t(".delete_tag") %></h2>
|
turbo: false,
|
||||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
controller: "deletion",
|
||||||
</header>
|
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" } } %>
|
||||||
|
|
||||||
<p class="text-gray-500 font-light">
|
<%= f.submit t(".delete_and_leave_uncategorized", tag_name: @tag.name),
|
||||||
<%= t(".explanation", tag_name: @tag.name) %>
|
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||||
</p>
|
data: { deletion_target: "submitButton" } %>
|
||||||
</div>
|
<% end %>
|
||||||
|
|
||||||
<%= 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 %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,10 +1,3 @@
|
||||||
<%= modal do %>
|
<%= modal_form_wrapper title: t(".edit") do %>
|
||||||
<article class="mx-auto w-full p-4 space-y-4">
|
<%= render "form", tag: @tag %>
|
||||||
<header class="flex justify-between">
|
|
||||||
<h2 class="font-medium text-xl"><%= t(".edit") %></h2>
|
|
||||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<%= render "form", tag: @tag %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,10 +1,3 @@
|
||||||
<%= modal do %>
|
<%= modal_form_wrapper title: t(".new") do %>
|
||||||
<article class="mx-auto w-full p-4 space-y-4">
|
<%= render "form", tag: @tag %>
|
||||||
<header class="flex justify-between">
|
|
||||||
<h2 class="font-medium text-xl"><%= t(".new") %></h2>
|
|
||||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<%= render "form", tag: @tag %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
<% if @transaction_entries.present? %>
|
<% if @transaction_entries.present? %>
|
||||||
<div hidden id="entry-selection-bar" data-bulk-select-target="selectionBar">
|
<div hidden id="entry-selection-bar" data-bulk-select-target="selectionBar">
|
||||||
<%= render "account/entries/entryables/transaction/selection_bar" %>
|
<%= render "account/transactions/selection_bar" %>
|
||||||
</div>
|
</div>
|
||||||
<div class="grow overflow-y-auto">
|
<div class="grow overflow-y-auto">
|
||||||
<div class="grid grid-cols-12 bg-gray-25 rounded-xl px-5 py-3 text-xs uppercase font-medium text-gray-500 items-center mb-4">
|
<div class="grid grid-cols-12 bg-gray-25 rounded-xl px-5 py-3 text-xs uppercase font-medium text-gray-500 items-center mb-4">
|
||||||
|
@ -27,8 +27,9 @@
|
||||||
<p class="col-span-2 justify-self-end">amount</p>
|
<p class="col-span-2 justify-self-end">amount</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<% @transaction_entries.group_by(&:date).each do |date, entries| %>
|
<%= entries_by_date(@transaction_entries) do |entries| %>
|
||||||
<%= render "account/entries/entry_group", date:, combine_transfers: true, entries: %>
|
<%= render entries.reject { |e| e.transfer_id.present? }, selectable: true %>
|
||||||
|
<%= render transfer_entries(entries), selectable: false %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,10 +1,3 @@
|
||||||
<%= modal do %>
|
<%= modal_form_wrapper title: t(".new_transaction") do %>
|
||||||
<article class="mx-auto p-4 space-y-4 w-screen max-w-xl">
|
<%= render "form", transaction: @transaction, entry: @entry %>
|
||||||
<header class="flex justify-between">
|
|
||||||
<h2 class="font-medium text-xl"><%= t(".new_transaction") %></h2>
|
|
||||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<%= render "form", transaction: @transaction, entry: @entry %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,74 +1,6 @@
|
||||||
{
|
{
|
||||||
"ignored_warnings": [
|
"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"
|
"brakeman_version": "6.1.2"
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,92 +2,12 @@
|
||||||
en:
|
en:
|
||||||
account:
|
account:
|
||||||
entries:
|
entries:
|
||||||
create:
|
|
||||||
success: "%{name} created"
|
|
||||||
destroy:
|
destroy:
|
||||||
success: Entry deleted
|
success: Entry deleted
|
||||||
empty:
|
empty:
|
||||||
description: Try adding an entry, editing filters or refining your search
|
description: Try adding an entry, editing filters or refining your search
|
||||||
title: No entries found
|
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: "<p>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.</p></br><p>The only way you’ll be
|
|
||||||
able to add this entry back is by re-entering it manually via a new
|
|
||||||
entry</p>"
|
|
||||||
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: Loading entries...
|
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:
|
update:
|
||||||
success: Entry updated
|
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
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ en:
|
||||||
name: name
|
name: name
|
||||||
needs_sync: Your account needs to sync the latest prices to calculate this
|
needs_sync: Your account needs to sync the latest prices to calculate this
|
||||||
portfolio
|
portfolio
|
||||||
new_holding: New holding
|
new_holding: New transaction
|
||||||
no_holdings: No holdings to show.
|
no_holdings: No holdings to show.
|
||||||
return: total return
|
return: total return
|
||||||
weight: weight
|
weight: weight
|
||||||
|
|
31
config/locales/views/account/trades/en.yml
Normal file
31
config/locales/views/account/trades/en.yml
Normal file
|
@ -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
|
44
config/locales/views/account/transactions/en.yml
Normal file
44
config/locales/views/account/transactions/en.yml
Normal file
|
@ -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.
|
27
config/locales/views/account/valuations/en.yml
Normal file
27
config/locales/views/account/valuations/en.yml
Normal file
|
@ -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: "<p>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.</p></br><p>The only way you’ll be able
|
||||||
|
to add this entry back is by re-entering it manually via a new entry</p>"
|
||||||
|
confirm_title: Delete Entry?
|
||||||
|
delete_entry: Delete entry
|
||||||
|
edit_entry: Edit entry
|
||||||
|
no_change: No change
|
||||||
|
start_balance: Starting balance
|
||||||
|
value_update: Value update
|
|
@ -81,13 +81,11 @@ Rails.application.routes.draw do
|
||||||
resources :holdings, only: %i[ index new show ]
|
resources :holdings, only: %i[ index new show ]
|
||||||
resources :cashes, only: :index
|
resources :cashes, only: :index
|
||||||
|
|
||||||
resources :entries, except: :index do
|
resources :transactions, only: %i[ index update ]
|
||||||
collection do
|
resources :valuations, only: %i[ index new create ]
|
||||||
get "transactions", as: :transaction
|
resources :trades, only: %i[ index new create ]
|
||||||
get "valuations", as: :valuation
|
|
||||||
get "trades", as: :trade
|
resources :entries, only: %i[ edit update show destroy ]
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
5
db/migrate/20240807153618_add_currency_field_to_trade.rb
Normal file
5
db/migrate/20240807153618_add_currency_field_to_trade.rb
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
class AddCurrencyFieldToTrade < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :account_trades, :currency, :string
|
||||||
|
end
|
||||||
|
end
|
3
db/schema.rb
generated
3
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
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.decimal "price", precision: 19, scale: 4
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "currency"
|
||||||
t.index ["security_id"], name: "index_account_trades_on_security_id"
|
t.index ["security_id"], name: "index_account_trades_on_security_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,9 @@ require "test_helper"
|
||||||
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
||||||
setup do
|
setup do
|
||||||
Capybara.default_max_wait_time = 5
|
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
|
end
|
||||||
|
|
||||||
driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : :chrome, screen_size: [ 1400, 1400 ]
|
driven_by :selenium, using: ENV["CI"].present? ? :headless_chrome : :chrome, screen_size: [ 1400, 1400 ]
|
||||||
|
|
|
@ -5,114 +5,15 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
|
||||||
sign_in @user = users(:family_admin)
|
sign_in @user = users(:family_admin)
|
||||||
@transaction = account_entries :transaction
|
@transaction = account_entries :transaction
|
||||||
@valuation = account_entries :valuation
|
@valuation = account_entries :valuation
|
||||||
|
@trade = account_entries :trade
|
||||||
end
|
end
|
||||||
|
|
||||||
test "should edit valuation entry" do
|
# =================
|
||||||
get edit_account_entry_url(@valuation.account, @valuation)
|
# Shared
|
||||||
assert_response :success
|
# =================
|
||||||
end
|
|
||||||
|
|
||||||
test "should show transaction entry" do
|
test "should destroy entry" do
|
||||||
get account_entry_url(@transaction.account, @transaction)
|
[ @transaction, @valuation, @trade ].each do |entry|
|
||||||
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|
|
|
||||||
assert_difference -> { Account::Entry.count } => -1, -> { entry.entryable_class.count } => -1 do
|
assert_difference -> { Account::Entry.count } => -1, -> { entry.entryable_class.count } => -1 do
|
||||||
delete account_entry_url(entry.account, entry)
|
delete account_entry_url(entry.account, entry)
|
||||||
end
|
end
|
||||||
|
@ -122,6 +23,38 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
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
|
private
|
||||||
|
|
||||||
# Simple guard to verify that nested attributes are passed the record ID to avoid new creation of record
|
# Simple guard to verify that nested attributes are passed the record ID to avoid new creation of record
|
||||||
|
|
63
test/controllers/account/trades_controller_test.rb
Normal file
63
test/controllers/account/trades_controller_test.rb
Normal file
|
@ -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
|
40
test/controllers/account/transactions_controller_test.rb
Normal file
40
test/controllers/account/transactions_controller_test.rb
Normal file
|
@ -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
|
50
test/controllers/account/valuations_controller_test.rb
Normal file
50
test/controllers/account/valuations_controller_test.rb
Normal file
|
@ -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
|
1
test/fixtures/account/trades.yml
vendored
1
test/fixtures/account/trades.yml
vendored
|
@ -2,3 +2,4 @@ one:
|
||||||
security: aapl
|
security: aapl
|
||||||
qty: 10
|
qty: 10
|
||||||
price: 214
|
price: 214
|
||||||
|
currency: USD
|
||||||
|
|
|
@ -34,7 +34,8 @@ module Account::EntriesTestHelper
|
||||||
trade = Account::Trade.new \
|
trade = Account::Trade.new \
|
||||||
qty: qty,
|
qty: qty,
|
||||||
security: security,
|
security: security,
|
||||||
price: trade_price
|
price: trade_price,
|
||||||
|
currency: "USD"
|
||||||
|
|
||||||
account.entries.create! \
|
account.entries.create! \
|
||||||
name: "Trade",
|
name: "Trade",
|
||||||
|
|
67
test/system/trades_test.rb
Normal file
67
test/system/trades_test.rb
Normal file
|
@ -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
|
Loading…
Add table
Add a link
Reference in a new issue