Founder & Lead Engineer, RAITHub
RAITHub ships and tests production software. See QA as a Service or talk to us.
A rent arrears system drifts the moment a part-payment or a late fee lands, because a spreadsheet balance is edited by hand. Build it on a per-tenant ledger of signed charges and payments, age the debt from that ledger, apply late fees as policy data, and run a scheduled escalation workflow that reminds on time. The balance is always the sum of the rows.
If you would rather have it built and tested for you, see how RAITHub would build this below.
This is the chase, not the charge. Collecting rent itself (Stripe, late-fee triggers, reminders) is covered in building online rent collection; here the rent is already overdue and the job is to track, age and recover it.
Why does rent arrears tracking drift?
Because a running balance is stored and edited, not derived. A tenant pays part of the rent, a late fee is added in one place and the balance updated in another, and a waiver is applied by hand. Within a few months the number on the statement no longer matches reality, and nobody can say when it diverged. The fix is the ledger discipline: every charge, payment, fee and waiver is one immutable row with a signed amount, and the balance is their sum.
| Leak | What goes wrong | The correct rule |
|---|---|---|
| Edited balance | A part-payment and a late fee update the balance separately; the two disagree | One append-only ledger; the balance is the sum of its rows |
| Late fee by hand | Fees are forgotten, double-applied, or applied to the wrong tenant | Fees generated by rule from the ledger, once, by a scheduled job |
| Aging guessed | "How overdue" is estimated, so escalation fires on the wrong tenants | Aging computed from the dates of the outstanding charges |
| Escalation by memory | Reminders depend on a person remembering to send them | A scheduled workflow with states and timed steps |
What does the ledger model look like?
A single ledger per tenant (or per lease) holds rent charges, payments, late fees, waivers and adjustments as signed rows. Charges are positive (they add to what is owed); payments and waivers are negative. The outstanding balance is the sum.
CREATE TABLE rent_ledger (
id bigserial PRIMARY KEY,
lease_id bigint NOT NULL REFERENCES leases(id),
amount_minor bigint NOT NULL, -- signed: +charge/+fee, -payment/-waiver
kind text NOT NULL CHECK (kind IN ('rent','late_fee','payment','waiver','adjustment')),
due_on date, -- for charges: when it was due (drives aging)
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX rent_ledger_by_lease ON rent_ledger (lease_id, due_on);
-- Outstanding balance for a lease is the sum of its rows (integer minor units).
-- SELECT COALESCE(SUM(amount_minor), 0) FROM rent_ledger WHERE lease_id = $1;
Store amounts as integer minor units, never floats, so fees and part-payments cannot round the balance off a cent at a time. This is the same money-type and append-only discipline used in a double-entry system, covered in double-entry ledger database design.
How do you age the arrears?
Aging is a report, not a stored field: bucket the outstanding charges by how long they have been due. Pay down the oldest charges first (or by your stated policy), so a tenant who pays something is credited against the oldest debt, not the newest.
-- How much of each lease's debt sits in each age bucket, as of today.
SELECT
lease_id,
SUM(amount_minor) FILTER (WHERE due_on > now()::date - 31) AS d0_30,
SUM(amount_minor) FILTER (WHERE due_on BETWEEN now()::date - 60 AND now()::date - 31) AS d31_60,
SUM(amount_minor) FILTER (WHERE due_on BETWEEN now()::date - 90 AND now()::date - 61) AS d61_90,
SUM(amount_minor) FILTER (WHERE due_on < now()::date - 90) AS d90_plus
FROM rent_ledger
WHERE kind IN ('rent','late_fee') AND amount_minor > 0
GROUP BY lease_id;
Because aging comes from the ledger, it is always consistent with the balance, and the escalation workflow can trust it to decide who needs chasing.
How do you apply late fees correctly?
As policy data, generated by a scheduled job, once per overdue period. Store the rule per lease or per property (a flat fee, a percentage, a grace period, a cap), so the same inputs always produce the same fee. The job runs daily, finds charges past their grace period with no fee yet applied, and inserts one late_fee row. Make it idempotent: running twice in a day must not add the fee twice. Jurisdictions limit late fees and the notices that must precede collection; this is general information, so confirm the rules that apply to your properties with your adviser.
How does the escalation workflow work?
As a state machine driven by the aging, not by anyone's memory. A typical path: current → reminder → first notice → final notice → handed to collections, each step gated by an age threshold and a wait, and each one logged. A scheduled job advances leases that qualify and sends the matching reminder, so nothing depends on staff remembering. Record every reminder and notice in an audit log, because when a dispute or a legal step follows, you must show exactly what was sent and when. The audit pattern is in designing an append-only audit log.
How do you test a rent arrears system?
The expensive bugs are a wrong balance and a double-applied fee, so test those directly.
import { describe, it, expect } from 'vitest'
import { balanceOf, applyLateFees } from './arrears'
describe('rent arrears', () => {
it('balance is the sum of charges, fees and payments', async () => {
const lease = await seedLease()
await charge(lease.id, 120000, '2026-09-01') // rent 1,200.00
await pay(lease.id, 50000) // part-payment 500.00
expect(await balanceOf(lease.id)).toBe(70000) // 700.00 outstanding
})
it('does not apply a late fee twice in one period', async () => {
const lease = await seedOverdueLease()
await applyLateFees()
await applyLateFees() // second run must be a no-op
expect(await feeCount(lease.id)).toBe(1)
})
})
Add a test that a part-payment is credited against the oldest charge, one that aging buckets match the balance, and one that the escalation job advances a lease exactly once per threshold.
Buy, build or hire?
| Option | Choose this when | The catch |
|---|---|---|
| A property platform's built-in arrears | You use that whole platform and its fee and notice rules fit your market | Late-fee caps, notice requirements and local payment methods follow the platform's model |
| A spreadsheet and manual reminders | A handful of leases and one diligent person | The balance is edited by hand, so part-payments and fees drift and reminders get missed |
| Custom build | You manage many leases, have specific fee and notice rules, or collect through local gateways | You own the ledger, the aging and the workflow, and the tests that keep arrears exact |
How long does it take to build yourself, and what is the risk?
For an experienced backend developer adding an arrears ledger, aging, late-fee rules and an escalation workflow to an existing property app, our estimate is 2 to 4 weeks, or 4 to 6 weeks with a full notice workflow and reporting. The main risk is legal: late-fee limits and the notices required before escalation differ by jurisdiction, and getting them wrong is worse than a bug. This is general information, so confirm the rules for your properties with your adviser.
Why RAITHub for this
- Rent logic in production. RAITHub built PropDesk, property-management SaaS with Stripe rent collection, four roles and five daily automation jobs, with 1,024 tests.
- Ledgers that balance. Sundor Skin enforces credit limits as append-only entries with a hash-chained audit log across 146 PostgreSQL tables and 530+ tests, the same discipline an arrears ledger needs.
- Workflows that run on time. Scheduled, idempotent jobs are everyday work, so reminders and escalation do not depend on anyone remembering.
When you don't need us
- Your platform's arrears module fits. If its fee and notice rules match your market, use it.
- Volume is tiny. A careful person and a checklist can manage a few leases.
- You only need the model. The ledger and aging query above are a fair start for your own developer.
How RAITHub would build this
- Arrears ledger: append-only charges, fees, payments and waivers in integer minor units, balance by sum.
- Aging and allocation: debt bucketed from charge dates, part-payments credited oldest-first.
- Late-fee engine: idempotent scheduled job applying fees by rule, once per period, with caps and grace.
- Escalation workflow: a state machine with timed reminders and notices, every step audit-logged.
Timeline: adding this to an existing app is typically 2 to 4 weeks; a full build with notices and reporting fits the 4 to 6 week fixed-scope range. See SaaS development and the real-estate industry page. A related build is service-charge budgeting and reconciliation for blocks.
You receive: the ledger, aging and workflow code, tests gated in CI, handover docs, and full IP in your name under NDA.
Next step: book the free 15-minute technical audit with your fee and notice rules, and we will follow up with a written fixed quote.
Frequently asked questions
Why does my rent arrears balance keep drifting?
Because the balance is stored and edited by hand, so a part-payment and a late fee update it separately and disagree. Keep an append-only ledger of signed charges, fees, payments and waivers, and compute the balance as their sum, so it can never drift.
How should rent arrears be aged?
By bucketing the outstanding charges by how long they have been due (0–30, 31–60, 61–90, 90+ days), computed from the ledger rather than stored. Credit part-payments against the oldest charge first, so aging stays honest and escalation targets the right tenants.
How are late fees applied without mistakes?
By rule, generated by an idempotent scheduled job, once per overdue period. Store the fee rule (flat or percentage, grace period, cap) per lease or property, and record each fee as a ledger row. Running the job twice in a day must add the fee only once.
How does the collections workflow escalate?
As a state machine driven by the aging: current, reminder, first notice, final notice, collections, each gated by an age threshold and a wait, and each sent by a scheduled job. Every reminder and notice is audit-logged for any later dispute or legal step.
Can I use my property platform's built-in arrears instead of building?
Yes, if its late-fee caps, notice requirements and payment methods fit your market. Build custom when you manage many leases, have specific fee or notice rules, or collect through local gateways the platform does not support.
Does the arrears system handle legal notices and late-fee limits?
It can enforce the fee and notice rules you configure, but the rules themselves, late-fee caps and required notices, differ by jurisdiction and are a legal matter. This is general information; confirm what applies to your properties with your adviser before you rely on it.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.