Creator form pattern
This commit is contained in:
@@ -1,17 +1,27 @@
|
||||
class TradesController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
# Defaults to a buy trade
|
||||
def new
|
||||
@account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
@model = Current.family.entries.new(
|
||||
account: @account,
|
||||
currency: @account ? @account.currency : Current.family.currency,
|
||||
entryable: Trade.new
|
||||
)
|
||||
end
|
||||
|
||||
# Can create a trade, transaction (e.g. "fees"), or transfer (e.g. "withdrawal")
|
||||
def create
|
||||
@entry = build_entry
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
@model = Trade::CreateForm.new(create_params.merge(account: @account)).create
|
||||
|
||||
if @model.persisted?
|
||||
flash[:notice] = t("entries.create.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) }
|
||||
format.html { redirect_back_or_to account_path(@account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to account_path(@account) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
@@ -41,11 +51,6 @@ class TradesController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
def build_entry
|
||||
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
TradeBuilder.new(create_entry_params.merge(account: account))
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:entry).permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
@@ -53,8 +58,8 @@ class TradesController < ApplicationController
|
||||
)
|
||||
end
|
||||
|
||||
def create_entry_params
|
||||
params.require(:entry).permit(
|
||||
def create_params
|
||||
params.require(:model).permit(
|
||||
:date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
)
|
||||
end
|
||||
|
||||
113
app/models/trade/create_form.rb
Normal file
113
app/models/trade/create_form.rb
Normal file
@@ -0,0 +1,113 @@
|
||||
class Trade::CreateForm
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
|
||||
# Either creates a trade, transaction, or transfer based on type
|
||||
# Returns the model, regardless of success or failure
|
||||
def create
|
||||
case type
|
||||
when "buy", "sell"
|
||||
create_trade
|
||||
when "interest"
|
||||
create_interest_income
|
||||
when "deposit", "withdrawal"
|
||||
create_transfer
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
|
||||
def security
|
||||
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
|
||||
|
||||
Security::Resolver.new(
|
||||
ticker_symbol,
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
).resolve
|
||||
end
|
||||
|
||||
def create_trade
|
||||
prefix = type == "sell" ? "Sell " : "Buy "
|
||||
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
|
||||
signed_qty = type == "sell" ? -qty.to_d : qty.to_d
|
||||
signed_amount = signed_qty * price.to_d
|
||||
|
||||
trade_entry = account.entries.new(
|
||||
name: trade_name,
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Trade.new(
|
||||
qty: signed_qty,
|
||||
price: price,
|
||||
currency: currency,
|
||||
security: security
|
||||
)
|
||||
)
|
||||
|
||||
if trade_entry.save
|
||||
trade_entry.lock_saved_attributes!
|
||||
account.sync_later
|
||||
end
|
||||
|
||||
trade_entry
|
||||
end
|
||||
|
||||
def create_interest_income
|
||||
signed_amount = amount.to_d * -1
|
||||
|
||||
entry = account.entries.build(
|
||||
name: "Interest payment",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
if entry.save
|
||||
entry.lock_saved_attributes!
|
||||
account.sync_later
|
||||
end
|
||||
|
||||
entry
|
||||
end
|
||||
|
||||
def create_transfer
|
||||
if transfer_account_id.present?
|
||||
from_account_id = type == "withdrawal" ? account.id : transfer_account_id
|
||||
to_account_id = type == "withdrawal" ? transfer_account_id : account.id
|
||||
|
||||
Transfer::Creator.new(
|
||||
family: account.family,
|
||||
source_account_id: from_account_id,
|
||||
destination_account_id: to_account_id,
|
||||
date: date,
|
||||
amount: amount
|
||||
).create
|
||||
else
|
||||
create_unlinked_transfer
|
||||
end
|
||||
end
|
||||
|
||||
# If user doesn't provide the reciprocal account, it's a regular transaction
|
||||
def create_unlinked_transfer
|
||||
signed_amount = type == "deposit" ? amount.to_d * -1 : amount.to_d
|
||||
|
||||
entry = account.entries.build(
|
||||
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
|
||||
if entry.save
|
||||
entry.lock_saved_attributes!
|
||||
account.sync_later
|
||||
end
|
||||
|
||||
entry
|
||||
end
|
||||
end
|
||||
@@ -1,12 +0,0 @@
|
||||
class Trade::Creator
|
||||
def initialize(attrs)
|
||||
@attrs = attrs
|
||||
end
|
||||
|
||||
def create
|
||||
# TODO
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :attrs
|
||||
end
|
||||
@@ -1,137 +0,0 @@
|
||||
class TradeBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
attr_accessor :account, :date, :amount, :currency, :qty,
|
||||
:price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
|
||||
attr_reader :buildable
|
||||
|
||||
def initialize(attributes = {})
|
||||
super
|
||||
@buildable = set_buildable
|
||||
end
|
||||
|
||||
def save
|
||||
buildable.save
|
||||
end
|
||||
|
||||
def lock_saved_attributes!
|
||||
if buildable.is_a?(Transfer)
|
||||
buildable.inflow_transaction.entry.lock_saved_attributes!
|
||||
buildable.outflow_transaction.entry.lock_saved_attributes!
|
||||
else
|
||||
buildable.lock_saved_attributes!
|
||||
end
|
||||
end
|
||||
|
||||
def entryable
|
||||
return nil if buildable.is_a?(Transfer)
|
||||
|
||||
buildable.entryable
|
||||
end
|
||||
|
||||
def errors
|
||||
buildable.errors
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
buildable.sync_account_later
|
||||
end
|
||||
|
||||
private
|
||||
def set_buildable
|
||||
case type
|
||||
when "buy", "sell"
|
||||
build_trade
|
||||
when "deposit", "withdrawal"
|
||||
build_transfer
|
||||
when "interest"
|
||||
build_interest
|
||||
else
|
||||
raise "Unknown trade type: #{type}"
|
||||
end
|
||||
end
|
||||
|
||||
def build_trade
|
||||
prefix = type == "sell" ? "Sell " : "Buy "
|
||||
trade_name = prefix + "#{qty.to_i.abs} shares of #{security.ticker}"
|
||||
|
||||
account.entries.new(
|
||||
name: trade_name,
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Trade.new(
|
||||
qty: signed_qty,
|
||||
price: price,
|
||||
currency: currency,
|
||||
security: security
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def build_transfer
|
||||
transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present?
|
||||
|
||||
if transfer_account
|
||||
from_account = type == "withdrawal" ? account : transfer_account
|
||||
to_account = type == "withdrawal" ? transfer_account : account
|
||||
|
||||
Transfer.from_accounts(
|
||||
from_account: from_account,
|
||||
to_account: to_account,
|
||||
date: date,
|
||||
amount: signed_amount
|
||||
)
|
||||
else
|
||||
account.entries.build(
|
||||
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def build_interest
|
||||
account.entries.build(
|
||||
name: "Interest payment",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
entryable: Transaction.new
|
||||
)
|
||||
end
|
||||
|
||||
def signed_qty
|
||||
return nil unless type.in?([ "buy", "sell" ])
|
||||
|
||||
type == "sell" ? -qty.to_d : qty.to_d
|
||||
end
|
||||
|
||||
def signed_amount
|
||||
case type
|
||||
when "buy", "sell"
|
||||
signed_qty * price.to_d
|
||||
when "deposit", "withdrawal"
|
||||
type == "deposit" ? -amount.to_d : amount.to_d
|
||||
when "interest"
|
||||
amount.to_d * -1
|
||||
end
|
||||
end
|
||||
|
||||
def family
|
||||
account.family
|
||||
end
|
||||
|
||||
# Users can either look up a ticker from our provider (Synth) or enter a manual, "offline" ticker (that we won't fetch prices for)
|
||||
def security
|
||||
ticker_symbol, exchange_operating_mic = ticker.present? ? ticker.split("|") : [ manual_ticker, nil ]
|
||||
|
||||
Security::Resolver.new(
|
||||
ticker_symbol,
|
||||
exchange_operating_mic: exchange_operating_mic
|
||||
).resolve
|
||||
end
|
||||
end
|
||||
@@ -65,6 +65,10 @@ class Transfer < ApplicationRecord
|
||||
update!(status: "confirmed")
|
||||
end
|
||||
|
||||
def date
|
||||
inflow_transaction.entry.date
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
inflow_transaction&.entry&.sync_account_later
|
||||
outflow_transaction&.entry&.sync_account_later
|
||||
|
||||
@@ -4,7 +4,7 @@ class Transfer::Creator
|
||||
@source_account = family.accounts.find(source_account_id) # early throw if not found
|
||||
@destination_account = family.accounts.find(destination_account_id) # early throw if not found
|
||||
@date = date
|
||||
@amount = amount
|
||||
@amount = amount.to_d
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<%# locals: (entry:) %>
|
||||
<%# locals: (model:, account:) %>
|
||||
|
||||
<% type = params[:type] || "buy" %>
|
||||
|
||||
<%= styled_form_with model: entry, url: trades_path, data: { controller: "trade-form" } do |form| %>
|
||||
|
||||
<%= form.hidden_field :account_id %>
|
||||
|
||||
<%= styled_form_with url: trades_path(account_id: account&.id), scope: :model, data: { controller: "trade-form" } do |form| %>
|
||||
<div class="space-y-4">
|
||||
<% if entry.errors.any? %>
|
||||
<%= render "shared/form_errors", model: entry %>
|
||||
<% if model.errors.any? %>
|
||||
<%= render "shared/form_errors", model: model %>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-2">
|
||||
@@ -22,7 +19,7 @@
|
||||
{ label: t(".type"), selected: type },
|
||||
{ data: {
|
||||
action: "trade-form#changeType",
|
||||
trade_form_url_param: new_trade_path(account_id: entry.account&.id || entry.account_id),
|
||||
trade_form_url_param: new_trade_path(account_id: account&.id),
|
||||
trade_form_key_param: "type",
|
||||
}} %>
|
||||
|
||||
@@ -41,10 +38,10 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= form.date_field :date, label: true, value: Date.current, required: true %>
|
||||
<%= form.date_field :date, label: true, value: model.date || Date.current, required: true %>
|
||||
|
||||
<% unless %w[buy sell].include?(type) %>
|
||||
<%= form.money_field :amount, label: t(".amount"), required: true %>
|
||||
<%= form.money_field :amount, label: t(".amount"), value: model.amount, required: true %>
|
||||
<% end %>
|
||||
|
||||
<% if %w[deposit withdrawal].include?(type) %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%= render DialogComponent.new do |dialog| %>
|
||||
<% dialog.with_header(title: t(".title")) %>
|
||||
<% dialog.with_body do %>
|
||||
<%= render "trades/form", entry: @entry %>
|
||||
<%= render "trades/form", model: @model, account: @account %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -39,9 +39,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_difference -> { Entry.count } => 2,
|
||||
-> { Transaction.count } => 2,
|
||||
-> { Transfer.count } => 1 do
|
||||
post trades_url, params: {
|
||||
entry: {
|
||||
account_id: @entry.account_id,
|
||||
post trades_url(account_id: @entry.account_id), params: {
|
||||
model: {
|
||||
type: "deposit",
|
||||
date: Date.current,
|
||||
amount: 10,
|
||||
@@ -60,9 +59,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_difference -> { Entry.count } => 2,
|
||||
-> { Transaction.count } => 2,
|
||||
-> { Transfer.count } => 1 do
|
||||
post trades_url, params: {
|
||||
entry: {
|
||||
account_id: @entry.account_id,
|
||||
post trades_url(account_id: @entry.account_id), params: {
|
||||
model: {
|
||||
type: "withdrawal",
|
||||
date: Date.current,
|
||||
amount: 10,
|
||||
@@ -79,9 +77,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_difference -> { Entry.count } => 1,
|
||||
-> { Transaction.count } => 1,
|
||||
-> { Transfer.count } => 0 do
|
||||
post trades_url, params: {
|
||||
entry: {
|
||||
account_id: @entry.account_id,
|
||||
post trades_url(account_id: @entry.account_id), params: {
|
||||
model: {
|
||||
type: "withdrawal",
|
||||
date: Date.current,
|
||||
amount: 10,
|
||||
@@ -98,9 +95,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "creates interest entry" do
|
||||
assert_difference [ "Entry.count", "Transaction.count" ], 1 do
|
||||
post trades_url, params: {
|
||||
entry: {
|
||||
account_id: @entry.account_id,
|
||||
post trades_url(account_id: @entry.account_id), params: {
|
||||
model: {
|
||||
type: "interest",
|
||||
date: Date.current,
|
||||
amount: 10,
|
||||
@@ -117,9 +113,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "creates trade buy entry" do
|
||||
assert_difference [ "Entry.count", "Trade.count", "Security.count" ], 1 do
|
||||
post trades_url, params: {
|
||||
entry: {
|
||||
account_id: @entry.account_id,
|
||||
post trades_url(account_id: @entry.account_id), params: {
|
||||
model: {
|
||||
type: "buy",
|
||||
date: Date.current,
|
||||
ticker: "NVDA (NASDAQ)",
|
||||
@@ -141,9 +136,8 @@ class TradesControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "creates trade sell entry" do
|
||||
assert_difference [ "Entry.count", "Trade.count" ], 1 do
|
||||
post trades_url, params: {
|
||||
entry: {
|
||||
account_id: @entry.account_id,
|
||||
post trades_url(account_id: @entry.account_id), params: {
|
||||
model: {
|
||||
type: "sell",
|
||||
ticker: "AAPL (NYSE)",
|
||||
date: Date.current,
|
||||
|
||||
@@ -24,7 +24,7 @@ class TradesTest < ApplicationSystemTestCase
|
||||
fill_in "Ticker symbol", with: "AAPL"
|
||||
fill_in "Date", with: Date.current
|
||||
fill_in "Quantity", with: shares_qty
|
||||
fill_in "entry[price]", with: 214.23
|
||||
fill_in "model[price]", with: 214.23
|
||||
|
||||
click_button "Add transaction"
|
||||
|
||||
@@ -45,7 +45,7 @@ class TradesTest < ApplicationSystemTestCase
|
||||
fill_in "Ticker symbol", with: aapl.ticker
|
||||
fill_in "Date", with: Date.current
|
||||
fill_in "Quantity", with: qty
|
||||
fill_in "entry[price]", with: 215.33
|
||||
fill_in "model[price]", with: 215.33
|
||||
|
||||
click_button "Add transaction"
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ class TransactionsTest < ApplicationSystemTestCase
|
||||
end
|
||||
select "Deposit", from: "Type"
|
||||
fill_in "Date", with: transfer_date
|
||||
fill_in "entry[amount]", with: 175.25
|
||||
fill_in "model[amount]", with: 175.25
|
||||
click_button "Add transaction"
|
||||
within "#entry-group-" + transfer_date.to_s do
|
||||
assert_text "175.25"
|
||||
|
||||
Reference in New Issue
Block a user