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, never float. 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 a decimal.Decimal constructed 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 escalated rent_amount_usd from 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 a hash field — 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.

python
@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.

python
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.

python
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.

python
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.

python
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()
From schedule and payments to flagged, hash-chained ledger entries Two inputs, the expected rent schedule keyed by due_date and rent_amount_usd, and the batch of received payments keyed by payment_id and amount_usd, converge on a reconcile and classify stage that applies a grace-period window and a short-payment tolerance. That stage emits one of four flags per due line: PAID, LATE, SHORT, or MISSED. Each flagged line is then sealed into a SHA-256 hash-chained ledger, where every entry's hash incorporates the previous entry's hash. Expected schedule due_date, rent_amount_usd Received payments payment_id, amount_usd Reconcile & classify grace period + tolerance one flag per due line PAID LATE SHORT MISSED Hash-chained ledger sha256(prev_hash| record)

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.

python
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:

text
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 flagged SHORT, 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 to rent_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_date is populated from the initiation date rather than the settled date, a payment that is actually fine will read as LATE or even MISSED if the reconciliation runs before settlement completes. Always key received_date off 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 raise ValueError the 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 into date().

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 pages