←Back to blog
Article

Reconciling fee collection against bank statements when partial payments break the match

Reconciling fee collection against bank statements when partial payments break the match
S

StriveBit

4 min readCustom Software

Reconciling fee collection against partial payments nobody warned you about

Take a school that collects ₹4,200 in three UPI transfers from the same parent over two weeks. The fee for the term is ₹38,500. The accounts team sees three credits of ₹1,000, ₹2,000, and ₹1,200 on the bank statement, none of which matches any invoice amount, and starts reconciling manually in Excel.

This is the part of fee collection that breaks. The payment gateway reports each transaction correctly. The bank statement reports each credit correctly. The ERP shows the invoice as partially paid. But none of these three sources agree on what a single fee payment looks like, because the parent treated the invoice as a target and paid toward it in pieces.

This is the kind of problem the fee collection module in StriveBit ERP has to handle. The naive first version of a reconciliation job matches each bank credit to a single invoice by amount. That works for the majority of transactions and fails on the rest: partial payments, late fees added after the parent initiated but before settlement, and cases where a grandparent paid from a different account than the one linked to the student.

The fix is to stop matching on amount alone and match on a combination of UTR, student ID embedded in the payment note, and a tolerance window for amount. When a parent pays through the ERP's payment link, the UTR and student ID are available in the gateway webhook. When they pay directly via UPI to the school's virtual account, the virtual account ID maps to the student, but the credit on the bank statement is a single line with a reference number.

Here is the reconciliation logic we settled on:

def match_credit(credit, open_invoices, tolerance=50):
    candidates = []
    for inv in open_invoices:
        if credit.utr and credit.utr in inv.gateway_references:
            return inv, "exact_utr"
        if abs(credit.amount - inv.balance_due) <= tolerance:
            candidates.append((inv, "amount_match"))
        if credit.virtual_account_id == inv.student.virtual_account_id:
            candidates.append((inv, "vpa_match"))
    if len(candidates) == 1:
        return candidates[0]
    if credit.amount < 5000:
        smallest = min(open_invoices, key=lambda i: i.balance_due)
        return smallest, "partial_fallback"
    return None, "unmatched"

The `partial_fallback` is the part that matters. When a credit is small and no exact match exists, we apply it to the invoice with the smallest outstanding balance for that student. This will be wrong some of the time — the parent might be paying toward a specific optional fee like transport, not tuition. But it gets the credit applied to the right student, and the accounts team can reclassify it in the ERP instead of starting from a raw bank statement.

The tradeoff we accepted: automatic application of partial payments means the ERP sometimes shows a fee as partially paid when the parent intended to close a different fee head. We flag these with a `confidence` field — `exact_utr` is high confidence, `partial_fallback` is low — and the accounts team reviews low-confidence matches before locking the month.

One thing we do not recommend relying on: asking parents to enter the invoice number in the UPI note field. Expect a minority to do it correctly, a large share to use the wrong format, and the rest to leave it blank. The reconciliation job cannot depend on data that only sometimes exists.

The bank statement format itself is a separate problem. Each bank exports a different CSV schema. We write adapters per bank, and the school accountant uploads the statement file. The job parses it, groups credits by virtual account ID, and runs the matching function against open invoices for that student. Credits that do not match any student land in an exception queue.

Done this way, fee reconciliation for an accounts team turns from days of Excel work into an hour or so a month, and that hour is spent where judgment is actually needed: the exception queue and the low-confidence partial payments that need reclassifying.

←Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch