Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deposit, Withdrawal, and Interest Transactions for Investment View #1075

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions app/controllers/account/trades_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ def index
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

Expand All @@ -29,6 +30,8 @@ def set_account
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
2 changes: 1 addition & 1 deletion app/helpers/account/entries_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def entry_name(entry)
name = entry.name || generated
name
else
entry.name
entry.name || "Transaction"
end
end

Expand Down
64 changes: 64 additions & 0 deletions app/javascript/controllers/trade_form_controller.js
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very rudimentary controller, highly coupled to the trade forms. Ideally we'll abstract this a bit in the future

45 changes: 45 additions & 0 deletions app/models/account/entry_builder.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions app/models/account/trade_builder.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
63 changes: 63 additions & 0 deletions app/models/account/transaction_builder.rb
Original file line number Diff line number Diff line change
@@ -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
29 changes: 22 additions & 7 deletions app/views/account/trades/_form.html.erb
Original file line number Diff line number Diff line change
@@ -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") %>
Expand Down
2 changes: 1 addition & 1 deletion app/views/account/trades/_trade.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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 %>
Expand Down
4 changes: 2 additions & 2 deletions app/views/account/transactions/_transaction.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -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" %>
Expand Down
4 changes: 4 additions & 0 deletions config/locales/views/account/trades/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions test/controllers/account/trades_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down