Skip to content
All posts
Case study: Quran Academy Portal

How I Built a Fee Ledger for an Online Quran Academy

How I built an online Quran academy fee management system that survives advance payments, siblings paid together, multiple currencies and admin overrides, with a full audit trail.

Muhammad Ramzan10 min read
  • Laravel
  • Vue
  • Billing
  • Data Modelling
  • MySQL

I built an online Quran academy fee management system, and the first version had one rule: a student has an invoice, and the invoice is paid or unpaid. It’s the model every tutorial teaches.

Then real families showed up. They paid before any invoice existed. They sent one transfer for several children. They paid in a different currency from the one they were billed in. And admins sometimes wanted to close an invoice by judgment, even when the numbers didn’t reconcile exactly.

None of that fits in a paid/unpaid flag. This post is about the ledger I built instead, and the mistakes I made on the way there.

The problem

Five things real families do

The product is Quran Academy Portal, a Laravel and Inertia/Vue monolith for running live online Quran classes. It has teachers, students, families, admins and managers. The fee module is one part of it, and it’s the part where being wrong costs someone money. Other online Quran work of mine, like the Quran Verse Pro tutoring platform, is on my projects page.

  • Advance payments. Money arrives before any invoice exists.

  • Partial and shared payments. A part-payment, or one transfer that covers several children.

  • Another currency. The family pays in a different currency from the one they’re billed in.

  • Discounts and waivers. They reduce what’s owed, but must not erase the original amount.

  • Admin judgment. Sometimes an admin wants to close an invoice even though the numbers don’t reconcile exactly.

Every one of these breaks “invoice is paid or unpaid”. They stop being special cases once you ask a different question. Not which student paid, but which family sent money and which invoices it covers.

Admin Fees screen with stat cards for billing groups, outstanding invoices and overdue invoices, above a filterable table of student invoices with status badges.
The admin Fees screen. Stat cards count billing groups, outstanding invoices and overdue invoices, and the table filters by status and billing type. All screenshots in this post use seeded demo data.

The data model

Five tables behind an online Quran academy fee management system

Money flows from a payment, through an allocation, onto an invoice. Adjustments change what an invoice is worth. Everything gets logged. Together, those tables are the Quran academy fee ledger.

Money moves left to right. Adjustments change what an invoice is worth. Every step is logged.
invoices
Per student. Carries a denormalised family_id so family-wide balance queries avoid joins. Holds amount, currency, a cached amount_paid, a cached status (unpaid, partial, paid, overdue), plus status_locked and status_note. Soft deleted.
payments
Belong to a family, not a student. amount, currency, paid_on, recorded_by_user_id.
payment_allocations
The join that makes it work. One payment can fund many invoices, and one invoice can be funded by many payments. Unique on (payment_id, invoice_id). A nullable invoice_currency_amount is used only for cross-currency allocations.
invoice_adjustments
Discount, waiver, write_off, correction. A positive amount reduces what’s owed. A negative one increases it.
financial_activity_logs
A before/after JSON snapshot for every money action.

Design decisions

How the fee management system works

Eight decisions shape this Laravel fee management system, from who owns a payment to how the database stops duplicate invoices. Each gets its own short section below.

Decision 01 · Ownership

Payments belong to families, invoices belong to students

A payment is money a family sent. It doesn’t know which child it’s for until someone allocates it. That one choice handles siblings paid together and partial payments with no special code.

Whatever part of a payment is unallocated is the family’s credit. When a new invoice is created, it sweeps that credit automatically, oldest payment first. Advance payments work the same way: the payment waits until an invoice exists.

Family detail dialog showing a family's outstanding balance, its students with monthly fee and billing day, and each student's invoices with Paid or Unpaid badges.
One family, several students. Each student has their own invoices and billing day, while the balance is shown for the family.
Apply credit dialog listing a PKR 8,500.00 unapplied payment, with 6500 PKR applied to a student invoice and 25 entered as the USD equivalent.
Applying a family’s leftover payment to an invoice by hand. The dialog lists the unapplied payment, and a cross-currency application takes the invoice-currency amount.

Decision 02 · Adjustments

Adjustments never touch the original amount

An invoice’s amount is what I billed, and it never changes. Discounts, waivers, write-offs and corrections are rows in invoice_adjustments. A positive amount reduces what’s owed. A negative one increases it.

The net is derived every time: netAmount() = amount - sum(adjustments). It is never stored. A waiver shows up as a waiver, and the original figure stays on record for anyone who asks what the family was first charged.

Decision 03 · Currency

Cross-currency without an exchange rate

Auto-sweep only happens when the payment and the invoice are in the same currency. When they differ, an admin enters the invoice-currency equivalent by hand, once per allocation. It’s stored in invoice_currency_amount.

Record payment dialog with a PKR 6500 amount applied to a USD invoice, with a manually typed field labelled approximately in USD.
Recording a PKR payment against a USD invoice. The admin types the USD equivalent in the “≈ in USD” field. Nothing is converted automatically.

Why not convert automatically? There is no trustworthy stored rate to convert with. Any rate I picked would be a guess, and a guess on money is worse than asking a human.

The manual figure is also a record of what was actually applied. An invoice’s paid total sums COALESCE(invoice_currency_amount, amount), so same-currency allocations need no extra work.

Decision 04 · Closing invoices

“Mark as paid” records a real payment

Admins need a way to close an invoice. The lazy version sets status = 'paid' and moves on.

That leaves the invoice showing Paid with 0.00 collected. The payment history is empty, and any revenue report disagrees with the badge.

So markInvoicePaid() creates a payment for exactly the remaining balance, in the invoice’s currency, and allocates it to that invoice.

markInvoicePaid()PHP
// markInvoicePaid(): a real payment, not a label flip
$remaining = bcsub($invoice->netAmount(), (string) $invoice->amount_paid, 2);

$this->recordPayment($invoice->family, [
    'amount'   => $remaining,
    'currency' => $invoice->currency,
    'paid_on'  => now(),
    'note'     => $note,
], [ $invoice->id => ['amount' => $remaining] ], $actor);

Now amount_paid, the payment history and any revenue report agree with the badge. The override shows up as money, and it goes through the same path as every other payment, so the audit trail covers it.

Decision 05 · Status

Status is a cache, and two things keep it honest

The invoice stores a status, but allocations are the truth. refreshStatus() recomputes it every time an allocation or an adjustment changes.

Invoice::refreshStatus()PHP
// Invoice::refreshStatus() — status derived from allocations, unless locked
$paid = (float) $this->allocations()
    ->sum(DB::raw('COALESCE(invoice_currency_amount, amount)'));
$netAmount = (float) $this->netAmount();

$attributes = ['amount_paid' => $paid];

if (! $this->status_locked) {
    $attributes['status'] = match (true) {
        $paid >= $netAmount        => 'paid',
        $this->due_date?->isPast() => 'overdue',
        $paid > 0                  => 'partial',
        default                    => 'unpaid',
    };
}

$this->forceFill($attributes)->save();

Some invoices become overdue with nobody touching them, so nothing calls refreshStatus(). A daily fee:sweep-overdue cron picks those up. That’s the second mechanism.

Overriding the status

Sometimes an admin wants to close an invoice on judgment. Setting status_locked makes refreshStatus() still recompute amount_paid for display, but leave status alone. There’s a status_note for the reason, and passing “auto” releases the lock.

The override is logged as invoice.status_overridden, so it’s never a silent edit.

Decision 06 · Recurring invoices

Recurring invoices follow the admin’s first invoice

The first invoice an admin creates for a student sets that student’s billing_day and due_day. The fee:generate-invoices cron follows that pattern from then on.

Two date details matter. It uses Carbon’s addMonthNoOverflow(), so a billing day of the 31st doesn’t skip a month. Plain month addition from 31 January lands in March, and February never gets an invoice. It also clamps the due day to the month’s real length.

It supports two shapes. Pre-paid invoices are due on or after the billing day in the same month. Post-paid invoices roll the due date to the next month.

Decision 07 · Concurrency

Stopping duplicate invoices in MySQL

Two concurrent requests can create the same invoice. An app-level exists() check can’t stop that, because both requests run the check before either one inserts.

The obvious fix is unique(student_id, billing_date). It breaks soft deletes: once an invoice is soft deleted, you can’t re-create it for that student and date.

My fix is a stored generated column that is NULL for soft-deleted rows, with the unique index on that column. MySQL unique indexes ignore NULLs, so deleted rows drop out of the constraint.

Migration SQLMySQL
-- Unique per student+date, but soft-deleted rows opt out
ALTER TABLE invoices
  ADD COLUMN billing_date_if_active DATE
  GENERATED ALWAYS AS (IF(deleted_at IS NULL, billing_date, NULL)) STORED;

ALTER TABLE invoices
  ADD CONSTRAINT invoices_student_billing_date_unique
  UNIQUE (student_id, billing_date_if_active);

The database now guarantees it. The exists() check is still useful for a friendly error message, but it’s no longer what keeps the data correct.

Decision 08 · Audit trail

Every money action leaves a snapshot

Every mutation writes a row to financial_activity_logs with a before and after JSON snapshot. The actions are named: invoice.created, payment.updated, invoice.credit_applied, invoice.status_overridden, and so on.

Activity log table of invoice and payment events with time and actor, with one payment row expanded to show its before and after field values.
The activity log page. Each row shows when, who and what. Expanding a row shows the before and after values, here for a newly created payment.

A null actor means the cron did it, so automation is easy to tell apart from a person. The admin UI has an activity log page.

When someone asks why a family’s balance looks wrong, the answer is in the rows rather than in anyone’s memory.

What broke along the way

The commit history is not flattering

The module is about 4,400 lines across Laravel and Vue, built over 40+ commits. I’d like to say I drew the design on paper first. The history says otherwise. “fee module in-progress” appears many times. Then “refactored” appears three times.

Those three refactors had one thing in common. In the early shape, status was the truth and everything else tried to agree with it. Every code path that touched money had to remember to keep the status right.

The fix was to flip it. Allocations are the truth. Status is a cache computed from them, kept honest by refreshStatus() and the daily sweep.

I’m glad the module has 35+ feature tests. Refactoring money code three times without them would have been guesswork.

What I’d do differently

A candid postmortem

I reviewed the module before writing this and found six weaknesses. Here they are, with where each one stands.

  1. 1

    Due today is overdue in one place and not in another

    NEXT UP

    refreshStatus() treats an invoice due today as overdue, because isPast() on a midnight date is already true. The fee:sweep-overdue cron only flips invoices due strictly before today. So the status you see depends on which code path touched the invoice last.

    Next on my list: Pick one definition of overdue and use it in both places.

  2. 2

    Credit application isn’t locked

    NEXT UP

    The unallocated-balance check and the allocation insert aren’t wrapped in lockForUpdate. Two admins applying the same credit at the same moment could over-allocate it.

    Next on my list: Wrap the check and the insert in one transaction that locks the payment row.

  3. 3

    No cap on the invoice side

    NEXT UP

    Allocations are checked against the payment amount, but not against the invoice’s remaining balance. An invoice can be overpaid.

    Next on my list: Check the invoice’s remaining balance as well, inside the same lock.

  4. 4

    Mixed money math

    NEXT UP

    Most of the module uses bcadd and bcsub. But refreshStatus() and the allocation checks cast to (float), as the snippet earlier shows. Money should use one exact approach throughout.

    Next on my list: Move everything to bcmath, or to integer minor units, and remove the casts.

  5. 5

    The audit log is written after commit

    NEXT UP

    The log entry is written after the database transaction commits. If logging fails, a money change exists with no record of it.

    Next on my list: Write the log inside the transaction, so both succeed or neither does.

  6. 6

    Partially paid and past due shows “overdue”

    INTENTIONAL

    A partially paid invoice past its due date shows “overdue” rather than “partial”. That can look like a bug. It’s deliberate.

    The trade-off: Overdue is the more actionable state for an admin, because it says someone needs to chase. The cost is that “partial” disappears once the due date passes, though amount_paid still shows what has come in.

Takeaways

What you can reuse

  • Attach money to whoever paid and obligations to whoever owes. Join them with an allocation table, and siblings, partial payments and advance payments stop being special cases.

  • Derive, don’t overwrite. Keep the original amount, store adjustments as rows, and compute the net. If you cache status, make allocations the truth and refresh the cache from them.

  • If you can’t trust an exchange rate, don’t guess one. Ask a human for the number and store it next to the record.

  • “Mark as paid” should write a real payment. Then the badge, the history and the revenue report can’t disagree.

  • Enforce uniqueness in the database, soft-deleted rows included. A generated column that is NULL for deleted rows does it in two statements.

More write-ups from production projects live on the blog. If you’re planning something similar, you can book a call and we can talk through your data model.

FAQ

Common questions

How do you handle advance payments in a fee management system?

Record the payment against the family, not a student. Until an invoice exists, the payment has nothing to fund, so it sits there as unallocated family credit. When a new invoice is created, it sweeps that credit automatically, oldest payment first. The same allocation model handles partial payments and advance payments with no special case.

How do you handle multi-currency fees without an exchange rate?

For multi-currency invoicing in Laravel, auto-sweep only happens when the payment and the invoice share a currency. When they differ, an admin enters the invoice-currency equivalent by hand for each allocation. There is no trustworthy stored rate, and a guess on money is worse than asking a human.

Should invoices belong to students or families?

Invoices belong to students. Payments belong to families, because one transfer can cover several children and one invoice can be paid in several parts. A payment_allocations table joins the two, and any unallocated payment amount becomes family credit. Invoices also carry a denormalised family_id so family-wide balance queries avoid joins.

Need billing, payments or a multi-role portal built properly?

Book a free 30-minute call. We can scope it, talk through the data model, and check if it’s a good fit.

calendar_monthBook a call

Or email directly: mhdramzan.dev@gmail.com