Fee cycles are a date problem, not an arithmetic problem
The naive implementation of monthly fees is a scheduled job that inserts a challan for
every active student on the first of the month. It survives about two weeks of real use.
The cases that break it are all ordinary: a student is admitted on the 18th; a student
leaves mid-session and wants the unused portion back; a class’s fee changes in March but
the student is on a concession agreed in January; a family pays three months in advance
and then one sibling is withdrawn.
What we settled on is treating the fee structure as a dated, versioned agreement between
a student and a section, not as a number on the class. Challan generation reads the
version that was in force for the period being billed, which means:
- Changing a class’s fee never rewrites history. Old challans keep their old basis.
- Pro-rata is computed from the admission date against the billing period’s day count,
and the calculation is stored on the challan line, not just its result. When a parent
disputes an amount, the school office can show them the working.
- Reversals are entries, never deletions. A refunded challan is settled by a credit note
that is itself auditable.
That last point matters more than it sounds. The most common request from schools that
have used other software is not a feature — it is the ability to explain why a number is
what it is, two months after the fact, to a parent standing at the counter.
Multi-campus isolation, without a database per campus
School systems want consolidated reporting; campuses want their own data. Running a
separate database per campus gives clean isolation and makes cross-campus reporting
painful. Running one shared schema with a CampusId column gives easy reporting and one
bad WHERE clause away from a data leak.
We use the shared schema, but the campus filter is not left to the developer writing the
query. It is applied by a global query filter at the ORM level, driven by the campus
claims on the authenticated user, so a query that forgets to filter returns nothing rather
than everything. Bypassing it requires an explicit, named call that only the consolidated
reporting layer uses — and that call is what the audit log watches most closely.
This is the pattern we would use again at this scale. Below roughly a hundred campuses,
the operational simplicity of one database is worth more than the theoretical isolation of
many, provided the filter is enforced somewhere a developer cannot forget it.
Notification delivery is a reliability problem, not a messaging feature
Sending a WhatsApp message is one API call. Being able to answer “did the parent get the
fee reminder?” three weeks later is a different piece of engineering.
Every outbound message is a row in an outbox table before it is an API call. A worker
picks it up, sends it, and writes the provider’s message ID back. Delivery webhooks update
the same row. Failures retry with backoff, and messages that exhaust their retries land in
a visible queue rather than disappearing.
The result is that a school office administrator can look at a student and see that the
fee reminder was delivered on the 3rd, read on the 4th, and that the absence alert on the
11th failed because the number is not registered on WhatsApp — which is actionable, and
which a fire-and-forget integration cannot tell them.
The same outbox also makes the system safe to re-run. If a fee-generation job partially
completes and is retried, parents do not receive duplicate reminders, because the outbox
row is keyed to the challan and the period rather than to the job run.