convert pdf to md, add OVERVIEW and docs

This commit is contained in:
2026-08-17 22:30:18 +00:00
parent 95d5787868
commit 4df62d2609
44 changed files with 3307 additions and 1 deletions

View File

@@ -0,0 +1,48 @@
# objects — the nouns
One job: hold one card per durable noun in the app, so an editor can answer *what is this*
and *what else moves* without reading the model tree.
## Inputs
- Reference (every read): `../CONTEXT.md` — universes and traps
- Reference (every write): `../_meta/schema.md`, `../_templates/object.md`
- Working: the app tree — `app/models/`, `db/schema.rb`, `app/services/`
## Clusters
Clustered by how an editor asks, not by where the files sit.
| Cluster | The question it answers | Cards |
|---|---|---|
| `identity/` | who is this person and what may they do | user, student, teacher |
| `org/` | how are school, time, and roster shaped | school, year, school-year, quarter, classroom, classroom-enrollment, grade-level |
| `gradebook/` | how is earning recorded | grade-book, grade-entry |
| `money/` | where do SIF dollars live | portfolio, portfolio-transaction, earnings-summary |
| `trading/` | what is bought and held | stock, order, portfolio-stock, portfolio-position, portfolio-snapshot |
| `announcement.md` | site-wide notices (singleton, unclustered) | announcement |
Pure join tables with no behaviour of their own — `teacher_classrooms`,
`classroom_grades` — do not get cards. They are described inside the parents they join.
`classroom-enrollment` **does** get a card: it carries primary/unenroll behaviour.
## Process
1. Copy `../_templates/object.md`. Never start from a blank page.
2. Fill Shape from the source, citing `path:line`. Prefer `db/schema.rb` for columns and
the model for behaviour.
3. Fill **If you change this** as Hits / Does not hit, **first-order only**. "Does not
hit" must name the obvious next noun that is the *wrong* one — that line is the whole
value of the card.
4. Set `status: verified` only with a date, a commit, and citations in the body.
5. Run `../_meta/build-index.sh`.
## Outputs
- One card per noun, in its cluster folder
- `_index.md` — regenerated, never hand-edited
## Human check
Pick one card you did not write. Follow its first citation into the app tree. If the line
it lands on does not state the claim, the card is wrong — fix the card, not the citation.

View File

@@ -0,0 +1,29 @@
# Object index
One line per noun. Open the card, not the folder.
_Generated by `_meta/build-index.sh` from card frontmatter. Do not hand-edit._
| Noun | Cluster | Universe | Status | Owning file |
|---|---|---|---|---|
| [Announcement](announcement.md) | content | live | verified | `app/models/announcement.rb` |
| [GradeBook](gradebook/grade-book.md) | gradebook | live | verified | `app/models/grade_book.rb` |
| [GradeEntry](gradebook/grade-entry.md) | gradebook | live | verified | `app/models/grade_entry.rb` |
| [Student](identity/student.md) | identity | live | verified | `app/models/student.rb` |
| [Teacher](identity/teacher.md) | identity | live | verified | `app/models/teacher.rb` |
| [User](identity/user.md) | identity | live | verified | `app/models/user.rb` |
| [EarningsSummary](money/earnings-summary.md) | money | live | verified | `app/models/earnings_summary.rb` |
| [Portfolio](money/portfolio.md) | money | live | verified | `app/models/portfolio.rb` |
| [PortfolioTransaction](money/portfolio-transaction.md) | money | live | verified | `app/models/portfolio_transaction.rb` |
| [ClassroomEnrollment](org/classroom-enrollment.md) | org | live | verified | `app/models/classroom_enrollment.rb` |
| [Classroom](org/classroom.md) | org | live | verified | `app/models/classroom.rb` |
| [Grade level — class `Grade`](org/grade-level.md) | org | live | verified | `app/models/grade.rb` |
| [Quarter](org/quarter.md) | org | live | verified | `app/models/quarter.rb` |
| [School](org/school.md) | org | live | verified | `app/models/school.rb` |
| [SchoolYear](org/school-year.md) | org | live | verified | `app/models/school_year.rb` |
| [Year](org/year.md) | org | live | verified | `app/models/year.rb` |
| [Order](trading/order.md) | trading | live | verified | `app/models/order.rb` |
| [PortfolioPosition](trading/portfolio-position.md) | trading | live | verified | `app/models/portfolio_position.rb` |
| [PortfolioSnapshot](trading/portfolio-snapshot.md) | trading | live | verified | `app/models/portfolio_snapshot.rb` |
| [PortfolioStock](trading/portfolio-stock.md) | trading | live | verified | `app/models/portfolio_stock.rb` |
| [Stock](trading/stock.md) | trading | live | verified | `app/models/stock.rb` |

View File

@@ -0,0 +1,74 @@
---
type: object
cluster: content
universe: live
status: verified
entity: app/models/announcement.rb
---
# Announcement
A site-wide notice written by an admin, with rich text. One may be "featured" at a time.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
**Content is Action Text, not a column.** `has_rich_text :content`
(`app/models/announcement.rb:4`) stores the body in `action_text_rich_texts`
(`db/schema.rb:17-25`) as a polymorphic association. So `content` is a record, not a
string: it is not selectable, not sortable, and not searchable with a plain `WHERE` on
this table.
**The `body` column is a ghost.** `announcements.body` exists (`db/schema.rb:56`) but is
never read, written, validated, or permitted — `announcement_params` allows only
`title`, `content`, `featured` (`app/controllers/admin/announcements_controller.rb:79-81`).
It is the pre-Action-Text column, left behind. Do not write to it expecting it to appear.
**"Only one featured" is a callback, not a constraint.** `before_save
:unfeature_other_announcements` demotes the current holder when a new one is featured
(`:9,27-32`), and `Announcement.current` simply does `find_by(featured: true)`
(`:13-15`). There is no unique index — concurrent writes can leave two featured rows, and
`current` will then return an arbitrary one. The demotion also runs `update` (not
`update!`) on the old record (`:31`), so a failure there is silent.
## Shape
- Table `announcements`, `db/schema.rb:55-62` — `title`, `featured`, `body` (ghost),
timestamps; index on `created_at DESC` (`db/schema.rb:61`)
- `validates :title, presence: true, length: { maximum: 255 }` (`:6`)
- `validates :content, presence: true` (`:7`) — validating the Action Text association
- `scope :latest` — newest first (`:11`)
- `self.current` — the featured one, or `nil` (`:13-15`)
- `excerpt(limit: 150)` — plain-text truncation (`:17-19`)
- `published_at` is an **alias for `created_at`** (`:21-23`); there is no publish workflow
and no draft state
## Connected to
- **owns:** its Action Text record
- **owned-by:** —
- **joins:** —
- **looks-like-but-is-not:** `published_at` is not a publication timestamp — an
announcement is live from the moment it is created. And `content` is not a column.
## If you change this
- **Hits:** `Admin::AnnouncementsController` (full CRUD) and
`AnnouncementsController#show`; the home page and any layout partial calling
`Announcement.current`; Action Text and Active Storage if you touch `content`, since
embedded attachments live there.
- **Does not hit:** anything financial. Announcements touch no portfolio, order, or
gradebook — this is the one object in the map with no path to money.
## Surfaces
| Surface | Role |
|---|---|
| `Admin::AnnouncementsController` | admin CRUD |
| `AnnouncementsController#show` | everyone reads |
| `HomeController#index` | reads the featured one |
## See
- Source: `app/models/announcement.rb`, `db/schema.rb:55-62`

View File

@@ -0,0 +1,76 @@
---
type: object
cluster: gradebook
universe: live
status: verified
entity: app/models/grade_book.rb
---
# GradeBook
One [classroom](../org/classroom.md)'s grades for one [quarter](../org/quarter.md), and
the object whose status decides whether students get paid.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
The model is tiny — two belongs-to, one has-many, one enum (`app/models/grade_book.rb`) —
but the enum is the payout gate.
`status` has three values: `draft → verified → completed` (`:8-12`). Read literally that
looks like a review workflow. **It is not.** `GradeBooksController#finalize` sets
`verified!` and calls `DistributeEarnings` on the very next line
(`app/controllers/grade_books_controller.rb:30-31`), so `verified` exists for a few
milliseconds. Its real job is to satisfy the service's own guard,
`return unless @grade_book.verified?` (`app/services/distribute_earnings.rb:14`), which
keeps the service safe to call from anywhere else.
**Double-payment is prevented by exactly one check** — the controller's
`if @grade_book.completed?` (`app/controllers/grade_books_controller.rb:26`). There is no
database constraint, no idempotency key on the resulting deposits, and
`DistributeEarnings` itself would happily pay twice if handed a `verified` book. Anything
new that finalizes a gradebook must repeat that check.
Gradebooks are never created by a controller: [classroom](../org/classroom.md) creates one
per quarter on `after_create` (`app/models/classroom.rb:29,112-116`).
## Shape
- Table `grade_books`, `db/schema.rb:98-107`; unique on `[quarter_id, classroom_id]`
(`db/schema.rb:105`) — one book per classroom per quarter
- `status` is a **string** column, default `"draft"`, `null: false` (`db/schema.rb:102`)
- `belongs_to :quarter`, `belongs_to :classroom` (`:4-5`)
- `has_many :grade_entries, dependent: :destroy` (`:6`)
## Connected to
- **owns:** [grade-entry](grade-entry.md)
- **owned-by:** [classroom](../org/classroom.md), [quarter](../org/quarter.md)
- **joins:** —
- **looks-like-but-is-not:** `verified` is not a human review state; see Why.
And a `GradeBook` is not a [grade-level](../org/grade-level.md).
## If you change this
- **Hits:** [portfolio-transaction](../money/portfolio-transaction.md) — finalizing mints
deposits; the [finalize-gradebook-earnings](../../processes/finalize-gradebook-earnings.md)
movement; [grade-entry](grade-entry.md) via `dependent: :destroy`;
`GradeBookPolicy`; the autosave Stimulus controller, which PATCHes entries into the
`update` action.
- **Does not hit:** [order](../trading/order.md) or any holding. Earnings arrive as cash
deposits only — finalizing never buys, sells, or touches
[portfolio-stock](../trading/portfolio-stock.md).
## Surfaces
| Surface | Role |
|---|---|
| `GradeBooksController` (`show`, `update`, `finalize`) | teacher reads/writes |
| `Classroom#create_gradebooks_for_quarters` | writes (creation) |
| `DistributeEarnings` | reads status, writes `completed!` |
## See
- Source: `app/models/grade_book.rb`, `db/schema.rb:98-107`
- As-built: `docs/gradebook-earnings.md`

View File

@@ -0,0 +1,90 @@
---
type: object
cluster: gradebook
universe: live
status: verified
entity: app/models/grade_entry.rb
---
# GradeEntry
One student's row in one [grade-book](grade-book.md): two letter grades, attendance days,
a perfect-attendance flag. **This is where every payout amount is defined.**
Verified 2026-08-16 against commit `63732df`.
## Why this shape
The payout table is five Ruby constants on this model, all in **cents**
(`app/models/grade_entry.rb:9-13`):
| Constant | Value | Meaning |
|---|---|---|
| `EARNINGS_PER_DAY_ATTENDANCE` | `20` | $0.20 per day present |
| `EARNINGS_FOR_A_GRADE` | `3_00` | $3.00 for any A |
| `EARNINGS_FOR_B_GRADE` | `2_00` | $2.00 for any B |
| `EARNINGS_FOR_IMPROVED_GRADE` | `2_00` | $2.00 for improving |
| `EARNINGS_FOR_PERFECT_ATTENDANCE` | `1_00` | $1.00 bonus |
They are not configuration. Changing what a student earns is a code change and a deploy —
there is no admin screen and no database row for these.
**`GRADE_OPTIONS` is ordered best-to-worst on purpose** (`:15`). `improved_grade?`
compares array *indices*, treating a lower index as better (`:64-67`). Reordering or
inserting into that array silently changes every improvement bonus in the app.
**There are no validations on this model at all** — grades are constrained only by the
`<select>` in `app/views/grade_books/_grade_entry.html.erb:8,17`, and the controller
permits the values straight through (`app/controllers/grade_books_controller.rb:53-57`).
A value outside `GRADE_OPTIONS` saves fine, then makes `improved_grade?` compare `nil`
indices and raise `NoMethodError` during the next quarter's payout. Grades C through F
earn nothing but are legal; anything not in the list is a latent failure.
## Shape
- Table `grade_entries`, `db/schema.rb:109-121`; unique on `[grade_book_id, user_id]`
(`db/schema.rb:118`) — one row per student per book
- `math_grade`, `reading_grade` — plain strings, nullable, unvalidated
- `attendance_days` — bigint, nullable; `earnings_for_attendance` returns 0 when blank
(`:17-21`)
- `is_perfect_attendance` — boolean, default false, `null: false` (`db/schema.rb:113`)
- `belongs_to :grade_book`, `belongs_to :user` (`:4-5`) — `user`, not `student`
- Earnings readers: `earnings_for_attendance`, `earnings_for_math`,
`earnings_for_reading`, `attendance_perfect_earnings`, `math_improvement_earnings`,
`reading_improvement_earnings` (`:17-49`)
Every earnings method is a **pure reader**. Nothing here writes money —
`DistributeEarnings` calls them and creates the deposits.
## Connected to
- **owns:** —
- **owned-by:** [grade-book](grade-book.md), [user](../identity/user.md)
- **joins:** —
- **looks-like-but-is-not:** `math_grade` is a **letter** (`"A+"`…`"F"`), unrelated to
[grade-level](../org/grade-level.md), which is 5–8.
## If you change this
- **Hits:** [portfolio-transaction](../money/portfolio-transaction.md) amounts — these
constants are the amounts; `DistributeEarnings`
(`app/services/distribute_earnings.rb:54-73`), which sums attendance + math + reading;
the [finalize-gradebook-earnings](../../processes/finalize-gradebook-earnings.md)
movement; `AttendanceEntryPresenter`.
- **Does not hit:** already-paid deposits. Editing an entry after finalize changes
nothing retroactively — the deposits are independent rows with no link back to the
entry that produced them (`db/schema.rb:170-179` has no `grade_entry_id`). Re-paying
would require re-running finalize, which the `completed?` guard blocks.
## Surfaces
| Surface | Role |
|---|---|
| `GradeBooksController#update` (+ autosave Stimulus controller) | teacher writes |
| `DistributeEarnings` | reads |
| `AttendanceEntryPresenter` | reads |
## See
- Source: `app/models/grade_entry.rb`, `db/schema.rb:109-121`
- As-built: `docs/gradebook-earnings.md`

View File

@@ -0,0 +1,72 @@
---
type: object
cluster: identity
universe: live
status: verified
entity: app/models/student.rb
---
# Student
A `User` with `type: "Student"` — the only user kind that owns a portfolio and can trade.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
Every money path downstream assumes a portfolio exists, so `Student` guarantees one on
create rather than letting callers remember (`app/models/student.rb:9,78-80`). Nothing in
the trading code null-checks for a missing portfolio because of this hook.
The class also carries the **roster bridge**. A student's classroom is reachable two ways
and `Student` is where they meet: `primary_classroom` prefers the enrollment record and
falls back to the legacy `classroom_id` column (`:37-43`). On create it writes both — but
`create_initial_enrollment` fires **only if `classroom_id` is present** (`:10,82-86`), so
a student created without it has no enrollment either. Read the roster trap in
`../../CONTEXT.md` before touching this.
## Shape
- STI subclass of [user](user.md); no table of its own
- `has_many :classroom_enrollments`, `has_many :classrooms, through:` (`:4-5`)
- Callbacks: `set_default_email` forces blank → `nil` (`:8,74-76`);
`ensure_portfolio` (`:9,78-80`); `create_initial_enrollment` (`:10,82-86`)
- Reads: `current_enrollments` (`:17-19`), `current_classrooms` (`:24-28`),
`primary_enrollment` (`:33-35`), `primary_classroom` (`:41-43`)
- Writes: `enroll_in!` (`:51-59`), `unenroll_from!` (`:66-70`)
`enroll_in!` always creates the row with `primary: false` and then promotes it via
`make_primary!` (`:52-57`) — the promotion is what enforces one-primary, not the insert.
## Connected to
- **owns:** [portfolio](../money/portfolio.md) (guaranteed on create),
[order](../trading/order.md)
- **owned-by:** [classroom](../org/classroom.md) — twice over, see Why
- **joins:** [classroom-enrollment](../org/classroom-enrollment.md)
- **looks-like-but-is-not:** `student.classrooms` (through enrollments) is **not**
`student.classroom` (the `classroom_id` column). They can disagree.
## If you change this
- **Hits:** [classroom-enrollment](../org/classroom-enrollment.md) and
[classroom](../org/classroom.md) — both rosters; [portfolio](../money/portfolio.md) if
you touch `ensure_portfolio`; `Admin::StudentsController` and `StudentsController`;
the [import-students](../../processes/import-students.md) movement, which creates
students by this exact path.
- **Does not hit:** [teacher](teacher.md). Same table, but no shared callbacks — `Teacher`
runs `sync_username_from_email` instead and shares none of the hooks above.
## Surfaces
| Surface | Role |
|---|---|
| `StudentsController` (nested under classroom) | teacher creates/edits, resets passwords |
| `Admin::StudentsController` | admin CRUD, CSV import, restore, manual transactions |
| `ImportStudentService` | writes |
| student's own portfolio + orders pages | reads |
## See
- Source: `app/models/student.rb`
- Base class: [user](user.md)

View File

@@ -0,0 +1,67 @@
---
type: object
cluster: identity
universe: live
status: verified
entity: app/models/teacher.rb
---
# Teacher
A `User` with `type: "Teacher"` — runs classrooms and gradebooks. Owns no portfolio and
cannot trade.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
Login is by `username` app-wide, but teachers think in email addresses. Rather than
splitting the auth key, `Teacher` **copies email into username** on every validation
(`app/models/teacher.rb:9,17-19`). So a teacher's username is their email, kept in sync
automatically — change the email and the login changes with it. This is the exact inverse
of [student](student.md), whose username is assigned and whose email is usually `nil`.
`attr_accessor :school_id` (`:4`) is a form-only field. It is **not a column and not
persisted** — a teacher reaches a school only through classrooms.
## Shape
- STI subclass of [user](user.md); no table of its own
- `has_many :teacher_classrooms`, `has_many :classrooms, through:` (`:6-7`)
- `before_validation :sync_username_from_email` (`:9`)
- `display_name` prefers `name`, then the email local-part (`:11-13`)
- Join table `teacher_classrooms` — unique on `[teacher_id, classroom_id]`
(`db/schema.rb:368`). No behaviour of its own, so it has no card.
## Connected to
- **owns:** [classroom](../org/classroom.md) (through `teacher_classrooms`)
- **owned-by:** —
- **joins:** `teacher_classrooms`
- **looks-like-but-is-not:** a teacher is not an admin. Admin is a boolean on `users`;
`teacher_or_admin?` (`app/models/user.rb:53-55`) exists precisely because the two are
independent and often both true.
## If you change this
- **Hits:** sign-in for every teacher if you touch `sync_username_from_email` — it
rewrites `username`, the auth key; `Order.for_teacher`
(`app/models/order.rb:40-42`), which scopes orders through
`users.classroom_id`, **not** through `teacher_classrooms`;
`Admin::Teachers::DeactivationsController` / `ReactivationsController`.
- **Does not hit:** [portfolio](../money/portfolio.md). `Portfolio` validates that its
user is a student (`app/models/portfolio.rb:102-104`), so no teacher change can create
or affect one.
## Surfaces
| Surface | Role |
|---|---|
| `Admin::TeachersController` | admin CRUD |
| `Admin::Teachers::DeactivationsController` / `ReactivationsController` | discard / restore |
| `ClassroomsController`, `GradeBooksController` | authorizes as teacher |
## See
- Source: `app/models/teacher.rb`
- Base class: [user](user.md)

View File

@@ -0,0 +1,78 @@
---
type: object
cluster: identity
universe: live
status: verified
entity: app/models/user.rb
---
# User
Every human in the app. STI base class for `Student` and `Teacher` — but **admin is a
boolean column on this table, not a subclass**.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
The users are middle-school students, so **email cannot be the login**. Devise is
reconfigured to authenticate on `username` (`config/initializers/devise.rb:49`), email is
optional, and its uniqueness index is partial — it applies only where email is non-null
and non-empty (`db/schema.rb:388`), so any number of students can have no email at all.
`Student` actively forces blank email back to `nil` to stay inside that index
(`app/models/student.rb:74-76`).
Hard deletes are blocked because a user owns a financial ledger. `destroy` and `destroy!`
are overridden to `discard`, and outside production they *raise* rather than silently
soft-delete (`app/models/user.rb:6-14,75-82`). `really_destroy!` is the deliberate escape
hatch (`:16-18`).
## Shape
- Table `users`, `db/schema.rb:372-391`
- `type` — `"User" | "Student" | "Teacher"`, validated at `app/models/user.rb:39`
- `admin` — boolean, default false (`db/schema.rb:373`); scope at `:43`
- `username` — `null: false`, unique index, the login key (`db/schema.rb:385,390`)
- `email` — nullable, partial unique index (`db/schema.rb:388`); required only for
teachers and admins (`app/models/user.rb:61-63`)
- `discarded_at` — soft delete via `Discard::Model` (`app/models/user.rb:4`)
- `classroom_id` — direct membership. See the roster trap in `../../CONTEXT.md`
`email_changed?` is hard-coded to `false` (`app/models/user.rb:65-67`), which suppresses
Devise's reconfirmation path. The code wins over the method name — it is not a real
dirty-check.
## Connected to
- **owns:** [portfolio](../money/portfolio.md) (`has_one`, students only),
[order](../trading/order.md) (`has_many`)
- **owned-by:** [classroom](../org/classroom.md) (`belongs_to`, optional)
- **joins:** [classroom-enrollment](../org/classroom-enrollment.md) as `Student`,
`teacher_classrooms` as `Teacher`
- **looks-like-but-is-not:** `admin` is not an STI type — there is no `Admin` class.
A `Teacher` with `admin: true` is one row, not two.
## If you change this
- **Hits:** [student](student.md) and [teacher](teacher.md) (same table);
[portfolio](../money/portfolio.md) — `Portfolio` validates its user is a student
(`app/models/portfolio.rb:102-104`); every Pundit policy, which branches on
`user.admin?` / `user.student?` (`app/policies/application_policy.rb:39-53`);
Devise sign-in if you touch `username` or `email` nullability.
- **Does not hit:** [portfolio-transaction](../money/portfolio-transaction.md). It hangs
off `Portfolio`, not `User` — discarding a user leaves the ledger fully intact and
still summable. That is deliberate, not an oversight.
## Surfaces
| Surface | Role |
|---|---|
| Devise controllers | reads (sign-in by username) |
| `Admin::UsersController`, `Admin::StudentsController`, `Admin::TeachersController` | read/write |
| `StudentsController` (nested under classrooms, teacher-facing) | read/write |
| `ApplicationController#authenticate_user!` | reads every request |
## See
- Source: `app/models/user.rb`, `db/schema.rb:372-391`
- Login config: `config/initializers/devise.rb:49`

View File

@@ -0,0 +1,76 @@
---
type: object
cluster: money
universe: live
status: verified
entity: app/models/earnings_summary.rb
---
# EarningsSummary
A plain Ruby object (**not** an Active Record model) that totals a
[portfolio](portfolio.md)'s earnings by reason for the "where did my money come from"
panel.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
It lives in `app/models/` but has no table and no superclass
(`app/models/earnings_summary.rb:3`). It wraps a portfolio and runs one grouped sum per
reason (`:36-41`) — five queries per render, deliberately simple rather than a single
grouped query, because it is only ever built for one student at a time.
**Known defect — `transaction_fees_cents` always returns 0.** `sum_by_reason` filters
`.deposits`, i.e. `transaction_type: :deposit` (`:38`), but fee rows are written with
`transaction_type: :fee` by `TransactionFeeProcessor`
(`app/services/transaction_fee_processor.rb:26-29`). The two never intersect, so
`transaction_fees_cents` (`:30-32`) sums an empty set. It is rendered to students as
"Transaction Fees" at `app/views/portfolios/_earnings_summary_card.html.erb:22`, where it
always shows $0.00. The fix is to drop `.deposits` for that one reason — but note that
`total_earnings_cents` (`:26-28`) deliberately excludes fees, so changing `sum_by_reason`
wholesale would alter the total too.
## Shape
- PORO; `initialize(portfolio)` (`:6-8`)
- Readers, all in **cents**: `attendance_earnings_cents`, `reading_earnings_cents`,
`math_earnings_cents`, `awards_cents`, `total_earnings_cents`,
`transaction_fees_cents` (`:10-32`)
- `total_earnings_cents` = attendance + reading + math + awards (`:26-28`). Fees are
**not** subtracted.
- No caching, no memoization — each reader hits the database
It covers four of the seven `reason` values. `administrative_adjustments` and
`transaction_fees` are not part of the total; `grade_earnings` is leftover.
## Connected to
- **owns:** —
- **owned-by:** [portfolio](portfolio.md) (by construction, not by association)
- **joins:** reads [portfolio-transaction](portfolio-transaction.md)
- **looks-like-but-is-not:** not an Active Record model — `EarningsSummary.find` and any
scope or callback do not exist. It also is **not** the balance: it counts income only
and ignores debits, credits, and withdrawals entirely.
## If you change this
- **Hits:** `PortfoliosController#show` (`app/controllers/portfolios_controller.rb:10`)
and `Admin::StudentsController#show`
(`app/controllers/admin/students_controller.rb:21`); the two views that render it —
`app/views/portfolios/_earnings_summary_card.html.erb` and
`app/views/admin/students/show.html.erb:85-100`.
- **Does not hit:** [portfolio](portfolio.md)`#cash_balance`. This class is read-only and
entirely parallel to the balance calculation — correcting the fee bug here changes a
displayed figure, not anyone's spendable money.
## Surfaces
| Surface | Role |
|---|---|
| `PortfoliosController#show` | student/teacher read |
| `Admin::StudentsController#show` | admin read |
## See
- Source: `app/models/earnings_summary.rb`

View File

@@ -0,0 +1,90 @@
---
type: object
cluster: money
universe: live
status: verified
entity: app/models/portfolio_transaction.rb
---
# PortfolioTransaction
One line in the ledger. **The only place SIF dollars actually exist** — every balance in
the app is a sum over these rows.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
**`amount_cents` is always positive; direction lives in `transaction_type`.** There is no
signed amount. `Portfolio#cash_on_hand_in_cents` adds `credits + deposits` and subtracts
`debits + withdrawals + fees` (`app/models/portfolio.rb:71-75`). A row written with a
negative `amount_cents` would pass validation — the column is only `null: false`
(`db/schema.rb:171`) — and quietly invert its own meaning. Nothing guards this.
**The five types split into two vocabularies**, as the comment at
`app/models/portfolio_transaction.rb:5-6` says:
| Type | Meaning | Written by |
|---|---|---|
| `deposit` | cash in from grades/attendance | `DistributeEarnings`, admin |
| `withdrawal` | cash out | admin |
| `credit` | proceeds of a **sell** | `ExecuteOrder` |
| `debit` | cost of a **buy** | `ExecuteOrder` |
| `fee` | the $1.00 trading fee | `TransactionFeeProcessor` |
So `deposit`/`withdrawal` are cash movements and `credit`/`debit` are stock movements —
not accounting-standard usage, and easy to get backwards.
`TRANSACTION_FEE_CENTS = 1_00` (`:4`) is defined here but consumed mostly by
[order](../trading/order.md) and `TransactionFeeProcessor`. It is charged **once per user
per execution batch**, not once per order (`app/services/transaction_fee_processor.rb:24,30`),
and [portfolio](portfolio.md) anticipates exactly one pending fee to match
(`app/models/portfolio.rb:98-100`).
## Shape
- Table `portfolio_transactions`, `db/schema.rb:170-179`
- `amount_cents` integer, `null: false`; `transaction_type` integer, `null: false`;
`reason` integer, nullable; `description` text
- `enum :transaction_type` — deposit/withdrawal/credit/debit/fee (`:7`)
- `enum :reason, allow_nil: true` — math/reading/attendance earnings, transaction fees,
awards, administrative adjustments (`:9-17`)
- `belongs_to :portfolio`; `has_one :order, dependent: :destroy` (`:19-20`)
- Scopes mirror the types (`:22-26`)
`reason: grade_earnings` (value 3) is **leftover** — marked deprecated at `:13` and
referenced nowhere else.
## Connected to
- **owns:** [order](../trading/order.md) — via `has_one ... dependent: :destroy`
- **owned-by:** [portfolio](portfolio.md)
- **joins:** —
- **looks-like-but-is-not:** a `fee` row is **not** a `deposit`, which is why
[earnings-summary](earnings-summary.md)`#transaction_fees_cents` never finds one.
## If you change this
- **Hits:** every balance and total in [portfolio](portfolio.md) — they are pure sums
over these rows; [earnings-summary](earnings-summary.md);
`Classroom.order_by_total_earnings`, which joins straight to this table
(`app/models/classroom.rb:41-49`); `Admin::PortfolioTransactionsController` and the
admin `add_transaction` action.
- **Does not hit:** [portfolio-stock](../trading/portfolio-stock.md). Cash and shares are
written by `ExecuteOrder` in the same database transaction
(`app/services/execute_order.rb:28-32`) but are otherwise independent — deleting a
ledger row does not remove the shares it paid for, it just makes the cash wrong.
## Surfaces
| Surface | Role |
|---|---|
| `ExecuteOrder`, `TransactionFeeProcessor`, `DistributeEarnings` | write |
| `Admin::PortfolioTransactionsController` | admin CRUD |
| `Admin::StudentsController#add_transaction` | admin writes manual adjustments |
| `Portfolio`, `EarningsSummary` | read |
## See
- Source: `app/models/portfolio_transaction.rb`, `db/schema.rb:170-179`
- As-built: `docs/orders-and-transactions.md`

View File

@@ -0,0 +1,91 @@
---
type: object
cluster: money
universe: live
status: verified
entity: app/models/portfolio.rb
---
# Portfolio
A student's account: cash plus holdings. One per
[student](../identity/student.md), created automatically, and **it stores no money at
all**.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
**The table has three columns: `id`, `user_id`, timestamps** (`db/schema.rb:181-186`).
There is no balance, no cash column, nothing cached. Every figure is computed on read
from [portfolio-transaction](portfolio-transaction.md) rows
(`app/models/portfolio.rb:71-100`). The ledger is the truth; the portfolio is a lens over
it. That is why a corrupt or negative transaction row cannot be "fixed" by adjusting a
balance — you post a compensating row.
**Balance includes money you have not spent yet.** `cash_on_hand_in_cents` subtracts
*pending buy orders* and a *pending transaction fee* alongside settled debits
(`:71-75,93-100`). Orders sit pending for up to 15 minutes before
[place-and-execute-order](../../processes/place-and-execute-order.md) runs, so this is
what stops a student spending the same dollar twice in that window. It also means the
balance can move without any transaction being written.
**The unit trap lives here.** `cash_balance` returns **dollars as a float**
(`:16-18` → `:67-69`, which divides by 100.0) while everything around it is integer
cents. Callers must convert back — `app/models/order.rb:137` does
`(user.portfolio&.cash_balance || 0) * 100`. Any new caller that forgets is wrong by 100×.
## Shape
- Table `portfolios`, `db/schema.rb:181-186` — no money columns
- `belongs_to :user`; validated to be a student (`:6-7,102-104`)
- `has_many :portfolio_transactions`, `:portfolio_stocks`, `:portfolio_snapshots`, all
`dependent: :destroy` (`:11-14`)
- **Dollars (float):** `cash_balance` (`:16`), `calculate_total_value` (`:36`),
`total_portfolio_worth` (`:40`), `holdings_value` (`:44`)
- **Cents (integer):** `cash_on_hand_in_cents` (`:71`), `holdings_value_cents` (`:48`),
`calculate_total_value_cents` (`:32`)
- `holdings_value_cents` sums in SQL: `portfolio_stocks.shares * stocks.price_cents`
(`:48-52`) — live prices, not purchase prices
- `shares_owned(stock_id)` sums the lot rows (`:24-26`)
- `positions` delegates to [portfolio-position](../trading/portfolio-position.md) (`:28-30`)
- `chart_data` returns the **last 12** snapshots (`:54-63`)
`total_portfolio_worth`, `calculate_total_value`, and `calculate_total_value_cents / 100`
are three names for one number (`:32-42`).
## Connected to
- **owns:** [portfolio-transaction](portfolio-transaction.md),
[portfolio-stock](../trading/portfolio-stock.md),
[portfolio-snapshot](../trading/portfolio-snapshot.md)
- **owned-by:** [student](../identity/student.md)
- **joins:** [stock](../trading/stock.md), through `portfolio_stocks`
- **looks-like-but-is-not:** `cash_balance` is **not** cents, unlike every column it is
derived from. And `Portfolio` is not the owner of [order](../trading/order.md) —
orders belong to the `User` (`app/models/order.rb:6`); `Order#portfolio` is a
delegation (`:30`).
## If you change this
- **Hits:** [order](../trading/order.md) validation — `sufficient_funds_for_buy` reads
`cash_balance` (`app/models/order.rb:134-148`); `ExecuteOrder`, which cancels on a
negative balance (`app/services/execute_order.rb:64-66`);
[portfolio-snapshot](../trading/portfolio-snapshot.md), whose worth comes from
`calculate_total_value_cents`; the portfolio chart and every balance shown in a view.
- **Does not hit:** [grade-entry](../gradebook/grade-entry.md) or earnings amounts.
Money flows one way — the gradebook writes deposits into the ledger and never reads a
balance back.
## Surfaces
| Surface | Role |
|---|---|
| `PortfoliosController#show` | student and teacher read |
| `Admin::StudentsController#show` | admin reads |
| `Student#ensure_portfolio` | writes (creation) |
| `MonthlyPortfolioSnapshotJob` | reads |
## See
- Source: `app/models/portfolio.rb`, `db/schema.rb:181-186`

View File

@@ -0,0 +1,81 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/classroom_enrollment.rb
---
# ClassroomEnrollment
A dated membership of one [student](../identity/student.md) in one
[classroom](classroom.md), with history. **The newer of the app's two roster paths** —
read `../../CONTEXT.md` before changing either.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
It exists because `users.classroom_id` can only say where a student is *now*. A student
who moves classrooms mid-year, or returns to one next year, needs rows — so membership
became a record with `enrolled_at` / `unenrolled_at`, and "current" is simply
`unenrolled_at IS NULL` (`app/models/classroom_enrollment.rb:26-27`). Nothing is deleted
on unenrollment; the row is closed (`:50-53`).
**The `primary` flag is enforced in Ruby only.** `only_one_primary_per_student` does an
`exists?` check before save (`:24,78-86`) and `make_primary!` demotes siblings inside a
transaction (`:36-44`) — but the supporting index is *not* unique. It is a partial index
on `[student_id, primary] WHERE primary = true` (`db/schema.rb:74`), which speeds the
lookup without constraining it. Two concurrent writes can therefore produce two primary
enrollments, and `primary_enrollment` will just take `.first`
(`app/models/student.rb:34`).
`unenroll!` clears `primary` as well as setting the date (`:51`), so unenrolling a
student's primary classroom leaves them with **no** primary at all — `primary_classroom`
then falls back to the legacy `classroom_id` column
(`app/models/student.rb:41-43`), which `unenroll!` never touched.
## Shape
- Table `classroom_enrollments`, `db/schema.rb:64-76`
- `enrolled_at` `null: false`; `unenrolled_at` nullable = still enrolled
- `primary` boolean, default false, `null: false` (`db/schema.rb:68`)
- Scopes: `current`, `historical`, `primary_enrollment`, `for_student`, `for_classroom`
(`:26-30`)
- Writes: `make_primary!` (`:36-44`), `unenroll!` (`:50-53`)
- Validation: `unenrolled_at` must be ≥ `enrolled_at` (`:23,71-76`)
- No uniqueness constraint on `[student_id, classroom_id]` — repeat enrollments in the
same classroom are intentional (`:3-8`)
## Connected to
- **owns:** —
- **owned-by:** [student](../identity/student.md), [classroom](classroom.md)
- **joins:** student ↔ classroom, over time
- **looks-like-but-is-not:** this is **not** `users.classroom_id`. Both are live. A
student can be enrolled here and absent from `Classroom#students`, or the reverse.
## If you change this
- **Hits:** [student](../identity/student.md) — `current_classrooms`,
`primary_enrollment`, `primary_classroom`, `enroll_in!`, `unenroll_from!`;
[classroom](classroom.md)`#current_students` / `#historical_students`;
`ClassroomEnrollmentsController`; `ClassroomFacade`, which builds the teacher's roster
view.
- **Does not hit:** [grade-entry](../gradebook/grade-entry.md). Entries are keyed to
`grade_book_id` + `user_id` (`db/schema.rb:118`) and carry no enrollment reference —
unenrolling a student does **not** remove or hide their gradebook rows, and
`DistributeEarnings` will still pay them.
## Surfaces
| Surface | Role |
|---|---|
| `ClassroomEnrollmentsController` | create, destroy, unenroll |
| `ClassroomFacade` | reads the roster |
| `Student#enroll_in!` / `#unenroll_from!` | writes |
## See
- Source: `app/models/classroom_enrollment.rb`, `db/schema.rb:64-76`
- The trap: `../../CONTEXT.md`

View File

@@ -0,0 +1,83 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/classroom.rb
---
# Classroom
One teacher's class within a [school-year](school-year.md). The unit teachers actually
work in, and **the switch that turns trading on**.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
Two things make this more than a grouping.
**1. `trading_enabled` defaults to `false`** (`db/schema.rb:93`). Order creation validates
it (`app/models/order.rb:26,203-207`), reaching the classroom by delegation through the
user (`app/models/user.rb:26`). A brand-new classroom therefore **cannot trade** until a
teacher flips it via `PATCH /classrooms/:id/toggle_trading` (`config/routes.rb:22`). If
trading "silently doesn't work," check this column first.
**2. Creating a classroom creates its gradebooks** — one per quarter of its school-year,
via `after_create` (`app/models/classroom.rb:29,112-116`). It uses `find_or_create_by!`,
so it is idempotent, but it only runs on create: adding a quarter later does **not**
backfill gradebooks for existing classrooms.
The class also holds **both rosters** (see the trap in `../../CONTEXT.md`):
`students` reads the legacy `users.classroom_id` column (`:20`) while `current_students`
reads [classroom-enrollment](classroom-enrollment.md) (`:74-78`). They can disagree.
## Shape
- Table `classrooms`, `db/schema.rb:88-96` — `name`, `archived`, `trading_enabled`,
`school_year_id`
- `GRADE_RANGE` — a frozen **Array** of levels 5–8, built from `MIN_GRADE`/`MAX_GRADE`
(`:4-6`); middle school only
- Rosters: `has_many :students, -> { kept }` on `classroom_id` (`:20`);
`has_many :enrolled_students, through: :classroom_enrollments` (`:19`)
- `has_many :users, dependent: :nullify` (`:15`) — deleting a classroom orphans users
rather than deleting them
- `has_many :grade_books, dependent: :destroy` (`:23`)
- `has_many :grades, through: :classroom_grades` (`:22`); must have at least one (`:27,118-120`)
- Sorting: `apply_sorting` + three scopes, including `order_by_total_earnings`, which
joins all the way to `portfolio_transactions` (`:41-49`)
- `grades_display` collapses `[5,6,7]` to `"5th-7th"` (`:89-104`)
## Connected to
- **owns:** [grade-book](../gradebook/grade-book.md),
[classroom-enrollment](classroom-enrollment.md)
- **owned-by:** [school-year](school-year.md)
- **joins:** [teacher](../identity/teacher.md) via `teacher_classrooms`,
[grade-level](grade-level.md) via `classroom_grades`
- **looks-like-but-is-not:** `classroom.students` ≠ `classroom.current_students`.
The first is the `classroom_id` column, the second is active enrollments.
## If you change this
- **Hits:** [order](../trading/order.md) — creation is gated on `trading_enabled`;
[grade-book](../gradebook/grade-book.md) via the create cascade and `dependent: :destroy`;
[student](../identity/student.md) rosters, both of them; `Order.for_teacher`
(`app/models/order.rb:40-42`) and `GradeBooksController`, which redirects
non-admins away from archived classrooms (`app/controllers/grade_books_controller.rb:47-50`).
- **Does not hit:** [portfolio](../money/portfolio.md). Portfolios belong to users and
survive `dependent: :nullify` intact — archiving or deleting a classroom never touches
a balance or a holding.
## Surfaces
| Surface | Role |
|---|---|
| `ClassroomsController` | teacher CRUD, `toggle_trading` |
| `Admin::ClassroomsController` | admin CRUD, `toggle_archive` |
| `ClassroomFacade`, `ClassroomPresenter` | read |
| `GradeBooksController` | reads (authorization + archive gate) |
## See
- Source: `app/models/classroom.rb`, `db/schema.rb:88-96`

View File

@@ -0,0 +1,73 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/grade.rb
---
# Grade level — class `Grade`
A school grade level: 5th through 8th. **Not a letter grade.** The class is called
`Grade`; this card is named `grade-level` to keep the two apart.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
A classroom can span several grade levels, so the link is many-to-many through
`classroom_grades` rather than a column on `classrooms`. A classroom must carry at least
one (`app/models/classroom.rb:27,118-120`), and `Classroom#grades_display` collapses a
contiguous set into `"5th-7th"` for display (`app/models/classroom.rb:89-104`).
`Grade` rows are reference data seeded once, not created by users — hence
`dependent: :restrict_with_error` (`app/models/grade.rb:4`): a level in use cannot be
deleted.
**The name collision is the point of this card.** `Grade#level` is `5..8`;
[grade-entry](../gradebook/grade-entry.md)`#math_grade` is `"A+"`…`"F"`. They share the
word "grade" and nothing else — no association, no foreign key, no shared table.
## Shape
- Table `grades`, `db/schema.rb:123-130` — `level` (integer) and `name` (string), both
`null: false` and both uniquely indexed
- `validates :name` uniqueness is `case_sensitive: false`; `:level` uniqueness is plain
(`app/models/grade.rb:7-8`)
- Join table `classroom_grades`, `db/schema.rb:78-86`, unique on
`[classroom_id, grade_id]` (`db/schema.rb:83`). It has a model (`app/models/classroom_grade.rb`)
but no behaviour, so no card.
- `Classroom::GRADE_RANGE` (`app/models/classroom.rb:4-6`) is a **separate** frozen array
of 5–8. The classroom form filters these rows through it —
`Grade.where(level: Classroom::GRADE_RANGE)`
(`app/views/classrooms/_form.html.erb:58`) — so a `Grade` row outside 5–8 exists but is
unselectable. The constant is not derived from the rows and can drift from them.
## Connected to
- **owns:** —
- **owned-by:** —
- **joins:** [classroom](classroom.md), through `classroom_grades`
- **looks-like-but-is-not:** not a letter grade
([grade-entry](../gradebook/grade-entry.md)), and not
[grade-book](../gradebook/grade-book.md).
## If you change this
- **Hits:** [classroom](classroom.md) — validation, `grades_display`, and the classroom
forms; seeds (`db/seeds`), which create these rows.
- **Does not hit:** any earnings. Nothing in `DistributeEarnings` or
[grade-entry](../gradebook/grade-entry.md) reads `Grade` — payouts are computed from
letter grades and attendance only, so adding or renaming a level never changes a
payout.
## Surfaces
| Surface | Role |
|---|---|
| `ClassroomsController`, `Admin::ClassroomsController` | read (form checkboxes) |
| seeds | writes |
## See
- Source: `app/models/grade.rb`, `app/models/classroom_grade.rb`, `db/schema.rb:123-130`

View File

@@ -0,0 +1,68 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/quarter.rb
---
# Quarter
One of four grading periods inside a [school-year](school-year.md). Auto-created in sets
of four; never made by hand.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
`Quarter#previous` is **load-bearing for money**, not just navigation. Improvement
bonuses compare a student's letter grade against the same student's grade in the previous
quarter's gradebook, and `DistributeEarnings` finds it by calling `quarter.previous`
(`app/services/distribute_earnings.rb:35-38`). If `previous` returns `nil`, the
improvement bonus silently pays zero — the run still succeeds.
That is why `previous` and `next` cross **year** boundaries rather than stopping at 1 and
4: quarter 1 reaches back to quarter 4 of the same school's previous year
(`app/models/quarter.rb:20-24,44-58`), matching on `school` and `year`, not on ID order.
So the bonus keeps working across a September rollover — but only if the previous year's
`Year` record exists and its name parses (see [year](year.md)).
## Shape
- Table `quarters`, `db/schema.rb:188-196`; unique on `[school_year_id, number]` (`db/schema.rb:194`)
- `belongs_to :school_year`; FK is `on_delete: :cascade` (`db/schema.rb:420`)
- `has_many :grade_books, dependent: :restrict_with_error` (`:5`)
- `number` — `1..4`, validated for inclusion and uniqueness per school-year (`:7-10`)
- `scope :ordered` by number (`:12`)
- `next` (`:14-18`), `previous` (`:20-24`) — both memoized, both may return `nil`
## Connected to
- **owns:** [grade-book](../gradebook/grade-book.md) (blocks its own deletion)
- **owned-by:** [school-year](school-year.md)
- **joins:** —
- **looks-like-but-is-not:** `quarter.previous` is not "number − 1". At number 1 it is a
**different school-year's** quarter 4, found by school + previous year.
## If you change this
- **Hits:** [grade-book](../gradebook/grade-book.md) — one per quarter per classroom;
the [finalize-gradebook-earnings](../../processes/finalize-gradebook-earnings.md)
movement, specifically the improvement bonus;
[portfolio-transaction](../money/portfolio-transaction.md) amounts, one step further
on, because that bonus becomes a deposit.
- **Does not hit:** [classroom-enrollment](classroom-enrollment.md). Enrollment windows
are plain timestamps (`enrolled_at` / `unenrolled_at`) and are **not** scoped to
quarters — a quarter change does not move anyone on or off a roster.
## Surfaces
| Surface | Role |
|---|---|
| `GradeBooksController` | reads (to label the gradebook) |
| `DistributeEarnings` | reads `previous` |
| `SchoolYear#create_quarters` | writes |
## See
- Source: `app/models/quarter.rb`, `db/schema.rb:188-196`

View File

@@ -0,0 +1,68 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/school_year.rb
---
# SchoolYear
One school's instance of one [year](year.md) — the join that everything academic hangs
from.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
It is a join table that grew behaviour. Creating a `SchoolYear` **auto-creates exactly
four [quarters](quarter.md)** (`app/models/school_year.rb:12,20-24`), which is the first
link in a two-step cascade that ends in gradebooks:
```
SchoolYear created → 4 Quarters → (later) Classroom created → 1 GradeBook per quarter
```
Neither half is optional and neither is done by a controller. If quarters or gradebooks
are ever missing, the cause is almost always that this callback did not run — the object
was built by `insert_all`, a fixture, or a migration that skipped callbacks.
`name` is computed, not stored: `"#{school_name} (#{year_name})"` (`:14-16`).
## Shape
- Table `school_years`, `db/schema.rb:198-206`; unique on `[school_id, year_id]` (`db/schema.rb:203`)
- `belongs_to :school`, `belongs_to :year` (`:4-5`)
- `has_many :classrooms, dependent: :restrict_with_error` (`:6`) — blocks deletion
- `has_many :quarters, dependent: :destroy` (`:7`) — cascades
- `after_create :create_quarters` (`:12`)
## Connected to
- **owns:** [quarter](quarter.md) (creates and destroys them),
[classroom](classroom.md) (blocks its own deletion)
- **owned-by:** [school](school.md), [year](year.md)
- **joins:** school ↔ year
- **looks-like-but-is-not:** not [year](year.md). Deleting a `Year` cascades to
`SchoolYear`; deleting a `School` does not.
## If you change this
- **Hits:** [quarter](quarter.md) directly — the count, numbering, and names of quarters
are decided here; [classroom](classroom.md), which validates its `school_year_id`
(`app/models/classroom.rb:122-124`); [grade-book](../gradebook/grade-book.md) at one
remove, since classrooms create one per quarter.
- **Does not hit:** [grade-entry](../gradebook/grade-entry.md). Entries are created per
student against an existing gradebook, never by this cascade — adding a quarter gives
you empty gradebooks, not populated ones.
## Surfaces
| Surface | Role |
|---|---|
| `Admin::SchoolYearsController` | admin CRUD |
| `SchoolYearPresenter` | reads |
## See
- Source: `app/models/school_year.rb`, `db/schema.rb:198-206`

View File

@@ -0,0 +1,59 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/school.rb
---
# School
A participating school. Little more than a name — it exists to be the thing a
[school-year](school-year.md) attaches to.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
Deliberately thin: the table is `id`, `name`, timestamps (`db/schema.rb:208-212`). All
real structure lives one level down in [school-year](school-year.md), because the same
school recurs every year and nothing about the school itself changes when it does.
Deletion is blocked, not cascaded — `dependent: :restrict_with_error`
(`app/models/school.rb:4`). A school with any history cannot be removed.
## Shape
- Table `schools`, `db/schema.rb:208-212` — `name` only
- `has_many :school_years, dependent: :restrict_with_error` (`:4`)
- `has_many :years, through: :school_years` (`:5`)
- `validates :name, presence: true` (`:7`) — note the column itself is nullable
## Connected to
- **owns:** [school-year](school-year.md)
- **owned-by:** —
- **joins:** [year](year.md), through `school_years`
- **looks-like-but-is-not:** `User#school` is a **delegation through classroom**
(`app/models/user.rb:22-24`), not an association. A user with no classroom has no
school, and that is why the delegate is `allow_nil`.
## If you change this
- **Hits:** [school-year](school-year.md) and everything under it;
`Admin::SchoolsController`; `Portfolio#school_name`, which reaches back up through
user → classroom → school (`app/models/portfolio.rb:9`).
- **Does not hit:** the top-level `SchoolsController` and `app/views/schools/*`. Those
are a **ghost** — no route reaches them (see `../../CONTEXT.md`). Editing them changes
nothing that runs.
## Surfaces
| Surface | Role |
|---|---|
| `Admin::SchoolsController` | admin CRUD (the live one) |
| `SchoolsController` | **none — unrouted ghost** |
## See
- Source: `app/models/school.rb`, `db/schema.rb:208-212`

View File

@@ -0,0 +1,68 @@
---
type: object
cluster: org
universe: live
status: verified
entity: app/models/year.rb
---
# Year
An academic year, identified by the **string** `"2024 - 2025"`. Shared across all schools.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
The whole model hangs on a parsed string. `years` has exactly one meaningful column,
`name` (`db/schema.rb:393-398`) — there is no `start_year` or `end_year` integer. So:
- ordering casts a substring to int in SQL:
`CAST(SUBSTRING(name FROM 1 FOR 4) AS INTEGER)` (`app/models/year.rb:10`)
- `previous_year` / `next_year` do **string arithmetic** on the split halves
(`:21-27`, `:39-41`)
- `current_school_year` builds the expected name from today's date, rolling over in
**July** — months 1–6 belong to the year that started last calendar year (`:12-19`)
The format `"YYYY - YYYY"` — spaces around the hyphen included — is therefore
load-bearing. A record named `"2024-2025"` sorts and navigates wrong without raising.
## Shape
- Table `years`, `db/schema.rb:393-398`; `name` `null: false`, unique index
- `validates :name, presence: true, uniqueness: true` (`:8`)
- `has_many :school_years, dependent: :destroy` (`:5`) — **cascades**, unlike
[school](school.md)
- `has_many :classrooms, through: :school_years` (`:7`)
- `scope :ordered_by_start_year` (`:10`)
- `self.current_school_year(date = Date.current)` returns a **relation**, not a record
(`:12-19`)
## Connected to
- **owns:** [school-year](school-year.md) (destroys them)
- **owned-by:** —
- **joins:** [school](school.md), through `school_years`
- **looks-like-but-is-not:** `Year` is not [school-year](school-year.md). `Year` is the
calendar span shared by every school; `SchoolYear` is one school's instance of it.
## If you change this
- **Hits:** [school-year](school-year.md) — `dependent: :destroy` means deleting a year
deletes school-years, and their [quarters](quarter.md) cascade too
(`db/schema.rb:420`); any admin year dropdown ordering (`:10`);
[quarter](quarter.md)`#next`/`#previous`, which cross year boundaries by calling
`Year#next_year` (`app/models/quarter.rb:30,46`).
- **Does not hit:** [classroom](classroom.md) rows directly. Classrooms belong to a
`school_year`, not a `year` — the `through:` association is read-only convenience.
## Surfaces
| Surface | Role |
|---|---|
| `Admin::SchoolYearsController` | reads for selection |
| `SchoolYearPresenter` | reads |
## See
- Source: `app/models/year.rb`, `db/schema.rb:393-398`

View File

@@ -0,0 +1,96 @@
---
type: object
cluster: trading
universe: live
status: verified
entity: app/models/order.rb
---
# Order
A student's intent to buy or sell shares. **Never executes immediately** — it sits
`pending` until a cron job sweeps it up.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
**Orders are deferred by design.** Creating one only writes a row; the money and shares
move later, when `OrderExecutionJob` runs — every 15 minutes
(`config/recurring.yml:2-6`). The student therefore trades at whatever
[stock](stock.md)`#price_cents` says **at execution time**, not the price on screen when
they clicked. This is the single most surprising fact about the trading model and the
reason `ExecuteOrder` re-checks funds and shares before committing
(`app/services/execute_order.rb:16-33`).
Because pending orders are just rows, [portfolio](../money/portfolio.md) has to subtract
them from the balance itself (`app/models/portfolio.rb:93-100`) — otherwise a student
could spend the same dollar repeatedly inside the 15-minute window.
**The $1.00 fee is per batch, not per order.** `Order#transaction_fee` returns 0 if the
user already has *any other* pending order (`:146-148`), matching
`TransactionFeeProcessor`, which charges each user once per sweep
(`app/services/transaction_fee_processor.rb:23-31`). So a student placing five orders in
one window pays $1.00 total.
**Validation is heavily conditional** (`:16-26`) — funds are checked on create, and
differently on update; share availability is re-checked only when the share count changes
(`:195-201`), specifically so the `pending → completed` status write does not trip a
spurious error. Read those `on:` and `if:` clauses before adding a validation here.
## Shape
- Table `orders`, `db/schema.rb:132-146`
- `status` — **integer** enum, `pending: 0 / completed: 1 / canceled: 2`, default pending
(`:11`, `db/schema.rb:138`)
- `action` — **string** enum, `"buy" / "sell"`, `null: false` (`:12`, `db/schema.rb:133`).
The two enums use different storage; this is not a mistake to "fix" casually.
- `shares` — `decimal` with no precision (`db/schema.rb:137`). **Fractional shares are
allowed**; only `> 0` is enforced (`:14`).
- `belongs_to :user` (not portfolio); `portfolio_stock` and `portfolio_transaction` are
optional and stay `nil` until execution (`:6-9`)
- Scopes: `buy`, `sell`, `pending`, `completed`, `canceled`, `for_student`, `for_teacher`
(`:32-42`)
- Eight sorting scopes + `SORTING_METHODS` + `apply_sorting` (`:44-99`)
- `cancel!` (`:101-103`), `purchase_cost = price_cents * shares` (`:105-107`)
The model `include ApplicationHelper` (`:4`) purely to call `format_money` inside
validation messages — a view helper reaching into a model.
## Connected to
- **owns:** nothing until executed; then references
[portfolio-stock](portfolio-stock.md) and
[portfolio-transaction](../money/portfolio-transaction.md)
- **owned-by:** [user](../identity/user.md), [stock](stock.md)
- **joins:** —
- **looks-like-but-is-not:** an order is **not** a transaction. The ledger row is created
by `ExecuteOrder` and the order merely points at it. Also `Order#portfolio` is a
delegation through user (`:30`), not an association — you cannot `joins(:portfolio)`.
## If you change this
- **Hits:** [portfolio](../money/portfolio.md)`#cash_balance` — pending buys and the
pending fee are part of the balance formula;
[place-and-execute-order](../../processes/place-and-execute-order.md) and
`ExecuteOrder`; `OrdersController` and `OrderPolicy`; the `order_form` Stimulus
controller; `Order.for_teacher`, which scopes through the **legacy**
`users.classroom_id` (`:40-42`) — teachers will not see orders from students enrolled
only via [classroom-enrollment](../org/classroom-enrollment.md).
- **Does not hit:** [portfolio-snapshot](portfolio-snapshot.md). Snapshots value settled
holdings and cash at month end; a pending order contributes only through the balance
formula and never creates or amends a snapshot.
## Surfaces
| Surface | Role |
|---|---|
| `OrdersController` (`index`, `new`, `create`, `edit`, `update`, `cancel`) | student writes |
| `OrderExecutionJob` → `ExecuteOrder` | reads pending, writes completed/canceled |
| `TransactionFeeProcessor` | reads |
| teacher order list (`for_teacher`) | reads |
## See
- Source: `app/models/order.rb`, `db/schema.rb:132-146`
- As-built: `docs/orders-and-transactions.md`

View File

@@ -0,0 +1,79 @@
---
type: object
cluster: trading
universe: live
status: verified
entity: app/models/portfolio_position.rb
---
# PortfolioPosition
A plain Ruby object (**no table**) that aggregates many
[portfolio-stock](portfolio-stock.md) lots into one row per stock — what a student sees as
"my holdings".
Verified 2026-08-16 against commit `63732df`.
## Why this shape
Holdings cannot be read directly because lots are append-only and sells are negative
(see [portfolio-stock](portfolio-stock.md)). `PortfolioPosition.for_portfolio` does the
collapsing in **one SQL query** rather than in Ruby (`app/models/portfolio_position.rb:24-40`):
it groups by `stocks.id`, filters `HAVING SUM(portfolio_stocks.shares) > 0` (`:29`) so
fully-sold stocks disappear, and computes gain/loss in the `SELECT` (`:30-38`).
The query starts from `Stock`, not from `Portfolio` — so each result is a **`Stock`
instance decorated with extra columns** (`total_shares`, `aggregated_change_amount`,
`aggregated_total_return`), which `build_position` then wraps (`:42-52`). That is why
the `stock:` passed in is already carrying aggregate data.
**`total_return_amount` is not a return.** The SQL behind it is
`(stocks.price_cents / 100.0) * SUM(shares)` (`:36`) — that is the position's *current
market value*, with no cost subtracted. The genuine gain/loss is `change_amount`, which
does subtract the basis (`:34-35`). Do not present `total_return_amount` as profit.
## Shape
- PORO; no table, no Active Record (`:3`)
- `attr_reader :stock, :shares, :portfolio, :change_amount, :total_return_amount` (`:4`)
- `initialize(stock:, shares:, portfolio: nil, financial_data: {})` (`:8-14`)
- `current_value` — dollars (`:16-18`); `current_value_cents` — cents (`:20-22`)
- `self.for_portfolio(portfolio)` returns an **Array**, not a relation (`:24-40`)
- `build_position` is `private_class_method` (`:54`)
- Delegates `current_price`, `price_cents`, `ticker` to the stock with a `stock_` prefix
(`:6`)
`current_value_cents` multiplies `shares * stock_price_cents` where `shares` is a decimal
from SQL — it returns a `BigDecimal`, not an `Integer`, unlike every other `_cents`
reader in the app.
## Connected to
- **owns:** —
- **owned-by:** [portfolio](../money/portfolio.md), which exposes it as `#positions`
(`app/models/portfolio.rb:28-30`)
- **joins:** reads [portfolio-stock](portfolio-stock.md) and [stock](stock.md)
- **looks-like-but-is-not:** not an Active Record model — no `where`, no `find`, and
`for_portfolio` cannot be chained. Not [portfolio-stock](portfolio-stock.md) either:
one position spans many lots.
## If you change this
- **Hits:** the portfolio holdings table in `app/views/portfolios/`;
`PortfoliosController#show`; anything reading `Portfolio#positions`.
- **Does not hit:** [portfolio](../money/portfolio.md)`#holdings_value_cents`. That is a
**separate** SQL sum (`app/models/portfolio.rb:48-52`) which does **not** apply the
`HAVING SUM(shares) > 0` filter. The two can disagree — a stock with a net-zero or
negative lot sum is excluded from positions but still counted in holdings value.
Changing one does not change the other.
## Surfaces
| Surface | Role |
|---|---|
| `PortfoliosController#show` → holdings table | read |
| `Portfolio#positions` | read |
## See
- Source: `app/models/portfolio_position.rb`

View File

@@ -0,0 +1,79 @@
---
type: object
cluster: trading
universe: live
status: verified
entity: app/models/portfolio_snapshot.rb
---
# PortfolioSnapshot
One portfolio's total worth on one date. **The only persisted history in the app** —
everything else about a portfolio is recomputed on every read.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
[portfolio](../money/portfolio.md) stores nothing, so "what was this student worth in
March?" is unanswerable from the ledger alone — reconstructing it would need historical
stock prices, which the app also does not keep ([stock](stock.md) holds only today and
yesterday). Snapshots exist to close that gap, and they are **write-once history**:
delete a row and that month is gone permanently.
Written only by `MonthlyPortfolioSnapshotJob` on the **last day of each month at 23:00**
(`config/recurring.yml:15-19`, cron `0 23 L * *`). Worth is taken from
`Portfolio#calculate_total_value_cents` — cash plus holdings at that moment
(`app/jobs/monthly_portfolio_snapshot_job.rb:24-29`).
**Re-running is safe.** The unique index on `[portfolio_id, date]`
(`db/schema.rb:154`), the model validation (`app/models/portfolio_snapshot.rb:8`), and
the job's own `exists?` guard (`app/jobs/monthly_portfolio_snapshot_job.rb:22`) all say
the same thing three times. The job also swallows `RecordInvalid` per portfolio and logs
it (`app/jobs/monthly_portfolio_snapshot_job.rb:30-31`), so one bad portfolio cannot abort the run.
Every portfolio is snapshotted, including empty ones — there is no skip for zero worth.
## Shape
- Table `portfolio_snapshots`, `db/schema.rb:148-156`
- `date` — a `date`, `null: false` (`db/schema.rb:150`)
- `worth_cents` — integer, `null: false`, validated `>= 0` (`db/schema.rb:153`,
`app/models/portfolio_snapshot.rb:7`)
- `belongs_to :portfolio` (`:4`)
- `current_worth` returns **dollars** (`:10-12`)
- Batched at 1,000 portfolios per pass (`app/jobs/monthly_portfolio_snapshot_job.rb:6,12`)
`worth_cents` cannot be negative, but a portfolio with an overdrawn cash balance would
compute one — that snapshot fails validation, gets logged, and is skipped.
## Connected to
- **owns:** —
- **owned-by:** [portfolio](../money/portfolio.md)
- **joins:** —
- **looks-like-but-is-not:** not a transaction and not an audit log. A snapshot records a
*total*, never a movement, and nothing reconciles it against
[portfolio-transaction](../money/portfolio-transaction.md).
## If you change this
- **Hits:** `Portfolio#chart_data`, which takes the **last 12** snapshots ordered by date
(`app/models/portfolio.rb:54-63`) — so the student chart shows at most a year;
the `portfolio_chart` Stimulus controller and the Chart.js view;
[snapshot-portfolio-worth](../../processes/snapshot-portfolio-worth.md).
- **Does not hit:** any balance or holding. Snapshots are pure output — nothing in the
app reads a snapshot back to compute current worth, so a wrong or missing snapshot
distorts the chart and nothing else.
## Surfaces
| Surface | Role |
|---|---|
| `MonthlyPortfolioSnapshotJob` | writes (the only writer) |
| `Portfolio#chart_data` → portfolio chart | reads |
## See
- Source: `app/models/portfolio_snapshot.rb`, `db/schema.rb:148-156`
- Schedule: `config/recurring.yml:15-19`, `docs/scheduling.md`

View File

@@ -0,0 +1,81 @@
---
type: object
cluster: trading
universe: live
status: verified
entity: app/models/portfolio_stock.rb
---
# PortfolioStock
One **lot** — a single executed buy or sell. Not "the shares a student owns": holdings are
the *sum* of these rows.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
**The table is append-only and sells are stored as negative shares.** `ExecuteOrder`
creates a new row per execution, negating the quantity for a sell
(`app/services/execute_order.rb:53-58`). Nothing ever updates or deletes a lot. So a
student who bought 10 and sold 4 has two rows, `+10` and `-4`, and owns 6 — which is why
`Portfolio#shares_owned` is a `SUM` (`app/models/portfolio.rb:24-26`) and
[portfolio-position](portfolio-position.md) filters `HAVING SUM(shares) > 0`
(`app/models/portfolio_position.rb:29`). Treating one row as a holding will be wrong for
anyone who has ever sold.
The model itself carries a one-line warning to this effect (`app/models/portfolio_stock.rb:3`).
**`purchase_price` is in dollars, not cents.** It is written as `stock.current_price`
(`app/services/execute_order.rb:57`), which already divides by 100
(`app/models/stock.rb:19-21`), into a `decimal(15,2)` column (`db/schema.rb:161`) —
while [stock](stock.md)`#price_cents` beside it is an integer in cents. Any query joining
the two must convert, and `PortfolioPosition`'s SQL does exactly that:
`(stocks.price_cents / 100.0) * SUM(shares) - SUM(purchase_price * shares)`
(`app/models/portfolio_position.rb:34-36`).
On a sell, the lot records the **sale** price in `purchase_price` — the column name lies
for negative rows.
## Shape
- Table `portfolio_stocks`, `db/schema.rb:158-168`
- `shares` — `decimal(15,2)`, may be negative (`db/schema.rb:162`)
- `purchase_price` — `decimal(15,2)`, **dollars** (`db/schema.rb:161`)
- `belongs_to :portfolio`, `belongs_to :stock` (`:5-6`) — no validations, no callbacks
- Composite index on `[portfolio_id, stock_id]` (`db/schema.rb:165`)
- No `order_id`. The link runs the other way:
[order](order.md)`#portfolio_stock_id` points here (`db/schema.rb:135`)
## Connected to
- **owns:** —
- **owned-by:** [portfolio](../money/portfolio.md), [stock](stock.md)
- **joins:** portfolio ↔ stock, once per execution
- **looks-like-but-is-not:** not a position. [portfolio-position](portfolio-position.md)
is the aggregate; this is one lot. Also not a ledger entry — cash lives in
[portfolio-transaction](../money/portfolio-transaction.md).
## If you change this
- **Hits:** [portfolio](../money/portfolio.md)`#shares_owned` and
`#holdings_value_cents` (`app/models/portfolio.rb:48-52`);
[portfolio-position](portfolio-position.md), whose entire query is over this table;
[order](order.md) sell validation, which calls `shares_owned`
(`app/models/order.rb:124-132`);
[portfolio-snapshot](portfolio-snapshot.md) values, computed from holdings.
- **Does not hit:** a student's cash. Shares and cash are written together by
`ExecuteOrder` but stored apart — adding or removing a lot changes holdings and total
worth, and leaves `cash_balance` untouched.
## Surfaces
| Surface | Role |
|---|---|
| `ExecuteOrder` | writes (the only writer) |
| `PortfolioPosition`, `Portfolio` | read |
| `MonthlyPortfolioSnapshotJob` | reads |
## See
- Source: `app/models/portfolio_stock.rb`, `db/schema.rb:158-168`

View File

@@ -0,0 +1,95 @@
---
type: object
cluster: trading
universe: live
status: verified
entity: app/models/stock.rb
---
# Stock
A real, tradeable ticker with a cached price. The catalogue students buy from — curated
by admins, priced nightly by Alpha Vantage.
Verified 2026-08-16 against commit `63732df`.
## Why this shape
**Prices are cached columns, not live lookups.** `price_cents` and
`yesterday_price_cents` are plain nullable integers (`db/schema.rb:352,358`) refreshed by
[refresh-market-data](../../processes/refresh-market-data.md). Every valuation in the app
reads these columns, so the whole portfolio is priced as of the last successful job run.
No request ever calls the API.
**`price_cents` is nullable, and nothing defaults it.** A stock created by an admin
without a price has `price_cents = nil` until the nightly job runs.
`Stock#current_price` copes (`nil.to_f / 100 == 0.0`, `:19-21`), but
`Order#purchase_cost` does `stock.price_cents * shares`
(`app/models/order.rb:105-107`) and raises `NoMethodError` on `nil`. Creating a stock and
trading it the same day is the way to hit this.
**Archived means unbuyable, not untradeable.** `prevent_archived_stock_purchase` is
guarded by `if: -> { buy? }` (`app/models/order.rb:25,189-193`), so students can still
**sell** an archived holding — deliberate, since archiving must not trap anyone's money.
Deletion is blocked outright: both associations are `dependent: :restrict_with_error`
(`:4-5`). Archive is the only retirement path.
**Two writers disagree about the analyst columns.** Admins may set all twenty-odd fields
(`app/controllers/admin/stocks_controller.rb:80-104`), but the weekly
`StockAttributeUpdate` overwrites only six — `company_name`, `description`,
`stock_exchange`, `industry`, `company_website`, `profit_margin`
(`app/services/stock_attribute_update.rb:62-72`). Hand-edit one of those six and the
Saturday job will silently revert it. The rest (`debt`, `cash_flow`, `debt_to_equity`,
`sales_growth`, `employees`, `management`, `competitor_names`, the three `industry_avg_*`)
are admin-only and never auto-updated.
## Shape
- Table `stocks`, `db/schema.rb:335-360`; `ticker` uniquely indexed (`db/schema.rb:359`)
- `validates :ticker, presence: true` (`:7`) — the column itself is nullable
- `company_website` must be a valid http/https URL, blank allowed (`:8-14`)
- `archived` boolean, default false, `null: false` (`db/schema.rb:336`)
- `last_trading_day` date — the freshness gate the price job compares against
- Scopes `active` / `archived` (`:16-17`)
- Readers in **dollars**: `current_price` (`:19`), `yesterday_price` (`:23`),
`percentage_change` (`:29`), `percentage_change_formatted` (`:35`)
- `yesterday_price` falls back to `current_price` when null, so day-one change is 0%
(`:23-27,29-33`)
## Connected to
- **owns:** —
- **owned-by:** —
- **joins:** [portfolio](../money/portfolio.md), through
[portfolio-stock](portfolio-stock.md); [order](order.md)
- **looks-like-but-is-not:** `price_cents` is the *cached* price, not a market price at
order time. An order placed at 9am executes at whatever `price_cents` says when the job
runs — see [place-and-execute-order](../../processes/place-and-execute-order.md).
## If you change this
- **Hits:** [order](order.md) — `purchase_cost`, all funds validations, and four sorting
scopes join this table (`app/models/order.rb:54-73`);
[portfolio](../money/portfolio.md)`#holdings_value_cents`, which multiplies
`price_cents` in SQL (`app/models/portfolio.rb:48-52`);
[portfolio-position](portfolio-position.md), whose gain/loss maths is raw SQL over
`stocks.price_cents` (`app/models/portfolio_position.rb:30-38`);
`ApplicationController#set_navbar_stocks`, which loads active stocks on **every**
request (`app/controllers/application_controller.rb:22-24`).
- **Does not hit:** [portfolio-transaction](../money/portfolio-transaction.md). Ledger
rows store the cents paid at execution time and never re-read the stock — a price
change never rewrites history, it only re-values current holdings.
## Surfaces
| Surface | Role |
|---|---|
| `Admin::StocksController` | admin CRUD (all columns) |
| `StocksController` (`index`, `show`) | student/teacher read |
| `StockPricesUpdateJob` | writes prices nightly |
| `StockAttributeUpdateJob` | writes six attributes weekly |
| every layout, via `@navbar_stocks` | reads |
## See
- Source: `app/models/stock.rb`, `db/schema.rb:335-360`