mirror of
https://github.com/we-promise/sure.git
synced 2026-06-05 10:49:01 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user