--- type: process universe: live status: verified consumes: ["../objects/gradebook/grade-book.md", "../objects/gradebook/grade-entry.md", "../objects/org/quarter.md"] produces: ["../objects/money/portfolio-transaction.md"] --- # finalize-gradebook-earnings Grades and attendance become SIF dollars. **The only path by which students earn.** Verified 2026-08-16 against commit `63732df`. ## Input → Movement → Output A teacher fills in a quarter's [grade-entries](../objects/gradebook/grade-entry.md) — two letter grades and attendance per student. An **admin** then presses Finalize, which flips the [grade-book](../objects/gradebook/grade-book.md) to `verified` and hands it to `DistributeEarnings`. The service writes up to three deposit rows per student and marks the book `completed`. ## Why this shape **Teachers enter, admins release.** `GradeBookPolicy#finalize?` is `user.admin?` (`app/policies/grade_book_policy.rb:12-14`) while `show?` and `update?` also accept the classroom's teachers (`:4-10`). Money is never minted by the person who entered the numbers. **`verified` is a millisecond-long state.** The controller sets `verified!` and calls the service on the next line (`app/controllers/grade_books_controller.rb:30-31`); the service refuses to run on anything else (`app/services/distribute_earnings.rb:14`). It is a handshake between the two, not a review queue. **One check prevents paying twice** — `if @grade_book.completed?` in the controller (`app/controllers/grade_books_controller.rb:26`). Deposits carry no link back to the entry that produced them, so a double run cannot be detected afterwards and would have to be unwound by hand. **Improvement bonuses reach into the previous quarter**, crossing school-year boundaries via `Quarter#previous` (`app/services/distribute_earnings.rb:35-38`, `app/models/quarter.rb:20-24`). If that returns `nil` — a first quarter with no prior year — the bonus silently pays zero and the run still succeeds. ## Steps 1. Teacher edits entries; `GradeBooksController#update` writes them inside one transaction (`app/controllers/grade_books_controller.rb:9-23`). The `autosave` Stimulus controller PATCHes as they type. 2. Admin posts `finalize` (`config/routes.rb:26`); `authorize @grade_book` resolves to `finalize?` → admin only (`app/controllers/grade_books_controller.rb:5-6,39-41`). 3. Already `completed`? Redirect and stop (`:26-28`). 4. Otherwise `@grade_book.verified!`, then `DistributeEarnings.execute(@grade_book)` (`:30-31`). 5. The service loads the previous quarter's gradebook entries, grouped by user (`app/services/distribute_earnings.rb:34-42`). 6. Per entry, it sums three buckets — attendance (days + perfect bonus), math (grade + improvement), reading (grade + improvement) (`:54-73`) — using the constants on [grade-entry](../objects/gradebook/grade-entry.md). 7. Each non-zero bucket becomes a `deposit` with its own `reason` (`:44-52`). **Zero-value buckets are skipped**, so a student with no earnings gets no row at all. 8. `@grade_book.completed!` inside the same transaction (`:16-19`). ## If you change this - **Hits:** [portfolio-transaction](../objects/money/portfolio-transaction.md) — this is where deposits come from; every balance downstream; [earnings-summary](../objects/money/earnings-summary.md), which groups those deposits by reason; [grade-book](../objects/gradebook/grade-book.md) status. - **Does not hit:** [order](../objects/trading/order.md), [portfolio-stock](../objects/trading/portfolio-stock.md), or any holding. Earnings arrive purely as cash — finalizing never buys anything, and a student with no orders is affected exactly as much as one with many. ## Failure modes seen in the code - A letter grade outside `GradeEntry::GRADE_OPTIONS` — possible, since the model has **no validations** — makes `improved_grade?` compare `nil` indices and raise (`app/models/grade_entry.rb:64-67`). The transaction rolls back and the whole classroom goes unpaid. - `DistributeEarnings` pays `entry.user` regardless of enrollment status (`:25-31`), so an unenrolled student with a lingering entry is still paid. ## Surfaces | Surface | Role | |---|---| | `GradeBooksController#update` + `autosave` Stimulus controller | teacher writes | | `GradeBooksController#finalize` | **admin** triggers | | `DistributeEarnings` | writes deposits | ## See - Objects: [grade-book](../objects/gradebook/grade-book.md), [grade-entry](../objects/gradebook/grade-entry.md) - Source: `app/services/distribute_earnings.rb`, `app/controllers/grade_books_controller.rb` - As-built: `docs/gradebook-earnings.md`