Creator form pattern

This commit is contained in:
Zach Gollwitzer
2025-06-17 12:54:09 -04:00
parent b049f23cad
commit 1e76df9520
11 changed files with 159 additions and 195 deletions

View File

@@ -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

View 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

View File

@@ -1,12 +0,0 @@
class Trade::Creator
def initialize(attrs)
@attrs = attrs
end
def create
# TODO
end
private
attr_reader :attrs
end

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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) %>

View File

@@ -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 %>

View File

@@ -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,

View File

@@ -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"

View File

@@ -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"