convert pdf to md, add OVERVIEW and docs
This commit is contained in:
@@ -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.
|
||||
@@ -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` |
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
Reference in New Issue
Block a user