Fee cycles are a date problem, not an arithmetic problem
The naive implementation of monthly fees is a scheduled job that inserts an invoice 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 joins on the 18th; a student leaves
mid-cycle and wants the unused portion back; a batch’s fee changes in March but the
student is on a discount agreed in January; a family pays three months in advance and
then one sibling drops out.
What we settled on is treating the fee structure as a dated, versioned agreement between
a student and a batch, not as a number on the batch. Invoice generation reads the
version that was in force for the period being billed, which means:
- Changing a batch’s fee never rewrites history. Old invoices keep their old basis.
- Pro-rata is computed from the enrollment date against the billing period’s day count,
and the calculation is stored on the invoice line, not just its result. When a parent
disputes an amount, the front desk can show them the working.
- Reversals are entries, never deletions. A refunded invoice is settled by a credit note
that is itself auditable.
That last point matters more than it sounds. The most common request from centres 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-branch isolation, without a database per branch
Chains want consolidated reporting; branches want their own data. Running a separate
database per branch gives clean isolation and makes cross-branch reporting painful.
Running one shared schema with a BranchId column gives easy reporting and one bad
WHERE clause away from a data leak.
We use the shared schema, but the branch 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 branch
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 branches,
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 front-desk 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 invoice and the period rather than to the job run.