1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-22 22:59: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:
Zach Gollwitzer 2024-08-09 20:11:27 -04:00 committed by GitHub
parent f3c44464be
commit 94be117a02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 286 additions and 17 deletions

View file

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

View file

@ -38,7 +38,7 @@ module Account::EntriesHelper
name = entry.name || generated
name
else
entry.name
entry.name || "Transaction"
end
end

View 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
}
}
}

View 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

View file

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

View 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

View file

@ -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.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 %>
<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 %>
<%= form.hidden_field :currency, value: entry.account.currency %>
</div>
</div>
<%= form.submit t(".submit") %>

View file

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

View file

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

View file

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

View file

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