--- type: process universe: live status: verified consumes: ["../objects/trading/order.md", "../objects/trading/stock.md", "../objects/money/portfolio.md"] produces: ["../objects/trading/portfolio-stock.md", "../objects/money/portfolio-transaction.md"] --- # place-and-execute-order A student's buy or sell becomes shares and cash — up to 15 minutes later. Verified 2026-08-16 against commit `63732df`. ## Input → Movement → Output A student submits a buy or sell, which is saved as a `pending` [order](../objects/trading/order.md) and nothing else. Every 15 minutes `OrderExecutionJob` sweeps all pending orders, re-validates each against the current balance and holdings, and either completes or cancels it. Completion writes one [portfolio-transaction](../objects/money/portfolio-transaction.md) and one [portfolio-stock](../objects/trading/portfolio-stock.md) lot, then a single $1.00 fee per user for the whole batch. ## Why this shape **Deferral is the design, not a queue optimisation.** Students trade at the price prevailing when the job runs, not when they click — the sweep re-reads `stock.price_cents` at execution time. This deliberately blunts day-trading in a classroom tool, and it is why `ExecuteOrder` re-checks funds and shares even though the model already validated them on create: the balance may have moved in between (`app/services/execute_order.rb:18-26`). **The fee is charged per user per sweep**, after all executions, by a separate service that tracks which users it has already billed (`app/services/transaction_fee_processor.rb:23-31`). [portfolio](../objects/money/portfolio.md) mirrors this by anticipating exactly one pending fee (`app/models/portfolio.rb:98-100`) — if the fee ever became per-order, that balance formula must change too. **Cancellation is silent.** An order that fails re-validation is cancelled, not errored (`app/services/execute_order.rb:18-26`). The student sees `canceled` with no reason attached — there is no failure-reason column. ## Steps 1. `OrdersController#create` saves the order with `user: current_user` (`app/controllers/orders_controller.rb:20-35`). Model validations check funds, shares, archived stock, and that the classroom has trading enabled (`app/models/order.rb:16-26`). 2. The order sits `pending`. `Portfolio#cash_on_hand_in_cents` already subtracts it and one fee, so the money is reserved (`app/models/portfolio.rb:93-100`). 3. Cron fires `OrderExecutionJob` every 15 minutes (`config/recurring.yml:2-6`). It retries up to 3 times with exponential backoff (`app/jobs/order_execution_job.rb:6`). 4. For each pending order, `ExecuteOrder.execute` runs (`app/jobs/order_execution_job.rb:35-39`). 5. `ExecuteOrder` returns unless still pending, then cancels on a negative balance (buy) or insufficient shares (sell) (`app/services/execute_order.rb:16-26,64-70`). 6. Otherwise, inside one DB transaction: create the ledger row — `debit` for a buy, `credit` for a sell (`:39-51`); create the lot with **negative shares for a sell** and `purchase_price: stock.current_price` in dollars (`:53-58`); mark the order `completed` and link both records (`:60-62`). 7. After the loop, `TransactionFeeProcessor.execute` charges $1.00 once per user across the whole batch (`app/jobs/order_execution_job.rb:41-43`, `app/services/transaction_fee_processor.rb:13-31`). ## If you change this - **Hits:** [portfolio](../objects/money/portfolio.md) balance — every step here is an input to it; [portfolio-stock](../objects/trading/portfolio-stock.md) and [portfolio-position](../objects/trading/portfolio-position.md); [portfolio-transaction](../objects/money/portfolio-transaction.md); `Order` validations, which duplicate the service's checks and must stay consistent with them. - **Does not hit:** [portfolio-snapshot](../objects/trading/portfolio-snapshot.md). Trades change what the next month-end snapshot will record but never write or amend one. Nor does it touch the gradebook — trading and earning are fully independent. ## Failure modes seen in the code - The fee is charged for every pending order's user even if **every** order in the batch was cancelled — `TransactionFeeProcessor` receives the original `pending_orders` relation and does not check status (`app/jobs/order_execution_job.rb:29-33`). - A stock with `price_cents = nil` raises in `Order#purchase_cost` (`app/models/order.rb:105-107`). ## Surfaces | Surface | Role | |---|---| | `OrdersController`, `order_form` Stimulus controller | student writes | | `OrderExecutionJob` (Solid Queue, every 15 min) | executes | | teacher/admin order lists | read | ## See - Objects: [order](../objects/trading/order.md), [portfolio](../objects/money/portfolio.md), [portfolio-stock](../objects/trading/portfolio-stock.md) - Source: `app/services/execute_order.rb`, `app/jobs/order_execution_job.rb` - As-built: `docs/orders-and-transactions.md`