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.
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).
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.
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
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.
Related
- Channel Manager Integration Patterns — the parent cluster on channel synchronization architecture.
- Rate Parity Compliance Across Booking Channels — the parity sibling to inventory reconciliation.
- Implementing circuit breakers for PMS failover — handling the case where the authoritative PMS is unreachable.