convert pdf to md, add OVERVIEW and docs
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
---
|
||||
type: object
|
||||
cluster: money
|
||||
universe: live
|
||||
status: verified
|
||||
entity: app/models/earnings_summary.rb
|
||||
---
|
||||
|
||||
# EarningsSummary
|
||||
|
||||
A plain Ruby object (**not** an Active Record model) that totals a
|
||||
[portfolio](portfolio.md)'s earnings by reason for the "where did my money come from"
|
||||
panel.
|
||||
|
||||
Verified 2026-08-16 against commit `63732df`.
|
||||
|
||||
## Why this shape
|
||||
|
||||
It lives in `app/models/` but has no table and no superclass
|
||||
(`app/models/earnings_summary.rb:3`). It wraps a portfolio and runs one grouped sum per
|
||||
reason (`:36-41`) — five queries per render, deliberately simple rather than a single
|
||||
grouped query, because it is only ever built for one student at a time.
|
||||
|
||||
**Known defect — `transaction_fees_cents` always returns 0.** `sum_by_reason` filters
|
||||
`.deposits`, i.e. `transaction_type: :deposit` (`:38`), but fee rows are written with
|
||||
`transaction_type: :fee` by `TransactionFeeProcessor`
|
||||
(`app/services/transaction_fee_processor.rb:26-29`). The two never intersect, so
|
||||
`transaction_fees_cents` (`:30-32`) sums an empty set. It is rendered to students as
|
||||
"Transaction Fees" at `app/views/portfolios/_earnings_summary_card.html.erb:22`, where it
|
||||
always shows $0.00. The fix is to drop `.deposits` for that one reason — but note that
|
||||
`total_earnings_cents` (`:26-28`) deliberately excludes fees, so changing `sum_by_reason`
|
||||
wholesale would alter the total too.
|
||||
|
||||
## Shape
|
||||
|
||||
- PORO; `initialize(portfolio)` (`:6-8`)
|
||||
- Readers, all in **cents**: `attendance_earnings_cents`, `reading_earnings_cents`,
|
||||
`math_earnings_cents`, `awards_cents`, `total_earnings_cents`,
|
||||
`transaction_fees_cents` (`:10-32`)
|
||||
- `total_earnings_cents` = attendance + reading + math + awards (`:26-28`). Fees are
|
||||
**not** subtracted.
|
||||
- No caching, no memoization — each reader hits the database
|
||||
|
||||
It covers four of the seven `reason` values. `administrative_adjustments` and
|
||||
`transaction_fees` are not part of the total; `grade_earnings` is leftover.
|
||||
|
||||
## Connected to
|
||||
|
||||
- **owns:** —
|
||||
- **owned-by:** [portfolio](portfolio.md) (by construction, not by association)
|
||||
- **joins:** reads [portfolio-transaction](portfolio-transaction.md)
|
||||
- **looks-like-but-is-not:** not an Active Record model — `EarningsSummary.find` and any
|
||||
scope or callback do not exist. It also is **not** the balance: it counts income only
|
||||
and ignores debits, credits, and withdrawals entirely.
|
||||
|
||||
## If you change this
|
||||
|
||||
- **Hits:** `PortfoliosController#show` (`app/controllers/portfolios_controller.rb:10`)
|
||||
and `Admin::StudentsController#show`
|
||||
(`app/controllers/admin/students_controller.rb:21`); the two views that render it —
|
||||
`app/views/portfolios/_earnings_summary_card.html.erb` and
|
||||
`app/views/admin/students/show.html.erb:85-100`.
|
||||
- **Does not hit:** [portfolio](portfolio.md)`#cash_balance`. This class is read-only and
|
||||
entirely parallel to the balance calculation — correcting the fee bug here changes a
|
||||
displayed figure, not anyone's spendable money.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Surface | Role |
|
||||
|---|---|
|
||||
| `PortfoliosController#show` | student/teacher read |
|
||||
| `Admin::StudentsController#show` | admin read |
|
||||
|
||||
## See
|
||||
|
||||
- Source: `app/models/earnings_summary.rb`
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
type: object
|
||||
cluster: money
|
||||
universe: live
|
||||
status: verified
|
||||
entity: app/models/portfolio_transaction.rb
|
||||
---
|
||||
|
||||
# PortfolioTransaction
|
||||
|
||||
One line in the ledger. **The only place SIF dollars actually exist** — every balance in
|
||||
the app is a sum over these rows.
|
||||
|
||||
Verified 2026-08-16 against commit `63732df`.
|
||||
|
||||
## Why this shape
|
||||
|
||||
**`amount_cents` is always positive; direction lives in `transaction_type`.** There is no
|
||||
signed amount. `Portfolio#cash_on_hand_in_cents` adds `credits + deposits` and subtracts
|
||||
`debits + withdrawals + fees` (`app/models/portfolio.rb:71-75`). A row written with a
|
||||
negative `amount_cents` would pass validation — the column is only `null: false`
|
||||
(`db/schema.rb:171`) — and quietly invert its own meaning. Nothing guards this.
|
||||
|
||||
**The five types split into two vocabularies**, as the comment at
|
||||
`app/models/portfolio_transaction.rb:5-6` says:
|
||||
|
||||
| Type | Meaning | Written by |
|
||||
|---|---|---|
|
||||
| `deposit` | cash in from grades/attendance | `DistributeEarnings`, admin |
|
||||
| `withdrawal` | cash out | admin |
|
||||
| `credit` | proceeds of a **sell** | `ExecuteOrder` |
|
||||
| `debit` | cost of a **buy** | `ExecuteOrder` |
|
||||
| `fee` | the $1.00 trading fee | `TransactionFeeProcessor` |
|
||||
|
||||
So `deposit`/`withdrawal` are cash movements and `credit`/`debit` are stock movements —
|
||||
not accounting-standard usage, and easy to get backwards.
|
||||
|
||||
`TRANSACTION_FEE_CENTS = 1_00` (`:4`) is defined here but consumed mostly by
|
||||
[order](../trading/order.md) and `TransactionFeeProcessor`. It is charged **once per user
|
||||
per execution batch**, not once per order (`app/services/transaction_fee_processor.rb:24,30`),
|
||||
and [portfolio](portfolio.md) anticipates exactly one pending fee to match
|
||||
(`app/models/portfolio.rb:98-100`).
|
||||
|
||||
## Shape
|
||||
|
||||
- Table `portfolio_transactions`, `db/schema.rb:170-179`
|
||||
- `amount_cents` integer, `null: false`; `transaction_type` integer, `null: false`;
|
||||
`reason` integer, nullable; `description` text
|
||||
- `enum :transaction_type` — deposit/withdrawal/credit/debit/fee (`:7`)
|
||||
- `enum :reason, allow_nil: true` — math/reading/attendance earnings, transaction fees,
|
||||
awards, administrative adjustments (`:9-17`)
|
||||
- `belongs_to :portfolio`; `has_one :order, dependent: :destroy` (`:19-20`)
|
||||
- Scopes mirror the types (`:22-26`)
|
||||
|
||||
`reason: grade_earnings` (value 3) is **leftover** — marked deprecated at `:13` and
|
||||
referenced nowhere else.
|
||||
|
||||
## Connected to
|
||||
|
||||
- **owns:** [order](../trading/order.md) — via `has_one ... dependent: :destroy`
|
||||
- **owned-by:** [portfolio](portfolio.md)
|
||||
- **joins:** —
|
||||
- **looks-like-but-is-not:** a `fee` row is **not** a `deposit`, which is why
|
||||
[earnings-summary](earnings-summary.md)`#transaction_fees_cents` never finds one.
|
||||
|
||||
## If you change this
|
||||
|
||||
- **Hits:** every balance and total in [portfolio](portfolio.md) — they are pure sums
|
||||
over these rows; [earnings-summary](earnings-summary.md);
|
||||
`Classroom.order_by_total_earnings`, which joins straight to this table
|
||||
(`app/models/classroom.rb:41-49`); `Admin::PortfolioTransactionsController` and the
|
||||
admin `add_transaction` action.
|
||||
- **Does not hit:** [portfolio-stock](../trading/portfolio-stock.md). Cash and shares are
|
||||
written by `ExecuteOrder` in the same database transaction
|
||||
(`app/services/execute_order.rb:28-32`) but are otherwise independent — deleting a
|
||||
ledger row does not remove the shares it paid for, it just makes the cash wrong.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Surface | Role |
|
||||
|---|---|
|
||||
| `ExecuteOrder`, `TransactionFeeProcessor`, `DistributeEarnings` | write |
|
||||
| `Admin::PortfolioTransactionsController` | admin CRUD |
|
||||
| `Admin::StudentsController#add_transaction` | admin writes manual adjustments |
|
||||
| `Portfolio`, `EarningsSummary` | read |
|
||||
|
||||
## See
|
||||
|
||||
- Source: `app/models/portfolio_transaction.rb`, `db/schema.rb:170-179`
|
||||
- As-built: `docs/orders-and-transactions.md`
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
type: object
|
||||
cluster: money
|
||||
universe: live
|
||||
status: verified
|
||||
entity: app/models/portfolio.rb
|
||||
---
|
||||
|
||||
# Portfolio
|
||||
|
||||
A student's account: cash plus holdings. One per
|
||||
[student](../identity/student.md), created automatically, and **it stores no money at
|
||||
all**.
|
||||
|
||||
Verified 2026-08-16 against commit `63732df`.
|
||||
|
||||
## Why this shape
|
||||
|
||||
**The table has three columns: `id`, `user_id`, timestamps** (`db/schema.rb:181-186`).
|
||||
There is no balance, no cash column, nothing cached. Every figure is computed on read
|
||||
from [portfolio-transaction](portfolio-transaction.md) rows
|
||||
(`app/models/portfolio.rb:71-100`). The ledger is the truth; the portfolio is a lens over
|
||||
it. That is why a corrupt or negative transaction row cannot be "fixed" by adjusting a
|
||||
balance — you post a compensating row.
|
||||
|
||||
**Balance includes money you have not spent yet.** `cash_on_hand_in_cents` subtracts
|
||||
*pending buy orders* and a *pending transaction fee* alongside settled debits
|
||||
(`:71-75,93-100`). Orders sit pending for up to 15 minutes before
|
||||
[place-and-execute-order](../../processes/place-and-execute-order.md) runs, so this is
|
||||
what stops a student spending the same dollar twice in that window. It also means the
|
||||
balance can move without any transaction being written.
|
||||
|
||||
**The unit trap lives here.** `cash_balance` returns **dollars as a float**
|
||||
(`:16-18` → `:67-69`, which divides by 100.0) while everything around it is integer
|
||||
cents. Callers must convert back — `app/models/order.rb:137` does
|
||||
`(user.portfolio&.cash_balance || 0) * 100`. Any new caller that forgets is wrong by 100×.
|
||||
|
||||
## Shape
|
||||
|
||||
- Table `portfolios`, `db/schema.rb:181-186` — no money columns
|
||||
- `belongs_to :user`; validated to be a student (`:6-7,102-104`)
|
||||
- `has_many :portfolio_transactions`, `:portfolio_stocks`, `:portfolio_snapshots`, all
|
||||
`dependent: :destroy` (`:11-14`)
|
||||
- **Dollars (float):** `cash_balance` (`:16`), `calculate_total_value` (`:36`),
|
||||
`total_portfolio_worth` (`:40`), `holdings_value` (`:44`)
|
||||
- **Cents (integer):** `cash_on_hand_in_cents` (`:71`), `holdings_value_cents` (`:48`),
|
||||
`calculate_total_value_cents` (`:32`)
|
||||
- `holdings_value_cents` sums in SQL: `portfolio_stocks.shares * stocks.price_cents`
|
||||
(`:48-52`) — live prices, not purchase prices
|
||||
- `shares_owned(stock_id)` sums the lot rows (`:24-26`)
|
||||
- `positions` delegates to [portfolio-position](../trading/portfolio-position.md) (`:28-30`)
|
||||
- `chart_data` returns the **last 12** snapshots (`:54-63`)
|
||||
|
||||
`total_portfolio_worth`, `calculate_total_value`, and `calculate_total_value_cents / 100`
|
||||
are three names for one number (`:32-42`).
|
||||
|
||||
## Connected to
|
||||
|
||||
- **owns:** [portfolio-transaction](portfolio-transaction.md),
|
||||
[portfolio-stock](../trading/portfolio-stock.md),
|
||||
[portfolio-snapshot](../trading/portfolio-snapshot.md)
|
||||
- **owned-by:** [student](../identity/student.md)
|
||||
- **joins:** [stock](../trading/stock.md), through `portfolio_stocks`
|
||||
- **looks-like-but-is-not:** `cash_balance` is **not** cents, unlike every column it is
|
||||
derived from. And `Portfolio` is not the owner of [order](../trading/order.md) —
|
||||
orders belong to the `User` (`app/models/order.rb:6`); `Order#portfolio` is a
|
||||
delegation (`:30`).
|
||||
|
||||
## If you change this
|
||||
|
||||
- **Hits:** [order](../trading/order.md) validation — `sufficient_funds_for_buy` reads
|
||||
`cash_balance` (`app/models/order.rb:134-148`); `ExecuteOrder`, which cancels on a
|
||||
negative balance (`app/services/execute_order.rb:64-66`);
|
||||
[portfolio-snapshot](../trading/portfolio-snapshot.md), whose worth comes from
|
||||
`calculate_total_value_cents`; the portfolio chart and every balance shown in a view.
|
||||
- **Does not hit:** [grade-entry](../gradebook/grade-entry.md) or earnings amounts.
|
||||
Money flows one way — the gradebook writes deposits into the ledger and never reads a
|
||||
balance back.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| Surface | Role |
|
||||
|---|---|
|
||||
| `PortfoliosController#show` | student and teacher read |
|
||||
| `Admin::StudentsController#show` | admin reads |
|
||||
| `Student#ensure_portfolio` | writes (creation) |
|
||||
| `MonthlyPortfolioSnapshotJob` | reads |
|
||||
|
||||
## See
|
||||
|
||||
- Source: `app/models/portfolio.rb`, `db/schema.rb:181-186`
|
||||
Reference in New Issue
Block a user