School fee management for Kenyan schools. Bursars get collections, overdue chasing and reconciliation in one place. Parents see what they owe and pay it over M-Pesa in a couple of taps.
- Features
- Tech stack
- How it fits together
- Design decisions
- Project structure
- Getting started
- Configuration
- M-Pesa setup
- Testing and CI
- Deploying and scaling
- Security
- More docs
- Status
Ledgr has four kinds of users: school admins, bursars, teachers and parents. A SUPER_ADMIN role exists for platform-level access.
- Students, classes, terms and fee structures, with single or bulk invoice generation by class and term.
- Payments, recorded manually (cash, bank, cheque) or via M-Pesa: STK Push and direct paybill (C2B), auto-reconciled. A second prompt for an invoice that already has one pending is blocked.
- Overpayments are held, not lost. Money beyond what an invoice owes is kept as credit (shown to the bursar as unallocated and to the parent as "credit held") instead of silently overpaying the invoice.
- Reconciliation tools: stray paybill payments that don't auto-match go to a review queue, and money that fits no invoice sits in an "unallocated" view until a bursar applies it, and an M-Pesa payment confirmed for a different amount than was asked waits in a "mismatch" list (nothing is credited until a bursar records or dismisses it).
- Documents: fee statements, itemized invoice PDFs and payment receipts.
- Payment plans: a risk-aware installment recommender that parents can request and staff can offer.
- Automatic overdue reminders by email and SMS, escalating at day 1, day 7 and day 14. Each tier is sent once per invoice.
- Fee aging report: outstanding balances grouped as current, 1-30, 31-60, 61-90 and 90+ days overdue, filterable by group, with a CSV export of the full filtered list.
- Payment notifications: guardians hear the moment a payment succeeds or fails, not just when they next open the app.
- Messaging: parent-to-school messages and bulk announcements.
- "Ask Ledgr" assistant: the bursar asks questions in plain language ("which Grade 4 students are overdue?"). It is read-only and answers only from live queries run that turn.
- Analytics: dashboard KPIs, collection charts and a forecast, default-risk scoring, and statistical payment-anomaly detection (Isolation Forest).
- Audit log of every meaningful action, with an append-only option enforced in the database.
- One login for every child, with a combined family balance when there is more than one.
- Pay by M-Pesa, either a whole invoice, the whole balance across invoices, or a part-payment.
- Invoices, receipts and statements as downloadable PDFs.
- Request and track an installment plan.
- A parent assistant that explains why a balance exists (itemized), summarizes a term and can preview a payment plan. It only sees that parent's own linked children.
- Direct messages with the school office and with teachers.
- Class group chats (with photo and file attachments) and private teacher-to-parent chats, encrypted at rest.
- Block and report tools, with a reports queue for admins to review.
- Email/password, Google Sign-In, TOTP two-factor and WebAuthn (fingerprint, Face ID, Windows Hello).
- Installable as a PWA (web manifest and service worker), with a mobile-first layout.
- Notification bell covering school messages, class chats, teacher chats and mentions.
- More than one school on one deployment. The signup page creates the first school only. Every further school is added by the platform operator (a
SUPER_ADMIN) from the Schools and billing page, and that school's admin gets an emailed link to set their own password. A parent who signs up is placed in the school that invited them (by phone or email), and Ledgr refuses to guess when it can't tell. See More than one school. - Subscriptions. Each school has a plan, trial and paid-until dates and an optional learner limit. A school moves through
TRIAL/ACTIVE, thenGRACE, thenEXPIRED(orSUSPENDEDby the operator). Expired and suspended schools become read-only for staff; parents can always pay, and M-Pesa callbacks never go through the check. A learner limit stops staff adding students past it (the school keeps the learners it has).
| Layer | What's used |
|---|---|
| Backend | FastAPI, SQLAlchemy 2.0, Alembic, Pydantic v2, Python 3.12 |
| Database | PostgreSQL (Supabase), Row-Level Security |
| Auth | JWT (PyJWT), bcrypt (cost 12), rotating refresh tokens, pyotp, WebAuthn, Google ID tokens |
| Background work | Postgres-backed job queue (FOR UPDATE SKIP LOCKED), APScheduler for the daily sweep |
| Payments | M-Pesa Daraja API (STK Push + C2B), sandbox and production |
| Messaging | Email over HTTPS (Brevo or Resend) or SMTP, Africa's Talking SMS |
| AI | Gemini (free tier) or Claude via a shared provider layer, tool-calling only |
| ML | scikit-learn (Isolation Forest anomaly detection) |
| Documents | ReportLab (PDFs), openpyxl (exports) |
| Scale and ops | slowapi rate limiting, request-size limits, Redis (optional), Sentry (optional), Gunicorn |
| Frontend | React 19, TypeScript, Vite, Tailwind CSS v4, React Router, Zustand, Axios, Recharts, react-hook-form + Zod |
| Testing | pytest (backend), Vitest + Testing Library (frontend), oxlint |
flowchart LR
subgraph Clients
A[Admin / Bursar]
T[Teacher]
P[Parent]
end
A & T & P -->|HTTPS + JWT| API[FastAPI API]
SAF[Safaricom Daraja] -->|STK + C2B callbacks<br/>secret token| API
API -->|restricted role, RLS| DB[(PostgreSQL)]
API -->|owner role: webhooks,<br/>login, worker| DB
API --> STK[Daraja: STK Push]
API --> LLM[Gemini / Claude]
W[Job worker] -->|claims jobs| DB
W --> SMS[Africa's Talking]
W --> MAIL[Brevo / Resend / SMTP]
CRON[Daily 08:00 EAT sweep] --> DB
Requests run as a restricted database role with Row-Level Security. Webhooks, pre-login endpoints and background jobs use a separate owner-role connection, because there is no user (and so no tenant) to scope to yet. Anything that needs to send an SMS or email is written as a job in the same transaction as the thing that triggered it, then delivered by a worker.
Worth knowing before you change the code:
- Multi-tenant by
school_id, enforced twice. Every query is scoped in the app (get_school_scopeincore/deps.py) and independently in Postgres via RLS (sql/enable_rls.sql). A bug in one layer does not leak data past the other. Pre-authentication endpoints intentionally use the unrestricted owner connection. - Money is
Numeric(12, 2), neverFloat. - Payments are append-only. A correction is a linked reversal row, so the ledger stays fully auditable.
- Invoice status is derived, not hand-set.
recalculate_invoice_statusrecomputesPAID/PARTIALLY_PAID/OVERDUEfrom confirmed payments every time money moves. - Payment, audit row and notification commit together. Notifications use a transactional outbox (
core/jobs.py), so a rolled-back payment never leaves a stray text behind, and a committed one never loses its notification. Urgent ones (payment results, password resets) carry a higher jobpriority, so they are not stuck behind a school-wide broadcast. - M-Pesa is never trusted blindly. A callback only resolves a
PENDINGpayment that Ledgr created, and an amount mismatch leaves the payment pending and lists it for a bursar to record or dismiss. Duplicate callbacks are serialized with row locks. The pending payment row is written before the STK prompt is sent, so money can never arrive for a payment Ledgr has no record of. - Whole-balance payments are allocated at confirmation time, oldest invoice first, against the balances as they are then, so a cash payment recorded mid-prompt can't cause an overpayment. Leftovers become unallocated rows for the bursar.
- The ML features stay honest about data volume. Risk scoring and anomaly detection are rules-based and statistical by design. Every scored invoice is logged (
invoice_risk_snapshots+invoice_outcomes) so a trained model becomes possible once a school has enough resolved invoices. - An email address is one identity, whatever its capitalisation. Emails are stored and compared in lower case (
core/emails.py), backed by a unique index onlower(email). - Typed text is escaped for the format it lands in (
services/text_safety.py): PDF markup, CSV/Excel formulas, email subjects. Use it for any new document or export. - Who can call what is pinned by tests.
tests/test_route_security.pylists every public route and every route open to any signed-in role. Adding one fails the build until someone reviews it on purpose. - Both assistants are tool-calling, not free-generation. They answer only from data queried that turn. The bursar assistant is read-only. The parent assistant's tools are hard-scoped to that parent's approved children.
backend/
├── app/
│ ├── core/ # config, DB sessions + RLS context, JWT, deps, job queue, idempotency, cache, locks
│ ├── models/ # SQLAlchemy models, one file per concern
│ ├── routers/ # FastAPI routers, one per resource
│ ├── schemas/ # Pydantic request/response models
│ ├── services/ # business logic (payments, invoices, M-Pesa, AI, PDFs, ...)
│ ├── main.py # app wiring, middleware, scheduler, health checks
│ └── worker.py # stand-alone queue worker
├── alembic/versions/ # migrations
├── sql/ # RLS + append-only audit log setup
├── scripts/ # C2B URL registration, DB backup, dev helpers
├── tests/ # pytest suite
└── loadtest/ # Locust load test (staging only)
frontend/
└── src/
├── api/ # thin axios wrappers, one file per backend resource
├── components/ # shells, chat, notification bell, payment UI; ui/ holds the primitives
├── hooks/ # data-fetching hooks
├── pages/ # one file per route across the admin, teacher and parent portals
├── store/ # Zustand auth store
├── types/ # shared TS types mirroring backend schemas
└── __tests__/ # Vitest tests
Requirements: Python 3.12, Node 22, and a Postgres database (a Supabase project works, use the Session pooler URI on port 5432).
cd backend
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env # set DATABASE_URL and JWT_SECRET at minimum
alembic upgrade head
uvicorn app.main:app --reload --port 4000The API runs on http://localhost:4000, with interactive docs at /docs. The docs are hidden anywhere that isn't a developer machine (any ENVIRONMENT other than development, or running on Render). On Windows you can also run python run.py.
cd frontend
npm install
cp .env.example .env # set VITE_API_URL if the backend isn't on localhost:4000
npm run devThe app runs on http://localhost:5173. Register a school through the signup page to get a staff account (the first school only, see below), or create a parent account and link it to a student to see the parent portal.
- Open
backend/sql/enable_rls.sqland set a real password in theCREATE ROLEline. - Run it once against your database as the table owner (Supabase SQL Editor, or
psql "$DATABASE_URL" -f sql/enable_rls.sql). - Set
APP_DATABASE_URLin.envto theledgr_approle's connection string.
Without step 3 the app falls back to the owner connection: the policies exist but are not enforced. In production this logs a security error on every boot.
enable_rls.sql also makes the audit log append-only (rows can be added and read, never changed or deleted). If your database already has RLS set up from an older version, run sql/audit_log_append_only.sql once to add that.
By default each API process runs a job worker thread. To scale queue work separately:
RUN_JOB_WORKER=false # on the API instances
python -m app.worker # as its own processThe public signup creates the first school and then refuses, from anyone, so a stranger can never open a school on a live system. Further schools are added by the platform operator, a SUPER_ADMIN with no school of their own. There is deliberately no way to become one through the website:
cd backend
python scripts/create_super_admin.py you@example.com "Your Name" # asks for the passwordSign in with that account, open Schools and billing, and use Add school: give the school's name, its administrator's name and email, and optionally a trial length and learner limit. The administrator is emailed a link (valid for 30 minutes) to set their own password, and can use "Forgot password" later if it expires. Nobody, including the operator, ever sees or chooses that password.
A parent's school is decided by an invite the school created ("Add student" or "Link parent" with their phone or email). With one school on the deployment no invite is needed. With several and no invite, signup is refused rather than guessed.
backend/.env.example lists everything with explanations. At minimum you need DATABASE_URL and JWT_SECRET. Features whose variables are unset degrade gracefully (a clear "not configured" error) rather than crashing the app.
| Area | Variables |
|---|---|
| Core | DATABASE_URL, APP_DATABASE_URL, JWT_SECRET, CORS_ORIGINS, FRONTEND_URL, ENVIRONMENT |
| M-Pesa | MPESA_CONSUMER_KEY, MPESA_CONSUMER_SECRET, MPESA_SHORTCODE, MPESA_PASSKEY, MPESA_CALLBACK_URL, MPESA_CALLBACK_SECRET, MPESA_C2B_VALIDATION_URL, MPESA_C2B_CONFIRMATION_URL, MPESA_ALLOWED_IPS (optional) |
| AI assistants | GEMINI_API_KEY or ANTHROPIC_API_KEY, AI_PROVIDER (auto / gemini / anthropic) |
BREVO_API_KEY or RESEND_API_KEY, plus EMAIL_FROM_EMAIL / EMAIL_FROM_NAME, optionally EMAIL_PROVIDER. SMTP_* still works where outbound SMTP isn't blocked |
|
| SMS | AFRICASTALKING_USERNAME / AFRICASTALKING_API_KEY |
| Sign-in | GOOGLE_CLIENT_ID (also VITE_GOOGLE_CLIENT_ID in the frontend), WEBAUTHN_RP_ID, WEBAUTHN_RP_NAME |
| Chat | MESSAGE_ENCRYPTION_KEYS, SUPABASE_URL, SUPABASE_SERVICE_KEY for attachments |
| Scale and ops | REDIS_URL, SENTRY_DSN, MONITOR_TOKEN, SWEEP_HEARTBEAT_URL, RUN_SCHEDULER, RUN_JOB_WORKER |
Email on Render's free tier: outbound SMTP ports (25/465/587) are blocked there, so SMTP_* times out. Use BREVO_API_KEY or RESEND_API_KEY, then run python -m scripts.dev.check_email you@example.com from a Render shell.
Set ENVIRONMENT=production explicitly on your live server. It is what switches on the hard startup checks below. Anywhere that is not plainly a developer machine (any ENVIRONMENT other than development, or a process running on Render) already gets the safe defaults regardless: API docs hidden, HSTS, TLS required for the database connection, and a wildcard CORS origin refused. Running on Render without ENVIRONMENT=production logs an error on every boot.
Required when ENVIRONMENT=production (the app refuses to start without them):
JWT_SECRETof at least 32 charactersMPESA_CALLBACK_SECRETof at least 24 charactersMESSAGE_ENCRYPTION_KEYS, asversion:base64(32 random bytes)entries. Back these up: losing the key means losing the private messages.
Also needed in production:
APP_DATABASE_URL, the restrictedledgr_approle. Without it the app connects as the table owner and Row-Level Security does not protect school data (logged as an error on every boot).CORS_ORIGINSlisting your real site(s), comma-separated, such ashttps://your-app.vercel.app. A*stops the app from starting. Methods and headers are restricted to what the frontend sends, and credentials are off because no cookies are used.JWT_ALGORITHM, which only acceptsHS256,HS384orHS512.- On the frontend host (Vercel),
VITE_API_URLat build time. If it is missing the bundle falls back tohttp://localhost:4000.
Free-tier Gemini requests may be used by Google to improve its products. Use a billing-enabled key once real families' data is involved. Run python -m scripts.dev.check_gemini to verify the key end to end.
- Get Daraja credentials from the Safaricom developer portal and set the
MPESA_*variables. - Generate a long random
MPESA_CALLBACK_SECRET. Safaricom doesn't sign callbacks, so the URL itself is the secret. Put it on every URL you give Safaricom:MPESA_CALLBACK_URL=https://api.example.com/api/payments/mpesa/callback?token=SECRET - Register the C2B URLs once with
python -m scripts.register_c2b_urls(also with?token=SECRET). Note Daraja rejects URLs containing the word "mpesa", which is why the C2B endpoints live under/api/payments/c2b. - For local testing, use a tunnel such as ngrok for the public callback, or set
MPESA_BASE_URLto point at a local simulator.
Parents pay through STK Push. Payments made straight to the paybill auto-match on the student's admission number. Anything ambiguous lands in the bursar's reconciliation queue instead of being guessed at.
cd backend
pip install -r requirements-dev.txt
pytest # pure-logic tests only; DB tests are skippedThe tests that matter most (payments, M-Pesa callbacks, reversals, idempotency, refresh-token rotation, tenant isolation, the job queue including concurrent-thread cases) need a throwaway Postgres. The suite drops and recreates tables, so TEST_DATABASE_URL must point at a database whose name contains test, and anything else is refused.
createdb ledgr_test
export TEST_DATABASE_URL=postgresql://postgres:password@localhost:5432/ledgr_test
# PowerShell: $env:TEST_DATABASE_URL = "postgresql://postgres:password@localhost:5432/ledgr_test"
pytestNever point this at Supabase or any real database.
The tenant-isolation tests deliberately run as the table owner, with Row-Level Security off, to prove the application-level school checks hold on their own. The database wall is not covered by the suite, so check it by hand whenever sql/enable_rls.sql changes (see below).
tests/test_route_security.py is a tripwire for accidental exposure: it pins every public route and every route open to any signed-in role, checks the sensitive routes are rate-limited, and covers CORS, request-size and configuration safeguards.
On a scratch database that has been through alembic upgrade head, run sql/enable_rls.sql as the owner (with a throwaway password on the CREATE ROLE line), put two schools with a student each in it, then connect as ledgr_app and confirm:
SELECT count(*) FROM students; -- 0: no school set, so nothing is visible
BEGIN;
SELECT set_config('app.current_school_id', 'A', true);
SELECT full_name FROM students; -- only school A's students
INSERT INTO students (id, school_id, admission_number, full_name, is_active)
VALUES ('x', 'B', '9', 'Evil', true); -- ERROR: violates row-level security policy
ROLLBACK;The role should also be unable to ALTER TABLE, or to DELETE from audit_logs.
cd frontend
npm run lint # oxlint
npm test # vitest
npm run build # typechecks (tsc -b) and buildsRun both now and then, and before a release:
cd backend && python -m pip install -U pip pip-audit && pip-audit # audits the packages installed in your venv
cd frontend && npm auditUpgrade pip first, as above: an old pip is itself flagged and would otherwise be the only thing in the report.
GitHub Actions runs both suites on every push and pull request that touches the relevant folder. The backend job runs against a real Postgres 16 service container.
- Sessions use
autoflush=False.recalculate_invoice_statusflushes before it sums, but flush first if you add a query that depends on unflushed changes. - Call
lock_invoicebefore inserting a payment row that references the invoice, or concurrent payments can deadlock. - An invoice's
amount_paidis the sum ofCONFIRMEDandREVERSEDrows. A reversal keeps the original (REVERSED, +x) and adds aCONFIRMEDcorrection (-x), so they net to zero.
cd backend
gunicorn -c gunicorn_conf.py app.main:app # Linux hosts (Render, Railway, a VPS, Docker)The API is stateless, so several instances can run behind a load balancer. Set REDIS_URL so rate limits and cache are shared, point every instance at the same Postgres, and keep workers x (pool + overflow) under your pooler's connection limit. The daily overdue sweep (08:00 Africa/Nairobi) is guarded by a Postgres advisory lock, so only one instance does the work.
Migrate first, then deploy. New code reads the columns new migrations add, so a deploy that gets ahead of its migration fails with column ... does not exist on the first request. Run alembic upgrade head against production before the new code goes live. If your Render plan has a Pre-Deploy Command, set it to alembic upgrade head so it can't be forgotten.
The frontend deploys to Vercel. frontend/vercel.json sets the security headers and rewrites every path to the app. See Security for the Content-Security-Policy.
Health endpoints: /health (liveness), /health/ready (database reachable, rate-limited per IP) and /health/status?token=... (queue backlog, dead jobs, scheduler state, hidden unless MONITOR_TOKEN is set).
See backend/SCALING.md, backend/BACKUPS.md and backend/MONITORING.md for details.
A full review of the code, configuration and dependencies is in SECURITY-AUDIT.md, including a checklist of the settings only you can verify in your hosting dashboards.
Tenants and access
- Row-Level Security as a second tenant wall, plus per-request school scoping in the app.
- Every staff route requires a role. The routes open to any signed-in role scope the data inside the handler (an approved guardian link, class membership, your own records), and a test pins the exact list.
- Guardian links must be approved before a parent sees any child data. Parents cannot list or open student records.
Sign-in and sessions
- 15-minute access tokens, rotating single-use refresh tokens (stored only as hashes) with reuse detection, and server-side revocation on deactivation or credential change.
- Tokens are verified with one pinned algorithm and must carry an expiry. Reset, 2FA and email-change tokens can never be used as login tokens.
- Passwords: bcrypt (cost 12), 8 to 72 bytes (bcrypt ignores anything longer), common passwords refused, old hashes upgraded at next sign-in.
- Per-account login lockout with a generous per-IP limit, TOTP 2FA and WebAuthn.
Abuse limits
- Rate limits on every auth endpoint and on everything that costs money or sends a message: M-Pesa prompts, SMS and email reminders, 2FA and passkey changes.
- Request bodies are capped (1 MiB for JSON, the upload limit plus 1 MiB for uploads) and every free-text field has a maximum length.
Data going out
- Typed text is escaped before it reaches a PDF, and spreadsheet cells that would run as formulas (
= + - @) are defused in CSV and Excel exports. Email subjects cannot carry line breaks. - The frontend has no raw-HTML sinks, and links built from data only allow http, https and blob URLs.
- Uploads are checked by extension and by content, SVG is refused, and files are served through short-lived signed links with
nosniffand a sandboxing policy.
Transport and configuration
- CORS allows only your site, only the methods and headers the app uses, and no credentials. JSON responses are
no-store, HSTS is on outside development, and the database connection requires TLS. - API docs are hidden outside development, stack traces never reach clients, and the app refuses to start in production with a weak or missing secret.
vercel.jsonsets the frontend security headers. It enforcesframe-ancestors,base-uriandform-action, and carries the full Content-Security-Policy as Report-Only. To enforce the full policy, open the live site with DevTools open, sign in (including with Google) and open an invoice PDF, look for CSP messages in the console, and if there are none rename theContent-Security-Policy-Report-Onlyheader toContent-Security-Policy. (Chrome appliesobject-src 'none'to PDFs opened fromblob:URLs, so check the PDFs first.)
Payments and messages
- Webhook secret token and optional IP allowlist on M-Pesa callbacks. A callback can only settle a payment Ledgr itself created.
- Private teacher-to-parent chats encrypted at rest with a versioned key ring.
Known limits. Login tokens are kept in the browser's localStorage, so the main defence against token theft is keeping script injection out (see above). SECURITY-AUDIT.md lists the other accepted trade-offs.
Found a vulnerability? Please report it privately rather than opening a public issue.
| Doc | What's in it |
|---|---|
SECURITY-AUDIT.md |
Security review: what was found and fixed, what was checked, and the pre-deploy checklist |
backend/README.md |
Backend setup, AI provider config, test details |
backend/SCALING.md |
Running more than one instance |
backend/BACKUPS.md |
Database backup and restore |
backend/MONITORING.md |
Sentry, uptime checks, dead-job alerts |
backend/scripts/dev/README.md |
One-off debugging helpers |
frontend/README.md |
Frontend notes |
Ledgr is built feature by feature rather than shipped as one release. If something here doesn't match what you find in the code, the code is more likely right. Treat this file as a map, not a contract.