# frozen_string_literal: true class Order < ApplicationRecord include ApplicationHelper belongs_to :user belongs_to :stock belongs_to :portfolio_stock, optional: true belongs_to :portfolio_transaction, optional: true enum :status, { pending: 0, completed: 1, canceled: 2 }, default: :pending enum :action, { buy: "buy", sell: "sell" } validates :shares, presence: true, numericality: { greater_than: 0 } validate :sufficient_funds_for_buy_when_update, on: :update, if: -> { buy? } validate :order_is_pending, on: :update # Now runs when creating orders or when user changes the share amount. # Skips during status changes (pending→completed) to avoid false errors. validate :sufficient_shares_for_sell, if: -> { sell? && validate_share_availability? }, on: %i[create update] validate :sufficient_funds_for_buy, if: -> { buy? }, on: :create validate :prevent_archived_stock_purchase, if: -> { buy? }, on: %i[create update] validate :trading_enabled_for_classroom, on: :create after_update :update_portfolio_transaction_for_pending_order delegate :portfolio, to: :user scope :buy, -> { where(action: :buy) } scope :sell, -> { where(action: :sell) } scope :pending, -> { where(status: :pending) } scope :completed, -> { where(status: :completed) } scope :canceled, -> { where(status: :canceled) } scope :for_student, ->(student) { where(user: student) } scope :for_teacher, lambda { |teacher| joins(user: :classroom).where(users: { classroom: teacher.classrooms }) } scope :order_by_created_at, lambda { |direction = :asc| reorder(created_at: safe_direction(direction)) } scope :order_by_username, lambda { |direction = :asc| joins(:user).reorder(users: { username: safe_direction(direction) }) } scope :order_by_classroom, lambda { |direction = :asc| joins(user: :classroom) .reorder(classrooms: { name: safe_direction(direction) }) } scope :order_by_stock, lambda { |direction = :asc| joins(:stock).reorder(stocks: { ticker: safe_direction(direction) }) } scope :order_by_price_per_share, lambda { |direction = :asc| joins(:stock) .reorder(stocks: { price_cents: safe_direction(direction) }) } scope :order_by_shares, lambda { |direction = :asc| reorder(shares: safe_direction(direction)) } scope :order_by_status, lambda { |direction = :asc| reorder(status: safe_direction(direction)) } scope :order_by_total_cost, lambda { |direction = :asc| dir = safe_direction(direction) expression = Arel.sql("stocks.price_cents * orders.shares") joins(:stock) .reorder(dir == :desc ? expression.desc : expression.asc) } # Apply sorting based on sort column param # @param collection [ActiveRecord::Relation] The collection to sort (optional) # @param sort_column [String] The column to sort by # @param direction [Symbol] :asc or :desc # @return [ActiveRecord::Relation] The sorted collection def self.apply_sorting(collection = nil, sort_column = nil, direction = :asc) base_scope = collection || all sorting_method = SORTING_METHODS[sort_column] return base_scope.order_by_created_at(:desc) unless sorting_method base_scope.send(sorting_method, direction) end # Map of sort column names to scope method names SORTING_METHODS = { "created_at" => :order_by_created_at, "username" => :order_by_username, "classroom" => :order_by_classroom, "stock" => :order_by_stock, "price_per_share" => :order_by_price_per_share, "shares" => :order_by_shares, "status" => :order_by_status, "total_cost" => :order_by_total_cost }.freeze def cancel! update(status: :canceled) end def purchase_cost stock.price_cents * shares end def existing_transaction_type order_transaction_type = portfolio_transaction&.transaction_type&.to_sym order_transaction_type == :debit ? "buy" : "sell" end def self.safe_direction(direction) direction.to_s.downcase == "desc" ? :desc : :asc end private def number_of_shares_valid_for_calculations? shares.is_a?(Numeric) && shares.positive? end def sufficient_shares_for_sell return unless number_of_shares_valid_for_calculations? current_shares = user.portfolio&.shares_owned(stock_id) || 0 return unless shares > current_shares formatted_shares = (current_shares % 1).zero? ? current_shares.to_i : current_shares errors.add(:base, "Cannot sell more shares than you own (#{formatted_shares} available)") end def sufficient_funds_for_buy return unless number_of_shares_valid_for_calculations? current_balance_cents = (user.portfolio&.cash_balance || 0) * 100 total_cost = purchase_cost + transaction_fee return if (current_balance_cents - total_cost) >= 0 formatted_balance = format_money(current_balance_cents) formatted_cost = format_money(total_cost) errors.add(:shares, "Insufficient funds. You have #{formatted_balance} but need #{formatted_cost}") end def transaction_fee user.orders.pending.where.not(id:).exists? ? 0 : PortfolioTransaction::TRANSACTION_FEE_CENTS end def sufficient_funds_for_buy_when_update return unless number_of_shares_valid_for_calculations? return unless shares_changed? previous_shares = shares_was refund = (stock.price_cents * previous_shares) + PortfolioTransaction::TRANSACTION_FEE_CENTS current_balance_cents = ((user.portfolio&.cash_balance || 0) * 100) + refund total_cost = purchase_cost + transaction_fee return if (current_balance_cents - total_cost) >= 0 formatted_balance = format_money(current_balance_cents) formatted_cost = format_money(total_cost) errors.add( :shares, "Insufficient funds. You have #{formatted_balance} but need #{formatted_cost}" ) end def order_is_pending prev_status, = status_change_to_be_saved return if pending? || prev_status.nil? || prev_status == "pending" errors.add(:base, "Cannot update non-pending orders") end def update_portfolio_transaction_for_pending_order return unless pending? && portfolio_transaction.present? portfolio_transaction.amount_cents = purchase_cost portfolio_transaction.transaction_type = translated_transaction_type portfolio_transaction.save end def translated_transaction_type buy? ? :debit : :credit end def prevent_archived_stock_purchase return unless stock&.archived? errors.add(:stock, "Cannot purchase shares of archived stocks") end # Only validate share availability when shares quantity changes, # not during status-only updates (pending → completed) def validate_share_availability? return true if new_record? shares_changed? end def trading_enabled_for_classroom return if user&.classroom&.trading_enabled? errors.add(:base, "Trading is currently disabled for your classroom") end end