Case study
Centralising Tutor Pay and Finance
Three divergent pay computations became one seam and one aggregate, with the backfill verified against production: 0 rows differed across 29 plans.
- Project
- Online tutoring platform
- Industry
- EdTech / SaaS
- Role
- Pay seam, payout ledger, finance hub
- Python
- FastAPI
- SQLModel
- Alembic
- PostgreSQL
- Next.js
- TypeScript
- SWR
- pytest
- Vitest
The context
Making tutor pay computable from exactly one place, and giving admins one finance area covering money in, tutor payouts, and expenses.
The problem
Pay was computed in three different ways across the three code paths that create a lesson.
- A tutor's home card computed money in the browser as hours × rate. A 30-minute lesson at a flat per-session rate showed 750 and a 2-hour one showed 3,000, though the academy owed the same either way.
- The card read 528 while the payout engine, which understood fee types, said 88.
- A retainer plan was paid twice: once per hour and once as the flat fee.
- Marking a payout as paid never wrote amount_paid, so the ledger showed "Paid 0, still owed 320, status Paid".
- Billing, payouts, and expenses lived in three places with duplicated tabs, formatters, and tables, and no revenue-vs-cost view.
My responsibility
- Traced every money path
- Built the single rate seam and the earnings aggregate
- Enforced the currency invariant
- Fixed the payout ledger
- Built the finance hub, with a Postgres-proven concurrency check
Architecture
- 3 session-creation paths + rate cardAd hoc, admin, and plan; the rate card is only a default
- plan_rate.py: the seamread_plan_rate / write_plan_rate
- Class.tutor_pay_amountStamped at booking
- compute_instructor_earningsSingle source of truth for the earnings card and payout generation
- PayoutsPENDING recomputed; APPROVED and PAID frozen
- /pay → installment → expense"salaries" category reserved for tutor pay
- Finance hubOverview · Money in · Tutor pay · Expenses, in a local-currency equivalent
Key decisions
One seam
read_plan_rate and write_plan_rate are the only code that reads or writes the pay-rate columns. A retainer plan without a fee raises instead of silently falling back to hourly.
Enum plus CHECK, not a lookup table
A periods table was requested, but each period carries its own maths: a new row would be accepted by the database and silently mispriced. After walking through the trade-off, we agreed on an enum plus a DB CHECK constraint.
Expand, then contract
The new tutor_rate column is dual-written with the legacy columns so a mid-deploy rollback still reads the right number. The backfill was verified with a SQL comparison across all 29 production plans: 0 rows differed.
Currency as a business constant
Tutor pay is always one fixed local currency. A non-matching write is a 422, and legacy lines are dropped from tutor-facing views.
Server-authoritative, per-student rates
The client never computes money. The rate card became only the default for new students, and a blanket "reprice all" endpoint was replaced by a per-plan one.
Technical challenges
1.Rate-less sessions
They surface as unrated_hours instead of being dropped, which explained an "8h beside money covering 6.5h" discrepancy.
2.Retainer period rules
A plan bills its full fee for each calendar month from start through end, inclusive. That needed an end-date column stamped when billing is switched off, and a query widened to include switched-off plans.
3.The unsaved-object trap
Restamping future sessions on an unsaved plan rendered plan_id IS NULL and would have repriced every plan-less session. Guarded and mutation-tested.
4.Concurrent settlement
Three simultaneous /pay calls produce one installment and one expense, proven on Postgres, not SQLite.
5.Migration validation
create_all() masks missing migrations in tests, so migrations were validated against a restored production copy.
Results
- Three divergent pay computations became 1 seam and 1 aggregate
- Backfill: 19 rate cards created, 0 rows differing across 29 plans
- 5 previously unrated completed lessons repriced on deploy, with no frozen payout rewritten
- Full backend suite green (3,997 tests at the time)
- Tutor "why is my card different from my payout?" queries: [X → 0]
- Month-end reconciliation time: [X hours → Y]
- Finance screens consolidated from 3 areas to 1, with [X] duplicated components removed
What I learned
- If money is computed in two places, one of them is wrong. Put the maths in one function and make everything else display it.
- Prefer an enum plus CHECK over a lookup table when each value has behaviour attached.
- Expand, backfill, verify against production, and only then contract.
- Every money test needs the edge row: a PAID row with amount_paid: 0.