rentiQ: allocating rent without guessing
How a rent ledger stays explainable after partial payments, overpayments and corrections: allocation as rows, arrears first, and reversals that add records instead of editing them.
View live site ↗The problem
A landlord’s books are two lists: what each tenant owes and what each tenant paid. The software’s job is to connect them in a way that still adds up a year later, after partial payments, overpayments, corrections, and a tenant who disputes March.
rentiQ is a multi-tenant property management platform for Kenyan landlords and agents. It turns lease charges into monthly invoices, and staff record payments against a lease: cash, bank transfer, cheque, or an M-Pesa reference. This piece is about what happens between “a payment was recorded” and “the invoice says paid”, which is where most of the design went.
One thing first, because it shapes the rest. rentiQ does not ingest M-Pesa payments automatically yet. A person records each payment and chooses the lease it belongs to. So the matching problem (whose money is this?) is still a person’s, and the allocation problem (which debt does it settle, and how is that undone?) is the system’s. The last section covers what changes when automatic ingestion arrives.
The hard part: money that does not line up
Exact payments are easy. A tenant owes 25,000 for March, pays 25,000, and any design works. The rest is where the software earns its keep: a tenant three months behind who pays half a month, a payment that overshoots, a cashier who typed 2,500 instead of 25,000 and noticed on Friday.
Three approaches that do not survive
Store a paid amount and edit it. The obvious model gives each invoice an amount_paid, and recording a payment adds to it. It works until the first correction. Somebody edits the number, and now the invoice says 12,000 paid with no record of which payments make up that figure or what it said yesterday. When a tenant disputes a balance, you have the current number and nothing behind it.
Let the payment pick its invoice. Apply the money to whichever invoice the tenant mentions, or to the newest one. It feels like respecting the tenant’s intent. It produces a ledger where March is paid while January is still open, which makes arrears ageing meaningless.
Sum the balances. Every rentiQ invoice shows arrears brought forward, because that is what a tenant expects to see on the document. So February’s balance includes January’s unpaid amount. Add up balances across invoices and January is counted twice. This one is easy to miss, because every individual number is correct.
Allocation is a row, not a number
Each row in payment_allocations lands part of one payment, or part of an applied credit, on one invoice. The table is append-only. An invoice’s paid amount always equals the sum of its allocation rows, so “why does this invoice say paid?” has a list for an answer.
The ordering rule is arrears-first. When a payment is recorded, the recorder loads the lease’s issued invoices that still carry a balance, oldest period first with ties broken by id, and locks those rows for the transaction. It walks them, allocating min(remaining, balance) to each, until the money runs out. Draft invoices are never paid. Staff may target one specific issued invoice, which settles first, and any remainder continues arrears-first. A target on another lease, or a draft, is refused with a validation error rather than quietly redirected.
An overpayment is not an error. The remainder becomes a positive entry in a per-lease credit ledger. When the next invoice is issued, an observer applies the available credit to it: a negative ledger entry, plus an allocation row that points at the credit entry instead of a payment. Two invariants hold together as a result. An invoice’s paid amount is the sum of its allocations, and a lease’s available credit is the sum of its ledger.
- RecordStaff record a payment against a lease: amount in cents, method, reference, date.
- TargetIf one issued invoice on that lease was chosen, it settles first. Anything else is refused.
- Arrears firstThen every issued invoice with a balance, oldest period first, row-locked. Each gets min(remaining, balance) as one allocation row.
- CreditWhatever is left becomes a positive entry in the lease credit ledger.
- Next invoiceWhen the next invoice is issued, credit is applied: a negative ledger entry and an allocation row that points at it.
Reverse A negative payment linked to the original, a negative allocation for each of its rows, banked credit unwound, and the original marked reversed. No row is edited or deleted.
All of that runs in one database transaction, and so does the receipt. Receipts are numbered RCPT-000001 upward per organisation, from a counter row locked FOR UPDATE inside the payment’s transaction. The numbering service throws if it is called outside one. A rollback releases the number along with everything else, so the sequence has no gaps, which matters the day an accountant asks where receipt 41 went.
Each receipt also stores a JSON snapshot of the payer, unit, amount, method, reference, allocations and the organisation’s KRA PIN at the moment of issue, and the PDF renders from that snapshot. Correcting a tenant’s name later does not rewrite a receipt that was already handed over.
The receipt email is dispatched after the transaction commits, never inside it. A notification sent from a transaction that later rolls back tells a tenant about a payment that does not exist.
Corrections add records
A recorded payment is never edited and never deleted. Reversing one creates a new payment with the negative amount, linked to the original and carrying a reason. For every allocation the original made, the reversal writes a negative allocation against the same invoice, locked again, and the invoice’s balance and payment status move back by exactly that amount. Credit the original banked is unwound with its own ledger entry. The original keeps every row and changes one field, its status, to reversed. A reversal note is issued from the same receipt sequence.
Two refusals stop this from turning into a tangle. A reversal cannot itself be reversed, and a payment cannot be reversed twice. If a reversal was the mistake, the fix is to record the payment again, which leaves a trail a person can follow.
What the audit trail has to answer
The test for all of this is a conversation with an unhappy tenant who says they paid in March. Without anybody reconstructing anything, the system should answer: which payment, recorded by whom, on what date, with what reference; which invoices it settled and by how much; whether any of it was reversed, when, by whom and why; and which receipt and reversal note the tenant was given, exactly as issued. Each of those is a row that already exists. That is the reason allocation is a row.
Reports follow the same rule. True debt is the sum of issued invoice subtotals minus the sum of payments, never a sum of balances. The ageing report pools a lease’s payments and re-applies them oldest-first, the order the recorder uses, so the report and the ledger cannot tell two different stories about which month is unpaid.
The design and what it costs
- One write path. Every payment goes through a
PaymentRecorderinterface with two methods,recordandreverse. Controllers depend on the interface, and manual entry is its one implementation. A test scans the source and fails if anything outside the payments service creates a payment. The cost is ceremony: even a one-line change to how payments are stored goes through the seam. - Row locks, not optimism. Allocation locks the lease’s outstanding invoices for the length of the transaction, so two people recording against the same lease at the same moment queue instead of both reading the same balance. A property business has very little contention, and a wrong balance costs far more than a short wait.
- Integers in minor units. Every amount is stored as a whole number of cents, so nothing is rounded halfway through an allocation.
- Stored totals with one writer. An invoice keeps its paid amount and status on the row, for fast reads. Only one service writes them, and both can always be derived again from the allocation rows. It is a cache with exactly one writer, which is the only kind I would put in front of money.
What I would do differently
Compute the arrears line instead of storing it. Keeping arrears brought forward on each invoice made the document match what tenants expect, and it made “never sum balances” a rule every report has to remember. I would now compute that figure when the document is rendered and keep the stored invoice to its own charges, so the obvious query is also the correct one.
Keep one copy of the allocation rule. The record dialog previews where the money will land, using a TypeScript function that mirrors the PHP. Both are tested, but they are two implementations of the rule the whole design rests on. A dry-run endpoint that runs the real recorder and rolls back would cost a round trip and remove the second copy.
Model negative credit. Suppose a payment banks credit, the credit is applied to a later invoice, and then the payment is reversed. The lease’s credit goes negative, because the tenant now owes it back. Auto-apply ignores a negative balance and the tenant statement shows it, so nothing breaks, but the state is tolerated rather than modelled. I would make it an explicit receivable.
When M-Pesa arrives
Automatic ingestion through Safaricom’s Daraja API is on the roadmap, not in the code. The seam is ready: a Daraja recorder implements the same interface and no caller changes. Two problems arrive with it that manual entry does not have.
The first is repeated delivery. Payment notifications can arrive more than once, and recording one twice is the worst bug this system could have. A repeat has to be a no-op, which means a key from the provider’s side, stored under a unique constraint and checked inside the same transaction as the allocation.
The second is money with no obvious owner: a wrong account reference, or a relative paying from their own number. Today a person decides which lease a payment belongs to. When the system has to decide, it must be allowed to say it does not know. The plan is that exact matches allocate automatically and everything else waits in a suspense queue for a person, with whatever the system could work out. A guessed allocation is worse than none, because it looks finished. Somebody finds it weeks later in a dispute, and by then undoing it touches three invoices and a receipt the tenant already holds.
Stack
Laravel, Inertia, Vue 3 and Vuetify, on MariaDB. The payment and allocation rules are covered by Pest feature tests, and the preview function by Vitest.