Reconciling inventory counts across channel managers

When the PMS says twelve rooms are available, one channel manager shows eleven, and an OTA is still selling fourteen, someone is about to get walked. Inventory reconciliation is the process of detecting and resolving these divergences before they turn into oversells or unsold rooms. This guide implements a reconciliation pass, extending Channel Manager Integration Patterns in the Core Architecture & Pricing Taxonomy pillar. The authoritative source is the PMS; every channel is reconciled toward it.

Prerequisites

  • Python 3.11+
  • Standard library dataclasses, logging, enum
  • A snapshot of counts from the PMS and each channel manager, keyed by room type and stay date
  • Write access to push corrections back to each channel
  • Idempotent correction pushes, consistent with Webhook vs REST Sync Patterns

Step 1 — Represent counts against a single source of truth

Model each channel’s count for a room-night alongside the PMS count. Treating the PMS as authoritative makes reconciliation a directed correction rather than an argument between equal peers.

python
from __future__ import annotations

import logging
from dataclasses import dataclass
from enum import Enum

logger = logging.getLogger("inventory.reconcile")


@dataclass(frozen=True)
class InventoryKey:
    room_type: str
    stay_date: str          # ISO-8601


@dataclass(frozen=True)
class Divergence:
    key: InventoryKey
    channel: str
    pms_count: int
    channel_count: int

    @property
    def delta(self) -> int:
        return self.channel_count - self.pms_count

Step 2 — Detect divergences

Diff each channel’s snapshot against the PMS snapshot and collect the mismatches. Classify oversells (channel above PMS — the dangerous direction) separately from undersells (channel below PMS — lost revenue but safe).

python
class Risk(Enum):
    OVERSELL = "oversell"        # channel selling more than exists
    UNDERSELL = "undersell"      # channel withholding sellable rooms


def find_divergences(pms: dict[InventoryKey, int],
                     channels: dict[str, dict[InventoryKey, int]]
                     ) -> list[Divergence]:
    out: list[Divergence] = []
    for channel, counts in channels.items():
        for key, pms_count in pms.items():
            ch_count = counts.get(key, 0)
            if ch_count != pms_count:
                out.append(Divergence(key, channel, pms_count, ch_count))
    # oversells first: they cause walks
    out.sort(key=lambda d: d.delta, reverse=True)
    return out


def classify(div: Divergence) -> Risk:
    return Risk.OVERSELL if div.delta > 0 else Risk.UNDERSELL

Step 3 — Correct toward the PMS, oversells first

Push corrections that set each channel to the PMS count. Sequence oversells ahead of undersells so the count that can cause a walk is closed before the one that merely costs a booking.

python
def reconcile(pms: dict[InventoryKey, int],
              channels: dict[str, dict[InventoryKey, int]],
              push_correction) -> int:
    divergences = find_divergences(pms, channels)
    corrected = 0
    for div in divergences:
        risk = classify(div)
        logger.warning("%s on %s %s/%s: channel=%d pms=%d",
                       risk.value, div.channel, div.key.room_type,
                       div.key.stay_date, div.channel_count, div.pms_count)
        # idempotent: setting to an already-correct value is a no-op
        push_correction(div.channel, div.key, div.pms_count)
        corrected += 1
    logger.info("reconciled %d divergences", corrected)
    return corrected

Correcting oversells first is a safety ordering, not a cosmetic one: during a large reconciliation the window where a channel is still overselling is exactly when a walk happens, so you shrink that window before touching the harmless undersells.

Verification and testing

python
def test_oversell_corrected_before_undersell() -> None:
    pms = {InventoryKey("std", "2026-08-01"): 10,
           InventoryKey("suite", "2026-08-01"): 4}
    channels = {"ota_a": {InventoryKey("std", "2026-08-01"): 13,     # oversell +3
                          InventoryKey("suite", "2026-08-01"): 2}}   # undersell -2
    order: list[int] = []
    reconcile(pms, channels, lambda ch, key, count: order.append(count))
    assert order[0] == 10     # the oversold std room is corrected first

Common pitfalls and edge cases

  • No source of truth. Reconciling channels against each other never converges; anchor everything to the PMS.
  • Undersells first. Correcting safe undersells before oversells leaves the walk risk open longer; order by risk.
  • Non-idempotent pushes. A retried correction that decrements rather than sets can overshoot; push absolute counts.
  • Snapshot skew. Comparing snapshots taken at different instants creates phantom divergences; snapshot all sources close together.
  • Ignoring stopsell. A room type on stopsell should read zero everywhere; treat stopsell as an explicit count, not a missing key.