--- 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)