Handling Controllable vs Non-Controllable CAM Expenses
Before a cap can be enforced, every recoverable dollar has to be labelled as either controllable or non-controllable — and getting that one boolean wrong is how a reconciliation quietly overbills. This page is a focused implementation recipe for building the deterministic classification pass that splits a general-ledger expense pool into the two buckets a lease treats differently, and it is the upstream stage that managing expense caps and controllable limits depends on: a ceiling can only clamp the controllable slice, so the split has to run first and be reproducible. Controllable costs — the ones a landlord can influence, such as janitorial, landscaping, and management fees — are subject to lease-defined ceilings; non-controllable costs — property taxes, insurance, and structural capital — pass through uncapped. Mislabel a single tax line as controllable and it enters a base the lease never subjected to a cap; mislabel a management fee as non-controllable and the tenant loses the ceiling it negotiated.
Context & When to Use This Approach
Reach for a dedicated controllability classifier the moment your leases cap any subset of CAM — which is nearly every institutional-grade commercial lease. A simple portfolio where every expense is fully recoverable and nothing is capped does not need this pass; the split only earns its keep when a cap, an index adjustment, or a base-year comparison applies to some expenses and not others. The concrete triggers are consistent across a triple-net (NNN) portfolio:
- A cap that names only controllables. The lease caps “controllable operating expenses” at 5% annually but leaves taxes and insurance uncapped, so the cap engine must be handed the controllable subtotal — not the gross pool.
- A vendor reclassifying invoices mid-year. A landscaping contractor rebills a drainage repair as a capital line, silently moving dollars across the controllable boundary between two reconciliation periods.
- A chart-of-accounts migration. An acquired property arrives with a different GL structure, and last year’s controllable mapping no longer resolves against this year’s account numbers.
- A disputed year-end statement. A tenant’s consultant asserts that a “management fee” line is really an above-market related-party charge that should never have been treated as a recoverable controllable at all.
The controllable label is a lease fact, not an accounting convenience: it originates in the same clause work that produces your CAM expense categories and should be sourced from the lease abstraction database rather than hard-coded. The GL accounts each line arrives under are assigned upstream by GL code mapping for CAM expenses, which is where a misclassified vendor invoice first becomes a controllability error.
Step-by-Step Implementation
The pipeline below turns a raw list of GL line items into two disjoint pools — a controllable subtotal the cap engine can clamp, and a non-controllable subtotal that passes through — plus a per-line audit record and a drift check against the prior period. Every monetary value is carried as decimal.Decimal, never float: a fraction of a cent of binary rounding drift compounds across a portfolio and breaks the conservation check that proves the split lost no money.
Step 1 — Model controllability rules as versioned data. Store each rule as a typed record that maps a GL account to a controllability flag, carries the lease_version that justified it, and names the lease clause. Sourcing these rows from the abstraction database keeps the split auditable and lets a reopened reconciliation reproduce the exact labelling in force during the period under review.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
class Controllability(str, Enum):
"""Whether a lease lets the landlord influence—and a cap govern—a cost."""
CONTROLLABLE = "controllable" # janitorial, landscaping, management fee
NON_CONTROLLABLE = "non_controllable" # taxes, insurance, structural capital
REVIEW = "review" # unmapped account: never guess a label
@dataclass(frozen=True)
class ControllabilityRule:
"""Maps a GL account to a controllability label, tied to a lease version."""
gl_account: str
label: Controllability
lease_version: str
clause_ref: str
Step 2 — Model the GL line item as an immutable record. Each line carries its account, description, and Decimal amount. Freezing the record means the classification pass cannot mutate an amount in place — it can only route the line into a pool.
@dataclass(frozen=True)
class GLLineItem:
"""One posted general-ledger line entering the controllability split."""
line_id: str
gl_account: str
description: str
amount: Decimal
Step 3 — Classify deterministically, and route the unknown to review. Look each line’s GL account up in the rule set. A hit returns the lease-defined label; a miss must never default to controllable or non-controllable — an unmapped account is routed to a review queue so a new vendor account or a chart-of-accounts change surfaces as a decision rather than a silent misclassification.
def classify(item: GLLineItem, rules: dict[str, ControllabilityRule]) -> Controllability:
"""Return the controllability label for one line; REVIEW when unmapped."""
rule = rules.get(item.gl_account)
if rule is None:
return Controllability.REVIEW
return rule.label
Step 4 — Partition the ledger into pools with an audit trail. Split the ledger into a controllable subtotal, a non-controllable subtotal, and a review queue, tagging every line with the label it received so the year-end statement can show exactly which pool each dollar landed in.
@dataclass(frozen=True)
class SplitResult:
"""The controllability split, carrying subtotals and a tagged ledger."""
controllable: Decimal
non_controllable: Decimal
review: Decimal
tagged: list[tuple[str, Controllability]]
def split_pool(
lines: list[GLLineItem],
rules: dict[str, ControllabilityRule],
) -> SplitResult:
"""Partition a GL ledger into controllable and non-controllable pools."""
controllable = Decimal("0.00")
non_controllable = Decimal("0.00")
review = Decimal("0.00")
tagged: list[tuple[str, Controllability]] = []
for item in lines:
label = classify(item, rules)
tagged.append((item.line_id, label))
if label is Controllability.CONTROLLABLE:
controllable += item.amount
elif label is Controllability.NON_CONTROLLABLE:
non_controllable += item.amount
else:
review += item.amount
return SplitResult(controllable, non_controllable, review, tagged)
Step 5 — Detect classification drift against the prior period. A cost that changed controllability between two reconciliations is the most expensive silent error in this stage. Compare each account’s current label to last year’s and raise the ones that moved, so a vendor reclassification or a mapping edit is caught before it reaches the cap engine.
def detect_drift(
current: dict[str, Controllability],
prior: dict[str, Controllability],
) -> list[tuple[str, Controllability, Controllability]]:
"""Return accounts whose controllability label changed between periods."""
return [
(account, prior[account], current[account])
for account in current.keys() & prior.keys()
if current[account] != prior[account]
]
Gotchas & Known Limitations
The classification logic is a dictionary lookup; the ways real portfolios break it are not. Treat each of the following as a failure mode the pass must handle explicitly rather than absorb into a wrong subtotal.
- Never default an unmapped account. Defaulting a miss to controllable pulls an unclassified tax line under a cap; defaulting to non-controllable strips a real controllable of its ceiling. Route the unknown to
REVIEWso the label is a decision, not an accident. - Snow removal and utilities are lease-specific. Weather-driven costs and utilities sit on the boundary — some leases treat them as controllable, others explicitly carve them out as uncontrollable. The label must come from the clause, so the same GL account can legitimately carry different labels across two properties.
- Amortized capital is a split line. A capital improvement is non-controllable, but its permitted amortized component may re-enter the pool as controllable. A single-label classifier mishandles this; carry the amortized portion as its own line with its own rule rather than labelling the gross.
- Related-party and above-market charges. A management fee labelled controllable is still challengeable if it exceeds a negotiated cap or is an above-market related-party charge — controllability classification does not replace the carve-out logic in exclusion mapping for tenant-specific CAM; it runs alongside it.
- Version the mapping, do not edit it in place. A label silently changed on a live rule makes every historical statement unreproducible. Pin each rule to a
lease_versionso a reopened prior year replays the labelling that was actually in force. - Order matters downstream. The split runs before the cap and before any gross-up so the ceiling clamps the correct normalized base; classify last and the whole expense allocation logic pipeline caps the wrong pool.
Verification
Classification bugs are invisible until a tenant’s consultant recomputes the controllable subtotal by hand and finds a tax line inside it, so the pass is tested against a small fixture where the correct pools are known from the lease. Two assertions do the real work: a conservation check proving no dollar was created or lost across the split, and a membership check proving each account landed in its lease-defined pool.
from decimal import Decimal
def test_split_conserves_and_labels_correctly() -> None:
rules = {
"5000": ControllabilityRule("5000", Controllability.CONTROLLABLE, "2026.1", "§5.1"),
"6300": ControllabilityRule("6300", Controllability.CONTROLLABLE, "2026.1", "§5.4"),
"7100": ControllabilityRule("7100", Controllability.NON_CONTROLLABLE, "2026.1", "§5.2"),
}
lines = [
GLLineItem("L1", "5000", "Landscaping", Decimal("12000.00")),
GLLineItem("L2", "6300", "Management fee", Decimal("6000.00")),
GLLineItem("L3", "7100", "Property tax", Decimal("40000.00")),
GLLineItem("L4", "9999", "Unmapped vendor", Decimal("1500.00")),
]
result = split_pool(lines, rules)
assert result.controllable == Decimal("18000.00")
assert result.non_controllable == Decimal("40000.00")
assert result.review == Decimal("1500.00")
# Conservation: nothing is created or lost by the split, to the cent.
gross = sum((line.amount for line in lines), Decimal("0.00"))
assert result.controllable + result.non_controllable + result.review == gross
# The unmapped account is quarantined, never silently pooled.
labels = dict(result.tagged)
assert labels["L4"] is Controllability.REVIEW
def test_drift_flags_a_reclassified_account() -> None:
prior = {"6300": Controllability.CONTROLLABLE}
current = {"6300": Controllability.NON_CONTROLLABLE}
assert detect_drift(current, prior) == [
("6300", Controllability.CONTROLLABLE, Controllability.NON_CONTROLLABLE)
]
Because every amount is Decimal, the conservation assertion uses exact equality rather than a floating-point tolerance, so a one-cent leak between pools fails the test instead of hiding under isclose. The drift test guards the most expensive case: an account that quietly crossed the controllable boundary between two periods is surfaced as a flagged pair, not a wrong subtotal that survives casual review and detonates in an audit.
Once the split is clean, the controllable subtotal flows into managing expense caps and controllable limits where the ceiling is applied, and the same clause-to-code discipline that produces these labels is detailed in how to map NNN lease clauses to CAM categories.
Related
- Managing Expense Caps and Controllable Limits — the parent stage that clamps the controllable subtotal this split produces to its lease ceiling.
- Exclusion Mapping for Tenant-Specific CAM — the carve-out engine that removes non-recoverable dollars alongside, and before, controllability labelling.
- Best Practices for CAM Expense Exclusion Tracking — the deterministic pass that strips carved-out costs from the pool before any allocation math runs.