diff --git a/app/controllers/account/trades_controller.rb b/app/controllers/account/trades_controller.rb index d93e5a216cf..9d4ba9a2cf9 100644 --- a/app/controllers/account/trades_controller.rb +++ b/app/controllers/account/trades_controller.rb @@ -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 @@ -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 diff --git a/app/helpers/account/entries_helper.rb b/app/helpers/account/entries_helper.rb index c3d3b97dc4a..0f2aff52366 100644 --- a/app/helpers/account/entries_helper.rb +++ b/app/helpers/account/entries_helper.rb @@ -38,7 +38,7 @@ def entry_name(entry) name = entry.name || generated name else - entry.name + entry.name || "Transaction" end end diff --git a/app/javascript/controllers/trade_form_controller.js b/app/javascript/controllers/trade_form_controller.js new file mode 100644 index 00000000000..61751c02853 --- /dev/null +++ b/app/javascript/controllers/trade_form_controller.js @@ -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 + } + } +} \ No newline at end of file diff --git a/app/models/account/entry_builder.rb b/app/models/account/entry_builder.rb new file mode 100644 index 00000000000..e818ac9204b --- /dev/null +++ b/app/models/account/entry_builder.rb @@ -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 diff --git a/app/models/account/trade_builder.rb b/app/models/account/trade_builder.rb index f460a486be0..bc5679441f6 100644 --- a/app/models/account/trade_builder.rb +++ b/app/models/account/trade_builder.rb @@ -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 diff --git a/app/models/account/transaction_builder.rb b/app/models/account/transaction_builder.rb new file mode 100644 index 00000000000..4c20237389a --- /dev/null +++ b/app/models/account/transaction_builder.rb @@ -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 diff --git a/app/views/account/trades/_form.html.erb b/app/views/account/trades/_form.html.erb index 75a1e748a76..10d0dac8686 100644 --- a/app/views/account/trades/_form.html.erb +++ b/app/views/account/trades/_form.html.erb @@ -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| %>
- <%= 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" } } %> +
+ <%= form.text_field :ticker, value: nil, label: t(".holding"), placeholder: t(".ticker_placeholder") %> +
+ <%= 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 %> + + + + + +
+ <%= 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.submit t(".submit") %> diff --git a/app/views/account/trades/_trade.html.erb b/app/views/account/trades/_trade.html.erb index 1e878677d25..273ec184342 100644 --- a/app/views/account/trades/_trade.html.erb +++ b/app/views/account/trades/_trade.html.erb @@ -42,7 +42,7 @@
<% 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 %> diff --git a/app/views/account/transactions/_transaction.html.erb b/app/views/account/transactions/_transaction.html.erb index faba41fa7c0..475ab256817 100644 --- a/app/views/account/transactions/_transaction.html.erb +++ b/app/views/account/transactions/_transaction.html.erb @@ -13,14 +13,14 @@
<%= content_tag :div, class: ["flex items-center gap-2"] do %>
- <%= entry.name[0].upcase %> + <%= entry_name(entry).first.upcase %>
<% 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" %> diff --git a/config/locales/views/account/trades/en.yml b/config/locales/views/account/trades/en.yml index 9cf195ce301..bfd920bb545 100644 --- a/config/locales/views/account/trades/en.yml +++ b/config/locales/views/account/trades/en.yml @@ -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 diff --git a/test/controllers/account/trades_controller_test.rb b/test/controllers/account/trades_controller_test.rb index b01089b639b..46f53046fc6 100644 --- a/test/controllers/account/trades_controller_test.rb +++ b/test/controllers/account/trades_controller_test.rb @@ -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: {