Best Practices for Rate Plan Naming Conventions in PMS-to-Channel-Manager Pipelines
Rate plan identifiers are the routing keys of every PMS-to-channel-manager sync, so a free-text label like "Non-Ref Winter Special" becomes an unparseable string the moment a Python worker tries to match, validate, or reconcile it. This page specifies a deterministic naming grammar and the ingestion-layer gate that enforces it, as the code-level companion to the rate plan taxonomy that owns the identifier model those codes plug into.
The goal is a single machine-readable contract that a revenue manager, an operations lead, and the engineer who owns the sync worker all trust: names that never truncate on a legacy channel, never desync a mapping, and never require manual reconciliation. Get the grammar right at the source and every downstream stage — validation, OTA channel mapping, and nightly batch reconciliation — inherits a clean key instead of a guess.
Prerequisites & environment
The gate below runs at the boundary where a PMS rate plan export becomes an outbound payload. Pin these versions so the regex, validator, and telemetry behave identically across workers:
- Python 3.11+ — for the
remodule’s compiled-pattern caching and modern type hints used throughout. pydantic2.6+ — the naming contract is afield_validatoron the canonical payload model (v2 API:field_validator,model_dump— not v1’svalidator/dict()).structlog24.x — every rejection is logged as a key=value event keyed byproperty_idandrate_plan_code, so a quarantine is greppable rather than buried in a formatted string.polars1.x — the drift pass runs a vectorized set difference over a full portfolio roster rather than a per-row Python loop.- A quarantine sink — a dead-letter table or queue (Redis Streams, SQS, or a
rate_plan_quarantineDB table) that holds rejected records with full context for a human. - API access — read scope on the PMS rate plan registry and the channel manager’s rate plan roster endpoint; credentials scoped per the security and authentication boundaries.
Every code the gate accepts must already conform to the standardized JSON payload shape the rest of the pipeline consumes; the naming rule is one clause of that broader schema contract.
Step-by-step implementation
The gate is four moving parts: a documented grammar with a controlled vocabulary, a compiled regex, a Pydantic v2 validator that rejects malformed names at construction, and a drift pass that catches divergence after the fact. Wire them in that order.
Step 1 — Fix the grammar and a controlled vocabulary
Adopt a rigid, delimiter-separated structure — segment_inclusion_restriction_channel — so each position maps to a normalized column in the PMS schema and stays machine-parseable. Encode the allowed tokens per position as sets, not free text, so an unknown segment fails loudly instead of minting a new implicit category.
# Controlled vocabulary per grammar position — the single source of truth for
# what a well-formed rate_plan_code may contain. Adding a token is a reviewed change,
# not something a marketing label can invent at sync time.
SEGMENTS = {"corp", "leisure", "group", "gov", "wholesale"}
INCLUSIONS = {"ro", "bb", "hb", "fb", "ai"} # room-only, B&B, half/full board, all-inclusive
RESTRICTIONS = {"flex", "nonref", "ap14", "ap30", "los3"} # cancellation / advance-purchase / min-stay
CHANNELS = {"direct", "ota", "gds", "wholesale"}
def parse_rate_plan_code(code: str) -> dict[str, str]:
"""Split a canonical code into its four grammar fields, validating each token."""
parts = code.split("_")
if len(parts) != 4:
raise ValueError(f"expected 4 segments, got {len(parts)} in {code!r}")
segment, inclusion, restriction, channel = parts
for value, allowed, name in (
(segment, SEGMENTS, "segment"), (inclusion, INCLUSIONS, "inclusion"),
(restriction, RESTRICTIONS, "restriction"), (channel, CHANNELS, "channel"),
):
if value not in allowed:
raise ValueError(f"{name} token {value!r} not in controlled vocabulary")
return {"segment": segment, "inclusion": inclusion,
"restriction": restriction, "channel": channel}
Validating each position against an explicit set is what stops slow vocabulary drift: without it, a typo like corp_bf_nonrefundable_ota silently becomes a fifth restriction value that no downstream mapping recognizes.
Step 2 — Compile the structural regex once
Before the per-token check, enforce the character class and length at the string level. Compile the pattern at module scope so the sync worker reuses one compiled object instead of recompiling on every record.
import re
# Lowercase alphanumeric + underscore, 8–40 chars. Compiled once at import so a
# high-throughput worker never pays repeated compilation cost on the hot path.
RATE_PLAN_PATTERN = re.compile(r"^[a-z0-9_]{8,40}$")
def is_structurally_valid(code: str) -> bool:
return RATE_PLAN_PATTERN.match(code) is not None
The {8,40} bound is not cosmetic: many legacy channel managers store the rate plan code in a fixed-width column and silently truncate anything longer, so an over-length name maps two distinct PMS plans onto one channel identifier — a parity bug that only surfaces days later on the OTA side.
Step 3 — Enforce the contract in the Pydantic v2 boundary model
Fold both checks into a field_validator on the canonical payload model, so no code path — API ingestion, batch derivation, or a test fixture — can construct a payload carrying an illegal name and still call model_dump() on it.
from pydantic import BaseModel, Field, field_validator
class RatePlanRecord(BaseModel):
property_id: str = Field(pattern=r"^PROP_\d+$")
rate_plan_code: str
room_type_code: str = Field(pattern=r"^[A-Z0-9_]+$")
base_rate: float = Field(gt=0)
currency: str = Field(min_length=3, max_length=3)
@field_validator("rate_plan_code")
@classmethod
def enforce_naming_contract(cls, v: str) -> str:
if not is_structurally_valid(v):
raise ValueError(f"{v!r} violates ^[a-z0-9_]{{8,40}}$")
parse_rate_plan_code(v) # raises on any bad grammar token
return v
# Transmitted form is model_dump() — a plain dict for the HTTP body.
record = RatePlanRecord(
property_id="PROP_8842", rate_plan_code="corp_bf_nonref_direct",
room_type_code="DBL_STD", base_rate=132.0, currency="EUR",
).model_dump()
Attaching the rule to the model rather than a standalone helper means the naming contract is enforced wherever a RatePlanRecord is built, so a new worker written next year cannot accidentally skip it.
Step 4 — Quarantine violations instead of dropping them
When a name fails, halt that record’s sync and route it to a quarantine sink with full context — never allow a partial update that leaves the rate written but the restriction missing. Log the failure as a structured event so an operator gets an actionable diagnostic, not a stack trace.
import structlog
from pydantic import ValidationError
log = structlog.get_logger()
def ingest_or_quarantine(raw: dict, quarantine: list) -> dict | None:
try:
return RatePlanRecord(**raw).model_dump()
except ValidationError as exc:
record = {
"event": "rate_plan_naming_rejected",
"property_id": raw.get("property_id", "UNKNOWN"),
"attempted_code": raw.get("rate_plan_code", "UNKNOWN"),
"error": exc.errors()[0]["msg"],
}
quarantine.append(record)
log.error(**record) # key=value, greppable by attempted_code
return None
Quarantining rather than raising to the top of the batch keeps one malformed plan from aborting an entire property’s sync — the good records propagate and the bad one lands somewhere a revenue manager can see and fix it, which maps directly onto the retryable-versus-terminal split in error categorization and retry logic.
Step 5 — Detect drift between the PMS and the channel roster
Even with a clean gate, manual overrides and legacy truncation cause the two sides to diverge over time. Fetch both rosters — pacing the channel-side pull inside your OTA rate-limit budget so a portfolio-wide roster dump never trips a 429 — normalize case and whitespace, and take a vectorized set difference to categorize the mismatch.
import polars as pl
def detect_naming_drift(pms_roster: list[str], cm_roster: list[str]) -> pl.DataFrame:
pms = {c.strip().lower() for c in pms_roster}
cm = {c.strip().lower() for c in cm_roster}
rows = (
[{"rate_plan_code": c, "state": "missing_in_channel"} for c in pms - cm]
+ [{"rate_plan_code": c, "state": "orphaned_in_channel"} for c in cm - pms]
)
frame = pl.DataFrame(rows) if rows else pl.DataFrame(
schema={"rate_plan_code": pl.Utf8, "state": pl.Utf8})
if frame.height:
log.warning("naming_drift_detected", drift_count=frame.height)
return frame
An orphaned_in_channel code almost always means a plan was created directly in the channel manager — the fix is to treat the PMS as the sole source of truth and push the canonical code back over the orphan, never to rename the PMS plan to match, which would break every idempotency key already derived from the old code.
Gotchas & production notes
- Uppercase and hyphens get silently dropped in XML/EDI transport. Older channel managers validate the
<RatePlan>node against a strict DTD or fixed-width EDI segment; a single capital letter or-can drop the entire node without an error response. Standardizing on^[a-z0-9_]{8,40}$at the gate (Step 2) guarantees the code survives JSON, XML, and EDI serialization intact. - A rename is never a rename — it is a new code. Editing a
rate_plan_codein place invalidates every mapping row and every idempotency key derived from it, silently desyncing the channel. Retire the old code and mint a new one, exactly as the rate plan taxonomy requires; the naming gate should reject an update that reuses a retired code for a different plan. - Normalize before you diff, not inside it. The drift pass (Step 5) lower-cases and strips both rosters first, because a channel that returns
CORP_BF_NONREF_DIRECTwhile the PMS holdscorp_bf_nonref_directis aligned, not drifted — comparing raw strings manufactures a phantom mismatch on every plan. - The controlled vocabulary is the real contract; the regex only guards its shape. A code can pass
^[a-z0-9_]{8,40}$and still be meaningless (aaaa_bbbb). Keep Step 1’s token sets under version control and treat adding a segment or restriction as a reviewed schema change, or the taxonomy erodes one plausible-looking typo at a time.
Verification snippet
Confirm the gate’s two load-bearing behaviours — that a well-formed code passes end to end and that each failure class is rejected — before trusting it in front of a live channel.
import pytest
from pydantic import ValidationError
def test_valid_code_passes_full_gate() -> None:
rec = RatePlanRecord(
property_id="PROP_8842", rate_plan_code="leisure_ro_ap14_ota",
room_type_code="DBL_STD", base_rate=115.0, currency="EUR",
)
assert parse_rate_plan_code(rec.rate_plan_code)["restriction"] == "ap14"
@pytest.mark.parametrize("bad", [
"Corp_BF_Nonref_Direct", # uppercase → fails regex
"corp-bf-nonref-direct", # hyphens → fails regex
"corp_bf_nonref", # only 3 segments → fails grammar
"corp_bf_nonrefundable_ota", # unknown restriction token
])
def test_malformed_codes_are_rejected(bad: str) -> None:
with pytest.raises(ValidationError):
RatePlanRecord(property_id="PROP_8842", rate_plan_code=bad,
room_type_code="DBL_STD", base_rate=100.0, currency="EUR")
def test_drift_ignores_case_differences() -> None:
drift = detect_naming_drift(["corp_bf_nonref_direct"], ["CORP_BF_NONREF_DIRECT"])
assert drift.height == 0 # same plan, different case → aligned, not drifted
test_valid_code_passes_full_gate()
test_drift_ignores_case_differences()
The parametrized rejection test is the important one: each case maps to a real ingestion failure — case variance, delimiter drift, a missing grammar field, and vocabulary erosion — so a green run proves the gate closes every door a malformed name walks through. In production, also assert the quarantine sink receives exactly one record per rejected input, so no failure is dropped on the floor.
Related
- Rate Plan Taxonomy Design — the parent workflow: the immutable, parent-child identifier model these codes plug into
- OTA Channel Mapping Strategies — translating a validated canonical code into each channel’s provider-assigned identifier
- Data Schema Standardization — the property-wide payload contract the naming rule is one clause of
- Error Categorization & Retry Logic — the retryable-versus-terminal split behind quarantining a malformed name
- Batch Reconciliation Workflows — the nightly loop that catches naming drift the gate cannot prevent at write time
← Back to Rate Plan Taxonomy Design