Rate Plan Taxonomy Design for PMS & Channel Manager Parity Automation

When a property’s pricing structure lives as free-text marketing labels instead of a governed data contract, every downstream sync inherits the ambiguity: a “Non-Ref Winter Special” in the PMS becomes an unrecognized string at the channel manager, the availability write lands on the wrong inventory pool, and a parity violation surfaces on Booking.com days before anyone notices. The people who absorb that failure are predictable — the revenue manager who gets flagged for undercutting a published rate, the operations lead fielding a double-booking, and the engineer paged to explain drift they cannot trace to a root cause. A rigorously engineered rate plan taxonomy is the fix: it treats every plan as an immutable identifier with explicit derivative relationships, giving automation a deterministic payload to reason about instead of a label to guess at. This design sits at the base of the PMS and channel manager architecture foundations, where deterministic routing and schema compliance are what keep sync failures from cascading across distributed hospitality systems.

This page specifies the taxonomy as engineering infrastructure — version-controlled, schema-validated, and enforced at the ingestion layer — for the Python engineer who owns the sync worker and the revenue manager who has to trust its output. It covers the identifier model, the canonical-to-channel mapping layer, the Pydantic v2 contract every payload passes through, floor-pricing and occupancy validation, idempotent transmission, and how to prove the whole thing ran correctly.

Architecture and prerequisites

The taxonomy is a directed tree, not a flat list. A single master base rate — the property’s Best Available Rate (bar_std) — anchors all pricing math. Every other plan is a derivative that inherits the base value and applies conditional modifiers: an advance-purchase window, a non-refundable penalty, a corporate contract discount, or a length-of-stay restriction. Each node carries a canonical rate_plan_code that exists in the PMS schema before it is ever translated to a channel-specific code, so the mapping layer never has to invent an identity mid-flight.

Canonical rate-plan tree resolved, mapped to channel IDs, and validated before the wire A single master base rate, bar_std, anchors the taxonomy. Three derivative nodes each inherit that base and apply a modifier: corp_bf_nonref (minus twelve percent, non-refundable), leisure_ro_ap14 (minus eighteen percent, fourteen-day advance purchase), and ota_flex_bb (flexible, breakfast included). All derivatives flow into a mapping layer that translates each canonical rate_plan_code into a per-channel identifier — a Booking.com rateId and an Expedia ratePlanID — while keeping the canonical code attached. Both channel-shaped payloads then pass through a single fail-closed validation gate before reaching the channel manager API; anything that breaches the floor or fails the contract never leaves the process. Immutable canonical taxonomy, mapped and validated before the wire One base rate anchors every derivative; canonical codes map to channel IDs and pass a fail-closed gate. Canonical rate-plan tree Mapping layer Channel identifiers Outbound bar_std master base · €150 floor corp_bf_nonref −12% · non-refundable leisure_ro_ap14 −18% · advance purchase 14d ota_flex_bb flexible · breakfast included Mapping layer code → channel ID booking_com rateId 1023847 expedia ratePlanID RP-CORP-BF Validation gate fail closed Channel manager API

Treating identifiers as immutable is the load-bearing rule. A rate_plan_code is minted once and never edited in place; a superseded plan is retired and a new code issued. This is what lets automation traverse the tree, compute net rates from the base, and push synchronized updates without duplicating inventory buckets or racing two writers against the same node. The naming grammar those codes follow — segment, inclusion, restriction, channel — is specified in full on best practices for rate plan naming conventions, which keeps codes machine-readable, length-constrained, and free of locale-specific characters that break XML and EDI parsers downstream.

Inputs, outputs, and environment for the reference implementation:

The one non-negotiable prerequisite is that the canonical layer is authoritative. If revenue managers can create ad hoc plans directly in the channel manager, the tree fragments, the mapping layer accumulates orphans, and drift detection degrades into manual reconciliation.

Implementation

Step 1 — Model the tree with explicit parent-child edges

Represent the registry as nodes that name their parent. A base rate has no parent; every derivative names exactly one. This makes net-rate resolution a traversal rather than a lookup table you hand-maintain.

python
from dataclasses import dataclass, field
from typing import Optional
import structlog

log = structlog.get_logger()

@dataclass(frozen=True)  # frozen enforces the immutability rule at the type level
class RatePlanNode:
    rate_plan_code: str          # canonical PMS identifier, e.g. "corp_bf_nonref"
    room_type_code: str          # e.g. "DBL_STD"
    base_rate_id: Optional[str]  # parent code; None only for the master base rate
    discount_pct: float = 0.0    # applied to the resolved parent rate
    penalty_flag: bool = False   # non-refundable / restriction marker
    floor_rate: float = 0.0      # net rate may never fall below this

def resolve_net_rate(code: str, registry: dict[str, RatePlanNode]) -> float:
    node = registry[code]
    if node.base_rate_id is None:          # reached the master base rate
        return node.floor_rate or 0.0
    parent_rate = resolve_net_rate(node.base_rate_id, registry)
    net = round(parent_rate * (1 - node.discount_pct), 2)
    log.info("net_rate_resolved", rate_plan_code=code,
             parent=node.base_rate_id, parent_rate=parent_rate, net=net)
    return net

Freezing the dataclass is deliberate: a rate_plan_code that cannot be mutated after construction makes accidental in-place edits a runtime error rather than a silent parity drift the next sync inherits.

Step 2 — Seed a realistic registry and derive the portfolio in one pass

For a full property you resolve every derivative against its base in a single vectorized pass rather than per-row API-time math, so the pushed rates are internally consistent for a given base snapshot.

python
import polars as pl

registry = {
    "bar_std":         RatePlanNode("bar_std", "DBL_STD", None, floor_rate=150.0),
    "corp_bf_nonref":  RatePlanNode("corp_bf_nonref", "DBL_STD", "bar_std",
                                    discount_pct=0.12, penalty_flag=True, floor_rate=120.0),
    "leisure_ro_ap14": RatePlanNode("leisure_ro_ap14", "DBL_STD", "bar_std",
                                    discount_pct=0.18, floor_rate=115.0),
}

derived = pl.DataFrame({
    "rate_plan_code": list(registry),
    "room_type_code": [n.room_type_code for n in registry.values()],
    "net_rate": [resolve_net_rate(c, registry) for c in registry],
    "floor_rate": [n.floor_rate for n in registry.values()],
}).with_columns(
    (pl.col("net_rate") < pl.col("floor_rate")).alias("floor_breach")
)

The floor_breach column is computed once for the whole portfolio so a discount that would push a derivative under its floor is flagged before any payload is built, not caught request-by-request after some writes already landed.

Step 3 — Build channel-shaped payloads that carry the canonical identifier

The mapping layer translates the canonical rate_plan_code into the channel’s own identifier, but the outbound payload keeps the canonical code as an internal reference so the availability write and the rate write cannot desync onto different pools. The per-channel translation rules themselves live in OTA channel mapping strategies.

python
CHANNEL_MAP = {
    ("booking_com", "corp_bf_nonref"): "1023847",   # provider-assigned rateId
    ("expedia",     "corp_bf_nonref"): "RP-CORP-BF",
}

def build_payload(code: str, channel: str, net_rate: float,
                  currency: str, property_id: str) -> dict:
    channel_code = CHANNEL_MAP.get((channel, code))
    if channel_code is None:
        raise KeyError(f"no {channel} mapping for {code}")  # never fabricate an ID
    return {
        "property_id": property_id,
        "channel": channel,
        "rate_plan_code": code,        # canonical, for internal correlation + logs
        "channel_rate_id": channel_code,
        "gross_rate": net_rate,
        "currency": currency,
    }

Raising on a missing mapping rather than defaulting is the safeguard that prevents the classic failure where an unmapped plan is pushed under a guessed identifier and silently overwrites an unrelated rate on the channel side.

Schema and data contracts

Before any payload reaches a channel manager API, it passes through a Pydantic v2 model that rejects malformed shapes at the boundary. This is the canonical contract every worker validates against, and it encodes the taxonomy invariants — a positive floor, a bounded occupancy basis, a valid currency, and the parent-child floor constraint — as code rather than convention.

python
from pydantic import BaseModel, Field, field_validator, model_validator
from datetime import date

class RatePlanPayload(BaseModel):
    property_id: str = Field(pattern=r"^PROP_\d+$")
    rate_plan_code: str = Field(min_length=8, max_length=40, pattern=r"^[a-z0-9_]+$")
    base_rate_id: str = Field(description="canonical parent identifier")
    room_type_code: str = Field(pattern=r"^[A-Z0-9_]+$")
    channel: str
    currency: str = Field(min_length=3, max_length=3)
    gross_rate: float = Field(gt=0)
    floor_rate: float = Field(gt=0)
    occupancy_basis: int = Field(ge=1, le=4)
    advance_purchase_days: int | None = Field(default=None, ge=0)
    is_non_refundable: bool = False
    effective_date: date
    expiry_date: date

    @field_validator("channel")
    @classmethod
    def known_channel(cls, v: str) -> str:
        allowed = {"booking_com", "expedia", "agoda", "hostelworld"}
        if v not in allowed:
            raise ValueError(f"unknown channel slug: {v}")
        return v

    @model_validator(mode="after")
    def enforce_invariants(self) -> "RatePlanPayload":
        if self.expiry_date <= self.effective_date:
            raise ValueError("expiry_date must be after effective_date")
        if self.gross_rate < self.floor_rate:
            raise ValueError(
                f"gross_rate {self.gross_rate} breaches floor_rate {self.floor_rate}"
            )
        return self

# Persisted / transmitted form is model_dump() — a plain dict for the HTTP body.
payload = RatePlanPayload(
    property_id="PROP_8842", rate_plan_code="corp_bf_nonref", base_rate_id="bar_std",
    room_type_code="DBL_STD", channel="booking_com", currency="EUR",
    gross_rate=132.0, floor_rate=120.0, occupancy_basis=2,
    advance_purchase_days=0, is_non_refundable=True,
    effective_date=date(2026, 7, 1), expiry_date=date(2026, 9, 30),
).model_dump()

Encoding the floor and date-range checks as a model_validator(mode="after") means the invariant is enforced on every construction path — API ingestion, batch derivation, and test fixtures alike — so no code path can assemble a payload that violates the taxonomy and still serialize it. This contract is the local specialization of the property-wide rules in data schema standardization and its JSON payload standard for channel managers.

Error handling and retry strategy

Rate-push failures split into retryable transport faults and terminal contract violations, and conflating them is what turns a transient hiccup into a parity incident. The taxonomy layer’s job is to fail loudly and locally on the terminal class and back off intelligently on the transient one. The broader retryable-versus-terminal taxonomy is specified in error categorization and retry logic.

How each rate-push outcome is classified and disposed of A single rate-push outcome fans out into five dispositions. A 2xx response is accepted: log the idempotency key and emit a success event. A 409 Conflict means the parent or mapping moved, so re-resolve the net rate, rebuild the payload, and resend. A 429 or 503 is a transient transport fault handled with exponential backoff and full jitter, capped at four attempts, honouring Retry-After. A pre-send Pydantic ValidationError — a floor breach or malformed code — is never transmitted and goes to the dead-letter queue with a full diagnostic. A 422 Unprocessable Entity means the channel rejected the payload shape and is terminal for that body: dead-letter it and alert, do not retry. Turquoise marks success, violet marks retryable dispositions, and pink marks terminal fail-closed outcomes. Classifying a rate-push outcome: success, retry, or fail closed Terminal faults stop loudly and locally; transient faults back off; conflicts rebuild from the current base. success retryable terminal · fail closed Rate-push outcome 2xx channel accepted the write Log idem_key and emit a success event 409 Conflict parent or mapping moved Re-resolve net rate, rebuild payload, resend 429 / 503 transient transport fault Exponential backoff, full jitter, ≤ 4 attempts, honour Retry-After ValidationError floor breach · bad code (pre-send) Never transmit → DLQ with full diagnostic 422 Unprocessable shape rejected by channel Terminal for this body → DLQ and alert, no retry
python
import hashlib

def idempotency_key(p: dict) -> str:
    raw = f"{p['property_id']}|{p['channel']}|{p['rate_plan_code']}|" \
          f"{p['effective_date']}|{p['gross_rate']}"
    return hashlib.sha256(raw.encode()).hexdigest()

def route_failure(payload: dict, error: Exception, dlq: list) -> None:
    record = {
        "rate_plan_code": payload.get("rate_plan_code", "UNKNOWN"),
        "property_id": payload.get("property_id", "UNKNOWN"),
        "error": str(error),
        "idem_key": idempotency_key(payload) if "gross_rate" in payload else None,
    }
    dlq.append(record)
    log.error("rate_push_dlq", **record)  # structured, greppable by rate_plan_code

Deriving the idempotency key from the resolved gross_rate (not a UUID) means an identical logical write always produces the same key, so replays after a 429 backoff are naturally deduplicated while a genuinely changed rate produces a new key and does propagate. Transport-level auth for the push follows the security and authentication boundaries, so every mutation is scoped to the correct property context and logged for audit.

Verification and testing

Prove the taxonomy resolves and validates correctly before it runs against live channels. Assert on resolved rates, invariant enforcement, and structured-log events rather than eyeballing HTTP responses.

python
import pytest
from pydantic import ValidationError

def test_derivative_inherits_and_discounts():
    assert resolve_net_rate("corp_bf_nonref", registry) == 132.0   # 150 * (1 - 0.12)
    assert resolve_net_rate("leisure_ro_ap14", registry) == 123.0  # 150 * (1 - 0.18)

def test_floor_breach_is_rejected():
    with pytest.raises(ValidationError, match="breaches floor_rate"):
        RatePlanPayload(
            property_id="PROP_8842", rate_plan_code="corp_bf_nonref",
            base_rate_id="bar_std", room_type_code="DBL_STD", channel="booking_com",
            currency="EUR", gross_rate=100.0, floor_rate=120.0, occupancy_basis=2,
            effective_date=date(2026, 7, 1), expiry_date=date(2026, 9, 30),
        )

def test_idempotency_key_is_stable_for_same_rate():
    p = {"property_id": "PROP_8842", "channel": "booking_com",
         "rate_plan_code": "corp_bf_nonref", "effective_date": "2026-07-01",
         "gross_rate": 132.0}
    assert idempotency_key(p) == idempotency_key(dict(p))

The three assertions map to the three ways the taxonomy silently breaks: a derivative that stops inheriting from its base (stale rates published), a floor breach that slips past validation (rate published below the property’s revenue floor), and an unstable idempotency key (retries double-applying writes). Every push should emit net_rate_resolved, rate_push_dlq, and a success event carrying rate_plan_code and idem_key, so an operator can reconcile what propagated against what the registry intended. That reconciliation loop is formalized in batch reconciliation workflows.

Troubleshooting

Symptom Root cause Fix
Derivative rates stale after a base change Net rates were cached per-plan instead of resolved from the base each cycle Re-run resolve_net_rate from the current base snapshot every derivation pass; never persist a derivative’s rate as authoritative
Channel rejects a plan with 422 occupancy_basis or restriction set unsupported for that room_type_code on the channel Validate the mapping against the channel’s room capabilities before build; DLQ and alert rather than retry
A rate lands on the wrong inventory pool Availability write used a different identifier than the rate write Carry the canonical rate_plan_code on both payloads and correlate by it in logs
Silent drift: PMS and channel plan lists diverge Ad hoc plan created directly in the channel manager, orphaning the mapping Treat the PMS registry as sole source of truth; run set-difference drift detection and push canonical codes back over orphans
Duplicate restriction applied after a retry Idempotency key was a UUID regenerated per attempt Derive the key deterministically from `property_id

FAQ

Why make rate plan codes immutable instead of just editing them?

An in-place edit to a rate_plan_code breaks every existing mapping and idempotency key that referenced the old value, so a single rename can desync an entire channel silently. Minting a new code and retiring the old one keeps the audit trail intact and lets you roll forward without orphaning historical rates. The frozen=True dataclass turns an accidental mutation into an immediate error rather than a drift you discover days later.

Should derived net rates be stored, or resolved on demand?

Resolve them on demand from the base each derivation cycle, and treat any stored value as a cache you can discard. If a derivative’s net rate is persisted as authoritative, a change to the master base rate no longer propagates and you publish stale pricing. Storing the resolved value only for a single snapshot (as the Polars pass does) is fine; storing it as the source of truth is the drift bug.

How deep should the parent-child hierarchy go?

Keep it shallow — a master base rate with one layer of derivatives covers most properties, and two layers (base to a segment rate to a channel-restricted variant) is the practical ceiling. Deeper trees make net-rate resolution harder to reason about and multiply the blast radius of a base change. If you find yourself needing a third layer, that is usually a signal a modifier belongs on the derivative itself rather than as a new node.

Where does the taxonomy end and channel mapping begin?

The taxonomy owns identity and pricing math in canonical terms; the mapping layer owns the translation from a canonical rate_plan_code to each channel’s provider-assigned identifier. The boundary matters because it lets you change a channel’s ID scheme without touching the pricing tree, and vice versa. The translation rules themselves are documented in OTA channel mapping strategies.

What prevents two workers from corrupting the same plan concurrently?

Two things: immutable codes mean no worker can edit a node in place, and the deterministic idempotency key means two workers pushing the same resolved rate produce the same key, so the provider deduplicates. If the rates differ, the writes are genuinely different and both are legitimate — the taxonomy never needs a lock for reads, only the credential-refresh path does.

← Back to PMS & Channel Manager Architecture Foundations