after moving all to cipher

This commit is contained in:
2026-08-19 10:19:57 +00:00
parent 4df62d2609
commit 9abade1a81
100 changed files with 1286 additions and 4335 deletions

View File

@@ -1,197 +0,0 @@
# Stocks in the Future — Overview
> A Rails 8 web app used by middle-school students, teachers, and admins to run a financial-literacy program: students earn "SIF dollars" from grades and attendance, then buy and sell real-ticker stocks in a simulated portfolio.
## Purpose
[Stocks in the Future](https://sifonline.org/) (SIF) pairs classroom incentives with an investing curriculum. Students are rewarded with virtual cash for attendance and for math/reading grades, and they invest that cash in a simulated brokerage backed by real daily stock prices from Alpha Vantage. Teachers manage classrooms, enter quarterly grade books, and finalize earnings; admins manage schools, school years, stocks, users, and manual portfolio adjustments.
This is a [Ruby for Good](https://rubyforgood.org/) volunteer project (`rubyforgood/stocks-in-the-future`). It is a server-rendered Rails monolith — Hotwire/Turbo with a sprinkle of Stimulus, no SPA front end.
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Language / runtime | Ruby 3.4.4 (`.ruby-version`) |
| Framework | Rails 8.1.2 (`config.load_defaults 8.0`) |
| Database | PostgreSQL 15 via `pg ~> 1.6` |
| Web server | Puma (`config/puma.rb`), nginx + unix socket in prod |
| Background jobs | Solid Queue 1.4 (DB-backed), `config/queue.yml` + `config/recurring.yml` |
| Auth | Devise 5.0 — **login is by `username`, not email** |
| Authorization | Pundit 2.5 (`app/policies/`) |
| Soft deletes | `discard ~> 2.0` on `User` |
| Assets | Propshaft + importmap-rails (no JS bundler), Tailwind via `tailwindcss-rails` |
| UI components | `shadcn-ui` gem (+ `tailwind_merge`), `lucide-rails` icons, `font-awesome-rails` |
| Front end | Hotwire (Turbo + Stimulus), Trix/Action Text, Chart.js 4.5 (CDN-pinned) |
| Rich text / files | Action Text, Active Storage |
| Tests | Minitest + FactoryBot, Capybara + Selenium (system), WebMock, Mocha, SimpleCov |
| Lint / security | RuboCop (+ rubocop-rails), erb_lint, i18n-tasks, Brakeman, bundler-audit |
| Migrations safety | `strong_migrations ~> 2.8` |
| Deploy | Capistrano 3 → AWS Lightsail (Ubuntu), Terraform for infra |
## Directory Structure
```
app/
controllers/ # student/teacher-facing controllers
admin/ # /admin namespace, all inherit Admin::BaseController
concerns/ # SoftDeletableFiltering (discarded/all/kept param scoping)
models/ # 24 models; User STI -> Student, Teacher
concerns/url_helpers.rb
services/ # ExecuteOrder, DistributeEarnings, TransactionFeeProcessor,
# AlphaVantageApiClient, StockAttributeUpdate,
# ImportStudentService, BulkStudentImportService,
# MemorablePasswordGenerator
jobs/ # OrderExecutionJob, StockPricesUpdateJob,
# StockAttributeUpdateJob, MonthlyPortfolioSnapshotJob
policies/ # Pundit: application, classroom, grade_book, order, portfolio, stock
facades/ # ClassroomFacade (student list + classroom stats)
presenters/ # AttendanceEntryPresenter, ClassroomPresenter, SchoolYearPresenter
form_builders/admin/ # Admin::FormBuilder
components/shadcn/ # Shadcn::FormBuilder
helpers/components/ # render_button / render_input / etc. -> app/views/components/ui/*
javascript/controllers/ # 8 Stimulus controllers (order form, portfolio chart, modal,
# admin sidebar, autosave, clickable row, filters, navbar toggle)
views/ # ERB; layouts/application.html.erb and layouts/admin.html.erb
assets/tailwind/ # application.css + admin/buttons/forms/navbar/shadcn/tables partials
config/
routes.rb application.rb recurring.yml queue.yml storage.yml
environments/{development,test,staging,production}.rb
deploy.rb deploy/{production,staging}.rb # Capistrano
initializers/api_keys.rb # global API_KEY constant
db/
schema.rb migrate/ seeds.rb seeds/{development,staging,production,test}.rb + partials/
docs/ # scheduling, orders-and-transactions, gradebook-earnings, seeds, schema
terraform/{production,staging}/ # Lightsail infra + bootstrap.sh
test/ # 87 test files: models, controllers, services, policies, jobs, system
docker/ Dockerfile Dockerfile.dev docker-compose.yml
```
## Architecture
### Users are STI with a separate admin flag
`User` (table `users`) has `type` in `%w[User Student Teacher]` plus a boolean `admin` column. So there are three effective roles: **student**, **teacher**, and **admin** (`admin` is a flag on any user, not an STI subclass). `Student` auto-creates a `Portfolio` and an initial `ClassroomEnrollment` after create; `Teacher` syncs `username` from `email`.
### Money is a ledger, always in cents
`portfolios` has **no balance column**. Cash on hand is derived in `Portfolio#cash_on_hand_in_cents` as
`(credits + deposits) - (debits + withdrawals + fees + pending buy orders + pending $1 fee)`.
`PortfolioTransaction#transaction_type` is `deposit | withdrawal | credit | debit | fee`, where deposit/withdrawal are classroom earnings and admin adjustments and credit/debit are stock sales/purchases. Transactions are meant to be immutable ledger rows (see `docs/orders-and-transactions.md`). All monetary columns are `*_cents` integers (`amount_cents`, `price_cents`, `worth_cents`).
### Order lifecycle (deferred execution)
1. A student creates an `Order` (`buy`/`sell`, whole shares) from a stock page. It is saved `pending` — nothing settles immediately. Validations at this point check trading is enabled for the classroom, the stock isn't archived, sufficient shares to sell, and sufficient funds including a single `$1.00` fee (`PortfolioTransaction::TRANSACTION_FEE_CENTS = 1_00`).
2. Students may edit or `cancel` pending orders; edits re-run funds validation with a refund of the previous cost.
3. `OrderExecutionJob` (recurring) calls `ExecuteOrder` for each pending order: creates the debit/credit `PortfolioTransaction`, creates a `PortfolioStock` row (**negative `shares` for sells**), and flips the order to `completed`. If funds/shares are insufficient at execution time the order is canceled instead.
4. `TransactionFeeProcessor` then charges **one $1.00 fee per user per run**, regardless of order count.
Holdings are therefore an append-only set of `portfolio_stocks` rows; current positions are computed in `PortfolioPosition.for_portfolio` with a grouped SQL query (`HAVING SUM(portfolio_stocks.shares) > 0`) that also derives change and total-return amounts.
### Grade book → earnings
`SchoolYear` auto-creates 4 `Quarter`s on create; `Classroom` auto-creates a `GradeBook` per quarter on create. Teachers fill `GradeEntry` rows (attendance days, perfect-attendance flag, math grade, reading grade). `GradeBooksController#finalize` marks the book `verified!` and runs `DistributeEarnings`, which creates `deposit` transactions and marks the book `completed`. Rates live in `GradeEntry` (cents): `$0.20`/day attended, `$1.00` perfect attendance, `$3.00` for an A-range grade, `$2.00` for a B-range grade, `$2.00` per subject for improving over the previous quarter (via `Quarter#previous`). Statuses: `draft → verified → completed`.
### Classroom membership is mid-migration
There are two membership mechanisms in the codebase at once: the legacy `users.classroom_id` foreign key, and the newer `classroom_enrollments` join table (supports multiple/historical enrollments, one `primary` per student, `enrolled_at`/`unenrolled_at`). `ClassroomFacade#students` unions both. Some scopes (e.g. `Order.for_teacher`, `Classroom.order_by_student_count`) still join only on the legacy `users.classroom_id`.
### Authorization
`ApplicationController` runs `authenticate_user!` for everything, sets `@navbar_stocks = policy_scope(Stock).active`, and rescues `Pundit::NotAuthorizedError` by redirecting students to their portfolio and everyone else to root. `Admin::BaseController` additionally requires `current_user&.admin?` and uses the `admin` layout. Policy scopes are role-shaped, e.g. `OrderPolicy::Scope` resolves to all / teacher's classrooms / own orders.
### Trading gates
`Classroom#trading_enabled` (toggled by `PATCH /classrooms/:id/toggle_trading`) blocks order creation when false; `Classroom#archived` hides classrooms from teachers and blocks grade book access for non-admins. `Stock#archived` blocks new purchases.
## Integrations
| Service | Use | Where |
|---------|-----|-------|
| **Alpha Vantage** (`GLOBAL_QUOTE`) | Daily stock price refresh; sleeps 1.1s between symbols to respect the rate limit | `app/services/alpha_vantage_api_client.rb`, `app/jobs/stock_prices_update_job.rb` |
| **Alpha Vantage** (`OVERVIEW`) | Weekly company metadata (name, description, exchange, industry, website, profit margin) | `app/services/stock_attribute_update.rb`, `app/jobs/stock_attribute_update_job.rb` |
| **Amazon SES (SMTP)** | Devise password-reset / account-setup mail in staging + production, `us-east-1`, DKIM on `sifonline.org` | `config/environments/production.rb`, `staging.rb` |
| **AWS Lightsail + SSM** | Hosting (`production_web` = `mi-059a7bcb37754c44d`, `staging_web` = `mi-0c65ce3a1a596c81c`), keyless ops via SSM | `terraform/`, README "Operations" |
| **AWS Secrets Manager** | Stores SES SMTP creds at `stocks-in-the-future/ses-smtp` | README |
| **Chart.js (jsDelivr CDN)** | Portfolio value chart from monthly snapshots | `config/importmap.rb`, `portfolio_chart_controller.js` |
| **GitHub Actions** | CI, lint, auto-deploy to staging, stale-issue cleanup | `.github/workflows/` |
Recurring schedules (`config/recurring.yml`, cron in UTC, app `time_zone` is Eastern in staging/production):
| Job | Schedule |
|-----|----------|
| `OrderExecutionJob` | `*/15 * * * *` (every 15 minutes) |
| `StockPricesUpdateJob` | `0 2 * * 2-6` (Tue–Sat 02:00 UTC ≈ weekday evenings ET) |
| `StockAttributeUpdateJob` | `0 4 * * 6` (Saturdays) |
| `MonthlyPortfolioSnapshotJob` | `0 23 L * *` (last day of month) |
## Database & Data Layer
- **Postgres** via Active Record; schema at `db/schema.rb` (version `2026_06_09_141805`), migrations in `db/migrate/`. `strong_migrations` guards unsafe migrations.
- Connection config: `config/database.yml` (copy from `config/database.yml.sample`). Local dev DBs are `stocks_in_the_future_development` / `_test`; production uses `STOCKS_IN_THE_FUTURE_DATABASE_PASSWORD`, and `DATABASE_URL` overrides everything (Docker uses `postgresql://sif:password@db/`).
- Core domain tables: `schools → school_years → classrooms` (with `years`, `quarters`, `grades`, `classroom_grades`), `users` (STI) + `classroom_enrollments` + `teacher_classrooms`, `portfolios → portfolio_stocks / portfolio_transactions / portfolio_snapshots`, `stocks`, `orders`, `grade_books → grade_entries`, `announcements`.
- Solid Queue owns 11 `solid_queue_*` tables in the **same** database (no Redis).
- Action Text (`action_text_rich_texts`) backs `Announcement#content`; Active Storage tables are present.
- Notable indexes/constraints: unique `stocks.ticker`, unique `users.username`, **partial** unique index on `users.email` (`WHERE email IS NOT NULL AND email <> ''`) so username-only students can share a null email, unique `(quarter_id, classroom_id)` on grade books, unique `(portfolio_id, date)` on snapshots, partial unique-ish index on primary enrollments.
- Seeds are environment-split: `db/seeds.rb` loads `db/seeds/#{Rails.env}.rb`, which loads ordered partials from `db/seeds/partials/`. After `bin/rails db:setup` you get logins `Teacher` / `Student` / `Admin`, all with password `password`.
## Connectivity & Configuration
| Variable | Purpose |
|----------|---------|
| `DATABASE_URL` | Full Postgres URL; used by Docker and CI |
| `STOCKS_IN_THE_FUTURE_DATABASE_PASSWORD` | Production DB password when not using `DATABASE_URL` |
| `RAILS_MAX_THREADS` | Puma threads / AR pool size |
| `WEB_CONCURRENCY`, `PORT`, `PIDFILE`, `PUMA_SOCKET` | Puma process/binding config |
| `SOLID_QUEUE_IN_PUMA` | If set, runs Solid Queue as a Puma plugin instead of a separate process |
| `JOB_CONCURRENCY` | Solid Queue worker processes (default 1) |
| `ALPHA_VANTAGE_API_KEY` | Stock price/overview API key |
| `APP_HOST` | Mailer host (`app.sifonline.org` / `staging.sifonline.org`) |
| `MAILER_SENDER` | Devise sender, default `no-reply@sifonline.org` |
| `SES_SMTP_USERNAME`, `SES_SMTP_PASSWORD` | **Required** in staging/production (`ENV.fetch` with no default — boot fails without them) |
| `SES_SMTP_ADDRESS`, `SES_SMTP_PORT` | Default `email-smtp.us-east-1.amazonaws.com`, `587` |
| `RAILS_LOG_LEVEL` | Production log level (default `info`) |
| `PRODUCTION_SERVER_IP`, `STAGING_SERVER_IP` | Capistrano deploy targets |
| `APP_PORT` | Docker Compose host/container port (default 3000) |
On the servers these are read from `/etc/stocks/env`; Capistrano sources that file for `assets:precompile` and `db:migrate`.
Ports and endpoints: app on `localhost:3000`, Postgres `5432`, Redis `6379` (compose only). Health check at `GET /up` (silenced in logs). Production terminates TLS at a Lightsail load balancer, so `assume_ssl = true` and `force_ssl = false`.
## Key Entry Points
| File | Why it matters |
|------|----------------|
| `config/routes.rb` | Complete surface area: `root home#index`, `devise_for :users`, `resources :classrooms` (nested grade books, students, enrollments), `resources :orders`, `namespace :admin` |
| `app/controllers/application_controller.rb` | Global auth, Pundit wiring, navbar stock scope, role-aware redirect on authorization failure |
| `app/controllers/admin/base_controller.rb` | Admin gate + shared sorting helper |
| `app/models/order.rb` | The densest file in the app — all trading validations and sort scopes |
| `app/services/execute_order.rb` + `app/jobs/order_execution_job.rb` | How a pending order actually settles |
| `app/models/portfolio.rb` + `app/models/portfolio_position.rb` | Balance derivation and holdings aggregation SQL |
| `app/models/grade_entry.rb` + `app/services/distribute_earnings.rb` | Earnings math |
| `config/recurring.yml`, `config/queue.yml` | Everything scheduled |
| `docs/orders-and-transactions.md`, `docs/gradebook-earnings.md` | Domain rules in prose — read these before touching money code |
## Development, Testing, Deployment
- **Run locally:** `bin/setup` then `bin/dev` (Procfile.dev = rails server + `tailwindcss:watch` + `solid_queue:start`). Docker: `docker compose up`, with `bin/dc <cmd>` as a shortcut for `docker compose run stocks`.
- **Tests:** `bin/rails test` and `bin/rails test:system` (87 test files). Minitest with FactoryBot factories in `test/factories/`, parallelized by processor count (override with `PARALLEL_WORKERS`), `WebMock.disable_net_connect!`, coverage via `COVERAGE=true` (forces 1 worker).
- **Lint:** `bin/lint` runs i18n-tasks normalization, RuboCop, erb_lint, Brakeman (`--exit-on-warn`), bundler-audit, and `importmap audit`. CI enforces this.
- **Deploy:** pushes to `main` run tests then `bundle exec cap staging deploy` (`.github/workflows/deploy-staging.yml`); production is a manual `cap production deploy`. Capistrano deploys to `/home/ubuntu/stocks-in-the-future` on Lightsail with rbenv Ruby 3.4.4, links `config/database.yml`, runs a custom `db:migrate` after publishing, restarts the `stocks` systemd unit, and re-chmods the Puma socket path for nginx.
## Notes & Gotchas
- **Hard deletes of users raise outside production.** `User#destroy`/`destroy!` are overridden to `discard`, and `soft_delete_guard` raises a loud error in dev/test. Use `really_destroy!` only if you truly mean it.
- **Devise quirks:** `config.authentication_keys = [:username]`, and `User#email_changed?` is hard-coded to `false` so Devise never demands re-confirmation. Students are created with `email = nil`; teachers get `username = email`.
- **Passwords for students are generated, not chosen** — `MemorablePasswordGenerator` builds `Superhero + number + Superhero` from Faker (marked "TODO: more robust solution later") and the plaintext is surfaced once in a flash message.
- **`API_KEY` is a global constant** defined in `config/initializers/api_keys.rb` with a default of `"test-api-key"`. `StockAttributeUpdate` uses that constant, while `AlphaVantageApiClient` reads `ENV` directly and returns `nil` when unset — so missing keys fail quietly in two different ways.
- **Docs drift from `config/recurring.yml`.** `docs/scheduling.md` says `OrderExecutionJob` runs at 1:00 AM ET on weekdays and then *triggers* `StockPricesUpdateJob`; in the code the job is scheduled every 15 minutes and the price update is an independent cron entry. Trust `config/recurring.yml` and the job source.
- `docs/README.md` links to `docs/architecture/index.md`, which does not exist in the repo.
- **Production Active Storage is `:heroku`, which is a `Disk` service rooted at `tmp/storage`** (`config/storage.yml`). Uploads are effectively ephemeral and not shared across instances.
- **Redis is vestigial.** `docker-compose.yml` starts Redis and CI sets `REDIS_URL`, but there is no `redis` gem and Solid Queue is entirely Postgres-backed.
- **Other leftovers:** `bin/delayed_job` exists although Delayed Job isn't in the Gemfile (the `daemons` gem is still there), and `.standard.yml` is present although `standard` isn't a dependency — RuboCop is the real linter.
- **Dual form-builder stacks:** `app/components/shadcn/form_builder.rb` and `app/form_builders/admin/form_builder.rb`, plus a hand-rolled component layer in `app/helpers/components/*` rendering `app/views/components/ui/*`. Check which one a view uses before adding fields.
- The `/admin` namespace is the in-house rewrite that used to live at `/admin-new` (see the comment in `config/routes.rb`); older non-admin controllers still serve overlapping teacher-facing screens (e.g. both `ClassroomsController` and `Admin::ClassroomsController`).
- `config.load_defaults 8.0` while running Rails 8.1 — new 8.1 framework defaults are not enabled.
- `Order` includes `ApplicationHelper` (a view helper) just to call `format_money` inside validation messages.
- Repo state note: the working tree is on a **detached HEAD**, `app/.DS_Store` files show as deleted, and an untracked 15 MB `GITFOLDER.zip` sits in the project root.

View File

@@ -11,13 +11,12 @@ app is built and evolves.
## 📚 Index
- [Architecture — the system map](map/CLAUDE.md) — what the nouns are, how they move, and what a change hits
- [Architecture Overview](architecture/index.md)
- [Database seeds](seeds.md)
- [Background job scheduling](scheduling.md)
- [Schema](schema.md)
- [Orders And Transactions](orders-and-transactions.md)
- [GradeBook Earnings](gradebook-earnings.md)
- [Responsive Design Guidelines](responsive-design-guidelines.md)
- [Old site](old-site/README.md)
---

View File

@@ -1,50 +0,0 @@
# Stocks in the Future — system map
An edit map of this Rails app: what the nouns are, how they move, and what else moves
when you change one. **The app tree is the source of truth** — cards cite `path:line`
and never restate behaviour. Read a card, then read the source it points at.
Built on ICM: folders carry sequencing, hierarchy carries context, files carry state.
## Where things live
| Folder | What it holds |
|---|---|
| `objects/` | one card per noun, clustered by how an editor asks |
| `processes/` | the six movements that actually run |
| `effects/` | change-impact index — "changing X? open these cards" |
| `_meta/` | schema: the closed set of node types and labels |
| `_templates/` | blank object/process cards — a new card is a copy |
## Route by what you are doing
| If you are… | Go to | Then stop at |
|---|---|---|
| orienting cold | `CONTEXT.md` | universes + traps, then one card |
| asking "what is X?" | `objects/_index.md` | the one card it names |
| asking "how does X happen?" | `processes/CONTEXT.md` | the one movement card |
| about to change something | `effects/CONTEXT.md` | the cards it lists |
| checking coverage | `objects/_index.md` | `status:` column |
## Names that collide
Read this table before editing. Full detail and citations: `CONTEXT.md`.
| You will hear | It actually is |
|---|---|
| "SIF dollars" | `portfolio_transactions.amount_cents` — integer cents, no `Money` type |
| "balance" | derived, never stored. `portfolios` has **no cash column** |
| "grade" | two things: `Grade` = level 5–8; `GradeEntry#math_grade` = letter `"A+"`..`"F"` |
| "admin" | a boolean column, **not** an STI type. Only `Student`/`Teacher` are types |
| "log in" | by `username`, **not** email |
| "the student's classroom" | two rival paths: `users.classroom_id` **and** `classroom_enrollments` |
| "Stocks for Good" | same app. Code says `StocksInTheFuture` |
## The one rule
A card may be wrong; the source cannot. If a card and the code disagree, the code wins —
fix the card the same day and set `status: stale` if you cannot.
---
`AGENTS.md` and `routing.md` are generated copies of this file. Never hand-edit them —
edit `CLAUDE.md` and run `_meta/sync-twins.sh`.

View File

@@ -1,50 +0,0 @@
# Stocks in the Future — system map
An edit map of this Rails app: what the nouns are, how they move, and what else moves
when you change one. **The app tree is the source of truth** — cards cite `path:line`
and never restate behaviour. Read a card, then read the source it points at.
Built on ICM: folders carry sequencing, hierarchy carries context, files carry state.
## Where things live
| Folder | What it holds |
|---|---|
| `objects/` | one card per noun, clustered by how an editor asks |
| `processes/` | the six movements that actually run |
| `effects/` | change-impact index — "changing X? open these cards" |
| `_meta/` | schema: the closed set of node types and labels |
| `_templates/` | blank object/process cards — a new card is a copy |
## Route by what you are doing
| If you are… | Go to | Then stop at |
|---|---|---|
| orienting cold | `CONTEXT.md` | universes + traps, then one card |
| asking "what is X?" | `objects/_index.md` | the one card it names |
| asking "how does X happen?" | `processes/CONTEXT.md` | the one movement card |
| about to change something | `effects/CONTEXT.md` | the cards it lists |
| checking coverage | `objects/_index.md` | `status:` column |
## Names that collide
Read this table before editing. Full detail and citations: `CONTEXT.md`.
| You will hear | It actually is |
|---|---|
| "SIF dollars" | `portfolio_transactions.amount_cents` — integer cents, no `Money` type |
| "balance" | derived, never stored. `portfolios` has **no cash column** |
| "grade" | two things: `Grade` = level 5–8; `GradeEntry#math_grade` = letter `"A+"`..`"F"` |
| "admin" | a boolean column, **not** an STI type. Only `Student`/`Teacher` are types |
| "log in" | by `username`, **not** email |
| "the student's classroom" | two rival paths: `users.classroom_id` **and** `classroom_enrollments` |
| "Stocks for Good" | same app. Code says `StocksInTheFuture` |
## The one rule
A card may be wrong; the source cannot. If a card and the code disagree, the code wins —
fix the card the same day and set `status: stale` if you cannot.
---
`AGENTS.md` and `routing.md` are generated copies of this file. Never hand-edit them —
edit `CLAUDE.md` and run `_meta/sync-twins.sh`.

View File

@@ -1,107 +0,0 @@
# How to walk this map
One job: tell a cold agent which parts of the app are in force, which are decoration,
and which words mean two things — before it opens a card.
Verified against commit `63732df` (detached HEAD), 2026-08-16.
## The three universes
| Universe | Meaning |
|---|---|
| **live** | In force. Implement and cite against these. |
| **leftover** | Still present and still wired, but no longer the main path. Touch only if that path is in scope. |
| **ghost** | Named or filed, not wired. **Do not implement against these.** |
Everything in `objects/` and `processes/` is `live` unless its frontmatter says otherwise.
### Ghosts — present in the tree, unreachable
- **`SchoolsController` + `app/views/schools/*` (9 files).** No route reaches them.
`resources :schools` appears only inside `namespace :admin` (`config/routes.rb:58`),
which resolves to `Admin::SchoolsController`. Rails scaffold remnant. If you want to
change school admin, edit `app/controllers/admin/schools_controller.rb`.
- **`admin_v2` / `/admin-new`.** Survives only as a comment (`config/routes.rb:42`).
The in-house admin is the live `namespace :admin` at `/admin`.
### Leftovers — wired, but not the path to build on
- **`users.classroom_id` direct membership.** See the trap below — this one is
load-bearing, not dead.
- **`PortfolioTransaction.reason: grade_earnings`** (enum value 3). Marked
`# Deprecated, will be removed in future` at `app/models/portfolio_transaction.rb:13`
and referenced nowhere else. New earnings use `math_earnings` / `reading_earnings`.
- **`docs/old-site/`.** Training material for the predecessor site.
## The traps
Four places where the obvious reading of the code is wrong. Each one has bitten or is
likely to.
### 1. A student's classroom has two rival sources of truth
Both are wired **right now**:
| Path | Where | Used by |
|---|---|---|
| `users.classroom_id` | `db/schema.rb:374`, `app/models/user.rb:20` | `Classroom#students` (`app/models/classroom.rb:20`), `Order.for_teacher` (`app/models/order.rb:40-42`), `ApplicationController` redirects |
| `classroom_enrollments` | `app/models/classroom_enrollment.rb` | `Student#current_classrooms` (`app/models/student.rb:24`), `Classroom#current_students` (`app/models/classroom.rb:74`) |
`Student#primary_classroom` bridges them and falls back to `classroom_id`
(`app/models/student.rb:37-43`, commented "for backward compatibility"). `Student`
creation writes **both**: `create_initial_enrollment` fires only when `classroom_id` is
present (`app/models/student.rb:10,82-86`).
**Consequence:** a student enrolled only via `ClassroomEnrollment` is invisible to
`Classroom#students` and to teacher order scoping. Changing either path without the
other splits the roster. Start at `objects/org/classroom-enrollment.md`.
### 2. Money is integer cents — except in one method
Every column is cents (`amount_cents`, `price_cents`, `worth_cents`). But
`Portfolio#cash_balance` returns **dollars** as a float
(`app/models/portfolio.rb:16-18` → `cash_on_hand` → `/ 100.0` at `app/models/portfolio.rb:67-69`).
Callers must multiply back: `app/models/order.rb:137` does
`(user.portfolio&.cash_balance || 0) * 100`. Any new caller that forgets is off by 100×.
Detail: `objects/money/portfolio.md`.
### 3. Cash is never stored
`portfolios` has exactly three columns — `id`, `user_id`, timestamps
(`db/schema.rb:181-186`). There is no balance column. Every balance is a live SUM over
`portfolio_transactions`, **plus a subtraction for pending orders and their fee**
(`app/models/portfolio.rb:71-100`). Balance is therefore a function of open orders, not
just settled history.
### 4. One API key, two homes, two different failure modes
| Reader | Source | Missing-key behaviour |
|---|---|---|
| `AlphaVantageApiClient` | `ENV["ALPHA_VANTAGE_API_KEY"]`, default `nil` (`app/services/alpha_vantage_api_client.rb:11`) | logs an error and returns `nil` (`:31-36`) |
| `StockAttributeUpdate` | global `API_KEY` (`app/services/stock_attribute_update.rb:75`) from `config/initializers/api_keys.rb:1`, default `"test-api-key"` | silently queries Alpha Vantage with a junk key |
One fact, two homes. If you consolidate, `objects/trading/stock.md` lists what reads it.
## Name collisions, stated once
| Product word | Code name | Note |
|---|---|---|
| SIF dollars | `PortfolioTransaction#amount_cents` | integer cents; no `Money`/`BigDecimal` wrapper |
| balance / cash on hand | `Portfolio#cash_balance` | derived; returns **dollars** |
| grade (5th–8th) | `Grade`, `grades.level` | `app/models/grade.rb` |
| grade (A+…F) | `GradeEntry#math_grade`, `#reading_grade` | `app/models/grade_entry.rb:15` |
| admin | `users.admin` boolean | **not** an STI type (`app/models/user.rb:39,43`) |
| student / teacher | STI on `users.type` | `Student < User`, `Teacher < User` |
| holding / position | `PortfolioStock` rows vs `PortfolioPosition` | rows are append-only lots; the PORO is the aggregate |
| gradebook "finalize" | `GradeBook#verified!` then `completed!` | two statuses, one button |
| Stocks for Good | `StocksInTheFuture` | `docs/README.md:1` vs `config/application.rb:14` |
## Walking order
1. This file — universes and traps.
2. `objects/_index.md` — find the noun.
3. One card. Follow its `See:` link into the app tree.
4. Before editing: `effects/CONTEXT.md`.
Do not read the whole `objects/` folder. The index exists so you do not have to.

View File

@@ -1,35 +0,0 @@
#!/usr/bin/env bash
# Rebuild objects/_index.md from card frontmatter.
#
# The index is generated, never hand-edited: a hand-curated index drifts, a derived one
# cannot. Run after adding, moving, or re-verifying any object card.
set -euo pipefail
map_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
objects_dir="${map_dir}/objects"
out="${objects_dir}/_index.md"
field() { awk -v k="^$2:" '$0 ~ k { sub(/^[^:]*: */, ""); print; exit }' "$1"; }
{
echo "# Object index"
echo
echo "One line per noun. Open the card, not the folder."
echo
echo "_Generated by \`_meta/build-index.sh\` from card frontmatter. Do not hand-edit._"
echo
echo "| Noun | Cluster | Universe | Status | Owning file |"
echo "|---|---|---|---|---|"
find "$objects_dir" -name '*.md' ! -name '_index.md' ! -name 'CONTEXT.md' \
| sort | while read -r f; do
rel="${f#"${objects_dir}"/}"
name=$(awk '/^# /{ sub(/^# /, ""); print; exit }' "$f")
printf '| [%s](%s) | %s | %s | %s | `%s` |\n' \
"$name" "$rel" "$(field "$f" cluster)" "$(field "$f" universe)" \
"$(field "$f" status)" "$(field "$f" entity)"
done
} > "$out"
echo "wrote $out ($(grep -c '^| \[' "$out") cards)"

View File

@@ -1,54 +0,0 @@
# Schema — the rules of this map
The closed set of node types, the labels they carry, and the naming they follow. When
practice and this file disagree, reconcile the same day — schema drift is how maps rot.
## Node types
| `type:` | Lives at | Carries |
|---|---|---|
| object | `objects/<cluster>/<slug>.md` | one noun: why / shape / connected to / hits |
| process | `processes/<slug>.md` | one movement: input → movement → output |
That is the whole set. `effects/CONTEXT.md` is an index, not a node type — it holds no
facts of its own, only pointers into the two types above.
## Frontmatter
Object cards:
```yaml
type: object
cluster: identity | org | gradebook | money | trading | content
universe: live | leftover | ghost
status: stub | verified | stale
entity: app/models/order.rb # the file that owns the fact
```
Process cards add `consumes:` and `produces:` as relative links to object cards. Those
links draw the graph on their own — do not maintain a separate edge list.
## Label rules
- `universe: live` is the default. `leftover` and `ghost` must say why in the card body.
- `status: verified` requires **a date, a commit, and citations** in the card. A card
with no `path:line` may not be `verified`.
- `status: stale` is allowed and preferred over a confident wrong claim.
- `entity:` is one path. If a noun is owned by several files, the card's Shape section
lists them; `entity:` names the primary one.
## Naming
- Slugs: kebab-case, singular, matching the product word where it differs from the class
name (`grade-level.md` owns `Grade`).
- Clusters are the six above. Adding a seventh requires three nouns that genuinely do not
fit — not one that is merely new.
- `_meta/` and `_templates/` hold rules and blanks. Underscore = about the map, not of it.
- `AGENTS.md` and `routing.md` are generated from `CLAUDE.md` by `_meta/sync-twins.sh`.
Never hand-edited.
## Citation rule
Code is the source of truth. Cite `path:line`. If a comment and the code disagree, the
code wins and the card says so. Never paste behaviour into a card that the source
already states — point at it.

View File

@@ -1,18 +0,0 @@
#!/usr/bin/env bash
# Regenerate the entry-file twins from CLAUDE.md.
#
# CLAUDE.md is the only hand-edited entry file. AGENTS.md and routing.md are
# byte-identical copies so that tools which ignore CLAUDE.md still find the catalog.
# Run this after every edit to CLAUDE.md; CI-safe and idempotent.
set -euo pipefail
map_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
src="${map_dir}/CLAUDE.md"
[[ -f "$src" ]] || { echo "missing $src" >&2; exit 1; }
for twin in AGENTS.md routing.md; do
cp "$src" "${map_dir}/${twin}"
echo "wrote ${map_dir}/${twin}"
done

View File

@@ -1,50 +0,0 @@
#!/usr/bin/env bash
# Check every path:line citation in the map against the real tree.
#
# A card marked `verified` with a citation that no longer resolves is worse than no card,
# so this runs cheap and often. It checks two forms:
# `app/models/order.rb:137` full path from the repo root
# `:137-148` shorthand, resolved against the card's `entity:`
# It cannot tell you a citation points at the *wrong* line — only that the line exists.
set -uo pipefail
map_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
repo_root="$(cd "${map_dir}/../.." && pwd)"
refs=$(mktemp)
trap 'rm -f "$refs"' EXIT
while IFS= read -r card; do
rel="${card#"${repo_root}"/}"
entity=$(awk '/^entity:/ { sub(/^entity: */, ""); print; exit }' "$card")
grep -oE '[A-Za-z0-9_][A-Za-z0-9_./-]*\.(rb|yml|erb|md|js|sh|json):[0-9]+(-[0-9]+)?' "$card" \
| sort -u | while read -r ref; do
printf '%s\t%s\t%s\n' "$rel" "${ref%:*}" "${ref##*:}"
done >> "$refs"
if [[ -n "$entity" ]]; then
grep -oE '`:[0-9]+(-[0-9]+)?`' "$card" | tr -d '`' | sort -u | while read -r ref; do
printf '%s\t%s\t%s\n' "$rel" "$entity" "${ref#:}"
done >> "$refs"
fi
done < <(find "$map_dir" -name '*.md')
total=0; bad=0
while IFS=$'\t' read -r card path spec; do
total=$((total + 1))
full="${repo_root}/${path}"
if [[ ! -f "$full" ]]; then
echo "MISSING FILE ${card} -> ${path}"
bad=$((bad + 1)); continue
fi
last="${spec##*-}"
lines=$(wc -l < "$full")
if (( last > lines )); then
echo "LINE OUT OF RANGE ${card} -> ${path}:${spec} (file has ${lines} lines)"
bad=$((bad + 1))
fi
done < "$refs"
echo "checked ${total} citations, ${bad} broken"
[[ $bad -eq 0 ]]

View File

@@ -1,44 +0,0 @@
---
type: object
cluster: {identity | org | gradebook | money | trading | content}
universe: live
status: stub
entity: {path to the owning file}
---
# {Name}
{One sentence. If the product word and the class name differ, say both.}
## Why this shape
{The load-bearing why, not a field tour. What would break if it were the obvious shape
instead?}
## Shape
- {keys, constraints, or owning files}
Citations: `{path}:{line}`
## Connected to
- **owns:**
- **owned-by:**
- **joins:**
- **looks-like-but-is-not:**
## If you change this
- **Hits:**
- **Does not hit:**
## Surfaces
| Surface | Role |
|---|---|
| {who} | {reads / writes / none} |
## See
- Source: `{path}`

View File

@@ -1,39 +0,0 @@
---
type: process
universe: live
status: stub
consumes: []
produces: []
---
# {process-name}
{One sentence: the movement, not the nouns.}
## Input → Movement → Output
{Three sentences.}
## Why this shape
{What would break if the obvious shortcut existed.}
## Steps
1. {Cite `{path}:{line}`.}
## If you change this
- **Hits:**
- **Does not hit:**
## Surfaces
| Surface | Role |
|---|---|
| {who} | {role} |
## See
- Objects: {links}
- Source: `{path}`

View File

@@ -1,66 +0,0 @@
# effects — if you are changing X, open these
One job: turn "I am about to change X" into a short list of cards to read first. This file
is an **index only**. It holds no facts — if it disagrees with a card, the card is right
and this file is stale.
## Inputs
- Reference: `../objects/_index.md`, `../processes/CONTEXT.md`
- Reference: `../CONTEXT.md` — read the traps before any change to money or rosters
## Read first, always
Four things are true of this codebase and wrong in most people's mental model. All four
are in `../CONTEXT.md`:
1. A student's classroom has **two** rival sources of truth.
2. Money is integer cents — except `Portfolio#cash_balance`, which returns dollars.
3. Cash is never stored; the balance includes **pending** orders.
4. The Alpha Vantage key is read two different ways with two different fallbacks.
## By what you are changing
| If you are changing… | Open | Then check |
|---|---|---|
| **anything with a balance** | [portfolio](../objects/money/portfolio.md), [portfolio-transaction](../objects/money/portfolio-transaction.md) | every `_cents` vs dollars boundary; pending-order subtraction |
| **order placement or execution** | [order](../objects/trading/order.md), [place-and-execute-order](../processes/place-and-execute-order.md) | model validations *and* `ExecuteOrder` — they duplicate each other |
| **the trading fee** | [portfolio-transaction](../objects/money/portfolio-transaction.md), [order](../objects/trading/order.md) | fee is per user **per sweep**, and `Portfolio` anticipates exactly one |
| **holdings or share counts** | [portfolio-stock](../objects/trading/portfolio-stock.md), [portfolio-position](../objects/trading/portfolio-position.md) | lots are append-only; sells are negative rows |
| **stock prices or the API** | [stock](../objects/trading/stock.md), [refresh-market-data](../processes/refresh-market-data.md) | the two key lookups; the six auto-overwritten columns |
| **payout amounts** | [grade-entry](../objects/gradebook/grade-entry.md), [finalize-gradebook-earnings](../processes/finalize-gradebook-earnings.md) | constants are code, not config; `GRADE_OPTIONS` order is load-bearing |
| **gradebook workflow or status** | [grade-book](../objects/gradebook/grade-book.md) | the `completed?` guard is the only double-pay protection; finalize is **admin-only** |
| **rosters or enrollment** | [classroom-enrollment](../objects/org/classroom-enrollment.md), [classroom](../objects/org/classroom.md), [student](../objects/identity/student.md) | both roster paths, every time |
| **the school-year skeleton** | [school-year](../objects/org/school-year.md), [quarter](../objects/org/quarter.md), [year](../objects/org/year.md) | the two auto-create cascades; the `"YYYY - YYYY"` string format |
| **login, roles, or permissions** | [user](../objects/identity/user.md), [authenticate-authorize](../processes/authenticate-authorize.md) | username-not-email; `/admin` bypasses Pundit; `verify_authorized` is off |
| **a scheduled job** | [processes/CONTEXT.md](../processes/CONTEXT.md), `config/recurring.yml` | jobs bypass authorization entirely |
| **charts or history** | [portfolio-snapshot](../objects/trading/portfolio-snapshot.md), [snapshot-portfolio-worth](../processes/snapshot-portfolio-worth.md) | history is unrecoverable if a month is missed |
| **creating students in bulk** | [import-students](../processes/import-students.md), [student](../objects/identity/student.md) | `classroom_id` drives the enrollment callback |
| **announcements** | [announcement](../objects/announcement.md) | `body` column is dead; content is Action Text |
## Changes with a wider blast radius than they look
| Change | Why it spreads |
|---|---|
| `Portfolio#cash_balance` return unit | every caller converts by hand; there is no shared money type |
| `GradeEntry::GRADE_OPTIONS` order | improvement bonuses compare array indices |
| `users.classroom_id` | still joined by `Order.for_teacher`, `Classroom#students`, and redirects |
| `PortfolioTransaction` enum values | integer-backed; renumbering rewrites the meaning of existing rows |
| `Year#name` format | SQL ordering and quarter navigation both parse the string |
| adding a `before_action` to `ApplicationController` | runs on `/admin` too — `Admin::BaseController` inherits it |
## Changes that are safer than they look
| Change | Why it is contained |
|---|---|
| editing a gradebook after finalize | deposits carry no link back; nothing recomputes |
| archiving a stock | sells still work; only buying is blocked |
| discarding a user | the ledger is untouched and still sums |
| deleting snapshots | charts break, balances do not |
| editing `app/controllers/schools_controller.rb` | it is a **ghost** — no route reaches it |
## Human check
After a change, re-read the **Does not hit** line of every card you opened. That line is
the one most likely to have gone stale, and a wrong "does not hit" is more expensive than
a missing card.

View File

@@ -1,48 +0,0 @@
# 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

@@ -1,29 +0,0 @@
# 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

@@ -1,74 +0,0 @@
---
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

@@ -1,76 +0,0 @@
---
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

@@ -1,90 +0,0 @@
---
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

@@ -1,72 +0,0 @@
---
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

@@ -1,67 +0,0 @@
---
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

@@ -1,78 +0,0 @@
---
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

@@ -1,76 +0,0 @@
---
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

@@ -1,90 +0,0 @@
---
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

@@ -1,91 +0,0 @@
---
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

@@ -1,81 +0,0 @@
---
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

@@ -1,83 +0,0 @@
---
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

@@ -1,73 +0,0 @@
---
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

@@ -1,68 +0,0 @@
---
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

@@ -1,68 +0,0 @@
---
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

@@ -1,59 +0,0 @@
---
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

@@ -1,68 +0,0 @@
---
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

@@ -1,96 +0,0 @@
---
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

@@ -1,79 +0,0 @@
---
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

@@ -1,79 +0,0 @@
---
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

@@ -1,81 +0,0 @@
---
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

@@ -1,95 +0,0 @@
---
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`

View File

@@ -1,45 +0,0 @@
# processes — the movements
One job: hold one card per movement that **actually runs**. Six do. Nothing here is
aspirational; if a card describes something that no scheduler, controller, or human
triggers, it does not belong in this folder.
## Inputs
- Reference (every read): `../CONTEXT.md` — universes and traps
- Reference (every write): `../_meta/schema.md`, `../_templates/process.md`
- Working: `config/recurring.yml`, `app/jobs/`, `app/services/`, `app/controllers/`
## The six movements
| Card | Trigger | Runs |
|---|---|---|
| [authenticate-authorize](authenticate-authorize.md) | every request | Devise (username) → Pundit → admin gate |
| [place-and-execute-order](place-and-execute-order.md) | student, then cron `*/15 * * * *` | `Order` → `OrderExecutionJob` → `ExecuteOrder` → fees |
| [finalize-gradebook-earnings](finalize-gradebook-earnings.md) | **admin** clicks Finalize | `verified!` → `DistributeEarnings` → deposits |
| [refresh-market-data](refresh-market-data.md) | cron nightly + weekly | Alpha Vantage → `Stock` |
| [snapshot-portfolio-worth](snapshot-portfolio-worth.md) | cron month-end | `Portfolio` → `PortfolioSnapshot` |
| [import-students](import-students.md) | admin uploads CSV | `BulkStudentImportService` → `Student` + `Portfolio` |
Four of the six are scheduled, not user-driven. The schedule is one file —
`config/recurring.yml` — and it is the fastest way to see what this app does on its own.
## Process
1. Copy `../_templates/process.md`.
2. Write Input → Movement → Output in three sentences before writing any steps.
3. Number the steps and cite `path:line` on each. Do not restate what the source says —
point at it.
4. Fill `consumes:` / `produces:` with links to object cards. Those links are the graph;
there is no separate edge list to maintain.
5. Fill Hits / Does not hit, first-order only.
## Outputs
- One card per movement, in this folder
## Human check
Read the Steps aloud against the source file open beside you. If a step describes a
behaviour the code does not have — or skips a guard clause that changes the outcome —
fix it now. A wrong movement card sends an agent to the wrong file.

View File

@@ -1,93 +0,0 @@
---
type: process
universe: live
status: verified
consumes: ["../objects/identity/user.md", "../objects/trading/stock.md"]
produces: []
---
# authenticate-authorize
Every request proves who you are with Devise, then proves you may act with Pundit.
Verified 2026-08-16 against commit `63732df`.
## Input → Movement → Output
A request arrives with a session cookie. `ApplicationController` authenticates it by
**username**, loads the navbar's stock list, and the controller action asks a Pundit
policy whether this user may proceed. The action runs, or a `rescue_from` redirects the
user somewhere they are allowed to be.
## Why this shape
**Authorization is opt-in, per action.** Pundit's `verify_authorized` after-action is not
enabled anywhere in the app — a controller that never calls `authorize` is simply not
authorized, and nothing complains. Two consequences are live today; see Gaps below.
The admin area does not use Pundit at all. `Admin::BaseController` has its own
`before_action :authenticate_admin` that redirects unless `current_user&.admin?`
(`app/controllers/admin/base_controller.rb:9,13-15`). So `/admin` is guarded by one line,
not by policies, and adding a policy will not protect an admin controller.
The `rescue_from` sends users somewhere sensible instead of a 403 — students go to their
own portfolio, everyone else to root (`app/controllers/application_controller.rb:31-40`).
That is why an authorization failure often looks like a redirect loop rather than an
error.
## Steps
1. `before_action :authenticate_user!` on every controller
(`app/controllers/application_controller.rb:6`). Devise matches on `username`, not
email (`config/initializers/devise.rb:49`).
2. `before_action :set_navbar_stocks` runs `policy_scope(Stock).active` on **every**
request (`app/controllers/application_controller.rb:8,22-24`) — `StockPolicy::Scope`
returns `scope.all` (`app/policies/stock_policy.rb:52-56`).
3. Under `/admin`, `authenticate_admin` redirects non-admins
(`app/controllers/admin/base_controller.rb:13-15`).
4. Elsewhere, the action calls `authorize record` or `policy_scope(Model)`. Role helpers
live on the base policy (`app/policies/application_policy.rb:39-53`).
5. On `Pundit::NotAuthorizedError`, redirect by role
(`app/controllers/application_controller.rb:31-40`).
## Gaps worth knowing
Stated as found, not as a recommendation:
- **`OrdersController#edit` and `#update` never authorize.** `set_order` is an unscoped
`Order.find` (`app/controllers/orders_controller.rb:4,66-68`) and only `cancel` calls
`authorize` (`:50`). `OrderPolicy` defines `update?` (`app/policies/order_policy.rb:12-14`),
but nothing invokes it.
- **`GradeBookPolicy#finalize?` is `user.admin?`** (`app/policies/grade_book_policy.rb:12-14`).
Teachers may `show` and `update` a gradebook but **cannot finalize it** — only admins
release earnings.
- **`ClassroomPolicy::Scope` does not inherit `ApplicationPolicy::Scope`**
(`app/policies/classroom_policy.rb:40-57`) and returns a bare `[]` rather than
`scope.none` for non-teachers — an Array where callers expect a relation.
- **`OrdersController#destroy` is defined below `private`** (`:59,105-112`), so the routed
`DELETE /orders/:id` cannot dispatch to it. `unauthorized_response` (`:83-88`) is never
called.
## If you change this
- **Hits:** every controller — this is the one movement with no local blast radius;
`ApplicationController`, `Admin::BaseController`, all six policies;
[user](../objects/identity/user.md) if you touch the auth key.
- **Does not hit:** the four scheduled jobs. `OrderExecutionJob`,
`StockPricesUpdateJob`, `StockAttributeUpdateJob` and
`MonthlyPortfolioSnapshotJob` run with no `current_user` and never consult a policy —
tightening authorization cannot break them, and cannot protect them either.
## Surfaces
| Surface | Role |
|---|---|
| every request | authenticated |
| `/admin/*` | admin boolean gate, not Pundit |
| Solid Queue jobs | bypass entirely |
## See
- Objects: [user](../objects/identity/user.md), [stock](../objects/trading/stock.md)
- Source: `app/controllers/application_controller.rb`,
`app/controllers/admin/base_controller.rb`, `app/policies/`

View File

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

View File

@@ -1,103 +0,0 @@
---
type: process
universe: live
status: verified
consumes: ["../objects/org/classroom.md"]
produces: ["../objects/identity/student.md", "../objects/money/portfolio.md", "../objects/org/classroom-enrollment.md"]
---
# import-students
An admin uploads a CSV and gets students with generated passwords, portfolios, and
enrollments.
Verified 2026-08-16 against commit `63732df`.
## Input → Movement → Output
An admin posts a CSV of `classroom_id,username` pairs. `BulkStudentImportService` walks
the rows and hands each to `ImportStudentService`, which creates a
[student](../objects/identity/student.md) with a generated password. Each successful
create cascades into a [portfolio](../objects/money/portfolio.md) and a primary
[classroom-enrollment](../objects/org/classroom-enrollment.md) via `Student`'s callbacks.
The admin is redirected with per-line counts.
## Why this shape
**Skip is a success, not a failure.** `ImportStudentService::Result` has three actions —
`created`, `skipped`, `failed` — and a skip returns `success?: true`
(`app/services/import_student_service.rb:4,35-42`). Duplicate usernames, blank usernames,
and blank classroom IDs are all skips (`:25-27`), so re-uploading the same file is safe
and reports zero new students rather than erroring.
**Line numbers start at 2.** `with_index(2)` accounts for the header row
(`app/services/bulk_student_import_service.rb:11`), so reported numbers match what the
admin sees in a spreadsheet.
**Passwords are generated, never chosen.** `MemorablePasswordGenerator` concatenates two
Faker superhero names and a number, stripping spaces, hyphens, and apostrophes
(`app/services/memorable_password_generator.rb:8-19`) — memorable enough for a
middle-schooler to type. `faker` is therefore a **production** dependency (`Gemfile:13`),
not a test one. The file carries its own `TODO: more robust solution later` (`:3`).
**The whole import hangs on `classroom_id` being present**, because
`Student#create_initial_enrollment` only fires when it is
(`app/models/student.rb:10,82-86`). A row without it is skipped outright, which is what
keeps enrollment-less students out of the system by this path.
## Steps
1. Admin posts to `POST /admin/students/import` (`config/routes.rb:63`);
`Admin::StudentsController#import` rejects a blank file
(`app/controllers/admin/students_controller.rb:117-118`).
2. `BulkStudentImportService.import_from_csv` reads with `headers: true`
(`app/services/bulk_student_import_service.rb:8,11`).
3. Rows missing either field are dropped **before** the service is called and produce no
result at all (`:15`) — they are invisible in the summary counts.
4. `ImportStudentService.call` strips whitespace, then skips on blank username, existing
username, or blank classroom ID (`app/services/import_student_service.rb:22-28`).
5. `Student.new(username:, classroom_id:, password: MemorablePasswordGenerator.generate)`
and save (`:38-46`). `ActiveRecord::InvalidForeignKey` — a classroom ID that does not
exist — is rescued into a `failed` result (`:50-52`).
6. Saving triggers `Student` callbacks: `ensure_portfolio` and
`create_initial_enrollment` (`app/models/student.rb:9-10`).
7. Results are wrapped with line numbers (`:22`) and partitioned for the flash message
(`app/controllers/admin/students_controller.rb:182,199`).
8. `GET /admin/students/template` downloads a sample CSV
(`app/services/bulk_student_import_service.rb:28-36`).
Malformed CSV is caught at the controller and reported
(`app/controllers/admin/students_controller.rb:123`).
## If you change this
- **Hits:** [student](../objects/identity/student.md) creation and both of its callbacks;
[portfolio](../objects/money/portfolio.md) and
[classroom-enrollment](../objects/org/classroom-enrollment.md), created as a side
effect; `Admin::StudentsController`.
- **Does not hit:** [grade-book](../objects/gradebook/grade-book.md) or
[grade-entry](../objects/gradebook/grade-entry.md). Importing students does **not**
create gradebook entries for them — gradebooks are created per classroom, and nothing
backfills entries for students who arrive afterwards.
## Notes
- There is no transaction around the batch. A CSV that fails halfway leaves the earlier
students created.
- The import is row-at-a-time with a `Student.exists?` query per row; large files are
slow but bounded.
## Surfaces
| Surface | Role |
|---|---|
| `Admin::StudentsController#import` / `#template` | admin writes |
| `BulkStudentImportService`, `ImportStudentService` | create |
| `MemorablePasswordGenerator` | generates credentials |
## See
- Objects: [student](../objects/identity/student.md),
[classroom-enrollment](../objects/org/classroom-enrollment.md)
- Source: `app/services/bulk_student_import_service.rb`,
`app/services/import_student_service.rb`

View File

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

View File

@@ -1,99 +0,0 @@
---
type: process
universe: live
status: verified
consumes: []
produces: ["../objects/trading/stock.md"]
---
# refresh-market-data
Two scheduled jobs pull from Alpha Vantage and overwrite
[stock](../objects/trading/stock.md) columns. **The app's only outbound integration.**
Verified 2026-08-16 against commit `63732df`.
## Input → Movement → Output
On a schedule, the app walks every stock and calls Alpha Vantage — nightly for prices,
weekly for company attributes. Each response overwrites columns on the `stocks` row.
Nothing else in the app ever calls the API: all valuations read these cached columns.
## Why this shape
**Two jobs, two endpoints, two key lookups.** Prices use `GLOBAL_QUOTE` through
`AlphaVantageApiClient`, which reads `ENV["ALPHA_VANTAGE_API_KEY"]` with a `nil` default
and logs an error if it is missing (`app/services/alpha_vantage_api_client.rb:11,31-36,47`).
Attributes use `OVERVIEW` through `StockAttributeUpdate`, which reads the **global
constant** `API_KEY` — defaulting to the literal `"test-api-key"`
(`app/services/stock_attribute_update.rb:75`, `config/initializers/api_keys.rb:1`). With
no key configured, the price job goes quiet and the attribute job queries with a junk key.
See the trap in `../CONTEXT.md`.
**Free-tier rate limiting is a `sleep`.** `StockPricesUpdateJob` sleeps 1.1 seconds
between stocks (`app/jobs/stock_prices_update_job.rb:20`), so the job's runtime is
roughly 1.1 × the number of stocks and it holds a worker the whole time.
**`yesterday_price_cents` is set before the fetch, not after.** The job assigns
`yesterday = current` and then tries to fetch (`:53-58`). If the fetch fails it saves
anyway (`:72-76`), making yesterday equal today and forcing
`Stock#percentage_change` to 0% (`app/models/stock.rb:29-33`) — a failed fetch shows as
"no movement", not as an error. If the trading day is not newer, it returns without
saving (`:64-67`) and the assignment is discarded.
## Steps
### Prices — nightly, Mon–Fri 21:00 ET (`0 2 * * 2-6` UTC)
1. Scheduled at `config/recurring.yml:8-13`; retries 3× with backoff
(`app/jobs/stock_prices_update_job.rb:6`).
2. Return immediately if there are no stocks (`:12-15`).
3. Per stock: skip blank tickers (`:46-51`), open a transaction, set
`yesterday_price_cents = price_cents` (`:53-58`).
4. `AlphaVantageApiClient#fetch_quote` parses `Global Quote → 05. price` and
`07. latest trading day` (`app/services/alpha_vantage_api_client.rb:50-61`). All
errors are rescued to `nil` (`:21-27`).
5. Update only if the trading day is newer than `last_trading_day` (`:78-80`); convert
dollars to cents and save (`:82-88`).
6. `sleep(1.1)` and continue (`:20`).
### Attributes — weekly, Saturday 23:00 ET (`0 4 * * 6` UTC)
7. Scheduled at `config/recurring.yml:22-26`; no retry configured.
8. Per stock, `StockAttributeUpdate.execute` fetches `OVERVIEW` and overwrites six fields:
`company_name`, `description`, `stock_exchange`, `industry`, `company_website`,
`profit_margin` (`app/services/stock_attribute_update.rb:62-72`).
## If you change this
- **Hits:** [stock](../objects/trading/stock.md) prices, and therefore
[portfolio](../objects/money/portfolio.md)`#holdings_value_cents`,
[portfolio-position](../objects/trading/portfolio-position.md) gain/loss, and every
order's `purchase_cost` at the next execution;
[snapshot-portfolio-worth](snapshot-portfolio-worth.md), which values holdings at
month end using whatever these jobs last wrote.
- **Does not hit:** [portfolio-transaction](../objects/money/portfolio-transaction.md) or
[portfolio-stock](../objects/trading/portfolio-stock.md). Settled history stores the
cents paid at the time — re-pricing revalues holdings but never rewrites a completed
trade.
## Failure modes seen in the code
- Admin edits to any of the six attribute fields are **silently reverted** every Saturday.
- A failed price fetch is indistinguishable from a flat day (see Why).
- `StockAttributeUpdate` has no retry and no rate-limit sleep, unlike the price job.
## Surfaces
| Surface | Role |
|---|---|
| Alpha Vantage (`alphavantage.co`) | external, read |
| `StockPricesUpdateJob`, `StockAttributeUpdateJob` | write |
| every price shown in the app | reads the cache |
## See
- Objects: [stock](../objects/trading/stock.md)
- Source: `app/jobs/stock_prices_update_job.rb`,
`app/services/alpha_vantage_api_client.rb`, `app/services/stock_attribute_update.rb`
- Schedule: `config/recurring.yml`, `docs/scheduling.md`

View File

@@ -1,79 +0,0 @@
---
type: process
universe: live
status: verified
consumes: ["../objects/money/portfolio.md", "../objects/trading/portfolio-stock.md", "../objects/trading/stock.md"]
produces: ["../objects/trading/portfolio-snapshot.md"]
---
# snapshot-portfolio-worth
Once a month, freeze every portfolio's total worth. **This is the only way history is
recorded anywhere in the app.**
Verified 2026-08-16 against commit `63732df`.
## Input → Movement → Output
On the last day of each month at 23:00, the job walks every
[portfolio](../objects/money/portfolio.md) in batches, computes cash plus holdings at
current prices, and writes one
[portfolio-snapshot](../objects/trading/portfolio-snapshot.md) row per portfolio. The
student's chart reads the last twelve of these.
## Why this shape
Nothing else stores the past. Balances are summed live, holdings are summed live, and
[stock](../objects/trading/stock.md) keeps only today's and yesterday's price — so a
missed month is **unrecoverable**, not merely delayed. Re-running the job later would
value that month at today's prices.
**Idempotence is asserted three times**: a unique index on `[portfolio_id, date]`
(`db/schema.rb:154`), a model validation
(`app/models/portfolio_snapshot.rb:8`), and an `exists?` check in the job
(`app/jobs/monthly_portfolio_snapshot_job.rb:22`). Re-running on the same day is safe and
is the correct recovery action if the run failed partway.
**One bad portfolio cannot abort the run.** `RecordInvalid` is rescued and logged per
portfolio (`:30-31`), so the loop continues. The most likely trigger is a negative worth
— `worth_cents` is validated `>= 0` — which happens if a student's cash is overdrawn.
Those portfolios are simply absent from that month, leaving a gap in the chart.
## Steps
1. Cron `0 23 L * *` — last day of the month, 23:00 (`config/recurring.yml:15-19`).
2. `perform(target_date = Date.current, batch_size = 1000)`
(`app/jobs/monthly_portfolio_snapshot_job.rb:6`). The schedule passes no arguments, so
the date is always "today".
3. `Portfolio.includes(:portfolio_stocks, :stocks).find_in_batches` — eager-loaded to
avoid N+1 on valuation (`:11-14`).
4. Skip if a snapshot already exists for that portfolio and date (`:22`).
5. `portfolio.calculate_total_value_cents` = cash on hand + holdings at current
`price_cents` (`app/models/portfolio.rb:32-34,48-52`).
6. Create the row; rescue and log `RecordInvalid` (`:26-31`).
Every portfolio is snapshotted, including empty ones and those of discarded users.
## If you change this
- **Hits:** [portfolio-snapshot](../objects/trading/portfolio-snapshot.md);
`Portfolio#chart_data` and the `portfolio_chart` Stimulus controller — the chart shows
the last 12 rows, so changing the cadence changes the window it covers
(`app/models/portfolio.rb:54-63`).
- **Does not hit:** any balance, holding, or ledger row. This job is **write-only into
snapshots** and read-only everywhere else — it cannot corrupt a student's money, and
deleting every snapshot would lose all charts while leaving every balance correct.
## Surfaces
| Surface | Role |
|---|---|
| `MonthlyPortfolioSnapshotJob` (Solid Queue, month-end) | writes |
| student portfolio chart | reads |
## See
- Objects: [portfolio](../objects/money/portfolio.md),
[portfolio-snapshot](../objects/trading/portfolio-snapshot.md)
- Source: `app/jobs/monthly_portfolio_snapshot_job.rb`
- Schedule: `config/recurring.yml:15-19`, `docs/scheduling.md`

View File

@@ -1,50 +0,0 @@
# Stocks in the Future — system map
An edit map of this Rails app: what the nouns are, how they move, and what else moves
when you change one. **The app tree is the source of truth** — cards cite `path:line`
and never restate behaviour. Read a card, then read the source it points at.
Built on ICM: folders carry sequencing, hierarchy carries context, files carry state.
## Where things live
| Folder | What it holds |
|---|---|
| `objects/` | one card per noun, clustered by how an editor asks |
| `processes/` | the six movements that actually run |
| `effects/` | change-impact index — "changing X? open these cards" |
| `_meta/` | schema: the closed set of node types and labels |
| `_templates/` | blank object/process cards — a new card is a copy |
## Route by what you are doing
| If you are… | Go to | Then stop at |
|---|---|---|
| orienting cold | `CONTEXT.md` | universes + traps, then one card |
| asking "what is X?" | `objects/_index.md` | the one card it names |
| asking "how does X happen?" | `processes/CONTEXT.md` | the one movement card |
| about to change something | `effects/CONTEXT.md` | the cards it lists |
| checking coverage | `objects/_index.md` | `status:` column |
## Names that collide
Read this table before editing. Full detail and citations: `CONTEXT.md`.
| You will hear | It actually is |
|---|---|
| "SIF dollars" | `portfolio_transactions.amount_cents` — integer cents, no `Money` type |
| "balance" | derived, never stored. `portfolios` has **no cash column** |
| "grade" | two things: `Grade` = level 5–8; `GradeEntry#math_grade` = letter `"A+"`..`"F"` |
| "admin" | a boolean column, **not** an STI type. Only `Student`/`Teacher` are types |
| "log in" | by `username`, **not** email |
| "the student's classroom" | two rival paths: `users.classroom_id` **and** `classroom_enrollments` |
| "Stocks for Good" | same app. Code says `StocksInTheFuture` |
## The one rule
A card may be wrong; the source cannot. If a card and the code disagree, the code wins —
fix the card the same day and set `status: stale` if you cannot.
---
`AGENTS.md` and `routing.md` are generated copies of this file. Never hand-edit them —
edit `CLAUDE.md` and run `_meta/sync-twins.sh`.