Files
project-work/worker-toolkit-stocks-in-the-future/repo/OVERVIEW.md

18 KiB
Raw Blame History

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 (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 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 Quarters 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.