Normalizing CAM Codes Across Property Management Systems
A portfolio that runs Yardi at one asset and MRI at another inherits two entirely different code vocabularies for the same common-area expense, and no consolidated reconciliation can add a Yardi E-4100 to an MRI CAM-JAN until both have been resolved to one shared identifier. Normalizing CAM codes across property management systems is the ingestion-time job of reading each source system through a system-specific adapter and rewriting its native charge codes into a single canonical code model that every downstream calculation trusts. This work sits inside standardizing CAM taxonomies across portfolios, which governs the shared vocabulary itself; this page is the connector layer that makes live data from heterogeneous systems actually speak that vocabulary. It is deliberately narrow — it does not invent categories and it does not re-key historical books, which is the separate concern covered when you are mapping legacy CAM categories to a standard chart of accounts.
Context & When to Use This Approach
Reach for a per-system normalization layer the moment a single reconciliation has to span two or more accounting platforms. The trigger is almost always an acquisition or a management-company merger: one region keeps its books in Yardi Voyager, a newly onboarded portfolio arrives on MRI, a handful of third-party managed assets export flat CSVs from something older, and finance is suddenly asked to produce one portfolio-wide CAM pool. Each system encodes the same economic concept — janitorial, landscaping, HVAC maintenance, management fee — under an incompatible charge code, and often with a different notion of what the code even means. Yardi’s E-4100 might bundle interior and exterior cleaning, while MRI splits those across CAM-JAN and CAM-SWEEP. Nothing downstream can be trusted until those are reconciled to one identifier.
This is distinct from the one-time crosswalk work of re-coding historical ledgers. Here the concern is a repeatable, runtime translation that runs on every ingest cycle, because the source systems keep producing native codes forever — you are not migrating them, you are continuously interpreting them. The design that follows leans on two ideas: an adapter per source system that isolates all the platform-specific parsing quirks in one place, and a single canonical code model that the rest of the platform — allocation, caps, statements — treats as the only vocabulary that exists. A clean canonical model is also what makes portfolio-wide rules tractable, including the controllable vs non-controllable expense segregation that has to apply identically no matter which platform an expense originated in. Do not build this if you run a single system portfolio-wide; a lone platform needs governance, not translation.
Step-by-Step Implementation
The pipeline is five stages: a canonical model, per-system adapters, a normalization rule pass, deterministic conflict resolution, and a validation gate. Every amount travels as decimal.Decimal from the adapter boundary onward, because source systems export cents and a single binary float round-trip is enough to make a consolidated pool fail to tie out to the source ledgers.
Step 1 — Define the canonical CAM code model
Start with the target, not the sources. The canonical model is the one vocabulary the platform admits; it names each recoverable concept once and records the two lease-relevant facts every calculation needs — whether the concept is controllable and its default recoverability. Source codes will be forced to resolve to one of these members or be rejected.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
class CanonicalCode(str, Enum):
"""The single CAM vocabulary every system resolves to."""
UTILITIES = "CAM-UTIL"
JANITORIAL = "CAM-JAN"
LANDSCAPING = "CAM-LAND"
SECURITY = "CAM-SEC"
HVAC_MAINTENANCE = "CAM-HVAC"
MANAGEMENT_FEE = "CAM-MGMT"
SNOW_REMOVAL = "CAM-SNOW"
@dataclass(frozen=True)
class CanonicalDefinition:
"""Lease-relevant attributes attached to each canonical code."""
code: CanonicalCode
controllable: bool # subject to a controllable-expense cap
default_recoverable: bool # absent a lease exclusion
# Owned by portfolio accounting; the authoritative definition table.
CANONICAL: dict[CanonicalCode, CanonicalDefinition] = {
CanonicalCode.UTILITIES: CanonicalDefinition(CanonicalCode.UTILITIES, False, True),
CanonicalCode.JANITORIAL: CanonicalDefinition(CanonicalCode.JANITORIAL, True, True),
CanonicalCode.LANDSCAPING: CanonicalDefinition(CanonicalCode.LANDSCAPING, True, True),
CanonicalCode.SECURITY: CanonicalDefinition(CanonicalCode.SECURITY, True, True),
CanonicalCode.HVAC_MAINTENANCE: CanonicalDefinition(CanonicalCode.HVAC_MAINTENANCE, True, True),
CanonicalCode.MANAGEMENT_FEE: CanonicalDefinition(CanonicalCode.MANAGEMENT_FEE, False, True),
CanonicalCode.SNOW_REMOVAL: CanonicalDefinition(CanonicalCode.SNOW_REMOVAL, True, True),
}
Because controllable lives on the canonical definition rather than being re-derived per platform, the controllable-cap logic never has to know which system a charge came from — it reads one flag.
Step 2 — Build a per-PMS adapter interface
An adapter’s only responsibility is to turn one source system’s export row into a neutral SourceCharge — a system tag, the raw code exactly as the platform emitted it, a raw label, and a Decimal amount. Isolating each platform’s field names and quirks behind a shared Protocol means the normalizer downstream never sees a Yardi-shaped or MRI-shaped record; it only ever sees SourceCharge.
from decimal import Decimal
from typing import Any, Iterable, Protocol
@dataclass(frozen=True)
class SourceCharge:
system: str # "yardi" | "mri" | ...
raw_code: str # the native charge code, verbatim
raw_label: str
amount: Decimal
class PmsAdapter(Protocol):
system: str
def read(self, rows: Iterable[dict[str, Any]]) -> list[SourceCharge]:
...
def _money(value: Any) -> Decimal:
"""Parse a source amount to cents without ever touching float."""
return Decimal(str(value)).quantize(Decimal("0.01"))
class YardiAdapter:
system = "yardi"
def read(self, rows: Iterable[dict[str, Any]]) -> list[SourceCharge]:
# Yardi Voyager exports charge codes under `chargcode`.
return [
SourceCharge(self.system, r["chargcode"].strip().upper(),
r.get("chargdesc", ""), _money(r["amount"]))
for r in rows
]
class MriAdapter:
system = "mri"
def read(self, rows: Iterable[dict[str, Any]]) -> list[SourceCharge]:
# MRI names the same field `ChargeCode` and the amount `Amt`.
return [
SourceCharge(self.system, r["ChargeCode"].strip().upper(),
r.get("Description", ""), _money(r["Amt"]))
for r in rows
]
Adding a third system later means writing one more adapter class; nothing else in the pipeline changes. This is the whole reason the adapter boundary exists.
Step 3 — Apply normalization rules to map source codes
With every input now a uniform SourceCharge, the normalization pass maps each (system, raw_code) pair to a CanonicalCode using an explicit rule table. Keep the mapping deterministic and data-driven — a dictionary that accounting can read and version, not code branches. Anything with no rule is not guessed; it is returned unmapped for the conflict and review path.
@dataclass(frozen=True)
class NormalizedCharge:
canonical: CanonicalCode | None
source: SourceCharge
# (system, raw_code) -> canonical code. Owned and versioned by accounting.
RULES: dict[tuple[str, str], CanonicalCode] = {
("yardi", "E-4100"): CanonicalCode.JANITORIAL,
("yardi", "E-4200"): CanonicalCode.LANDSCAPING,
("yardi", "E-5000"): CanonicalCode.HVAC_MAINTENANCE,
("mri", "CAM-JAN"): CanonicalCode.JANITORIAL,
("mri", "CAM-SWEEP"): CanonicalCode.JANITORIAL, # MRI splits; we merge
("mri", "CAM-LAND"): CanonicalCode.LANDSCAPING,
("mri", "CAM-MGMT"): CanonicalCode.MANAGEMENT_FEE,
}
def normalize(charge: SourceCharge) -> NormalizedCharge:
"""Resolve one source charge to a canonical code, or leave it unmapped."""
canonical = RULES.get((charge.system, charge.raw_code))
return NormalizedCharge(canonical=canonical, source=charge)
Note the two MRI codes that both resolve to CAM-JAN: where a source system splits a concept the canonical model keeps whole, the rule table quietly merges them. That is intentional, and it is the kind of asymmetry an adapter-plus-rules design absorbs without special-casing.
Step 4 — Resolve conflicts deterministically
Merging surfaces conflicts. When two source codes land on the same canonical code within one property and period, that is fine — they sum. The dangerous case is a mapping ambiguity or a definition disagreement: a source code that no rule covers, or two systems asserting incompatible facts about the same concept. The resolver applies fixed, auditable rules — never a coin flip — and routes anything it cannot settle to a review queue rather than silently dropping money.
from collections import defaultdict
@dataclass(frozen=True)
class ResolvedPool:
canonical: CanonicalCode
amount: Decimal
def resolve(charges: list[NormalizedCharge]) -> tuple[list[ResolvedPool], list[SourceCharge]]:
"""Sum mapped charges per canonical code; return unmapped for review."""
pools: dict[CanonicalCode, Decimal] = defaultdict(lambda: Decimal("0.00"))
unmapped: list[SourceCharge] = []
for nc in charges:
if nc.canonical is None:
unmapped.append(nc.source) # never absorb an unknown code
continue
pools[nc.canonical] += nc.source.amount
resolved = [
ResolvedPool(code, amount.quantize(Decimal("0.01")))
for code, amount in sorted(pools.items(), key=lambda kv: kv[0].value)
]
return resolved, unmapped
The precedence rule here is simple and total: a known mapping always wins and accumulates; an unknown code always routes to review. There is no path where an unrecognized raw_code is charged to the nearest-looking pool, because a mis-merge is far more expensive to unwind at year-end than an item held for an accountant to map.
Step 5 — Validate before publishing to the canonical store
The final gate confirms that every pool the pipeline is about to publish names a code the canonical definition table actually knows, and that the amounts are clean cents. A pool referencing a code absent from CANONICAL is a signal that the rule table drifted ahead of the definition table — a governance error that must fail loudly, not reach a tenant statement. Only pools that pass are written to the shared store that portfolio reconciliation reads.
class NormalizationError(ValueError):
"""Raised when a resolved pool cannot be published to the canonical store."""
def validate(pools: list[ResolvedPool]) -> list[ResolvedPool]:
"""Confirm every pool is a defined canonical code with a cent-quantized amount."""
for pool in pools:
if pool.canonical not in CANONICAL:
raise NormalizationError(f"undefined canonical code: {pool.canonical!r}")
if pool.amount != pool.amount.quantize(Decimal("0.01")):
raise NormalizationError(f"unquantized amount for {pool.canonical.value}")
return pools
Running the whole chain — adapters, then normalize, then resolve, then validate — turns a heap of Yardi and MRI rows into a validated set of canonical pools plus an explicit review list, with no floating-point drift and no silently dropped charge.
Gotchas & Known Limitations
- A one-to-many source split must be decided, not defaulted. When MRI carries
CAM-SWEEPandCAM-JANseparately but your canonical model has a single janitorial code, merging is a policy choice — confirm the lease treats them identically before collapsing, because separating them again after a true-up is painful. - Same raw code, different meaning across systems. A code like
E-4100can mean cleaning in one Yardi database and elevator maintenance in another company’s Yardi instance. Key rules on(system, raw_code)at minimum, and add the property or database identifier where two source instances of the same platform disagree. - Adapters must never coerce with
float. A source amount read asfloat(row["amount"])before it reachesDecimalhas already lost exactness. Parse straight from the string form, as_moneydoes, or the consolidated pool will not tie back to the source ledger. - The rule table and the definition table drift apart. Adding a rule that points at a canonical code you never defined passes normalization and fails validation — by design. Treat a
NormalizationErrorfor an undefined code as a release-blocking governance defect, not a data problem. - Unmapped is not empty. A quiet pipeline that maps everything usually means a new source code is being swallowed by an over-broad rule, not that coverage is perfect. Watch the review queue size; a sudden drop is as suspicious as a spike.
- Canonical codes carry lease semantics, so renaming one is not cosmetic. Because
controllableand recoverability hang off the canonical definition, retiring or merging a code changes how caps and exclusions apply portfolio-wide; version the definition table and never mutate a released code in place.
Verification
Verify the normalizer the way you would verify a ledger: with fixtures that feed known source rows through the full chain and assert both the resulting pools and the review list. Because every amount is a Decimal quantized to cents, pool totals compare exactly — there is no tolerance to argue about — and the merge behaviour is pinned by summing two source codes that resolve to one canonical code.
from decimal import Decimal
def test_yardi_and_mri_merge_into_one_canonical_pool() -> None:
yardi = YardiAdapter().read([{"chargcode": "e-4100", "chargdesc": "cleaning", "amount": "1200.00"}])
mri = MriAdapter().read([
{"ChargeCode": "cam-jan", "Description": "janitorial", "Amt": "300.50"},
{"ChargeCode": "cam-sweep", "Description": "lot sweep", "Amt": "99.50"},
])
normalized = [normalize(c) for c in (*yardi, *mri)]
pools, unmapped = resolve(normalized)
validate(pools)
janitorial = next(p for p in pools if p.canonical is CanonicalCode.JANITORIAL)
assert janitorial.amount == Decimal("1600.00") # 1200.00 + 300.50 + 99.50
assert unmapped == []
def test_unknown_source_code_routes_to_review_not_a_pool() -> None:
rows = MriAdapter().read([{"ChargeCode": "CAM-MYSTERY", "Description": "?", "Amt": "500.00"}])
pools, unmapped = resolve([normalize(c) for c in rows])
assert pools == []
assert len(unmapped) == 1 and unmapped[0].raw_code == "CAM-MYSTERY"
Beyond fixtures, reconcile the sum of every canonical pool back to the sum of every source amount minus the review queue on each ingest; the two must agree to the cent, and any gap is a dropped or double-counted charge caught before it reaches an allocation. Track the mapped-versus-unmapped ratio per system over time, because a newly onboarded portfolio always starts with a heavy review tail that should shrink as its codes are added to the rule table.
Once codes normalize cleanly, the canonical pools feed straight into the wider governance model in standardizing CAM taxonomies across portfolios; if you also need to re-code the historical books that predate this pipeline, that is the separate discipline of mapping legacy CAM categories to a standard chart of accounts.
Related
- Mapping legacy CAM categories to a standard chart of accounts — the one-time historical crosswalk that complements this runtime, multi-system translation.
- Controllable vs non-controllable expense segregation — the portfolio-wide split that relies on the controllable flag carried by each canonical code.
- GL code mapping for CAM expenses — how individual vendor invoices reach a GL category before they are consolidated across systems.