mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-23 15:19:38 +02:00
Deposit, Withdrawal, and Interest Transactions for Investment View (#1075)
* Trade and Transaction builders * Consolidate logic * Remove redundant fields from trade form * Add deposit, withdrawal, and interest form controls
This commit is contained in:
parent
f3c44464be
commit
94be117a02
11 changed files with 286 additions and 17 deletions
|
@ -12,13 +12,14 @@ class Account::TradesController < ApplicationController
|
|||
end
|
||||
|
||||
def create
|
||||
@builder = Account::TradeBuilder.new(entry_params)
|
||||
@builder = Account::EntryBuilder.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
|
||||
flash[:alert] = t(".failure")
|
||||
redirect_back_or_to account_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -29,6 +30,8 @@ class Account::TradesController < ApplicationController
|
|||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:type, :date, :qty, :ticker, :price).merge(account: @account)
|
||||
params.require(:account_entry)
|
||||
.permit(:type, :date, :qty, :ticker, :price, :amount, :currency, :transfer_account_id)
|
||||
.merge(account: @account)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -38,7 +38,7 @@ module Account::EntriesHelper
|
|||
name = entry.name || generated
|
||||
name
|
||||
else
|
||||
entry.name
|
||||
entry.name || "Transaction"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
64
app/javascript/controllers/trade_form_controller.js
Normal file
64
app/javascript/controllers/trade_form_controller.js
Normal file
|
@ -0,0 +1,64 @@
|
|||
import {Controller} from "@hotwired/stimulus"
|
||||
|
||||
const TRADE_TYPES = {
|
||||
BUY: "buy",
|
||||
SELL: "sell",
|
||||
TRANSFER_IN: "transfer_in",
|
||||
TRANSFER_OUT: "transfer_out",
|
||||
INTEREST: "interest"
|
||||
}
|
||||
|
||||
const FIELD_VISIBILITY = {
|
||||
[TRADE_TYPES.BUY]: {ticker: true, qty: true, price: true},
|
||||
[TRADE_TYPES.SELL]: {ticker: true, qty: true, price: true},
|
||||
[TRADE_TYPES.TRANSFER_IN]: {amount: true, transferAccount: true},
|
||||
[TRADE_TYPES.TRANSFER_OUT]: {amount: true, transferAccount: true},
|
||||
[TRADE_TYPES.INTEREST]: {amount: true}
|
||||
}
|
||||
|
||||
// Connects to data-controller="trade-form"
|
||||
export default class extends Controller {
|
||||
static targets = ["typeInput", "tickerInput", "amountInput", "transferAccountInput", "qtyInput", "priceInput"]
|
||||
|
||||
connect() {
|
||||
this.handleTypeChange = this.handleTypeChange.bind(this)
|
||||
this.typeInputTarget.addEventListener("change", this.handleTypeChange)
|
||||
this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.typeInputTarget.removeEventListener("change", this.handleTypeChange)
|
||||
}
|
||||
|
||||
handleTypeChange(event) {
|
||||
this.updateFields(event.target.value)
|
||||
}
|
||||
|
||||
updateFields(type) {
|
||||
const visibleFields = FIELD_VISIBILITY[type] || {}
|
||||
|
||||
Object.entries(this.fieldTargets).forEach(([field, target]) => {
|
||||
const isVisible = visibleFields[field] || false
|
||||
|
||||
// Update visibility
|
||||
target.hidden = !isVisible
|
||||
|
||||
// Update required status based on visibility
|
||||
if (isVisible) {
|
||||
target.setAttribute('required', '')
|
||||
} else {
|
||||
target.removeAttribute('required')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get fieldTargets() {
|
||||
return {
|
||||
ticker: this.tickerInputTarget,
|
||||
amount: this.amountInputTarget,
|
||||
transferAccount: this.transferAccountInputTarget,
|
||||
qty: this.qtyInputTarget,
|
||||
price: this.priceInputTarget
|
||||
}
|
||||
}
|
||||
}
|
45
app/models/account/entry_builder.rb
Normal file
45
app/models/account/entry_builder.rb
Normal file
|
@ -0,0 +1,45 @@
|
|||
class Account::EntryBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[ income expense buy sell interest transfer_in transfer_out ].freeze
|
||||
|
||||
attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id
|
||||
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
def save
|
||||
if valid?
|
||||
create_builder.save
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_builder
|
||||
case type
|
||||
when "buy", "sell"
|
||||
create_trade_builder
|
||||
else
|
||||
create_transaction_builder
|
||||
end
|
||||
end
|
||||
|
||||
def create_trade_builder
|
||||
Account::TradeBuilder.new \
|
||||
type: type,
|
||||
date: date,
|
||||
qty: qty,
|
||||
ticker: ticker,
|
||||
price: price,
|
||||
account: account
|
||||
end
|
||||
|
||||
def create_transaction_builder
|
||||
Account::TransactionBuilder.new \
|
||||
type: type,
|
||||
date: date,
|
||||
amount: amount,
|
||||
account: account,
|
||||
transfer_account_id: transfer_account_id
|
||||
end
|
||||
end
|
|
@ -1,8 +1,8 @@
|
|||
class Account::TradeBuilder
|
||||
TYPES = %w[ buy sell ].freeze
|
||||
|
||||
class Account::TradeBuilder < Account::EntryBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[ buy sell ].freeze
|
||||
|
||||
attr_accessor :type, :qty, :price, :ticker, :date, :account
|
||||
|
||||
validates :type, :qty, :price, :ticker, :date, presence: true
|
||||
|
|
63
app/models/account/transaction_builder.rb
Normal file
63
app/models/account/transaction_builder.rb
Normal file
|
@ -0,0 +1,63 @@
|
|||
class Account::TransactionBuilder
|
||||
include ActiveModel::Model
|
||||
|
||||
TYPES = %w[ income expense interest transfer_in transfer_out ].freeze
|
||||
|
||||
attr_accessor :type, :amount, :date, :account, :transfer_account_id
|
||||
|
||||
validates :type, :amount, :date, presence: true
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
|
||||
def save
|
||||
if valid?
|
||||
transfer? ? create_transfer : create_transaction
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transfer?
|
||||
%w[transfer_in transfer_out].include?(type)
|
||||
end
|
||||
|
||||
def create_transfer
|
||||
return create_unlinked_transfer(account.id, signed_amount) unless transfer_account_id
|
||||
|
||||
from_account_id = type == "transfer_in" ? transfer_account_id : account.id
|
||||
to_account_id = type == "transfer_in" ? account.id : transfer_account_id
|
||||
|
||||
outflow = create_unlinked_transfer(from_account_id, signed_amount.abs)
|
||||
inflow = create_unlinked_transfer(to_account_id, signed_amount.abs * -1)
|
||||
|
||||
Account::Transfer.create! entries: [ outflow, inflow ]
|
||||
|
||||
inflow
|
||||
end
|
||||
|
||||
def create_unlinked_transfer(account_id, amount)
|
||||
build_entry(account_id, amount, marked_as_transfer: true).tap(&:save!)
|
||||
end
|
||||
|
||||
def create_transaction
|
||||
build_entry(account.id, signed_amount).tap(&:save!)
|
||||
end
|
||||
|
||||
def build_entry(account_id, amount, marked_as_transfer: false)
|
||||
Account::Entry.new \
|
||||
account_id: account_id,
|
||||
amount: amount,
|
||||
currency: account.currency,
|
||||
date: date,
|
||||
marked_as_transfer: marked_as_transfer,
|
||||
entryable: Account::Transaction.new
|
||||
end
|
||||
|
||||
def signed_amount
|
||||
case type
|
||||
when "expense", "transfer_out"
|
||||
amount.to_d
|
||||
else
|
||||
amount.to_d * -1
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,17 +1,32 @@
|
|||
<%# locals: (entry:) %>
|
||||
|
||||
<%= styled_form_with data: { turbo_frame: "_top" },
|
||||
<%= styled_form_with data: { turbo_frame: "_top", controller: "trade-form" },
|
||||
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.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %>
|
||||
<div data-trade-form-target="tickerInput">
|
||||
<%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %>
|
||||
</div>
|
||||
|
||||
<%= 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 data-trade-form-target="amountInput" hidden>
|
||||
<%= money_with_currency_field form, :amount_money, label: t(".amount"), disable_currency: true %>
|
||||
</div>
|
||||
|
||||
<div data-trade-form-target="transferAccountInput" hidden>
|
||||
<%= form.collection_select :transfer_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") } %>
|
||||
</div>
|
||||
|
||||
<div data-trade-form-target="qtyInput">
|
||||
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0 %>
|
||||
</div>
|
||||
|
||||
<div data-trade-form-target="priceInput">
|
||||
<%= money_with_currency_field form, :price_money, label: t(".price"), disable_currency: true %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form.submit t(".submit") %>
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
|
||||
<div class="col-span-3 flex items-center justify-end">
|
||||
<% if entry.account_transaction? %>
|
||||
<%= tag.p format_money(entry.amount_money), class: { "text-green-500": entry.inflow? } %>
|
||||
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": entry.inflow? } %>
|
||||
<% else %>
|
||||
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
|
||||
<% end %>
|
||||
|
|
|
@ -13,14 +13,14 @@
|
|||
<div class="max-w-full">
|
||||
<%= content_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">
|
||||
<%= entry.name[0].upcase %>
|
||||
<%= entry_name(entry).first.upcase %>
|
||||
</div>
|
||||
|
||||
<div class="truncate text-gray-900">
|
||||
<% if entry.new_record? %>
|
||||
<%= content_tag :p, entry.name %>
|
||||
<% else %>
|
||||
<%= link_to entry.name,
|
||||
<%= link_to entry_name(entry),
|
||||
account_entry_path(account, entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
|
|
|
@ -3,8 +3,12 @@ en:
|
|||
account:
|
||||
trades:
|
||||
create:
|
||||
failure: Something went wrong
|
||||
success: Transaction created successfully.
|
||||
form:
|
||||
account: Transfer account (optional)
|
||||
account_prompt: Search account
|
||||
amount: Amount
|
||||
holding: Ticker symbol
|
||||
price: Price per share
|
||||
qty: Quantity
|
||||
|
|
|
@ -16,6 +16,81 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
|||
assert_response :success
|
||||
end
|
||||
|
||||
test "creates deposit entry" do
|
||||
from_account = accounts(:depository) # Account the deposit is coming from
|
||||
|
||||
assert_difference -> { Account::Entry.count } => 2,
|
||||
-> { Account::Transaction.count } => 2,
|
||||
-> { Account::Transfer.count } => 1 do
|
||||
post account_trades_url(@entry.account), params: {
|
||||
account_entry: {
|
||||
type: "transfer_in",
|
||||
date: Date.current,
|
||||
amount: 10,
|
||||
transfer_account_id: from_account.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to account_path(@entry.account)
|
||||
end
|
||||
|
||||
test "creates withdrawal entry" do
|
||||
to_account = accounts(:depository) # Account the withdrawal is going to
|
||||
|
||||
assert_difference -> { Account::Entry.count } => 2,
|
||||
-> { Account::Transaction.count } => 2,
|
||||
-> { Account::Transfer.count } => 1 do
|
||||
post account_trades_url(@entry.account), params: {
|
||||
account_entry: {
|
||||
type: "transfer_out",
|
||||
date: Date.current,
|
||||
amount: 10,
|
||||
transfer_account_id: to_account.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to account_path(@entry.account)
|
||||
end
|
||||
|
||||
test "deposit and withdrawal has optional transfer account" do
|
||||
assert_difference -> { Account::Entry.count } => 1,
|
||||
-> { Account::Transaction.count } => 1,
|
||||
-> { Account::Transfer.count } => 0 do
|
||||
post account_trades_url(@entry.account), params: {
|
||||
account_entry: {
|
||||
type: "transfer_out",
|
||||
date: Date.current,
|
||||
amount: 10
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
created_entry = Account::Entry.order(created_at: :desc).first
|
||||
|
||||
assert created_entry.amount.positive?
|
||||
assert created_entry.marked_as_transfer
|
||||
assert_redirected_to account_path(@entry.account)
|
||||
end
|
||||
|
||||
test "creates interest entry" do
|
||||
assert_difference [ "Account::Entry.count", "Account::Transaction.count" ], 1 do
|
||||
post account_trades_url(@entry.account), params: {
|
||||
account_entry: {
|
||||
type: "interest",
|
||||
date: Date.current,
|
||||
amount: 10
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
created_entry = Account::Entry.order(created_at: :desc).first
|
||||
|
||||
assert created_entry.amount.negative?
|
||||
assert_redirected_to account_path(@entry.account)
|
||||
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: {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue