Flagging missed rent payments with a compliance ledger
A tower lease’s rent obligation is only as trustworthy as the process that checks it against what actually arrived. Lease Payment & Escalation Tracking covers the wider problem of tracking rent across a lease’s lifecycle — base amounts, escalation clauses, renewal triggers — but none of that matters operationally if a $3,200 monthly payment from a carrier tenant lands two weeks late, or $300 short, and nobody flags it before the next FCC or municipal audit. This page solves one concrete task: take a lease’s expected payment schedule, reconcile it against the batch of payments actually received, apply a grace period and a short-payment tolerance so ACH float and rounding don’t trigger false alarms, classify every due line as PAID, LATE, SHORT, or MISSED, and seal each result into an immutable, hash-chained ledger entry that an auditor can verify was never altered after the fact.
Manual reconciliation — someone eyeballing a bank statement against a spreadsheet once a quarter — misses exactly the cases that matter most: a landlord who receives a short wire and doesn’t notice for two escalation cycles, or a tenant’s ACH batch that silently fails and nobody follows up until the next site visit. An automated ledger flags the line the moment its grace window closes, and because every entry is chained to the one before it, nobody can quietly delete a MISSED flag after the fact without breaking the chain.
That matters beyond bookkeeping. A landlord dispute over unpaid rent on a co-located structure can freeze an otherwise routine lease renewal, and a municipal or FCC auditor reviewing a site’s compliance posture will ask not just whether rent was paid, but whether the operator can prove, retroactively, exactly when a shortfall was detected and how it was escalated. A spreadsheet edited in place answers neither question credibly — anyone with edit access could have changed a MISSED cell to PAID after the fact, and there is no way to prove otherwise. A reconciliation pipeline that classifies deterministically and seals its output the moment it runs closes that gap: the ledger itself becomes the evidence.
Prerequisites & Context
Everything below runs on Python 3.10 or newer with no third-party packages — this task, unlike schema-mapping work, only needs the standard library. Three things matter before you write a single line:
- Money is
Decimal, neverfloat. Rent figures move through subtraction and comparison against a tolerance threshold; a float’s binary rounding error is exactly the kind of noise that turns a real short payment into a false negative, or a clean payment into a phantom flag. Every dollar amount in this page is adecimal.Decimalconstructed from a string literal. - The rent figure you feed in must already reflect the current lease year’s escalation. If a lease’s rent increased under a CPI clause partway through the term, the schedule you build here has to use the post-escalation amount for every due line from the effective date forward, or every payment after that date will misclassify as
SHORT. If you need to derive that figure first, see Calculating CPI-linked rent escalations in Python, which covers computing the escalatedrent_amount_usdfrom a lease’s CPI index and base rent. - The ledger’s shape has to match what the rest of the system expects. The hash-chained entry format built below —
prev_hash, a canonical serialization, and ahashfield — follows the same structure used across Telecom Tower Compliance Architecture & Data Mapping, and it is the same pattern documented independently in the Audit Log Schema Reference; write to that shape and any tool built against the reference can verify these entries without special-casing this pipeline.
With those settled, the task is a five-step pipeline: build the schedule, match payments to it, apply tolerance, classify, and seal.
Step-by-Step Implementation
Step 1 — Build the expected schedule. Every due line is a due_date and the rent_amount_usd owed on that date — nothing else. Keep it as a small, explicit structure so nothing downstream has to guess what a bare tuple means.
@dataclass
class DueLine:
due_date: date
rent_amount_usd: Decimal
A schedule generator turns a lease start date, a term length, and a rent figure into a list of these — one per billing cycle, with the rent already reflecting whatever escalation applies for that period.
Step 2 — Match received payments to due lines. A payment record carries a payment_id, the received_date it actually settled, and its amount_usd. Matching is not a lookup by exact date — tenants pay a few days early or a few weeks late — so each due line searches an unclaimed-payments pool for candidates inside a window around its due_date and takes the closest one.
def match_payment(due, unclaimed):
lo, hi = due.due_date - timedelta(days=3), due.due_date + timedelta(days=25)
window = [p for p in unclaimed if lo <= p.received_date <= hi]
return min(window, key=lambda p: abs((p.received_date - due.due_date).days)) if window else None
Once a payment is matched it is removed from the unclaimed pool so it cannot be double-counted against a later due line. The window’s asymmetry is deliberate: it opens a few days before the due date, because tenants routinely schedule an ACH transfer to land a day or two early, but it extends nearly a month past it, because a payment received closer to the next due date than to this one almost certainly belongs to that next line instead, and should be left unclaimed so it can match correctly when its own turn comes.
Step 3 — Apply a grace period and a short-payment tolerance. A payment received a day or two after the due date is not a compliance event; it is normal billing friction. Define a GRACE_DAYS window after which lateness counts, and a small SHORT_TOLERANCE_USD under which a rounding-scale shortfall does not trigger a flag.
GRACE_DAYS = 5
SHORT_TOLERANCE_USD = Decimal("1.00")
Step 4 — Classify each line. With a matched payment (or none) and today’s reconciliation date in hand, each due line resolves to exactly one of four states. A shortfall beyond tolerance always wins as SHORT regardless of timing; a full payment received after the grace window is LATE; a full payment inside the window is PAID; and a due line with no matched payment is not flagged at all until its own grace window has closed, at which point it becomes MISSED.
def classify(due, payment, as_of):
grace_end = due.due_date + timedelta(days=GRACE_DAYS)
if payment is None:
if as_of <= grace_end:
return None
raise MissedPaymentError(f"{due.due_date} uncleared as of {as_of}")
if due.rent_amount_usd - payment.amount_usd > SHORT_TOLERANCE_USD:
return Status.SHORT
return Status.LATE if payment.received_date > grace_end else Status.PAID
Raising MissedPaymentError here, rather than silently returning a status, means the missed-payment path is impossible to skip by accident — the caller must handle it, even if that handling is simply catching it and recording MISSED.
Step 5 — Append an immutable, hash-chained ledger entry. Each classified line becomes a record; that record is sealed with a SHA-256 digest computed over its own canonical serialization and the hash of the entry before it. Chaining to the previous hash is what makes the ledger tamper-evident as a sequence, not just as individual rows — altering or deleting one historical entry breaks every hash after it.
def seal(prev_hash, record):
canonical = json.dumps(record, sort_keys=True, default=str, separators=(",", ":"))
return hashlib.sha256(f"{prev_hash}|{canonical}".encode("utf-8")).hexdigest()
Figure: the expected schedule and received payments converge on reconciliation, which emits one of four flags per due line and seals each into the hash chain.
Complete Runnable Example
The block below is self-contained on the standard library and runs a full reconciliation for site TWR-8842: four monthly due lines against three payments, deliberately covering all four outcomes — one payment lands clean, one lands late, one is short, and the fourth month gets no payment at all.
import hashlib
import json
import logging
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal
from enum import Enum
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("rent_ledger")
GRACE_DAYS = 5
SHORT_TOLERANCE_USD = Decimal("1.00")
class MissedPaymentError(Exception):
"""Raised when a due line clears its grace window with no matching payment."""
class Status(str, Enum):
PAID, LATE, SHORT, MISSED = "PAID", "LATE", "SHORT", "MISSED"
@dataclass
class DueLine:
due_date: date
rent_amount_usd: Decimal
@dataclass
class Payment:
payment_id: str
received_date: date
amount_usd: Decimal
def build_schedule(start: date, months: int, rent: Decimal) -> list[DueLine]:
lines = []
for i in range(months):
total = start.month - 1 + i
y, m = start.year + total // 12, total % 12 + 1
lines.append(DueLine(date(y, m, start.day), rent))
return lines
def match_payment(due: DueLine, unclaimed: list[Payment]) -> Payment | None:
lo, hi = due.due_date - timedelta(days=3), due.due_date + timedelta(days=25)
window = [p for p in unclaimed if lo <= p.received_date <= hi]
return min(window, key=lambda p: abs((p.received_date - due.due_date).days)) if window else None
def classify(due: DueLine, payment: Payment | None, as_of: date) -> Status | None:
grace_end = due.due_date + timedelta(days=GRACE_DAYS)
if payment is None:
if as_of <= grace_end:
return None
raise MissedPaymentError(f"{due.due_date} uncleared as of {as_of}")
if due.rent_amount_usd - payment.amount_usd > SHORT_TOLERANCE_USD:
return Status.SHORT
return Status.LATE if payment.received_date > grace_end else Status.PAID
def seal(prev_hash: str, record: dict) -> str:
canonical = json.dumps(record, sort_keys=True, default=str, separators=(",", ":"))
return hashlib.sha256(f"{prev_hash}|{canonical}".encode("utf-8")).hexdigest()
def reconcile(site_id: str, schedule: list[DueLine], payments: list[Payment], as_of: date) -> list[dict]:
ledger, prev_hash, unclaimed = [], "0" * 64, list(payments)
for due in schedule:
payment = match_payment(due, unclaimed)
if payment:
unclaimed.remove(payment)
try:
status = classify(due, payment, as_of)
except MissedPaymentError as exc:
log.error("flagging missed line: %s", exc)
status = Status.MISSED
if status is None:
continue
record = {"site_id": site_id, "due_date": due.due_date.isoformat(),
"expected_usd": str(due.rent_amount_usd),
"received_usd": str(payment.amount_usd) if payment else None,
"status": status.value, "prev_hash": prev_hash}
record["hash"] = seal(prev_hash, record)
prev_hash = record["hash"]
ledger.append(record)
log.info("%s -> %s (hash=%s)", due.due_date, status.value, record["hash"][:12])
return ledger
if __name__ == "__main__":
schedule = build_schedule(date(2026, 1, 1), 4, Decimal("3200.00"))
payments = [
Payment("PMT-3301", date(2026, 1, 3), Decimal("3200.00")),
Payment("PMT-3317", date(2026, 2, 20), Decimal("3200.00")),
Payment("PMT-3329", date(2026, 3, 2), Decimal("2900.00")),
]
ledger = reconcile("TWR-8842", schedule, payments, as_of=date(2026, 5, 10))
print(json.dumps(ledger, indent=2))
Verification & Expected Output
Save the block as rent_ledger.py and run python3 rent_ledger.py. The four due lines resolve, in order, to PAID, LATE, SHORT, and MISSED:
INFO 2026-01-01 -> PAID (hash=f4bf9c197f0d)
INFO 2026-02-01 -> LATE (hash=c8557a3c5a7d)
INFO 2026-03-01 -> SHORT (hash=1f7c1aaa7827)
ERROR flagging missed line: 2026-04-01 uncleared as of 2026-05-10
INFO 2026-04-01 -> MISSED (hash=37e723e80e17)
followed by the JSON array of four ledger entries, each carrying its own prev_hash and hash. The January entry chains from the genesis hash "0" * 64; every entry after it carries the previous entry’s hash as its own prev_hash, so the digest of entry n is a function of every entry before it — re-run the script unchanged and every hash reproduces bit-for-bit, because seal() hashes a deterministic sorted-key serialization, not a timestamp or a random value. If you alter any field in an already-sealed entry — say, hand-editing "expected_usd": "2900.00" in the March record to hide the shortfall — recomputing seal() over that record no longer matches the stored hash, and every entry after it fails to verify too, because the tampered record’s hash no longer matches what the next entry chained to. That propagating mismatch, not a single bad checksum, is what makes the chain tamper-evident rather than just tamper-detectable.
Gotchas & Edge Cases
- Split payments break the single-match assumption.
match_payment()claims exactly one payment per due line. A tenant who wires $2,000 against a $3,200 due line and follows up four days later with the remaining $1,200 will have the first wire matched and flaggedSHORT, while the second wire sits unclaimed and gets matched against the next month’s due line instead — corrupting that month’s classification too. Production reconciliation needs to sum every unclaimed payment inside a due line’s window before comparing the total torent_amount_usd, not just take the closest single payment. - Grace period counts calendar days, not settlement certainty. A wire initiated on the due date can still take two or three business days to clear, especially across a weekend. If
received_dateis populated from the initiation date rather than the settled date, a payment that is actually fine will read asLATEor evenMISSEDif the reconciliation runs before settlement completes. Always keyreceived_dateoff confirmed settlement, and run reconciliation after the batch has had time to clear — not the instant a wire is sent. build_schedule()'s day-of-month arithmetic assumes a due day that exists in every month. It works cleanly for the 1st, as used here, but a lease due on the 30th or 31st will raiseValueErrorthe first time the schedule crosses February or another short month. Clamp the day to the shorter month’s last valid day the same way a calendar-aware billing system does, rather than passing the raw day straight intodate().
FAQ
Why raise MissedPaymentError instead of having classify() just return MISSED directly?
Returning Status.MISSED silently makes it easy for a future refactor to drop the case by accident — a missing branch just falls through with no flag at all. Raising forces the caller to make an explicit decision about what a missed payment means for this run, whether that is catching it and recording MISSED, halting the batch, or paging someone. In reconcile() the exception is caught immediately and converted to a status, but the exception itself, not a silent return value, is what guarantees the case can’t be skipped.
Why chain each ledger entry's hash to the previous one instead of hashing each record independently?
An independently hashed record only proves that one row, in isolation, has not changed since it was sealed. Chaining each entry’s hash into the input of the next means altering or deleting any historical entry — not just the most recent one — changes every hash computed after it, because each seal() call takes prev_hash as part of its input. An auditor checking a lease’s history months later can walk the whole chain and confirm nothing in the sequence was reordered or removed, which a set of standalone hashes cannot guarantee.
What happens if a tenant's payment covers two months at once?
As written, match_payment() matches the single closest unclaimed payment to a due line, so a payment sized for two months’ rent will still be compared against one month’s rent_amount_usd and classified SHORT for that line — while the following due line finds no unclaimed payment in its window and eventually reads MISSED. Combined-period payments need to be split into per-period allocations, keyed off an agreed schedule, before they enter this reconciliation step; treating a lump payment as a single event against a single due line will always misclassify one side of it.
Related
- Parent topic: Lease Payment & Escalation Tracking
- Sibling guide: Calculating CPI-linked rent escalations in Python
- Ledger format contract: Audit Log Schema Reference
- Section overview: Telecom Tower Compliance Architecture & Data Mapping