Improve investment activity labels UX and add convert-to-trade feature (#649)

* Add `investment_activity_label` to trades and enhance activity label handling

- Introduced `investment_activity_label` column to the `trades` table with a migration.
- Backfilled existing `trades` with activity labels based on quantity (`Buy`, `Sell`, or `Other`).
- Replaced `category_id` in trades with `investment_activity_label` for better alignment with transaction labels.
- Updated views and controllers to display and manage activity labels for trades.
- Added localized badge components for displaying and editing labels dynamically.
- Enhanced `PlaidAccount::Investments::TransactionsProcessor` to assign and process activity labels automatically.
- Added investment flows section to reports for tracking contributions and withdrawals.
- Refactored related tests and models for consistency and to ensure proper validation and filtering.

* Improve handling of `investment_activity_label`, trade type, and security selection in trades and transactions

- Refined label assignment logic in `trades_controller` to default to `Buy`/`Sell` based on transaction nature.
- Simplified security selection in `transactions_controller` by resolving via unique IDs or custom tickers.
- Streamlined UI for trade and transaction forms by updating dropdown options and label text.
- Enabled quick-edit badges to open `convert_to_trade` modal when applicable, enhancing flexibility.
- Adjusted tests and views to align with updated workflows and ensure consistent behavior.

* Improve handling of `investment_activity_label`, trade type, and security selection in trades and transactions

- Refined label assignment logic in `trades_controller` to default to `Buy`/`Sell` based on transaction nature.
- Simplified security selection in `transactions_controller` by resolving via unique IDs or custom tickers.
- Streamlined UI for trade and transaction forms by updating dropdown options and label text.
- Enabled quick-edit badges to open `convert_to_trade` modal when applicable, enhancing flexibility.
- Adjusted tests and views to align with updated workflows and ensure consistent behavior.

* Improve handling of `investment_activity_label`, trade type, and security selection in trades and transactions

- Refined label assignment logic in `trades_controller` to default to `Buy`/`Sell` based on transaction nature.
- Simplified security selection in `transactions_controller` by resolving via unique IDs or custom tickers.
- Streamlined UI for trade and transaction forms by updating dropdown options and label text.
- Enabled quick-edit badges to open `convert_to_trade` modal when applicable, enhancing flexibility.
- Adjusted tests and views to align with updated workflows and ensure consistent behavior.

* Add safeguard for `dropdownTarget` existence in quick edit controller

- Prevent errors by ensuring `dropdownTarget` is present before toggling its visibility.

* Fix undefined method 'category' for Trade on mobile view

Trade model uses investment_activity_label, not category. The upstream
merge introduced a call to trade.category which doesn't exist. Use the
activity label badge on mobile instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix activity label logic for zero/blank quantity and sell inference

- Return `nil` for blank or zero quantity in `investment_activity_label_for`.
- Correct `is_sell` logic to use the amount’s sign properly in `transactions_controller`.

* Fix i18n key paths in transactions controller for convert_to_trade

- Update flash message translations to use full i18n paths.
- Use `BigDecimal` for quantity and price calculations to improve precision.

---------

Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
LPW
2026-01-16 15:04:10 -05:00
committed by GitHub
parent 1ca84d8048
commit 0c2026680c
36 changed files with 885 additions and 154 deletions

View File

@@ -149,6 +149,87 @@ class TransactionsController < ApplicationController
redirect_back_or_to transactions_path
end
def convert_to_trade
@transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
@entry = @transaction.entry
unless @entry.account.investment?
flash[:alert] = t("transactions.convert_to_trade.errors.not_investment_account")
redirect_back_or_to transactions_path
return
end
render :convert_to_trade
end
def create_trade_from_transaction
@transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
@entry = @transaction.entry
# Pre-transaction validations
unless @entry.account.investment?
flash[:alert] = t("transactions.convert_to_trade.errors.not_investment_account")
redirect_back_or_to transactions_path
return
end
if @entry.excluded?
flash[:alert] = t("transactions.convert_to_trade.errors.already_converted")
redirect_back_or_to transactions_path
return
end
# Resolve security before transaction
security = resolve_security_for_conversion
return if performed? # Early exit if redirect already happened
# Validate and calculate qty/price before transaction
qty, price = calculate_qty_and_price
return if performed? # Early exit if redirect already happened
activity_label = params[:investment_activity_label].presence
# Infer sell from amount sign: negative amount = money coming in = sell
is_sell = activity_label == "Sell" || (activity_label.blank? && @entry.amount < 0)
ActiveRecord::Base.transaction do
# For trades: positive qty = buy (money out), negative qty = sell (money in)
signed_qty = is_sell ? -qty : qty
trade_amount = qty * price
# Sells bring money in (negative amount), Buys take money out (positive amount)
signed_amount = is_sell ? -trade_amount : trade_amount
# Default activity label if not provided
activity_label ||= is_sell ? "Sell" : "Buy"
# Create trade entry
@entry.account.entries.create!(
name: params[:trade_name] || Trade.build_name(is_sell ? "sell" : "buy", qty, security.ticker),
date: @entry.date,
amount: signed_amount,
currency: @entry.currency,
entryable: Trade.new(
security: security,
qty: signed_qty,
price: price,
currency: @entry.currency,
investment_activity_label: activity_label
)
)
# Mark original transaction as excluded (soft delete)
@entry.update!(excluded: true)
end
flash[:notice] = t("transactions.convert_to_trade.success")
redirect_to account_path(@entry.account), status: :see_other
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
flash[:alert] = t("transactions.convert_to_trade.errors.conversion_failed", error: e.message)
redirect_back_or_to transactions_path, status: :see_other
rescue StandardError => e
flash[:alert] = t("transactions.convert_to_trade.errors.unexpected_error", error: e.message)
redirect_back_or_to transactions_path, status: :see_other
end
def mark_as_recurring
transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
@@ -280,4 +361,64 @@ class TransactionsController < ApplicationController
def preferences_params
params.require(:preferences).permit(collapsed_sections: {})
end
# Helper methods for convert_to_trade
def resolve_security_for_conversion
if params[:security_id] == "__custom__"
ticker = params[:custom_ticker].presence
unless ticker.present?
flash[:alert] = t("transactions.convert_to_trade.errors.enter_ticker")
redirect_back_or_to transactions_path
return nil
end
Security::Resolver.new(
ticker.strip,
exchange_operating_mic: params[:exchange_operating_mic].presence
).resolve
elsif params[:security_id].present?
found = Security.find_by(id: params[:security_id])
unless found
flash[:alert] = t("transactions.convert_to_trade.errors.security_not_found")
redirect_back_or_to transactions_path
return nil
end
found
elsif params[:ticker].present?
Security::Resolver.new(
params[:ticker].strip,
exchange_operating_mic: params[:exchange_operating_mic].presence
).resolve
end.tap do |security|
if security.nil? && !performed?
flash[:alert] = t("transactions.convert_to_trade.errors.select_security")
redirect_back_or_to transactions_path
end
end
end
def calculate_qty_and_price
amount = @entry.amount.abs
qty = params[:qty].present? ? params[:qty].to_d.abs : nil
price = params[:price].present? ? params[:price].to_d : nil
if qty.nil? && price.nil?
flash[:alert] = t("transactions.convert_to_trade.errors.enter_qty_or_price")
redirect_back_or_to transactions_path, status: :see_other
return [ nil, nil ]
elsif qty.nil? && price.present? && price > 0
qty = (amount / price).round(6)
elsif price.nil? && qty.present? && qty > 0
price = (amount / qty).round(4)
end
if qty.nil? || qty <= 0 || price.nil? || price <= 0
flash[:alert] = t("transactions.convert_to_trade.errors.invalid_qty_or_price")
redirect_back_or_to transactions_path, status: :see_other
return [ nil, nil ]
end
[ qty, price ]
end
end