18 KiB
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)
- A student creates an
Order(buy/sell, whole shares) from a stock page. It is savedpending— 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.00fee (PortfolioTransaction::TRANSACTION_FEE_CENTS = 1_00). - Students may edit or
cancelpending orders; edits re-run funds validation with a refund of the previous cost. OrderExecutionJob(recurring) callsExecuteOrderfor each pending order: creates the debit/creditPortfolioTransaction, creates aPortfolioStockrow (negativesharesfor sells), and flips the order tocompleted. If funds/shares are insufficient at execution time the order is canceled instead.TransactionFeeProcessorthen 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(version2026_06_09_141805), migrations indb/migrate/.strong_migrationsguards unsafe migrations. - Connection config:
config/database.yml(copy fromconfig/database.yml.sample). Local dev DBs arestocks_in_the_future_development/_test; production usesSTOCKS_IN_THE_FUTURE_DATABASE_PASSWORD, andDATABASE_URLoverrides everything (Docker usespostgresql://sif:password@db/). - Core domain tables:
schools → school_years → classrooms(withyears,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) backsAnnouncement#content; Active Storage tables are present. - Notable indexes/constraints: unique
stocks.ticker, uniqueusers.username, partial unique index onusers.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.rbloadsdb/seeds/#{Rails.env}.rb, which loads ordered partials fromdb/seeds/partials/. Afterbin/rails db:setupyou get loginsTeacher/Student/Admin, all with passwordpassword.
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/setupthenbin/dev(Procfile.dev = rails server +tailwindcss:watch+solid_queue:start). Docker:docker compose up, withbin/dc <cmd>as a shortcut fordocker compose run stocks. - Tests:
bin/rails testandbin/rails test:system(87 test files). Minitest with FactoryBot factories intest/factories/, parallelized by processor count (override withPARALLEL_WORKERS),WebMock.disable_net_connect!, coverage viaCOVERAGE=true(forces 1 worker). - Lint:
bin/lintruns i18n-tasks normalization, RuboCop, erb_lint, Brakeman (--exit-on-warn), bundler-audit, andimportmap audit. CI enforces this. - Deploy: pushes to
mainrun tests thenbundle exec cap staging deploy(.github/workflows/deploy-staging.yml); production is a manualcap production deploy. Capistrano deploys to/home/ubuntu/stocks-in-the-futureon Lightsail with rbenv Ruby 3.4.4, linksconfig/database.yml, runs a customdb:migrateafter publishing, restarts thestockssystemd unit, and re-chmods the Puma socket path for nginx.
Notes & Gotchas
- Hard deletes of users raise outside production.
User#destroy/destroy!are overridden todiscard, andsoft_delete_guardraises a loud error in dev/test. Usereally_destroy!only if you truly mean it. - Devise quirks:
config.authentication_keys = [:username], andUser#email_changed?is hard-coded tofalseso Devise never demands re-confirmation. Students are created withemail = nil; teachers getusername = email. - Passwords for students are generated, not chosen —
MemorablePasswordGeneratorbuildsSuperhero + number + Superherofrom Faker (marked "TODO: more robust solution later") and the plaintext is surfaced once in a flash message. API_KEYis a global constant defined inconfig/initializers/api_keys.rbwith a default of"test-api-key".StockAttributeUpdateuses that constant, whileAlphaVantageApiClientreadsENVdirectly and returnsnilwhen unset — so missing keys fail quietly in two different ways.- Docs drift from
config/recurring.yml.docs/scheduling.mdsaysOrderExecutionJobruns at 1:00 AM ET on weekdays and then triggersStockPricesUpdateJob; in the code the job is scheduled every 15 minutes and the price update is an independent cron entry. Trustconfig/recurring.ymland the job source. docs/README.mdlinks todocs/architecture/index.md, which does not exist in the repo.- Production Active Storage is
:heroku, which is aDiskservice rooted attmp/storage(config/storage.yml). Uploads are effectively ephemeral and not shared across instances. - Redis is vestigial.
docker-compose.ymlstarts Redis and CI setsREDIS_URL, but there is noredisgem and Solid Queue is entirely Postgres-backed. - Other leftovers:
bin/delayed_jobexists although Delayed Job isn't in the Gemfile (thedaemonsgem is still there), and.standard.ymlis present althoughstandardisn't a dependency — RuboCop is the real linter. - Dual form-builder stacks:
app/components/shadcn/form_builder.rbandapp/form_builders/admin/form_builder.rb, plus a hand-rolled component layer inapp/helpers/components/*renderingapp/views/components/ui/*. Check which one a view uses before adding fields. - The
/adminnamespace is the in-house rewrite that used to live at/admin-new(see the comment inconfig/routes.rb); older non-admin controllers still serve overlapping teacher-facing screens (e.g. bothClassroomsControllerandAdmin::ClassroomsController). config.load_defaults 8.0while running Rails 8.1 — new 8.1 framework defaults are not enabled.OrderincludesApplicationHelper(a view helper) just to callformat_moneyinside validation messages.- Repo state note: the working tree is on a detached HEAD,
app/.DS_Storefiles show as deleted, and an untracked 15 MBGITFOLDER.zipsits in the project root.