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:

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.

Anatomy of a rate_plan_code and the fail-closed gate that guards it The canonical code corp_bf_nonref_direct is split into four grammar positions — corp (segment), bf (inclusion), nonref (restriction), direct (channel) — separated by underscores. Each position binds to a normalized PMS column: segment to market_segment, inclusion to meal_plan_code, restriction to cancel_policy, channel to distribution_channel. Below, an ingestion gate applies the regex caret a to z zero to nine underscore, eight to forty characters, plus a controlled vocabulary, inside a Pydantic field_validator. A well-formed code passes and is emitted to the channel manager API; a malformed candidate, Corp BF Non-Ref with capitals, spaces and an exclamation mark, fails and is routed to the rate_plan_quarantine dead-letter sink. Anatomy of a rate_plan_code, and the gate that guards it Four grammar positions, each bound to a normalized PMS column, then one fail-closed gate before the wire. assembled rate_plan_code _ _ _ corp segment bf inclusion nonref restriction direct channel normalized PMS column market_segment meal_plan_code cancel_policy distribution_channel Ingestion gate — fail-closed: a valid code ships, a malformed one is quarantined. corp_bf_nonref_direct well-formed candidate Corp BF Non-Ref! caps · spaces · '!' Naming gate ^[a-z0-9_]{8,40}$ + controlled vocabulary Pydantic field_validator Channel manager API accepted → outbound rate_plan_quarantine rejected → dead-letter ✓ pass ✕ fail

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.

python
# 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.

python
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.

python
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.

python
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.

python
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

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.

python
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.

← Back to Rate Plan Taxonomy Design