convert pdf to md, add OVERVIEW and docs

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

View File

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

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

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

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

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

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

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