mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-23 07:09:39 +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
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@builder = Account::TradeBuilder.new(entry_params)
|
@builder = Account::EntryBuilder.new(entry_params)
|
||||||
|
|
||||||
if entry = @builder.save
|
if entry = @builder.save
|
||||||
entry.sync_account_later
|
entry.sync_account_later
|
||||||
redirect_to account_path(@account), notice: t(".success")
|
redirect_to account_path(@account), notice: t(".success")
|
||||||
else
|
else
|
||||||
render :new, status: :unprocessable_entity
|
flash[:alert] = t(".failure")
|
||||||
|
redirect_back_or_to account_path(@account)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -29,6 +30,8 @@ class Account::TradesController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def entry_params
|
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
|
||||||
end
|
end
|
||||||
|
|
|
@ -38,7 +38,7 @@ module Account::EntriesHelper
|
||||||
name = entry.name || generated
|
name = entry.name || generated
|
||||||
name
|
name
|
||||||
else
|
else
|
||||||
entry.name
|
entry.name || "Transaction"
|
||||||
end
|
end
|
||||||
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
|
class Account::TradeBuilder < Account::EntryBuilder
|
||||||
TYPES = %w[ buy sell ].freeze
|
|
||||||
|
|
||||||
include ActiveModel::Model
|
include ActiveModel::Model
|
||||||
|
|
||||||
|
TYPES = %w[ buy sell ].freeze
|
||||||
|
|
||||||
attr_accessor :type, :qty, :price, :ticker, :date, :account
|
attr_accessor :type, :qty, :price, :ticker, :date, :account
|
||||||
|
|
||||||
validates :type, :qty, :price, :ticker, :date, presence: true
|
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:) %>
|
<%# locals: (entry:) %>
|
||||||
|
|
||||||
<%= styled_form_with data: { turbo_frame: "_top" },
|
<%= styled_form_with data: { turbo_frame: "_top", controller: "trade-form" },
|
||||||
scope: :account_entry,
|
scope: :account_entry,
|
||||||
url: entry.new_record? ? account_trades_path(entry.account) : account_entry_path(entry.account, entry) do |form| %>
|
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-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell]], "buy"), label: t(".type") %>
|
<%= 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" } } %>
|
||||||
<%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %>
|
<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.date_field :date, label: true %>
|
||||||
<%= form.hidden_field :currency, value: entry.account.currency %>
|
|
||||||
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0 %>
|
<div data-trade-form-target="amountInput" hidden>
|
||||||
<%= money_with_currency_field form, :price_money, label: t(".price"), disable_currency: true %>
|
<%= money_with_currency_field form, :amount_money, label: t(".amount"), disable_currency: true %>
|
||||||
<%= form.hidden_field :currency, value: entry.account.currency %>
|
</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>
|
</div>
|
||||||
|
|
||||||
<%= form.submit t(".submit") %>
|
<%= form.submit t(".submit") %>
|
||||||
|
|
|
@ -42,7 +42,7 @@
|
||||||
|
|
||||||
<div class="col-span-3 flex items-center justify-end">
|
<div class="col-span-3 flex items-center justify-end">
|
||||||
<% if entry.account_transaction? %>
|
<% 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 %>
|
<% else %>
|
||||||
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
|
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -13,14 +13,14 @@
|
||||||
<div class="max-w-full">
|
<div class="max-w-full">
|
||||||
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
|
<%= 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">
|
<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 %>
|
||||||
<% 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" %>
|
||||||
|
|
|
@ -3,8 +3,12 @@ en:
|
||||||
account:
|
account:
|
||||||
trades:
|
trades:
|
||||||
create:
|
create:
|
||||||
|
failure: Something went wrong
|
||||||
success: Transaction created successfully.
|
success: Transaction created successfully.
|
||||||
form:
|
form:
|
||||||
|
account: Transfer account (optional)
|
||||||
|
account_prompt: Search account
|
||||||
|
amount: Amount
|
||||||
holding: Ticker symbol
|
holding: Ticker symbol
|
||||||
price: Price per share
|
price: Price per share
|
||||||
qty: Quantity
|
qty: Quantity
|
||||||
|
|
|
@ -16,6 +16,81 @@ class Account::TradesControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_response :success
|
assert_response :success
|
||||||
end
|
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
|
test "creates trade buy entry" do
|
||||||
assert_difference [ "Account::Entry.count", "Account::Trade.count", "Security.count" ], 1 do
|
assert_difference [ "Account::Entry.count", "Account::Trade.count", "Security.count" ], 1 do
|
||||||
post account_trades_url(@entry.account), params: {
|
post account_trades_url(@entry.account), params: {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue