Generating Payment Schedules from Escalation Rules

The precise problem this page resolves is the compute step that sits just before a ledger write: given a canonical lease’s base rent and a single normalized escalation rule, produce the explicit list of month-by-month rows — each with an exact rent amount and a stable period key — that covers the entire term. This is the expansion that feeds Payment Schedule Sync; it is deliberately not the sync itself. Its sibling, syncing rent schedules to accounts-receivable systems, takes an already-computed schedule and writes it into Yardi, RealPage, or NetSuite. This page never touches a billing system. It answers one question: what does each month owe, and can I rebuild that answer byte-for-byte tomorrow?

Getting the expansion right is harder than it looks because a lease compresses years of rising rent into two or three lines of prose. “Three percent annually” is ambiguous until you decide whether it compounds, whether the increase lands on the lease anniversary or on January 1, and what happens to the half-month a tenant occupies before their first full period. A schedule that resolves those choices differently on a re-run is not a schedule — it is a source of invoice disputes. Everything below treats every amount as Decimal, every date as ISO-8601, and the whole expansion as a pure function whose output is stable across replays.

Architectural context

This compute step consumes the normalized rule produced by escalation formula mapping, which has already turned raw clause prose — a fixed bump, a CPI reset, a negotiated step schedule — into a machine-readable EscalationRule with a rate, a cadence, and an anniversary basis. It does not re-interpret the lease; that interpretation is upstream and authoritative. Recurring operating-expense charges arrive already separated by the work in handling CAM charge variations, so this expansion escalates base rent and leaves CAM on its own line rather than folding the two into one figure that can never be un-mixed. When a rule is missing a parameter it needs — a CPI term with no index series, a percent with no anniversary basis — the row cannot be honestly computed, and the lease diverts through fallback routing logic to review instead of guessing.

Expanding base rent and an escalation rule into month-by-month schedule rows A canonical lease carrying a base monthly rent and a normalized escalation rule enters a term expansion engine. The engine steps month by month from the start date to the end date, and at each escalation anniversary it applies the rule to raise the rate. It produces a strip of month-by-month payment rows across the term: the first month is prorated, several early months are abated to zero, and the rate steps up at each anniversary boundary, marked with an upward arrow. Every amount is a Decimal quantized with ROUND_HALF_UP, and each row carries a stable ISO year-month period key so a rebuild produces identical rows. Base monthly rent Decimal, from string Escalation rule kind · rate · anniversary Term expansion engine step month · escalateon anniversary prorate · abate · quantize Decimal · ROUND_HALF_UP Month-by-month rows M1 prorated abated year 1 year 2 year 3 ↑ escalation step-up at each anniversary each row: stable ISO year-month period key · rebuild yields identical rows
Base rent and a normalized escalation rule fan out into one row per month; the first month prorates, abated months zero out, and the rate steps up at each anniversary boundary, all under Decimal with a stable period key so a rebuild reproduces the same rows.

Escalation types, their formula, and when rent changes

The engine dispatches on the rule’s kind. Each type answers the same two questions differently: what is the rate for a given point in the term, and on which calendar boundary does it change. Reading the table as the spec makes the implementation below a proof of it.

Escalation kind Rate formula for anniversary year k When rent changes
Flat / none base Never — one rate for the whole term
Fixed percent, compounding base × (1 + r)^k Each anniversary; every year’s increase is applied to the already-escalated prior rate
Fixed percent, simple base × (1 + r×k) Each anniversary; every increase is a fixed fraction of the original base
Step-up (explicit schedule) steps[k], absolute amount for year k At each negotiated step boundary; amounts are authoritative, no percentage math
CPI-indexed base × (I_k / I_0), clamped to floor/cap At each reset date when a new index print lands

Two axes cause most real-world disagreement. The first is compounding versus simple: at 3% over five years, compounding reaches base × 1.159 while simple reaches base × 1.150 — a divergence that is small in year two and material in year ten, so the rule must state which it is rather than letting the engine assume. The second is the anniversary boundary: an increase tied to the lease anniversary steps up on the term’s start-month each year, whereas a calendar-year increase steps up every January 1 regardless of when the lease commenced, which for a mid-year commencement means the first “year” is a stub of only a few months.

The function below expands a base rent and an EscalationRule into a list of validated PaymentScheduleRow objects spanning the term. It uses Decimal for every amount, relativedelta for month stepping so year boundaries never drift, and a single money() helper so rounding is identical on every machine. The escalation rule is consumed as normalized data — the mapper already produced it — and the anniversary index is computed per period rather than accumulated, so no floating rate is carried forward to drift.

from __future__ import annotations
from calendar import monthrange
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
from typing import Literal, Optional

from dateutil.relativedelta import relativedelta
from pydantic import BaseModel, Field, field_validator

CENTS = Decimal("0.01")

def money(value: Decimal) -> Decimal:
    """Quantize to two places with one deterministic rule for the whole engine."""
    return value.quantize(CENTS, rounding=ROUND_HALF_UP)

class EscalationRule(BaseModel):
    kind: Literal["flat", "fixed_percent", "step", "cpi_indexed"]
    # fixed_percent: annual rate, e.g. Decimal("0.03"); compound=False => simple.
    percent: Optional[Decimal] = None
    compound: bool = True
    # step: absolute base per anniversary year, index 0 = year 1.
    steps: Optional[list[Decimal]] = None
    # cpi_indexed: base index I0 plus a {anniversary_year: index} print series.
    base_index: Optional[Decimal] = None
    index_by_year: Optional[dict[int, Decimal]] = None
    floor_pct: Optional[Decimal] = None
    cap_pct: Optional[Decimal] = None
    # "anniversary" steps on the term's start-month; "calendar" steps every Jan 1.
    anniversary_basis: Literal["anniversary", "calendar"] = "anniversary"

class PaymentScheduleRow(BaseModel):
    lease_id: str
    period: str            # ISO year-month, e.g. "2026-08" — stable sort + rebuild key
    rate_year: int         # 0-based anniversary index that set this month's rate
    rent_amount: Decimal
    prorated: bool = False
    abated: bool = False

    @field_validator("rent_amount")
    @classmethod
    def _non_negative(cls, v: Decimal) -> Decimal:
        if v < Decimal("0"):
            raise ValueError("a schedule row must never carry negative rent")
        return money(v)

def _rate_year(start: date, cursor: date, basis: str) -> int:
    """0-based escalation year for this month under the chosen boundary."""
    if basis == "calendar":
        return cursor.year - start.year
    return relativedelta(cursor, start).years

def _rate_for_year(base: Decimal, rule: EscalationRule, k: int) -> Decimal:
    if rule.kind == "flat":
        return base
    if rule.kind == "step" and rule.steps:
        return rule.steps[min(k, len(rule.steps) - 1)]
    if rule.kind == "fixed_percent" and rule.percent is not None:
        if rule.compound:
            return money(base * (Decimal("1") + rule.percent) ** k)
        return money(base * (Decimal("1") + rule.percent * Decimal(k)))
    if rule.kind == "cpi_indexed" and rule.base_index and rule.index_by_year:
        index = rule.index_by_year.get(k, rule.base_index)
        change = (index - rule.base_index) / rule.base_index
        if rule.floor_pct is not None:
            change = max(change, rule.floor_pct)
        if rule.cap_pct is not None:
            change = min(change, rule.cap_pct)
        return money(base * (Decimal("1") + change))
    raise ValueError(f"escalation rule missing parameters for kind={rule.kind}")

def expand_from_escalation(
    lease_id: str,
    start_date: date,
    end_date: date,
    base_monthly_rent: Decimal,
    rule: EscalationRule,
    abatement_periods: set[str] | None = None,
) -> list[PaymentScheduleRow]:
    """Expand base rent + an escalation rule into one deterministic row per month."""
    abatement_periods = abatement_periods or set()
    rows: list[PaymentScheduleRow] = []
    cursor = date(start_date.year, start_date.month, 1)

    while cursor <= end_date:
        period = f"{cursor.year:04d}-{cursor.month:02d}"
        k = _rate_year(start_date, cursor, rule.anniversary_basis)
        rent = _rate_for_year(base_monthly_rent, rule, max(k, 0))

        # Proration for a partial first or last month, on the real day count.
        total_days = monthrange(cursor.year, cursor.month)[1]
        month_end = cursor + relativedelta(day=31)
        occ_start = max(cursor, start_date)
        occ_end = min(month_end, end_date)
        occupied = (occ_end - occ_start).days + 1
        prorated = occupied < total_days
        if prorated:
            rent = money(rent * Decimal(occupied) / Decimal(total_days))

        # Free-rent / abatement zeroes rent even inside an escalated year.
        abated = period in abatement_periods
        if abated:
            rent = Decimal("0.00")

        rows.append(PaymentScheduleRow(
            lease_id=lease_id, period=period, rate_year=max(k, 0),
            rent_amount=money(rent), prorated=prorated, abated=abated,
        ))
        cursor += relativedelta(months=+1)

    return rows

Because _rate_for_year recomputes the rate from the original base and the anniversary index every time, the schedule is fully replayable: nothing is accumulated across iterations, so expanding the same lease twice yields identical rows and identical period keys. That replayability is what lets the downstream upsert treat a re-run as a no-op rather than a second set of charges.

Edge cases specific to commercial leases

  • Mid-year CPI reset. When a CPI-indexed reset date falls mid-year but the official index print lands late, the index_by_year lookup has no entry for that anniversary. Do not silently fall back to the base index — that under-bills. Compute a provisional rate from the trailing series, flag the affected rows, and reconcile when the print publishes, mirroring the delayed-index handling in escalation formula mapping.
  • Anniversary versus calendar year. A lease commencing 1 August with a calendar-year escalation puts only five months (Aug–Dec) in rate_year 0, then steps up on 1 January. The anniversary_basis flag must come from the rule, never be inferred from the start month; guessing wrong shifts every increase by up to eleven months across the whole term.
  • Abatement inside an escalated step. Free rent granted in, say, month 14 lands inside rate_year 1, not rate_year 0. The engine must escalate first and abate second, so the record of the period still shows which escalated rate it belonged to even though the billed amount is zero — otherwise a later true-up or credit references the wrong base.
  • Rounding accumulation. Never carry a running escalated rate forward and multiply it again each year; each &#215; (1 + r) re-rounds and the cents drift upward over a decade. Recompute base &#215; (1 + r)^k from the untouched base for every period so rounding happens exactly once per amount.
  • Amendment changes the rate mid-term. When an amendment revises the escalation rate from month 30 onward, do not mutate the rule in place. Expand the pre-amendment rule up to its effective date and the amended rule from that date, then concatenate — the append-only version history lives upstream in amendment versioning, and this stage simply expands each bounded rule over its own window so prior periods stay reproducible exactly as billed.

When to escalate to review

The expansion is only honest when the rule carries every parameter it needs. Divert the lease to fallback routing logic rather than emitting a guessed schedule when:

  • A fixed_percent rule omits compound — the compounding-versus-simple choice changes the ten-year total materially, so an unstated basis is a review item, not a default.
  • A cpi_indexed rule has no index series or no floor — with no floor, a deflationary print would cut rent, which almost no commercial lease permits; a missing series means the reset simply cannot be computed.
  • A step schedule is shorter than the term in years — the engine clamps to the last step, but a term that outlives its steps usually signals a truncated extraction rather than an intended flat tail.
  • The anniversary basis is ambiguous in the source clause — “increasing 3% each year” without a stated boundary should be resolved by a human against the executed lease, not assumed.

Frequently asked questions

Should a fixed-percent escalation compound or apply simply? It depends entirely on the lease language, which is why the rule carries an explicit compound flag rather than a default. Most modern commercial leases compound — each year’s increase applies to the already-escalated rate — but negotiated deals sometimes specify a simple increase on the original base. The divergence is negligible in year two and material by year ten, so an unstated basis should divert to review rather than being assumed.

How does the anniversary boundary change the schedule? An anniversary-based increase steps up on the term’s commencement month each year; a calendar-year increase steps up every January 1 regardless of when the lease began. For a mid-year commencement the calendar basis produces a short first rate year — only the months from commencement to December — so the flag must come from the mapped rule and never be inferred from the start date.

Why recompute the rate from the base each month instead of carrying it forward? Carrying a running escalated rate and multiplying it again each anniversary re-rounds the amount every year, and those roundings drift upward over a long term until the schedule disagrees with the books. Recomputing base &#215; (1 + r)^k from the untouched original base means each amount is rounded exactly once, keeping the whole schedule reproducible and audit-safe.

What happens when an abatement falls inside an escalated year? The engine escalates first and abates second, so the row still records which escalated rate year it belonged to even though the billed rent is zeroed. This keeps a later credit, true-up, or reconciliation anchored to the correct base rather than treating the free-rent month as if it were unescalated.

← Back to Payment Schedule Sync

← Back to Payment Schedule Sync