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.
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.
Recommended implementation
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_yearlookup 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_year0, then steps up on 1 January. Theanniversary_basisflag 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_year1, notrate_year0. 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
× (1 + r)re-rounds and the cents drift upward over a decade. Recomputebase × (1 + r)^kfrom 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_percentrule omitscompound— the compounding-versus-simple choice changes the ten-year total materially, so an unstated basis is a review item, not a default. - A
cpi_indexedrule 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
stepschedule 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 × (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.
Related
- Syncing Rent Schedules to Accounts-Receivable Systems — the sibling stage that writes the rows this page computes into Yardi, RealPage, or NetSuite idempotently.
- Payment Schedule Sync — the parent stage that pairs this expansion with an idempotent upsert and amendment reconciliation.
- Escalation Formula Mapping — the upstream engine that turns escalation prose into the normalized rule this expansion consumes.
- Fixed-Step vs CPI Escalation Formula Tradeoffs — how to choose and model each escalation type before it reaches this expansion.