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

View File

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

View File

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